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:
@@ -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()
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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()
|
||||
unittest.main()
|
||||
@@ -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()
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user