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:
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user