IP Whitelistening - global ip, fixing 30s timeout on wrong sender authentication using username and password to be instant.
This commit is contained in:
+24
-7
@@ -29,10 +29,11 @@ class EnhancedAuthenticator:
|
|||||||
- Comprehensive audit logging
|
- Comprehensive audit logging
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __call__(self, server, session, envelope, mechanism, auth_data):
|
async def __call__(self, server, session, envelope, mechanism, auth_data):
|
||||||
if not isinstance(auth_data, LoginPassword):
|
if not isinstance(auth_data, LoginPassword):
|
||||||
logger.warning(f'Invalid auth data format: {type(auth_data)}')
|
logger.warning(f'Invalid auth data format: {type(auth_data)}')
|
||||||
return AuthResult(success=False, handled=True, message='535 Authentication failed')
|
await server.push('535 Authentication failed')
|
||||||
|
return AuthResult(success=False, handled=True)
|
||||||
|
|
||||||
# Decode bytes to string if necessary
|
# Decode bytes to string if necessary
|
||||||
username = auth_data.login
|
username = auth_data.login
|
||||||
@@ -73,7 +74,8 @@ class EnhancedAuthenticator:
|
|||||||
message=f'Invalid credentials for {username}'
|
message=f'Invalid credentials for {username}'
|
||||||
)
|
)
|
||||||
logger.warning(f'Authentication failed for {username}: invalid credentials')
|
logger.warning(f'Authentication failed for {username}: invalid credentials')
|
||||||
return AuthResult(success=False, handled=True, message='535 Authentication failed')
|
await server.push('535 Authentication failed')
|
||||||
|
return AuthResult(success=False, handled=True)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f'Authentication error for {username}: {e}')
|
logger.error(f'Authentication error for {username}: {e}')
|
||||||
@@ -84,7 +86,8 @@ class EnhancedAuthenticator:
|
|||||||
success=False,
|
success=False,
|
||||||
message=f'Authentication error: {str(e)}'
|
message=f'Authentication error: {str(e)}'
|
||||||
)
|
)
|
||||||
return AuthResult(success=False, handled=True, message='451 Internal server error')
|
await server.push('535 Authentication failed')
|
||||||
|
return AuthResult(success=False, handled=True)
|
||||||
|
|
||||||
class EnhancedIPAuthenticator:
|
class EnhancedIPAuthenticator:
|
||||||
"""
|
"""
|
||||||
@@ -98,7 +101,7 @@ class EnhancedIPAuthenticator:
|
|||||||
|
|
||||||
def can_authenticate_for_domain(self, ip_address: str, domain_name: str) -> tuple[bool, str]:
|
def can_authenticate_for_domain(self, ip_address: str, domain_name: str) -> tuple[bool, str]:
|
||||||
"""
|
"""
|
||||||
Check if IP can authenticate for a specific domain.
|
Check if IP can authenticate for a specific domain or is globally whitelisted.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
ip_address: Client IP address
|
ip_address: Client IP address
|
||||||
@@ -108,11 +111,25 @@ class EnhancedIPAuthenticator:
|
|||||||
(success, message) tuple
|
(success, message) tuple
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
# First, check for domain-specific whitelist
|
||||||
whitelisted_ip = get_whitelisted_ip(ip_address, domain_name)
|
whitelisted_ip = get_whitelisted_ip(ip_address, domain_name)
|
||||||
if whitelisted_ip:
|
if whitelisted_ip:
|
||||||
return True, f"IP {ip_address} authorized for domain {domain_name}"
|
return True, f"IP {ip_address} authorized for domain {domain_name}"
|
||||||
else:
|
# Then, check for global whitelist
|
||||||
return False, f"IP {ip_address} not authorized for domain {domain_name}"
|
from email_server.models import Session, WhitelistedIP, get_domain_by_name
|
||||||
|
session = Session()
|
||||||
|
try:
|
||||||
|
global_ip = session.query(WhitelistedIP).filter_by(ip_address=ip_address, global_ip=True, is_active=True).first()
|
||||||
|
if global_ip:
|
||||||
|
# Check if the domain exists and is active
|
||||||
|
domain = get_domain_by_name(domain_name)
|
||||||
|
if domain:
|
||||||
|
return True, f"IP {ip_address} is globally whitelisted for existing domain {domain_name}"
|
||||||
|
else:
|
||||||
|
return False, f"Domain {domain_name} does not exist or is not active on this server"
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
return False, f"IP {ip_address} not authorized for domain {domain_name} or globally"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error checking IP authorization: {e}")
|
logger.error(f"Error checking IP authorization: {e}")
|
||||||
return False, f"Error checking IP authorization: {str(e)}"
|
return False, f"Error checking IP authorization: {str(e)}"
|
||||||
|
|||||||
@@ -88,16 +88,18 @@ class Sender(Base):
|
|||||||
|
|
||||||
class WhitelistedIP(Base):
|
class WhitelistedIP(Base):
|
||||||
"""
|
"""
|
||||||
IP whitelist model with domain-specific authentication.
|
IP whitelist model with domain-specific and global authentication.
|
||||||
|
|
||||||
Security feature:
|
Security feature:
|
||||||
- IPs can only send emails for their specific authorized domain
|
- IPs can be global (allowed for any domain) or domain-specific
|
||||||
|
- IPs can only send emails for their specific authorized domain unless global_ip is True
|
||||||
"""
|
"""
|
||||||
__tablename__ = 'esrv_whitelisted_ips'
|
__tablename__ = 'esrv_whitelisted_ips'
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
ip_address = Column(String, nullable=False)
|
ip_address = Column(String, nullable=False)
|
||||||
domain_id = Column(Integer, ForeignKey('esrv_domains.id'), nullable=False)
|
domain_id = Column(Integer, ForeignKey('esrv_domains.id'), nullable=True)
|
||||||
|
global_ip = Column(Boolean, default=False, nullable=False)
|
||||||
is_active = Column(Boolean, default=True)
|
is_active = Column(Boolean, default=True)
|
||||||
created_at = Column(DateTime, default=func.now())
|
created_at = Column(DateTime, default=func.now())
|
||||||
|
|
||||||
@@ -107,13 +109,13 @@ class WhitelistedIP(Base):
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
domain_name: The domain name to check
|
domain_name: The domain name to check
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if IP is authorized for this domain
|
True if IP is authorized for this domain or is global
|
||||||
"""
|
"""
|
||||||
if not self.is_active:
|
if not self.is_active:
|
||||||
return False
|
return False
|
||||||
|
if self.global_ip:
|
||||||
|
return True
|
||||||
# Need to check against the actual domain
|
# Need to check against the actual domain
|
||||||
session = Session()
|
session = Session()
|
||||||
try:
|
try:
|
||||||
@@ -126,7 +128,7 @@ class WhitelistedIP(Base):
|
|||||||
session.close()
|
session.close()
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"<WhitelistedIP(id={self.id}, ip='{self.ip_address}', domain_id={self.domain_id})>"
|
return f"<WhitelistedIP(id={self.id}, ip='{self.ip_address}', domain_id={self.domain_id}, global_ip={self.global_ip})>"
|
||||||
|
|
||||||
class EmailLog(Base):
|
class EmailLog(Base):
|
||||||
"""Email log model for tracking sent emails."""
|
"""Email log model for tracking sent emails."""
|
||||||
|
|||||||
@@ -22,52 +22,66 @@ def ips_list():
|
|||||||
"""List all whitelisted IPs."""
|
"""List all whitelisted IPs."""
|
||||||
session = Session()
|
session = Session()
|
||||||
try:
|
try:
|
||||||
ips = session.query(WhitelistedIP, Domain).join(Domain, WhitelistedIP.domain_id == Domain.id).order_by(WhitelistedIP.ip_address).all()
|
all_ips = session.query(WhitelistedIP, Domain).join(Domain, WhitelistedIP.domain_id == Domain.id, isouter=True).order_by(WhitelistedIP.ip_address).all()
|
||||||
return render_template('ips.html', ips=ips)
|
domain_ips = [item for item in all_ips if not item[0].global_ip]
|
||||||
|
global_ips = [item for item in all_ips if item[0].global_ip]
|
||||||
|
return render_template('ips.html', ips=domain_ips, global_ips=global_ips)
|
||||||
finally:
|
finally:
|
||||||
session.close()
|
session.close()
|
||||||
|
|
||||||
@email_bp.route('/ips/add', methods=['GET', 'POST'])
|
@email_bp.route('/ips/add', methods=['GET', 'POST'])
|
||||||
def add_ip():
|
def add_ip():
|
||||||
"""Add new whitelisted IP."""
|
"""Add new whitelisted IP(s)."""
|
||||||
session = Session()
|
session = Session()
|
||||||
try:
|
try:
|
||||||
domains = session.query(Domain).filter_by(is_active=True).order_by(Domain.domain_name).all()
|
domains = session.query(Domain).filter_by(is_active=True).order_by(Domain.domain_name).all()
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
ip_address = request.form.get('ip_address', '').strip()
|
ip_addresses_raw = request.form.get('ip_addresses', '').strip()
|
||||||
domain_id = request.form.get('domain_id', type=int)
|
domain_id = request.form.get('domain_id', type=int)
|
||||||
|
global_ip = request.form.get('global_ip') == 'on'
|
||||||
if not all([ip_address, domain_id]):
|
# Split IPs by line or comma
|
||||||
flash('All fields are required', 'error')
|
ip_list = []
|
||||||
|
for line in ip_addresses_raw.splitlines():
|
||||||
|
for ip in line.split(','):
|
||||||
|
ip = ip.strip()
|
||||||
|
if ip:
|
||||||
|
ip_list.append(ip)
|
||||||
|
if not ip_list:
|
||||||
|
flash('Please enter at least one IP address', 'error')
|
||||||
return redirect(url_for('email.add_ip'))
|
return redirect(url_for('email.add_ip'))
|
||||||
|
if not global_ip and not domain_id:
|
||||||
# Basic IP validation
|
flash('Please select a domain for non-global IPs', 'error')
|
||||||
try:
|
|
||||||
socket.inet_aton(ip_address)
|
|
||||||
except socket.error:
|
|
||||||
flash('Invalid IP address format', 'error')
|
|
||||||
return redirect(url_for('email.add_ip'))
|
return redirect(url_for('email.add_ip'))
|
||||||
|
added = 0
|
||||||
# Check if IP already exists for this domain
|
for ip_address in ip_list:
|
||||||
existing = session.query(WhitelistedIP).filter_by(ip_address=ip_address, domain_id=domain_id).first()
|
# Basic IP validation
|
||||||
if existing:
|
try:
|
||||||
flash(f'IP {ip_address} already whitelisted for this domain', 'error')
|
socket.inet_aton(ip_address)
|
||||||
return redirect(url_for('email.ips_list'))
|
except socket.error:
|
||||||
|
flash(f'Invalid IP address format: {ip_address}', 'error')
|
||||||
# Create whitelisted IP
|
continue
|
||||||
whitelist = WhitelistedIP(
|
# Check if IP already exists for this domain/global
|
||||||
ip_address=ip_address,
|
existing = session.query(WhitelistedIP).filter_by(ip_address=ip_address, global_ip=global_ip)
|
||||||
domain_id=domain_id
|
if not global_ip:
|
||||||
)
|
existing = existing.filter_by(domain_id=domain_id)
|
||||||
session.add(whitelist)
|
else:
|
||||||
|
existing = existing.filter_by(domain_id=None)
|
||||||
|
if existing.first():
|
||||||
|
flash(f'IP {ip_address} already whitelisted for this domain/global', 'error')
|
||||||
|
continue
|
||||||
|
# Create whitelisted IP
|
||||||
|
whitelist = WhitelistedIP(
|
||||||
|
ip_address=ip_address,
|
||||||
|
domain_id=None if global_ip else domain_id,
|
||||||
|
global_ip=global_ip
|
||||||
|
)
|
||||||
|
session.add(whitelist)
|
||||||
|
added += 1
|
||||||
session.commit()
|
session.commit()
|
||||||
|
if added:
|
||||||
flash(f'IP {ip_address} added to whitelist', 'success')
|
flash(f'{added} IP(s) added to whitelist', 'success')
|
||||||
return redirect(url_for('email.ips_list'))
|
return redirect(url_for('email.ips_list'))
|
||||||
|
|
||||||
return render_template('add_ip.html', domains=domains)
|
return render_template('add_ip.html', domains=domains)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
session.rollback()
|
session.rollback()
|
||||||
logger.error(f"Error adding IP: {e}")
|
logger.error(f"Error adding IP: {e}")
|
||||||
@@ -166,33 +180,35 @@ def edit_ip(ip_id: int):
|
|||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
ip_address = request.form.get('ip_address', '').strip()
|
ip_address = request.form.get('ip_address', '').strip()
|
||||||
domain_id = request.form.get('domain_id', type=int)
|
domain_id = request.form.get('domain_id', type=int)
|
||||||
|
global_ip = request.form.get('global_ip') == 'on'
|
||||||
|
|
||||||
if not all([ip_address, domain_id]):
|
if not ip_address:
|
||||||
flash('All fields are required', 'error')
|
flash('IP address is required', 'error')
|
||||||
return redirect(url_for('email.edit_ip', ip_id=ip_id))
|
return redirect(url_for('email.edit_ip', ip_id=ip_id))
|
||||||
|
|
||||||
# Basic IP validation
|
# Basic IP validation
|
||||||
try:
|
try:
|
||||||
socket.inet_aton(ip_address)
|
socket.inet_aton(ip_address)
|
||||||
except socket.error:
|
except socket.error:
|
||||||
flash('Invalid IP address format', 'error')
|
flash('Invalid IP address format', 'error')
|
||||||
return redirect(url_for('email.edit_ip', ip_id=ip_id))
|
return redirect(url_for('email.edit_ip', ip_id=ip_id))
|
||||||
|
# Check if IP already exists for this domain/global (excluding current record)
|
||||||
# Check if IP already exists for this domain (excluding current record)
|
|
||||||
existing = session.query(WhitelistedIP).filter(
|
existing = session.query(WhitelistedIP).filter(
|
||||||
WhitelistedIP.ip_address == ip_address,
|
WhitelistedIP.ip_address == ip_address,
|
||||||
WhitelistedIP.domain_id == domain_id,
|
WhitelistedIP.global_ip == global_ip,
|
||||||
WhitelistedIP.id != ip_id
|
WhitelistedIP.id != ip_id
|
||||||
).first()
|
)
|
||||||
if existing:
|
if not global_ip:
|
||||||
flash(f'IP {ip_address} already whitelisted for this domain', 'error')
|
existing = existing.filter(WhitelistedIP.domain_id == domain_id)
|
||||||
|
else:
|
||||||
|
existing = existing.filter(WhitelistedIP.domain_id == None)
|
||||||
|
if existing.first():
|
||||||
|
flash(f'IP {ip_address} already whitelisted for this domain/global', 'error')
|
||||||
return redirect(url_for('email.edit_ip', ip_id=ip_id))
|
return redirect(url_for('email.edit_ip', ip_id=ip_id))
|
||||||
|
|
||||||
# Update IP record
|
# Update IP record
|
||||||
ip_record.ip_address = ip_address
|
ip_record.ip_address = ip_address
|
||||||
ip_record.domain_id = domain_id
|
ip_record.global_ip = global_ip
|
||||||
|
ip_record.domain_id = None if global_ip else domain_id
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
flash(f'IP whitelist record updated', 'success')
|
flash(f'IP whitelist record updated', 'success')
|
||||||
return redirect(url_for('email.ips_list'))
|
return redirect(url_for('email.ips_list'))
|
||||||
|
|
||||||
|
|||||||
@@ -40,30 +40,30 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form method="POST">
|
<form method="POST">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="ip_address" class="form-label">IP Address</label>
|
<label for="ip_addresses" class="form-label">IP Addresses</label>
|
||||||
<input type="text"
|
<textarea class="form-control font-monospace" id="ip_addresses" name="ip_addresses" rows="3" required placeholder="One IP per line, or separate with commas">{{ request.args.get('ip', '') }}</textarea>
|
||||||
class="form-control font-monospace"
|
|
||||||
id="ip_address"
|
|
||||||
name="ip_address"
|
|
||||||
required
|
|
||||||
pattern="^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$"
|
|
||||||
placeholder="192.168.1.100"
|
|
||||||
value="{{ request.args.get('ip', '') }}">
|
|
||||||
<div class="form-text">
|
<div class="form-text">
|
||||||
IPv4 address that will be allowed to send emails without authentication
|
Enter one or more IPv4 addresses (one per line or comma-separated).<br>
|
||||||
|
Each IP will be added as a separate whitelist entry.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mb-3 form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="global_ip" name="global_ip">
|
||||||
|
<label class="form-check-label" for="global_ip">
|
||||||
|
Global IP (allow for any domain)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label for="domain_id" class="form-label">Authorized Domain</label>
|
<label for="domain_id" class="form-label">Authorized Domain</label>
|
||||||
<select class="form-select" id="domain_id" name="domain_id" required>
|
<select class="form-select" id="domain_id" name="domain_id">
|
||||||
<option value="">Select a domain...</option>
|
<option value="">Select a domain...</option>
|
||||||
{% for domain in domains %}
|
{% for domain in domains %}
|
||||||
<option value="{{ domain.id }}">{{ domain.domain_name }}</option>
|
<option value="{{ domain.id }}">{{ domain.domain_name }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
<div class="form-text">
|
<div class="form-text">
|
||||||
This IP will only be able to send emails for the selected domain
|
This IP will only be able to send emails for the selected domain (unless Global IP is checked)
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -109,25 +109,18 @@
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
document.getElementById('current-ip').innerHTML =
|
document.getElementById('current-ip').innerHTML =
|
||||||
`<span class="text-primary">${data.ip_addr}</span>`;
|
`<span class="text-primary">${data.ip_addr}</span>`;
|
||||||
} catch (er) {
|
} catch (error) {
|
||||||
try {
|
document.getElementById('current-ip').innerHTML =
|
||||||
const response = await fetch('https://httpbin.org/ip');
|
|
||||||
const data = await response.json();
|
|
||||||
document.getElementById('current-ip').innerHTML =
|
|
||||||
`<span class="text-primary">${data.origin}</span>`;
|
|
||||||
} catch (error) {
|
|
||||||
document.getElementById('current-ip').innerHTML =
|
|
||||||
'<span class="text-muted">Unable to detect</span>';
|
'<span class="text-muted">Unable to detect</span>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function useCurrentIP() {
|
function useCurrentIP() {
|
||||||
const currentIPElement = document.getElementById('current-ip');
|
const currentIPElement = document.getElementById('current-ip');
|
||||||
const ip = currentIPElement.textContent.trim();
|
const ip = currentIPElement.textContent.trim();
|
||||||
|
|
||||||
if (ip && ip !== 'Detecting...' && ip !== 'Unable to detect') {
|
if (ip && ip !== 'Detecting...' && ip !== 'Unable to detect') {
|
||||||
document.getElementById('ip_address').value = ip;
|
document.getElementById('ip_addresses').value = ip;
|
||||||
// Focus on domain selection
|
// Focus on domain selection
|
||||||
document.getElementById('domain_id').focus();
|
document.getElementById('domain_id').focus();
|
||||||
} else {
|
} else {
|
||||||
@@ -135,19 +128,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// IP address validation
|
|
||||||
document.getElementById('ip_address').addEventListener('input', function(e) {
|
|
||||||
const ip = e.target.value;
|
|
||||||
const ipPattern = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
|
|
||||||
|
|
||||||
if (ip && !ipPattern.test(ip)) {
|
|
||||||
e.target.setCustomValidity('Please enter a valid IPv4 address');
|
|
||||||
} else {
|
|
||||||
e.target.setCustomValidity('');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Auto-detect IP on page load
|
// Auto-detect IP on page load
|
||||||
detectCurrentIP();
|
detectCurrentIP();
|
||||||
|
|
||||||
|
// Enable/disable domain select based on global_ip checkbox
|
||||||
|
const globalIpCheckbox = document.getElementById('global_ip');
|
||||||
|
const domainSelect = document.getElementById('domain_id');
|
||||||
|
function toggleDomainSelect() {
|
||||||
|
if (globalIpCheckbox.checked) {
|
||||||
|
domainSelect.disabled = true;
|
||||||
|
domainSelect.required = false;
|
||||||
|
} else {
|
||||||
|
domainSelect.disabled = false;
|
||||||
|
domainSelect.required = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
globalIpCheckbox.addEventListener('change', toggleDomainSelect);
|
||||||
|
toggleDomainSelect();
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -21,16 +21,21 @@
|
|||||||
id="ip_address"
|
id="ip_address"
|
||||||
name="ip_address"
|
name="ip_address"
|
||||||
value="{{ ip_record.ip_address }}"
|
value="{{ ip_record.ip_address }}"
|
||||||
placeholder="e.g., 192.168.1.1 or 192.168.1.0/24"
|
placeholder="e.g., 192.168.1.1"
|
||||||
required>
|
required>
|
||||||
<div class="form-text">
|
<div class="form-text">
|
||||||
Enter a single IP address or CIDR block
|
Enter a single IP address
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mb-3 form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="global_ip" name="global_ip" {% if ip_record.global_ip %}checked{% endif %}>
|
||||||
|
<label class="form-check-label" for="global_ip">
|
||||||
|
Global IP (allow for any domain)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="domain_id" class="form-label">Domain</label>
|
<label for="domain_id" class="form-label">Domain</label>
|
||||||
<select class="form-select" id="domain_id" name="domain_id" required>
|
<select class="form-select" id="domain_id" name="domain_id" {% if ip_record.global_ip %}disabled{% endif %} required>
|
||||||
<option value="">Select a domain</option>
|
<option value="">Select a domain</option>
|
||||||
{% for domain in domains %}
|
{% for domain in domains %}
|
||||||
<option value="{{ domain.id }}"
|
<option value="{{ domain.id }}"
|
||||||
@@ -40,7 +45,7 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
<div class="form-text">
|
<div class="form-text">
|
||||||
This IP will be able to send emails for the selected domain
|
This IP will be able to send emails for the selected domain (unless Global IP is checked)
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -158,6 +163,19 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
const parts = ip.split('/')[0].split('.');
|
const parts = ip.split('/')[0].split('.');
|
||||||
return parts.every(part => parseInt(part) >= 0 && parseInt(part) <= 255);
|
return parts.every(part => parseInt(part) >= 0 && parseInt(part) <= 255);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enable/disable domain select based on global_ip checkbox
|
||||||
|
const globalIpCheckbox = document.getElementById('global_ip');
|
||||||
|
const domainSelect = document.getElementById('domain_id');
|
||||||
|
function toggleDomainSelect() {
|
||||||
|
if (globalIpCheckbox.checked) {
|
||||||
|
domainSelect.disabled = true;
|
||||||
|
} else {
|
||||||
|
domainSelect.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
globalIpCheckbox.addEventListener('change', toggleDomainSelect);
|
||||||
|
toggleDomainSelect();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h5 class="mb-0">
|
<h5 class="mb-0">
|
||||||
<i class="bi bi-list me-2"></i>
|
<i class="bi bi-list me-2"></i>
|
||||||
Whitelisted IP Addresses
|
Per Domain Whitelisted IP Addresses
|
||||||
</h5>
|
</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
@@ -44,7 +44,11 @@
|
|||||||
<div class="fw-bold font-monospace">{{ ip.ip_address }}</div>
|
<div class="fw-bold font-monospace">{{ ip.ip_address }}</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="badge bg-secondary">{{ domain.domain_name }}</span>
|
{% if ip.global_ip %}
|
||||||
|
<span class="badge bg-info">Global</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary">{{ domain.domain_name }}</span>
|
||||||
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if ip.is_active %}
|
{% if ip.is_active %}
|
||||||
@@ -123,6 +127,97 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if global_ips %}
|
||||||
|
<div class="card mt-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">
|
||||||
|
<i class="bi bi-list me-2"></i>
|
||||||
|
Global Whitelisted IP Addresses <span class="text-muted small">(can be used for all domains)</span>
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>IP Address</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Added</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for ip, domain in global_ips %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div class="fw-bold font-monospace">{{ ip.ip_address }}</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if ip.is_active %}
|
||||||
|
<span class="badge bg-success">
|
||||||
|
<i class="bi bi-check-circle me-1"></i>
|
||||||
|
Active
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-danger">
|
||||||
|
<i class="bi bi-x-circle me-1"></i>
|
||||||
|
Inactive
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<small class="text-muted">
|
||||||
|
{{ ip.created_at.strftime('%Y-%m-%d %H:%M') }}
|
||||||
|
</small>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="btn-group" role="group">
|
||||||
|
<!-- Edit Button -->
|
||||||
|
<a href="{{ url_for('email.edit_ip', ip_id=ip.id) }}"
|
||||||
|
class="btn btn-outline-primary btn-sm"
|
||||||
|
title="Edit IP">
|
||||||
|
<i class="bi bi-pencil"></i>
|
||||||
|
</a>
|
||||||
|
<!-- Enable/Disable Button -->
|
||||||
|
{% if ip.is_active %}
|
||||||
|
<form method="post" action="{{ url_for('email.disable_ip', ip_id=ip.id) }}" class="d-inline">
|
||||||
|
<button type="submit"
|
||||||
|
class="btn btn-outline-warning btn-sm"
|
||||||
|
title="Disable IP"
|
||||||
|
onclick="return confirm('Disable {{ ip.ip_address }}?')">
|
||||||
|
<i class="bi bi-pause-circle"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<form method="post" action="{{ url_for('email.enable_ip', ip_id=ip.id) }}" class="d-inline">
|
||||||
|
<button type="submit"
|
||||||
|
class="btn btn-outline-success btn-sm"
|
||||||
|
title="Enable IP"
|
||||||
|
onclick="return confirm('Enable {{ ip.ip_address }}?')">
|
||||||
|
<i class="bi bi-play-circle"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
<!-- Permanent Remove Button -->
|
||||||
|
<form method="post" action="{{ url_for('email.remove_ip', ip_id=ip.id) }}" class="d-inline">
|
||||||
|
<button type="submit"
|
||||||
|
class="btn btn-outline-danger btn-sm"
|
||||||
|
title="Permanently Remove IP"
|
||||||
|
onclick="return confirm('Permanently remove {{ ip.ip_address }}? This cannot be undone!')">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-lg-4">
|
<div class="col-lg-4">
|
||||||
@@ -139,7 +234,7 @@
|
|||||||
<ul class="list-unstyled mb-3">
|
<ul class="list-unstyled mb-3">
|
||||||
<li class="mb-2">
|
<li class="mb-2">
|
||||||
<i class="bi bi-check-circle text-success me-2"></i>
|
<i class="bi bi-check-circle text-success me-2"></i>
|
||||||
<strong>Active IPs:</strong> {{ ips|selectattr('0.is_active')|list|length }}
|
<strong>Active IPs:</strong> {{ (ips|selectattr('0.is_active')|list|length) + (global_ips|selectattr('0.is_active')|list|length) }}
|
||||||
</li>
|
</li>
|
||||||
<li class="mb-2">
|
<li class="mb-2">
|
||||||
<i class="bi bi-server text-info me-2"></i>
|
<i class="bi bi-server text-info me-2"></i>
|
||||||
@@ -161,9 +256,10 @@
|
|||||||
</h6>
|
</h6>
|
||||||
<ul class="mb-0 small">
|
<ul class="mb-0 small">
|
||||||
<li>Whitelisted IPs can send emails without username/password authentication</li>
|
<li>Whitelisted IPs can send emails without username/password authentication</li>
|
||||||
<li>Each IP is associated with a specific domain</li>
|
<li>Per Domain IP associated with a specific domain</li>
|
||||||
<li>IP can only send emails for its authorized domain</li>
|
<li>Global IP can be used for all domains</li>
|
||||||
<li>Useful for server-to-server email sending</li>
|
<li>Global IP whitelisting only Domains added to server, if domain is not added, global IP will not work</li>
|
||||||
|
<li>If IP is whitelisted, invalid username/password will still pass authentication</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -193,6 +289,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
|
|||||||
@@ -49,16 +49,22 @@ class EnhancedCombinedAuthenticator:
|
|||||||
self.user_auth = EnhancedAuthenticator()
|
self.user_auth = EnhancedAuthenticator()
|
||||||
self.ip_auth = EnhancedIPAuthenticator()
|
self.ip_auth = EnhancedIPAuthenticator()
|
||||||
|
|
||||||
def __call__(self, server, session, envelope, mechanism, auth_data):
|
async def __call__(self, server, session, envelope, mechanism, auth_data):
|
||||||
from aiosmtpd.smtp import LoginPassword
|
from aiosmtpd.smtp import LoginPassword
|
||||||
|
|
||||||
# If auth_data is provided (username/password), try user authentication first
|
# If auth_data is provided (username/password), try user authentication first
|
||||||
if auth_data and isinstance(auth_data, LoginPassword):
|
if auth_data and isinstance(auth_data, LoginPassword):
|
||||||
result = self.user_auth(server, session, envelope, mechanism, auth_data)
|
try:
|
||||||
if result.success:
|
result = self.user_auth(server, session, envelope, mechanism, auth_data)
|
||||||
return result
|
if result.success:
|
||||||
# If user auth fails, don't try IP auth - return the failure
|
return result
|
||||||
return result
|
# If user auth fails, send immediate response
|
||||||
|
await server.push('535 Authentication failed')
|
||||||
|
return AuthResult(success=False, handled=True)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Authentication error: {e}")
|
||||||
|
await server.push('535 Authentication failed')
|
||||||
|
return AuthResult(success=False, handled=True)
|
||||||
|
|
||||||
# If no auth_data provided, IP auth will be validated during MAIL FROM
|
# If no auth_data provided, IP auth will be validated during MAIL FROM
|
||||||
# For now, allow the connection to proceed
|
# For now, allow the connection to proceed
|
||||||
|
|||||||
+50
-46
@@ -1,8 +1,8 @@
|
|||||||
"""Initial migration
|
"""update ip whitelist
|
||||||
|
|
||||||
Revision ID: 3ce273a1be20
|
Revision ID: 8652d2ab8a26
|
||||||
Revises:
|
Revises:
|
||||||
Create Date: 2025-06-07 15:25:35.603295
|
Create Date: 2025-06-10 02:17:23.718102
|
||||||
|
|
||||||
"""
|
"""
|
||||||
from alembic import op
|
from alembic import op
|
||||||
@@ -10,7 +10,7 @@ import sqlalchemy as sa
|
|||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
revision = '3ce273a1be20'
|
revision = '8652d2ab8a26'
|
||||||
down_revision = None
|
down_revision = None
|
||||||
branch_labels = None
|
branch_labels = None
|
||||||
depends_on = None
|
depends_on = None
|
||||||
@@ -18,12 +18,12 @@ depends_on = None
|
|||||||
|
|
||||||
def upgrade():
|
def upgrade():
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
op.drop_table('esrv_dkim_keys')
|
|
||||||
op.drop_table('esrv_auth_logs')
|
op.drop_table('esrv_auth_logs')
|
||||||
op.drop_table('esrv_users')
|
|
||||||
op.drop_table('esrv_domains')
|
|
||||||
op.drop_table('esrv_whitelisted_ips')
|
|
||||||
op.drop_table('esrv_email_logs')
|
op.drop_table('esrv_email_logs')
|
||||||
|
op.drop_table('esrv_senders')
|
||||||
|
op.drop_table('esrv_whitelisted_ips')
|
||||||
|
op.drop_table('esrv_dkim_keys')
|
||||||
|
op.drop_table('esrv_domains')
|
||||||
op.drop_table('esrv_custom_headers')
|
op.drop_table('esrv_custom_headers')
|
||||||
# ### end Alembic commands ###
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
@@ -37,8 +37,50 @@ def downgrade():
|
|||||||
sa.Column('header_value', sa.VARCHAR(), nullable=False),
|
sa.Column('header_value', sa.VARCHAR(), nullable=False),
|
||||||
sa.Column('is_active', sa.BOOLEAN(), nullable=True),
|
sa.Column('is_active', sa.BOOLEAN(), nullable=True),
|
||||||
sa.Column('created_at', sa.DATETIME(), nullable=True),
|
sa.Column('created_at', sa.DATETIME(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['domain_id'], ['esrv_domains.id'], ),
|
||||||
sa.PrimaryKeyConstraint('id')
|
sa.PrimaryKeyConstraint('id')
|
||||||
)
|
)
|
||||||
|
op.create_table('esrv_domains',
|
||||||
|
sa.Column('id', sa.INTEGER(), nullable=False),
|
||||||
|
sa.Column('domain_name', sa.VARCHAR(), nullable=False),
|
||||||
|
sa.Column('is_active', sa.BOOLEAN(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DATETIME(), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('domain_name')
|
||||||
|
)
|
||||||
|
op.create_table('esrv_dkim_keys',
|
||||||
|
sa.Column('id', sa.INTEGER(), nullable=False),
|
||||||
|
sa.Column('domain_id', sa.INTEGER(), nullable=False),
|
||||||
|
sa.Column('selector', sa.VARCHAR(), nullable=False),
|
||||||
|
sa.Column('private_key', sa.TEXT(), nullable=False),
|
||||||
|
sa.Column('public_key', sa.TEXT(), nullable=False),
|
||||||
|
sa.Column('is_active', sa.BOOLEAN(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DATETIME(), nullable=True),
|
||||||
|
sa.Column('replaced_at', sa.DATETIME(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['domain_id'], ['esrv_domains.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_table('esrv_whitelisted_ips',
|
||||||
|
sa.Column('id', sa.INTEGER(), nullable=False),
|
||||||
|
sa.Column('ip_address', sa.VARCHAR(), nullable=False),
|
||||||
|
sa.Column('domain_id', sa.INTEGER(), nullable=False),
|
||||||
|
sa.Column('is_active', sa.BOOLEAN(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DATETIME(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['domain_id'], ['esrv_domains.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_table('esrv_senders',
|
||||||
|
sa.Column('id', sa.INTEGER(), nullable=False),
|
||||||
|
sa.Column('email', sa.VARCHAR(), nullable=False),
|
||||||
|
sa.Column('password_hash', sa.VARCHAR(), nullable=False),
|
||||||
|
sa.Column('domain_id', sa.INTEGER(), nullable=False),
|
||||||
|
sa.Column('can_send_as_domain', sa.BOOLEAN(), nullable=True),
|
||||||
|
sa.Column('is_active', sa.BOOLEAN(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DATETIME(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['domain_id'], ['esrv_domains.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('email')
|
||||||
|
)
|
||||||
op.create_table('esrv_email_logs',
|
op.create_table('esrv_email_logs',
|
||||||
sa.Column('id', sa.INTEGER(), nullable=False),
|
sa.Column('id', sa.INTEGER(), nullable=False),
|
||||||
sa.Column('message_id', sa.VARCHAR(), nullable=False),
|
sa.Column('message_id', sa.VARCHAR(), nullable=False),
|
||||||
@@ -57,33 +99,6 @@ def downgrade():
|
|||||||
sa.PrimaryKeyConstraint('id'),
|
sa.PrimaryKeyConstraint('id'),
|
||||||
sa.UniqueConstraint('message_id')
|
sa.UniqueConstraint('message_id')
|
||||||
)
|
)
|
||||||
op.create_table('esrv_whitelisted_ips',
|
|
||||||
sa.Column('id', sa.INTEGER(), nullable=False),
|
|
||||||
sa.Column('ip_address', sa.VARCHAR(), nullable=False),
|
|
||||||
sa.Column('domain_id', sa.INTEGER(), nullable=False),
|
|
||||||
sa.Column('is_active', sa.BOOLEAN(), nullable=True),
|
|
||||||
sa.Column('created_at', sa.DATETIME(), nullable=True),
|
|
||||||
sa.PrimaryKeyConstraint('id')
|
|
||||||
)
|
|
||||||
op.create_table('esrv_domains',
|
|
||||||
sa.Column('id', sa.INTEGER(), nullable=False),
|
|
||||||
sa.Column('domain_name', sa.VARCHAR(), nullable=False),
|
|
||||||
sa.Column('is_active', sa.BOOLEAN(), nullable=True),
|
|
||||||
sa.Column('created_at', sa.DATETIME(), nullable=True),
|
|
||||||
sa.PrimaryKeyConstraint('id'),
|
|
||||||
sa.UniqueConstraint('domain_name')
|
|
||||||
)
|
|
||||||
op.create_table('esrv_users',
|
|
||||||
sa.Column('id', sa.INTEGER(), nullable=False),
|
|
||||||
sa.Column('email', sa.VARCHAR(), nullable=False),
|
|
||||||
sa.Column('password_hash', sa.VARCHAR(), nullable=False),
|
|
||||||
sa.Column('domain_id', sa.INTEGER(), nullable=False),
|
|
||||||
sa.Column('can_send_as_domain', sa.BOOLEAN(), nullable=True),
|
|
||||||
sa.Column('is_active', sa.BOOLEAN(), nullable=True),
|
|
||||||
sa.Column('created_at', sa.DATETIME(), nullable=True),
|
|
||||||
sa.PrimaryKeyConstraint('id'),
|
|
||||||
sa.UniqueConstraint('email')
|
|
||||||
)
|
|
||||||
op.create_table('esrv_auth_logs',
|
op.create_table('esrv_auth_logs',
|
||||||
sa.Column('id', sa.INTEGER(), nullable=False),
|
sa.Column('id', sa.INTEGER(), nullable=False),
|
||||||
sa.Column('auth_type', sa.VARCHAR(), nullable=False),
|
sa.Column('auth_type', sa.VARCHAR(), nullable=False),
|
||||||
@@ -94,15 +109,4 @@ def downgrade():
|
|||||||
sa.Column('created_at', sa.DATETIME(), nullable=True),
|
sa.Column('created_at', sa.DATETIME(), nullable=True),
|
||||||
sa.PrimaryKeyConstraint('id')
|
sa.PrimaryKeyConstraint('id')
|
||||||
)
|
)
|
||||||
op.create_table('esrv_dkim_keys',
|
|
||||||
sa.Column('id', sa.INTEGER(), nullable=False),
|
|
||||||
sa.Column('domain_id', sa.INTEGER(), nullable=False),
|
|
||||||
sa.Column('selector', sa.VARCHAR(), nullable=False),
|
|
||||||
sa.Column('private_key', sa.TEXT(), nullable=False),
|
|
||||||
sa.Column('public_key', sa.TEXT(), nullable=False),
|
|
||||||
sa.Column('is_active', sa.BOOLEAN(), nullable=True),
|
|
||||||
sa.Column('created_at', sa.DATETIME(), nullable=True),
|
|
||||||
sa.Column('replaced_at', sa.DATETIME(), nullable=True),
|
|
||||||
sa.PrimaryKeyConstraint('id')
|
|
||||||
)
|
|
||||||
# ### end Alembic commands ###
|
# ### end Alembic commands ###
|
||||||
Reference in New Issue
Block a user