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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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}`;
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-gray-500 mt-2">
|
||||
DNS is set to <code className="bg-gray-100 px-1 rounded">{serverConf?.dns_ip || '172.20.0.3'}</code> (PIC CoreDNS) — required to resolve <code className="bg-gray-100 px-1 rounded">.{domain}</code> domains.
|
||||
DNS is set to <code className="bg-gray-100 px-1 rounded">{serverConf?.dns_ip || '10.0.0.1'}</code> (PIC DNS) — required to resolve <code className="bg-gray-100 px-1 rounded">.{domain}</code> domains.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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}`;
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mb-3">
|
||||
{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.'}
|
||||
</p>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user