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