diff --git a/api/app.py b/api/app.py index f8eee4b..268ce7b 100644 --- a/api/app.py +++ b/api/app.py @@ -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, diff --git a/api/caddy_manager.py b/api/caddy_manager.py index 8b5ccc4..afb72d7 100644 --- a/api/caddy_manager.py +++ b/api/caddy_manager.py @@ -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} diff --git a/api/config_manager.py b/api/config_manager.py index 20582b7..252e954 100644 --- a/api/config_manager.py +++ b/api/config_manager.py @@ -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: diff --git a/api/email_manager.py b/api/email_manager.py index 5663567..9f63cb9 100644 --- a/api/email_manager.py +++ b/api/email_manager.py @@ -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: diff --git a/api/managers.py b/api/managers.py index 7275ef1..eb57135 100644 --- a/api/managers.py +++ b/api/managers.py @@ -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, diff --git a/api/routes/config.py b/api/routes/config.py index ab6e6b8..7380096 100644 --- a/api/routes/config.py +++ b/api/routes/config.py @@ -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) diff --git a/api/routes/email.py b/api/routes/email.py index 48ce94c..ed2fece 100644 --- a/api/routes/email.py +++ b/api/routes/email.py @@ -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: diff --git a/api/service_bus.py b/api/service_bus.py index cb808d4..c0e0095 100644 --- a/api/service_bus.py +++ b/api/service_bus.py @@ -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: diff --git a/tests/test_caddy_manager.py b/tests/test_caddy_manager.py index cb60b8e..ab1bc9e 100644 --- a/tests/test_caddy_manager.py +++ b/tests/test_caddy_manager.py @@ -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() diff --git a/tests/test_config_manager.py b/tests/test_config_manager.py index 20f2574..fd7c859 100644 --- a/tests/test_config_manager.py +++ b/tests/test_config_manager.py @@ -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.""" diff --git a/tests/test_email_manager.py b/tests/test_email_manager.py index 8d8de48..094838a 100644 --- a/tests/test_email_manager.py +++ b/tests/test_email_manager.py @@ -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() \ No newline at end of file + unittest.main() \ No newline at end of file diff --git a/tests/test_service_bus.py b/tests/test_service_bus.py index 9c26ff7..7171232 100644 --- a/tests/test_service_bus.py +++ b/tests/test_service_bus.py @@ -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() \ No newline at end of file + unittest.main() \ No newline at end of file diff --git a/webui/src/pages/Settings.jsx b/webui/src/pages/Settings.jsx index d09ce03..cd26b72 100644 --- a/webui/src/pages/Settings.jsx +++ b/webui/src/pages/Settings.jsx @@ -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() {

External: {identity.cell_name || '…'}.pic.ngo

)} - - { setIdentity((i) => ({ ...i, domain: v })); setIdentityDirty(true); draftConfig?.setDirty('identity', true); }} - placeholder="cell" - maxLength={255} - /> - + {domainMode === 'lan' ? ( + + { setIdentity((i) => ({ ...i, domain: v })); setIdentityDirty(true); draftConfig?.setDirty('identity', true); }} + placeholder="cell" + maxLength={255} + /> + + ) : ( + +
+
+

Cell Domain

+

{effectiveDomain || `${identity.cell_name}.pic.ngo`}

+
+ managed by DDNS +
+
+ + {showAdvancedZone && ( +
+ 255 ? 'Domain must be 255 characters or fewer' : null} hint="Used for LAN DNS — most users should not change this"> + { setIdentity((i) => ({ ...i, domain: v })); setIdentityDirty(true); draftConfig?.setDirty('identity', true); }} + placeholder="cell" + maxLength={255} + /> + +
+ )} +
+
+ )}