diff --git a/src/main/managed-server/lifecycle/orcad-managed-bound-port.ts b/src/main/managed-server/lifecycle/orcad-managed-bound-port.ts index d295a214114..085cf964db9 100644 --- a/src/main/managed-server/lifecycle/orcad-managed-bound-port.ts +++ b/src/main/managed-server/lifecycle/orcad-managed-bound-port.ts @@ -19,7 +19,7 @@ import { execOrcadRemote, type OrcadRemoteExecTarget } from '../install/orcad-remote-runtime-control' -import type { OrcadHostTransport } from '../../ssh/orcad-host-transport' +import type { OrcadHostTarget, OrcadHostTransport } from '../../ssh/orcad-host-transport' export type OrcadTunnelPortInput = { environment: KnownRuntimeEnvironment @@ -52,10 +52,11 @@ async function readManagedOrcadSlotReadiness( return parsed.state === 'ready' ? parsed.readiness : null } -/** What the active slot publishes now, read over SSH; null when none is active or ready. */ -export async function readActiveManagedOrcadReadiness( - input: Pick -): Promise { +/** What the active slot publishes now, over SSH or WSL; null when none is active or ready. */ +export async function readActiveManagedOrcadReadiness(input: { + target: OrcadHostTarget + connection: OrcadHostTransport +}): Promise { const context = await resolveOrcadRemoteContext(input.target, input.connection) const active = context.activationRecord.active if (!active) { diff --git a/src/main/managed-server/lifecycle/orcad-managed-pairing-repair.ts b/src/main/managed-server/lifecycle/orcad-managed-pairing-repair.ts index 314c513b7f7..c3e6cf40054 100644 --- a/src/main/managed-server/lifecycle/orcad-managed-pairing-repair.ts +++ b/src/main/managed-server/lifecycle/orcad-managed-pairing-repair.ts @@ -14,9 +14,8 @@ import { getPreferredPairingOffer, type KnownRuntimeEnvironment } from '../../../shared/runtime-environments' -import type { SshTarget } from '../../../shared/ssh-types' import type { ServeReadiness } from '../../server/serve-readiness' -import type { OrcadHostTransport } from '../../ssh/orcad-host-transport' +import type { OrcadHostTarget, OrcadHostTransport } from '../../ssh/orcad-host-transport' import { tunneledOrcadPairingCode } from '../../ssh/orcad-tunneled-pairing' import { getSshConnectionManager, @@ -33,16 +32,17 @@ export const PAIRING_REPAIR_WINDOW_MS = 10 * 60_000 // Why remembered briefly: every reconnecting pane would otherwise re-read the host over SSH. export const PAIRING_REPAIR_FAILURE_HOLD_MS = 15_000 -export type ManagedOrcadPairingRepairDeps = { +export type ManagedOrcadHostAccess = { target: OrcadHostTarget; connection: OrcadHostTransport } + +/** SSH reaches the host over SSH; WSL through its distro, the trust root of its own pairing. */ +export type ManagedOrcadPairingRepairDeps< + Access extends ManagedOrcadHostAccess = ManagedOrcadHostAccess +> = { resolve: (environmentId: string) => KnownRuntimeEnvironment - connect: (environment: KnownRuntimeEnvironment) => Promise<{ - target: SshTarget - connection: OrcadHostTransport - }> - readReadiness: (input: { - target: SshTarget - connection: OrcadHostTransport - }) => Promise + connect: (environment: KnownRuntimeEnvironment) => Promise + readReadiness: (input: Access) => Promise + /** The bridge or tunnel ports the pairing names; defaults to the SSH deployment's. */ + ports?: (environment: KnownRuntimeEnvironment) => { localPort: number; remotePort: number } | null refresh: (environmentId: string, pairingCode: string) => KnownRuntimeEnvironment /** Retires every transport still authenticating with the refused token, once per repair. */ repaired: (environmentId: string) => void | Promise @@ -95,14 +95,16 @@ type StandingFailure = { error: Error; revision: number; until: number } * refused pairing. Keyed by the refused revision: a refusal seen under an older pairing than the * stored one was already answered, so it gets the stored pairing without reaching the host. */ -export class ManagedOrcadPairingRepairs { +export class ManagedOrcadPairingRepairs< + Access extends ManagedOrcadHostAccess = ManagedOrcadHostAccess +> { private readonly inFlight = new Map>() private readonly attemptedAt = new Map() private readonly standing = new Map() // Why: a repair that was already running when the user asked again must not undo that reset. private readonly resets = new Map() - constructor(private readonly deps: ManagedOrcadPairingRepairDeps) {} + constructor(private readonly deps: ManagedOrcadPairingRepairDeps) {} /** * The failure that stands for this pairing now, or null. A build under it would cost SSH execs, @@ -166,11 +168,11 @@ export class ManagedOrcadPairingRepairs { throw fail(capSpentError(recent.length)) } assertRuntimeEnvironmentNotReconciling(environment) - const deployment = getOrcadSshDeployment(environment) + const deployment = (this.deps.ports ?? getOrcadSshDeployment)(environment) if (!deployment) { - throw new OrcadManagedPairingRepairFailedError('this server is not managed over SSH') + throw new OrcadManagedPairingRepairFailedError('this server has no managed deployment') } - // Why counted only once read: losing SSH proves nothing about the pairing. + // Why counted only once read: losing contact with the host proves nothing about the pairing. const readiness = await this.deps.readReadiness(await this.deps.connect(environment)) // Why re-read before judging the answer: an update or recovery may have stored a newer pairing // while SSH answered; everything after this is synchronous, so the check holds through the write. @@ -249,6 +251,12 @@ export class ManagedOrcadPairingRepairs { } } +/** What callers of the gate use; independent of how a host is reached. */ +export type ManagedOrcadPairingGate = Pick< + ManagedOrcadPairingRepairs, + 'standingFailure' | 'repair' | 'recordRefusedAfterRepair' | 'resetEpoch' | 'forgetFailures' +> + function capSpentError(attempts: number): Error { return new OrcadManagedPairingRepairFailedError( `it tried ${attempts} times in ${PAIRING_REPAIR_WINDOW_MS / 60_000} minutes ` + diff --git a/src/main/managed-server/lifecycle/orcad-managed-tunnel-pairing-recovery.ts b/src/main/managed-server/lifecycle/orcad-managed-tunnel-pairing-recovery.ts index fd9eb1e2388..a498af07759 100644 --- a/src/main/managed-server/lifecycle/orcad-managed-tunnel-pairing-recovery.ts +++ b/src/main/managed-server/lifecycle/orcad-managed-tunnel-pairing-recovery.ts @@ -4,12 +4,12 @@ * foreign server never reaches the repair; it fails the build as a foreign answer instead. */ import type { KnownRuntimeEnvironment } from '../../../shared/runtime-environments' -import { pairingRevisionOf, type ManagedOrcadPairingRepairs } from './orcad-managed-pairing-repair' +import { pairingRevisionOf, type ManagedOrcadPairingGate } from './orcad-managed-pairing-repair' import { OrcadManagedPairingRejectedError } from './orcad-managed-tunnel-identity' export type ManagedOrcadPairingRecovery = { resolve: (environmentId: string) => KnownRuntimeEnvironment - repairs: ManagedOrcadPairingRepairs + repairs: ManagedOrcadPairingGate /** Forwards on the new pairing; the refusal already removed the old forward. */ ensure: (environment: KnownRuntimeEnvironment) => Promise } @@ -37,7 +37,7 @@ export async function buildWithPairingRepair( if (!(error instanceof OrcadManagedPairingRejectedError)) { throw error } - console.warn('[ssh] The managed Orca server refused this desktop; pairing again:', error) + console.warn('[managed-orcad] The Orca server refused this desktop; pairing again:', error) } // Why the refused revision: a repair another caller already landed is joined, not redone. const repaired = await recovery.repairs.repair(environmentId, pairingRevisionOf(refused)) diff --git a/src/main/wsl/wsl-host-connector.ts b/src/main/wsl/wsl-host-connector.ts index 030320a1c66..a8660d060d3 100644 --- a/src/main/wsl/wsl-host-connector.ts +++ b/src/main/wsl/wsl-host-connector.ts @@ -1,4 +1,5 @@ /** The production WSL side of `WslHostService`: one holder, connection manager and supervisor. */ +import { createWslOrcadPairingRecovery } from './wsl-orcad-pairing-repair' import { readConfirmedRunningWslDistros } from '../wsl-background-distro-gate' import { parsePairingCode } from '../../shared/pairing' import { pairedRuntimeConnectionFromOffer } from '../../shared/paired-runtime-connection' @@ -62,8 +63,10 @@ export function createWslHostConnector(options: { ? unguardedHolder.ensureHeld(distro) : Promise.reject(refuseUnknown(distro)) } + const pairingRecovery = createWslOrcadPairingRecovery(options.userDataPath) const manager = new WslOrcadConnectionManager({ holder, + pairingRecovery, getPairingOffer: (environmentId) => findWslHostDeployment(options.userDataPath, environmentId) ? resolveEnvironmentPairingOffer(options.userDataPath, environmentId) @@ -141,6 +144,7 @@ export function createWslHostConnector(options: { listDistros: () => listWslHostDistros(), readOrcadPublicKey: (distro) => readWslDistroOrcadPublicKey(transportFor(distro)), connect: (distro, connectOptions) => liveness.connect(distro, connectOptions), + forgetPairingFailures: (environmentId) => pairingRecovery.repairs.forgetFailures(environmentId), get: (distro) => manager.get(distro), isReachable: async (distro, environmentId) => options.runtime.isReady?.(environmentId) || diff --git a/src/main/wsl/wsl-host-service.test.ts b/src/main/wsl/wsl-host-service.test.ts index bcc786d18c2..97eb6d78791 100644 --- a/src/main/wsl/wsl-host-service.test.ts +++ b/src/main/wsl/wsl-host-service.test.ts @@ -48,6 +48,7 @@ function makeConnector(distros: WslHostDistroCandidate[]) { return opened }), get: vi.fn((distro: string) => active.get(distro.toLowerCase()) ?? null), + forgetPairingFailures: vi.fn((_environmentId: string) => {}), isReachable: vi.fn(async (distro: string) => active.has(distro.toLowerCase())), disconnect: vi.fn(async (distro: string) => { active.delete(distro.toLowerCase()) @@ -258,6 +259,8 @@ describe('WslHostService', () => { expect(connector.connect).toHaveBeenLastCalledWith('Ubuntu', { intent: 'force' }) + // A user's Connect is asking again, so a standing pairing-repair failure must not answer it. + expect(connector.forgetPairingFailures).toHaveBeenLastCalledWith('wsl:ubuntu') }) it('reconciles a retained dead connection for user work without booting for passive calls', async () => { diff --git a/src/main/wsl/wsl-host-service.ts b/src/main/wsl/wsl-host-service.ts index 7b2c7ca2a0d..63be518da66 100644 --- a/src/main/wsl/wsl-host-service.ts +++ b/src/main/wsl/wsl-host-service.ts @@ -36,6 +36,8 @@ export type WslHostConnector = { readOrcadPublicKey(distro: string): Promise /** User-initiated; may boot the distro. An intent retries or forces a skipped update. */ connect(distro: string, options?: { intent?: WslHostConnectIntent }): Promise + /** The user asked again: a standing pairing-repair failure or spent cap must not answer it. */ + forgetPairingFailures?(environmentId: string): void get(distro: string): WslOrcadConnection | null /** Checks the paired server without starting a stopped or unconfirmed distro. */ isReachable(distro: string, environmentId: string): Promise @@ -217,6 +219,7 @@ export class WslHostService { if (refusal || this.removing.has(environmentId)) { throw new Error(refusal?.message ?? WSL_HOST_REMOVING_MESSAGE) } + this.wsl.forgetPairingFailures?.(environmentId) await this.wsl.connect(record.distro, options) } diff --git a/src/main/wsl/wsl-orcad-connection-types.ts b/src/main/wsl/wsl-orcad-connection-types.ts new file mode 100644 index 00000000000..e830bbe8aa8 --- /dev/null +++ b/src/main/wsl/wsl-orcad-connection-types.ts @@ -0,0 +1,19 @@ +import type { WslOrcadDeferredUpdate } from './wsl-orcad-deploy' + +/** One open bridge to a distro's orcad, as the connection manager publishes it. */ +export type WslOrcadConnection = { + distro: string + environmentId: string + /** The loopback port on this machine the pairing offer points at. */ + localPort: number + /** orcad's port inside the distro. */ + remotePort: number + fullVersion: string + /** orcad's pid inside the distro, from its pid file; null when unreadable. */ + pid: number | null + /** orcad's pairing code, with its endpoint rewritten to `ws://127.0.0.1:`. */ + pairingOffer: string + /** An app update the incumbent `fullVersion` is waiting to take (live terminals). */ + deferredUpdate: WslOrcadDeferredUpdate | null + close(): Promise +} diff --git a/src/main/wsl/wsl-orcad-connection.test.ts b/src/main/wsl/wsl-orcad-connection.test.ts index 94ef1b16cd4..3356d617a16 100644 --- a/src/main/wsl/wsl-orcad-connection.test.ts +++ b/src/main/wsl/wsl-orcad-connection.test.ts @@ -3,6 +3,15 @@ import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest import { encodePairingOffer, parsePairingCode, PAIRING_OFFER_VERSION } from '../../shared/pairing' import type { ServeReadiness } from '../server/serve-readiness' import { OrcadManagedIdentityError } from '../managed-server/lifecycle/orcad-managed-tunnel-identity' +import { + ManagedOrcadPairingRepairs, + type ManagedOrcadPairingRepairDeps +} from '../managed-server/lifecycle/orcad-managed-pairing-repair' +import { + createEnvironmentFromPairingOffer, + getPreferredPairingOffer, + type KnownRuntimeEnvironment +} from '../../shared/runtime-environments' import { checkOrcadStdioBridge, openOrcadStdioBridgeChannel } from '../ssh/orcad-stdio-bridge' import { startEchoServer } from '../managed-server/lifecycle/orcad-stdio-bridge-test-channel' import { fakeWslBridgeChildren, posixOrcadStdioBridge } from './wsl-orcad-bridge-test-child' @@ -383,3 +392,164 @@ describe('WslOrcadConnectionManager', () => { expect(wslOrcadHostDistro(connection.environmentId)).toBeNull() }) }) + +describe('a WSL host that refuses the enrolled pairing', () => { + function offerCode(deviceToken: string, publicKeyB64 = 'public-key'): string { + return encodePairingOffer({ + v: PAIRING_OFFER_VERSION, + endpoint: 'ws://127.0.0.1:41000', + deviceToken, + publicKeyB64 + }) + } + + /** An in-memory pairing store and the managed repair gate over it. */ + function repairable(published: ServeReadiness) { + let stored: KnownRuntimeEnvironment = createEnvironmentFromPairingOffer({ + id: 'wsl:ubuntu', + name: 'Ubuntu', + now: 1, + offer: parsePairingCode(offerCode('lost-token'))! + }) + const refresh = vi.fn((environmentId: string, pairingCode: string) => { + stored = createEnvironmentFromPairingOffer({ + id: environmentId, + name: 'Ubuntu', + now: 2, + offer: parsePairingCode(pairingCode)! + }) + return stored + }) + const deps: ManagedOrcadPairingRepairDeps = { + resolve: () => stored, + connect: async () => { + const target = { kind: 'wsl', id: 'wsl:ubuntu', distro: 'Ubuntu', label: 'Ubuntu' } as const + // Never used: readReadiness answers from the fixture. + const spawn = fakeWslBridgeChildren().spawn + return { target, connection: createWslOrcadHostTransport({ target, spawn }) } + }, + readReadiness: async () => published, + ports: () => ({ localPort: 41000, remotePort: 6790 }), + refresh, + repaired: vi.fn(), + now: () => 1_000 + } + const recovery = { repairs: new ManagedOrcadPairingRepairs(deps), resolve: deps.resolve } + return { stored: () => stored, refresh, recovery } + } + + // The host knows only the token it published after losing its device registry. + const verifyIdentity = async (pairingOffer: string) => + parsePairingCode(pairingOffer)?.deviceToken === 'fresh-token' + ? ({ verdict: 'verified' } as const) + : ({ verdict: 'unpaired', detail: 'access grant refused' } as const) + + it('heals on the next connect, and the next update counts terminals with the new pairing', async () => { + const store = repairable(readiness(first.port, 'fresh-token')) + const { manager, deployAndLaunch } = setup({ + verifyIdentity, + pairingRecovery: store.recovery, + getPairingOffer: () => getPreferredPairingOffer(store.stored()) + }) + const connection = await manager.ensure('Ubuntu', { environmentId: 'wsl:ubuntu' }) + expect(parsePairingCode(connection.pairingOffer)?.deviceToken).toBe('fresh-token') + expect(store.refresh).toHaveBeenCalledOnce() + await manager.ensure('Ubuntu', { environmentId: 'wsl:ubuntu' }) + expect(deployAndLaunch).toHaveBeenLastCalledWith( + 'Ubuntu', + expect.objectContaining({ + pairedOffer: expect.objectContaining({ deviceToken: 'fresh-token' }) + }) + ) + }) + + it('retires the bridge when the repaired pairing is refused too, so no failure stores the old token', async () => { + const store = repairable(readiness(first.port, 'fresh-token')) + let hostLostRegistry = false + const onChange = vi.fn() + const { manager } = setup({ + // The host accepts the enrolled token until it loses its registry, then accepts nothing. + verifyIdentity: async (pairingOffer: string) => + !hostLostRegistry && parsePairingCode(pairingOffer)?.deviceToken === 'lost-token' + ? ({ verdict: 'verified' } as const) + : ({ verdict: 'unpaired', detail: 'access grant refused' } as const), + pairingRecovery: store.recovery, + getPairingOffer: () => getPreferredPairingOffer(store.stored()), + onChange + }) + await manager.ensure('Ubuntu', { environmentId: 'wsl:ubuntu' }) + hostLostRegistry = true + await expect(manager.ensure('Ubuntu', { environmentId: 'wsl:ubuntu' })).rejects.toThrow() + expect(manager.get('Ubuntu')).toBeNull() + expect(onChange).toHaveBeenLastCalledWith( + 'Ubuntu', + null, + expect.objectContaining({ state: 'failed' }) + ) + expect(getPreferredPairingOffer(store.stored()).deviceToken).toBe('fresh-token') + }) + + it('still runs an Update queued behind a reconnect whose pairing failed', async () => { + const store = repairable(readiness(first.port, 'fresh-token')) + let hostLostRegistry = false + const { manager, deployAndLaunch } = setup({ + verifyIdentity: async (pairingOffer: string) => + !hostLostRegistry && parsePairingCode(pairingOffer)?.deviceToken === 'lost-token' + ? ({ verdict: 'verified' } as const) + : ({ verdict: 'unpaired', detail: 'access grant refused' } as const), + pairingRecovery: store.recovery, + getPairingOffer: () => getPreferredPairingOffer(store.stored()) + }) + await manager.ensure('Ubuntu', { environmentId: 'wsl:ubuntu' }) + hostLostRegistry = true + const reconnect = manager.ensure('Ubuntu', { environmentId: 'wsl:ubuntu' }) + const update = manager.ensure('Ubuntu', { environmentId: 'wsl:ubuntu', intent: 'update' }) + await reconnect.catch(() => {}) + const settled = await update.then( + () => null, + (error: unknown) => error + ) + expect(settled).not.toBeInstanceOf(WslOrcadConnectionSupersededError) + expect(deployAndLaunch).toHaveBeenLastCalledWith( + 'Ubuntu', + expect.objectContaining({ intent: 'update' }) + ) + }) + + it('reuses the bridge of a host still being added, which has no stored pairing to repair', async () => { + const store = repairable(readiness(first.port, 'fresh-token')) + const resolve = vi.fn(() => { + throw new Error('no environment is recorded for this host yet') + }) + const { manager } = setup({ + pairingRecovery: { repairs: store.recovery.repairs, resolve }, + getPairingOffer: () => null + }) + const opened = await manager.ensure('Ubuntu', { environmentId: 'wsl:ubuntu' }) + await expect(manager.ensure('Ubuntu', { environmentId: 'wsl:ubuntu' })).resolves.toMatchObject({ + pairingOffer: opened.pairingOffer + }) + expect(resolve).not.toHaveBeenCalled() + }) + + it('never gives the token to a server under another host key', async () => { + const published = readiness(first.port, 'fresh-token') + if (!published.pairing.available) { + throw new Error('fixture readiness publishes a pairing offer') + } + const store = repairable({ + ...published, + pairing: { ...published.pairing, url: offerCode('fresh-token', 'another-key') } + }) + const { manager } = setup({ + verifyIdentity, + pairingRecovery: store.recovery, + getPairingOffer: () => getPreferredPairingOffer(store.stored()) + }) + await expect(manager.ensure('Ubuntu', { environmentId: 'wsl:ubuntu' })).rejects.toBeInstanceOf( + OrcadManagedIdentityError + ) + expect(store.refresh).not.toHaveBeenCalled() + expect(getPreferredPairingOffer(store.stored()).deviceToken).toBe('lost-token') + }) +}) diff --git a/src/main/wsl/wsl-orcad-connection.ts b/src/main/wsl/wsl-orcad-connection.ts index 3c74feb8b72..50fac1b66c3 100644 --- a/src/main/wsl/wsl-orcad-connection.ts +++ b/src/main/wsl/wsl-orcad-connection.ts @@ -4,6 +4,7 @@ * distro; a relaunch that moved orcad's port moves the bridge with it. In memory only: the * deployment record (C7) persists what `onChange` reports. */ +import type { WslOrcadConnection } from './wsl-orcad-connection-types' import { WslOrcadConnectionSupersededError, WslOrcadLaunchDeferredError @@ -24,35 +25,19 @@ import { wslDistroHolderKey } from './wsl-distro-holder-record' import { defaultWslOrcadEnvironmentId, deployAndLaunchWslOrcad, - type WslOrcadDeferredUpdate, type WslOrcadDeployOptions, type WslOrcadLaunch } from './wsl-orcad-deploy' import type { WslOrcadHostTarget } from './wsl-orcad-host-target' import { createWslOrcadHostTransport } from './wsl-orcad-host-transport' -import { resolveWslOrcadPairing } from './wsl-orcad-pairing-identity' +import { resolveWslOrcadPairing, type WslOrcadPairingRecovery } from './wsl-orcad-pairing-identity' import { trackWslOrcadHostEnvironment } from './wsl-orcad-host-environments' import { listenWslOrcadStdioBridgeOnPreferredPort, type WslOrcadStdioBridgeOptions } from './wsl-orcad-stdio-bridge' -export type WslOrcadConnection = { - distro: string - environmentId: string - /** The loopback port on this machine the pairing offer points at. */ - localPort: number - /** orcad's port inside the distro. */ - remotePort: number - fullVersion: string - /** orcad's pid inside the distro, from its pid file; null when unreadable. */ - pid: number | null - /** orcad's pairing code, with its endpoint rewritten to `ws://127.0.0.1:`. */ - pairingOffer: string - /** An app update the incumbent `fullVersion` is waiting to take (live terminals). */ - deferredUpdate: WslOrcadDeferredUpdate | null - close(): Promise -} +export type { WslOrcadConnection } from './wsl-orcad-connection-types' export type WslOrcadConnectOptions = { environmentId?: string @@ -80,6 +65,8 @@ export type WslOrcadConnectionDependencies = { options: WslOrcadStdioBridgeOptions ) => Promise verifyIdentity?: (pairingOffer: string, readiness: ServeReadiness) => Promise + /** Re-pairs a refused enrolled token; without it a refusal fails the connect. */ + pairingRecovery?: Omit /** A connection (re)opened, its offer changed, or its status moved; null once it closed. */ onChange?: ( distro: string, @@ -146,6 +133,15 @@ export class WslOrcadConnectionManager { return tracked } + /** The persisted enrolled pairing, else the live connection's (a host not yet recorded). */ + private enrolledOffer(key: string, environmentId: string): PairingOffer | null { + const current = this.active.get(key)?.connection + return ( + this.dependencies.getPairingOffer?.(environmentId) ?? + (current?.environmentId === environmentId ? parsePairingCode(current.pairingOffer) : null) + ) + } + get(distro: string): WslOrcadConnection | null { return this.active.get(wslDistroHolderKey(distro))?.connection ?? null } @@ -166,10 +162,17 @@ export class WslOrcadConnectionManager { if (!entry) { return } - this.active.delete(key) + await this.drop(key, entry) + this.dependencies.onChange?.(entry.connection.distro, null, { state: 'closed' }) + } + + /** Retires one bridge; unlike close, a connect queued behind it still runs. */ + private async drop(key: string, entry: ActiveWslOrcadConnection): Promise { + if (this.active.get(key) === entry) { + this.active.delete(key) + } await entry.forward.close() trackWslOrcadHostEnvironment(entry.connection, null) - this.dependencies.onChange?.(entry.connection.distro, null, { state: 'closed' }) } async dispose(): Promise { @@ -201,7 +204,7 @@ export class WslOrcadConnectionManager { environmentId, label, intent: options.intent, - pairedOffer: this.dependencies.getPairingOffer?.(environmentId) ?? null, + pairedOffer: this.enrolledOffer(key, environmentId), signal: options.signal, holder: this.dependencies.holder, createTransport: () => transport @@ -213,11 +216,14 @@ export class WslOrcadConnectionManager { throw new WslOrcadConnectionSupersededError(distro) } const current = this.active.get(key) - const paired = - this.dependencies.getPairingOffer?.(environmentId) ?? - (current?.connection.environmentId === environmentId - ? parsePairingCode(current.connection.pairingOffer) - : null) + const persisted = this.dependencies.getPairingOffer?.(environmentId) ?? null + const paired = persisted ?? this.enrolledOffer(key, environmentId) + // Why only a persisted pairing: the gate re-pairs the stored record, which a host still being + // added has not written yet. + const recovery = + persisted && this.dependencies.pairingRecovery + ? { ...this.dependencies.pairingRecovery, environmentId } + : undefined if ( current && current.connection.remotePort === launch.port && @@ -227,8 +233,14 @@ export class WslOrcadConnectionManager { launch, current.connection.localPort, paired, - this.dependencies.verifyIdentity - ) + this.dependencies.verifyIdentity, + recovery + ).catch(async (error: unknown) => { + // Why retire: this bridge holds a token the host refused, and a failure published with it + // would store that token over a repair the gate already saved. + await this.drop(key, current) + throw error + }) if (this.generation(key) !== generation) { throw new WslOrcadConnectionSupersededError(distro) } @@ -243,9 +255,7 @@ export class WslOrcadConnectionManager { // orcad moved ports (or this is the first connect): the old bridge dials a dead port. const preferredLocalPort = current?.connection.localPort ?? options.localPort ?? 0 if (current) { - this.active.delete(key) - await current.forward.close() - trackWslOrcadHostEnvironment(current.connection, null) + await this.drop(key, current) this.dependencies.onChange?.(distro, null, status.pending) } const forward = await listenWslOrcadStdioBridgeOnPreferredPort( @@ -266,7 +276,8 @@ export class WslOrcadConnectionManager { launch, forward.entry.localPort, paired, - this.dependencies.verifyIdentity + this.dependencies.verifyIdentity, + recovery ) if (this.generation(key) !== generation) { throw new WslOrcadConnectionSupersededError(distro) diff --git a/src/main/wsl/wsl-orcad-pairing-identity.ts b/src/main/wsl/wsl-orcad-pairing-identity.ts index 602eebbee7c..0d82f15f02b 100644 --- a/src/main/wsl/wsl-orcad-pairing-identity.ts +++ b/src/main/wsl/wsl-orcad-pairing-identity.ts @@ -7,14 +7,56 @@ import { type OrcadTunnelIdentity } from '../managed-server/lifecycle/orcad-managed-tunnel-identity' import { tunneledOrcadPairingCode } from '../ssh/orcad-tunneled-pairing' +import { getPreferredPairingOffer } from '../../shared/runtime-environments' +import { + buildWithPairingRepair, + type ManagedOrcadPairingRecovery +} from '../managed-server/lifecycle/orcad-managed-tunnel-pairing-recovery' import type { WslOrcadLaunch } from './wsl-orcad-deploy' -/** Reconnect with the enrolled grant and host key; startup offers only enroll new clients. */ +/** The managed repair gate a refused enrolled token goes through, for one WSL host. */ +export type WslOrcadPairingRecovery = Omit & { + environmentId: string +} + +/** + * Reconnect with the enrolled grant and host key; startup offers only enroll new clients. A token + * the host refuses re-pairs through the gate once, then must verify under the new pairing. + */ export async function resolveWslOrcadPairing( launch: Extract, localPort: number, paired: PairingOffer | null, - verifyIdentity = verifyWslOrcadIdentity + verifyIdentity = verifyWslOrcadIdentity, + recovery?: WslOrcadPairingRecovery +): Promise { + const verified = (offer: PairingOffer | null) => + verifyWslOrcadPairing(launch, localPort, offer, verifyIdentity) + // Why only an enrolled token: a first pairing has nothing to repair. + if (!recovery || !paired) { + return verified(paired) + } + let pairingOffer = '' + await buildWithPairingRepair( + { + ...recovery, + ensure: async (environment) => { + pairingOffer = await verified(getPreferredPairingOffer(environment)) + } + }, + recovery.environmentId, + async () => { + pairingOffer = await verified(paired) + } + ) + return pairingOffer +} + +async function verifyWslOrcadPairing( + launch: Extract, + localPort: number, + paired: PairingOffer | null, + verifyIdentity: typeof verifyWslOrcadIdentity ): Promise { const pairingOffer = tunneledOrcadPairingCode(launch.readiness, localPort, paired) const identity = await verifyIdentity(pairingOffer, launch.readiness) diff --git a/src/main/wsl/wsl-orcad-pairing-repair.ts b/src/main/wsl/wsl-orcad-pairing-repair.ts new file mode 100644 index 00000000000..768844a0ac3 --- /dev/null +++ b/src/main/wsl/wsl-orcad-pairing-repair.ts @@ -0,0 +1,80 @@ +/** + * Pairing again with a WSL host whose orcad refused this desktop's enrolled token (a lost device + * registry, a revoked device), through the managed SSH repair gate: one repair at a time, a capped + * number per window, and only an offer under the pinned host key. The distro runs on this machine, + * so its published offer is read from the distro itself, as SSH reads it over SSH. + */ +import { resolveEnvironment } from '../../shared/runtime-environment-store' +import { + findWslHostDeployment, + refreshWslHostPairing +} from '../../shared/runtime-environment-wsl-host-store' +import type { KnownRuntimeEnvironment } from '../../shared/runtime-environments' +import { readActiveManagedOrcadReadiness } from '../managed-server/lifecycle/orcad-managed-bound-port' +import { + ManagedOrcadPairingRepairs, + type ManagedOrcadPairingRepairDeps +} from '../managed-server/lifecycle/orcad-managed-pairing-repair' +import type { WslOrcadHostTransport } from './wsl-orcad-host-transport' +import { OrcadManagedPairingRepairFailedError } from '../managed-server/lifecycle/orcad-managed-pairing-repair-failure' +import { notifyManagedOrcadPairingRepaired } from '../managed-server/lifecycle/orcad-managed-pairing-repaired-listener' +import type { WslOrcadHostTarget } from './wsl-orcad-host-target' +import { createWslOrcadHostTransport } from './wsl-orcad-host-transport' +import type { WslOrcadPairingRecovery } from './wsl-orcad-pairing-identity' + +type WslOrcadHostAccess = { target: WslOrcadHostTarget; connection: WslOrcadHostTransport } + +export function liveWslOrcadPairingRepairDeps( + userDataPath: string +): ManagedOrcadPairingRepairDeps { + const record = (environmentId: string) => { + const found = findWslHostDeployment(userDataPath, environmentId) + if (!found) { + throw new OrcadManagedPairingRepairFailedError('this server is not a WSL host') + } + return found + } + return { + resolve: (environmentId) => resolveEnvironment(userDataPath, environmentId), + connect: async (environment: KnownRuntimeEnvironment) => { + const target: WslOrcadHostTarget = { + kind: 'wsl', + id: environment.id, + distro: record(environment.id).distro, + label: environment.name + } + return { target, connection: createWslOrcadHostTransport({ target }) } + }, + readReadiness: async (access) => { + try { + return await readActiveManagedOrcadReadiness(access) + } finally { + access.connection.dispose() + } + }, + ports: (environment) => { + const found = findWslHostDeployment(userDataPath, environment.id) + return found ? { localPort: found.localPort, remotePort: found.remotePort } : null + }, + refresh: (environmentId, pairingCode) => { + const { localPort, remotePort } = record(environmentId) + return refreshWslHostPairing(userDataPath, environmentId, { + pairingCode, + localPort, + remotePort + }).environment + }, + repaired: notifyManagedOrcadPairingRepaired, + now: Date.now + } +} + +/** One gate per desktop, so the cap and a standing failure hold across every WSL connect. */ +export function createWslOrcadPairingRecovery( + userDataPath: string, + deps: ManagedOrcadPairingRepairDeps = liveWslOrcadPairingRepairDeps( + userDataPath + ) +): Omit { + return { repairs: new ManagedOrcadPairingRepairs(deps), resolve: deps.resolve } +}