From 3912452fd6e46dc997417d10ab04cf894343e737 Mon Sep 17 00:00:00 2001 From: Dmitrii Iurco Date: Tue, 21 Apr 2026 07:47:19 -0400 Subject: [PATCH] fix: wireguard port/subnet/domain propagate to peer configs and new peer IPs Backend: - wireguard_manager: _get_configured_port/address/network() read from wg0.conf instead of module-level constants; get_split_tunnel_ips() derives VPN network from configured Address; get_server_config() returns configured port, dns_ip, split_tunnel_ips, vpn_network - add_peer() and get_peer_config() use configured port (not hardcoded 51820) - _next_peer_ip() derives subnet from wireguard_manager._get_configured_address() so new peers are allocated IPs from the correct VPN range after address change - refresh-ip and check-port API endpoints return configured port, not 51820 - PUT /api/config: when wireguard port/address changes, all peers are marked config_needs_reinstall so users know to re-download tunnel configs - get_peer_config endpoint: uses configured split tunnel IPs (not hardcoded) Frontend: - Peers.jsx: SERVICES domains use live domain from ConfigContext; generateConfig() uses serverConf.dns_ip and serverConf.split_tunnel_ips; vpn_network shown in peer-access description; DNS hint uses live domain; server config loaded at mount time so it is available without re-fetching on every peer action; handleUpdatePeer uses /32 for server-side AllowedIPs (was incorrectly using full/split tunnel CIDRs which the backend rejects) - WireGuard.jsx: generateWireGuardConfig() uses serverConfig.dns_ip, split_tunnel_ips from server-config API; split-tunnel description shows live IPs Tests: 9 new tests in TestWireGuardConfigReads verify all config reads Co-Authored-By: Claude Sonnet 4.6 --- api/app.py | 28 +++++++---- api/wireguard_manager.py | 60 +++++++++++++++++++--- tests/test_wireguard_manager.py | 88 +++++++++++++++++++++++++++++++++ webui/src/pages/Peers.jsx | 51 +++++++++++-------- webui/src/pages/WireGuard.jsx | 12 ++--- 5 files changed, 198 insertions(+), 41 deletions(-) diff --git a/api/app.py b/api/app.py index 7059c52..6782157 100644 --- a/api/app.py +++ b/api/app.py @@ -434,6 +434,13 @@ def update_config(): 'service': service, 'config': config }) + # VPN port or subnet change → all peer client configs are stale + if service == 'wireguard' and ('port' in config or 'address' in config): + for p in peer_registry.list_peers(): + peer_registry.update_peer(p['peer'], {'config_needs_reinstall': True}) + n = len(peer_registry.list_peers()) + if n: + all_warnings.append(f'WireGuard endpoint changed — {n} peer(s) must reinstall VPN config') # Apply cell identity domain to network and email services if identity_updates.get('domain'): @@ -1028,7 +1035,7 @@ def get_peer_config(): allowed_ips = data.get('allowed_ips') or None if not allowed_ips and registered: internet_access = registered.get('internet_access', True) - allowed_ips = wireguard_manager.FULL_TUNNEL_IPS if internet_access else wireguard_manager.SPLIT_TUNNEL_IPS + allowed_ips = wireguard_manager.FULL_TUNNEL_IPS if internet_access else wireguard_manager.get_split_tunnel_ips() result = wireguard_manager.get_peer_config( peer_name=peer_name, @@ -1055,10 +1062,11 @@ def get_server_config(): def refresh_external_ip(): try: ip = wireguard_manager.get_external_ip(force_refresh=True) + port = wireguard_manager._get_configured_port() return jsonify({ 'external_ip': ip, - 'port': 51820, - 'endpoint': f'{ip}:51820' if ip else None, + 'port': port, + 'endpoint': f'{ip}:{port}' if ip else None, }) except Exception as e: logger.error(f"Error refreshing external IP: {e}") @@ -1079,7 +1087,7 @@ def apply_wireguard_enforcement(): def check_wireguard_port(): try: port_open = wireguard_manager.check_port_open() - return jsonify({'port_open': port_open, 'port': 51820}) + return jsonify({'port_open': port_open, 'port': wireguard_manager._get_configured_port()}) except Exception as e: return jsonify({"error": str(e)}), 500 @@ -1095,17 +1103,19 @@ def get_peers(): return jsonify({"error": str(e)}), 500 def _next_peer_ip() -> str: - """Auto-assign the next free 10.0.0.x address (starts at .2, skips .1 = server).""" + """Auto-assign the next free host address from the configured VPN subnet.""" import ipaddress + server_addr = wireguard_manager._get_configured_address() # e.g. '10.0.0.1/24' + network = ipaddress.ip_network(server_addr, strict=False) + server_ip = str(ipaddress.ip_interface(server_addr).ip) used = {p.get('ip', '').split('/')[0] for p in peer_registry.list_peers()} - network = ipaddress.ip_network('10.0.0.0/24') for host in network.hosts(): ip = str(host) - if ip == '10.0.0.1': - continue # server address + if ip == server_ip: + continue if ip not in used: return ip - raise ValueError('No free IPs left in 10.0.0.0/24') + raise ValueError(f'No free IPs left in {network}') @app.route('/api/peers', methods=['POST']) diff --git a/api/wireguard_manager.py b/api/wireguard_manager.py index 4dcd80b..7a7d3f7 100644 --- a/api/wireguard_manager.py +++ b/api/wireguard_manager.py @@ -158,6 +158,49 @@ class WireGuardManager(BaseServiceManager): f.write(content) self._syncconf() + # ── Config value readers (always read from wg0.conf, never hardcode) ───── + + def _read_iface_field(self, key: str) -> Optional[str]: + """Return the value of a field from the [Interface] section of wg0.conf.""" + cf = self._config_file() + if not os.path.exists(cf): + return None + with open(cf) as f: + in_iface = False + for line in f: + stripped = line.strip() + if stripped == '[Interface]': + in_iface = True + elif stripped.startswith('[') and stripped.endswith(']'): + in_iface = False + elif in_iface and '=' in stripped: + k, _, v = stripped.partition('=') + if k.strip() == key: + return v.strip() + return None + + def _get_configured_port(self) -> int: + val = self._read_iface_field('ListenPort') + try: + return int(val) if val else DEFAULT_PORT + except (ValueError, TypeError): + return DEFAULT_PORT + + def _get_configured_address(self) -> str: + return self._read_iface_field('Address') or SERVER_ADDRESS + + def _get_configured_network(self) -> str: + import ipaddress + addr = self._get_configured_address() + try: + return str(ipaddress.ip_network(addr, strict=False)) + except Exception: + return SERVER_NETWORK + + def get_split_tunnel_ips(self) -> str: + """Return split-tunnel AllowedIPs: VPN subnet + Docker bridge.""" + return f'{self._get_configured_network()}, 172.20.0.0/16' + def apply_config(self, config: Dict[str, Any]) -> Dict[str, Any]: """Update wg0.conf interface fields and restart cell-wireguard.""" restarted = [] @@ -304,7 +347,7 @@ class WireGuardManager(BaseServiceManager): f'PersistentKeepalive = {persistent_keepalive}\n' ) if endpoint_ip: - peer_block += f'Endpoint = {endpoint_ip}:{DEFAULT_PORT}\n' + peer_block += f'Endpoint = {endpoint_ip}:{self._get_configured_port()}\n' self._write_config(content + peer_block) return True except Exception as e: @@ -376,7 +419,7 @@ class WireGuardManager(BaseServiceManager): self._write_config('\n'.join(new_lines)) return True - SPLIT_TUNNEL_IPS = '10.0.0.0/24, 172.20.0.0/16' + SPLIT_TUNNEL_IPS = '10.0.0.0/24, 172.20.0.0/16' # legacy fallback; use get_split_tunnel_ips() FULL_TUNNEL_IPS = '0.0.0.0/0, ::/0' def get_peer_config(self, peer_name: str, peer_ip: str, @@ -388,7 +431,8 @@ class WireGuardManager(BaseServiceManager): allowed_ips = self.FULL_TUNNEL_IPS server_keys = self.get_keys() peer_dns = _resolve_peer_dns() - endpoint = server_endpoint if ':' in server_endpoint else f'{server_endpoint}:{DEFAULT_PORT}' + port = self._get_configured_port() + endpoint = server_endpoint if ':' in server_endpoint else f'{server_endpoint}:{port}' addr = peer_ip if '/' in peer_ip else f'{peer_ip}/32' return ( f'[Interface]\n' @@ -468,16 +512,20 @@ class WireGuardManager(BaseServiceManager): return False def get_server_config(self) -> Dict[str, Any]: - """Return server public key, external IP, endpoint, and port status.""" + """Return server public key, external IP, endpoint, port, and tunnel info.""" keys = self.get_keys() external_ip = self.get_external_ip() - endpoint = f'{external_ip}:{DEFAULT_PORT}' if external_ip else None + port = self._get_configured_port() + endpoint = f'{external_ip}:{port}' if external_ip else None return { 'public_key': keys['public_key'], 'external_ip': external_ip, 'endpoint': endpoint, - 'port': DEFAULT_PORT, + 'port': port, 'port_open': None, + 'dns_ip': _resolve_peer_dns(), + 'split_tunnel_ips': self.get_split_tunnel_ips(), + 'vpn_network': self._get_configured_network(), } def get_peer_status(self, public_key: str) -> Dict[str, Any]: diff --git a/tests/test_wireguard_manager.py b/tests/test_wireguard_manager.py index 15301ae..f9ee222 100644 --- a/tests/test_wireguard_manager.py +++ b/tests/test_wireguard_manager.py @@ -322,5 +322,93 @@ PersistentKeepalive = 30 success = self.wg_manager.update_peer_ip('non_existent_key', '10.0.0.9/32') self.assertFalse(success) + +class TestWireGuardConfigReads(unittest.TestCase): + """Test that port/address/network are read from wg0.conf, not hardcoded.""" + + 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(self.data_dir, exist_ok=True) + os.makedirs(self.config_dir, exist_ok=True) + patcher = patch.object(WireGuardManager, '_syncconf', return_value=None) + self.mock_sync = patcher.start() + self.addCleanup(patcher.stop) + self.wg = WireGuardManager(self.data_dir, self.config_dir) + + def tearDown(self): + shutil.rmtree(self.test_dir) + + def _write_wg_conf(self, port=51820, address='10.0.0.1/24', extra=''): + conf = ( + f'[Interface]\n' + f'PrivateKey = dummykey\n' + f'Address = {address}\n' + f'ListenPort = {port}\n' + f'{extra}' + ) + cf = self.wg._config_file() + os.makedirs(os.path.dirname(cf), exist_ok=True) + with open(cf, 'w') as f: + f.write(conf) + + def test_get_configured_port_reads_from_wg_conf(self): + self._write_wg_conf(port=54321) + self.assertEqual(self.wg._get_configured_port(), 54321) + + def test_get_configured_port_fallback_when_no_file(self): + # No wg0.conf exists — fall back to DEFAULT_PORT + self.assertEqual(self.wg._get_configured_port(), 51820) + + def test_get_configured_address_reads_from_wg_conf(self): + self._write_wg_conf(address='10.1.0.1/24') + self.assertEqual(self.wg._get_configured_address(), '10.1.0.1/24') + + def test_get_configured_network_derives_from_address(self): + self._write_wg_conf(address='10.1.0.1/24') + self.assertEqual(self.wg._get_configured_network(), '10.1.0.0/24') + + def test_get_split_tunnel_ips_uses_configured_network(self): + self._write_wg_conf(address='10.1.0.1/24') + split = self.wg.get_split_tunnel_ips() + self.assertIn('10.1.0.0/24', split) + self.assertIn('172.20.0.0/16', split) + self.assertNotIn('10.0.0.0/24', split) + + def test_get_server_config_uses_configured_port(self): + self._write_wg_conf(port=54321) + with patch.object(self.wg, 'get_external_ip', return_value='1.2.3.4'): + cfg = self.wg.get_server_config() + self.assertEqual(cfg['port'], 54321) + self.assertIn(':54321', cfg['endpoint']) + + def test_get_server_config_includes_dns_and_split_tunnel(self): + self._write_wg_conf(address='10.2.0.1/24') + with patch.object(self.wg, 'get_external_ip', return_value='1.2.3.4'): + cfg = self.wg.get_server_config() + self.assertIn('dns_ip', cfg) + self.assertIn('split_tunnel_ips', cfg) + self.assertIn('10.2.0.0/24', cfg['split_tunnel_ips']) + + def test_get_peer_config_uses_configured_port_in_endpoint(self): + self._write_wg_conf(port=54321) + result = self.wg.get_peer_config( + peer_name='alice', + peer_ip='10.0.0.2', + peer_private_key='privkeyalice=', + server_endpoint='5.6.7.8', + ) + self.assertIn(':54321', result) + self.assertNotIn(':51820', result) + + def test_add_peer_uses_configured_port_in_endpoint(self): + self._write_wg_conf(port=54321) + self.wg.add_peer('alice', 'pubkeyalice=', '5.6.7.8', '10.0.0.2/32') + content = self.wg._read_config() + self.assertIn('Endpoint = 5.6.7.8:54321', content) + self.assertNotIn(':51820', content) + + if __name__ == '__main__': unittest.main() \ No newline at end of file diff --git a/webui/src/pages/Peers.jsx b/webui/src/pages/Peers.jsx index 9b6e55a..2330f2e 100644 --- a/webui/src/pages/Peers.jsx +++ b/webui/src/pages/Peers.jsx @@ -1,18 +1,10 @@ import { useState, useEffect } from 'react'; import { Plus, Trash2, Edit, Eye, Shield, Copy, Download, Key, AlertTriangle, CheckCircle, Globe, Lock, Users, Server } from 'lucide-react'; import { peerAPI, wireguardAPI } from '../services/api'; +import { useConfig } from '../contexts/ConfigContext'; import QRCode from 'qrcode'; -const CELL_DNS = '172.20.0.3'; const FULL_TUNNEL_IPS = '0.0.0.0/0, ::/0'; -const SPLIT_TUNNEL_IPS = '10.0.0.0/24, 172.20.0.0/16'; - -const SERVICES = [ - { key: 'calendar', label: 'Calendar', domain: 'calendar.cell' }, - { key: 'files', label: 'Files', domain: 'files.cell' }, - { key: 'mail', label: 'Webmail', domain: 'mail.cell' }, - { key: 'webdav', label: 'WebDAV', domain: 'webdav.cell' }, -]; const emptyForm = () => ({ name: '', @@ -53,7 +45,16 @@ function Toggle({ checked, onChange, label, description }) { } 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 [peers, setPeers] = useState([]); + const [serverConf, setServerConf] = useState(null); const [isLoading, setIsLoading] = useState(true); const [showAddModal, setShowAddModal] = useState(false); const [showEditModal, setShowEditModal] = useState(false); @@ -77,9 +78,10 @@ function Peers() { const fetchPeers = async () => { try { - const [regResp, statusResp] = await Promise.all([ + const [regResp, statusResp, scResp] = await Promise.all([ peerAPI.getPeers(), wireguardAPI.getPeerStatuses().catch(() => ({ data: {} })), + fetch('/api/wireguard/server-config').then(r => r.ok ? r.json() : null).catch(() => null), ]); const regPeers = regResp.data || []; const statusMap = statusResp.data || {}; @@ -93,6 +95,7 @@ function Peers() { transfer_tx: statusMap[p.public_key]?.transfer_tx ?? 0, })); setPeers(merged); + if (scResp) setServerConf(scResp); } catch (err) { console.error('Failed to fetch peers:', err); } finally { @@ -101,23 +104,30 @@ function Peers() { }; const getServerConfig = async () => { + if (serverConf) return serverConf; try { const r = await fetch('/api/wireguard/server-config'); - if (r.ok) return await r.json(); + if (r.ok) { + const sc = await r.json(); + setServerConf(sc); + return sc; + } } catch {} return { public_key: 'SERVER_PUBLIC_KEY_PLACEHOLDER', endpoint: 'YOUR_SERVER_IP:51820' }; }; - const generateConfig = (peer, serverConf) => { + const generateConfig = (peer, sc) => { const privateKey = peer.private_key || 'YOUR_PRIVATE_KEY_HERE'; - const serverPubKey = peer.server_public_key || serverConf?.public_key || 'SERVER_PUBLIC_KEY_PLACEHOLDER'; - const endpoint = peer.server_endpoint || serverConf?.endpoint || 'YOUR_SERVER_IP:51820'; + const serverPubKey = peer.server_public_key || sc?.public_key || 'SERVER_PUBLIC_KEY_PLACEHOLDER'; + const endpoint = peer.server_endpoint || sc?.endpoint || 'YOUR_SERVER_IP:51820'; const address = peer.ip?.includes('/') ? peer.ip : `${peer.ip}/32`; - const allowedIPs = peer.internet_access !== false ? FULL_TUNNEL_IPS : SPLIT_TUNNEL_IPS; + const splitTunnelIPs = sc?.split_tunnel_ips || `10.0.0.0/24, 172.20.0.0/16`; + const allowedIPs = peer.internet_access !== false ? FULL_TUNNEL_IPS : splitTunnelIPs; + const dnsIp = sc?.dns_ip || '172.20.0.3'; return `[Interface] PrivateKey = ${privateKey} Address = ${address} -DNS = ${CELL_DNS} +DNS = ${dnsIp} [Peer] PublicKey = ${serverPubKey} @@ -216,11 +226,12 @@ PersistentKeepalive = ${peer.persistent_keepalive || 25}`; }); const result = await r.json(); - // Also update WireGuard server-side AllowedIPs + // Server-side AllowedIPs for the peer must stay as /32 (host route only) + const existingIp = selectedPeer.ip?.includes('/') ? selectedPeer.ip : `${selectedPeer.ip}/32`; await wireguardAPI.addPeer({ name: selectedPeer.name, public_key: formData.public_key || selectedPeer.public_key, - allowed_ips: formData.internet_access ? FULL_TUNNEL_IPS : SPLIT_TUNNEL_IPS, + allowed_ips: existingIp, persistent_keepalive: formData.persistent_keepalive, }); @@ -373,7 +384,7 @@ PersistentKeepalive = ${peer.persistent_keepalive || 25}`; checked={data.peer_access !== false} onChange={v => onChange({ peer_access: v })} label="Allow peer-to-peer traffic" - description="This peer can communicate with other VPN peers (10.0.0.0/24)" + description={`This peer can communicate with other VPN peers (${serverConf?.vpn_network || 'VPN subnet'})`} /> @@ -675,7 +686,7 @@ PersistentKeepalive = ${peer.persistent_keepalive || 25}`; )}

- DNS is set to 172.20.0.3 (PIC CoreDNS) — required to resolve .cell domains. + DNS is set to {serverConf?.dns_ip || '172.20.0.3'} (PIC CoreDNS) — required to resolve .{domain} domains.

diff --git a/webui/src/pages/WireGuard.jsx b/webui/src/pages/WireGuard.jsx index bf894b6..eda5af6 100644 --- a/webui/src/pages/WireGuard.jsx +++ b/webui/src/pages/WireGuard.jsx @@ -181,21 +181,21 @@ function WireGuard() { return { public_key: '', endpoint: ':51820' }; }; - const CELL_DNS = '172.20.0.3'; - const SPLIT_TUNNEL_IPS = '10.0.0.0/24, 172.20.0.0/16'; const FULL_TUNNEL_IPS = '0.0.0.0/0, ::/0'; const generateWireGuardConfig = (peer, mode = tunnelMode) => { const serverPublicKey = peer.server_public_key || "SERVER_PUBLIC_KEY_PLACEHOLDER"; - const serverEndpoint = peer.server_endpoint || "YOUR_SERVER_IP:51820"; + const serverEndpoint = peer.server_endpoint || serverConfig?.endpoint || "YOUR_SERVER_IP:51820"; const privateKey = peer.private_key || 'YOUR_PRIVATE_KEY_HERE'; const peerAddress = peer.ip?.includes('/') ? peer.ip : `${peer.ip}/32`; - const allowedIPs = mode === 'full' ? FULL_TUNNEL_IPS : SPLIT_TUNNEL_IPS; + const splitTunnelIPs = serverConfig?.split_tunnel_ips || '10.0.0.0/24, 172.20.0.0/16'; + const allowedIPs = mode === 'full' ? FULL_TUNNEL_IPS : splitTunnelIPs; + const dnsIp = serverConfig?.dns_ip || '172.20.0.3'; return `[Interface] PrivateKey = ${privateKey} Address = ${peerAddress} -DNS = ${CELL_DNS} +DNS = ${dnsIp} [Peer] PublicKey = ${serverPublicKey} @@ -631,7 +631,7 @@ PersistentKeepalive = ${peer.persistent_keepalive || 25}`;

{tunnelMode === 'split' - ? 'Split tunnel: only cell services (10.0.0.0/24, 172.20.0.0/16) route through VPN — local network & internet traffic stay direct.' + ? `Split tunnel: only cell services (${serverConfig?.split_tunnel_ips || '10.0.0.0/24, 172.20.0.0/16'}) route through VPN — local network & internet traffic stay direct.` : 'Full tunnel: all traffic (internet + local) routes through VPN server.'}