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
+5 -2
View File
@@ -325,13 +325,16 @@ export const probeRelayDesktopHost = async (relay: DesktopHostRelay): Promise<Ho
}
};
export const desktopHostProbe = async (url: string, options?: { clientToken?: string | null; requestHeaders?: Record<string, string> | null }): Promise<HostProbeResult> => {
export const desktopHostProbe = async (url: string, options?: { clientToken?: string | null; requestHeaders?: Record<string, string> | null; expectedServerId?: string | null }): Promise<HostProbeResult> => {
const invoke = getInvoke();
if (!invoke) {
return { status: 'unreachable', latencyMs: 0 };
}
const raw = await invoke('desktop_host_probe', { url, clientToken: options?.clientToken || undefined, requestHeaders: options?.requestHeaders || undefined });
// `expectedServerId` makes the main-process probe verify the address's
// UNAUTHENTICATED /health identity before sending the bearer token — required
// when probing an address learned at runtime rather than typed by the user.
const raw = await invoke('desktop_host_probe', { url, clientToken: options?.clientToken || undefined, requestHeaders: options?.requestHeaders || undefined, expectedServerId: options?.expectedServerId || undefined });
if (!isRecord(raw)) {
return { status: 'unreachable', latencyMs: 0 };
}
+97 -1
View File
@@ -1,7 +1,99 @@
import { isElectronShell } from '@/lib/desktop';
import { desktopHostProbe, desktopHostsGet, getDesktopHostApiUrl, normalizeHostUrl } from '@/lib/desktopHosts';
import { desktopHostProbe, desktopHostsGet, desktopHostsSet, getDesktopHostApiUrl, normalizeHostUrl } from '@/lib/desktopHosts';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { getRuntimeKey, switchRuntimeEndpoint } from '@/lib/runtime-switch';
// Let the post-switch bootstrap traffic settle before the background refresh.
const CANDIDATE_REFRESH_DELAY_MS = 5_000;
let candidateRefreshInFlight = false;
/**
* Background candidate refresh for a relay-connected desktop host: ask the
* server (over the live authenticated transport) for its CURRENT LAN addresses,
* update the stored host's direct `apiUrl` if it moved (pairing-time addresses
* go stale when DHCP reassigns the host machine's IP), then probe the fresh
* address — identity-gated by the host's pinned relay serverId — and hot-switch
* relay → direct when it is reachable. The runtime key stays `host:<id>`, so the
* swap is a transport change, not an instance switch.
*
* This rewrites only the direct address of an ALREADY-TRUSTED host, learned from
* that host itself over the E2EE tunnel pinned to its key — the token and trust
* boundary are unchanged, so no user confirmation is required. An https apiUrl
* (stable tunnel hostname) is never overwritten: the DHCP problem does not apply
* to it and the server does not know its own public hostnames.
*/
export const refreshDesktopHostCandidates = async (hostId: string): Promise<void> => {
if (!isElectronShell() || candidateRefreshInFlight) return;
const runtimeKey = `host:${hostId}`;
// The candidates fetch rides the active runtime's transport — only meaningful
// while this host IS the active runtime.
if (getRuntimeKey() !== runtimeKey) return;
candidateRefreshInFlight = true;
try {
const config = await desktopHostsGet().catch(() => null);
const host = config?.hosts.find((entry) => entry.id === hostId);
if (!config || !host?.relay) return;
const currentApiUrl = host.apiUrl ? normalizeHostUrl(host.apiUrl) : null;
if (currentApiUrl && currentApiUrl.startsWith('https://')) return;
const response = await runtimeFetch('/api/client-auth/connection/candidates').catch(() => null);
if (!response?.ok) return;
const payload = await response.json().catch(() => null) as { serverId?: unknown; candidates?: unknown } | null;
// Identity gate: the refresh must come from the server this host entry is
// pinned to; anything else (including old servers without serverId) is ignored.
if (!payload || payload.serverId !== host.relay.serverId) return;
const reported = Array.isArray(payload.candidates) ? payload.candidates : [];
const lanUrls: string[] = [];
for (const entry of reported) {
if (!entry || typeof entry !== 'object') continue;
const record = entry as Record<string, unknown>;
if (record.type !== 'lan' || typeof record.url !== 'string') continue;
const url = normalizeHostUrl(record.url);
if (url && !lanUrls.includes(url)) lanUrls.push(url);
}
// Empty answer (loopback-only bind / scan failure) must not erase a stored
// address — a stale one only costs a fast failed probe on the next start.
if (lanUrls.length === 0) return;
const nextApiUrl = currentApiUrl && lanUrls.includes(currentApiUrl) ? currentApiUrl : lanUrls[0];
if (nextApiUrl !== currentApiUrl) {
await desktopHostsSet({
hosts: config.hosts.map((entry) => (entry.id === hostId ? { ...entry, apiUrl: nextApiUrl } : entry)),
defaultHostId: config.defaultHostId,
initialHostChoiceCompleted: config.initialHostChoiceCompleted,
}).catch(() => undefined);
}
// We are on the relay for this host (the refresh call itself proves the
// tunnel works) — if the fresh direct address answers AND proves the same
// server identity, hot-switch to it.
const probe = await desktopHostProbe(nextApiUrl, {
clientToken: host.clientToken || null,
requestHeaders: host.requestHeaders || null,
expectedServerId: host.relay.serverId,
}).catch(() => ({ status: 'unreachable' as const, latencyMs: 0 }));
if (probe.status === 'unreachable' || probe.status === 'wrong-service' || probe.status === 'incompatible') return;
if (getRuntimeKey() !== runtimeKey) return; // user switched away meanwhile
switchRuntimeEndpoint({
apiBaseUrl: nextApiUrl,
clientToken: host.clientToken || null,
requestHeaders: host.requestHeaders || null,
runtimeKey,
});
} finally {
candidateRefreshInFlight = false;
}
};
/** Fire-and-forget wrapper: schedule the refresh after a relay switch settles. */
export const scheduleDesktopHostCandidateRefresh = (hostId: string): void => {
if (typeof window === 'undefined') return;
window.setTimeout(() => {
void refreshDesktopHostCandidates(hostId).catch(() => undefined);
}, CANDIDATE_REFRESH_DELAY_MS);
};
/**
* On desktop startup, reconnect a relay-capable default host. The Electron
* shell boots the LOCAL UI for any host that carries a relay leg and defers
@@ -48,4 +140,8 @@ export const restoreDesktopRelayRuntime = async (targetHostId?: string): Promise
runtimeKey,
relay: host.relay,
});
// Landed on the relay because the stored direct address failed — ask the
// server for its current LAN address in the background and hot-switch back
// to direct if it simply moved (DHCP re-lease).
scheduleDesktopHostCandidateRefresh(host.id);
};