Certificates¶
Certificates for the box's own services, from an issuer, kept renewed: Cockpit (the web console) and strongholdd's HTTPS API and standalone UI. Without one, each keeps its own self-signed certificate.
Config¶
# stronghold.yaml
certificates:
- id: box
names: [gw.example.net, gw.lan.example.net] # the first is the common name
for: [cockpit, api]
issuer: acme # acme, vault (OpenBao or Vault) or ipa
acme:
email: admin@example.net
directory: letsencrypt # or letsencrypt-staging, or a URL
challenge: dns # dns (DNS-01) or http (HTTP-01)
dns-provider: cloudflare # lego's providers: cloudflare, route53, ...
- id: inside
names: [gw.corp.example]
for: [api]
issuer: vault
vault:
address: https://bao.corp.example:8200
mount: pki # the PKI engine (default pki)
role: gateways # the role that signs
ca-certificate: | # trusted for the server, if private
-----BEGIN CERTIFICATE-----
...
- id: idm
names: [gw.idm.example]
for: [cockpit]
issuer: ipa # the box enrolled in FreeIPA / Red Hat IdM
ipa:
principal: HTTP/gw.idm.example # the default: HTTP/<first name>
The secrets, kept on the box, never in the config's git:
stronghold certificates secret ID NAME (asked, or
STRONGHOLD_CERT_SECRET), or the API's certificates section
(secrets: {id: {name: value}}):
- vault:
token, a token whose policy may use the role'ssignpath. - acme, DNS-01: the DNS provider's variables, as lego names them
(
CF_DNS_API_TOKENfor Cloudflare,AWS_ACCESS_KEY_IDandAWS_SECRET_ACCESS_KEYfor Route 53, ...). They reach lego as its environment, never on a command line; names that change how programs run (LD_*,PATH, ...) are refused.
For an IPsec tunnel¶
for may also name a site-to-site tunnel with certificates
(vpn.md, "IPsec"): ipsec:<tunnel id>, a tunnel in vpn.yaml with
auth: cert, one certificate each. The certificate and its key are
given to the tunnel as its own secrets (ipsec/<id>.cert and .key) and
libreswan loads them, replacing the tunnel's connection at every renewal.
The other side's CA is not the issuer's business: give it with
stronghold vpn ipsec cert ID --ca FILE. The names become the
certificate's DNS names, which the other side matches with remote-id:
"@name". A certificate no longer in the config leaves the tunnel what it
has. Use a private CA (Vault or OpenBao PKI, IPA) that issues
clientAuth: Let's Encrypt's certificates are for servers only. Tried in
the lab with a stand-in for the Vault API (the box's certificate issued,
installed and the tunnel up with it).
For an OpenVPN server¶
for: [openvpn:<server id>] the same way (vpn.md, "OpenVPN"): the
certificate and key become the server's secrets and the server restarts at
each renewal. The CA the clients' certificates come from is given with
stronghold vpn openvpn cert ID --ca FILE.
How¶
- The key is made on the box (ECDSA P-256) with a CSR for the names,
and the issuer signs it: lego (
--csr), OpenBao's or Vault's/<mount>/sign/<role>. Only certmonger makes its own key (on the box too). The key stays in/var/lib/stronghold/certs/<id>(0600). - When: a certificate is got when it is missing, its names or issuer
changed, or two thirds of its life are gone (Let's Encrypt's 90 days:
after 60), checked every hour and at every config change, in the
background (an ACME challenge takes a while). A new or changed one only
75 seconds after its change: a change is applied before it is
confirmed, and one that is rolled back mustn't have asked a CA (Let's
Encrypt's rate limits). Renewals don't wait. After a failure, not
again for an hour; the old certificate is used meanwhile.
stronghold certificates renew IDgets one now. The alerts warn two weeks before a certificate expires. - ACME with lego, in a container of its own (
stronghold-acme: lego's account and certificates in its volume; nothing runs between renewals; lego 5's command line). DNS-01 through lego's DNS providers, the way for a box whose port 80 the internet can't reach or for names it doesn't serve. HTTP-01: lego answers on port 80, and strongholdd opens the WANs' port 80 in firewalld (a runtime rule) for the challenge and closes it after. Not with firewalld's--timeout: while such a rule is there,firewall-cmd --reloadfails ("dictionary changed size during iteration", firewalld 2 on Python 3.14), and with it the next change. If the CA no longer knows lego's account (deactivated, a test CA that forgets), lego gets a new one. - OpenBao or Vault: strongholdd asks the PKI engine itself (HTTPS, the token from the secret). HashiCorp Vault's API is the same; Vault itself isn't in Fedora (BSL licence), OpenBao is.
- FreeIPA / Red Hat IdM: certmonger (on the box: the box's enrolment
is the host's) asks the IdM CA for the service principal, and keeps
tracking and renewing the certificate itself (
getcert list); the principal must exist in IdM (ipa service-add HTTP/gw.idm.example). strongholdd starts certmonger when it needs it, and has it stop tracking a certificate the config no longer has. - Never a certificate for another key: whatever the issuer answers, the certificate must be for the key the box made (and for every name), or it is refused and the old one stays. And should the API's files ever not load, strongholdd serves a self-signed certificate rather than fail every change, until the box's is put back.
- Where they go: Cockpit's
/etc/cockpit/ws-certs.d/50-stronghold.certand.key(Cockpit uses the last one by name; cockpit.service is restarted when they change); strongholdd's API certificate (/var/lib/stronghold/api/tls.crt), which it reads again when it changes, without a restart. A service no certificate is for any more gets its self-signed one back.
Status: stronghold certificates, GET /v1/certificates, the System
tab's "Certificates".
In the lab¶
2026-10-22, with a real FreeIPA server (its container, 4.13, a domain of
its own; the box joined with stronghold identity join): the principal
HTTP/sg-a.lab2.example added in IdM (ipa service-add), a certificate
for: [ipsec:t] with issuer: ipa. Found: it failed at once with
certmonger's "Path ... insufficient permissions". getcert, run as
strongholdd, checks that it may write the directory first, and SELinux
refused strongholdd the write on cert_t (hidden by a dontaudit, so
nothing was logged): the directory is stronghold_cert_t now, managed by
strongholdd and certmonger (selinux.md). Then IdM's CA ("Certificate
Authority" of the realm) issued an RSA certificate valid for two years, the
IPsec tunnel came up with it, the peer trusting IdM's CA (/etc/ipa/ca.crt).
The earlier IPA test below used certmonger's own CA only.
2026-10-17, on stronghold-iso, each issuer for real:
- OpenBao (a dev server on the box: a PKI with a root and a role for
lab.home.arpa,key_type: anyfor the box's ECDSA key): Cockpit servedgw.lab.home.arpafrom "Lab Root CA", the token a secret, not in the config's git. - ACME (Pebble on the box, validating through the box's Unbound): Pebble fetched the HTTP-01 challenge from lego in its container and issued; the API served Pebble's certificate without a restart.
- IPA's path (certmonger's own
localCA: no IdM server then):MONITORING, the key and certificate where Stronghold reads them. - Found: lego 5 changed its command line (the command first,
--http.address); lego gave up when Pebble had forgotten its account; firewalld's reload failed after the HTTP-01 rule with a timeout, and rolled the next change back; a change rolled back had already asked certmonger, which kept tracking. Worst: asked to renew 18 minutes after the first certificate, lego 5's run handed back that one, not due yet, made for the old key; strongholdd installed it with the new key for the API, and from then on every change failed at the API's certificate and was rolled back. lego's last certificate is removed before each run now, a certificate for another key is refused, and the API falls back to self-signed instead of failing. All fixed, as above.