Broker + agents¶
For a fleet whose hosts can't reach the internet but can reach a service on
your network. A broker (gapcertd) sits in a DMZ / egress zone, holds the ACME
account and DNS credentials, and issues certificates for thin agents over
mutual TLS.
[internal host] [broker: DMZ, egress-only] [internet]
gapcert agent ──CSR──► gapcertd ──────► ACME CA
(key stays here) ACME account + DNS creds ─────► DNS provider
◄─cert── policy · mTLS · audit log
Agents generate their key locally and submit only a CSR, so hundreds of hosts never touch cloud credentials and no private key crosses the network.
One-time broker setup¶
Write a broker config (/etc/gapcertd/config.yaml):
listen: 0.0.0.0:8443
server_names: ["gapcert-broker.example.com"]
data_dir: /var/lib/gapcertd
ca: letsencrypt
email: ops@example.com
policies:
- match: "vpn.example.com" # per-host: agent keeps its own key
solver: challenge-zone
solvers:
challenge-zone:
type: acme-dns
api_base: https://acme-dns.example.com
storage_path: /var/lib/gapcertd/acme-dns.json
Create the internal CA (once) — it prints the fingerprint agents pin at enrollment:
$ gapcertd init -config /etc/gapcertd/config.yaml
internal CA created in /var/lib/gapcertd/ca
CA fingerprint (give to agents at enrollment):
184dbde412aa719a66b6cdefe734a4a7726779c1deae42f213c8409ff44bc67b
Then run it (gapcertd serve -config …, e.g. under systemd). The broker's API
is always mTLS-gated and enrollment is always token-gated, so binding a routable
interface is expected; the startup line states the posture.
Enrolling an agent¶
On the broker, mint a one-time token for a named agent:
$ gapcertd token -config /etc/gapcertd/config.yaml -agent vpn-01
one-time enrollment token for agent "vpn-01" (valid 24h):
<token>
CA fingerprint:
184dbde412aa719a66b6cdefe734a4a7726779c1deae42f213c8409ff44bc67b
On the agent host, enroll with the token and the fingerprint (pinning is mandatory — fail-closed):
$ gapcert agent enroll -broker https://gapcert-broker.example.com:8443 \
-token <token> -ca-fingerprint 184dbde4…4bc67b
enrolled with https://gapcert-broker.example.com:8443
The agent config just names what it wants; the broker's policy decides the rest:
# ~/.config/gapcert/agent.yaml
data_dir: /var/lib/gapcert
certificates:
- name: vpn
domains: ["vpn.example.com"]
hook: "systemctl reload strongswan"
$ gapcert agent obtain
vpn requesting [vpn.example.com] from broker ...
vpn stored /var/lib/gapcert/certs/vpn (expires 2026-10-24)
Run gapcert agent renew on a timer. The broker's fleet view shows every
issuance:
$ gapcertd status -config /etc/gapcertd/config.yaml
AGENT NAME DOMAINS EXPIRES DAYS STATUS
vpn-01 vpn vpn.example.com 2026-10-24 88 ok
Security properties¶
- Enrollment is a single-use token bound to an agent name; the issued client-cert identity comes from the token, never the CSR.
- mTLS on every API call; deleting an agent record revokes its access.
- Default-deny policy: a name matching no policy is refused.
- Bounded: per-IP enrollment rate limiting, request-body caps, an audit log.
The broker went through a full security cadence; see the security model for the trust boundary and residual risks.