🤖 LLM/AI WARNING 🤖
This project was largely written by Claude It has been reviewed and tested, but use in production at your own discretion.
🤖 LLM/AI WARNING 🤖
openvox-ca is a drop-in replacement for the certificate authority built into
OpenVox Server (and its upstream, Puppet
Server), written in Go. It speaks the same HTTP API that OpenVox and Puppet
agents — and the puppetserver ca / puppet ssl tooling — already use, and
reads and writes a certificate store compatible with the existing Puppet CA
directory layout. Existing agents keep working without reconfiguration.
Run it instead of OpenVox Server's built-in (Clojure) CA when you want a small, self-contained CA process that scales out across replicas, keeps its key in OpenBao, exports to Kubernetes, or exposes Prometheus metrics — while remaining wire-compatible with your existing Puppet/OpenVox fleet.
Migrating from OpenVox Server or Puppet Server? See the migration guide for step-by-step instructions, directory layout mapping, and CLI command translation, and authorisation behaviour worth knowing for the two rules that differ from a relaxed
auth.conf.
- Full Puppet CA API compatibility: all 13 endpoints used by agents and OpenVox Server. See the HTTP API reference
- Pluggable storage: filesystem (default; drop-in compatible with the OpenVox/Puppet Server CA layout), SQLite (single database file), or PostgreSQL / MySQL (MariaDB) / etcd / Redis (Valkey) for HA clusters; CA cert/key can be pinned to local files independently. See the storage backends guide
- Pluggable CA key custody: keep the CA private key as a local file (default) or delegate it entirely to an OpenBao Transit secrets engine key, which never leaves OpenBao — works identically on a VM (AppRole/token) or in Kubernetes (native ServiceAccount auth, no sidecar). See OpenBao Transit-engine CA key
- Autosigning:
true, glob-pattern file, or executable plugin modes - mTLS support: optional HTTPS with per-endpoint tier-based client certificate authorization
- CA import: replace a bootstrapped CA with an external cert/key pair offline
- Intermediate CA: run under an external root, with
openvox-ca csremitting a signing request for a parent CA andopenvox-ca import-ca-certinstalling the signed chain. No key material is ever supplied on the command line, so it works identically for everyca_key_providerincluding an OpenBao Transit key that never leaves the vault. See the operator CLI reference - Server-side key generation: issue cert+key pairs without a node-submitted CSR; configurable RSA (2048/3072/4096) or ECDSA (P-256/P-384/P-521)
- Offline certificate minting:
openvox-ca generateissues a certificate directly against storage with no running server and no API — the only way to mint a newpp_cli_authadministrator credential, and the way to mint before a server exists - Configurable key algorithms: CA and leaf certificates can use RSA or ECDSA; ECDSA support for both bootstrapped CAs and generated leaf certs
- Random serial numbers: every issued leaf certificate gets a cryptographically random 128-bit serial (CA/Browser Forum guidance)
- CRL Distribution Points: optionally embed a CRL URL in every issued certificate (
--crl-url) so verifiers can automatically fetch the CRL - Configurable CRL validity: control how long each published CRL is valid (
crl_validity_days) - Automatic CRL refresh: a background job re-signs this CA's own CRL before its validity lapses, so a low-churn CA never serves an expired CRL; safe across replicas (serialised on the shared CRL lock) and tunable or disablable. Operators can also force a refresh on demand via
openvox-ca-ctl reissue-crl. Imported ancestor CRLs are preserved but cannot be re-signed here, so keeping them current iscrl_chain_file's job (below) or, failing that, a re-import before they expire - Revocation that propagates: on the HA backends every replica reloads the stored CRL on a short timer (
crl_sync_interval_sec, 60s by default), so a certificate revoked on one replica stops being accepted by the others within that window rather than whenever they next happen to re-sign. Renewal re-reads the CRL from storage, so a revoked certificate cannot renew itself into a fresh one on a replica that has yet to catch up. See Revocation across replicas - Upstream CRL chain (opt-in): point
crl_chain_fileat a PEM bundle of ancestor CRLs and openvox-ca re-reads it on a timer and republishes it alongside its own, so a sub-CA's ancestors stay current without anyone remembering to re-import before eachnextUpdate. An ancestor's CRL can never move backwards. On every storage backend exceptfilesystem, and underencrypt_ca_keyorca_key_provider: openbao, this is the only mechanism that does not require stopping the CA. See configuration - Client trust domains (opt-in): by default the CA trusts exactly one issuer for client authentication — itself.
client_caadds others, each entry with its own admin allow list, its ownallow_pp_cli_authsetting, and its own CRLs — per entry, so give each issuer its own entry where the grants should differ; a foreign client is checked against its own issuer's CRLs, chain-wide. Grants default to off, andclient_revocation_policydefaults torequire, which refuses a client whose issuer has no currently valid CRL. See trusting client certificates from another CA - Expired-certificate cleanup (opt-in): a background job removes certificates that expired more than a configurable grace period ago from the inventory and the CRL (and deletes their stored signed certificate), keeping both from growing without bound as nodes are decommissioned; safe across replicas (serialised on the shared CRL lock)
- Delayed supersession: a renewal records the certificate it replaced rather than revoking it inside the call, and a background job revokes it once an overlap window elapses (
superseded_cert_revoke_after_sec, 24h by default) — the overlap a certificate other parties are actively verifying needs in order to be replaced without a gap. The window is a deliberate weakening, so it is bounded at both ends: a superseded certificate cannot renew itself into a fresh one, and revoking a subject retires anything of its own still inside a window. Set0to revoke inside the renewal as earlier releases did. Safe across replicas (serialised on the shared CRL lock). See delayed supersession - OCSP responder: built-in RFC 6960 OCSP responder; AIA extension embedded in issued certs when
--ocsp-urlis set; in-memory cache with nonce bypass. On the HA backends each replica reloads the set of serials it recognises on a timer (ocsp_index_sync_interval_sec, 5m by default), so a certificate signed on one replica is not reportedunknownby the others. See OCSP status across replicas - Health probes:
/healthz/live,/healthz/ready, and/healthz/startupendpoints for Kubernetes-style liveness/readiness checks - Prometheus exporter: optional
/metricslistener (--metrics-listen) exposing Go runtime/process and HTTP metrics plus CA certificate, CRL, and per–leaf-certificate expiry and issuance-status series; ships with a Jsonnet alerting mixin. See metrics & monitoring - Kubernetes export (opt-in): publish the CA certificate and/or CRL into any number of Kubernetes Secrets and ConfigMaps via in-cluster server-side apply, with configurable names, namespaces, data keys, labels, annotations, Secret
type, and how much of the chain to publish (cert_scope/crl_scope, which publish the whole stored chain by default — set them toselfon a target whose consumer wants this CA's block alone); CRL-bearing objects are refreshed whenever the CRL changes. See Kubernetes export - Helm chart: an OCI-published chart, versioned in lockstep with the server, covering dual-stack Services, TLS-passthrough Ingress and Gateway API routes, an opt-in ServiceMonitor and network policies; the server's own settings pass straight through to its config file, so the whole configuration reference is reachable. See deploying with Helm
- Graceful shutdown:
SIGTERM/SIGINTdrains in-flight requests with a configurable window (25s default) before exiting; deferred storage and signer cleanup always runs - Configuration reload:
SIGHUP(orsystemctl reload) re-reads the TLS keypair and the admin allow list without dropping connections, so renewing the CA's server certificate or decommissioning a compile server needs no restart. See reloading configuration - systemd integration:
Type=notifyreadiness (systemctl startreturns once the listener is actually accepting), a live status line covering the listener, CA expiry and CRL freshness, watchdog keep-alives, andsystemctl reloadfor TLS certificate renewal and admin allow-list changes; ships a hardened unit file. See running under systemd - FIPS-compatible: the core CA uses the standard library only (
crypto/x509,net/http); no CGO by default; FIPS build available viaGOEXPERIMENT=boringcrypto(the optional Kubernetes export adds theclient-godependency) openvox-ca-ctl: operator CLI matchingpuppetserver casubcommands. See the operator CLI reference
Prebuilt, multi-arch images are published to the GitHub Container Registry for every release:
$ docker pull ghcr.io/voxpupuli/openvox-ca:latestSee container images for the available tags and a
docker run example, or use the compose.yml at the repository
root for a Docker/Podman Compose deployment.
Each release publishes four tarballs — linux_amd64 and linux_arm64, each in a standard and a
FIPS (_fips) build — plus an SBOM pair per tarball, checksums.txt, and a signed provenance
bundle (see verifying what you downloaded). Every archive
contains both binaries (openvox-ca, openvox-ca-ctl) and the systemd unit
openvox-ca.service. Asset names carry the release version, so set VERSION to the release you
want (the newest is on the
releases page) and download by tag:
$ VERSION=0.9.0
$ curl -fLO https://github.com/voxpupuli/openvox-ca/releases/download/v${VERSION}/openvox-ca_${VERSION}_linux_amd64.tar.gz
$ curl -fLO https://github.com/voxpupuli/openvox-ca/releases/download/v${VERSION}/checksums.txt
$ sha256sum --ignore-missing -c checksums.txt
$ tar xzf openvox-ca_${VERSION}_linux_amd64.tar.gzSee running under systemd for the rest of a VM install.
checksums.txt establishes that a download arrived intact. Provenance establishes
where it came from: every tarball, container image and chart published from a
release tag carries a SLSA v1.0 build
provenance attestation, signed through Sigstore with a
short-lived certificate — there is no long-lived signing key to trust or rotate.
With the GitHub CLI, verification is one command per artefact:
$ gh attestation verify openvox-ca_${VERSION}_linux_amd64.tar.gz \
--repo voxpupuli/openvox-ca \
--signer-workflow voxpupuli/openvox-ca/.github/workflows/release.yml \
--source-ref refs/tags/v${VERSION}All three flags matter, and they do different jobs. --repo on its own accepts any
attestation this repository produced — and the container image workflow signs
pull-request builds too, so repository-scoped verification is weaker than it
looks. --signer-workflow pins which workflow signed; it has no ref
component, so it does not on its own separate a release from a pull request.
--source-ref is what pins the ref. The cosign commands below get all three
properties from one certificate identity — --certificate-identity where the
exact URL is known, --certificate-identity-regexp where the version is not
fixed in advance — because the identity URL carries repository, workflow path
and ref together.
The provenance bundle is also published as a release asset, so verification needs
nothing but cosign and curl — no GitHub API call, and it works air-gapped.
It is a Sigstore-format bundle, so it wants cosign v3 or newer (on cosign v2.4
and later, add --new-bundle-format; earlier v2 releases cannot read it):
$ curl -fLO https://github.com/voxpupuli/openvox-ca/releases/download/v${VERSION}/provenance.sigstore.json
$ cosign verify-blob-attestation openvox-ca_${VERSION}_linux_amd64.tar.gz \
--bundle provenance.sigstore.json \
--type slsaprovenance1 \
--certificate-identity https://github.com/voxpupuli/openvox-ca/.github/workflows/release.yml@refs/tags/v${VERSION} \
--certificate-oidc-issuer https://token.actions.githubusercontent.comThe bundle covers every file listed in checksums.txt — each tarball and each SBOM —
so the same bundle verifies any of them.
Container images and the Helm chart are both attested and signed. Because images are
also built for pull requests, whose certificates name refs/pull/N/merge, pin the
identity to the release shape rather than accepting any identity from this repository:
$ cosign verify ghcr.io/voxpupuli/openvox-ca:${VERSION} \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-identity-regexp '^https://github\.com/voxpupuli/openvox-ca/\.github/workflows/container-images\.yml@refs/tags/v'SBOMs. Each tarball ships an SBOM in both SPDX-JSON (.spdx.json, ISO/IEC 5962)
and CycloneDX-JSON (.cdx.json, ECMA-424), generated from the built binaries rather
than from go.mod, so they record what actually linked.
Container images carry the equivalent pair as registry attestations, catalogued from the image so the base-layer packages are included too. These are attached to each per-architecture image rather than to the multi-arch index, because that is where they differ — so resolve a child digest first; a tag resolves to the index, which carries provenance only:
$ digest="$(docker buildx imagetools inspect ghcr.io/voxpupuli/openvox-ca:${VERSION} \
--format '{{range .Manifest.Manifests}}{{if eq .Platform.Architecture "amd64"}}{{.Digest}}{{end}}{{end}}')"
$ gh attestation verify oci://ghcr.io/voxpupuli/openvox-ca@${digest} \
--repo voxpupuli/openvox-ca \
--signer-workflow voxpupuli/openvox-ca/.github/workflows/container-images.yml \
--source-ref refs/tags/v${VERSION} \
--predicate-type https://spdx.dev/Document/v2.3Select the child by architecture rather than by position — the order the index
lists them in is not meaningful, so index … 0 would verify whichever image
happened to sort first. Substitute arm64 for the other architecture, and
--predicate-type https://cyclonedx.org/bom for the CycloneDX document.
The SPDX predicate type carries the document's own SPDX version, so it moves if
the generator's output version does: check the spdxVersion field of the
published .spdx.json if v2.3 stops matching. CycloneDX's is unversioned.
The Helm chart carries provenance and a signature but no SBOM: it declares no
dependencies, so there would be nothing to catalogue.
A chart is published as an OCI artefact for every release, versioned in lockstep with the server:
$ helm install openvox-ca \
oci://ghcr.io/voxpupuli/openvox-ca-charts/openvox-ca \
--namespace puppet --create-namespace \
--set tls.existingSecret=openvox-ca-server-tlsSee deploying with Helm for the guide and the chart README for the values reference. The chart is signed and attested like the images:
$ cosign verify ghcr.io/voxpupuli/openvox-ca-charts/openvox-ca:${VERSION} \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-identity-regexp '^https://github\.com/voxpupuli/openvox-ca/\.github/workflows/helm-chart\.yml@refs/tags/v'Requires Go 1.25+ and Mage:
git clone https://github.com/voxpupuli/openvox-ca.git
cd openvox-ca
mage build:all # → bin/openvox-ca and bin/openvox-ca-ctlFull build instructions (including the FIPS build) are in
CONTRIBUTING.md.
./bin/openvox-ca --cadir /etc/puppetlabs/puppet/ssl/ca --host 127.0.0.1 --hostname puppet.example.comOn first run the server bootstraps a new CA under --cadir and begins serving
plain HTTP on port 8140. The server refuses plain HTTP on a non-loopback
address unless --no-tls-required is set — only do that behind a trusted
TLS-terminating proxy or in test environments. For anything reachable from the
network, serve HTTPS as below.
./bin/openvox-ca \
--cadir /etc/puppetlabs/puppet/ssl/ca \
--tls-cert /etc/puppetlabs/puppet/ssl/ca/signed/puppet.example.com.pem \
--tls-key /etc/puppetlabs/puppet/ssl/ca/private/puppet.example.com_key.pem \
--puppet-server puppet.example.com--tls-cert needs a serving certificate the CA has issued to itself. The CA's
own ca_crt.pem is not one — its keyUsage is certSign, cRLSign and it
carries no subjectAltName, so a server presenting it completes the handshake
and is then rejected by every agent, and the server says so at startup. Issue
one by starting the CA on loopback with TLS off and asking it for a
certificate:
# Stop any CA already listening on 8140 first, or this one cannot bind.
./bin/openvox-ca --tls-cert= --tls-key= \
--cadir /etc/puppetlabs/puppet/ssl/ca --host 127.0.0.1 --hostname puppet.example.com &
PCA_PID=$!
# Wait for it to serve: a cold cadir generates an RSA-4096 key first.
for _ in $(seq 1 300); do
curl -sf http://127.0.0.1:8140/puppet-ca/v1/certificate/ca >/dev/null && break; sleep 1
done
./bin/openvox-ca-ctl generate \
--server-url http://127.0.0.1:8140 \
--certname puppet.example.com \
--dns puppet.example.com \
--out-dir /etc/puppetlabs/puppet/ssl/ca/private
kill $PCA_PID; wait $PCA_PID 2>/dev/nullWhile that server is up its whole admin API is unauthenticated — the
authorisation middleware is only installed when tls_cert and tls_key are
both set, and that includes POST /generate/<subject>, which hands back a
signed certificate and its private key. --host 127.0.0.1 is required rather
than advisable, and the kill above is part of the procedure, not tidying.
--server-url is needed because it defaults to HTTPS while that server speaks
plain HTTP, and the empty --tls-cert=/--tls-key= keep the start from
picking up a tls_cert in /etc/puppet-ca/config.yaml — which would name the
file this step is about to create. --out-dir points at the directory the CA
writes the key to anyway, so no second copy of it is left lying around; omit it
and one lands in the current directory. The certificate is printed on stdout,
and with the default filesystem backend the CA also writes both halves under
cadir, at the two paths --tls-cert/--tls-key point at above, so once the
temporary server is stopped you can start again with the TLS flags. Serving
certificate has the full procedure,
including the other storage backends and running it under systemd.
When --tls-cert and --tls-key are both set, the server:
- Presents those certs to connecting clients
- Requests (but does not require) a client certificate from every connection, and does not verify it at the TLS layer
- Enforces endpoint-level authorization, which is where a presented certificate is verified against this CA and checked against the CRL (see Authorization tiers)
The complete flag, environment-variable, and config-file reference is in configuring the server.
| Guide | What it covers |
|---|---|
| Configuring the server | Every flag, environment variable, config-file key; the serving certificate; autosigning; directory layout; graceful shutdown; reloading configuration; trusting client certificates from another CA |
| HTTP API reference | All endpoints, authorization tiers, and admin credential resolution |
Operator CLI (openvox-ca-ctl) |
The openvox-ca-ctl command reference, and the offline openvox-ca subcommands (csr, import-ca-cert, generate) that run against the server's own configuration |
| Storage backends | filesystem, SQLite, PostgreSQL, MySQL, etcd, Redis/Valkey; migrating between them |
| CA key security | Process isolation and the signer handshake, key encryption at rest, key-custody options, PKCS#11 plans, destructive-op monitoring |
| OpenBao Transit-engine CA key | Delegating CA key custody to OpenBao |
| Deploying with Helm | The openvox-ca chart: installation, TLS passthrough, ingress and Gateway API, monitoring |
| Kubernetes export | Publishing the CA cert/CRL into Secrets and ConfigMaps |
| Metrics & monitoring | The Prometheus exporter and the alerting mixin |
| Running under systemd | The Type=notify unit, status text, systemctl reload, watchdog, and hardening |
| Container images | Pulling and running the published images |
| Migration guide | Replacing an OpenVox/Puppet Server built-in CA |
Two rules catch operators out, neither of which changes a stock deployment.
GET /certificate_status/{subject} is admin-only. That matches OpenVox/Puppet
Server's shipped auth.conf, so only a configuration that had been relaxed to
let ordinary agent certificates read statuses loses that access. It can be
granted back two ways, both of which confer the whole admin tier — signing and
revocation included — plus an escape hatch that makes the route public rather
than authenticated. Denials are logged, with the string to grep for, in
authorisation parity.
POST /certificate_renewal accepts only certificates this CA issued and has not
revoked. In the default single-issuer topology nothing reaches that check that
the authorisation middleware had not already refused with 403 access denied
(TLS requests but does not verify a client certificate — see HTTPS with
mTLS). The gate becomes load-bearing once a second issuer can
be trusted for client authentication. There is no opt-out: an agent holding a
certificate from a replaced CA must re-enrol. See renewal
eligibility.
Contributions are welcome. See CONTRIBUTING.md for how to
build, test, and submit changes, AGENTS.md for the repository
conventions, and the development documentation for internal
design notes and the test suites.
See LICENSE.