NPRPC docs

Let's Encrypt with certbot

How to get and renew Let's Encrypt certificates for nprpc backends sitting behind npquicrouter, which routes by SNI and does not terminate TLS.

The topology

                              ┌──────────────────────────────┐
   :80  ──── HTTP-01 ────────►│ npquicrouter                 │
                              │  acme_webroot=/var/www/acme  │
   :443 TCP ─ TLS passthrough►│  SNI → backend               │
   :443 UDP ─ QUIC passthrough│                              │
                              └───┬──────────────────────┬───┘
                                  │                      │
                    site1.example.com         site2.example.com
                    (nprpc, own cert)         (nprpc, own cert)

Each hostname maps to exactly one backend, and each backend terminates TLS with a certificate for its own name. Nothing is shared, so there is no contention between backends over who owns a certificate.

Answering the challenge: HTTP-01 through the router

npquicrouter does not terminate TLS, so it cannot answer a tls-alpn-01 challenge. It does own port 80, so it answers http-01 for every hostname it routes. The challenge token only has to exist on the router, and the backends take no part in issuance.

Set acme_webroot in the router config to the directory you hand certbot:

{
  "http_redirect_port": 80,
  "acme_webroot": "/var/www/acme"
}

With that set, GET /.well-known/acme-challenge/<token> is served from disk and everything else still gets its 301 to HTTPS. See the router's ACME HTTP-01 section for the path handling and token validation.

The router runs as www-data in the sample systemd unit while certbot runs as root, so create the webroot readable by both:

sudo install -d -o root -g www-data -m 0755 /var/www/acme
sudo install -d -o root -g www-data -m 0755 /var/www/acme/.well-known
sudo install -d -o root -g www-data -m 0775 /var/www/acme/.well-known/acme-challenge

Certificate reload in nprpc

nprpc reads its certificate and key when a listener starts. Since a renewal happens roughly every 60 days and would otherwise go unnoticed until the old certificate expires, the files can be re-read in place:

#include <nprpc/nprpc.hpp>

bool ok = nprpc::reload_certificates();

This covers every listener that terminates TLS with those files:

Listener What reload does
HTTP/1.1 + WebSocket builds a new ssl::context and publishes it; the next accepted connection picks it up
HTTP/3 each worker reloads its own SSL_CTX on its own io_context thread
QUIC RPC (with_quic) builds a new MsQuic configuration for connections accepted from now on

Connections already established keep the certificate they handshook with — reload is not a disconnect. A subsystem whose files fail to parse keeps its previous certificate rather than dropping to none, so a truncated or half-written renewal cannot take TLS down; the call returns false and logs which subsystem refused.

reload_certificates() reads files and allocates, so call it from a normal thread — an asio::signal_set handler is fine, a raw POSIX signal handler is not.

Driving it from a certbot deploy hook

Wire SIGHUP to the reload in your server:

boost::asio::io_context signal_ioc;
boost::asio::signal_set signals(signal_ioc, SIGHUP);

std::function<void(const boost::system::error_code&, int)> on_hup =
    [&](const boost::system::error_code& ec, int) {
      if (ec)
        return;
      nprpc::reload_certificates();
      signals.async_wait(on_hup);   // re-arm
    };
signals.async_wait(on_hup);

Then issue and renew with a hook that signals the right backend:

certbot certonly --webroot -w /var/www/acme \
  -d site1.example.com \
  --deploy-hook 'systemctl kill -s HUP site1'

Renewals reuse the hook recorded at issuance, so certbot renew (the timer Debian and Arch install by default) needs no further configuration.

Or let nprpc watch the files

When certbot cannot conveniently run a hook — it renews inside a container, or writes to a shared volume — nprpc can poll the certificate and key instead and reload when either changes:

auto* rpc = nprpc::RpcBuilder()
                .with_http(443)
                    .root_dir("/srv/site1")
                    .ssl("/etc/letsencrypt/live/site1.example.com/fullchain.pem",
                         "/etc/letsencrypt/live/site1.example.com/privkey.pem")
                    .watch_certificates(std::chrono::minutes(10))
                    .enable_http3()
                .build();

Polling is off by default. It compares mtime and size on both files, so a same-second rewrite of a PEM is still noticed. Ten minutes is a reasonable interval — the deadline being met is measured in days, not seconds.

A --deploy-hook is still the better mechanism where it is available: it reloads at the moment the new certificate lands, with no polling and no window in which the old one is still being served.

From Swift

The Swift binding exposes the same two mechanisms.

let builder = RpcBuilder()
let http = builder.withHttp(443)
http.rootDir("/app/www")
http.ssl(certFile: "/certs/live/example.com/fullchain.pem",
         keyFile:  "/certs/live/example.com/privkey.pem")
http.enableHttp3()
// Optional: poll instead of (or alongside) a signal. 0 = off, the default.
http.watchCertificates(intervalSeconds: 600)

let rpc = try builder.build()
try rpc.startThreadPool(4)

Rpc.reloadCertificates() is the on-demand call. Wire it to SIGHUP next to the existing SIGINT handler — note the signal(SIGHUP, SIG_IGN), which DispatchSource requires so the default action does not kill the process first:

let hup = DispatchSource.makeSignalSource(signal: SIGHUP, queue: .main)
hup.setEventHandler {
    let ok = rpc.reloadCertificates()
    print("certificate reload: \(ok ? "ok" : "FAILED")")
}
signal(SIGHUP, SIG_IGN)
hup.resume()

Unlike SIGINT, the handler does not re-arm anything — a DispatchSource signal source keeps firing for every signal until cancelled.

Backends in Docker

A container changes only how the certificate gets in and how the signal gets out. Both are straightforward.

Getting the certificate in. Mount the host's /etc/letsencrypt read-only and point the server at the paths inside the container, here passed as environment variables your server reads:

-v /etc/letsencrypt:/certs:ro
-e TLS_CERT=/certs/live/example.com/fullchain.pem
-e TLS_KEY=/certs/live/example.com/privkey.pem

Mount the whole tree, not just live/. certbot's live/*.pem are relative symlinks into ../../archive/, so a mount of live/ alone leaves them dangling inside the container. With the full tree mounted, the host's certbot renews and the container sees the new files immediately — no copying, no image rebuild.

Getting the signal in. The renewal runs on the host, so the deploy hook signals the container:

certbot certonly --webroot -w /var/www/acme \
  -d example.com \
  --deploy-hook 'docker kill -s HUP myapp'

docker kill -s HUP delivers to PID 1 in the container. That reaches the server as long as the entrypoint execs it rather than leaving a shell as PID 1 — a shell would receive the signal and drop it. If the entrypoint ends in exec /app/MyServer "$@", you are fine.

Sanity-check what PID 1 actually is before relying on it:

docker exec myapp ps -o pid,comm -p 1

Or skip the signal entirely. With watchCertificates, nothing has to cross the container boundary — the server notices the mounted files changing on its own. That is the lower-friction option when the container is restarted or renamed often enough that a hook naming it would go stale, and it works unchanged if you later move renewal into its own container. The watcher stats through symlinks, so certbot repointing live/fullchain.pem at a new archive/fullchain2.pem registers as a change.

Pointing at Let's Encrypt output

certbot writes fullchain.pem and privkey.pem into /etc/letsencrypt/live/<name>/ as symlinks into archive/, and replaces the symlinks on renewal. Point nprpc at the live/ paths, not at archive/.

live/ and archive/ are root-only by default. Either run the backend with enough privilege to read them, or give the hook the job of copying the pair somewhere the service user can read and signalling afterwards:

--deploy-hook 'install -o site1 -m 0600 \
    /etc/letsencrypt/live/site1.example.com/privkey.pem /etc/site1/tls.key && \
  install -o site1 -m 0644 \
    /etc/letsencrypt/live/site1.example.com/fullchain.pem /etc/site1/tls.crt && \
  systemctl kill -s HUP site1'

Copying the key first and the certificate second still leaves a moment where the two do not match. That is harmless here: a reload that lands in that window fails its mismatch check, keeps the previous pair, and the HUP after both copies reloads a consistent one.

Several servers for one name

Let's Encrypt does not replace a name's certificate when a new one is issued. Every issued certificate stays valid until it expires or is revoked, so several servers can each obtain their own certificate for the same name. What limits this is the rate limits, in particular the one on duplicate certificates for an identical set of names. For a wildcard, or many servers behind one name, issue once with DNS-01 and distribute the certificate instead.

Checking what is actually being served

# HTTP/1.1 / TLS over TCP
echo | openssl s_client -connect site1.example.com:443 \
       -servername site1.example.com 2>/dev/null \
  | openssl x509 -noout -subject -dates

# HTTP/3 over QUIC
curl -sv --http3-only https://site1.example.com/ 2>&1 | grep -E 'subject:|expire'

Both should show the new dates immediately after a reload, without the process having restarted.

docs/CERTBOT.md