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