feat: per-peer access enforcement, live peer status, auto IP assignment

Server-side access control:
- firewall_manager.py: per-peer iptables FORWARD rules in WireGuard container;
  virtual IPs on Caddy (172.20.0.21-24) for per-service DROP/ACCEPT targeting
- CoreDNS Corefile regenerated with ACL blocks for blocked services per peer
- POST /api/wireguard/apply-enforcement re-applies rules after WireGuard restart;
  wg0.conf PostUp calls it via curl so rules restore automatically on container start

WireGuard fixes:
- _syncconf uses `wg set peer` instead of `wg syncconf` to avoid resetting ListenPort
- add_peer validates AllowedIPs must be /32 — rejects full/split tunnel CIDRs that
  would route internet or LAN traffic to that peer
- _config_file() checks for linuxserver wg_confs/ subdirectory first

UI:
- Peers page fetches /api/wireguard/peers/statuses for live handshake data;
  status badge now shows real Online/Offline + seconds since last handshake
- IP field removed from Add Peer form (auto-assigned from 10.0.0.0/24)

Tests (246 pass):
- test_firewall_manager.py: 22 tests for ACL generation, iptables rule correctness,
  comment tagging, clear_peer_rules filter logic
- test_peer_wg_integration.py: 10 tests for /32 enforcement, IP auto-assignment,
  syncconf called on add/remove
- test_wireguard_manager.py: updated to reflect correct IPs and /32 requirement

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-21 01:01:07 -04:00
parent 8e41568964
commit 53c7661812
13 changed files with 1457 additions and 378 deletions
+86 -1
View File
@@ -136,6 +136,10 @@ class WireGuardManager(BaseServiceManager):
)
def _config_file(self) -> str:
# linuxserver/wireguard stores configs in wg_confs/
wg_confs = os.path.join(self.wireguard_dir, 'wg_confs')
if os.path.isdir(wg_confs):
return os.path.join(wg_confs, 'wg0.conf')
return os.path.join(self.wireguard_dir, 'wg0.conf')
def _read_config(self) -> str:
@@ -148,14 +152,95 @@ class WireGuardManager(BaseServiceManager):
def _write_config(self, content: str):
with open(self._config_file(), 'w') as f:
f.write(content)
self._syncconf()
def _syncconf(self):
"""Sync live WireGuard peers using 'wg set' — never touches [Interface] settings.
wg syncconf resets the ListenPort when given a peers-only config,
breaking client connections. We diff the config file against the live
interface and add/remove peers individually instead.
"""
import subprocess, re
try:
# Parse desired peers from config file
content = self._read_config()
desired: dict = {}
current_peer = None
for line in content.splitlines():
line = line.strip()
if line == '[Peer]':
current_peer = {}
elif current_peer is not None:
if line.startswith('PublicKey'):
current_peer['pub'] = line.split('=', 1)[1].strip()
elif line.startswith('AllowedIPs'):
current_peer['ips'] = line.split('=', 1)[1].strip()
elif line.startswith('PersistentKeepalive'):
current_peer['ka'] = line.split('=', 1)[1].strip()
elif line == '' and 'pub' in current_peer:
desired[current_peer['pub']] = current_peer
current_peer = None
if current_peer and 'pub' in current_peer:
desired[current_peer['pub']] = current_peer
# Get live peers
dump = subprocess.run(
['docker', 'exec', 'cell-wireguard', 'wg', 'show', 'wg0', 'dump'],
capture_output=True, text=True, timeout=5
)
live_pubs = set()
for line in dump.stdout.splitlines():
parts = line.split('\t')
if len(parts) >= 4 and parts[0] not in ('(none)', ''):
live_pubs.add(parts[0])
# Remove peers no longer in config
for pub in live_pubs - set(desired):
subprocess.run(
['docker', 'exec', 'cell-wireguard', 'wg', 'set', 'wg0',
'peer', pub, 'remove'],
capture_output=True, timeout=5
)
logger.info(f'wg: removed peer {pub[:16]}...')
# Add/update peers in config
for pub, p in desired.items():
args = ['docker', 'exec', 'cell-wireguard', 'wg', 'set', 'wg0',
'peer', pub,
'allowed-ips', p.get('ips', ''),
'persistent-keepalive', p.get('ka', '25')]
subprocess.run(args, capture_output=True, timeout=5)
logger.info(f'wg set applied: {len(desired)} peers')
except Exception as e:
logger.warning(f'_syncconf failed (non-fatal): {e}')
# ── Peer CRUD ─────────────────────────────────────────────────────────────
def add_peer(self, name: str, public_key: str, endpoint_ip: str,
allowed_ips: str = SERVER_NETWORK,
persistent_keepalive: int = 25) -> bool:
"""Add a [Peer] block to wg0.conf."""
"""Add a [Peer] block to wg0.conf.
Server-side AllowedIPs must be the peer's specific VPN IP (/32).
Passing full-tunnel or split-tunnel CIDRs here would cause the server
to route all internet or LAN traffic to that peer — breaking everything.
"""
import ipaddress
try:
# Enforce /32: reject any CIDR wider than a single host
for cidr in (c.strip() for c in allowed_ips.split(',')):
try:
net = ipaddress.ip_network(cidr, strict=False)
if net.prefixlen < 32 and not cidr.endswith('/32'):
raise ValueError(
f"Server-side AllowedIPs must be a /32 host address, got '{cidr}'. "
"Full/split tunnel CIDRs belong in the CLIENT config only."
)
except ValueError as ve:
raise ve
content = self._read_config()
peer_block = (
f'\n[Peer]\n'