admin: terminate TLS in the binary, with a certificate that reloads itself
server-test / test (push) Successful in 33s
server-test / test (push) Successful in 33s
Direct rather than behind Caddy or nginx. This binary already serves TLS for the control plane, so it is reuse rather than new machinery; one process with one config file is most of what makes this thing pleasant to run; and a proxy on the box would invite someone to eventually front the control plane too, which would break SPKI pinning because clients pin that certificate's key. The hard part of TLS is not termination, it is renewal - so the certificate is re-read when the files change. No reload hook to write, and none to quietly stop working months later and be noticed only after the certificate has expired. A torn write (renewal tools write cert and key separately) keeps the previous certificate rather than taking the listener down. Not applied to the control plane, on purpose: clients pin that key, so replacing it should cost an operator a moment's thought and a restart, not happen because a file changed. Two listeners, two different right answers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
cd187f9ef5
commit
6afcb131ef
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user