fix: DNS split-horizon in DDNS mode, service access filter, health check, verbosity persistence
Unit Tests / test (push) Successful in 7m32s

- DNS (critical): add _configured_dns_params() that returns (primary_domain,
  split_horizon_zones) from config_manager so all apply_all_dns_rules() callers
  pass the correct primary zone (e.g. 'pic.ngo') and split-horizon list
  (e.g. ['pic1.pic.ngo']) instead of the FQDN as the primary — fixes
  DNS_PROBE_FINISHED_BAD_CONFIG for all external domains when on VPN

- firewall_manager: add split_horizon_zones param to apply_all_dns_rules()
  and forward it to generate_corefile()

- Peers: filter service_access list to installed services only; peers.py
  derives valid services from config_manager.get_installed_services() with
  the email→mail ID mapping; Peers.jsx fetches from /api/store/installed
  and filters the checkboxes and defaults accordingly

- Health check: fix file_manager→'files' ID mapping so files service health
  is checked when installed (was silently skipped due to 'file' vs 'files')

- Verbosity persistence: move log_levels.json from non-mounted
  /app/api/config/ to CONFIG_DIR (/app/config/) which maps to config/api/
  on the host; both load (managers.py) and save (routes/services.py) updated

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 13:05:58 -04:00
parent 4ebcb1d077
commit c696ca9ef6
10 changed files with 83 additions and 46 deletions
+19 -4
View File
@@ -298,6 +298,19 @@ def _configured_domain() -> str:
return identity.get('domain_name') or identity.get('domain', 'cell')
def _configured_dns_params():
"""Return (primary_domain, split_horizon_zones) for Corefile generation.
In DDNS mode the primary CoreDNS zone is the parent domain (e.g. 'pic.ngo')
and the cell's FQDN (e.g. 'pic1.pic.ngo') is a separate split-horizon block
so LAN clients resolve *.pic1.pic.ngo to the internal Caddy IP.
In LAN mode both values are the same so split_horizon_zones is empty.
"""
primary = config_manager.get_internal_domain()
effective = config_manager.get_effective_domain()
return primary, ([effective] if effective != primary else [])
def _restore_cell_wg_peers(cell_links):
"""Re-add any cell link [Peer] blocks that are missing from wg0.conf.
@@ -359,8 +372,10 @@ def _apply_startup_enforcement():
# (happens if the container was rebuilt, wg0.conf was reset, etc.)
_restore_cell_wg_peers(cell_links)
wireguard_manager.sync_cell_routes()
firewall_manager.apply_all_dns_rules(peers, COREFILE_PATH, _configured_domain(),
cell_links=cell_links)
_dns_primary, _dns_szones = _configured_dns_params()
firewall_manager.apply_all_dns_rules(peers, COREFILE_PATH, _dns_primary,
cell_links=cell_links,
split_horizon_zones=_dns_szones)
logger.info(f"Applied enforcement rules for {len(peers)} peers, {len(cell_links)} cells on startup")
# Phase 3: reapply policy routing rules for peers whose internet traffic is
# routed through an exit cell (ip rule entries don't survive container restart)
@@ -529,12 +544,12 @@ def perform_health_check():
# email/calendar/files are optional store services — only check them when installed
_installed_store_ids = set(config_manager.get_installed_services())
_OPTIONAL_STORE_MANAGERS = frozenset({'email_manager', 'calendar_manager', 'file_manager'})
_MANAGER_TO_STORE_ID = {'email_manager': 'email', 'calendar_manager': 'calendar', 'file_manager': 'files'}
# Get health from each service
for service_name in service_bus.list_services():
if service_name in _OPTIONAL_STORE_MANAGERS:
# Map manager name to store service id (strip _manager suffix)
store_id = service_name.replace('_manager', '')
store_id = _MANAGER_TO_STORE_ID[service_name]
if store_id not in _installed_store_ids:
continue
try:
+3 -2
View File
@@ -815,9 +815,10 @@ def reload_coredns() -> bool:
def apply_all_dns_rules(peers: List[Dict[str, Any]], corefile_path: str = COREFILE_PATH,
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:
"""Regenerate Corefile (including any cell-to-cell forwarding stanzas) and reload CoreDNS."""
ok = generate_corefile(peers, corefile_path, domain, cell_links)
ok = generate_corefile(peers, corefile_path, domain, cell_links, split_horizon_zones)
if ok:
reload_coredns()
return ok
+2 -2
View File
@@ -121,9 +121,9 @@ _service_log_configs = {
for _svc, _cfg in _service_log_configs.items():
log_manager.add_service_logger(_svc, _cfg)
# Apply any persisted log level overrides
# Apply any persisted log level overrides (stored in the mounted config volume)
import json as _json
_levels_file = os.path.join(os.path.dirname(__file__), 'config', 'log_levels.json')
_levels_file = os.path.join(CONFIG_DIR, 'log_levels.json')
if os.path.exists(_levels_file):
try:
with open(_levels_file) as _lf:
+5 -5
View File
@@ -145,13 +145,13 @@ def update_cell_permissions(cell_name):
# Regenerate Corefile so outbound DNS changes take effect
try:
from app import config_manager
_id = config_manager.configs.get('_identity', {})
domain = _id.get('domain_name') or _id.get('domain', 'cell')
from app import _configured_dns_params
peers = peer_registry.list_peers()
cell_links = cell_link_manager.list_connections()
firewall_manager.apply_all_dns_rules(peers, COREFILE_PATH, domain,
cell_links=cell_links)
_dns_primary, _dns_szones = _configured_dns_params()
firewall_manager.apply_all_dns_rules(peers, COREFILE_PATH, _dns_primary,
cell_links=cell_links,
split_horizon_zones=_dns_szones)
except Exception as e:
logger.warning(f"DNS regen after permission update failed (non-fatal): {e}")
+19 -10
View File
@@ -37,7 +37,8 @@ def add_peer():
try:
from app import (peer_registry, wireguard_manager, firewall_manager,
email_manager, calendar_manager, file_manager, auth_manager,
cell_link_manager, _configured_domain, COREFILE_PATH)
cell_link_manager, _configured_domain, _configured_dns_params,
config_manager as _app_cfg, COREFILE_PATH)
try:
_wg_addr = wireguard_manager._get_configured_address()
_wg_subnet = str(ipaddress.ip_network(_wg_addr, strict=False)) if _wg_addr else '10.0.0.0/24'
@@ -64,7 +65,9 @@ def add_peer():
except ValueError as e:
return jsonify({'error': str(e)}), 409
_valid_services = {'calendar', 'files', 'mail', 'webdav'}
_STORE_ID_TO_ACCESS = {'email': 'mail', 'calendar': 'calendar', 'files': 'files'}
_installed = set(_app_cfg.get_installed_services() or {})
_valid_services = {'webdav'} | {_STORE_ID_TO_ACCESS[sid] for sid in _installed if sid in _STORE_ID_TO_ACCESS}
service_access = data.get('service_access', list(_valid_services))
if not isinstance(service_access, list) or not all(s in _valid_services for s in service_access):
return jsonify({"error": f"service_access must be a list of: {sorted(_valid_services)}"}), 400
@@ -160,8 +163,10 @@ def add_peer():
except Exception as wg_err:
logger.warning(f"Peer {peer_name}: WireGuard server config update failed (non-fatal): {wg_err}")
firewall_manager.apply_all_dns_rules(peer_registry.list_peers(), COREFILE_PATH, _configured_domain(),
cell_links=cell_link_manager.list_connections())
_dns_primary, _dns_szones = _configured_dns_params()
firewall_manager.apply_all_dns_rules(peer_registry.list_peers(), COREFILE_PATH, _dns_primary,
cell_links=cell_link_manager.list_connections(),
split_horizon_zones=_dns_szones)
return jsonify({"message": f"Peer {peer_name} added successfully", "ip": assigned_ip}), 201
except Exception as e:
@@ -200,7 +205,7 @@ def get_peer(peer_name):
def update_peer(peer_name):
try:
from app import (peer_registry, wireguard_manager, firewall_manager,
cell_link_manager, _configured_domain, COREFILE_PATH)
cell_link_manager, _configured_dns_params, COREFILE_PATH)
try:
_wg_addr = wireguard_manager._get_configured_address()
_wg_subnet = str(ipaddress.ip_network(_wg_addr, strict=False)) if _wg_addr else '10.0.0.0/24'
@@ -229,8 +234,10 @@ def update_peer(peer_name):
if updated_peer:
firewall_manager.apply_peer_rules(updated_peer['ip'], updated_peer,
wg_subnet=_wg_subnet, cell_subnets=_cell_subnets)
firewall_manager.apply_all_dns_rules(peer_registry.list_peers(), COREFILE_PATH, _configured_domain(),
cell_links=cell_link_manager.list_connections())
_dns_primary, _dns_szones = _configured_dns_params()
firewall_manager.apply_all_dns_rules(peer_registry.list_peers(), COREFILE_PATH, _dns_primary,
cell_links=cell_link_manager.list_connections(),
split_horizon_zones=_dns_szones)
return jsonify({"message": f"Peer {peer_name} updated", "config_changed": config_changed})
return jsonify({"error": "Update failed"}), 500
except Exception as e:
@@ -331,7 +338,7 @@ def remove_peer(peer_name):
try:
from app import (peer_registry, wireguard_manager, firewall_manager,
email_manager, calendar_manager, file_manager, auth_manager,
cell_link_manager, _configured_domain, COREFILE_PATH)
cell_link_manager, _configured_domain, _configured_dns_params, COREFILE_PATH)
peer = peer_registry.get_peer(peer_name)
if not peer:
return jsonify({"message": f"Peer {peer_name} not found or already removed"})
@@ -341,8 +348,10 @@ def remove_peer(peer_name):
if success:
if peer_ip:
firewall_manager.clear_peer_rules(peer_ip)
firewall_manager.apply_all_dns_rules(peer_registry.list_peers(), COREFILE_PATH, _configured_domain(),
cell_links=cell_link_manager.list_connections())
_dns_primary, _dns_szones = _configured_dns_params()
firewall_manager.apply_all_dns_rules(peer_registry.list_peers(), COREFILE_PATH, _dns_primary,
cell_links=cell_link_manager.list_connections(),
split_horizon_zones=_dns_szones)
if peer_pubkey:
try:
wireguard_manager.remove_peer(peer_pubkey)
+2 -1
View File
@@ -348,7 +348,8 @@ def set_log_verbosity():
data = request.get_json(silent=True) or {}
for service, level in data.items():
log_manager.set_service_level(service, level)
levels_file = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'config', 'log_levels.json')
_config_dir = os.environ.get('CONFIG_DIR', '/app/config')
levels_file = os.path.join(_config_dir, 'log_levels.json')
os.makedirs(os.path.dirname(levels_file), exist_ok=True)
current = {}
if os.path.exists(levels_file):
+5 -3
View File
@@ -265,7 +265,7 @@ def refresh_external_ip():
def apply_wireguard_enforcement():
try:
from app import (peer_registry, wireguard_manager, firewall_manager,
cell_link_manager, _configured_domain, COREFILE_PATH)
cell_link_manager, _configured_dns_params, COREFILE_PATH)
peers = peer_registry.list_peers()
try:
_wg_addr = wireguard_manager._get_configured_address()
@@ -275,8 +275,10 @@ def apply_wireguard_enforcement():
_cell_links = cell_link_manager.list_connections()
_cell_subnets = [l['vpn_subnet'] for l in _cell_links if l.get('vpn_subnet')]
firewall_manager.apply_all_peer_rules(peers, wg_subnet=_wg_subnet, cell_subnets=_cell_subnets)
firewall_manager.apply_all_dns_rules(peers, COREFILE_PATH, _configured_domain(),
cell_links=_cell_links)
_dns_primary, _dns_szones = _configured_dns_params()
firewall_manager.apply_all_dns_rules(peers, COREFILE_PATH, _dns_primary,
cell_links=_cell_links,
split_horizon_zones=_dns_szones)
return jsonify({'ok': True, 'peers': len(peers)})
except Exception as e:
return jsonify({'error': str(e)}), 500
+2 -2
View File
@@ -144,7 +144,7 @@ class TestApplyAllDnsRulesPassesCellLinks(unittest.TestCase):
cell_links=cell_links,
)
mock_gen.assert_called_once_with(
[], '/tmp/fake_Corefile', 'cell', cell_links
[], '/tmp/fake_Corefile', 'cell', cell_links, None
)
def test_cell_links_none_forwarded_as_none(self):
@@ -156,7 +156,7 @@ class TestApplyAllDnsRulesPassesCellLinks(unittest.TestCase):
domain='cell',
cell_links=None,
)
mock_gen.assert_called_once_with([], '/tmp/fake_Corefile', 'cell', None)
mock_gen.assert_called_once_with([], '/tmp/fake_Corefile', 'cell', None, None)
def test_reload_called_on_success(self):
with patch.object(firewall_manager, 'generate_corefile', return_value=True), \
+1 -5
View File
@@ -333,12 +333,8 @@ class TestLogVerbosity(unittest.TestCase):
def test_put_verbosity_returns_200_and_calls_set_level(self, mock_lm):
mock_lm.get_service_levels.return_value = {'dns': 'DEBUG'}
with tempfile.TemporaryDirectory() as tmpdir:
# Endpoint builds: os.path.join(os.path.dirname(__file__), 'config', 'log_levels.json')
# Patch dirname to return tmpdir so the full path becomes tmpdir/config/log_levels.json
config_dir = os.path.join(tmpdir, 'config')
os.makedirs(config_dir)
with patch('app.auth_manager', MagicMock(spec=object)), \
patch('app.os.path.dirname', return_value=tmpdir):
patch.dict('os.environ', {'CONFIG_DIR': tmpdir}):
r = self.client.put(
'/api/logs/verbosity',
data=json.dumps({'dns': 'DEBUG'}),
+25 -12
View File
@@ -1,18 +1,18 @@
import { useState, useEffect } from 'react';
import { Plus, Trash2, Edit, Eye, Shield, Copy, Download, Key, AlertTriangle, CheckCircle, Globe, Lock, Users, Server } from 'lucide-react';
import { peerRegistryAPI, wireguardAPI, cellLinkAPI, getCsrfToken } from '../services/api';
import { peerRegistryAPI, wireguardAPI, cellLinkAPI, storeAPI, getCsrfToken } from '../services/api';
import { useConfig } from '../contexts/ConfigContext';
import QRCode from 'qrcode';
const FULL_TUNNEL_IPS = '0.0.0.0/0, ::/0';
const emptyForm = () => ({
const emptyForm = (availableServiceKeys = ['webdav']) => ({
name: '',
description: '',
public_key: '',
persistent_keepalive: 25,
internet_access: true,
service_access: ['calendar', 'files', 'mail', 'webdav'],
service_access: availableServiceKeys,
peer_access: true,
create_calendar: false,
password: '',
@@ -52,14 +52,20 @@ function Toggle({ checked, onChange, label, description }) {
);
}
const STORE_ID_TO_ACCESS = { email: 'mail', calendar: 'calendar', files: 'files' };
const ALL_SERVICES = [
{ key: 'calendar', label: 'Calendar' },
{ key: 'files', label: 'Files' },
{ key: 'mail', label: 'Webmail' },
{ key: 'webdav', label: 'WebDAV' },
];
function Peers() {
const { domain = 'cell' } = useConfig();
const SERVICES = [
{ key: 'calendar', label: 'Calendar', domain: `calendar.${domain}` },
{ key: 'files', label: 'Files', domain: `files.${domain}` },
{ key: 'mail', label: 'Webmail', domain: `mail.${domain}` },
{ key: 'webdav', label: 'WebDAV', domain: `webdav.${domain}` },
];
const [installedServiceKeys, setInstalledServiceKeys] = useState(['webdav']);
const SERVICES = ALL_SERVICES
.filter(s => installedServiceKeys.includes(s.key))
.map(s => ({ ...s, domain: `${s.key}.${domain}` }));
const [peers, setPeers] = useState([]);
const [connectedCells, setConnectedCells] = useState([]);
@@ -69,7 +75,7 @@ function Peers() {
const [showEditModal, setShowEditModal] = useState(false);
const [showViewModal, setShowViewModal] = useState(false);
const [selectedPeer, setSelectedPeer] = useState(null);
const [formData, setFormData] = useState(emptyForm());
const [formData, setFormData] = useState(emptyForm(installedServiceKeys));
const [showAdvanced, setShowAdvanced] = useState(false);
const [peerConfig, setPeerConfig] = useState('');
const [qrCodeDataUrl, setQrCodeDataUrl] = useState('');
@@ -81,6 +87,13 @@ function Peers() {
useEffect(() => {
fetchPeers();
cellLinkAPI.listConnections().then(r => setConnectedCells(r.data || [])).catch(() => {});
storeAPI.listInstalled().then(r => {
const installed = r.data?.installed || {};
const keys = ['webdav', ...Object.keys(installed)
.map(id => STORE_ID_TO_ACCESS[id])
.filter(Boolean)];
setInstalledServiceKeys(keys);
}).catch(() => {});
}, []);
const showToast = (msg, type = 'success') => {
@@ -212,7 +225,7 @@ PersistentKeepalive = ${peer.persistent_keepalive || 25}`;
? Object.entries(provisioned).filter(([, v]) => v).map(([k]) => k).join(', ')
: '';
setShowAddModal(false);
setFormData(emptyForm());
setFormData(emptyForm(installedServiceKeys));
setErrors({});
fetchPeers();
showToast(
@@ -471,7 +484,7 @@ PersistentKeepalive = ${peer.persistent_keepalive || 25}`;
<h1 className="text-2xl font-bold text-gray-900">Peers</h1>
<p className="mt-1 text-gray-600">Manage VPN peer connections and access policies</p>
</div>
<button onClick={() => { setFormData(emptyForm()); setErrors({}); setShowAdvanced(false); setShowAddModal(true); }}
<button onClick={() => { setFormData(emptyForm(installedServiceKeys)); setErrors({}); setShowAdvanced(false); setShowAddModal(true); }}
className="btn btn-primary flex items-center">
<Plus className="h-4 w-4 mr-2" />Add Peer
</button>