edge: e2e — issuer mints, every edge serves cert and challenge
One fake bucket, two edges, one certificate. Edge A holds the housekeeper gate; edge B holds nothing, may never order, and still has to answer A's HTTP-01 challenge and serve A's cert. The CA is a real (if minimal) RFC 8555 server rather than a mock of the ACME client: directory, account, order, one http-01 authorization, a LIVE outbound validation, CSR parse and X.509 issuance under a test root. It computes the expected key authorization itself — token + "." + the JWK thumbprint of the account key that placed the order — so "B answered A's challenge" is checked by an independent validator instead of narrated. The wildcard tests were said to carry such a CA; they do not (they point at deadDirectory, whose job is to make an accidental order fail offline). Proved: the CA reaches B and never A (A's :80 chain served 0 requests); B answers from a LIVE bucket read of control/certs/<token>+http-01 with an empty mirror snapshot; a real TLS client with SNI gets A's exact leaf DER from B after ONE Refresh, and fails against B before the mint; B started zero orders and registered zero accounts; a gated twin refuses an in-registry host with errNotIssuer without burning an order, while still serving its own DirCache material; a fresher bucket cert lands in the local cache and flips autocert's renewal decision from due to not-due (R3's cache-first check); and the mirror reads only its own handle, only under control/certs/, a prefix the fleet door's ACL refuses outright. Test-only. Refresh under -race from four goroutines is clean: the missing refreshMu is a coherence question, not a memory-safety one, and production has exactly one caller (the boot pass completes before run() starts). Left unfixed and recorded in the task report rather than changed silently.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 file changed,
+1157
−0
internal/edge/certsync_e2e_test.go+1157 −0
diff --git a/internal/edge/certsync_e2e_test.go b/internal/edge/certsync_e2e_test.go| new file mode 100644 |
| index 000000000..e7f538956 |
| --- /dev/null |
| +++ b/internal/edge/certsync_e2e_test.go |
| @@ -0,0 +1,1157 @@ |
| +package edge |
| + |
| +// Task 7 of the multi-region ingress plan: the acceptance test the whole of |
| +// certsync.go exists for — ONE bucket, TWO edges, one certificate. |
| +// |
| +// Edge A holds the housekeeper gate and is the only box allowed to talk to a |
| +// CA. Edge B holds nothing, may never order, and yet has to (a) answer the |
| +// HTTP-01 challenge for an order A is running and (b) serve the certificate A |
| +// minted. Everything between the two is the control bucket's control/certs/ |
| +// prefix and nothing else: no RPC, no gossip, no second writer. |
| +// |
| +// The CA here is a fake one (fakeACME below) rather than a mock of the ACME |
| +// client, because the property under test is not "autocert was called" but |
| +// "an independent validator, reaching a DIFFERENT box than the one that |
| +// ordered, got the right key authorization and issued a certificate that a |
| +// real TLS client will accept". A mock cannot fail that; a validator can. |
| +// It is deliberately pointed at edge B's :80 handler and NEVER at edge A's — |
| +// A's handler is served too, purely so the test can assert it received |
| +// nothing. |
| +// |
| +// Conventions borrowed from the neighbouring harnesses (tunnel_e2e_test.go, |
| +// fleet_e2e_test.go): real loopback listeners, in-process components wired |
| +// the way edge.go's RunWithActivatorReady wires them, and bounds asserted |
| +// rather than narrated. Helpers already in this package (testIssuerEdge, |
| +// ecdsaHello, offlineManager, hostCertPEM, putBucket, localCert) are reused |
| +// rather than re-cut. |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "crypto/ecdsa" |
| + "crypto/elliptic" |
| + "crypto/rand" |
| + "crypto/tls" |
| + "crypto/x509" |
| + "crypto/x509/pkix" |
| + "encoding/base64" |
| + "encoding/json" |
| + "encoding/pem" |
| + "errors" |
| + "fmt" |
| + "io" |
| + "log" |
| + "math/big" |
| + "net" |
| + "net/http" |
| + "net/http/httptest" |
| + "os" |
| + "slices" |
| + "strings" |
| + "sync" |
| + "sync/atomic" |
| + "testing" |
| + "time" |
| + |
| + "golang.org/x/crypto/acme" |
| + "golang.org/x/crypto/acme/autocert" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +const ( |
| + // e2eHost is the name edge A mints for and edge B serves. A .test TLD, so |
| + // nothing here can resolve or be reached for real. |
| + e2eHost = "app.bab.example.test" |
| + // e2eUnmintedHost is in both edges' registries but is never issued: it is |
| + // what the gated twin is asked to mint, so the refusal is the GATE's and |
| + // not "that host isn't mine". |
| + e2eUnmintedHost = "other.bab.example.test" |
| + // e2eLocalOnlyHost exists only in edge B's own on-disk cache — yesterday's |
| + // material, from before the election moved. Nothing publishes it. |
| + e2eLocalOnlyHost = "old.bab.example.test" |
| +) |
| + |
| +// ---- the fake ACME CA -------------------------------------------------------- |
| + |
| +// fakeACME is a minimal RFC 8555 CA: directory, nonce, account, order, one |
| +// http-01 authorization, finalize, certificate. It is NOT a security model — |
| +// it never verifies a JWS signature and never checks a nonce, because neither |
| +// is under test. What it DOES check is the only thing that matters here: that |
| +// the HTTP-01 response body served back to it equals token + "." + |
| +// JWK-thumbprint of the account key that placed the order. That check is what |
| +// makes "edge B answered edge A's challenge" a proof rather than a narration. |
| +// |
| +// It offers ONLY an http-01 challenge. autocert prefers tls-alpn-01 and falls |
| +// back to http-01 after a whole failed order (autocert.go's |
| +// AuthorizeOrderLoop); offering one type keeps the run to a single order, so |
| +// the order counter below is an exact count of "who talked to a CA". |
| +type fakeACME struct { |
| + srv *httptest.Server |
| + rootKey *ecdsa.PrivateKey |
| + root *x509.Certificate |
| + |
| + mu sync.Mutex |
| + // validateVia is the base URL the CA fetches HTTP-01 challenges from — |
| + // set to edge B's :80 server, never edge A's. |
| + validateVia string |
| + // thumb is the account key's JWK thumbprint, learned from the JWS |
| + // protected header of the new-account request. |
| + thumb string |
| + // accounts and orders are the counters the test asserts on: "B started |
| + // zero ACME orders" is this number not moving. |
| + accounts int |
| + orders int |
| + seq int |
| + byID map[string]*fakeACMEOrder |
| + // validations records every challenge fetch the CA made, in order. |
| + validations []fakeACMEValidation |
| + // faults collects anything the CA found wrong. Never t.Fatalf: these |
| + // handlers run on the server's goroutines and, for deactivatePendingAuthz, |
| + // can outlive the test. |
| + faults []string |
| +} |
| + |
| +type fakeACMEOrder struct { |
| + domain string |
| + token string |
| + authz string // pending | valid | invalid |
| + status string // pending | ready | valid |
| + chain []byte // PEM, once finalized |
| +} |
| + |
| +// fakeACMEValidation is one HTTP-01 fetch: where it went, what Host it |
| +// carried, the order's token, and whether the body was the right key |
| +// authorization. |
| +type fakeACMEValidation struct { |
| + url string |
| + host string |
| + token string |
| + ok bool |
| +} |
| + |
| +func newFakeACME(t *testing.T) *fakeACME { |
| + t.Helper() |
| + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + tmpl := &x509.Certificate{ |
| + SerialNumber: big.NewInt(1), |
| + Subject: pkix.Name{CommonName: "CARLOS test CA"}, |
| + NotBefore: time.Now().Add(-time.Hour), |
| + NotAfter: time.Now().Add(365 * 24 * time.Hour), |
| + KeyUsage: x509.KeyUsageCertSign, |
| + BasicConstraintsValid: true, |
| + IsCA: true, |
| + } |
| + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + root, err := x509.ParseCertificate(der) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + ca := &fakeACME{rootKey: key, root: root, byID: map[string]*fakeACMEOrder{}} |
| + |
| + mux := http.NewServeMux() |
| + mux.HandleFunc("/directory", ca.serveDirectory) |
| + mux.HandleFunc("/nonce", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }) |
| + mux.HandleFunc("/new-account", ca.serveNewAccount) |
| + mux.HandleFunc("/new-order", ca.serveNewOrder) |
| + mux.HandleFunc("/authz/", ca.serveAuthorization) |
| + mux.HandleFunc("/chal/", ca.serveChallengeAccept) |
| + mux.HandleFunc("/order/", ca.serveOrder) |
| + mux.HandleFunc("/finalize/", ca.serveFinalize) |
| + mux.HandleFunc("/cert/", ca.serveCert) |
| + // Every ACME response carries a fresh Replay-Nonce; the client will not |
| + // make a second POST without one. |
| + ca.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Replay-Nonce", ca.nextNonce()) |
| + mux.ServeHTTP(w, r) |
| + })) |
| + t.Cleanup(ca.srv.Close) |
| + return ca |
| +} |
| + |
| +func (ca *fakeACME) directoryURL() string { return ca.srv.URL + "/directory" } |
| + |
| +func (ca *fakeACME) url(path string) string { return ca.srv.URL + path } |
| + |
| +// validateAgainst points the CA's HTTP-01 validator at one edge's :80 server. |
| +func (ca *fakeACME) validateAgainst(base string) { |
| + ca.mu.Lock() |
| + defer ca.mu.Unlock() |
| + ca.validateVia = base |
| +} |
| + |
| +func (ca *fakeACME) nextNonce() string { |
| + ca.mu.Lock() |
| + defer ca.mu.Unlock() |
| + ca.seq++ |
| + return fmt.Sprintf("nonce-%d", ca.seq) |
| +} |
| + |
| +func (ca *fakeACME) counts() (accounts, orders int) { |
| + ca.mu.Lock() |
| + defer ca.mu.Unlock() |
| + return ca.accounts, ca.orders |
| +} |
| + |
| +func (ca *fakeACME) lastValidation() (fakeACMEValidation, bool) { |
| + ca.mu.Lock() |
| + defer ca.mu.Unlock() |
| + if len(ca.validations) == 0 { |
| + return fakeACMEValidation{}, false |
| + } |
| + return ca.validations[len(ca.validations)-1], true |
| +} |
| + |
| +func (ca *fakeACME) recordFault(format string, args ...any) { |
| + ca.mu.Lock() |
| + defer ca.mu.Unlock() |
| + ca.faults = append(ca.faults, fmt.Sprintf(format, args...)) |
| +} |
| + |
| +// checkFaults fails the test with anything the CA rejected along the way — a |
| +// malformed JWS, a CSR for the wrong name, an unknown order. Called at the end |
| +// of each phase so a subtle protocol mistake surfaces as itself rather than as |
| +// a timeout. |
| +func (ca *fakeACME) checkFaults(t *testing.T) { |
| + t.Helper() |
| + ca.mu.Lock() |
| + faults := append([]string(nil), ca.faults...) |
| + ca.mu.Unlock() |
| + for _, f := range faults { |
| + t.Errorf("fake CA: %s", f) |
| + } |
| +} |
| + |
| +func (ca *fakeACME) rootPool() *x509.CertPool { |
| + pool := x509.NewCertPool() |
| + pool.AddCert(ca.root) |
| + return pool |
| +} |
| + |
| +func (ca *fakeACME) serveDirectory(w http.ResponseWriter, _ *http.Request) { |
| + writeJSON(w, http.StatusOK, map[string]string{ |
| + "newNonce": ca.url("/nonce"), |
| + "newAccount": ca.url("/new-account"), |
| + "newOrder": ca.url("/new-order"), |
| + "revokeCert": ca.url("/revoke"), |
| + "keyChange": ca.url("/key-change"), |
| + }) |
| +} |
| + |
| +func (ca *fakeACME) serveNewAccount(w http.ResponseWriter, r *http.Request) { |
| + protected, _, err := decodeJWS(r) |
| + if err != nil { |
| + ca.recordFault("new-account: %v", err) |
| + http.Error(w, "bad jws", http.StatusBadRequest) |
| + return |
| + } |
| + raw, ok := protected["jwk"] |
| + if !ok { |
| + ca.recordFault("new-account: no jwk in the protected header (a KID-form registration?)") |
| + http.Error(w, "no jwk", http.StatusBadRequest) |
| + return |
| + } |
| + thumb, err := jwkThumbprintFromJSON(raw) |
| + if err != nil { |
| + ca.recordFault("new-account: %v", err) |
| + http.Error(w, "bad jwk", http.StatusBadRequest) |
| + return |
| + } |
| + ca.mu.Lock() |
| + ca.accounts++ |
| + n := ca.accounts |
| + ca.thumb = thumb |
| + ca.mu.Unlock() |
| + w.Header().Set("Location", ca.url(fmt.Sprintf("/acct/%d", n))) |
| + writeJSON(w, http.StatusCreated, map[string]any{"status": "valid"}) |
| +} |
| + |
| +func (ca *fakeACME) serveNewOrder(w http.ResponseWriter, r *http.Request) { |
| + _, payload, err := decodeJWS(r) |
| + if err != nil { |
| + ca.recordFault("new-order: %v", err) |
| + http.Error(w, "bad jws", http.StatusBadRequest) |
| + return |
| + } |
| + var req struct { |
| + Identifiers []struct{ Type, Value string } `json:"identifiers"` |
| + } |
| + if err := json.Unmarshal(payload, &req); err != nil || len(req.Identifiers) != 1 { |
| + ca.recordFault("new-order: want exactly one identifier, got %q (%v)", payload, err) |
| + http.Error(w, "bad order", http.StatusBadRequest) |
| + return |
| + } |
| + tokenBytes := make([]byte, 32) |
| + if _, err := rand.Read(tokenBytes); err != nil { |
| + ca.recordFault("new-order: %v", err) |
| + http.Error(w, "no entropy", http.StatusInternalServerError) |
| + return |
| + } |
| + ca.mu.Lock() |
| + ca.orders++ |
| + id := fmt.Sprintf("%d", ca.orders) |
| + ca.byID[id] = &fakeACMEOrder{ |
| + domain: req.Identifiers[0].Value, |
| + // base64url of 32 bytes: 43 chars, which is what |
| + // validChallengeToken's entropy floor is calibrated against. |
| + token: base64.RawURLEncoding.EncodeToString(tokenBytes), |
| + authz: "pending", |
| + status: "pending", |
| + } |
| + ca.mu.Unlock() |
| + w.Header().Set("Location", ca.url("/order/"+id)) |
| + writeJSON(w, http.StatusCreated, ca.orderJSON(id)) |
| +} |
| + |
| +// orderJSON renders an order in the shape acme.responseOrder parses. Caller |
| +// must not hold ca.mu. |
| +func (ca *fakeACME) orderJSON(id string) map[string]any { |
| + ca.mu.Lock() |
| + defer ca.mu.Unlock() |
| + o := ca.byID[id] |
| + if o == nil { |
| + return map[string]any{"status": "invalid"} |
| + } |
| + body := map[string]any{ |
| + "status": o.status, |
| + "identifiers": []map[string]string{{"type": "dns", "value": o.domain}}, |
| + "authorizations": []string{ca.url("/authz/" + id)}, |
| + "finalize": ca.url("/finalize/" + id), |
| + } |
| + if o.status == "valid" { |
| + body["certificate"] = ca.url("/cert/" + id) |
| + } |
| + return body |
| +} |
| + |
| +func writeJSON(w http.ResponseWriter, code int, body any) { |
| + w.Header().Set("Content-Type", "application/json") |
| + w.WriteHeader(code) |
| + _ = json.NewEncoder(w).Encode(body) |
| +} |
| + |
| +// ---- JWS / JWK ----------------------------------------------------------- |
| + |
| +// decodeJWS pulls the protected header and the payload out of a flattened JWS. |
| +// The signature is deliberately not verified — see fakeACME's doc. |
| +func decodeJWS(r *http.Request) (map[string]json.RawMessage, []byte, error) { |
| + raw, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) |
| + if err != nil { |
| + return nil, nil, fmt.Errorf("read body: %w", err) |
| + } |
| + var msg struct { |
| + Protected string `json:"protected"` |
| + Payload string `json:"payload"` |
| + } |
| + if err := json.Unmarshal(raw, &msg); err != nil { |
| + return nil, nil, fmt.Errorf("not a JWS: %w", err) |
| + } |
| + phead, err := base64.RawURLEncoding.DecodeString(msg.Protected) |
| + if err != nil { |
| + return nil, nil, fmt.Errorf("protected header: %w", err) |
| + } |
| + var protected map[string]json.RawMessage |
| + if err := json.Unmarshal(phead, &protected); err != nil { |
| + return nil, nil, fmt.Errorf("protected header JSON: %w", err) |
| + } |
| + // POST-as-GET carries a zero-length payload. |
| + payload, err := base64.RawURLEncoding.DecodeString(msg.Payload) |
| + if err != nil { |
| + return nil, nil, fmt.Errorf("payload: %w", err) |
| + } |
| + return protected, payload, nil |
| +} |
| + |
| +// jwkThumbprintFromJSON reconstructs the P-256 public key from a JWK and hands |
| +// it to acme.JWKThumbprint — the library's OWN canonicalization, so the |
| +// expected key authorization is computed the way a real CA computes it rather |
| +// than by hashing whatever bytes happened to be on the wire. |
| +func jwkThumbprintFromJSON(raw json.RawMessage) (string, error) { |
| + var jwk struct{ Crv, Kty, X, Y string } |
| + if err := json.Unmarshal(raw, &jwk); err != nil { |
| + return "", fmt.Errorf("jwk JSON: %w", err) |
| + } |
| + if jwk.Kty != "EC" || jwk.Crv != "P-256" { |
| + return "", fmt.Errorf("jwk kty/crv = %q/%q, want EC/P-256 (autocert's account key)", jwk.Kty, jwk.Crv) |
| + } |
| + x, err := base64.RawURLEncoding.DecodeString(jwk.X) |
| + if err != nil { |
| + return "", fmt.Errorf("jwk x: %w", err) |
| + } |
| + y, err := base64.RawURLEncoding.DecodeString(jwk.Y) |
| + if err != nil { |
| + return "", fmt.Errorf("jwk y: %w", err) |
| + } |
| + return acme.JWKThumbprint(&ecdsa.PublicKey{ |
| + Curve: elliptic.P256(), |
| + X: new(big.Int).SetBytes(x), |
| + Y: new(big.Int).SetBytes(y), |
| + }) |
| +} |
| + |
| +// ---- two edges ----------------------------------------------------------- |
| + |
| +// e2eEdge is one box, wired exactly as edge.go's RunWithActivatorReady wires |
| +// it: the raw local cache kept aside, the publishing wrapper over it, the |
| +// mirror built from the RAW handle, autocert's manager over the wrapper, and |
| +// the two chains (handshake and :80) composed in the same order. |
| +type e2eEdge struct { |
| + edge *Edge |
| + store *e2eRecordingStore |
| + local autocert.Cache |
| + mirror *certMirror |
| + mgr *autocert.Manager |
| + // getCert is the full handshake chain: mirror in front of autocert, with |
| + // the direct local-cache last resort behind it. |
| + getCert func(*tls.ClientHelloInfo) (*tls.Certificate, error) |
| + // httpHandler is the full :80 ACME chain: the mirror's challenge handler |
| + // in front of autocert's HTTPHandler. |
| + httpHandler http.Handler |
| + tlsConfig *tls.Config |
| + // hits counts requests that reached this box's :80 chain. |
| + hits atomic.Int64 |
| +} |
| + |
| +// newE2EEdge builds one edge over its OWN handle on the shared bucket |
| +// directory — two boxes, two clients, one bucket, which is what makes the |
| +// disjointness assertion in the P6 subtest meaningful. |
| +func newE2EEdge(t *testing.T, bucketDir string, holds bool, directoryURL string, hosts ...string) *e2eEdge { |
| + t.Helper() |
| + b := &e2eEdge{store: &e2eRecordingStore{Store: release.NewDirStore(bucketDir)}} |
| + b.edge = testIssuerEdge(hosts...) |
| + b.edge.staticStore = b.store |
| + b.edge.issuer = func() (context.Context, bool) { |
| + if !holds { |
| + return nil, false |
| + } |
| + return context.Background(), true |
| + } |
| + |
| + // The RAW cache, kept before the publishing wrapper goes on (edge.go). |
| + b.local = autocert.DirCache(t.TempDir()) |
| + cache := b.edge.armCertIssuance(b.local) |
| + if _, wrapped := cache.(*publishingCache); !wrapped { |
| + t.Fatal("a store-backed edge must publish every issuance artifact") |
| + } |
| + b.mirror = b.edge.newCertMirror(b.local) |
| + if b.mirror == nil { |
| + t.Fatal("a store-backed edge must have a mirror to serve from") |
| + } |
| + b.mgr = &autocert.Manager{ |
| + Prompt: autocert.AcceptTOS, |
| + Cache: cache, |
| + HostPolicy: b.edge.issuerHostPolicy(), |
| + Client: &acme.Client{DirectoryURL: directoryURL}, |
| + } |
| + b.tlsConfig = b.mgr.TLSConfig() |
| + b.getCert = b.mirror.wrapGetCertificate(b.tlsConfig.GetCertificate) |
| + b.tlsConfig.GetCertificate = b.getCert |
| + |
| + // HTTPHandler is what sets autocert's tryHTTP01, so it has to be built |
| + // before any order starts — exactly as edge.go builds it before binding. |
| + fallback := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { |
| + w.WriteHeader(http.StatusTeapot) |
| + }) |
| + chain := b.mirror.challengeHandler(b.mgr.HTTPHandler(fallback)) |
| + b.httpHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + b.hits.Add(1) |
| + chain.ServeHTTP(w, r) |
| + }) |
| + return b |
| +} |
| + |
| +// serveHTTP puts this edge's :80 chain on a loopback listener and returns its |
| +// base URL — what the CA is (or, for edge A, deliberately is not) pointed at. |
| +func (b *e2eEdge) serveHTTP(t *testing.T) string { |
| + t.Helper() |
| + srv := httptest.NewServer(b.httpHandler) |
| + t.Cleanup(srv.Close) |
| + return srv.URL |
| +} |
| + |
| +// serveTLS puts this edge's handshake chain on a loopback listener. Raw |
| +// tls.Server rather than an HTTP server: the assertion is about the |
| +// certificate the handshake produces, and nothing above it. |
| +func (b *e2eEdge) serveTLS(t *testing.T) net.Listener { |
| + t.Helper() |
| + ln, err := net.Listen("tcp", "127.0.0.1:0") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { _ = ln.Close() }) |
| + cfg := b.tlsConfig |
| + go func() { |
| + for { |
| + conn, err := ln.Accept() |
| + if err != nil { |
| + return |
| + } |
| + go func() { |
| + tc := tls.Server(conn, cfg) |
| + _ = tc.HandshakeContext(context.Background()) |
| + _ = tc.Close() |
| + }() |
| + } |
| + }() |
| + return ln |
| +} |
| + |
| +// e2eDial completes one handshake against ln with SNI name, verifying the |
| +// chain against the fake CA's root — a real client's verification, not a |
| +// peek at the certificate the server chose. |
| +func e2eDial(ln net.Listener, name string, roots *x509.CertPool) (*tls.ConnectionState, error) { |
| + conn, err := tls.Dial("tcp", ln.Addr().String(), &tls.Config{ |
| + ServerName: name, |
| + RootCAs: roots, |
| + MinVersion: tls.VersionTLS12, |
| + }) |
| + if err != nil { |
| + return nil, err |
| + } |
| + defer conn.Close() |
| + state := conn.ConnectionState() |
| + return &state, nil |
| +} |
| + |
| +// e2eRecordingStore is one box's handle on the bucket, remembering every key |
| +// it read and every key it wrote. Reads bound what the mirror touches (P6); |
| +// writes are how "a gated box publishes nothing" is asserted without trusting |
| +// the gate's own bookkeeping. |
| +type e2eRecordingStore struct { |
| + release.Store |
| + mu sync.Mutex |
| + reads []string |
| + wrote []string |
| +} |
| + |
| +func (s *e2eRecordingStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| + s.mu.Lock() |
| + s.reads = append(s.reads, key) |
| + s.mu.Unlock() |
| + return s.Store.Get(ctx, key) |
| +} |
| + |
| +func (s *e2eRecordingStore) List(ctx context.Context, prefix string) ([]string, error) { |
| + s.mu.Lock() |
| + s.reads = append(s.reads, prefix) |
| + s.mu.Unlock() |
| + return s.Store.List(ctx, prefix) |
| +} |
| + |
| +func (s *e2eRecordingStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| + s.mu.Lock() |
| + s.wrote = append(s.wrote, key) |
| + s.mu.Unlock() |
| + return s.Store.Put(ctx, key, body, ifMatch) |
| +} |
| + |
| +func (s *e2eRecordingStore) Delete(ctx context.Context, key string) error { |
| + s.mu.Lock() |
| + s.wrote = append(s.wrote, key) |
| + s.mu.Unlock() |
| + return s.Store.Delete(ctx, key) |
| +} |
| + |
| +func (s *e2eRecordingStore) snapshot() (reads, wrote []string) { |
| + s.mu.Lock() |
| + defer s.mu.Unlock() |
| + return append([]string(nil), s.reads...), append([]string(nil), s.wrote...) |
| +} |
| + |
| +// e2eQuietLogs sends the edge's own log lines to the void. The chain under |
| +// test is deliberately chatty (publishing armed, mirror armed, serving from |
| +// the local cache); the test's output is the assertions. |
| +func e2eQuietLogs(t *testing.T) { |
| + t.Helper() |
| + log.SetOutput(io.Discard) |
| + t.Cleanup(func() { log.SetOutput(os.Stderr) }) |
| +} |
| + |
| +// ---- P1/P2/P3/P6: the two-edge scenario -------------------------------------- |
| + |
| +func TestCertSyncE2EMintOnOneServeOnTheOther(t *testing.T) { |
| + e2eQuietLogs(t) |
| + bucket := t.TempDir() |
| + ca := newFakeACME(t) |
| + |
| + // Edge B first: the CA has to be pointed at it before edge A places an |
| + // order, because B is the box that will answer the challenge. |
| + edgeB := newE2EEdge(t, bucket, false, ca.directoryURL(), e2eHost, e2eUnmintedHost, e2eLocalOnlyHost) |
| + ca.validateAgainst(edgeB.serveHTTP(t)) |
| + |
| + // Edge A: the housekeeper. Its :80 chain is served too, so "the CA never |
| + // reached the issuer" is an assertion and not an assumption. |
| + edgeA := newE2EEdge(t, bucket, true, ca.directoryURL(), e2eHost, e2eUnmintedHost) |
| + edgeA.serveHTTP(t) |
| + |
| + // B's own on-disk material from before the election moved: nothing |
| + // publishes this, so only the direct local-cache last resort can serve it. |
| + if err := edgeB.local.Put(context.Background(), e2eLocalOnlyHost, |
| + hostCertPEM(t, e2eLocalOnlyHost, time.Now().Add(60*24*time.Hour))); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + lnB := edgeB.serveTLS(t) |
| + |
| + // Before anything is minted, B has nothing for e2eHost anywhere: empty |
| + // mirror, empty local cache, and autocert gated shut. The handshake must |
| + // fail — otherwise the post-mint success below would prove nothing. |
| + if _, err := e2eDial(lnB, e2eHost, ca.rootPool()); err == nil { |
| + t.Fatal("edge B answered a handshake for a certificate that does not exist yet") |
| + } |
| + |
| + // ---- the mint, on edge A only ---- |
| + minted, err := edgeA.getCert(ecdsaHello(e2eHost)) |
| + if err != nil { |
| + t.Fatalf("the issuer failed to mint %s: %v", e2eHost, err) |
| + } |
| + ca.checkFaults(t) |
| + if minted.Leaf == nil || minted.Leaf.VerifyHostname(e2eHost) != nil { |
| + t.Fatalf("minted cert does not cover %s", e2eHost) |
| + } |
| + if err := minted.Leaf.CheckSignatureFrom(ca.root); err != nil { |
| + t.Fatalf("minted cert was not issued by the CA under test: %v", err) |
| + } |
| + |
| + t.Run("P1 the challenge was answered by the other edge", func(t *testing.T) { |
| + v, ok := ca.lastValidation() |
| + if !ok { |
| + t.Fatal("the CA never validated anything — the order did not go through HTTP-01") |
| + } |
| + if !v.ok { |
| + t.Fatalf("validation failed: the body served for %s was not the key authorization", v.host) |
| + } |
| + if v.host != e2eHost { |
| + t.Fatalf("validated Host = %q, want %q", v.host, e2eHost) |
| + } |
| + if n := edgeB.hits.Load(); n == 0 { |
| + t.Fatal("edge B's :80 chain was never reached — the challenge was not answered off-issuer") |
| + } |
| + if n := edgeA.hits.Load(); n != 0 { |
| + t.Fatalf("edge A's :80 chain served %d request(s) — the CA must never have reached the issuer", n) |
| + } |
| + // The answer could only have come out of the bucket: B never ran an |
| + // order, so it holds no token in memory, and its own autocert |
| + // HTTPHandler 403s every challenge while gated. |
| + if err := edgeB.edge.issuerHostPolicy()(context.Background(), e2eHost); !errors.Is(err, errNotIssuer) { |
| + t.Fatalf("edge B's HostPolicy = %v, want errNotIssuer — its own handler must be shut", err) |
| + } |
| + // And it was a LIVE read, not a poll: B has never refreshed, so its |
| + // snapshot is still empty — challengeResponse's read-through is the |
| + // only path that could have produced the body, and the exact key it |
| + // read is the order's own token entry. |
| + if n := len(edgeB.mirror.snapshot().entries); n != 0 { |
| + t.Fatalf("edge B's mirror holds %d entry/entries already — the read-through is not what was tested", n) |
| + } |
| + want := certKey(v.token + httpTokenSuffix) |
| + reads, _ := edgeB.store.snapshot() |
| + if !slices.Contains(reads, want) { |
| + t.Fatalf("edge B read %v, want a live read of %q", reads, want) |
| + } |
| + }) |
| + |
| + accountsAfterMint, ordersAfterMint := ca.counts() |
| + if accountsAfterMint != 1 || ordersAfterMint != 1 { |
| + t.Fatalf("CA saw %d account(s) and %d order(s) after one mint, want 1 and 1", |
| + accountsAfterMint, ordersAfterMint) |
| + } |
| + |
| + t.Run("P2 the other edge serves the issuer's cert after one refresh", func(t *testing.T) { |
| + if err := edgeB.mirror.Refresh(context.Background()); err != nil { |
| + t.Fatalf("one refresh must be enough: %v", err) |
| + } |
| + // The in-memory mirror map holds it — the half that serves a box whose |
| + // own disk is empty. |
| + if c := edgeB.mirror.certificate(e2eHost, false); c == nil { |
| + t.Fatal("the mirror map has no certificate for the host the issuer minted") |
| + } else if !bytes.Equal(c.Leaf.Raw, minted.Leaf.Raw) { |
| + t.Fatal("the mirror holds a DIFFERENT certificate than the one the issuer minted") |
| + } |
| + state, err := e2eDial(lnB, e2eHost, ca.rootPool()) |
| + if err != nil { |
| + t.Fatalf("edge B must serve the issuer's certificate: %v", err) |
| + } |
| + if len(state.PeerCertificates) == 0 { |
| + t.Fatal("no peer certificate") |
| + } |
| + if !bytes.Equal(state.PeerCertificates[0].Raw, minted.Leaf.Raw) { |
| + t.Fatal("edge B served a certificate that is not the one edge A minted") |
| + } |
| + if got := state.ServerName; got != e2eHost { |
| + t.Fatalf("SNI = %q, want %q", got, e2eHost) |
| + } |
| + accounts, orders := ca.counts() |
| + if accounts != accountsAfterMint || orders != ordersAfterMint { |
| + t.Fatalf("edge B talked to the CA: accounts %d→%d, orders %d→%d — it must start zero", |
| + accountsAfterMint, accounts, ordersAfterMint, orders) |
| + } |
| + _, wrote := edgeB.store.snapshot() |
| + if len(wrote) != 0 { |
| + t.Fatalf("a gated edge wrote %v to the control bucket — only the issuer may", wrote) |
| + } |
| + }) |
| + |
| + t.Run("P3 the gated twin refuses to mint and still serves", func(t *testing.T) { |
| + // Edge B is edge A's twin in every respect but the gate: same |
| + // construction, same store, same CA client, HTTP-01 armed. Asked for a |
| + // host nobody has minted, it must refuse as the ISSUER gate and start |
| + // no order at all. |
| + _, ordersBefore := ca.counts() |
| + _, err := edgeB.getCert(ecdsaHello(e2eUnmintedHost)) |
| + if !errors.Is(err, errNotIssuer) { |
| + t.Fatalf("gated twin minting %s = %v, want errNotIssuer", e2eUnmintedHost, err) |
| + } |
| + if _, orders := ca.counts(); orders != ordersBefore { |
| + t.Fatalf("the gated twin started %d order(s)", orders-ordersBefore) |
| + } |
| + // ...while still serving what is on its OWN disk. autocert refuses the |
| + // name before it ever looks there (HostPolicy precedes cacheGet), so |
| + // this can only be the chain's direct local-cache last resort. |
| + got, err := edgeB.getCert(ecdsaHello(e2eLocalOnlyHost)) |
| + if err != nil { |
| + t.Fatalf("a gated box must keep serving its own cached material: %v", err) |
| + } |
| + if got.Leaf == nil || got.Leaf.VerifyHostname(e2eLocalOnlyHost) != nil { |
| + t.Fatalf("served cert does not cover %s", e2eLocalOnlyHost) |
| + } |
| + if _, ok := edgeB.mirror.Cert(e2eLocalOnlyHost); ok { |
| + t.Fatal("test bug: that host was supposed to exist only on B's disk") |
| + } |
| + }) |
| + |
| + t.Run("P6 the mirror reads its own handle, never the fleet door", func(t *testing.T) { |
| + // Structural: the mirror holds the edge's own deployment-store handle. |
| + if edgeB.mirror.s != release.Store(edgeB.store) { |
| + t.Fatal("the mirror is not reading through this box's own store handle") |
| + } |
| + if edgeB.store == edgeA.store { |
| + t.Fatal("test bug: the two edges must hold separate handles on the one bucket") |
| + } |
| + // And the door could not have served those reads even if something |
| + // tried: the fleet ACL (storedoor.go) refuses every key under |
| + // control/certs/ for every tenant and fleet, so a fleet box reaching |
| + // for cert material over the door gets the byte-identical 404. |
| + for _, key := range []string{certKey(e2eHost), certKey("acme_account+key"), certPrefix} { |
| + if aclRead("bdk", "pi-fleet", key) { |
| + t.Fatalf("the fleet door ACL allows %q — cert material must never be reachable through it", key) |
| + } |
| + } |
| + // Every read this box's mirror made stayed inside the one prefix. |
| + reads, _ := edgeB.store.snapshot() |
| + for _, key := range reads { |
| + if !strings.HasPrefix(key, certPrefix) { |
| + t.Fatalf("the mirror read %q, outside %s", key, certPrefix) |
| + } |
| + } |
| + }) |
| + |
| + ca.checkFaults(t) |
| +} |
| + |
| +// ---- the CA's remaining handlers --------------------------------------------- |
| +// |
| +// Split out below the scenario they serve, so the test reads first. |
| + |
| +func (ca *fakeACME) orderID(r *http.Request) (string, *fakeACMEOrder, bool) { |
| + idx := strings.LastIndex(r.URL.Path, "/") |
| + id := r.URL.Path[idx+1:] |
| + ca.mu.Lock() |
| + defer ca.mu.Unlock() |
| + o, ok := ca.byID[id] |
| + return id, o, ok |
| +} |
| + |
| +// serveAuthorization answers POST-as-GET on an authorization, offering the one |
| +// http-01 challenge (see fakeACME's doc for why only one type). |
| +func (ca *fakeACME) serveAuthorization(w http.ResponseWriter, r *http.Request) { |
| + id, o, ok := ca.orderID(r) |
| + if !ok { |
| + ca.recordFault("authz %s: unknown", id) |
| + http.Error(w, "no such authz", http.StatusNotFound) |
| + return |
| + } |
| + ca.mu.Lock() |
| + body := map[string]any{ |
| + "identifier": map[string]string{"type": "dns", "value": o.domain}, |
| + "status": o.authz, |
| + "challenges": []map[string]string{{ |
| + "type": "http-01", |
| + "url": ca.url("/chal/" + id), |
| + "token": o.token, |
| + "status": o.authz, |
| + }}, |
| + } |
| + ca.mu.Unlock() |
| + writeJSON(w, http.StatusOK, body) |
| +} |
| + |
| +// serveChallengeAccept is the CA's validation. It runs synchronously: the |
| +// whole point of the read-through in challengeResponse is that a validation's |
| +// life is measured in seconds, and a CA that answered on the next poll would |
| +// be testing the wrong thing. |
| +func (ca *fakeACME) serveChallengeAccept(w http.ResponseWriter, r *http.Request) { |
| + id, o, ok := ca.orderID(r) |
| + if !ok { |
| + ca.recordFault("challenge %s: unknown", id) |
| + http.Error(w, "no such challenge", http.StatusNotFound) |
| + return |
| + } |
| + ca.mu.Lock() |
| + base, thumb, domain, token := ca.validateVia, ca.thumb, o.domain, o.token |
| + ca.mu.Unlock() |
| + |
| + url := base + acmeChallengePrefix + token |
| + got, ferr := ca.fetchChallenge(url, domain) |
| + want := token + "." + thumb |
| + valid := ferr == nil && got == want |
| + |
| + ca.mu.Lock() |
| + ca.validations = append(ca.validations, fakeACMEValidation{url: url, host: domain, token: token, ok: valid}) |
| + if valid { |
| + o.authz, o.status = "valid", "ready" |
| + } else { |
| + o.authz, o.status = "invalid", "invalid" |
| + ca.faults = append(ca.faults, fmt.Sprintf( |
| + "http-01 for %s at %s: body %q, want %q (fetch err %v)", domain, url, got, want, ferr)) |
| + } |
| + ca.mu.Unlock() |
| + |
| + writeJSON(w, http.StatusOK, map[string]string{ |
| + "type": "http-01", "url": ca.url("/chal/" + id), "token": token, "status": "valid", |
| + }) |
| +} |
| + |
| +// fetchChallenge is the validator: an ordinary HTTP GET carrying the Host |
| +// header of the name under validation, exactly as a CA sends it (and exactly |
| +// what challengeResponse's host bound is calibrated against). |
| +func (ca *fakeACME) fetchChallenge(url, host string) (string, error) { |
| + req, err := http.NewRequest("GET", url, nil) |
| + if err != nil { |
| + return "", err |
| + } |
| + req.Host = host |
| + client := &http.Client{Timeout: 10 * time.Second} |
| + res, err := client.Do(req) |
| + if err != nil { |
| + return "", err |
| + } |
| + defer res.Body.Close() |
| + body, err := io.ReadAll(io.LimitReader(res.Body, 1<<16)) |
| + if err != nil { |
| + return "", err |
| + } |
| + if res.StatusCode != http.StatusOK { |
| + return "", fmt.Errorf("challenge fetch: %s", res.Status) |
| + } |
| + return string(body), nil |
| +} |
| + |
| +func (ca *fakeACME) serveOrder(w http.ResponseWriter, r *http.Request) { |
| + id, _, ok := ca.orderID(r) |
| + if !ok { |
| + ca.recordFault("order %s: unknown", id) |
| + http.Error(w, "no such order", http.StatusNotFound) |
| + return |
| + } |
| + w.Header().Set("Location", ca.url("/order/"+id)) |
| + writeJSON(w, http.StatusOK, ca.orderJSON(id)) |
| +} |
| + |
| +func (ca *fakeACME) serveFinalize(w http.ResponseWriter, r *http.Request) { |
| + id, o, ok := ca.orderID(r) |
| + if !ok { |
| + ca.recordFault("finalize %s: unknown", id) |
| + http.Error(w, "no such order", http.StatusNotFound) |
| + return |
| + } |
| + _, payload, err := decodeJWS(r) |
| + if err != nil { |
| + ca.recordFault("finalize %s: %v", id, err) |
| + http.Error(w, "bad jws", http.StatusBadRequest) |
| + return |
| + } |
| + var req struct { |
| + CSR string `json:"csr"` |
| + } |
| + if err := json.Unmarshal(payload, &req); err != nil { |
| + ca.recordFault("finalize %s: %v", id, err) |
| + http.Error(w, "bad finalize", http.StatusBadRequest) |
| + return |
| + } |
| + der, err := base64.RawURLEncoding.DecodeString(req.CSR) |
| + if err != nil { |
| + ca.recordFault("finalize %s: csr: %v", id, err) |
| + http.Error(w, "bad csr", http.StatusBadRequest) |
| + return |
| + } |
| + ca.mu.Lock() |
| + domain, authz := o.domain, o.authz |
| + ca.mu.Unlock() |
| + if authz != "valid" { |
| + ca.recordFault("finalize %s: authorization is %q — a CA must not issue on an unvalidated order", id, authz) |
| + http.Error(w, "not authorized", http.StatusForbidden) |
| + return |
| + } |
| + chain, err := ca.issue(der, domain) |
| + if err != nil { |
| + ca.recordFault("finalize %s: %v", id, err) |
| + http.Error(w, "cannot issue", http.StatusBadRequest) |
| + return |
| + } |
| + ca.mu.Lock() |
| + o.chain, o.status = chain, "valid" |
| + ca.mu.Unlock() |
| + w.Header().Set("Location", ca.url("/order/"+id)) |
| + writeJSON(w, http.StatusOK, ca.orderJSON(id)) |
| +} |
| + |
| +func (ca *fakeACME) serveCert(w http.ResponseWriter, r *http.Request) { |
| + id, o, ok := ca.orderID(r) |
| + if !ok { |
| + ca.recordFault("cert %s: unknown", id) |
| + http.Error(w, "no such cert", http.StatusNotFound) |
| + return |
| + } |
| + ca.mu.Lock() |
| + chain := o.chain |
| + ca.mu.Unlock() |
| + w.Header().Set("Content-Type", "application/pem-certificate-chain") |
| + w.WriteHeader(http.StatusOK) |
| + _, _ = w.Write(chain) |
| +} |
| + |
| +// issue signs the CSR's public key for domain under the fake root. The CSR's |
| +// own signature and name are checked: a CA that issued for whatever it was |
| +// asked would let a chain-of-custody bug through unnoticed. |
| +func (ca *fakeACME) issue(csrDER []byte, domain string) ([]byte, error) { |
| + csr, err := x509.ParseCertificateRequest(csrDER) |
| + if err != nil { |
| + return nil, fmt.Errorf("parse csr: %w", err) |
| + } |
| + if err := csr.CheckSignature(); err != nil { |
| + return nil, fmt.Errorf("csr signature: %w", err) |
| + } |
| + if len(csr.DNSNames) != 1 || csr.DNSNames[0] != domain { |
| + return nil, fmt.Errorf("csr names %v, want [%s]", csr.DNSNames, domain) |
| + } |
| + serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 96)) |
| + if err != nil { |
| + return nil, err |
| + } |
| + tmpl := &x509.Certificate{ |
| + SerialNumber: serial, |
| + Subject: pkix.Name{CommonName: domain}, |
| + DNSNames: []string{domain}, |
| + NotBefore: time.Now().Add(-time.Hour), |
| + // 90 days, like the real thing: autocert's renewal timer is then |
| + // ~60 days out and nothing fires during the test. |
| + NotAfter: time.Now().Add(90 * 24 * time.Hour), |
| + KeyUsage: x509.KeyUsageDigitalSignature, |
| + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, |
| + } |
| + der, err := x509.CreateCertificate(rand.Reader, tmpl, ca.root, csr.PublicKey, ca.rootKey) |
| + if err != nil { |
| + return nil, err |
| + } |
| + var buf bytes.Buffer |
| + if err := pem.Encode(&buf, &pem.Block{Type: "CERTIFICATE", Bytes: der}); err != nil { |
| + return nil, err |
| + } |
| + if err := pem.Encode(&buf, &pem.Block{Type: "CERTIFICATE", Bytes: ca.root.Raw}); err != nil { |
| + return nil, err |
| + } |
| + return buf.Bytes(), nil |
| +} |
| + |
| +// ---- P4: the renewal timer's cache-first check ------------------------------- |
| + |
| +// R3: autocert's renewal timers never consult HostPolicy (see |
| +// issuerHostPolicy), so a box that has stood down still has armed timers. What |
| +// stops one ordering is renewal.go's do(), which reads the CACHE first and |
| +// returns without touching a CA when the cached cert is far enough from |
| +// expiry. The mirror's syncLocal is what puts a fresher cert there. |
| +// |
| +// The boundary, stated rather than papered over: autocert's real timer is not |
| +// fast-forwarded here — time.AfterFunc's deadline is unreachable from a test |
| +// and faking it would test the fake. What IS proven is every input the timer's |
| +// decision reads: the local cache holds the fresher bytes after one Refresh, |
| +// autocert's own cacheGet (the exact call do() makes, reached here through |
| +// GetCertificate) returns the fresher certificate, and the renewal threshold |
| +// re-derived from renewal.go's next() flips from "due" to "not due" across the |
| +// refresh. |
| +func TestCertSyncE2EFresherBucketCertReachesAutocertsCache(t *testing.T) { |
| + e2eQuietLogs(t) |
| + ctx := context.Background() |
| + store := release.NewDirStore(t.TempDir()) |
| + local := autocert.DirCache(t.TempDir()) |
| + |
| + // Stale but live: a 90-day cert with 20 days left, which is inside |
| + // autocert's 30-day renewal threshold — this box's timer is due to order. |
| + stale := e2eDatedCertPEM(t, e2eHost, time.Now().Add(-70*24*time.Hour), time.Now().Add(20*24*time.Hour)) |
| + if err := local.Put(ctx, e2eHost, stale); err != nil { |
| + t.Fatal(err) |
| + } |
| + if due := e2eRenewalDue(t, stale); !due { |
| + t.Fatal("test bug: the seeded cert must be inside the renewal threshold, or there is nothing to rescue") |
| + } |
| + |
| + // The issuer has already renewed and published: 90 days, all of them left. |
| + fresh := e2eDatedCertPEM(t, e2eHost, time.Now().Add(-time.Hour), time.Now().Add(90*24*time.Hour)) |
| + putBucket(t, store, e2eHost, fresh) |
| + |
| + m := newCertMirror(store, local) |
| + if err := m.Refresh(ctx); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + got, err := localCert(t, local, e2eHost) |
| + if err != nil { |
| + t.Fatalf("the mirror must write fresh material into this box's own cache: %v", err) |
| + } |
| + if !bytes.Equal(got, fresh) { |
| + t.Fatal("the local cache still holds the stale cert — the renewal timer would go to the CA") |
| + } |
| + if due := e2eRenewalDue(t, got); due { |
| + t.Fatal("the cached cert is still inside the renewal threshold: do() would fall through and order") |
| + } |
| + |
| + // The cache-first read do() makes, made through the public door onto it. |
| + // The manager is UNGATED (this box may have won the election back) and |
| + // points at a dead directory, so a cache miss fails offline and fast |
| + // rather than reaching a CA. |
| + e := testIssuerEdge(e2eHost) |
| + served, err := offlineManager(t, local, e.issuerHostPolicy()).GetCertificate(ecdsaHello(e2eHost)) |
| + if err != nil { |
| + t.Fatalf("autocert's own cache read must find the mirrored cert: %v", err) |
| + } |
| + freshLeaf := e2eLeafOf(t, fresh) |
| + staleLeaf := e2eLeafOf(t, stale) |
| + if !freshLeaf.NotAfter.After(staleLeaf.NotAfter) { |
| + t.Fatal("test bug: the two certs are not ordered by expiry") |
| + } |
| + if !served.Leaf.NotAfter.Equal(freshLeaf.NotAfter) { |
| + t.Fatalf("autocert served a cert expiring %s, want the mirrored one at %s", |
| + served.Leaf.NotAfter, freshLeaf.NotAfter) |
| + } |
| +} |
| + |
| +// e2eDatedCertPEM is hostCertPEM with a caller-chosen NotBefore, so a test can |
| +// build a cert whose LIFETIME (and therefore autocert's renewal threshold) is |
| +// realistic rather than an artefact of the helper. |
| +func e2eDatedCertPEM(t *testing.T, host string, notBefore, notAfter time.Time) []byte { |
| + t.Helper() |
| + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + tmpl := &x509.Certificate{ |
| + SerialNumber: big.NewInt(2), |
| + Subject: pkix.Name{CommonName: host}, |
| + DNSNames: []string{host}, |
| + NotBefore: notBefore, |
| + NotAfter: notAfter, |
| + } |
| + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + leaf, err := x509.ParseCertificate(der) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + raw, err := encodeWildcardPEM(&tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key, Leaf: leaf}, key) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return raw |
| +} |
| + |
| +func e2eLeafOf(t *testing.T, entry []byte) *x509.Certificate { |
| + t.Helper() |
| + cert, err := parseCertEntry(entry) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return cert.Leaf |
| +} |
| + |
| +// e2eRenewalDue re-derives autocert's own renewal decision (renewal.go's |
| +// next(), with no RenewBefore set): threshold = min(lifetime/3, 30 days), and |
| +// the timer's cache-first branch falls through to an ORDER when the renewal |
| +// moment has already passed. Jitter is ignored — it is at most an hour and |
| +// both fixtures here are days from the boundary. |
| +func e2eRenewalDue(t *testing.T, entry []byte) bool { |
| + t.Helper() |
| + leaf := e2eLeafOf(t, entry) |
| + threshold := min(leaf.NotAfter.Sub(leaf.NotBefore)/3, 30*24*time.Hour) |
| + return !leaf.NotAfter.Add(-threshold).After(time.Now()) |
| +} |
| + |
| +// ---- P5: Refresh under concurrency ------------------------------------------- |
| + |
| +// The mirror has no refreshMu: Refresh is written for the single poll loop |
| +// that calls it (run(), plus the one synchronous boot pass edge.go runs BEFORE |
| +// that goroutine starts). This test runs it from several goroutines at once |
| +// anyway, because "only one caller today" is a property of the callers and not |
| +// of the code, and the -race gate is where a wrong answer to that question is |
| +// cheap to learn. |
| +// |
| +// What it asserts is memory safety and a coherent OUTCOME: whichever |
| +// interleaving wins, the published snapshot and the local cache both end up |
| +// holding every entry the bucket holds. It deliberately does NOT assert |
| +// linearizability — two overlapping polls can publish out of order, and the |
| +// next poll converges. See the task report for that finding. |
| +func TestCertSyncE2EConcurrentRefreshIsRaceFree(t *testing.T) { |
| + e2eQuietLogs(t) |
| + ctx := context.Background() |
| + store := release.NewDirStore(t.TempDir()) |
| + hosts := []string{"a." + e2eHost, "b." + e2eHost, "c." + e2eHost} |
| + want := map[string][]byte{} |
| + for _, h := range hosts { |
| + body := hostCertPEM(t, h, time.Now().Add(80*24*time.Hour)) |
| + putBucket(t, store, h, body) |
| + want[h] = body |
| + } |
| + putBucket(t, store, "acme_account+key", []byte("ACCOUNT KEY")) |
| + |
| + local := autocert.DirCache(t.TempDir()) |
| + m := newCertMirror(store, local) |
| + |
| + var wg sync.WaitGroup |
| + for range 4 { |
| + wg.Add(1) |
| + go func() { |
| + defer wg.Done() |
| + for range 5 { |
| + if err := m.Refresh(ctx); err != nil { |
| + t.Errorf("concurrent refresh: %v", err) |
| + return |
| + } |
| + } |
| + }() |
| + } |
| + wg.Wait() |
| + |
| + snap := m.snapshot() |
| + for h, body := range want { |
| + if got, ok := snap.entries[h]; !ok || !bytes.Equal(got, body) { |
| + t.Fatalf("snapshot entry %s = %q (%v), want the published body", h, got, ok) |
| + } |
| + if snap.certs[h] == nil { |
| + t.Fatalf("snapshot has no parsed certificate for %s", h) |
| + } |
| + got, err := localCert(t, local, h) |
| + if err != nil || !bytes.Equal(got, body) { |
| + t.Fatalf("local cache %s = %q, %v — every entry must land exactly once", h, got, err) |
| + } |
| + } |
| + if got, err := localCert(t, local, "acme_account+key"); err != nil || string(got) != "ACCOUNT KEY" { |
| + t.Fatalf("account key seeding raced: %q, %v", got, err) |
| + } |
| +} |