From 53c76618124b33520d765db88ed74fcf67c8efb3 Mon Sep 17 00:00:00 2001 From: Dmitrii Iurco Date: Tue, 21 Apr 2026 01:01:07 -0400 Subject: [PATCH] feat: per-peer access enforcement, live peer status, auto IP assignment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- api/app.py | 63 +++++- api/firewall_manager.py | 305 ++++++++++++++++++++++++++++++ api/wireguard_manager.py | 87 ++++++++- config/caddy/Caddyfile | 16 +- docker-compose.yml | 3 + tests/test_firewall_manager.py | 275 +++++++++++++++++++++++++++ tests/test_peer_wg_integration.py | 124 ++++++++++++ tests/test_wireguard_manager.py | 100 +++++----- webui/src/pages/Calendar.jsx | 278 +++++++++++++++++---------- webui/src/pages/Email.jsx | 258 ++++++++++++++++--------- webui/src/pages/Files.jsx | 273 +++++++++++++++++--------- webui/src/pages/Peers.jsx | 52 ++--- webui/src/services/api.js | 1 + 13 files changed, 1457 insertions(+), 378 deletions(-) create mode 100644 api/firewall_manager.py create mode 100644 tests/test_firewall_manager.py create mode 100644 tests/test_peer_wg_integration.py diff --git a/api/app.py b/api/app.py index 7c189b1..4742029 100644 --- a/api/app.py +++ b/api/app.py @@ -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: diff --git a/api/firewall_manager.py b/api/firewall_manager.py new file mode 100644 index 0000000..8342bc2 --- /dev/null +++ b/api/firewall_manager.py @@ -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 diff --git a/api/wireguard_manager.py b/api/wireguard_manager.py index a91929d..12e989e 100644 --- a/api/wireguard_manager.py +++ b/api/wireguard_manager.py @@ -136,6 +136,10 @@ class WireGuardManager(BaseServiceManager): ) 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') def _read_config(self) -> str: @@ -148,14 +152,95 @@ class WireGuardManager(BaseServiceManager): def _write_config(self, content: str): with open(self._config_file(), 'w') as f: 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 ───────────────────────────────────────────────────────────── def add_peer(self, name: str, public_key: str, endpoint_ip: str, allowed_ips: str = SERVER_NETWORK, 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: + # 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() peer_block = ( f'\n[Peer]\n' diff --git a/config/caddy/Caddyfile b/config/caddy/Caddyfile index 002739e..f385c8a 100644 --- a/config/caddy/Caddyfile +++ b/config/caddy/Caddyfile @@ -2,8 +2,8 @@ auto_https off } -# Main cell domain -http://mycell.cell { +# Main cell domain — no service-IP restriction needed +http://mycell.cell, http://172.20.0.2:80 { handle /api/* { reverse_proxy cell-api:3000 } @@ -21,20 +21,20 @@ http://mycell.cell { } } -# Service aliases -http://calendar.cell { +# Per-service virtual IPs — each gets its own IP so iptables can target them +http://calendar.cell, http://172.20.0.21:80 { reverse_proxy cell-radicale:5232 } -http://files.cell { +http://files.cell, http://172.20.0.22:80 { 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 } -http://webdav.cell { +http://webdav.cell, http://172.20.0.24:80 { reverse_proxy cell-webdav:80 } @@ -42,7 +42,7 @@ http://api.cell { reverse_proxy cell-api:3000 } -# Catch-all for direct IP and localhost access +# Catch-all for direct IP / localhost :80 { handle /api/* { reverse_proxy cell-api:3000 diff --git a/docker-compose.yml b/docker-compose.yml index 501309f..88e5b43 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,6 +13,8 @@ services: - ./data/caddy:/data - ./config/caddy/certs:/config/caddy/certs restart: unless-stopped + cap_add: + - NET_ADMIN networks: cell-network: ipv4_address: 172.20.0.2 @@ -156,6 +158,7 @@ services: - ./data/dns:/app/data/dns - ./config/api:/app/config - ./config/wireguard:/app/config/wireguard + - ./config/dns:/app/config/dns - /var/run/docker.sock:/var/run/docker.sock pid: host restart: unless-stopped diff --git a/tests/test_firewall_manager.py b/tests/test_firewall_manager.py new file mode 100644 index 0000000..4a059d0 --- /dev/null +++ b/tests/test_firewall_manager.py @@ -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() diff --git a/tests/test_peer_wg_integration.py b/tests/test_peer_wg_integration.py new file mode 100644 index 0000000..0c289f8 --- /dev/null +++ b/tests/test_peer_wg_integration.py @@ -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() diff --git a/tests/test_wireguard_manager.py b/tests/test_wireguard_manager.py index 687731f..15301ae 100644 --- a/tests/test_wireguard_manager.py +++ b/tests/test_wireguard_manager.py @@ -26,7 +26,7 @@ from wireguard_manager import WireGuardManager class TestWireGuardManager(unittest.TestCase): """Test cases for WireGuardManager class""" - + def setUp(self): """Set up test environment""" self.test_dir = tempfile.mkdtemp() @@ -34,10 +34,14 @@ class TestWireGuardManager(unittest.TestCase): self.config_dir = os.path.join(self.test_dir, 'config') os.makedirs(self.data_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 self.wg_manager = WireGuardManager(self.data_dir, self.config_dir) - + def tearDown(self): """Clean up test environment""" shutil.rmtree(self.test_dir) @@ -100,54 +104,51 @@ class TestWireGuardManager(unittest.TestCase): def test_generate_config(self): """Test WireGuard configuration generation""" config = self.wg_manager.generate_config('wg0', 51820) - + self.assertIsInstance(config, str) self.assertIn('[Interface]', 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('PostUp', config) self.assertIn('PostDown', config) def test_add_peer(self): - """Test adding a peer to WireGuard configuration""" - # Generate peer keys first + """Test adding a peer — server-side AllowedIPs must be /32.""" peer_keys = self.wg_manager.generate_peer_keys('testpeer') - + success = self.wg_manager.add_peer( 'testpeer', peer_keys['public_key'], - '192.168.1.100', - '172.20.0.0/16', + '', + '10.0.0.2/32', 25 ) - + self.assertTrue(success) - - # Check if config file was created - config_file = os.path.join(self.wg_manager.wireguard_dir, 'wg0.conf') + + config_file = self.wg_manager._config_file() self.assertTrue(os.path.exists(config_file)) - - # Check config content + with open(config_file, 'r') as f: config = f.read() self.assertIn('[Peer]', 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) def test_remove_peer(self): """Test removing a peer from WireGuard configuration""" # Add a peer first 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 success = self.wg_manager.remove_peer(peer_keys['public_key']) self.assertTrue(success) # 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: config = f.read() self.assertNotIn(peer_keys['public_key'], config) @@ -156,7 +157,7 @@ class TestWireGuardManager(unittest.TestCase): """Test getting list of configured peers""" # Add a peer first 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() @@ -221,46 +222,40 @@ class TestWireGuardManager(unittest.TestCase): def test_update_peer_ip(self): """Test updating peer IP address""" - # Add a peer first peer_keys = self.wg_manager.generate_peer_keys('testpeer') - self.wg_manager.add_peer('testpeer', peer_keys['public_key'], '192.168.1.100') - - # Update peer IP - success = self.wg_manager.update_peer_ip(peer_keys['public_key'], '192.168.1.200') + self.wg_manager.add_peer('testpeer', peer_keys['public_key'], '', '10.0.0.2/32') + + success = self.wg_manager.update_peer_ip(peer_keys['public_key'], '10.0.0.9/32') self.assertTrue(success) - - # Check if IP was updated in config - config_file = os.path.join(self.wg_manager.wireguard_dir, 'wg0.conf') - with open(config_file, 'r') as f: + + with open(self.wg_manager._config_file(), 'r') as f: config = f.read() - self.assertIn('192.168.1.200', config) + self.assertIn('10.0.0.9/32', config) def test_get_peer_config(self): - """Test generating peer configuration""" + """Test generating peer client configuration.""" peer_keys = self.wg_manager.generate_peer_keys('testpeer') 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.assertIn('[Interface]', config) self.assertIn('[Peer]', config) self.assertIn('PrivateKey', config) - self.assertIn('Address = 192.168.1.100/32', config) - self.assertIn('DNS = 172.20.0.2', config) + self.assertIn('Address = 10.0.0.2/32', config) + self.assertIn('DNS = 172.20.0.3', 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): """Test managing multiple peers""" - # Add first peer 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) - - # Add second peer + 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) # Get peers @@ -310,18 +305,21 @@ PersistentKeepalive = 30 self.assertEqual(peers[1]['persistent_keepalive'], 30) def test_error_handling(self): - """Test error handling in WireGuard operations""" - # Test with invalid public key - success = self.wg_manager.add_peer('testpeer', 'invalid_key', '192.168.1.100') - # Should still return True as it writes to config file + """Test error handling in WireGuard operations.""" + # Wide CIDR rejected — server-side AllowedIPs must be /32 + success = self.wg_manager.add_peer('testpeer', 'invalid_key', '', '172.20.0.0/16') + 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) - - # 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') self.assertTrue(success) - - # Test updating non-existent peer IP - success = self.wg_manager.update_peer_ip('non_existent_key', '192.168.1.200') + + # Updating IP for peer not in config returns False + success = self.wg_manager.update_peer_ip('non_existent_key', '10.0.0.9/32') self.assertFalse(success) if __name__ == '__main__': diff --git a/webui/src/pages/Calendar.jsx b/webui/src/pages/Calendar.jsx index 066311f..0573382 100644 --- a/webui/src/pages/Calendar.jsx +++ b/webui/src/pages/Calendar.jsx @@ -1,98 +1,180 @@ -import { useState, useEffect } from 'react'; -import { Calendar as CalendarIcon, Users, Clock } from 'lucide-react'; -import { calendarAPI } from '../services/api'; - -function Calendar() { - const [users, setUsers] = useState([]); - const [status, setStatus] = useState(null); - const [isLoading, setIsLoading] = useState(true); - - useEffect(() => { - fetchCalendarData(); - }, []); - - const fetchCalendarData = async () => { - try { - const [usersResponse, statusResponse] = await Promise.all([ - calendarAPI.getUsers(), - calendarAPI.getStatus() - ]); - - setUsers(usersResponse.data); - setStatus(statusResponse.data); - } catch (error) { - console.error('Failed to fetch calendar data:', error); - } finally { - setIsLoading(false); - } - }; - - if (isLoading) { - return ( -
-
-
- ); - } - - return ( -
-
-

Calendar Services

-

- Manage Radicale CalDAV and CardDAV services -

-
- -
- {/* Status */} -
-
- -

Service Status

-
- {status ? ( -
-
- Radicale: - Running -
-
- CalDAV: - Active -
-
- CardDAV: - Active -
-
- ) : ( -

Status unavailable

- )} -
- - {/* Users */} -
-
- -

Calendar Users

-
-
- {users.length > 0 ? ( - users.map((user, index) => ( -
- {user.username} - {user.calendars || 0} calendars -
- )) - ) : ( -

No calendar users configured

- )} -
-
-
-
- ); -} - -export default Calendar; \ No newline at end of file +import { useState, useEffect } from 'react'; +import { Calendar as CalendarIcon, Users, Wifi, Copy, CheckCheck } from 'lucide-react'; +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 ( + + ); +} + +function InfoRow({ label, value }) { + return ( +
+ {label} +
+ {value} + +
+
+ ); +} + +function Calendar() { + const [users, setUsers] = useState([]); + const [status, setStatus] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + fetchCalendarData(); + }, []); + + const fetchCalendarData = async () => { + try { + const [usersResponse, statusResponse] = await Promise.all([ + calendarAPI.getUsers(), + calendarAPI.getStatus() + ]); + setUsers(usersResponse.data); + setStatus(statusResponse.data); + } catch (error) { + console.error('Failed to fetch calendar data:', error); + } finally { + setIsLoading(false); + } + }; + + if (isLoading) { + return ( +
+
+
+ ); + } + + return ( +
+
+

Calendar & Contacts

+

Radicale CalDAV / CardDAV server

+
+ +
+ {/* Connection Info */} +
+
+ +

Connect your device

+
+

+ Use these settings in your calendar / contacts app (iOS, Android, Thunderbird, etc.) +

+
+ + + + + + +
+

+ Requires VPN connection. DNS server must be set to 172.20.0.3. +

+
+ + {/* iOS / Android quick guide */} +
+
+ +

Quick setup guide

+
+
+
+

iOS (Settings → Calendar → Accounts)

+
    +
  1. Add Account → Other → Add CalDAV Account
  2. +
  3. Server: calendar.cell
  4. +
  5. Enter username & password
  6. +
  7. For contacts: Add CardDAV Account, same server
  8. +
+
+
+

Android (DAVx⁵ app)

+
    +
  1. Install DAVx⁵ from Play Store / F-Droid
  2. +
  3. Login with URL: http://calendar.cell/
  4. +
  5. Select calendars & address books to sync
  6. +
+
+
+

Thunderbird

+
    +
  1. Calendar → New Calendar → On the Network
  2. +
  3. Format: CalDAV, Location: http://calendar.cell/
  4. +
+
+
+
+ + {/* Status */} +
+
+ +

Service Status

+
+ {status ? ( +
+
+ Radicale: + Running +
+
+ CalDAV: + Active +
+
+ CardDAV: + Active +
+
+ ) : ( +

Status unavailable

+ )} +
+ + {/* Users */} +
+
+ +

Calendar Users

+
+
+ {users.length > 0 ? ( + users.map((user, index) => ( +
+ {user.username} + {user.calendars || 0} calendars +
+ )) + ) : ( +

No calendar users configured

+ )} +
+
+
+
+ ); +} + +export default Calendar; diff --git a/webui/src/pages/Email.jsx b/webui/src/pages/Email.jsx index 9e67c50..d162dd5 100644 --- a/webui/src/pages/Email.jsx +++ b/webui/src/pages/Email.jsx @@ -1,94 +1,164 @@ -import { useState, useEffect } from 'react'; -import { Mail, Users, Send } from 'lucide-react'; -import { emailAPI } from '../services/api'; - -function Email() { - const [users, setUsers] = useState([]); - const [status, setStatus] = useState(null); - const [isLoading, setIsLoading] = useState(true); - - useEffect(() => { - fetchEmailData(); - }, []); - - const fetchEmailData = async () => { - try { - const [usersResponse, statusResponse] = await Promise.all([ - emailAPI.getUsers(), - emailAPI.getStatus() - ]); - - setUsers(usersResponse.data); - setStatus(statusResponse.data); - } catch (error) { - console.error('Failed to fetch email data:', error); - } finally { - setIsLoading(false); - } - }; - - if (isLoading) { - return ( -
-
-
- ); - } - - return ( -
-
-

Email Services

-

- Manage Postfix and Dovecot email services -

-
- -
- {/* Status */} -
-
- -

Service Status

-
- {status ? ( -
-
- Postfix: - Running -
-
- Dovecot: - Running -
-
- ) : ( -

Status unavailable

- )} -
- - {/* Users */} -
-
- -

Email Users

-
-
- {users.length > 0 ? ( - users.map((user, index) => ( -
- {user.username} - {user.domain} -
- )) - ) : ( -

No email users configured

- )} -
-
-
-
- ); -} - -export default Email; \ No newline at end of file +import { useState, useEffect } from 'react'; +import { Mail, Users, Wifi, Copy, CheckCheck } from 'lucide-react'; +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 ( + + ); +} + +function InfoRow({ label, value }) { + return ( +
+ {label} +
+ {value} + +
+
+ ); +} + +function Email() { + const [users, setUsers] = useState([]); + const [status, setStatus] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + fetchEmailData(); + }, []); + + const fetchEmailData = async () => { + try { + const [usersResponse, statusResponse] = await Promise.all([ + emailAPI.getUsers(), + emailAPI.getStatus() + ]); + setUsers(usersResponse.data); + setStatus(statusResponse.data); + } catch (error) { + console.error('Failed to fetch email data:', error); + } finally { + setIsLoading(false); + } + }; + + if (isLoading) { + return ( +
+
+
+ ); + } + + return ( +
+
+

Email Services

+

Postfix (SMTP) + Dovecot (IMAP)

+
+ +
+ {/* Incoming mail */} +
+
+ +

Incoming mail (IMAP)

+
+
+ + + + +
+
+ + {/* Outgoing mail */} +
+
+ +

Outgoing mail (SMTP)

+
+
+ + + + +
+
+ + {/* Webmail */} +
+
+ +

Webmail

+
+
+ + + +
+

+ Requires VPN + DNS set to 172.20.0.3. +

+
+ + {/* Status */} +
+
+ +

Service Status

+
+ {status ? ( +
+
+ Postfix (SMTP): + Running +
+
+ Dovecot (IMAP): + Running +
+
+ ) : ( +

Status unavailable

+ )} +
+ + {/* Users */} +
+
+ +

Email Accounts

+
+
+ {users.length > 0 ? ( + users.map((user, index) => ( +
+ {user.username} + {user.domain} +
+ )) + ) : ( +

No email accounts configured

+ )} +
+
+
+
+ ); +} + +export default Email; diff --git a/webui/src/pages/Files.jsx b/webui/src/pages/Files.jsx index 465d448..046380c 100644 --- a/webui/src/pages/Files.jsx +++ b/webui/src/pages/Files.jsx @@ -1,94 +1,179 @@ -import { useState, useEffect } from 'react'; -import { FolderOpen, Users, HardDrive } from 'lucide-react'; -import { fileAPI } from '../services/api'; - -function Files() { - const [users, setUsers] = useState([]); - const [status, setStatus] = useState(null); - const [isLoading, setIsLoading] = useState(true); - - useEffect(() => { - fetchFilesData(); - }, []); - - const fetchFilesData = async () => { - try { - const [usersResponse, statusResponse] = await Promise.all([ - fileAPI.getUsers(), - fileAPI.getStatus() - ]); - - setUsers(usersResponse.data); - setStatus(statusResponse.data); - } catch (error) { - console.error('Failed to fetch files data:', error); - } finally { - setIsLoading(false); - } - }; - - if (isLoading) { - return ( -
-
-
- ); - } - - return ( -
-
-

File Storage

-

- Manage WebDAV file storage services -

-
- -
- {/* Status */} -
-
- -

Service Status

-
- {status ? ( -
-
- WebDAV: - Running -
-
- Storage: - Available -
-
- ) : ( -

Status unavailable

- )} -
- - {/* Users */} -
-
- -

Storage Users

-
-
- {users.length > 0 ? ( - users.map((user, index) => ( -
- {user.username} - {user.storage_used || '0'} MB -
- )) - ) : ( -

No storage users configured

- )} -
-
-
-
- ); -} - -export default Files; \ No newline at end of file +import { useState, useEffect } from 'react'; +import { FolderOpen, Users, HardDrive, Wifi, Copy, CheckCheck } from 'lucide-react'; +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 ( + + ); +} + +function InfoRow({ label, value }) { + return ( +
+ {label} +
+ {value} + +
+
+ ); +} + +function Files() { + const [users, setUsers] = useState([]); + const [status, setStatus] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + fetchFilesData(); + }, []); + + const fetchFilesData = async () => { + try { + const [usersResponse, statusResponse] = await Promise.all([ + fileAPI.getUsers(), + fileAPI.getStatus() + ]); + setUsers(usersResponse.data); + setStatus(statusResponse.data); + } catch (error) { + console.error('Failed to fetch files data:', error); + } finally { + setIsLoading(false); + } + }; + + if (isLoading) { + return ( +
+
+
+ ); + } + + return ( +
+
+

File Storage

+

FileGator (browser) + WebDAV (native clients)

+
+ +
+ {/* File Manager */} +
+
+ +

Web file manager

+
+
+ + + +
+

+ Browser-based file manager. Requires VPN. +

+
+ + {/* WebDAV */} +
+
+ +

WebDAV (mount as drive)

+
+
+ + + + +
+

+ Mount in macOS Finder, Windows Explorer, or any WebDAV client. +

+
+ + {/* OS quick guide */} +
+
+ +

Mount as network drive

+
+
+
+

macOS (Finder)

+

Go → Connect to Server → http://webdav.cell

+
+
+

Windows

+

Map Network Drive → \\webdav.cell\DavWWWRoot or use http://webdav.cell in "Connect to a Web Site"

+
+
+

iOS (Files app)

+

Files → ... → Connect to Server → http://webdav.cell

+
+
+

Android

+

Use Solid Explorer or FX File Explorer → Add cloud → WebDAV

+
+
+
+ + {/* Status */} +
+
+ +

Service Status

+
+ {status ? ( +
+
+ FileGator: + Running +
+
+ WebDAV: + Running +
+
+ ) : ( +

Status unavailable

+ )} +
+ + {/* Users */} + {users.length > 0 && ( +
+
+ +

Storage Users

+
+
+ {users.map((user, index) => ( +
+ {user.username} + {user.storage_used || '0'} MB +
+ ))} +
+
+ )} +
+
+ ); +} + +export default Files; diff --git a/webui/src/pages/Peers.jsx b/webui/src/pages/Peers.jsx index b7a42a5..9b6e55a 100644 --- a/webui/src/pages/Peers.jsx +++ b/webui/src/pages/Peers.jsx @@ -16,7 +16,6 @@ const SERVICES = [ const emptyForm = () => ({ name: '', - ip: '', description: '', public_key: '', persistent_keepalive: 25, @@ -78,14 +77,20 @@ function Peers() { const fetchPeers = async () => { 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 wgMap = {}; - (wgResp.data || []).forEach(p => { wgMap[p.name] = p; }); + const statusMap = statusResp.data || {}; const merged = regPeers.map(p => ({ ...p, 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); } catch (err) { @@ -135,8 +140,6 @@ PersistentKeepalive = ${peer.persistent_keepalive || 25}`; const errs = {}; 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 (!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; }; @@ -167,11 +170,14 @@ PersistentKeepalive = ${peer.persistent_keepalive || 25}`; service_access: formData.service_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({ name: formData.name, 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, }); @@ -201,7 +207,6 @@ PersistentKeepalive = ${peer.persistent_keepalive || 25}`; method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - ip: formData.ip, description: formData.description, internet_access: formData.internet_access, service_access: formData.service_access, @@ -449,7 +454,12 @@ PersistentKeepalive = ${peer.persistent_keepalive || 25}`; - Online + + {peer.online ? 'Online' : 'Offline'} + + {peer.last_handshake_seconds_ago != null && ( +
{peer.last_handshake_seconds_ago}s ago
+ )}
@@ -493,18 +503,11 @@ PersistentKeepalive = ${peer.persistent_keepalive || 25}`; {errors.name &&

{errors.name}

}
- - { setFormData(f => ({ ...f, ip: e.target.value })); setErrors(e2 => ({ ...e2, ip: undefined })); }} - className={`input ${errors.ip ? 'border-red-500' : ''}`} placeholder="10.0.0.3" /> - {errors.ip &&

{errors.ip}

} + + setFormData(f => ({ ...f, description: e.target.value }))} + className="input" placeholder="My laptop" />
-
- - setFormData(f => ({ ...f, description: e.target.value }))} - className="input" placeholder="My laptop" /> -
setFormData(f => ({ ...f, ...updates }))} /> @@ -587,11 +590,8 @@ PersistentKeepalive = ${peer.persistent_keepalive || 25}`;
- - { setFormData(f => ({ ...f, ip: e.target.value })); setErrors(e2 => ({ ...e2, ip: undefined })); }} - className={`input ${errors.ip ? 'border-red-500' : ''}`} /> - {errors.ip &&

{errors.ip}

} + +
diff --git a/webui/src/services/api.js b/webui/src/services/api.js index 74b2118..5648e6c 100644 --- a/webui/src/services/api.js +++ b/webui/src/services/api.js @@ -63,6 +63,7 @@ export const wireguardAPI = { testConnectivity: (data) => api.post('/api/wireguard/connectivity', data), updatePeerIP: (data) => api.put('/api/wireguard/peers/ip', data), getPeerConfig: (data) => api.post('/api/wireguard/peers/config', data), + getPeerStatuses: () => api.get('/api/wireguard/peers/statuses'), }; // Peer Registry API