diff --git a/api/app.py b/api/app.py index fbc7618..9aeb05f 100644 --- a/api/app.py +++ b/api/app.py @@ -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: diff --git a/api/firewall_manager.py b/api/firewall_manager.py index f315402..4ead462 100644 --- a/api/firewall_manager.py +++ b/api/firewall_manager.py @@ -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 diff --git a/api/managers.py b/api/managers.py index 0af5ad5..722c40b 100644 --- a/api/managers.py +++ b/api/managers.py @@ -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: diff --git a/api/routes/cells.py b/api/routes/cells.py index 075576c..39bd69e 100644 --- a/api/routes/cells.py +++ b/api/routes/cells.py @@ -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}") diff --git a/api/routes/peers.py b/api/routes/peers.py index b35ae86..a6ee951 100644 --- a/api/routes/peers.py +++ b/api/routes/peers.py @@ -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) diff --git a/api/routes/services.py b/api/routes/services.py index b3e56e3..dd382a2 100644 --- a/api/routes/services.py +++ b/api/routes/services.py @@ -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): diff --git a/api/routes/wireguard.py b/api/routes/wireguard.py index ddfccf4..55904d9 100644 --- a/api/routes/wireguard.py +++ b/api/routes/wireguard.py @@ -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 diff --git a/tests/test_cell_link_dns.py b/tests/test_cell_link_dns.py index 297d5d8..0f693f5 100644 --- a/tests/test_cell_link_dns.py +++ b/tests/test_cell_link_dns.py @@ -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), \ diff --git a/tests/test_logs_endpoints.py b/tests/test_logs_endpoints.py index 80d7a7c..a2811f9 100644 --- a/tests/test_logs_endpoints.py +++ b/tests/test_logs_endpoints.py @@ -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'}), diff --git a/webui/src/pages/Peers.jsx b/webui/src/pages/Peers.jsx index 1de73dc..ecaa66a 100644 --- a/webui/src/pages/Peers.jsx +++ b/webui/src/pages/Peers.jsx @@ -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}`;

Peers

Manage VPN peer connections and access policies

-