feat: move termix identity into a plugin (C6)

This commit is contained in:
LukeGus committed 2026-09-24 20:50:54 -05:00
1 parent 6e56c90aac
commit 4dbce08379
157 files changed
+20743 -5766

No files matched your search

+74 -26
View File
@@ -441,29 +441,30 @@ when the plugin deactivates. Every registered component is wrapped in the
plugin's scope (so the hooks know which plugin they belong to), an error
boundary and Suspense.
| Member | What it does |
| --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| `registerRailItem` | A rail button, hidden from users without its `permission`. Hideable items show in Appearance > Sidebar > Navigation. The id must be a declared view |
| `registerPanel` | A rail panel, id in `contributes.panels` or `contributes.tabs` |
| `registerTab` | A tab type, id in `contributes.tabs`. Options cover persistence, layouts, singletons and host needs |
| `registerHostEditorSection` | A host editor tab in the top strip or the SSH group, with `form`, `setField` and `updateForm` |
| `registerHostAction` | A connect or open action on a host: sidebar row, palette, dashboard, default connect |
| `registerHostProtocol` | A connection protocol next to SSH (**B14**): General tab switch, Hosts panel filter and grouping, Quick Connect, the host's primary type and port |
| `registerHostBadge`, `registerHostContextMenuItem` | Host row badges and context menu entries |
| `registerPaletteEntry` | A global or per-host command palette entry |
| `registerDashboardCard` | A dashboard card, id in `contributes.dashboardCards` |
| `registerHomepageWidget` | A homepage widget, with an optional edit form |
| `registerSettingsComponent` | A component a `type: "custom"` settings field names |
| `registerAction`, `declareActionSlot` | Frontend actions and the slots a plugin owns |
| `registerSlotContribution`, `invokeAction` | Fill a slot with a `button` or a `component`, with an optional `when`; call an action and get its result |
| `registerSshAuthEditor` | An SSH auth method's editor in the host editor |
| `registerLoginMethod`, `registerSecondFactorUI` | Login screen UI for a method (`placement: "inline"` beside the password form) and a second factor challenge, each with an optional `enrollment` |
| `api`, `wsUrl(path)` | axios on `/plugin-api/<id>/` and the plugin's WebSocket URL |
| `apiFor(origin)` | The same client for a resolved connection origin: `"remote"` reaches the desktop app's connected server (**B14**) |
| `fetch(path, init)` | A raw `fetch` on `/plugin-api/<id>/<path>` with core's auth, for a streamed response (SSE) axios cannot read (**B18**) |
| `t`, `hasPermission` | The plugin's strings and a permission check, for code outside a component (a toast from `activate`) |
| `tabs.open`, `getLayout`, `applyLayout`, `onChange` | Tab control, used by workspaces; `tabs.openRailView` (**B12**) opens a rail view |
| `guest`, `info`, `onDispose` | Guest mode flag, plugin info, extra cleanup |
| Member | What it does |
| --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `registerRailItem` | A rail button, hidden from users without its `permission`. Hideable items show in Appearance > Sidebar > Navigation. The id must be a declared view |
| `registerPanel` | A rail panel, id in `contributes.panels` or `contributes.tabs` |
| `registerTab` | A tab type, id in `contributes.tabs`. Options cover persistence, layouts, singletons and host needs |
| `registerHostEditorSection` | A host editor tab in the top strip or the SSH group, with `form`, `setField` and `updateForm` |
| `registerHostAction` | A connect or open action on a host: sidebar row, palette, dashboard, default connect |
| `registerHostProtocol` | A connection protocol next to SSH (**B14**): General tab switch, Hosts panel filter and grouping, Quick Connect, the host's primary type and port |
| `registerHostBadge`, `registerHostContextMenuItem` | Host row badges and context menu entries |
| `registerPaletteEntry` | A global or per-host command palette entry |
| `registerDashboardCard` | A dashboard card, id in `contributes.dashboardCards` |
| `registerHomepageWidget` | A homepage widget, with an optional edit form |
| `registerSettingsComponent` | A component a `type: "custom"` settings field names |
| `registerAction`, `declareActionSlot` | Frontend actions and the slots a plugin owns |
| `registerSlotContribution`, `invokeAction` | Fill a slot with a `button` or a `component`, with an optional `when`; call an action and get its result |
| `registerSshAuthEditor` | An SSH auth method's editor in the host editor |
| `registerLoginMethod`, `registerSecondFactorUI` | Login screen UI for a method (`placement: "inline"` beside the password form) and a second factor challenge, each with an optional `enrollment` |
| `api`, `wsUrl(path)` | axios on `/plugin-api/<id>/` and the plugin's WebSocket URL |
| `apiFor(origin)` | The same client for a resolved connection origin: `"remote"` reaches the desktop app's connected server (**B14**) |
| `fetch(path, init)` | A raw `fetch` on `/plugin-api/<id>/<path>` with core's auth, for a streamed response (SSE) axios cannot read (**B18**) |
| `t`, `hasPermission` | The plugin's strings and a permission check, for code outside a component (a toast from `activate`) |
| `tabs.open`, `getLayout`, `applyLayout`, `onChange` | Tab control, used by workspaces; `tabs.openRailView` (**B12**) opens a rail view |
| `guest`, `info`, `onDispose` | Guest mode flag, plugin info, extra cleanup |
| `desktop` | `available`, `remoteServerUrl()` and `onRemoteServerChange`: whether the frontend runs in the desktop app and which remote server it syncs with (**C6**) |
Hooks: `useTranslation` (the plugin's namespace), `usePermission` (a short
name resolves to `<id>.<name>`), `useSettings`, `useHost`, `useHosts`,
@@ -484,8 +485,11 @@ components `send(type, data)` for the session socket and `connectPayload()`,
the data an `<interaction>_auth_completed` needs to reconnect, so a sign-in
flow such as OPKSSH's lives entirely in its plugin), `onboarding.steps`, `onboarding.features`,
`onboarding.workflow`, `hosts.importMenu`, `hosts.panel`, `proxmox.hostEditor`,
`shell.overlay`, `dashboard.hostMetrics`, `homepage.hostMetrics` and
`dashboard.secondaryView`. **B16** added the middle two, component slots in
`shell.overlay`, `dashboard.hostMetrics`, `homepage.hostMetrics`,
`credentials.badges` and `dashboard.secondaryView`. **C6** added
`credentials.badges`, a component slot after the key/password badge on each
saved credential row (`{ credentialId }`); termix-identity shows its "ID"
badge there, so the credential list no longer fetches Termix ID data. **B16** added the middle two, component slots in
the dashboard's host status card (`{ hostId, online }`, one per host row) and
the homepage's host status widget (`{ hostId, shownMetrics, online }`): core
draws the status and a plugin draws the numbers, so core no longer calls the
@@ -1190,6 +1194,32 @@ base types and branches on no plugin type name.
message names (`vault_auth_url`, `vault_completed`, `vault_error`) for
Termix-Mobile.
**C6** moved Termix Identity out. It never was an SSH auth type: issued
certificates are downloaded once and never stored, and no connect path reads
them, so `termix-identity` is a standalone feature plugin with no provider.
- It adopts `termix_identities`, `termix_identity_keys` and
`termix_identity_ca` as `p_termix_identity_identities`, `_keys` and `_ca`.
The migrations keep, by hand, the links from keys and CA to their identity
(cascade) and from a key to `ssh_credentials` (set null).
- The CA private key is sealed with `ctx.secrets.seal` (`secrets:own`), so
issuing a certificate no longer needs the owner's data key unlocked.
`termix-identity-ca-migration.ts` reseals 2.8 rows, which core encrypted
with the owner's data key; it runs from `runPluginDataMoves` right after
the adoption and again at boot for a row whose key was not open.
- The resolver, `/plugin-api/termix-identity/u/<handle>[/<ALGO>|/ca]`, is
public through `ctx.http.router({ public })`. The 2.8 URLs under
`/termix-id/u/` answer 308 to it (`registerTermixIdCompatRoutes` in
`host-compat-routes.ts`; nginx proxies only `/termix-id/u/` now), because
servers fetch them from provisioning scripts. The management routes moved
and are gated on the new `termix-identity.use` (admins and users).
- Publishing a saved key and saving a generated pair go through
`ctx.credentials.listSshKeys` and `createSshKey` (**C6**, see the ctx
surface): the plugin never sees a stored private key.
- The rail item keeps its id `termix-id`, so saved layouts, hidden-rail
preferences and presets carry over, and uses `app.desktop` to stay hidden
in a standalone desktop app, as the shell did before.
#### One SSH connect pipeline
Every SSH connection, core's and plugins', goes through
@@ -1693,6 +1723,7 @@ deciding, not the mechanism.
| `device:serial` | high | Open a physical serial or USB device on the Termix server |
| `hosts:write` | medium | Create and change hosts |
| `credentials:use` | medium | Connect using a host's stored credentials, without seeing them |
| `credentials:write` | medium | Save new credentials for the acting user |
| `network:outbound` | medium | Make outbound requests |
| `network:serve` | medium | Open a listening port |
| `network:broadcast` | medium | Send network broadcast packets |
@@ -1763,6 +1794,8 @@ Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`.
| `ctx.desktop.launchNativeRdp` / `.available` | `desktop:window` (launch only) | **B14** |
| `ctx.credentials.resolveHostProtocol` | `credentials:read` | **B14** |
| `ctx.credentials.registerSecretResolver` | `auth:provide` | **B20** |
| `ctx.credentials.listSshKeys` | `credentials:use` | **C6** |
| `ctx.credentials.createSshKey` | `credentials:write` | **C6** |
| `ctx.audit.record` | none, the actor is the runtime's | **B9** |
| `ctx.schedule.every` / `.after` | none | **B16** |
| `ctx.fetch` | `network:outbound` | **B17**, signal **B18**, tls **C4** |
@@ -1924,6 +1957,21 @@ resolution (the owner's shared snapshot or their own override), never the
owner's raw secret; no connect access, or no host, is `null`. Every call is
audited as `plugin_credentials_read`, allowed or refused.
**C6** added two members for Termix Identity, both for the acting user's own
saved SSH keys and both audited:
- `listSshKeys()` returns `{ id, name, username, publicKey }` for every key
credential the actor owns. Core derives the public key from the private
key when none was stored, so the plugin never receives private material.
Needs `credentials:use` and the core `credentials.view` permission.
- `createSshKey({ name, description, username, privateKey, publicKey,
keyType })` saves a key pair as a new encrypted credential and returns its
id. Needs `credentials:write`, a new capability, the core
`credentials.create` permission and an unlocked data key. There is no
delete: a plugin that has to undo a save orders its own writes so it never
needs one (termix-identity publishes the key first and removes that row if
the save fails).
**C3** added `ctx.process`, for a plugin that ships a program (opkssh):
- `run(file, args, { env, cwd, timeoutMs })` starts it without a shell and
@@ -2570,7 +2618,7 @@ Then, with the app running:
The bundled plugins predate the SDK, apart from workspaces (A9), snippets
(B2), remote-desktop (B14), docker (B15), host-metrics (B16), automations
(B17), ai (B18), homepage (B19), totp and webauthn (C1), sso and ldap (C2),
opkssh and warpgate (C3), step-ca (C4), vault (C5),
opkssh and warpgate (C3), step-ca (C4), vault (C5), termix-identity (C6),
which import nothing from core. The others still reach core by relative
path (`../../../../src/backend/...`), which an esbuild plugin,
`packages/plugin-sdk/cli/lib/legacy-core-imports.mjs`, keeps out of the bundle
+14
View File
@@ -657,3 +657,17 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
editor (sharing only offered with "Share Vault profiles"); switch to the new
redirect URI after allowing it in the Vault role; disable the vault plugin
and a Vault host says it needs it, then enable it again.
- **C6 (termix-identity):** only the public resolver URLs under
`/termix-id/u/` redirect to the plugin. The 2.8 management routes
(`/termix-id/me`, `/termix-id/keys`, `/termix-id/ca` and the rest) now 404.
Nothing in this repo calls them; confirm Termix-Mobile does not before
2.9.0 ships, and add redirects if it does. Owner: D0.
- **C6 (termix-identity):** on MySQL the adopted `p_termix_identity_keys` has
no `idx_termix_identity_keys_identity` (core never created one there and
an adoption writes no MySQL indexes), so the public resolver scans the
keys table. Add it in a later plugin migration if a MySQL install needs
it. Owner: D0.
- **C6 (termix-identity):** the docs site still shows the
`/termix-id/u/<handle>` resolver URL. It keeps working through the 308,
but the docs should show `/plugin-api/termix-identity/u/<handle>`. Owner:
D0.
@@ -106,8 +106,10 @@
"device:serial",
"hosts:write",
"credentials:use",
"credentials:write",
"network:outbound",
"network:serve",
"network:broadcast",
"users:read",
"events:core",
"notify:send",
@@ -116,6 +118,7 @@
"hosts:read",
"db:own",
"kv:own",
"files:own",
"secrets:own",
"settings:read-core",
"ui:surface"
+32 -1
View File
@@ -1382,11 +1382,42 @@ export interface PluginProtocolTarget {
};
}
/** 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 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[]>;
/**
* 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 }>;
/** Null when the host does not exist or the acting user cannot connect to it. */
resolveHostProtocol: (
hostId: number,
@@ -1627,7 +1658,7 @@ export interface PluginContext {
readonly auth: PluginAuth;
/** Opens Electron windows outside the main renderer. Needs desktop:window. */
readonly desktop: PluginDesktop;
/** Plaintext host protocol credentials. Needs credentials:read. */
/** 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;
+1
View File
@@ -49,6 +49,7 @@ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [
entry("hosts:write", "medium"),
entry("credentials:use", "medium"),
entry("credentials:write", "medium"),
entry("network:outbound", "medium"),
entry("network:serve", "medium"),
entry("network:broadcast", "medium"),
+15
View File
@@ -530,6 +530,19 @@ export interface PluginWsTarget {
protocols?: string[];
}
/** Where the frontend runs, for a plugin that behaves differently in the desktop app. */
export interface DesktopApi {
/** True inside the Termix desktop app. */
readonly available: boolean;
/**
* The remote server the desktop app is connected to for sync, or null
* outside the desktop app and while it runs standalone.
*/
remoteServerUrl: () => Promise<string | null>;
/** Called when the desktop app connects to or leaves a remote server. */
onRemoteServerChange: (listener: () => void) => Disposer;
}
export interface TermixAppInfo {
readonly pluginId: string;
readonly manifest: PluginManifest;
@@ -629,6 +642,8 @@ export interface TermixApp extends TermixAppInfo {
options?: { origin?: unknown },
) => Promise<PluginWsTarget | null>;
tabs: TabsApi;
/** The desktop app, when the frontend runs in it. */
desktop: DesktopApi;
/** Registered cleanup, run when the plugin is disabled. */
onDispose: (dispose: Disposer) => void;
}
+38
View File
@@ -58,6 +58,8 @@ import type {
PluginHostShareResult,
PluginShareableUser,
PluginShareableRole,
PluginSshKeyCredential,
PluginSshKeyCredentialInput,
} from "./backend.js";
import type { SyncEntityRegistration } from "./backend.js";
import type { PluginDatabase } from "./backend.js";
@@ -120,6 +122,8 @@ export interface FakeContextOptions {
* "<hostId>:<protocol>". A missing key answers null.
*/
protocolTargets?: Record<string, PluginProtocolTarget>;
/** What ctx.credentials.listSshKeys answers. */
sshKeyCredentials?: PluginSshKeyCredential[];
/** What ctx.desktop.available() answers. Defaults to false. */
desktopAvailable?: boolean;
/** What ctx.hosts.status.get and check answer, by host id. */
@@ -273,6 +277,8 @@ export interface FakePluginContext {
nativeRdpLaunches: PluginNativeRdpRequest[];
/** Every ctx.credentials.resolveHostProtocol call, in order. */
credentialReads: Array<{ hostId: number; protocol: string }>;
/** Every ctx.credentials.createSshKey call, with the id it answered. */
createdSshKeys: Array<PluginSshKeyCredentialInput & { id: number }>;
/** Every ctx.notify.send call, with the actor it ran as. */
notifications: Array<{
actor: string | undefined;
@@ -392,6 +398,7 @@ export function createFakeContext(
const desktopWindows: FakePluginContext["desktopWindows"] = [];
const nativeRdpLaunches: FakePluginContext["nativeRdpLaunches"] = [];
const credentialReads: FakePluginContext["credentialReads"] = [];
const createdSshKeys: FakePluginContext["createdSshKeys"] = [];
const notifications: FakePluginContext["notifications"] = [];
const fetches: FakePluginContext["fetches"] = [];
const processRuns: FakePluginContext["processRuns"] = [];
@@ -972,6 +979,25 @@ export function createFakeContext(
},
credentials: {
listSshKeys: async () => [
...(options.sshKeyCredentials ?? []),
...createdSshKeys.map((key) => ({
id: key.id,
name: key.name,
username: key.username ?? null,
publicKey: key.publicKey,
})),
],
createSshKey: async (input) => {
const id =
Math.max(
0,
...(options.sshKeyCredentials ?? []).map((key) => key.id),
...createdSshKeys.map((key) => key.id),
) + 1;
createdSshKeys.push({ ...input, id });
return { id };
},
resolveHostProtocol: async (hostId, protocol) => {
credentialReads.push({ hostId, protocol });
return options.protocolTargets?.[`${hostId}:${protocol}`] ?? null;
@@ -1049,6 +1075,7 @@ export function createFakeContext(
desktopWindows,
nativeRdpLaunches,
credentialReads,
createdSshKeys,
notifications,
fetches,
processRuns,
@@ -1105,6 +1132,8 @@ export interface MockContextOptions {
sshHosts?: PluginSshHost[];
/** What ctx.credentials serves. See FakeContextOptions. */
protocolTargets?: Record<string, PluginProtocolTarget>;
/** What ctx.credentials.listSshKeys serves. */
sshKeyCredentials?: PluginSshKeyCredential[];
/** What ctx.desktop.available() answers. */
desktopAvailable?: boolean;
/** What ctx.hosts.status answers. See FakeContextOptions. */
@@ -1164,6 +1193,7 @@ export function createMockCtx(
sshHosts: options.sshHosts,
services: options.services,
protocolTargets: options.protocolTargets,
sshKeyCredentials: options.sshKeyCredentials,
desktopAvailable: options.desktopAvailable,
hostStatuses: options.hostStatuses,
notificationChannels: options.notificationChannels,
@@ -1462,6 +1492,14 @@ export function createMockCtx(
},
credentials: {
listSshKeys: async () => {
require("credentials:use");
return ctx.credentials.listSshKeys();
},
createSshKey: async (input) => {
require("credentials:write");
return ctx.credentials.createSshKey(input);
},
resolveHostProtocol: async (hostId, protocol) => {
require("credentials:read");
return ctx.credentials.resolveHostProtocol(hostId, protocol);