mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
1021 lines
34 KiB
TypeScript
1021 lines
34 KiB
TypeScript
/**
|
|
* The backend contract: what a plugin's activate(ctx) receives.
|
|
*
|
|
* These are types only. Core builds the real object in
|
|
* src/backend/plugins/ctx.ts and injects it at activation, so the SDK stays a
|
|
* description of the contract rather than an implementation a plugin could
|
|
* reach around.
|
|
*
|
|
* ctx.ssh is the part of step B's host surface that A8 needed early, so plugin
|
|
* transports could leave core's internals behind.
|
|
*/
|
|
|
|
import type { PluginManifest, PluginSettingsField } from "./manifest.js";
|
|
import type { PluginTableDefinition } from "./db.js";
|
|
|
|
/**
|
|
* Thrown when a plugin calls a guarded member without the capability.
|
|
*
|
|
* Two things must be true: the manifest declares it, and it is granted. A
|
|
* grant for something the manifest never declared is ignored, so widening a
|
|
* plugin's reach always needs a new manifest the user can see.
|
|
*/
|
|
export class PluginCapabilityError extends Error {
|
|
readonly code = "EPLUGINCAP";
|
|
readonly pluginId: string;
|
|
readonly capability: string;
|
|
|
|
constructor(pluginId: string, capability: string) {
|
|
super(
|
|
`Plugin "${pluginId}" is not granted the "${capability}" capability. ` +
|
|
`Declare it in the manifest's capabilities array and grant it in the plugin's settings.`,
|
|
);
|
|
this.name = "PluginCapabilityError";
|
|
this.pluginId = pluginId;
|
|
this.capability = capability;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Thrown by a login method or second factor to refuse a sign-in with a
|
|
* message the user can see. `code` is a stable id the login screen can map to
|
|
* its own text; `status` is the HTTP status for form methods.
|
|
*/
|
|
export class LoginMethodError extends Error {
|
|
readonly status: number;
|
|
readonly code?: string;
|
|
|
|
constructor(message: string, status = 401, code?: string) {
|
|
super(message);
|
|
this.name = "LoginMethodError";
|
|
this.status = status;
|
|
this.code = code;
|
|
}
|
|
}
|
|
|
|
export interface PluginLogger {
|
|
debug: (message: string) => void;
|
|
info: (message: string) => void;
|
|
warn: (message: string) => void;
|
|
error: (message: string, error?: Error) => void;
|
|
}
|
|
|
|
export interface PluginEvents {
|
|
/** Topics are namespaced to plugin.<id>.* unless the plugin holds events:core. */
|
|
emit: (topic: string, payload: unknown) => void;
|
|
on: (topic: string, listener: (payload: unknown) => void) => () => void;
|
|
}
|
|
|
|
/** Small key/value state owned by this plugin. Requires kv:own. */
|
|
export interface PluginKeyValue {
|
|
get: (key: string) => Promise<unknown>;
|
|
set: (key: string, value: unknown) => Promise<void>;
|
|
delete: (key: string) => Promise<boolean>;
|
|
list: () => Promise<string[]>;
|
|
}
|
|
|
|
/**
|
|
* The plugin's own tables.
|
|
*
|
|
* `define` registers a table definition and returns the Drizzle table object
|
|
* to query through `db`. Both require db:own.
|
|
*
|
|
* Scoped by name: every table carries the p_<id>_ prefix, and `refs` exposes
|
|
* users, ssh_data, roles and user_roles read-only so a plugin can join against
|
|
* them without being able to write them. In-process code could reach around
|
|
* all of this - see "What this protects, and what it does not" in
|
|
* ARCHITECTURE.md. The capability, the prefix, lint and review are the
|
|
* contract, not a sandbox.
|
|
*/
|
|
export interface PluginDatabase {
|
|
/** Registers a definition and returns its queryable table object. */
|
|
define: <T = unknown>(definition: PluginTableDefinition) => Promise<T>;
|
|
/** Drizzle handle, scoped to this plugin's tables. */
|
|
client: <T = unknown>() => Promise<T>;
|
|
/** Read-only references to the core tables a plugin may point at. */
|
|
refs: <T = unknown>() => Promise<T>;
|
|
/**
|
|
* Flushes writes to disk. Call it after every write: on SQLite the database
|
|
* lives in memory and is saved to its encrypted file only when asked, so a
|
|
* write without it can be lost on restart. A no-op on Postgres and MySQL.
|
|
*/
|
|
persist: () => Promise<void>;
|
|
/**
|
|
* The engine the server runs on. Table objects encode the same on all
|
|
* three, but MySQL has no RETURNING, so portable code reads a written row
|
|
* back by its key rather than relying on .returning().
|
|
*/
|
|
readonly dialect: "sqlite" | "postgres" | "mysql";
|
|
}
|
|
|
|
/** A row's shape on the wire, before it is written to a table. */
|
|
export type SyncRow = Record<string, unknown>;
|
|
|
|
export interface SyncEntityReference {
|
|
/** Stored column holding a local numeric id, e.g. "credentialId". */
|
|
field: string;
|
|
/** Wire field holding the portable id, e.g. "credentialSyncId". */
|
|
syncField: string;
|
|
/** The entity the id points at. */
|
|
entityType: string;
|
|
}
|
|
|
|
export interface SyncEntityRegistration {
|
|
/** Stable wire name. Never change it: tombstones and remote rows match on it. */
|
|
type: string;
|
|
/** The table, as returned by ctx.db.define. */
|
|
table: unknown;
|
|
/** Column holding the owning user id. Defaults to "userId". */
|
|
userColumn?: string;
|
|
/** Fields DataCrypto translates between plaintext wire and encrypted row. */
|
|
encryptedFields?: readonly string[];
|
|
/** Local-id to sync-id translations applied on the way out and back. */
|
|
references?: readonly SyncEntityReference[];
|
|
/** Lower sorts first. Reference targets must sort before their referrers. */
|
|
order?: number;
|
|
/** Fields that must never be overwritten by an inbound payload. */
|
|
readOnlyFields?: readonly string[];
|
|
/** One row per user rather than many, keyed on the owner. */
|
|
singleton?: boolean;
|
|
/**
|
|
* Rows this returns false for are left out of sync in both directions, for
|
|
* state that belongs to one install (a per-device "last session").
|
|
*/
|
|
shouldSync?: (row: SyncRow) => boolean;
|
|
/**
|
|
* Escape hatch for a row whose references are not plain columns, such as ids
|
|
* embedded in a JSON blob. Runs instead of `references`, not alongside it.
|
|
*/
|
|
serialize?: (
|
|
row: SyncRow,
|
|
resolveSyncId: (entityType: string, id: number) => Promise<string | null>,
|
|
) => Promise<SyncRow>;
|
|
deserialize?: (
|
|
row: SyncRow,
|
|
resolveId: (entityType: string, syncId: string) => Promise<number | null>,
|
|
) => Promise<SyncRow>;
|
|
}
|
|
|
|
/** Adds an entity to remote sync between a desktop backend and a server. */
|
|
export interface PluginSync {
|
|
registerEntity: (entity: SyncEntityRegistration) => void;
|
|
/**
|
|
* Records that a row was deleted, so a pull on the other side of remote
|
|
* sync removes it too. Call it with the row's syncId right after deleting
|
|
* it, for a registered entity whose rows can be deleted (a no-op silently
|
|
* loses deletes across devices otherwise). A falsy syncId is ignored.
|
|
*/
|
|
recordTombstone: (
|
|
userId: string,
|
|
entityType: string,
|
|
syncId: string | null | undefined,
|
|
) => Promise<void>;
|
|
}
|
|
|
|
export interface PluginRegistry {
|
|
provide: <T>(key: string, value: T) => void;
|
|
consume: <T>(key: string) => T | undefined;
|
|
revoke: (key: string, value?: unknown) => boolean;
|
|
}
|
|
|
|
export interface PluginServices {
|
|
provide: <T extends object>(service: string, implementation: T) => void;
|
|
get: <T extends object>(service: string, options?: { userId?: string }) => T;
|
|
}
|
|
|
|
export interface PluginSecrets {
|
|
offer: (
|
|
key: string,
|
|
resolve: (userId: string) => Promise<string | null> | string | null,
|
|
) => void;
|
|
withdraw: (key: string) => boolean;
|
|
getShared: (
|
|
pluginId: string,
|
|
key: string,
|
|
options?: { userId?: string },
|
|
) => Promise<string | null>;
|
|
}
|
|
|
|
/**
|
|
* Express types, structurally. The SDK does not depend on express: a plugin
|
|
* brings its own copy, and a nominal type from core's would not match it.
|
|
*/
|
|
export interface PluginRequestLike {
|
|
method: string;
|
|
url: string;
|
|
path: string;
|
|
headers: Record<string, unknown>;
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
export interface PluginResponseLike {
|
|
status: (code: number) => PluginResponseLike;
|
|
json: (body: unknown) => unknown;
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
export type PluginNextFunction = (error?: unknown) => void;
|
|
|
|
export type PluginMiddleware = (
|
|
req: PluginRequestLike,
|
|
res: PluginResponseLike,
|
|
next: PluginNextFunction,
|
|
) => void;
|
|
|
|
export interface PluginRouterOptions {
|
|
/**
|
|
* Paths served without authentication, relative to the plugin's mount point
|
|
* and matched exactly ("/callback", not "/plugin-api/<id>/callback").
|
|
*
|
|
* For the handful of routes an unauthenticated third party has to reach: an
|
|
* OIDC callback, an inbound webhook. Every entry is audited when the router
|
|
* is registered and every request that uses one is logged, because "this
|
|
* plugin opened a hole in auth" is exactly the thing an operator should be
|
|
* able to find later.
|
|
*/
|
|
public?: readonly string[];
|
|
/** Body size limit for JSON and urlencoded bodies. Defaults to "1mb". */
|
|
bodyLimit?: string;
|
|
/**
|
|
* Skips the built-in body parsers entirely, for a router that parses its own
|
|
* (multer, a raw body for signature checking, a proxied stream).
|
|
*/
|
|
rawBody?: boolean;
|
|
}
|
|
|
|
/**
|
|
* HTTP routes, served at /plugin-api/<id>/ by the main backend server.
|
|
*
|
|
* No plugin gets its own port or nginx block. Core runs auth, the actor, the
|
|
* enabled check, body limits and an error wrapper in front of every route, so
|
|
* a plugin's router only has to handle its own paths.
|
|
*/
|
|
export interface PluginHttp {
|
|
/**
|
|
* Returns an Express Router mounted at /plugin-api/<id>/. Call it once in
|
|
* activate and register routes on the result; it is unmounted automatically
|
|
* on deactivate.
|
|
*/
|
|
router: <T = unknown>(options?: PluginRouterOptions) => T;
|
|
}
|
|
|
|
/**
|
|
* Core RBAC, as a plugin sees it.
|
|
*
|
|
* Every method takes either a short name this plugin declares
|
|
* ("services.use"), which is prefixed with the plugin id, or a full id
|
|
* belonging to another plugin or a core group, which is used as given. That is
|
|
* what makes a cross-plugin check expressible without letting a plugin gate its
|
|
* own routes on someone else's authority.
|
|
*/
|
|
export interface PluginRbac {
|
|
/** Whether the user this call is running as holds `permission`. */
|
|
has: (permission: string) => Promise<boolean>;
|
|
|
|
/** Whether `userId` holds `permission`. */
|
|
hasFor: (userId: string, permission: string) => Promise<boolean>;
|
|
|
|
/**
|
|
* Middleware that rejects a request whose user lacks `permission`.
|
|
*
|
|
* A plugin may only require a permission it declares in
|
|
* contributes.permissions, so a route cannot gate on admin.users.manage and
|
|
* borrow someone else's authority.
|
|
*/
|
|
require: (permission: string) => PluginMiddleware;
|
|
}
|
|
|
|
export interface PluginWebSocketConnection {
|
|
/** The authenticated user for this socket. */
|
|
readonly userId: string;
|
|
/** The upgrade request, for query parameters and headers. */
|
|
readonly request: unknown;
|
|
/** The ws WebSocket. Binary frames and backpressure work as usual. */
|
|
readonly socket: unknown;
|
|
}
|
|
|
|
export type PluginWebSocketHandler = (
|
|
connection: PluginWebSocketConnection,
|
|
) => void | Promise<void>;
|
|
|
|
export interface PluginWebSocketOptions {
|
|
/**
|
|
* Serves the upgrade without core authentication. The handler gets an empty
|
|
* userId and must authenticate the socket itself, which is what a share-link
|
|
* guest or a token-in-query protocol needs.
|
|
*/
|
|
public?: boolean;
|
|
}
|
|
|
|
/**
|
|
* WebSocket routes, served at /plugin-ws/<id>/<path> through the main server's
|
|
* upgrade event. Every socket a plugin opened is closed on deactivate.
|
|
*/
|
|
export interface PluginWebSockets {
|
|
route: (
|
|
path: string,
|
|
handler: PluginWebSocketHandler,
|
|
options?: PluginWebSocketOptions,
|
|
) => void;
|
|
/**
|
|
* Hands the raw upgrade to the plugin, for a library that insists on owning
|
|
* its own WebSocket server (guacamole-lite attaches this way). Auth and the
|
|
* actor still run first.
|
|
*/
|
|
upgrade: (
|
|
path: string,
|
|
handler: (
|
|
request: unknown,
|
|
socket: unknown,
|
|
head: unknown,
|
|
userId: string,
|
|
) => void,
|
|
options?: PluginWebSocketOptions,
|
|
) => void;
|
|
}
|
|
|
|
/**
|
|
* Anything the runtime should undo when the plugin deactivates. Everything
|
|
* registered through ctx is tracked automatically; this is for resources a
|
|
* plugin creates itself, such as a server or an interval.
|
|
*/
|
|
export interface PluginDisposables {
|
|
add: (dispose: () => void | Promise<void>) => void;
|
|
}
|
|
|
|
/**
|
|
* The plugin's own settings, as declared in contributes.settings.
|
|
*
|
|
* Reading and writing a plugin's own settings needs no capability: the
|
|
* manifest already says which fields exist, and a plugin that could not read
|
|
* its own configuration would be useless. Only readCore, which reaches outside
|
|
* the plugin's namespace, is gated on settings:read-core.
|
|
*
|
|
* Values are validated against the declared field on every write, so a key the
|
|
* manifest never declared is rejected rather than stored. Secret fields are
|
|
* encrypted at rest and returned decrypted here; the HTTP surface never sends
|
|
* them to a browser.
|
|
*/
|
|
export interface PluginSettings {
|
|
/** Install-wide admin settings. */
|
|
get: <T = unknown>(key: string) => Promise<T | undefined>;
|
|
set: (key: string, value: unknown) => Promise<void>;
|
|
|
|
/** Per-user settings. */
|
|
getUser: <T = unknown>(userId: string, key: string) => Promise<T | undefined>;
|
|
setUser: (userId: string, key: string, value: unknown) => Promise<void>;
|
|
|
|
/** Per-host settings. */
|
|
getHost: <T = unknown>(
|
|
hostId: number | string,
|
|
key: string,
|
|
) => Promise<T | undefined>;
|
|
setHost: (
|
|
hostId: number | string,
|
|
key: string,
|
|
value: unknown,
|
|
) => Promise<void>;
|
|
|
|
/** Every value in one scope, with declared defaults merged in. */
|
|
getAll: (
|
|
scope: "admin" | "user" | "host",
|
|
scopeId?: string | number,
|
|
) => Promise<Record<string, unknown>>;
|
|
|
|
/**
|
|
* Fires when a key in this plugin's settings changes, from either the ctx
|
|
* API or the HTTP routes. Disposed automatically on deactivate.
|
|
*/
|
|
onChange: (key: string, listener: (value: unknown) => void) => () => void;
|
|
|
|
/**
|
|
* Reads a core server setting from a small documented allowlist.
|
|
* Requires the settings:read-core capability.
|
|
*/
|
|
readCore: (key: string) => Promise<string | null>;
|
|
}
|
|
|
|
/** A host's non-secret fields, for listing, tag matching and display. */
|
|
export interface PluginHostSummary {
|
|
id: number;
|
|
userId: string;
|
|
name: string | null;
|
|
ip: string;
|
|
port: number;
|
|
username: string;
|
|
/** Comma-separated, as ssh_data stores them. Split on "," to match tags. */
|
|
tags: string | null;
|
|
folder: string | null;
|
|
authType: string;
|
|
}
|
|
|
|
/**
|
|
* A host record wide enough for a plugin that creates or updates hosts on the
|
|
* user's behalf (import, discovery, sync), decrypted for that user. Unlike
|
|
* PluginHostSummary this carries the fields a plugin needs to set up a
|
|
* connectable host, not just what a list view shows. Still no secret auth
|
|
* material (password, key, vault tokens): a plugin that creates a host picks
|
|
* an authType and, for "credential", a credentialId it does not need to see
|
|
* the contents of.
|
|
*/
|
|
export interface PluginHostRecord {
|
|
id: number;
|
|
userId: string;
|
|
name: string | null;
|
|
ip: string;
|
|
port: number;
|
|
username: string;
|
|
authType: string;
|
|
credentialId?: number | null;
|
|
overrideCredentialUsername?: boolean | null;
|
|
connectionType?: string | null;
|
|
tags: string | null;
|
|
folder: string | null;
|
|
jumpHosts?: unknown;
|
|
enableSsh?: boolean | null;
|
|
enableRdp?: boolean | null;
|
|
enableTerminal?: boolean | null;
|
|
enableFileManager?: boolean | null;
|
|
enableTunnel?: boolean | null;
|
|
enableDocker?: boolean | null;
|
|
createdAt?: string | null;
|
|
updatedAt?: string | null;
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
/** Fields a plugin may set when creating a host it will own or manage. */
|
|
export type PluginHostCreateInput = Partial<
|
|
Omit<PluginHostRecord, "id" | "userId">
|
|
> & {
|
|
name: string;
|
|
ip: string;
|
|
port: number;
|
|
username: string;
|
|
authType: string;
|
|
};
|
|
|
|
/** Fields a plugin may change on a host it already created or was granted access to. */
|
|
export type PluginHostUpdateInput = Partial<
|
|
Omit<PluginHostRecord, "id" | "userId">
|
|
>;
|
|
|
|
/** The same shape canAccessHost returns internally, without secrets. */
|
|
export interface PluginHostAccess {
|
|
hasAccess: boolean;
|
|
isOwner: boolean;
|
|
isShared: boolean;
|
|
permissionLevel?: "connect" | "view" | "edit" | "manage";
|
|
expiresAt?: string | null;
|
|
}
|
|
|
|
export type PluginHostShareLevel = "connect" | "view" | "edit" | "manage";
|
|
|
|
export interface PluginShareTarget {
|
|
type: "user" | "role";
|
|
id: string | number;
|
|
}
|
|
|
|
export interface PluginHostShareResult {
|
|
hostId: number;
|
|
shared: boolean;
|
|
reason?: string;
|
|
}
|
|
|
|
/** A user or role a plugin can offer as a share target. No secrets, no roles' permission lists. */
|
|
export interface PluginShareableUser {
|
|
id: string;
|
|
username: string;
|
|
}
|
|
|
|
export interface PluginShareableRole {
|
|
id: number;
|
|
name: string;
|
|
displayName: string | null;
|
|
}
|
|
|
|
/**
|
|
* Hosts the acting user can see, and the RBAC + sharing operations a plugin
|
|
* needs to build a feature (like fleets) on top of hosts it does not own.
|
|
*
|
|
* list/get/checkAccess need hosts:read. share, and the user/role pickers a
|
|
* share UI needs, need hosts:write: sharing changes who can reach a host,
|
|
* which is a write on that host's access, even though the plugin owns
|
|
* neither the host nor the grant.
|
|
*/
|
|
export interface PluginHosts {
|
|
/** Every host the acting user owns or was shared, without secrets. */
|
|
list: () => Promise<PluginHostSummary[]>;
|
|
/** One host the acting user can see, or null if it does not exist or they cannot. */
|
|
get: (hostId: number) => Promise<PluginHostSummary | null>;
|
|
/** The same access rule ctx.ssh.connect enforces, but naming the level, not just yes/no. */
|
|
checkAccess: (
|
|
hostId: number,
|
|
level: PluginHostShareLevel,
|
|
) => Promise<PluginHostAccess>;
|
|
/**
|
|
* Creates a host owned by the acting user, encrypted the same way the host
|
|
* editor's own create route does it. Needs hosts:write. For a plugin that
|
|
* imports or discovers hosts on the user's behalf (B5's proxmox discovery).
|
|
*/
|
|
create: (host: PluginHostCreateInput) => Promise<PluginHostRecord>;
|
|
/**
|
|
* Updates a host the acting user owns. Refuses a host it does not own
|
|
* (sharing a write onto someone else's host goes through share(), not
|
|
* update()). Needs hosts:write.
|
|
*/
|
|
update: (
|
|
hostId: number,
|
|
patch: PluginHostUpdateInput,
|
|
) => Promise<PluginHostRecord | null>;
|
|
/**
|
|
* Every host the acting user owns, decrypted and wide (PluginHostRecord,
|
|
* not the narrow list() summary). For a plugin that scans its own hosts in
|
|
* the background (auto-sync) and needs fields list() does not carry, such
|
|
* as credentialId or jumpHosts. Needs hosts:write, matching create/update:
|
|
* this is the same "full host detail" surface a create/update caller needs
|
|
* to read back, not a wider read grant than hosts:read gives via list/get.
|
|
*/
|
|
listOwned: () => Promise<PluginHostRecord[]>;
|
|
/**
|
|
* Grants access to a host the caller manages, snapshotting shared secrets
|
|
* for each target the same way the host editor's own share action does.
|
|
* Refuses a host the caller does not hold "manage" on.
|
|
*/
|
|
share: (
|
|
hostId: number,
|
|
targets: PluginShareTarget[],
|
|
permissionLevel: PluginHostShareLevel,
|
|
durationHours?: number,
|
|
) => Promise<PluginHostShareResult>;
|
|
/** Users the caller may pick as a share target, for building a picker UI. */
|
|
listUsers: () => Promise<PluginShareableUser[]>;
|
|
/** Non-system roles the caller may pick as a share target. */
|
|
listRoles: () => Promise<PluginShareableRole[]>;
|
|
}
|
|
|
|
/**
|
|
* A host as the SSH pipeline reads it: resolved by core, with secrets filled
|
|
* in for the acting user. Plugins get one from core and hand it back; they
|
|
* should not build one from scratch.
|
|
*/
|
|
export interface PluginSshHost {
|
|
id: number;
|
|
ip: string;
|
|
port: number;
|
|
username: string;
|
|
userId?: string | null;
|
|
authType?: string | null;
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
/** Picks keepalive and timeout defaults. "plugin" is the generic one. */
|
|
export type PluginSshPurpose =
|
|
| "plugin"
|
|
| "docker"
|
|
| "docker-console"
|
|
| "metrics"
|
|
| "proxmox"
|
|
| "fleet"
|
|
| "remote-desktop"
|
|
| "tunnel";
|
|
|
|
export type PluginSshAuthOutcome =
|
|
| { status: "ready" }
|
|
| {
|
|
status: "interaction-required";
|
|
/** Transports send "<interaction>_auth_required", e.g. "opkssh". */
|
|
interaction: string;
|
|
message: string;
|
|
/** Extra boolean HTTP transports put in their 401 body. */
|
|
flag?: string;
|
|
}
|
|
| {
|
|
status: "error";
|
|
code:
|
|
| "missing-secret"
|
|
| "invalid-key"
|
|
| "passphrase-required"
|
|
| "provider-missing"
|
|
| "failed";
|
|
message: string;
|
|
}
|
|
| {
|
|
/** Try once more with these config changes. */
|
|
status: "retry";
|
|
patch: Record<string, unknown>;
|
|
message: string;
|
|
};
|
|
|
|
export type PluginSshBannerDecision =
|
|
| {
|
|
/** Hold the connect timeout open and wait for the out-of-band step. */
|
|
action: "hold";
|
|
timeoutMs: number;
|
|
message: string;
|
|
details?: Record<string, unknown>;
|
|
}
|
|
| {
|
|
/** The out-of-band step finished; resume the normal connect timeout. */
|
|
action: "release";
|
|
details?: Record<string, unknown>;
|
|
};
|
|
|
|
export interface PluginKeyboardInteractivePrompt {
|
|
prompt: string;
|
|
echo?: boolean;
|
|
}
|
|
|
|
export type PluginKeyboardInteractiveDecision =
|
|
| { kind: "auto"; responses: string[] }
|
|
| {
|
|
kind: "warpgate";
|
|
url: string | null;
|
|
securityKey: string;
|
|
instructions: string;
|
|
}
|
|
| { kind: "totp"; promptIndex: number }
|
|
| { kind: "input"; promptIndex: number; isPush: boolean };
|
|
|
|
export type PluginSshPromptRequest =
|
|
| { kind: "totp"; prompt: string; retry: boolean }
|
|
| { kind: "input"; prompt: string; echo: boolean; isPush: boolean }
|
|
| {
|
|
kind: "warpgate";
|
|
url: string | null;
|
|
securityKey: string;
|
|
instructions: string;
|
|
};
|
|
|
|
/** Answers keyboard-interactive prompts. Resolve null to give up. */
|
|
export interface PluginSshPromptChannel {
|
|
ask: (request: PluginSshPromptRequest) => Promise<string | null>;
|
|
}
|
|
|
|
export interface PluginSshConnectOptions {
|
|
purpose?: PluginSshPurpose;
|
|
/** Overall time to reach "ready". Defaults to 30s. */
|
|
timeoutMs?: number;
|
|
/** Without one, the stored password answers password prompts. */
|
|
prompt?: PluginSshPromptChannel;
|
|
/** Config fields to force, e.g. a longer readyTimeout. */
|
|
overrides?: Record<string, unknown>;
|
|
}
|
|
|
|
export interface PluginSshConnection<Client = unknown> {
|
|
/** An ssh2 Client, already "ready". */
|
|
client: Client;
|
|
jumpClient: Client | null;
|
|
/** The host as core resolved it: ip, port, username, name and the rest. */
|
|
host: PluginSshHost;
|
|
/** Ends the connection and its jump chain. Also runs on deactivate. */
|
|
dispose: () => void;
|
|
}
|
|
|
|
/**
|
|
* SSH through core's one connect pipeline: host resolution, auth providers,
|
|
* jump hosts, proxies, host key checks, keepalive and port knocking.
|
|
*
|
|
* Every call needs ssh:connect and credentials:use, runs as the acting user
|
|
* (or the host's owner for background work on a resolved host) and writes an
|
|
* audit line per new connection. Connections still open on deactivate are
|
|
* closed.
|
|
*/
|
|
export interface PluginSsh {
|
|
connect: <Client = unknown>(
|
|
host: number | PluginSshHost,
|
|
options?: PluginSshConnectOptions,
|
|
) => Promise<PluginSshConnection<Client>>;
|
|
|
|
/** Borrows a pooled connection. The pool key is `<pool>:<owner>:<address>`. */
|
|
withConnection: <T, Client = unknown>(
|
|
host: number | PluginSshHost,
|
|
options: PluginSshConnectOptions & { pool: string },
|
|
fn: (client: Client) => Promise<T>,
|
|
) => Promise<T>;
|
|
|
|
/**
|
|
* A ready client at the end of a jump host chain, each hop resolved and
|
|
* authenticated through the pipeline. For forwarding to something that is
|
|
* not an SSH server (an RDP port behind a bastion).
|
|
*/
|
|
jumpChain: <Client = unknown>(
|
|
jumpHosts: Array<{ hostId: number }>,
|
|
options?: {
|
|
/** Background work: resolve the hops as this host's owner. */
|
|
forHost?: PluginSshHost;
|
|
},
|
|
) => Promise<PluginSshConnection<Client>>;
|
|
|
|
/** The pool key withConnection would use, for clearing it. */
|
|
poolKey: (pool: string, host: PluginSshHost) => string;
|
|
|
|
/**
|
|
* Lower level: fills an ssh2 config for a client the plugin drives itself,
|
|
* for transports with their own prompt flow. Never connects.
|
|
*/
|
|
prepare: (
|
|
host: PluginSshHost,
|
|
options: {
|
|
purpose?: PluginSshPurpose;
|
|
client: unknown;
|
|
/** Server-side host id when it differs from host.id. */
|
|
serverHostId?: number;
|
|
log?: (level: "info" | "warning" | "error", message: string) => void;
|
|
},
|
|
) => Promise<{
|
|
config: Record<string, unknown>;
|
|
outcome: PluginSshAuthOutcome;
|
|
}>;
|
|
|
|
/** Lower level: port knocking, jump hosts or proxy; sets config.sock. */
|
|
openTransport: (
|
|
host: PluginSshHost,
|
|
config: Record<string, unknown>,
|
|
) => Promise<{ jumpClient: unknown | null; via: string }>;
|
|
|
|
/** What a keyboard-interactive round is. Pure; no capability needed. */
|
|
classifyKeyboardInteractive: (
|
|
round: {
|
|
name: string;
|
|
instructions: string;
|
|
prompts: PluginKeyboardInteractivePrompt[];
|
|
},
|
|
host: PluginSshHost,
|
|
) => PluginKeyboardInteractiveDecision;
|
|
|
|
/** Stored password for password prompts, empty for the rest. */
|
|
autoResponses: (
|
|
prompts: PluginKeyboardInteractivePrompt[],
|
|
password: string | null | undefined,
|
|
) => string[];
|
|
|
|
/** Whether an auth type carries a secret (drives "auth_required" flows). */
|
|
requiresSecret: (authType: string) => boolean;
|
|
|
|
/** Whether an auth type can connect unattended, for polling. */
|
|
supportsBackground: (authType: string) => boolean;
|
|
}
|
|
|
|
export interface PluginSshAuthEnv {
|
|
/** The ssh2 Client about to connect; certificate auth patches it. */
|
|
client: unknown;
|
|
userId: string;
|
|
hostId: number;
|
|
purpose: string;
|
|
interactive: boolean;
|
|
log: (level: "info" | "warning" | "error", message: string) => void;
|
|
}
|
|
|
|
/**
|
|
* An SSH auth type. Its `type` is what ssh_data.auth_type stores, and it must
|
|
* be listed in the manifest's contributes.auth.sshAuthTypes.
|
|
*/
|
|
export interface PluginSshAuthProvider {
|
|
type: string;
|
|
labelKey: string;
|
|
descriptionKey?: string;
|
|
/** Host and credential editor fields, in the settings field schema. */
|
|
fields?: PluginSettingsField[];
|
|
/** Also offered as a stored credential type. */
|
|
credentialType?: boolean;
|
|
/** Needs a browser sign-in or a person at the keyboard. */
|
|
needsUserInteraction?: boolean;
|
|
/** Carries a secret, so a shared host needs one resolved per recipient. */
|
|
requiresSecret?: boolean;
|
|
/** Can connect unattended. Defaults to true. */
|
|
supportsBackground?: boolean;
|
|
/** Interaction name its outcomes use, for startInteraction routing. */
|
|
interaction?: string;
|
|
connectOptions?: (
|
|
host: PluginSshHost,
|
|
purpose: string,
|
|
) => Record<string, unknown>;
|
|
/** Fills the ssh2 config for this host. Never connects. */
|
|
prepare: (
|
|
config: Record<string, unknown>,
|
|
host: PluginSshHost,
|
|
env: PluginSshAuthEnv,
|
|
) => Promise<PluginSshAuthOutcome>;
|
|
/** Claims keyboard-interactive rounds for hosts of this type. */
|
|
onKeyboardInteractive?: (
|
|
round: {
|
|
name: string;
|
|
instructions: string;
|
|
prompts: PluginKeyboardInteractivePrompt[];
|
|
},
|
|
host: PluginSshHost,
|
|
) => PluginKeyboardInteractiveDecision | null;
|
|
/** Synchronous: decide on a retry before the socket closes. */
|
|
onAuthFailed?: (
|
|
host: PluginSshHost,
|
|
env: PluginSshAuthEnv,
|
|
context: {
|
|
error: Error;
|
|
retries: number;
|
|
canRetry: boolean;
|
|
methodNotAvailable: boolean;
|
|
},
|
|
) => PluginSshAuthOutcome | undefined;
|
|
/**
|
|
* Claims an ssh2 "banner" event during the handshake, for a server that
|
|
* holds the connection open pending an out-of-band step (Tailscale SSH
|
|
* check mode). A transport that can show this sends `<type>_check_required`
|
|
* / `<type>_check_completed` over its socket, with `details` merged in.
|
|
* Return nothing to leave the banner unhandled.
|
|
*/
|
|
onBanner?: (
|
|
banner: string,
|
|
host: PluginSshHost,
|
|
env: PluginSshAuthEnv,
|
|
) => PluginSshBannerDecision | undefined;
|
|
/** Starts the browser step behind an interaction-required outcome. */
|
|
startInteraction?: (request: {
|
|
userId: string;
|
|
hostId: number;
|
|
host: { name?: string | null; ip: string; username: string };
|
|
socket: unknown;
|
|
requestOrigin: string;
|
|
payload: Record<string, unknown>;
|
|
}) => Promise<void>;
|
|
}
|
|
|
|
/**
|
|
* Who a login method says the user is. Core finds or provisions the user,
|
|
* runs second factors and issues the session; a method never does.
|
|
*/
|
|
export type PluginVerifiedIdentity =
|
|
| {
|
|
kind: "user";
|
|
userId: string;
|
|
/** The method already proved a second factor (a verified passkey). */
|
|
mfaSatisfied?: boolean;
|
|
/** Password logins unlock the user's data key with it. */
|
|
password?: string;
|
|
/** Redirect methods: where the browser goes back to. */
|
|
returnTo?: string;
|
|
rememberMe?: boolean;
|
|
}
|
|
| {
|
|
kind: "external";
|
|
/** Stable provider id, e.g. an SSO provider row id. */
|
|
provider: string;
|
|
/** The provider's id for the user. At most 255 characters. */
|
|
subject: string;
|
|
email?: string | null;
|
|
name?: string | null;
|
|
groups?: string[];
|
|
/** Provider says the user is an admin (admin group, LDAP group). */
|
|
isAdmin?: boolean;
|
|
/** Allowed-users list to check before provisioning or signing in. */
|
|
allowedUsers?: string | null;
|
|
mfaSatisfied?: boolean;
|
|
/** Redirect methods: where the browser goes back to. */
|
|
returnTo?: string;
|
|
rememberMe?: boolean;
|
|
};
|
|
|
|
/** Request shape a login method sees. Structural, like PluginRequestLike. */
|
|
export interface PluginLoginRequest {
|
|
body: Record<string, unknown>;
|
|
query: Record<string, unknown>;
|
|
headers: Record<string, unknown>;
|
|
ip?: string;
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
export interface PluginLoginInstance {
|
|
/** Passed back to start/verify as instanceId, e.g. an SSO provider id. */
|
|
id: string;
|
|
label: string;
|
|
enabled: boolean;
|
|
}
|
|
|
|
export interface PluginLoginMethod {
|
|
id: string;
|
|
labelKey: string;
|
|
icon?: string;
|
|
kind: "redirect" | "form";
|
|
/**
|
|
* True for a method that authenticates against something other than a
|
|
* password or key this server holds: SSO, an external directory, a
|
|
* federated identity provider. Governs whether the "ask for a second
|
|
* factor after external logins" admin setting applies to it. Leave unset
|
|
* for a local method (password, a passkey, a plugin's own local form).
|
|
*/
|
|
external?: boolean;
|
|
/** Enabled instances, shown on the login screen. No secrets. */
|
|
describe?: () => Promise<PluginLoginInstance[]>;
|
|
/** Redirect methods: where to send the browser. */
|
|
start?: (
|
|
request: PluginLoginRequest,
|
|
instanceId: string | null,
|
|
) => Promise<{ redirectUrl: string }>;
|
|
/** Redirect methods: the provider's callback. */
|
|
callback?: (request: PluginLoginRequest) => Promise<PluginVerifiedIdentity>;
|
|
/** Form methods: check what the user typed. */
|
|
verify?: (
|
|
request: PluginLoginRequest,
|
|
instanceId: string | null,
|
|
) => Promise<PluginVerifiedIdentity>;
|
|
}
|
|
|
|
export interface PluginSecondFactor {
|
|
id: string;
|
|
labelKey: string;
|
|
isEnrolled: (userId: string) => Promise<boolean>;
|
|
/** Anything the client needs before the user answers. */
|
|
challenge?: (userId: string) => Promise<unknown>;
|
|
/**
|
|
* True when the answer is right. Return an object to refuse with a specific
|
|
* message, for example when the factor had to be reset.
|
|
*/
|
|
verify: (
|
|
userId: string,
|
|
body: Record<string, unknown>,
|
|
) => Promise<boolean | { ok: false; error: string; code?: string }>;
|
|
/** Removes the user's enrolment. Called by the admin reset. */
|
|
reset?: (userId: string) => Promise<void>;
|
|
}
|
|
|
|
/**
|
|
* Login methods, second factors and SSH auth types. Every register call needs
|
|
* auth:provide and a matching contributes.auth entry, and is undone on
|
|
* deactivate. Core owns sessions: a method returns an identity and core does
|
|
* the rest.
|
|
*/
|
|
export interface PluginAuth {
|
|
registerSshAuthProvider: (provider: PluginSshAuthProvider) => void;
|
|
registerLoginMethod: (method: PluginLoginMethod) => void;
|
|
registerSecondFactor: (factor: PluginSecondFactor) => void;
|
|
/**
|
|
* Marks a user as enrolled in one of this plugin's factors. Core keeps the
|
|
* row even when the plugin is gone, so it can refuse the login instead of
|
|
* skipping the factor.
|
|
*/
|
|
recordEnrollment: (userId: string, factorId: string) => Promise<void>;
|
|
removeEnrollment: (userId: string, factorId: string) => Promise<void>;
|
|
}
|
|
|
|
export interface PluginContext {
|
|
readonly pluginId: string;
|
|
readonly manifest: PluginManifest;
|
|
readonly log: PluginLogger;
|
|
readonly events: PluginEvents;
|
|
readonly kv: PluginKeyValue;
|
|
readonly db: PluginDatabase;
|
|
readonly sync: PluginSync;
|
|
readonly registry: PluginRegistry;
|
|
readonly services: PluginServices;
|
|
readonly secrets: PluginSecrets;
|
|
readonly http: PluginHttp;
|
|
readonly ws: PluginWebSockets;
|
|
readonly rbac: PluginRbac;
|
|
readonly settings: PluginSettings;
|
|
readonly disposables: PluginDisposables;
|
|
/** Hosts the actor can see, and host-sharing operations. Needs hosts:read / hosts:write. */
|
|
readonly hosts: PluginHosts;
|
|
/** SSH through core's connect pipeline. Needs ssh:connect and credentials:use. */
|
|
readonly ssh: PluginSsh;
|
|
/** Login methods, second factors and SSH auth types. Needs auth:provide. */
|
|
readonly auth: PluginAuth;
|
|
|
|
/**
|
|
* Runs `fn` with `userId` as the acting user, for background work that has
|
|
* no request behind it. Always audited, and every core API inside still
|
|
* applies that user's RBAC.
|
|
*
|
|
* This is the only way a plugin can name a user. Nothing else trusts a user
|
|
* id that came from plugin code.
|
|
*/
|
|
asUser: <T>(userId: string, fn: () => Promise<T> | T) => Promise<T>;
|
|
|
|
/** The acting user for the current call, when there is one. */
|
|
currentActor: () => string | undefined;
|
|
}
|
|
|
|
export type PluginActivate = (ctx: PluginContext) => void | Promise<void>;
|
|
export type PluginDeactivate = () => void | Promise<void>;
|
|
|
|
export interface PluginModule {
|
|
activate: PluginActivate;
|
|
deactivate?: PluginDeactivate;
|
|
}
|
|
|
|
/**
|
|
* Identity helper that gives a plugin entry its types without importing the
|
|
* runtime. Everything a plugin creates belongs inside activate: the module is
|
|
* cached by the ESM loader, so disable-then-enable re-runs activate on the
|
|
* same module object.
|
|
*/
|
|
export function definePlugin(plugin: PluginModule): PluginModule {
|
|
return plugin;
|
|
}
|
|
|
|
export type { PluginManifest } from "./manifest.js";
|
|
export type { PluginTableDefinition } from "./db.js";
|
|
export type {
|
|
PluginSettingsField,
|
|
PluginSettingsContribution,
|
|
PluginSettingsScope,
|
|
} from "./manifest.js";
|