feat: ssh transport providers, core ssh relay and plugin folders api

This commit is contained in:
LukeGus committed 2026-10-19 19:20:32 -05:00
1 parent 3caf6cca62
commit 901a235de4
20 files changed
+736 -77

No files matched your search

+2
View File
@@ -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 - 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 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 - 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 - 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 ### Changed
+2
View File
@@ -7,6 +7,8 @@ Plugin API 1.8.0.
### Added ### Added
- `ctx.sync.provide` and the `sync:provide` capability, so a plugin can answer every /sync request - `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 ### Removed
+79 -73
View File
@@ -202,38 +202,40 @@ Bundled as ESM for Node 22+ into `dist/backend.js`. Backend imports of your own
### What ctx has ### What ctx has
| Member | Capability | What it does | | Member | Capability | What it does |
| ---------------------- | -------------------------------- | -------------------------------------------------------------- | | ---------------------- | -------------------------------- | --------------------------------------------------------------- |
| `pluginId`, `manifest` | none | Your id and manifest | | `pluginId`, `manifest` | none | Your id and manifest |
| `log` | none | Log lines tagged with your plugin | | `log` | none | Log lines tagged with your plugin |
| `db` | `db:own` | Your own tables | | `db` | `db:own` | Your own tables |
| `kv` | `kv:own` | Small key value state | | `kv` | `kv:own` | Small key value state |
| `files.dataDir()` | `files:own` | A folder of your own | | `files.dataDir()` | `files:own` | A folder of your own |
| `settings` | none | Your settings | | `settings` | none | Your settings |
| `secrets` | `secrets:own` | Encrypted values per user, `seal`/`unseal` for your own tables | | `secrets` | `secrets:own` | Encrypted values per user, `seal`/`unseal` for your own tables |
| `http`, `ws` | `network:serve` | Routes and sockets | | `http`, `ws` | `network:serve` | Routes and sockets |
| `rbac` | none | Check the actor's permissions | | `rbac` | none | Check the actor's permissions |
| `hosts` | `hosts:read`, `hosts:write` | List, read, create, update and share hosts as the actor | | `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 | | `ssh` | `ssh:connect`, `credentials:use` | Connect to hosts through Termix's SSH pipeline |
| `credentials` | varies | The actor's saved keys, and protocol logins | | `credentials` | varies | The actor's saved keys, and protocol logins |
| `services` | per service | Call other plugins and offer your own | | `services` | per service | Call other plugins and offer your own |
| `events` | `events:core` for core topics | Emit and listen for events | | `events` | `events:core` for core topics | Emit and listen for events |
| `notify` | `notify:send` | Send alerts to the Alerts inbox | | `notify` | `notify:send` | Send alerts to the Alerts inbox |
| `fetch` | `network:outbound` | Outbound HTTP with SSRF protection | | `fetch` | `network:outbound` | Outbound HTTP with SSRF protection |
| `process` | `process:spawn` | Run programs on the server | | `process` | `process:spawn` | Run programs on the server |
| `schedule` | none | Timers that stop on disable and never overlap | | `schedule` | none | Timers that stop on disable and never overlap |
| `audit.record` | none | Write your own audit line | | `audit.record` | none | Write your own audit line |
| `sync` | none | Sync your tables to a linked desktop | | `sync` | none | Sync your tables to a linked desktop |
| `auth` | `auth:provide` | Login methods, second factors, SSH auth types | | `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.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 | | `auth.policy` | `auth:policy` | Login rules: idle timeout, session length, second factors |
| `sync.provide` | `sync:provide` | Answers all /sync requests: server sync, linked desktops | | `sync.provide` | `sync:provide` | Answers all /sync requests: server sync, linked desktops |
| `users.getLocale` | `users:read` | A user's UI language | | `ssh.transports` | `ssh:transport` | Carries a host's SSH connection over your own stream |
| `system` | `system:tls` | The server's HTTPS certificate | | `hosts.folders` | `hosts:write` | Create a folder row with a color and icon, remove it when empty |
| `plugins.list()` | `plugins:read` | Which plugins are installed | | `users.getLocale` | `users:read` | A user's UI language |
| `asUser(userId, fn)` | always audited | Run background work as a user | | `system` | `system:tls` | The server's HTTPS certificate |
| `currentActor()` | none | The user the current call runs for | | `plugins.list()` | `plugins:read` | Which plugins are installed |
| `disposables.add(fn)` | none | Extra cleanup on disable | | `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 ### 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 - `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. `hosts.edit` checks a core permission.
- Host access: `ctx.hosts.checkAccess(hostId, "connect" | "view" | "edit" | "manage")`. - 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 - 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 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 `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 ## Capabilities
| Capability | Risk | What it allows | | Capability | Risk | What it allows |
| ------------------------ | -------- | ------------------------------------------------------- | | ------------------------ | -------- | ------------------------------------------------------------- |
| `credentials:read` | critical | Read stored secrets for hosts the user can reach | | `credentials:read` | critical | Read stored secrets for hosts the user can reach |
| `system:tls` | critical | Replace the HTTPS certificate, answer CA domain checks | | `system:tls` | critical | Replace the HTTPS certificate, answer CA domain checks |
| `ssh:connect` | high | Run any command on hosts, with the user's access | | `ssh:connect` | high | Run any command on hosts, with the user's access |
| `process:spawn` | high | Run programs on the Termix server | | `process:spawn` | high | Run programs on the Termix server |
| `users:write` | high | Add users and change their roles | | `users:write` | high | Add users and change their roles |
| `users:impersonate` | high | Act as another user, or get a session for them, audited | | `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) | | `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:provide` | high | Add login methods, second factors, SSH auth types |
| `auth:policy` | high | Set login rules: idle timeout, session length, 2FA | | `auth:policy` | high | Set login rules: idle timeout, session length, 2FA |
| `sync:provide` | high | Run sync: answer every /sync request | | `sync:provide` | high | Run sync: answer every /sync request |
| `device:serial` | high | Open serial and USB ports on the server | | `device:serial` | high | Open serial and USB ports on the server |
| `desktop:local` | high | Use the user's own computer, desktop app only | | `desktop:local` | high | Use the user's own computer, desktop app only |
| `hosts:export` | high | Export hosts with their saved logins | | `hosts:export` | high | Export hosts with their saved logins |
| `notify:hub` | high | Receive every alert (the alerts hub only) | | `notify:hub` | high | Receive every alert (the alerts hub only) |
| `ui:layout` | high | Replace the whole app layout | | `ui:layout` | high | Replace the whole app layout |
| `hosts:write` | medium | Add and edit hosts | | `hosts:write` | medium | Add and edit hosts |
| `hosts:status-provide` | medium | Be the source of host status dots | | `hosts:status-provide` | medium | Be the source of host status dots |
| `hosts:activity-provide` | medium | Be the source of the recent activity list | | `hosts:activity-provide` | medium | Be the source of the recent activity list |
| `hosts:lock` | medium | Make hosts it manages read only for everyone else | | `ssh:transport` | high | Carry SSH connections for hosts it claims over its own stream |
| `network:probe` | medium | Check that a host answers on a TCP port | | `hosts:lock` | medium | Make hosts it manages read only for everyone else |
| `credentials:use` | medium | Connect with saved credentials without seeing them | | `network:probe` | medium | Check that a host answers on a TCP port |
| `credentials:write` | medium | Save new credentials | | `credentials:use` | medium | Connect with saved credentials without seeing them |
| `network:outbound` | medium | Outbound HTTP through `ctx.fetch` | | `credentials:write` | medium | Save new credentials |
| `network:serve` | medium | HTTP routes and sockets | | `network:outbound` | medium | Outbound HTTP through `ctx.fetch` |
| `network:broadcast` | medium | Local network broadcasts, like Wake-on-LAN | | `network:serve` | medium | HTTP routes and sockets |
| `users:read` | medium | Usernames, roles and UI language | | `network:broadcast` | medium | Local network broadcasts, like Wake-on-LAN |
| `events:core` | medium | Listen to core events | | `users:read` | medium | Usernames, roles and UI language |
| `notify:send` | medium | Send alerts | | `events:core` | medium | Listen to core events |
| `audit:read` | medium | Read the audit log | | `notify:send` | medium | Send alerts |
| `desktop:window` | medium | Open windows in the desktop app | | `audit:read` | medium | Read the audit log |
| `hosts:read` | low | Read hosts the user can see | | `desktop:window` | medium | Open windows in the desktop app |
| `db:own` | low | Your own tables | | `hosts:read` | low | Read hosts the user can see |
| `kv:own` | low | Small key value state | | `db:own` | low | Your own tables |
| `files:own` | low | Your own folder on disk | | `kv:own` | low | Small key value state |
| `secrets:own` | low | Your own encrypted secrets | | `files:own` | low | Your own folder on disk |
| `settings:read-core` | low | Read Termix server settings | | `secrets:own` | low | Your own encrypted secrets |
| `plugins:read` | low | See installed plugins | | `settings:read-core` | low | Read Termix server settings |
| `ui:surface` | low | Add tabs, panels and settings | | `plugins:read` | low | See installed plugins |
| `ui:theme` | low | Add color themes, CSS only | | `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. An update that adds a capability waits for an admin to approve it.
@@ -153,6 +153,7 @@
"auth:provide", "auth:provide",
"auth:policy", "auth:policy",
"sync:provide", "sync:provide",
"ssh:transport",
"system:tls", "system:tls",
"device:serial", "device:serial",
"hosts:write", "hosts:write",
+34
View File
@@ -1395,6 +1395,18 @@ export interface PluginHosts {
activity: PluginHostActivityApi; activity: PluginHostActivityApi;
/** Read only hosts this plugin manages. */ /** Read only hosts this plugin manages. */
locks: PluginHostLocksApi; 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<void>;
/** Removes the folder row and subfolders once no host is left in them. */
removeEmpty: (name: string) => Promise<boolean>;
} }
export interface PluginHostImportOptions { export interface PluginHostImportOptions {
@@ -1777,7 +1789,29 @@ export interface PluginNetwork {
) => Promise<boolean>; ) => Promise<boolean>;
} }
/**
* 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<import("node:stream").Duplex>;
}
export interface PluginSshTransports {
/** Needs ssh:transport. Removed on deactivate. */
provide: (provider: PluginSshTransportProvider) => () => void;
}
export interface PluginSsh { 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 * 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 * shared and closed when idle. The acting user must be able to connect to
+1
View File
@@ -49,6 +49,7 @@ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [
entry("auth:provide", "high"), entry("auth:provide", "high"),
entry("auth:policy", "high"), entry("auth:policy", "high"),
entry("sync:provide", "high"), entry("sync:provide", "high"),
entry("ssh:transport", "high"),
entry("device:serial", "high"), entry("device:serial", "high"),
entry("notify:hub", "high"), entry("notify:hub", "high"),
entry("ui:layout", "high"), entry("ui:layout", "high"),
+21
View File
@@ -1070,6 +1070,10 @@ export function createFakeContext(
}; };
}, },
}, },
folders: {
ensure: async () => {},
removeEmpty: async () => true,
},
locks: { locks: {
provide: (provider) => { provide: (provider) => {
hostLockProviders.push(provider); hostLockProviders.push(provider);
@@ -1148,6 +1152,7 @@ export function createFakeContext(
}, },
ssh: { ssh: {
transports: { provide: () => () => {} },
probe: async (hostId, probeOptions) => probe: async (hostId, probeOptions) =>
options.reachable?.(hostId, probeOptions?.port) ?? false, options.reachable?.(hostId, probeOptions?.port) ?? false,
connect: async (host, connectOptions) => { connect: async (host, connectOptions) => {
@@ -2007,6 +2012,16 @@ export function createMockCtx(
return ctx.hosts.activity.provide(provider); 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: { locks: {
provide: (provider) => { provide: (provider) => {
require("hosts:lock"); require("hosts:lock");
@@ -2040,6 +2055,12 @@ export function createMockCtx(
ssh: { ssh: {
...ctx.ssh, ...ctx.ssh,
transports: {
provide: (provider) => {
require("ssh:transport");
return ctx.ssh.transports.provide(provider);
},
},
probe: async (hostId, probeOptions) => { probe: async (hostId, probeOptions) => {
require("ssh:connect"); require("ssh:connect");
return ctx.ssh.probe(hostId, probeOptions); return ctx.ssh.probe(hostId, probeOptions);
+2
View File
@@ -35,6 +35,7 @@ import {
mountPluginLegacyPaths, mountPluginLegacyPaths,
} from "./routes/plugin-api-routes.js"; } from "./routes/plugin-api-routes.js";
import { attachPluginWebSockets } from "../plugins/ws.js"; import { attachPluginWebSockets } from "../plugins/ws.js";
import { registerSshRelay } from "../hosts/ssh-relay.js";
import pluginRoutes from "./routes/plugins.js"; import pluginRoutes from "./routes/plugins.js";
import { createPluginAssetsRouter } from "../plugins/assets.js"; import { createPluginAssetsRouter } from "../plugins/assets.js";
import { getPluginRuntime } from "../plugins/index.js"; import { getPluginRuntime } from "../plugins/index.js";
@@ -1898,6 +1899,7 @@ const httpServer = http.createServer(app);
// Plugin sockets ride this server at /plugin-ws/<id>/<path>. Anything else is // Plugin sockets ride this server at /plugin-ws/<id>/<path>. Anything else is
// left alone, so core's own upgrade handling is unaffected. // left alone, so core's own upgrade handling is unaffected.
attachPluginWebSockets(httpServer); attachPluginWebSockets(httpServer);
registerSshRelay();
httpServer.on("error", (err: NodeJS.ErrnoException) => { httpServer.on("error", (err: NodeJS.ErrnoException) => {
if (err.code === "EADDRINUSE") { if (err.code === "EADDRINUSE") {
@@ -236,6 +236,29 @@ export class HostFolderRepository {
.where(and(eq(hosts.userId, userId), folderMatch(hosts.folder))); .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<boolean> {
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( async deleteHostsAndFolderRecords(
userId: string, userId: string,
folderName: string, folderName: string,
@@ -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<symbol, Entry>();
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<Duplex | null> {
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;
}
+14 -3
View File
@@ -1,6 +1,6 @@
/** /**
* How the bytes get to a host: port knocking, a jump host chain or a SOCKS5 * How the bytes get to a host: a transport a plugin provides, else port
* proxy, in that order. Sets config.sock and * 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. * returns the jump client so the caller can close it with the connection.
*/ */
@@ -13,6 +13,7 @@ import {
} from "../../utils/socks5-helper.js"; } from "../../utils/socks5-helper.js";
import type { ProxyNode } from "../../../types/index.js"; import type { ProxyNode } from "../../../types/index.js";
import { createJumpHostChain } from "../jump-host-chain.js"; import { createJumpHostChain } from "../jump-host-chain.js";
import { openProvidedTransport } from "./transport-providers.js";
import { resolveSshConnectConfigHost } from "../ssh-dns.js"; import { resolveSshConnectConfigHost } from "../ssh-dns.js";
import { performPortKnocking } from "../terminal-auth-helpers.js"; import { performPortKnocking } from "../terminal-auth-helpers.js";
import type { import type {
@@ -59,7 +60,7 @@ export interface OpenTransportOptions {
export interface OpenedTransport { export interface OpenedTransport {
jumpClient: Client | null; jumpClient: Client | null;
via: "direct" | "proxy" | "jump"; via: "direct" | "proxy" | "jump" | "provided";
} }
function forwardThrough( function forwardThrough(
@@ -94,6 +95,16 @@ export async function openSshTransport(
config: MutableConnectConfig, config: MutableConnectConfig,
options: OpenTransportOptions = {}, options: OpenTransportOptions = {},
): Promise<OpenedTransport> { ): Promise<OpenedTransport> {
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 ( if (
options.portKnock !== false && options.portKnock !== false &&
Array.isArray(host.portKnockSequence) && Array.isArray(host.portKnockSequence) &&
+110
View File
@@ -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<string, number>();
function close(ws: WebSocket, code: number, reason: string): void {
try {
ws.close(code, reason);
} catch {
// Already closed.
}
}
async function handleRelay(
connection: PluginWebSocketConnection,
): Promise<void> {
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);
}
+25
View File
@@ -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: { activity: {
provide: (provider) => { provide: (provider) => {
if (!declared.includes("hosts:activity-provide")) { if (!declared.includes("hosts:activity-provide")) {
+21
View File
@@ -43,6 +43,7 @@ import {
registerSecondFactor, registerSecondFactor,
} from "../auth/registry.js"; } from "../auth/registry.js";
import { provideAuthPolicy } from "../auth/policy.js"; import { provideAuthPolicy } from "../auth/policy.js";
import { provideSshTransport } from "../hosts/connect/transport-providers.js";
import type { VerifiedIdentity } from "../auth/types.js"; import type { VerifiedIdentity } from "../auth/types.js";
import type { import type {
MutableConnectConfig, MutableConnectConfig,
@@ -296,6 +297,26 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
}`; }`;
return { 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) => { probe: async (hostId, probeOptions) => {
await assertCapability(pluginId, "ssh:connect", declared); await assertCapability(pluginId, "ssh:connect", declared);
if (!Number.isInteger(hostId) || hostId <= 0) return false; if (!Number.isInteger(hostId) || hostId <= 0) return false;
+32 -1
View File
@@ -54,6 +54,8 @@ interface Route {
rawHandler?: RawUpgradeHandler; rawHandler?: RawUpgradeHandler;
/** Sockets handed to rawHandler, destroyed when the route goes away. */ /** Sockets handed to rawHandler, destroyed when the route goes away. */
rawSockets?: Set<Duplex>; rawSockets?: Set<Duplex>;
/** Core's own socket: served under /plugin-ws/core/, no plugin grant. */
core?: boolean;
} }
/** Largest frame a plugin socket accepts. ws defaults to 100MB. */ /** 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 // checked per upgrade rather than at registration, and a revoke takes effect
// on the next connection without a restart. // on the next connection without a restart.
const { hasCapability } = await import("./permissions.js"); 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"); reject(socket, 403, "Forbidden");
return true; return true;
} }
@@ -303,6 +308,32 @@ export function registerPluginWsRoute(
return () => disposeRoute(routeKey, pluginId, normalized); return () => disposeRoute(routeKey, pluginId, normalized);
} }
/**
* A socket core itself serves, at /plugin-ws/core/<path>, 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( export function registerPluginWsUpgrade(
pluginId: string, pluginId: string,
path: string, path: string,
@@ -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<string, unknown> = {};
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<string, unknown> = {};
const result = await openSshTransport(host as never, config as never);
expect(result.via).toBe("direct");
expect(config.sock).toBeUndefined();
});
});
+138
View File
@@ -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<void>),
}));
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<void>((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<void>((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<string>((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<string>((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);
});
});
@@ -30,6 +30,8 @@ const h = vi.hoisted(() => ({
existingAccess: [] as Array<{ id: number }>, existingAccess: [] as Array<{ id: number }>,
revoked: [] as number[], revoked: [] as number[],
protocolWrites: [] as Array<{ hostId: number; protocols: string[] }>, protocolWrites: [] as Array<{ hostId: number; protocols: string[] }>,
folderCalls: [] as Array<unknown[]>,
folderEmpty: true,
roles: [] as Array<{ roles: [] as Array<{
id: number; id: number;
name: string; name: string;
@@ -96,6 +98,15 @@ vi.mock("../../database/repositories/factory.js", () => ({
createCurrentUserRepository: () => ({ createCurrentUserRepository: () => ({
listAll: async () => h.users, 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: () => ({ createCurrentHostRepository: () => ({
createEncryptedForUser: async ( createEncryptedForUser: async (
userId: string, userId: string,
@@ -961,3 +972,45 @@ describe("ctx.hosts.locks", () => {
expect(h.updates).toHaveLength(1); 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"]);
});
});
+20
View File
@@ -154,6 +154,26 @@ describe("ctx.ws routing", () => {
if (result.outcome === "open") result.socket.close(); 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 () => { it("404s a path nothing registered", async () => {
const base = await startServer(); const base = await startServer();
+4
View File
@@ -1143,6 +1143,10 @@
"title": "Set sign in rules", "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." "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": { "sync:provide": {
"title": "Run sync", "title": "Run sync",
"consequence": "It can answer all sync requests, so it sees the data that devices and servers sync." "consequence": "It can answer all sync requests, so it sees the data that devices and servers sync."