fix: DNS split-horizon in DDNS mode, service access filter, health check, verbosity persistence
Unit Tests / test (push) Successful in 7m32s
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:
+19
-4
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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), \
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user