Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/desktop/src/ipc/DesktopIpcHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,15 @@ import {
setTailscaleServeEnabled,
} from "./methods/serverExposure.ts";
import {
acquireSshPortForward,
bootstrapSshBearerSession,
disconnectSshEnvironment,
discoverSshHosts,
ensureSshEnvironment,
fetchSshEnvironmentDescriptor,
fetchSshSessionState,
issueSshWebSocketTicket,
releaseSshPortForward,
resolveSshHost,
resolveSshPasswordPrompt,
} from "./methods/sshEnvironment.ts";
Expand Down Expand Up @@ -116,6 +118,8 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"
yield* ipc.handle(resolveSshHost);
yield* ipc.handle(ensureSshEnvironment);
yield* ipc.handle(disconnectSshEnvironment);
yield* ipc.handle(acquireSshPortForward);
yield* ipc.handle(releaseSshPortForward);
yield* ipc.handle(fetchSshEnvironmentDescriptor);
yield* ipc.handle(bootstrapSshBearerSession);
yield* ipc.handle(fetchSshSessionState);
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/ipc/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ export const CLEAR_CONNECTION_CATALOG_CHANNEL = "desktop:clear-connection-catalo
export const DISCOVER_SSH_HOSTS_CHANNEL = "desktop:discover-ssh-hosts";
export const RESOLVE_SSH_HOST_CHANNEL = "desktop:resolve-ssh-host";
export const ENSURE_SSH_ENVIRONMENT_CHANNEL = "desktop:ensure-ssh-environment";
export const ACQUIRE_SSH_PORT_FORWARD_CHANNEL = "desktop:acquire-ssh-port-forward";
export const RELEASE_SSH_PORT_FORWARD_CHANNEL = "desktop:release-ssh-port-forward";
export const DISCONNECT_SSH_ENVIRONMENT_CHANNEL = "desktop:disconnect-ssh-environment";
export const FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL = "desktop:fetch-ssh-environment-descriptor";
export const BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL = "desktop:bootstrap-ssh-bearer-session";
Expand Down
112 changes: 111 additions & 1 deletion apps/desktop/src/ipc/methods/sshEnvironment.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { assert, describe, it } from "@effect/vitest";
import { SshHttpBridgeError } from "@t3tools/ssh/errors";
import { SshHttpBridgeError, SshPasswordPromptError, SshReadinessError } from "@t3tools/ssh/errors";
import * as Cause from "effect/Cause";
import * as Effect from "effect/Effect";
import * as Exit from "effect/Exit";
Expand All @@ -8,10 +8,40 @@ import * as Option from "effect/Option";
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";

import {
acquireSshPortForward,
releaseSshPortForward,
DesktopSshEnvironmentRequestError,
fetchSshEnvironmentDescriptor,
} from "./sshEnvironment.ts";

import * as DesktopSshEnvironment from "../../ssh/DesktopSshEnvironment.ts";
import * as DesktopSshPasswordPrompts from "../../ssh/DesktopSshPasswordPrompts.ts";

const forwardTarget = {
alias: "devbox",
hostname: "devbox.example.test",
username: "developer",
port: 22,
};
const forwardLayer = (
acquire: DesktopSshEnvironment.DesktopSshEnvironment["Service"]["acquirePortForward"],
releases: string[] = [],
) =>
Layer.succeed(
DesktopSshEnvironment.DesktopSshEnvironment,
DesktopSshEnvironment.DesktopSshEnvironment.of({
discoverHosts: () => Effect.die("unused"),
resolveHost: () => Effect.die("unused"),
ensureEnvironment: () => Effect.die("unused"),
disconnectEnvironment: () => Effect.die("unused"),
acquirePortForward: acquire,
releasePortForward: (leaseId) =>
Effect.sync(() => {
releases.push(leaseId);
}),
}),
);

function jsonResponse(request: HttpClientRequest.HttpClientRequest, body: unknown, status = 200) {
return HttpClientResponse.fromWeb(
request,
Expand All @@ -34,6 +64,86 @@ function layerHttpClient(
}

describe("SSH environment IPC", () => {
it.effect("acquires distinct leases and passes repeated releases through", () => {
let count = 0;
const releases: string[] = [];
const layer = forwardLayer(
(target, port) =>
Effect.sync(() => {
assert.deepEqual(target, forwardTarget);
assert.equal(port, 5173);
return { leaseId: `lease-${++count}`, localPort: 41773 };
}),
releases,
);
return Effect.gen(function* () {
assert.deepEqual(
yield* acquireSshPortForward.handler({ target: forwardTarget, remotePort: 5173 }),
{ leaseId: "lease-1", localPort: 41773 },
);
assert.deepEqual(
yield* acquireSshPortForward.handler({ target: forwardTarget, remotePort: 5173 }),
{ leaseId: "lease-2", localPort: 41773 },
);
yield* releaseSshPortForward.handler({ leaseId: "lease-1" });
yield* releaseSshPortForward.handler({ leaseId: "lease-1" });
assert.deepEqual(releases, ["lease-1", "lease-1"]);
}).pipe(Effect.provide(layer));
});

it.effect("returns the same prompt-cancellation envelope as ensure", () => {
const layer = forwardLayer(() =>
Effect.fail(
new SshPasswordPromptError({
message: "SSH authentication cancelled for devbox.",
cause: new DesktopSshPasswordPrompts.DesktopSshPromptCancelledError({
requestId: "prompt-1",
destination: "devbox",
}),
}),
),
);
return Effect.gen(function* () {
assert.deepEqual(
yield* acquireSshPortForward.handler({ target: forwardTarget, remotePort: 5173 }),
{
type: "ssh-password-prompt-cancelled",
message: "SSH authentication cancelled for devbox.",
},
);
}).pipe(Effect.provide(layer));
});

it.effect("does not disguise startup failures as cancellation", () => {
const layer = forwardLayer(() =>
Effect.fail(new SshReadinessError({ message: "Forward failed." })),
);
return Effect.gen(function* () {
const exit = yield* Effect.exit(
acquireSshPortForward.handler({ target: forwardTarget, remotePort: 5173 }),
);
assert.isTrue(Exit.isFailure(exit));
if (Exit.isFailure(exit)) {
const error = Cause.findErrorOption(exit.cause);
assert.isTrue(Option.isSome(error));
if (Option.isSome(error)) assert.instanceOf(error.value, SshReadinessError);
}
}).pipe(Effect.provide(layer));
});

it.effect("rejects invalid remote ports before calling the manager", () => {
const layer = forwardLayer(() => Effect.die("invalid port reached manager"));
return Effect.gen(function* () {
for (const remotePort of [0, -1, 65536, 5173.5, "5173"]) {
const exit = yield* Effect.exit(
acquireSshPortForward.handler({ target: forwardTarget, remotePort }),
);
assert.isTrue(Exit.isFailure(exit));
if (Exit.isFailure(exit)) assert.isFalse(Cause.hasDies(exit.cause));
}
}).pipe(Effect.provide(layer));
});

it.effect("fetches and decodes the remote environment descriptor", () => {
const requestUrls: string[] = [];
const layer = layerHttpClient((request) =>
Expand Down
33 changes: 33 additions & 0 deletions apps/desktop/src/ipc/methods/sshEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ import {
DesktopSshEnvironmentEnsureResultSchema,
DesktopSshEnvironmentTargetSchema,
DesktopSshHttpBaseUrlInputSchema,
DesktopSshPortForwardAcquireInputSchema,
DesktopSshPortForwardAcquireResultSchema,
DesktopSshPortForwardReleaseInputSchema,
DesktopSshPasswordPromptCancelledType,
DesktopSshPasswordPromptResolutionInputSchema,
ExecutionEnvironmentDescriptor,
Expand Down Expand Up @@ -157,6 +160,36 @@ export const disconnectSshEnvironment = DesktopIpc.makeIpcMethod({
}),
});

export const acquireSshPortForward = DesktopIpc.makeIpcMethod({
channel: IpcChannels.ACQUIRE_SSH_PORT_FORWARD_CHANNEL,
payload: DesktopSshPortForwardAcquireInputSchema,
result: DesktopSshPortForwardAcquireResultSchema,
handler: Effect.fn("desktop.ipc.sshEnvironment.acquirePortForward")(function* ({
target,
remotePort,
}) {
const sshEnvironment = yield* DesktopSshEnvironment.DesktopSshEnvironment;
return yield* sshEnvironment.acquirePortForward(target, remotePort).pipe(
Effect.catchIf(DesktopSshEnvironment.isDesktopSshPasswordPromptCancellation, (error) =>
Effect.succeed({
type: DesktopSshPasswordPromptCancelledType,
message: error.message,
}),
),
);
}),
});

export const releaseSshPortForward = DesktopIpc.makeIpcMethod({
channel: IpcChannels.RELEASE_SSH_PORT_FORWARD_CHANNEL,
payload: DesktopSshPortForwardReleaseInputSchema,
result: Schema.Void,
handler: Effect.fn("desktop.ipc.sshEnvironment.releasePortForward")(function* ({ leaseId }) {
const sshEnvironment = yield* DesktopSshEnvironment.DesktopSshEnvironment;
yield* sshEnvironment.releasePortForward(leaseId);
}),
});

export const fetchSshEnvironmentDescriptor = DesktopIpc.makeIpcMethod({
channel: IpcChannels.FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL,
payload: DesktopSshHttpBaseUrlInputSchema,
Expand Down
15 changes: 12 additions & 3 deletions apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ if (clientPlatform === "darwin") {
window.addEventListener("resize", syncWindowControlInset);
}

function unwrapEnsureSshEnvironmentResult(result: unknown) {
function unwrapSshPasswordPromptResult<T>(result: T): T {
if (
typeof result === "object" &&
result !== null &&
Expand All @@ -72,7 +72,7 @@ function unwrapEnsureSshEnvironmentResult(result: unknown) {
: "SSH authentication cancelled.";
throw new Error(message);
}
return result as Awaited<ReturnType<DesktopBridge["ensureSshEnvironment"]>>;
return result;
}

contextBridge.exposeInMainWorld("desktopBridge", {
Expand Down Expand Up @@ -142,14 +142,23 @@ contextBridge.exposeInMainWorld("desktopBridge", {
discoverSshHosts: () => ipcRenderer.invoke(IpcChannels.DISCOVER_SSH_HOSTS_CHANNEL),
resolveSshHost: (alias) => ipcRenderer.invoke(IpcChannels.RESOLVE_SSH_HOST_CHANNEL, alias),
ensureSshEnvironment: async (target, options) =>
unwrapEnsureSshEnvironmentResult(
unwrapSshPasswordPromptResult<Awaited<ReturnType<DesktopBridge["ensureSshEnvironment"]>>>(
await ipcRenderer.invoke(IpcChannels.ENSURE_SSH_ENVIRONMENT_CHANNEL, {
target,
...(options === undefined ? {} : { options }),
}),
),
disconnectSshEnvironment: (target) =>
ipcRenderer.invoke(IpcChannels.DISCONNECT_SSH_ENVIRONMENT_CHANNEL, target),
acquireSshPortForward: async (target, remotePort) =>
unwrapSshPasswordPromptResult<Awaited<ReturnType<DesktopBridge["acquireSshPortForward"]>>>(
await ipcRenderer.invoke(IpcChannels.ACQUIRE_SSH_PORT_FORWARD_CHANNEL, {
target,
remotePort,
}),
),
releaseSshPortForward: (leaseId) =>
ipcRenderer.invoke(IpcChannels.RELEASE_SSH_PORT_FORWARD_CHANNEL, { leaseId }),
fetchSshEnvironmentDescriptor: (httpBaseUrl) =>
ipcRenderer.invoke(IpcChannels.FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL, { httpBaseUrl }),
bootstrapSshBearerSession: (httpBaseUrl, credential) =>
Expand Down
15 changes: 15 additions & 0 deletions apps/desktop/src/ssh/DesktopSshEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,11 @@ export class DesktopSshEnvironment extends Context.Service<
readonly disconnectEnvironment: (
target: DesktopSshEnvironmentTarget,
) => Effect.Effect<void, DesktopSshEnvironmentOperationError>;
readonly acquirePortForward: (
target: DesktopSshEnvironmentTarget,
remotePort: number,
) => Effect.Effect<SshTunnel.SshPortForwardLease, DesktopSshEnvironmentOperationError>;
readonly releasePortForward: (leaseId: string) => Effect.Effect<void>;
}
>()("@t3tools/desktop/ssh/DesktopSshEnvironment") {}

Expand Down Expand Up @@ -157,6 +162,16 @@ export const make = Effect.gen(function* () {
Effect.provide(runtimeContext),
Effect.withSpan("desktop.ssh.disconnectEnvironment"),
),
acquirePortForward: (target, remotePort) =>
manager
.acquirePortForward(target, remotePort)
.pipe(
Effect.provideService(SshAuth.SshPasswordPrompt, passwordPrompt),
Effect.provide(runtimeContext),
Effect.withSpan("desktop.ssh.acquirePortForward"),
),
releasePortForward: (leaseId) =>
manager.releasePortForward(leaseId).pipe(Effect.withSpan("desktop.ssh.releasePortForward")),
});
});

Expand Down
27 changes: 26 additions & 1 deletion apps/web/src/browser/browserTargetResolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,15 @@ import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
const readPreparedConnection = vi.fn();

vi.mock("~/state/session", () => ({ readPreparedConnection }));
const readSshEnvironmentTarget = vi.fn(() => null as unknown);
vi.mock("./sshPreviewForwards", () => ({ readSshEnvironmentTarget }));

describe("browser target resolver", () => {
beforeEach(() => readPreparedConnection.mockReset());
beforeEach(() => {
readPreparedConnection.mockReset();
readSshEnvironmentTarget.mockReset();
readSshEnvironmentTarget.mockReturnValue(null);
});

it("maps environment ports onto a private network host", async () => {
readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://192.168.1.25:3773" });
Expand Down Expand Up @@ -141,6 +147,25 @@ describe("browser target resolver", () => {
).toMatchObject({ resolvedUrl: "http://localhost:5173", resolutionKind: "direct" });
});

it("keeps SSH environment ports remote-loopback, normalized, for forwarding", async () => {
readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://127.0.0.1:41000" });
readSshEnvironmentTarget.mockReturnValue({ alias: "devbox" });
const { resolveBrowserNavigationTarget, resolveDiscoveredServerUrl } =
await import("./browserTargetResolver");
const environmentId = EnvironmentId.make("environment-ssh");
expect(resolveDiscoveredServerUrl(environmentId, "localhost:5173")).toBe(
"http://localhost:5173/",
);
expect(
resolveBrowserNavigationTarget(environmentId, { kind: "environment-port", port: 5173 }),
).toEqual({
requestedUrl: "http://localhost:5173/",
resolvedUrl: "http://localhost:5173/",
resolutionKind: "ssh-forward",
environmentId,
});
});

it("normalizes schemeless localhost server-picker values", async () => {
readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://localhost:3773" });
const { resolveDiscoveredServerUrl } = await import("./browserTargetResolver");
Expand Down
20 changes: 17 additions & 3 deletions apps/web/src/browser/browserTargetResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { isLocalLoopbackHost, isPrivateNetworkHost } from "@t3tools/shared/hostC

import { readPreparedConnection } from "~/state/session";

import { readSshEnvironmentTarget } from "./sshPreviewForwards";

export {
normalizeHostname,
isLocalLoopbackHost,
Expand All @@ -28,13 +30,25 @@ const resolveEnvironmentPortTarget = (
requestedUrl?: string,
sourceUrl?: URL,
): PreviewUrlResolution => {
const protocol = target.protocol ?? "http";
const path = target.path?.startsWith("/") ? target.path : `/${target.path ?? ""}`;
const loopbackUrl = requestedUrl ?? `${protocol}://localhost:${target.port}${path}`;
// An SSH environment's server URL is itself a local forward, so its host says
// nothing about where the port lives. The URL stays remote-loopback and
// navigation forwards it (see sshPreviewForwards).
if (readSshEnvironmentTarget(environmentId) !== null) {
return {
requestedUrl: loopbackUrl,
resolvedUrl: (sourceUrl ?? new URL(loopbackUrl)).toString(),
resolutionKind: "ssh-forward",
environmentId,
};
}
if (!isPrivateNetworkHost(environmentUrl.hostname)) {
throw new Error(
"This environment port needs the planned authenticated preview gateway; its server address is not directly private-network reachable.",
);
}
const protocol = target.protocol ?? "http";
const path = target.path?.startsWith("/") ? target.path : `/${target.path ?? ""}`;
const normalizedEnvironmentHost = environmentUrl.hostname.replace(/^\[|\]$/g, "");
// Local loopback environments should advertise `localhost` so Chromium
// dual-stack lookup can reach a Vite server bound only to ::1 or 127.0.0.1.
Expand All @@ -51,7 +65,7 @@ const resolveEnvironmentPortTarget = (
resolved.port = String(target.port);
}
return {
requestedUrl: requestedUrl ?? `${protocol}://localhost:${target.port}${path}`,
requestedUrl: loopbackUrl,
resolvedUrl: resolved.toString(),
resolutionKind: isLocalLoopbackHost(normalizedEnvironmentHost)
? "direct"
Expand Down
Loading
Loading