feat: connection candidates refresh + relay identity hardening

Candidates refresh (server + mobile + desktop clients):
- GET /api/client-auth/connection/candidates returns the server's current
  LAN URLs, relay candidate, and serverId for already-paired devices
- /health and /api/version expose serverId so clients can verify a learned
  address belongs to the expected server before sending their bearer token
- mobile: refresh saved candidates over the live transport after every
  connect/wake, hot-switch relay->LAN when a fresh address is reachable;
  serverId gate on direct probes; token no longer sent to /health
- desktop: refresh stored host apiUrl after a relay connect and hot-switch
  back to direct; electron probe verifies serverId before authenticated fetch

Fixes found while debugging a dead pairing:
- settings: strict reader that throws on corrupt/unreadable file instead of
  returning {}; relay signing/encryption key generation is now gated on it,
  so a swallowed read failure can no longer mint a new server identity and
  orphan every paired device (loud log when a keypair IS generated)
- SessionAuthGate: bounded auto-retry for transient session-check failures
  (initial request racing the relay tunnel's first WS attempt, startup 5xx)
This commit is contained in:
Bohdan Triapitsyn
2026-07-12 18:09:54 +03:00
parent 22d5ad3814
commit afb368e11b
16 changed files with 610 additions and 27 deletions
+38
View File
@@ -365,6 +365,7 @@ const settingsRuntime = createSettingsRuntime({
const readSettingsFromDiskMigrated = (...args) => settingsRuntime.readSettingsFromDiskMigrated(...args);
const readSettingsFromDisk = (...args) => settingsRuntime.readSettingsFromDisk(...args);
const readSettingsFromDiskStrict = (...args) => settingsRuntime.readSettingsFromDiskStrict(...args);
const writeSettingsToDisk = (...args) => settingsRuntime.writeSettingsToDisk(...args);
const persistSettings = (...args) => settingsRuntime.persistSettings(...args);
@@ -409,6 +410,7 @@ const apnsRuntime = createApnsRuntime({
APNS_TOKENS_FILE_PATH,
readSettingsFromDiskMigrated,
writeSettingsToDisk,
readSettingsStrict: readSettingsFromDiskStrict,
});
const addOrUpdateApnsToken = (...args) => apnsRuntime.addOrUpdateApnsToken(...args);
@@ -1214,6 +1216,37 @@ async function main(options = {}) {
const lan = lanHost ? `http://${lanHost.includes(':') ? `[${lanHost}]` : lanHost}:${activePort}` : null;
return { local, lan, relayAvailable: true };
};
// ALL direct LAN URLs this server is currently reachable on, for the
// candidates-refresh endpoint: the address the requesting client already
// reached us on first (guaranteed routable from its network — over the relay
// tunnel this is loopback and yields nothing), then every non-internal IPv4
// interface. A client that paired while the machine had a different DHCP
// lease uses this to replace its stale LAN candidate.
const resolveDirectLanUrls = (req) => {
const activePort = tunnelRuntimeContext.getActivePort() || port;
const urls = [];
const push = (host) => {
if (typeof host !== 'string' || !host) return;
const url = `http://${host.includes(':') ? `[${host}]` : host}:${activePort}`;
if (!urls.includes(url)) urls.push(url);
};
if (isNetworkExposedBindHost(effectiveBindHost)) {
push(requestReachedLanAddress(req));
try {
for (const list of Object.values(os.networkInterfaces())) {
for (const entry of (list || [])) {
if (entry.family === 'IPv4' && !entry.internal) push(entry.address);
}
}
} catch {
// interface scan failure → whatever we already collected
}
} else {
const h = String(effectiveBindHost || '').toLowerCase();
if (h && h !== '127.0.0.1' && h !== 'localhost' && h !== '::1') push(effectiveBindHost);
}
return urls;
};
const uiPassword = typeof options.uiPassword === 'string'
? options.uiPassword
: (typeof process.env.OPENCHAMBER_UI_PASSWORD === 'string' ? process.env.OPENCHAMBER_UI_PASSWORD : null);
@@ -1374,6 +1407,10 @@ async function main(options = {}) {
// redeemed device can flip relay demand on or off).
reconcileRelay: () => (relayServiceInstance ? relayServiceInstance.reconcile() : Promise.resolve()),
getPairingTransports: resolvePairingTransports,
getDirectCandidateUrls: resolveDirectLanUrls,
// Stable server identity for client-side verification of learned addresses.
// Lazily resolved: the relay service is constructed after these routes.
getServerId: () => (relayServiceInstance ? relayServiceInstance.getServerId() : Promise.resolve(null)),
// The display name a paired device shows for THIS server. Devices name the
// connection by the issuing machine's hostname, not the per-device pairing
// label typed by the operator.
@@ -1436,6 +1473,7 @@ async function main(options = {}) {
os,
readSettingsFromDiskMigrated,
writeSettingsToDisk,
readSettingsStrict: readSettingsFromDiskStrict,
remoteClientAuthRuntime,
getLocalPort: () => tunnelRuntimeContext.getActivePort(),
// Relay demand = any paired device or pending pairing session that uses the