Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Custom Script Signer

The custom signer backend delegates certificate issuance, revocation and metadata retrieval to an external script (Bash, Python, Go, …). Use it to integrate acme-proxy with legacy PKI systems, HSMs, or internal APIs that do not speak ACME natively.

acme-proxy still serves ACME to its own clients and still enforces domain control, filters and EAB; only the signing step is handed off.

Configuration

[signer]
backend = "custom"

[signer.custom]
script_path = "/usr/local/bin/legacy-pki-bridge.sh"
timeout_ms = 15000
args = []
supports_crl = false
supports_renewal_info = false

Reference

script_path (String) — Default: "" | Env: ACME_PROXY_SIGNER__CUSTOM__SCRIPT_PATH

Path to the executable. An empty value is a startup error once this backend is selected.

timeout_ms (Integer) — Default: 5000 | Env: ACME_PROXY_SIGNER__CUSTOM__TIMEOUT_MS

Budget for one invocation. issue and revoke run in the job queue, so there it bounds one attempt. The crl and renewal_info hooks answer a request inline, so while either is enabled this must stay below server.request_timeout_ms — the server refuses to start otherwise.

args (Array) — Default: [] | Env: ACME_PROXY_SIGNER__CUSTOM__ARGS

Static arguments passed on every invocation. These are the only command-line arguments the script receives; the hook is not passed as an argument.

supports_crl (Boolean) — Default: false | Env: ACME_PROXY_SIGNER__CUSTOM__SUPPORTS_CRL

Whether the script implements the crl hook. While false, the hook is never invoked — no process is spawned at all — and GET /crl has nothing to serve.

With it on, the answer is cached for a minute and re-served, so a burst of requests to the unauthenticated GET /crl is one run of the script rather than one per request. At most four read hooks (crl and renewal_info together) run at a time, whatever the request rate.

supports_renewal_info (Boolean) — Default: false | Env: ACME_PROXY_SIGNER__CUSTOM__SUPPORTS_RENEWAL_INFO

Whether the script implements the renewal_info hook. While false, the hook is never invoked and GET /renewalInfo/{certID} falls back to the server’s own local estimate.

These two flags default to false and gate the hooks entirely. A renewal_info hook written without setting supports_renewal_info = true will simply never run, with no error to explain why.

Hooks

The hook is selected by the ACME_SIGNER_HOOK environment variable, not by a command-line argument. Every hook receives a JSON object on stdin.

HookstdinstdoutGated by
issue{"hook":"issue","order_id":…,"identifiers":[{"type":"dns","value":"…"}],"csr_der_base64":"…"}PEM certificate chain, leaf firstalways
revoke{"hook":"revoke","cert_der_base64":"…","reason":<int|null>}ignoredalways
crl{"hook":"crl"}raw DER of the CRLsupports_crl
renewal_info{"hook":"renewal_info","cert_der_base64":"…"}see belowsupports_renewal_info

csr_der_base64 and cert_der_base64 are standard base64 of the DER bytes — not PEM, and not ACME’s base64url.

issue

Exit codes are the contract:

  • 0 — stdout is the PEM chain (leaf first, issuers after). Trailing whitespace is trimmed and exactly one newline re-appended, since a strict parser needs a newline after the final -----END CERTIFICATE-----.
  • 3 — reserved: the CSR is bad. The order becomes invalid with a badCSR error, which the client reads when it polls. Do not use this exit code for backend failures.
  • anything else — an internal failure. The issuance is retried under the job queue’s attempt budget, and the order is marked invalid (terminal, but pollable) once it runs out.

The script runs in the worker role, in the signer_issue job finalize queues: the client is answered processing and polls until the certificate is there. It never runs inside a client’s request, so it may take as long as its timeout_ms without holding one open.

The order’s requested notBefore/notAfter (RFC 8555 §7.4) are not passed to the script — there is no contract for it, and inventing one would break existing scripts. Your script decides validity on its own. (The local_ca backend does honour them, clamped.)

revoke

Exit 0 means revoked. Any non-zero exit is an internal failure, and acme-proxy then leaves the order un-revoked so the operation can be retried — the CA-side action is authoritative.

Revocation must be idempotent: acme-proxy may call this hook for a certificate your PKI already considers revoked, and that must succeed rather than error.

renewal_info

stdout drives RFC 9773:

  • empty — no opinion; the server falls back to its own estimate.
  • <start> <end> — the renewal window, as epoch seconds.
  • <start> <end> <explanationURL> — additionally supplies RFC 9773 §4.2’s optional explanationURL. The URL is last and optional so an existing two-token script keeps working unchanged.

Any other token count, or a non-integer timestamp, is an internal failure.

crl

stdout is the raw DER of the CRL, served by GET /crl. Empty stdout means “no CRL”. Failures here are logged and swallowed — a broken crl hook degrades to no CRL rather than taking the endpoint down.

Environment variables

VariableSet forValue
ACME_SIGNER_HOOKevery hookissue, revoke, crl or renewal_info
ACME_SIGNER_ORDER_IDissueThe order being finalized
ACME_SIGNER_IDENTIFIERSissueComma-joined identifier values
ACME_SIGNER_REASONrevokeRFC 5280 reason code, empty when none given

There is no ACME_SIGNER_PROFILE; the signer backend is never told which profile it is serving. (Backends are shared between profiles with identical [signer] configuration, so there would not always be one answer.)

Security & process isolation

  1. Environment clearing: env_clear() is called. The script inherits a minimal PATH plus the ACME_SIGNER_* variables above — nothing else. The server’s own environment may hold the NetBox token, SMTP password or RFC 2136 TSIG key, and a signing script has no business reading them.
  2. Zombie protection: the child runs with kill_on_drop(true) under a tokio::time::timeout. A timeout alone only drops the future, so without this a hung script would outlive its deadline and leak a process per request.
  3. Failure reporting: on a non-zero exit, the first non-empty line of stdout (falling back to stderr) is used as the error detail.

See Custom Plugins Examples for a complete script.