mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat: move acme certificate automation into a plugin (C7)
This commit is contained in:
1 parent
4dbce08379
commit
8c1e02af89
123 files changed
+4808
-1912
No files matched your search
@@ -1715,11 +1715,11 @@ deciding, not the mechanism.
|
||||
| Capability | Risk | Meaning |
|
||||
| -------------------- | -------- | -------------------------------------------------------------- |
|
||||
| `credentials:read` | critical | See plaintext secrets for any reachable host |
|
||||
| `system:tls` | critical | Replace the server certificate and answer ACME challenges |
|
||||
| `ssh:connect` | high | Run commands on hosts it is connected to |
|
||||
| `process:spawn` | high | Run programs on the Termix server |
|
||||
| `users:write` | high | Create and change user accounts |
|
||||
| `auth:provide` | high | Add a login method, second factor or SSH auth type |
|
||||
| `system:tls` | high | Request and replace the server certificate |
|
||||
| `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 |
|
||||
@@ -1801,6 +1801,7 @@ Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`.
|
||||
| `ctx.fetch` | `network:outbound` | **B17**, signal **B18**, tls **C4** |
|
||||
| `ctx.process.run` / `.ensureBinary` | `process:spawn` (+ `network:outbound` to download) | **C3** |
|
||||
| `ctx.auth.registerKeyboardInteractiveHandler` | `auth:provide` | **C3** |
|
||||
| `ctx.system.*` | `system:tls` | **C7** |
|
||||
|
||||
**B11** added `ctx.capabilities.has(capability)` / `.require(capability)`, a
|
||||
generic check for a capability no other ctx member wraps. Unlike every other
|
||||
@@ -1972,6 +1973,33 @@ keyType })` saves a key pair as a new encrypted credential and returns its
|
||||
needs one (termix-identity publishes the key first and removes that row if
|
||||
the save fails).
|
||||
|
||||
**C7** added `ctx.system`, the server certificate, all behind `system:tls`
|
||||
(critical: whoever holds it decides what certificate every browser is shown)
|
||||
and all audited. Core keeps serving HTTPS: at boot it makes a self-signed
|
||||
certificate only when none exists or its own self-signed one is expiring, and
|
||||
it never replaces a certificate a CA issued, so a certificate keeps being
|
||||
served, unrenewed, with its plugin disabled. The code is in `src/backend/tls/`.
|
||||
|
||||
- `tlsStatus()` is `{ enabled, certificate, renewal }`: the served leaf (names,
|
||||
issuer, dates, `selfSigned`, fingerprint) and the plugin that renews it.
|
||||
- `writeTlsCertificate(cert, key)` validates the pair with node's X509 parser
|
||||
(it parses, the leaf has not expired, the key matches) and swaps both files
|
||||
in by rename. It does not reload.
|
||||
- `reloadTls()` serves what is on disk now: `setSecureContext` on the direct
|
||||
HTTPS server (starting it if HTTPS was off), or an nginx reload in Docker.
|
||||
- `publishHttpChallenge(token, content)` answers
|
||||
`GET /.well-known/acme-challenge/<token>`, which core serves publicly
|
||||
because no `/plugin-api/` path can be on it. nginx proxies that location
|
||||
to the backend on both the HTTPS server and the port 80 redirect server.
|
||||
- `registerTlsRenewer()` tells core the plugin renews the certificate. Admin
|
||||
settings warn with the expiry date when a CA-issued certificate has no
|
||||
renewer. Both registrations are dropped on deactivate.
|
||||
|
||||
Uploading a certificate by hand stays core (`POST /users/tls-certificate`),
|
||||
since it is not ACME. The acme-ssl plugin is the first caller: it runs
|
||||
acme-client with every request sent through `ctx.fetch` (an axios adapter),
|
||||
keeps its account key sealed in `ctx.kv`, and checks every 12 hours.
|
||||
|
||||
**C3** added `ctx.process`, for a plugin that ships a program (opkssh):
|
||||
|
||||
- `run(file, args, { env, cwd, timeoutMs })` starts it without a shell and
|
||||
@@ -2619,6 +2647,7 @@ 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), termix-identity (C6),
|
||||
acme-ssl (C7),
|
||||
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
|
||||
|
||||
@@ -671,3 +671,19 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
|
||||
`/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.
|
||||
- **C7 (acme-ssl):** the 2.8 leftovers stay on disk and in the database for
|
||||
one release: the `acme_ssl_settings` settings row (the Cloudflare token is
|
||||
already stripped from it), `DATA_DIR/certbot` and `DATA_DIR/acme-webroot`.
|
||||
Delete them in the release after 2.9.0. Owner: after D4.
|
||||
- **C7 (acme-ssl):** the docs page linked from Admin Settings > HTTPS
|
||||
certificate (`docs.termix.site/features/networking/ssl`) still describes
|
||||
certbot and the webroot challenge. Point it at the plugin. Owner: D0.
|
||||
- **C7, pre-existing:** `AutoSSLSetup.setupEnvironmentVariables` in
|
||||
`src/backend/tls/self-signed.ts` rewrites `SSL_CERT_PATH`, `SSL_KEY_PATH`
|
||||
and `SSL_DOMAIN` to the defaults in `DATA_DIR/.env` on every boot, so custom
|
||||
certificate paths set in the environment are lost after the first start.
|
||||
Owner: D0.
|
||||
- **C7, pre-existing:** `src/backend/scripts/setup-ssl.sh` and
|
||||
`enable-ssl.sh` are not called by anything and write a `JWT_SECRET` and
|
||||
`DATABASE_KEY` to `.env`, which SystemCrypto now owns. Delete or rewrite
|
||||
them. Owner: D0.
|
||||
@@ -1631,6 +1631,65 @@ export interface PluginSchedule {
|
||||
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>;
|
||||
}
|
||||
|
||||
export interface PluginContext {
|
||||
readonly pluginId: string;
|
||||
readonly manifest: PluginManifest;
|
||||
@@ -1670,6 +1729,8 @@ export interface PluginContext {
|
||||
readonly fetch: PluginFetch;
|
||||
/** Programs on the Termix server. Needs process:spawn. */
|
||||
readonly process: PluginProcess;
|
||||
/** The server certificate. Needs system:tls. */
|
||||
readonly system: PluginSystem;
|
||||
|
||||
/**
|
||||
* Runs `fn` with `userId` as the acting user, for background work that has
|
||||
|
||||
@@ -39,12 +39,12 @@ function entry(id: string, risk: CapabilityRisk): CapabilityInfo {
|
||||
*/
|
||||
export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [
|
||||
entry("credentials:read", "critical"),
|
||||
entry("system:tls", "critical"),
|
||||
|
||||
entry("ssh:connect", "high"),
|
||||
entry("process:spawn", "high"),
|
||||
entry("users:write", "high"),
|
||||
entry("auth:provide", "high"),
|
||||
entry("system:tls", "high"),
|
||||
entry("device:serial", "high"),
|
||||
|
||||
entry("hosts:write", "medium"),
|
||||
|
||||
@@ -71,6 +71,8 @@ import type {
|
||||
PluginNotificationChannel,
|
||||
PluginFetchInit,
|
||||
PluginBinarySpec,
|
||||
PluginTlsCertificateInfo,
|
||||
PluginTlsStatus,
|
||||
PluginKeyboardInteractiveHandler,
|
||||
PluginProcessHandle,
|
||||
PluginProcessOptions,
|
||||
@@ -140,6 +142,19 @@ export interface FakeContextOptions {
|
||||
* ensureBinary it answers "/tmp/<pluginId>/bin/<name>".
|
||||
*/
|
||||
process?: FakeProcessOptions;
|
||||
/**
|
||||
* What ctx.system.tlsStatus answers before any write. renewal is filled in
|
||||
* from registerTlsRenewer calls.
|
||||
*/
|
||||
tlsStatus?: Omit<PluginTlsStatus, "renewal">;
|
||||
/**
|
||||
* Checks a ctx.system.writeTlsCertificate call. Throw to reject the pair.
|
||||
* Without it every pair is accepted and described with placeholder info.
|
||||
*/
|
||||
validateTls?: (
|
||||
certificatePem: string,
|
||||
privateKeyPem: string,
|
||||
) => PluginTlsCertificateInfo;
|
||||
/**
|
||||
* Makes ctx.auth.recordEnrollment refuse the way core's policy does (trusted
|
||||
* proxy login on, password login off), with this message and a 409.
|
||||
@@ -297,6 +312,15 @@ export interface FakePluginContext {
|
||||
}>;
|
||||
/** Every ctx.process.ensureBinary call, in order. */
|
||||
binaries: PluginBinarySpec[];
|
||||
/** What ctx.system did: accepted writes, reloads, live challenges and renewers. */
|
||||
tls: {
|
||||
writes: Array<{ certificatePem: string; privateKeyPem: string }>;
|
||||
reloads: number;
|
||||
/** ctx.system calls by name, in order. */
|
||||
calls: string[];
|
||||
challenges: Map<string, string>;
|
||||
renewers: number;
|
||||
};
|
||||
/** Every ctx.audit.record entry, in order. */
|
||||
audits: Array<{ action: string; success: boolean; [key: string]: unknown }>;
|
||||
/** Every ctx.hosts.recordActivity call, in order. */
|
||||
@@ -403,6 +427,17 @@ export function createFakeContext(
|
||||
const fetches: FakePluginContext["fetches"] = [];
|
||||
const processRuns: FakePluginContext["processRuns"] = [];
|
||||
const binaries: FakePluginContext["binaries"] = [];
|
||||
const tls: FakePluginContext["tls"] = {
|
||||
writes: [],
|
||||
reloads: 0,
|
||||
calls: [],
|
||||
challenges: new Map(),
|
||||
renewers: 0,
|
||||
};
|
||||
let tlsState: Omit<PluginTlsStatus, "renewal"> = options.tlsStatus ?? {
|
||||
enabled: true,
|
||||
certificate: null,
|
||||
};
|
||||
const secretStore = new Map<string, string>();
|
||||
const audits: FakePluginContext["audits"] = [];
|
||||
const activities: FakePluginContext["activities"] = [];
|
||||
@@ -1044,6 +1079,62 @@ export function createFakeContext(
|
||||
},
|
||||
},
|
||||
|
||||
system: {
|
||||
tlsStatus: async () => {
|
||||
tls.calls.push("tlsStatus");
|
||||
return {
|
||||
...tlsState,
|
||||
renewal: tls.renewers > 0 ? { pluginId, pluginName: pluginId } : null,
|
||||
};
|
||||
},
|
||||
writeTlsCertificate: async (certificatePem, privateKeyPem) => {
|
||||
tls.calls.push("writeTlsCertificate");
|
||||
const info = options.validateTls
|
||||
? options.validateTls(certificatePem, privateKeyPem)
|
||||
: {
|
||||
subject: "CN=fake",
|
||||
issuer: "CN=fake-ca",
|
||||
names: [],
|
||||
notBefore: new Date().toISOString(),
|
||||
notAfter: new Date(Date.now() + 90 * 86_400_000).toISOString(),
|
||||
selfSigned: false,
|
||||
fingerprint: "00",
|
||||
};
|
||||
tls.writes.push({ certificatePem, privateKeyPem });
|
||||
tlsState = { ...tlsState, certificate: info };
|
||||
return info;
|
||||
},
|
||||
reloadTls: async () => {
|
||||
tls.calls.push("reloadTls");
|
||||
tls.reloads++;
|
||||
return { applied: true, message: "reloaded" };
|
||||
},
|
||||
publishHttpChallenge: async (token, content) => {
|
||||
tls.calls.push("publishHttpChallenge");
|
||||
tls.challenges.set(token, content);
|
||||
let live = true;
|
||||
const remove = () => {
|
||||
if (!live) return;
|
||||
live = false;
|
||||
tls.challenges.delete(token);
|
||||
};
|
||||
disposals.push(remove);
|
||||
return remove;
|
||||
},
|
||||
registerTlsRenewer: async () => {
|
||||
tls.calls.push("registerTlsRenewer");
|
||||
tls.renewers++;
|
||||
let live = true;
|
||||
const remove = () => {
|
||||
if (!live) return;
|
||||
live = false;
|
||||
tls.renewers--;
|
||||
};
|
||||
disposals.push(remove);
|
||||
return remove;
|
||||
},
|
||||
},
|
||||
|
||||
asUser: async (userId, fn) => {
|
||||
const previous = actor;
|
||||
actor = userId;
|
||||
@@ -1080,6 +1171,7 @@ export function createFakeContext(
|
||||
fetches,
|
||||
processRuns,
|
||||
binaries,
|
||||
tls,
|
||||
secretStore,
|
||||
audits,
|
||||
activities,
|
||||
@@ -1148,6 +1240,10 @@ export interface MockContextOptions {
|
||||
/** See FakeContextOptions. */
|
||||
process?: FakeProcessOptions;
|
||||
/** See FakeContextOptions. */
|
||||
tlsStatus?: FakeContextOptions["tlsStatus"];
|
||||
/** See FakeContextOptions. */
|
||||
validateTls?: FakeContextOptions["validateTls"];
|
||||
/** See FakeContextOptions. */
|
||||
refuseEnrollment?: string;
|
||||
/** See FakeContextOptions. */
|
||||
baseUrl?: string;
|
||||
@@ -1171,7 +1267,7 @@ export interface MockPluginContext extends FakePluginContext {
|
||||
* topic outside the plugin's own namespace, settings:read-core on
|
||||
* ctx.settings.readCore, ssh:connect plus credentials:use on ctx.ssh,
|
||||
* notify:send on ctx.notify, network:outbound on ctx.fetch, process:spawn on
|
||||
* ctx.process, and auth:provide on ctx.auth. Reading a plugin's own settings is deliberately
|
||||
* ctx.process, system:tls on ctx.system, and auth:provide on ctx.auth. Reading a plugin's own settings is deliberately
|
||||
* ungated. As the SDK grows a member, add its gate here in the same shape.
|
||||
*/
|
||||
export function createMockCtx(
|
||||
@@ -1199,6 +1295,8 @@ export function createMockCtx(
|
||||
notificationChannels: options.notificationChannels,
|
||||
fetch: options.fetch,
|
||||
process: options.process,
|
||||
tlsStatus: options.tlsStatus,
|
||||
validateTls: options.validateTls,
|
||||
refuseEnrollment: options.refuseEnrollment,
|
||||
baseUrl: options.baseUrl,
|
||||
linkedUsers: options.linkedUsers,
|
||||
@@ -1537,6 +1635,29 @@ export function createMockCtx(
|
||||
},
|
||||
},
|
||||
|
||||
system: {
|
||||
tlsStatus: async () => {
|
||||
require("system:tls");
|
||||
return ctx.system.tlsStatus();
|
||||
},
|
||||
writeTlsCertificate: async (certificatePem, privateKeyPem) => {
|
||||
require("system:tls");
|
||||
return ctx.system.writeTlsCertificate(certificatePem, privateKeyPem);
|
||||
},
|
||||
reloadTls: async () => {
|
||||
require("system:tls");
|
||||
return ctx.system.reloadTls();
|
||||
},
|
||||
publishHttpChallenge: async (token, content) => {
|
||||
require("system:tls");
|
||||
return ctx.system.publishHttpChallenge(token, content);
|
||||
},
|
||||
registerTlsRenewer: async () => {
|
||||
require("system:tls");
|
||||
return ctx.system.registerTlsRenewer();
|
||||
},
|
||||
},
|
||||
|
||||
secrets: {
|
||||
...ctx.secrets,
|
||||
get: async (key) => {
|
||||
|
||||
Reference in new issue
Block a user