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({
|
return jsonify({
|
||||||
"cell_name": identity.get('cell_name', os.environ.get('CELL_NAME', 'mycell')),
|
"cell_name": identity.get('cell_name', os.environ.get('CELL_NAME', 'mycell')),
|
||||||
"domain": identity.get('domain', os.environ.get('CELL_DOMAIN', 'cell')),
|
"domain": identity.get('domain', os.environ.get('CELL_DOMAIN', 'cell')),
|
||||||
|
"effective_domain": config_manager.get_effective_domain(),
|
||||||
"uptime": uptime_seconds,
|
"uptime": uptime_seconds,
|
||||||
"peers_count": len(peers),
|
"peers_count": len(peers),
|
||||||
"services": services_status,
|
"services": services_status,
|
||||||
|
|||||||
+15
-3
@@ -51,7 +51,8 @@ class CaddyManager(BaseServiceManager):
|
|||||||
|
|
||||||
def __init__(self, config_manager=None,
|
def __init__(self, config_manager=None,
|
||||||
data_dir: str = '/app/data',
|
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)
|
super().__init__('caddy', data_dir, config_dir)
|
||||||
self.config_manager = config_manager
|
self.config_manager = config_manager
|
||||||
self.container_name = 'cell-caddy'
|
self.container_name = 'cell-caddy'
|
||||||
@@ -60,6 +61,10 @@ class CaddyManager(BaseServiceManager):
|
|||||||
# the caller restarts the container).
|
# the caller restarts the container).
|
||||||
self._health_failures = 0
|
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 ───────────────────────────────────────
|
# ── BaseServiceManager required ───────────────────────────────────────
|
||||||
|
|
||||||
def get_status(self) -> Dict[str, Any]:
|
def get_status(self) -> Dict[str, Any]:
|
||||||
@@ -126,14 +131,14 @@ class CaddyManager(BaseServiceManager):
|
|||||||
if domain_mode == 'pic_ngo':
|
if domain_mode == 'pic_ngo':
|
||||||
return self._caddyfile_pic_ngo(cell_name, service_routes, core_routes)
|
return self._caddyfile_pic_ngo(cell_name, service_routes, core_routes)
|
||||||
if domain_mode == 'cloudflare':
|
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(
|
return self._caddyfile_cloudflare(
|
||||||
custom_domain, service_routes, core_routes
|
custom_domain, service_routes, core_routes
|
||||||
)
|
)
|
||||||
if domain_mode == 'duckdns':
|
if domain_mode == 'duckdns':
|
||||||
return self._caddyfile_duckdns(cell_name, service_routes, core_routes)
|
return self._caddyfile_duckdns(cell_name, service_routes, core_routes)
|
||||||
if domain_mode == 'http01':
|
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)
|
return self._caddyfile_http01(host, installed_services, core_routes)
|
||||||
|
|
||||||
# Fallback to lan so we always emit a valid Caddyfile.
|
# Fallback to lan so we always emit a valid Caddyfile.
|
||||||
@@ -379,6 +384,13 @@ class CaddyManager(BaseServiceManager):
|
|||||||
content = self.generate_caddyfile(identity, installed_services)
|
content = self.generate_caddyfile(identity, installed_services)
|
||||||
return self.write_caddyfile(content)
|
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]:
|
def get_cert_status(self) -> Dict[str, Any]:
|
||||||
"""Return TLS cert status from identity['tls'] if present."""
|
"""Return TLS cert status from identity['tls'] if present."""
|
||||||
default = {'status': 'unknown', 'expiry': None, 'days_remaining': None}
|
default = {'status': 'unknown', 'expiry': None, 'days_remaining': None}
|
||||||
|
|||||||
@@ -45,6 +45,21 @@ class ConfigManager:
|
|||||||
self.configs['connectivity'] = {'exits': {}, 'peer_exit_map': {}}
|
self.configs['connectivity'] = {'exits': {}, 'peer_exit_map': {}}
|
||||||
if not self.config_file.exists():
|
if not self.config_file.exists():
|
||||||
self._save_all_configs()
|
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]:
|
def _load_service_schemas(self) -> Dict[str, Dict]:
|
||||||
"""Load configuration schemas for all services"""
|
"""Load configuration schemas for all services"""
|
||||||
@@ -478,6 +493,23 @@ class ConfigManager:
|
|||||||
"""Return the current identity configuration."""
|
"""Return the current identity configuration."""
|
||||||
return self.configs.get('_identity', {})
|
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):
|
def set_identity_field(self, key: str, value: Any):
|
||||||
"""Set a single field in the identity configuration and persist."""
|
"""Set a single field in the identity configuration and persist."""
|
||||||
if '_identity' not in self.configs:
|
if '_identity' not in self.configs:
|
||||||
|
|||||||
+15
-1
@@ -19,7 +19,8 @@ logger = logging.getLogger(__name__)
|
|||||||
class EmailManager(BaseServiceManager):
|
class EmailManager(BaseServiceManager):
|
||||||
"""Manages email service configuration and users"""
|
"""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)
|
super().__init__('email', data_dir, config_dir)
|
||||||
self.email_data_dir = os.path.join(data_dir, 'email')
|
self.email_data_dir = os.path.join(data_dir, 'email')
|
||||||
self.email_dir = self.email_data_dir # alias used by tests
|
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(self.dovecot_dir)
|
||||||
self.safe_makedirs(os.path.dirname(self.domain_config_file))
|
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]:
|
def _get_service_config(self) -> Dict[str, Any]:
|
||||||
"""Read configured ports/domain from service config file."""
|
"""Read configured ports/domain from service config file."""
|
||||||
cfg = self.get_config()
|
cfg = self.get_config()
|
||||||
@@ -252,6 +257,15 @@ class EmailManager(BaseServiceManager):
|
|||||||
|
|
||||||
return {'restarted': restarted, 'warnings': warnings}
|
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]:
|
def get_email_status(self) -> Dict[str, Any]:
|
||||||
"""Get detailed email service status including postfix/dovecot state."""
|
"""Get detailed email service status including postfix/dovecot state."""
|
||||||
try:
|
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)
|
network_manager = NetworkManager(data_dir=DATA_DIR, config_dir=CONFIG_DIR)
|
||||||
wireguard_manager = WireGuardManager(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)
|
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)
|
calendar_manager = CalendarManager(data_dir=DATA_DIR, config_dir=CONFIG_DIR)
|
||||||
file_manager = FileManager(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)
|
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)
|
auth_manager = AuthManager(data_dir=DATA_DIR, config_dir=CONFIG_DIR)
|
||||||
setup_manager = SetupManager(config_manager=config_manager, auth_manager=auth_manager)
|
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)
|
ddns_manager = DDNSManager(config_manager=config_manager, data_dir=DATA_DIR, config_dir=CONFIG_DIR)
|
||||||
connectivity_manager = ConnectivityManager(
|
connectivity_manager = ConnectivityManager(
|
||||||
config_manager=config_manager,
|
config_manager=config_manager,
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ def get_config():
|
|||||||
config['service_configs'] = service_configs
|
config['service_configs'] = service_configs
|
||||||
config['domain_mode'] = identity.get('domain_mode', 'lan')
|
config['domain_mode'] = identity.get('domain_mode', 'lan')
|
||||||
config['domain_name'] = identity.get('domain_name', '')
|
config['domain_name'] = identity.get('domain_name', '')
|
||||||
|
config['effective_domain'] = config_manager.get_effective_domain()
|
||||||
ddns_section = config_manager.configs.get('ddns', {})
|
ddns_section = config_manager.configs.get('ddns', {})
|
||||||
config['ddns'] = {
|
config['ddns'] = {
|
||||||
'provider': ddns_section.get('provider', ''),
|
'provider': ddns_section.get('provider', ''),
|
||||||
@@ -376,6 +377,16 @@ def update_config():
|
|||||||
pre_change_snapshot=_pre_change_snapshot,
|
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 = {
|
_PORT_CHANGE_MAP = {
|
||||||
('network', 'dns_port'): ('dns_port', ['dns']),
|
('network', 'dns_port'): ('dns_port', ['dns']),
|
||||||
('wireguard','port'): ('wg_port', ['wireguard']),
|
('wireguard','port'): ('wg_port', ['wireguard']),
|
||||||
@@ -579,6 +590,15 @@ def ddns_register():
|
|||||||
new_sub = result.get('subdomain', f'{cell_name}.pic.ngo')
|
new_sub = result.get('subdomain', f'{cell_name}.pic.ngo')
|
||||||
config_manager.set_identity_field('domain_name', new_sub)
|
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)
|
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})
|
return jsonify({'registered': True, 'subdomain': new_sub})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error('Error in /api/ddns/register: %s', e)
|
logger.error('Error in /api/ddns/register: %s', e)
|
||||||
|
|||||||
+4
-4
@@ -18,12 +18,12 @@ def get_email_users():
|
|||||||
def create_email_user():
|
def create_email_user():
|
||||||
"""Create email user."""
|
"""Create email user."""
|
||||||
try:
|
try:
|
||||||
from app import email_manager, _configured_domain
|
from app import email_manager, config_manager
|
||||||
data = request.get_json(silent=True)
|
data = request.get_json(silent=True)
|
||||||
if data is None:
|
if data is None:
|
||||||
return jsonify({"error": "No data provided"}), 400
|
return jsonify({"error": "No data provided"}), 400
|
||||||
username = data.get('username')
|
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')
|
password = data.get('password')
|
||||||
if not username or not password:
|
if not username or not password:
|
||||||
return jsonify({"error": "Missing required fields: username, password"}), 400
|
return jsonify({"error": "Missing required fields: username, password"}), 400
|
||||||
@@ -37,8 +37,8 @@ def create_email_user():
|
|||||||
def delete_email_user(username):
|
def delete_email_user(username):
|
||||||
"""Delete email user."""
|
"""Delete email user."""
|
||||||
try:
|
try:
|
||||||
from app import email_manager, _configured_domain
|
from app import email_manager, config_manager
|
||||||
domain = request.args.get('domain') or _configured_domain()
|
domain = request.args.get('domain') or config_manager.get_effective_domain()
|
||||||
result = email_manager.delete_email_user(username, domain)
|
result = email_manager.delete_email_user(username, domain)
|
||||||
return jsonify({"deleted": result})
|
return jsonify({"deleted": result})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ class EventType(Enum):
|
|||||||
CERTIFICATE_EXPIRING = "certificate_expiring"
|
CERTIFICATE_EXPIRING = "certificate_expiring"
|
||||||
BACKUP_CREATED = "backup_created"
|
BACKUP_CREATED = "backup_created"
|
||||||
RESTORE_COMPLETED = "restore_completed"
|
RESTORE_COMPLETED = "restore_completed"
|
||||||
|
IDENTITY_CHANGED = "identity_changed"
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Event:
|
class Event:
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ class TestGenerateCaddyfileCloudflare(unittest.TestCase):
|
|||||||
identity = {
|
identity = {
|
||||||
'cell_name': 'beta',
|
'cell_name': 'beta',
|
||||||
'domain_mode': 'cloudflare',
|
'domain_mode': 'cloudflare',
|
||||||
'custom_domain': 'example.com',
|
'domain_name': 'example.com',
|
||||||
}
|
}
|
||||||
out = mgr.generate_caddyfile(identity, [])
|
out = mgr.generate_caddyfile(identity, [])
|
||||||
self.assertIn('dns cloudflare {$CF_API_TOKEN}', out)
|
self.assertIn('dns cloudflare {$CF_API_TOKEN}', out)
|
||||||
@@ -85,6 +85,23 @@ class TestGenerateCaddyfileCloudflare(unittest.TestCase):
|
|||||||
self.assertIn('email {$ACME_EMAIL}', out)
|
self.assertIn('email {$ACME_EMAIL}', out)
|
||||||
self.assertIn('acme_ca {$ACME_CA_URL}', 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):
|
class TestGenerateCaddyfileDuckDns(unittest.TestCase):
|
||||||
def test_duckdns_has_dns_duckdns(self):
|
def test_duckdns_has_dns_duckdns(self):
|
||||||
@@ -101,7 +118,7 @@ class TestGenerateCaddyfileHttp01(unittest.TestCase):
|
|||||||
identity = {
|
identity = {
|
||||||
'cell_name': 'delta',
|
'cell_name': 'delta',
|
||||||
'domain_mode': 'http01',
|
'domain_mode': 'http01',
|
||||||
'custom_domain': 'delta.noip.me',
|
'domain_name': 'delta.noip.me',
|
||||||
}
|
}
|
||||||
services = [
|
services = [
|
||||||
{'name': 'calendar', 'caddy_route':
|
{'name': 'calendar', 'caddy_route':
|
||||||
@@ -224,5 +241,37 @@ class TestCertStatus(unittest.TestCase):
|
|||||||
self.assertEqual(out['days_remaining'], 84)
|
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__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -260,6 +260,92 @@ class TestConfigManager(unittest.TestCase):
|
|||||||
"import must not inject zero-filled entries for absent services")
|
"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):
|
class TestNetworkManagerApply(unittest.TestCase):
|
||||||
"""Test apply_config / apply_domain actually write real config files."""
|
"""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)
|
info = self.manager.get_mailbox_info(None, None)
|
||||||
self.assertIn('error', info)
|
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__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
@@ -214,5 +214,38 @@ class TestServiceBus(unittest.TestCase):
|
|||||||
mock_service.stop.assert_called_once()
|
mock_service.stop.assert_called_once()
|
||||||
mock_service.start.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__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
@@ -433,6 +433,8 @@ function Settings() {
|
|||||||
const [identity, setIdentity] = useState({ cell_name: '', domain: '', ip_range: '' });
|
const [identity, setIdentity] = useState({ cell_name: '', domain: '', ip_range: '' });
|
||||||
const [identityDirty, setIdentityDirty] = useState(false);
|
const [identityDirty, setIdentityDirty] = useState(false);
|
||||||
const [loadedCellName, setLoadedCellName] = useState('');
|
const [loadedCellName, setLoadedCellName] = useState('');
|
||||||
|
const [effectiveDomain, setEffectiveDomain] = useState('');
|
||||||
|
const [showAdvancedZone, setShowAdvancedZone] = useState(false);
|
||||||
|
|
||||||
// DDNS
|
// DDNS
|
||||||
const [domainMode, setDomainMode] = useState('lan');
|
const [domainMode, setDomainMode] = useState('lan');
|
||||||
@@ -477,6 +479,7 @@ function Settings() {
|
|||||||
ip_range: cfg.ip_range || '',
|
ip_range: cfg.ip_range || '',
|
||||||
});
|
});
|
||||||
setLoadedCellName(cfg.cell_name || '');
|
setLoadedCellName(cfg.cell_name || '');
|
||||||
|
setEffectiveDomain(cfg.effective_domain || cfg.domain_name || cfg.domain || '');
|
||||||
setIdentityDirty(false);
|
setIdentityDirty(false);
|
||||||
setDomainMode(cfg.domain_mode || 'lan');
|
setDomainMode(cfg.domain_mode || 'lan');
|
||||||
setDomainName(cfg.domain_name || '');
|
setDomainName(cfg.domain_name || '');
|
||||||
@@ -514,9 +517,11 @@ function Settings() {
|
|||||||
? 'Cell name must be 255 characters or fewer'
|
? 'Cell name must be 255 characters or fewer'
|
||||||
: (!identity.cell_name ? 'Cell name is required' : null);
|
: (!identity.cell_name ? 'Cell name is required' : null);
|
||||||
|
|
||||||
const domainError = identity.domain && identity.domain.length > 255
|
const domainError = domainMode !== 'lan'
|
||||||
? 'Domain must be 255 characters or fewer'
|
? null
|
||||||
: (!identity.domain ? 'Domain is required' : 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
|
// pic_ngo availability check — fires 900ms after cell_name changes
|
||||||
const picAvailTimerRef = useRef(null);
|
const picAvailTimerRef = useRef(null);
|
||||||
@@ -553,6 +558,7 @@ function Settings() {
|
|||||||
// Refresh to get updated domain_name after DDNS registration
|
// Refresh to get updated domain_name after DDNS registration
|
||||||
const cfgRes = await cellAPI.getConfig();
|
const cfgRes = await cellAPI.getConfig();
|
||||||
setDomainName(cfgRes.data.domain_name || '');
|
setDomainName(cfgRes.data.domain_name || '');
|
||||||
|
setEffectiveDomain(cfgRes.data.effective_domain || cfgRes.data.domain_name || cfgRes.data.domain || '');
|
||||||
setDdnsHasToken(cfgRes.data.ddns?.has_token || false);
|
setDdnsHasToken(cfgRes.data.ddns?.has_token || false);
|
||||||
refreshConfig();
|
refreshConfig();
|
||||||
} catch (err) {
|
} 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>
|
<p className="mt-1 text-xs text-gray-400">External: <span className="font-mono">{identity.cell_name || '…'}.pic.ngo</span></p>
|
||||||
)}
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Local Domain" error={domainError}>
|
{domainMode === 'lan' ? (
|
||||||
<TextInput
|
<Field label="Local Domain" error={domainError}>
|
||||||
value={identity.domain}
|
<TextInput
|
||||||
onChange={(v) => { setIdentity((i) => ({ ...i, domain: v })); setIdentityDirty(true); draftConfig?.setDirty('identity', true); }}
|
value={identity.domain}
|
||||||
placeholder="cell"
|
onChange={(v) => { setIdentity((i) => ({ ...i, domain: v })); setIdentityDirty(true); draftConfig?.setDirty('identity', true); }}
|
||||||
maxLength={255}
|
placeholder="cell"
|
||||||
/>
|
maxLength={255}
|
||||||
</Field>
|
/>
|
||||||
|
</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}>
|
<Field label="IP Range" hint="Docker bridge subnet" error={ipRangeError}>
|
||||||
<TextInput
|
<TextInput
|
||||||
value={identity.ip_range}
|
value={identity.ip_range}
|
||||||
|
|||||||
Reference in New Issue
Block a user