recipe_certbot.ts

Recipes for the certbot bootstrap + renewal scripts.

These build the check/run payloads of the certbot script resources a TLS-terminating host declares:

  • certbot_standalone_issue_core — issues the first certificate via certbot certonly --standalone, stopping nginx first to free port 80; the recipe_debian_certbot.ts / recipe_alpine_certbot.ts wrappers ({debian,alpine}_certbot_standalone_issue) pass the service-manager stop command, the one step that differs by distro.
  • certbot_webroot_switch — re-issues once via --webroot to pin the unattended renewal authenticator to webroot (so renewal works while nginx holds port 80), then runs the deploy hook it is given, so the server picks up the re-issued certificate.

Both take extra_domains for the names beside the primary one a cert covers (the www. alias a site redirects), matching the nginx site recipe's redirect_domains.

Both scripts that lead to a request to the CA — the first issuance and the webroot switch — run a DNS preflight before they change anything: each name the certificate covers must resolve, on the target, only to expected_addresses (by default the target's DEPLOY_HOST). A name that doesn't resolve, or resolves anywhere else, refuses the script with the reason on stderr, so a host whose DNS isn't pointed at it yet doesn't spend Let's Encrypt's failed-validation limit. The issuance stops nginx inside the same run, after the preflight passes, so a refusal never leaves nginx stopped, and the stop runs only once the names the request covers have passed. The preflight reads the target's resolver, not the CA's: see CertbotDnsPreflightOptions.expected_addresses for what that can't know.

Renewal scheduling also differs by distro: Debian's certbot package installs certbot.timer automatically; Alpine has none, so recipe_alpine_certbot.ts supplies alpine_certbot_renew_cron.

Neither schedule reloads anything after a renewal, so a server keeps serving the old certificate until it restarts. certbot_deploy_hook_core builds the file that closes that gap: an executable in CERTBOT_DEPLOY_HOOKS_DIR, which certbot runs after every certificate it renews, whatever started the renewal. The distro wrappers (debian_certbot_reload_nginx_hook, alpine_certbot_reload_nginx_hook) pass the reload command.

The scripts embed references to DEPLOY_DOMAIN / DEPLOY_CERTBOT_EMAIL (env_ref) that stay inert in the plan and are resolved in zap's Rust parent at apply time. The domain/email options default to those references so the typical caller passes nothing. Every value is one single-quoted word of the command (single_quote), so a value — a reference's or a literal — can't change what the command runs.

Recipes are imported at config eval time inside zap's sandboxed config eval (fgro eval), which has no network, no writes, and no environment, and where the clock and randomness throw. This module is therefore a set of pure functions: each only assembles strings from its arguments — no I/O, no clock, no randomness, and no import beyond types, env_ref, single_quote and validate_run_parts_name, themselves pure. The output is deterministic for given inputs, so it stays a reliable content-addressed plan artifact.

The caller spreads each script result into a script resource, e.g. zap.script({id: 'ssl-certificate', name: '...', ...debian_certbot_standalone_issue(), depends_on: [...]}), and a deploy hook into a file, e.g. zap.file({id: 'certbot-deploy-hook', name: '...', ...hook, depends_on: ['certbot']}) with const hook = debian_certbot_reload_nginx_hook(), which the webroot switch also takes (certbot_webroot_switch({deploy_hook: hook}), its script depending on the hook's file).

view source

Declarations
#

11 declarations

certbot_deploy_hook_core
#

recipe_certbot.ts view source

(command: string, name: string): RunPartsFile import {certbot_deploy_hook_core} from '@fuzdev/zap/recipe_certbot.js';

Build the path / mode / content of a certbot deploy hook, ready to spread into a zap.file({...}): an executable #!/bin/sh script at CERTBOT_DEPLOY_HOOKS_DIR/<name> that execs command.

A hook file rather than a --deploy-hook flag, so the renewal schedule (certbot.timer, a cron job) needs no arguments and any renewal, scheduled or run by hand, picks it up. On a certbot that also runs deploy hooks after certonly, the hook fires during the first --standalone issuance, while nginx is stopped, so command must succeed as a no-op when the service isn't running; the distro wrappers' commands do. A failing hook is reported by certbot but doesn't undo the renewal.

command

the command the hook runs, as shell words by design (the hook is a shell script); the caller's own literal, never a value from elsewhere

type string

name

the hook's file name: letters, digits, _ and - only, so run-parts-style runners don't skip it and it can't leave the directory

type string

returns

RunPartsFile

a spreadable {path, mode, content} for an inline file resource.

throws

  • when - `name` holds any other character

CERTBOT_DEPLOY_HOOKS_DIR
#

recipe_certbot.ts view source

"/etc/letsencrypt/renewal-hooks/deploy" import {CERTBOT_DEPLOY_HOOKS_DIR} from '@fuzdev/zap/recipe_certbot.js';

The directory certbot runs deploy hooks from: every executable here runs after a certificate is renewed (with certbot renew, the command both distros schedule). Since certbot 5.3.0 they also run after certonly gets a new certificate; certbot 4.0.0, which Debian 13 and Alpine 3.22 ship, runs them for renew only, so certbot_webroot_switch runs its hook itself.

certbot_standalone_issue_core
#

recipe_certbot.ts view source

(stop_command: string, options?: CertbotStandaloneIssueOptions | undefined): CertbotScript import {certbot_standalone_issue_core} from '@fuzdev/zap/recipe_certbot.js';

The neutral core of the ssl-certificate bootstrap scripts ({debian,alpine}_certbot_standalone_issue), which differ only in stop_command.

Issues the first certificate via certbot certonly --standalone. The run passes the DNS preflight, then stops nginx with stop_command (best effort, || true) to free port 80, then runs certbot; the stop and the request take their names from the one options, and a refusal stops nothing. The check short-circuits once the cert exists.

stop_command

the service manager's stop command, as shell words by design; the caller's own literal, never a value from elsewhere

type string

options?

the certificate's names, its registration email, and the addresses the names must resolve to

optional

returns

CertbotScript

the script check/run pair.

throws

  • when - `options.expected_addresses` is empty

certbot_webroot_switch
#

recipe_certbot.ts view source

(options: CertbotWebrootSwitchOptions): CertbotScript import {certbot_webroot_switch} from '@fuzdev/zap/recipe_certbot.js';

Build the certbot-renewal-webroot script.

Re-issues once via --webroot to pin the unattended renewal authenticator to webroot, then runs deploy_hook; the check short-circuits once the renewal config records webroot (and the live cert covers each of extra_domains). The re-issue is a CA request, for every name the certificate covers, so the run passes the DNS preflight first.

options

returns

CertbotScript

the script check/run pair.

throws

  • when - `options.expected_addresses` is empty

CertbotDnsPreflightOptions
#

recipe_certbot.ts view source

CertbotDnsPreflightOptions import type {CertbotDnsPreflightOptions} from '@fuzdev/zap/recipe_certbot.js';

The DNS preflight's option, shared by every certbot script that leads to a CA request.

expected_addresses?

The IP addresses every name the certificate covers must resolve to, on the target. Before the script changes anything, the target resolves each name with getent ahosts and getent hosts — through its own resolver, so /etc/hosts first — and refuses when a name doesn't resolve, when no address can be read, or when any A/AAAA address either prints is outside this set: Let's Encrypt validates whichever address it picks, so an undeclared AAAA would fail the validation. The two lookups together see every A and AAAA but in one case: on a musl host (Alpine) with IPv6 disabled, ahosts drops the AAAA records and hosts prints only the first, so a name with several AAAA, the first of them declared, passes unseen.

The guard reads the target's resolver, not the CA's. An /etc/hosts line, a split-horizon answer, or a cached one can pass while public DNS doesn't point at the host yet, or refuse while it does. A name /etc/hosts maps to a loopback address refuses, the safe direction. That includes the 127.0.1.1 <name> line a Debian install writes for the host's own name, when that name is the domain: remove the line, or give the host a name that isn't the domain (a tool that manages /etc/hosts may write the line back).

Each value is an IP literal, or a reference resolving to one at apply time; the target checks it and refuses anything else (a host name, say), asking for this option. An IPv6 address is compared as text, lowercased, so write it as getent prints it (the compressed form). Defaults to a reference to DEPLOY_HOST, the target's host; declare the addresses when the host is reached by name or the names also carry an AAAA.

type readonly string[]

CertbotReloadNginxHookOptions
#

recipe_certbot.ts view source

CertbotReloadNginxHookOptions import type {CertbotReloadNginxHookOptions} from '@fuzdev/zap/recipe_certbot.js';

Options for the distro *_certbot_reload_nginx_hook wrappers — neutral; the wrappers differ in the service-manager reload command.

name?

The hook's file name under CERTBOT_DEPLOY_HOOKS_DIR. Defaults to RELOAD_NGINX_HOOK_NAME ('reload-nginx').

type string

CertbotScript
#

recipe_certbot.ts view source

CertbotScript import type {CertbotScript} from '@fuzdev/zap/recipe_certbot.js';

The check/run pair the certbot recipes return (caller supplies id/name/depends_on).

check

Check command — exit 0 means the script is already applied (skip).

type string

run

The command the planner emits when the check fails.

type string

CertbotStandaloneIssueOptions
#

recipe_certbot.ts view source

CertbotStandaloneIssueOptions import type {CertbotStandaloneIssueOptions} from '@fuzdev/zap/recipe_certbot.js';

Options for certbot_standalone_issue_core and its distro wrappers ({debian,alpine}_certbot_standalone_issue) — neutral; the wrappers differ in the service-manager stop command.

inheritance

domain?

The certificate domain. Defaults to a reference to DEPLOY_DOMAIN.

type string

extra_domains?

Further names the certificate covers beside domain, one -d each — e.g. the www. name a site's redirect_domains 301s to domain. The cert stays named for domain. Defaults to none.

type readonly string[]

email?

The registration email. Defaults to a reference to DEPLOY_CERTBOT_EMAIL.

type string

CertbotWebrootSwitchOptions
#

recipe_certbot.ts view source

CertbotWebrootSwitchOptions import type {CertbotWebrootSwitchOptions} from '@fuzdev/zap/recipe_certbot.js';

inheritance

domain?

The certificate domain. Defaults to a reference to DEPLOY_DOMAIN.

type string

extra_domains?

Further names the certificate covers beside domain (see CertbotStandaloneIssueOptions.extra_domains). When set, the check also requires each name in the live cert's subjectAltName, so adding a name to a host whose cert predates it re-issues the cert to cover it. Defaults to none.

type readonly string[]

email?

The registration email. Defaults to a reference to DEPLOY_CERTBOT_EMAIL.

type string

webroot?

The ACME challenge webroot. Defaults to /var/www/certbot.

type string

deploy_hook

The deploy hook the run executes after certonly succeeds — the file a *_certbot_reload_nginx_hook returns, or any with its path — so the server loads the re-issued certificate: certbot before 5.3.0 runs deploy hooks for renew only. On a later certbot the hook runs twice, which a reload tolerates. The switch's script must depend on the hook's file.

type Pick<RunPartsFile, "path">

letsencrypt_live_path
#

recipe_certbot.ts view source

(domain: string, file: "fullchain.pem" | "privkey.pem"): string import {letsencrypt_live_path} from '@fuzdev/zap/recipe_certbot.js';

The path of one of the files certbot keeps for domain's certificate, under /etc/letsencrypt/live/. domain is spliced in as given, so a reference or a {{domain}} template placeholder composes as a literal name does.

domain

the certificate's name, its first -d domain

type string

file

the certificate chain or its private key

type "fullchain.pem" | "privkey.pem"

returns

string

the path

RELOAD_NGINX_HOOK_NAME
#

recipe_certbot.ts view source

"reload-nginx" import {RELOAD_NGINX_HOOK_NAME} from '@fuzdev/zap/recipe_certbot.js';

The default file name of the distro *_certbot_reload_nginx_hook hooks.

Depends on
#

Imported by
#