fix(vpn): sync WireGuard server key on startup; fix DNS zone cell_name/SOA; fix peer status UI
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -297,7 +297,16 @@ def _recover_pending_apply():
|
|||||||
|
|
||||||
_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
|
# 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=_apply_startup_enforcement, daemon=True).start()
|
||||||
threading.Thread(target=_bootstrap_dns, daemon=True).start()
|
threading.Thread(target=_bootstrap_dns, daemon=True).start()
|
||||||
|
|
||||||
|
|||||||
+37
-10
@@ -476,12 +476,18 @@ class NetworkManager(BaseServiceManager):
|
|||||||
if os.path.exists(src):
|
if os.path.exists(src):
|
||||||
with open(src) as f:
|
with open(src) as f:
|
||||||
zone_content = f.read()
|
zone_content = f.read()
|
||||||
|
# Try $ORIGIN first, then fall back to SOA MNAME
|
||||||
m = re.search(r'^\$ORIGIN\s+(\S+)', zone_content, re.MULTILINE)
|
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:
|
if old_origin and old_origin != domain:
|
||||||
zone_content = zone_content.replace(f'{old_origin}.', f'{domain}.')
|
zone_content = zone_content.replace(f'{old_origin}.', f'{domain}.')
|
||||||
zone_content = re.sub(
|
if re.search(r'^\$ORIGIN\s+', zone_content, re.MULTILINE):
|
||||||
r'^\$ORIGIN\s+\S+', f'$ORIGIN {domain}.', zone_content, flags=re.MULTILINE)
|
zone_content = re.sub(
|
||||||
|
r'^\$ORIGIN\s+\S+', f'$ORIGIN {domain}.', zone_content, flags=re.MULTILINE)
|
||||||
with open(dst, 'w') as f:
|
with open(dst, 'w') as f:
|
||||||
f.write(zone_content)
|
f.write(zone_content)
|
||||||
for zone_path in zone_files:
|
for zone_path in zone_files:
|
||||||
@@ -507,11 +513,15 @@ class NetworkManager(BaseServiceManager):
|
|||||||
"""Update the cell hostname record in the primary DNS zone file.
|
"""Update the cell hostname record in the primary DNS zone file.
|
||||||
|
|
||||||
reload=False writes the zone file only — use when deferring container restart.
|
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 = []
|
restarted = []
|
||||||
warnings = []
|
warnings = []
|
||||||
if not old_name or not new_name or old_name == new_name:
|
if not new_name:
|
||||||
return {'restarted': restarted, 'warnings': warnings}
|
return {'restarted': restarted, 'warnings': warnings}
|
||||||
|
_service_names = {'api', 'webui', 'calendar', 'files', 'mail', 'webmail', 'webdav'}
|
||||||
|
changed = False
|
||||||
try:
|
try:
|
||||||
dns_data = os.path.join(self.data_dir, 'dns')
|
dns_data = os.path.join(self.data_dir, 'dns')
|
||||||
if os.path.isdir(dns_data):
|
if os.path.isdir(dns_data):
|
||||||
@@ -520,16 +530,33 @@ class NetworkManager(BaseServiceManager):
|
|||||||
zone_file = os.path.join(dns_data, fname)
|
zone_file = os.path.join(dns_data, fname)
|
||||||
with open(zone_file) as f:
|
with open(zone_file) as f:
|
||||||
content = f.read()
|
content = f.read()
|
||||||
# Match name with optional TTL: "name [ttl] IN A value"
|
# Determine which name to replace: prefer old_name if present,
|
||||||
content = re.sub(
|
# otherwise detect from zone (non-service A record not in _service_names)
|
||||||
rf'^{re.escape(old_name)}(\s+(?:\d+\s+)?IN\s+A\s+)',
|
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',
|
f'{new_name}\\1',
|
||||||
content, flags=re.MULTILINE
|
content, flags=re.MULTILINE
|
||||||
)
|
)
|
||||||
with open(zone_file, 'w') as f:
|
if new_content != content:
|
||||||
f.write(content)
|
with open(zone_file, 'w') as f:
|
||||||
|
f.write(new_content)
|
||||||
|
changed = True
|
||||||
break
|
break
|
||||||
if reload:
|
if changed and reload:
|
||||||
self._reload_dns_service()
|
self._reload_dns_service()
|
||||||
restarted.append('cell-dns (reloaded)')
|
restarted.append('cell-dns (reloaded)')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -23,6 +23,32 @@ function WireGuard() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchWireGuardData();
|
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 () => {
|
const refreshExternalIp = async () => {
|
||||||
@@ -466,7 +492,7 @@ PersistentKeepalive = ${peer.persistent_keepalive || 25}`;
|
|||||||
<h3 className="text-lg font-medium text-gray-900">Live Connected Peers</h3>
|
<h3 className="text-lg font-medium text-gray-900">Live Connected Peers</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-500">
|
<div className="text-sm text-gray-500">
|
||||||
{peers.length} peer{peers.length !== 1 ? 's' : ''} currently connected
|
{peers.filter(p => p._liveStatus?.online).length} / {peers.length} peer{peers.length !== 1 ? 's' : ''} online
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user