feat: make DDNS domain_name the effective domain across all services
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:
2026-05-28 02:48:47 -04:00
parent 393d56d4ca
commit 1f016de855
13 changed files with 403 additions and 25 deletions
+1
View File
@@ -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
View File
@@ -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}
+32
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+20
View File
@@ -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
View File
@@ -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:
+1
View File
@@ -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:
+51 -2
View File
@@ -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()
+86
View File
@@ -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."""
+90
View File
@@ -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()
+33
View File
@@ -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()
+42 -2
View File
@@ -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
const domainError = domainMode !== 'lan'
? null
: (identity.domain && identity.domain.length > 255
? 'Domain must be 255 characters or fewer'
: (!identity.domain ? 'Domain is required' : null);
: (!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,6 +873,7 @@ function Settings() {
<p className="mt-1 text-xs text-gray-400">External: <span className="font-mono">{identity.cell_name || '…'}.pic.ngo</span></p>
)}
</Field>
{domainMode === 'lan' ? (
<Field label="Local Domain" error={domainError}>
<TextInput
value={identity.domain}
@@ -875,6 +882,39 @@ function Settings() {
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}