fix: sync API key-store from wg0.conf to prevent WireGuard handshake failure

linuxserver/wireguard auto-generates its own PrivateKey on first container
start, independently of the PIC API's key-store.  When the two diverge, the
API generates peer configs with the wrong server public key and the WireGuard
handshake fails silently — the client can ping the VPN subnet (10.0.0.x) but
gets no internet and cannot reach any Docker service (172.20.0.x).

Adds _sync_keys_from_conf(): called at the top of apply_config(), reads the
PrivateKey from wg0.conf, derives the matching public key, and overwrites the
API key files (private.key / public.key) if they differ.  This makes wg0.conf
the authoritative source for the server identity, keeping get_peer_config()
consistent with the live WireGuard interface.

Adds 5 new tests in TestSyncKeysFromConf covering:
- key-store update when conf key differs
- no-op when keys already match
- get_peer_config() uses the synced key
- no raise when conf is missing
- apply_config() passes the synced key through bootstrap

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-26 16:40:21 -04:00
parent 9418c3da5b
commit e5d59fd94d
2 changed files with 140 additions and 0 deletions
+44
View File
@@ -223,6 +223,45 @@ class WireGuardManager(BaseServiceManager):
except Exception:
return []
def _sync_keys_from_conf(self) -> None:
"""Sync the API's key store from wg0.conf so both agree on the server identity.
linuxserver/wireguard auto-generates a PrivateKey on first container start.
The API generates its own key independently. Any time apply_config() runs,
read the PrivateKey from wg0.conf (the container's authoritative source) and
update the API's key-store files to match — keeping get_keys() consistent.
"""
import base64 as _b64
cf = self._config_file()
if not os.path.exists(cf):
return
try:
with open(cf) as f:
raw = f.read()
for line in raw.splitlines():
stripped = line.strip()
if stripped.startswith('PrivateKey'):
conf_priv = stripped.split('=', 1)[1].strip()
api_keys = self.get_keys()
if conf_priv == api_keys.get('private_key'):
return # already in sync
# Derive public key from private key and update both files
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
priv_bytes = _b64.b64decode(conf_priv)
priv_obj = X25519PrivateKey.from_private_bytes(priv_bytes)
pub_bytes = priv_obj.public_key().public_bytes_raw()
pub_b64 = _b64.b64encode(pub_bytes).decode()
priv_file = os.path.join(self.keys_dir, 'private.key')
pub_file = os.path.join(self.keys_dir, 'public.key')
with open(priv_file, 'wb') as f:
f.write(priv_bytes)
with open(pub_file, 'wb') as f:
f.write(pub_bytes)
logger.info(f'wg: key-store synced from wg0.conf (new pub={pub_b64[:16]}...)')
return
except Exception as e:
logger.warning(f'_sync_keys_from_conf failed (non-fatal): {e}')
def apply_config(self, config: Dict[str, Any]) -> Dict[str, Any]:
"""Update wg0.conf interface fields and restart cell-wireguard."""
restarted = []
@@ -232,6 +271,11 @@ class WireGuardManager(BaseServiceManager):
warnings.append('wg0.conf not found — skipping')
return {'restarted': restarted, 'warnings': warnings}
try:
# Sync the API key-store from wg0.conf before doing anything else.
# linuxserver/wireguard auto-generates its own key; this keeps both in sync
# so get_peer_config() always embeds the correct server public key.
self._sync_keys_from_conf()
with open(cf) as f:
raw = f.read()