openvox-ca implements the same HTTP API that OpenVox and Puppet agents, and
the puppetserver ca / puppet ssl tooling, already use — the Puppet CA API.
All endpoints are served under both the bare path and /puppet-ca/v1/<path>, so the server can be used directly by agents or placed behind a proxy that strips the prefix.
| Method | Path | Description |
|---|---|---|
GET |
/certificate_status/{subject} |
Get status: signed, requested, or revoked |
PUT |
/certificate_status/{subject} |
Change state (signed or revoked); supports cert_ttl (seconds). revoked returns 409 Conflict when the stored CRL was not signed by the CA certificate this process loaded — see the note below. signed returns 409 Conflict when the queued CSR requests subject alternative names policy refuses (allow_subject_alt_names) or extensions that disallow signing; both carry a body naming the cause |
DELETE |
/certificate_status/{subject} |
Revoke + delete cert and CSR (puppet cert clean). The delete happens even if the revocation fails, as does the deletion of any pending request — see the note below and Authorization tiers |
PUT |
/certificate_status_by_serial/{serial} |
Revoke one specific serial, rather than whatever is newest for a subject — see Revocation by serial; admin-only |
PUT body:
{ "desired_state": "signed", "cert_ttl": 86400 }A revocation takes the per-subject lock that signing and renewal take, so it waits for an issuance already under way for that subject instead of overlapping it. That leaves two orderings, and both are answers rather than races: the revocation commits first and the renewal's under-lock re-check refuses it, or the renewal completes and the revocation then retires the serial it has just issued. Note what the second ordering does not promise. A revocation retires the subject's latest serial and no other, so it leaves the agent nothing usable only where the renewal retired the certificate it replaced — the default, but not a guarantee: revoke_on_auto_renew: false keeps the predecessor deliberately, and on both paths that revoke is best-effort and only warns when it fails. superseded_cert_revoke_after_sec does not belong on that list, and the difference matters when you are containing an agent: a predecessor inside its overlap window is recorded, so revoking the subject retires it too, in the same call. What the setting does change is the failure mode above — a predecessor whose supersession was never recorded is not on the pending list either, so nothing but revoke --serial reaches it, and puppetca_supersede_failures_total is what tells you one exists. A predecessor left behind stays valid for its own key, and still authenticates, because admission tests the serial presented rather than whatever certificate is on disk. Three further consequences worth knowing. The wait is not short, and it is not bounded the way you would expect. A renewal holds that lock across its own wait for the CRL lock, so a busy CA can keep a revocation queued; and the server's 60-second lockTimeout bounds only a wait for another replica, because every backend serialises callers within one process on a lock that ignores the deadline. A revocation queued behind an issuance on the same replica therefore waits as long as that issuance takes — SaveRequest holds the subject lock across an autosign signature, which has no bound of its own. Once that wait passes 60 seconds the revocation fails on every backend — never as a late success — but it fails in two different places, and the difference is visible in your monitoring. On PostgreSQL, MySQL, etcd and Redis it reaches its cross-node acquisition with the deadline already spent and is rejected there, before any CRL work: nothing is written and no counter moves. On SQLite and the filesystem — the default — there is no cross-node acquisition to reject it, so a wait behind another goroutine in this same process proceeds into the CRL work still carrying the spent deadline and fails on its first storage read instead. Nothing is written there either, but that failure is counted into puppetca_crl_update_failures_total, so on a single-node deployment a revocation that merely queued too long can fire PuppetCACRLUpdateFailing. The one exception is a wait for a different process on the same host — an openvox-ca-ctl command, a second server — which SQLite and the filesystem do now reject at acquisition like the others, uncounted, with an error naming the lock; see running a second process against a live store. openvox-ca-ctl gives up after 30 seconds regardless, so treat a client-side timeout as unknown rather than failed. To settle it, ask the replica that served the request — GET /certificate_status/{subject} answers from the CRL the revocation would have amended — or simply repeat the revocation, which is a no-op if the first one landed. The logs settle only the failure direction (Revoke failed, a warning); a revocation that commits is logged at debug level, so it is silent at the default verbosity. Either way the answer is a bare 409 Conflict with body conflict, which is not the CRL-provenance 409 described below and carries none of its remedy; retrying is always safe, because revoking a serial already on the CRL is a no-op. POST /generate/{subject} takes that same per-subject lock, so a revocation waits for a server-side key generation exactly as it waits for a signature, on every replica rather than only within one process. Close off issuance before revoking when you are containing an agent.
Note: revoking amends this CA's own CRL, so it fails with
409 Conflictwhen the stored CRL was not signed by the CA certificate this process loaded — re-signing it would destroy a list this CA cannot reproduce. The usual cause is a CA certificate replaced under a running process, since it is read once at startup; the response body names the cause and the remedy, which is to restart the replica holding the stale certificate.PUT /certificate_revocation_list/carefuses the same state for the same reason.
cleanis not symmetric withrevokehere. Clean's job is to remove the certificate, so a revocation that fails does not stop it:DELETE /certificate_status/{subject}andPUT /cleanreturn success, delete the certificate, and leave it unrevoked and usable as a credential until it expires. In the409state above that means the whole revoke family does not fail closed — thePUTrefuses, theDELETEsucceeds. The revoke failure is logged atWARN("stays a valid credential until it expires") and counted inpuppetca_crl_update_failures_total, soPuppetCACRLUpdateFailingfires. Recovering means restarting the stale replica and then revoking the certificate that was deleted but not revoked —cleancannot be repeated, because the certificate is no longer in storage. The inventory row outlives the delete, soPUT /certificate_status/{subject}still resolves to that serial and is enough on its own; revocation by serial is needed only once a replacement has been issued for the same name, which makes the by-subject lookup resolve to the replacement instead. That is common after a clean, since the agent re-enrols.In this state the
WARNline names the serial. A revocation that failed before the CRL was reached — a lock it could not take, or a subject the inventory could not resolve — does not, and the serial has to come from the inventory instead.
GET response:
{
"name": "agent.example.com",
"state": "signed",
"fingerprint": "AA:BB:...",
"fingerprints": { "SHA256": "AA:BB:...", "default": "AA:BB:..." },
"dns_alt_names": ["agent.example.com"],
"subject_alt_names": ["agent.example.com"],
"authorization_extensions": {},
"serial_number": 7329847239485029341,
"not_before": "2025-01-01T00:00:00Z",
"not_after": "2030-01-01T00:00:00Z"
}Note:
serial_numberis the low 64 bits of the certificate's cryptographically random 128-bit serial, returned as a signed int64 for API compatibility. It is omitted for certificates in therequestedstate.
PUT /certificate_status/{subject} with desired_state: revoked revokes
whichever serial is newest for that subject. That is what an operator almost
always wants, but it leaves one state unreachable: a certificate that a later
issuance for the same subject displaced without retiring, and without the CA
recording that it owes it a revocation. Asking for the subject then revokes the
live replacement and leaves the superseded certificate valid — worse than doing
nothing.
A predecessor inside an overlap window is not in that state: it is on the
pending-supersession list, and revoking the subject retires everything of that
subject's the list names, in the same call. What stays unreachable is a
predecessor whose retirement was never recorded at all — an immediate-path
revoke that failed, or a supersession the CA could not write down.
puppetca_supersede_failures_total is what tells you one exists. PUT /certificate_status_by_serial/{serial} names the
certificate instead of the subject, so it can reach that one.
The serial is hexadecimal, in any case and with or without leading zeros; it is
matched against the inventory in canonical form — uppercase and unpadded, which
is how openvox-ca logs it. openssl x509 -noout -serial prints the same digits
zero-padded to whole bytes, a rendering this route accepts unchanged.
PUT body:
{ "desired_state": "revoked", "force": false }desired_state must be revoked — there is no by-serial signing operation, and
any other value is a 400 rather than a revocation. Responses:
| Status | Meaning |
|---|---|
204 No Content |
Revoked, or already revoked (repeating the call is a no-op) |
400 Bad Request |
The serial is not hexadecimal, or desired_state was not revoked |
404 Not Found |
No inventory entry carries that serial |
409 Conflict |
The serial is the certificate currently stored for its subject; the stored certificate could not be read, so that could not be determined; or the stored CRL was not signed by this process's CA certificate (see the note above). Each returns a body naming which, and what to do about it |
503 Service Unavailable |
The CA could not service the request — an inventory read (including an integrity failure), a CRL read/sign/write, or a lock it could not take. The cause is in the server log, not the response, because the message can name storage paths |
A 409 always carries a descriptive body. "force": true is the documented way
past the first two causes and only those two — not the foreign CRL, and not a
503; re-running with it would revoke without the live-certificate guard having
run.
Two refusals are deliberate:
-
A serial this CA has no record of issuing is refused, and
forcedoes not override it.CleanupExpiredCertsdrops CRL entries only for serials it finds in the inventory, so an entry admitted for an unknown serial could never be removed again — permanent growth in a document served to every agent. -
The serial of the certificate currently stored for its subject is refused unless
forceis set. That case is exactly whatPUT /certificate_status/{subject}is for, so reaching it by serial is more likely a mistyped digit than an intent, and the cost of being wrong is a working node losing its credential. The409body names the subject and the by-name command. Set"force": trueto revoke it anyway — the legitimate case is a compromise where the serial, not the name, is what you have.If the stored certificate cannot be read at all (corrupt, or an I/O failure), the request is refused for the same reason: the CA cannot show that the serial is not in circulation. That is a distinct
409whose body says so and names the subject — the guard did not fire, it could not run, and the first thing to try is the request again once storage is healthy.forceoverrides this too, revoking with the guard never having run, and logs that it did.
| Method | Path | Description |
|---|---|---|
GET |
/certificate_statuses/{any} |
List all certificates; filter with ?state=requested|signed|revoked |
| Method | Path | Description |
|---|---|---|
GET |
/certificate_request/{subject} |
Retrieve a pending CSR PEM |
PUT |
/certificate_request/{subject} |
Submit a new CSR (body: raw PEM) |
DELETE |
/certificate_request/{subject} |
Delete a pending CSR |
PUT /certificate_request/{subject} answers 200 OK when the CSR is accepted —
whether it was autosigned immediately or queued for manual signing, and also
when a signed certificate already exists for the subject, which is a 200 so
that an agent continues its poll loop and collects the certificate by GET
rather than treating the submission as fatal. It answers 400 Bad Request for a
CSR that cannot be decoded or parsed, whose signature does not verify, or whose
Common Name does not match the subject in the path. Those three are refused
whatever the autosign policy, because they are decided before it. A fourth is
not: a CSR requesting subject alternative names it may not have, which
allow_subject_alt_names
governs, is refused here with 400 only when the submission is autosigned,
since that is the only case in which this endpoint signs anything. With manual
signing the CSR is accepted (200) and queued as usual, and the refusal
surfaces later — at PUT /certificate_status as a 409, or in POST /sign's
signing-errors list. An operator testing the setting with autosign off should
expect the 200. That last refusal is deliberately generic — it names no entries, so a
caller on this unauthenticated endpoint cannot use it to discover which names
the CA would issue; the refused entries go to the CA's log at WARN. Anything
else is a 500, whose message is withheld for the same reason — except a submission
refused by csr_rate_limit, which answers 429 Too Many Requests before the CSR is read at all.
DELETE /certificate_request/{subject} is an operator rejecting a request
rather than signing it, and it takes the same per-subject lock that POST /sign, PUT /certificate_request's autosign and renewal
take. The rejection therefore orders against those issuances rather than racing
them, and both orderings are answers. Win the lock and the pending CSR is gone
before the sign looks for it, so the sign fails with nothing to sign. Lose it
and the CSR has usually been consumed already — signing removes it once the
certificate is stored — so the delete answers 404 with the certificate
issued. That removal is best-effort and only warns when it fails, so on a
storage fault there the delete removes the CSR instead and answers 204 for a
subject that already has a certificate. What the lock rules out is the case
that made this a bug: a 204 telling the operator the request was rejected
while a certificate was being issued for it.
The endpoint answers 400 Bad Request for a subject that fails validation,
404 Not Found when there is no pending CSR for the subject, and 503 Service Unavailable when the deletion could not be carried out — a storage fault, or a
lock it could not take. The 404 and the 503 are deliberately different
answers: a 404 says the request is no longer queued, so reporting a failed
deletion as one would tell an operator their rejection had landed while the CSR
was still there and still signable. Nothing is deleted in any of the 503
cases, so retrying is always safe.
The wait for that lock is bounded the way a revocation's is, which is to say
not uniformly.
The server's 60-second lockTimeout bounds only a wait for another replica,
because every backend serialises callers within one process on a lock that
ignores the deadline — so a delete queued behind an issuance on the same
replica waits as long as that issuance takes, and an autosigning SaveRequest
holds the subject lock across a signature that has no bound of its own. Note
too that the server's WriteTimeout is itself 60 seconds: a wait that exhausts
the lock budget reaches the response write with the connection's deadline
already spent, so expect a dropped connection rather than the 503 above. The
DeleteRequest failed line in the serving replica's log is the authoritative
record. To settle it from the outside, repeat the DELETE — it is a safe
retry, and its three answers distinguish all three outcomes. Do not reach for
GET /certificate_request/{subject}: that route reports a storage failure as
404 as well, so it cannot tell "no longer queued" from "could not tell you",
which is the conflation this endpoint just stopped making.
| Method | Path | Description |
|---|---|---|
GET |
/certificate/{subject} |
Retrieve a signed certificate PEM (ca returns the CA cert) |
| Method | Path | Description |
|---|---|---|
POST |
/certificate_renewal |
Renew an existing certificate; body: raw CSR PEM, or empty; returns new certificate PEM. Restricted to certificates this CA issued — see renewal eligibility |
Requires a valid CA-signed client certificate. The new certificate is issued immediately without entering the pending-CSR queue or autosign evaluation, and the certificate it replaces is retired once the new one is safely stored (see revoke_on_auto_renew below for the auto-renewal case). "Retired" means revoked before the response is returned, unless superseded_cert_revoke_after_sec grants an overlap window, in which case the replaced serial is recorded and a sweep revokes it once the window elapses — so both certificates verify for the length of the window, on this path including the replaced private key.
- CSR body (re-key): the CSR Common Name must match the authenticated client CN — an agent can only renew its own certificate, not another's. Issues a certificate for the new key in the CSR. Puppet OID extensions are copied from the CSR except authorization-arc OIDs (
1.3.6.1.4.1.34380.1.3.*, such aspp_cli_auth), which are stripped so a submitted CSR cannot request elevated privileges. - Empty body (wire-compatible auto-renewal): matches the request real OpenVox/Puppet agents send by default (
hostcert_renewal_interval, and thepuppet ssl renew_certCLI action). Identity and key possession come solely from the mTLS-presented client certificate; the same public key is reissued with a fresh serial and validity, carrying forward the original certificate's SANs and Puppet OID extensions unchanged. Unlike the CSR path, this preserves authorization-arc OIDs (e.g.pp_cli_auth): they were already vetted when the presented certificate was issued, so a cert that legitimately holds them keeps them across renewal.
If the CA has not finished initialising, the request returns 503 Service Unavailable (retry once it is ready).
If the presented certificate's (or CSR's) key falls below the CA key-strength policy — for example an RSA-1024 key imported from a legacy CA — the request is rejected with 422 Unprocessable Entity rather than renewed; the agent must re-key via the CSR path with a compliant key.
A CSR-body renewal requesting a subject alternative name the presented certificate does not already carry is rejected with 400 Bad Request, governed by allow_subject_alt_names. A renewal may keep the names its own certificate already holds — that is what stops the setting stranding the nodes it was enabled for — but it may not introduce new ones, so this is 400 rather than a server error: the request can never succeed as submitted and the agent should stop rather than retry. The refusal names no entries; they go to the CA's log at WARN. The empty-body path cannot reach this refusal at all, since it carries the presented certificate's SANs forward rather than reading any from a request.
If the presented certificate has been revoked, both paths reject the request with 403 Forbidden. That check re-reads the CRL from storage rather than trusting the in-memory copy the mTLS layer uses, so it holds even on a replica that has not yet picked up a revocation performed elsewhere — renewal does not become a way out of a lockout during the propagation window.
It is also asked again once the per-subject lock is held, immediately before the replacement is issued. Acquiring that lock can wait behind an issuance already under way for the subject, and a revocation landing inside that window would otherwise be outrun: the first answer has already been given, and nothing between it and the signing looks again. Revoking takes the same lock, so a revocation cannot slip into the gap between the second answer and the issuance either — see Certificate status.
The re-read is best-effort in one direction: if the storage read itself fails, the check falls back to the CRL already in memory rather than refusing every renewal in the fleet over a storage blip. A renewal in that window is then bounded by the same propagation window as everything else, not by the stronger guarantee above. puppetca_crl_sync_failures_total counts those failures — see metrics. See also Revocation across replicas.
revoke_on_auto_renew (env PUPPET_CA_REVOKE_ON_AUTO_RENEW, default true) controls whether the certificate replaced by an auto-renewal (empty body) is revoked. The default keeps only the newest serial per subject valid. Set to false to match OpenVox Server's own (Clojure) CA exactly, which leaves the replaced certificate valid — for the same key — until it naturally expires. This setting has no effect on the CSR-body (re-key) path, which always retires the certificate it replaces. It is a whether, not a when: superseded_cert_revoke_after_sec decides how long the replaced certificate stays valid first, on both paths, and with revoke_on_auto_renew: false the auto-renewal path records nothing at all rather than deferring anything.
CRL growth: with the default
true, every auto-renewal appends the retired serial to the CRL, and the entry stays there until the certificate expires. Entries are only pruned by the expired-certificate cleanup job, which is off by default — enableenable_expired_cert_cleanupto bound CRL size on busy CAs, and watchpuppetca_crl_revoked_certificatesto keep an eye on it. Revocation is best-effort (a failure never fails the renewal), and which counter sees a failure depends on which path retired the certificate. Retiring it immediately — the default, with no overlap window configured — puts a CRL that could not be read, re-signed or written intopuppetca_crl_update_failures_total, so that counter catches most, but not all, of them: a CRL lock the renewal could not take is logged only. Retiring it later, withsuperseded_cert_revoke_after_secset, puts every failure to record the supersession intopuppetca_supersede_failures_totalinstead — the lock acquisition included, because a supersession that was never recorded is lost outright rather than merely missing from the CRL. See delayed supersession. A superseded certificate left valid this way is reachable only by revocation by serial: asking for its subject would revoke the replacement instead. See Authorization tiers for the same distinction on the clean path.
The presented client certificate must be one this CA issued, must not be revoked, must not be one a previous renewal already superseded, and must be the certificate for the subject being renewed.
The revocation condition is reached in ordinary operation: the CA re-reads the CRL from storage before renewing, so a replica whose in-memory copy has not yet caught up — up to crl_sync_interval_sec, see revocation across replicas — admits the certificate at the middleware and then refuses it here, with 403 access denied. That is what stops renewal being a way out of a lockout during the propagation window.
The supersession condition can only refuse where an overlap window is configured — nothing is ever recorded without one — and it is what keeps that window a grace period for relying parties rather than a reprieve for the credential. A superseded certificate is deliberately absent from the CRL, so the revocation check above cannot see it — and a renewal mints a fresh full-lifetime certificate. Without this condition the replaced credential could renew itself out of the window entirely, which on the re-key path means the previous private key doing it. A match, and an unparseable list, both answer 403 access denied, like the revocation condition. A list that cannot be read fails closed too, but answers 500 internal server error: the CA cannot determine the certificate's status, which is not an authorisation decision, and the same is true of the revocation check when its storage read fails. Note that this check runs on every renewal whatever superseded_cert_revoke_after_sec says — a store that cannot serve the pending-supersession key refuses renewals on a CA that never enabled a window, and raises puppetca_supersede_failures_total.
The issuer condition is reachable as soon as a client_ca entry is configured — see trusting client certificates from another CA. Renewal is scoped to domain zero, this CA's own: a foreign certificate authenticates and may hold admin authority in its own domain, but it has no standing to renew, because renewal reissues under our authority using the presented certificate's own subject, which is only safe for names we assigned. It is answered 403 certificate not eligible for renewal. With no client_ca configured there is one trust domain and the condition stays unreachable, as it always was.
The subject condition cannot be reached from HTTP at all, because the handler derives the subject from the presented certificate; it guards future callers of the internal API.
| Method | Path | Description |
|---|---|---|
POST |
/sign |
Sign one or more CSRs; body: {"certnames":["a","b"]} |
POST |
/sign/all |
Sign every pending CSR |
| Method | Path | Description |
|---|---|---|
PUT |
/clean |
Revoke + delete multiple certificates in bulk; body: {"certnames":["a","b"]} |
Response:
{ "cleaned": ["a.example.com"], "not-found": ["missing.example.com"], "clean-errors": [] }Each subject goes through the same path as DELETE /certificate_status/{subject}, with the same best-effort revoke and deletes, so
clean-errors does not capture any of them: a subject whose revocation or
deletion failed is reported under cleaned. The CRL is the authority for what
was actually revoked — see Authorization tiers.
| Method | Path | Description |
|---|---|---|
GET |
/certificate_revocation_list/ca |
Download the stored CRL PEM verbatim. When a CRL chain was imported (see --crl-chain) the response is that whole chain, this CA's own CRL first, so agents can perform full-chain revocation checking (Puppet's default certificate_revocation = chain). Ancestor CRLs are preserved across re-signing. This CA never issues them — it cannot re-sign another CA's list — but it will republish newer copies if crl_chain_file is configured; without it they stay as current as what was imported |
PUT |
/certificate_revocation_list/ca |
Re-sign this CA's own CRL with a fresh validity window (preserves all revocations, and every ancestor block unless crl_chain_file is set — with it, that file decides which ancestors are published); admin-only. Returns the whole stored chain, this CA's own CRL first. Returns 409 Conflict when the stored CRL was not signed by the CA certificate this process loaded — the usual cause is a replaced CA certificate on an unrestarted replica, and the response body names the remedy |
| Method | Path | Description |
|---|---|---|
GET |
/expirations |
CA cert and CRL expiry dates. The CRL date is this CA's own CRL (block 0) only — imported ancestor CRLs are not reflected, so an ancestor can be past its nextUpdate while this reports a comfortable date. See metrics |
| Method | Path | Description |
|---|---|---|
POST |
/generate/{subject} |
Generate key + cert server-side; optional ?dns=alt.name. Key algorithm follows --leaf-key-algo / --leaf-key-size (default: RSA 2048) |
Response:
{ "private_key": "-----BEGIN RSA PRIVATE KEY-----\n...", "certificate": "-----BEGIN CERTIFICATE-----\n..." }This route serialises on the same per-subject lock as signing, so it can block while another replica holds that lock.
It cannot issue a certificate carrying pp_cli_auth — see
authorization tiers — and it needs a running server.
Neither is true of openvox-ca generate,
which mints against storage directly.
| Method | Path | Description |
|---|---|---|
PUT |
/certificate/{subject} |
Import a certificate issued outside this CA's normal signing flow into the inventory; admin-only |
Shares its path with GET /certificate/{subject} (certificate retrieval, above) but is a distinct, admin-only operation. Request body: raw certificate PEM. {subject} must match the certificate's CN or one of its DNS Subject Alternative Names — this lets an operator import under a specific identity even when the certificate's SANs list several names.
This is for certificates that were signed by this CA's key but never went through Sign/Generate — most commonly certificates migrated from a legacy CA installation that shared this CA's key material. The certificate's signature must verify against this CA's certificate (a pure cryptographic check, so an already-expired certificate is still accepted for record-keeping); CA certificates (IsCA: true) are rejected — use openvox-ca-ctl import for CA bundle import instead (or openvox-ca import-ca-cert when the CA key is held by a provider, which openvox-ca-ctl import cannot reach — see offline subcommands on the server binary).
Once imported, the certificate is tracked exactly like a normally-issued one: it appears in listings and status lookups, is cleaned up by the normal expiry sweep, and can be revoked via the usual PUT /certificate_status/{subject} (desired_state: "revoked") mechanism.
Conflict handling, in priority order:
- If the exact same certificate (same serial, byte-identical) is already the tracked certificate for the subject, the request succeeds as a no-op (
"imported": falsein the response). - Otherwise, if the certificate's serial number is already tracked anywhere in the inventory (under this subject or another), the request is rejected with
409 Conflict. - Otherwise, if the subject already has an active (non-revoked) certificate, the request is rejected with
409 Conflict. If the subject's existing certificate is revoked, it is evicted and the import proceeds.
Invalid certificates — malformed or multi-block PEM, a signature that does not chain to this CA, a CA certificate (IsCA: true), a subject that matches neither the CN nor any DNS SAN, a non-positive serial, or a bad validity window — are rejected with 400 Bad Request. If the CA has not finished initialising, the request returns 503 Service Unavailable (retry once it is ready).
Response:
{ "subject": "legacy-node.example.com", "serial": "1A2B3C4D5E6F", "not_before": "2020-01-01T00:00:00Z", "not_after": "2025-01-01T00:00:00Z", "imported": true }serial is uppercase hex (matching the inventory/CRL/OCSP convention), unlike the decimal serial_number field in certificate status responses (which is decimal only to preserve the full 128-bit value without int64 truncation — a constraint that doesn't apply to this string field).
| Method | Path | Description |
|---|---|---|
POST |
/ocsp |
RFC 6960 OCSP request; body is DER-encoded OCSPRequest |
GET |
/ocsp/{request} |
RFC 6960 GET form; {request} is standard or URL-safe base64-encoded DER |
Both paths are also served under /puppet-ca/v1/. Responses are signed by the CA key directly (Content-Type: application/ocsp-response). The AIA extension is embedded in issued certificates when --ocsp-url is set; the endpoint answers either way.
A GET carries Cache-Control: max-age=…, public for a good or revoked answer to a nonce-free request, and Cache-Control: no-store otherwise. Two cases get no-store: a request carrying a nonce, since an RFC 8954 response answers that request and no other and so also bypasses the pre-signed cache; and an unknown, which additionally carries no NextUpdate, because a later inventory read can overturn it — see OCSP status across replicas. A POST carries no Cache-Control at all.
These endpoints are served at bare paths only (no /puppet-ca/v1 prefix) and require no client certificate.
| Method | Path | Description |
|---|---|---|
GET |
/healthz/live |
Liveness probe: always 200 while the process is running |
GET |
/healthz/ready |
Readiness probe: 200 once the CA is initialised, 503 before |
GET |
/healthz/startup |
Startup probe: delegates to the readiness check |
Response body: {"status":"ok"} (200) or {"status":"not_ready"} (503).
When mTLS is enabled (both --tls-cert and --tls-key set), each endpoint requires a minimum client certificate tier:
| Tier | Required client cert | Endpoints |
|---|---|---|
| Public | None | GET /healthz/*, GET /certificate/{subject}, GET /certificate_revocation_list/ca, PUT /certificate_request/{subject}, GET /expirations, POST /ocsp, GET /ocsp/{request} |
| Own client | A cert this CA issued, with clientAuth EKU |
POST /certificate_renewal — restricted to certificates this CA issued (see renewal eligibility); renewal reissues under our authority using that certificate's own subject, which is only safe for names we assigned. The empty-body path also carries that certificate's SANs and Puppet OID extensions forward unchanged; the CSR path takes Puppet OID extensions from the CSR and strips authorization-arc OIDs (see Certificate renewal) |
| Self or admin | Cert CN matches path subject and was issued by this CA, OR cert is admin | GET /certificate_request/{subject} |
| Admin | Cert is admin (see below) | GET /certificate_status/{subject} (public with --allow-public-status), PUT /certificate_status/{subject}, DELETE /certificate_status/{subject}, DELETE /certificate_request/{subject}, GET /certificate_statuses/*, POST /sign, POST /sign/all, POST /generate/{subject}, PUT /clean, PUT /certificate_revocation_list/ca, PUT /certificate/{subject}, PUT /certificate_status_by_serial/{serial} |
Above the public tier, a presented certificate must also be currently valid,
not revoked, and carry the clientAuth extended key usage: an expired
certificate, one listed in the CRL, or one issued for serverAuth only is
refused at every tier above public — including POST /certificate_renewal, so
revoking an agent's certificate cuts off its access to every authenticated
endpoint, not merely its next renewal.
That is unconditional for certificates this CA issued. For a client from a
configured client_ca domain, the
revocation half is governed by client_revocation_policy: the default
require refuses a client whose issuer has no currently valid CRL, check
consults whatever CRLs are loaded and admits the client when there are none, and
skip does not check at all. Under skip — and under check for an issuer
publishing no CRL — a foreign certificate its own issuer has revoked is
admitted above the public tier, including where its CN grants it admin.
The public tier is unaffected, because it examines no client certificate at
all. A revoked agent can still fetch the CA certificate and the CRL, read
/expirations, query OCSP, and submit a CSR to
PUT /certificate_request/{subject}.
Neither form of revocation prevents re-enrolment under the same subject. Submitting a CSR evicts a revoked certificate for that subject (see Certificate import for the same rule on the import path), so the difference between the two verbs is only when the stored certificate goes away:
PUT /certificate_status/{subject}with{"desired_state":"revoked"}adds the serial to the CRL and leaves the certificate in storage until a later CSR displaces it. Eviction reads the same per-process CRL cache as the admission check described below, so on the HA backends a peer that has not yet synced answers200 OKand silently discards the CSR instead — for at mostcrl_sync_interval_sec, see below.DELETE /certificate_status/{subject}revokes, then deletes the certificate and any pending request, all against shared storage, so no step depends on a replica's cache. All three are best-effort: each failure is logged and the handler carries on, and the endpoint answers204regardless — so a204records that the subject existed, not that it was revoked or removed. Since admission reads the CRL rather than storage, the dangerous combination is a delete whose revocation failed: the file is gone and the certificate still works. Confirm the serial inGET /certificate_revocation_list/ca; one that is missing there can still be retired. The inventory row outlives the delete, soPUT /certificate_status/{subject}still resolves to it and is enough on its own — unless a replacement has since been issued for the same subject, which makes it resolve to the replacement instead and leaves revocation by serial the only way back to the original. The server'sClean: revoke failed,Clean: delete cert failedandClean: delete CSR failedwarnings are the complete signal;puppetca_crl_update_failures_totalcovers most of the first — a CRL that could not be read, signed or written all count, as does an inventory read that failed while resolving the subject's serial — but not a revocation refused at a cross-node lock acquisition, which never reaches the CRL work, not a subject that was simply never issued, and not a failed delete at all.
Otherwise the next CSR is accepted: with autosign enabled it is signed at once and the agent is back with a fresh, unrevoked certificate; with autosign off it queues for manual signing. The CRL entry for the old serial persists in both cases, so the old certificate stays refused — but that is not the same as locking the agent out.
When containing a compromised agent, apply the levers that hold, in this order:
-
Close off issuance: disable autosign, or use an autosign policy that excludes the subject, and block the agent at the network layer.
-
Revoke the certificate.
-
Wait one sync interval, or force it. Each replica re-reads the stored CRL every
crl_sync_interval_sec(60s by default) and installs it once it has advanced, so that interval is the worst case for a replica that did not perform the revocation — see revocation across replicas. To not wait, force a re-sign against a replica's own address (openvox-ca-ctl reissue-crl, orPUT /certificate_revocation_list/ca); each call refreshes only the replica that serves it, so through a load balancer it refreshes whichever one the VIP picked.To confirm a given replica, ask that address.
GET /certificate_status/{subject}reportsrevokedfrom the very cache the admission check reads, and an/ocspquery carrying a nonce (openssl ocspsends one unless you pass-no_nonce) resolves status from it too; thepuppetca_crl_cached_numbergauge is the same answer as a number. Each is reliable only while issuance stays closed off, per step 1, since the status answer describes whatever certificate is in storage for that subject now. The status probe also needs that certificate to still be there, so it suits thePUTroute: after aDELETE— whatopenvox-ca-ctl cleansends — every replica answers404and it distinguishes nothing, so record the serial beforehand and use OCSP or the gauge. OCSP answersUnknown— notrevoked— for a serial a peer issued that this replica has not yet indexed, which the index sync bounds atocsp_index_sync_interval_sec(5 minutes by default); see OCSP status across replicas. Send the nonce (openssl ocspdoes unless you pass-no_nonce), because a nonce-free query may be answered from a response pre-signed up to four hours ago. That cache cannot contradict this replica's own CRL — every install of a new CRL drops the responses it revokes — but for a probe, a freshly signed answer is the one worth having. What cannot answer it at all is anything reading shared storage:GET /certificate_revocation_list/ca,puppetca_crl_numberandpuppetca_crl_revoked_certificatesall read the same on a stale replica as on a fresh one.
The order matters. Step 3 is also what makes a stale replica willing to evict the revoked certificate and issue a replacement, so letting the sync land while autosign is still open hands whoever holds the compromised key a fresh, valid certificate.
Revocation is not enforced cluster-wide instantly. Every admission decision reads an in-memory copy of the CRL, not storage: the hot path of an authenticated request cannot afford a storage round trip. A revocation reaches shared storage at once and
GET /certificate_revocation_list/caserves it from there immediately, but the replica that performed it is the only one that rewrites its own copy on the spot. The rest pick it up on thecrl_sync_interval_secpoll, which is what bounds the window — 60 seconds by default. Two things it does not cover: OCSP responses already handed out (signed with four hours of validity, and a client's cache cannot be recalled), and other live certificates the same subject already holds. Both are set out under revocation across replicas.
In plain HTTP mode (no TLS), all endpoints are accessible without authentication:
the authorisation middleware is only installed when --tls-cert/--tls-key
(tls_cert/tls_key in the config file) are both set.
Note:
GET /certificate_status/{subject}is admin-only, matching Puppet Server's shippedauth.conf, which grantscertificate_statusandcertificate_statusestopp_cli_authonly. An ordinary agent certificate is refused with 403. Use--allow-public-statusto make it public instead, for environments where bootstrapping agents need to poll status before obtaining a client certificate — note that this removes authentication from the route entirely rather than relaxing it to any client. The response exposes state, fingerprint, serial number, and authorization extensions. If tooling of yours read statuses with an agent certificate, see Authorisation parity for the ways to restore it and what each one grants.
A client certificate is considered an admin credential if either condition is met:
- CN allow list: the certificate's Common Name appears in the
--puppet-servercomma-separated list or in the file pointed to by--puppet-server-file(one CN per line;#comments and blank lines ignored). Both sources can be used simultaneously; their CNs are merged. pp_cli_authextension: the certificate carries the Puppet authorization extension OID1.3.6.1.4.1.34380.1.3.39with the UTF8String value"true". OpenVox Server embeds this extension in its own certificate by default, so thepuppetserver caCLI can authenticate without being listed by CN.
The pp_cli_auth check is enabled by default. Disable it with --no-pp-cli-auth (or no_pp_cli_auth: true in the config file) if you prefer strict CN-only authorization.
Both conditions are scoped to the issuer that signed the certificate. A CN means something only within the namespace of the CA that signed it, so --puppet-server and pp_cli_auth grant admin to certificates this CA issued. Certificates from another issuer are granted admin by that entry's admin_cns and allow_pp_cli_auth — entry, not certificate: where an entry's file bundles more than one anchor the grant spans all of them, which the server warns about at startup, so give each issuer its own entry — see trusting client certificates from another CA. With no client_ca configured there is one issuer and this distinction has no effect.
The CN allow list is not fixed for the life of the process: SIGHUP (or systemctl reload) rebuilds it from the current contents of --puppet-server-file, merged with the --puppet-server value the process started with, so CN-based admin access can be granted or withdrawn without a restart. The swap is atomic with respect to in-flight requests, and the CNs added or removed are named in the log. See reloading configuration.
Two limits are worth knowing before relying on that to decommission a host. --puppet-server itself is frozen at startup — only the file is re-read — and condition 2 is untouched by a reload: a certificate carrying pp_cli_auth=true, which OpenVox Server issues to itself by default, remains an admin credential until it is revoked or the server is restarted with --no-pp-cli-auth. Revoking the certificate (openvox-ca-ctl revoke, or PUT /certificate_status/{subject} with desired_state: revoked) is the step that actually removes admin authority.
No new pp_cli_auth grant can be requested through this API: authorization-arc OIDs are stripped from submitted CSRs so that no request can ask for elevated privileges. Mint one offline with openvox-ca generate --pp-cli-auth, which also documents what the grant covers and the three steps needed to withdraw it.
An existing holder can still obtain a fresh certificate carrying the grant, because empty-body auto-renewal preserves authorization-arc OIDs deliberately (see Certificate renewal above). That is not an escalation — it requires already holding one — but it does mean a compromised admin credential can renew itself into a new serial and a new validity window, which is why withdrawing one is not simply a matter of waiting for it to expire.
OID source:
lib/puppet/ssl/oids.rb