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
+36 -5
View File
@@ -876,11 +876,28 @@ def get_network_status():
def get_peer_config(): def get_peer_config():
try: try:
data = request.get_json(silent=True) or {} data = request.get_json(silent=True) or {}
peer_name = data.get('name', data.get('peer', ''))
# Look up peer details from registry if not supplied
peer_ip = data.get('ip', '')
peer_private_key = data.get('private_key', '')
if peer_name and (not peer_ip or not peer_private_key):
registered = peer_registry.get_peer(peer_name)
if registered:
peer_ip = peer_ip or registered.get('ip', '')
peer_private_key = peer_private_key or registered.get('private_key', '')
# Use real external endpoint if not supplied
server_endpoint = data.get('server_endpoint', '')
if not server_endpoint:
srv = wireguard_manager.get_server_config()
server_endpoint = srv.get('endpoint') or '<SERVER_IP>'
result = wireguard_manager.get_peer_config( result = wireguard_manager.get_peer_config(
peer_name=data.get('name', data.get('peer', '')), peer_name=peer_name,
peer_ip=data.get('ip', ''), peer_ip=peer_ip,
peer_private_key=data.get('private_key', ''), peer_private_key=peer_private_key,
server_endpoint=data.get('server_endpoint', '<SERVER_IP>') server_endpoint=server_endpoint,
) )
return jsonify({"config": result}) return jsonify({"config": result})
except Exception as e: except Exception as e:
@@ -890,13 +907,27 @@ def get_peer_config():
@app.route('/api/wireguard/server-config', methods=['GET']) @app.route('/api/wireguard/server-config', methods=['GET'])
def get_server_config(): def get_server_config():
try: try:
# Get server configuration from WireGuard manager
config = wireguard_manager.get_server_config() config = wireguard_manager.get_server_config()
return jsonify(config) return jsonify(config)
except Exception as e: except Exception as e:
logger.error(f"Error getting server config: {e}") logger.error(f"Error getting server config: {e}")
return jsonify({"error": str(e)}), 500 return jsonify({"error": str(e)}), 500
@app.route('/api/wireguard/refresh-ip', methods=['POST'])
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,
})
except Exception as e:
logger.error(f"Error refreshing external IP: {e}")
return jsonify({"error": str(e)}), 500
# Peer Registry API # Peer Registry API
@app.route('/api/peers', methods=['GET']) @app.route('/api/peers', methods=['GET'])
def get_peers(): def get_peers():
+88 -2
View File
@@ -6,13 +6,20 @@ WireGuard Manager for Personal Internet Cell
import os import os
import json import json
import base64 import base64
import socket
import subprocess import subprocess
import logging import logging
import time
from datetime import datetime from datetime import datetime
from typing import Dict, List, Optional, Any from typing import Dict, List, Optional, Any
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
from base_service_manager import BaseServiceManager from base_service_manager import BaseServiceManager
try:
import requests as _requests
except ImportError:
_requests = None
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
SERVER_ADDRESS = '172.20.0.1/16' SERVER_ADDRESS = '172.20.0.1/16'
@@ -215,16 +222,95 @@ class WireGuardManager(BaseServiceManager):
return ( return (
f'[Interface]\n' f'[Interface]\n'
f'PrivateKey = {peer_private_key}\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'DNS = {PEER_DNS}\n'
f'\n' f'\n'
f'[Peer]\n' f'[Peer]\n'
f'PublicKey = {server_keys["public_key"]}\n' f'PublicKey = {server_keys["public_key"]}\n'
f'AllowedIPs = {SERVER_NETWORK}\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' 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 ───────────────────────────────────────────────── # ── Status & connectivity ─────────────────────────────────────────────────
def get_status(self) -> Dict[str, Any]: def get_status(self) -> Dict[str, Any]:
+15
View File
@@ -4,6 +4,21 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
# Proxy API and health calls to the backend container
location /api/ {
proxy_pass http://cell-api:3000/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 60s;
}
location /health {
proxy_pass http://cell-api:3000/health;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# Handle client-side routing # Handle client-side routing
location / { location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
+68 -14
View File
@@ -1,10 +1,12 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Shield, Key, Users, Activity, Wifi, Download, Copy, RefreshCw, Play, Pause, AlertCircle, Eye } from 'lucide-react'; import { Shield, Key, Users, Activity, Wifi, Download, Copy, RefreshCw, Play, Pause, AlertCircle, Eye, Globe, CheckCircle, XCircle } from 'lucide-react';
import { wireguardAPI, peerAPI } from '../services/api'; import { wireguardAPI, peerAPI } from '../services/api';
import QRCode from 'qrcode'; import QRCode from 'qrcode';
function WireGuard() { function WireGuard() {
const [status, setStatus] = useState(null); const [status, setStatus] = useState(null);
const [serverConfig, setServerConfig] = useState(null);
const [isRefreshingIp, setIsRefreshingIp] = useState(false);
const [peers, setPeers] = useState([]); const [peers, setPeers] = useState([]);
const [totalPeers, setTotalPeers] = useState(0); const [totalPeers, setTotalPeers] = useState(0);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
@@ -19,15 +21,30 @@ function WireGuard() {
fetchWireGuardData(); fetchWireGuardData();
}, []); }, []);
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 }));
} catch (e) {
console.error('Failed to refresh IP:', e);
} finally {
setIsRefreshingIp(false);
}
};
const fetchWireGuardData = async () => { const fetchWireGuardData = async () => {
try { try {
const [statusResponse, peersResponse, wireguardResponse] = await Promise.all([ const [statusResponse, peersResponse, wireguardResponse, serverConfigResponse] = await Promise.all([
wireguardAPI.getStatus(), wireguardAPI.getStatus(),
peerAPI.getPeers(), peerAPI.getPeers(),
wireguardAPI.getPeers() wireguardAPI.getPeers(),
fetch('/api/wireguard/server-config').then(r => r.json()).catch(() => null),
]); ]);
setStatus(statusResponse.data); setStatus(statusResponse.data);
if (serverConfigResponse) setServerConfig(serverConfigResponse);
// Merge peer registry data with WireGuard data (same as Peers page) // Merge peer registry data with WireGuard data (same as Peers page)
const peersData = peersResponse.data || []; const peersData = peersResponse.data || [];
@@ -160,25 +177,18 @@ function WireGuard() {
}; };
const getServerConfig = async () => { const getServerConfig = async () => {
if (serverConfig?.public_key) return serverConfig;
try { try {
// Try to get server configuration from API
const response = await fetch('/api/wireguard/server-config'); const response = await fetch('/api/wireguard/server-config');
if (response.ok) { if (response.ok) {
const config = await response.json(); const config = await response.json();
return { setServerConfig(config);
public_key: config.public_key || "SERVER_PUBLIC_KEY_PLACEHOLDER", return config;
endpoint: config.endpoint || "YOUR_SERVER_IP:51820"
};
} }
} catch (error) { } catch (error) {
console.warn('Could not get server config:', error); console.warn('Could not get server config:', error);
} }
return { public_key: '', endpoint: '<SERVER_IP>:51820' };
// Return default values
return {
public_key: "SERVER_PUBLIC_KEY_PLACEHOLDER",
endpoint: "YOUR_SERVER_IP:51820"
};
}; };
const generateWireGuardConfig = (peer) => { const generateWireGuardConfig = (peer) => {
@@ -354,6 +364,50 @@ PersistentKeepalive = ${peer.persistent_keepalive || 25}`;
</div> </div>
</div> </div>
{/* External IP & Port Status */}
<div className="card mb-8">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center">
<Globe className="h-5 w-5 text-gray-500 mr-2" />
<h2 className="text-lg font-semibold text-gray-900">Server Endpoint</h2>
</div>
<button
onClick={refreshExternalIp}
disabled={isRefreshingIp}
className="btn btn-secondary flex items-center text-sm"
>
<RefreshCw className={`h-3 w-3 mr-1 ${isRefreshingIp ? 'animate-spin' : ''}`} />
Refresh IP
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<p className="text-sm text-gray-500">External IP</p>
<p className="font-mono font-semibold text-gray-900">
{serverConfig?.external_ip || <span className="text-yellow-600">Detecting</span>}
</p>
</div>
<div>
<p className="text-sm text-gray-500">WireGuard Endpoint</p>
<p className="font-mono font-semibold text-gray-900">
{serverConfig?.endpoint || `<SERVER_IP>:${serverConfig?.port || 51820}`}
</p>
</div>
<div>
<p className="text-sm text-gray-500 mb-1">Server Public Key</p>
<p className="font-mono text-xs text-gray-700 break-all">
{serverConfig?.public_key || '—'}
</p>
</div>
</div>
{serverConfig && !serverConfig.external_ip && (
<div className="mt-3 flex items-center text-yellow-700 bg-yellow-50 rounded p-2 text-sm">
<AlertCircle className="h-4 w-4 mr-2 flex-shrink-0" />
External IP could not be detected. Check internet connectivity, then click Refresh IP.
</div>
)}
</div>
{/* Traffic Stats */} {/* Traffic Stats */}
{status?.total_traffic && ( {status?.total_traffic && (
<div className="card mb-8"> <div className="card mb-8">
+1 -1
View File
@@ -2,7 +2,7 @@ import axios from 'axios';
// Create axios instance with base configuration // Create axios instance with base configuration
const api = axios.create({ const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3000', baseURL: import.meta.env.VITE_API_URL || '',
timeout: 10000, timeout: 10000,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',