feat: per-peer access enforcement, live peer status, auto IP assignment
Server-side access control: - firewall_manager.py: per-peer iptables FORWARD rules in WireGuard container; virtual IPs on Caddy (172.20.0.21-24) for per-service DROP/ACCEPT targeting - CoreDNS Corefile regenerated with ACL blocks for blocked services per peer - POST /api/wireguard/apply-enforcement re-applies rules after WireGuard restart; wg0.conf PostUp calls it via curl so rules restore automatically on container start WireGuard fixes: - _syncconf uses `wg set peer` instead of `wg syncconf` to avoid resetting ListenPort - add_peer validates AllowedIPs must be /32 — rejects full/split tunnel CIDRs that would route internet or LAN traffic to that peer - _config_file() checks for linuxserver wg_confs/ subdirectory first UI: - Peers page fetches /api/wireguard/peers/statuses for live handshake data; status badge now shows real Online/Offline + seconds since last handshake - IP field removed from Add Peer form (auto-assigned from 10.0.0.0/24) Tests (246 pass): - test_firewall_manager.py: 22 tests for ACL generation, iptables rule correctness, comment tagging, clear_peer_rules filter logic - test_peer_wg_integration.py: 10 tests for /32 enforcement, IP auto-assignment, syncconf called on add/remove - test_wireguard_manager.py: updated to reflect correct IPs and /32 requirement Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+57
-6
@@ -41,6 +41,7 @@ from container_manager import ContainerManager
|
|||||||
from config_manager import ConfigManager
|
from 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."""
|
||||||
@@ -968,17 +1009,19 @@ def add_peer():
|
|||||||
data = request.get_json(silent=True)
|
data = request.get_json(silent=True)
|
||||||
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:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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'
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -26,7 +26,7 @@ from wireguard_manager import WireGuardManager
|
|||||||
|
|
||||||
class TestWireGuardManager(unittest.TestCase):
|
class TestWireGuardManager(unittest.TestCase):
|
||||||
"""Test cases for WireGuardManager class"""
|
"""Test cases for WireGuardManager class"""
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
"""Set up test environment"""
|
"""Set up test environment"""
|
||||||
self.test_dir = tempfile.mkdtemp()
|
self.test_dir = tempfile.mkdtemp()
|
||||||
@@ -34,10 +34,14 @@ class TestWireGuardManager(unittest.TestCase):
|
|||||||
self.config_dir = os.path.join(self.test_dir, 'config')
|
self.config_dir = os.path.join(self.test_dir, 'config')
|
||||||
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)
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
"""Clean up test environment"""
|
"""Clean up test environment"""
|
||||||
shutil.rmtree(self.test_dir)
|
shutil.rmtree(self.test_dir)
|
||||||
@@ -100,54 +104,51 @@ class TestWireGuardManager(unittest.TestCase):
|
|||||||
def test_generate_config(self):
|
def test_generate_config(self):
|
||||||
"""Test WireGuard configuration generation"""
|
"""Test WireGuard configuration generation"""
|
||||||
config = self.wg_manager.generate_config('wg0', 51820)
|
config = self.wg_manager.generate_config('wg0', 51820)
|
||||||
|
|
||||||
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__':
|
||||||
|
|||||||
+180
-98
@@ -1,98 +1,180 @@
|
|||||||
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';
|
||||||
|
|
||||||
function Calendar() {
|
const CELL_HOST = 'calendar.cell';
|
||||||
const [users, setUsers] = useState([]);
|
const CELL_IP = '172.20.0.21';
|
||||||
const [status, setStatus] = useState(null);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
function CopyButton({ text }) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
useEffect(() => {
|
const copy = () => {
|
||||||
fetchCalendarData();
|
navigator.clipboard.writeText(text);
|
||||||
}, []);
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 1500);
|
||||||
const fetchCalendarData = async () => {
|
};
|
||||||
try {
|
return (
|
||||||
const [usersResponse, statusResponse] = await Promise.all([
|
<button onClick={copy} className="ml-2 text-gray-400 hover:text-gray-600" title="Copy">
|
||||||
calendarAPI.getUsers(),
|
{copied ? <CheckCheck className="h-3.5 w-3.5 text-green-500" /> : <Copy className="h-3.5 w-3.5" />}
|
||||||
calendarAPI.getStatus()
|
</button>
|
||||||
]);
|
);
|
||||||
|
}
|
||||||
setUsers(usersResponse.data);
|
|
||||||
setStatus(statusResponse.data);
|
function InfoRow({ label, value }) {
|
||||||
} catch (error) {
|
return (
|
||||||
console.error('Failed to fetch calendar data:', error);
|
<div className="flex items-center justify-between py-1.5 border-b border-gray-100 last:border-0">
|
||||||
} finally {
|
<span className="text-sm text-gray-500 w-32 shrink-0">{label}</span>
|
||||||
setIsLoading(false);
|
<div className="flex items-center">
|
||||||
}
|
<span className="text-sm font-mono font-medium text-gray-800">{value}</span>
|
||||||
};
|
<CopyButton text={value} />
|
||||||
|
</div>
|
||||||
if (isLoading) {
|
</div>
|
||||||
return (
|
);
|
||||||
<div className="flex items-center justify-center h-64">
|
}
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
|
||||||
</div>
|
function Calendar() {
|
||||||
);
|
const [users, setUsers] = useState([]);
|
||||||
}
|
const [status, setStatus] = useState(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
return (
|
|
||||||
<div>
|
useEffect(() => {
|
||||||
<div className="mb-8">
|
fetchCalendarData();
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Calendar Services</h1>
|
}, []);
|
||||||
<p className="mt-2 text-gray-600">
|
|
||||||
Manage Radicale CalDAV and CardDAV services
|
const fetchCalendarData = async () => {
|
||||||
</p>
|
try {
|
||||||
</div>
|
const [usersResponse, statusResponse] = await Promise.all([
|
||||||
|
calendarAPI.getUsers(),
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
calendarAPI.getStatus()
|
||||||
{/* Status */}
|
]);
|
||||||
<div className="card">
|
setUsers(usersResponse.data);
|
||||||
<div className="flex items-center mb-4">
|
setStatus(statusResponse.data);
|
||||||
<CalendarIcon className="h-6 w-6 text-primary-500 mr-2" />
|
} catch (error) {
|
||||||
<h3 className="text-lg font-medium text-gray-900">Service Status</h3>
|
console.error('Failed to fetch calendar data:', error);
|
||||||
</div>
|
} finally {
|
||||||
{status ? (
|
setIsLoading(false);
|
||||||
<div className="space-y-2">
|
}
|
||||||
<div className="flex justify-between">
|
};
|
||||||
<span className="text-sm text-gray-500">Radicale:</span>
|
|
||||||
<span className="text-sm font-medium text-success-600">Running</span>
|
if (isLoading) {
|
||||||
</div>
|
return (
|
||||||
<div className="flex justify-between">
|
<div className="flex items-center justify-center h-64">
|
||||||
<span className="text-sm text-gray-500">CalDAV:</span>
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||||
<span className="text-sm font-medium text-success-600">Active</span>
|
</div>
|
||||||
</div>
|
);
|
||||||
<div className="flex justify-between">
|
}
|
||||||
<span className="text-sm text-gray-500">CardDAV:</span>
|
|
||||||
<span className="text-sm font-medium text-success-600">Active</span>
|
return (
|
||||||
</div>
|
<div>
|
||||||
</div>
|
<div className="mb-8">
|
||||||
) : (
|
<h1 className="text-2xl font-bold text-gray-900">Calendar & Contacts</h1>
|
||||||
<p className="text-gray-500 text-sm">Status unavailable</p>
|
<p className="mt-2 text-gray-600">Radicale CalDAV / CardDAV server</p>
|
||||||
)}
|
</div>
|
||||||
</div>
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
{/* Users */}
|
{/* Connection Info */}
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<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" />
|
<Wifi className="h-5 w-5 text-primary-500 mr-2" />
|
||||||
<h3 className="text-lg font-medium text-gray-900">Calendar Users</h3>
|
<h3 className="text-lg font-medium text-gray-900">Connect your device</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<p className="text-xs text-gray-500 mb-3">
|
||||||
{users.length > 0 ? (
|
Use these settings in your calendar / contacts app (iOS, Android, Thunderbird, etc.)
|
||||||
users.map((user, index) => (
|
</p>
|
||||||
<div key={index} className="flex justify-between items-center p-2 bg-gray-50 rounded">
|
<div className="bg-gray-50 rounded-lg px-4 py-2">
|
||||||
<span className="text-sm font-medium">{user.username}</span>
|
<InfoRow label="Server URL" value={`http://${CELL_HOST}`} />
|
||||||
<span className="text-sm text-gray-500">{user.calendars || 0} calendars</span>
|
<InfoRow label="CalDAV path" value={`http://${CELL_HOST}/`} />
|
||||||
</div>
|
<InfoRow label="CardDAV path" value={`http://${CELL_HOST}/`} />
|
||||||
))
|
<InfoRow label="Port" value="80" />
|
||||||
) : (
|
<InfoRow label="Direct IP" value={CELL_IP} />
|
||||||
<p className="text-gray-500 text-sm">No calendar users configured</p>
|
<InfoRow label="Protocol" value="HTTP (CalDAV/CardDAV)" />
|
||||||
)}
|
</div>
|
||||||
</div>
|
<p className="text-xs text-gray-400 mt-3">
|
||||||
</div>
|
Requires VPN connection. DNS server must be set to <span className="font-mono">172.20.0.3</span>.
|
||||||
</div>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
|
||||||
}
|
{/* iOS / Android quick guide */}
|
||||||
|
<div className="card">
|
||||||
export default Calendar;
|
<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 & 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 & 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 */}
|
||||||
|
<div className="card">
|
||||||
|
<div className="flex items-center mb-4">
|
||||||
|
<CalendarIcon className="h-6 w-6 text-primary-500 mr-2" />
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">Service Status</h3>
|
||||||
|
</div>
|
||||||
|
{status ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-sm text-gray-500">Radicale:</span>
|
||||||
|
<span className="text-sm font-medium text-success-600">Running</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-sm text-gray-500">CalDAV:</span>
|
||||||
|
<span className="text-sm font-medium text-success-600">Active</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-sm text-gray-500">CardDAV:</span>
|
||||||
|
<span className="text-sm font-medium text-success-600">Active</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-gray-500 text-sm">Status unavailable</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Users */}
|
||||||
|
<div className="card">
|
||||||
|
<div className="flex items-center mb-4">
|
||||||
|
<Users className="h-6 w-6 text-primary-500 mr-2" />
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">Calendar Users</h3>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{users.length > 0 ? (
|
||||||
|
users.map((user, index) => (
|
||||||
|
<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 text-gray-500">{user.calendars || 0} calendars</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className="text-gray-500 text-sm">No calendar users configured</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Calendar;
|
||||||
|
|||||||
+164
-94
@@ -1,94 +1,164 @@
|
|||||||
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';
|
||||||
|
|
||||||
function Email() {
|
const CELL_HOST = 'mail.cell';
|
||||||
const [users, setUsers] = useState([]);
|
const CELL_IP = '172.20.0.23';
|
||||||
const [status, setStatus] = useState(null);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
function CopyButton({ text }) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
useEffect(() => {
|
const copy = () => {
|
||||||
fetchEmailData();
|
navigator.clipboard.writeText(text);
|
||||||
}, []);
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 1500);
|
||||||
const fetchEmailData = async () => {
|
};
|
||||||
try {
|
return (
|
||||||
const [usersResponse, statusResponse] = await Promise.all([
|
<button onClick={copy} className="ml-2 text-gray-400 hover:text-gray-600" title="Copy">
|
||||||
emailAPI.getUsers(),
|
{copied ? <CheckCheck className="h-3.5 w-3.5 text-green-500" /> : <Copy className="h-3.5 w-3.5" />}
|
||||||
emailAPI.getStatus()
|
</button>
|
||||||
]);
|
);
|
||||||
|
}
|
||||||
setUsers(usersResponse.data);
|
|
||||||
setStatus(statusResponse.data);
|
function InfoRow({ label, value }) {
|
||||||
} catch (error) {
|
return (
|
||||||
console.error('Failed to fetch email data:', error);
|
<div className="flex items-center justify-between py-1.5 border-b border-gray-100 last:border-0">
|
||||||
} finally {
|
<span className="text-sm text-gray-500 w-36 shrink-0">{label}</span>
|
||||||
setIsLoading(false);
|
<div className="flex items-center">
|
||||||
}
|
<span className="text-sm font-mono font-medium text-gray-800">{value}</span>
|
||||||
};
|
<CopyButton text={value} />
|
||||||
|
</div>
|
||||||
if (isLoading) {
|
</div>
|
||||||
return (
|
);
|
||||||
<div className="flex items-center justify-center h-64">
|
}
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
|
||||||
</div>
|
function Email() {
|
||||||
);
|
const [users, setUsers] = useState([]);
|
||||||
}
|
const [status, setStatus] = useState(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
return (
|
|
||||||
<div>
|
useEffect(() => {
|
||||||
<div className="mb-8">
|
fetchEmailData();
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Email Services</h1>
|
}, []);
|
||||||
<p className="mt-2 text-gray-600">
|
|
||||||
Manage Postfix and Dovecot email services
|
const fetchEmailData = async () => {
|
||||||
</p>
|
try {
|
||||||
</div>
|
const [usersResponse, statusResponse] = await Promise.all([
|
||||||
|
emailAPI.getUsers(),
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
emailAPI.getStatus()
|
||||||
{/* Status */}
|
]);
|
||||||
<div className="card">
|
setUsers(usersResponse.data);
|
||||||
<div className="flex items-center mb-4">
|
setStatus(statusResponse.data);
|
||||||
<Mail className="h-6 w-6 text-primary-500 mr-2" />
|
} catch (error) {
|
||||||
<h3 className="text-lg font-medium text-gray-900">Service Status</h3>
|
console.error('Failed to fetch email data:', error);
|
||||||
</div>
|
} finally {
|
||||||
{status ? (
|
setIsLoading(false);
|
||||||
<div className="space-y-2">
|
}
|
||||||
<div className="flex justify-between">
|
};
|
||||||
<span className="text-sm text-gray-500">Postfix:</span>
|
|
||||||
<span className="text-sm font-medium text-success-600">Running</span>
|
if (isLoading) {
|
||||||
</div>
|
return (
|
||||||
<div className="flex justify-between">
|
<div className="flex items-center justify-center h-64">
|
||||||
<span className="text-sm text-gray-500">Dovecot:</span>
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||||
<span className="text-sm font-medium text-success-600">Running</span>
|
</div>
|
||||||
</div>
|
);
|
||||||
</div>
|
}
|
||||||
) : (
|
|
||||||
<p className="text-gray-500 text-sm">Status unavailable</p>
|
return (
|
||||||
)}
|
<div>
|
||||||
</div>
|
<div className="mb-8">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Email Services</h1>
|
||||||
{/* Users */}
|
<p className="mt-2 text-gray-600">Postfix (SMTP) + Dovecot (IMAP)</p>
|
||||||
<div className="card">
|
</div>
|
||||||
<div className="flex items-center mb-4">
|
|
||||||
<Users className="h-6 w-6 text-primary-500 mr-2" />
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
<h3 className="text-lg font-medium text-gray-900">Email Users</h3>
|
{/* Incoming mail */}
|
||||||
</div>
|
<div className="card">
|
||||||
<div className="space-y-2">
|
<div className="flex items-center mb-4">
|
||||||
{users.length > 0 ? (
|
<Wifi className="h-5 w-5 text-primary-500 mr-2" />
|
||||||
users.map((user, index) => (
|
<h3 className="text-lg font-medium text-gray-900">Incoming mail (IMAP)</h3>
|
||||||
<div key={index} className="flex justify-between items-center p-2 bg-gray-50 rounded">
|
</div>
|
||||||
<span className="text-sm font-medium">{user.username}</span>
|
<div className="bg-gray-50 rounded-lg px-4 py-2">
|
||||||
<span className="text-sm text-gray-500">{user.domain}</span>
|
<InfoRow label="Server" value={CELL_HOST} />
|
||||||
</div>
|
<InfoRow label="Port" value="993" />
|
||||||
))
|
<InfoRow label="Security" value="SSL/TLS" />
|
||||||
) : (
|
<InfoRow label="Direct IP" value={CELL_IP} />
|
||||||
<p className="text-gray-500 text-sm">No email users configured</p>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
{/* Outgoing mail */}
|
||||||
</div>
|
<div className="card">
|
||||||
</div>
|
<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>
|
||||||
export default Email;
|
<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 */}
|
||||||
|
<div className="card">
|
||||||
|
<div className="flex items-center mb-4">
|
||||||
|
<Mail className="h-6 w-6 text-primary-500 mr-2" />
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">Service Status</h3>
|
||||||
|
</div>
|
||||||
|
{status ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-sm text-gray-500">Postfix (SMTP):</span>
|
||||||
|
<span className="text-sm font-medium text-success-600">Running</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-sm text-gray-500">Dovecot (IMAP):</span>
|
||||||
|
<span className="text-sm font-medium text-success-600">Running</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-gray-500 text-sm">Status unavailable</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Users */}
|
||||||
|
<div className="card lg:col-span-2">
|
||||||
|
<div className="flex items-center mb-4">
|
||||||
|
<Users className="h-6 w-6 text-primary-500 mr-2" />
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">Email Accounts</h3>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{users.length > 0 ? (
|
||||||
|
users.map((user, index) => (
|
||||||
|
<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 text-gray-500">{user.domain}</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className="text-gray-500 text-sm">No email accounts configured</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Email;
|
||||||
|
|||||||
+179
-94
@@ -1,94 +1,179 @@
|
|||||||
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';
|
||||||
|
|
||||||
function Files() {
|
const FILES_HOST = 'files.cell';
|
||||||
const [users, setUsers] = useState([]);
|
const FILES_IP = '172.20.0.22';
|
||||||
const [status, setStatus] = useState(null);
|
const WEBDAV_HOST = 'webdav.cell';
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const WEBDAV_IP = '172.20.0.24';
|
||||||
|
|
||||||
useEffect(() => {
|
function CopyButton({ text }) {
|
||||||
fetchFilesData();
|
const [copied, setCopied] = useState(false);
|
||||||
}, []);
|
const copy = () => {
|
||||||
|
navigator.clipboard.writeText(text);
|
||||||
const fetchFilesData = async () => {
|
setCopied(true);
|
||||||
try {
|
setTimeout(() => setCopied(false), 1500);
|
||||||
const [usersResponse, statusResponse] = await Promise.all([
|
};
|
||||||
fileAPI.getUsers(),
|
return (
|
||||||
fileAPI.getStatus()
|
<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>
|
||||||
setUsers(usersResponse.data);
|
);
|
||||||
setStatus(statusResponse.data);
|
}
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch files data:', error);
|
function InfoRow({ label, value }) {
|
||||||
} finally {
|
return (
|
||||||
setIsLoading(false);
|
<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>
|
||||||
if (isLoading) {
|
<CopyButton text={value} />
|
||||||
return (
|
</div>
|
||||||
<div className="flex items-center justify-center h-64">
|
</div>
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
);
|
||||||
</div>
|
}
|
||||||
);
|
|
||||||
}
|
function Files() {
|
||||||
|
const [users, setUsers] = useState([]);
|
||||||
return (
|
const [status, setStatus] = useState(null);
|
||||||
<div>
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
<div className="mb-8">
|
|
||||||
<h1 className="text-2xl font-bold text-gray-900">File Storage</h1>
|
useEffect(() => {
|
||||||
<p className="mt-2 text-gray-600">
|
fetchFilesData();
|
||||||
Manage WebDAV file storage services
|
}, []);
|
||||||
</p>
|
|
||||||
</div>
|
const fetchFilesData = async () => {
|
||||||
|
try {
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
const [usersResponse, statusResponse] = await Promise.all([
|
||||||
{/* Status */}
|
fileAPI.getUsers(),
|
||||||
<div className="card">
|
fileAPI.getStatus()
|
||||||
<div className="flex items-center mb-4">
|
]);
|
||||||
<HardDrive className="h-6 w-6 text-primary-500 mr-2" />
|
setUsers(usersResponse.data);
|
||||||
<h3 className="text-lg font-medium text-gray-900">Service Status</h3>
|
setStatus(statusResponse.data);
|
||||||
</div>
|
} catch (error) {
|
||||||
{status ? (
|
console.error('Failed to fetch files data:', error);
|
||||||
<div className="space-y-2">
|
} finally {
|
||||||
<div className="flex justify-between">
|
setIsLoading(false);
|
||||||
<span className="text-sm text-gray-500">WebDAV:</span>
|
}
|
||||||
<span className="text-sm font-medium text-success-600">Running</span>
|
};
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
if (isLoading) {
|
||||||
<span className="text-sm text-gray-500">Storage:</span>
|
return (
|
||||||
<span className="text-sm font-medium text-success-600">Available</span>
|
<div className="flex items-center justify-center h-64">
|
||||||
</div>
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
);
|
||||||
<p className="text-gray-500 text-sm">Status unavailable</p>
|
}
|
||||||
)}
|
|
||||||
</div>
|
return (
|
||||||
|
<div>
|
||||||
{/* Users */}
|
<div className="mb-8">
|
||||||
<div className="card">
|
<h1 className="text-2xl font-bold text-gray-900">File Storage</h1>
|
||||||
<div className="flex items-center mb-4">
|
<p className="mt-2 text-gray-600">FileGator (browser) + WebDAV (native clients)</p>
|
||||||
<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>
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
<div className="space-y-2">
|
{/* File Manager */}
|
||||||
{users.length > 0 ? (
|
<div className="card">
|
||||||
users.map((user, index) => (
|
<div className="flex items-center mb-4">
|
||||||
<div key={index} className="flex justify-between items-center p-2 bg-gray-50 rounded">
|
<Wifi className="h-5 w-5 text-primary-500 mr-2" />
|
||||||
<span className="text-sm font-medium">{user.username}</span>
|
<h3 className="text-lg font-medium text-gray-900">Web file manager</h3>
|
||||||
<span className="text-sm text-gray-500">{user.storage_used || '0'} MB</span>
|
</div>
|
||||||
</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}`} />
|
||||||
<p className="text-gray-500 text-sm">No storage users configured</p>
|
<InfoRow label="Port" value="80" />
|
||||||
)}
|
</div>
|
||||||
</div>
|
<p className="text-xs text-gray-400 mt-3">
|
||||||
</div>
|
Browser-based file manager. Requires VPN.
|
||||||
</div>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
|
||||||
}
|
{/* WebDAV */}
|
||||||
|
<div className="card">
|
||||||
export default Files;
|
<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 */}
|
||||||
|
<div className="card">
|
||||||
|
<div className="flex items-center mb-4">
|
||||||
|
<HardDrive className="h-6 w-6 text-primary-500 mr-2" />
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">Service Status</h3>
|
||||||
|
</div>
|
||||||
|
{status ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-sm text-gray-500">FileGator:</span>
|
||||||
|
<span className="text-sm font-medium text-success-600">Running</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-sm text-gray-500">WebDAV:</span>
|
||||||
|
<span className="text-sm font-medium text-success-600">Running</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-gray-500 text-sm">Status unavailable</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Users */}
|
||||||
|
{users.length > 0 && (
|
||||||
|
<div className="card lg:col-span-2">
|
||||||
|
<div className="flex items-center mb-4">
|
||||||
|
<Users className="h-6 w-6 text-primary-500 mr-2" />
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">Storage Users</h3>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{users.map((user, index) => (
|
||||||
|
<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 text-gray-500">{user.storage_used || '0'} MB</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Files;
|
||||||
|
|||||||
+26
-26
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user