From 7493c0aa17751c717d300b0d70be7020aa7989fb Mon Sep 17 00:00:00 2001 From: LukeGus Date: Thu, 29 Oct 2026 12:44:43 -0500 Subject: [PATCH] feat: give ssh transports the host origin and sync id (#1717) --- packages/plugin-sdk/CHANGELOG.md | 1 + packages/plugin-sdk/PLUGIN-GUIDE.md | 2 +- packages/plugin-sdk/src/backend.ts | 20 +++++++++++----- .../hosts/connect/transport-providers.ts | 15 ++++++------ src/backend/hosts/connect/transport.ts | 4 ++++ src/backend/hosts/connect/types.ts | 3 +++ src/backend/hosts/ssh-relay.ts | 18 +++++++++------ .../hosts/connect/transport-providers.test.ts | 23 +++++++++++++++++++ src/backend/tests/hosts/ssh-relay.test.ts | 19 +++++++++++++++ 9 files changed, 84 insertions(+), 21 deletions(-) diff --git a/packages/plugin-sdk/CHANGELOG.md b/packages/plugin-sdk/CHANGELOG.md index 42e65be73..f95f4a1df 100644 --- a/packages/plugin-sdk/CHANGELOG.md +++ b/packages/plugin-sdk/CHANGELOG.md @@ -9,6 +9,7 @@ Plugin API 1.13.0. - `contributes.mobile` in the manifest says how a plugin shows in the Termix mobile app: `webview`, `native` or `none`, with an optional mobile bundle, screen sizes, views and a note - `termix-plugin build` bundles `src/mobile` into the `contributes.mobile.entry` file, and `validate` checks that it exists - `@termix-ssh/plugin-sdk/frame-host` runs one plugin view outside the shell, with core's bridge, registries and CSS, so the mobile app can show `webview` plugins +- SSH transport providers get the host's owner, sync id, connection origin and local only flag in `claims` and `open`, and core's SSH relay takes a `syncId` instead of a host id ### Fixed diff --git a/packages/plugin-sdk/PLUGIN-GUIDE.md b/packages/plugin-sdk/PLUGIN-GUIDE.md index 65ba63f70..a0d3b50e5 100644 --- a/packages/plugin-sdk/PLUGIN-GUIDE.md +++ b/packages/plugin-sdk/PLUGIN-GUIDE.md @@ -346,7 +346,7 @@ router.post("/notes", async (req, res) => { - `ctx.rbac.require("manage")` on a single route, `await ctx.rbac.has("manage")` in code. A full id like `hosts.edit` checks a core permission. - Host access: `ctx.hosts.checkAccess(hostId, "connect" | "view" | "edit" | "manage")`. -- Transports: with `ssh:transport`, `ctx.ssh.transports.provide({ claims(hostId), open({ id, ip, port }) })` +- Transports: with `ssh:transport`, `ctx.ssh.transports.provide({ claims(hostId, host), open(host) })`, where `host` has `id`, `ip`, `port` and on Termix 26.10.6+ also `userId`, `syncId`, `connectionOrigin` and `localOnly` returns a stream the SSH client uses instead of a direct, proxy or jump host connection. Core also serves `/plugin-ws/core/ssh-relay?hostId=` for callers with connect access, so one Termix can relay to another. - Managed hosts: with `hosts:lock`, `ctx.hosts.locks.provide({ isLocked, note, allowManualCreate })` makes diff --git a/packages/plugin-sdk/src/backend.ts b/packages/plugin-sdk/src/backend.ts index bf1ccfab6..7f3776b6a 100644 --- a/packages/plugin-sdk/src/backend.ts +++ b/packages/plugin-sdk/src/backend.ts @@ -1813,13 +1813,21 @@ export interface PluginNetwork { * asked for every connection, so it must be quick. `open` returns a stream * the SSH client runs over, like a relayed socket. */ +/** The host a transport is asked about. Fields past port come from Termix 26.10.6. */ +export interface PluginSshTransportHost { + id: number; + ip: string; + port: number; + /** Owner of the host row. */ + userId?: string | null; + syncId?: string | null; + connectionOrigin?: "local" | "remote" | null; + localOnly?: boolean; +} + export interface PluginSshTransportProvider { - claims: (hostId: number) => boolean; - open: (host: { - id: number; - ip: string; - port: number; - }) => Promise; + claims: (hostId: number, host?: PluginSshTransportHost) => boolean; + open: (host: PluginSshTransportHost) => Promise; } export interface PluginSshTransports { diff --git a/src/backend/hosts/connect/transport-providers.ts b/src/backend/hosts/connect/transport-providers.ts index 8059fd321..661ac5d9e 100644 --- a/src/backend/hosts/connect/transport-providers.ts +++ b/src/backend/hosts/connect/transport-providers.ts @@ -7,7 +7,10 @@ */ import type { Duplex } from "node:stream"; -import type { PluginSshTransportProvider } from "@termix-ssh/plugin-sdk/backend"; +import type { + PluginSshTransportHost, + PluginSshTransportProvider, +} from "@termix-ssh/plugin-sdk/backend"; interface Entry { pluginId: string; @@ -33,15 +36,13 @@ export function clearSshTransports(): void { } /** Opens a stream to the host if a provider claims it, else null. */ -export async function openProvidedTransport(host: { - id: number; - ip: string; - port: number; -}): Promise { +export async function openProvidedTransport( + host: PluginSshTransportHost, +): Promise { for (const { provider } of providers.values()) { let claimed = false; try { - claimed = provider.claims(host.id); + claimed = provider.claims(host.id, host); } catch { claimed = false; } diff --git a/src/backend/hosts/connect/transport.ts b/src/backend/hosts/connect/transport.ts index 5f8a7c069..b2d9e2bfd 100644 --- a/src/backend/hosts/connect/transport.ts +++ b/src/backend/hosts/connect/transport.ts @@ -99,6 +99,10 @@ export async function openSshTransport( id: host.id, ip: host.ip, port: host.port || 22, + userId: host.userId ?? null, + syncId: host.syncId ?? null, + connectionOrigin: host.connectionOrigin ?? null, + localOnly: Boolean(host.localOnly), }); if (provided) { config.sock = provided; diff --git a/src/backend/hosts/connect/types.ts b/src/backend/hosts/connect/types.ts index eaf85e71e..1925905fb 100644 --- a/src/backend/hosts/connect/types.ts +++ b/src/backend/hosts/connect/types.ts @@ -24,6 +24,9 @@ export interface SshConnectHost { port: number; username: string; userId?: string | null; + syncId?: string | null; + connectionOrigin?: "local" | "remote" | null; + localOnly?: boolean | null; authType?: string | null; password?: string | null; key?: string | Buffer | null; diff --git a/src/backend/hosts/ssh-relay.ts b/src/backend/hosts/ssh-relay.ts index ad1126068..7126ceafc 100644 --- a/src/backend/hosts/ssh-relay.ts +++ b/src/backend/hosts/ssh-relay.ts @@ -13,7 +13,7 @@ import net from "node:net"; import type { Duplex } from "node:stream"; import { createWebSocketStream, type WebSocket } from "ws"; import type { PluginWebSocketConnection } from "@termix-ssh/plugin-sdk/backend"; -import { resolveHostById } from "./host-resolver.js"; +import { resolveHostById, resolveHostBySyncId } from "./host-resolver.js"; import { openSshTransport } from "./connect/transport.js"; import type { MutableConnectConfig, SshConnectHost } from "./connect/types.js"; import { sshLogger } from "../utils/logger.js"; @@ -35,12 +35,13 @@ async function handleRelay( ): Promise { const ws = connection.socket as WebSocket; const url = String((connection.request as { url?: string }).url ?? ""); - const hostId = Number( - new URL(url, "http://relay").searchParams.get("hostId"), - ); + const params = new URL(url, "http://relay").searchParams; + const hostId = Number(params.get("hostId")); + // A linked desktop knows its hosts by sync id, not by this server's id. + const syncId = params.get("syncId"); const userId = connection.userId; - if (!userId || !Number.isSafeInteger(hostId) || hostId <= 0) { + if (!userId || (!syncId && (!Number.isSafeInteger(hostId) || hostId <= 0))) { close(ws, 1008, "A host is required"); return; } @@ -49,7 +50,9 @@ async function handleRelay( return; } - const host = await resolveHostById(hostId, userId); + const host = syncId + ? await resolveHostBySyncId(syncId, userId) + : await resolveHostById(hostId, userId); if (!host) { close(ws, 1008, "Host not found or access denied"); return; @@ -93,7 +96,8 @@ async function handleRelay( } catch (error) { sshLogger.warn("A relay could not reach its host", { operation: "ssh_relay_open", - hostId, + hostId: syncId ? undefined : hostId, + syncId, error: error instanceof Error ? error.message : String(error), }); finish(); diff --git a/src/backend/tests/hosts/connect/transport-providers.test.ts b/src/backend/tests/hosts/connect/transport-providers.test.ts index 67bc3bca3..9a214ca7b 100644 --- a/src/backend/tests/hosts/connect/transport-providers.test.ts +++ b/src/backend/tests/hosts/connect/transport-providers.test.ts @@ -37,6 +37,29 @@ afterEach(() => { }); describe("ssh transport providers", () => { + it("tells providers the owner, sync id and origin of the host", async () => { + const claims = vi.fn(() => false); + provideSshTransport("a", { claims, open: vi.fn() }); + await openSshTransport( + { + ...host, + userId: "u1", + syncId: "s1", + connectionOrigin: "remote", + } as never, + {} as never, + ).catch(() => {}); + expect(claims).toHaveBeenCalledWith(7, { + id: 7, + ip: "10.0.0.7", + port: 22, + userId: "u1", + syncId: "s1", + connectionOrigin: "remote", + localOnly: false, + }); + }); + it("opens nothing when no provider claims the host", async () => { provideSshTransport("a", { claims: () => false, open: vi.fn() }); expect(await openProvidedTransport(host)).toBeNull(); diff --git a/src/backend/tests/hosts/ssh-relay.test.ts b/src/backend/tests/hosts/ssh-relay.test.ts index 821e580e6..78f937248 100644 --- a/src/backend/tests/hosts/ssh-relay.test.ts +++ b/src/backend/tests/hosts/ssh-relay.test.ts @@ -7,6 +7,7 @@ import { WebSocket, WebSocketServer } from "ws"; const mocks = vi.hoisted(() => ({ resolveHostById: vi.fn(), + resolveHostBySyncId: vi.fn(), openSshTransport: vi.fn(), handler: null as null | ((connection: unknown) => Promise), })); @@ -17,6 +18,7 @@ vi.mock("../../utils/logger.js", () => { }); vi.mock("../../hosts/host-resolver.js", () => ({ resolveHostById: mocks.resolveHostById, + resolveHostBySyncId: mocks.resolveHostBySyncId, })); vi.mock("../../hosts/connect/transport.js", () => ({ openSshTransport: mocks.openSshTransport, @@ -114,6 +116,23 @@ describe("ssh relay", () => { await vi.waitFor(() => expect(end).toHaveBeenCalled()); }); + it("finds the host by sync id for a linked desktop", async () => { + mocks.resolveHostBySyncId.mockResolvedValue({ + id: 9, + ip: "127.0.0.1", + port: echoPort, + }); + const { socket } = await open("?syncId=abc"); + const received = new Promise((resolve) => + socket.once("message", (data) => resolve(data.toString())), + ); + socket.send("ping"); + expect(await received).toBe("ping"); + expect(mocks.resolveHostBySyncId).toHaveBeenCalledWith("abc", "user-1"); + expect(mocks.resolveHostById).not.toHaveBeenCalled(); + socket.close(); + }); + it("refuses a host the user can't connect to", async () => { mocks.resolveHostById.mockResolvedValue(null); const { closed } = await open("?hostId=5");