fix: invalidate stale repository SSH probes
This commit is contained in:
@@ -1,5 +1,65 @@
|
|||||||
import type { WorkspaceRuntimeResource } from "$lib/generated/workspace-api";
|
import type { WorkspaceRuntimeResource } from "$lib/generated/workspace-api";
|
||||||
|
|
||||||
|
export type RepositorySshProbeSelection<T> = Readonly<{
|
||||||
|
changed: boolean;
|
||||||
|
runtimeId: string;
|
||||||
|
probe: T | null;
|
||||||
|
selectedHostKey: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export function changeRepositorySshProbeRuntime<T>(
|
||||||
|
currentRuntimeId: string,
|
||||||
|
nextRuntimeId: string,
|
||||||
|
probe: T | null,
|
||||||
|
selectedHostKey: string,
|
||||||
|
): RepositorySshProbeSelection<T> {
|
||||||
|
if (currentRuntimeId === nextRuntimeId) {
|
||||||
|
return {
|
||||||
|
changed: false,
|
||||||
|
runtimeId: currentRuntimeId,
|
||||||
|
probe,
|
||||||
|
selectedHostKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
changed: true,
|
||||||
|
runtimeId: nextRuntimeId,
|
||||||
|
probe: null,
|
||||||
|
selectedHostKey: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RepositorySshProbeOperation = Readonly<{
|
||||||
|
runtimeId: string;
|
||||||
|
generation: number;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export class RepositorySshProbeFence {
|
||||||
|
#runtimeId: string | null = null;
|
||||||
|
#generation = 0;
|
||||||
|
|
||||||
|
enter(runtimeId: string): number {
|
||||||
|
if (this.#runtimeId !== runtimeId) {
|
||||||
|
this.#runtimeId = runtimeId;
|
||||||
|
this.#generation += 1;
|
||||||
|
}
|
||||||
|
return this.#generation;
|
||||||
|
}
|
||||||
|
|
||||||
|
capture(runtimeId: string): RepositorySshProbeOperation {
|
||||||
|
return { runtimeId, generation: this.enter(runtimeId) };
|
||||||
|
}
|
||||||
|
|
||||||
|
isCurrent(
|
||||||
|
operation: RepositorySshProbeOperation,
|
||||||
|
runtimeId: string,
|
||||||
|
): boolean {
|
||||||
|
return operation.runtimeId === runtimeId &&
|
||||||
|
operation.generation === this.#generation &&
|
||||||
|
this.#runtimeId === runtimeId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function repositorySshProbeRuntimes(
|
export function repositorySshProbeRuntimes(
|
||||||
runtimes: readonly WorkspaceRuntimeResource[],
|
runtimes: readonly WorkspaceRuntimeResource[],
|
||||||
): WorkspaceRuntimeResource[] {
|
): WorkspaceRuntimeResource[] {
|
||||||
|
|||||||
@@ -7,7 +7,11 @@
|
|||||||
import { parseRepositorySshHostTrust } from '$lib/workspace/api/repository-access';
|
import { parseRepositorySshHostTrust } from '$lib/workspace/api/repository-access';
|
||||||
import { formatDate, workspaceApiPath } from '$lib/workspace/api/http';
|
import { formatDate, workspaceApiPath } from '$lib/workspace/api/http';
|
||||||
import { parseRepositorySshConnectionProbeResponse } from '$lib/workspace/api/workspace-model';
|
import { parseRepositorySshConnectionProbeResponse } from '$lib/workspace/api/workspace-model';
|
||||||
import { repositorySshProbeRuntimes } from '$lib/workspace/repositories/ssh-connection';
|
import {
|
||||||
|
changeRepositorySshProbeRuntime,
|
||||||
|
RepositorySshProbeFence,
|
||||||
|
repositorySshProbeRuntimes
|
||||||
|
} from '$lib/workspace/repositories/ssh-connection';
|
||||||
import type { PageProps } from './$types';
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
let { data }: PageProps = $props();
|
let { data }: PageProps = $props();
|
||||||
@@ -16,11 +20,28 @@
|
|||||||
let selectedHostKey = $state('');
|
let selectedHostKey = $state('');
|
||||||
let pending = $state(false);
|
let pending = $state(false);
|
||||||
let connectionMessage = $state<string | null>(null);
|
let connectionMessage = $state<string | null>(null);
|
||||||
|
const probeFence = new RepositorySshProbeFence();
|
||||||
const probeRuntimes = $derived(data.runtimes ? repositorySshProbeRuntimes(data.runtimes.items) : []);
|
const probeRuntimes = $derived(data.runtimes ? repositorySshProbeRuntimes(data.runtimes.items) : []);
|
||||||
|
|
||||||
|
function selectRuntime(runtimeId: string) {
|
||||||
|
const selection = changeRepositorySshProbeRuntime(
|
||||||
|
selectedRuntimeId,
|
||||||
|
runtimeId,
|
||||||
|
probe,
|
||||||
|
selectedHostKey
|
||||||
|
);
|
||||||
|
if (!selection.changed) return;
|
||||||
|
selectedRuntimeId = selection.runtimeId;
|
||||||
|
probe = selection.probe;
|
||||||
|
selectedHostKey = selection.selectedHostKey;
|
||||||
|
probeFence.enter(runtimeId);
|
||||||
|
connectionMessage = null;
|
||||||
|
pending = false;
|
||||||
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!selectedRuntimeId) {
|
if (!selectedRuntimeId) {
|
||||||
selectedRuntimeId = probeRuntimes[0]?.runtime_id ?? '';
|
selectRuntime(probeRuntimes[0]?.runtime_id ?? '');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -45,42 +66,52 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function runConnectionTest() {
|
async function runConnectionTest() {
|
||||||
|
const operation = probeFence.capture(selectedRuntimeId);
|
||||||
pending = true;
|
pending = true;
|
||||||
connectionMessage = null;
|
connectionMessage = null;
|
||||||
probe = null;
|
probe = null;
|
||||||
selectedHostKey = '';
|
selectedHostKey = '';
|
||||||
try {
|
try {
|
||||||
const body: RepositorySshConnectionProbeRequest = { runtime_id: selectedRuntimeId };
|
const body: RepositorySshConnectionProbeRequest = { runtime_id: operation.runtimeId };
|
||||||
probe = parseRepositorySshConnectionProbeResponse(await requestConnectionTest('POST', body));
|
const nextProbe = parseRepositorySshConnectionProbeResponse(await requestConnectionTest('POST', body));
|
||||||
|
if (!probeFence.isCurrent(operation, selectedRuntimeId)) return;
|
||||||
|
if (nextProbe.runtime_id !== operation.runtimeId) {
|
||||||
|
throw new Error('SSH connection test returned a different Runtime');
|
||||||
|
}
|
||||||
|
probe = nextProbe;
|
||||||
selectedHostKey = probe.candidates[0]?.host_key ?? '';
|
selectedHostKey = probe.candidates[0]?.host_key ?? '';
|
||||||
connectionMessage = probe.trust_state === 'verified'
|
connectionMessage = probe.trust_state === 'verified'
|
||||||
? 'The observed SSH host key matches the Workspace trust record.'
|
? 'The observed SSH host key matches the Workspace trust record.'
|
||||||
: 'Review the observed fingerprint before trusting this SSH host.';
|
: 'Review the observed fingerprint before trusting this SSH host.';
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (!probeFence.isCurrent(operation, selectedRuntimeId)) return;
|
||||||
connectionMessage = error instanceof Error ? error.message : 'SSH connection test failed';
|
connectionMessage = error instanceof Error ? error.message : 'SSH connection test failed';
|
||||||
} finally {
|
} finally {
|
||||||
pending = false;
|
if (probeFence.isCurrent(operation, selectedRuntimeId)) pending = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function confirmHostTrust() {
|
async function confirmHostTrust() {
|
||||||
if (!probe || !selectedHostKey) return;
|
if (!probe || !selectedHostKey || probe.runtime_id !== selectedRuntimeId) return;
|
||||||
|
const operation = probeFence.capture(selectedRuntimeId);
|
||||||
pending = true;
|
pending = true;
|
||||||
connectionMessage = null;
|
connectionMessage = null;
|
||||||
try {
|
try {
|
||||||
const body: ConfirmRepositorySshHostTrustRequest = {
|
const body: ConfirmRepositorySshHostTrustRequest = {
|
||||||
operation_id: `repository-ssh-confirm-${crypto.randomUUID()}`,
|
operation_id: `repository-ssh-confirm-${crypto.randomUUID()}`,
|
||||||
runtime_id: probe.runtime_id,
|
runtime_id: operation.runtimeId,
|
||||||
host_key: selectedHostKey,
|
host_key: selectedHostKey,
|
||||||
expected_host_trust_revision: probe.expected_host_trust_revision
|
expected_host_trust_revision: probe.expected_host_trust_revision
|
||||||
};
|
};
|
||||||
parseRepositorySshHostTrust(await requestConnectionTest('PUT', body));
|
parseRepositorySshHostTrust(await requestConnectionTest('PUT', body));
|
||||||
|
if (!probeFence.isCurrent(operation, selectedRuntimeId)) return;
|
||||||
probe = { ...probe, trust_state: 'verified' };
|
probe = { ...probe, trust_state: 'verified' };
|
||||||
connectionMessage = 'SSH host trust saved. Future connections must present this key.';
|
connectionMessage = 'SSH host trust saved. Future connections must present this key.';
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (!probeFence.isCurrent(operation, selectedRuntimeId)) return;
|
||||||
connectionMessage = error instanceof Error ? error.message : 'Failed to save SSH host trust';
|
connectionMessage = error instanceof Error ? error.message : 'Failed to save SSH host trust';
|
||||||
} finally {
|
} finally {
|
||||||
pending = false;
|
if (probeFence.isCurrent(operation, selectedRuntimeId)) pending = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -167,7 +198,7 @@
|
|||||||
{:else if data.runtimes}
|
{:else if data.runtimes}
|
||||||
<label>
|
<label>
|
||||||
<span>Runtime</span>
|
<span>Runtime</span>
|
||||||
<select bind:value={selectedRuntimeId} disabled={pending}>
|
<select value={selectedRuntimeId} onchange={(event) => selectRuntime(event.currentTarget.value)}>
|
||||||
{#each probeRuntimes as runtime}
|
{#each probeRuntimes as runtime}
|
||||||
<option value={runtime.runtime_id}>{runtime.label} · {runtime.runtime_id}</option>
|
<option value={runtime.runtime_id}>{runtime.label} · {runtime.runtime_id}</option>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { assert, assertEquals } from "jsr:@std/assert";
|
import { assert, assertEquals } from "jsr:@std/assert";
|
||||||
import type { WorkspaceRuntimeResource } from "../src/lib/generated/workspace-api.ts";
|
import type { WorkspaceRuntimeResource } from "../src/lib/generated/workspace-api.ts";
|
||||||
import { parseRepositorySshConnectionProbeResponse } from "../src/lib/workspace/api/workspace-model.ts";
|
import { parseRepositorySshConnectionProbeResponse } from "../src/lib/workspace/api/workspace-model.ts";
|
||||||
import { repositorySshProbeRuntimes } from "../src/lib/workspace/repositories/ssh-connection.ts";
|
import {
|
||||||
|
changeRepositorySshProbeRuntime,
|
||||||
|
RepositorySshProbeFence,
|
||||||
|
repositorySshProbeRuntimes,
|
||||||
|
} from "../src/lib/workspace/repositories/ssh-connection.ts";
|
||||||
|
|
||||||
const root = new URL("../", import.meta.url);
|
const root = new URL("../", import.meta.url);
|
||||||
const pageSource = await Deno.readTextFile(
|
const pageSource = await Deno.readTextFile(
|
||||||
@@ -84,6 +88,47 @@ Deno.test("Repository SSH probe offers configured remote Runtimes regardless of
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Deno.test("changing the SSH probe Runtime clears the prior result and confirmation key", () => {
|
||||||
|
const runtimeAProbe = {
|
||||||
|
runtime_id: "runtime-a",
|
||||||
|
candidates: [{ host_key: "ssh-ed25519 runtime-a" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
changeRepositorySshProbeRuntime(
|
||||||
|
"runtime-a",
|
||||||
|
"runtime-b",
|
||||||
|
runtimeAProbe,
|
||||||
|
"ssh-ed25519 runtime-a",
|
||||||
|
),
|
||||||
|
{
|
||||||
|
changed: true,
|
||||||
|
runtimeId: "runtime-b",
|
||||||
|
probe: null,
|
||||||
|
selectedHostKey: "",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("a delayed SSH probe response cannot apply after the Runtime changes", async () => {
|
||||||
|
const fence = new RepositorySshProbeFence();
|
||||||
|
const operation = fence.capture("runtime-a");
|
||||||
|
let renderedRuntimeId: string | null = null;
|
||||||
|
let resolveProbe!: (runtimeId: string) => void;
|
||||||
|
const delayedProbe = new Promise<string>((resolve) => {
|
||||||
|
resolveProbe = resolve;
|
||||||
|
}).then((runtimeId) => {
|
||||||
|
if (fence.isCurrent(operation, "runtime-b")) {
|
||||||
|
renderedRuntimeId = runtimeId;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
fence.enter("runtime-b");
|
||||||
|
resolveProbe("runtime-a");
|
||||||
|
await delayedProbe;
|
||||||
|
assertEquals(renderedRuntimeId, null);
|
||||||
|
});
|
||||||
|
|
||||||
Deno.test("Repository SSH connection test requires an explicit host-key confirmation", () => {
|
Deno.test("Repository SSH connection test requires an explicit host-key confirmation", () => {
|
||||||
for (
|
for (
|
||||||
const token of [
|
const token of [
|
||||||
|
|||||||
Reference in New Issue
Block a user