mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 22:25:24 +00:00
163 lines
4.5 KiB
TypeScript
163 lines
4.5 KiB
TypeScript
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);
|
|
}
|