mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-21 02:51:13 +00:00
feat: ssh transport providers, core ssh relay and plugin folders api
This commit is contained in:
1 parent
3caf6cca62
commit
901a235de4
20 files changed
+736
-77
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) &&
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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")) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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."
|
||||
|
||||
Reference in new issue
Block a user