Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b05611dca8 |
@@ -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)}"
|
||||
|
||||
@@ -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"<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):
|
||||
"""Email log model for tracking sent emails."""
|
||||
|
||||
@@ -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'))
|
||||
|
||||
|
||||
@@ -40,30 +40,30 @@
|
||||
<div class="card-body">
|
||||
<form method="POST">
|
||||
<div class="mb-3">
|
||||
<label for="ip_address" class="form-label">IP Address</label>
|
||||
<input type="text"
|
||||
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', '') }}">
|
||||
<label for="ip_addresses" class="form-label">IP Addresses</label>
|
||||
<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>
|
||||
<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 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">
|
||||
<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>
|
||||
{% for domain in domains %}
|
||||
<option value="{{ domain.id }}">{{ domain.domain_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<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>
|
||||
|
||||
@@ -109,25 +109,18 @@
|
||||
const data = await response.json();
|
||||
document.getElementById('current-ip').innerHTML =
|
||||
`<span class="text-primary">${data.ip_addr}</span>`;
|
||||
} catch (er) {
|
||||
try {
|
||||
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 =
|
||||
} catch (error) {
|
||||
document.getElementById('current-ip').innerHTML =
|
||||
'<span class="text-muted">Unable to detect</span>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -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>
|
||||
<div class="form-text">
|
||||
Enter a single IP address or CIDR block
|
||||
Enter a single IP address
|
||||
</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">
|
||||
<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>
|
||||
{% for domain in domains %}
|
||||
<option value="{{ domain.id }}"
|
||||
@@ -40,7 +45,7 @@
|
||||
{% endfor %}
|
||||
</select>
|
||||
<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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-list me-2"></i>
|
||||
Whitelisted IP Addresses
|
||||
Per Domain Whitelisted IP Addresses
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@@ -44,7 +44,11 @@
|
||||
<div class="fw-bold font-monospace">{{ ip.ip_address }}</div>
|
||||
</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>
|
||||
{% if ip.is_active %}
|
||||
@@ -123,6 +127,97 @@
|
||||
{% endif %}
|
||||
</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 class="col-lg-4">
|
||||
@@ -139,7 +234,7 @@
|
||||
<ul class="list-unstyled mb-3">
|
||||
<li class="mb-2">
|
||||
<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 class="mb-2">
|
||||
<i class="bi bi-server text-info me-2"></i>
|
||||
@@ -161,9 +256,10 @@
|
||||
</h6>
|
||||
<ul class="mb-0 small">
|
||||
<li>Whitelisted IPs can send emails without username/password authentication</li>
|
||||
<li>Each IP is associated with a specific domain</li>
|
||||
<li>IP can only send emails for its authorized domain</li>
|
||||
<li>Useful for server-to-server email sending</li>
|
||||
<li>Per Domain IP associated with a specific domain</li>
|
||||
<li>Global IP can be used for all domains</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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -193,6 +289,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ###
|
||||
Reference in New Issue
Block a user