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:
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user