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: