From 901a235de45fd21e54b781e865ebc52cda4c2e4e Mon Sep 17 00:00:00 2001 From: LukeGus Date: Mon, 19 Oct 2026 19:20:32 -0500 Subject: [PATCH] feat: ssh transport providers, core ssh relay and plugin folders api --- CHANGELOG.md | 2 + packages/plugin-sdk/CHANGELOG.md | 2 + packages/plugin-sdk/PLUGIN-GUIDE.md | 152 +++++++++--------- .../plugin-sdk/schema/manifest.schema.json | 1 + packages/plugin-sdk/src/backend.ts | 34 ++++ packages/plugin-sdk/src/capabilities.ts | 1 + packages/plugin-sdk/src/testing.ts | 21 +++ src/backend/database/database.ts | 2 + .../repositories/host-folder-repository.ts | 23 +++ .../hosts/connect/transport-providers.ts | 51 ++++++ src/backend/hosts/connect/transport.ts | 17 +- src/backend/hosts/ssh-relay.ts | 110 +++++++++++++ src/backend/plugins/ctx-hosts.ts | 25 +++ src/backend/plugins/ctx-ssh-auth.ts | 21 +++ src/backend/plugins/ws.ts | 33 +++- .../hosts/connect/transport-providers.test.ts | 103 ++++++++++++ src/backend/tests/hosts/ssh-relay.test.ts | 138 ++++++++++++++++ src/backend/tests/plugins/ctx-hosts.test.ts | 53 ++++++ src/backend/tests/plugins/ws.test.ts | 20 +++ src/ui/locales/en.json | 4 + 20 files changed, 736 insertions(+), 77 deletions(-) create mode 100644 src/backend/hosts/connect/transport-providers.ts create mode 100644 src/backend/hosts/ssh-relay.ts create mode 100644 src/backend/tests/hosts/connect/transport-providers.test.ts create mode 100644 src/backend/tests/hosts/ssh-relay.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c3b37c8f..08719c77b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,8 @@ - A plugin can answer all sync requests and use the replica store, change feed and linked server list, so sync no longer lives in the core app - The desktop app can hold several accounts, each with its own copy of a server's data. The app signs in as the active one and its auto session follows a switch - The linked server list holds one link per account, with room for more connection options per server +- A plugin can carry SSH connections over its own stream, and core relays a host's connection over a socket for another Termix +- Plugins can create a folder with a color and icon and remove it when it is empty - Another Termix server can manage this one with an API key, which can now ask for a one minute ticket to open plugin sockets as its user ### Changed diff --git a/packages/plugin-sdk/CHANGELOG.md b/packages/plugin-sdk/CHANGELOG.md index af2fbae0f..a746d65b8 100644 --- a/packages/plugin-sdk/CHANGELOG.md +++ b/packages/plugin-sdk/CHANGELOG.md @@ -7,6 +7,8 @@ Plugin API 1.8.0. ### Added - `ctx.sync.provide` and the `sync:provide` capability, so a plugin can answer every /sync request +- `ctx.ssh.transports.provide` and the `ssh:transport` capability, so a plugin can carry SSH connections over its own stream +- `ctx.hosts.folders.ensure` and `removeEmpty` ### Removed diff --git a/packages/plugin-sdk/PLUGIN-GUIDE.md b/packages/plugin-sdk/PLUGIN-GUIDE.md index e50cafebc..d03522a36 100644 --- a/packages/plugin-sdk/PLUGIN-GUIDE.md +++ b/packages/plugin-sdk/PLUGIN-GUIDE.md @@ -202,38 +202,40 @@ Bundled as ESM for Node 22+ into `dist/backend.js`. Backend imports of your own ### What ctx has -| Member | Capability | What it does | -| ---------------------- | -------------------------------- | -------------------------------------------------------------- | -| `pluginId`, `manifest` | none | Your id and manifest | -| `log` | none | Log lines tagged with your plugin | -| `db` | `db:own` | Your own tables | -| `kv` | `kv:own` | Small key value state | -| `files.dataDir()` | `files:own` | A folder of your own | -| `settings` | none | Your settings | -| `secrets` | `secrets:own` | Encrypted values per user, `seal`/`unseal` for your own tables | -| `http`, `ws` | `network:serve` | Routes and sockets | -| `rbac` | none | Check the actor's permissions | -| `hosts` | `hosts:read`, `hosts:write` | List, read, create, update and share hosts as the actor | -| `ssh` | `ssh:connect`, `credentials:use` | Connect to hosts through Termix's SSH pipeline | -| `credentials` | varies | The actor's saved keys, and protocol logins | -| `services` | per service | Call other plugins and offer your own | -| `events` | `events:core` for core topics | Emit and listen for events | -| `notify` | `notify:send` | Send alerts to the Alerts inbox | -| `fetch` | `network:outbound` | Outbound HTTP with SSRF protection | -| `process` | `process:spawn` | Run programs on the server | -| `schedule` | none | Timers that stop on disable and never overlap | -| `audit.record` | none | Write your own audit line | -| `sync` | none | Sync your tables to a linked desktop | -| `auth` | `auth:provide` | Login methods, second factors, SSH auth types | -| `auth.issueUserToken` | `users:impersonate` | A short-lived session for calling Termix's API as a user | -| `auth.policy` | `auth:policy` | Login rules: idle timeout, session length, second factors | -| `sync.provide` | `sync:provide` | Answers all /sync requests: server sync, linked desktops | -| `users.getLocale` | `users:read` | A user's UI language | -| `system` | `system:tls` | The server's HTTPS certificate | -| `plugins.list()` | `plugins:read` | Which plugins are installed | -| `asUser(userId, fn)` | always audited | Run background work as a user | -| `currentActor()` | none | The user the current call runs for | -| `disposables.add(fn)` | none | Extra cleanup on disable | +| Member | Capability | What it does | +| ---------------------- | -------------------------------- | --------------------------------------------------------------- | +| `pluginId`, `manifest` | none | Your id and manifest | +| `log` | none | Log lines tagged with your plugin | +| `db` | `db:own` | Your own tables | +| `kv` | `kv:own` | Small key value state | +| `files.dataDir()` | `files:own` | A folder of your own | +| `settings` | none | Your settings | +| `secrets` | `secrets:own` | Encrypted values per user, `seal`/`unseal` for your own tables | +| `http`, `ws` | `network:serve` | Routes and sockets | +| `rbac` | none | Check the actor's permissions | +| `hosts` | `hosts:read`, `hosts:write` | List, read, create, update and share hosts as the actor | +| `ssh` | `ssh:connect`, `credentials:use` | Connect to hosts through Termix's SSH pipeline | +| `credentials` | varies | The actor's saved keys, and protocol logins | +| `services` | per service | Call other plugins and offer your own | +| `events` | `events:core` for core topics | Emit and listen for events | +| `notify` | `notify:send` | Send alerts to the Alerts inbox | +| `fetch` | `network:outbound` | Outbound HTTP with SSRF protection | +| `process` | `process:spawn` | Run programs on the server | +| `schedule` | none | Timers that stop on disable and never overlap | +| `audit.record` | none | Write your own audit line | +| `sync` | none | Sync your tables to a linked desktop | +| `auth` | `auth:provide` | Login methods, second factors, SSH auth types | +| `auth.issueUserToken` | `users:impersonate` | A short-lived session for calling Termix's API as a user | +| `auth.policy` | `auth:policy` | Login rules: idle timeout, session length, second factors | +| `sync.provide` | `sync:provide` | Answers all /sync requests: server sync, linked desktops | +| `ssh.transports` | `ssh:transport` | Carries a host's SSH connection over your own stream | +| `hosts.folders` | `hosts:write` | Create a folder row with a color and icon, remove it when empty | +| `users.getLocale` | `users:read` | A user's UI language | +| `system` | `system:tls` | The server's HTTPS certificate | +| `plugins.list()` | `plugins:read` | Which plugins are installed | +| `asUser(userId, fn)` | always audited | Run background work as a user | +| `currentActor()` | none | The user the current call runs for | +| `disposables.add(fn)` | none | Extra cleanup on disable | ### The actor @@ -302,6 +304,9 @@ 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 }) })` + 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 the hosts you manage read only for the UI, the API and other plugins (pins and order still work), and `allowManualCreate: () => false` blocks adding hosts by hand. Your own `ctx.hosts` writes still go @@ -902,47 +907,48 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie ## Capabilities -| Capability | Risk | What it allows | -| ------------------------ | -------- | ------------------------------------------------------- | -| `credentials:read` | critical | Read stored secrets for hosts the user can reach | -| `system:tls` | critical | Replace the HTTPS certificate, answer CA domain checks | -| `ssh:connect` | high | Run any command on hosts, with the user's access | -| `process:spawn` | high | Run programs on the Termix server | -| `users:write` | high | Add users and change their roles | -| `users:impersonate` | high | Act as another user, or get a session for them, audited | -| `db:core-refs` | high | Query core users, roles and hosts tables (read only) | -| `auth:provide` | high | Add login methods, second factors, SSH auth types | -| `auth:policy` | high | Set login rules: idle timeout, session length, 2FA | -| `sync:provide` | high | Run sync: answer every /sync request | -| `device:serial` | high | Open serial and USB ports on the server | -| `desktop:local` | high | Use the user's own computer, desktop app only | -| `hosts:export` | high | Export hosts with their saved logins | -| `notify:hub` | high | Receive every alert (the alerts hub only) | -| `ui:layout` | high | Replace the whole app layout | -| `hosts:write` | medium | Add and edit hosts | -| `hosts:status-provide` | medium | Be the source of host status dots | -| `hosts:activity-provide` | medium | Be the source of the recent activity list | -| `hosts:lock` | medium | Make hosts it manages read only for everyone else | -| `network:probe` | medium | Check that a host answers on a TCP port | -| `credentials:use` | medium | Connect with saved credentials without seeing them | -| `credentials:write` | medium | Save new credentials | -| `network:outbound` | medium | Outbound HTTP through `ctx.fetch` | -| `network:serve` | medium | HTTP routes and sockets | -| `network:broadcast` | medium | Local network broadcasts, like Wake-on-LAN | -| `users:read` | medium | Usernames, roles and UI language | -| `events:core` | medium | Listen to core events | -| `notify:send` | medium | Send alerts | -| `audit:read` | medium | Read the audit log | -| `desktop:window` | medium | Open windows in the desktop app | -| `hosts:read` | low | Read hosts the user can see | -| `db:own` | low | Your own tables | -| `kv:own` | low | Small key value state | -| `files:own` | low | Your own folder on disk | -| `secrets:own` | low | Your own encrypted secrets | -| `settings:read-core` | low | Read Termix server settings | -| `plugins:read` | low | See installed plugins | -| `ui:surface` | low | Add tabs, panels and settings | -| `ui:theme` | low | Add color themes, CSS only | +| Capability | Risk | What it allows | +| ------------------------ | -------- | ------------------------------------------------------------- | +| `credentials:read` | critical | Read stored secrets for hosts the user can reach | +| `system:tls` | critical | Replace the HTTPS certificate, answer CA domain checks | +| `ssh:connect` | high | Run any command on hosts, with the user's access | +| `process:spawn` | high | Run programs on the Termix server | +| `users:write` | high | Add users and change their roles | +| `users:impersonate` | high | Act as another user, or get a session for them, audited | +| `db:core-refs` | high | Query core users, roles and hosts tables (read only) | +| `auth:provide` | high | Add login methods, second factors, SSH auth types | +| `auth:policy` | high | Set login rules: idle timeout, session length, 2FA | +| `sync:provide` | high | Run sync: answer every /sync request | +| `device:serial` | high | Open serial and USB ports on the server | +| `desktop:local` | high | Use the user's own computer, desktop app only | +| `hosts:export` | high | Export hosts with their saved logins | +| `notify:hub` | high | Receive every alert (the alerts hub only) | +| `ui:layout` | high | Replace the whole app layout | +| `hosts:write` | medium | Add and edit hosts | +| `hosts:status-provide` | medium | Be the source of host status dots | +| `hosts:activity-provide` | medium | Be the source of the recent activity list | +| `ssh:transport` | high | Carry SSH connections for hosts it claims over its own stream | +| `hosts:lock` | medium | Make hosts it manages read only for everyone else | +| `network:probe` | medium | Check that a host answers on a TCP port | +| `credentials:use` | medium | Connect with saved credentials without seeing them | +| `credentials:write` | medium | Save new credentials | +| `network:outbound` | medium | Outbound HTTP through `ctx.fetch` | +| `network:serve` | medium | HTTP routes and sockets | +| `network:broadcast` | medium | Local network broadcasts, like Wake-on-LAN | +| `users:read` | medium | Usernames, roles and UI language | +| `events:core` | medium | Listen to core events | +| `notify:send` | medium | Send alerts | +| `audit:read` | medium | Read the audit log | +| `desktop:window` | medium | Open windows in the desktop app | +| `hosts:read` | low | Read hosts the user can see | +| `db:own` | low | Your own tables | +| `kv:own` | low | Small key value state | +| `files:own` | low | Your own folder on disk | +| `secrets:own` | low | Your own encrypted secrets | +| `settings:read-core` | low | Read Termix server settings | +| `plugins:read` | low | See installed plugins | +| `ui:surface` | low | Add tabs, panels and settings | +| `ui:theme` | low | Add color themes, CSS only | An update that adds a capability waits for an admin to approve it. diff --git a/packages/plugin-sdk/schema/manifest.schema.json b/packages/plugin-sdk/schema/manifest.schema.json index 3c7f343fe..ee22422e2 100644 --- a/packages/plugin-sdk/schema/manifest.schema.json +++ b/packages/plugin-sdk/schema/manifest.schema.json @@ -153,6 +153,7 @@ "auth:provide", "auth:policy", "sync:provide", + "ssh:transport", "system:tls", "device:serial", "hosts:write", diff --git a/packages/plugin-sdk/src/backend.ts b/packages/plugin-sdk/src/backend.ts index 5de206c14..c7dfd214a 100644 --- a/packages/plugin-sdk/src/backend.ts +++ b/packages/plugin-sdk/src/backend.ts @@ -1395,6 +1395,18 @@ export interface PluginHosts { activity: PluginHostActivityApi; /** Read only hosts this plugin manages. */ locks: PluginHostLocksApi; + /** Folder rows for the acting user. Needs hosts:write. */ + folders: PluginHostFoldersApi; +} + +export interface PluginHostFoldersApi { + /** Creates the folder row if missing and sets its color and icon. */ + ensure: ( + name: string, + style?: { color?: string | null; icon?: string | null }, + ) => Promise; + /** Removes the folder row and subfolders once no host is left in them. */ + removeEmpty: (name: string) => Promise; } export interface PluginHostImportOptions { @@ -1777,7 +1789,29 @@ export interface PluginNetwork { ) => Promise; } +/** + * A way to reach some hosts other than a plain TCP connection. `claims` is + * asked for every connection, so it must be quick. `open` returns a stream + * the SSH client runs over, like a relayed socket. + */ +export interface PluginSshTransportProvider { + claims: (hostId: number) => boolean; + open: (host: { + id: number; + ip: string; + port: number; + }) => Promise; +} + +export interface PluginSshTransports { + /** Needs ssh:transport. Removed on deactivate. */ + provide: (provider: PluginSshTransportProvider) => () => void; +} + export interface PluginSsh { + /** Other ways to reach hosts, used by every connection to them. */ + transports: PluginSshTransports; + /** * Whether a TCP port answers through the host's jump host chain. Chains are * shared and closed when idle. The acting user must be able to connect to diff --git a/packages/plugin-sdk/src/capabilities.ts b/packages/plugin-sdk/src/capabilities.ts index 93b573cc3..e03def44c 100644 --- a/packages/plugin-sdk/src/capabilities.ts +++ b/packages/plugin-sdk/src/capabilities.ts @@ -49,6 +49,7 @@ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [ entry("auth:provide", "high"), entry("auth:policy", "high"), entry("sync:provide", "high"), + entry("ssh:transport", "high"), entry("device:serial", "high"), entry("notify:hub", "high"), entry("ui:layout", "high"), diff --git a/packages/plugin-sdk/src/testing.ts b/packages/plugin-sdk/src/testing.ts index 8b69592a2..7b0d3f053 100644 --- a/packages/plugin-sdk/src/testing.ts +++ b/packages/plugin-sdk/src/testing.ts @@ -1070,6 +1070,10 @@ export function createFakeContext( }; }, }, + folders: { + ensure: async () => {}, + removeEmpty: async () => true, + }, locks: { provide: (provider) => { hostLockProviders.push(provider); @@ -1148,6 +1152,7 @@ export function createFakeContext( }, ssh: { + transports: { provide: () => () => {} }, probe: async (hostId, probeOptions) => options.reachable?.(hostId, probeOptions?.port) ?? false, connect: async (host, connectOptions) => { @@ -2007,6 +2012,16 @@ export function createMockCtx( return ctx.hosts.activity.provide(provider); }, }, + folders: { + ensure: async (name, style) => { + require("hosts:write"); + return ctx.hosts.folders.ensure(name, style); + }, + removeEmpty: async (name) => { + require("hosts:write"); + return ctx.hosts.folders.removeEmpty(name); + }, + }, locks: { provide: (provider) => { require("hosts:lock"); @@ -2040,6 +2055,12 @@ export function createMockCtx( ssh: { ...ctx.ssh, + transports: { + provide: (provider) => { + require("ssh:transport"); + return ctx.ssh.transports.provide(provider); + }, + }, probe: async (hostId, probeOptions) => { require("ssh:connect"); return ctx.ssh.probe(hostId, probeOptions); diff --git a/src/backend/database/database.ts b/src/backend/database/database.ts index 4ecc43468..94b3b76e0 100644 --- a/src/backend/database/database.ts +++ b/src/backend/database/database.ts @@ -35,6 +35,7 @@ import { mountPluginLegacyPaths, } from "./routes/plugin-api-routes.js"; import { attachPluginWebSockets } from "../plugins/ws.js"; +import { registerSshRelay } from "../hosts/ssh-relay.js"; import pluginRoutes from "./routes/plugins.js"; import { createPluginAssetsRouter } from "../plugins/assets.js"; import { getPluginRuntime } from "../plugins/index.js"; @@ -1898,6 +1899,7 @@ const httpServer = http.createServer(app); // Plugin sockets ride this server at /plugin-ws//. Anything else is // left alone, so core's own upgrade handling is unaffected. attachPluginWebSockets(httpServer); +registerSshRelay(); httpServer.on("error", (err: NodeJS.ErrnoException) => { if (err.code === "EADDRINUSE") { diff --git a/src/backend/database/repositories/host-folder-repository.ts b/src/backend/database/repositories/host-folder-repository.ts index 9f561bad7..de337d7d9 100644 --- a/src/backend/database/repositories/host-folder-repository.ts +++ b/src/backend/database/repositories/host-folder-repository.ts @@ -236,6 +236,29 @@ export class HostFolderRepository { .where(and(eq(hosts.userId, userId), folderMatch(hosts.folder))); } + /** Drops the folder rows (and subfolders) only when no host is left in them. */ + async deleteEmptyFolderRecords( + userId: string, + folderName: string, + ): Promise { + if ((await this.listHostsInFolder(userId, folderName)).length > 0) { + return false; + } + await deleteReturning( + this.context, + sshFolders, + and( + eq(sshFolders.userId, userId), + or( + eq(sshFolders.name, folderName), + like(sshFolders.name, `${folderName} / %`), + ), + ), + ); + await this.afterWrite(); + return true; + } + async deleteHostsAndFolderRecords( userId: string, folderName: string, diff --git a/src/backend/hosts/connect/transport-providers.ts b/src/backend/hosts/connect/transport-providers.ts new file mode 100644 index 000000000..8059fd321 --- /dev/null +++ b/src/backend/hosts/connect/transport-providers.ts @@ -0,0 +1,51 @@ +/** + * Other ways to reach a host, provided by plugins through + * ctx.ssh.transports.provide. A provider claims some hosts by id and hands the + * pipeline an open stream to them instead of a TCP connection, for example a + * relay through another Termix server. The first provider that claims a host + * wins, and nothing else (jump hosts, proxies) is tried for it. + */ + +import type { Duplex } from "node:stream"; +import type { PluginSshTransportProvider } from "@termix-ssh/plugin-sdk/backend"; + +interface Entry { + pluginId: string; + provider: PluginSshTransportProvider; +} + +const providers = new Map(); + +export function provideSshTransport( + pluginId: string, + provider: PluginSshTransportProvider, +): () => void { + const key = Symbol(pluginId); + providers.set(key, { pluginId, provider }); + return () => { + providers.delete(key); + }; +} + +/** Test seam. */ +export function clearSshTransports(): void { + providers.clear(); +} + +/** 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 { + for (const { provider } of providers.values()) { + let claimed = false; + try { + claimed = provider.claims(host.id); + } catch { + claimed = false; + } + if (claimed) return provider.open(host); + } + return null; +} diff --git a/src/backend/hosts/connect/transport.ts b/src/backend/hosts/connect/transport.ts index f75137abf..5f8a7c069 100644 --- a/src/backend/hosts/connect/transport.ts +++ b/src/backend/hosts/connect/transport.ts @@ -1,6 +1,6 @@ /** - * How the bytes get to a host: port knocking, a jump host chain or a SOCKS5 - * proxy, in that order. Sets config.sock and + * How the bytes get to a host: a transport a plugin provides, else port + * knocking, a jump host chain or a SOCKS5 proxy, in that order. Sets config.sock and * returns the jump client so the caller can close it with the connection. */ @@ -13,6 +13,7 @@ import { } from "../../utils/socks5-helper.js"; import type { ProxyNode } from "../../../types/index.js"; import { createJumpHostChain } from "../jump-host-chain.js"; +import { openProvidedTransport } from "./transport-providers.js"; import { resolveSshConnectConfigHost } from "../ssh-dns.js"; import { performPortKnocking } from "../terminal-auth-helpers.js"; import type { @@ -59,7 +60,7 @@ export interface OpenTransportOptions { export interface OpenedTransport { jumpClient: Client | null; - via: "direct" | "proxy" | "jump"; + via: "direct" | "proxy" | "jump" | "provided"; } function forwardThrough( @@ -94,6 +95,16 @@ export async function openSshTransport( config: MutableConnectConfig, options: OpenTransportOptions = {}, ): Promise { + const provided = await openProvidedTransport({ + id: host.id, + ip: host.ip, + port: host.port || 22, + }); + if (provided) { + config.sock = provided; + return { jumpClient: null, via: "provided" }; + } + if ( options.portKnock !== false && Array.isArray(host.portKnockSequence) && diff --git a/src/backend/hosts/ssh-relay.ts b/src/backend/hosts/ssh-relay.ts new file mode 100644 index 000000000..ad1126068 --- /dev/null +++ b/src/backend/hosts/ssh-relay.ts @@ -0,0 +1,110 @@ +/** + * A relay to a host's SSH port, for another Termix server that manages this + * one. It opens a WebSocket at /plugin-ws/core/ssh-relay with an API key + * ticket, names a host, and the bytes of an SSH connection run through it. + * This server reaches the host the way it always does, jump hosts, proxy and + * port knocking included, so the other server never needs a route to it. The + * SSH session itself stays between that server and the host. + * + * The caller needs connect access to the host, the same as for a session. + */ + +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 { openSshTransport } from "./connect/transport.js"; +import type { MutableConnectConfig, SshConnectHost } from "./connect/types.js"; +import { sshLogger } from "../utils/logger.js"; +import { registerCoreWsRoute } from "../plugins/ws.js"; + +const MAX_RELAYS_PER_USER = 100; +const open = new Map(); + +function close(ws: WebSocket, code: number, reason: string): void { + try { + ws.close(code, reason); + } catch { + // Already closed. + } +} + +async function handleRelay( + connection: PluginWebSocketConnection, +): 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 userId = connection.userId; + + if (!userId || !Number.isSafeInteger(hostId) || hostId <= 0) { + close(ws, 1008, "A host is required"); + return; + } + if ((open.get(userId) ?? 0) >= MAX_RELAYS_PER_USER) { + close(ws, 1013, "Too many connections"); + return; + } + + const host = await resolveHostById(hostId, userId); + if (!host) { + close(ws, 1008, "Host not found or access denied"); + return; + } + + open.set(userId, (open.get(userId) ?? 0) + 1); + let stream: Duplex | null = null; + let jumpEnd: (() => void) | null = null; + let done = false; + const finish = () => { + if (done) return; + done = true; + open.set(userId, Math.max(0, (open.get(userId) ?? 1) - 1)); + stream?.destroy(); + jumpEnd?.(); + close(ws, 1000, "Closed"); + }; + ws.once("close", finish); + + try { + const sshHost = host as unknown as SshConnectHost; + const config = { + host: sshHost.ip, + port: sshHost.port || 22, + } as MutableConnectConfig; + const { jumpClient } = await openSshTransport(sshHost, config); + jumpEnd = () => jumpClient?.end(); + stream = (config.sock as Duplex | undefined) ?? null; + if (!stream) { + stream = net.connect({ + host: String(config.host), + port: Number(config.port), + }); + } + const pipe = createWebSocketStream(ws); + stream.once("error", finish); + stream.once("close", finish); + pipe.once("error", finish); + stream.pipe(pipe); + pipe.pipe(stream); + } catch (error) { + sshLogger.warn("A relay could not reach its host", { + operation: "ssh_relay_open", + hostId, + error: error instanceof Error ? error.message : String(error), + }); + finish(); + } +} + +let registered = false; + +/** Mounts the relay once, for servers that other Termix servers manage. */ +export function registerSshRelay(): void { + if (registered) return; + registered = true; + registerCoreWsRoute("/ssh-relay", handleRelay); +} diff --git a/src/backend/plugins/ctx-hosts.ts b/src/backend/plugins/ctx-hosts.ts index c86b08669..6cd1095fb 100644 --- a/src/backend/plugins/ctx-hosts.ts +++ b/src/backend/plugins/ctx-hosts.ts @@ -1041,6 +1041,31 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts { }, }, + folders: { + ensure: async (name, style) => { + await requireWrite(); + const userId = actingUser(); + const { createCurrentHostFolderRepository } = + await import("../database/repositories/factory.js"); + await createCurrentHostFolderRepository().upsertMetadata( + userId, + name, + style?.color ?? null, + style?.icon ?? null, + ); + }, + removeEmpty: async (name) => { + await requireWrite(); + const userId = actingUser(); + const { createCurrentHostFolderRepository } = + await import("../database/repositories/factory.js"); + return createCurrentHostFolderRepository().deleteEmptyFolderRecords( + userId, + name, + ); + }, + }, + activity: { provide: (provider) => { if (!declared.includes("hosts:activity-provide")) { diff --git a/src/backend/plugins/ctx-ssh-auth.ts b/src/backend/plugins/ctx-ssh-auth.ts index ca02bf8b9..db9d3a1e9 100644 --- a/src/backend/plugins/ctx-ssh-auth.ts +++ b/src/backend/plugins/ctx-ssh-auth.ts @@ -43,6 +43,7 @@ import { registerSecondFactor, } from "../auth/registry.js"; import { provideAuthPolicy } from "../auth/policy.js"; +import { provideSshTransport } from "../hosts/connect/transport-providers.js"; import type { VerifiedIdentity } from "../auth/types.js"; import type { MutableConnectConfig, @@ -296,6 +297,26 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh { }`; return { + transports: { + provide: (provider) => { + if (!declared.includes("ssh:transport")) { + throw capabilityRefused(pluginId, "ssh:transport"); + } + const remove = provideSshTransport(pluginId, provider); + void audit( + "ssh_transport_provide", + `${pluginId} carries host connections`, + { + success: true, + }, + ); + const drop = bag.add(remove, "ssh transport"); + return () => { + drop(); + remove(); + }; + }, + }, probe: async (hostId, probeOptions) => { await assertCapability(pluginId, "ssh:connect", declared); if (!Number.isInteger(hostId) || hostId <= 0) return false; diff --git a/src/backend/plugins/ws.ts b/src/backend/plugins/ws.ts index c69eecd85..b9c0cf873 100644 --- a/src/backend/plugins/ws.ts +++ b/src/backend/plugins/ws.ts @@ -54,6 +54,8 @@ interface Route { rawHandler?: RawUpgradeHandler; /** Sockets handed to rawHandler, destroyed when the route goes away. */ rawSockets?: Set; + /** Core's own socket: served under /plugin-ws/core/, no plugin grant. */ + core?: boolean; } /** Largest frame a plugin socket accepts. ws defaults to 100MB. */ @@ -163,7 +165,10 @@ async function handlePluginUpgrade( // checked per upgrade rather than at registration, and a revoke takes effect // on the next connection without a restart. const { hasCapability } = await import("./permissions.js"); - if (!(await hasCapability(route.pluginId, "network:serve", route.declared))) { + if ( + !route.core && + !(await hasCapability(route.pluginId, "network:serve", route.declared)) + ) { reject(socket, 403, "Forbidden"); return true; } @@ -303,6 +308,32 @@ export function registerPluginWsRoute( return () => disposeRoute(routeKey, pluginId, normalized); } +/** + * A socket core itself serves, at /plugin-ws/core/, so it rides the same + * proxy rules and token checks as plugin sockets. Authenticated like them. + */ +export function registerCoreWsRoute( + path: string, + handler: PluginWebSocketHandler, +): () => void { + const routeKey = key("core", path); + const normalized = normalizePath(path); + if (routes.has(routeKey)) return () => {}; + routes.set(routeKey, { + pluginId: "core", + path: normalized, + options: {}, + declared: [], + handler, + core: true, + }); + servers.set( + routeKey, + new WebSocketServer({ noServer: true, maxPayload: MAX_PAYLOAD_BYTES }), + ); + return () => disposeRoute(routeKey, "core", normalized); +} + export function registerPluginWsUpgrade( pluginId: string, path: string, diff --git a/src/backend/tests/hosts/connect/transport-providers.test.ts b/src/backend/tests/hosts/connect/transport-providers.test.ts new file mode 100644 index 000000000..67bc3bca3 --- /dev/null +++ b/src/backend/tests/hosts/connect/transport-providers.test.ts @@ -0,0 +1,103 @@ +import { PassThrough } from "node:stream"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + performPortKnocking: vi.fn(), + createJumpHostChain: vi.fn(), + createSocks5Connection: vi.fn(), +})); + +vi.mock("../../../utils/logger.js", () => { + const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; + return { logger: log, sshLogger: log }; +}); +vi.mock("../../../hosts/terminal-auth-helpers.js", () => ({ + performPortKnocking: mocks.performPortKnocking, +})); +vi.mock("../../../hosts/jump-host-chain.js", () => ({ + createJumpHostChain: mocks.createJumpHostChain, +})); +vi.mock("../../../utils/socks5-helper.js", () => ({ + createSocks5Connection: mocks.createSocks5Connection, +})); +vi.mock("../../../hosts/ssh-dns.js", () => ({ + resolveSshConnectConfigHost: async (config: unknown) => config, +})); + +const { clearSshTransports, openProvidedTransport, provideSshTransport } = + await import("../../../hosts/connect/transport-providers.js"); +const { openSshTransport } = + await import("../../../hosts/connect/transport.js"); + +const host = { id: 7, ip: "10.0.0.7", port: 22, username: "root" }; + +afterEach(() => { + clearSshTransports(); + vi.clearAllMocks(); +}); + +describe("ssh transport providers", () => { + it("opens nothing when no provider claims the host", async () => { + provideSshTransport("a", { claims: () => false, open: vi.fn() }); + expect(await openProvidedTransport(host)).toBeNull(); + }); + + it("hands back the claiming provider's stream", async () => { + const stream = new PassThrough(); + const open = vi.fn(async () => stream); + provideSshTransport("a", { claims: (id) => id === 7, open }); + expect(await openProvidedTransport(host)).toBe(stream); + expect(open).toHaveBeenCalledWith(host); + expect(await openProvidedTransport({ ...host, id: 8 })).toBeNull(); + }); + + it("treats a provider that throws while claiming as not claiming", async () => { + provideSshTransport("a", { + claims: () => { + throw new Error("boom"); + }, + open: vi.fn(), + }); + expect(await openProvidedTransport(host)).toBeNull(); + }); + + it("stops using a provider once it is removed", async () => { + const remove = provideSshTransport("a", { + claims: () => true, + open: async () => new PassThrough(), + }); + remove(); + expect(await openProvidedTransport(host)).toBeNull(); + }); +}); + +describe("openSshTransport with a provider", () => { + it("uses the provided stream and skips knocking, jump hosts and proxies", async () => { + const stream = new PassThrough(); + provideSshTransport("a", { claims: () => true, open: async () => stream }); + const config: Record = {}; + const result = await openSshTransport( + { + ...host, + portKnockSequence: [{ port: 1 }], + jumpHosts: [{ hostId: 1 }], + useSocks5: true, + socks5Host: "proxy", + userId: "u1", + } as never, + config as never, + ); + expect(result).toEqual({ jumpClient: null, via: "provided" }); + expect(config.sock).toBe(stream); + expect(mocks.performPortKnocking).not.toHaveBeenCalled(); + expect(mocks.createJumpHostChain).not.toHaveBeenCalled(); + expect(mocks.createSocks5Connection).not.toHaveBeenCalled(); + }); + + it("connects directly when nothing claims the host", async () => { + const config: Record = {}; + const result = await openSshTransport(host as never, config as never); + expect(result.via).toBe("direct"); + expect(config.sock).toBeUndefined(); + }); +}); diff --git a/src/backend/tests/hosts/ssh-relay.test.ts b/src/backend/tests/hosts/ssh-relay.test.ts new file mode 100644 index 000000000..821e580e6 --- /dev/null +++ b/src/backend/tests/hosts/ssh-relay.test.ts @@ -0,0 +1,138 @@ +import net from "node:net"; +import type { AddressInfo } from "node:net"; +import http from "node:http"; +import { PassThrough } from "node:stream"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { WebSocket, WebSocketServer } from "ws"; + +const mocks = vi.hoisted(() => ({ + resolveHostById: vi.fn(), + openSshTransport: vi.fn(), + handler: null as null | ((connection: unknown) => Promise), +})); + +vi.mock("../../utils/logger.js", () => { + const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; + return { sshLogger: log, logger: log }; +}); +vi.mock("../../hosts/host-resolver.js", () => ({ + resolveHostById: mocks.resolveHostById, +})); +vi.mock("../../hosts/connect/transport.js", () => ({ + openSshTransport: mocks.openSshTransport, +})); +vi.mock("../../plugins/ws.js", () => ({ + registerCoreWsRoute: (_path: string, handler: never) => { + mocks.handler = handler; + return () => {}; + }, +})); + +const { registerSshRelay } = await import("../../hosts/ssh-relay.js"); + +let echo: net.Server; +let echoPort = 0; +let relayServer: http.Server; +let wss: WebSocketServer; +let relayUrl = ""; + +beforeEach(async () => { + vi.clearAllMocks(); + registerSshRelay(); + + echo = net.createServer((socket) => socket.pipe(socket)); + await new Promise((resolve) => echo.listen(0, "127.0.0.1", resolve)); + echoPort = (echo.address() as AddressInfo).port; + + relayServer = http.createServer(); + wss = new WebSocketServer({ server: relayServer }); + wss.on("connection", (socket, request) => { + void mocks.handler?.({ userId: "user-1", request, socket }); + }); + await new Promise((resolve) => + relayServer.listen(0, "127.0.0.1", resolve), + ); + relayUrl = `ws://127.0.0.1:${(relayServer.address() as AddressInfo).port}`; + + mocks.resolveHostById.mockResolvedValue({ + id: 5, + ip: "127.0.0.1", + port: echoPort, + }); + mocks.openSshTransport.mockResolvedValue({ jumpClient: null, via: "direct" }); +}); + +afterEach(async () => { + wss.close(); + await new Promise((resolve) => relayServer.close(resolve)); + await new Promise((resolve) => echo.close(resolve)); +}); + +function open(query: string): Promise<{ + socket: WebSocket; + closed: Promise<{ code: number; reason: string }>; +}> { + return new Promise((resolve) => { + const socket = new WebSocket(`${relayUrl}/${query}`); + const closed = new Promise<{ code: number; reason: string }>((done) => + socket.once("close", (code, reason) => + done({ code, reason: reason.toString() }), + ), + ); + socket.once("open", () => resolve({ socket, closed })); + }); +} + +describe("ssh relay", () => { + it("carries bytes to the host and back", async () => { + const { socket } = await open("?hostId=5"); + const received = new Promise((resolve) => + socket.once("message", (data) => resolve(data.toString())), + ); + socket.send(Buffer.from("SSH-2.0-test\r\n")); + expect(await received).toBe("SSH-2.0-test\r\n"); + expect(mocks.resolveHostById).toHaveBeenCalledWith(5, "user-1"); + socket.close(); + }); + + it("uses the stream the transport made, such as a jump host channel", async () => { + const channel = new PassThrough(); + const end = vi.fn(); + mocks.openSshTransport.mockImplementation( + async (_host: unknown, config: { sock?: unknown }) => { + config.sock = channel; + return { jumpClient: { end }, via: "jump" }; + }, + ); + const { socket } = await open("?hostId=5"); + const received = new Promise((resolve) => + socket.once("message", (data) => resolve(data.toString())), + ); + socket.send("ping"); + expect(await received).toBe("ping"); + socket.close(); + await vi.waitFor(() => expect(end).toHaveBeenCalled()); + }); + + it("refuses a host the user can't connect to", async () => { + mocks.resolveHostById.mockResolvedValue(null); + const { closed } = await open("?hostId=5"); + expect(await closed).toMatchObject({ + code: 1008, + reason: "Host not found or access denied", + }); + expect(mocks.openSshTransport).not.toHaveBeenCalled(); + }); + + it("needs a host id", async () => { + const { closed } = await open(""); + expect((await closed).code).toBe(1008); + expect(mocks.resolveHostById).not.toHaveBeenCalled(); + }); + + it("closes the socket when the host can't be reached", async () => { + mocks.openSshTransport.mockRejectedValue(new Error("no route")); + const { closed } = await open("?hostId=5"); + expect((await closed).code).toBe(1000); + }); +}); diff --git a/src/backend/tests/plugins/ctx-hosts.test.ts b/src/backend/tests/plugins/ctx-hosts.test.ts index c572fdbb2..3f3c7bff1 100644 --- a/src/backend/tests/plugins/ctx-hosts.test.ts +++ b/src/backend/tests/plugins/ctx-hosts.test.ts @@ -30,6 +30,8 @@ const h = vi.hoisted(() => ({ existingAccess: [] as Array<{ id: number }>, revoked: [] as number[], protocolWrites: [] as Array<{ hostId: number; protocols: string[] }>, + folderCalls: [] as Array, + folderEmpty: true, roles: [] as Array<{ id: number; name: string; @@ -96,6 +98,15 @@ vi.mock("../../database/repositories/factory.js", () => ({ createCurrentUserRepository: () => ({ listAll: async () => h.users, }), + createCurrentHostFolderRepository: () => ({ + upsertMetadata: async (...args: unknown[]) => { + h.folderCalls.push(["ensure", ...args]); + }, + deleteEmptyFolderRecords: async (...args: unknown[]) => { + h.folderCalls.push(["remove", ...args]); + return h.folderEmpty; + }, + }), createCurrentHostRepository: () => ({ createEncryptedForUser: async ( userId: string, @@ -961,3 +972,45 @@ describe("ctx.hosts.locks", () => { expect(h.updates).toHaveLength(1); }); }); + +describe("ctx.hosts.folders", () => { + beforeEach(() => { + h.granted = new Set(["hosts:write"]); + h.actor = "user-1"; + h.folderCalls = []; + h.folderEmpty = true; + }); + + it("needs hosts:write", async () => { + h.granted = new Set(); + const hosts = createPluginHosts({ + manifest: manifest(["hosts:write"]), + audit: vi.fn(async () => {}), + }); + await expect(hosts.folders.ensure("Site")).rejects.toThrow( + PluginCapabilityError, + ); + }); + + it("creates the folder row for the acting user with its style", async () => { + const hosts = createPluginHosts({ + manifest: manifest(["hosts:write"]), + audit: vi.fn(async () => {}), + }); + await hosts.folders.ensure("Site", { icon: "cloud-cog" }); + expect(h.folderCalls).toEqual([ + ["ensure", "user-1", "Site", null, "cloud-cog"], + ]); + }); + + it("removes only an empty folder", async () => { + const hosts = createPluginHosts({ + manifest: manifest(["hosts:write"]), + audit: vi.fn(async () => {}), + }); + expect(await hosts.folders.removeEmpty("Site")).toBe(true); + h.folderEmpty = false; + expect(await hosts.folders.removeEmpty("Site")).toBe(false); + expect(h.folderCalls[0]).toEqual(["remove", "user-1", "Site"]); + }); +}); diff --git a/src/backend/tests/plugins/ws.test.ts b/src/backend/tests/plugins/ws.test.ts index 13c21522c..766eb4ccf 100644 --- a/src/backend/tests/plugins/ws.test.ts +++ b/src/backend/tests/plugins/ws.test.ts @@ -154,6 +154,26 @@ describe("ctx.ws routing", () => { if (result.outcome === "open") result.socket.close(); }); + it("serves a socket core owns without any plugin grant", async () => { + state.granted = new Set(); + const seen: string[] = []; + ws.registerCoreWsRoute("/relay", ({ userId }) => { + seen.push(userId); + }); + + const base = await startServer(); + const signedIn = await connect( + `${base}/plugin-ws/core/relay`, + authProtocols(state.validToken), + ); + const anonymous = await connect(`${base}/plugin-ws/core/relay`); + + expect(signedIn.outcome).toBe("open"); + expect(anonymous.outcome).toBe(401); + expect(seen).toEqual([state.userId]); + if (signedIn.outcome === "open") signedIn.socket.close(); + }); + it("404s a path nothing registered", async () => { const base = await startServer(); diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index c53a70147..6d3be80a3 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -1143,6 +1143,10 @@ "title": "Set sign in rules", "consequence": "It can sign people out when idle, limit session length, turn off remember me and require a second factor." }, + "ssh:transport": { + "title": "Choose how hosts are reached", + "consequence": "It can carry the SSH connection to a host over its own route, so it sees everything sent to and from those hosts, passwords included." + }, "sync:provide": { "title": "Run sync", "consequence": "It can answer all sync requests, so it sees the data that devices and servers sync."