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
- 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
+2
View File
@@ -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
+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
| 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.
@@ -153,6 +153,7 @@
"auth:provide",
"auth:policy",
"sync:provide",
"ssh:transport",
"system:tls",
"device:serial",
"hosts:write",
+34
View File
@@ -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<void>;
/** Removes the folder row and subfolders once no host is left in them. */
removeEmpty: (name: string) => Promise<boolean>;
}
export interface PluginHostImportOptions {
@@ -1777,7 +1789,29 @@ export interface PluginNetwork {
) => 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 {
/** 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
+1
View File
@@ -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"),
+21
View File
@@ -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);
+2
View File
@@ -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/<id>/<path>. 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") {
@@ -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<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(
userId: 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
* 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<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 (
options.portKnock !== false &&
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: {
provide: (provider) => {
if (!declared.includes("hosts:activity-provide")) {
+21
View File
@@ -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;
+32 -1
View File
@@ -54,6 +54,8 @@ interface Route {
rawHandler?: RawUpgradeHandler;
/** Sockets handed to rawHandler, destroyed when the route goes away. */
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. */
@@ -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/<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(
pluginId: 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 }>,
revoked: [] as number[],
protocolWrites: [] as Array<{ hostId: number; protocols: string[] }>,
folderCalls: [] as Array<unknown[]>,
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"]);
});
});
+20
View File
@@ -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();
+4
View File
@@ -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."