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:
2026-05-01 08:05:45 -04:00
parent 5d0238ff3c
commit f3118ff401
3 changed files with 73 additions and 11 deletions
+9
View File
@@ -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()
+37 -10
View File
@@ -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: