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:
2026-05-02 04:07:10 -04:00
parent 9a800e3b6b
commit f1666ba19c
5 changed files with 169 additions and 9 deletions
+3
View File
@@ -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
+94 -3
View File
@@ -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(),
}