mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat: convert secret-sources into a plugin (B20)
This commit is contained in:
1 parent
2ac7779eda
commit
f4f26a9b8b
72 files changed
+18843
-933
No files matched your search
@@ -752,7 +752,7 @@ sessions shared with a user from the `sessions.sharedWithMe` action.
|
||||
(`snippets.access`, a hard dependency), fleets (`fleets.access`), tunnels
|
||||
(`tunnels.access`), Docker (`docker.containers`, `docker.events`), host metrics
|
||||
(`host-metrics.viewers`) and wake-on-lan (`wake-on-lan.send` v1,
|
||||
`wake(hostId)`, which B20 provides) as services, notification channels through
|
||||
`wake(hostId)`, which **B20** provides) as services, notification channels through
|
||||
`ctx.notify`, the HTTP step through `ctx.fetch`, hosts through `ctx.hosts` and
|
||||
`ctx.ssh.resolveHost`, and commands through `ctx.ssh.withConnection`. Every run
|
||||
executes inside `ctx.asUser(<owner>)`, so each of those calls is the owner's.
|
||||
@@ -845,6 +845,38 @@ several widget-owning plugins relied on through the `homepage` namespace's
|
||||
core fallback moved to core's `common.*`, which is what that fallback is
|
||||
actually for.
|
||||
|
||||
**Wake-on-LAN (B20)** imports nothing from core. It owns no table: `macAddress`
|
||||
and `broadcastAddress` are host settings (`contributes.settings.host`), moved
|
||||
out of `ssh_data.mac_address`/`wol_broadcast_address` by a boot migration in
|
||||
`src/backend/utils/crypto-migration/wake-on-lan-settings-migration.ts`. A
|
||||
"Wake" host action sends the magic packet, gated on `network:broadcast`
|
||||
(medium, added to the capability catalog this step). It provides
|
||||
`wake-on-lan.send` v1 (`wake(hostId): Promise<void>`, which resolves the
|
||||
host's MAC and broadcast address itself and checks the caller's own
|
||||
`wake-on-lan.send` permission), which automations (B17) already declared as
|
||||
an optional dependency.
|
||||
|
||||
**Secret sources (B20)** imports nothing from core. It adopts `secret_sources`
|
||||
(rename only; the access token is not a column - see below), and provides no
|
||||
service, only `ctx.credentials.registerSecretResolver("op", resolve)`. The
|
||||
1Password Connect client, its egress allowlist (an admin `privateEndpoints`
|
||||
textarea, the same shape as the ai plugin's) and the source manager UI (a
|
||||
component slot core's host editor and credential editor render through
|
||||
`app.registerComponent("secret-sources.hint" | "secret-sources.manager", ...)`
|
||||
and `PluginComponent`, replacing the static imports those two core files used
|
||||
to carry) all moved in. The access token follows the pattern the ai plugin set
|
||||
for provider keys: it lives in `ctx.secrets` under `"source:<id>"` rather than
|
||||
in the table, so it is encrypted with the installation key rather than the
|
||||
owner's data key and is readable whenever the plugin runs, not only while the
|
||||
owner is signed in. A shared source's token is still only ever read for its
|
||||
own owner: `resolveConnectReference` and the `/test` route both go through
|
||||
`ctx.asUser(<owner>)` before reading it, so a recipient using a shared source
|
||||
never touches the raw value directly. `src/backend/utils/crypto-migration/secret-sources-token-migration.ts`
|
||||
moves an existing owner-DEK-encrypted token out of the column and into
|
||||
`ctx.secrets` at boot, the same shape as `ai-settings-migration.ts`'s provider
|
||||
key move. The `/users/secret-source-private-endpoints` route and its admin UI
|
||||
are gone; the allowlist is the plugin's own admin setting now.
|
||||
|
||||
#### Host status is core
|
||||
|
||||
The status dot in the host list, the dashboard and the homepage widget is
|
||||
@@ -1276,6 +1308,7 @@ not do.
|
||||
"sshAuthTypes": ["corp-ca"], // host auth types this plugin handles
|
||||
"loginMethods": ["corp-sso"],
|
||||
"secondFactors": ["pin"],
|
||||
"secretSchemes": ["op"], // "<scheme>://..." secret references this plugin resolves
|
||||
},
|
||||
"settings": {
|
||||
"admin": [
|
||||
@@ -1380,6 +1413,10 @@ accept the kinds `button` and `component`.
|
||||
`contributes.auth` lists the ids a plugin registers through `ctx.auth`. A
|
||||
register call for an id not listed here throws. Core also reads
|
||||
`sshAuthTypes` from disabled plugins, to say which plugin a host needs.
|
||||
`secretSchemes` (**B20**) is the same idea for
|
||||
`ctx.credentials.registerSecretResolver`: core reads it from disabled
|
||||
plugins too, so a reference whose scheme belongs to a disabled plugin can
|
||||
name that plugin in its error instead of saying nothing resolves it.
|
||||
|
||||
`contributes.hostCapability`, `provides`, `requires`, `providesSecret` and
|
||||
`requiresSecret` are carried forward from v1 unchanged because the service and
|
||||
@@ -1416,6 +1453,7 @@ deciding, not the mechanism.
|
||||
| `credentials:use` | medium | Connect using a host's stored credentials, without seeing them |
|
||||
| `network:outbound` | medium | Make outbound requests |
|
||||
| `network:serve` | medium | Open a listening port |
|
||||
| `network:broadcast` | medium | Send network broadcast packets |
|
||||
| `users:read` | medium | See usernames and roles, never hashes or 2FA state |
|
||||
| `events:core` | medium | Subscribe to and emit core events carrying other users' data |
|
||||
| `notify:send` | medium | Send notifications through configured channels |
|
||||
@@ -1477,6 +1515,7 @@ Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`.
|
||||
| `ctx.desktop.openIsolatedWindow` | `desktop:window` | **B8** |
|
||||
| `ctx.desktop.launchNativeRdp` / `.available` | `desktop:window` (launch only) | **B14** |
|
||||
| `ctx.credentials.resolveHostProtocol` | `credentials:read` | **B14** |
|
||||
| `ctx.credentials.registerSecretResolver` | `auth:provide` | **B20** |
|
||||
| `ctx.audit.record` | none, the actor is the runtime's | **B9** |
|
||||
| `ctx.schedule.every` / `.after` | none | **B16** |
|
||||
| `ctx.fetch` | `network:outbound` | **B17**, signal **B18** |
|
||||
@@ -1636,6 +1675,22 @@ 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.
|
||||
|
||||
**B20** added `ctx.credentials.registerSecretResolver(scheme, resolve)`,
|
||||
behind `auth:provide` like `ctx.auth`'s registrations, for a plugin that
|
||||
expands `"<scheme>://..."` references in a host's secret fields into the
|
||||
real secret (secret-sources registers `"op"` for
|
||||
`"op://vault/item/field"`). The scheme must be listed in
|
||||
`contributes.auth.secretSchemes`, which exists for the same reason
|
||||
`sshAuthTypes` does: so core can name the plugin a disabled scheme needs. A
|
||||
core registry in `src/backend/hosts/connect/secret-resolver-registry.ts`
|
||||
mirrors the SSH auth provider registry one-for-one (one owner per scheme,
|
||||
`SecretResolverMissingError` naming the plugin when none is registered).
|
||||
`resolve` is called once per host resolution, for the user the field's owner
|
||||
resolves as, by `src/backend/hosts/external-secrets.ts`, which is scheme-
|
||||
generic now: it detects a reference by its `scheme://` prefix, looks up the
|
||||
registered resolver, and caches the result for a minute. Core registers
|
||||
nothing here itself.
|
||||
|
||||
**B16** added `ctx.hosts.status`, the plugin side of core's host status
|
||||
check (see [Host status](#host-status-is-core)):
|
||||
|
||||
|
||||
@@ -375,11 +375,6 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
|
||||
there: it writes host rows only and never plugin settings. Owner: D1, with
|
||||
the generic host-settings export and import.
|
||||
|
||||
- **B17 (automations), for B20:** the wake-on-lan step calls the optional
|
||||
`wake-on-lan.send` v1 service, `wake(hostId): Promise<void>`, as the
|
||||
automation's owner; the provider resolves the host's MAC and checks access
|
||||
itself. Until B20 provides it, the step is offered nowhere and an existing
|
||||
one is skipped with "Needs the wake-on-lan plugin". Owner: B20.
|
||||
- **B17 (automations), for D2:** the ctx table says `ctx.events.on` needs
|
||||
`events:core` for core topics, but the runtime only checks `emit`.
|
||||
docker, file-manager, host-metrics, session-recording and snippets
|
||||
@@ -450,6 +445,14 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
|
||||
own B10 step moved off of (per `plugins/tmux-monitor/CHANGELOG.md`) without
|
||||
updating this frontend constant to `/plugin-api/tmux-monitor`. Not
|
||||
something this step touched or verified further. Owner: D0.
|
||||
- **Pre-existing, found in B20:** `npm run test:plugins` fails on three tests
|
||||
unrelated to wake-on-lan or secret-sources: `ssh-terminal` and `tunnels`
|
||||
both assert `mock.wsRoutes` equals `[{ path, raw }]`, but the mock now also
|
||||
carries `handler` and `options` (added for **B15**'s doubles, per the
|
||||
contract's "Tests" section), so the exact-equality assertion fails; and
|
||||
`web-endpoint`'s "answers 503 while the tunnels plugin is not available"
|
||||
test gets 502. Confirmed present on `dev-2.9.0` before this step (checked
|
||||
by stashing B20's changes and re-running). Owner: D0.
|
||||
|
||||
## Manual checks after 2.9.0
|
||||
|
||||
|
||||
@@ -292,6 +292,10 @@
|
||||
"secondFactors": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "pattern": "^[a-z][a-z0-9-]{0,63}$" }
|
||||
},
|
||||
"secretSchemes": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "pattern": "^[a-z][a-z0-9-]{0,63}$" }
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1257,6 +1257,21 @@ export interface PluginCredentials {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -257,6 +257,8 @@ export interface PluginAuthContribution {
|
||||
loginMethods?: string[];
|
||||
/** Second factor ids. */
|
||||
secondFactors?: string[];
|
||||
/** Schemes this plugin resolves for "<scheme>://..." secret references, e.g. "op". */
|
||||
secretSchemes?: string[];
|
||||
}
|
||||
|
||||
export interface PluginContributions {
|
||||
@@ -790,11 +792,16 @@ function validateAuthContribution(value: unknown, errors: string[]): void {
|
||||
}
|
||||
rejectUnknown(
|
||||
value,
|
||||
["sshAuthTypes", "loginMethods", "secondFactors"],
|
||||
["sshAuthTypes", "loginMethods", "secondFactors", "secretSchemes"],
|
||||
"contributes.auth",
|
||||
errors,
|
||||
);
|
||||
for (const key of ["sshAuthTypes", "loginMethods", "secondFactors"]) {
|
||||
for (const key of [
|
||||
"sshAuthTypes",
|
||||
"loginMethods",
|
||||
"secondFactors",
|
||||
"secretSchemes",
|
||||
]) {
|
||||
const list = value[key];
|
||||
if (list === undefined) continue;
|
||||
if (!Array.isArray(list)) {
|
||||
@@ -1343,7 +1350,8 @@ export function parseManifest(raw: unknown): ParsedManifest {
|
||||
const contributesAuth =
|
||||
(auth?.sshAuthTypes?.length ?? 0) +
|
||||
(auth?.loginMethods?.length ?? 0) +
|
||||
(auth?.secondFactors?.length ?? 0) >
|
||||
(auth?.secondFactors?.length ?? 0) +
|
||||
(auth?.secretSchemes?.length ?? 0) >
|
||||
0;
|
||||
if (contributesAuth && !manifest.capabilities.includes("auth:provide")) {
|
||||
errors.push(
|
||||
|
||||
@@ -126,6 +126,11 @@ export interface FakeAuthRegistrations {
|
||||
secondFactors: PluginSecondFactor[];
|
||||
/** "<userId>:<factorId>" for every recorded enrolment. */
|
||||
enrollments: Set<string>;
|
||||
/** Secret schemes registered through ctx.credentials.registerSecretResolver. */
|
||||
secretResolvers: Map<
|
||||
string,
|
||||
(userId: string, reference: string) => Promise<string>
|
||||
>;
|
||||
}
|
||||
|
||||
export interface FakePluginContext {
|
||||
@@ -336,6 +341,7 @@ export function createFakeContext(
|
||||
loginMethods: [],
|
||||
secondFactors: [],
|
||||
enrollments: new Set(),
|
||||
secretResolvers: new Map(),
|
||||
};
|
||||
const sshClient = options.sshClient ?? {};
|
||||
let actor = options.actor;
|
||||
@@ -800,6 +806,12 @@ export function createFakeContext(
|
||||
credentialReads.push({ hostId, protocol });
|
||||
return options.protocolTargets?.[`${hostId}:${protocol}`] ?? null;
|
||||
},
|
||||
registerSecretResolver: (scheme, resolve) => {
|
||||
auth.secretResolvers.set(scheme, resolve);
|
||||
disposals.push(() => {
|
||||
auth.secretResolvers.delete(scheme);
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
notify: {
|
||||
@@ -1223,6 +1235,10 @@ export function createMockCtx(
|
||||
require("credentials:read");
|
||||
return ctx.credentials.resolveHostProtocol(hostId, protocol);
|
||||
},
|
||||
registerSecretResolver: (scheme, resolve) => {
|
||||
require("auth:provide");
|
||||
ctx.credentials.registerSecretResolver(scheme, resolve);
|
||||
},
|
||||
},
|
||||
|
||||
notify: {
|
||||
|
||||
Reference in new issue
Block a user