For reading and verification only. You may not copy, use, modify or redistribute this code. Full notice on the main page.
tunnel/certreload_test.go
| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "bufio" |
| 5 | "context" |
| 6 | "crypto/ecdsa" |
| 7 | "crypto/elliptic" |
| 8 | "crypto/rand" |
| 9 | "crypto/tls" |
| 10 | "crypto/x509" |
| 11 | "crypto/x509/pkix" |
| 12 | "encoding/pem" |
| 13 | "log/slog" |
| 14 | "math/big" |
| 15 | "net" |
| 16 | "os" |
| 17 | "path/filepath" |
| 18 | "strings" |
| 19 | "testing" |
| 20 | "time" |
| 21 | ) |
| 22 | |
| 23 | // The node's own public-CA certificate is hot reloaded: a renewal goes into service without a restart and without a dropped connection, and a renewal |
| 24 | // that is not good never replaces the one that works. |
| 25 | |
| 26 | type certFiles struct { |
| 27 | dir, cert, key string |
| 28 | } |
| 29 | |
| 30 | func newCertFiles(t *testing.T) *certFiles { |
| 31 | dir := t.TempDir() |
| 32 | return &certFiles{dir: dir, cert: filepath.Join(dir, "fullchain.pem"), key: filepath.Join(dir, "privkey.pem")} |
| 33 | } |
| 34 | |
| 35 | // write puts a certificate for names, valid between notBefore and notAfter, on disk (the key too, unless keyFrom is set: then that pair's key is |
| 36 | // written, which is a renewal whose key does not match). It returns the serial. |
| 37 | func (c *certFiles) write(t *testing.T, serial int64, notBefore, notAfter time.Time, names ...string) { |
| 38 | t.Helper() |
| 39 | key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) |
| 40 | if err != nil { |
| 41 | t.Fatal(err) |
| 42 | } |
| 43 | tmpl := &x509.Certificate{ |
| 44 | SerialNumber: big.NewInt(serial), Subject: pkix.Name{CommonName: names[0]}, NotBefore: notBefore, NotAfter: notAfter, |
| 45 | KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, DNSNames: names, |
| 46 | } |
| 47 | der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) |
| 48 | if err != nil { |
| 49 | t.Fatal(err) |
| 50 | } |
| 51 | keyDER, _ := x509.MarshalECPrivateKey(key) |
| 52 | c.put(t, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})) |
| 53 | } |
| 54 | |
| 55 | // put writes both files the way a renewal tool does: each replaced atomically, the key after the certificate (so there is a moment when they do not match). |
| 56 | func (c *certFiles) put(t *testing.T, certPEM, keyPEM []byte) { |
| 57 | t.Helper() |
| 58 | for path, data := range map[string][]byte{c.cert: certPEM, c.key: keyPEM} { |
| 59 | tmp := path + ".new" |
| 60 | if err := os.WriteFile(tmp, data, 0o600); err != nil { |
| 61 | t.Fatal(err) |
| 62 | } |
| 63 | if err := os.Rename(tmp, path); err != nil { |
| 64 | t.Fatal(err) |
| 65 | } |
| 66 | } |
| 67 | } |
| 68 | |
| 69 | func (c *certFiles) fresh(t *testing.T, serial int64, names ...string) { |
| 70 | t.Helper() |
| 71 | c.write(t, serial, time.Now().Add(-time.Hour), time.Now().Add(60*24*time.Hour), names...) |
| 72 | } |
| 73 | |
| 74 | // servedSerial connects to the node's own name and reports the serial of the certificate it presents. |
| 75 | func servedSerial(t *testing.T, addr string) int64 { |
| 76 | t.Helper() |
| 77 | conn, err := tls.DialWithDialer(&net.Dialer{Timeout: 3 * time.Second}, "tcp", addr, &tls.Config{ |
| 78 | ServerName: testNodeHost, InsecureSkipVerify: true, NextProtos: []string{policyData.Protocol.ALPN}, MinVersion: tls.VersionTLS13, |
| 79 | }) |
| 80 | if err != nil { |
| 81 | t.Fatal(err) |
| 82 | } |
| 83 | defer conn.Close() |
| 84 | return conn.ConnectionState().PeerCertificates[0].SerialNumber.Int64() |
| 85 | } |
| 86 | |
| 87 | func certNode(t *testing.T, r *certReloader) *testEnv { |
| 88 | return newTestEnv(t, func(c *Config) { c.CertSource = r.get }) |
| 89 | } |
| 90 | |
| 91 | func TestCertReload_ARenewalIsInServiceForTheNextHandshakeAndAnOpenConnectionIsUntouched(t *testing.T) { |
| 92 | files := newCertFiles(t) |
| 93 | files.fresh(t, 1, testNodeHost) |
| 94 | r, err := newCertReloader(files.cert, files.key, testNodeHost, clock{}, nil) |
| 95 | if err != nil { |
| 96 | t.Fatal(err) |
| 97 | } |
| 98 | r.lookGap = 0 |
| 99 | e := certNode(t, r) |
| 100 | if got := servedSerial(t, e.httpsAddr); got != 1 { |
| 101 | t.Fatalf("served %d", got) |
| 102 | } |
| 103 | // a device is connected (its TLS session was made with the first certificate) |
| 104 | d := e.newDevice() |
| 105 | conn, err := tls.DialWithDialer(&net.Dialer{Timeout: 3 * time.Second}, "tcp", e.httpsAddr, &tls.Config{ |
| 106 | ServerName: testNodeHost, InsecureSkipVerify: true, NextProtos: []string{policyData.Protocol.ALPN}, MinVersion: tls.VersionTLS13, |
| 107 | }) |
| 108 | if err != nil { |
| 109 | t.Fatal(err) |
| 110 | } |
| 111 | defer conn.Close() |
| 112 | dc := &devConn{t: t, e: e, d: d, c: conn, br: bufio.NewReaderSize(conn, 1<<17)} |
| 113 | dc.session = e.session(d, nil) |
| 114 | dc.send("HELLO 2 " + dc.session) |
| 115 | m, perr := parseLine(dc.recv(), dirN2D) |
| 116 | if perr != nil || m.str("verb") != "CHALLENGE" { |
| 117 | t.Fatal("no challenge") |
| 118 | } |
| 119 | dc.send("PROOF " + e.proof(d, dc.session, m.str("nonce"), nil)) |
| 120 | if line := dc.recv(); !strings.HasPrefix(line, "OK ") { |
| 121 | t.Fatalf("%q", line) |
| 122 | } |
| 123 | |
| 124 | // the renewal lands on disk, the node is not restarted |
| 125 | time.Sleep(20 * time.Millisecond) |
| 126 | files.fresh(t, 2, testNodeHost) |
| 127 | if got := servedSerial(t, e.httpsAddr); got != 2 { |
| 128 | t.Fatalf("after the renewal the node served %d", got) |
| 129 | } |
| 130 | // the connection that was up goes on |
| 131 | dc.send("PING") |
| 132 | if line := dc.recv(); line != "PONG" { |
| 133 | t.Fatalf("a connection made with the old certificate: %q", line) |
| 134 | } |
| 135 | if !e.srv.Online(d.host) { |
| 136 | t.Fatal("the device was dropped by the renewal") |
| 137 | } |
| 138 | if want := r.NotAfter(); want.Before(time.Now().Add(50 * 24 * time.Hour)) { |
| 139 | t.Fatalf("not_after %s", want) |
| 140 | } |
| 141 | } |
| 142 | |
| 143 | func TestCertReload_ARenewalThatIsNotGoodNeverReplacesTheOneThatWorks(t *testing.T) { |
| 144 | files := newCertFiles(t) |
| 145 | files.fresh(t, 1, testNodeHost) |
| 146 | var logs syncBuf |
| 147 | r, err := newCertReloader(files.cert, files.key, testNodeHost, clock{}, slog.New(slog.NewJSONHandler(&logs, nil))) |
| 148 | if err != nil { |
| 149 | t.Fatal(err) |
| 150 | } |
| 151 | r.lookGap = 0 |
| 152 | e := certNode(t, r) |
| 153 | good := func() { |
| 154 | t.Helper() |
| 155 | if got := servedSerial(t, e.httpsAddr); got != 1 { |
| 156 | t.Fatalf("the node serves %d, the certificate that works is 1", got) |
| 157 | } |
| 158 | } |
| 159 | now := time.Now() |
| 160 | for name, write := range map[string]func(){ |
| 161 | "another name": func() { files.write(t, 10, now.Add(-time.Hour), now.Add(48*time.Hour), "someone-else.example.test") }, |
| 162 | "expired": func() { files.write(t, 11, now.Add(-72*time.Hour), now.Add(-time.Hour), testNodeHost) }, |
| 163 | "not yet valid": func() { files.write(t, 12, now.Add(48*time.Hour), now.Add(96*time.Hour), testNodeHost) }, |
| 164 | "garbage": func() { files.put(t, []byte("garbage"), []byte("garbage")) }, |
| 165 | "half written": func() { _ = os.WriteFile(files.cert, []byte("-----BEGIN CERTIFICATE-----\nAAAA"), 0o600) }, |
| 166 | "a missing key": func() { _ = os.Remove(files.key) }, |
| 167 | "a missing cert": func() { files.fresh(t, 13, testNodeHost); _ = os.Remove(files.cert) }, |
| 168 | "a certificate and a key that are not a pair": func() { |
| 169 | other := newCertFiles(t) |
| 170 | other.fresh(t, 14, testNodeHost) |
| 171 | certPEM, _ := os.ReadFile(other.cert) |
| 172 | third := newCertFiles(t) |
| 173 | third.fresh(t, 15, testNodeHost) |
| 174 | keyPEM, _ := os.ReadFile(third.key) |
| 175 | files.put(t, certPEM, keyPEM) |
| 176 | }, |
| 177 | } { |
| 178 | time.Sleep(15 * time.Millisecond) |
| 179 | write() |
| 180 | good() |
| 181 | if name != "a missing key" && name != "a missing cert" && name != "half written" && r.LastError() == "" { |
| 182 | t.Errorf("%s: the refusal is not recorded", name) |
| 183 | } |
| 184 | } |
| 185 | if !strings.Contains(logs.String(), "node_cert_reload_refused") { |
| 186 | t.Fatal("refusals are not logged") |
| 187 | } |
| 188 | // the files are in a bad state now; a good renewal still gets in |
| 189 | time.Sleep(20 * time.Millisecond) |
| 190 | files.fresh(t, 3, testNodeHost) |
| 191 | if got := servedSerial(t, e.httpsAddr); got != 3 { |
| 192 | t.Fatalf("a good renewal after the bad ones: served %d", got) |
| 193 | } |
| 194 | if r.LastError() != "" { |
| 195 | t.Fatalf("last error %q", r.LastError()) |
| 196 | } |
| 197 | } |
| 198 | |
| 199 | func TestCertReload_ARefusedRenewalIsNotParsedAgainAtEveryHandshake(t *testing.T) { |
| 200 | files := newCertFiles(t) |
| 201 | files.fresh(t, 1, testNodeHost) |
| 202 | var logs syncBuf |
| 203 | r, err := newCertReloader(files.cert, files.key, testNodeHost, clock{}, slog.New(slog.NewJSONHandler(&logs, nil))) |
| 204 | if err != nil { |
| 205 | t.Fatal(err) |
| 206 | } |
| 207 | r.lookGap = 0 |
| 208 | time.Sleep(15 * time.Millisecond) |
| 209 | files.put(t, []byte("garbage"), []byte("garbage")) |
| 210 | for i := 0; i < 50; i++ { |
| 211 | if pair, err := r.get(nil); err != nil || pair.Leaf.SerialNumber.Int64() != 1 { |
| 212 | t.Fatalf("handshake %d: %v", i, err) |
| 213 | } |
| 214 | } |
| 215 | if n := logs.Count("node_cert_reload_refused"); n != 1 { |
| 216 | t.Fatalf("one bad renewal was parsed and refused %d times", n) |
| 217 | } |
| 218 | } |
| 219 | |
| 220 | func TestCertReload_ReloadNowIsWhatSIGHUPDoes(t *testing.T) { |
| 221 | files := newCertFiles(t) |
| 222 | files.fresh(t, 1, testNodeHost) |
| 223 | r, err := newCertReloader(files.cert, files.key, testNodeHost, clock{}, nil) |
| 224 | if err != nil { |
| 225 | t.Fatal(err) |
| 226 | } |
| 227 | // a look gap: the handshake path has just looked and does not look again for a while, SIGHUP does not wait |
| 228 | _, _ = r.get(nil) |
| 229 | files.fresh(t, 2, testNodeHost) |
| 230 | if pair, _ := r.get(nil); pair.Leaf.SerialNumber.Int64() != 1 { |
| 231 | t.Fatal("looked inside the gap") |
| 232 | } |
| 233 | if err := r.Reload(); err != nil { |
| 234 | t.Fatal(err) |
| 235 | } |
| 236 | if pair, _ := r.get(nil); pair.Leaf.SerialNumber.Int64() != 2 { |
| 237 | t.Fatal("Reload did not put the new certificate in service") |
| 238 | } |
| 239 | files.write(t, 3, time.Now().Add(-time.Hour), time.Now().Add(-time.Minute), testNodeHost) |
| 240 | if err := r.Reload(); err == nil || r.LastError() == "" { |
| 241 | t.Fatal("an expired certificate was accepted by Reload") |
| 242 | } |
| 243 | if pair, _ := r.get(nil); pair.Leaf.SerialNumber.Int64() != 2 { |
| 244 | t.Fatal("a refused Reload replaced the certificate") |
| 245 | } |
| 246 | } |
| 247 | |
| 248 | func TestCertReload_TheWatcherPicksUpARenewalWithoutAnyHandshakeAndWarnsBeforeTheEnd(t *testing.T) { |
| 249 | files := newCertFiles(t) |
| 250 | files.write(t, 1, time.Now().Add(-time.Hour), time.Now().Add(3*24*time.Hour), testNodeHost) // 3 days left: under the warning |
| 251 | var logs syncBuf |
| 252 | clk := newFakeClock() |
| 253 | r, err := newCertReloader(files.cert, files.key, testNodeHost, newClock(clk.Now, clk.AfterFunc), slog.New(slog.NewJSONHandler(&logs, nil))) |
| 254 | if err != nil { |
| 255 | t.Fatal(err) |
| 256 | } |
| 257 | ctx, cancel := context.WithCancel(context.Background()) |
| 258 | done := make(chan struct{}) |
| 259 | go func() { r.Run(ctx); close(done) }() |
| 260 | eventually(t, "the first look and its warning", 2*time.Second, func() bool { return logs.Count("node_cert_expires_soon") == 1 }) |
| 261 | eventually(t, "the watcher sleeping", 2*time.Second, func() bool { return clk.pending() == 1 }) |
| 262 | |
| 263 | time.Sleep(20 * time.Millisecond) |
| 264 | files.fresh(t, 2, testNodeHost) |
| 265 | clk.Advance(certWatchEvery) |
| 266 | eventually(t, "the renewal in service", 2*time.Second, func() bool { return logs.Count("node_cert_reloaded") == 1 }) |
| 267 | if pair, _ := r.get(nil); pair.Leaf.SerialNumber.Int64() != 2 { |
| 268 | t.Fatal("not in service") |
| 269 | } |
| 270 | eventually(t, "the watcher sleeping again", 2*time.Second, func() bool { return clk.pending() == 1 }) |
| 271 | clk.Advance(certWatchEvery) |
| 272 | time.Sleep(50 * time.Millisecond) |
| 273 | if logs.Count("node_cert_expires_soon") != 1 { |
| 274 | t.Fatal("warned about a certificate with 60 days left") |
| 275 | } |
| 276 | cancel() |
| 277 | select { |
| 278 | case <-done: |
| 279 | case <-time.After(2 * time.Second): |
| 280 | t.Fatal("the watcher did not stop") |
| 281 | } |
| 282 | } |
| 283 | |
| 284 | func TestCertReload_TheNodeDoesNotStartWithoutAUsableCertificate(t *testing.T) { |
| 285 | files := newCertFiles(t) |
| 286 | if _, err := newCertReloader(files.cert, files.key, testNodeHost, clock{}, nil); err == nil { |
| 287 | t.Fatal("started without files") |
| 288 | } |
| 289 | files.write(t, 1, time.Now().Add(-time.Hour), time.Now().Add(time.Hour), "other.example.test") |
| 290 | if _, err := newCertReloader(files.cert, files.key, testNodeHost, clock{}, nil); err == nil || !strings.Contains(err.Error(), testNodeHost) { |
| 291 | t.Fatalf("started with a certificate for another name: %v", err) |
| 292 | } |
| 293 | files.write(t, 2, time.Now().Add(-48*time.Hour), time.Now().Add(-time.Hour), testNodeHost) |
| 294 | if _, err := newCertReloader(files.cert, files.key, testNodeHost, clock{}, nil); err == nil { |
| 295 | t.Fatal("started with an expired certificate") |
| 296 | } |
| 297 | } |