Fix ensure_postup_dnat to strip-and-replace all DNAT rules idempotently

_get_dnat_container_ips() used a concatenating docker inspect format that
produced "invalid IP" when containers had multiple network attachments.
The old ensure_postup_dnat appended rather than replacing, so each update
call added a broken duplicate set of rules causing iptables to fail on
startup and tear down wg0 entirely.

Fix _get_dnat_container_ips to use a space separator in the format string
and validate each token as a real IP before accepting it.

Rewrite ensure_postup_dnat with _is_dnat_rule() helper: strips every
managed DNAT/FORWARD rule (any IP, port 53/80) on semicolon-split and
appends a single correct set — fully idempotent regardless of prior state.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-04 06:54:20 -04:00
parent d36fe88e16
commit 28a193e430
2 changed files with 64 additions and 34 deletions
+57 -29
View File
@@ -115,15 +115,23 @@ class WireGuardManager(BaseServiceManager):
def _get_dnat_container_ips(self) -> tuple:
"""Return (dns_ip, caddy_ip) by inspecting running containers."""
import re as _re
_ip_re = _re.compile(r'^\d{1,3}(?:\.\d{1,3}){3}$')
def _inspect(name, fallback):
try:
r = subprocess.run(
['docker', 'inspect', '--format',
'{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}', name],
'{{range .NetworkSettings.Networks}}{{.IPAddress}} {{end}}', name],
capture_output=True, text=True, check=False)
return r.stdout.strip() or fallback
# Network range may return multiple IPs — pick the first valid one.
for candidate in r.stdout.split():
candidate = candidate.strip()
if candidate and _ip_re.match(candidate):
return candidate
except Exception:
return fallback
pass
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:
@@ -176,11 +184,28 @@ class WireGuardManager(BaseServiceManager):
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.
@staticmethod
def _is_dnat_rule(token: str) -> bool:
"""Return True if this semicolon-split token is a DNAT/FORWARD rule managed by us."""
t = token.strip()
if not t.startswith('iptables'):
return False
# PREROUTING DNAT on ports 53 or 80
if 'PREROUTING' in t and 'DNAT' in t and ('--dport 53' in t or '--dport 80' in t):
return True
# FORWARD accept to eth0 for ports 53 or 80 (service traffic forwarding)
if 'FORWARD' in t and '-o eth0' in t and ('--dport 53' in t or '--dport 80' in t):
return True
return False
Called at startup so rules persist across WireGuard interface restarts.
Returns True if the file was changed (caller should reload WG config).
def ensure_postup_dnat(self) -> bool:
"""Rewrite wg0.conf PostUp/PostDown with the correct DNS/service DNAT rules.
Strips ALL managed DNAT and FORWARD rules (any IP, any port 53/80) and
replaces them with a single correct set for the current container IPs.
This is fully idempotent — stale IPs and duplicates are always cleaned.
Returns True if the file was changed.
"""
cf = self._config_file()
if not os.path.exists(cf):
@@ -189,44 +214,47 @@ class WireGuardManager(BaseServiceManager):
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'
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'
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
if line.startswith('PostUp = ') or line.startswith('PostDown = '):
prefix, _, rest = line.partition(' = ')
# Split on ';', strip managed DNAT rules, rejoin
tokens = [t for t in rest.split(';') if not self._is_dnat_rule(t)]
clean = '; '.join(t.strip() for t in tokens if t.strip())
new_rules = dnat_up if line.startswith('PostUp') else dnat_down
new_line = f'{prefix} = {clean}; {new_rules}'
if new_line != line:
updated.append(new_line)
changed = True
else:
updated.append(line)
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 '
logger.info(f'ensure_postup_dnat: rewrote wg0.conf DNAT rules '
f'(dns={dns_ip}, caddy={caddy_ip})')
return changed