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 <noreply@anthropic.com>
This commit is contained in:
2026-04-20 12:43:23 -04:00
parent bd67764bf4
commit e79ee08c63
14 changed files with 422 additions and 306 deletions
+94 -24
View File
@@ -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 = '<SERVER_IP>') -> str:
"""Generate a WireGuard client config string."""
server_endpoint: str = '<SERVER_IP>',
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]: