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
+55 -4
View File
@@ -41,6 +41,7 @@ from container_manager import ContainerManager
from config_manager import ConfigManager from config_manager import ConfigManager
from service_bus import ServiceBus, EventType from service_bus import ServiceBus, EventType
from log_manager import LogManager from log_manager import LogManager
import firewall_manager
# Context variable for request info # Context variable for request info
request_context = contextvars.ContextVar('request_context', default={}) 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) app.vault_manager = VaultManager(data_dir=_DATA_DIR, config_dir=_CONFIG_DIR)
container_manager = ContainerManager(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 # Register services with service bus
service_bus.register_service('network', network_manager) service_bus.register_service('network', network_manager)
service_bus.register_service('wireguard', wireguard_manager) service_bus.register_service('wireguard', wireguard_manager)
@@ -942,6 +958,17 @@ def refresh_external_ip():
logger.error(f"Error refreshing external IP: {e}") logger.error(f"Error refreshing external IP: {e}")
return jsonify({"error": str(e)}), 500 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']) @app.route('/api/wireguard/check-port', methods=['POST'])
def check_wireguard_port(): def check_wireguard_port():
try: try:
@@ -961,6 +988,20 @@ def get_peers():
logger.error(f"Error getting peers: {e}") logger.error(f"Error getting peers: {e}")
return jsonify({"error": str(e)}), 500 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']) @app.route('/api/peers', methods=['POST'])
def add_peer(): def add_peer():
"""Add a peer.""" """Add a peer."""
@@ -969,16 +1010,18 @@ def add_peer():
if data is None: if data is None:
return jsonify({"error": "No data provided"}), 400 return jsonify({"error": "No data provided"}), 400
# Validate required fields # Validate required fields (ip is optional — auto-assigned if omitted)
required_fields = ['name', 'ip', 'public_key'] required_fields = ['name', 'public_key']
for field in required_fields: for field in required_fields:
if field not in data: if field not in data:
return jsonify({"error": f"Missing required field: {field}"}), 400 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 # Add peer to registry with all provided fields
peer_info = { peer_info = {
'peer': data['name'], 'peer': data['name'],
'ip': data['ip'], 'ip': assigned_ip,
'public_key': data['public_key'], 'public_key': data['public_key'],
'private_key': data.get('private_key'), 'private_key': data.get('private_key'),
'server_public_key': data.get('server_public_key'), 'server_public_key': data.get('server_public_key'),
@@ -994,7 +1037,10 @@ def add_peer():
success = peer_registry.add_peer(peer_info) success = peer_registry.add_peer(peer_info)
if success: 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: else:
return jsonify({"error": f"Peer {data['name']} already exists"}), 400 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) success = peer_registry.update_peer(peer_name, updates)
if success: 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} result = {"message": f"Peer {peer_name} updated", "config_changed": config_changed}
return jsonify(result) return jsonify(result)
else: else:
+305
View File
@@ -0,0 +1,305 @@
#!/usr/bin/env python3
"""
Firewall Manager for Personal Internet Cell
Manages per-peer iptables rules in the WireGuard container and DNS ACLs in CoreDNS.
"""
import os
import subprocess
import logging
import re
from typing import Dict, List, Any, Optional
logger = logging.getLogger(__name__)
# Virtual IPs assigned to Caddy per service — must match Caddyfile listeners
SERVICE_IPS = {
'calendar': '172.20.0.21',
'files': '172.20.0.22',
'mail': '172.20.0.23',
'webdav': '172.20.0.24',
}
# Internal RFC-1918 ranges (peer traffic stays inside these = cell-only access)
PRIVATE_NETS = ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16']
WIREGUARD_CONTAINER = 'cell-wireguard'
CADDY_CONTAINER = 'cell-caddy'
COREFILE_PATH = '/app/config/dns/Corefile'
ZONE_DATA_DIR = '/data' # inside CoreDNS container; mounted from ./data/dns
def _run(cmd: List[str], check: bool = True) -> subprocess.CompletedProcess:
"""Run a shell command and return the result."""
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
if check and result.returncode != 0:
logger.warning(f"Command {cmd} exited {result.returncode}: {result.stderr.strip()}")
return result
except Exception as e:
logger.error(f"Command {cmd} failed: {e}")
raise
def _wg_exec(args: List[str]) -> subprocess.CompletedProcess:
"""Run a command inside the WireGuard container via docker exec."""
return _run(['docker', 'exec', WIREGUARD_CONTAINER] + args, check=False)
def _caddy_exec(args: List[str]) -> subprocess.CompletedProcess:
"""Run a command inside the Caddy container via docker exec."""
return _run(['docker', 'exec', CADDY_CONTAINER] + args, check=False)
# ---------------------------------------------------------------------------
# Virtual IP management (Caddy container)
# ---------------------------------------------------------------------------
def ensure_caddy_virtual_ips() -> bool:
"""Add per-service virtual IPs to Caddy's eth0 if not already present."""
try:
result = _caddy_exec(['ip', 'addr', 'show', 'eth0'])
existing = result.stdout
for service, ip in SERVICE_IPS.items():
if ip not in existing:
r = _caddy_exec(['ip', 'addr', 'add', f'{ip}/16', 'dev', 'eth0'])
if r.returncode == 0:
logger.info(f"Added virtual IP {ip} for {service} to Caddy eth0")
else:
logger.warning(f"Failed to add virtual IP {ip}: {r.stderr.strip()}")
return True
except Exception as e:
logger.error(f"ensure_caddy_virtual_ips failed: {e}")
return False
# ---------------------------------------------------------------------------
# iptables rule helpers
# ---------------------------------------------------------------------------
def _iptables(args: List[str], check: bool = False) -> subprocess.CompletedProcess:
return _wg_exec(['iptables'] + args)
def _rule_exists(chain: str, rule_args: List[str]) -> bool:
result = _iptables(['-C', chain] + rule_args)
return result.returncode == 0
def _ensure_rule(chain: str, rule_args: List[str]) -> None:
"""Insert rule at top of chain if it doesn't already exist."""
if not _rule_exists(chain, rule_args):
_iptables(['-I', chain] + rule_args)
def _delete_rule(chain: str, rule_args: List[str]) -> None:
"""Delete rule from chain (silently if it doesn't exist)."""
while _rule_exists(chain, rule_args):
_iptables(['-D', chain] + rule_args)
# ---------------------------------------------------------------------------
# Per-peer rule management
# ---------------------------------------------------------------------------
def _peer_comment(peer_ip: str) -> str:
return f'pic-peer-{peer_ip.replace(".", "-")}'
def clear_peer_rules(peer_ip: str) -> None:
"""Remove all FORWARD rules tagged with this peer's IP via iptables-save/restore."""
comment = _peer_comment(peer_ip)
try:
# Dump rules, strip matching lines, restore — atomic and order-stable
save = _wg_exec(['iptables-save'])
if save.returncode != 0:
return
lines = save.stdout.splitlines()
filtered = [l for l in lines if comment not in l]
if len(filtered) == len(lines):
return # nothing to remove
restore_input = '\n'.join(filtered) + '\n'
restore = subprocess.run(
['docker', 'exec', '-i', WIREGUARD_CONTAINER, 'iptables-restore'],
input=restore_input, capture_output=True, text=True, timeout=10
)
if restore.returncode != 0:
logger.warning(f"iptables-restore failed: {restore.stderr.strip()}")
except Exception as e:
logger.error(f"clear_peer_rules({peer_ip}): {e}")
def apply_peer_rules(peer_ip: str, settings: Dict[str, Any]) -> bool:
"""
Apply iptables FORWARD rules for a peer based on their access settings.
Each rule is inserted at position 1 (-I), so the LAST call ends up at the TOP.
We insert in reverse-priority order: lowest-priority rules first, highest last.
Desired final chain order (top = highest priority):
1. Per-service DROP/ACCEPT (most specific must beat private-net ACCEPT)
2. Peer-to-peer ACCEPT/DROP (10.0.0.0/24)
3. Private-net ACCEPTs (for no-internet peers to reach local resources)
4. Internet DROP or ACCEPT (lowest priority catch-all)
"""
try:
comment = _peer_comment(peer_ip)
clear_peer_rules(peer_ip)
internet_access = settings.get('internet_access', True)
service_access = settings.get('service_access', list(SERVICE_IPS.keys()))
peer_access = settings.get('peer_access', True)
# --- Step 1 (inserted first → ends up at bottom before default ACCEPT) ---
# Internet catch-all: allow or block
if internet_access:
_iptables(['-I', 'FORWARD', '-s', peer_ip,
'-m', 'comment', '--comment', comment, '-j', 'ACCEPT'])
else:
# Block non-private, allow private nets
_iptables(['-I', 'FORWARD', '-s', peer_ip,
'-m', 'comment', '--comment', comment, '-j', 'DROP'])
for net in reversed(PRIVATE_NETS):
_iptables(['-I', 'FORWARD', '-s', peer_ip, '-d', net,
'-m', 'comment', '--comment', comment, '-j', 'ACCEPT'])
# --- Step 2 --- Peer-to-peer (10.0.0.0/24)
target = 'ACCEPT' if peer_access else 'DROP'
_iptables(['-I', 'FORWARD', '-s', peer_ip, '-d', '10.0.0.0/24',
'-m', 'comment', '--comment', comment, '-j', target])
# --- Step 3 (inserted last → ends up at TOP of chain) ---
# Per-service rules — inserted in reverse dict order so first service ends up at top
for service, svc_ip in reversed(list(SERVICE_IPS.items())):
target = 'ACCEPT' if service in service_access else 'DROP'
_iptables(['-I', 'FORWARD', '-s', peer_ip, '-d', svc_ip,
'-m', 'comment', '--comment', comment, '-j', target])
logger.info(f"Applied rules for {peer_ip}: internet={internet_access} "
f"services={service_access} peers={peer_access}")
return True
except Exception as e:
logger.error(f"apply_peer_rules({peer_ip}): {e}")
return False
def apply_all_peer_rules(peers: List[Dict[str, Any]]) -> None:
"""Re-apply rules for all peers (called on startup)."""
ensure_caddy_virtual_ips()
for peer in peers:
ip = peer.get('ip')
if not ip:
continue
apply_peer_rules(ip, {
'internet_access': peer.get('internet_access', True),
'service_access': peer.get('service_access', list(SERVICE_IPS.keys())),
'peer_access': peer.get('peer_access', True),
})
# ---------------------------------------------------------------------------
# DNS ACL (CoreDNS Corefile generation)
# ---------------------------------------------------------------------------
# Map service name → DNS hostname in .cell zone
SERVICE_HOSTS = {
'calendar': 'calendar.cell.',
'files': 'files.cell.',
'mail': 'mail.cell.',
'webdav': 'webdav.cell.',
}
def _build_acl_block(blocked_peers_by_service: Dict[str, List[str]]) -> str:
"""
Build CoreDNS ACL plugin stanzas.
blocked_peers_by_service: { 'calendar': ['10.0.0.2', '10.0.0.3'], ... }
Returns a string to embed in the `cell { }` zone block.
"""
if not blocked_peers_by_service:
return ''
lines = []
for service, peer_ips in blocked_peers_by_service.items():
host = SERVICE_HOSTS.get(service)
if not host or not peer_ips:
continue
for ip in peer_ips:
lines.append(f' acl {host} {{')
lines.append(f' block net {ip}/32')
lines.append(f' allow net 0.0.0.0/0')
lines.append(f' allow net ::/0')
lines.append(f' }}')
return '\n'.join(lines)
def generate_corefile(peers: List[Dict[str, Any]], corefile_path: str = COREFILE_PATH) -> bool:
"""
Rewrite the CoreDNS Corefile with per-peer ACL rules and reload plugin.
The file is written to corefile_path (API-side path mapped into CoreDNS container).
"""
try:
# Collect which peers block which services
blocked: Dict[str, List[str]] = {s: [] for s in SERVICE_IPS}
for peer in peers:
ip = peer.get('ip')
if not ip:
continue
allowed_services = peer.get('service_access', list(SERVICE_IPS.keys()))
for service in SERVICE_IPS:
if service not in allowed_services:
blocked[service].append(ip)
acl_block = _build_acl_block(blocked)
cell_zone_block = 'cell {\n file /data/cell.zone\n log\n'
if acl_block:
cell_zone_block += acl_block + '\n'
cell_zone_block += '}\n'
corefile = f""". {{
forward . 8.8.8.8 1.1.1.1
cache
log
health
}}
{cell_zone_block}
local.cell {{
file /data/local.zone
log
}}
"""
os.makedirs(os.path.dirname(corefile_path), exist_ok=True)
with open(corefile_path, 'w') as f:
f.write(corefile)
logger.info(f"Wrote Corefile to {corefile_path}")
return True
except Exception as e:
logger.error(f"generate_corefile: {e}")
return False
def reload_coredns() -> bool:
"""Send SIGHUP to CoreDNS container to reload config."""
try:
result = _run(['docker', 'kill', '--signal=SIGHUP', 'cell-dns'], check=False)
if result.returncode == 0:
logger.info("Sent SIGHUP to cell-dns")
return True
logger.warning(f"SIGHUP to cell-dns failed: {result.stderr.strip()}")
return False
except Exception as e:
logger.error(f"reload_coredns: {e}")
return False
def apply_all_dns_rules(peers: List[Dict[str, Any]], corefile_path: str = COREFILE_PATH) -> bool:
"""Regenerate Corefile and reload CoreDNS."""
ok = generate_corefile(peers, corefile_path)
if ok:
reload_coredns()
return ok
+86 -1
View File
@@ -136,6 +136,10 @@ class WireGuardManager(BaseServiceManager):
) )
def _config_file(self) -> str: 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') return os.path.join(self.wireguard_dir, 'wg0.conf')
def _read_config(self) -> str: def _read_config(self) -> str:
@@ -148,14 +152,95 @@ class WireGuardManager(BaseServiceManager):
def _write_config(self, content: str): def _write_config(self, content: str):
with open(self._config_file(), 'w') as f: with open(self._config_file(), 'w') as f:
f.write(content) 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 ───────────────────────────────────────────────────────────── # ── Peer CRUD ─────────────────────────────────────────────────────────────
def add_peer(self, name: str, public_key: str, endpoint_ip: str, def add_peer(self, name: str, public_key: str, endpoint_ip: str,
allowed_ips: str = SERVER_NETWORK, allowed_ips: str = SERVER_NETWORK,
persistent_keepalive: int = 25) -> bool: 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: 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() content = self._read_config()
peer_block = ( peer_block = (
f'\n[Peer]\n' f'\n[Peer]\n'
+8 -8
View File
@@ -2,8 +2,8 @@
auto_https off auto_https off
} }
# Main cell domain # Main cell domain — no service-IP restriction needed
http://mycell.cell { http://mycell.cell, http://172.20.0.2:80 {
handle /api/* { handle /api/* {
reverse_proxy cell-api:3000 reverse_proxy cell-api:3000
} }
@@ -21,20 +21,20 @@ http://mycell.cell {
} }
} }
# Service aliases # Per-service virtual IPs — each gets its own IP so iptables can target them
http://calendar.cell { http://calendar.cell, http://172.20.0.21:80 {
reverse_proxy cell-radicale:5232 reverse_proxy cell-radicale:5232
} }
http://files.cell { http://files.cell, http://172.20.0.22:80 {
reverse_proxy cell-filegator:8080 reverse_proxy cell-filegator:8080
} }
http://mail.cell, http://webmail.cell { http://mail.cell, http://webmail.cell, http://172.20.0.23:80 {
reverse_proxy cell-rainloop:8888 reverse_proxy cell-rainloop:8888
} }
http://webdav.cell { http://webdav.cell, http://172.20.0.24:80 {
reverse_proxy cell-webdav:80 reverse_proxy cell-webdav:80
} }
@@ -42,7 +42,7 @@ http://api.cell {
reverse_proxy cell-api:3000 reverse_proxy cell-api:3000
} }
# Catch-all for direct IP and localhost access # Catch-all for direct IP / localhost
:80 { :80 {
handle /api/* { handle /api/* {
reverse_proxy cell-api:3000 reverse_proxy cell-api:3000
+3
View File
@@ -13,6 +13,8 @@ services:
- ./data/caddy:/data - ./data/caddy:/data
- ./config/caddy/certs:/config/caddy/certs - ./config/caddy/certs:/config/caddy/certs
restart: unless-stopped restart: unless-stopped
cap_add:
- NET_ADMIN
networks: networks:
cell-network: cell-network:
ipv4_address: 172.20.0.2 ipv4_address: 172.20.0.2
@@ -156,6 +158,7 @@ services:
- ./data/dns:/app/data/dns - ./data/dns:/app/data/dns
- ./config/api:/app/config - ./config/api:/app/config
- ./config/wireguard:/app/config/wireguard - ./config/wireguard:/app/config/wireguard
- ./config/dns:/app/config/dns
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
pid: host pid: host
restart: unless-stopped restart: unless-stopped
+275
View File
@@ -0,0 +1,275 @@
#!/usr/bin/env python3
"""
Tests for firewall_manager per-peer iptables rule generation and DNS ACL logic.
All docker exec calls are mocked so tests run without a live Docker environment.
"""
import sys
import os
import tempfile
import shutil
import unittest
from unittest.mock import patch, call, MagicMock
from pathlib import Path
api_dir = Path(__file__).parent.parent / 'api'
sys.path.insert(0, str(api_dir))
import firewall_manager
def _make_peer(ip, internet=True, services=None, peers=True):
if services is None:
services = list(firewall_manager.SERVICE_IPS.keys())
return {'ip': ip, 'internet_access': internet, 'service_access': services, 'peer_access': peers}
# ---------------------------------------------------------------------------
# _peer_comment
# ---------------------------------------------------------------------------
class TestPeerComment(unittest.TestCase):
def test_dots_replaced_with_dashes(self):
self.assertEqual(firewall_manager._peer_comment('10.0.0.2'), 'pic-peer-10-0-0-2')
def test_different_ip(self):
self.assertEqual(firewall_manager._peer_comment('192.168.1.100'), 'pic-peer-192-168-1-100')
# ---------------------------------------------------------------------------
# _build_acl_block
# ---------------------------------------------------------------------------
class TestBuildAclBlock(unittest.TestCase):
def test_empty_returns_empty_string(self):
self.assertEqual(firewall_manager._build_acl_block({}), '')
def test_no_blocked_peers_returns_empty(self):
blocked = {s: [] for s in firewall_manager.SERVICE_IPS}
self.assertEqual(firewall_manager._build_acl_block(blocked), '')
def test_blocked_peer_appears_in_acl(self):
blocked = {'calendar': ['10.0.0.5'], 'files': [], 'mail': [], 'webdav': []}
result = firewall_manager._build_acl_block(blocked)
self.assertIn('acl calendar.cell.', result)
self.assertIn('block net 10.0.0.5/32', result)
self.assertIn('allow net 0.0.0.0/0', result)
def test_unknown_service_skipped(self):
blocked = {'nonexistent': ['10.0.0.2']}
result = firewall_manager._build_acl_block(blocked)
self.assertEqual(result, '')
def test_multiple_peers_blocked_from_same_service(self):
blocked = {'mail': ['10.0.0.2', '10.0.0.3'], 'calendar': [], 'files': [], 'webdav': []}
result = firewall_manager._build_acl_block(blocked)
self.assertEqual(result.count('block net'), 2)
self.assertIn('10.0.0.2/32', result)
self.assertIn('10.0.0.3/32', result)
# ---------------------------------------------------------------------------
# generate_corefile
# ---------------------------------------------------------------------------
class TestGenerateCorefile(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.mkdtemp()
self.path = os.path.join(self.tmp, 'Corefile')
def tearDown(self):
shutil.rmtree(self.tmp)
def test_creates_corefile(self):
firewall_manager.generate_corefile([], self.path)
self.assertTrue(os.path.exists(self.path))
def test_contains_forward_and_cache(self):
firewall_manager.generate_corefile([], self.path)
content = open(self.path).read()
self.assertIn('forward . 8.8.8.8', content)
self.assertIn('cache', content)
self.assertIn('cell {', content)
def test_no_blocked_services_no_acl_block(self):
peers = [_make_peer('10.0.0.2', internet=True,
services=list(firewall_manager.SERVICE_IPS.keys()))]
firewall_manager.generate_corefile(peers, self.path)
content = open(self.path).read()
self.assertNotIn('block net', content)
def test_blocked_service_generates_acl(self):
peers = [_make_peer('10.0.0.3', internet=False, services=['calendar'])]
firewall_manager.generate_corefile(peers, self.path)
content = open(self.path).read()
# files/mail/webdav are blocked for this peer
self.assertIn('block net 10.0.0.3/32', content)
def test_peer_with_all_services_allowed_no_acl(self):
peers = [_make_peer('10.0.0.2', services=list(firewall_manager.SERVICE_IPS.keys()))]
firewall_manager.generate_corefile(peers, self.path)
self.assertNotIn('block net', open(self.path).read())
def test_returns_false_on_write_error(self):
result = firewall_manager.generate_corefile([], '/nonexistent/path/Corefile')
self.assertFalse(result)
# ---------------------------------------------------------------------------
# apply_peer_rules — iptables call verification
# ---------------------------------------------------------------------------
class TestApplyPeerRules(unittest.TestCase):
"""Verify correct iptables calls for full-internet vs split-tunnel peers."""
def _run_apply(self, peer_ip, settings):
calls_made = []
def fake_wg_exec(args):
calls_made.append(args)
m = MagicMock()
m.returncode = 0
m.stdout = ''
return m
with patch.object(firewall_manager, '_wg_exec', side_effect=fake_wg_exec):
firewall_manager.apply_peer_rules(peer_ip, settings)
return calls_made
def test_full_internet_peer_gets_accept_rule(self):
calls = self._run_apply('10.0.0.2', {'internet_access': True,
'service_access': list(firewall_manager.SERVICE_IPS.keys()),
'peer_access': True})
iptables_calls = [c for c in calls if 'iptables' in c]
targets = [c[c.index('-j') + 1] for c in iptables_calls if '-j' in c]
# Full-internet peer: only ACCEPT rules (no DROP except iptables-restore clears)
self.assertNotIn('DROP', targets)
self.assertIn('ACCEPT', targets)
def test_no_internet_peer_gets_drop_rule(self):
calls = self._run_apply('10.0.0.3', {'internet_access': False,
'service_access': list(firewall_manager.SERVICE_IPS.keys()),
'peer_access': True})
iptables_calls = [c for c in calls if 'iptables' in c]
targets = [c[c.index('-j') + 1] for c in iptables_calls if '-j' in c]
self.assertIn('DROP', targets)
self.assertIn('ACCEPT', targets)
def test_service_access_restriction_generates_drop(self):
calls = self._run_apply('10.0.0.4', {'internet_access': False,
'service_access': ['calendar'],
'peer_access': True})
iptables_calls = [c for c in calls if 'iptables' in c]
# files/mail/webdav should be DROPped, calendar ACCEPTed
targets_with_ips = [
(c[c.index('-d') + 1], c[c.index('-j') + 1])
for c in iptables_calls
if '-d' in c and '-j' in c
]
svc_rules = {ip: t for ip, t in targets_with_ips
if ip in firewall_manager.SERVICE_IPS.values()}
calendar_ip = firewall_manager.SERVICE_IPS['calendar']
files_ip = firewall_manager.SERVICE_IPS['files']
self.assertEqual(svc_rules.get(calendar_ip), 'ACCEPT')
self.assertEqual(svc_rules.get(files_ip), 'DROP')
def test_all_rules_tagged_with_peer_comment(self):
calls = self._run_apply('10.0.0.2', {'internet_access': True,
'service_access': list(firewall_manager.SERVICE_IPS.keys()),
'peer_access': True})
iptables_calls = [c for c in calls if 'iptables' in c]
comment = firewall_manager._peer_comment('10.0.0.2')
for c in iptables_calls:
if '-I' in c: # only insertion rules need the comment
self.assertIn(comment, c, f"Rule missing comment tag: {c}")
def test_peer_with_no_peer_access_gets_drop_for_vpn_subnet(self):
calls = self._run_apply('10.0.0.5', {'internet_access': True,
'service_access': list(firewall_manager.SERVICE_IPS.keys()),
'peer_access': False})
iptables_calls = [c for c in calls if 'iptables' in c]
vpn_rules = [c for c in iptables_calls if '-d' in c and '10.0.0.0/24' in c]
self.assertTrue(vpn_rules, "Expected a rule for 10.0.0.0/24")
for c in vpn_rules:
self.assertIn('DROP', c)
# ---------------------------------------------------------------------------
# apply_all_peer_rules
# ---------------------------------------------------------------------------
class TestApplyAllPeerRules(unittest.TestCase):
def test_calls_apply_per_peer(self):
peers = [_make_peer('10.0.0.2'), _make_peer('10.0.0.3', internet=False)]
with patch.object(firewall_manager, 'ensure_caddy_virtual_ips', return_value=True), \
patch.object(firewall_manager, 'apply_peer_rules', return_value=True) as mock_apply:
firewall_manager.apply_all_peer_rules(peers)
self.assertEqual(mock_apply.call_count, 2)
called_ips = {c.args[0] for c in mock_apply.call_args_list}
self.assertEqual(called_ips, {'10.0.0.2', '10.0.0.3'})
def test_peer_without_ip_is_skipped(self):
peers = [{'internet_access': True}, _make_peer('10.0.0.2')]
with patch.object(firewall_manager, 'ensure_caddy_virtual_ips', return_value=True), \
patch.object(firewall_manager, 'apply_peer_rules', return_value=True) as mock_apply:
firewall_manager.apply_all_peer_rules(peers)
self.assertEqual(mock_apply.call_count, 1)
# ---------------------------------------------------------------------------
# clear_peer_rules
# ---------------------------------------------------------------------------
class TestClearPeerRules(unittest.TestCase):
def test_removes_only_matching_comment_lines(self):
save_output = (
'*filter\n'
':INPUT ACCEPT [0:0]\n'
':FORWARD ACCEPT [0:0]\n'
'-A FORWARD -s 10.0.0.2 -m comment --comment pic-peer-10-0-0-2 -j ACCEPT\n'
'-A FORWARD -s 10.0.0.3 -m comment --comment pic-peer-10-0-0-3 -j DROP\n'
'COMMIT\n'
)
restored = []
def fake_wg_exec(args):
m = MagicMock()
m.returncode = 0
if args == ['iptables-save']:
m.stdout = save_output
return m
def fake_restore(cmd, input, **kwargs):
restored.append(input)
m = MagicMock()
m.returncode = 0
return m
with patch.object(firewall_manager, '_wg_exec', side_effect=fake_wg_exec), \
patch('subprocess.run', side_effect=fake_restore):
firewall_manager.clear_peer_rules('10.0.0.2')
self.assertEqual(len(restored), 1)
restored_content = restored[0]
self.assertNotIn('pic-peer-10-0-0-2', restored_content)
self.assertIn('pic-peer-10-0-0-3', restored_content)
def test_no_op_when_no_matching_rules(self):
save_output = '*filter\n:FORWARD ACCEPT [0:0]\nCOMMIT\n'
def fake_wg_exec(args):
m = MagicMock()
m.returncode = 0
m.stdout = save_output
return m
with patch.object(firewall_manager, '_wg_exec', side_effect=fake_wg_exec), \
patch('subprocess.run') as mock_restore:
firewall_manager.clear_peer_rules('10.0.0.99')
mock_restore.assert_not_called()
if __name__ == '__main__':
unittest.main()
+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()
+34 -36
View File
@@ -35,6 +35,10 @@ class TestWireGuardManager(unittest.TestCase):
os.makedirs(self.data_dir, exist_ok=True) os.makedirs(self.data_dir, exist_ok=True)
os.makedirs(self.config_dir, exist_ok=True) os.makedirs(self.config_dir, exist_ok=True)
patcher = patch.object(WireGuardManager, '_syncconf', return_value=None)
self.mock_sync = patcher.start()
self.addCleanup(patcher.stop)
# Create WireGuardManager instance # Create WireGuardManager instance
self.wg_manager = WireGuardManager(self.data_dir, self.config_dir) self.wg_manager = WireGuardManager(self.data_dir, self.config_dir)
@@ -104,50 +108,47 @@ class TestWireGuardManager(unittest.TestCase):
self.assertIsInstance(config, str) self.assertIsInstance(config, str)
self.assertIn('[Interface]', config) self.assertIn('[Interface]', config)
self.assertIn('PrivateKey', config) self.assertIn('PrivateKey', config)
self.assertIn('Address = 172.20.0.1/16', config) self.assertIn('Address = 10.0.0.1/24', config)
self.assertIn('ListenPort = 51820', config) self.assertIn('ListenPort = 51820', config)
self.assertIn('PostUp', config) self.assertIn('PostUp', config)
self.assertIn('PostDown', config) self.assertIn('PostDown', config)
def test_add_peer(self): def test_add_peer(self):
"""Test adding a peer to WireGuard configuration""" """Test adding a peer — server-side AllowedIPs must be /32."""
# Generate peer keys first
peer_keys = self.wg_manager.generate_peer_keys('testpeer') peer_keys = self.wg_manager.generate_peer_keys('testpeer')
success = self.wg_manager.add_peer( success = self.wg_manager.add_peer(
'testpeer', 'testpeer',
peer_keys['public_key'], peer_keys['public_key'],
'192.168.1.100', '',
'172.20.0.0/16', '10.0.0.2/32',
25 25
) )
self.assertTrue(success) self.assertTrue(success)
# Check if config file was created config_file = self.wg_manager._config_file()
config_file = os.path.join(self.wg_manager.wireguard_dir, 'wg0.conf')
self.assertTrue(os.path.exists(config_file)) self.assertTrue(os.path.exists(config_file))
# Check config content
with open(config_file, 'r') as f: with open(config_file, 'r') as f:
config = f.read() config = f.read()
self.assertIn('[Peer]', config) self.assertIn('[Peer]', config)
self.assertIn(peer_keys['public_key'], config) self.assertIn(peer_keys['public_key'], config)
self.assertIn('AllowedIPs = 172.20.0.0/16', config) self.assertIn('AllowedIPs = 10.0.0.2/32', config)
self.assertIn('PersistentKeepalive = 25', config) self.assertIn('PersistentKeepalive = 25', config)
def test_remove_peer(self): def test_remove_peer(self):
"""Test removing a peer from WireGuard configuration""" """Test removing a peer from WireGuard configuration"""
# Add a peer first # Add a peer first
peer_keys = self.wg_manager.generate_peer_keys('testpeer') peer_keys = self.wg_manager.generate_peer_keys('testpeer')
self.wg_manager.add_peer('testpeer', peer_keys['public_key'], '192.168.1.100') self.wg_manager.add_peer('testpeer', peer_keys['public_key'], '', '10.0.0.2/32')
# Remove the peer # Remove the peer
success = self.wg_manager.remove_peer(peer_keys['public_key']) success = self.wg_manager.remove_peer(peer_keys['public_key'])
self.assertTrue(success) self.assertTrue(success)
# Check if peer was removed # Check if peer was removed
config_file = os.path.join(self.wg_manager.wireguard_dir, 'wg0.conf') config_file = self.wg_manager._config_file()
with open(config_file, 'r') as f: with open(config_file, 'r') as f:
config = f.read() config = f.read()
self.assertNotIn(peer_keys['public_key'], config) self.assertNotIn(peer_keys['public_key'], config)
@@ -156,7 +157,7 @@ class TestWireGuardManager(unittest.TestCase):
"""Test getting list of configured peers""" """Test getting list of configured peers"""
# Add a peer first # Add a peer first
peer_keys = self.wg_manager.generate_peer_keys('testpeer') peer_keys = self.wg_manager.generate_peer_keys('testpeer')
self.wg_manager.add_peer('testpeer', peer_keys['public_key'], '192.168.1.100') self.wg_manager.add_peer('testpeer', peer_keys['public_key'], '', '10.0.0.2/32')
peers = self.wg_manager.get_peers() peers = self.wg_manager.get_peers()
@@ -221,46 +222,40 @@ class TestWireGuardManager(unittest.TestCase):
def test_update_peer_ip(self): def test_update_peer_ip(self):
"""Test updating peer IP address""" """Test updating peer IP address"""
# Add a peer first
peer_keys = self.wg_manager.generate_peer_keys('testpeer') peer_keys = self.wg_manager.generate_peer_keys('testpeer')
self.wg_manager.add_peer('testpeer', peer_keys['public_key'], '192.168.1.100') self.wg_manager.add_peer('testpeer', peer_keys['public_key'], '', '10.0.0.2/32')
# Update peer IP success = self.wg_manager.update_peer_ip(peer_keys['public_key'], '10.0.0.9/32')
success = self.wg_manager.update_peer_ip(peer_keys['public_key'], '192.168.1.200')
self.assertTrue(success) self.assertTrue(success)
# Check if IP was updated in config with open(self.wg_manager._config_file(), 'r') as f:
config_file = os.path.join(self.wg_manager.wireguard_dir, 'wg0.conf')
with open(config_file, 'r') as f:
config = f.read() config = f.read()
self.assertIn('192.168.1.200', config) self.assertIn('10.0.0.9/32', config)
def test_get_peer_config(self): def test_get_peer_config(self):
"""Test generating peer configuration""" """Test generating peer client configuration."""
peer_keys = self.wg_manager.generate_peer_keys('testpeer') peer_keys = self.wg_manager.generate_peer_keys('testpeer')
keys = self.wg_manager.get_keys() keys = self.wg_manager.get_keys()
config = self.wg_manager.get_peer_config('testpeer', '192.168.1.100', peer_keys['private_key']) config = self.wg_manager.get_peer_config('testpeer', '10.0.0.2', peer_keys['private_key'])
self.assertIsInstance(config, str) self.assertIsInstance(config, str)
self.assertIn('[Interface]', config) self.assertIn('[Interface]', config)
self.assertIn('[Peer]', config) self.assertIn('[Peer]', config)
self.assertIn('PrivateKey', config) self.assertIn('PrivateKey', config)
self.assertIn('Address = 192.168.1.100/32', config) self.assertIn('Address = 10.0.0.2/32', config)
self.assertIn('DNS = 172.20.0.2', config) self.assertIn('DNS = 172.20.0.3', config)
self.assertIn(keys['public_key'], config) self.assertIn(keys['public_key'], config)
self.assertIn('AllowedIPs = 172.20.0.0/16', config) self.assertIn('AllowedIPs', config)
def test_multiple_peers(self): def test_multiple_peers(self):
"""Test managing multiple peers""" """Test managing multiple peers"""
# Add first peer
peer1_keys = self.wg_manager.generate_peer_keys('peer1') peer1_keys = self.wg_manager.generate_peer_keys('peer1')
success1 = self.wg_manager.add_peer('peer1', peer1_keys['public_key'], '192.168.1.100') success1 = self.wg_manager.add_peer('peer1', peer1_keys['public_key'], '', '10.0.0.2/32')
self.assertTrue(success1) self.assertTrue(success1)
# Add second peer
peer2_keys = self.wg_manager.generate_peer_keys('peer2') peer2_keys = self.wg_manager.generate_peer_keys('peer2')
success2 = self.wg_manager.add_peer('peer2', peer2_keys['public_key'], '192.168.1.101') success2 = self.wg_manager.add_peer('peer2', peer2_keys['public_key'], '', '10.0.0.3/32')
self.assertTrue(success2) self.assertTrue(success2)
# Get peers # Get peers
@@ -310,18 +305,21 @@ PersistentKeepalive = 30
self.assertEqual(peers[1]['persistent_keepalive'], 30) self.assertEqual(peers[1]['persistent_keepalive'], 30)
def test_error_handling(self): def test_error_handling(self):
"""Test error handling in WireGuard operations""" """Test error handling in WireGuard operations."""
# Test with invalid public key # Wide CIDR rejected — server-side AllowedIPs must be /32
success = self.wg_manager.add_peer('testpeer', 'invalid_key', '192.168.1.100') success = self.wg_manager.add_peer('testpeer', 'invalid_key', '', '172.20.0.0/16')
# Should still return True as it writes to config file self.assertFalse(success, "Wide CIDR must be rejected")
# Valid /32 with any key string is accepted (key format not validated at this layer)
success = self.wg_manager.add_peer('testpeer', 'any_key_string=', '', '10.0.0.2/32')
self.assertTrue(success) self.assertTrue(success)
# Test removing non-existent peer # Removing non-existent peer is a no-op, not an error
success = self.wg_manager.remove_peer('non_existent_key') success = self.wg_manager.remove_peer('non_existent_key')
self.assertTrue(success) self.assertTrue(success)
# Test updating non-existent peer IP # Updating IP for peer not in config returns False
success = self.wg_manager.update_peer_ip('non_existent_key', '192.168.1.200') success = self.wg_manager.update_peer_ip('non_existent_key', '10.0.0.9/32')
self.assertFalse(success) self.assertFalse(success)
if __name__ == '__main__': if __name__ == '__main__':
+88 -6
View File
@@ -1,7 +1,36 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Calendar as CalendarIcon, Users, Clock } from 'lucide-react'; import { Calendar as CalendarIcon, Users, Wifi, Copy, CheckCheck } from 'lucide-react';
import { calendarAPI } from '../services/api'; import { calendarAPI } from '../services/api';
const CELL_HOST = 'calendar.cell';
const CELL_IP = '172.20.0.21';
function CopyButton({ text }) {
const [copied, setCopied] = useState(false);
const copy = () => {
navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
};
return (
<button onClick={copy} className="ml-2 text-gray-400 hover:text-gray-600" title="Copy">
{copied ? <CheckCheck className="h-3.5 w-3.5 text-green-500" /> : <Copy className="h-3.5 w-3.5" />}
</button>
);
}
function InfoRow({ label, value }) {
return (
<div className="flex items-center justify-between py-1.5 border-b border-gray-100 last:border-0">
<span className="text-sm text-gray-500 w-32 shrink-0">{label}</span>
<div className="flex items-center">
<span className="text-sm font-mono font-medium text-gray-800">{value}</span>
<CopyButton text={value} />
</div>
</div>
);
}
function Calendar() { function Calendar() {
const [users, setUsers] = useState([]); const [users, setUsers] = useState([]);
const [status, setStatus] = useState(null); const [status, setStatus] = useState(null);
@@ -17,7 +46,6 @@ function Calendar() {
calendarAPI.getUsers(), calendarAPI.getUsers(),
calendarAPI.getStatus() calendarAPI.getStatus()
]); ]);
setUsers(usersResponse.data); setUsers(usersResponse.data);
setStatus(statusResponse.data); setStatus(statusResponse.data);
} catch (error) { } catch (error) {
@@ -38,13 +66,67 @@ function Calendar() {
return ( return (
<div> <div>
<div className="mb-8"> <div className="mb-8">
<h1 className="text-2xl font-bold text-gray-900">Calendar Services</h1> <h1 className="text-2xl font-bold text-gray-900">Calendar &amp; Contacts</h1>
<p className="mt-2 text-gray-600"> <p className="mt-2 text-gray-600">Radicale CalDAV / CardDAV server</p>
Manage Radicale CalDAV and CardDAV services
</p>
</div> </div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Connection Info */}
<div className="card">
<div className="flex items-center mb-4">
<Wifi className="h-5 w-5 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Connect your device</h3>
</div>
<p className="text-xs text-gray-500 mb-3">
Use these settings in your calendar / contacts app (iOS, Android, Thunderbird, etc.)
</p>
<div className="bg-gray-50 rounded-lg px-4 py-2">
<InfoRow label="Server URL" value={`http://${CELL_HOST}`} />
<InfoRow label="CalDAV path" value={`http://${CELL_HOST}/`} />
<InfoRow label="CardDAV path" value={`http://${CELL_HOST}/`} />
<InfoRow label="Port" value="80" />
<InfoRow label="Direct IP" value={CELL_IP} />
<InfoRow label="Protocol" value="HTTP (CalDAV/CardDAV)" />
</div>
<p className="text-xs text-gray-400 mt-3">
Requires VPN connection. DNS server must be set to <span className="font-mono">172.20.0.3</span>.
</p>
</div>
{/* iOS / Android quick guide */}
<div className="card">
<div className="flex items-center mb-4">
<CalendarIcon className="h-5 w-5 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Quick setup guide</h3>
</div>
<div className="space-y-3 text-sm text-gray-700">
<div>
<p className="font-medium text-gray-900 mb-1">iOS (Settings Calendar Accounts)</p>
<ol className="list-decimal ml-4 space-y-0.5 text-xs text-gray-600">
<li>Add Account Other Add CalDAV Account</li>
<li>Server: <span className="font-mono">calendar.cell</span></li>
<li>Enter username &amp; password</li>
<li>For contacts: Add CardDAV Account, same server</li>
</ol>
</div>
<div>
<p className="font-medium text-gray-900 mb-1">Android (DAVx⁵ app)</p>
<ol className="list-decimal ml-4 space-y-0.5 text-xs text-gray-600">
<li>Install DAVx⁵ from Play Store / F-Droid</li>
<li>Login with URL: <span className="font-mono">http://calendar.cell/</span></li>
<li>Select calendars &amp; address books to sync</li>
</ol>
</div>
<div>
<p className="font-medium text-gray-900 mb-1">Thunderbird</p>
<ol className="list-decimal ml-4 space-y-0.5 text-xs text-gray-600">
<li>Calendar New Calendar On the Network</li>
<li>Format: CalDAV, Location: <span className="font-mono">http://calendar.cell/</span></li>
</ol>
</div>
</div>
</div>
{/* Status */} {/* Status */}
<div className="card"> <div className="card">
<div className="flex items-center mb-4"> <div className="flex items-center mb-4">
+80 -10
View File
@@ -1,7 +1,36 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Mail, Users, Send } from 'lucide-react'; import { Mail, Users, Wifi, Copy, CheckCheck } from 'lucide-react';
import { emailAPI } from '../services/api'; import { emailAPI } from '../services/api';
const CELL_HOST = 'mail.cell';
const CELL_IP = '172.20.0.23';
function CopyButton({ text }) {
const [copied, setCopied] = useState(false);
const copy = () => {
navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
};
return (
<button onClick={copy} className="ml-2 text-gray-400 hover:text-gray-600" title="Copy">
{copied ? <CheckCheck className="h-3.5 w-3.5 text-green-500" /> : <Copy className="h-3.5 w-3.5" />}
</button>
);
}
function InfoRow({ label, value }) {
return (
<div className="flex items-center justify-between py-1.5 border-b border-gray-100 last:border-0">
<span className="text-sm text-gray-500 w-36 shrink-0">{label}</span>
<div className="flex items-center">
<span className="text-sm font-mono font-medium text-gray-800">{value}</span>
<CopyButton text={value} />
</div>
</div>
);
}
function Email() { function Email() {
const [users, setUsers] = useState([]); const [users, setUsers] = useState([]);
const [status, setStatus] = useState(null); const [status, setStatus] = useState(null);
@@ -17,7 +46,6 @@ function Email() {
emailAPI.getUsers(), emailAPI.getUsers(),
emailAPI.getStatus() emailAPI.getStatus()
]); ]);
setUsers(usersResponse.data); setUsers(usersResponse.data);
setStatus(statusResponse.data); setStatus(statusResponse.data);
} catch (error) { } catch (error) {
@@ -39,12 +67,54 @@ function Email() {
<div> <div>
<div className="mb-8"> <div className="mb-8">
<h1 className="text-2xl font-bold text-gray-900">Email Services</h1> <h1 className="text-2xl font-bold text-gray-900">Email Services</h1>
<p className="mt-2 text-gray-600"> <p className="mt-2 text-gray-600">Postfix (SMTP) + Dovecot (IMAP)</p>
Manage Postfix and Dovecot email services
</p>
</div> </div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Incoming mail */}
<div className="card">
<div className="flex items-center mb-4">
<Wifi className="h-5 w-5 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Incoming mail (IMAP)</h3>
</div>
<div className="bg-gray-50 rounded-lg px-4 py-2">
<InfoRow label="Server" value={CELL_HOST} />
<InfoRow label="Port" value="993" />
<InfoRow label="Security" value="SSL/TLS" />
<InfoRow label="Direct IP" value={CELL_IP} />
</div>
</div>
{/* Outgoing mail */}
<div className="card">
<div className="flex items-center mb-4">
<Mail className="h-5 w-5 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Outgoing mail (SMTP)</h3>
</div>
<div className="bg-gray-50 rounded-lg px-4 py-2">
<InfoRow label="Server" value={CELL_HOST} />
<InfoRow label="Port" value="587" />
<InfoRow label="Security" value="STARTTLS" />
<InfoRow label="Auth" value="Username + Password" />
</div>
</div>
{/* Webmail */}
<div className="card">
<div className="flex items-center mb-4">
<Mail className="h-5 w-5 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Webmail</h3>
</div>
<div className="bg-gray-50 rounded-lg px-4 py-2">
<InfoRow label="URL" value="http://mail.cell" />
<InfoRow label="Alt URL" value="http://webmail.cell" />
<InfoRow label="Direct IP" value={`http://${CELL_IP}`} />
</div>
<p className="text-xs text-gray-400 mt-3">
Requires VPN + DNS set to <span className="font-mono">172.20.0.3</span>.
</p>
</div>
{/* Status */} {/* Status */}
<div className="card"> <div className="card">
<div className="flex items-center mb-4"> <div className="flex items-center mb-4">
@@ -54,11 +124,11 @@ function Email() {
{status ? ( {status ? (
<div className="space-y-2"> <div className="space-y-2">
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-sm text-gray-500">Postfix:</span> <span className="text-sm text-gray-500">Postfix (SMTP):</span>
<span className="text-sm font-medium text-success-600">Running</span> <span className="text-sm font-medium text-success-600">Running</span>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-sm text-gray-500">Dovecot:</span> <span className="text-sm text-gray-500">Dovecot (IMAP):</span>
<span className="text-sm font-medium text-success-600">Running</span> <span className="text-sm font-medium text-success-600">Running</span>
</div> </div>
</div> </div>
@@ -68,10 +138,10 @@ function Email() {
</div> </div>
{/* Users */} {/* Users */}
<div className="card"> <div className="card lg:col-span-2">
<div className="flex items-center mb-4"> <div className="flex items-center mb-4">
<Users className="h-6 w-6 text-primary-500 mr-2" /> <Users className="h-6 w-6 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Email Users</h3> <h3 className="text-lg font-medium text-gray-900">Email Accounts</h3>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
{users.length > 0 ? ( {users.length > 0 ? (
@@ -82,7 +152,7 @@ function Email() {
</div> </div>
)) ))
) : ( ) : (
<p className="text-gray-500 text-sm">No email users configured</p> <p className="text-gray-500 text-sm">No email accounts configured</p>
)} )}
</div> </div>
</div> </div>
+106 -21
View File
@@ -1,7 +1,38 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { FolderOpen, Users, HardDrive } from 'lucide-react'; import { FolderOpen, Users, HardDrive, Wifi, Copy, CheckCheck } from 'lucide-react';
import { fileAPI } from '../services/api'; import { fileAPI } from '../services/api';
const FILES_HOST = 'files.cell';
const FILES_IP = '172.20.0.22';
const WEBDAV_HOST = 'webdav.cell';
const WEBDAV_IP = '172.20.0.24';
function CopyButton({ text }) {
const [copied, setCopied] = useState(false);
const copy = () => {
navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
};
return (
<button onClick={copy} className="ml-2 text-gray-400 hover:text-gray-600" title="Copy">
{copied ? <CheckCheck className="h-3.5 w-3.5 text-green-500" /> : <Copy className="h-3.5 w-3.5" />}
</button>
);
}
function InfoRow({ label, value }) {
return (
<div className="flex items-center justify-between py-1.5 border-b border-gray-100 last:border-0">
<span className="text-sm text-gray-500 w-36 shrink-0">{label}</span>
<div className="flex items-center">
<span className="text-sm font-mono font-medium text-gray-800">{value}</span>
<CopyButton text={value} />
</div>
</div>
);
}
function Files() { function Files() {
const [users, setUsers] = useState([]); const [users, setUsers] = useState([]);
const [status, setStatus] = useState(null); const [status, setStatus] = useState(null);
@@ -17,7 +48,6 @@ function Files() {
fileAPI.getUsers(), fileAPI.getUsers(),
fileAPI.getStatus() fileAPI.getStatus()
]); ]);
setUsers(usersResponse.data); setUsers(usersResponse.data);
setStatus(statusResponse.data); setStatus(statusResponse.data);
} catch (error) { } catch (error) {
@@ -39,12 +69,69 @@ function Files() {
<div> <div>
<div className="mb-8"> <div className="mb-8">
<h1 className="text-2xl font-bold text-gray-900">File Storage</h1> <h1 className="text-2xl font-bold text-gray-900">File Storage</h1>
<p className="mt-2 text-gray-600"> <p className="mt-2 text-gray-600">FileGator (browser) + WebDAV (native clients)</p>
Manage WebDAV file storage services
</p>
</div> </div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* File Manager */}
<div className="card">
<div className="flex items-center mb-4">
<Wifi className="h-5 w-5 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Web file manager</h3>
</div>
<div className="bg-gray-50 rounded-lg px-4 py-2">
<InfoRow label="URL" value={`http://${FILES_HOST}`} />
<InfoRow label="Direct IP" value={`http://${FILES_IP}`} />
<InfoRow label="Port" value="80" />
</div>
<p className="text-xs text-gray-400 mt-3">
Browser-based file manager. Requires VPN.
</p>
</div>
{/* WebDAV */}
<div className="card">
<div className="flex items-center mb-4">
<FolderOpen className="h-5 w-5 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">WebDAV (mount as drive)</h3>
</div>
<div className="bg-gray-50 rounded-lg px-4 py-2">
<InfoRow label="URL" value={`http://${WEBDAV_HOST}`} />
<InfoRow label="Direct IP" value={`http://${WEBDAV_IP}`} />
<InfoRow label="Port" value="80" />
<InfoRow label="Auth" value="Basic (user / password)" />
</div>
<p className="text-xs text-gray-400 mt-3">
Mount in macOS Finder, Windows Explorer, or any WebDAV client.
</p>
</div>
{/* OS quick guide */}
<div className="card">
<div className="flex items-center mb-4">
<HardDrive className="h-5 w-5 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Mount as network drive</h3>
</div>
<div className="space-y-3 text-sm">
<div>
<p className="font-medium text-gray-900 mb-1">macOS (Finder)</p>
<p className="text-xs text-gray-600">Go Connect to Server <span className="font-mono">http://webdav.cell</span></p>
</div>
<div>
<p className="font-medium text-gray-900 mb-1">Windows</p>
<p className="text-xs text-gray-600">Map Network Drive <span className="font-mono">\\webdav.cell\DavWWWRoot</span> or use <span className="font-mono">http://webdav.cell</span> in "Connect to a Web Site"</p>
</div>
<div>
<p className="font-medium text-gray-900 mb-1">iOS (Files app)</p>
<p className="text-xs text-gray-600">Files ... Connect to Server <span className="font-mono">http://webdav.cell</span></p>
</div>
<div>
<p className="font-medium text-gray-900 mb-1">Android</p>
<p className="text-xs text-gray-600">Use <strong>Solid Explorer</strong> or <strong>FX File Explorer</strong> Add cloud WebDAV</p>
</div>
</div>
</div>
{/* Status */} {/* Status */}
<div className="card"> <div className="card">
<div className="flex items-center mb-4"> <div className="flex items-center mb-4">
@@ -54,12 +141,12 @@ function Files() {
{status ? ( {status ? (
<div className="space-y-2"> <div className="space-y-2">
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-sm text-gray-500">WebDAV:</span> <span className="text-sm text-gray-500">FileGator:</span>
<span className="text-sm font-medium text-success-600">Running</span> <span className="text-sm font-medium text-success-600">Running</span>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-sm text-gray-500">Storage:</span> <span className="text-sm text-gray-500">WebDAV:</span>
<span className="text-sm font-medium text-success-600">Available</span> <span className="text-sm font-medium text-success-600">Running</span>
</div> </div>
</div> </div>
) : ( ) : (
@@ -68,24 +155,22 @@ function Files() {
</div> </div>
{/* Users */} {/* Users */}
<div className="card"> {users.length > 0 && (
<div className="flex items-center mb-4"> <div className="card lg:col-span-2">
<Users className="h-6 w-6 text-primary-500 mr-2" /> <div className="flex items-center mb-4">
<h3 className="text-lg font-medium text-gray-900">Storage Users</h3> <Users className="h-6 w-6 text-primary-500 mr-2" />
</div> <h3 className="text-lg font-medium text-gray-900">Storage Users</h3>
<div className="space-y-2"> </div>
{users.length > 0 ? ( <div className="space-y-2">
users.map((user, index) => ( {users.map((user, index) => (
<div key={index} className="flex justify-between items-center p-2 bg-gray-50 rounded"> <div key={index} className="flex justify-between items-center p-2 bg-gray-50 rounded">
<span className="text-sm font-medium">{user.username}</span> <span className="text-sm font-medium">{user.username}</span>
<span className="text-sm text-gray-500">{user.storage_used || '0'} MB</span> <span className="text-sm text-gray-500">{user.storage_used || '0'} MB</span>
</div> </div>
)) ))}
) : ( </div>
<p className="text-gray-500 text-sm">No storage users configured</p>
)}
</div> </div>
</div> )}
</div> </div>
</div> </div>
); );
+26 -26
View File
@@ -16,7 +16,6 @@ const SERVICES = [
const emptyForm = () => ({ const emptyForm = () => ({
name: '', name: '',
ip: '',
description: '', description: '',
public_key: '', public_key: '',
persistent_keepalive: 25, persistent_keepalive: 25,
@@ -78,14 +77,20 @@ function Peers() {
const fetchPeers = async () => { const fetchPeers = async () => {
try { try {
const [regResp, wgResp] = await Promise.all([peerAPI.getPeers(), wireguardAPI.getPeers()]); const [regResp, statusResp] = await Promise.all([
peerAPI.getPeers(),
wireguardAPI.getPeerStatuses().catch(() => ({ data: {} })),
]);
const regPeers = regResp.data || []; const regPeers = regResp.data || [];
const wgMap = {}; const statusMap = statusResp.data || {};
(wgResp.data || []).forEach(p => { wgMap[p.name] = p; });
const merged = regPeers.map(p => ({ const merged = regPeers.map(p => ({
...p, ...p,
name: p.peer || p.name, name: p.peer || p.name,
wg: wgMap[p.peer || p.name] || {}, online: statusMap[p.public_key]?.online ?? false,
last_handshake: statusMap[p.public_key]?.last_handshake ?? null,
last_handshake_seconds_ago: statusMap[p.public_key]?.last_handshake_seconds_ago ?? null,
transfer_rx: statusMap[p.public_key]?.transfer_rx ?? 0,
transfer_tx: statusMap[p.public_key]?.transfer_tx ?? 0,
})); }));
setPeers(merged); setPeers(merged);
} catch (err) { } catch (err) {
@@ -135,8 +140,6 @@ PersistentKeepalive = ${peer.persistent_keepalive || 25}`;
const errs = {}; const errs = {};
if (!data.name.trim()) errs.name = 'Name is required'; if (!data.name.trim()) errs.name = 'Name is required';
if (!/^[a-zA-Z0-9_-]+$/.test(data.name)) errs.name = 'Only letters, numbers, - and _ allowed'; if (!/^[a-zA-Z0-9_-]+$/.test(data.name)) errs.name = 'Only letters, numbers, - and _ allowed';
if (!data.ip.trim()) errs.ip = 'IP address is required';
if (!/^\d{1,3}(\.\d{1,3}){3}(\/\d+)?$/.test(data.ip.trim())) errs.ip = 'Invalid IP address';
return errs; return errs;
}; };
@@ -167,11 +170,14 @@ PersistentKeepalive = ${peer.persistent_keepalive || 25}`;
service_access: formData.service_access, service_access: formData.service_access,
peer_access: formData.peer_access, peer_access: formData.peer_access,
}; };
await peerAPI.addPeer(peerData); const addResult = await peerAPI.addPeer(peerData);
const assignedIp = addResult.data?.ip;
// Server-side AllowedIPs = peer's VPN IP only (/32).
// Full/split tunnel is a CLIENT-side setting (AllowedIPs in the client config).
await wireguardAPI.addPeer({ await wireguardAPI.addPeer({
name: formData.name, name: formData.name,
public_key: publicKey, public_key: publicKey,
allowed_ips: formData.internet_access ? FULL_TUNNEL_IPS : SPLIT_TUNNEL_IPS, allowed_ips: assignedIp ? `${assignedIp}/32` : `${peerData.ip}/32`,
persistent_keepalive: formData.persistent_keepalive, persistent_keepalive: formData.persistent_keepalive,
}); });
@@ -201,7 +207,6 @@ PersistentKeepalive = ${peer.persistent_keepalive || 25}`;
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
ip: formData.ip,
description: formData.description, description: formData.description,
internet_access: formData.internet_access, internet_access: formData.internet_access,
service_access: formData.service_access, service_access: formData.service_access,
@@ -449,7 +454,12 @@ PersistentKeepalive = ${peer.persistent_keepalive || 25}`;
</div> </div>
</td> </td>
<td className="px-6 py-4"> <td className="px-6 py-4">
<span className="status-indicator status-online">Online</span> <span className={`status-indicator ${peer.online ? 'status-online' : 'status-offline'}`}>
{peer.online ? 'Online' : 'Offline'}
</span>
{peer.last_handshake_seconds_ago != null && (
<div className="text-xs text-gray-400 mt-0.5">{peer.last_handshake_seconds_ago}s ago</div>
)}
</td> </td>
<td className="px-6 py-4"> <td className="px-6 py-4">
<div className="flex space-x-2"> <div className="flex space-x-2">
@@ -493,18 +503,11 @@ PersistentKeepalive = ${peer.persistent_keepalive || 25}`;
{errors.name && <p className="text-xs text-red-600 mt-1">{errors.name}</p>} {errors.name && <p className="text-xs text-red-600 mt-1">{errors.name}</p>}
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">VPN IP *</label> <label className="block text-sm font-medium text-gray-700 mb-1">Description</label>
<input value={formData.ip} <input value={formData.description} onChange={e => setFormData(f => ({ ...f, description: e.target.value }))}
onChange={e => { setFormData(f => ({ ...f, ip: e.target.value })); setErrors(e2 => ({ ...e2, ip: undefined })); }} className="input" placeholder="My laptop" />
className={`input ${errors.ip ? 'border-red-500' : ''}`} placeholder="10.0.0.3" />
{errors.ip && <p className="text-xs text-red-600 mt-1">{errors.ip}</p>}
</div> </div>
</div> </div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Description</label>
<input value={formData.description} onChange={e => setFormData(f => ({ ...f, description: e.target.value }))}
className="input" placeholder="My laptop" />
</div>
<AccessForm data={formData} onChange={updates => setFormData(f => ({ ...f, ...updates }))} /> <AccessForm data={formData} onChange={updates => setFormData(f => ({ ...f, ...updates }))} />
@@ -587,11 +590,8 @@ PersistentKeepalive = ${peer.persistent_keepalive || 25}`;
<input value={formData.name} className="input bg-gray-50" disabled /> <input value={formData.name} className="input bg-gray-50" disabled />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">VPN IP *</label> <label className="block text-sm font-medium text-gray-700 mb-1">VPN IP</label>
<input value={formData.ip} <input value={selectedPeer?.ip || ''} className="input bg-gray-50 font-mono" disabled />
onChange={e => { setFormData(f => ({ ...f, ip: e.target.value })); setErrors(e2 => ({ ...e2, ip: undefined })); }}
className={`input ${errors.ip ? 'border-red-500' : ''}`} />
{errors.ip && <p className="text-xs text-red-600 mt-1">{errors.ip}</p>}
</div> </div>
</div> </div>
<div> <div>
+1
View File
@@ -63,6 +63,7 @@ export const wireguardAPI = {
testConnectivity: (data) => api.post('/api/wireguard/connectivity', data), testConnectivity: (data) => api.post('/api/wireguard/connectivity', data),
updatePeerIP: (data) => api.put('/api/wireguard/peers/ip', data), updatePeerIP: (data) => api.put('/api/wireguard/peers/ip', data),
getPeerConfig: (data) => api.post('/api/wireguard/peers/config', data), getPeerConfig: (data) => api.post('/api/wireguard/peers/config', data),
getPeerStatuses: () => api.get('/api/wireguard/peers/statuses'),
}; };
// Peer Registry API // Peer Registry API