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; 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; /** Issues now, whatever the served certificate looks like. */ issue: () => Promise; /** The scheduled check: issues only when renewal is on and due. */ check: (now?: Date) => Promise; /** Registers or drops the renewer to match the current settings. */ syncRenewer: () => Promise; } export function createAcmeRunner( ctx: PluginContext, issue: IssueFn, ): AcmeRunner { let running: Promise | null = null; let dropRenewer: (() => void) | null = null; const state = async (): Promise => { const stored = (await ctx.kv.get(STATE_KEY)) as Partial | null; return { lastAttemptAt: stored?.lastAttemptAt ?? null, lastIssuedAt: stored?.lastIssuedAt ?? null, lastError: stored?.lastError ?? null, }; }; const record = async (patch: Partial) => { await ctx.kv.set(STATE_KEY, { ...(await state()), ...patch }); }; const runIssue = async (): Promise => { 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); }