Skip to content

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.