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
+124
View File
@@ -0,0 +1,124 @@
#!/usr/bin/env python3
"""
Tests for peer add/remove flow — ensures server-side WireGuard AllowedIPs
are always the peer's /32 VPN IP, never the client tunnel AllowedIPs.
"""
import sys
import os
import tempfile
import shutil
import unittest
from pathlib import Path
from unittest.mock import patch, MagicMock
api_dir = Path(__file__).parent.parent / 'api'
sys.path.insert(0, str(api_dir))
from wireguard_manager import WireGuardManager
from peer_registry import PeerRegistry
class TestServerSideAllowedIPs(unittest.TestCase):
"""Server-side peer AllowedIPs must always be peer_ip/32."""
def setUp(self):
self.tmp = tempfile.mkdtemp()
self.data_dir = os.path.join(self.tmp, 'data')
self.config_dir = os.path.join(self.tmp, 'config')
os.makedirs(self.data_dir)
os.makedirs(self.config_dir)
# Patch syncconf so tests don't need docker
patcher = patch.object(WireGuardManager, '_syncconf', return_value=None)
self.mock_sync = patcher.start()
self.addCleanup(patcher.stop)
self.wg = WireGuardManager(self.data_dir, self.config_dir)
def tearDown(self):
shutil.rmtree(self.tmp)
def _config(self):
with open(self.wg._config_file()) as f:
return f.read()
def test_add_peer_uses_host_slash32(self):
"""Peer added with /32 stays as /32 in config."""
self.wg.add_peer('alice', 'ALICEPUBKEY=', '', allowed_ips='10.0.0.2/32')
cfg = self._config()
self.assertIn('AllowedIPs = 10.0.0.2/32', cfg)
def test_full_tunnel_client_ips_rejected(self):
"""add_peer must refuse 0.0.0.0/0 — it would route all internet traffic to that peer."""
result = self.wg.add_peer('bob', 'BOBPUBKEY=', '', allowed_ips='0.0.0.0/0, ::/0')
self.assertFalse(result,
"0.0.0.0/0 in server peer AllowedIPs routes ALL traffic to that peer, breaking internet")
def test_split_tunnel_client_ips_rejected(self):
"""add_peer must refuse 172.20.0.0/16 — it would route docker network to that peer."""
result = self.wg.add_peer('carol', 'CAROLPUBKEY=', '', allowed_ips='10.0.0.0/24, 172.20.0.0/16')
self.assertFalse(result,
"172.20.0.0/16 in server peer AllowedIPs routes docker network traffic to that peer")
def test_remove_peer_cleans_config(self):
self.wg.add_peer('dave', 'DAVEPUBKEY=', '', allowed_ips='10.0.0.4/32')
self.wg.remove_peer('DAVEPUBKEY=')
cfg = self._config()
self.assertNotIn('DAVEPUBKEY=', cfg)
def test_syncconf_called_on_add(self):
self.wg.add_peer('eve', 'EVEPUBKEY=', '', allowed_ips='10.0.0.5/32')
self.mock_sync.assert_called()
def test_syncconf_called_on_remove(self):
self.wg.add_peer('frank', 'FRANKPUBKEY=', '', allowed_ips='10.0.0.6/32')
self.mock_sync.reset_mock()
self.wg.remove_peer('FRANKPUBKEY=')
self.mock_sync.assert_called()
class TestAutoAssignIP(unittest.TestCase):
"""Auto-assigned peer IPs must be unique /32s starting at 10.0.0.2."""
def setUp(self):
self.tmp = tempfile.mkdtemp()
self.registry = PeerRegistry(data_dir=self.tmp, config_dir=self.tmp)
def tearDown(self):
shutil.rmtree(self.tmp)
def _next_ip(self):
import ipaddress
used = {p.get('ip', '').split('/')[0] for p in self.registry.list_peers()}
for host in ipaddress.ip_network('10.0.0.0/24').hosts():
ip = str(host)
if ip != '10.0.0.1' and ip not in used:
return ip
raise ValueError('No free IPs')
def test_first_peer_gets_10_0_0_2(self):
ip = self._next_ip()
self.assertEqual(ip, '10.0.0.2')
def test_second_peer_gets_10_0_0_3(self):
self.registry.add_peer({'peer': 'p1', 'ip': '10.0.0.2'})
ip = self._next_ip()
self.assertEqual(ip, '10.0.0.3')
def test_no_duplicate_ips(self):
assigned = []
for i in range(5):
ip = self._next_ip()
self.assertNotIn(ip, assigned, f"Duplicate IP assigned: {ip}")
assigned.append(ip)
self.registry.add_peer({'peer': f'peer{i}', 'ip': ip})
def test_server_ip_never_assigned(self):
# Fill up .2 through .10
for i in range(2, 11):
self.registry.add_peer({'peer': f'p{i}', 'ip': f'10.0.0.{i}'})
ip = self._next_ip()
self.assertNotEqual(ip, '10.0.0.1', "Server IP 10.0.0.1 must never be assigned to a peer")
if __name__ == '__main__':
unittest.main()