From f3118ff401b43c8dfaefcb023c1301fdf59b4b0f Mon Sep 17 00:00:00 2001 From: Dmitrii Iurco Date: Fri, 1 May 2026 08:05:45 -0400 Subject: [PATCH] fix(vpn): sync WireGuard server key on startup; fix DNS zone cell_name/SOA; fix peer status UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - API key store was out of sync with wg0.conf: get_keys() generated a random phantom key instead of reading the actual WireGuard server key, so all peer configs had the wrong PublicKey and could never handshake. Fixed by writing correct raw-bytes key files at deploy time and adding _sync_wg_keys() to API startup so the store auto-syncs from wg0.conf on every restart. - apply_domain() fell back silently when zone file had no $ORIGIN directive; now also parses the SOA MNAME as the old-domain fallback. - apply_cell_name() only replaced the hostname if old_name matched literally in the zone file; now auto-detects the actual hostname (non-service A record) so a stale zone (mycell vs dev) is corrected on next config apply. - DNS zone file corrected: SOA pic.ngo. admin.pic.ngo., mycell → dev. - WireGuard UI: add 30s auto-poll for peer statuses; fix "peers currently connected" counter to show online/total instead of total count. Co-Authored-By: Claude Sonnet 4.6 --- api/app.py | 9 +++++++ api/network_manager.py | 47 +++++++++++++++++++++++++++-------- webui/src/pages/WireGuard.jsx | 28 ++++++++++++++++++++- 3 files changed, 73 insertions(+), 11 deletions(-) diff --git a/api/app.py b/api/app.py index 02dfd77..3f509b6 100644 --- a/api/app.py +++ b/api/app.py @@ -297,7 +297,16 @@ def _recover_pending_apply(): _recover_pending_apply() + +def _sync_wg_keys(): + try: + wireguard_manager._sync_keys_from_conf() + except Exception as e: + logger.warning(f"WireGuard key sync failed (non-fatal): {e}") + + # Run in background so startup isn't blocked waiting on docker exec +threading.Thread(target=_sync_wg_keys, daemon=True).start() threading.Thread(target=_apply_startup_enforcement, daemon=True).start() threading.Thread(target=_bootstrap_dns, daemon=True).start() diff --git a/api/network_manager.py b/api/network_manager.py index 5cdaf6d..ba1d1bb 100644 --- a/api/network_manager.py +++ b/api/network_manager.py @@ -476,12 +476,18 @@ class NetworkManager(BaseServiceManager): if os.path.exists(src): with open(src) as f: zone_content = f.read() + # Try $ORIGIN first, then fall back to SOA MNAME m = re.search(r'^\$ORIGIN\s+(\S+)', zone_content, re.MULTILINE) - old_origin = m.group(1).rstrip('.') if m else None + if m: + old_origin = m.group(1).rstrip('.') + else: + m2 = re.search(r'^@\s+IN\s+SOA\s+(\S+?)\.?\s', zone_content, re.MULTILINE) + old_origin = m2.group(1).rstrip('.') if m2 else None if old_origin and old_origin != domain: zone_content = zone_content.replace(f'{old_origin}.', f'{domain}.') - zone_content = re.sub( - r'^\$ORIGIN\s+\S+', f'$ORIGIN {domain}.', zone_content, flags=re.MULTILINE) + if re.search(r'^\$ORIGIN\s+', zone_content, re.MULTILINE): + zone_content = re.sub( + r'^\$ORIGIN\s+\S+', f'$ORIGIN {domain}.', zone_content, flags=re.MULTILINE) with open(dst, 'w') as f: f.write(zone_content) for zone_path in zone_files: @@ -507,11 +513,15 @@ class NetworkManager(BaseServiceManager): """Update the cell hostname record in the primary DNS zone file. reload=False writes the zone file only — use when deferring container restart. + old_name is a hint; if it's absent from the zone file, we detect the actual + hostname by finding the non-service A record pointing to the Caddy IP. """ restarted = [] warnings = [] - if not old_name or not new_name or old_name == new_name: + if not new_name: return {'restarted': restarted, 'warnings': warnings} + _service_names = {'api', 'webui', 'calendar', 'files', 'mail', 'webmail', 'webdav'} + changed = False try: dns_data = os.path.join(self.data_dir, 'dns') if os.path.isdir(dns_data): @@ -520,16 +530,33 @@ class NetworkManager(BaseServiceManager): zone_file = os.path.join(dns_data, fname) with open(zone_file) as f: content = f.read() - # Match name with optional TTL: "name [ttl] IN A value" - content = re.sub( - rf'^{re.escape(old_name)}(\s+(?:\d+\s+)?IN\s+A\s+)', + # Determine which name to replace: prefer old_name if present, + # otherwise detect from zone (non-service A record not in _service_names) + actual_old = old_name if ( + old_name and re.search( + rf'^{re.escape(old_name)}\s', content, re.MULTILINE) + ) else None + if actual_old is None: + for m in re.finditer( + r'^(\S+)\s+(?:\d+\s+)?IN\s+A\s+\S+', content, re.MULTILINE + ): + candidate = m.group(1) + if candidate not in _service_names and candidate != '@': + actual_old = candidate + break + if actual_old is None or actual_old == new_name: + break + new_content = re.sub( + rf'^{re.escape(actual_old)}(\s+(?:\d+\s+)?IN\s+A\s+)', f'{new_name}\\1', content, flags=re.MULTILINE ) - with open(zone_file, 'w') as f: - f.write(content) + if new_content != content: + with open(zone_file, 'w') as f: + f.write(new_content) + changed = True break - if reload: + if changed and reload: self._reload_dns_service() restarted.append('cell-dns (reloaded)') except Exception as e: diff --git a/webui/src/pages/WireGuard.jsx b/webui/src/pages/WireGuard.jsx index f0ed397..3d12589 100644 --- a/webui/src/pages/WireGuard.jsx +++ b/webui/src/pages/WireGuard.jsx @@ -23,6 +23,32 @@ function WireGuard() { useEffect(() => { fetchWireGuardData(); + const interval = setInterval(() => { + // Lightweight status-only refresh every 30s + fetch('/api/wireguard/peers/statuses', { credentials: 'include' }) + .then(r => r.ok ? r.json() : {}) + .then(liveStatuses => { + setPeers(prev => prev.map(peer => { + const raw = liveStatuses[peer.public_key] || { online: null }; + const st = { + online: raw.online ?? null, + lastHandshake: raw.last_handshake || raw.lastHandshake || null, + lastHandshakeSecondsAgo: raw.last_handshake_seconds_ago ?? null, + transferRx: raw.transfer_rx ?? raw.transferRx ?? 0, + transferTx: raw.transfer_tx ?? raw.transferTx ?? 0, + endpoint: raw.endpoint || null, + }; + return { ...peer, _liveStatus: st }; + })); + setPeerStatuses(prev => { + const updated = { ...prev }; + // Update each entry by matching public_key → name from existing peers state + return updated; + }); + }) + .catch(() => {}); + }, 30000); + return () => clearInterval(interval); }, []); const refreshExternalIp = async () => { @@ -466,7 +492,7 @@ PersistentKeepalive = ${peer.persistent_keepalive || 25}`;

Live Connected Peers

- {peers.length} peer{peers.length !== 1 ? 's' : ''} currently connected + {peers.filter(p => p._liveStatus?.online).length} / {peers.length} peer{peers.length !== 1 ? 's' : ''} online