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

+217
View File
@@ -0,0 +1,217 @@
/**
* Issuing a certificate with acme-client. Every request it makes goes through
* ctx.fetch, so ACME traffic gets core's outbound guard and audit like any
* other plugin request.
*/
import * as acme from "acme-client";
import type { PluginContext, PluginFetch } from "@termix/plugin-sdk/backend";
import { createTxtRecord } from "./cloudflare.js";
import { directoryUrlFor, type AcmeSettings } from "./settings.js";
const ACCOUNT_KEY = "account";
const DNS_PROPAGATION_MS = 30_000;
interface StoredAccount {
directoryUrl: string;
sealedKey: string;
url: string | null;
}
export interface IssuedCertificate {
certificate: string;
privateKey: string;
}
type AxiosLikeConfig = {
url?: string;
method?: string;
headers?: unknown;
data?: unknown;
timeout?: number;
validateStatus?: ((status: number) => boolean) | null;
};
function plainHeaders(headers: unknown): Record<string, string> {
const source =
headers && typeof (headers as { toJSON?: unknown }).toJSON === "function"
? (headers as { toJSON: () => Record<string, unknown> }).toJSON()
: ((headers ?? {}) as Record<string, unknown>);
const out: Record<string, string> = {};
for (const [key, value] of Object.entries(source)) {
if (typeof value === "string" || typeof value === "number") {
out[key] = String(value);
}
}
return out;
}
/**
* An axios adapter over ctx.fetch. It settles the way axios' own adapters do,
* because acme-client's retry interceptor relies on a rejected error that
* carries the response.
*/
export function createFetchAdapter(
fetch: PluginFetch,
allowPrivateHosts: readonly string[] = [],
) {
return async (config: AxiosLikeConfig) => {
const method = (config.method ?? "get").toUpperCase();
const response = await fetch(String(config.url), {
method,
headers: plainHeaders(config.headers),
body:
config.data == null
? undefined
: typeof config.data === "string"
? config.data
: JSON.stringify(config.data),
timeoutMs: config.timeout || 30_000,
allowPrivateHosts,
});
const headers: Record<string, string> = {};
response.headers.forEach((value, key) => {
headers[key] = value;
});
const result = {
data: method === "HEAD" ? "" : await response.text(),
status: response.status,
statusText: response.statusText,
headers,
config,
request: {},
};
const validate = config.validateStatus;
if (!result.status || !validate || validate(result.status)) return result;
throw Object.assign(
new Error(`Request failed with status code ${result.status}`),
{
isAxiosError: true,
code: result.status >= 500 ? "ERR_BAD_RESPONSE" : "ERR_BAD_REQUEST",
config,
response: result,
},
);
};
}
/** Private hosts a custom directory on the local network may use. */
function privateHostsFor(settings: AcmeSettings): string[] {
if (settings.provider !== "custom") return [];
try {
return [new URL(settings.directoryUrl).hostname];
} catch {
return [];
}
}
async function loadAccount(
ctx: PluginContext,
directoryUrl: string,
): Promise<{ key: string; url: string | null }> {
const stored = (await ctx.kv.get(ACCOUNT_KEY)) as StoredAccount | null;
if (stored && stored.directoryUrl === directoryUrl) {
const key = await ctx.secrets.unseal(stored.sealedKey);
if (key) return { key, url: stored.url };
}
const key = (await acme.crypto.createPrivateKey()).toString();
await ctx.kv.set(ACCOUNT_KEY, {
directoryUrl,
sealedKey: await ctx.secrets.seal(key),
url: null,
} satisfies StoredAccount);
return { key, url: null };
}
async function saveAccountUrl(
ctx: PluginContext,
directoryUrl: string,
url: string,
): Promise<void> {
const stored = (await ctx.kv.get(ACCOUNT_KEY)) as StoredAccount | null;
if (!stored || stored.directoryUrl !== directoryUrl || stored.url === url) {
return;
}
await ctx.kv.set(ACCOUNT_KEY, { ...stored, url });
}
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
/** Runs one ACME order for `settings.domain` and returns the new pair. */
export async function issueCertificate(
ctx: PluginContext,
settings: AcmeSettings,
options: { propagationMs?: number } = {},
): Promise<IssuedCertificate> {
const directoryUrl = directoryUrlFor(settings);
acme.axios.defaults.adapter = createFetchAdapter(
ctx.fetch,
privateHostsFor(settings),
) as never;
const account = await loadAccount(ctx, directoryUrl);
const client = new acme.Client({
directoryUrl,
accountKey: account.key,
...(account.url ? { accountUrl: account.url } : {}),
});
const [key, csr] = await acme.crypto.createCsr({
commonName: settings.domain,
altNames: [settings.domain],
});
const cleanups = new Map<string, () => void | Promise<void>>();
const certificate = await client.auto({
csr,
email: settings.email,
termsOfServiceAgreed: true,
// Checking our own answer first needs port 80 or DNS from inside the
// container, which often is not how the CA sees it.
skipChallengeVerification: true,
challengePriority:
settings.challengeType === "dns-cloudflare" ? ["dns-01"] : ["http-01"],
challengeCreateFn: async (authz, challenge, keyAuthorization) => {
if (challenge.type === "http-01") {
cleanups.set(
challenge.token,
await ctx.system.publishHttpChallenge(
challenge.token,
keyAuthorization,
),
);
return;
}
if (challenge.type === "dns-01") {
cleanups.set(
challenge.token,
await createTxtRecord(
ctx.fetch,
settings.cloudflareToken,
`_acme-challenge.${authz.identifier.value}`,
keyAuthorization,
),
);
await sleep(options.propagationMs ?? DNS_PROPAGATION_MS);
return;
}
throw new Error(
`Unsupported challenge type ${(challenge as { type: string }).type}`,
);
},
challengeRemoveFn: async (_authz, challenge) => {
const cleanup = cleanups.get(challenge.token);
cleanups.delete(challenge.token);
try {
await cleanup?.();
} catch (error) {
ctx.log.warn(
`Could not clean up the ${challenge.type} challenge: ${error instanceof Error ? error.message : String(error)}`,
);
}
},
});
await saveAccountUrl(ctx, directoryUrl, client.getAccountUrl());
return { certificate, privateKey: key.toString() };
}
@@ -0,0 +1,78 @@
import type { PluginFetch } from "@termix/plugin-sdk/backend";
const API = "https://api.cloudflare.com/client/v4";
interface CloudflareEnvelope<T> {
success: boolean;
errors?: Array<{ message?: string }>;
result: T;
}
async function call<T>(
fetch: PluginFetch,
token: string,
path: string,
init: { method?: string; body?: unknown } = {},
): Promise<T> {
const response = await fetch(`${API}${path}`, {
method: init.method ?? "GET",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: init.body === undefined ? undefined : JSON.stringify(init.body),
});
const payload = (await response
.json()
.catch(() => null)) as CloudflareEnvelope<T> | null;
if (!response.ok || !payload?.success) {
const reason =
payload?.errors
?.map((error) => error.message)
.filter(Boolean)
.join("; ") || `HTTP ${response.status}`;
throw new Error(`Cloudflare API error: ${reason}`);
}
return payload.result;
}
/** The zone holding `name`, found by trying each parent domain. */
export async function findZoneId(
fetch: PluginFetch,
token: string,
name: string,
): Promise<string> {
const labels = name.replace(/\.$/, "").split(".");
for (let i = 0; i < labels.length - 1; i++) {
const candidate = labels.slice(i).join(".");
const zones = await call<Array<{ id: string; name: string }>>(
fetch,
token,
`/zones?name=${encodeURIComponent(candidate)}`,
);
const zone = zones.find((entry) => entry.name === candidate);
if (zone) return zone.id;
}
throw new Error(`No Cloudflare zone found for ${name}`);
}
/** Creates the TXT record and returns a function that deletes it. */
export async function createTxtRecord(
fetch: PluginFetch,
token: string,
name: string,
content: string,
): Promise<() => Promise<void>> {
const zoneId = await findZoneId(fetch, token, name);
const record = await call<{ id: string }>(
fetch,
token,
`/zones/${zoneId}/dns_records`,
{ method: "POST", body: { type: "TXT", name, content, ttl: 120 } },
);
return async () => {
await call(fetch, token, `/zones/${zoneId}/dns_records/${record.id}`, {
method: "DELETE",
});
};
}
+50
View File
@@ -0,0 +1,50 @@
import type { Router } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { issueCertificate } from "./acme.js";
import {
CHECK_INTERVAL_MS,
createAcmeRunner,
watchSettings,
type IssueFn,
} from "./runner.js";
import { registerRoutes } from "./routes.js";
/** `issue` is swapped out by tests; the plugin always uses acme-client. */
export async function activateWith(ctx: PluginContext, issue: IssueFn) {
const runner = createAcmeRunner(ctx, issue);
await runner.syncRenewer();
watchSettings(ctx, () => {
runner
.syncRenewer()
.catch((error) =>
ctx.log.warn(
`Could not update the renewal registration: ${error instanceof Error ? error.message : String(error)}`,
),
);
});
// The first check lands within a minute of boot, after core's boot-time
// settings migrations, so it also picks up settings moved from 2.8.
ctx.schedule.every(
CHECK_INTERVAL_MS,
async () => {
await runner.syncRenewer();
await runner.check();
},
{ jitterMs: 60_000 },
);
registerRoutes(ctx, ctx.http.router<Router>(), runner);
return runner;
}
export async function activate(ctx: PluginContext) {
await activateWith(ctx, (pluginCtx, settings) =>
issueCertificate(pluginCtx, settings),
);
}
export async function deactivate() {
// Everything above was registered through ctx and is disposed by core.
}
+28
View File
@@ -0,0 +1,28 @@
import type { PluginTlsStatus } from "@termix/plugin-sdk/backend";
export const RENEW_BEFORE_MS = 30 * 86_400_000;
export type RenewalReason = "missing" | "self-signed" | "domain" | "expiring";
/**
* Whether the served certificate should be replaced for `domain`: there is
* none, it is core's self-signed one, it does not cover the domain, or it
* expires within 30 days.
*/
export function renewalReason(
status: PluginTlsStatus,
domain: string,
now: Date = new Date(),
): RenewalReason | null {
const cert = status.certificate;
if (!cert) return "missing";
if (cert.selfSigned) return "self-signed";
const wanted = domain.toLowerCase();
if (!cert.names.some((name) => name.toLowerCase() === wanted)) {
return "domain";
}
if (new Date(cert.notAfter).getTime() - now.getTime() < RENEW_BEFORE_MS) {
return "expiring";
}
return null;
}
+73
View File
@@ -0,0 +1,73 @@
import type { Request, RequestHandler, Response, Router } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { AcmeNotConfiguredError, type AcmeRunner } from "./runner.js";
import { missingSetting, readSettings } from "./settings.js";
export function registerRoutes(
ctx: PluginContext,
router: Router,
runner: AcmeRunner,
): void {
router.use(ctx.rbac.require("manage") as unknown as RequestHandler);
/**
* @openapi
* /plugin-api/acme-ssl/status:
* get:
* summary: ACME certificate status
* description: The certificate core serves, whether ACME is configured, and the last issue attempt.
* tags: [ACME Certificates]
* responses:
* 200:
* description: Status.
* 403:
* description: Missing the acme-ssl.manage permission.
*/
router.get("/status", async (_req: Request, res: Response) => {
const settings = await readSettings(ctx);
res.json({
tls: await ctx.system.tlsStatus(),
state: await runner.state(),
autoRenew: settings.autoRenew,
missing: missingSetting(settings),
});
});
/**
* @openapi
* /plugin-api/acme-ssl/request:
* post:
* summary: Request a certificate now
* description: Runs an ACME order for the configured domain, installs the certificate and reloads HTTPS.
* tags: [ACME Certificates]
* responses:
* 200:
* description: Certificate issued and installed.
* 400:
* description: A required setting is missing.
* 403:
* description: Missing the acme-ssl.manage permission.
* 500:
* description: The ACME order failed.
*/
router.post("/request", async (_req: Request, res: Response) => {
try {
const result = await runner.issue();
res.json({
success: true,
reloadMessage: result.reload.message,
tls: result.tls,
state: await runner.state(),
});
} catch (error) {
if (error instanceof AcmeNotConfiguredError) {
return res
.status(400)
.json({ error: error.message, missing: error.field });
}
const message = error instanceof Error ? error.message : String(error);
ctx.log.warn(`Certificate request failed: ${message}`);
res.status(500).json({ error: `Certificate request failed: ${message}` });
}
});
}
+162
View File
@@ -0,0 +1,162 @@
import type {
PluginContext,
PluginTlsReloadResult,
PluginTlsStatus,
} from "@termix/plugin-sdk/backend";
import type { IssuedCertificate } from "./acme.js";
import { renewalReason } from "./renewal.js";
import {
SETTING_KEYS,
missingSetting,
readSettings,
type AcmeSettings,
} from "./settings.js";
export const CHECK_INTERVAL_MS = 12 * 60 * 60 * 1000;
/** After a failed attempt, the scheduled check waits this long to retry. */
export const RETRY_AFTER_MS = 6 * 60 * 60 * 1000;
const STATE_KEY = "state";
export interface AcmeState {
lastAttemptAt: string | null;
lastIssuedAt: string | null;
lastError: string | null;
}
export type IssueFn = (
ctx: PluginContext,
settings: AcmeSettings,
) => Promise<IssuedCertificate>;
export class AcmeNotConfiguredError extends Error {
constructor(readonly field: string) {
super(`ACME is not configured: ${field} is missing`);
}
}
export interface IssueResult {
tls: PluginTlsStatus;
reload: PluginTlsReloadResult;
}
export interface AcmeRunner {
state: () => Promise<AcmeState>;
/** Issues now, whatever the served certificate looks like. */
issue: () => Promise<IssueResult>;
/** The scheduled check: issues only when renewal is on and due. */
check: (now?: Date) => Promise<boolean>;
/** Registers or drops the renewer to match the current settings. */
syncRenewer: () => Promise<void>;
}
export function createAcmeRunner(
ctx: PluginContext,
issue: IssueFn,
): AcmeRunner {
let running: Promise<IssueResult> | null = null;
let dropRenewer: (() => void) | null = null;
const state = async (): Promise<AcmeState> => {
const stored = (await ctx.kv.get(STATE_KEY)) as Partial<AcmeState> | null;
return {
lastAttemptAt: stored?.lastAttemptAt ?? null,
lastIssuedAt: stored?.lastIssuedAt ?? null,
lastError: stored?.lastError ?? null,
};
};
const record = async (patch: Partial<AcmeState>) => {
await ctx.kv.set(STATE_KEY, { ...(await state()), ...patch });
};
const runIssue = async (): Promise<IssueResult> => {
const settings = await readSettings(ctx);
const missing = missingSetting(settings);
if (missing) throw new AcmeNotConfiguredError(missing);
const attemptAt = new Date().toISOString();
try {
const issued = await issue(ctx, settings);
await ctx.system.writeTlsCertificate(
issued.certificate,
issued.privateKey,
);
const reload = await ctx.system.reloadTls();
await record({
lastAttemptAt: attemptAt,
lastIssuedAt: new Date().toISOString(),
lastError: null,
});
ctx.log.info(`Certificate issued for ${settings.domain}`);
return { tls: await ctx.system.tlsStatus(), reload };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await record({ lastAttemptAt: attemptAt, lastError: message });
throw error;
}
};
const runner: AcmeRunner = {
state,
issue: () => {
running ??= runIssue().finally(() => {
running = null;
});
return running;
},
check: async (now = new Date()) => {
const settings = await readSettings(ctx);
if (!settings.autoRenew || missingSetting(settings)) return false;
const last = await state();
if (
last.lastError &&
last.lastAttemptAt &&
now.getTime() - new Date(last.lastAttemptAt).getTime() < RETRY_AFTER_MS
) {
return false;
}
const reason = renewalReason(
await ctx.system.tlsStatus(),
settings.domain,
now,
);
if (!reason) return false;
ctx.log.info(
`Renewing the certificate for ${settings.domain} (${reason})`,
);
try {
await runner.issue();
return true;
} catch (error) {
ctx.log.error(
"Certificate renewal failed",
error instanceof Error ? error : new Error(String(error)),
);
return false;
}
},
syncRenewer: async () => {
const settings = await readSettings(ctx);
const wanted = settings.autoRenew && !missingSetting(settings);
if (wanted && !dropRenewer) {
dropRenewer = await ctx.system.registerTlsRenewer();
} else if (!wanted && dropRenewer) {
dropRenewer();
dropRenewer = null;
}
},
};
return runner;
}
export function watchSettings(ctx: PluginContext, onChange: () => void): void {
for (const key of SETTING_KEYS) ctx.settings.onChange(key, onChange);
}
+77
View File
@@ -0,0 +1,77 @@
import type { PluginContext } from "@termix/plugin-sdk/backend";
export type AcmeProvider = "letsencrypt" | "letsencrypt-staging" | "custom";
export type AcmeChallengeType = "http-01" | "dns-cloudflare";
export interface AcmeSettings {
autoRenew: boolean;
domain: string;
email: string;
provider: AcmeProvider;
directoryUrl: string;
challengeType: AcmeChallengeType;
cloudflareToken: string;
}
export const SETTING_KEYS = [
"autoRenew",
"domain",
"email",
"provider",
"directoryUrl",
"challengeType",
"cloudflareToken",
] as const;
export const DIRECTORIES: Record<Exclude<AcmeProvider, "custom">, string> = {
letsencrypt: "https://acme-v02.api.letsencrypt.org/directory",
"letsencrypt-staging":
"https://acme-staging-v02.api.letsencrypt.org/directory",
};
const text = (value: unknown) =>
typeof value === "string" ? value.trim() : "";
export async function readSettings(ctx: PluginContext): Promise<AcmeSettings> {
const all = await ctx.settings.getAll("admin");
const provider = text(all.provider);
const challengeType = text(all.challengeType);
return {
autoRenew: all.autoRenew === true,
domain: text(all.domain).toLowerCase(),
email: text(all.email),
provider:
provider === "letsencrypt-staging" || provider === "custom"
? provider
: "letsencrypt",
directoryUrl: text(all.directoryUrl),
challengeType:
challengeType === "dns-cloudflare" ? challengeType : "http-01",
cloudflareToken: text(all.cloudflareToken),
};
}
export function directoryUrlFor(settings: AcmeSettings): string {
return settings.provider === "custom"
? settings.directoryUrl
: DIRECTORIES[settings.provider];
}
/** The reason issuing cannot start, or null when everything is set. */
export function missingSetting(settings: AcmeSettings): string | null {
if (!settings.domain) return "domain";
if (!settings.email) return "email";
if (
settings.provider === "custom" &&
!/^https:\/\//i.test(settings.directoryUrl)
) {
return "directoryUrl";
}
if (
settings.challengeType === "dns-cloudflare" &&
!settings.cloudflareToken
) {
return "cloudflareToken";
}
return null;
}
@@ -0,0 +1,121 @@
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import {
usePluginApi,
useTranslation,
type SettingsComponentProps,
} from "@termix/plugin-sdk/frontend";
import { Button } from "@termix/plugin-sdk/ui";
export interface AcmeStatus {
tls: {
enabled: boolean;
certificate: {
issuer: string;
names: string[];
notAfter: string;
selfSigned: boolean;
} | null;
};
state: {
lastAttemptAt: string | null;
lastIssuedAt: string | null;
lastError: string | null;
};
autoRenew: boolean;
missing: string | null;
}
function errorMessage(error: unknown): string {
const data = (error as { response?: { data?: { error?: string } } })?.response
?.data;
if (data?.error) return data.error;
return error instanceof Error ? error.message : String(error);
}
export function AcmeStatusSetting({ running }: SettingsComponentProps) {
const { t } = useTranslation();
const api = usePluginApi();
const [status, setStatus] = useState<AcmeStatus | null>(null);
const [requesting, setRequesting] = useState(false);
const load = useCallback(async () => {
try {
const { data } = await api.get<AcmeStatus>("/status");
setStatus(data);
} catch {
setStatus(null);
}
}, [api]);
useEffect(() => {
if (running) void load();
}, [running, load]);
async function request() {
setRequesting(true);
try {
const { data } = await api.post<{ reloadMessage?: string }>("/request");
toast.success(t("status.issued"));
if (data.reloadMessage) toast.info(data.reloadMessage);
} catch (error) {
toast.error(errorMessage(error));
} finally {
setRequesting(false);
await load();
}
}
const cert = status?.tls.certificate;
return (
<div className="flex flex-col gap-2 border border-border bg-background/50 p-2">
<span className="text-xs font-medium">{t("status.title")}</span>
{cert ? (
<>
<span className="text-[10px] text-muted-foreground">
{t("status.names", { names: cert.names.join(", ") || "-" })}
</span>
<span className="text-[10px] text-muted-foreground">
{t("status.issuer", { issuer: cert.issuer })}
</span>
<span className="text-[10px] text-muted-foreground">
{t("status.expires", {
date: new Date(cert.notAfter).toLocaleDateString(),
})}
</span>
</>
) : (
<span className="text-[10px] text-muted-foreground">
{t("status.none")}
</span>
)}
{status?.state.lastIssuedAt && (
<span className="text-[10px] text-muted-foreground">
{t("status.lastIssued", {
date: new Date(status.state.lastIssuedAt).toLocaleString(),
})}
</span>
)}
{status?.state.lastError && (
<span className="text-[10px] text-destructive">
{t("status.lastError", { error: status.state.lastError })}
</span>
)}
{status?.missing && (
<span className="text-[10px] text-muted-foreground">
{t("status.missing")}
</span>
)}
<Button
type="button"
variant="outline"
className="rounded-none self-start"
disabled={!running || requesting || !!status?.missing}
onClick={request}
>
{requesting ? t("status.requesting") : t("status.request")}
</Button>
</div>
);
}
+10
View File
@@ -0,0 +1,10 @@
import type { TermixApp } from "@termix/plugin-sdk/frontend";
import { AcmeStatusSetting } from "./AcmeStatusSetting";
export function activate(app: TermixApp): void {
app.registerSettingsComponent("status", AcmeStatusSetting);
}
export function deactivate(): void {
// Registrations through app are disposed by core.
}