feat: make DDNS domain_name the effective domain across all services
Unit Tests / test (push) Successful in 11m35s
Unit Tests / test (push) Successful in 11m35s
- ConfigManager.get_effective_domain(): returns domain_name when DDNS
active (pic_ngo/cloudflare/duckdns), domain otherwise. Used by all
public-facing services so they use the real registered FQDN.
- ConfigManager.get_internal_domain(): always returns _identity.domain
(CoreDNS zone name, dnsmasq, cell-link invites — stays internal).
- Silent migration: if domain_mode != lan and domain is generic "cell",
auto-set to {cell_name}.local for unique CoreDNS zone naming.
- caddy_manager: fix custom_domain bug — cloudflare/http01 modes were
reading identity.get('custom_domain') which never exists; now reads
domain_name correctly.
- routes/config, app: expose effective_domain in GET /api/config and
/api/status responses.
- email_manager, routes/email: use get_effective_domain() for
OVERRIDE_HOSTNAME, POSTMASTER_ADDRESS, and new-user email defaults.
- ServiceBus.IDENTITY_CHANGED event: emitted from PUT /api/config and
POST /api/ddns/register after identity writes; caddy_manager and
email_manager subscribe to regenerate config automatically.
- Settings.jsx: hide Local Domain input in non-LAN modes; show
read-only effective_domain with "managed by DDNS" badge and an
Advanced toggle for the internal CoreDNS zone name.
- 11 new test classes covering all new helpers, event subscriptions,
caddy/email handlers, and the custom_domain fix.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -704,6 +704,7 @@ def get_cell_status():
|
||||
return jsonify({
|
||||
"cell_name": identity.get('cell_name', os.environ.get('CELL_NAME', 'mycell')),
|
||||
"domain": identity.get('domain', os.environ.get('CELL_DOMAIN', 'cell')),
|
||||
"effective_domain": config_manager.get_effective_domain(),
|
||||
"uptime": uptime_seconds,
|
||||
"peers_count": len(peers),
|
||||
"services": services_status,
|
||||
|
||||
+15
-3
@@ -51,7 +51,8 @@ class CaddyManager(BaseServiceManager):
|
||||
|
||||
def __init__(self, config_manager=None,
|
||||
data_dir: str = '/app/data',
|
||||
config_dir: str = '/app/config'):
|
||||
config_dir: str = '/app/config',
|
||||
service_bus=None):
|
||||
super().__init__('caddy', data_dir, config_dir)
|
||||
self.config_manager = config_manager
|
||||
self.container_name = 'cell-caddy'
|
||||
@@ -60,6 +61,10 @@ class CaddyManager(BaseServiceManager):
|
||||
# the caller restarts the container).
|
||||
self._health_failures = 0
|
||||
|
||||
if service_bus is not None:
|
||||
from service_bus import EventType
|
||||
service_bus.subscribe_to_event(EventType.IDENTITY_CHANGED, self._on_identity_changed)
|
||||
|
||||
# ── BaseServiceManager required ───────────────────────────────────────
|
||||
|
||||
def get_status(self) -> Dict[str, Any]:
|
||||
@@ -126,14 +131,14 @@ class CaddyManager(BaseServiceManager):
|
||||
if domain_mode == 'pic_ngo':
|
||||
return self._caddyfile_pic_ngo(cell_name, service_routes, core_routes)
|
||||
if domain_mode == 'cloudflare':
|
||||
custom_domain = identity.get('custom_domain', f'{cell_name}.local')
|
||||
custom_domain = identity.get('domain_name', identity.get('domain', f'{cell_name}.local'))
|
||||
return self._caddyfile_cloudflare(
|
||||
custom_domain, service_routes, core_routes
|
||||
)
|
||||
if domain_mode == 'duckdns':
|
||||
return self._caddyfile_duckdns(cell_name, service_routes, core_routes)
|
||||
if domain_mode == 'http01':
|
||||
host = identity.get('custom_domain', f'{cell_name}.noip.me')
|
||||
host = identity.get('domain_name', identity.get('domain', f'{cell_name}.noip.me'))
|
||||
return self._caddyfile_http01(host, installed_services, core_routes)
|
||||
|
||||
# Fallback to lan so we always emit a valid Caddyfile.
|
||||
@@ -379,6 +384,13 @@ class CaddyManager(BaseServiceManager):
|
||||
content = self.generate_caddyfile(identity, installed_services)
|
||||
return self.write_caddyfile(content)
|
||||
|
||||
def _on_identity_changed(self, event) -> None:
|
||||
"""Regenerate and reload the Caddyfile when cell identity changes."""
|
||||
try:
|
||||
self.regenerate_with_installed([])
|
||||
except Exception as exc:
|
||||
self.logger.warning('caddy_manager identity_changed handler failed: %s', exc)
|
||||
|
||||
def get_cert_status(self) -> Dict[str, Any]:
|
||||
"""Return TLS cert status from identity['tls'] if present."""
|
||||
default = {'status': 'unknown', 'expiry': None, 'days_remaining': None}
|
||||
|
||||
@@ -45,6 +45,21 @@ class ConfigManager:
|
||||
self.configs['connectivity'] = {'exits': {}, 'peer_exit_map': {}}
|
||||
if not self.config_file.exists():
|
||||
self._save_all_configs()
|
||||
# Silent migration: when DDNS is active but the internal domain is still
|
||||
# the generic "cell" default, give CoreDNS a unique zone name so multiple
|
||||
# cells on the same LAN don't collide.
|
||||
try:
|
||||
_ident = self.configs.get('_identity', {})
|
||||
_mode = _ident.get('domain_mode', 'lan')
|
||||
_domain = _ident.get('domain', '')
|
||||
_cell_name = _ident.get('cell_name', '')
|
||||
if (_mode != 'lan' and _cell_name
|
||||
and (_domain in ('cell', '', None))):
|
||||
_new_domain = f'{_cell_name}.local'
|
||||
self.configs['_identity']['domain'] = _new_domain
|
||||
self._save_all_configs()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _load_service_schemas(self) -> Dict[str, Dict]:
|
||||
"""Load configuration schemas for all services"""
|
||||
@@ -478,6 +493,23 @@ class ConfigManager:
|
||||
"""Return the current identity configuration."""
|
||||
return self.configs.get('_identity', {})
|
||||
|
||||
def get_effective_domain(self) -> str:
|
||||
"""Return the FQDN that public-facing services should use.
|
||||
In lan mode: _identity.domain. Otherwise: _identity.domain_name
|
||||
(falls back to domain if domain_name not yet registered)."""
|
||||
ident = self.get_identity()
|
||||
mode = ident.get('domain_mode', 'lan')
|
||||
if mode == 'lan':
|
||||
return ident.get('domain') or os.environ.get('CELL_DOMAIN', 'cell')
|
||||
return (ident.get('domain_name')
|
||||
or ident.get('domain')
|
||||
or os.environ.get('CELL_DOMAIN', 'cell'))
|
||||
|
||||
def get_internal_domain(self) -> str:
|
||||
"""Return the CoreDNS zone name (always _identity.domain)."""
|
||||
ident = self.get_identity()
|
||||
return ident.get('domain') or os.environ.get('CELL_DOMAIN', 'cell')
|
||||
|
||||
def set_identity_field(self, key: str, value: Any):
|
||||
"""Set a single field in the identity configuration and persist."""
|
||||
if '_identity' not in self.configs:
|
||||
|
||||
+15
-1
@@ -19,7 +19,8 @@ logger = logging.getLogger(__name__)
|
||||
class EmailManager(BaseServiceManager):
|
||||
"""Manages email service configuration and users"""
|
||||
|
||||
def __init__(self, data_dir: str = '/app/data', config_dir: str = '/app/config'):
|
||||
def __init__(self, data_dir: str = '/app/data', config_dir: str = '/app/config',
|
||||
service_bus=None):
|
||||
super().__init__('email', data_dir, config_dir)
|
||||
self.email_data_dir = os.path.join(data_dir, 'email')
|
||||
self.email_dir = self.email_data_dir # alias used by tests
|
||||
@@ -33,6 +34,10 @@ class EmailManager(BaseServiceManager):
|
||||
self.safe_makedirs(self.dovecot_dir)
|
||||
self.safe_makedirs(os.path.dirname(self.domain_config_file))
|
||||
|
||||
if service_bus is not None:
|
||||
from service_bus import EventType
|
||||
service_bus.subscribe_to_event(EventType.IDENTITY_CHANGED, self._on_identity_changed)
|
||||
|
||||
def _get_service_config(self) -> Dict[str, Any]:
|
||||
"""Read configured ports/domain from service config file."""
|
||||
cfg = self.get_config()
|
||||
@@ -252,6 +257,15 @@ class EmailManager(BaseServiceManager):
|
||||
|
||||
return {'restarted': restarted, 'warnings': warnings}
|
||||
|
||||
def _on_identity_changed(self, event) -> None:
|
||||
"""Regenerate email config when cell identity changes."""
|
||||
try:
|
||||
effective = event.data.get('effective_domain')
|
||||
if effective:
|
||||
self.apply_config({'domain': effective})
|
||||
except Exception as exc:
|
||||
self.logger.warning('email_manager identity_changed handler failed: %s', exc)
|
||||
|
||||
def get_email_status(self) -> Dict[str, Any]:
|
||||
"""Get detailed email service status including postfix/dovecot state."""
|
||||
try:
|
||||
|
||||
+2
-2
@@ -45,7 +45,7 @@ log_manager = LogManager(log_dir='./data/logs')
|
||||
network_manager = NetworkManager(data_dir=DATA_DIR, config_dir=CONFIG_DIR)
|
||||
wireguard_manager = WireGuardManager(data_dir=DATA_DIR, config_dir=CONFIG_DIR)
|
||||
peer_registry = PeerRegistry(data_dir=DATA_DIR, config_dir=CONFIG_DIR)
|
||||
email_manager = EmailManager(data_dir=DATA_DIR, config_dir=CONFIG_DIR)
|
||||
email_manager = EmailManager(data_dir=DATA_DIR, config_dir=CONFIG_DIR, service_bus=service_bus)
|
||||
calendar_manager = CalendarManager(data_dir=DATA_DIR, config_dir=CONFIG_DIR)
|
||||
file_manager = FileManager(data_dir=DATA_DIR, config_dir=CONFIG_DIR)
|
||||
routing_manager = RoutingManager(data_dir=DATA_DIR, config_dir=CONFIG_DIR)
|
||||
@@ -58,7 +58,7 @@ cell_link_manager = CellLinkManager(
|
||||
)
|
||||
auth_manager = AuthManager(data_dir=DATA_DIR, config_dir=CONFIG_DIR)
|
||||
setup_manager = SetupManager(config_manager=config_manager, auth_manager=auth_manager)
|
||||
caddy_manager = CaddyManager(config_manager=config_manager, data_dir=DATA_DIR, config_dir=CONFIG_DIR)
|
||||
caddy_manager = CaddyManager(config_manager=config_manager, data_dir=DATA_DIR, config_dir=CONFIG_DIR, service_bus=service_bus)
|
||||
ddns_manager = DDNSManager(config_manager=config_manager, data_dir=DATA_DIR, config_dir=CONFIG_DIR)
|
||||
connectivity_manager = ConnectivityManager(
|
||||
config_manager=config_manager,
|
||||
|
||||
@@ -120,6 +120,7 @@ def get_config():
|
||||
config['service_configs'] = service_configs
|
||||
config['domain_mode'] = identity.get('domain_mode', 'lan')
|
||||
config['domain_name'] = identity.get('domain_name', '')
|
||||
config['effective_domain'] = config_manager.get_effective_domain()
|
||||
ddns_section = config_manager.configs.get('ddns', {})
|
||||
config['ddns'] = {
|
||||
'provider': ddns_section.get('provider', ''),
|
||||
@@ -376,6 +377,16 @@ def update_config():
|
||||
pre_change_snapshot=_pre_change_snapshot,
|
||||
)
|
||||
|
||||
if identity_updates:
|
||||
_cur_identity = config_manager.configs.get('_identity', {})
|
||||
service_bus.publish_event(EventType.IDENTITY_CHANGED, 'config', {
|
||||
'cell_name': _cur_identity.get('cell_name'),
|
||||
'domain': _cur_identity.get('domain'),
|
||||
'domain_name': _cur_identity.get('domain_name'),
|
||||
'domain_mode': _cur_identity.get('domain_mode'),
|
||||
'effective_domain': config_manager.get_effective_domain(),
|
||||
})
|
||||
|
||||
_PORT_CHANGE_MAP = {
|
||||
('network', 'dns_port'): ('dns_port', ['dns']),
|
||||
('wireguard','port'): ('wg_port', ['wireguard']),
|
||||
@@ -579,6 +590,15 @@ def ddns_register():
|
||||
new_sub = result.get('subdomain', f'{cell_name}.pic.ngo')
|
||||
config_manager.set_identity_field('domain_name', new_sub)
|
||||
logger.info('DDNS registered via /api/ddns/register: cell_name=%r subdomain=%r', cell_name, new_sub)
|
||||
from app import service_bus, EventType
|
||||
_reg_identity = config_manager.configs.get('_identity', {})
|
||||
service_bus.publish_event(EventType.IDENTITY_CHANGED, 'ddns_register', {
|
||||
'cell_name': _reg_identity.get('cell_name'),
|
||||
'domain': _reg_identity.get('domain'),
|
||||
'domain_name': new_sub,
|
||||
'domain_mode': _reg_identity.get('domain_mode'),
|
||||
'effective_domain': config_manager.get_effective_domain(),
|
||||
})
|
||||
return jsonify({'registered': True, 'subdomain': new_sub})
|
||||
except Exception as e:
|
||||
logger.error('Error in /api/ddns/register: %s', e)
|
||||
|
||||
+4
-4
@@ -18,12 +18,12 @@ def get_email_users():
|
||||
def create_email_user():
|
||||
"""Create email user."""
|
||||
try:
|
||||
from app import email_manager, _configured_domain
|
||||
from app import email_manager, config_manager
|
||||
data = request.get_json(silent=True)
|
||||
if data is None:
|
||||
return jsonify({"error": "No data provided"}), 400
|
||||
username = data.get('username')
|
||||
domain = data.get('domain') or _configured_domain()
|
||||
domain = data.get('domain') or config_manager.get_effective_domain()
|
||||
password = data.get('password')
|
||||
if not username or not password:
|
||||
return jsonify({"error": "Missing required fields: username, password"}), 400
|
||||
@@ -37,8 +37,8 @@ def create_email_user():
|
||||
def delete_email_user(username):
|
||||
"""Delete email user."""
|
||||
try:
|
||||
from app import email_manager, _configured_domain
|
||||
domain = request.args.get('domain') or _configured_domain()
|
||||
from app import email_manager, config_manager
|
||||
domain = request.args.get('domain') or config_manager.get_effective_domain()
|
||||
result = email_manager.delete_email_user(username, domain)
|
||||
return jsonify({"deleted": result})
|
||||
except Exception as e:
|
||||
|
||||
@@ -31,6 +31,7 @@ class EventType(Enum):
|
||||
CERTIFICATE_EXPIRING = "certificate_expiring"
|
||||
BACKUP_CREATED = "backup_created"
|
||||
RESTORE_COMPLETED = "restore_completed"
|
||||
IDENTITY_CHANGED = "identity_changed"
|
||||
|
||||
@dataclass
|
||||
class Event:
|
||||
|
||||
@@ -77,7 +77,7 @@ class TestGenerateCaddyfileCloudflare(unittest.TestCase):
|
||||
identity = {
|
||||
'cell_name': 'beta',
|
||||
'domain_mode': 'cloudflare',
|
||||
'custom_domain': 'example.com',
|
||||
'domain_name': 'example.com',
|
||||
}
|
||||
out = mgr.generate_caddyfile(identity, [])
|
||||
self.assertIn('dns cloudflare {$CF_API_TOKEN}', out)
|
||||
@@ -85,6 +85,23 @@ class TestGenerateCaddyfileCloudflare(unittest.TestCase):
|
||||
self.assertIn('email {$ACME_EMAIL}', out)
|
||||
self.assertIn('acme_ca {$ACME_CA_URL}', out)
|
||||
|
||||
def test_caddyfile_cloudflare_uses_domain_name(self):
|
||||
"""Caddyfile must use domain_name for TLS host, not any 'custom_domain' key."""
|
||||
mgr = _mgr()
|
||||
identity = {
|
||||
'cell_name': 'beta',
|
||||
'domain_mode': 'cloudflare',
|
||||
'domain_name': 'home.example.com',
|
||||
'domain': 'home.local',
|
||||
}
|
||||
out = mgr.generate_caddyfile(identity, [])
|
||||
self.assertIn('*.home.example.com', out)
|
||||
self.assertIn('home.example.com', out)
|
||||
# Must not use the internal domain for TLS
|
||||
self.assertNotIn('*.home.local', out)
|
||||
# 'custom_domain' must not appear literally as a key in the output
|
||||
self.assertNotIn('custom_domain', out)
|
||||
|
||||
|
||||
class TestGenerateCaddyfileDuckDns(unittest.TestCase):
|
||||
def test_duckdns_has_dns_duckdns(self):
|
||||
@@ -101,7 +118,7 @@ class TestGenerateCaddyfileHttp01(unittest.TestCase):
|
||||
identity = {
|
||||
'cell_name': 'delta',
|
||||
'domain_mode': 'http01',
|
||||
'custom_domain': 'delta.noip.me',
|
||||
'domain_name': 'delta.noip.me',
|
||||
}
|
||||
services = [
|
||||
{'name': 'calendar', 'caddy_route':
|
||||
@@ -224,5 +241,37 @@ class TestCertStatus(unittest.TestCase):
|
||||
self.assertEqual(out['days_remaining'], 84)
|
||||
|
||||
|
||||
class TestCaddyManagerIdentityChangedSubscription(unittest.TestCase):
|
||||
def test_subscribes_to_identity_changed_on_init(self):
|
||||
"""When service_bus is provided, CaddyManager subscribes to IDENTITY_CHANGED."""
|
||||
from service_bus import EventType
|
||||
mock_bus = MagicMock()
|
||||
mgr = CaddyManager(config_manager=MagicMock(), service_bus=mock_bus)
|
||||
mock_bus.subscribe_to_event.assert_called_once_with(
|
||||
EventType.IDENTITY_CHANGED, mgr._on_identity_changed
|
||||
)
|
||||
|
||||
def test_no_subscription_without_service_bus(self):
|
||||
"""When service_bus is omitted, no subscription is attempted."""
|
||||
mock_bus = MagicMock()
|
||||
CaddyManager(config_manager=MagicMock())
|
||||
mock_bus.subscribe_to_event.assert_not_called()
|
||||
|
||||
def test_on_identity_changed_calls_regenerate_with_installed(self):
|
||||
"""_on_identity_changed calls regenerate_with_installed([])."""
|
||||
mgr = _mgr()
|
||||
with patch.object(mgr, 'regenerate_with_installed', return_value=True) as mock_regen:
|
||||
event = MagicMock()
|
||||
mgr._on_identity_changed(event)
|
||||
mock_regen.assert_called_once_with([])
|
||||
|
||||
def test_on_identity_changed_swallows_exceptions(self):
|
||||
"""_on_identity_changed must not propagate exceptions."""
|
||||
mgr = _mgr()
|
||||
with patch.object(mgr, 'regenerate_with_installed', side_effect=Exception('boom')):
|
||||
event = MagicMock()
|
||||
mgr._on_identity_changed(event) # must not raise
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
@@ -260,6 +260,92 @@ class TestConfigManager(unittest.TestCase):
|
||||
"import must not inject zero-filled entries for absent services")
|
||||
|
||||
|
||||
class TestGetEffectiveDomain(unittest.TestCase):
|
||||
"""Tests for ConfigManager.get_effective_domain and get_internal_domain."""
|
||||
|
||||
def setUp(self):
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
self.config_file = os.path.join(self.temp_dir, 'cell_config.json')
|
||||
self.data_dir = os.path.join(self.temp_dir, 'data')
|
||||
os.makedirs(self.data_dir, exist_ok=True)
|
||||
|
||||
def tearDown(self):
|
||||
shutil.rmtree(self.temp_dir)
|
||||
|
||||
def _make_cm(self, identity):
|
||||
cm = ConfigManager(self.config_file, self.data_dir)
|
||||
cm.configs['_identity'] = identity
|
||||
return cm
|
||||
|
||||
def test_get_effective_domain_lan_mode(self):
|
||||
cm = self._make_cm({'domain': 'home.local', 'domain_mode': 'lan'})
|
||||
self.assertEqual(cm.get_effective_domain(), 'home.local')
|
||||
|
||||
def test_get_effective_domain_pic_ngo_uses_domain_name(self):
|
||||
cm = self._make_cm({
|
||||
'domain': 'home.local',
|
||||
'domain_mode': 'pic_ngo',
|
||||
'domain_name': 'home.pic.ngo',
|
||||
})
|
||||
self.assertEqual(cm.get_effective_domain(), 'home.pic.ngo')
|
||||
|
||||
def test_get_effective_domain_pic_ngo_fallback(self):
|
||||
cm = self._make_cm({'domain': 'home.local', 'domain_mode': 'pic_ngo'})
|
||||
self.assertEqual(cm.get_effective_domain(), 'home.local')
|
||||
|
||||
def test_get_internal_domain_always_returns_domain(self):
|
||||
cm = self._make_cm({
|
||||
'domain': 'home.local',
|
||||
'domain_mode': 'pic_ngo',
|
||||
'domain_name': 'home.pic.ngo',
|
||||
})
|
||||
self.assertEqual(cm.get_internal_domain(), 'home.local')
|
||||
|
||||
def test_get_internal_domain_ignores_domain_name(self):
|
||||
cm = self._make_cm({
|
||||
'domain': 'myzone.local',
|
||||
'domain_mode': 'cloudflare',
|
||||
'domain_name': 'example.com',
|
||||
})
|
||||
self.assertEqual(cm.get_internal_domain(), 'myzone.local')
|
||||
|
||||
def test_get_effective_domain_cloudflare_uses_domain_name(self):
|
||||
cm = self._make_cm({
|
||||
'domain': 'home.local',
|
||||
'domain_mode': 'cloudflare',
|
||||
'domain_name': 'example.com',
|
||||
})
|
||||
self.assertEqual(cm.get_effective_domain(), 'example.com')
|
||||
|
||||
def test_silent_migration_sets_unique_internal_domain(self):
|
||||
"""When DDNS is active and domain is the generic 'cell', migration sets cell_name.local."""
|
||||
config_file2 = os.path.join(self.temp_dir, 'cell_config2.json')
|
||||
with open(config_file2, 'w') as f:
|
||||
json.dump({
|
||||
'_identity': {
|
||||
'cell_name': 'alpha',
|
||||
'domain': 'cell',
|
||||
'domain_mode': 'pic_ngo',
|
||||
}
|
||||
}, f)
|
||||
cm = ConfigManager(config_file2, self.data_dir)
|
||||
self.assertEqual(cm.get_internal_domain(), 'alpha.local')
|
||||
|
||||
def test_silent_migration_does_not_touch_lan_mode(self):
|
||||
"""Migration must leave domain unchanged when domain_mode is 'lan'."""
|
||||
config_file2 = os.path.join(self.temp_dir, 'cell_config3.json')
|
||||
with open(config_file2, 'w') as f:
|
||||
json.dump({
|
||||
'_identity': {
|
||||
'cell_name': 'beta',
|
||||
'domain': 'cell',
|
||||
'domain_mode': 'lan',
|
||||
}
|
||||
}, f)
|
||||
cm = ConfigManager(config_file2, self.data_dir)
|
||||
self.assertEqual(cm.get_internal_domain(), 'cell')
|
||||
|
||||
|
||||
class TestNetworkManagerApply(unittest.TestCase):
|
||||
"""Test apply_config / apply_domain actually write real config files."""
|
||||
|
||||
|
||||
@@ -104,5 +104,95 @@ class TestEmailManager(unittest.TestCase):
|
||||
info = self.manager.get_mailbox_info(None, None)
|
||||
self.assertIn('error', info)
|
||||
|
||||
class TestEmailManagerEffectiveDomain(unittest.TestCase):
|
||||
"""Verify that email OVERRIDE_HOSTNAME and POSTMASTER_ADDRESS use the
|
||||
caller-supplied domain (which should come from get_effective_domain in the
|
||||
route layer when no explicit domain is provided by the client)."""
|
||||
|
||||
def setUp(self):
|
||||
self.test_dir = tempfile.mkdtemp()
|
||||
self.data_dir = os.path.join(self.test_dir, 'data')
|
||||
self.config_dir = os.path.join(self.test_dir, 'config')
|
||||
os.makedirs(os.path.join(self.config_dir, 'mail'), exist_ok=True)
|
||||
os.makedirs(os.path.join(self.data_dir, 'email'), exist_ok=True)
|
||||
with open(os.path.join(self.config_dir, 'mail', 'mailserver.env'), 'w') as f:
|
||||
f.write('OVERRIDE_HOSTNAME=mail.cell\nPOSTMASTER_ADDRESS=admin@cell\n')
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / 'api'))
|
||||
from email_manager import EmailManager
|
||||
self.em = EmailManager(data_dir=self.data_dir, config_dir=self.config_dir)
|
||||
|
||||
def tearDown(self):
|
||||
shutil.rmtree(self.test_dir)
|
||||
|
||||
@patch('subprocess.run')
|
||||
def test_email_hostname_uses_effective_domain_in_ddns_mode(self, mock_run):
|
||||
"""When apply_config is called with domain='home.pic.ngo' (as provided
|
||||
by the route layer via get_effective_domain), OVERRIDE_HOSTNAME and
|
||||
POSTMASTER_ADDRESS should use 'home.pic.ngo', not the internal 'cell'."""
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
result = self.em.apply_config({'domain': 'home.pic.ngo'})
|
||||
env = open(os.path.join(self.config_dir, 'mail', 'mailserver.env')).read()
|
||||
self.assertIn('OVERRIDE_HOSTNAME=mail.home.pic.ngo', env)
|
||||
self.assertIn('POSTMASTER_ADDRESS=admin@home.pic.ngo', env)
|
||||
self.assertIn('cell-mail', result['restarted'])
|
||||
|
||||
|
||||
class TestEmailManagerIdentityChangedSubscription(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.test_dir = tempfile.mkdtemp()
|
||||
self.data_dir = os.path.join(self.test_dir, 'data')
|
||||
self.config_dir = os.path.join(self.test_dir, 'config')
|
||||
os.makedirs(self.data_dir, exist_ok=True)
|
||||
os.makedirs(self.config_dir, exist_ok=True)
|
||||
|
||||
def tearDown(self):
|
||||
shutil.rmtree(self.test_dir)
|
||||
|
||||
def test_subscribes_to_identity_changed_on_init(self):
|
||||
"""When service_bus is provided, __init__ subscribes to IDENTITY_CHANGED."""
|
||||
from service_bus import EventType
|
||||
mock_bus = MagicMock()
|
||||
manager = EmailManager(
|
||||
data_dir=self.data_dir,
|
||||
config_dir=self.config_dir,
|
||||
service_bus=mock_bus,
|
||||
)
|
||||
mock_bus.subscribe_to_event.assert_called_once_with(
|
||||
EventType.IDENTITY_CHANGED, manager._on_identity_changed
|
||||
)
|
||||
|
||||
def test_no_subscription_without_service_bus(self):
|
||||
"""When service_bus is not provided, no subscription is attempted."""
|
||||
mock_bus = MagicMock()
|
||||
EmailManager(data_dir=self.data_dir, config_dir=self.config_dir)
|
||||
mock_bus.subscribe_to_event.assert_not_called()
|
||||
|
||||
@patch.object(EmailManager, 'apply_config', return_value={'restarted': [], 'warnings': []})
|
||||
def test_on_identity_changed_calls_apply_config(self, mock_apply):
|
||||
"""_on_identity_changed calls apply_config with the effective_domain."""
|
||||
manager = EmailManager(data_dir=self.data_dir, config_dir=self.config_dir)
|
||||
event = MagicMock()
|
||||
event.data = {'effective_domain': 'mycell.pic.ngo'}
|
||||
manager._on_identity_changed(event)
|
||||
mock_apply.assert_called_once_with({'domain': 'mycell.pic.ngo'})
|
||||
|
||||
@patch.object(EmailManager, 'apply_config', side_effect=Exception('boom'))
|
||||
def test_on_identity_changed_swallows_exceptions(self, mock_apply):
|
||||
"""_on_identity_changed must not propagate exceptions."""
|
||||
manager = EmailManager(data_dir=self.data_dir, config_dir=self.config_dir)
|
||||
event = MagicMock()
|
||||
event.data = {'effective_domain': 'mycell.pic.ngo'}
|
||||
manager._on_identity_changed(event) # must not raise
|
||||
|
||||
def test_on_identity_changed_skips_when_no_effective_domain(self):
|
||||
"""_on_identity_changed does nothing when effective_domain is absent."""
|
||||
manager = EmailManager(data_dir=self.data_dir, config_dir=self.config_dir)
|
||||
event = MagicMock()
|
||||
event.data = {'cell_name': 'mycell'}
|
||||
with patch.object(manager, 'apply_config') as mock_apply:
|
||||
manager._on_identity_changed(event)
|
||||
mock_apply.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -214,5 +214,38 @@ class TestServiceBus(unittest.TestCase):
|
||||
mock_service.stop.assert_called_once()
|
||||
mock_service.start.assert_called_once()
|
||||
|
||||
class TestIdentityChangedEventType(unittest.TestCase):
|
||||
"""Tests for the IDENTITY_CHANGED event type."""
|
||||
|
||||
def test_identity_changed_event_type_exists(self):
|
||||
self.assertEqual(EventType.IDENTITY_CHANGED.value, "identity_changed")
|
||||
|
||||
def test_identity_changed_published_and_received(self):
|
||||
"""Publish IDENTITY_CHANGED and verify the subscriber receives it."""
|
||||
bus = ServiceBus()
|
||||
bus.start()
|
||||
try:
|
||||
received = []
|
||||
|
||||
def handler(event):
|
||||
received.append(event)
|
||||
|
||||
bus.subscribe_to_event(EventType.IDENTITY_CHANGED, handler)
|
||||
bus.publish_event(EventType.IDENTITY_CHANGED, 'test', {
|
||||
'cell_name': 'mycell',
|
||||
'domain': 'cell',
|
||||
'domain_name': 'mycell.pic.ngo',
|
||||
'domain_mode': 'pic_ngo',
|
||||
'effective_domain': 'mycell.pic.ngo',
|
||||
})
|
||||
time.sleep(0.2)
|
||||
self.assertEqual(len(received), 1)
|
||||
self.assertEqual(received[0].event_type, EventType.IDENTITY_CHANGED)
|
||||
self.assertEqual(received[0].data['cell_name'], 'mycell')
|
||||
self.assertEqual(received[0].data['effective_domain'], 'mycell.pic.ngo')
|
||||
finally:
|
||||
bus.stop()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -433,6 +433,8 @@ function Settings() {
|
||||
const [identity, setIdentity] = useState({ cell_name: '', domain: '', ip_range: '' });
|
||||
const [identityDirty, setIdentityDirty] = useState(false);
|
||||
const [loadedCellName, setLoadedCellName] = useState('');
|
||||
const [effectiveDomain, setEffectiveDomain] = useState('');
|
||||
const [showAdvancedZone, setShowAdvancedZone] = useState(false);
|
||||
|
||||
// DDNS
|
||||
const [domainMode, setDomainMode] = useState('lan');
|
||||
@@ -477,6 +479,7 @@ function Settings() {
|
||||
ip_range: cfg.ip_range || '',
|
||||
});
|
||||
setLoadedCellName(cfg.cell_name || '');
|
||||
setEffectiveDomain(cfg.effective_domain || cfg.domain_name || cfg.domain || '');
|
||||
setIdentityDirty(false);
|
||||
setDomainMode(cfg.domain_mode || 'lan');
|
||||
setDomainName(cfg.domain_name || '');
|
||||
@@ -514,9 +517,11 @@ function Settings() {
|
||||
? 'Cell name must be 255 characters or fewer'
|
||||
: (!identity.cell_name ? 'Cell name is required' : null);
|
||||
|
||||
const domainError = identity.domain && identity.domain.length > 255
|
||||
? 'Domain must be 255 characters or fewer'
|
||||
: (!identity.domain ? 'Domain is required' : null);
|
||||
const domainError = domainMode !== 'lan'
|
||||
? null
|
||||
: (identity.domain && identity.domain.length > 255
|
||||
? 'Domain must be 255 characters or fewer'
|
||||
: (!identity.domain ? 'Domain is required' : null));
|
||||
|
||||
// pic_ngo availability check — fires 900ms after cell_name changes
|
||||
const picAvailTimerRef = useRef(null);
|
||||
@@ -553,6 +558,7 @@ function Settings() {
|
||||
// Refresh to get updated domain_name after DDNS registration
|
||||
const cfgRes = await cellAPI.getConfig();
|
||||
setDomainName(cfgRes.data.domain_name || '');
|
||||
setEffectiveDomain(cfgRes.data.effective_domain || cfgRes.data.domain_name || cfgRes.data.domain || '');
|
||||
setDdnsHasToken(cfgRes.data.ddns?.has_token || false);
|
||||
refreshConfig();
|
||||
} catch (err) {
|
||||
@@ -867,14 +873,48 @@ function Settings() {
|
||||
<p className="mt-1 text-xs text-gray-400">External: <span className="font-mono">{identity.cell_name || '…'}.pic.ngo</span></p>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Local Domain" error={domainError}>
|
||||
<TextInput
|
||||
value={identity.domain}
|
||||
onChange={(v) => { setIdentity((i) => ({ ...i, domain: v })); setIdentityDirty(true); draftConfig?.setDirty('identity', true); }}
|
||||
placeholder="cell"
|
||||
maxLength={255}
|
||||
/>
|
||||
</Field>
|
||||
{domainMode === 'lan' ? (
|
||||
<Field label="Local Domain" error={domainError}>
|
||||
<TextInput
|
||||
value={identity.domain}
|
||||
onChange={(v) => { setIdentity((i) => ({ ...i, domain: v })); setIdentityDirty(true); draftConfig?.setDirty('identity', true); }}
|
||||
placeholder="cell"
|
||||
maxLength={255}
|
||||
/>
|
||||
</Field>
|
||||
) : (
|
||||
<Field label="Cell Domain">
|
||||
<div className="rounded-lg bg-gray-50 border border-gray-200 px-3 py-2.5 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500 mb-0.5">Cell Domain</p>
|
||||
<p className="text-sm font-mono text-gray-800">{effectiveDomain || `${identity.cell_name}.pic.ngo`}</p>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400">managed by DDNS</span>
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdvancedZone((v) => !v)}
|
||||
className="text-xs text-gray-400 hover:text-gray-600 flex items-center gap-1"
|
||||
>
|
||||
{showAdvancedZone ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
|
||||
Advanced
|
||||
</button>
|
||||
{showAdvancedZone && (
|
||||
<div className="mt-2 pl-1 border-l-2 border-gray-100">
|
||||
<Field label="Internal zone name (advanced)" error={identity.domain && identity.domain.length > 255 ? 'Domain must be 255 characters or fewer' : null} hint="Used for LAN DNS — most users should not change this">
|
||||
<TextInput
|
||||
value={identity.domain}
|
||||
onChange={(v) => { setIdentity((i) => ({ ...i, domain: v })); setIdentityDirty(true); draftConfig?.setDirty('identity', true); }}
|
||||
placeholder="cell"
|
||||
maxLength={255}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Field>
|
||||
)}
|
||||
<Field label="IP Range" hint="Docker bridge subnet" error={ipRangeError}>
|
||||
<TextInput
|
||||
value={identity.ip_range}
|
||||
|
||||
Reference in New Issue
Block a user