From f1666ba19c861030cb5d45b0911cd5f5f44cc927 Mon Sep 17 00:00:00 2001 From: Dmitrii Iurco Date: Sat, 2 May 2026 04:07:10 -0400 Subject: [PATCH] fix: embed DNAT rules in wg0.conf PostUp for persistence + fix dns_ip in server config DNAT rules applied via docker exec are lost whenever wg-easy reloads the WireGuard interface (PostDown flushes the nat table then PostUp only re-adds static rules). Fix: embed DNS (port 53) and service (port 80) DNAT rules directly in wg0.conf PostUp/PostDown so they reapply on every interface restart. ensure_postup_dnat() patches existing configs on startup. get_server_config() now returns the WG server IP (e.g. 10.0.0.1) for dns_ip instead of the cell-dns container IP (172.20.0.3). This makes the value consistent with what get_peer_config() writes into the .conf file, and fixes the stale hint text in Peers.jsx and WireGuard.jsx. UI: fallback dns_ip changed from 172.20.0.3 to 10.0.0.1; split-tunnel fallback drops the 172.20.0.0/16 stale range. Co-Authored-By: Claude Sonnet 4.6 --- api/app.py | 3 + api/wireguard_manager.py | 97 ++++++++++++++++++++++++++++++++- tests/test_wireguard_manager.py | 66 ++++++++++++++++++++++ webui/src/pages/Peers.jsx | 6 +- webui/src/pages/WireGuard.jsx | 6 +- 5 files changed, 169 insertions(+), 9 deletions(-) diff --git a/api/app.py b/api/app.py index e0b45cc..3a47d04 100644 --- a/api/app.py +++ b/api/app.py @@ -305,6 +305,9 @@ def _apply_startup_enforcement(): firewall_manager.apply_all_peer_rules(peers) firewall_manager.apply_all_cell_rules(cell_links) firewall_manager.ensure_cell_api_dnat() + # Embed DNAT rules in PostUp so they survive WireGuard interface restarts, + # then also apply them immediately for the current session. + wireguard_manager.ensure_postup_dnat() firewall_manager.ensure_dns_dnat() firewall_manager.ensure_service_dnat() # Restore any cell link WireGuard peers that were lost from wg0.conf diff --git a/api/wireguard_manager.py b/api/wireguard_manager.py index 17ddcba..edee88b 100644 --- a/api/wireguard_manager.py +++ b/api/wireguard_manager.py @@ -113,6 +113,19 @@ class WireGuardManager(BaseServiceManager): """Return server config (alias for generate_config, returns dict for API compat).""" return {'config': self.generate_config(interface, port)} + def _get_dnat_container_ips(self) -> tuple: + """Return (dns_ip, caddy_ip) by inspecting running containers.""" + def _inspect(name, fallback): + try: + r = subprocess.run( + ['docker', 'inspect', '--format', + '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}', name], + capture_output=True, text=True, check=False) + return r.stdout.strip() or fallback + except Exception: + return fallback + return _inspect('cell-dns', '172.20.0.3'), _inspect('cell-caddy', '172.20.0.2') + def generate_config(self, interface: str = 'wg0', port: int = DEFAULT_PORT) -> str: """Return a WireGuard [Interface] config string for the server.""" import ipaddress @@ -129,6 +142,23 @@ class WireGuardManager(BaseServiceManager): if ext_ip else '' ) cfg_port = self._get_configured_port() if os.path.exists(self._config_file()) else port + dns_ip, caddy_ip = self._get_dnat_container_ips() + dnat_up = ( + f'iptables -t nat -A PREROUTING -i %i -p udp --dport 53 -j DNAT --to-destination {dns_ip}:53; ' + f'iptables -t nat -A PREROUTING -i %i -p tcp --dport 53 -j DNAT --to-destination {dns_ip}:53; ' + f'iptables -t nat -A PREROUTING -i %i -p tcp --dport 80 -j DNAT --to-destination {caddy_ip}:80; ' + f'iptables -I FORWARD -i %i -o eth0 -p tcp --dport 80 -j ACCEPT; ' + f'iptables -I FORWARD -i %i -o eth0 -p udp --dport 53 -j ACCEPT; ' + f'iptables -I FORWARD -i %i -o eth0 -p tcp --dport 53 -j ACCEPT' + ) + dnat_down = ( + f'iptables -t nat -D PREROUTING -i %i -p udp --dport 53 -j DNAT --to-destination {dns_ip}:53 2>/dev/null || true; ' + f'iptables -t nat -D PREROUTING -i %i -p tcp --dport 53 -j DNAT --to-destination {dns_ip}:53 2>/dev/null || true; ' + f'iptables -t nat -D PREROUTING -i %i -p tcp --dport 80 -j DNAT --to-destination {caddy_ip}:80 2>/dev/null || true; ' + f'iptables -D FORWARD -i %i -o eth0 -p tcp --dport 80 -j ACCEPT 2>/dev/null || true; ' + f'iptables -D FORWARD -i %i -o eth0 -p udp --dport 53 -j ACCEPT 2>/dev/null || true; ' + f'iptables -D FORWARD -i %i -o eth0 -p tcp --dport 53 -j ACCEPT 2>/dev/null || true' + ) return ( f'[Interface]\n' f'PrivateKey = {keys["private_key"]}\n' @@ -137,13 +167,69 @@ class WireGuardManager(BaseServiceManager): f'PostUp = iptables -A FORWARD -i %i -j DROP; ' f'iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE; ' f'{hairpin}' + f'{dnat_up}; ' f'sysctl -q net.ipv4.conf.all.rp_filter=0 || true\n' - f'PostDown = iptables -D FORWARD -i %i -j DROP; ' - f'iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE; ' + f'PostDown = iptables -D FORWARD -i %i -j DROP 2>/dev/null || true; ' + f'iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE 2>/dev/null || true; ' f'{hairpin_down}' + f'{dnat_down}; ' f'sysctl -q net.ipv4.conf.all.rp_filter=1 || true\n' ) + def ensure_postup_dnat(self) -> bool: + """Update wg0.conf PostUp/PostDown to include DNS (53) and service (80) DNAT rules. + + Called at startup so rules persist across WireGuard interface restarts. + Returns True if the file was changed (caller should reload WG config). + """ + cf = self._config_file() + if not os.path.exists(cf): + return False + with open(cf) as f: + content = f.read() + + dns_ip, caddy_ip = self._get_dnat_container_ips() + dnat_marker = f'--dport 53 -j DNAT --to-destination {dns_ip}:53' + if dnat_marker in content: + return False + + dnat_up = ( + f'iptables -t nat -A PREROUTING -i %i -p udp --dport 53 -j DNAT --to-destination {dns_ip}:53; ' + f'iptables -t nat -A PREROUTING -i %i -p tcp --dport 53 -j DNAT --to-destination {dns_ip}:53; ' + f'iptables -t nat -A PREROUTING -i %i -p tcp --dport 80 -j DNAT --to-destination {caddy_ip}:80; ' + f'iptables -I FORWARD -i %i -o eth0 -p tcp --dport 80 -j ACCEPT; ' + f'iptables -I FORWARD -i %i -o eth0 -p udp --dport 53 -j ACCEPT; ' + f'iptables -I FORWARD -i %i -o eth0 -p tcp --dport 53 -j ACCEPT' + ) + dnat_down = ( + f'iptables -t nat -D PREROUTING -i %i -p udp --dport 53 -j DNAT --to-destination {dns_ip}:53 2>/dev/null || true; ' + f'iptables -t nat -D PREROUTING -i %i -p tcp --dport 53 -j DNAT --to-destination {dns_ip}:53 2>/dev/null || true; ' + f'iptables -t nat -D PREROUTING -i %i -p tcp --dport 80 -j DNAT --to-destination {caddy_ip}:80 2>/dev/null || true; ' + f'iptables -D FORWARD -i %i -o eth0 -p tcp --dport 80 -j ACCEPT 2>/dev/null || true; ' + f'iptables -D FORWARD -i %i -o eth0 -p udp --dport 53 -j ACCEPT 2>/dev/null || true; ' + f'iptables -D FORWARD -i %i -o eth0 -p tcp --dport 53 -j ACCEPT 2>/dev/null || true' + ) + + lines = content.split('\n') + updated = [] + changed = False + for line in lines: + if line.startswith('PostUp = ') and dnat_marker not in line: + updated.append(line + '; ' + dnat_up) + changed = True + elif line.startswith('PostDown = ') and '--dport 53 -j DNAT' not in line: + updated.append(line + '; ' + dnat_down) + changed = True + else: + updated.append(line) + + if changed: + with open(cf, 'w') as f: + f.write('\n'.join(updated)) + logger.info(f'ensure_postup_dnat: updated wg0.conf with DNAT rules ' + f'(dns={dns_ip}, caddy={caddy_ip})') + return changed + def _config_file(self) -> str: # linuxserver/wireguard stores configs in wg_confs/ wg_confs = os.path.join(self.wireguard_dir, 'wg_confs') @@ -841,17 +927,22 @@ class WireGuardManager(BaseServiceManager): def get_server_config(self) -> Dict[str, Any]: """Return server public key, external IP, endpoint, port, and tunnel info.""" + import ipaddress as _ipaddress keys = self.get_keys() external_ip = self.get_external_ip() port = self._get_configured_port() endpoint = f'{external_ip}:{port}' if external_ip else None + try: + dns_ip = str(_ipaddress.ip_interface(self._get_configured_address()).ip) + except Exception: + dns_ip = _resolve_peer_dns() return { 'public_key': keys['public_key'], 'external_ip': external_ip, 'endpoint': endpoint, 'port': port, 'port_open': None, - 'dns_ip': _resolve_peer_dns(), + 'dns_ip': dns_ip, 'split_tunnel_ips': self.get_split_tunnel_ips(), 'vpn_network': self._get_configured_network(), } diff --git a/tests/test_wireguard_manager.py b/tests/test_wireguard_manager.py index 9b82721..617a7b2 100644 --- a/tests/test_wireguard_manager.py +++ b/tests/test_wireguard_manager.py @@ -434,6 +434,8 @@ class TestWireGuardConfigReads(unittest.TestCase): 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) + # dns_ip must be the WG server IP, not the container IP + self.assertEqual(cfg['dns_ip'], '10.2.0.1') self.assertIn('split_tunnel_ips', cfg) self.assertIn('10.2.0.0/24', cfg['split_tunnel_ips']) @@ -486,6 +488,70 @@ class TestWireGuardSysctlAndPortCheck(unittest.TestCase): self.assertIn('FORWARD -i %i -j DROP', cfg) self.assertNotIn('FORWARD -i %i -j ACCEPT', cfg) + def test_generate_config_includes_dns_dnat_in_postup(self): + cfg = self.wg.generate_config() + self.assertIn('--dport 53 -j DNAT', cfg) + self.assertIn('--dport 80 -j DNAT', cfg) + + def test_generate_config_postdown_removes_dnat(self): + cfg = self.wg.generate_config() + postdown_line = [l for l in cfg.splitlines() if l.startswith('PostDown')][0] + self.assertIn('--dport 53 -j DNAT', postdown_line) + self.assertIn('--dport 80 -j DNAT', postdown_line) + + # ── ensure_postup_dnat ──────────────────────────────────────────────────── + + def _write_wg_conf_postup(self, address='10.0.0.1/24', extra_postup=''): + import os + wg_dir = os.path.join(self.test_dir, 'wireguard', 'wg_confs') + os.makedirs(wg_dir, exist_ok=True) + conf_path = os.path.join(wg_dir, 'wg0.conf') + postup = ( + 'iptables -A FORWARD -i %i -j DROP; ' + 'iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE' + ) + if extra_postup: + postup += f'; {extra_postup}' + postdown = ( + 'iptables -D FORWARD -i %i -j DROP; ' + 'iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE' + ) + content = ( + '[Interface]\n' + f'Address = {address}\n' + f'PostUp = {postup}\n' + f'PostDown = {postdown}\n' + ) + with open(conf_path, 'w') as f: + f.write(content) + return conf_path + + @patch('subprocess.run') + def test_ensure_postup_dnat_adds_rules_when_missing(self, mock_run): + mock_run.return_value.returncode = 0 + mock_run.return_value.stdout = '172.20.0.3' + self._write_wg_conf_postup() + changed = self.wg.ensure_postup_dnat() + self.assertTrue(changed) + with open(self.wg._config_file()) as f: + content = f.read() + self.assertIn('--dport 53 -j DNAT', content) + self.assertIn('--dport 80 -j DNAT', content) + + @patch('subprocess.run') + def test_ensure_postup_dnat_idempotent_when_rules_present(self, mock_run): + mock_run.return_value.returncode = 0 + mock_run.return_value.stdout = '172.20.0.3' + self._write_wg_conf_postup( + extra_postup='iptables -t nat -A PREROUTING -i %i -p udp --dport 53 -j DNAT --to-destination 172.20.0.3:53' + ) + changed = self.wg.ensure_postup_dnat() + self.assertFalse(changed) + + def test_ensure_postup_dnat_returns_false_when_no_conf(self): + changed = self.wg.ensure_postup_dnat() + self.assertFalse(changed) + # ── check_port_open ─────────────────────────────────────────────────────── @patch('subprocess.run') diff --git a/webui/src/pages/Peers.jsx b/webui/src/pages/Peers.jsx index bd0f1b5..1de73dc 100644 --- a/webui/src/pages/Peers.jsx +++ b/webui/src/pages/Peers.jsx @@ -133,9 +133,9 @@ function Peers() { 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 splitTunnelIPs = sc?.split_tunnel_ips || `10.0.0.0/24, 172.20.0.0/16`; + const splitTunnelIPs = sc?.split_tunnel_ips || `10.0.0.0/24`; const allowedIPs = peer.internet_access !== false ? FULL_TUNNEL_IPS : splitTunnelIPs; - const dnsIp = sc?.dns_ip || '172.20.0.3'; + const dnsIp = sc?.dns_ip || '10.0.0.1'; return `[Interface] PrivateKey = ${privateKey} Address = ${address} @@ -767,7 +767,7 @@ PersistentKeepalive = ${peer.persistent_keepalive || 25}`; )}

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

diff --git a/webui/src/pages/WireGuard.jsx b/webui/src/pages/WireGuard.jsx index 3d12589..3e6574f 100644 --- a/webui/src/pages/WireGuard.jsx +++ b/webui/src/pages/WireGuard.jsx @@ -227,9 +227,9 @@ function WireGuard() { 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 splitTunnelIPs = serverConfig?.split_tunnel_ips || '10.0.0.0/24, 172.20.0.0/16'; + const splitTunnelIPs = serverConfig?.split_tunnel_ips || '10.0.0.0/24'; const allowedIPs = mode === 'full' ? FULL_TUNNEL_IPS : splitTunnelIPs; - const dnsIp = serverConfig?.dns_ip || '172.20.0.3'; + const dnsIp = serverConfig?.dns_ip || '10.0.0.1'; return `[Interface] PrivateKey = ${privateKey} @@ -667,7 +667,7 @@ PersistentKeepalive = ${peer.persistent_keepalive || 25}`;

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