Skip to content
Open
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 @@ -14,13 +14,15 @@ import {
setTailscaleServeEnabled,
} from "./methods/serverExposure.ts";
import {
acquireSshPortForward,
bootstrapSshBearerSession,
disconnectSshEnvironment,
discoverSshHosts,
ensureSshEnvironment,
fetchSshEnvironmentDescriptor,
fetchSshSessionState,
issueSshWebSocketTicket,
releaseSshPortForward,
resolveSshPasswordPrompt,
} from "./methods/sshEnvironment.ts";
import {
Expand Down Expand Up @@ -62,6 +64,8 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"
yield* ipc.handle(discoverSshHosts);
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 @@ -24,6 +24,8 @@ export const CLEAR_CONNECTION_CATALOG_CHANNEL = "desktop:clear-connection-catalo
export const DISCOVER_SSH_HOSTS_CHANNEL = "desktop:discover-ssh-hosts";
export const ENSURE_SSH_ENVIRONMENT_CHANNEL = "desktop:ensure-ssh-environment";
export const DISCONNECT_SSH_ENVIRONMENT_CHANNEL = "desktop:disconnect-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 FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL = "desktop:fetch-ssh-environment-descriptor";
export const BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL = "desktop:bootstrap-ssh-bearer-session";
export const FETCH_SSH_SESSION_STATE_CHANNEL = "desktop:fetch-ssh-session-state";
Expand Down
38 changes: 38 additions & 0 deletions apps/desktop/src/ipc/methods/sshEnvironment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,12 @@ import * as Option from "effect/Option";
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";

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

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

describe("SSH environment IPC", () => {
it.effect("acquires and releases SSH port-forward leases", () => {
const calls: unknown[] = [];
const target = {
alias: "devbox",
hostname: "devbox.example.test",
username: "developer",
port: 22,
} as const;
const layer = Layer.succeed(
DesktopSshEnvironment,
DesktopSshEnvironment.of({
discoverHosts: () => Effect.succeed([]),
ensureEnvironment: () => Effect.die("unused"),
disconnectEnvironment: () => Effect.die("unused"),
acquirePortForward: (receivedTarget, remotePort) =>
Effect.sync(() => {
calls.push([receivedTarget, remotePort]);
return { leaseId: "lease-1", localPort: 42_173, remotePort };
}),
releasePortForward: (leaseId) => Effect.sync(() => void calls.push(leaseId)),
}),
);

return Effect.gen(function* () {
const acquired = yield* acquireSshPortForward.handler({ target, remotePort: 5173 });
assert.deepEqual(acquired, {
leaseId: "lease-1",
localPort: 42_173,
remotePort: 5173,
});
yield* releaseSshPortForward.handler({ leaseId: "lease-1" });
assert.deepEqual(calls, [[target, 5173], "lease-1"]);
}).pipe(Effect.provide(layer));
});

it.effect("fetches and decodes the remote environment descriptor", () => {
const requestUrls: string[] = [];
const layer = makeHttpClientLayer((request) =>
Expand Down
35 changes: 35 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 @@ -149,6 +152,38 @@ 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.catch((error) =>
DesktopSshEnvironment.isDesktopSshPasswordPromptCancellation(error)
? Effect.succeed({
type: DesktopSshPasswordPromptCancelledType,
message: error.message,
})
: Effect.fail(error),
),
);
}),
});

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 @@ -11,7 +11,7 @@ import * as IpcChannels from "./ipc/channels.ts";

exposeClerkBridge({ passkeys: true });

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

contextBridge.exposeInMainWorld("desktopBridge", {
Expand Down Expand Up @@ -53,14 +53,23 @@ contextBridge.exposeInMainWorld("desktopBridge", {
clearConnectionCatalog: () => ipcRenderer.invoke(IpcChannels.CLEAR_CONNECTION_CATALOG_CHANNEL),
discoverSshHosts: () => ipcRenderer.invoke(IpcChannels.DISCOVER_SSH_HOSTS_CHANNEL),
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 @@ -152,6 +157,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
1 change: 1 addition & 0 deletions apps/desktop/src/wsl/DesktopWslBackend.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ const backendConfigurationLayer = Layer.succeed(
);

const netLayer = Layer.succeed(NetService.NetService, {
hasListenerOnHost: () => Effect.succeed(false),
canListenOnHost: () => Effect.succeed(true),
isPortAvailableOnLoopback: () => Effect.succeed(true),
reserveLoopbackPort: () => Effect.succeed(41773),
Expand Down
7 changes: 7 additions & 0 deletions apps/mobile/src/connection/platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,13 @@ const capabilitiesLayer = Layer.effectContext(
}),
),
disconnect: () => Effect.void,
forwardPort: () =>
Effect.fail(
new ConnectionBlockedError({
reason: "unsupported",
detail: "SSH environments are only available in the desktop app.",
}),
),
}),
),
);
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/preview/PortScanner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ const makeProbeFailureLayer = (run: ProcessRunner.ProcessRunner["Service"]["run"
Layer.mergeAll(
Layer.succeed(ProcessRunner.ProcessRunner, { run }),
Layer.succeed(Net.NetService, {
hasListenerOnHost: () => Effect.succeed(false),
canListenOnHost: () => Effect.succeed(true),
isPortAvailableOnLoopback: () => Effect.succeed(true),
reserveLoopbackPort: () => Effect.succeed(40_000),
Expand Down
Loading
Loading