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:
|
||||
|
||||
Reference in New Issue
Block a user