feat(tls): per-node certificate authority + Settings install flow
Demo images / Build & push demo images (push) Failing after 2m19s

The node served a bare self-signed leaf, so a browser exception had to be
granted per ORIGIN — scheme + host + port. The dashboard on :443 and an app
on :8334 are different origins, and a certificate interstitial CANNOT be
accepted inside an iframe, so a gated app embedded over HTTPS could never
render no matter how many warnings the user clicked through. (Mixed content
blocks the plain-HTTP variant first, before the SameSite cookie question the
symptom was originally filed under.)

A CA fixes it structurally: ports are not part of a certificate's identity, so
one leaf with the right SANs covers every port on the host, and one installed
CA trusts them all.

- scripts/setup-node-ca.sh generates the CA (4096-bit, pathlen:0, keyCertSign
  only) and issues a 397-day leaf covering archipelago.local, the hostname, the
  Tailscale MagicDNS name and every global address the host holds. Idempotent —
  re-running reuses the CA and only reissues the leaf, so gaining an address
  does not invalidate copies users already installed. --force-ca is the
  deliberate escape hatch and says what it costs.
- nginx serves the public CA at /ca.crt on both schemes, unauthenticated by
  design: a device fetches it before it can validate the node, so gating it
  behind HTTPS or a login would be a chicken-and-egg.
- Settings → System shows the fingerprint and per-platform install steps.
  crypto.subtle does not exist outside a secure context — precisely the case
  this feature exists to fix — so an HTTP dashboard gets the openssl command
  to verify by hand instead of a blank field.

Verified locally: chain validates, key pairs with the leaf, CA:TRUE/CA:FALSE
are correct, keys are 0600. Two TLS servers on different ports both verify
(ssl_verify_result=0) against the CA alone and are rejected without it — the
one-CA-covers-every-port claim, tested rather than assumed.

Not yet wired: app ports still serve plain HTTP. Putting TLS on them is the
next step and is what actually closes the iframe-login bug.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-06 14:38:30 -04:00
co-authored by Claude Opus 5
parent c65ee03a5c
commit aab74127f5
4 changed files with 299 additions and 0 deletions
@@ -18,6 +18,17 @@ server {
root /opt/archipelago/web-ui;
index index.html;
# This node's CA, for devices that have not trusted it yet. Deliberately
# unauthenticated and served over plain HTTP: a device fetches this BEFORE
# it can validate the node's own certificate, so requiring HTTPS or a login
# here would be a chicken-and-egg. It is a public certificate — never a key
# — and the dashboard shows its fingerprint so it can be checked on sight.
location = /ca.crt {
alias /etc/archipelago/ssl/ca-download.crt;
default_type application/x-x509-ca-cert;
add_header Content-Disposition 'attachment; filename="archipelago-node-ca.crt"';
}
# Security headers
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
@@ -934,6 +945,13 @@ server {
index index.html;
include snippets/archipelago-pwa.conf;
# Same CA download over HTTPS — see the note in the HTTP block above.
location = /ca.crt {
alias /etc/archipelago/ssl/ca-download.crt;
default_type application/x-x509-ca-cert;
add_header Content-Disposition 'attachment; filename="archipelago-node-ca.crt"';
}
# Security headers
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;