Under construction

This documentation is still in progress. Some information may be incomplete or out of date. Please check back later.

Docs/TLS, ACME & OCSP

Internals

TLS, ACME & OCSP

How httpjet selects certificates, verifies clients, and resumes sessions — plus the two opt-in certificate services: ACME issuance and renewal (HTTP-01 and DNS-01, --features acme) and OCSP stapling (--features ocsp). Both are default-off at build and runtime, and both publish into the same generation-bound resolver slots that file-based certificates and SIGHUP use.

TLS configuration

hj-tls builds one rustls::ServerConfig for TCP and QUIC via build_server_config_alpn(cfg, listener, alpn), on the aws-lc-rs provider. A certificate rotation, verifier change or CRL update therefore lands on both transports at once.

Listener XMLMeaning
secure, keyFile, certFileThe listener's default identity (PEM). certChain=1 appends CACertFile as the served chain.
per-vhost <vhssl>The SNI identity for that vhost's domains — server chain material, never a client-verify root. Configure an ECDSA and an RSA pair for the same names to serve modern clients the smaller chain (ECDSA is chosen when the client offers ECDSA signature algorithms).
clientVerify 0–3, CACertFile, verifyDepthMutual TLS: 0 none · 1 optional · 2 required · 3 optional without CA check. The verifier is built from the listener CA only. Positive chain verdicts are memoized off the hot path.
crlFile httpjetPEM CRL or bundle enforced by the webpki verifier. Absent = no revocation checking. Loaded at config build; rotating means a reload of the trust generation.
enableStaplingParsed for compatibility; OCSP fetching needs the ocsp feature and --ocsp-responder (see below). check says so.

Handshake posture

!
Two CAs, two roles

A per-vhost CACertFile is chain material sent to clients. The listener CACertFile is the root that verifies client certificates. Treating the vhost chain CA as a client-verify root would silently widen the mTLS trust boundary; httpjet keeps them distinct.

ACME certificate automation --features acme

Build with cargo build --locked -p httpjet --features acme. The manager is native CLI, not pretend LiteSpeed XML: nothing happens unless all four required flags are present. One manager, account and identifier set is supported per process; its domain and TLS/HTTP vhost mappings are boot-frozen.

serve · HTTP-01
httpjet --root /abs/root serve … \
  --acme-directory https://YOUR-CA/directory \
  --acme-domains example.test,www.example.test \
  --acme-store /abs/private/acme \      # pre-created, mode 0700, owned by the service uid
  --acme-accept-terms \
  --acme-bootstrap                      # only for first issuance without a configured certificate

Requires public TCP port 80 and an enabled TLS listener. The raw challenge route /.well-known/acme-challenge/<token> runs before URI normalization, redirects, rewrites, auth, cache and backends; only plaintext GET on the validated authority answers (404 for unknown tokens, 405 otherwise, always Cache-Control: no-store); tokens are redacted in the access log. No exemption is granted for forwarded-scheme headers, and the historical path-prefix bypass (#259) stays closed.

serve · DNS-01
httpjet --root /abs/root serve … \
  --acme-directory https://YOUR-CA/directory \
  --acme-domains 'example.test,*.example.test' \   # quote the wildcard
  --acme-store /abs/private/acme --acme-accept-terms \
  --acme-dns-webhook https://YOUR-CONTROLLER/acme \
  --acme-dns-zones example.test \
  --acme-dns-token-file /abs/private/dns-controller.token   # regular file, 0600, 16..4096 printable chars

No port 80 requirement and no HTTP challenge exemption. There is no built-in Cloudflare or provider integration: you run a controller that implements the webhook contract below with a credential scoped to the listed zones. A managed wildcard matches exactly one leftmost label — request the apex separately.

controller contract · POST, Bearer token, application/json
{"version":1,"operation":"present","name":"_acme-challenge.example.test","value":"<43-char base64url SHA-256>"}
{"version":1,"operation":"ready",  "name":"…","value":"…"}   → {"ok":true,"ready":true|false}
{"version":1,"operation":"cleanup","name":"…","value":"…"}   → {"ok":true}

Operations must be idempotent: present adds the exact value without replacing the RRset; cleanup removes only that value and succeeds if it is already absent (apex and wildcard share one owner name). Replies over 16 KiB, redirects, non-2xx and unexpected fields are rejected; each call has a 5 s deadline; a ready:true returned without real DNS visibility is not a valid production implementation.

--acme-test-mode permits literal-loopback HTTP directories and alternate HTTP ports and requires loopback serving addresses. --acme-test-ca-root and --acme-test-issuer-root replace trust roots without disabling verification; --acme-dns-test-root does the same for the DNS controller. These are for the Pebble fixture only:

acceptance · Pebble v2.10.1
cargo test --locked -p hj-acme
cargo build --locked -p httpjet --features acme
python3 -B scripts/acme-pebble-test.py --binary target/debug/httpjet \
  --pebble /path/to/pebble --dns /path/to/pebble-challtestsrv \
  --pebble-source /path/to/pebble-v2.10.1 --bootstrap [--dns01]

Issuance, recovery and activation

TLS resolversTCP · QUIC · kTLSACME CA(RFC 8555)httpjetacme managerPrivateStore(0700 dir, atomic snapshots)renewal scheduled with 1/3 of remaining lifetime in reserve (+ jitter)persist account key (before registration)1newAccount / newOrder (bounded HTTP: 5 s connect, 10 s request, 1 MiB)2persist certificate key + CSR (before finalize)3authorization → http-01 token4register token in ChallengeRegistry (≤128 live, TTL ≤15 min)5GET http://host/.well-known/acme-challenge/<token> (port 80, plaintext only)6key authorization (Cache-Control: no-store — no rewrite, cache, auth or backend involved)7finalize → poll (120 s attempt deadline, backoff + Retry-After)8certificate chain9validate chain, validity, exact SAN set, key match10commit cert + key + order state in ONE snapshot11publish immutable CertifiedKey overlay (exact-SNI)12
HTTP-01 issuance. Every durable step precedes the network step that depends on it: the account key before registration, the certificate key and CSR before finalize, and the issued pair in the same atomic snapshot as account/order state. Only a fully validated pair is published to the TLS resolvers.
diagram source (mermaid)
sequenceDiagram
  autonumber
  participant S as PrivateStore<br/>(0700 dir, atomic snapshots)
  participant J as httpjet<br/>acme manager
  participant CA as ACME CA<br/>(RFC 8555)
  participant R as TLS resolvers<br/>TCP · QUIC · kTLS
  J->>S: persist account key (before registration)
  J->>CA: newAccount / newOrder (bounded HTTP: 5 s connect, 10 s request, 1 MiB)
  J->>S: persist certificate key + CSR (before finalize)
  CA-->>J: authorization → http-01 token
  J->>J: register token in ChallengeRegistry (≤128 live, TTL ≤15 min)
  CA->>J: GET http://host/.well-known/acme-challenge/<token> (port 80, plaintext only)
  J-->>CA: key authorization (Cache-Control: no-store — no rewrite, cache, auth or backend involved)
  J->>CA: finalize → poll (120 s attempt deadline, backoff + Retry-After)
  CA-->>J: certificate chain
  J->>J: validate chain, validity, exact SAN set, key match
  J->>S: commit cert + key + order state in ONE snapshot
  J->>R: publish immutable CertifiedKey overlay (exact-SNI)
  Note over J,R: renewal scheduled with 1/3 of remaining lifetime in reserve (+ jitter)
ACME CADNS controller(operator webhook, Bearer auth)PrivateStorehttpjetacme managerloop[bounded polling]a failed cleanup keeps the journal entry: new issuance blocks until replayed (also at startup and shutdown)journal cleanup intent {name, value} BEFORE any DNS change1POST {"version":1,"operation":"present","name":"_acme-challenge.example.test","value":"<43-char digest>"}2{"ok":true}3POST operation "ready"4{"ok":true,"ready":false|true}5ready → finalize → poll (inside the 120 s attempt)6certificate (apex and/or *.zone — one leftmost label)7POST operation "cleanup" (removes only that value — idempotent)8{"ok":true}9clear journal entry, commit certificate snapshot10
DNS-01 with durable cleanup. The exact owner/value cleanup intent is journaled before any DNS mutation, so a failed, cancelled or timed-out presentation is always recoverable; cleanup replays after attempts, at shutdown and at startup, and blocks new issuance until it succeeds. Snapshots bind cleanup to the provider endpoint and sorted zone scope — changing those while reusing the store fails closed.
diagram source (mermaid)
sequenceDiagram
  autonumber
  participant J as httpjet<br/>acme manager
  participant S as PrivateStore
  participant D as DNS controller<br/>(operator webhook, Bearer auth)
  participant CA as ACME CA
  J->>S: journal cleanup intent {name, value} BEFORE any DNS change
  J->>D: POST {"version":1,"operation":"present","name":"_acme-challenge.example.test","value":"<43-char digest>"}
  D-->>J: {"ok":true}
  loop bounded polling
    J->>D: POST operation "ready"
    D-->>J: {"ok":true,"ready":false|true}
  end
  J->>CA: ready → finalize → poll (inside the 120 s attempt)
  CA-->>J: certificate (apex and/or *.zone — one leftmost label)
  J->>D: POST operation "cleanup" (removes only that value — idempotent)
  D-->>J: {"ok":true}
  J->>S: clear journal entry, commit certificate snapshot
  Note over J,D: a failed cleanup keeps the journal entry: new issuance blocks until replayed (also at startup and shutdown)

Scope today: DNS identifiers only (ASCII A-labels), one account/group per process, same-origin ACME endpoints, no EAB, contact management or ARI. Production at windowsforum.com still renews through certbot; the manager is an adoption path, not a forced replacement.

OCSP stapling --features ocsp

Build with --features ocsp (OpenSSL 3.x development headers and pkg-config at build time, the shared libraries at runtime). Pass --ocsp-responder https://ocsp.example.net/status to serve, optionally with --ocsp-required. The explicit CLI opt-in applies to every certificate on the process's TLS listeners regardless of the XML enableStapling boolean, and the one responder must serve every enabled issuer (per-issuer routing is not implemented).

!
Not for Let's Encrypt

Let's Encrypt retired its OCSP service on 2025-08-06 and publishes CRLs instead. There is no responder to fetch from — do not enable this manager for current Let's Encrypt certificates. It exists for other CAs.

GOODREVOKEDUNKNOWN / malformed /transientyesnonoyes

--ocsp-responder URL
(build with --features ocsp)

background manager
≤128 identities · ≤4 concurrent fetches
5 s connect / 10 s total / 64 KiB

response verified?
issuer or delegated signer · SHA-256 CertID
thisUpdate ≤ 7 d old · nextUpdate in future

staple installed in generation-bound slot
refresh before expiry with jitter

suppress future handshakes for that leaf
(not undone by same-leaf reload)

still-valid cached staple?

--ocsp-required
or Must-Staple?

serve without a staple

reject new TLS handshakes until a valid response arrives

handshake: staple attached
TLS resumption disabled while OCSP is enabled

Status lifecycle. Fetching never happens during a handshake. A verified GOOD response fills a generation-bound staple slot and is refreshed before expiry with jitter; a verified REVOKED result suppresses that leaf's handshakes even across a same-leaf reload; malformed or transient failures preserve a still-valid staple and otherwise fall to the optional/required policy.
diagram source (mermaid)
flowchart TB
  classDef bad fill:#2a1420,stroke:#e879f9,color:#fff
  classDef ok fill:#0f2a24,stroke:#34d399,color:#fff
  classDef warn fill:#2a2314,stroke:#fbbf73,color:#fff
  A["--ocsp-responder URL<br/>(build with --features ocsp)"] --> B["background manager<br/>≤128 identities · ≤4 concurrent fetches<br/>5 s connect / 10 s total / 64 KiB"]
  B --> C("response verified?<br/>issuer or delegated signer · SHA-256 CertID<br/>thisUpdate ≤ 7 d old · nextUpdate in future")
  C -- GOOD --> D["staple installed in generation-bound slot<br/>refresh before expiry with jitter"]:::ok
  C -- REVOKED --> E["suppress future handshakes for that leaf<br/>(not undone by same-leaf reload)"]:::bad
  C -- UNKNOWN / malformed / transient --> F("still-valid cached staple?")
  F -- yes --> D
  F -- no --> G("--ocsp-required<br/>or Must-Staple?")
  G -- no --> H["serve without a staple"]:::warn
  G -- yes --> I["reject new TLS handshakes until a valid response arrives"]:::bad
  D --> J["handshake: staple attached<br/>TLS resumption disabled while OCSP is enabled"]
acceptance · real-server fixtures on isolated listeners
cargo test --locked -p hj-ocsp
cargo test --locked -p hj-tls --features ocsp ocsp
cargo build --locked -p httpjet --features acme,ocsp
python3 -B scripts/ocsp-runtime-test.py --binary target/debug/httpjet            # required policy
python3 -B scripts/ocsp-runtime-test.py --binary target/debug/httpjet --optional
python3 -B scripts/ocsp-runtime-test.py --binary target/debug/httpjet --optional --must-staple

Kernel TLS

The staged --ktls path (feature ktls) upgrades a completed TLS 1.3 rustls handshake to kernel TLS so H1 file bodies leave through sendfile(2). It is hardware-aware and off on virtual NICs by default; see Protocol stacks → File egress for the policy and the measured (negative-on-GVE) receipt.

httpjet · documentation · synced to the source tree on 2026-10-13 (v0.0.5).
Source: github.com/faratech/httpjet · Home: httpjet.net · Found an error? Open an issue on the repository.