Feature Specification: Resource ssl

Created: 2026-10-07

Status: Implemented

Input: Reconstructed from the code at 093e68b6f (core/resources/ssl/default.nix, core/resources/ssl/cross-node.nix, core/resources/ssl/self-signed.nix, core/resources/ssl/acme.nix, core/resources/ssl/openbao-pki.nix, core/resources/ssl/openbao-pki-login.nix, core/resources/ssl/openbao-pki-trust.nix, core/setups/wiring/tls-routing.nix, core/setups/wiring/tls-trust.nix, core/setups/wiring/served-domains.nix, core/setups/hosts-common.nix), pull requests #1184, #1205, #1236, #1238, #1241, #1246, #1266, #1361, #1383, #1405, #1433 and #1690, and issue #527.

User Scenarios & Testing (mandatory)

User Story 1 - Serve a domain over TLS (Priority: P1)

A component serves one or more domains. It states a request with domains. The policy routes each domain to a provider of the ssl contract. The consumer node gets an nginx vhost with forceSSL and the certificate and key from the provider. A service that terminates TLS itself, such as PostgreSQL or maddy, gets the files but no vhost.

Why this priority: each public and internal endpoint needs a certificate. Without one, clients refuse to connect.

Independent Test: contracts-ssl-self-signed and integrations-openbao-pki. nix-unit-setups-ssl-key-accounts checks the key owners. nix-unit-resources-ssl evaluates the apply of the resource as a node: the nginx vhost and port for each domain, and no vhost for a domain whose key another service owns.

Acceptance Scenarios:

  1. Given a request with domains = [ "a.example.org" ], When the consumer side is evaluated, Then nginx gets a vhost for a.example.org with sslCertificate and sslCertificateKey from contracts.ssl.results.fediversity."a.example.org", and the firewall opens port 443.
  2. Given a domain whose provider request sets a keyOwner other than nginx, When the consumer side is evaluated, Then nginx gets no vhost for that domain.
  3. Given a domain without an explicit key group, When the consumer side is evaluated, Then keyGroup defaults to nginx and restartServices defaults to [ "nginx.service" ].
  4. Given a request with no domains, When the policy is evaluated, Then apply gives no consumer side.

User Story 2 - Pick a certificate authority per domain (Priority: P1)

A domain that browsers open needs a certificate from a public CA. A domain that only the platform opens can use the private CA of OpenBao, or a self-signed certificate. tls-routing.nix sorts each domain into one of these routes. hosts-common.nix turns the result into per-domain provider overrides.

Why this priority: a wrong route gives either a browser warning or a failed ACME order for a name the internet cannot reach.

Independent Test: nix-unit-setups-tls-routing, nix-unit-setups-wildcard-tls and nix-unit-setups-served-domains.

Acceptance Scenarios:

  1. Given a domain of a component with certAudience = "browser", When partition runs, Then the domain goes to acmeDomains.
  2. Given a domain of a component with certAudience = "bootstrap" that is not a UI domain, When partition runs, Then the domain goes to pkiDomains.
  3. Given the group has a PKI, When pkiPlan runs, Then the valkey, postgresql and forward-auth UI domains go to the openbao-pki provider. Without a PKI, they go to self-signed. pkiPlan reads whether the group has a provider of each resource through hasProvider, keyed by resource name.
  4. Given a wildcard domain routed to acme, When the node is evaluated, Then the node gets a security.acme.certs entry with a DNS-01 provider. A non-wildcard domain uses the nginx enableACME.

User Story 3 - Issue and renew private certificates from OpenBao (Priority: P2)

A node logs in to OpenBao with its SPIFFE identity. It issues each certificate from the PKI mount. Every hour, it renews each certificate that is in the last third of its lifetime, and restarts the services that use that certificate.

Why this priority: private certificates expire after 72h. Without renewal, the internal endpoints break after three days.

Independent Test: integrations-openbao-pki and nix-unit-resources-ssl-openbao-pki-login.

Acceptance Scenarios:

  1. Given a domain routed to openbao-pki, When openbao-pki-<certName> runs, Then it runs bao write <mount>/issue/<role> and writes /run/openbao-pki/<certName>/cert.pem (mode 0644) and key.pem (mode keyMode, default 0640).
  2. Given a new certificate, When the unit writes it, Then the unit runs systemctl reset-failed and systemctl reload-or-restart --no-block on each unit in restartServices.
  3. Given a node without its own SPIRE login, When vars-openbao-login runs, Then it fetches an SVID, runs bao login -method=cert and writes /run/vars-openbao/token.
  4. Given the SPIRE agent is not healthy before the deadline, When vars-openbao-login times out, Then it exits with code 3 and systemd does not restart it.
  5. Given a node with vars-openbao-login from openbao-pki-login.nix, When the node is evaluated, Then the login is ordered before the openbao-pki-<certName> unit of each domain routed to openbao-pki and before no other unit. Each of these units also orders itself after the login and wants it.

User Story 4 - Trust certificates that other nodes serve (Priority: P2)

A node connects to services on other nodes over TLS. It must trust their certificates. For a self-signed domain, the node bakes the certificate into security.pki.certificateFiles. For a private CA, the node fetches the CA and builds a trust bundle.

Why this priority: without trust, every cross-node TLS connection fails the certificate check.

Independent Test: nix-unit-setups-pki-trust.

Acceptance Scenarios:

  1. Given a trust domain routed to self-signed, When the node is evaluated, Then the certificate of that domain is in security.pki.certificateFiles.
  2. Given caCertFile and caFetchUrl are set, When openbao-pki-ca-fetch runs, Then it fetches the CA over plain HTTP, and a timer fetches it again every 15 minutes.
  3. Given caCertFile is set, When openbao-pki-trust runs, Then it writes /etc/ssl/certs/openbao-pki-bundle.crt from security.pki.caBundle plus the CA, and SSL_CERT_FILE points to that bundle.

Edge Cases

  • A wildcard domain routes to acme and fediversity.acme.dnsProvider is null: an assertion fails at eval.
  • A self-signed certificate is built at eval through IFD. Its derivation name carries 12 characters of the certificate digest, so a new certificate changes the name.
  • The CA fetch fails: the unit fails soft, and the timer tries again.
  • OpenBao is not reachable at issue time: openbao-pki-<certName> restarts after 10 seconds without a start limit.
  • The OpenBao address is https and caCertFile is set: the issue unit sets VAULT_CACERT.

Requirements (mandatory)

Functional Requirements

  • FR-001: The policy MUST route each domain through contracts.ssl.want.<consumer>.<domain>.request.domain.
  • FR-002: The policy MUST make nginx vhosts only for domains that nginx terminates.
  • FR-003: The policy MUST set enableACME only for a non-wildcard domain routed to acme, and MUST set useACMEHost for a wildcard domain.
  • FR-004: crossNode MUST give the results, the bakeableDomains (the domains routed to self-signed) and the pkiCaCertFiles.
  • FR-005: The self-signed provider MUST make an RSA 4096 certificate with CN=<domain>, a SAN for the domain plus extraSubjectAltNames, and a validity of 36500 days.
  • FR-006: The acme provider MUST use Let's Encrypt when server is null, and HTTP-01 only when dnsProvider is null.
  • FR-007: The openbao-pki provider MUST issue with ttl (default 72h). Every hour, it MUST renew a certificate that is missing, that is in the last third of its lifetime, or that does not chain to caCertFile, and only then restart the services that use it.
  • FR-008: vars-openbao-login MUST refresh the OpenBao token every 30 minutes.
  • FR-009: When systemd-creds-openbao is enabled, vars-openbao-login MUST reload or restart it after a login.
  • FR-010: openbao-pki-trust.nix MUST set SSL_CERT_FILE both as an environment variable and in the systemd manager DefaultEnvironment.

Key Entities

  • ssl request: domains (served here) and trustDomains (served elsewhere, trusted here).
  • ssl policy: routing and the read-only crossNode.
  • ssl providers: self-signed, acme (options under fediversity.acme) and openbao-pki (options under fediversity.openbao-pki).
  • Provider request fields: domain, keyOwner, keyGroup, keyMode and restartServices.
  • Routing plan: pkiDomains, forwardAuthPkiDomains, acmeDomains, challengeNodes, selfSignedDomains and allPkiDomains from tls-routing.nix.

Success Criteria (mandatory)

Measurable Outcomes

  • SC-001: contracts-ssl-self-signed and integrations-openbao-pki pass.
  • SC-002: nix-unit-setups-tls-routing, nix-unit-setups-pki-trust, nix-unit-setups-wildcard-tls, nix-unit-setups-ssl-key-accounts, nix-unit-setups-served-domains and nix-unit-resources-ssl pass.
  • SC-003: A browser opens each public domain without a certificate warning.

Assumptions

  • The wiring and the setup flow are as in 005. The request and policy model is as in 001.
  • The private CA is the PKI mount of the openbao component. The node identity comes from the spire component.
  • The DNS-01 challenge needs a zone that the platform publishes, see the bind component.
  • The smtp, oidc and postgresql resources declare their own sslDomains. See smtp, oidc and postgresql.
  • No check runs the acme provider against an ACME server.

Known Deviations

None.