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')
|
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):
|
def _restore_cell_wg_peers(cell_links):
|
||||||
"""Re-add any cell link [Peer] blocks that are missing from wg0.conf.
|
"""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.)
|
# (happens if the container was rebuilt, wg0.conf was reset, etc.)
|
||||||
_restore_cell_wg_peers(cell_links)
|
_restore_cell_wg_peers(cell_links)
|
||||||
wireguard_manager.sync_cell_routes()
|
wireguard_manager.sync_cell_routes()
|
||||||
firewall_manager.apply_all_dns_rules(peers, COREFILE_PATH, _configured_domain(),
|
_dns_primary, _dns_szones = _configured_dns_params()
|
||||||
cell_links=cell_links)
|
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")
|
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
|
# 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)
|
# 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
|
# email/calendar/files are optional store services — only check them when installed
|
||||||
_installed_store_ids = set(config_manager.get_installed_services())
|
_installed_store_ids = set(config_manager.get_installed_services())
|
||||||
_OPTIONAL_STORE_MANAGERS = frozenset({'email_manager', 'calendar_manager', 'file_manager'})
|
_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
|
# Get health from each service
|
||||||
for service_name in service_bus.list_services():
|
for service_name in service_bus.list_services():
|
||||||
if service_name in _OPTIONAL_STORE_MANAGERS:
|
if service_name in _OPTIONAL_STORE_MANAGERS:
|
||||||
# Map manager name to store service id (strip _manager suffix)
|
store_id = _MANAGER_TO_STORE_ID[service_name]
|
||||||
store_id = service_name.replace('_manager', '')
|
|
||||||
if store_id not in _installed_store_ids:
|
if store_id not in _installed_store_ids:
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -815,9 +815,10 @@ def reload_coredns() -> bool:
|
|||||||
|
|
||||||
def apply_all_dns_rules(peers: List[Dict[str, Any]], corefile_path: str = COREFILE_PATH,
|
def apply_all_dns_rules(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:
|
||||||
"""Regenerate Corefile (including any cell-to-cell forwarding stanzas) and reload CoreDNS."""
|
"""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:
|
if ok:
|
||||||
reload_coredns()
|
reload_coredns()
|
||||||
return ok
|
return ok
|
||||||
|
|||||||
+2
-2
@@ -121,9 +121,9 @@ _service_log_configs = {
|
|||||||
for _svc, _cfg in _service_log_configs.items():
|
for _svc, _cfg in _service_log_configs.items():
|
||||||
log_manager.add_service_logger(_svc, _cfg)
|
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
|
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):
|
if os.path.exists(_levels_file):
|
||||||
try:
|
try:
|
||||||
with open(_levels_file) as _lf:
|
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
|
# Regenerate Corefile so outbound DNS changes take effect
|
||||||
try:
|
try:
|
||||||
from app import config_manager
|
from app import _configured_dns_params
|
||||||
_id = config_manager.configs.get('_identity', {})
|
|
||||||
domain = _id.get('domain_name') or _id.get('domain', 'cell')
|
|
||||||
peers = peer_registry.list_peers()
|
peers = peer_registry.list_peers()
|
||||||
cell_links = cell_link_manager.list_connections()
|
cell_links = cell_link_manager.list_connections()
|
||||||
firewall_manager.apply_all_dns_rules(peers, COREFILE_PATH, domain,
|
_dns_primary, _dns_szones = _configured_dns_params()
|
||||||
cell_links=cell_links)
|
firewall_manager.apply_all_dns_rules(peers, COREFILE_PATH, _dns_primary,
|
||||||
|
cell_links=cell_links,
|
||||||
|
split_horizon_zones=_dns_szones)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"DNS regen after permission update failed (non-fatal): {e}")
|
logger.warning(f"DNS regen after permission update failed (non-fatal): {e}")
|
||||||
|
|
||||||
|
|||||||
+19
-10
@@ -37,7 +37,8 @@ def add_peer():
|
|||||||
try:
|
try:
|
||||||
from app import (peer_registry, wireguard_manager, firewall_manager,
|
from app import (peer_registry, wireguard_manager, firewall_manager,
|
||||||
email_manager, calendar_manager, file_manager, auth_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:
|
try:
|
||||||
_wg_addr = wireguard_manager._get_configured_address()
|
_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'
|
_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:
|
except ValueError as e:
|
||||||
return jsonify({'error': str(e)}), 409
|
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))
|
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):
|
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
|
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:
|
except Exception as wg_err:
|
||||||
logger.warning(f"Peer {peer_name}: WireGuard server config update failed (non-fatal): {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(),
|
_dns_primary, _dns_szones = _configured_dns_params()
|
||||||
cell_links=cell_link_manager.list_connections())
|
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
|
return jsonify({"message": f"Peer {peer_name} added successfully", "ip": assigned_ip}), 201
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -200,7 +205,7 @@ def get_peer(peer_name):
|
|||||||
def update_peer(peer_name):
|
def update_peer(peer_name):
|
||||||
try:
|
try:
|
||||||
from app import (peer_registry, wireguard_manager, firewall_manager,
|
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:
|
try:
|
||||||
_wg_addr = wireguard_manager._get_configured_address()
|
_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'
|
_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:
|
if updated_peer:
|
||||||
firewall_manager.apply_peer_rules(updated_peer['ip'], updated_peer,
|
firewall_manager.apply_peer_rules(updated_peer['ip'], updated_peer,
|
||||||
wg_subnet=_wg_subnet, cell_subnets=_cell_subnets)
|
wg_subnet=_wg_subnet, cell_subnets=_cell_subnets)
|
||||||
firewall_manager.apply_all_dns_rules(peer_registry.list_peers(), COREFILE_PATH, _configured_domain(),
|
_dns_primary, _dns_szones = _configured_dns_params()
|
||||||
cell_links=cell_link_manager.list_connections())
|
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({"message": f"Peer {peer_name} updated", "config_changed": config_changed})
|
||||||
return jsonify({"error": "Update failed"}), 500
|
return jsonify({"error": "Update failed"}), 500
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -331,7 +338,7 @@ def remove_peer(peer_name):
|
|||||||
try:
|
try:
|
||||||
from app import (peer_registry, wireguard_manager, firewall_manager,
|
from app import (peer_registry, wireguard_manager, firewall_manager,
|
||||||
email_manager, calendar_manager, file_manager, auth_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)
|
peer = peer_registry.get_peer(peer_name)
|
||||||
if not peer:
|
if not peer:
|
||||||
return jsonify({"message": f"Peer {peer_name} not found or already removed"})
|
return jsonify({"message": f"Peer {peer_name} not found or already removed"})
|
||||||
@@ -341,8 +348,10 @@ def remove_peer(peer_name):
|
|||||||
if success:
|
if success:
|
||||||
if peer_ip:
|
if peer_ip:
|
||||||
firewall_manager.clear_peer_rules(peer_ip)
|
firewall_manager.clear_peer_rules(peer_ip)
|
||||||
firewall_manager.apply_all_dns_rules(peer_registry.list_peers(), COREFILE_PATH, _configured_domain(),
|
_dns_primary, _dns_szones = _configured_dns_params()
|
||||||
cell_links=cell_link_manager.list_connections())
|
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:
|
if peer_pubkey:
|
||||||
try:
|
try:
|
||||||
wireguard_manager.remove_peer(peer_pubkey)
|
wireguard_manager.remove_peer(peer_pubkey)
|
||||||
|
|||||||
@@ -348,7 +348,8 @@ def set_log_verbosity():
|
|||||||
data = request.get_json(silent=True) or {}
|
data = request.get_json(silent=True) or {}
|
||||||
for service, level in data.items():
|
for service, level in data.items():
|
||||||
log_manager.set_service_level(service, level)
|
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)
|
os.makedirs(os.path.dirname(levels_file), exist_ok=True)
|
||||||
current = {}
|
current = {}
|
||||||
if os.path.exists(levels_file):
|
if os.path.exists(levels_file):
|
||||||
|
|||||||
@@ -265,7 +265,7 @@ def refresh_external_ip():
|
|||||||
def apply_wireguard_enforcement():
|
def apply_wireguard_enforcement():
|
||||||
try:
|
try:
|
||||||
from app import (peer_registry, wireguard_manager, firewall_manager,
|
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()
|
peers = peer_registry.list_peers()
|
||||||
try:
|
try:
|
||||||
_wg_addr = wireguard_manager._get_configured_address()
|
_wg_addr = wireguard_manager._get_configured_address()
|
||||||
@@ -275,8 +275,10 @@ def apply_wireguard_enforcement():
|
|||||||
_cell_links = cell_link_manager.list_connections()
|
_cell_links = cell_link_manager.list_connections()
|
||||||
_cell_subnets = [l['vpn_subnet'] for l in _cell_links if l.get('vpn_subnet')]
|
_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_peer_rules(peers, wg_subnet=_wg_subnet, cell_subnets=_cell_subnets)
|
||||||
firewall_manager.apply_all_dns_rules(peers, COREFILE_PATH, _configured_domain(),
|
_dns_primary, _dns_szones = _configured_dns_params()
|
||||||
cell_links=_cell_links)
|
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)})
|
return jsonify({'ok': True, 'peers': len(peers)})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'error': str(e)}), 500
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ class TestApplyAllDnsRulesPassesCellLinks(unittest.TestCase):
|
|||||||
cell_links=cell_links,
|
cell_links=cell_links,
|
||||||
)
|
)
|
||||||
mock_gen.assert_called_once_with(
|
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):
|
def test_cell_links_none_forwarded_as_none(self):
|
||||||
@@ -156,7 +156,7 @@ class TestApplyAllDnsRulesPassesCellLinks(unittest.TestCase):
|
|||||||
domain='cell',
|
domain='cell',
|
||||||
cell_links=None,
|
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):
|
def test_reload_called_on_success(self):
|
||||||
with patch.object(firewall_manager, 'generate_corefile', return_value=True), \
|
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):
|
def test_put_verbosity_returns_200_and_calls_set_level(self, mock_lm):
|
||||||
mock_lm.get_service_levels.return_value = {'dns': 'DEBUG'}
|
mock_lm.get_service_levels.return_value = {'dns': 'DEBUG'}
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
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)), \
|
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(
|
r = self.client.put(
|
||||||
'/api/logs/verbosity',
|
'/api/logs/verbosity',
|
||||||
data=json.dumps({'dns': 'DEBUG'}),
|
data=json.dumps({'dns': 'DEBUG'}),
|
||||||
|
|||||||
+25
-12
@@ -1,18 +1,18 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Plus, Trash2, Edit, Eye, Shield, Copy, Download, Key, AlertTriangle, CheckCircle, Globe, Lock, Users, Server } from 'lucide-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 { useConfig } from '../contexts/ConfigContext';
|
||||||
import QRCode from 'qrcode';
|
import QRCode from 'qrcode';
|
||||||
|
|
||||||
const FULL_TUNNEL_IPS = '0.0.0.0/0, ::/0';
|
const FULL_TUNNEL_IPS = '0.0.0.0/0, ::/0';
|
||||||
|
|
||||||
const emptyForm = () => ({
|
const emptyForm = (availableServiceKeys = ['webdav']) => ({
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
public_key: '',
|
public_key: '',
|
||||||
persistent_keepalive: 25,
|
persistent_keepalive: 25,
|
||||||
internet_access: true,
|
internet_access: true,
|
||||||
service_access: ['calendar', 'files', 'mail', 'webdav'],
|
service_access: availableServiceKeys,
|
||||||
peer_access: true,
|
peer_access: true,
|
||||||
create_calendar: false,
|
create_calendar: false,
|
||||||
password: '',
|
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() {
|
function Peers() {
|
||||||
const { domain = 'cell' } = useConfig();
|
const { domain = 'cell' } = useConfig();
|
||||||
const SERVICES = [
|
const [installedServiceKeys, setInstalledServiceKeys] = useState(['webdav']);
|
||||||
{ key: 'calendar', label: 'Calendar', domain: `calendar.${domain}` },
|
const SERVICES = ALL_SERVICES
|
||||||
{ key: 'files', label: 'Files', domain: `files.${domain}` },
|
.filter(s => installedServiceKeys.includes(s.key))
|
||||||
{ key: 'mail', label: 'Webmail', domain: `mail.${domain}` },
|
.map(s => ({ ...s, domain: `${s.key}.${domain}` }));
|
||||||
{ key: 'webdav', label: 'WebDAV', domain: `webdav.${domain}` },
|
|
||||||
];
|
|
||||||
|
|
||||||
const [peers, setPeers] = useState([]);
|
const [peers, setPeers] = useState([]);
|
||||||
const [connectedCells, setConnectedCells] = useState([]);
|
const [connectedCells, setConnectedCells] = useState([]);
|
||||||
@@ -69,7 +75,7 @@ function Peers() {
|
|||||||
const [showEditModal, setShowEditModal] = useState(false);
|
const [showEditModal, setShowEditModal] = useState(false);
|
||||||
const [showViewModal, setShowViewModal] = useState(false);
|
const [showViewModal, setShowViewModal] = useState(false);
|
||||||
const [selectedPeer, setSelectedPeer] = useState(null);
|
const [selectedPeer, setSelectedPeer] = useState(null);
|
||||||
const [formData, setFormData] = useState(emptyForm());
|
const [formData, setFormData] = useState(emptyForm(installedServiceKeys));
|
||||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||||
const [peerConfig, setPeerConfig] = useState('');
|
const [peerConfig, setPeerConfig] = useState('');
|
||||||
const [qrCodeDataUrl, setQrCodeDataUrl] = useState('');
|
const [qrCodeDataUrl, setQrCodeDataUrl] = useState('');
|
||||||
@@ -81,6 +87,13 @@ function Peers() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchPeers();
|
fetchPeers();
|
||||||
cellLinkAPI.listConnections().then(r => setConnectedCells(r.data || [])).catch(() => {});
|
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') => {
|
const showToast = (msg, type = 'success') => {
|
||||||
@@ -212,7 +225,7 @@ PersistentKeepalive = ${peer.persistent_keepalive || 25}`;
|
|||||||
? Object.entries(provisioned).filter(([, v]) => v).map(([k]) => k).join(', ')
|
? Object.entries(provisioned).filter(([, v]) => v).map(([k]) => k).join(', ')
|
||||||
: '';
|
: '';
|
||||||
setShowAddModal(false);
|
setShowAddModal(false);
|
||||||
setFormData(emptyForm());
|
setFormData(emptyForm(installedServiceKeys));
|
||||||
setErrors({});
|
setErrors({});
|
||||||
fetchPeers();
|
fetchPeers();
|
||||||
showToast(
|
showToast(
|
||||||
@@ -471,7 +484,7 @@ PersistentKeepalive = ${peer.persistent_keepalive || 25}`;
|
|||||||
<h1 className="text-2xl font-bold text-gray-900">Peers</h1>
|
<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>
|
<p className="mt-1 text-gray-600">Manage VPN peer connections and access policies</p>
|
||||||
</div>
|
</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">
|
className="btn btn-primary flex items-center">
|
||||||
<Plus className="h-4 w-4 mr-2" />Add Peer
|
<Plus className="h-4 w-4 mr-2" />Add Peer
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user