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
+54 -26
View File
@@ -115,14 +115,22 @@ class WireGuardManager(BaseServiceManager):
def _get_dnat_container_ips(self) -> tuple: def _get_dnat_container_ips(self) -> tuple:
"""Return (dns_ip, caddy_ip) by inspecting running containers.""" """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): def _inspect(name, fallback):
try: try:
r = subprocess.run( r = subprocess.run(
['docker', 'inspect', '--format', ['docker', 'inspect', '--format',
'{{range .NetworkSettings.Networks}}{{.IPAddress}} {{end}}', name], '{{range .NetworkSettings.Networks}}{{.IPAddress}} {{end}}', name],
capture_output=True, text=True, check=False) 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: except Exception:
pass
return fallback return fallback
return _inspect('cell-dns', '172.20.0.3'), _inspect('cell-caddy', '172.20.0.2') return _inspect('cell-dns', '172.20.0.3'), _inspect('cell-caddy', '172.20.0.2')
@@ -176,11 +184,28 @@ class WireGuardManager(BaseServiceManager):
f'sysctl -q net.ipv4.conf.all.rp_filter=1 || true\n' f'sysctl -q net.ipv4.conf.all.rp_filter=1 || true\n'
) )
def ensure_postup_dnat(self) -> bool: @staticmethod
"""Update wg0.conf PostUp/PostDown to include DNS (53) and service (80) DNAT rules. 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. def ensure_postup_dnat(self) -> bool:
Returns True if the file was changed (caller should reload WG config). """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() cf = self._config_file()
if not os.path.exists(cf): if not os.path.exists(cf):
@@ -189,44 +214,47 @@ class WireGuardManager(BaseServiceManager):
content = f.read() content = f.read()
dns_ip, caddy_ip = self._get_dnat_container_ips() 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 = ( 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 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 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 -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 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 udp --dport 53 -j ACCEPT'
f'iptables -I FORWARD -i %i -o eth0 -p tcp --dport 53 -j ACCEPT' f'; iptables -I FORWARD -i %i -o eth0 -p tcp --dport 53 -j ACCEPT'
) )
dnat_down = ( 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 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 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 -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 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 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 -D FORWARD -i %i -o eth0 -p tcp --dport 53 -j ACCEPT 2>/dev/null || true'
) )
lines = content.split('\n') lines = content.split('\n')
updated = [] updated = []
changed = False changed = False
for line in lines: for line in lines:
if line.startswith('PostUp = ') and dnat_marker not in line: if line.startswith('PostUp = ') or line.startswith('PostDown = '):
updated.append(line + '; ' + dnat_up) prefix, _, rest = line.partition(' = ')
changed = True # Split on ';', strip managed DNAT rules, rejoin
elif line.startswith('PostDown = ') and '--dport 53 -j DNAT' not in line: tokens = [t for t in rest.split(';') if not self._is_dnat_rule(t)]
updated.append(line + '; ' + dnat_down) 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 changed = True
else: else:
updated.append(line) updated.append(line)
else:
updated.append(line)
if changed: if changed:
with open(cf, 'w') as f: with open(cf, 'w') as f:
f.write('\n'.join(updated)) 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})') f'(dns={dns_ip}, caddy={caddy_ip})')
return changed return changed
+7 -5
View File
@@ -565,11 +565,13 @@ class TestWireGuardSysctlAndPortCheck(unittest.TestCase):
def test_ensure_postup_dnat_idempotent_when_rules_present(self, mock_run): def test_ensure_postup_dnat_idempotent_when_rules_present(self, mock_run):
mock_run.return_value.returncode = 0 mock_run.return_value.returncode = 0
mock_run.return_value.stdout = '172.20.0.3' mock_run.return_value.stdout = '172.20.0.3'
self._write_wg_conf_postup( 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' # First call: writes all 6 DNAT rules
) first = self.wg.ensure_postup_dnat()
changed = self.wg.ensure_postup_dnat() self.assertTrue(first)
self.assertFalse(changed) # Second call: rules already correct, no change
second = self.wg.ensure_postup_dnat()
self.assertFalse(second)
def test_ensure_postup_dnat_returns_false_when_no_conf(self): def test_ensure_postup_dnat_returns_false_when_no_conf(self):
changed = self.wg.ensure_postup_dnat() changed = self.wg.ensure_postup_dnat()