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
falseand gate the hooks entirely. Arenewal_infohook written without settingsupports_renewal_info = truewill 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.
| Hook | stdin | stdout | Gated by |
|---|---|---|---|
issue | {"hook":"issue","order_id":…,"identifiers":[{"type":"dns","value":"…"}],"csr_der_base64":"…"} | PEM certificate chain, leaf first | always |
revoke | {"hook":"revoke","cert_der_base64":"…","reason":<int|null>} | ignored | always |
crl | {"hook":"crl"} | raw DER of the CRL | supports_crl |
renewal_info | {"hook":"renewal_info","cert_der_base64":"…"} | see below | supports_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 becomesinvalidwith abadCSRerror, 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. (Thelocal_cabackend 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 optionalexplanationURL. 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
| Variable | Set for | Value |
|---|---|---|
ACME_SIGNER_HOOK | every hook | issue, revoke, crl or renewal_info |
ACME_SIGNER_ORDER_ID | issue | The order being finalized |
ACME_SIGNER_IDENTIFIERS | issue | Comma-joined identifier values |
ACME_SIGNER_REASON | revoke | RFC 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
- Environment clearing:
env_clear()is called. The script inherits a minimalPATHplus theACME_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. - Zombie protection: the child runs with
kill_on_drop(true)under atokio::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. - 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.