mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-11-01 08:05:23 +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
@@ -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() };
|
||||
}
|
||||
Reference in new issue
Block a user