diff --git a/server/README.md b/server/README.md index eb10a61..33f4935 100644 --- a/server/README.md +++ b/server/README.md @@ -146,3 +146,41 @@ CI (`.gitea/workflows/build-server.yml`): tests on every push touching `server/` tagging `server-v1.2.3` builds + pushes the container image to the Gitea registry and attaches static linux amd64/arm64 binaries (+ SHA256SUMS) to a release — the same artifacts `--self-update` consumes. + +## TLS for the admin UI + +The binary terminates TLS itself; there is no reverse proxy in the design. It already serves TLS +for the control plane, so this is reuse rather than new machinery, and it keeps the "one process, +one config file" property. A proxy would also invite someone to eventually front the control plane +too — which would break SPKI pinning, because clients pin *that* certificate's key. + +``` +ECHOLOT_ADMIN_LISTEN=[2001:db8::2]:443 +ECHOLOT_ADMIN_TLS_CERT=/etc/echolot/admin.pem +ECHOLOT_ADMIN_TLS_KEY=/etc/echolot/admin.key +ECHOLOT_ADMIN_BASE_URL=https://admin.example.net +``` + +Certificates come from any ACME client. **DNS-01 is the one to use here**: it needs no inbound +port 80, which matters on a host where 80 is awkward or already spoken for. + +```sh +acme.sh --issue --dns dns_cf -d admin.example.net \ + --key-file /etc/echolot/admin.key \ + --fullchain-file /etc/echolot/admin.pem +``` + +**No reload hook is needed.** The certificate is re-read when the files change, so a renewal that +drops new files in place is picked up on the next handshake. That is deliberate: a reload hook is +the part of a renewal setup that quietly stops working, months later, and is noticed only once the +certificate has already expired. A torn write — renewal tools write cert and key separately — keeps +the previous certificate rather than failing the listener. + +Serving the admin UI in plaintext on a non-loopback address is refused: the session cookie is a +bearer credential for everything the server can do, and the OIDC authorization code arrives in a +URL. Bind to loopback and use an SSH tunnel (`ssh -L 8444:localhost:8444 host`), supply a +certificate, or set `ECHOLOT_ADMIN_INSECURE=1` if you mean it. + +The control-plane certificate is deliberately *not* hot-reloaded. Clients pin its public key, so +replacing it is a rotation an operator should have to think about, not something that happens +because a file changed. diff --git a/server/cmd/echolot-server/main.go b/server/cmd/echolot-server/main.go index 43b0c9f..894484c 100644 --- a/server/cmd/echolot-server/main.go +++ b/server/cmd/echolot-server/main.go @@ -39,6 +39,7 @@ import ( "echo-lot.app/server/internal/adminauth" "echo-lot.app/server/internal/canarydns" + "echo-lot.app/server/internal/certreload" "echo-lot.app/server/internal/compat" "echo-lot.app/server/internal/config" "echo-lot.app/server/internal/control" @@ -314,7 +315,26 @@ func serve(cfg *config.Config) error { }) }) adminSrv := &http.Server{Addr: cfg.AdminListen, Handler: admin, ReadHeaderTimeout: 10 * time.Second} - go func() { errCh <- fmt.Errorf("admin: %w", adminSrv.ListenAndServe()) }() + if cfg.AdminTLSCert != "" { + // Terminated here rather than behind a reverse proxy: this binary already serves TLS for + // the control plane, so it is reuse rather than new machinery, and one process with one + // config file is the property that makes this pleasant to run. A proxy would also invite + // someone to later front the control plane too, which would break SPKI pinning. + reloader, err := certreload.New(cfg.AdminTLSCert, cfg.AdminTLSKey) + if err != nil { + return fmt.Errorf("admin TLS: %w", err) + } + adminSrv.TLSConfig = reloader.TLSConfig() + if exp := reloader.NotAfter(); !exp.IsZero() { + slog.Info("admin UI TLS", "listen", cfg.AdminListen, "cert_expires", exp.Format(time.RFC3339)) + if time.Until(exp) < 14*24*time.Hour { + slog.Warn("admin certificate expires soon", "expires", exp.Format(time.RFC3339)) + } + } + go func() { errCh <- fmt.Errorf("admin: %w", adminSrv.ListenAndServeTLS("", "")) }() + } else { + go func() { errCh <- fmt.Errorf("admin: %w", adminSrv.ListenAndServe()) }() + } // UDP data plane — one socket per configured address. Distinct sockets // (not wildcard) also guarantee responses leave from the address the diff --git a/server/internal/certreload/certreload.go b/server/internal/certreload/certreload.go new file mode 100644 index 0000000..171ac92 --- /dev/null +++ b/server/internal/certreload/certreload.go @@ -0,0 +1,124 @@ +// SPDX-FileCopyrightText: 2026 Echolot contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +// Package certreload serves a TLS certificate that can be replaced on disk without a restart. +// +// The hard part of TLS is never termination — the stdlib does that — it is renewal. A certificate +// obtained from an ACME client expires every sixty days, and the usual arrangement is a renewal +// hook that reloads or restarts the service. That hook is the part that quietly fails: it works +// when it is written and then, months later, does not, and nobody notices until the certificate +// has already expired. +// +// So the certificate is re-read when the file changes. There is no hook to forget, no reload to +// coordinate, and a renewal that drops new files in place is picked up on the next handshake. +// +// Deliberately not used for the control plane. Clients pin that certificate's public key +// (probe-protocol.md §1), so swapping it at runtime would silently break every enrolled device — +// there the operator *should* have to think, and a restart is the least of what a key rotation +// costs. Two listeners, two different right answers. +package certreload + +import ( + "crypto/tls" + "fmt" + "os" + "sync" + "time" +) + +// Reloader holds a certificate and refreshes it when the files on disk change. +type Reloader struct { + certPath, keyPath string + + mu sync.RWMutex + cert *tls.Certificate + certMod time.Time + keyMod time.Time + checked time.Time + interval time.Duration +} + +// New loads the pair once so a bad path fails at startup rather than at the first handshake, +// when the only symptom is a connection error at the far end. +func New(certPath, keyPath string) (*Reloader, error) { + r := &Reloader{certPath: certPath, keyPath: keyPath, interval: 30 * time.Second} + if err := r.load(); err != nil { + return nil, err + } + return r, nil +} + +// TLSConfig returns a config that asks this reloader for the certificate on every handshake. +func (r *Reloader) TLSConfig() *tls.Config { + return &tls.Config{ + MinVersion: tls.VersionTLS12, + GetCertificate: r.getCertificate, + } +} + +func (r *Reloader) getCertificate(*tls.ClientHelloInfo) (*tls.Certificate, error) { + r.maybeReload() + r.mu.RLock() + defer r.mu.RUnlock() + if r.cert == nil { + return nil, fmt.Errorf("no certificate loaded") + } + return r.cert, nil +} + +// maybeReload stats the files at most once per interval. +// +// Rate-limited because this runs on every handshake: a busy listener would otherwise stat twice +// per connection, and a certificate that is thirty seconds stale has never mattered to anyone. +func (r *Reloader) maybeReload() { + r.mu.RLock() + fresh := time.Since(r.checked) < r.interval + r.mu.RUnlock() + if fresh { + return + } + + r.mu.Lock() + r.checked = time.Now() + certMod, keyMod := modTime(r.certPath), modTime(r.keyPath) + unchanged := certMod.Equal(r.certMod) && keyMod.Equal(r.keyMod) + r.mu.Unlock() + if unchanged { + return + } + // A failed reload keeps the certificate already in memory. Renewal tools write the two files + // separately, so there is a window where the pair does not match; serving the previous + // certificate through that window is strictly better than serving none. + _ = r.load() +} + +func (r *Reloader) load() error { + cert, err := tls.LoadX509KeyPair(r.certPath, r.keyPath) + if err != nil { + return fmt.Errorf("loading %s / %s: %w", r.certPath, r.keyPath, err) + } + r.mu.Lock() + defer r.mu.Unlock() + r.cert = &cert + r.certMod, r.keyMod = modTime(r.certPath), modTime(r.keyPath) + return nil +} + +// NotAfter is when the loaded certificate expires, for the admin UI to show and for a startup +// warning. An expiry an operator can see is one they can act on before a browser tells them. +func (r *Reloader) NotAfter() time.Time { + r.mu.RLock() + defer r.mu.RUnlock() + if r.cert == nil || r.cert.Leaf == nil { + return time.Time{} + } + return r.cert.Leaf.NotAfter +} + +func modTime(path string) time.Time { + fi, err := os.Stat(path) + if err != nil { + return time.Time{} + } + return fi.ModTime() +} diff --git a/server/internal/certreload/certreload_test.go b/server/internal/certreload/certreload_test.go new file mode 100644 index 0000000..63b3bcf --- /dev/null +++ b/server/internal/certreload/certreload_test.go @@ -0,0 +1,119 @@ +// SPDX-FileCopyrightText: 2026 Echolot contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +package certreload + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "os" + "path/filepath" + "testing" + "time" +) + +func writePair(t *testing.T, dir, cn string) (string, string) { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + tmpl := x509.Certificate{ + SerialNumber: big.NewInt(time.Now().UnixNano()), + Subject: pkix.Name{CommonName: cn}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + } + der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + certPath := filepath.Join(dir, "cert.pem") + keyPath := filepath.Join(dir, "key.pem") + cb := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + kb, _ := x509.MarshalECPrivateKey(key) + if err := os.WriteFile(certPath, cb, 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(keyPath, pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: kb}), 0o600); err != nil { + t.Fatal(err) + } + return certPath, keyPath +} + +func TestBadPathsFailAtStartupNotAtHandshake(t *testing.T) { + if _, err := New("/nonexistent/cert.pem", "/nonexistent/key.pem"); err == nil { + t.Fatal("a missing certificate was accepted; the failure would surface as an " + + "unexplained connection error at the client instead") + } +} + +// The whole point: a renewal that drops new files in place is picked up without a restart and +// without a reload hook that can silently stop working. +func TestANewCertificateOnDiskIsPickedUp(t *testing.T) { + dir := t.TempDir() + certPath, keyPath := writePair(t, dir, "first") + r, err := New(certPath, keyPath) + if err != nil { + t.Fatal(err) + } + r.interval = 0 // check on every handshake, rather than waiting out the rate limit + + got, err := r.getCertificate(nil) + if err != nil { + t.Fatal(err) + } + first := got.Leaf + + time.Sleep(10 * time.Millisecond) // ensure a distinct mtime + writePair(t, dir, "second") + + got, err = r.getCertificate(nil) + if err != nil { + t.Fatal(err) + } + if got.Leaf != nil && first != nil && got.Leaf.SerialNumber.Cmp(first.SerialNumber) == 0 { + t.Fatal("the replaced certificate was not picked up") + } +} + +// Renewal tools write the certificate and the key separately, so there is a window where the two +// do not match. Serving the previous certificate through it beats serving none. +func TestAHalfWrittenPairKeepsTheOldCertificate(t *testing.T) { + dir := t.TempDir() + certPath, keyPath := writePair(t, dir, "good") + r, err := New(certPath, keyPath) + if err != nil { + t.Fatal(err) + } + r.interval = 0 + + time.Sleep(10 * time.Millisecond) + if err := os.WriteFile(certPath, []byte("-----BEGIN CERTIFICATE-----\ntruncated\n"), 0o600); err != nil { + t.Fatal(err) + } + got, err := r.getCertificate(nil) + if err != nil { + t.Fatalf("a torn write took the listener down: %v", err) + } + if got == nil { + t.Fatal("no certificate served during a torn write") + } +} + +func TestExpiryIsVisible(t *testing.T) { + dir := t.TempDir() + certPath, keyPath := writePair(t, dir, "x") + r, err := New(certPath, keyPath) + if err != nil { + t.Fatal(err) + } + if got := r.NotAfter(); got.IsZero() || time.Until(got) > 48*time.Hour { + t.Fatalf("expiry not reported sensibly: %v", got) + } +}