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