feat: external IP detection, port status, fix peer config generation

- WireGuardManager: get_external_ip() (cached 1h), check_port_open(),
  get_server_config() returning public_key + detected endpoint
- API: /api/wireguard/server-config returns real external IP;
  /api/wireguard/refresh-ip forces re-detection;
  /api/wireguard/peers/config now looks up peer IP + private key from
  registry and uses real server endpoint automatically
- Fix doubled port in Endpoint (178.x:51820:51820 → 178.x:51820)
- Fix Address=/32 when peer_ip already has mask
- WebUI nginx: proxy /api/ and /health to cell-api (fixes localhost:3000
  hardcode — UI now works from any machine)
- api.js: baseURL='' so all calls go through nginx proxy
- WireGuard page: show Server Endpoint card with external IP, endpoint,
  public key, and Refresh IP button

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-20 02:41:50 -04:00
parent 5239751a71
commit bd67764bf4
5 changed files with 209 additions and 23 deletions
+88 -2
View File
@@ -6,13 +6,20 @@ WireGuard Manager for Personal Internet Cell
import os
import json
import base64
import socket
import subprocess
import logging
import time
from datetime import datetime
from typing import Dict, List, Optional, Any
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
from base_service_manager import BaseServiceManager
try:
import requests as _requests
except ImportError:
_requests = None
logger = logging.getLogger(__name__)
SERVER_ADDRESS = '172.20.0.1/16'
@@ -215,16 +222,95 @@ class WireGuardManager(BaseServiceManager):
return (
f'[Interface]\n'
f'PrivateKey = {peer_private_key}\n'
f'Address = {peer_ip}/32\n'
f'Address = {peer_ip if "/" in peer_ip else f"{peer_ip}/32"}\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}:{DEFAULT_PORT}\n'
f'Endpoint = {server_endpoint if ":" in server_endpoint else f"{server_endpoint}:{DEFAULT_PORT}"}\n'
f'PersistentKeepalive = 25\n'
)
# ── External IP & port ────────────────────────────────────────────────────
def _ip_cache_file(self) -> str:
return os.path.join(self.keys_dir, 'external_ip.json')
def get_external_ip(self, force_refresh: bool = False) -> Optional[str]:
"""Detect external IP, caching result for 1 hour."""
cache_file = self._ip_cache_file()
if not force_refresh and os.path.exists(cache_file):
try:
with open(cache_file) as f:
data = json.load(f)
if time.time() - data.get('ts', 0) < 3600:
return data.get('ip')
except Exception:
pass
ip = None
services = [
'https://api.ipify.org',
'https://ifconfig.me/ip',
'https://icanhazip.com',
]
if _requests:
for url in services:
try:
resp = _requests.get(url, timeout=5)
candidate = resp.text.strip()
if candidate and len(candidate) < 45:
ip = candidate
break
except Exception:
continue
if ip:
try:
with open(cache_file, 'w') as f:
json.dump({'ip': ip, 'ts': time.time()}, f)
except (PermissionError, OSError):
pass
return ip
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:
return False
# Use an external UDP port-check service
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'))
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
def get_server_config(self) -> Dict[str, Any]:
"""Return server public key, external IP, endpoint, and port status."""
keys = self.get_keys()
external_ip = self.get_external_ip()
endpoint = f'{external_ip}:{DEFAULT_PORT}' if external_ip else None
return {
'public_key': keys['public_key'],
'external_ip': external_ip,
'endpoint': endpoint,
'port': DEFAULT_PORT,
}
# ── Status & connectivity ─────────────────────────────────────────────────
def get_status(self) -> Dict[str, Any]: