mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
* feat: shell controller, TabStage, layout and theme plugins with repair view * feat: move the classic layout into its own bundled plugin * feat: move color themes into a bundled themes plugin * fix: plugin tests and type checks work with a linked sdk * fix: home tab, layout switch and missing layout fallbacks for looks * chore: add classic layout and themes to the readme * chore: drop example plugin links from the plugin guide * feat: plugin filters by type, paged browse, hide plugins needing newer termix * feat: move desktop, status, import, audit and branding features into plugins * feat: host lock api so a plugin can load hosts from a config file (#1581) * chore: add hosts config to the plugin table * feat: login policy hook for plugins and optional https redirect (#1584) * chore: add login security to the plugin table * feat: api key socket tickets so a master can open remote sessions (#1654) * feat: sync moves to a plugin, desktop gets multiple accounts (#1580, #1654) * chore: add sync to the plugin table * feat: move the sync screens and wizard out of core into the plugin (#1580) * fix: add a neutral linked desktop hook for plugins (#1580) * fix: show the linked account's name and admin flag in the app (#1580) * feat: ssh transport providers, core ssh relay and plugin folders api * feat: support FIDO2 security keys through ssh agent * feat: add companion panels beside host tabs * feat: mTLS client certificates for desktop server links * feat: native web view for plugins in the desktop app * fix: detect the desktop link window by Electron's default user agent Fixes #1675 * fix: changelog for desktop link fix * fix: keep text primary keys in the plugin data export * fix: ship sharp binaries for Windows arm64 builds * feat: client cert on the sync link types (sdk 1.0.18) * chore: update translations * feat: themed title bar for the desktop app (#1690) * chore: changelog for the themed title bar * fix: draw the title bar in the sidebar color * feat: per host layout for the terminal panel bar * chore: sdk 1.0.19 for the panel bar layout * chore: bundle all 43 plugins for 26.10.3 * chore: update translations --------- Co-authored-by: SirHephaistos <60616553+SirHephaistos@users.noreply.github.com> Co-authored-by: Giovanny Aranda <garanda21@gmail.com>
3168 lines
106 KiB
TypeScript
3168 lines
106 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 { HostSshOptions } from "./ssh-options.js";
|
|
export type { HostSshOptions } from "./ssh-options.js";
|
|
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[]>;
|
|
}
|
|
|
|
/**
|
|
* A folder on disk this plugin owns, for state too large for ctx.kv (session
|
|
* recordings, uploaded files). Requires files:own.
|
|
*/
|
|
export interface PluginFiles {
|
|
/**
|
|
* The plugin's own folder under DATA_DIR, created if it does not exist yet.
|
|
* Always the same path for this plugin; a plugin lays out its own
|
|
* subdirectories underneath it.
|
|
*/
|
|
dataDir: () => 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 (behind db:core-refs) so a plugin can
|
|
* join against them. In-process code could reach around
|
|
* all of this. 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>;
|
|
/**
|
|
* Core tables a plugin may join against: users, hosts, roles, userRoles.
|
|
* Needs db:core-refs. Treat them as read-only.
|
|
*/
|
|
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: (options?: {
|
|
/**
|
|
* Mark the database dirty and let the debounced save pick it up, rather
|
|
* than saving now. For frequent, low-value writes such as samples, where
|
|
* losing a couple of seconds on a crash is fine.
|
|
*/
|
|
lazy?: boolean;
|
|
}) => 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 {
|
|
/**
|
|
* Where a local numeric id lives. A plain column ("credentialId"), or a
|
|
* path into a JSON column: "jumpHosts[].hostId" walks every item of the
|
|
* jumpHosts array, "layout.panes[].hostId" walks nested objects. A JSON
|
|
* column may be stored as a string or as parsed JSON.
|
|
*/
|
|
field: string;
|
|
/**
|
|
* Wire field holding the portable id, e.g. "credentialSyncId". Only for a
|
|
* plain column; a JSON path is rewritten in place.
|
|
*/
|
|
syncField?: string;
|
|
/** The entity the id points at. */
|
|
entityType: string;
|
|
/** How a JSON path stores the local id. Defaults to "number". */
|
|
idType?: "number" | "string";
|
|
}
|
|
|
|
export interface SyncEntityPermissions {
|
|
/** Checked before a pushed row that does not exist yet is written. */
|
|
create?: string;
|
|
/** Checked before a pushed change to an existing row is written. */
|
|
update?: string;
|
|
/** Checked before a pushed delete runs. */
|
|
delete?: string;
|
|
}
|
|
|
|
export interface SyncWriteEvent {
|
|
/** The local id of the row that was written. */
|
|
id: number | null;
|
|
userId: string;
|
|
/** The row as it arrived, before references were resolved. */
|
|
wire: SyncRow;
|
|
created: boolean;
|
|
/** Another entity's syncId to its local id here, for ids serialize put on the wire. */
|
|
resolveId?: (entityType: string, syncId: string) => Promise<number | null>;
|
|
}
|
|
|
|
export interface SyncEntityRegistration {
|
|
/** Stable wire name. Never change it: records on both sides 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 stored encrypted with the owner's data key. Plaintext on the wire. */
|
|
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 stay on this side: never sent, never overwritten by an
|
|
* inbound row, and ignored when deciding whether a row changed.
|
|
*/
|
|
readOnlyFields?: readonly string[];
|
|
/** One row per user rather than many, keyed on the owner. */
|
|
singleton?: boolean;
|
|
/**
|
|
* Names core uses when its own rows point at this entity without knowing
|
|
* which plugin provides it. Nothing in core uses one today.
|
|
*/
|
|
answersTo?: readonly string[];
|
|
/**
|
|
* 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;
|
|
/** RBAC permissions a pushed change needs on the server. */
|
|
permissions?: SyncEntityPermissions;
|
|
/**
|
|
* Runs after `references` on the way out, for anything they cannot
|
|
* express. Gets the row with references already translated.
|
|
*/
|
|
serialize?: (
|
|
row: SyncRow,
|
|
resolveSyncId: (entityType: string, id: number) => Promise<string | null>,
|
|
) => Promise<SyncRow>;
|
|
/** Runs after `references` on the way in. */
|
|
deserialize?: (
|
|
row: SyncRow,
|
|
resolveId: (entityType: string, syncId: string) => Promise<number | null>,
|
|
) => Promise<SyncRow>;
|
|
/** Runs after a synced row was written on this side. */
|
|
afterWrite?: (event: SyncWriteEvent) => Promise<void>;
|
|
/**
|
|
* Replaces the plain delete of a synced row, for rows with cleanup of their
|
|
* own. Gets the stored row.
|
|
*/
|
|
remove?: (row: SyncRow, userId: string) => Promise<void>;
|
|
}
|
|
|
|
/**
|
|
* Adds an entity to sync between a linked desktop and its server.
|
|
*
|
|
* Deletes need nothing extra: core notices a registered row is gone and
|
|
* sends the delete itself.
|
|
*/
|
|
export interface PluginSync {
|
|
registerEntity: (entity: SyncEntityRegistration) => void;
|
|
/** Needs sync:provide. Takes over /sync/v2 and /sync/link. Removed on deactivate. */
|
|
provide: (provider: PluginSyncProvider) => () => void;
|
|
/** Needs sync:provide. The replica store, change feed and link registry. */
|
|
readonly host: PluginSyncHost;
|
|
}
|
|
|
|
/**
|
|
* The plugin that owns server and desktop sync. Core forwards every request
|
|
* under /sync to `handle` (an Express style handler) and answers 404 when no
|
|
* plugin provides it. Call `next()` to let core answer instead.
|
|
*/
|
|
export interface PluginSyncProvider {
|
|
handle: (
|
|
req: unknown,
|
|
res: unknown,
|
|
next: (error?: unknown) => void,
|
|
) => void | Promise<void>;
|
|
}
|
|
|
|
/** A kind of data that syncs, as the sync provider sees it. */
|
|
export interface SyncEntityInfo {
|
|
type: string;
|
|
owner: string;
|
|
order: number;
|
|
readOnly: boolean;
|
|
/** Whether the row would show in a load even if it exists. */
|
|
covers?: (syncId: string) => boolean;
|
|
}
|
|
|
|
/** A row ready for the wire, with its content hash. */
|
|
export interface SyncWireRow {
|
|
syncId: string;
|
|
wire: SyncRow;
|
|
hash: string;
|
|
}
|
|
|
|
export interface SyncRecordInfo {
|
|
entityType: string;
|
|
syncId: string;
|
|
revision: number;
|
|
hash: string | null;
|
|
deleted: boolean;
|
|
error: string | null;
|
|
errorHash: string | null;
|
|
seq: number;
|
|
}
|
|
|
|
export interface SyncConflictInfo {
|
|
id: number;
|
|
entityType: string;
|
|
syncId: string;
|
|
localRow: string;
|
|
createdAt: string;
|
|
}
|
|
|
|
export interface SyncAccountInfo {
|
|
id: string;
|
|
username: string;
|
|
isAdmin: boolean;
|
|
active: boolean;
|
|
}
|
|
|
|
export interface SyncProxyHeader {
|
|
name: string;
|
|
value: string;
|
|
}
|
|
|
|
export interface SyncBasicAuth {
|
|
username: string;
|
|
password: string;
|
|
}
|
|
|
|
/** A client certificate for a server that asks for mutual TLS. */
|
|
export interface SyncClientCert {
|
|
/** Base64 of the PFX/P12 file. */
|
|
pfx: string;
|
|
passphrase: string;
|
|
/** The file name, to show which certificate is saved. */
|
|
name: string | null;
|
|
}
|
|
|
|
/** Extra per server connection options. Kept open so new ones need no migration. */
|
|
export type SyncLinkOptions = Record<string, unknown>;
|
|
|
|
/** One server a desktop is linked to, with a replica of its data. */
|
|
export interface SyncLinkInfo {
|
|
id: number;
|
|
/** The local user the replica belongs to. */
|
|
userId: string;
|
|
serverUrl: string;
|
|
serverName: string | null;
|
|
serverVersion: string | null;
|
|
sessionToken: string | null;
|
|
customHeaders: SyncProxyHeader[];
|
|
basicAuth: SyncBasicAuth | null;
|
|
allowInvalidCertificate: boolean;
|
|
clientCert: SyncClientCert | null;
|
|
remoteUserId: string | null;
|
|
remoteUsername: string | null;
|
|
account: {
|
|
remoteUserId?: string;
|
|
username?: string;
|
|
isAdmin?: boolean;
|
|
roles?: string[];
|
|
permissions?: string[];
|
|
} | null;
|
|
disabledTypes: string[];
|
|
/** Extra connection options for this server, kept open for new ones. */
|
|
options: SyncLinkOptions;
|
|
knownTypes: string[];
|
|
cursor: number;
|
|
status: "idle" | "syncing" | "offline" | "error" | "signed_out";
|
|
lastError: string | null;
|
|
linkedAt: string;
|
|
lastSyncAt: string | null;
|
|
}
|
|
|
|
export interface SyncLinkCreate {
|
|
userId: string;
|
|
serverUrl: string;
|
|
serverName?: string | null;
|
|
serverVersion?: string | null;
|
|
sessionToken: string;
|
|
customHeaders?: SyncProxyHeader[];
|
|
basicAuth?: SyncBasicAuth | null;
|
|
allowInvalidCertificate?: boolean;
|
|
clientCert?: SyncClientCert | null;
|
|
remoteUserId?: string | null;
|
|
remoteUsername?: string | null;
|
|
}
|
|
|
|
export type SyncLinkPatch = Partial<
|
|
Pick<
|
|
SyncLinkInfo,
|
|
| "serverName"
|
|
| "serverVersion"
|
|
| "sessionToken"
|
|
| "customHeaders"
|
|
| "basicAuth"
|
|
| "allowInvalidCertificate"
|
|
| "clientCert"
|
|
| "remoteUserId"
|
|
| "remoteUsername"
|
|
| "account"
|
|
| "disabledTypes"
|
|
| "options"
|
|
| "knownTypes"
|
|
| "cursor"
|
|
| "status"
|
|
| "lastError"
|
|
| "lastSyncAt"
|
|
>
|
|
>;
|
|
|
|
export type SyncRemoteErrorKind =
|
|
| "offline"
|
|
| "tls"
|
|
| "client_cert"
|
|
| "proxy"
|
|
| "basic_auth"
|
|
| "signed_out"
|
|
| "server";
|
|
|
|
export interface SyncRemoteError extends Error {
|
|
readonly kind: SyncRemoteErrorKind;
|
|
readonly status?: number;
|
|
}
|
|
|
|
export interface SyncRemoteTarget {
|
|
serverUrl: string;
|
|
sessionToken?: string | null;
|
|
customHeaders?: SyncProxyHeader[];
|
|
basicAuth?: SyncBasicAuth | null;
|
|
allowInvalidCertificate?: boolean;
|
|
clientCert?: SyncClientCert | null;
|
|
}
|
|
|
|
export interface SyncRemoteRequest {
|
|
method?: string;
|
|
body?: unknown;
|
|
timeoutMs?: number;
|
|
signal?: AbortSignal;
|
|
}
|
|
|
|
export interface SyncPushOp {
|
|
entityType: string;
|
|
syncId: string;
|
|
baseRevision: number;
|
|
op: "upsert" | "delete";
|
|
row?: SyncRow;
|
|
}
|
|
|
|
export type SyncPushResult =
|
|
| { syncId: string; entityType: string; status: "ok"; revision: number }
|
|
| {
|
|
syncId: string;
|
|
entityType: string;
|
|
status: "conflict";
|
|
revision: number;
|
|
deleted: boolean;
|
|
row: SyncRow | null;
|
|
}
|
|
| { syncId: string; entityType: string; status: "rejected"; reason: string };
|
|
|
|
export interface SyncRemotePlugin {
|
|
id: string;
|
|
name: string;
|
|
version: string;
|
|
source: string;
|
|
artifact: boolean;
|
|
enabled: boolean;
|
|
active: boolean;
|
|
desktop: string;
|
|
syncEntities: string[];
|
|
granted: string[];
|
|
}
|
|
|
|
/**
|
|
* What the sync provider gets from core: the replica store, the change feed,
|
|
* the linked server registry, sessions and a few helpers. Core keeps all of
|
|
* it neutral; the provider decides when and how to use it. Needs sync:provide.
|
|
*/
|
|
export interface PluginSyncHost {
|
|
/** True on the desktop app's own backend. */
|
|
readonly embedded: boolean;
|
|
info: { appName: () => Promise<string>; version: () => string };
|
|
|
|
entities: {
|
|
list: () => SyncEntityInfo[];
|
|
get: (type: string) => SyncEntityInfo | undefined;
|
|
/** Registers core's own entities once. */
|
|
ensureCore: () => Promise<void>;
|
|
onChanged: (listener: () => void) => () => void;
|
|
onLocalWrite: (listener: () => void) => () => void;
|
|
};
|
|
|
|
/** Rows and bookkeeping of one local user's replica. */
|
|
replica: {
|
|
resolvers: (userId: string, onMissing?: () => void) => unknown;
|
|
loadRows: (
|
|
type: string,
|
|
userId: string,
|
|
resolvers: unknown,
|
|
) => Promise<{ rows: Map<string, SyncWireRow>; failed: Set<string> }>;
|
|
loadRow: (
|
|
type: string,
|
|
userId: string,
|
|
syncId: string,
|
|
resolvers: unknown,
|
|
) => Promise<SyncWireRow | null>;
|
|
writeRow: (
|
|
type: string,
|
|
userId: string,
|
|
wire: SyncRow,
|
|
resolvers: unknown,
|
|
) => Promise<void>;
|
|
deleteRow: (type: string, userId: string, syncId: string) => Promise<void>;
|
|
orderSelfReferences: (type: string, rows: SyncRow[]) => SyncRow[];
|
|
counts: (userId: string) => Promise<Record<string, number>>;
|
|
wipe: (userId: string) => Promise<void>;
|
|
regenerateSyncIds: (userId: string) => Promise<void>;
|
|
removeSharedCopies: (userId: string) => Promise<void>;
|
|
records: {
|
|
list: (userId: string) => Promise<Map<string, SyncRecordInfo>>;
|
|
put: (
|
|
userId: string,
|
|
type: string,
|
|
syncId: string,
|
|
values: { revision: number; hash: string | null; deleted: boolean },
|
|
) => Promise<void>;
|
|
setError: (
|
|
userId: string,
|
|
type: string,
|
|
syncId: string,
|
|
reason: string,
|
|
hash: string,
|
|
) => Promise<void>;
|
|
clearErrors: (userId: string) => Promise<void>;
|
|
listErrors: (userId: string) => Promise<SyncRecordInfo[]>;
|
|
deleteForUser: (userId: string) => Promise<void>;
|
|
key: (type: string, syncId: string) => string;
|
|
};
|
|
conflicts: {
|
|
save: (
|
|
userId: string,
|
|
type: string,
|
|
syncId: string,
|
|
row: SyncRow,
|
|
revision: number,
|
|
) => Promise<void>;
|
|
list: (userId: string) => Promise<SyncConflictInfo[]>;
|
|
get: (userId: string, id: number) => Promise<SyncConflictInfo | null>;
|
|
delete: (userId: string, id: number) => Promise<void>;
|
|
};
|
|
};
|
|
|
|
/** The server side: what changed for an account and what a desktop pushed. */
|
|
feed: {
|
|
withUserLock: <T>(userId: string, run: () => Promise<T>) => Promise<T>;
|
|
changes: (
|
|
userId: string,
|
|
cursor: number,
|
|
limit: number,
|
|
types?: string[],
|
|
) => Promise<{
|
|
records: Array<{
|
|
seq: number;
|
|
entityType: string;
|
|
syncId: string;
|
|
revision: number;
|
|
deleted: boolean;
|
|
row: SyncRow | null;
|
|
}>;
|
|
hasMore: boolean;
|
|
nextCursor: number;
|
|
entityTypes: Array<{
|
|
type: string;
|
|
owner: string;
|
|
order: number;
|
|
readOnly: boolean;
|
|
}>;
|
|
}>;
|
|
parsePushOps: (body: unknown) => Promise<SyncPushOp[] | null>;
|
|
applyPush: (userId: string, ops: SyncPushOp[]) => Promise<SyncPushResult[]>;
|
|
/** Streams a change notice to the account's open event streams. */
|
|
listen: (userId: string, res: unknown) => Promise<() => void>;
|
|
};
|
|
|
|
/**
|
|
* The linked server registry, kept in core so it is there before plugins.
|
|
* One link per local user. Without a user, the active account is meant.
|
|
*/
|
|
links: {
|
|
list: () => Promise<SyncLinkInfo[]>;
|
|
get: (userId?: string) => Promise<SyncLinkInfo | null>;
|
|
create: (link: SyncLinkCreate) => Promise<SyncLinkInfo>;
|
|
update: (
|
|
patch: SyncLinkPatch,
|
|
userId?: string,
|
|
) => Promise<SyncLinkInfo | null>;
|
|
remove: (userId?: string) => Promise<void>;
|
|
};
|
|
|
|
/** Desktop accounts: the local user and the replica user of each server. */
|
|
accounts: {
|
|
list: () => Promise<SyncAccountInfo[]>;
|
|
/** A new empty account, ready to be linked to a server. */
|
|
create: () => Promise<string>;
|
|
/** Which account the app signs in as from now on. */
|
|
setActive: (userId: string) => Promise<void>;
|
|
activeId: () => Promise<string | null>;
|
|
/** Deletes an account and all its data. The last one cannot go. */
|
|
remove: (userId: string) => Promise<void>;
|
|
};
|
|
|
|
http: {
|
|
json: <T>(
|
|
target: SyncRemoteTarget,
|
|
pathname: string,
|
|
request?: SyncRemoteRequest,
|
|
) => Promise<T>;
|
|
fetch: (
|
|
target: SyncRemoteTarget,
|
|
pathname: string,
|
|
request?: SyncRemoteRequest,
|
|
) => Promise<{
|
|
ok: boolean;
|
|
status: number;
|
|
body: AsyncIterable<Uint8Array> | null;
|
|
}>;
|
|
normalizeServerUrl: (input: string) => string | null;
|
|
isRemoteError: (error: unknown) => error is SyncRemoteError;
|
|
};
|
|
|
|
sessions: {
|
|
/** A long lived session for a linked desktop. Shows in the account's sessions. */
|
|
issueDesktop: (
|
|
userId: string,
|
|
options: { days: number; deviceInfo: string },
|
|
) => Promise<string>;
|
|
revoke: (sessionId: string) => Promise<void>;
|
|
find: (
|
|
sessionId: string,
|
|
) => Promise<{ expiresAt: string; deviceInfo: string | null } | null>;
|
|
findUser: (
|
|
userId: string,
|
|
) => Promise<{ id: string; username: string } | null>;
|
|
/** The user and session behind a token, or null when it is not valid. */
|
|
verify: (
|
|
token: string,
|
|
) => Promise<{ userId: string; sessionId?: string } | null>;
|
|
/** Sets the sign in cookie on a response, like a login does. A name sets another cookie. */
|
|
setCookie: (
|
|
req: unknown,
|
|
res: unknown,
|
|
value: string,
|
|
options?: { name?: string; days?: number },
|
|
) => void;
|
|
/** Removes the sign in cookie, without ending the session behind it. */
|
|
clearCookie: (req: unknown, res: unknown, name?: string) => void;
|
|
/** Express middleware that signs the request in and sets userId and sessionId. */
|
|
middleware: () => unknown;
|
|
/** Express middleware that lets a request through only on a desktop. */
|
|
desktopOnly: () => unknown;
|
|
};
|
|
|
|
hosts: {
|
|
findForSync: (
|
|
userId: string,
|
|
syncId: string,
|
|
) => Promise<{ id: number; name: string | null } | null>;
|
|
statusBySyncId: (userId: string) => Promise<Record<string, unknown>>;
|
|
};
|
|
|
|
plugins: {
|
|
listForDesktop: () => Promise<{
|
|
plugins: SyncRemotePlugin[];
|
|
registryKeys: unknown;
|
|
}>;
|
|
/** Sends a plugin archive; false when there is no such plugin to share. */
|
|
sendPackage: (pluginId: string, res: unknown) => Promise<boolean>;
|
|
mirror: (link: SyncLinkInfo) => Promise<void>;
|
|
lastRemote: () => SyncRemotePlugin[];
|
|
localNames: () => Map<string, string>;
|
|
};
|
|
|
|
desktop: {
|
|
/** Tells the app's main process how to reach the linked origin. */
|
|
applyProxy: (link: SyncLinkInfo | null) => Promise<void>;
|
|
deviceName: () => string;
|
|
};
|
|
|
|
alerts: {
|
|
send: (alert: {
|
|
title: string;
|
|
body: string;
|
|
severity: "info" | "warning" | "error";
|
|
category: string;
|
|
dedupeKey: string;
|
|
link?: { tab: string };
|
|
}) => Promise<void>;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* A plain key/value hand-off between plugins. `provide` and `revoke` only take
|
|
* keys under the plugin's own id (`<pluginId>.something`); anyone may
|
|
* `consume`.
|
|
*/
|
|
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 {
|
|
/**
|
|
* `name` registers one of several providers of the same service, for a
|
|
* service keyed by something the caller picks (sessions.live by session
|
|
* type). It must be listed in the manifest's `provides[].names`.
|
|
*/
|
|
provide: <T extends object>(
|
|
service: string,
|
|
implementation: T,
|
|
options?: { name?: string },
|
|
) => void;
|
|
/**
|
|
* The service must be listed in the manifest's `requires` (or be one this
|
|
* plugin provides). `provider` picks a named provider; omitted, the unnamed
|
|
* one. A `userId` other than the acting user needs users:impersonate.
|
|
*/
|
|
get: <T extends object>(
|
|
service: string,
|
|
options?: { userId?: string; provider?: string },
|
|
) => T;
|
|
/** Names of the providers running now ("" for an unnamed one). */
|
|
providers: (service: string) => string[];
|
|
}
|
|
|
|
export interface PluginSecrets {
|
|
/**
|
|
* The acting user's own copy of a secret this plugin stored, decrypted, or
|
|
* null when nothing is stored. Needs secrets:own and an actor. Not audited:
|
|
* a plugin reads its keys on every request that uses them.
|
|
*/
|
|
get: (key: string) => Promise<string | null>;
|
|
/**
|
|
* Stores a secret for the acting user, encrypted at rest. Null clears it.
|
|
* Needs secrets:own and an actor. Audited without the value.
|
|
*/
|
|
set: (key: string, value: string | null) => Promise<void>;
|
|
/** Removes the acting user's secret. Needs secrets:own. Audited. */
|
|
delete: (key: string) => Promise<void>;
|
|
/**
|
|
* Encrypts a value with the installation key, for a plugin that keeps a
|
|
* secret in its own table and has to read it without an acting user (a
|
|
* second factor checked during login). Needs secrets:own. Not audited.
|
|
*/
|
|
seal: (value: string) => Promise<string>;
|
|
/** Reverses seal(). Null when the value cannot be decrypted. */
|
|
unseal: (sealed: string) => Promise<string | null>;
|
|
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"). A
|
|
* ":name" segment matches one segment, and a trailing "/*" matches any
|
|
* number of further segments, for a proxied page ("/chooser/:id/*").
|
|
* An entry only opens the route registered with that same path, for the
|
|
* methods it was registered with, so a literal route beside a ":param"
|
|
* ("/webhook/config" next to "/webhook/:token") still needs a login.
|
|
*
|
|
* 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;
|
|
/**
|
|
* Requests per minute one address may make to this plugin's public routes.
|
|
* Defaults to 120, which a proxied page loading its own scripts and images
|
|
* can pass on its own. Capped at 10000, and an admin's
|
|
* TERMIX_PLUGIN_PUBLIC_RATE_LIMIT wins over it.
|
|
*/
|
|
publicRateLimit?: number;
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
/**
|
|
* The public base URL a request came in on: origin plus the install's base
|
|
* path, no trailing slash. Honours forwarded headers from a trusted proxy,
|
|
* BASE_PATH and EXTERNAL_FORCE_HTTPS. For building absolute callback URLs.
|
|
*/
|
|
baseUrl: (req: unknown) => string;
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
/** The client address, behind a trusted proxy when one is configured. */
|
|
readonly clientIp: string;
|
|
/** The public origin the request came in on, for browser redirects. */
|
|
readonly requestOrigin: string;
|
|
/**
|
|
* Whether the user's data key is still unlocked. False for a guest and
|
|
* once a session expires, so a long-lived socket can refuse work it can no
|
|
* longer decrypt for.
|
|
*/
|
|
isDataUnlocked: () => boolean;
|
|
}
|
|
|
|
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;
|
|
/**
|
|
* With `public`, still resolves a token when the client sent one, so one
|
|
* route can serve signed-in users and a guest arriving with a token of its
|
|
* own. The handler gets the user id, or an empty one for a guest.
|
|
*/
|
|
optionalAuth?: 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. A host follows its host defaults (server, user,
|
|
* folder) for every key it has not set itself, and the value read here is
|
|
* already the resolved one.
|
|
*/
|
|
getHost: <T = unknown>(
|
|
hostId: number | string,
|
|
key: string,
|
|
) => Promise<T | undefined>;
|
|
/**
|
|
* The value as one user sees it. Only a `personal` field differs from
|
|
* getHost: on a host shared with that user and following its defaults, it
|
|
* resolves against the user's own defaults rather than the owner's.
|
|
*/
|
|
getHostFor: <T = unknown>(
|
|
hostId: number | string,
|
|
userId: string,
|
|
key: string,
|
|
) => Promise<T | undefined>;
|
|
/**
|
|
* What a user's hosts get for a host field when nothing more specific
|
|
* sets it: their own defaults, then the server's, then the manifest
|
|
* default. For something with no host, such as a quick connect.
|
|
*/
|
|
getHostDefault: <T = unknown>(
|
|
userId: string,
|
|
key: string,
|
|
) => Promise<T | undefined>;
|
|
/**
|
|
* Writes a host value, which makes it the host's own rather than
|
|
* following its defaults. `{ inherit: true }` hands the key back to the
|
|
* defaults instead and ignores `value`.
|
|
*/
|
|
setHost: (
|
|
hostId: number | string,
|
|
key: string,
|
|
value: unknown,
|
|
options?: { inherit?: boolean },
|
|
) => Promise<void>;
|
|
|
|
/**
|
|
* Every host that saved a value for one of this plugin's host fields, across
|
|
* all users, with the host's owner. For a background job that must know
|
|
* which hosts to act on, and as whom, before any request is in flight.
|
|
* Requires hosts:read. Secret fields cannot be listed.
|
|
*/
|
|
listHostValues: <T = unknown>(
|
|
key: string,
|
|
) => Promise<Array<{ hostId: number; userId: string; value: T }>>;
|
|
|
|
/** 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;
|
|
|
|
/**
|
|
* Checks a save from the settings screen or host editor before anything is
|
|
* written. Return field key to message for what is wrong; nothing means the
|
|
* save goes ahead. Disposed automatically on deactivate.
|
|
*/
|
|
onValidate: (
|
|
scope: "admin" | "user" | "host",
|
|
validator: (
|
|
values: Record<string, unknown>,
|
|
/**
|
|
* `defaults` is set when the values are a host defaults level being
|
|
* saved rather than one host, so there is no hostId.
|
|
*/
|
|
context: {
|
|
hostId?: number;
|
|
defaults?: { level: "admin" | "user" | "folder" };
|
|
},
|
|
) => Record<string, string> | void | Promise<Record<string, string> | 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 as ctx.hosts hands it to a plugin that creates, updates or scans
|
|
* hosts on the user's behalf (import, discovery, sync), decrypted for that
|
|
* user. Every field is listed here and core builds it field by field, so a
|
|
* core column a plugin needs has to be added to this contract first. Carries
|
|
* 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;
|
|
/** Stable across a desktop and the server it syncs with. */
|
|
syncId: string | null;
|
|
name: string | null;
|
|
ip: string;
|
|
port: number;
|
|
username: string;
|
|
authType: string;
|
|
credentialId: number | null;
|
|
overrideCredentialUsername: boolean;
|
|
/** "ssh", or the id of the plugin protocol a host without SSH uses. */
|
|
connectionType: string;
|
|
/** Comma-separated, as ssh_data stores them. */
|
|
tags: string | null;
|
|
folder: string | null;
|
|
parentHostId: number | null;
|
|
pin: boolean;
|
|
notes: string | null;
|
|
jumpHosts: PluginHostJumpHost[];
|
|
enableSsh: boolean;
|
|
sshPort: number | null;
|
|
statusCheckEnabled: boolean;
|
|
/** Seconds between status checks; null follows the global setting. */
|
|
statusCheckInterval: number | null;
|
|
connectionOrigin: "local" | "remote" | null;
|
|
/** Keepalive, legacy algorithms, agent and environment options. */
|
|
sshOptions: HostSshOptions;
|
|
/**
|
|
* The calling plugin's own host settings, secrets redacted. Another
|
|
* plugin's settings are that plugin's to hand out, through a service.
|
|
*/
|
|
pluginSettings: Record<string, unknown>;
|
|
/** Core's last reachability check, or null before the first one. */
|
|
status: PluginHostStatusEntry | null;
|
|
/** Desktop only: kept on this device, never synced to the server. */
|
|
localOnly: boolean;
|
|
createdAt: string | null;
|
|
updatedAt: string | null;
|
|
}
|
|
|
|
export interface PluginHostJumpHost {
|
|
hostId: number;
|
|
}
|
|
|
|
/**
|
|
* The fields a plugin may set when it creates a host. A key not listed here
|
|
* is refused with an error rather than dropped. Host settings go in
|
|
* `pluginSettings`, keyed by the plugin that declares them, and are
|
|
* validated against that plugin's manifest: an undeclared key is refused, and
|
|
* settings for a plugin that is not running are skipped and logged.
|
|
*/
|
|
export interface PluginHostCreateInput {
|
|
name: string;
|
|
ip: string;
|
|
port: number;
|
|
username: string;
|
|
authType: string;
|
|
credentialId?: number | null;
|
|
overrideCredentialUsername?: boolean;
|
|
connectionType?: string;
|
|
/** Comma-separated or a list. */
|
|
tags?: string | string[] | null;
|
|
folder?: string | null;
|
|
pin?: boolean;
|
|
notes?: string | null;
|
|
jumpHosts?: PluginHostJumpHost[] | null;
|
|
enableSsh?: boolean;
|
|
sshPort?: number | null;
|
|
statusCheckEnabled?: boolean;
|
|
statusCheckInterval?: number | null;
|
|
forceKeyboardInteractive?: boolean;
|
|
sshOptions?: HostSshOptions | null;
|
|
/** Host settings by plugin id, the shape a Termix export carries. */
|
|
pluginSettings?: Record<string, Record<string, unknown>>;
|
|
/**
|
|
* Each plugin's own host switch (its manifest enableKey), by plugin id.
|
|
* A plugin that is not running is skipped and logged; one with no switch
|
|
* is refused.
|
|
*/
|
|
pluginEnable?: Record<string, boolean>;
|
|
/**
|
|
* Secrets, stored encrypted with the owner's data key and never returned
|
|
* in a PluginHostRecord. An empty string or null clears the stored value.
|
|
*/
|
|
password?: string | null;
|
|
key?: string | null;
|
|
keyPassword?: string | null;
|
|
keyType?: string | null;
|
|
sudoPassword?: string | null;
|
|
/**
|
|
* Logins for plugin protocols (contributes.protocols), keyed by protocol
|
|
* id. null removes that protocol's login. Unknown protocol ids are skipped.
|
|
*/
|
|
protocolAuth?: Record<string, PluginHostProtocolAuthInput | null>;
|
|
}
|
|
|
|
/** One protocol login on a host write. Absent keys keep what is stored. */
|
|
export interface PluginHostProtocolAuthInput {
|
|
authType?: "direct" | "credential" | "none";
|
|
credentialId?: number | null;
|
|
username?: string | null;
|
|
password?: string | null;
|
|
/** The protocol's declared credentialFields, secret ones included. */
|
|
fields?: Record<string, string>;
|
|
}
|
|
|
|
/** Fields a plugin may change on a host the acting user owns. */
|
|
export type PluginHostUpdateInput = Partial<PluginHostCreateInput>;
|
|
|
|
/** Every key PluginHostCreateInput and PluginHostUpdateInput accept. */
|
|
export const PLUGIN_HOST_INPUT_KEYS = [
|
|
"name",
|
|
"ip",
|
|
"port",
|
|
"username",
|
|
"authType",
|
|
"credentialId",
|
|
"overrideCredentialUsername",
|
|
"connectionType",
|
|
"tags",
|
|
"folder",
|
|
"pin",
|
|
"notes",
|
|
"jumpHosts",
|
|
"enableSsh",
|
|
"sshPort",
|
|
"statusCheckEnabled",
|
|
"statusCheckInterval",
|
|
"forceKeyboardInteractive",
|
|
"sshOptions",
|
|
"pluginSettings",
|
|
"pluginEnable",
|
|
"password",
|
|
"key",
|
|
"keyPassword",
|
|
"keyType",
|
|
"sudoPassword",
|
|
"protocolAuth",
|
|
] as const satisfies readonly (keyof PluginHostCreateInput)[];
|
|
|
|
/** 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;
|
|
}
|
|
|
|
/** One grant in ctx.hosts.setShares. */
|
|
export interface PluginHostShareGrant {
|
|
target: PluginShareTarget;
|
|
permissionLevel: PluginHostShareLevel;
|
|
/** Omit for a grant that never expires. */
|
|
durationHours?: number;
|
|
}
|
|
|
|
/**
|
|
* Hosts a plugin manages itself, for example from a config file. Core refuses
|
|
* edits, deletes and share changes on a locked host from the UI, the API and
|
|
* other plugins, and marks it in host reads so a layout can show it read only.
|
|
* Pins and order stay with the user. Only the plugin that locks a host may
|
|
* still write it.
|
|
*/
|
|
export interface PluginHostLockProvider {
|
|
isLocked: (hostId: number) => boolean;
|
|
/** A short line shown under the managed note, or null. */
|
|
note: (hostId: number) => string | null;
|
|
/** False blocks adding hosts by hand (editor, import, other plugins). */
|
|
allowManualCreate: () => boolean;
|
|
}
|
|
|
|
export interface PluginHostLocksApi {
|
|
/** Registers this plugin's locks. Needs hosts:lock. Removed on deactivate. */
|
|
provide: (provider: PluginHostLockProvider) => () => void;
|
|
}
|
|
|
|
/**
|
|
* 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>;
|
|
/**
|
|
* Deletes a host the acting user owns, the same way the host editor's
|
|
* delete does: its access grants, activity and plugin settings go with it
|
|
* and remote sync gets a tombstone. Needs hosts:write, and the acting user
|
|
* needs the core hosts.delete permission. False when the host does not
|
|
* exist or is not theirs.
|
|
*/
|
|
delete: (hostId: number) => Promise<boolean>;
|
|
/**
|
|
* 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>;
|
|
/**
|
|
* Replaces every grant on a host the acting user owns with this list.
|
|
* Grants that stay keep their row and counters, missing ones are revoked.
|
|
* Needs hosts:write.
|
|
*/
|
|
setShares: (
|
|
hostId: number,
|
|
grants: PluginHostShareGrant[],
|
|
) => 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[]>;
|
|
/**
|
|
* Marks a live session on a host, for the online indicator. Counted with
|
|
* every other feature's sessions; call the returned function once when the
|
|
* session ends. Needs hosts:read.
|
|
*/
|
|
trackSession: (hostId: number) => () => void;
|
|
/**
|
|
* Adds a recent activity entry for the acting user, rate limited and
|
|
* refused for a host they cannot reach, like the dashboard's own route.
|
|
* Needs hosts:read.
|
|
*/
|
|
recordActivity: (
|
|
hostId: number,
|
|
type: string,
|
|
hostName: string,
|
|
) => Promise<void>;
|
|
/**
|
|
* Core's reachability status, the one behind the host list's status dot.
|
|
* Needs hosts:read.
|
|
*/
|
|
status: PluginHostStatusApi;
|
|
/**
|
|
* The hosts the acting user may connect to (their own and shared ones) as a
|
|
* status provider needs them: address, port and jump hosts, no secrets.
|
|
* Pass hostIds to narrow it. Needs hosts:read.
|
|
*/
|
|
probeTargets: (filter?: {
|
|
hostIds?: number[];
|
|
}) => Promise<PluginHostProbeTarget[]>;
|
|
/** Whether a session is open to the host right now. Needs hosts:read. */
|
|
sessionActive: (hostId: number) => boolean;
|
|
/**
|
|
* Imports Termix JSON host rows for the acting user, the same way POST
|
|
* /host/bulk-import does: up to 100 rows, jump hosts remapped by exportId,
|
|
* defaults, protocol logins and plugin settings applied, one save at the end.
|
|
* Needs hosts:write, plus credentials:write when credentials are passed, and
|
|
* the acting user needs the core hosts.create and hosts.edit permissions.
|
|
*/
|
|
importMany: (
|
|
hosts: Record<string, unknown>[],
|
|
options?: PluginHostImportOptions,
|
|
) => Promise<PluginHostImportResult>;
|
|
/** Imports the Host entries of an OpenSSH config file. Same rules as importMany. */
|
|
importSshConfig: (
|
|
content: string,
|
|
options?: Pick<PluginHostImportOptions, "overwrite" | "atomic">,
|
|
) => Promise<PluginHostImportResult>;
|
|
/**
|
|
* The acting user's own hosts as Termix JSON. Without share the secrets are
|
|
* included, decrypted. With share every secret is left out and credential
|
|
* logins point at a scrubbed credentials list by alias. Needs hosts:export
|
|
* and is always audited.
|
|
*/
|
|
exportMany: (options?: {
|
|
share?: boolean;
|
|
/** Only these of the user's hosts. */
|
|
hostIds?: number[];
|
|
}) => Promise<PluginHostExport>;
|
|
/**
|
|
* Validates and writes this plugin's own host settings from an imported
|
|
* row. Called for every imported row once the host exists. Return the
|
|
* values to write, or null to write nothing. Removed when the plugin
|
|
* deactivates. Needs hosts:write.
|
|
*/
|
|
registerImportNormalizer: (
|
|
normalizer: PluginHostImportNormalizer,
|
|
) => () => void;
|
|
/** The recent activity list on the dashboard. */
|
|
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 {
|
|
/** Update a host that matches on ip, port and username instead of skipping it. */
|
|
overwrite?: boolean;
|
|
/**
|
|
* All or nothing: if any row fails, the hosts this call created are deleted
|
|
* and the result says rolledBack. Cannot be combined with overwrite.
|
|
*/
|
|
atomic?: boolean;
|
|
/** Credential placeholders rows can refer to by credentialAlias. */
|
|
credentials?: Array<Record<string, unknown>>;
|
|
}
|
|
|
|
export interface PluginHostImportResult {
|
|
success: number;
|
|
updated: number;
|
|
skipped: number;
|
|
failed: number;
|
|
errors: string[];
|
|
createdHostIds: number[];
|
|
rolledBack?: boolean;
|
|
}
|
|
|
|
export interface PluginHostExport {
|
|
hosts: Array<Record<string, unknown>>;
|
|
/** Share exports only. */
|
|
version?: string;
|
|
exportedAt?: string;
|
|
credentials?: Array<Record<string, unknown>>;
|
|
}
|
|
|
|
export type PluginHostImportNormalizer = (
|
|
raw: Record<string, unknown>,
|
|
) => Record<string, unknown> | null;
|
|
|
|
export interface PluginHostProbeTarget {
|
|
id: number;
|
|
/** The user who owns the host; its jump hosts are theirs. */
|
|
ownerUserId: string;
|
|
ip: string;
|
|
port: number;
|
|
connectionType: string;
|
|
jumpHosts: Array<{ hostId: number }>;
|
|
statusCheckEnabled: boolean;
|
|
/** Seconds, or null to use the global interval. */
|
|
statusCheckInterval: number | null;
|
|
}
|
|
|
|
/**
|
|
* The plugin that owns host status. Core keeps the routes, the sync feed and
|
|
* ctx.hosts.status; this fills them. One provider at a time.
|
|
*/
|
|
export interface PluginHostStatusProvider {
|
|
start: () => void;
|
|
stop: () => void;
|
|
get: (hostId: number) => PluginHostStatusEntry | null;
|
|
/**
|
|
* Every status known, after polling is brought in line with the request:
|
|
* with hostIds, exactly those of the user's hosts; without, all of them.
|
|
*/
|
|
statusesFor: (
|
|
userId: string,
|
|
hostIds: number[] | null,
|
|
) => Promise<Map<number, PluginHostStatusEntry>>;
|
|
/** Restarts the user's checks. */
|
|
refresh: (userId: string) => Promise<void>;
|
|
check: (hostId: number) => Promise<PluginHostStatusEntry | null>;
|
|
reportLogin: (hostId: number, outcome: { ok: boolean }) => void;
|
|
registerPort: (
|
|
connectionType: string,
|
|
resolve: (
|
|
hostId: number,
|
|
) => number | undefined | Promise<number | undefined>,
|
|
) => () => void;
|
|
/** The global check interval in seconds. */
|
|
getIntervalSeconds: () => Promise<number>;
|
|
setIntervalSeconds: (seconds: number) => Promise<void>;
|
|
}
|
|
|
|
export interface PluginActivityEntry {
|
|
id: number;
|
|
userId: string;
|
|
type: string;
|
|
hostId: number;
|
|
hostName: string | null;
|
|
timestamp: string;
|
|
}
|
|
|
|
export type PluginRecordActivityResult =
|
|
| { status: "logged"; id: number }
|
|
| { status: "rate_limited" }
|
|
| { status: "invalid_type" }
|
|
| { status: "denied" }
|
|
| { status: "unavailable" };
|
|
|
|
/** The plugin that stores recent activity. One provider at a time. */
|
|
export interface PluginActivityProvider {
|
|
record: (
|
|
userId: string,
|
|
entry: { type: string; hostId: number; hostName: string },
|
|
) => Promise<PluginRecordActivityResult>;
|
|
list: (userId: string, limit: number) => Promise<PluginActivityEntry[]>;
|
|
deleteByUserId: (userId: string) => Promise<void>;
|
|
deleteByHostIds: (hostIds: number[]) => Promise<void>;
|
|
}
|
|
|
|
export interface PluginHostActivityApi {
|
|
/**
|
|
* Becomes the store behind the dashboard's recent activity and
|
|
* ctx.hosts.recordActivity. Needs hosts:activity-provide. Removed on
|
|
* deactivate.
|
|
*/
|
|
provide: (provider: PluginActivityProvider) => () => void;
|
|
}
|
|
|
|
/**
|
|
* online: the port answers, a session is open or a login worked. offline: the
|
|
* port does not answer. "reachable" is no longer sent and stays only so older
|
|
* plugins still type check.
|
|
*/
|
|
export type PluginHostStatus = "online" | "reachable" | "offline";
|
|
|
|
export interface PluginHostStatusEntry {
|
|
status: PluginHostStatus;
|
|
lastChecked: string;
|
|
/** @deprecated No longer sent. */
|
|
reason?: "host_key_changed";
|
|
}
|
|
|
|
/**
|
|
* Core checks every host with status checks on by opening a TCP connection to
|
|
* it. A plugin that logs in to hosts can report a working login, which marks
|
|
* the host online right away.
|
|
*/
|
|
export interface PluginHostStatusApi {
|
|
/**
|
|
* The last known status, or null when core has not checked the host yet or
|
|
* the acting user cannot reach it.
|
|
*/
|
|
get: (hostId: number) => Promise<PluginHostStatusEntry | null>;
|
|
/** Checks the host now unless core checked it recently. */
|
|
check: (hostId: number) => Promise<PluginHostStatusEntry | null>;
|
|
/**
|
|
* Reports a login attempt. Only a working login changes the status; a
|
|
* failed one is ignored. Not audited: pollers call it every sample.
|
|
*/
|
|
reportLogin: (
|
|
hostId: number,
|
|
outcome: { ok: boolean; hostKeyChanged?: boolean },
|
|
) => void;
|
|
/**
|
|
* Tells core which port proves a host of this connection type is up, for
|
|
* protocols whose port lives in the plugin's own host settings. Core falls
|
|
* back to the host's port. Removed on deactivate.
|
|
*/
|
|
registerPort: (
|
|
connectionType: string,
|
|
resolve: (
|
|
hostId: number,
|
|
) => number | undefined | Promise<number | undefined>,
|
|
) => () => void;
|
|
/**
|
|
* Becomes core's host status. Needs hosts:status-provide. Removed on
|
|
* deactivate, which hands status back to core's built-in checks.
|
|
*/
|
|
provide: (provider: PluginHostStatusProvider) => () => void;
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
sshOptions?: HostSshOptions | null;
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
/** Picks keepalive and timeout defaults. "plugin" is the generic one. */
|
|
/** What a connection is for, as a free label. Auth providers see it. */
|
|
export type PluginSshPurpose = string;
|
|
|
|
/**
|
|
* Keepalive, timeout and environment defaults for a kind of connection.
|
|
* terminal: an interactive shell. session: a long-lived browsing session.
|
|
* stream: a long transfer or console. forward: port forwarding. background
|
|
* (the default): short exec work such as polling.
|
|
*/
|
|
export type PluginSshProfile =
|
|
"terminal" | "session" | "stream" | "forward" | "background";
|
|
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* A round the user finishes in a browser: open a URL, confirm a code there,
|
|
* then continue. Transports show one generic dialog for it and name their
|
|
* messages after `id` (the terminal sends "<id>_auth_required" and waits for
|
|
* "<id>_auth_continue").
|
|
*/
|
|
export interface PluginBrowserSignInRound {
|
|
kind: "browser";
|
|
/** The keyboard-interactive handler that claimed the round. */
|
|
id: string;
|
|
/** Product name for the dialog title. */
|
|
label: string;
|
|
url: string | null;
|
|
/** A code to compare in the browser, or "N/A". */
|
|
code: string;
|
|
instructions: string;
|
|
}
|
|
|
|
export type PluginKeyboardInteractiveDecision =
|
|
| { kind: "auto"; responses: string[] }
|
|
| PluginBrowserSignInRound
|
|
| { 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 }
|
|
| PluginBrowserSignInRound;
|
|
|
|
/** What a keyboard-interactive handler's detect may return. */
|
|
export type PluginKeyboardInteractiveDetection =
|
|
| Exclude<PluginKeyboardInteractiveDecision, PluginBrowserSignInRound>
|
|
| Omit<PluginBrowserSignInRound, "id" | "label">;
|
|
|
|
/**
|
|
* Claims keyboard-interactive rounds on any host, whatever its auth type,
|
|
* for a server that speaks its own prompt style (a bastion asking for a
|
|
* browser sign-in). Handlers run before core's TOTP and push detection.
|
|
*
|
|
* `settings` is this plugin's host-scope settings for the host, with manifest
|
|
* defaults filled in. Both hooks are synchronous; they run inside ssh2's
|
|
* keyboard-interactive callback.
|
|
*/
|
|
export interface PluginKeyboardInteractiveHandler {
|
|
/** Listed in contributes.auth.keyboardInteractive. */
|
|
id: string;
|
|
/** Product name shown when a browser round is claimed. */
|
|
label: string;
|
|
detect: (
|
|
round: {
|
|
name: string;
|
|
instructions: string;
|
|
prompts: PluginKeyboardInteractivePrompt[];
|
|
},
|
|
host: PluginSshHost,
|
|
settings: Record<string, unknown>,
|
|
) => PluginKeyboardInteractiveDetection | null;
|
|
/** Answer password prompts with the stored password instead of asking. */
|
|
autoAnswerPasswords?: (
|
|
host: PluginSshHost,
|
|
settings: Record<string, unknown>,
|
|
) => boolean;
|
|
}
|
|
|
|
/** Answers keyboard-interactive prompts. Resolve null to give up. */
|
|
export interface PluginSshPromptChannel {
|
|
ask: (request: PluginSshPromptRequest) => Promise<string | null>;
|
|
}
|
|
|
|
export interface PluginSshConnectOptions {
|
|
purpose?: PluginSshPurpose;
|
|
/** Keepalive and timeout defaults. Defaults to "background". */
|
|
profile?: PluginSshProfile;
|
|
/** 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>;
|
|
/**
|
|
* An already-open stream to the host, such as a forwardOut channel through
|
|
* another host. The transport step is skipped: no port knocking, proxy,
|
|
* jump hosts or DNS lookup. Host key checks and auth still run.
|
|
*/
|
|
sock?: unknown;
|
|
}
|
|
|
|
export interface PluginSshExecOptions extends PluginSshConnectOptions {
|
|
/** Time to wait for the command to finish. Default 30s, at most 10 minutes. */
|
|
timeoutMs?: number;
|
|
/** Most bytes kept of each of stdout and stderr. Default 256 KB, at most 4 MB. */
|
|
maxOutputBytes?: number;
|
|
/** Written to the command's standard input, then closed. */
|
|
stdin?: string;
|
|
}
|
|
|
|
export interface PluginSshExecResult {
|
|
stdout: string;
|
|
stderr: string;
|
|
/** The exit code. Null when the command was killed or ran out of time. */
|
|
code: number | null;
|
|
/** Output went past maxOutputBytes and the rest was dropped. */
|
|
truncated: boolean;
|
|
timedOut: boolean;
|
|
}
|
|
|
|
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 PluginNetwork {
|
|
/**
|
|
* Whether a TCP port answers. An SSH banner is read and answered politely;
|
|
* nothing is ever sent that logs in. Loopback, private and link-local
|
|
* addresses are allowed, since status checks target the user's own hosts.
|
|
*/
|
|
tcpProbe: (
|
|
host: string,
|
|
port: number,
|
|
options?: { timeoutMs?: number },
|
|
) => 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
|
|
* the host. Needs ssh:connect.
|
|
*/
|
|
probe: (
|
|
hostId: number,
|
|
options?: { port?: number; timeoutMs?: number },
|
|
) => Promise<boolean>;
|
|
|
|
connect: <Client = unknown>(
|
|
host: number | PluginSshHost,
|
|
options?: PluginSshConnectOptions,
|
|
) => Promise<PluginSshConnection<Client>>;
|
|
|
|
/**
|
|
* Runs one command on a host with its stored login and closes the
|
|
* connection. The acting user needs connect access to the host, the same as
|
|
* connect. Output is capped and the command is stopped at the time limit.
|
|
* Needs ssh:connect and credentials:use.
|
|
*/
|
|
exec: (
|
|
host: number | PluginSshHost,
|
|
command: string,
|
|
options?: PluginSshExecOptions,
|
|
) => Promise<PluginSshExecResult>;
|
|
|
|
/** 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 (a remote desktop port behind a bastion).
|
|
*/
|
|
jumpChain: <Client = unknown>(
|
|
jumpHosts: Array<{ hostId: number }>,
|
|
options?: {
|
|
/** Background work: resolve the hops as this host's owner. */
|
|
forHost?: PluginSshHost;
|
|
/** Answers a hop asking for TOTP or other input. */
|
|
prompt?: PluginSshPromptChannel;
|
|
},
|
|
) => Promise<PluginSshConnection<Client>>;
|
|
|
|
/** The pool key withConnection would use, for clearing it. */
|
|
poolKey: (pool: string, host: PluginSshHost) => string;
|
|
|
|
/**
|
|
* Closes this plugin's pooled connections to a host, for when its details
|
|
* changed. Only keys this plugin created are touched.
|
|
*/
|
|
dropPooled: (pool: string, hostId: number) => void;
|
|
|
|
/**
|
|
* The host as the acting user may connect to it: RBAC, owner-key
|
|
* decryption, shared-host overrides and external secret references
|
|
* resolved, secrets included. By sync id when given, since a numeric id
|
|
* only names a host on the database that issued it. Null when the host is
|
|
* not on this server or the user cannot reach it.
|
|
*/
|
|
resolveHost: (
|
|
hostId: number,
|
|
options?: { syncId?: string | null },
|
|
) => Promise<PluginSshHost | null>;
|
|
|
|
/**
|
|
* 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;
|
|
/** Keepalive and timeout defaults. Defaults to "background". */
|
|
profile?: PluginSshProfile;
|
|
client: unknown;
|
|
/** Server-side host id when it differs from host.id. */
|
|
serverHostId?: number;
|
|
log?: (level: "info" | "warning" | "error", message: string) => void;
|
|
/** A person is at the keyboard, so providers may prompt. */
|
|
interactive?: boolean;
|
|
/**
|
|
* The ws socket the host key verifier asks on when a key is new or has
|
|
* changed. Without one a changed key is refused.
|
|
*/
|
|
hostKeySocket?: unknown;
|
|
},
|
|
) => Promise<PluginSshPrepared>;
|
|
|
|
/** Lower level: port knocking, jump hosts or proxy; sets config.sock. */
|
|
openTransport: (
|
|
host: PluginSshHost,
|
|
config: Record<string, unknown>,
|
|
options?: {
|
|
/** False when the caller already resolved DNS, or a jump host will. */
|
|
resolveDns?: boolean;
|
|
log?: (level: "info" | "warning" | "error", message: string) => void;
|
|
/** Answers a jump host asking for TOTP or other input. */
|
|
prompt?: PluginSshPromptChannel;
|
|
},
|
|
) => Promise<{ jumpClient: unknown | null; via: string }>;
|
|
|
|
/**
|
|
* Starts the browser step behind an `<interaction>_auth_required` message.
|
|
* The provider for the host's own auth type wins, else the first provider
|
|
* that owns the interaction. Rejects with PluginSshInteractionError when
|
|
* none can.
|
|
*/
|
|
startInteraction: (
|
|
interaction: string,
|
|
request: {
|
|
hostId: number;
|
|
socket: unknown;
|
|
requestOrigin: string;
|
|
payload?: Record<string, unknown>;
|
|
},
|
|
) => Promise<void>;
|
|
|
|
/** Cancels a pending browser step on every provider that owns it. */
|
|
cancelInteraction: (
|
|
interaction: string,
|
|
request: { hostId?: number; requestId?: string },
|
|
) => Promise<void>;
|
|
|
|
/** 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;
|
|
}
|
|
|
|
/** What ctx.ssh.prepare built, plus the provider hooks bound to it. */
|
|
export interface PluginSshPrepared {
|
|
config: Record<string, unknown>;
|
|
outcome: PluginSshAuthOutcome;
|
|
/** The auth type whose provider prepared it, null when none exists. */
|
|
authType: string | null;
|
|
/** The provider's banner hook, bound to this host and connection. */
|
|
onBanner?: (banner: string) => PluginSshBannerDecision | undefined;
|
|
/** The provider's auth failure hook, bound to this host and connection. */
|
|
onAuthFailed?: (context: {
|
|
error: Error;
|
|
retries: number;
|
|
canRetry: boolean;
|
|
methodNotAvailable: boolean;
|
|
}) => PluginSshAuthOutcome | undefined;
|
|
}
|
|
|
|
/** Thrown by ctx.ssh.startInteraction when no provider can start it. */
|
|
export class PluginSshInteractionError extends Error {
|
|
constructor(message: string) {
|
|
super(message);
|
|
this.name = "PluginSshInteractionError";
|
|
}
|
|
}
|
|
|
|
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;
|
|
/**
|
|
* Works for a host that was never saved (nothing in host settings, no host
|
|
* id to key a cache on), so Quick Connect offers it. Default false.
|
|
*/
|
|
quickConnect?: 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>;
|
|
/** Cancels a pending browser step. */
|
|
cancelInteraction?: (request: {
|
|
userId: string;
|
|
hostId?: number;
|
|
requestId?: string;
|
|
}) => void | 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;
|
|
/**
|
|
* Provider groups mapped to Termix roles. Core adds the desired roles
|
|
* and removes managed roles the user no longer maps to, on every login.
|
|
* Roles outside `managed` are never touched.
|
|
*/
|
|
roles?: { desired: string[]; managed: string[] };
|
|
/**
|
|
* Stored on the session so `ctx.auth.revokeSessions` can find it later,
|
|
* for an identity provider's back-channel logout.
|
|
*/
|
|
logoutClaims?: {
|
|
providerId?: number | null;
|
|
sub?: string | null;
|
|
sid?: string | null;
|
|
};
|
|
/**
|
|
* The identifier a 2.8 install kept for the user. Only for a plugin
|
|
* that took over a 2.8 login method: core also checks `identifier`
|
|
* against `allowedUsers`. `providerRowId` is no longer read.
|
|
*/
|
|
legacy?: { identifier: string; providerRowId?: number | null };
|
|
/**
|
|
* The key this attempt was counted under with
|
|
* `ctx.auth.loginRateLimit.recordFailure`. Cleared on success.
|
|
*/
|
|
rateLimitKey?: string;
|
|
};
|
|
|
|
/** 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;
|
|
/**
|
|
* Provider type shown to 2.8 clients by the old `GET /users/sso-providers`
|
|
* route ("oidc", "github", "google", "ldap"). Leave unset otherwise.
|
|
*/
|
|
type?: string;
|
|
/**
|
|
* Redirect methods: the login page starts this instance without a click,
|
|
* for a server that signs everyone in through one provider. The first
|
|
* instance that asks wins.
|
|
*/
|
|
autoStart?: 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>;
|
|
}
|
|
|
|
/** A login core is about to turn into a session. */
|
|
export interface PluginAuthPolicyInput {
|
|
userId: string;
|
|
/** Login method id, `"proxy"` for a trusted proxy login. */
|
|
methodId: string;
|
|
/** The method signs in through an outside identity provider (SSO, LDAP). */
|
|
external: boolean;
|
|
deviceType: "web" | "desktop" | "mobile";
|
|
/** The device passed a second factor before and was remembered. */
|
|
trustedDevice: boolean;
|
|
/** The method itself proved a second factor, like a verified passkey. */
|
|
mfaSatisfied: boolean;
|
|
/** Every second factor core has, and whether this user set it up. */
|
|
secondFactors: { id: string; pluginId: string; enrolled: boolean }[];
|
|
}
|
|
|
|
export interface PluginAuthPolicyDecision {
|
|
/** False turns remember me off and ignores remembered devices. */
|
|
allowRememberDevice?: boolean;
|
|
/** Caps how long the session lasts. */
|
|
maxSessionSeconds?: number;
|
|
/** Ends the session after this many seconds without a request. */
|
|
idleTimeoutSeconds?: number;
|
|
/**
|
|
* Requires a second factor even for users who have none: they get a
|
|
* session that can only set one up. `"new-device"` skips it on a
|
|
* remembered device.
|
|
*/
|
|
secondFactor?: "always" | "new-device";
|
|
}
|
|
|
|
/**
|
|
* Login rules from a plugin. Core asks every provider and the strictest
|
|
* answer wins. A provider that throws refuses the login.
|
|
*/
|
|
export interface PluginAuthPolicyProvider {
|
|
evaluate: (
|
|
input: PluginAuthPolicyInput,
|
|
) => PluginAuthPolicyDecision | Promise<PluginAuthPolicyDecision>;
|
|
/** Whether the login page should offer remember me. */
|
|
allowRememberDevice?: () => boolean;
|
|
}
|
|
|
|
export interface PluginAuthPolicyApi {
|
|
/** Needs auth:policy. Removed on deactivate. */
|
|
provide: (provider: PluginAuthPolicyProvider) => () => void;
|
|
}
|
|
|
|
export interface PluginUserTokenOptions {
|
|
/** Why the token exists, kept in the audit log and on the session. */
|
|
purpose: string;
|
|
/** Seconds until it expires. Defaults to 300 and is capped at 900. */
|
|
ttlSeconds?: number;
|
|
}
|
|
|
|
export interface PluginUserToken {
|
|
/** Send as `Authorization: Bearer <token>` to core's own API. */
|
|
token: string;
|
|
/** ISO time the session ends. */
|
|
expiresAt: string;
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
/** A host-independent keyboard-interactive handler. */
|
|
registerKeyboardInteractiveHandler: (
|
|
handler: PluginKeyboardInteractiveHandler,
|
|
) => 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>;
|
|
/**
|
|
* Finishes a redirect login from one of this plugin's own public routes:
|
|
* runs the registered method's `callback`, then core finds or provisions
|
|
* the user, runs second factors and redirects the browser back with a
|
|
* session, exactly like `/users/auth/<methodId>/callback`. The method must
|
|
* be one this plugin registered.
|
|
*/
|
|
completeRedirectLogin: (
|
|
methodId: string,
|
|
req: unknown,
|
|
res: unknown,
|
|
) => Promise<void>;
|
|
/**
|
|
* Revokes sessions whose login carried matching `logoutClaims`, for a
|
|
* back-channel logout. Needs `sub` or `sid`; `providerId` narrows it.
|
|
* Returns how many sessions ended.
|
|
*/
|
|
revokeSessions: (match: {
|
|
providerId?: number | null;
|
|
sub?: string | null;
|
|
sid?: string | null;
|
|
}) => Promise<number>;
|
|
/**
|
|
* Core's login rate limiter, the one password login uses. Keys are kept
|
|
* apart per plugin. Put the key in the identity's `rateLimitKey` so a
|
|
* successful login clears it.
|
|
*/
|
|
loginRateLimit: {
|
|
isLocked: (
|
|
ip: string,
|
|
key: string,
|
|
) => Promise<{ locked: boolean; remainingTime?: number }>;
|
|
recordFailure: (ip: string, key: string) => Promise<void>;
|
|
};
|
|
/**
|
|
* How many users are linked to an external identity from `provider` (the
|
|
* `provider` of the identities this plugin returns). For refusing to
|
|
* delete a provider people still sign in with.
|
|
*/
|
|
countLinkedUsers: (provider: string) => Promise<number>;
|
|
/**
|
|
* A short-lived session for an existing user, for calling core's own HTTP
|
|
* API as them from work with no request behind it, like a scheduled task
|
|
* driving the Termix CLI. The user's RBAC applies unchanged. Needs
|
|
* users:impersonate, not auth:provide, and every call is audited.
|
|
*
|
|
* The session is marked with the plugin and purpose. It cannot change the
|
|
* user's password, second factors, API keys or linked logins, so it can
|
|
* never outlive itself.
|
|
*/
|
|
issueUserToken: (
|
|
userId: string,
|
|
options: PluginUserTokenOptions,
|
|
) => Promise<PluginUserToken>;
|
|
/** Login rules: idle timeout, session length, remember me, second factors. */
|
|
policy: PluginAuthPolicyApi;
|
|
/** Second factors from every running plugin. Needs no capability. */
|
|
listSecondFactors: () => { id: string; pluginId: string }[];
|
|
}
|
|
|
|
/** What a plugin may learn about a user. Needs users:read. */
|
|
export interface PluginUsers {
|
|
/** The user's UI language, like "zh_CN", or null when they never chose one. */
|
|
getLocale: (userId: string) => Promise<string | null>;
|
|
}
|
|
|
|
export interface PluginOpenIsolatedWindowRequest {
|
|
/** http(s) only. Refused when it points anywhere else. */
|
|
url: string;
|
|
/** Isolated Electron session partition; a fresh one when omitted. */
|
|
partition?: string;
|
|
title?: string;
|
|
/** Present an invalid TLS certificate on this window's own origin only. */
|
|
ignoreCert?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Opens a desktop window outside the main renderer, for a target a plugin
|
|
* does not want sharing Termix's own session (a tunnelled or direct web UI).
|
|
* Electron only: rejects when the server is not running embedded in the
|
|
* desktop app. Needs desktop:window.
|
|
*/
|
|
/**
|
|
* A host to open in the operating system's own client for a protocol. The
|
|
* desktop app supports "rdp" (mstsc on Windows) today; any other protocol is
|
|
* refused.
|
|
*/
|
|
export interface PluginExternalClientRequest {
|
|
protocol: string;
|
|
host: string;
|
|
port?: number;
|
|
username?: string;
|
|
domain?: string;
|
|
}
|
|
|
|
export interface PluginDesktop {
|
|
openIsolatedWindow: (
|
|
request: PluginOpenIsolatedWindowRequest,
|
|
) => Promise<{ success: true }>;
|
|
/**
|
|
* Opens the operating system's own client for a protocol. The password is
|
|
* never passed; the client asks for it. Desktop app only.
|
|
*/
|
|
launchExternalClient: (
|
|
request: PluginExternalClientRequest,
|
|
) => Promise<{ success: boolean; error?: string }>;
|
|
/** Whether the server runs embedded in the desktop app, so the calls above can work. */
|
|
available: () => boolean;
|
|
/**
|
|
* The user's own machine. Only works on the desktop app's embedded backend
|
|
* and needs desktop:local; every call is audited once per plugin start.
|
|
*/
|
|
readonly local: PluginDesktopLocal;
|
|
}
|
|
|
|
export interface PluginLocalDirEntry {
|
|
name: string;
|
|
path: string;
|
|
type: "file" | "directory" | "link";
|
|
size: number;
|
|
modifiedTimestamp?: number;
|
|
linkTarget?: string;
|
|
hidden: boolean;
|
|
}
|
|
|
|
export interface PluginLocalWalkResult {
|
|
files: Array<{ localPath: string; relativePath: string; size: number }>;
|
|
emptyDirs: string[];
|
|
totalBytes: number;
|
|
}
|
|
|
|
export interface PluginLocalPty {
|
|
readonly pid: number;
|
|
/** The shell program that was started. */
|
|
readonly shell: string;
|
|
write: (data: string) => void;
|
|
resize: (cols: number, rows: number) => void;
|
|
kill: () => void;
|
|
onData: (handler: (data: string) => void) => () => void;
|
|
onExit: (handler: (exitCode: number) => void) => () => void;
|
|
}
|
|
|
|
/** A download being written next to its final name, published when done. */
|
|
export interface PluginLocalDownload {
|
|
/** Final path inside the chosen folder, links inside it already refused. */
|
|
readonly path: string;
|
|
readonly stream: NodeJS.WritableStream;
|
|
/** Moves the finished partial onto its final name. */
|
|
publish: (options?: { overwrite?: boolean }) => Promise<string>;
|
|
/** Removes the partial file. Safe to call after publish. */
|
|
abort: () => Promise<void>;
|
|
}
|
|
|
|
export interface PluginLocalResponse {
|
|
readonly status: number;
|
|
readonly ok: boolean;
|
|
readonly headers: { get: (name: string) => string | null };
|
|
/** A web stream of the body, or null when there is none. */
|
|
readonly body: AsyncIterable<Uint8Array> | null;
|
|
text: () => Promise<string>;
|
|
}
|
|
|
|
export interface PluginDesktopLocal {
|
|
/** Whether local access is possible: embedded backend and capability granted. */
|
|
available: () => boolean;
|
|
/**
|
|
* Calls this plugin's own HTTP routes on the embedded backend ("local") or
|
|
* on the server the desktop is linked to ("remote"), as the acting user.
|
|
* The path is under /plugin-api/<this plugin id>; nothing else is
|
|
* reachable. The link's proxy headers and certificate rules apply. A body
|
|
* may be a string, a buffer or a Node stream.
|
|
*/
|
|
pluginFetch: (
|
|
origin: "local" | "remote",
|
|
subPath: string,
|
|
init?: {
|
|
method?: string;
|
|
headers?: Record<string, string>;
|
|
body?: string | Buffer | NodeJS.ReadableStream;
|
|
signal?: AbortSignal;
|
|
},
|
|
) => Promise<PluginLocalResponse>;
|
|
info: () => Promise<{
|
|
home: string;
|
|
separator: string;
|
|
platform: NodeJS.Platform;
|
|
/** The desktop app's user data folder. */
|
|
userDataDir: string;
|
|
/** The port the embedded backend listens on. */
|
|
backendPort: number;
|
|
/** The server this desktop is linked to, or null. */
|
|
link: {
|
|
serverUrl: string;
|
|
token: string | null;
|
|
/**
|
|
* Headers for a request to the linked server: the session, plus the
|
|
* proxy headers and basic auth the link was set up with.
|
|
*/
|
|
headers: Record<string, string>;
|
|
/** The link accepts a self-signed certificate. */
|
|
allowInvalidCertificate: boolean;
|
|
} | null;
|
|
}>;
|
|
/**
|
|
* GET a JSON route on the linked server with the link's session, proxy
|
|
* headers and certificate rules. Rejects with an error whose `kind` is
|
|
* "signed_out" when the server refused the session.
|
|
*/
|
|
linkJson: (pathname: string) => Promise<unknown>;
|
|
readonly fs: {
|
|
list: (dirPath: string) => Promise<{
|
|
path: string;
|
|
parent: string | null;
|
|
entries: PluginLocalDirEntry[];
|
|
}>;
|
|
mkdir: (parentPath: string, name: string) => Promise<string>;
|
|
createFile: (parentPath: string, name: string) => Promise<string>;
|
|
/** Renames inside the same folder; never replaces an existing name. */
|
|
rename: (path: string, newName: string) => Promise<string>;
|
|
/** Creates a folder; with rootPath it must stay inside that folder. */
|
|
ensureDir: (dirPath: string, rootPath?: string) => Promise<string>;
|
|
/** Which of the given paths exist. */
|
|
exists: (paths: string[]) => Promise<string[]>;
|
|
walk: (rootPaths: string[]) => Promise<PluginLocalWalkResult>;
|
|
/** Sends entries to the operating system's trash. */
|
|
trash: (paths: string[]) => Promise<{
|
|
trashed: number;
|
|
failed: Array<{ path: string; error: string }>;
|
|
}>;
|
|
reveal: (path: string) => Promise<void>;
|
|
open: (path: string) => Promise<void>;
|
|
openRead: (path: string) => Promise<{
|
|
stream: NodeJS.ReadableStream;
|
|
size: number;
|
|
}>;
|
|
openDownload: (options: {
|
|
rootPath: string;
|
|
destPath: string;
|
|
transferId: string;
|
|
overwrite?: boolean;
|
|
}) => Promise<PluginLocalDownload>;
|
|
};
|
|
readonly pty: {
|
|
spawn: (options?: {
|
|
cols?: number;
|
|
rows?: number;
|
|
/** "wsl" on Windows starts WSL instead of PowerShell. */
|
|
shell?: string;
|
|
}) => Promise<PluginLocalPty>;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* A host protocol whose login core keeps next to the host: any id a plugin
|
|
* declares in contributes.protocols.
|
|
*/
|
|
export type PluginHostProtocol = string;
|
|
|
|
/**
|
|
* What a plugin needs to hand a host's protocol login to something core
|
|
* does not run (guacd): the address and the plaintext credentials the acting
|
|
* user is allowed to use. A shared recipient gets the owner's shared
|
|
* snapshot or their own override, never the owner's raw secret.
|
|
*/
|
|
export interface PluginProtocolTarget {
|
|
host: {
|
|
id: number;
|
|
name: string | null;
|
|
ip: string;
|
|
/** The host's SSH port, the old fallback when no protocol port is set. */
|
|
port: number;
|
|
ownerUserId: string;
|
|
jumpHosts: Array<{ hostId: number }>;
|
|
};
|
|
/** True when the acting user is not the owner. */
|
|
shared: boolean;
|
|
auth: {
|
|
/** "direct", "credential", or "none" when the user is asked at connect time. */
|
|
authType: string;
|
|
username: string;
|
|
password: string;
|
|
/** The protocol's declared credentialFields, secret ones included; "" when unset. */
|
|
fields: Record<string, string>;
|
|
};
|
|
}
|
|
|
|
/** One of the acting user's saved SSH key credentials, public half only. */
|
|
export interface PluginSshKeyCredential {
|
|
id: number;
|
|
name: string;
|
|
username: string | null;
|
|
/** "<type> <base64>", derived from the private key when none was stored. Null when it cannot be. */
|
|
publicKey: string | null;
|
|
}
|
|
|
|
/** A saved credential without its secrets. */
|
|
export interface PluginCredentialSummary {
|
|
id: number;
|
|
name: string;
|
|
username: string | null;
|
|
authType: string;
|
|
folder: string | null;
|
|
}
|
|
|
|
/** A key pair to save as a new credential for the acting user. */
|
|
export interface PluginSshKeyCredentialInput {
|
|
name: string;
|
|
description?: string;
|
|
username?: string | null;
|
|
privateKey: string;
|
|
publicKey: string;
|
|
/** The key's algorithm token, such as "ssh-ed25519". */
|
|
keyType: string;
|
|
}
|
|
|
|
/**
|
|
* Plaintext host credentials, for a plugin that has to hand them to another
|
|
* program. Needs credentials:read, and every call is audited.
|
|
*/
|
|
export interface PluginCredentials {
|
|
/**
|
|
* The acting user's own SSH key credentials, without any private material.
|
|
* Needs credentials:use and the core credentials.view permission. Audited.
|
|
*/
|
|
listSshKeys: () => Promise<PluginSshKeyCredential[]>;
|
|
/**
|
|
* The acting user's own credentials by name, with no secret material, so a
|
|
* plugin can point a host at one. Needs credentials:use and the core
|
|
* credentials.view permission. Audited.
|
|
*/
|
|
list: () => Promise<PluginCredentialSummary[]>;
|
|
/**
|
|
* Saves a key pair as a new encrypted credential owned by the acting user
|
|
* and returns its id. Needs credentials:write, the core credentials.create
|
|
* permission and an unlocked data key. Audited.
|
|
*/
|
|
createSshKey: (input: PluginSshKeyCredentialInput) => Promise<{ id: number }>;
|
|
/**
|
|
* The host's login for one of this plugin's own contributes.protocols.
|
|
* Null when the host does not exist or the acting user cannot connect to
|
|
* it. Needs credentials:read. Audited.
|
|
*/
|
|
resolveHostProtocol: (
|
|
hostId: number,
|
|
protocol: PluginHostProtocol,
|
|
) => Promise<PluginProtocolTarget | null>;
|
|
/**
|
|
* Registers this plugin as the resolver for "<scheme>://..." references in
|
|
* a host's secret fields (secret-sources registers "op" for
|
|
* "op://vault/item/field"). Needs auth:provide, like registerSshAuthProvider.
|
|
*
|
|
* `resolve` runs once per host resolution, for the user the field's owner
|
|
* resolves as (the host owner, or the recipient for a personal override),
|
|
* and its result is cached briefly by the connect pipeline. A scheme with no
|
|
* registered resolver fails the connect with a message naming the plugin
|
|
* that would provide it.
|
|
*/
|
|
registerSecretResolver: (
|
|
scheme: string,
|
|
resolve: (userId: string, reference: string) => Promise<string>,
|
|
) => void;
|
|
/**
|
|
* The public key line of an SSH key credential the acting user can see
|
|
* (their own, with the core credentials.view permission). Derived from the
|
|
* private key when none is stored, including a key kept behind a secret
|
|
* reference. Null when the credential is missing, is not a key, or cannot
|
|
* be read. Needs credentials:use. Audited.
|
|
*/
|
|
publicKey: (credentialId: number) => Promise<string | null>;
|
|
/**
|
|
* Replaces secret references ("op://...") in a key and its passphrase with
|
|
* the real values, for the acting user. Plain values pass through. Hands
|
|
* private key material to the plugin. Needs credentials:read. Audited.
|
|
*/
|
|
resolveKeyRefs: (
|
|
key: string,
|
|
passphrase?: string | null,
|
|
) => Promise<{ key: string; passphrase?: string }>;
|
|
}
|
|
|
|
/**
|
|
* An audit line under an action name of the plugin's choosing (an SSH login),
|
|
* for the acting user, next to the plugin_* line every privileged ctx call
|
|
* already writes.
|
|
*/
|
|
export interface PluginAudit {
|
|
record: (entry: {
|
|
action: string;
|
|
resourceType?: string;
|
|
resourceId?: string;
|
|
resourceName?: string;
|
|
details?: string;
|
|
success: boolean;
|
|
errorMessage?: string;
|
|
/**
|
|
* The HTTP request or WebSocket upgrade this happened on, for the line's
|
|
* IP address and user agent.
|
|
*/
|
|
request?: unknown;
|
|
}) => Promise<void>;
|
|
/**
|
|
* Called for every audit line core or any plugin writes, after it is
|
|
* accepted. Fire and forget: a slow or throwing handler never delays or
|
|
* fails the audited action. Lines written before the plugin started are not
|
|
* replayed; read them with list or export. Returns an unsubscribe function;
|
|
* it is also removed when the plugin deactivates. Needs audit:read.
|
|
*/
|
|
subscribe: (handler: (entry: PluginAuditEntry) => void) => () => void;
|
|
/** A page of stored lines, newest first. Needs audit:read. */
|
|
list: (options?: {
|
|
filters?: PluginAuditFilters;
|
|
limit?: number;
|
|
offset?: number;
|
|
}) => Promise<{ entries: PluginAuditEntry[]; total: number }>;
|
|
/**
|
|
* Stored lines in time order, in batches, so an export never holds the
|
|
* whole trail in memory. Needs audit:read.
|
|
*/
|
|
exportBatches: (
|
|
filters?: PluginAuditFilters,
|
|
batchSize?: number,
|
|
) => AsyncGenerator<PluginAuditEntry[]>;
|
|
}
|
|
|
|
export interface PluginAuditFilters {
|
|
userId?: string;
|
|
action?: string;
|
|
resourceType?: string;
|
|
success?: boolean;
|
|
/** ISO date-time. */
|
|
startDate?: string;
|
|
/** ISO date-time. */
|
|
endDate?: string;
|
|
}
|
|
|
|
export interface PluginAuditEntry {
|
|
/** Only on stored lines (list and export). */
|
|
id?: number;
|
|
/** ISO date-time. */
|
|
timestamp: string;
|
|
userId: string | null;
|
|
username: string;
|
|
action: string;
|
|
resourceType: string;
|
|
resourceId: string | null;
|
|
resourceName: string | null;
|
|
details: string | null;
|
|
ipAddress: string | null;
|
|
userAgent: string | null;
|
|
success: boolean;
|
|
errorMessage: string | null;
|
|
}
|
|
|
|
/** A notification channel a user set up. Its config never leaves the hub. */
|
|
export interface PluginNotificationChannel {
|
|
id: number;
|
|
name: string;
|
|
/** "webhook", "ntfy", "discord", "email", or whatever the hub supports. */
|
|
type: string;
|
|
enabled: boolean;
|
|
}
|
|
|
|
export type PluginNotificationSeverity =
|
|
"info" | "success" | "warning" | "critical";
|
|
|
|
/**
|
|
* Who receives an alert. Left out, it goes to the acting user. "admins" and
|
|
* a permission reach every user who holds it when the alert is sent.
|
|
*/
|
|
export type PluginNotificationAudience =
|
|
{ userId: string } | { permission: string } | "admins";
|
|
|
|
export interface PluginNotification {
|
|
title: string;
|
|
body?: string;
|
|
/** Defaults to "warning". */
|
|
severity?: PluginNotificationSeverity;
|
|
/**
|
|
* What kind of alert this is, "<pluginId>.<kind>" by convention, e.g.
|
|
* "acme-ssl.renewal_failed". Users route alerts to channels by it.
|
|
* Defaults to the sending plugin's id.
|
|
*/
|
|
category?: string;
|
|
audience?: PluginNotificationAudience;
|
|
/**
|
|
* Channels to deliver to on top of the recipient's own routing rules.
|
|
* Only channels the recipient owns are used.
|
|
*/
|
|
channelIds?: number[];
|
|
/** Where the alert points: a tab type to open, or an http(s) URL. */
|
|
link?: { tab?: string; url?: string };
|
|
/** While an unread alert with this key exists, repeats are dropped. */
|
|
dedupeKey?: string;
|
|
/**
|
|
* Extra fields a webhook receiver gets alongside the message. `sourceId`
|
|
* and `sourceName` name what sent it (an automation, a rule).
|
|
*/
|
|
context?: {
|
|
hostId?: number;
|
|
hostName?: string;
|
|
sourceId?: number | string;
|
|
sourceName?: string;
|
|
triggerType?: string;
|
|
value?: unknown;
|
|
threshold?: unknown;
|
|
[key: string]: unknown;
|
|
};
|
|
}
|
|
|
|
export interface PluginNotifyResult {
|
|
/** Users the alert reached. */
|
|
recipients: number;
|
|
/** Channel deliveries that succeeded, across every recipient. */
|
|
delivered: number;
|
|
failures: Array<{ channelId: number; name: string; error: string }>;
|
|
}
|
|
|
|
/**
|
|
* The plugin that stores alerts and delivers them to channels. Core resolves
|
|
* the audience and hands the hub a list of user ids; it never sees a channel.
|
|
*/
|
|
export interface PluginNotifyHub {
|
|
deliver: (input: {
|
|
/** The plugin that sent the alert. */
|
|
source: string;
|
|
recipients: string[];
|
|
notification: PluginNotification;
|
|
}) => Promise<PluginNotifyResult>;
|
|
channels: (userId: string) => Promise<PluginNotificationChannel[]>;
|
|
}
|
|
|
|
/**
|
|
* Alerts. Every plugin sends through here and the hub plugin decides where
|
|
* they land. Needs notify:send. With no hub running, send reaches nobody and
|
|
* channels answers an empty list, so callers never have to check.
|
|
*/
|
|
export interface PluginNotify {
|
|
/** The acting user's channels, without their config. Needs an actor. */
|
|
channels: () => Promise<PluginNotificationChannel[]>;
|
|
/**
|
|
* Sends an alert. With no audience it goes to the acting user, so a call
|
|
* with neither is refused. One channel failing does not stop the others.
|
|
*/
|
|
send: (notification: PluginNotification) => Promise<PluginNotifyResult>;
|
|
/**
|
|
* Makes this plugin the hub. Needs notify:hub. Only one hub runs at a time;
|
|
* a second one is refused. Revoked on deactivate.
|
|
*/
|
|
serve: (hub: PluginNotifyHub) => () => void;
|
|
}
|
|
|
|
export interface PluginFetchInit {
|
|
/** Return redirect responses without following them, or reject them (default). */
|
|
redirect?: "error" | "manual";
|
|
method?: string;
|
|
headers?: Record<string, string>;
|
|
body?: string;
|
|
/**
|
|
* Aborts the request when no response headers arrive within this long.
|
|
* Defaults to 30 seconds. A streamed body may also sit idle between chunks
|
|
* for this long, never less than 5 minutes.
|
|
*/
|
|
timeoutMs?: number;
|
|
/**
|
|
* Exact private or loopback hosts this request may reach. Everything else
|
|
* private is refused, and redirects are never followed.
|
|
*/
|
|
allowPrivateHosts?: readonly string[];
|
|
/** Aborts the request, and a streamed body, when the caller gives up. */
|
|
signal?: AbortSignal;
|
|
/**
|
|
* TLS for a private CA. `ca` trusts this PEM bundle instead of the system
|
|
* store. `rejectUnauthorized: false` skips verification, only for fetching
|
|
* a CA root by fingerprint, where the caller checks the answer itself.
|
|
*/
|
|
tls?: { ca?: string; rejectUnauthorized?: boolean };
|
|
}
|
|
|
|
/**
|
|
* Outbound HTTP through core's SSRF guard: http and https only, no embedded
|
|
* credentials, DNS pinned, private addresses refused unless listed. Needs
|
|
* network:outbound.
|
|
*/
|
|
export type PluginFetch = (
|
|
url: string,
|
|
init?: PluginFetchInit,
|
|
) => Promise<Response>;
|
|
|
|
/**
|
|
* A generic capability check, for a privileged action that has no dedicated
|
|
* ctx method to wrap it: a plugin bundling its own native dependency to touch
|
|
* hardware (a serial port, a USB device) is the first caller. Core cannot
|
|
* mediate that access the way it mediates ctx.ssh or ctx.db, so the
|
|
* capability here is a declared, reviewable, audited statement of intent
|
|
* rather than a technical gate. Running programs has its own member,
|
|
* ctx.process.
|
|
*/
|
|
export interface PluginCapabilities {
|
|
/** Whether this plugin currently holds `capability`. Not audited: for deciding whether to offer something, not for gating an action. */
|
|
has: (capability: string) => Promise<boolean>;
|
|
/** Throws PluginCapabilityError if this plugin does not hold `capability`. Audited like every guarded ctx method. */
|
|
require: (capability: string) => Promise<void>;
|
|
}
|
|
|
|
export interface PluginProcessOptions {
|
|
/** Added to a small base environment (PATH, HOME, temp, locale, proxy), never the whole server one. */
|
|
env?: Record<string, string>;
|
|
cwd?: string;
|
|
/** Kills the program with SIGKILL after this long. */
|
|
timeoutMs?: number;
|
|
}
|
|
|
|
/** A running program started with ctx.process.run. */
|
|
export interface PluginProcessHandle {
|
|
readonly pid: number | undefined;
|
|
/** Output as UTF-8 text, in the chunks it arrives in. */
|
|
onStdout: (listener: (chunk: string) => void) => void;
|
|
onStderr: (listener: (chunk: string) => void) => void;
|
|
/** Settles when the program ends, or rejects when it cannot start. */
|
|
readonly exited: Promise<{ code: number | null; signal: string | null }>;
|
|
kill: (signal?: "SIGTERM" | "SIGKILL") => void;
|
|
}
|
|
|
|
/** A program a plugin downloads and runs, pinned to one checksum. */
|
|
export interface PluginBinarySpec {
|
|
/** File name under the plugin's data dir, e.g. "opkssh-linux-amd64". */
|
|
name: string;
|
|
version: string;
|
|
/** https URL of the release asset. Redirects are followed. */
|
|
url: string;
|
|
/** Hex SHA-256 the file must have. */
|
|
sha256: string;
|
|
/**
|
|
* Absolute paths checked before downloading, such as a copy baked into the
|
|
* Docker image. Only a regular file named `name` is considered, and one
|
|
* whose checksum matches is used in place.
|
|
*/
|
|
prebuilt?: readonly string[];
|
|
}
|
|
|
|
/**
|
|
* Programs on the Termix server. Needs process:spawn. Every program still
|
|
* running is killed on deactivate.
|
|
*/
|
|
export interface PluginProcess {
|
|
/** Starts `file` with `args`, no shell. Audited. */
|
|
run: (
|
|
file: string,
|
|
args: readonly string[],
|
|
options?: PluginProcessOptions,
|
|
) => Promise<PluginProcessHandle>;
|
|
/**
|
|
* Returns the path of a verified executable: a matching prebuilt copy, the
|
|
* copy downloaded earlier, or a fresh download (which also needs
|
|
* network:outbound). Throws when the checksum does not match.
|
|
*/
|
|
ensureBinary: (spec: PluginBinarySpec) => Promise<string>;
|
|
}
|
|
|
|
/**
|
|
* Timers owned by the plugin. Every timer is cleared on deactivate, a run that
|
|
* throws is logged instead of crashing the server, and a repeating job never
|
|
* overlaps itself: a tick that arrives while the last run is still going is
|
|
* skipped. No capability needed.
|
|
*/
|
|
export interface PluginSchedule {
|
|
/** Runs `fn` every `intervalMs`. Returns a function that stops it. */
|
|
every: (
|
|
intervalMs: number,
|
|
fn: () => void | Promise<void>,
|
|
options?: {
|
|
/** Delays the first run by up to this much, so many timers spread out. */
|
|
jitterMs?: number;
|
|
/** Also run once straight away. */
|
|
runNow?: boolean;
|
|
},
|
|
) => () => void;
|
|
/** Runs `fn` once after `delayMs`. Returns a function that cancels it. */
|
|
after: (delayMs: number, fn: () => void | Promise<void>) => () => void;
|
|
}
|
|
|
|
/** The certificate core serves, read from its configured paths. */
|
|
export interface PluginTlsCertificateInfo {
|
|
subject: string;
|
|
issuer: string;
|
|
/** DNS names and IPs from the subject alternative names. */
|
|
names: string[];
|
|
notBefore: string;
|
|
notAfter: string;
|
|
/** True for core's own first-boot certificate. */
|
|
selfSigned: boolean;
|
|
/** SHA-256 fingerprint, colon separated. */
|
|
fingerprint: string;
|
|
}
|
|
|
|
export interface PluginTlsStatus {
|
|
/** Whether Termix is set up to serve HTTPS at all. */
|
|
enabled: boolean;
|
|
/** Null when there is no readable certificate. */
|
|
certificate: PluginTlsCertificateInfo | null;
|
|
/** The plugin that renews the certificate, when one is registered. */
|
|
renewal: { pluginId: string; pluginName: string } | null;
|
|
}
|
|
|
|
export interface PluginTlsReloadResult {
|
|
/** Whether the new certificate is being served now. */
|
|
applied: boolean;
|
|
message: string;
|
|
}
|
|
|
|
/**
|
|
* The server certificate. Needs system:tls, and every call is audited. Core
|
|
* owns serving it; a plugin can only replace it, reload it and answer ACME
|
|
* http-01 challenges.
|
|
*/
|
|
export interface PluginSystem {
|
|
tlsStatus: () => Promise<PluginTlsStatus>;
|
|
/**
|
|
* Replaces the served certificate and key. Throws when either does not
|
|
* parse, the certificate has expired, or the key does not match it. Does
|
|
* not reload: call reloadTls() after.
|
|
*/
|
|
writeTlsCertificate: (
|
|
certificatePem: string,
|
|
privateKeyPem: string,
|
|
) => Promise<PluginTlsCertificateInfo>;
|
|
/** Serves the certificate on disk without a restart. */
|
|
reloadTls: () => Promise<PluginTlsReloadResult>;
|
|
/**
|
|
* Answers GET /.well-known/acme-challenge/<token> with `content` until the
|
|
* returned function is called or the plugin is disabled.
|
|
*/
|
|
publishHttpChallenge: (token: string, content: string) => Promise<() => void>;
|
|
/**
|
|
* Tells core this plugin renews the certificate, so admin settings stop
|
|
* warning that nothing does. Dropped on deactivate.
|
|
*/
|
|
registerTlsRenewer: () => Promise<() => void>;
|
|
}
|
|
|
|
/** One installed plugin as ctx.plugins.list reports it. */
|
|
export interface PluginSummary {
|
|
id: string;
|
|
version: string;
|
|
source: "bundled" | "user";
|
|
/** "active" when running; anything else means it is not. */
|
|
state: string;
|
|
}
|
|
|
|
/** The installed plugins. Needs plugins:read. */
|
|
export interface PluginPlugins {
|
|
list: () => Promise<PluginSummary[]>;
|
|
}
|
|
|
|
export interface PluginContext {
|
|
readonly pluginId: string;
|
|
readonly manifest: PluginManifest;
|
|
readonly log: PluginLogger;
|
|
readonly events: PluginEvents;
|
|
readonly kv: PluginKeyValue;
|
|
readonly files: PluginFiles;
|
|
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;
|
|
/** A generic capability check, for privileged code no ctx method wraps. */
|
|
readonly capabilities: PluginCapabilities;
|
|
/** 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,
|
|
* except issueUserToken, which needs users:impersonate.
|
|
*/
|
|
readonly auth: PluginAuth;
|
|
/** What a plugin may learn about a user. Needs users:read. */
|
|
readonly users: PluginUsers;
|
|
/** Opens Electron windows outside the main renderer. Needs desktop:window. */
|
|
readonly desktop: PluginDesktop;
|
|
/** Host protocol credentials and the user's saved SSH keys. See PluginCredentials. */
|
|
readonly credentials: PluginCredentials;
|
|
/** Audit lines under the plugin's own action names. */
|
|
readonly audit: PluginAudit;
|
|
/** Timers that stop on deactivate. */
|
|
readonly schedule: PluginSchedule;
|
|
/** Core's notification channels. Needs notify:send. */
|
|
readonly notify: PluginNotify;
|
|
/** Outbound HTTP through core's SSRF guard. Needs network:outbound. */
|
|
readonly fetch: PluginFetch;
|
|
/** Plain reachability checks. Needs network:probe. */
|
|
readonly network: PluginNetwork;
|
|
/** Programs on the Termix server. Needs process:spawn. */
|
|
readonly process: PluginProcess;
|
|
/** The server certificate. Needs system:tls. */
|
|
readonly system: PluginSystem;
|
|
/** The installed plugins and whether each runs. Needs plugins:read. */
|
|
readonly plugins: PluginPlugins;
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
* Needs users:impersonate. That, auth.issueUserToken and a `userId` option
|
|
* on services.get or secrets.getShared are the only ways a plugin can act
|
|
* as a user it names.
|
|
*/
|
|
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";
|