feat: move acme certificate automation into a plugin (C7)

This commit is contained in:
LukeGus committed 2026-09-24 21:19:27 -05:00
1 parent 4dbce08379
commit 8c1e02af89
123 files changed
+4808 -1912

No files matched your search

+61
View File
@@ -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
+1 -1
View File
@@ -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"),
+122 -1
View File
@@ -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) => {