diff --git a/email_server/auth.py b/email_server/auth.py index 08ad04d..82d51a0 100644 --- a/email_server/auth.py +++ b/email_server/auth.py @@ -29,10 +29,11 @@ class EnhancedAuthenticator: - 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): 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 username = auth_data.login @@ -73,7 +74,8 @@ class EnhancedAuthenticator: message=f'Invalid credentials for {username}' ) 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: logger.error(f'Authentication error for {username}: {e}') @@ -84,7 +86,8 @@ class EnhancedAuthenticator: success=False, 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: """ @@ -98,7 +101,7 @@ class EnhancedIPAuthenticator: 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: ip_address: Client IP address @@ -108,11 +111,25 @@ class EnhancedIPAuthenticator: (success, message) tuple """ try: + # First, check for domain-specific whitelist whitelisted_ip = get_whitelisted_ip(ip_address, domain_name) if whitelisted_ip: return True, f"IP {ip_address} authorized for domain {domain_name}" - else: - return False, f"IP {ip_address} not authorized for domain {domain_name}" + # Then, check for global whitelist + 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: logger.error(f"Error checking IP authorization: {e}") return False, f"Error checking IP authorization: {str(e)}" diff --git a/email_server/models.py b/email_server/models.py index 3875428..74f04bc 100644 --- a/email_server/models.py +++ b/email_server/models.py @@ -88,16 +88,18 @@ class Sender(Base): class WhitelistedIP(Base): """ - IP whitelist model with domain-specific authentication. + IP whitelist model with domain-specific and global authentication. 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' id = Column(Integer, primary_key=True) 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) created_at = Column(DateTime, default=func.now()) @@ -107,13 +109,13 @@ class WhitelistedIP(Base): Args: domain_name: The domain name to check - 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: return False - + if self.global_ip: + return True # Need to check against the actual domain session = Session() try: @@ -126,7 +128,7 @@ class WhitelistedIP(Base): session.close() def __repr__(self): - return f"" + return f"" class EmailLog(Base): """Email log model for tracking sent emails.""" diff --git a/email_server/server_web_ui/ip_whitelist.py b/email_server/server_web_ui/ip_whitelist.py index a9ed0ae..303745a 100644 --- a/email_server/server_web_ui/ip_whitelist.py +++ b/email_server/server_web_ui/ip_whitelist.py @@ -22,52 +22,66 @@ def ips_list(): """List all whitelisted IPs.""" session = Session() try: - ips = session.query(WhitelistedIP, Domain).join(Domain, WhitelistedIP.domain_id == Domain.id).order_by(WhitelistedIP.ip_address).all() - return render_template('ips.html', ips=ips) + all_ips = session.query(WhitelistedIP, Domain).join(Domain, WhitelistedIP.domain_id == Domain.id, isouter=True).order_by(WhitelistedIP.ip_address).all() + 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: session.close() @email_bp.route('/ips/add', methods=['GET', 'POST']) def add_ip(): - """Add new whitelisted IP.""" + """Add new whitelisted IP(s).""" session = Session() try: domains = session.query(Domain).filter_by(is_active=True).order_by(Domain.domain_name).all() - 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) - - if not all([ip_address, domain_id]): - flash('All fields are required', 'error') + global_ip = request.form.get('global_ip') == 'on' + # Split IPs by line or comma + 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')) - - # Basic IP validation - try: - socket.inet_aton(ip_address) - except socket.error: - flash('Invalid IP address format', 'error') + if not global_ip and not domain_id: + flash('Please select a domain for non-global IPs', 'error') return redirect(url_for('email.add_ip')) - - # Check if IP already exists for this domain - existing = session.query(WhitelistedIP).filter_by(ip_address=ip_address, domain_id=domain_id).first() - if existing: - flash(f'IP {ip_address} already whitelisted for this domain', 'error') - return redirect(url_for('email.ips_list')) - - # Create whitelisted IP - whitelist = WhitelistedIP( - ip_address=ip_address, - domain_id=domain_id - ) - session.add(whitelist) + added = 0 + for ip_address in ip_list: + # Basic IP validation + try: + socket.inet_aton(ip_address) + except socket.error: + flash(f'Invalid IP address format: {ip_address}', 'error') + continue + # Check if IP already exists for this domain/global + existing = session.query(WhitelistedIP).filter_by(ip_address=ip_address, global_ip=global_ip) + if not global_ip: + existing = existing.filter_by(domain_id=domain_id) + 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() - - flash(f'IP {ip_address} added to whitelist', 'success') + if added: + flash(f'{added} IP(s) added to whitelist', 'success') return redirect(url_for('email.ips_list')) - return render_template('add_ip.html', domains=domains) - except Exception as e: session.rollback() logger.error(f"Error adding IP: {e}") @@ -166,33 +180,35 @@ def edit_ip(ip_id: int): if request.method == 'POST': ip_address = request.form.get('ip_address', '').strip() domain_id = request.form.get('domain_id', type=int) + global_ip = request.form.get('global_ip') == 'on' - if not all([ip_address, domain_id]): - flash('All fields are required', 'error') + if not ip_address: + flash('IP address is required', 'error') return redirect(url_for('email.edit_ip', ip_id=ip_id)) - # Basic IP validation try: socket.inet_aton(ip_address) except socket.error: flash('Invalid IP address format', 'error') return redirect(url_for('email.edit_ip', ip_id=ip_id)) - - # Check if IP already exists for this domain (excluding current record) + # Check if IP already exists for this domain/global (excluding current record) existing = session.query(WhitelistedIP).filter( WhitelistedIP.ip_address == ip_address, - WhitelistedIP.domain_id == domain_id, + WhitelistedIP.global_ip == global_ip, WhitelistedIP.id != ip_id - ).first() - if existing: - flash(f'IP {ip_address} already whitelisted for this domain', 'error') + ) + if not global_ip: + 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)) - # Update IP record 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() - flash(f'IP whitelist record updated', 'success') return redirect(url_for('email.ips_list')) diff --git a/email_server/server_web_ui/templates/add_ip.html b/email_server/server_web_ui/templates/add_ip.html index 97d26b0..c6c290d 100644 --- a/email_server/server_web_ui/templates/add_ip.html +++ b/email_server/server_web_ui/templates/add_ip.html @@ -40,30 +40,30 @@
- - + +
- IPv4 address that will be allowed to send emails without authentication + Enter one or more IPv4 addresses (one per line or comma-separated).
+ Each IP will be added as a separate whitelist entry.
+
+ + +
- {% for domain in domains %} {% endfor %}
- 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)
@@ -109,25 +109,18 @@ const data = await response.json(); document.getElementById('current-ip').innerHTML = `${data.ip_addr}`; - } catch (er) { - try { - const response = await fetch('https://httpbin.org/ip'); - const data = await response.json(); - document.getElementById('current-ip').innerHTML = - `${data.origin}`; - } catch (error) { - document.getElementById('current-ip').innerHTML = + } catch (error) { + document.getElementById('current-ip').innerHTML = 'Unable to detect'; } } - } function useCurrentIP() { const currentIPElement = document.getElementById('current-ip'); const ip = currentIPElement.textContent.trim(); if (ip && ip !== 'Detecting...' && ip !== 'Unable to detect') { - document.getElementById('ip_address').value = ip; + document.getElementById('ip_addresses').value = ip; // Focus on domain selection document.getElementById('domain_id').focus(); } 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 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(); {% endblock %} diff --git a/email_server/server_web_ui/templates/edit_ip.html b/email_server/server_web_ui/templates/edit_ip.html index ba8375d..9f6aa1a 100644 --- a/email_server/server_web_ui/templates/edit_ip.html +++ b/email_server/server_web_ui/templates/edit_ip.html @@ -21,16 +21,21 @@ id="ip_address" name="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>
- Enter a single IP address or CIDR block + Enter a single IP address
- +
+ + +
- {% for domain in domains %}
@@ -158,6 +163,19 @@ document.addEventListener('DOMContentLoaded', function() { const parts = ip.split('/')[0].split('.'); 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(); }); {% endblock %} diff --git a/email_server/server_web_ui/templates/ips.html b/email_server/server_web_ui/templates/ips.html index bedefe4..febdae6 100644 --- a/email_server/server_web_ui/templates/ips.html +++ b/email_server/server_web_ui/templates/ips.html @@ -21,7 +21,7 @@
- Whitelisted IP Addresses + Per Domain Whitelisted IP Addresses
@@ -44,7 +44,11 @@
{{ ip.ip_address }}
- {{ domain.domain_name }} + {% if ip.global_ip %} + Global + {% else %} + {{ domain.domain_name }} + {% endif %} {% if ip.is_active %} @@ -123,6 +127,97 @@ {% endif %}
+ + {% if global_ips %} +
+
+
+ + Global Whitelisted IP Addresses (can be used for all domains) +
+
+
+
+ + + + + + + + + + + {% for ip, domain in global_ips %} + + + + + + + {% endfor %} + +
IP AddressStatusAddedActions
+
{{ ip.ip_address }}
+
+ {% if ip.is_active %} + + + Active + + {% else %} + + + Inactive + + {% endif %} + + + {{ ip.created_at.strftime('%Y-%m-%d %H:%M') }} + + +
+ + + + + + {% if ip.is_active %} + + + + {% else %} +
+ +
+ {% endif %} + +
+ +
+
+
+
+
+
+ {% endif %}
@@ -139,7 +234,7 @@
  • - Active IPs: {{ ips|selectattr('0.is_active')|list|length }} + Active IPs: {{ (ips|selectattr('0.is_active')|list|length) + (global_ips|selectattr('0.is_active')|list|length) }}
  • @@ -161,9 +256,10 @@
    • Whitelisted IPs can send emails without username/password authentication
    • -
    • Each IP is associated with a specific domain
    • -
    • IP can only send emails for its authorized domain
    • -
    • Useful for server-to-server email sending
    • +
    • Per Domain IP associated with a specific domain
    • +
    • Global IP can be used for all domains
    • +
    • Global IP whitelisting only Domains added to server, if domain is not added, global IP will not work
    • +
    • If IP is whitelisted, invalid username/password will still pass authentication
@@ -193,6 +289,7 @@ + {% endblock %} {% block extra_js %} diff --git a/email_server/smtp_handler.py b/email_server/smtp_handler.py index db100a1..3dcd35b 100644 --- a/email_server/smtp_handler.py +++ b/email_server/smtp_handler.py @@ -49,16 +49,22 @@ class EnhancedCombinedAuthenticator: self.user_auth = EnhancedAuthenticator() 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 # If auth_data is provided (username/password), try user authentication first if auth_data and isinstance(auth_data, LoginPassword): - result = self.user_auth(server, session, envelope, mechanism, auth_data) - if result.success: - return result - # If user auth fails, don't try IP auth - return the failure - return result + try: + result = self.user_auth(server, session, envelope, mechanism, auth_data) + if result.success: + 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 # For now, allow the connection to proceed diff --git a/migrations/versions/3ce273a1be20_initial_migration.py b/migrations/versions/8652d2ab8a26_update_ip_whitelist.py similarity index 90% rename from migrations/versions/3ce273a1be20_initial_migration.py rename to migrations/versions/8652d2ab8a26_update_ip_whitelist.py index 6ef2a27..097a71c 100644 --- a/migrations/versions/3ce273a1be20_initial_migration.py +++ b/migrations/versions/8652d2ab8a26_update_ip_whitelist.py @@ -1,8 +1,8 @@ -"""Initial migration +"""update ip whitelist -Revision ID: 3ce273a1be20 +Revision ID: 8652d2ab8a26 Revises: -Create Date: 2025-06-07 15:25:35.603295 +Create Date: 2025-06-10 02:17:23.718102 """ from alembic import op @@ -10,7 +10,7 @@ import sqlalchemy as sa # revision identifiers, used by Alembic. -revision = '3ce273a1be20' +revision = '8652d2ab8a26' down_revision = None branch_labels = None depends_on = None @@ -18,12 +18,12 @@ depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### - op.drop_table('esrv_dkim_keys') 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_senders') + op.drop_table('esrv_whitelisted_ips') + op.drop_table('esrv_dkim_keys') + op.drop_table('esrv_domains') op.drop_table('esrv_custom_headers') # ### end Alembic commands ### @@ -37,8 +37,50 @@ def downgrade(): sa.Column('header_value', sa.VARCHAR(), 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_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', sa.Column('id', sa.INTEGER(), nullable=False), sa.Column('message_id', sa.VARCHAR(), nullable=False), @@ -57,33 +99,6 @@ def downgrade(): sa.PrimaryKeyConstraint('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', sa.Column('id', sa.INTEGER(), 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.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 ###