feat: route PIC services as subdomains of the cell's effective domain
Unit Tests / test (push) Successful in 11m33s
Unit Tests / test (push) Successful in 11m33s
In DDNS modes (pic_ngo, cloudflare, duckdns, http01), all built-in services are now reachable as subdomains of the cell domain, e.g. calendar.pic1.pic.ngo instead of pic1.pic.ngo/calendar. Key changes: - CaddyManager._build_core_service_routes(): new helper generates Caddy named-matcher host blocks for calendar, mail/webmail, files, webdav, and api subdomains within the wildcard TLS server block. - All ACME modes (pic_ngo, cloudflare, duckdns) use the new subdomain matchers; http01 emits a dedicated server block per service. - http01: installed store-plugin services whose name clashes with a core service are skipped to prevent duplicate server blocks. - routes/config.py: ip_utils.write_caddyfile() is skipped in non-LAN modes so LAN Caddy config never overwrites the ACME config. - firewall_manager.generate_corefile(): new split_horizon_zones param adds local authoritative file zones so LAN clients resolve *.pic1.pic.ngo to the internal Caddy IP without hairpin NAT. - NetworkManager.update_split_horizon_zone(): writes the wildcard zone file and regenerates the Corefile with the split-horizon block; called automatically after every identity change in non-LAN mode. - Added @ to allowed record-name chars in update_dns_zone validation. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+54
-13
@@ -161,6 +161,32 @@ class CaddyManager(BaseServiceManager):
|
|||||||
lines.append("}")
|
lines.append("}")
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_core_service_routes(domain: str) -> str:
|
||||||
|
"""Return 4-space-indented named-matcher + handle blocks for core services."""
|
||||||
|
return (
|
||||||
|
f" @calendar host calendar.{domain}\n"
|
||||||
|
f" handle @calendar {{\n"
|
||||||
|
f" reverse_proxy cell-radicale:5232\n"
|
||||||
|
f" }}\n"
|
||||||
|
f" @mail host mail.{domain} webmail.{domain}\n"
|
||||||
|
f" handle @mail {{\n"
|
||||||
|
f" reverse_proxy cell-rainloop:8888\n"
|
||||||
|
f" }}\n"
|
||||||
|
f" @files host files.{domain}\n"
|
||||||
|
f" handle @files {{\n"
|
||||||
|
f" reverse_proxy cell-filegator:8080\n"
|
||||||
|
f" }}\n"
|
||||||
|
f" @webdav host webdav.{domain}\n"
|
||||||
|
f" handle @webdav {{\n"
|
||||||
|
f" reverse_proxy cell-webdav:80\n"
|
||||||
|
f" }}\n"
|
||||||
|
f" @api host api.{domain}\n"
|
||||||
|
f" handle @api {{\n"
|
||||||
|
f" reverse_proxy cell-api:3000\n"
|
||||||
|
f" }}"
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _indent_routes(routes: str, spaces: int = 4) -> str:
|
def _indent_routes(routes: str, spaces: int = 4) -> str:
|
||||||
"""Indent a multi-line route block by ``spaces`` columns."""
|
"""Indent a multi-line route block by ``spaces`` columns."""
|
||||||
@@ -203,16 +229,17 @@ class CaddyManager(BaseServiceManager):
|
|||||||
def _caddyfile_pic_ngo(self, cell_name: str,
|
def _caddyfile_pic_ngo(self, cell_name: str,
|
||||||
service_routes: str, core_routes: str) -> str:
|
service_routes: str, core_routes: str) -> str:
|
||||||
"""pic_ngo mode: wildcard DNS-01 via the pic_ngo plugin."""
|
"""pic_ngo mode: wildcard DNS-01 via the pic_ngo plugin."""
|
||||||
body = []
|
domain = f"{cell_name}.pic.ngo"
|
||||||
|
body = [self._build_core_service_routes(domain)]
|
||||||
if service_routes:
|
if service_routes:
|
||||||
body.append(self._indent_routes(service_routes))
|
body.append(self._indent_routes(service_routes))
|
||||||
body.append(core_routes)
|
body.append(core_routes)
|
||||||
inner = "\n".join(body)
|
inner = "\n".join(body)
|
||||||
email = f"admin@{cell_name}.pic.ngo"
|
email = f"admin@{domain}"
|
||||||
return (
|
return (
|
||||||
f"{self._global_acme_block(email)}\n"
|
f"{self._global_acme_block(email)}\n"
|
||||||
"\n"
|
"\n"
|
||||||
f"*.{cell_name}.pic.ngo, {cell_name}.pic.ngo {{\n"
|
f"*.{domain}, {domain} {{\n"
|
||||||
" tls {\n"
|
" tls {\n"
|
||||||
" dns pic_ngo {\n"
|
" dns pic_ngo {\n"
|
||||||
" token {$PIC_NGO_DDNS_TOKEN}\n"
|
" token {$PIC_NGO_DDNS_TOKEN}\n"
|
||||||
@@ -226,7 +253,7 @@ class CaddyManager(BaseServiceManager):
|
|||||||
def _caddyfile_cloudflare(self, custom_domain: str,
|
def _caddyfile_cloudflare(self, custom_domain: str,
|
||||||
service_routes: str, core_routes: str) -> str:
|
service_routes: str, core_routes: str) -> str:
|
||||||
"""cloudflare mode: wildcard DNS-01 via the cloudflare plugin."""
|
"""cloudflare mode: wildcard DNS-01 via the cloudflare plugin."""
|
||||||
body = []
|
body = [self._build_core_service_routes(custom_domain)]
|
||||||
if service_routes:
|
if service_routes:
|
||||||
body.append(self._indent_routes(service_routes))
|
body.append(self._indent_routes(service_routes))
|
||||||
body.append(core_routes)
|
body.append(core_routes)
|
||||||
@@ -245,7 +272,8 @@ class CaddyManager(BaseServiceManager):
|
|||||||
def _caddyfile_duckdns(self, cell_name: str,
|
def _caddyfile_duckdns(self, cell_name: str,
|
||||||
service_routes: str, core_routes: str) -> str:
|
service_routes: str, core_routes: str) -> str:
|
||||||
"""duckdns mode: DNS-01 via the duckdns plugin."""
|
"""duckdns mode: DNS-01 via the duckdns plugin."""
|
||||||
body = []
|
domain = f"{cell_name}.duckdns.org"
|
||||||
|
body = [self._build_core_service_routes(domain)]
|
||||||
if service_routes:
|
if service_routes:
|
||||||
body.append(self._indent_routes(service_routes))
|
body.append(self._indent_routes(service_routes))
|
||||||
body.append(core_routes)
|
body.append(core_routes)
|
||||||
@@ -253,7 +281,7 @@ class CaddyManager(BaseServiceManager):
|
|||||||
return (
|
return (
|
||||||
f"{self._global_acme_block(None)}\n"
|
f"{self._global_acme_block(None)}\n"
|
||||||
"\n"
|
"\n"
|
||||||
f"*.{cell_name}.duckdns.org {{\n"
|
f"*.{domain} {{\n"
|
||||||
" tls {\n"
|
" tls {\n"
|
||||||
" dns duckdns {$DUCKDNS_TOKEN}\n"
|
" dns duckdns {$DUCKDNS_TOKEN}\n"
|
||||||
" }\n"
|
" }\n"
|
||||||
@@ -265,23 +293,36 @@ class CaddyManager(BaseServiceManager):
|
|||||||
installed_services: List[Dict[str, Any]],
|
installed_services: List[Dict[str, Any]],
|
||||||
core_routes: str) -> str:
|
core_routes: str) -> str:
|
||||||
"""http01 mode: no wildcard. Each service gets its own block."""
|
"""http01 mode: no wildcard. Each service gets its own block."""
|
||||||
# Main host block — only the core routes (api + webui). Service
|
# Main host block — only the core routes (api + webui).
|
||||||
# routes that could otherwise be served as path-prefixes are NOT
|
|
||||||
# placed here because in http01 mode each service is intended to
|
|
||||||
# live on its own subdomain (otherwise it could also use a path
|
|
||||||
# prefix here, but the spec calls for separate blocks).
|
|
||||||
out = [self._global_acme_block('{$ACME_EMAIL}'), ""]
|
out = [self._global_acme_block('{$ACME_EMAIL}'), ""]
|
||||||
out.append(f"{host} {{")
|
out.append(f"{host} {{")
|
||||||
out.append(core_routes)
|
out.append(core_routes)
|
||||||
out.append("}")
|
out.append("}")
|
||||||
|
|
||||||
# One block per installed service that has a caddy_route.
|
# One block per core service subdomain.
|
||||||
|
_core_services = [
|
||||||
|
('calendar', 'cell-radicale:5232'),
|
||||||
|
('mail', 'cell-rainloop:8888'),
|
||||||
|
('webmail', 'cell-rainloop:8888'),
|
||||||
|
('files', 'cell-filegator:8080'),
|
||||||
|
('webdav', 'cell-webdav:80'),
|
||||||
|
('api', 'cell-api:3000'),
|
||||||
|
]
|
||||||
|
for subdomain, backend in _core_services:
|
||||||
|
out.append("")
|
||||||
|
out.append(f"{subdomain}.{host} {{")
|
||||||
|
out.append(f" reverse_proxy {backend}")
|
||||||
|
out.append("}")
|
||||||
|
|
||||||
|
# One block per installed (store plugin) service that has a caddy_route,
|
||||||
|
# skipping any name that conflicts with a core service.
|
||||||
|
_core_names = {s for s, _ in _core_services}
|
||||||
for svc in installed_services or []:
|
for svc in installed_services or []:
|
||||||
if not svc:
|
if not svc:
|
||||||
continue
|
continue
|
||||||
route = svc.get('caddy_route')
|
route = svc.get('caddy_route')
|
||||||
name = svc.get('name') or svc.get('subdomain')
|
name = svc.get('name') or svc.get('subdomain')
|
||||||
if not route or not name:
|
if not route or not name or name in _core_names:
|
||||||
continue
|
continue
|
||||||
out.append("")
|
out.append("")
|
||||||
out.append(f"{name}.{host} {{")
|
out.append(f"{name}.{host} {{")
|
||||||
|
|||||||
+18
-2
@@ -710,7 +710,8 @@ def _build_acl_block(blocked_peers_by_service: Dict[str, List[str]],
|
|||||||
|
|
||||||
def generate_corefile(peers: List[Dict[str, Any]], corefile_path: str = COREFILE_PATH,
|
def generate_corefile(peers: List[Dict[str, Any]], corefile_path: str = COREFILE_PATH,
|
||||||
domain: str = 'cell',
|
domain: str = 'cell',
|
||||||
cell_links: Optional[List[Dict[str, Any]]] = None) -> bool:
|
cell_links: Optional[List[Dict[str, Any]]] = None,
|
||||||
|
split_horizon_zones: Optional[List[str]] = None) -> bool:
|
||||||
"""
|
"""
|
||||||
Rewrite the CoreDNS Corefile with per-peer ACL rules and reload plugin.
|
Rewrite the CoreDNS Corefile with per-peer ACL rules and reload plugin.
|
||||||
The file is written to corefile_path (API-side path mapped into CoreDNS container).
|
The file is written to corefile_path (API-side path mapped into CoreDNS container).
|
||||||
@@ -718,6 +719,10 @@ def generate_corefile(peers: List[Dict[str, Any]], corefile_path: str = COREFILE
|
|||||||
cell_links: optional list of cell-to-cell DNS forwarding entries, each a dict with
|
cell_links: optional list of cell-to-cell DNS forwarding entries, each a dict with
|
||||||
'domain' and 'dns_ip' keys (same shape as CellLinkManager.list_connections()).
|
'domain' and 'dns_ip' keys (same shape as CellLinkManager.list_connections()).
|
||||||
When non-empty, a forwarding stanza is appended for each entry.
|
When non-empty, a forwarding stanza is appended for each entry.
|
||||||
|
split_horizon_zones: optional list of FQDNs (e.g. ['pic1.pic.ngo']) for which a
|
||||||
|
local authoritative zone block is added so LAN clients resolve
|
||||||
|
service subdomains to the internal Caddy IP without hairpin NAT.
|
||||||
|
Each zone must have a corresponding zone file under /data/<fqdn>.zone.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Collect which peers block which services
|
# Collect which peers block which services
|
||||||
@@ -748,6 +753,17 @@ def generate_corefile(peers: List[Dict[str, Any]], corefile_path: str = COREFILE
|
|||||||
|
|
||||||
{primary_zone_block}"""
|
{primary_zone_block}"""
|
||||||
|
|
||||||
|
# Split-horizon zones for DDNS/public domains — LAN clients resolve
|
||||||
|
# *.pic1.pic.ngo to the internal Caddy IP without hairpin NAT.
|
||||||
|
if split_horizon_zones:
|
||||||
|
for sz in split_horizon_zones:
|
||||||
|
corefile += (
|
||||||
|
f'\n{sz} {{\n'
|
||||||
|
f' file /data/{sz}.zone\n'
|
||||||
|
f' log\n'
|
||||||
|
f'}}\n'
|
||||||
|
)
|
||||||
|
|
||||||
# Append cell-to-cell DNS forwarding stanzas if provided
|
# Append cell-to-cell DNS forwarding stanzas if provided
|
||||||
if cell_links:
|
if cell_links:
|
||||||
for link in cell_links:
|
for link in cell_links:
|
||||||
@@ -762,7 +778,7 @@ def generate_corefile(peers: List[Dict[str, Any]], corefile_path: str = COREFILE
|
|||||||
f' log\n'
|
f' log\n'
|
||||||
f'}}\n'
|
f'}}\n'
|
||||||
)
|
)
|
||||||
else:
|
elif not split_horizon_zones:
|
||||||
corefile += '\n'
|
corefile += '\n'
|
||||||
|
|
||||||
# local.{domain} block intentionally omitted: /data/local.zone does not exist
|
# local.{domain} block intentionally omitted: /data/local.zone does not exist
|
||||||
|
|||||||
+29
-1
@@ -45,7 +45,7 @@ class NetworkManager(BaseServiceManager):
|
|||||||
for rec in records:
|
for rec in records:
|
||||||
rname = rec.get('name', '')
|
rname = rec.get('name', '')
|
||||||
rvalue = rec.get('value', '')
|
rvalue = rec.get('value', '')
|
||||||
if rname and not re.match(r'^[a-zA-Z0-9_.*-]{1,253}$', str(rname)):
|
if rname and not re.match(r'^[a-zA-Z0-9_@.*-]{1,253}$', str(rname)):
|
||||||
logger.error(f"update_dns_zone: invalid record name {rname!r}")
|
logger.error(f"update_dns_zone: invalid record name {rname!r}")
|
||||||
return False
|
return False
|
||||||
if rvalue and not re.match(r'^[a-zA-Z0-9._: -]{1,512}$', str(rvalue)):
|
if rvalue and not re.match(r'^[a-zA-Z0-9._: -]{1,512}$', str(rvalue)):
|
||||||
@@ -165,6 +165,34 @@ class NetworkManager(BaseServiceManager):
|
|||||||
self.update_dns_zone(domain, records)
|
self.update_dns_zone(domain, records)
|
||||||
logger.info(f"Created {len(records)} default DNS records for zone '{domain}'")
|
logger.info(f"Created {len(records)} default DNS records for zone '{domain}'")
|
||||||
|
|
||||||
|
def update_split_horizon_zone(self, effective_domain: str, caddy_ip: str,
|
||||||
|
primary_domain: str = 'cell',
|
||||||
|
peers: Optional[List[Dict]] = None,
|
||||||
|
cell_links: Optional[List[Dict]] = None) -> bool:
|
||||||
|
"""Write a local authoritative zone for effective_domain pointing all
|
||||||
|
hosts (wildcard) to caddy_ip so LAN clients resolve service subdomains
|
||||||
|
without hairpin NAT. Regenerates the Corefile and reloads CoreDNS."""
|
||||||
|
import firewall_manager as _fm
|
||||||
|
# SOA/NS are generated by _generate_zone_content; just pass the A records.
|
||||||
|
records = [
|
||||||
|
{'name': '@', 'type': 'A', 'value': caddy_ip},
|
||||||
|
{'name': '*', 'type': 'A', 'value': caddy_ip},
|
||||||
|
]
|
||||||
|
ok = self.update_dns_zone(effective_domain, records)
|
||||||
|
if not ok:
|
||||||
|
logger.warning('update_split_horizon_zone: zone file write failed for %s', effective_domain)
|
||||||
|
|
||||||
|
corefile = os.path.join(self.config_dir, 'dns', 'Corefile')
|
||||||
|
peers_data = peers or []
|
||||||
|
ok_cf = _fm.generate_corefile(
|
||||||
|
peers_data, corefile, primary_domain,
|
||||||
|
cell_links=cell_links,
|
||||||
|
split_horizon_zones=[effective_domain],
|
||||||
|
)
|
||||||
|
if ok_cf:
|
||||||
|
_fm.reload_coredns()
|
||||||
|
return ok and ok_cf
|
||||||
|
|
||||||
def apply_ip_range(self, ip_range: str, cell_name: str, domain: str) -> Dict[str, Any]:
|
def apply_ip_range(self, ip_range: str, cell_name: str, domain: str) -> Dict[str, Any]:
|
||||||
"""Rewrite the primary DNS zone file with IPs derived from the new subnet."""
|
"""Rewrite the primary DNS zone file with IPs derived from the new subnet."""
|
||||||
restarted: List[str] = []
|
restarted: List[str] = []
|
||||||
|
|||||||
+34
-18
@@ -316,11 +316,12 @@ def update_config():
|
|||||||
net_result = network_manager.apply_domain(domain, reload=False)
|
net_result = network_manager.apply_domain(domain, reload=False)
|
||||||
all_warnings.extend(net_result.get('warnings', []))
|
all_warnings.extend(net_result.get('warnings', []))
|
||||||
_cur_id = config_manager.configs.get('_identity', {})
|
_cur_id = config_manager.configs.get('_identity', {})
|
||||||
ip_utils.write_caddyfile(
|
if _cur_id.get('domain_mode', 'lan') == 'lan':
|
||||||
_cur_id.get('ip_range', os.environ.get('CELL_IP_RANGE', '172.20.0.0/16')),
|
ip_utils.write_caddyfile(
|
||||||
_cur_id.get('cell_name', os.environ.get('CELL_NAME', 'mycell')),
|
_cur_id.get('ip_range', os.environ.get('CELL_IP_RANGE', '172.20.0.0/16')),
|
||||||
domain, '/app/config-caddy/Caddyfile'
|
_cur_id.get('cell_name', os.environ.get('CELL_NAME', 'mycell')),
|
||||||
)
|
domain, '/app/config-caddy/Caddyfile'
|
||||||
|
)
|
||||||
_set_pending_restart(
|
_set_pending_restart(
|
||||||
[f'domain changed to {domain}'],
|
[f'domain changed to {domain}'],
|
||||||
['dns', 'caddy'],
|
['dns', 'caddy'],
|
||||||
@@ -334,12 +335,13 @@ def update_config():
|
|||||||
cn_result = network_manager.apply_cell_name(old_name, new_name, reload=False)
|
cn_result = network_manager.apply_cell_name(old_name, new_name, reload=False)
|
||||||
all_warnings.extend(cn_result.get('warnings', []))
|
all_warnings.extend(cn_result.get('warnings', []))
|
||||||
_cur_id2 = config_manager.configs.get('_identity', {})
|
_cur_id2 = config_manager.configs.get('_identity', {})
|
||||||
ip_utils.write_caddyfile(
|
if _cur_id2.get('domain_mode', 'lan') == 'lan':
|
||||||
_cur_id2.get('ip_range', os.environ.get('CELL_IP_RANGE', '172.20.0.0/16')),
|
ip_utils.write_caddyfile(
|
||||||
new_name,
|
_cur_id2.get('ip_range', os.environ.get('CELL_IP_RANGE', '172.20.0.0/16')),
|
||||||
identity_updates.get('domain') or _cur_id2.get('domain', os.environ.get('CELL_DOMAIN', 'cell')),
|
new_name,
|
||||||
'/app/config-caddy/Caddyfile'
|
identity_updates.get('domain') or _cur_id2.get('domain', os.environ.get('CELL_DOMAIN', 'cell')),
|
||||||
)
|
'/app/config-caddy/Caddyfile'
|
||||||
|
)
|
||||||
_set_pending_restart(
|
_set_pending_restart(
|
||||||
[f'cell_name changed to {new_name}'],
|
[f'cell_name changed to {new_name}'],
|
||||||
['dns'],
|
['dns'],
|
||||||
@@ -370,7 +372,8 @@ def update_config():
|
|||||||
firewall_manager.ensure_caddy_virtual_ips()
|
firewall_manager.ensure_caddy_virtual_ips()
|
||||||
env_file = os.environ.get('COMPOSE_ENV_FILE', '/app/.env.compose')
|
env_file = os.environ.get('COMPOSE_ENV_FILE', '/app/.env.compose')
|
||||||
ip_utils.write_env_file(new_range, env_file, _collect_service_ports(config_manager.configs))
|
ip_utils.write_env_file(new_range, env_file, _collect_service_ports(config_manager.configs))
|
||||||
ip_utils.write_caddyfile(new_range, cur_cell_name, cur_domain, '/app/config-caddy/Caddyfile')
|
if cur_identity.get('domain_mode', 'lan') == 'lan':
|
||||||
|
ip_utils.write_caddyfile(new_range, cur_cell_name, cur_domain, '/app/config-caddy/Caddyfile')
|
||||||
_set_pending_restart(
|
_set_pending_restart(
|
||||||
[f'ip_range changed to {new_range} — network will be recreated'],
|
[f'ip_range changed to {new_range} — network will be recreated'],
|
||||||
['*'], network_recreate=True,
|
['*'], network_recreate=True,
|
||||||
@@ -379,13 +382,25 @@ def update_config():
|
|||||||
|
|
||||||
if identity_updates:
|
if identity_updates:
|
||||||
_cur_identity = config_manager.configs.get('_identity', {})
|
_cur_identity = config_manager.configs.get('_identity', {})
|
||||||
|
_eff_domain = config_manager.get_effective_domain()
|
||||||
service_bus.publish_event(EventType.IDENTITY_CHANGED, 'config', {
|
service_bus.publish_event(EventType.IDENTITY_CHANGED, 'config', {
|
||||||
'cell_name': _cur_identity.get('cell_name'),
|
'cell_name': _cur_identity.get('cell_name'),
|
||||||
'domain': _cur_identity.get('domain'),
|
'domain': _cur_identity.get('domain'),
|
||||||
'domain_name': _cur_identity.get('domain_name'),
|
'domain_name': _cur_identity.get('domain_name'),
|
||||||
'domain_mode': _cur_identity.get('domain_mode'),
|
'domain_mode': _cur_identity.get('domain_mode'),
|
||||||
'effective_domain': config_manager.get_effective_domain(),
|
'effective_domain': _eff_domain,
|
||||||
})
|
})
|
||||||
|
if _cur_identity.get('domain_mode', 'lan') != 'lan' and _eff_domain:
|
||||||
|
try:
|
||||||
|
import ip_utils as _ip_sh
|
||||||
|
_ip_range = _cur_identity.get('ip_range', os.environ.get('CELL_IP_RANGE', '172.20.0.0/16'))
|
||||||
|
_caddy_ip = _ip_sh.get_service_ips(_ip_range).get('caddy', '172.20.0.2')
|
||||||
|
_primary_domain = _cur_identity.get('domain', os.environ.get('CELL_DOMAIN', 'cell'))
|
||||||
|
network_manager.update_split_horizon_zone(
|
||||||
|
_eff_domain, _caddy_ip, primary_domain=_primary_domain
|
||||||
|
)
|
||||||
|
except Exception as _sh_exc:
|
||||||
|
logger.warning('split-horizon zone update failed: %s', _sh_exc)
|
||||||
|
|
||||||
_PORT_CHANGE_MAP = {
|
_PORT_CHANGE_MAP = {
|
||||||
('network', 'dns_port'): ('dns_port', ['dns']),
|
('network', 'dns_port'): ('dns_port', ['dns']),
|
||||||
@@ -644,11 +659,12 @@ def cancel_pending_config():
|
|||||||
if cur_cell_name and old_cell_name and cur_cell_name != old_cell_name:
|
if cur_cell_name and old_cell_name and cur_cell_name != old_cell_name:
|
||||||
network_manager.apply_cell_name(cur_cell_name, old_cell_name, reload=False)
|
network_manager.apply_cell_name(cur_cell_name, old_cell_name, reload=False)
|
||||||
|
|
||||||
_ip_revert.write_caddyfile(
|
if _id.get('domain_mode', 'lan') == 'lan':
|
||||||
_id.get('ip_range', os.environ.get('CELL_IP_RANGE', '172.20.0.0/16')),
|
_ip_revert.write_caddyfile(
|
||||||
_id.get('cell_name', os.environ.get('CELL_NAME', 'mycell')),
|
_id.get('ip_range', os.environ.get('CELL_IP_RANGE', '172.20.0.0/16')),
|
||||||
_dom, '/app/config-caddy/Caddyfile'
|
_id.get('cell_name', os.environ.get('CELL_NAME', 'mycell')),
|
||||||
)
|
_dom, '/app/config-caddy/Caddyfile'
|
||||||
|
)
|
||||||
|
|
||||||
_clear_pending_restart()
|
_clear_pending_restart()
|
||||||
return jsonify({'message': 'Pending changes discarded'})
|
return jsonify({'message': 'Pending changes discarded'})
|
||||||
|
|||||||
@@ -70,6 +70,21 @@ class TestGenerateCaddyfilePicNgo(unittest.TestCase):
|
|||||||
# ACME staging hook
|
# ACME staging hook
|
||||||
self.assertIn('acme_ca {$ACME_CA_URL}', out)
|
self.assertIn('acme_ca {$ACME_CA_URL}', out)
|
||||||
|
|
||||||
|
def test_pic_ngo_has_subdomain_service_routes(self):
|
||||||
|
mgr = _mgr()
|
||||||
|
identity = {'cell_name': 'alpha', 'domain_mode': 'pic_ngo'}
|
||||||
|
out = mgr.generate_caddyfile(identity, [])
|
||||||
|
# Core services get named-matcher subdomain routing
|
||||||
|
self.assertIn('@calendar host calendar.alpha.pic.ngo', out)
|
||||||
|
self.assertIn('reverse_proxy cell-radicale:5232', out)
|
||||||
|
self.assertIn('@mail host mail.alpha.pic.ngo webmail.alpha.pic.ngo', out)
|
||||||
|
self.assertIn('reverse_proxy cell-rainloop:8888', out)
|
||||||
|
self.assertIn('@files host files.alpha.pic.ngo', out)
|
||||||
|
self.assertIn('reverse_proxy cell-filegator:8080', out)
|
||||||
|
self.assertIn('@webdav host webdav.alpha.pic.ngo', out)
|
||||||
|
self.assertIn('reverse_proxy cell-webdav:80', out)
|
||||||
|
self.assertIn('@api host api.alpha.pic.ngo', out)
|
||||||
|
|
||||||
|
|
||||||
class TestGenerateCaddyfileCloudflare(unittest.TestCase):
|
class TestGenerateCaddyfileCloudflare(unittest.TestCase):
|
||||||
def test_cloudflare_has_dns_cloudflare(self):
|
def test_cloudflare_has_dns_cloudflare(self):
|
||||||
@@ -101,6 +116,9 @@ class TestGenerateCaddyfileCloudflare(unittest.TestCase):
|
|||||||
self.assertNotIn('*.home.local', out)
|
self.assertNotIn('*.home.local', out)
|
||||||
# 'custom_domain' must not appear literally as a key in the output
|
# 'custom_domain' must not appear literally as a key in the output
|
||||||
self.assertNotIn('custom_domain', out)
|
self.assertNotIn('custom_domain', out)
|
||||||
|
# Service subdomains use the correct public domain
|
||||||
|
self.assertIn('@calendar host calendar.home.example.com', out)
|
||||||
|
self.assertIn('@files host files.home.example.com', out)
|
||||||
|
|
||||||
|
|
||||||
class TestGenerateCaddyfileDuckDns(unittest.TestCase):
|
class TestGenerateCaddyfileDuckDns(unittest.TestCase):
|
||||||
@@ -110,6 +128,8 @@ class TestGenerateCaddyfileDuckDns(unittest.TestCase):
|
|||||||
out = mgr.generate_caddyfile(identity, [])
|
out = mgr.generate_caddyfile(identity, [])
|
||||||
self.assertIn('dns duckdns {$DUCKDNS_TOKEN}', out)
|
self.assertIn('dns duckdns {$DUCKDNS_TOKEN}', out)
|
||||||
self.assertIn('*.gamma.duckdns.org', out)
|
self.assertIn('*.gamma.duckdns.org', out)
|
||||||
|
self.assertIn('@calendar host calendar.gamma.duckdns.org', out)
|
||||||
|
self.assertIn('@files host files.gamma.duckdns.org', out)
|
||||||
|
|
||||||
|
|
||||||
class TestGenerateCaddyfileHttp01(unittest.TestCase):
|
class TestGenerateCaddyfileHttp01(unittest.TestCase):
|
||||||
@@ -120,24 +140,44 @@ class TestGenerateCaddyfileHttp01(unittest.TestCase):
|
|||||||
'domain_mode': 'http01',
|
'domain_mode': 'http01',
|
||||||
'domain_name': 'delta.noip.me',
|
'domain_name': 'delta.noip.me',
|
||||||
}
|
}
|
||||||
|
# Store-plugin service (not a core service name)
|
||||||
services = [
|
services = [
|
||||||
{'name': 'calendar', 'caddy_route':
|
{'name': 'chat', 'caddy_route': 'reverse_proxy cell-chat:8090'},
|
||||||
'reverse_proxy cell-radicale:5232'},
|
|
||||||
{'name': 'files', 'caddy_route':
|
|
||||||
'reverse_proxy cell-filegator:8080'},
|
|
||||||
]
|
]
|
||||||
out = mgr.generate_caddyfile(identity, services)
|
out = mgr.generate_caddyfile(identity, services)
|
||||||
# No wildcard, no DNS-01 plugins.
|
# No wildcard, no DNS-01 plugins.
|
||||||
self.assertNotIn('*.delta', out)
|
self.assertNotIn('*.delta', out)
|
||||||
self.assertNotIn('dns ', out)
|
self.assertNotIn('dns ', out)
|
||||||
# No explicit tls block (no internal CA, no plugin) — the host block
|
# No explicit tls block — Caddy uses HTTP-01 by default.
|
||||||
# itself is left empty so Caddy uses HTTP-01 by default.
|
|
||||||
self.assertNotIn('tls {', out)
|
self.assertNotIn('tls {', out)
|
||||||
# Per-service blocks
|
# Core service blocks are always generated
|
||||||
self.assertIn('calendar.delta.noip.me {', out)
|
self.assertIn('calendar.delta.noip.me {', out)
|
||||||
self.assertIn('files.delta.noip.me {', out)
|
self.assertIn('files.delta.noip.me {', out)
|
||||||
|
self.assertIn('mail.delta.noip.me {', out)
|
||||||
|
self.assertIn('webmail.delta.noip.me {', out)
|
||||||
|
self.assertIn('webdav.delta.noip.me {', out)
|
||||||
|
self.assertIn('api.delta.noip.me {', out)
|
||||||
self.assertIn('reverse_proxy cell-radicale:5232', out)
|
self.assertIn('reverse_proxy cell-radicale:5232', out)
|
||||||
self.assertIn('reverse_proxy cell-filegator:8080', out)
|
self.assertIn('reverse_proxy cell-filegator:8080', out)
|
||||||
|
# Installed plugin service block
|
||||||
|
self.assertIn('chat.delta.noip.me {', out)
|
||||||
|
self.assertIn('reverse_proxy cell-chat:8090', out)
|
||||||
|
|
||||||
|
def test_http01_installed_service_with_core_name_is_skipped(self):
|
||||||
|
"""An installed service named 'calendar' must not produce a duplicate block."""
|
||||||
|
mgr = _mgr()
|
||||||
|
identity = {
|
||||||
|
'cell_name': 'delta',
|
||||||
|
'domain_mode': 'http01',
|
||||||
|
'domain_name': 'delta.noip.me',
|
||||||
|
}
|
||||||
|
services = [{'name': 'calendar', 'caddy_route': 'reverse_proxy cell-other:9000'}]
|
||||||
|
out = mgr.generate_caddyfile(identity, services)
|
||||||
|
# Only one calendar block (the core one)
|
||||||
|
self.assertEqual(out.count('calendar.delta.noip.me {'), 1)
|
||||||
|
# The core backend wins
|
||||||
|
self.assertIn('reverse_proxy cell-radicale:5232', out)
|
||||||
|
self.assertNotIn('cell-other:9000', out)
|
||||||
|
|
||||||
|
|
||||||
class TestServiceRoutesIncluded(unittest.TestCase):
|
class TestServiceRoutesIncluded(unittest.TestCase):
|
||||||
|
|||||||
@@ -219,6 +219,62 @@ class TestGenerateCorefileWithCellLinks(unittest.TestCase):
|
|||||||
self.assertNotIn('nope.cell', content)
|
self.assertNotIn('nope.cell', content)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# generate_corefile with split_horizon_zones
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestGenerateCorefileSplitHorizon(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.mkdtemp()
|
||||||
|
self.path = os.path.join(self.tmp, 'Corefile')
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
shutil.rmtree(self.tmp)
|
||||||
|
|
||||||
|
def _content(self):
|
||||||
|
return open(self.path).read()
|
||||||
|
|
||||||
|
def test_split_horizon_zone_block_added(self):
|
||||||
|
"""A split_horizon_zones entry produces a local file zone block."""
|
||||||
|
firewall_manager.generate_corefile([], self.path, split_horizon_zones=['pic1.pic.ngo'])
|
||||||
|
content = self._content()
|
||||||
|
self.assertIn('pic1.pic.ngo {', content)
|
||||||
|
self.assertIn('file /data/pic1.pic.ngo.zone', content)
|
||||||
|
|
||||||
|
def test_split_horizon_zone_does_not_add_forward(self):
|
||||||
|
"""Split-horizon blocks must use 'file', not 'forward'."""
|
||||||
|
firewall_manager.generate_corefile([], self.path, split_horizon_zones=['pic1.pic.ngo'])
|
||||||
|
content = self._content()
|
||||||
|
# Only the default internet forwarder; no extra forward for the horizon zone
|
||||||
|
forward_lines = [l for l in content.splitlines() if 'forward' in l and 'pic1' in l]
|
||||||
|
self.assertEqual(len(forward_lines), 0)
|
||||||
|
|
||||||
|
def test_multiple_split_horizon_zones(self):
|
||||||
|
"""Multiple zones all get their own file block."""
|
||||||
|
firewall_manager.generate_corefile(
|
||||||
|
[], self.path, split_horizon_zones=['a.pic.ngo', 'b.example.com']
|
||||||
|
)
|
||||||
|
content = self._content()
|
||||||
|
self.assertIn('a.pic.ngo {', content)
|
||||||
|
self.assertIn('file /data/a.pic.ngo.zone', content)
|
||||||
|
self.assertIn('b.example.com {', content)
|
||||||
|
self.assertIn('file /data/b.example.com.zone', content)
|
||||||
|
|
||||||
|
def test_split_horizon_with_cell_links(self):
|
||||||
|
"""Split-horizon zones and cell-link forwarding stanzas coexist."""
|
||||||
|
cell_links = [{'domain': 'other.cell', 'dns_ip': '10.99.0.1'}]
|
||||||
|
firewall_manager.generate_corefile(
|
||||||
|
[], self.path,
|
||||||
|
cell_links=cell_links,
|
||||||
|
split_horizon_zones=['pic1.pic.ngo'],
|
||||||
|
)
|
||||||
|
content = self._content()
|
||||||
|
self.assertIn('pic1.pic.ngo {', content)
|
||||||
|
self.assertIn('file /data/pic1.pic.ngo.zone', content)
|
||||||
|
self.assertIn('other.cell {', content)
|
||||||
|
self.assertIn('forward . 10.99.0.1', content)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# apply_peer_rules — iptables call verification
|
# apply_peer_rules — iptables call verification
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -412,5 +412,52 @@ class TestCellDnsForwarding(unittest.TestCase):
|
|||||||
# The Corefile is regenerated (new canonical format) — that's correct.
|
# The Corefile is regenerated (new canonical format) — that's correct.
|
||||||
|
|
||||||
|
|
||||||
|
class TestUpdateSplitHorizonZone(unittest.TestCase):
|
||||||
|
"""Test update_split_horizon_zone writes zone file and Corefile."""
|
||||||
|
|
||||||
|
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.data_dir, 'dns'), exist_ok=True)
|
||||||
|
os.makedirs(os.path.join(self.config_dir, 'dns'), exist_ok=True)
|
||||||
|
self.nm = NetworkManager(self.data_dir, self.config_dir)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
shutil.rmtree(self.test_dir)
|
||||||
|
|
||||||
|
@patch('subprocess.run')
|
||||||
|
def test_creates_zone_file_with_wildcard(self, _mock):
|
||||||
|
"""Zone file must contain wildcard A record pointing to caddy_ip."""
|
||||||
|
self.nm.update_split_horizon_zone('pic1.pic.ngo', '172.20.0.2')
|
||||||
|
zone_path = os.path.join(self.data_dir, 'dns', 'pic1.pic.ngo.zone')
|
||||||
|
self.assertTrue(os.path.exists(zone_path))
|
||||||
|
content = open(zone_path).read()
|
||||||
|
self.assertIn('172.20.0.2', content)
|
||||||
|
|
||||||
|
@patch('subprocess.run')
|
||||||
|
def test_corefile_contains_split_horizon_block(self, _mock):
|
||||||
|
"""Corefile must reference the new zone file."""
|
||||||
|
self.nm.update_split_horizon_zone('pic1.pic.ngo', '172.20.0.2')
|
||||||
|
corefile = os.path.join(self.config_dir, 'dns', 'Corefile')
|
||||||
|
self.assertTrue(os.path.exists(corefile))
|
||||||
|
content = open(corefile).read()
|
||||||
|
self.assertIn('pic1.pic.ngo {', content)
|
||||||
|
self.assertIn('file /data/pic1.pic.ngo.zone', content)
|
||||||
|
|
||||||
|
@patch('subprocess.run')
|
||||||
|
def test_returns_true_on_success(self, _mock):
|
||||||
|
ok = self.nm.update_split_horizon_zone('pic1.pic.ngo', '172.20.0.2')
|
||||||
|
self.assertTrue(ok)
|
||||||
|
|
||||||
|
@patch('subprocess.run')
|
||||||
|
def test_sends_sigusr1_to_coredns(self, mock_run):
|
||||||
|
"""CoreDNS reload (SIGUSR1) must be triggered after writing."""
|
||||||
|
mock_run.return_value = MagicMock(returncode=0, stderr='')
|
||||||
|
self.nm.update_split_horizon_zone('pic1.pic.ngo', '172.20.0.2')
|
||||||
|
calls = [str(c) for c in mock_run.call_args_list]
|
||||||
|
self.assertTrue(any('SIGUSR1' in c for c in calls))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
Reference in New Issue
Block a user