The handshake length expression combined shifts with +/|, so when bit 2
of the 24-bit length was set it computed four bytes too few. The
extensions block was then truncated, SNI and ALPN parsed as empty, and
the relay dropped the connection as "sni \"\" matches no tunnel
hostname". It only hit certain ClientHello sizes, so some routes or
ALPN choices reset while others worked.
Parenthesize the length and add a regression test with a synthetic
hello whose length sets bit 2.
Tunnels now get a per-tunnel certificate from the configured ACME CA
(Let's Encrypt by default) with DNS-01 through Porkbun, keeping the
client's CSR and key. Issuance is asynchronous (issuing/ready/failed);
the account persists in the store, renewals run in a 30-day window with
retries, and a failed renewal keeps the old certificate serving.
Switching from the local CA reissues existing tunnels automatically,
and the admin dashboard shows the issuer and certificate state.
Verified against Pebble and Let's Encrypt staging.
Frontend in web/ (Vite via bun, pure CSS, no component libraries),
built to internal/dashboard/dist and embedded into the binary; the
Dockerfile builds it in a bun stage. Old static dashboard.html removed.
HTTP API per the recorded contract, bridge WebSocket, SNI relay with
pass-through tunnel TLS, local CA issuance, JSON store. Verified
end-to-end with the official opentunnel CLI.