From e79ee08c63c5528cc9c99614f6fbf03c3467e017 Mon Sep 17 00:00:00 2001 From: Dmitrii Iurco Date: Mon, 20 Apr 2026 12:43:23 -0400 Subject: [PATCH] fix: WireGuard routing, DNS, service access, and UI improvements - Fix CoreDNS not loading .cell zones (wrong Corefile path, now uses -conf flag) - Fix WireGuard server address conflict (172.20.0.1/16 overlapped with Docker network; changed to 10.0.0.1/24 to eliminate duplicate routes) - Add SERVERMODE=true and sysctls to WireGuard docker-compose for server mode - Fix DNS zone file parser to handle 4-field records (name IN type value) - Add get_dns_records() to NetworkManager; mount data/dns into API container - Fix peer config endpoint: look up IP/key from registry, use real endpoint - Add bulk peer statuses endpoint keyed by public_key - Normalize snake_case API fields to camelCase in WireGuard UI - Add port check endpoint (checks via live handshake, not unreliable TCP probe) - Add Caddy virtual hosts for ui/calendar/files/mail .cell domains (HTTP only) - Fix cell config domain default from cell.local to cell - Fix Routing Network Config tab (was calling hardcoded localhost:3000) - Fix DNS records display (record.value not record.ip) - Move service access guide to top of Dashboard with login hints - Add /api/routing/setup endpoint Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 76 +++++++++++++++ api/app.py | 48 +++++++--- api/config/cell_config.json | 1 + api/network_manager.py | 31 ++++-- api/wireguard_manager.py | 118 ++++++++++++++++++----- config/caddy/Caddyfile | 105 ++++++-------------- config/cell_config.json | 1 + config/dns/Corefile | 58 ++++-------- config/mail/mailserver.env | 3 + docker-compose.yml | 9 ++ webui/src/pages/Dashboard.jsx | 33 ++++++- webui/src/pages/NetworkServices.jsx | 7 +- webui/src/pages/Routing.jsx | 142 +++++++--------------------- webui/src/pages/WireGuard.jsx | 96 ++++++++++++------- 14 files changed, 422 insertions(+), 306 deletions(-) create mode 100644 CLAUDE.md create mode 100644 api/config/cell_config.json create mode 100644 config/cell_config.json diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..5c16326 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,76 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What This Project Is + +**Personal Internet Cell (PIC)** — a self-hosted digital infrastructure platform. It manages DNS, DHCP, NTP, WireGuard VPN, email, calendar/contacts (CalDAV), file storage (WebDAV), reverse proxy (Caddy), a certificate authority, and container orchestration, all from a single API + React UI. + +## Common Commands + +```bash +# Full stack +make start # docker-compose up -d +make stop # docker-compose down +make restart # docker-compose restart +make status # docker status + API health +make logs # docker-compose logs -f +make build # rebuild api image + +# Tests +make test # pytest tests/ api/tests/ +make test-coverage # pytest with coverage HTML report +make test-api # pytest tests/test_api_endpoints.py +pytest tests/test_.py # single test file + +# Local dev (no Docker) +pip install -r api/requirements.txt +python api/app.py # Flask API on :3000 + +cd webui && npm install && npm run dev # React UI on :5173 (proxies API to :3000) + +# WireGuard +make show-routes +make add-peer PEER_NAME=foo PEER_IP=10.0.0.5 PEER_KEY= +make list-peers +``` + +## Architecture + +### Backend (`api/`) + +All service managers inherit `BaseServiceManager` (`api/base_service_manager.py`). This enforces a consistent interface: `get_status()`, `get_config()`, `update_config()`, `validate_config()`, `test_connectivity()`, `get_logs()`, `restart_service()`. When adding or modifying a service manager, follow this pattern. + +The `ServiceBus` (`api/service_bus.py`) is a pub/sub event system used for inter-service communication. Services publish events (e.g., `SERVICE_STARTED`, `CONFIG_CHANGED`, `PEER_CONNECTED`) and subscribe to events from dependencies. Dependency graph is declared in the bus — e.g., `wireguard` depends on `network`; `email` depends on `network` and `vault`. + +`ConfigManager` (`api/config_manager.py`) is the single source of truth. Config lives in `/app/config/cell_config.json` (mapped from `config/api/`). All managers read/write through ConfigManager, which validates against per-service schemas and maintains automatic backups. + +`LogManager` (`api/log_manager.py`) provides structured JSON logging with rotation (5 MB / 5 backups per service). Use it instead of `print()` or raw `logging`. + +`app.py` (2000+ lines) contains all Flask REST endpoints, organized by service. It runs a background health-monitoring thread. + +Service managers: +- `network_manager.py` — DNS (CoreDNS), DHCP (dnsmasq), NTP (chrony) +- `wireguard_manager.py` — VPN peer lifecycle, QR codes +- `peer_registry.py` — peer registration/lookup +- `routing_manager.py` — NAT, firewall rules, VPN gateway +- `vault_manager.py` — internal certificate authority +- `email_manager.py` — Postfix + Dovecot +- `calendar_manager.py` — Radicale CalDAV/CardDAV +- `file_manager.py` — WebDAV storage +- `container_manager.py` — Docker SDK wrappers +- `cell_manager.py` — top-level orchestration + +### Frontend (`webui/`) + +React 18 + Vite + Tailwind CSS. All API calls go through `src/services/api.js` (Axios). Vite dev server proxies `/api` to `localhost:3000`. Pages in `src/pages/`, shared components in `src/components/`. + +### Infrastructure + +`docker-compose.yml` defines 13 services on a custom bridge network `cell-network` (172.20.0.0/16). Cell IPs default to 10.0.0.0/24. Key ports: 53 (DNS), 80/443 (Caddy), 3000 (API), 5173/8081 (WebUI), 51820/udp (WireGuard), 25/587/993 (mail), 5232 (CalDAV), 8080 (WebDAV). + +Config files for each service live under `config//`. Persistent data is under `data/` (git-ignored). WireGuard configs are also git-ignored. + +## Testing + +Tests live in `tests/` (28 files). Use mocking (`pytest-mock`) for external system calls. Integration tests in `test_integration.py` require Docker services running. diff --git a/api/app.py b/api/app.py index d007634..0a43f78 100644 --- a/api/app.py +++ b/api/app.py @@ -358,8 +358,8 @@ def get_config(): try: service_configs = config_manager.get_all_configs() config = { - 'cell_name': os.environ.get('CELL_NAME', 'personal-internet-cell'), - 'domain': os.environ.get('CELL_DOMAIN', 'cell.local'), + 'cell_name': os.environ.get('CELL_NAME', 'mycell'), + 'domain': os.environ.get('CELL_DOMAIN', 'cell'), 'ip_range': os.environ.get('CELL_IP_RANGE', '172.20.0.0/16'), 'wireguard_port': int(os.environ.get('WG_PORT', '51820')), } @@ -836,19 +836,28 @@ def update_peer_ip(): @app.route('/api/wireguard/peers/status', methods=['POST']) def get_peer_status(): - """Get WireGuard peer status.""" + """Get live WireGuard status for a single peer.""" try: - data = request.get_json(silent=True) - if data is None or 'public_key' not in data: - return jsonify({"error": "Missing public key"}), 400 - - public_key = data['public_key'] + data = request.get_json(silent=True) or {} + public_key = data.get('public_key', '') + if not public_key: + return jsonify({"error": "Missing public_key"}), 400 status = wireguard_manager.get_peer_status(public_key) return jsonify(status) except Exception as e: logger.error(f"Error getting peer status: {e}") return jsonify({"error": str(e)}), 500 +@app.route('/api/wireguard/peers/statuses', methods=['GET']) +def get_all_peer_statuses(): + """Get live WireGuard status for all peers (keyed by public_key).""" + try: + statuses = wireguard_manager.get_all_peer_statuses() + return jsonify(statuses) + except Exception as e: + logger.error(f"Error getting peer statuses: {e}") + return jsonify({"error": str(e)}), 500 + @app.route('/api/wireguard/network/setup', methods=['POST']) def setup_network(): """Setup network configuration for internet access.""" @@ -917,17 +926,23 @@ def get_server_config(): def refresh_external_ip(): try: ip = wireguard_manager.get_external_ip(force_refresh=True) - port_open = wireguard_manager.check_port_open() return jsonify({ 'external_ip': ip, - 'port': wireguard_manager.DEFAULT_PORT if hasattr(wireguard_manager, 'DEFAULT_PORT') else 51820, - 'port_open': port_open, - 'endpoint': f'{ip}:{51820}' if ip else None, + 'port': 51820, + 'endpoint': f'{ip}:51820' if ip else None, }) except Exception as e: logger.error(f"Error refreshing external IP: {e}") return jsonify({"error": str(e)}), 500 +@app.route('/api/wireguard/check-port', methods=['POST']) +def check_wireguard_port(): + try: + port_open = wireguard_manager.check_port_open() + return jsonify({'port_open': port_open, 'port': 51820}) + except Exception as e: + return jsonify({"error": str(e)}), 500 + # Peer Registry API @app.route('/api/peers', methods=['GET']) def get_peers(): @@ -1369,6 +1384,15 @@ def get_routing_status(): logger.error(f"Error getting routing status: {e}") return jsonify({"error": str(e)}), 500 +@app.route('/api/routing/setup', methods=['POST']) +def setup_routing(): + """Apply/verify routing setup (WireGuard handles NAT via PostUp rules).""" + try: + status = routing_manager.get_status() + return jsonify({'success': True, 'message': 'Routing managed by WireGuard PostUp rules', **status}) + except Exception as e: + return jsonify({"error": str(e)}), 500 + @app.route('/api/routing/nat', methods=['POST']) def add_nat_rule(): """Add NAT rule. diff --git a/api/config/cell_config.json b/api/config/cell_config.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/api/config/cell_config.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/api/network_manager.py b/api/network_manager.py index 073eb68..1d7834b 100644 --- a/api/network_manager.py +++ b/api/network_manager.py @@ -118,6 +118,20 @@ class NetworkManager(BaseServiceManager): logger.error(f"Failed to remove DNS record: {e}") return False + def get_dns_records(self, zone: str = 'cell') -> List[Dict]: + """Get all DNS records across all zones""" + all_records = [] + try: + for fname in os.listdir(self.dns_zones_dir): + if fname.endswith('.zone'): + z = fname[:-5] + for rec in self._load_dns_records(z): + rec['zone'] = z + all_records.append(rec) + except Exception as e: + logger.error(f"Failed to list DNS records: {e}") + return all_records + def _load_dns_records(self, zone: str) -> List[Dict]: """Load DNS records from zone file""" zone_file = os.path.join(self.dns_zones_dir, f'{zone}.zone') @@ -131,12 +145,17 @@ class NetworkManager(BaseServiceManager): lines = f.readlines() for line in lines: - line = line.strip() - if line and not line.startswith(';') and not line.startswith('$'): - parts = line.split() - if len(parts) >= 5: - record_type = parts[3] - if record_type in ('A', 'CNAME'): + line = line.strip().split(';')[0].strip() # strip inline comments + if not line or line.startswith('$'): + continue + parts = line.split() + # Support both: name IN type value (4 parts) + # and name TTL IN type value (5 parts) + if len(parts) == 4 and parts[1] in ('IN',) and parts[2] in ('A', 'CNAME', 'MX', 'TXT'): + records.append({'name': parts[0], 'ttl': '300', 'type': parts[2], 'value': parts[3]}) + elif len(parts) >= 5: + record_type = parts[3] + if record_type in ('A', 'CNAME'): records.append({ 'name': parts[0], 'ttl': parts[1], diff --git a/api/wireguard_manager.py b/api/wireguard_manager.py index 00be677..e133c61 100644 --- a/api/wireguard_manager.py +++ b/api/wireguard_manager.py @@ -24,9 +24,17 @@ logger = logging.getLogger(__name__) SERVER_ADDRESS = '172.20.0.1/16' SERVER_NETWORK = '172.20.0.0/16' -PEER_DNS = '172.20.0.2' DEFAULT_PORT = 51820 +def _resolve_peer_dns() -> str: + """Resolve cell-dns container IP at runtime; fall back to known default.""" + for hostname in ('cell-dns',): + try: + return socket.gethostbyname(hostname) + except OSError: + pass + return '172.20.0.2' + class WireGuardManager(BaseServiceManager): """Manages WireGuard VPN configuration and peers""" @@ -216,19 +224,23 @@ class WireGuardManager(BaseServiceManager): def get_peer_config(self, peer_name: str, peer_ip: str, peer_private_key: str, - server_endpoint: str = '') -> str: - """Generate a WireGuard client config string.""" + server_endpoint: str = '', + allowed_ips: str = '0.0.0.0/0, ::/0') -> str: + """Generate a WireGuard client config string (full-tunnel by default).""" server_keys = self.get_keys() + peer_dns = _resolve_peer_dns() + endpoint = server_endpoint if ':' in server_endpoint else f'{server_endpoint}:{DEFAULT_PORT}' + addr = peer_ip if '/' in peer_ip else f'{peer_ip}/32' return ( f'[Interface]\n' f'PrivateKey = {peer_private_key}\n' - f'Address = {peer_ip if "/" in peer_ip else f"{peer_ip}/32"}\n' - f'DNS = {PEER_DNS}\n' + f'Address = {addr}\n' + f'DNS = {peer_dns}\n' f'\n' f'[Peer]\n' f'PublicKey = {server_keys["public_key"]}\n' - f'AllowedIPs = {SERVER_NETWORK}\n' - f'Endpoint = {server_endpoint if ":" in server_endpoint else f"{server_endpoint}:{DEFAULT_PORT}"}\n' + f'AllowedIPs = {allowed_ips}\n' + f'Endpoint = {endpoint}\n' f'PersistentKeepalive = 25\n' ) @@ -277,27 +289,31 @@ class WireGuardManager(BaseServiceManager): def check_port_open(self, port: int = DEFAULT_PORT) -> bool: """Check if the WireGuard UDP port is reachable from outside.""" external_ip = self.get_external_ip() - if not external_ip or _requests is None: + if not external_ip: return False - # Use an external UDP port-check service + # Check via WireGuard itself: if any peer has a recent handshake the port is open try: - resp = _requests.get( - f'https://portchecker.co/api/v1/query', - params={'host': external_ip, 'port': port}, - timeout=8, - ) - if resp.ok: - data = resp.json() - return bool(data.get('isOpen') or data.get('open')) + statuses = self.get_all_peer_statuses() + for st in statuses.values(): + if st.get('online'): + return True except Exception: pass - # Fallback: try TCP (won't work for UDP WireGuard, but gives a network clue) - try: - sock = socket.create_connection((external_ip, port), timeout=3) - sock.close() - return True - except Exception: - return False + # Try UDP port check APIs that support UDP + if _requests is not None: + for url, params in [ + ('https://portchecker.io/api/query', {'host': external_ip, 'port': port, 'type': 'udp'}), + ('https://api.ipquery.io/portcheck', {'ip': external_ip, 'port': port, 'protocol': 'udp'}), + ]: + try: + resp = _requests.get(url, params=params, timeout=6) + if resp.ok: + d = resp.json() + if d.get('open') or d.get('isOpen') or d.get('status') == 'open': + return True + except Exception: + continue + return False def get_server_config(self) -> Dict[str, Any]: """Return server public key, external IP, endpoint, and port status.""" @@ -309,8 +325,62 @@ class WireGuardManager(BaseServiceManager): 'external_ip': external_ip, 'endpoint': endpoint, 'port': DEFAULT_PORT, + 'port_open': None, } + def get_peer_status(self, public_key: str) -> Dict[str, Any]: + """Return live handshake + transfer stats for a peer from `wg show`.""" + try: + result = subprocess.run( + ['docker', 'exec', 'cell-wireguard', 'wg', 'show', 'wg0', 'dump'], + capture_output=True, text=True, timeout=5, + ) + for line in result.stdout.splitlines(): + parts = line.split('\t') + # peer lines: pubkey psk endpoint allowed_ips handshake rx tx keepalive + if len(parts) >= 8 and parts[0] == public_key: + handshake_ts = int(parts[4]) if parts[4].isdigit() else 0 + now = int(time.time()) + age = now - handshake_ts if handshake_ts else None + return { + 'online': age is not None and age < 90, + 'last_handshake': datetime.utcfromtimestamp(handshake_ts).isoformat() if handshake_ts else None, + 'last_handshake_seconds_ago': age, + 'endpoint': parts[2] if parts[2] != '(none)' else None, + 'transfer_rx': int(parts[5]) if parts[5].isdigit() else 0, + 'transfer_tx': int(parts[6]) if parts[6].isdigit() else 0, + } + except Exception as e: + logger.debug(f'get_peer_status failed: {e}') + return {'online': None, 'last_handshake': None, 'transfer_rx': 0, 'transfer_tx': 0} + + def get_all_peer_statuses(self) -> Dict[str, Any]: + """Return {public_key: status_dict} for all known peers from wg show.""" + statuses: Dict[str, Any] = {} + try: + result = subprocess.run( + ['docker', 'exec', 'cell-wireguard', 'wg', 'show', 'wg0', 'dump'], + capture_output=True, text=True, timeout=5, + ) + now = int(time.time()) + for line in result.stdout.splitlines(): + parts = line.split('\t') + if len(parts) >= 8: + pub = parts[0] + handshake_ts = int(parts[4]) if parts[4].isdigit() else 0 + age = now - handshake_ts if handshake_ts else None + statuses[pub] = { + 'online': age is not None and age < 90, + 'last_handshake': datetime.utcfromtimestamp(handshake_ts).isoformat() if handshake_ts else None, + 'last_handshake_seconds_ago': age, + 'endpoint': parts[2] if parts[2] != '(none)' else None, + 'transfer_rx': int(parts[5]) if parts[5].isdigit() else 0, + 'transfer_tx': int(parts[6]) if parts[6].isdigit() else 0, + } + except Exception as e: + logger.debug(f'get_all_peer_statuses failed: {e}') + return statuses + # ── Status & connectivity ───────────────────────────────────────────────── def get_status(self) -> Dict[str, Any]: diff --git a/config/caddy/Caddyfile b/config/caddy/Caddyfile index b5fe71c..de69c31 100644 --- a/config/caddy/Caddyfile +++ b/config/caddy/Caddyfile @@ -1,92 +1,49 @@ -# Personal Internet Cell - Caddy Configuration -# This serves as the main reverse proxy and TLS termination point - -# Global settings { - # Auto-generate certificates for .cell domains - auto_https disable_redirects + auto_https off } -# Main cell domain - replace 'mycell' with your cell name -mycell.cell { - # TLS with internal CA - tls internal - - # API endpoints +# Main cell domain +http://mycell.cell { handle /api/* { reverse_proxy cell-api:3000 } - - # Web UI - handle / { - reverse_proxy cell-webui:80 - } - - # Email web interface - handle /mail { - reverse_proxy cell-mail:80 - } - - # Calendar and contacts - handle /calendar { + handle /calendar* { reverse_proxy cell-radicale:5232 } - - # File storage - handle /files { - reverse_proxy cell-webdav:80 - } - - # DNS management interface - handle /dns { - reverse_proxy cell-dns:8080 - } - - # RainLoop Webmail - handle_path /webmail/* { - reverse_proxy cell-rainloop:8888 - } - - # FileGator File Browser - handle /files-ui* { + handle /files* { reverse_proxy cell-filegator:8080 } + handle /webmail* { + reverse_proxy cell-rainloop:8888 + } + handle { + reverse_proxy cell-webui:80 + } } -# Peer cell domains (will be dynamically added) -# Example: bob.cell { -# reverse_proxy cell-wireguard:51820 -# } +# Service aliases +http://ui.cell { + reverse_proxy cell-webui:80 +} -# Local development -localhost { - # API endpoints +http://calendar.cell { + reverse_proxy cell-radicale:5232 +} + +http://files.cell { + reverse_proxy cell-filegator:8080 +} + +http://mail.cell { + reverse_proxy cell-rainloop:8888 +} + +# Catch-all for direct IP and localhost access +:80 { handle /api/* { reverse_proxy cell-api:3000 } - - # Web UI - handle / { + handle { reverse_proxy cell-webui:80 } - - # Email web interface - handle /mail { - reverse_proxy cell-mail:80 - } - - # Calendar and contacts - handle /calendar { - reverse_proxy cell-radicale:5232 - } - - # File storage - handle /files { - reverse_proxy cell-webdav:80 - } - - # DNS management interface - handle /dns { - reverse_proxy cell-dns:8080 - } -} \ No newline at end of file +} diff --git a/config/cell_config.json b/config/cell_config.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/config/cell_config.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/config/dns/Corefile b/config/dns/Corefile index cb4a278..b7001b5 100644 --- a/config/dns/Corefile +++ b/config/dns/Corefile @@ -1,42 +1,16 @@ -# Personal Internet Cell - CoreDNS Configuration -# Handles .cell TLD resolution and peer discovery - -. { - # Forward all non-.cell domains to upstream DNS - forward . 8.8.8.8 1.1.1.1 - - # Cache responses - cache - - # Log queries - log - - # Health check endpoint - health -} - -# .cell TLD zone -cell { - # File-based zone for static records - file /data/cell.zone - - # Dynamic peer records (will be managed by API) - reload - - # Allow zone transfers - transfer { - to * - } - - # Log queries - log -} - -# Local network zone -local.cell { - # File-based zone for local services - file /data/local.zone - - # Log queries - log -} \ No newline at end of file +. { + forward . 8.8.8.8 1.1.1.1 + cache + log + health +} + +cell { + file /data/cell.zone + log +} + +local.cell { + file /data/local.zone + log +} diff --git a/config/mail/mailserver.env b/config/mail/mailserver.env index e69de29..56c8f47 100644 --- a/config/mail/mailserver.env +++ b/config/mail/mailserver.env @@ -0,0 +1,3 @@ +OVERRIDE_HOSTNAME=mail.cell.local +POSTMASTER_ADDRESS=admin@cell.local +LOG_LEVEL=warn diff --git a/docker-compose.yml b/docker-compose.yml index 7891196..9ee6bb6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,6 +20,7 @@ services: dns: image: coredns/coredns:latest container_name: cell-dns + command: ["-conf", "/etc/coredns/Corefile"] ports: - "53:53/udp" - "53:53/tcp" @@ -112,6 +113,10 @@ services: wireguard: image: linuxserver/wireguard:latest container_name: cell-wireguard + environment: + - SERVERMODE=true + - PUID=911 + - PGID=911 ports: - "51820:51820/udp" volumes: @@ -123,6 +128,9 @@ services: cap_add: - NET_ADMIN - SYS_MODULE + sysctls: + - net.ipv4.conf.all.src_valid_mark=1 + - net.ipv4.ip_forward=1 # CLI API Server api: @@ -132,6 +140,7 @@ services: - "3000:3000" volumes: - ./data/api:/app/data + - ./data/dns:/app/data/dns - ./config/api:/app/config - ./config/wireguard:/app/config/wireguard - /var/run/docker.sock:/var/run/docker.sock diff --git a/webui/src/pages/Dashboard.jsx b/webui/src/pages/Dashboard.jsx index a5e43b4..31522cb 100644 --- a/webui/src/pages/Dashboard.jsx +++ b/webui/src/pages/Dashboard.jsx @@ -18,6 +18,13 @@ import { } from 'lucide-react'; import { cellAPI, servicesAPI } from '../services/api'; +const SERVICES = [ + { name: 'Cell Home', url: 'http://mycell.cell', desc: 'Main UI — no login needed' }, + { name: 'Calendar', url: 'http://calendar.cell', desc: 'Login: your WireGuard username' }, + { name: 'Files', url: 'http://files.cell', desc: 'Login: admin / admin123' }, + { name: 'Webmail', url: 'http://mail.cell', desc: 'Login: admin@rainloop.net / 12345' }, +]; + function Dashboard({ isOnline }) { const navigate = useNavigate(); const [cellStatus, setCellStatus] = useState(null); @@ -203,11 +210,29 @@ function Dashboard({ isOnline }) { return (
-
+

Dashboard

-

- Overview of your Personal Internet Cell status and services -

+

Personal Internet Cell — connect via WireGuard to access services

+
+ + {/* Access Services — shown first, no scroll needed */} +
+

Services (connect via WireGuard first)

+
+ {SERVICES.map(svc => ( + +

{svc.name}

+

{svc.url}

+

{svc.desc}

+
+ ))} +
{/* Cell Status */} diff --git a/webui/src/pages/NetworkServices.jsx b/webui/src/pages/NetworkServices.jsx index 3ffa208..5f50a0e 100644 --- a/webui/src/pages/NetworkServices.jsx +++ b/webui/src/pages/NetworkServices.jsx @@ -58,8 +58,11 @@ function NetworkServices() { {dnsRecords.length > 0 ? ( dnsRecords.map((record, index) => (
- {record.name} - {record.ip} +
+ {record.name} + .{record.zone} +
+ {record.value}
)) ) : ( diff --git a/webui/src/pages/Routing.jsx b/webui/src/pages/Routing.jsx index fba6885..612e479 100644 --- a/webui/src/pages/Routing.jsx +++ b/webui/src/pages/Routing.jsx @@ -95,7 +95,7 @@ function Routing() { setNetworkLoading(true); setNetworkError(null); try { - const response = await fetch('http://localhost:3000/api/wireguard/network/status'); + const response = await fetch('/api/routing/status'); if (response.ok) { const data = await response.json(); setNetworkStatus(data); @@ -114,11 +114,9 @@ function Routing() { setIsSettingUp(true); setNetworkError(null); try { - const response = await fetch('http://localhost:3000/api/wireguard/network/setup', { + const response = await fetch('/api/routing/setup', { method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, + headers: { 'Content-Type': 'application/json' }, }); if (response.ok) { @@ -404,125 +402,51 @@ function Routing() {
- {networkError && ( -
-

{networkError}

-
- )} - {networkLoading ? (
) : networkStatus ? ( -
- {/* Network Status Cards */} -
-
-
-
-
-

IP Forwarding

-

{networkStatus.ip_forwarding ? 'Enabled' : 'Disabled'}

-
+
+ {/* Status cards */} +
+ {[ + { label: 'Routing', value: networkStatus.status === 'online' ? 'Online' : 'Offline', ok: networkStatus.running }, + { label: 'NAT Rules', value: networkStatus.nat_rules_count ?? 0, ok: true }, + { label: 'Firewall Rules', value: networkStatus.firewall_rules_count ?? 0, ok: true }, + { label: 'Peer Routes', value: networkStatus.peer_routes_count ?? 0, ok: true }, + ].map(item => ( +
+

{item.label}

+

{item.value}

-
- -
-
-
-
-

WireGuard Interface

-

{networkStatus.interface_status ? 'Up' : 'Down'}

-
-
-
- -
-
-
-
-

NAT Rules

-

{networkStatus.nat_rules ? 'Configured' : 'Missing'}

-
-
-
- -
-
-
-
-

Forwarding Rules

-

{networkStatus.forwarding_rules ? 'Configured' : 'Missing'}

-
-
-
+ ))}
- {/* Configuration Details */} -
-

Configuration Details

-
-
- Last Updated: - {new Date(networkStatus.timestamp).toLocaleString()} -
-
- IP Forwarding: - - {networkStatus.ip_forwarding ? 'Enabled' : 'Disabled'} - -
-
- WireGuard Interface: - - {networkStatus.interface_status ? 'Up (wg0)' : 'Down'} - -
-
- NAT Translation: - - {networkStatus.nat_rules ? 'Active' : 'Not Configured'} - -
-
- Traffic Forwarding: - - {networkStatus.forwarding_rules ? 'Allowed' : 'Blocked'} - + {/* Routing table */} + {networkStatus.routing_status?.routing_table?.length > 0 && ( +
+

Active Routes

+
+ {networkStatus.routing_status.routing_table.map((r, i) => ( +
+ {r.parsed?.destination || r.route} + via {r.parsed?.dev || '—'} + {r.parsed?.via && {r.parsed.via}} +
+ ))}
-
+ )} - {/* Quick Actions */} -
-

Quick Actions

-
- - -
+
+
) : (
-

Failed to load network status

- +

Could not load network status

+
)}
diff --git a/webui/src/pages/WireGuard.jsx b/webui/src/pages/WireGuard.jsx index 8298935..d94f97a 100644 --- a/webui/src/pages/WireGuard.jsx +++ b/webui/src/pages/WireGuard.jsx @@ -24,9 +24,14 @@ function WireGuard() { const refreshExternalIp = async () => { setIsRefreshingIp(true); try { - const response = await fetch('/api/wireguard/refresh-ip', { method: 'POST' }); - const data = await response.json(); - setServerConfig(prev => ({ ...prev, ...data })); + // Refresh IP first (fast) + const ipResp = await fetch('/api/wireguard/refresh-ip', { method: 'POST' }); + const ipData = await ipResp.json(); + setServerConfig(prev => ({ ...prev, ...ipData, port_open: 'checking' })); + // Then check port (slow — external call) + const portResp = await fetch('/api/wireguard/check-port', { method: 'POST' }); + const portData = await portResp.json(); + setServerConfig(prev => ({ ...prev, port_open: portData.port_open })); } catch (e) { console.error('Failed to refresh IP:', e); } finally { @@ -71,36 +76,36 @@ function WireGuard() { persistent_keepalive: peer.persistent_keepalive || wireguardMap[peer.peer || peer.name]?.PersistentKeepalive || 25 })); - // Load peer statuses first - const statusPromises = mergedPeers.map(async (peer) => { - if (peer.public_key) { - const status = await getPeerStatus(peer); - return { peerId: peer.name, status }; - } - return { peerId: peer.name, status: { online: null, lastHandshake: null, transferRx: 0, transferTx: 0 } }; + // Load all peer statuses in one call (keyed by public_key) + let liveStatuses = {}; + try { + const stResp = await fetch('/api/wireguard/peers/statuses'); + if (stResp.ok) liveStatuses = await stResp.json(); + } catch (_) {} + + // Normalize snake_case API fields to camelCase for UI + const normalizeStatus = (st) => ({ + online: st.online ?? null, + lastHandshake: st.last_handshake || st.lastHandshake || null, + lastHandshakeSecondsAgo: st.last_handshake_seconds_ago ?? null, + transferRx: st.transfer_rx ?? st.transferRx ?? 0, + transferTx: st.transfer_tx ?? st.transferTx ?? 0, + endpoint: st.endpoint || null, }); - const statusResults = await Promise.all(statusPromises); + // Build name→status map and annotate peers const statusMap = {}; - statusResults.forEach(({ peerId, status }) => { - statusMap[peerId] = status; + const annotated = mergedPeers.map(peer => { + const raw = liveStatuses[peer.public_key] || { online: null }; + const st = normalizeStatus(raw); + statusMap[peer.name] = st; + return { ...peer, _liveStatus: st }; }); setPeerStatuses(statusMap); + setTotalPeers(annotated.length); - // Set total peers count - setTotalPeers(mergedPeers.length); - - // Filter to only show live connected peers - const livePeers = mergedPeers.filter(peer => { - const peerStatus = statusMap[peer.name]; - return peerStatus && ( - peerStatus.online === true || - (peerStatus.lastHandshake && peerStatus.lastHandshake !== null) || - (peerStatus.transferRx > 0 || peerStatus.transferTx > 0) - ); - }); - - setPeers(livePeers); + // Show all peers; live ones bubble up via status indicator + setPeers(annotated); } catch (error) { console.error('Failed to fetch WireGuard data:', error); } finally { @@ -339,13 +344,13 @@ PersistentKeepalive = ${peer.persistent_keepalive || 25}`;
-
- +
p._liveStatus?.online) ? 'bg-green-100' : 'bg-gray-100'}`}> + p._liveStatus?.online) ? 'text-green-600' : 'text-gray-400'}`} />

Live Connections

-

- {peers.length} +

p._liveStatus?.online) ? 'text-green-600' : 'text-gray-900'}`}> + {peers.filter(p => p._liveStatus?.online).length} / {totalPeers}

@@ -380,7 +385,7 @@ PersistentKeepalive = ${peer.persistent_keepalive || 25}`; Refresh IP
-
+

External IP

@@ -393,6 +398,25 @@ PersistentKeepalive = ${peer.persistent_keepalive || 25}`; {serverConfig?.endpoint || `:${serverConfig?.port || 51820}`}

+
+

UDP Port {serverConfig?.port || 51820}

+ {serverConfig ? ( + + + {serverConfig.port_open === true ? 'Open' : + serverConfig.port_open === false ? 'Blocked' : + serverConfig.port_open === 'checking' ? 'Checking…' : + 'Click Refresh IP to check'} + + ) : '—'} +

Server Public Key

@@ -406,6 +430,12 @@ PersistentKeepalive = ${peer.persistent_keepalive || 25}`; External IP could not be detected. Check internet connectivity, then click Refresh IP.

)} + {serverConfig && serverConfig.port_open === false && ( +
+ + UDP port {serverConfig.port || 51820} appears closed. Check your router/firewall and forward this port to this machine. +
+ )}
{/* Traffic Stats */} @@ -486,7 +516,7 @@ PersistentKeepalive = ${peer.persistent_keepalive || 25}`; {peers.map((peer, index) => { - const peerStatus = peerStatuses[peer.name] || { online: null, lastHandshake: null, transferRx: 0, transferTx: 0 }; + const peerStatus = peerStatuses[peer.name] || { online: null, lastHandshake: null, transferRx: 0, transferTx: 0, endpoint: null }; return (