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
+57 -6
View File
@@ -41,6 +41,7 @@ from container_manager import ContainerManager
from config_manager import ConfigManager
from service_bus import ServiceBus, EventType
from log_manager import LogManager
import firewall_manager
# Context variable for request info
request_context = contextvars.ContextVar('request_context', default={})
@@ -168,6 +169,21 @@ cell_manager = CellManager(data_dir=_DATA_DIR, config_dir=_CONFIG_DIR)
app.vault_manager = VaultManager(data_dir=_DATA_DIR, config_dir=_CONFIG_DIR)
container_manager = ContainerManager(data_dir=_DATA_DIR, config_dir=_CONFIG_DIR)
# Apply firewall + DNS rules from stored peer settings (survives API restarts)
def _apply_startup_enforcement():
try:
peers = peer_registry.list_peers()
firewall_manager.apply_all_peer_rules(peers)
firewall_manager.apply_all_dns_rules(peers, COREFILE_PATH)
logger.info(f"Applied enforcement rules for {len(peers)} peers on startup")
except Exception as e:
logger.warning(f"Startup enforcement failed (non-fatal): {e}")
COREFILE_PATH = '/app/config/dns/Corefile'
# Run in background so startup isn't blocked waiting on docker exec
threading.Thread(target=_apply_startup_enforcement, daemon=True).start()
# Register services with service bus
service_bus.register_service('network', network_manager)
service_bus.register_service('wireguard', wireguard_manager)
@@ -942,6 +958,17 @@ def refresh_external_ip():
logger.error(f"Error refreshing external IP: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/wireguard/apply-enforcement', methods=['POST'])
def apply_wireguard_enforcement():
"""Re-apply per-peer iptables and DNS enforcement rules (call after WireGuard restart)."""
try:
peers = peer_registry.list_peers()
firewall_manager.apply_all_peer_rules(peers)
firewall_manager.apply_all_dns_rules(peers, COREFILE_PATH)
return jsonify({'ok': True, 'peers': len(peers)})
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/wireguard/check-port', methods=['POST'])
def check_wireguard_port():
try:
@@ -961,6 +988,20 @@ def get_peers():
logger.error(f"Error getting peers: {e}")
return jsonify({"error": str(e)}), 500
def _next_peer_ip() -> str:
"""Auto-assign the next free 10.0.0.x address (starts at .2, skips .1 = server)."""
import ipaddress
used = {p.get('ip', '').split('/')[0] for p in peer_registry.list_peers()}
network = ipaddress.ip_network('10.0.0.0/24')
for host in network.hosts():
ip = str(host)
if ip == '10.0.0.1':
continue # server address
if ip not in used:
return ip
raise ValueError('No free IPs left in 10.0.0.0/24')
@app.route('/api/peers', methods=['POST'])
def add_peer():
"""Add a peer."""
@@ -968,17 +1009,19 @@ def add_peer():
data = request.get_json(silent=True)
if data is None:
return jsonify({"error": "No data provided"}), 400
# Validate required fields
required_fields = ['name', 'ip', 'public_key']
# Validate required fields (ip is optional — auto-assigned if omitted)
required_fields = ['name', 'public_key']
for field in required_fields:
if field not in data:
return jsonify({"error": f"Missing required field: {field}"}), 400
assigned_ip = data.get('ip') or _next_peer_ip()
# Add peer to registry with all provided fields
peer_info = {
'peer': data['name'],
'ip': data['ip'],
'ip': assigned_ip,
'public_key': data['public_key'],
'private_key': data.get('private_key'),
'server_public_key': data.get('server_public_key'),
@@ -994,7 +1037,10 @@ def add_peer():
success = peer_registry.add_peer(peer_info)
if success:
return jsonify({"message": f"Peer {data['name']} added successfully"}), 201
# Apply server-side enforcement immediately
firewall_manager.apply_peer_rules(peer_info['ip'], peer_info)
firewall_manager.apply_all_dns_rules(peer_registry.list_peers(), COREFILE_PATH)
return jsonify({"message": f"Peer {data['name']} added successfully", "ip": assigned_ip}), 201
else:
return jsonify({"error": f"Peer {data['name']} already exists"}), 400
@@ -1025,6 +1071,11 @@ def update_peer(peer_name):
success = peer_registry.update_peer(peer_name, updates)
if success:
# Re-apply server-side enforcement with updated settings
updated_peer = peer_registry.get_peer(peer_name)
if updated_peer:
firewall_manager.apply_peer_rules(updated_peer['ip'], updated_peer)
firewall_manager.apply_all_dns_rules(peer_registry.list_peers(), COREFILE_PATH)
result = {"message": f"Peer {peer_name} updated", "config_changed": config_changed}
return jsonify(result)
else: