edge: cert mirror — every edge serves every cert and challenge
The serving half of Task 4's issuance gate, and what makes that gate safe to shut: a 30s poll of control/certs/ that any edge serves from. Handshakes (wrapGetCertificate): wildcard → mirror → autocert → the local cache read DIRECTLY. autocert runs HostPolicy BEFORE its cache lookup, so a gated manager serves nothing at all — the mirror is the PRIMARY path on a non-issuer, not a fallback, and the direct cache read is what carries a box through a bucket outage or an undecided election. The short-circuit ahead of autocert applies while gated and only then: autocert arms its renewal timers lazily out of GetCertificate, so a mirror that answered every hello would leave the ISSUER with no renewal timers at all. Challenges (challengeHandler): in FRONT of autocert's HTTPHandler, which 403s a non-issuer before it looks a token up. A miss reads THROUGH to the bucket, because a validation's whole life is shorter than a poll — bounded by token shape, a memo of hits and misses, single flight per name, a 2s budget and a global rate limit. The same bounded path answers tls-alpn-01 for another edge's order, which autocert PREFERS over http-01 and would otherwise fail on (N-1)/N of every deployment's orders. Every refresh also copies fresh material into this box's own autocert cache: renewal timers bypass HostPolicy and check the cache first, so a stood-down box's stale timers find a cert there instead of ordering. Account keys are seeded when absent (one deployment, one ACME account) but never overwritten and never servable. A changed wildcard PEM wakes the wildcard manager, so a takeover propagates in one poll rather than twelve hours. Refresh errors keep the last-good map: an empty map on a bucket blip would be a TLS outage on every non-issuer. Nothing here writes to the bucket.
4 files changed,
+1874
−4
internal/edge/certsync.go+916 −0internal/edge/certsync_test.go+899 −0internal/edge/edge.go+50 −2internal/edge/wildcard_test.go+9 −2
diff --git a/internal/edge/certsync.go b/internal/edge/certsync.go| index 40a3e835b..a12c68c58 100644 |
| --- a/internal/edge/certsync.go |
| +++ b/internal/edge/certsync.go |
| @@ -28,14 +28,21 @@ package edge |
| // in seconds is never on a deadline. |
| import ( |
| + "bytes" |
| "context" |
| + "crypto/tls" |
| + "crypto/x509" |
| "errors" |
| "fmt" |
| "log" |
| + "net/http" |
| "os" |
| + "path" |
| "strings" |
| + "sync" |
| "time" |
| + "golang.org/x/crypto/acme" |
| "golang.org/x/crypto/acme/autocert" |
| "github.com/carlosframework/platform/internal/release" |
| @@ -322,3 +329,912 @@ func putCert(ctx context.Context, s release.Store, name string, body []byte) err |
| } |
| return fmt.Errorf("%s: lost CAS race three times", key) |
| } |
| + |
| +// ---- the mirror: every edge serves every cert -------------------------------- |
| +// |
| +// certMirror is the serving half of the pair at the top of this file, and the |
| +// reason the issuance gate above is safe to shut. It POLLS control/certs/ (it |
| +// never writes there — the writer map gives that prefix to the housekeeper |
| +// alone) and keeps the whole prefix in memory, so any edge can answer a |
| +// handshake for a name some OTHER edge issued, and can answer an HTTP-01 |
| +// challenge for an order some OTHER edge is running. |
| +// |
| +// Three consumers, in the order a request meets them: |
| +// |
| +// - GetCertificate (wrapGetCertificate, wildcard.go): the mirror is the |
| +// PRIMARY serving path on a non-issuer, ahead of autocert, because |
| +// autocert's HostPolicy runs BEFORE its cache lookup — a gated manager |
| +// serves nothing at all, cached or not. |
| +// - the :80 challenge handler (challengeHandler): in FRONT of autocert's |
| +// HTTPHandler for the same reason — that handler 403s a non-issuer before |
| +// it ever looks a token up. |
| +// - the LOCAL autocert cache: every refresh copies fresh cert material into |
| +// it. autocert's renewal timers never consult HostPolicy (see |
| +// issuerHostPolicy), and renewal.go's do() checks the cache FIRST, so a |
| +// mirror-refreshed local cache is what stops a stood-down box's stale |
| +// timers from ordering — and what carries a box through the boot and |
| +// crash windows where the election has not yet been decided. |
| +// |
| +// Account keys are the one thing that is mirrored but never served: they are |
| +// issuer material and have no business in a map a handshake can reach. |
| + |
| +// certMirrorInterval is the poll cadence. Renewals run ~30 days before |
| +// expiry, so nothing on this path is on a deadline — except live HTTP-01 |
| +// validations, which the poll deliberately does NOT try to catch (see |
| +// challengeResponse's read-through). |
| +var certMirrorInterval = 30 * time.Second |
| + |
| +// certMirrorTimeout budgets one whole poll: list plus the gets. Generous, |
| +// because it runs on its own goroutine and blocks nothing; bounded, because a |
| +// stalled bucket must not wedge the loop forever. |
| +var certMirrorTimeout = 2 * time.Minute |
| + |
| +// certMirrorBootTimeout budgets the ONE refresh that runs before the |
| +// listeners bind. Much shorter than a poll's: a stalled bucket must delay the |
| +// front door by seconds, not minutes, and the box still has its own cache to |
| +// serve from while the loop catches up. |
| +var certMirrorBootTimeout = 20 * time.Second |
| + |
| +// certSnapshot is one poll's result, swapped in whole. Immutable once |
| +// published, so readers hold no lock beyond the pointer read. |
| +type certSnapshot struct { |
| + // entries is every mirrored name → body, account keys included (the local |
| + // sync needs them; Cert refuses to serve them). |
| + entries map[string][]byte |
| + // certs is the parsed form of every cert-shaped entry, keyed by the same |
| + // name. Parsing happens ONCE per poll per changed entry, never per |
| + // handshake. A name whose body does not parse is absent, not nil. |
| + certs map[string]*tls.Certificate |
| +} |
| + |
| +var emptyCertSnapshot = &certSnapshot{entries: map[string][]byte{}, certs: map[string]*tls.Certificate{}} |
| + |
| +type certMirror struct { |
| + s release.Store |
| + // local is this box's OWN autocert cache — the raw one, NOT the |
| + // publishing wrapper: a mirror write is a copy inwards, and pushing it |
| + // back out to the bucket would have the issuer republishing its own |
| + // material on every poll. |
| + local autocert.Cache |
| + |
| + // wake is called after a poll that changed wildcard material — the |
| + // wildcard manager's refresh (R4), so a takeover propagates on the poll |
| + // instead of on the manager's own 12h tick. nil when wildcards are off. |
| + wake func() |
| + |
| + // allowed reports whether a hostname is one this box serves (Edge.Allowed) |
| + // — the bound on the tls-alpn-01 read-through, whose key is |
| + // attacker-chosen SNI. nil = unbounded, which is tests only. |
| + allowed func(string) bool |
| + |
| + // gated reports whether issuance is currently BARRED on this box, which |
| + // is what decides where the mirror sits in the handshake chain (see |
| + // wrapGetCertificate). Read per hello, because the election moves under a |
| + // running process. nil = treat as gated, the safe default: it serves the |
| + // same certificates either way, it only costs an issuer its lazily-armed |
| + // renewal timers, and a mirror built without a gate (tests) has no |
| + // issuance to arm. |
| + gated func() bool |
| + |
| + mu sync.RWMutex |
| + snap *certSnapshot |
| + |
| + // synced remembers, per name, the mirrored bytes last reconciled into the |
| + // local cache, so a steady state costs no local I/O at all. |
| + syncMu sync.Mutex |
| + synced map[string][]byte |
| + |
| + // rt is the read-through memo (hits and misses), its in-flight set, and |
| + // the global token bucket that bounds live bucket reads. |
| + rtMu sync.Mutex |
| + rt map[string]readThroughEntry |
| + rtInflight map[string]chan struct{} |
| + rtTokens float64 |
| + rtLast time.Time |
| + lastRTLog time.Time |
| + |
| + // localCerts memoes the last-resort reads of this box's own cache; |
| + // lastLocalLog throttles the line that says the mirror had nothing. |
| + localMu sync.Mutex |
| + localCerts map[string]localCertEntry |
| + lastLocalLog time.Time |
| +} |
| + |
| +// newCertMirror is the Edge's mirror, or nil when there is nothing to mirror |
| +// FROM. The nil case is exactly armCertIssuance's no-store case — the two |
| +// decisions are the same decision, and a box that lands there is ungated and |
| +// serves its own cache through autocert as it always has. |
| +func (e *Edge) newCertMirror(local autocert.Cache) *certMirror { |
| + if e.staticStore == nil { |
| + return nil |
| + } |
| + m := newCertMirror(e.staticStore, local) |
| + m.gated = func() bool { |
| + _, held := e.issuerGate() |
| + return !held |
| + } |
| + // The tls-alpn-01 read-through is keyed by attacker-chosen SNI, so it is |
| + // bounded to the hosts this box actually serves. Allowed is the same |
| + // exact-match registry check autocert's HostPolicy uses, which is the |
| + // right bound: a per-host order only ever exists for a name that passed it. |
| + m.allowed = e.Allowed |
| + return m |
| +} |
| + |
| +func newCertMirror(s release.Store, local autocert.Cache) *certMirror { |
| + return &certMirror{ |
| + s: s, |
| + local: local, |
| + snap: emptyCertSnapshot, |
| + synced: map[string][]byte{}, |
| + rt: map[string]readThroughEntry{}, |
| + rtInflight: map[string]chan struct{}{}, |
| + localCerts: map[string]localCertEntry{}, |
| + } |
| +} |
| + |
| +// Refresh rebuilds the whole map from one list plus a get per key. No delta |
| +// cleverness: the prefix holds one small object per host and renewals are |
| +// rare, and a full rebuild is what makes a withdrawn entry (a spent challenge |
| +// token) actually leave. |
| +// |
| +// An error leaves the last-good snapshot in place and is returned for the |
| +// caller to log. That is the edge's standing degrade-not-die posture, and it |
| +// matters more here than usual: an empty map on a bucket blip would be a TLS |
| +// outage on every non-issuer in the deployment. |
| +func (m *certMirror) Refresh(ctx context.Context) error { |
| + keys, err := m.s.List(ctx, certPrefix) |
| + if err != nil { |
| + return fmt.Errorf("list %s: %w", certPrefix, err) |
| + } |
| + next := make(map[string][]byte, len(keys)) |
| + for _, key := range keys { |
| + name, ok := strings.CutPrefix(key, certPrefix) |
| + // publishableCertName is the same filter the publish side applies: |
| + // anything nested under the prefix was not written by this deployment's |
| + // issuer and has no cache name to map back onto. |
| + if !ok || !publishableCertName(name) { |
| + continue |
| + } |
| + body, _, err := m.s.Get(ctx, key) |
| + if errors.Is(err, os.ErrNotExist) { |
| + // Raced a delete between the list and the get — a spent token. Not |
| + // a fault; the entry is simply gone. |
| + continue |
| + } |
| + if err != nil { |
| + return fmt.Errorf("get %s: %w", key, err) |
| + } |
| + next[name] = body |
| + } |
| + prev := m.snapshot() |
| + snap := buildCertSnapshot(prev, next) |
| + m.mu.Lock() |
| + m.snap = snap |
| + m.mu.Unlock() |
| + m.syncLocal(ctx, snap) |
| + if m.wake != nil && wildcardMaterialChanged(prev, snap) { |
| + // A takeover (or a first wildcard for a new account) reaches the |
| + // serving path in one poll instead of up to twelve hours. |
| + m.wake() |
| + } |
| + return nil |
| +} |
| + |
| +// buildCertSnapshot parses what changed and carries the rest of the previous |
| +// parse forward — a handshake never pays for x509 parsing, and a quiet |
| +// deployment pays for it once. |
| +func buildCertSnapshot(prev *certSnapshot, entries map[string][]byte) *certSnapshot { |
| + snap := &certSnapshot{entries: entries, certs: make(map[string]*tls.Certificate, len(entries))} |
| + for name, body := range entries { |
| + if !certShapedName(name) { |
| + continue |
| + } |
| + if _, seen := prev.entries[name]; seen && bytes.Equal(prev.entries[name], body) { |
| + // Unchanged. Carry the previous RESULT forward, failure included: |
| + // re-parsing identical bytes cannot answer differently, and |
| + // re-logging a corrupt entry every 30s forever is not "loud", it is |
| + // noise. |
| + if old, ok := prev.certs[name]; ok { |
| + snap.certs[name] = old |
| + } |
| + continue |
| + } |
| + cert, err := parseCertEntry(body) |
| + if err != nil { |
| + // Loud, and skipped rather than served: a body that does not parse |
| + // is a body no handshake can use, and the next poll re-reads it. |
| + log.Printf("certsync: mirrored entry %q does not parse as a certificate (%v) — skipping it", name, err) |
| + continue |
| + } |
| + snap.certs[name] = cert |
| + } |
| + return snap |
| +} |
| + |
| +func (m *certMirror) snapshot() *certSnapshot { |
| + m.mu.RLock() |
| + defer m.mu.RUnlock() |
| + return m.snap |
| +} |
| + |
| +// Cert returns the mirrored body for a cache name. Account keys are refused |
| +// here, not filtered at poll time, because the local sync legitimately needs |
| +// them: the refusal belongs at the SERVING door. |
| +func (m *certMirror) Cert(name string) ([]byte, bool) { |
| + if accountKeyName(name) { |
| + return nil, false |
| + } |
| + b, ok := m.snapshot().entries[name] |
| + return b, ok |
| +} |
| + |
| +// Challenge returns the HTTP-01 response body for token, if the mirror holds |
| +// it. The cache name is autocert's own (autocert.go:896, |
| +// httpTokenCacheKey): path.Base(<challenge path>) + "+http-01" — flat, with |
| +// no /.well-known/ prefix. |
| +func (m *certMirror) Challenge(token string) ([]byte, bool) { |
| + b, ok := m.snapshot().entries[token+httpTokenSuffix] |
| + return b, ok |
| +} |
| + |
| +const ( |
| + httpTokenSuffix = "+http-01" // autocert's HTTP-01 response entries |
| + alpnTokenSuffix = "+token" // autocert's tls-alpn-01 challenge certs |
| + // autocertAccountKeyName is autocert's own ACME account key entry |
| + // (autocert.go's accountKey cache name). wildcardAccountKeyName |
| + // (wildcard.go) is the DNS-01 manager's. |
| + autocertAccountKeyName = "acme_account+key" |
| +) |
| + |
| +// accountKeyName reports whether name is ACME ACCOUNT material — the issuer's |
| +// identity at the CA, never a serving artifact. |
| +func accountKeyName(name string) bool { |
| + return name == autocertAccountKeyName || name == wildcardAccountKeyName |
| +} |
| + |
| +// certShapedName reports whether an entry is expected to parse as a |
| +// certificate: everything except account keys and HTTP-01 response bodies. |
| +// That leaves per-host certs (<host>, <host>+rsa), wildcard PEMs |
| +// (wildcard-<parent>.pem) and tls-alpn-01 token certs (<host>+token). |
| +func certShapedName(name string) bool { |
| + return !accountKeyName(name) && !strings.HasSuffix(name, httpTokenSuffix) |
| +} |
| + |
| +// parseCertEntry parses an autocert cache entry — the private key PEM |
| +// followed by the chain, which is also wildcard.go's encodeWildcardPEM shape — |
| +// into a usable tls.Certificate. Both halves come out of the one blob, and |
| +// X509KeyPair is what checks that the key actually matches the leaf. |
| +func parseCertEntry(raw []byte) (*tls.Certificate, error) { |
| + cert, err := tls.X509KeyPair(raw, raw) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if cert.Leaf == nil { |
| + // X509KeyPair has populated Leaf since Go 1.23; belt and braces, since |
| + // every serving decision below reads it. |
| + leaf, err := x509.ParseCertificate(cert.Certificate[0]) |
| + if err != nil { |
| + return nil, err |
| + } |
| + cert.Leaf = leaf |
| + } |
| + return &cert, nil |
| +} |
| + |
| +// run polls until stop closes — the edge's standing background-loop shape |
| +// (Watch): fixed ticker, errors counted and logged at the first and then |
| +// every tenth, recovery logged once. |
| +func (m *certMirror) run(stop <-chan struct{}) { |
| + log.Printf("edge: cert mirror armed (%s poll of %s*) — this box serves every cert and challenge the issuer publishes", certMirrorInterval, certPrefix) |
| + tick := time.NewTicker(certMirrorInterval) |
| + defer tick.Stop() |
| + errs := 0 |
| + for { |
| + select { |
| + case <-stop: |
| + return |
| + case <-tick.C: |
| + } |
| + ctx, cancel := context.WithTimeout(context.Background(), certMirrorTimeout) |
| + err := m.Refresh(ctx) |
| + cancel() |
| + if err != nil { |
| + errs++ |
| + if errs == 1 || errs%10 == 0 { |
| + log.Printf("edge: cert mirror refresh failed (#%d): %v — serving the last good copy", errs, err) |
| + } |
| + continue |
| + } |
| + if errs > 0 { |
| + log.Printf("edge: cert mirror refresh recovered after %d failure(s)", errs) |
| + errs = 0 |
| + } |
| + } |
| +} |
| + |
| +// syncLocal copies mirrored material into this box's OWN autocert cache. It |
| +// is the quiet half of the mirror, and the one that matters when nothing is |
| +// looking: |
| +// |
| +// - autocert's renewal timers bypass HostPolicy and check the cache FIRST |
| +// (renewal.go's do()), so a stood-down box whose timers are still armed |
| +// finds a fresh cert here and never reaches the CA; |
| +// - a box that boots, or crashes and restarts, has no election result for |
| +// the first ~20 minutes; a warm local cache serves through that window |
| +// even before the mirror map is consulted; |
| +// - the wildcard manager loads parents from this same cache (loadCached), |
| +// which is how a wildcard the issuer minted reaches a non-issuer's |
| +// handshakes at all. |
| +// |
| +// Never written locally: challenge tokens of either kind. They are seconds |
| +// long, the issuer owns their deletion, and a copy on every box's disk is |
| +// litter nothing sweeps. |
| +// |
| +// Writes are best-effort and loud: a full disk must not stop the mirror from |
| +// SERVING what it already holds in memory. |
| +func (m *certMirror) syncLocal(ctx context.Context, snap *certSnapshot) { |
| + if m.local == nil { |
| + return |
| + } |
| + m.syncMu.Lock() |
| + defer m.syncMu.Unlock() |
| + var failed int |
| + var lastErr error |
| + for name, body := range snap.entries { |
| + if strings.HasSuffix(name, httpTokenSuffix) || strings.HasSuffix(name, alpnTokenSuffix) { |
| + continue |
| + } |
| + // Unchanged since the last reconcile: the local copy was already |
| + // settled then, and nothing but autocert itself (which only ever writes |
| + // fresher material) can have moved it since. Steady state costs no |
| + // local I/O at all. |
| + if prev, ok := m.synced[name]; ok && bytes.Equal(prev, body) { |
| + continue |
| + } |
| + switch err := m.reconcileLocal(ctx, name, body, snap.certs[name]); { |
| + case err == nil: |
| + m.synced[name] = body |
| + case errors.Is(err, errUnparseableEntry): |
| + // Already reported by the snapshot build; nothing to say twice. |
| + default: |
| + failed++ |
| + lastErr = err |
| + } |
| + } |
| + if failed > 0 { |
| + // One line per poll, not one per name: a local cache that has stopped |
| + // accepting writes fails for EVERY name at once, and the fault is the |
| + // cache, not the certificate. |
| + log.Printf("certsync: %d mirrored entry/entries could not be reconciled into this box's local cert cache (last: %v) — they are still served from memory", failed, lastErr) |
| + } |
| + // Forget names the bucket no longer holds, so the map tracks the prefix |
| + // rather than growing with every token this box has ever seen. |
| + for name := range m.synced { |
| + if _, ok := snap.entries[name]; !ok { |
| + delete(m.synced, name) |
| + } |
| + } |
| +} |
| + |
| +// errUnparseableEntry marks the one reconcile failure that is the ENTRY's |
| +// fault rather than the local cache's — already reported once by the snapshot |
| +// build, and not worth a second line. |
| +var errUnparseableEntry = errors.New("certsync: mirrored entry does not parse") |
| + |
| +// reconcileLocal writes one entry into the local cache when the local copy is |
| +// worse. A nil error means the local copy can be considered settled. |
| +func (m *certMirror) reconcileLocal(ctx context.Context, name string, body []byte, cert *tls.Certificate) error { |
| + local, err := m.local.Get(ctx, name) |
| + if err != nil && !errors.Is(err, autocert.ErrCacheMiss) { |
| + // An unreadable local cache is a real fault (permissions, disk). |
| + return fmt.Errorf("read %q: %w", name, err) |
| + } |
| + have := err == nil |
| + if accountKeyName(name) { |
| + // ACME ACCOUNT material. Seeded when absent, so that a box winning the |
| + // election later takes over the DEPLOYMENT's account — one account, one |
| + // rate-limit bucket, which is the envelope the issuance gate's |
| + // no-fencing decision is priced against. Never overwritten: a box that |
| + // already registered its own account has orders and authorizations tied |
| + // to that key, and replacing it under them buys nothing. |
| + if have { |
| + return nil |
| + } |
| + if err := m.local.Put(ctx, name, body); err != nil { |
| + return fmt.Errorf("seed the deployment's ACME account key %q: %w", name, err) |
| + } |
| + log.Printf("certsync: seeded the deployment's ACME account key (%s) into the local cache", name) |
| + return nil |
| + } |
| + if cert == nil { |
| + // Did not parse (already reported by buildCertSnapshot). Writing an |
| + // unparseable body over a working local cert would be a downgrade. |
| + return errUnparseableEntry |
| + } |
| + if have { |
| + if cur, err := parseCertEntry(local); err == nil && !cur.Leaf.NotAfter.Before(cert.Leaf.NotAfter) { |
| + // The local copy is at least as fresh — this box may BE the issuer, |
| + // one write ahead of what it has published. Leave it alone. |
| + return nil |
| + } |
| + } |
| + if err := m.local.Put(ctx, name, body); err != nil { |
| + return fmt.Errorf("write %q: %w", name, err) |
| + } |
| + return nil |
| +} |
| + |
| +// wildcardPEMName reports whether an entry is a wildcard cert PEM |
| +// (wildcard.go's wildcardPEMKey shape). |
| +func wildcardPEMName(name string) bool { |
| + return strings.HasPrefix(name, "wildcard-") && strings.HasSuffix(name, ".pem") |
| +} |
| + |
| +// wildcardMaterialChanged reports whether this poll brought a wildcard PEM |
| +// that is new or different. Only then is the wildcard manager nudged: its own |
| +// loop sleeps 12h between passes, so a takeover — or a first wildcard for a |
| +// new account, minted on the issuer — would otherwise wait that long to reach |
| +// this box's handshakes. A withdrawal is not a wake: there is nothing new to |
| +// load, and the manager keeps serving what it has until it expires. |
| +func wildcardMaterialChanged(prev, next *certSnapshot) bool { |
| + for name, body := range next.entries { |
| + if !wildcardPEMName(name) { |
| + continue |
| + } |
| + if !bytes.Equal(prev.entries[name], body) { |
| + return true |
| + } |
| + } |
| + return false |
| +} |
| + |
| +// ---- serving: the four-layer chain ------------------------------------------ |
| +// |
| +// wrapGetCertificate is the mirror's half of the handshake path. The FULL |
| +// chain a hello meets, outermost first (RunWithActivatorReady composes it): |
| +// |
| +// wildcard (wildcard.go) → mirror → local autocert → local cache, direct |
| +// |
| +// The order is forced by autocert's own shape, not by preference. autocert |
| +// runs HostPolicy — which carries the issuance gate — BEFORE its cache |
| +// lookup (autocert.go:292), so on a non-issuer autocert serves NOTHING: not |
| +// what the mirror handed it, not what it holds on disk itself. Putting the |
| +// mirror behind it would therefore serve nothing at all on every box but one. |
| +// |
| +// The last layer is the local cache read DIRECTLY, bypassing autocert, and it |
| +// is not redundant: it is what a box serves when the bucket is unreachable at |
| +// boot (empty mirror) but its own disk still holds yesterday's certs, and |
| +// what covers the ~20 minute window after a boot or a crash while the |
| +// housekeeper election is undecided. |
| +// |
| +// The short-circuit ahead of autocert applies while this box is GATED, and |
| +// only then. autocert arms its renewal timers LAZILY, out of GetCertificate |
| +// (m.cert -> startRenew, autocert.go:464) — a mirror that answered every |
| +// hello would leave an ISSUER with no renewal timers at all, and its certs |
| +// would expire ninety days later with nothing in the journal. An ungated box |
| +// loses nothing by going through autocert first: syncLocal has already put |
| +// every mirrored cert in the cache autocert reads, and the mirror stays |
| +// behind it as a fallback for anything autocert cannot serve. |
| +func (m *certMirror) wrapGetCertificate(inner func(*tls.ClientHelloInfo) (*tls.Certificate, error)) func(*tls.ClientHelloInfo) (*tls.Certificate, error) { |
| + return func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { |
| + name := strings.ToLower(strings.TrimSuffix(hello.ServerName, ".")) |
| + if len(hello.SupportedProtos) == 1 && hello.SupportedProtos[0] == acme.ALPNProto { |
| + // A tls-alpn-01 validation. autocert answers its OWN order from |
| + // memory; anything else is another edge's order, and the CA reached |
| + // this box because every edge answers the same names. |
| + cert, err := inner(hello) |
| + if err == nil { |
| + return cert, nil |
| + } |
| + if c := m.alpnTokenCert(helloContext(hello), name); c != nil { |
| + return c, nil |
| + } |
| + return nil, err |
| + } |
| + wantRSA := !helloSupportsECDSA(hello) |
| + if m.isGated() { |
| + if c := m.certificate(name, wantRSA); c != nil { |
| + return c, nil |
| + } |
| + } |
| + cert, err := inner(hello) |
| + if err == nil { |
| + return cert, nil |
| + } |
| + // Ungated and autocert could not answer (an order that failed, a name |
| + // it will not order for): the mirror is still the better answer than a |
| + // dropped handshake. Gated, this repeat is a cheap map miss. |
| + if c := m.certificate(name, wantRSA); c != nil { |
| + return c, nil |
| + } |
| + if c := m.localCertificate(helloContext(hello), name, wantRSA); c != nil { |
| + return c, nil |
| + } |
| + return nil, err |
| + } |
| +} |
| + |
| +// isGated reads the issuance gate, defaulting to "gated" (see the field). |
| +func (m *certMirror) isGated() bool { |
| + return m.gated == nil || m.gated() |
| +} |
| + |
| +// helloContext is the handshake's context, or a background one for the |
| +// hand-built hellos tests use. |
| +func helloContext(hello *tls.ClientHelloInfo) context.Context { |
| + if ctx := hello.Context(); ctx != nil { |
| + return ctx |
| + } |
| + return context.Background() |
| +} |
| + |
| +// certificate returns a mirrored cert for name, or nil. The two cache names |
| +// are autocert's own ("<host>" for ECDSA, "<host>+rsa" for legacy clients); |
| +// the preferred one is tried first and the other accepted rather than |
| +// dropping the handshake, since on a non-issuer no order can produce the |
| +// missing type. |
| +func (m *certMirror) certificate(name string, wantRSA bool) *tls.Certificate { |
| + if name == "" { |
| + return nil |
| + } |
| + snap := m.snapshot() |
| + for _, key := range certEntryNames(name, wantRSA) { |
| + if c := snap.certs[key]; usableFor(c, name) { |
| + return c |
| + } |
| + } |
| + return nil |
| +} |
| + |
| +// certEntryNames lists the cache names that can hold a cert for host, most |
| +// preferred first. |
| +func certEntryNames(host string, wantRSA bool) [2]string { |
| + if wantRSA { |
| + return [2]string{host + "+rsa", host} |
| + } |
| + return [2]string{host, host + "+rsa"} |
| +} |
| + |
| +// usableFor reports whether cert can be served for name right now. An expired |
| +// or wrong-named entry is treated as absent so the chain keeps falling |
| +// through — a dead cert fails the handshake just as surely as no cert, and |
| +// the next layer may have a live one. |
| +func usableFor(cert *tls.Certificate, name string) bool { |
| + return cert != nil && cert.Leaf != nil && |
| + time.Now().Before(cert.Leaf.NotAfter) && |
| + cert.Leaf.VerifyHostname(name) == nil |
| +} |
| + |
| +// helloSupportsECDSA is autocert's own supportsECDSA (autocert.go), ported |
| +// because it is unexported there and the mirror must look under the SAME |
| +// cache name autocert would have chosen for this client. |
| +func helloSupportsECDSA(hello *tls.ClientHelloInfo) bool { |
| + if hello.SignatureSchemes != nil { |
| + ecdsaOK := false |
| + schemeLoop: |
| + for _, scheme := range hello.SignatureSchemes { |
| + const tlsECDSAWithSHA1 tls.SignatureScheme = 0x0203 |
| + switch scheme { |
| + case tlsECDSAWithSHA1, tls.ECDSAWithP256AndSHA256, |
| + tls.ECDSAWithP384AndSHA384, tls.ECDSAWithP521AndSHA512: |
| + ecdsaOK = true |
| + break schemeLoop |
| + } |
| + } |
| + if !ecdsaOK { |
| + return false |
| + } |
| + } |
| + if hello.SupportedCurves != nil { |
| + ecdsaOK := false |
| + for _, curve := range hello.SupportedCurves { |
| + if curve == tls.CurveP256 { |
| + ecdsaOK = true |
| + break |
| + } |
| + } |
| + if !ecdsaOK { |
| + return false |
| + } |
| + } |
| + for _, suite := range hello.CipherSuites { |
| + switch suite { |
| + case tls.TLS_ECDHE_ECDSA_WITH_RC4_128_SHA, |
| + tls.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA, |
| + tls.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA, |
| + tls.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256, |
| + tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, |
| + tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, |
| + tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305: |
| + return true |
| + } |
| + } |
| + return false |
| +} |
| + |
| +// localCertTTL bounds how long a last-resort read is remembered. Short |
| +// enough that a renewal reaching the local cache is picked up promptly, long |
| +// enough that a flood of handshakes for one name is not a file read each. |
| +const localCertTTL = 30 * time.Second |
| + |
| +type localCertEntry struct { |
| + cert *tls.Certificate |
| + at time.Time |
| +} |
| + |
| +// localCertificate is the last layer: this box's own autocert cache, read |
| +// DIRECTLY, because a gated autocert.Manager refuses the name before it ever |
| +// looks there. Both outcomes are remembered briefly so a busy name costs one |
| +// file read per TTL rather than one per handshake. |
| +func (m *certMirror) localCertificate(ctx context.Context, name string, wantRSA bool) *tls.Certificate { |
| + if m.local == nil || name == "" { |
| + return nil |
| + } |
| + for _, key := range certEntryNames(name, wantRSA) { |
| + m.localMu.Lock() |
| + e, ok := m.localCerts[key] |
| + fresh := ok && time.Since(e.at) < localCertTTL |
| + m.localMu.Unlock() |
| + if fresh { |
| + if usableFor(e.cert, name) { |
| + return e.cert |
| + } |
| + continue |
| + } |
| + var cert *tls.Certificate |
| + if raw, err := m.local.Get(ctx, key); err == nil { |
| + if c, err := parseCertEntry(raw); err == nil { |
| + cert = c |
| + } |
| + } |
| + m.localMu.Lock() |
| + if len(m.localCerts) > readThroughCacheMax { |
| + // Crude but bounded: the map is a cache, and every entry is |
| + // re-readable from disk. |
| + clear(m.localCerts) |
| + } |
| + m.localCerts[key] = localCertEntry{cert: cert, at: time.Now()} |
| + m.localMu.Unlock() |
| + if usableFor(cert, name) { |
| + // Worth saying: the mirror had nothing for a name this box is |
| + // serving anyway. Once a minute for the whole box, not once per |
| + // name — every host on a box whose bucket is unreachable lands here |
| + // at once, and one line is enough to know it. |
| + m.localMu.Lock() |
| + quiet := time.Since(m.lastLocalLog) < time.Minute |
| + if !quiet { |
| + m.lastLocalLog = time.Now() |
| + } |
| + m.localMu.Unlock() |
| + if !quiet { |
| + log.Printf("edge: serving %s from this box's local cert cache — the mirror has no copy and issuance is gated here", name) |
| + } |
| + return cert |
| + } |
| + } |
| + return nil |
| +} |
| + |
| +// ---- the :80 challenge path ------------------------------------------------- |
| + |
| +const acmeChallengePrefix = "/.well-known/acme-challenge/" |
| + |
| +// challengeHandler answers HTTP-01 challenges from the mirror BEFORE |
| +// autocert's own handler sees them, and delegates everything else. The order |
| +// is forced, again, by autocert: HTTPHandler runs HostPolicy first |
| +// (autocert.go:405), so on a non-issuer it 403s a challenge whose answer is |
| +// sitting in the mirror. Behind this, autocert still answers its OWN live |
| +// orders from memory, which is what the issuer needs. |
| +func (m *certMirror) challengeHandler(next http.Handler) http.Handler { |
| + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + if !strings.HasPrefix(r.URL.Path, acmeChallengePrefix) { |
| + next.ServeHTTP(w, r) |
| + return |
| + } |
| + // path.Base is the token autocert itself would derive |
| + // (httpTokenCacheKey), so both handlers agree on the name. |
| + if body, ok := m.challengeResponse(r.Context(), path.Base(r.URL.Path)); ok { |
| + w.Header().Set("Content-Type", "text/plain; charset=utf-8") |
| + _, _ = w.Write(body) |
| + return |
| + } |
| + next.ServeHTTP(w, r) |
| + }) |
| +} |
| + |
| +// challengeResponse answers from the polled map, then — and this is the part |
| +// the 30s poll cannot do — reads through to the bucket. A validation's whole |
| +// life is a few seconds: the issuer writes the token, sends Accept, and the |
| +// CA arrives at whichever edge DNS handed it. A poll that runs every 30s |
| +// misses that window every time. |
| +func (m *certMirror) challengeResponse(ctx context.Context, token string) ([]byte, bool) { |
| + if body, ok := m.Challenge(token); ok { |
| + return body, true |
| + } |
| + if !validChallengeToken(token) { |
| + return nil, false |
| + } |
| + body, _, ok := m.readThrough(ctx, token+httpTokenSuffix) |
| + return body, ok |
| +} |
| + |
| +// validChallengeToken bounds what an unauthenticated URL path may turn into a |
| +// bucket read: ACME tokens are base64url of at least 128 bits of entropy, so |
| +// nothing shorter or otherwise shaped can be one. It also keeps the key |
| +// inside the flat prefix (publishableCertName's concern, from the other |
| +// side). |
| +func validChallengeToken(token string) bool { |
| + if len(token) < 16 || len(token) > 128 { |
| + return false |
| + } |
| + for _, r := range token { |
| + switch { |
| + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_': |
| + default: |
| + return false |
| + } |
| + } |
| + return true |
| +} |
| + |
| +// alpnTokenCert answers a tls-alpn-01 validation for an order some OTHER edge |
| +// is running. autocert prefers tls-alpn-01 over http-01 and only falls back |
| +// to it after a whole failed order (autocert.go's AuthorizeOrderLoop), so |
| +// without this every issuance in an N-edge deployment burns a failed |
| +// validation with probability (N-1)/N before it ever tries the HTTP path. |
| +// |
| +// Bounded harder than the HTTP-01 path, because the name here is |
| +// attacker-chosen SNI rather than a high-entropy token: only hosts this box |
| +// actually serves are ever looked up. |
| +func (m *certMirror) alpnTokenCert(ctx context.Context, host string) *tls.Certificate { |
| + if host == "" { |
| + return nil |
| + } |
| + key := host + alpnTokenSuffix |
| + if c := m.snapshot().certs[key]; usableFor(c, host) { |
| + return c |
| + } |
| + if m.allowed != nil && !m.allowed(host) { |
| + return nil |
| + } |
| + _, cert, _ := m.readThrough(ctx, key) |
| + if usableFor(cert, host) { |
| + return cert |
| + } |
| + return nil |
| +} |
| + |
| +// ---- read-through: the bounded live lookup ---------------------------------- |
| + |
| +const ( |
| + // readThroughTimeout budgets one live bucket read. It runs inside a |
| + // handshake or a :80 request, so it is short by construction. |
| + readThroughTimeout = 2 * time.Second |
| + // readThroughHitTTL is about one poll: after that the poll itself holds |
| + // the entry (or it has been withdrawn, which is also the answer). |
| + readThroughHitTTL = 30 * time.Second |
| + // readThroughMissTTL keeps a scanner walking the challenge path from |
| + // turning into a bucket GET per request. |
| + readThroughMissTTL = 5 * time.Second |
| + // readThroughCacheMax bounds the memo; over it, the map is dropped whole. |
| + readThroughCacheMax = 512 |
| + // readThroughBurst / readThroughRefill are the global budget: distinct |
| + // names defeat the negative cache, so the last bound is a rate. Under a |
| + // flood the read-through degrades to "off" — the 30s poll still covers |
| + // everything that is not a live validation. |
| + readThroughBurst = 20 |
| + readThroughRefill = 5 // per second |
| +) |
| + |
| +type readThroughEntry struct { |
| + body []byte |
| + cert *tls.Certificate |
| + ok bool |
| + at time.Time |
| +} |
| + |
| +func (e readThroughEntry) fresh() bool { |
| + ttl := readThroughMissTTL |
| + if e.ok { |
| + ttl = readThroughHitTTL |
| + } |
| + return time.Since(e.at) < ttl |
| +} |
| + |
| +// readThrough fetches one control/certs/ entry outside the poll cycle: memoed |
| +// (hits and misses), single-flighted per name, rate-limited globally, and |
| +// budgeted per call. Errors are answers here, not faults — a bucket that is |
| +// down simply means this box cannot help with someone else's live |
| +// validation, and the CA retries. |
| +func (m *certMirror) readThrough(ctx context.Context, name string) ([]byte, *tls.Certificate, bool) { |
| + m.rtMu.Lock() |
| + if e, ok := m.rt[name]; ok && e.fresh() { |
| + m.rtMu.Unlock() |
| + return e.body, e.cert, e.ok |
| + } |
| + if done, ok := m.rtInflight[name]; ok { |
| + // Somebody is already asking. The CA opens several connections for one |
| + // validation; they share the one bucket read. |
| + m.rtMu.Unlock() |
| + select { |
| + case <-done: |
| + case <-ctx.Done(): |
| + return nil, nil, false |
| + } |
| + m.rtMu.Lock() |
| + e := m.rt[name] |
| + m.rtMu.Unlock() |
| + return e.body, e.cert, e.ok |
| + } |
| + if !m.allowFetchLocked() { |
| + m.rtMu.Unlock() |
| + return nil, nil, false |
| + } |
| + done := make(chan struct{}) |
| + m.rtInflight[name] = done |
| + m.rtMu.Unlock() |
| + |
| + // Detached from the caller: a client that hangs up mid-validation must not |
| + // cancel the read every other in-flight waiter is sharing. |
| + fetchCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), readThroughTimeout) |
| + body, _, err := m.s.Get(fetchCtx, certKey(name)) |
| + cancel() |
| + e := readThroughEntry{at: time.Now()} |
| + switch { |
| + case err == nil: |
| + e.body, e.ok = body, true |
| + if certShapedName(name) { |
| + if cert, perr := parseCertEntry(body); perr == nil { |
| + e.cert = cert |
| + } else { |
| + log.Printf("certsync: live read of %q does not parse as a certificate: %v", name, perr) |
| + } |
| + } |
| + case errors.Is(err, os.ErrNotExist): |
| + // The ordinary case: no such token. Negative-cached, not logged. |
| + default: |
| + m.logReadThroughError(name, err) |
| + } |
| + m.rtMu.Lock() |
| + if len(m.rt) > readThroughCacheMax { |
| + clear(m.rt) |
| + } |
| + m.rt[name] = e |
| + delete(m.rtInflight, name) |
| + m.rtMu.Unlock() |
| + close(done) |
| + return e.body, e.cert, e.ok |
| +} |
| + |
| +// logReadThroughError says once a minute that live lookups are failing. Not |
| +// per call: the rate limiter still allows several a second, and a bucket |
| +// outage is one fault however many validations hit it. |
| +func (m *certMirror) logReadThroughError(name string, err error) { |
| + m.rtMu.Lock() |
| + quiet := time.Since(m.lastRTLog) < time.Minute |
| + if !quiet { |
| + m.lastRTLog = time.Now() |
| + } |
| + m.rtMu.Unlock() |
| + if !quiet { |
| + log.Printf("certsync: live read of %q failed: %v — the %s poll still covers everything but live validations", name, err, certMirrorInterval) |
| + } |
| +} |
| + |
| +// allowFetchLocked spends one token from the read-through budget. Caller |
| +// holds rtMu. |
| +func (m *certMirror) allowFetchLocked() bool { |
| + now := time.Now() |
| + if m.rtLast.IsZero() { |
| + m.rtTokens = readThroughBurst |
| + } else { |
| + m.rtTokens = min(readThroughBurst, m.rtTokens+now.Sub(m.rtLast).Seconds()*readThroughRefill) |
| + } |
| + m.rtLast = now |
| + if m.rtTokens < 1 { |
| + return false |
| + } |
| + m.rtTokens-- |
| + return true |
| +} |
diff --git a/internal/edge/certsync_test.go b/internal/edge/certsync_test.go| index 42cbff42b..34f0d8946 100644 |
| --- a/internal/edge/certsync_test.go |
| +++ b/internal/edge/certsync_test.go |
| @@ -5,8 +5,10 @@ import ( |
| "context" |
| "crypto/tls" |
| "errors" |
| + "fmt" |
| "io" |
| "log" |
| + "net/http" |
| "net/http/httptest" |
| "os" |
| "strings" |
| @@ -527,3 +529,900 @@ func TestWildcardUngatedOrdersAsToday(t *testing.T) { |
| t.Fatal("with no gate configured (carlos edge, single box) issuance must run exactly as today") |
| } |
| } |
| + |
| +// ---- the mirror (Task 5) ---------------------------------------------------- |
| + |
| +// hostCertPEM mints a cert for host in autocert's own cache-entry shape (the |
| +// private key PEM, then the chain) — byte-for-byte what the issuer publishes |
| +// to control/certs/<host>. |
| +func hostCertPEM(t *testing.T, host string, notAfter time.Time) []byte { |
| + t.Helper() |
| + cert, key := selfSignedLeaf(t, host, notAfter) |
| + raw, err := encodeWildcardPEM(cert, key) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return raw |
| +} |
| + |
| +// putBucket writes one control/certs/ entry the way the issuer's |
| +// publishingCache would. |
| +func putBucket(t *testing.T, s release.Store, name string, body []byte) { |
| + t.Helper() |
| + if err := putCert(context.Background(), s, name, body); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func TestCertMirrorServesBucketCert(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + putBucket(t, s, "example.com", []byte("PEM")) |
| + m := newCertMirror(s, nil) |
| + if err := m.Refresh(context.Background()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if b, ok := m.Cert("example.com"); !ok || string(b) != "PEM" { |
| + t.Fatalf("mirror cert = %q, %v", b, ok) |
| + } |
| + if _, ok := m.Cert("nobody.example"); ok { |
| + t.Fatal("a name the bucket does not hold must miss") |
| + } |
| +} |
| + |
| +// The HTTP-01 cache key is autocert's, read from the vendored source |
| +// (autocert.go:896): path.Base(tokenPath) + "+http-01" — a FLAT name, no |
| +// /.well-known/ prefix (publishableCertName would refuse one anyway). Task 7 |
| +// fails loudly if this drifts. |
| +func TestCertMirrorServesChallengeTokens(t *testing.T) { |
| + const token = "0123456789abcdefghij0123456789abcdefghijkl" |
| + s := release.NewDirStore(t.TempDir()) |
| + putBucket(t, s, token+"+http-01", []byte("resp")) |
| + m := newCertMirror(s, nil) |
| + if err := m.Refresh(context.Background()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if b, ok := m.Challenge(token); !ok || string(b) != "resp" { |
| + t.Fatalf("challenge = %q, %v", b, ok) |
| + } |
| +} |
| + |
| +// Account keys are ISSUER material: they must never enter the serving map, |
| +// where a name lookup could hand one to a handshake. |
| +func TestCertMirrorKeepsAccountKeysOutOfTheServingMap(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + putBucket(t, s, "acme_account+key", []byte("KEY")) |
| + putBucket(t, s, wildcardAccountKeyName, []byte("KEY")) |
| + putBucket(t, s, "example.com", []byte("PEM")) |
| + m := newCertMirror(s, nil) |
| + if err := m.Refresh(context.Background()); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, name := range []string{"acme_account+key", wildcardAccountKeyName} { |
| + if _, ok := m.Cert(name); ok { |
| + t.Fatalf("%s is issuer material and must not be servable", name) |
| + } |
| + } |
| + if _, ok := m.Cert("example.com"); !ok { |
| + t.Fatal("ordinary cert material must still be served") |
| + } |
| +} |
| + |
| +// listFailingStore fails List (and optionally Get) on demand — a bucket blip |
| +// mid-poll. |
| +type listFailingStore struct { |
| + release.Store |
| + mu sync.Mutex |
| + fail bool |
| +} |
| + |
| +func (l *listFailingStore) setFail(v bool) { |
| + l.mu.Lock() |
| + l.fail = v |
| + l.mu.Unlock() |
| +} |
| + |
| +func (l *listFailingStore) failing() bool { |
| + l.mu.Lock() |
| + defer l.mu.Unlock() |
| + return l.fail |
| +} |
| + |
| +func (l *listFailingStore) List(ctx context.Context, prefix string) ([]string, error) { |
| + if l.failing() { |
| + return nil, errors.New("bucket down") |
| + } |
| + return l.Store.List(ctx, prefix) |
| +} |
| + |
| +func (l *listFailingStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| + if l.failing() { |
| + return nil, "", errors.New("bucket down") |
| + } |
| + return l.Store.Get(ctx, key) |
| +} |
| + |
| +// Degrade, don't die: a failed poll keeps the last-good map. The alternative |
| +// (an empty map) turns a bucket blip into a TLS outage on every non-issuer. |
| +func TestCertMirrorRefreshErrorKeepsLastGood(t *testing.T) { |
| + s := &listFailingStore{Store: release.NewDirStore(t.TempDir())} |
| + putBucket(t, s.Store, "example.com", []byte("PEM")) |
| + m := newCertMirror(s, nil) |
| + if err := m.Refresh(context.Background()); err != nil { |
| + t.Fatal(err) |
| + } |
| + s.setFail(true) |
| + if err := m.Refresh(context.Background()); err == nil { |
| + t.Fatal("a failed poll must be reported to the loop, which logs it") |
| + } |
| + if b, ok := m.Cert("example.com"); !ok || string(b) != "PEM" { |
| + t.Fatalf("after a failed refresh the mirror = %q, %v — want the last-good copy", b, ok) |
| + } |
| +} |
| + |
| +// A withdrawn entry (a spent challenge token the issuer deleted) leaves the |
| +// map on the next good poll. |
| +func TestCertMirrorRefreshDropsWithdrawnEntries(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + putBucket(t, s, "example.com", []byte("PEM")) |
| + m := newCertMirror(s, nil) |
| + if err := m.Refresh(context.Background()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := s.Delete(context.Background(), certKey("example.com")); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := m.Refresh(context.Background()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, ok := m.Cert("example.com"); ok { |
| + t.Fatal("a deleted bucket entry must leave the mirror") |
| + } |
| +} |
| + |
| +// ---- R3: the mirror feeds this box's own autocert cache --------------------- |
| + |
| +// localCert reads a name back out of a local autocert cache. |
| +func localCert(t *testing.T, c autocert.Cache, name string) ([]byte, error) { |
| + t.Helper() |
| + return c.Get(context.Background(), name) |
| +} |
| + |
| +// autocert's renewal timers never consult HostPolicy, and renewal.go's do() |
| +// checks the CACHE first — so a mirror-fed local cache is what stops a |
| +// stood-down box's stale timers from ordering. It also carries a box through |
| +// the boot window before the election has been decided. |
| +func TestCertMirrorWritesFreshCertsIntoTheLocalCache(t *testing.T) { |
| + const host = "app.bab.oncarlos.com" |
| + s := release.NewDirStore(t.TempDir()) |
| + fresh := hostCertPEM(t, host, time.Now().Add(80*24*time.Hour)) |
| + putBucket(t, s, host, fresh) |
| + putBucket(t, s, "wildcard-bab.oncarlos.com.pem", hostCertPEM(t, "*.bab.oncarlos.com", time.Now().Add(80*24*time.Hour))) |
| + |
| + local := autocert.DirCache(t.TempDir()) |
| + m := newCertMirror(s, local) |
| + if err := m.Refresh(context.Background()); err != nil { |
| + t.Fatal(err) |
| + } |
| + got, err := localCert(t, local, host) |
| + if err != nil || !bytes.Equal(got, fresh) { |
| + t.Fatalf("local cache after refresh = %d byte(s), %v — want the mirrored cert", len(got), err) |
| + } |
| + // The wildcard PEM lands under wildcard.go's own cache name, which is what |
| + // the wildcard manager's loadCached reads. |
| + if _, err := localCert(t, local, "wildcard-bab.oncarlos.com.pem"); err != nil { |
| + t.Fatalf("wildcard PEM did not reach the local cache: %v", err) |
| + } |
| +} |
| + |
| +// A renewal in the bucket replaces an older local copy; a local copy that is |
| +// already newer is left alone (this box may be the issuer, one poll ahead of |
| +// what it published). |
| +func TestCertMirrorLocalWriteRespectsFreshness(t *testing.T) { |
| + const host = "app.bab.oncarlos.com" |
| + s := release.NewDirStore(t.TempDir()) |
| + local := autocert.DirCache(t.TempDir()) |
| + |
| + old := hostCertPEM(t, host, time.Now().Add(10*24*time.Hour)) |
| + newer := hostCertPEM(t, host, time.Now().Add(80*24*time.Hour)) |
| + |
| + // Local holds the OLD one; the bucket holds a renewal. |
| + if err := local.Put(context.Background(), host, old); err != nil { |
| + t.Fatal(err) |
| + } |
| + putBucket(t, s, host, newer) |
| + m := newCertMirror(s, local) |
| + if err := m.Refresh(context.Background()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got, _ := localCert(t, local, host); !bytes.Equal(got, newer) { |
| + t.Fatal("a renewal in the bucket must replace the older local copy") |
| + } |
| + |
| + // Now the other way round: local is fresher than the bucket. |
| + local2 := autocert.DirCache(t.TempDir()) |
| + if err := local2.Put(context.Background(), host, newer); err != nil { |
| + t.Fatal(err) |
| + } |
| + s2 := release.NewDirStore(t.TempDir()) |
| + putBucket(t, s2, host, old) |
| + m2 := newCertMirror(s2, local2) |
| + if err := m2.Refresh(context.Background()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got, _ := localCert(t, local2, host); !bytes.Equal(got, newer) { |
| + t.Fatal("a local cert that is already fresher must not be downgraded by the mirror") |
| + } |
| +} |
| + |
| +// Account keys reach the local cache for issuer-takeover warmth (one ACME |
| +// account per deployment, one rate-limit bucket) — but only when this box has |
| +// none: clobbering a registered account key is never an improvement. |
| +func TestCertMirrorSeedsAccountKeysOnlyWhenAbsent(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + putBucket(t, s, "acme_account+key", []byte("BUCKET-KEY")) |
| + putBucket(t, s, wildcardAccountKeyName, []byte("BUCKET-WILDCARD-KEY")) |
| + |
| + local := autocert.DirCache(t.TempDir()) |
| + m := newCertMirror(s, local) |
| + if err := m.Refresh(context.Background()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got, err := localCert(t, local, "acme_account+key"); err != nil || string(got) != "BUCKET-KEY" { |
| + t.Fatalf("account key not seeded: %q, %v", got, err) |
| + } |
| + |
| + local2 := autocert.DirCache(t.TempDir()) |
| + if err := local2.Put(context.Background(), "acme_account+key", []byte("MINE")); err != nil { |
| + t.Fatal(err) |
| + } |
| + m2 := newCertMirror(s, local2) |
| + if err := m2.Refresh(context.Background()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got, _ := localCert(t, local2, "acme_account+key"); string(got) != "MINE" { |
| + t.Fatalf("this box's own account key was clobbered: %q", got) |
| + } |
| +} |
| + |
| +// Challenge tokens are ephemeral and the issuer owns their deletion: copying |
| +// them onto every box's disk would litter caches nothing ever sweeps. |
| +func TestCertMirrorNeverWritesChallengeTokensLocally(t *testing.T) { |
| + const token = "0123456789abcdefghij0123456789abcdefghijkl" |
| + s := release.NewDirStore(t.TempDir()) |
| + putBucket(t, s, token+"+http-01", []byte("resp")) |
| + local := autocert.DirCache(t.TempDir()) |
| + m := newCertMirror(s, local) |
| + if err := m.Refresh(context.Background()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := localCert(t, local, token+"+http-01"); !errors.Is(err, autocert.ErrCacheMiss) { |
| + t.Fatalf("a challenge token must not land in the local cache: %v", err) |
| + } |
| +} |
| + |
| +// A steady state costs no local I/O: an unchanged entry is not rewritten. |
| +func TestCertMirrorSkipsUnchangedLocalWrites(t *testing.T) { |
| + const host = "app.bab.oncarlos.com" |
| + s := release.NewDirStore(t.TempDir()) |
| + putBucket(t, s, host, hostCertPEM(t, host, time.Now().Add(80*24*time.Hour))) |
| + local := &countingWriteCache{Cache: autocert.DirCache(t.TempDir())} |
| + m := newCertMirror(s, local) |
| + for range 3 { |
| + if err := m.Refresh(context.Background()); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if n := local.puts(); n != 1 { |
| + t.Fatalf("local writes = %d, want exactly 1 — an unchanged mirror must not rewrite the cache", n) |
| + } |
| +} |
| + |
| +// countingWriteCache counts Puts. |
| +type countingWriteCache struct { |
| + autocert.Cache |
| + mu sync.Mutex |
| + n int |
| +} |
| + |
| +func (c *countingWriteCache) Put(ctx context.Context, key string, data []byte) error { |
| + c.mu.Lock() |
| + c.n++ |
| + c.mu.Unlock() |
| + return c.Cache.Put(ctx, key, data) |
| +} |
| + |
| +func (c *countingWriteCache) puts() int { |
| + c.mu.Lock() |
| + defer c.mu.Unlock() |
| + return c.n |
| +} |
| + |
| +// ---- R4: a wildcard takeover propagates on the poll ------------------------- |
| + |
| +func TestCertMirrorWakesTheWildcardManagerOnChangedWildcardMaterial(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + putBucket(t, s, "app.bab.oncarlos.com", hostCertPEM(t, "app.bab.oncarlos.com", time.Now().Add(80*24*time.Hour))) |
| + local := autocert.DirCache(t.TempDir()) |
| + m := newCertMirror(s, local) |
| + var wakes int |
| + m.wake = func() { wakes++ } |
| + |
| + if err := m.Refresh(context.Background()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if wakes != 0 { |
| + t.Fatalf("a per-host cert is not wildcard material: %d wake(s)", wakes) |
| + } |
| + |
| + putBucket(t, s, "wildcard-bab.oncarlos.com.pem", hostCertPEM(t, "*.bab.oncarlos.com", time.Now().Add(80*24*time.Hour))) |
| + if err := m.Refresh(context.Background()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if wakes != 1 { |
| + t.Fatalf("a new wildcard must wake the manager: %d wake(s)", wakes) |
| + } |
| + if err := m.Refresh(context.Background()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if wakes != 1 { |
| + t.Fatalf("an unchanged poll must not wake the manager: %d wake(s)", wakes) |
| + } |
| + putBucket(t, s, "wildcard-bab.oncarlos.com.pem", hostCertPEM(t, "*.bab.oncarlos.com", time.Now().Add(85*24*time.Hour))) |
| + if err := m.Refresh(context.Background()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if wakes != 2 { |
| + t.Fatalf("a renewed wildcard must wake the manager: %d wake(s)", wakes) |
| + } |
| +} |
| + |
| +// ---- R1: the serving chain -------------------------------------------------- |
| + |
| +const mirrorHost = "app.bab.oncarlos.com" |
| + |
| +// gatedChain builds what RunWithActivatorReady builds for a NON-ISSUER: a |
| +// gated autocert manager with m in front of it and autocert's own cache |
| +// (counted, so "did autocert serve this?" is observable) behind it. |
| +// |
| +// The mirror is passed in rather than built here so that a test can give it |
| +// NO local cache: the mirror layer and the last-resort local-cache layer both |
| +// serve the same bytes, and a test that means to pin one must not be able to |
| +// pass on the other. |
| +func gatedChain(t *testing.T, m *certMirror, local autocert.Cache, hosts ...string) (func(*tls.ClientHelloInfo) (*tls.Certificate, error), *countingCache) { |
| + t.Helper() |
| + e := testIssuerEdge(hosts...) |
| + e.issuer = func() (context.Context, bool) { return nil, false } // never the issuer |
| + counting := &countingCache{Cache: local, gets: map[string]int{}} |
| + mgr := offlineManager(t, counting, e.issuerHostPolicy()) |
| + return m.wrapGetCertificate(mgr.GetCertificate), counting |
| +} |
| + |
| +// The whole point of Task 5: a gated box serves the issuer's cert, and does |
| +// so BEFORE autocert — which would refuse the name outright. |
| +func TestMirrorServesTheGatedNonIssuersHandshake(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + putBucket(t, s, mirrorHost, hostCertPEM(t, mirrorHost, time.Now().Add(80*24*time.Hour))) |
| + // No local cache on the mirror: only the mirror layer itself can answer. |
| + m := newCertMirror(s, nil) |
| + get, counting := gatedChain(t, m, autocert.DirCache(t.TempDir()), mirrorHost) |
| + if err := m.Refresh(context.Background()); err != nil { |
| + t.Fatal(err) |
| + } |
| + got, err := get(ecdsaHello(mirrorHost)) |
| + if err != nil { |
| + t.Fatalf("a non-issuer must serve the mirrored cert: %v", err) |
| + } |
| + if got.Leaf == nil || got.Leaf.VerifyHostname(mirrorHost) != nil { |
| + t.Fatalf("served cert does not cover %s", mirrorHost) |
| + } |
| + if n := counting.count(mirrorHost); n != 0 { |
| + t.Fatalf("autocert was consulted %d time(s) — a valid mirror cert must short-circuit before it", n) |
| + } |
| +} |
| + |
| +// Each layer's miss falls through cleanly. With no gate (a single-box |
| +// deployment) and an empty mirror, autocert serves from its own cache exactly |
| +// as it does today. |
| +func TestMirrorFallsThroughToAutocert(t *testing.T) { |
| + local := autocert.DirCache(t.TempDir()) |
| + if err := local.Put(context.Background(), mirrorHost, hostCertPEM(t, mirrorHost, time.Now().Add(80*24*time.Hour))); err != nil { |
| + t.Fatal(err) |
| + } |
| + e := testIssuerEdge(mirrorHost) // ungated |
| + m := newCertMirror(release.NewDirStore(t.TempDir()), local) |
| + get := m.wrapGetCertificate(offlineManager(t, local, e.issuerHostPolicy()).GetCertificate) |
| + if _, err := get(ecdsaHello(mirrorHost)); err != nil { |
| + t.Fatalf("an empty mirror must fall through to autocert: %v", err) |
| + } |
| +} |
| + |
| +// Last resort: gated autocert AND an empty mirror (a bucket outage across a |
| +// restart), but the cert is on this box's disk from before. autocert refuses |
| +// it — HostPolicy runs before its cache lookup — so the chain reads the |
| +// DirCache directly rather than dropping the handshake. |
| +func TestMirrorLastResortServesTheLocalCacheEvenWhenGated(t *testing.T) { |
| + local := autocert.DirCache(t.TempDir()) |
| + if err := local.Put(context.Background(), mirrorHost, hostCertPEM(t, mirrorHost, time.Now().Add(80*24*time.Hour))); err != nil { |
| + t.Fatal(err) |
| + } |
| + m := newCertMirror(release.NewDirStore(t.TempDir()), local) |
| + get, _ := gatedChain(t, m, local, mirrorHost) |
| + got, err := get(ecdsaHello(mirrorHost)) |
| + if err != nil { |
| + t.Fatalf("the local cache is the last resort, and it has the cert: %v", err) |
| + } |
| + if got.Leaf.VerifyHostname(mirrorHost) != nil { |
| + t.Fatal("served cert does not cover the host") |
| + } |
| +} |
| + |
| +// Nothing anywhere: the error the caller sees is autocert's own, so the |
| +// journal still says WHY (not-issuer, or unknown host) rather than blaming |
| +// the mirror. |
| +func TestMirrorChainKeepsAutocertsError(t *testing.T) { |
| + get, _ := gatedChain(t, newCertMirror(release.NewDirStore(t.TempDir()), nil), autocert.DirCache(t.TempDir()), mirrorHost) |
| + _, err := get(ecdsaHello(mirrorHost)) |
| + if !errors.Is(err, errNotIssuer) { |
| + t.Fatalf("chain error = %v, want errNotIssuer", err) |
| + } |
| +} |
| + |
| +// A mirrored entry that is expired, or does not cover the name asked for, is |
| +// not served: the chain falls through instead. Serving a dead cert is worse |
| +// than serving none — the handshake fails either way, but a fall-through can |
| +// still find a good one. |
| +func TestMirrorRefusesUnusableEntries(t *testing.T) { |
| + for _, tc := range []struct { |
| + name string |
| + key string |
| + pem []byte |
| + }{ |
| + {"expired", mirrorHost, hostCertPEM(t, mirrorHost, time.Now().Add(-time.Hour))}, |
| + {"wrong host", mirrorHost, hostCertPEM(t, "other.bab.oncarlos.com", time.Now().Add(80*24*time.Hour))}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + putBucket(t, s, tc.key, tc.pem) |
| + m := newCertMirror(s, nil) |
| + get, _ := gatedChain(t, m, autocert.DirCache(t.TempDir()), mirrorHost) |
| + if err := m.Refresh(context.Background()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := get(ecdsaHello(mirrorHost)); !errors.Is(err, errNotIssuer) { |
| + t.Fatalf("an unusable mirrored entry must fall through, got %v", err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// autocert keys legacy clients' certs under "<host>+rsa", so the mirror must |
| +// look under the SAME name for the SAME client — with both entries present, |
| +// the choice is the one autocert would have made. |
| +func TestMirrorPicksTheEntryAutocertWouldHave(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + ecdsaPEM := hostCertPEM(t, mirrorHost, time.Now().Add(80*24*time.Hour)) |
| + rsaPEM := hostCertPEM(t, mirrorHost, time.Now().Add(70*24*time.Hour)) |
| + putBucket(t, s, mirrorHost, ecdsaPEM) |
| + putBucket(t, s, mirrorHost+"+rsa", rsaPEM) |
| + m := newCertMirror(s, nil) |
| + get, _ := gatedChain(t, m, autocert.DirCache(t.TempDir()), mirrorHost) |
| + if err := m.Refresh(context.Background()); err != nil { |
| + t.Fatal(err) |
| + } |
| + rsaOnly := &tls.ClientHelloInfo{ |
| + ServerName: mirrorHost, |
| + SignatureSchemes: []tls.SignatureScheme{tls.PKCS1WithSHA256}, |
| + CipherSuites: []uint16{tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256}, |
| + } |
| + for _, tc := range []struct { |
| + name string |
| + hello *tls.ClientHelloInfo |
| + want []byte |
| + }{ |
| + {"ecdsa client", ecdsaHello(mirrorHost), ecdsaPEM}, |
| + {"rsa-only client", rsaOnly, rsaPEM}, |
| + } { |
| + got, err := get(tc.hello) |
| + if err != nil { |
| + t.Fatalf("%s: %v", tc.name, err) |
| + } |
| + want, err := parseCertEntry(tc.want) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !bytes.Equal(got.Certificate[0], want.Certificate[0]) { |
| + t.Fatalf("%s was served the other entry", tc.name) |
| + } |
| + } |
| +} |
| + |
| +// ---- tls-alpn-01: the challenge autocert PREFERS ---------------------------- |
| + |
| +func alpnHello(host string) *tls.ClientHelloInfo { |
| + h := ecdsaHello(host) |
| + h.SupportedProtos = []string{acme.ALPNProto} |
| + return h |
| +} |
| + |
| +// autocert tries tls-alpn-01 first and only falls back to http-01 after a |
| +// whole failed order, so an N-edge deployment would burn a failed validation |
| +// on (N-1)/N of its orders if only the issuer could answer these. |
| +func TestMirrorAnswersAnotherEdgesALPNValidation(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + putBucket(t, s, mirrorHost+"+token", hostCertPEM(t, mirrorHost, time.Now().Add(24*time.Hour))) |
| + m := newCertMirror(s, nil) |
| + get, _ := gatedChain(t, m, autocert.DirCache(t.TempDir()), mirrorHost) |
| + m.allowed = func(h string) bool { return h == mirrorHost } |
| + // Deliberately NOT refreshed: a validation is live, and the poll is 30s. |
| + if _, err := get(alpnHello(mirrorHost)); err != nil { |
| + t.Fatalf("a non-issuer must answer another edge's tls-alpn-01 validation: %v", err) |
| + } |
| +} |
| + |
| +// The SNI here is attacker-chosen, so the live lookup is bounded to hosts |
| +// this box actually serves. |
| +func TestMirrorALPNReadThroughOnlyForServedHosts(t *testing.T) { |
| + s := &bucketGetCounter{Store: release.NewDirStore(t.TempDir())} |
| + m := newCertMirror(s, nil) |
| + get, _ := gatedChain(t, m, autocert.DirCache(t.TempDir()), mirrorHost) |
| + m.allowed = func(string) bool { return false } |
| + if _, err := get(alpnHello("stranger.example")); err == nil { |
| + t.Fatal("there is no token cert for a host this box does not serve") |
| + } |
| + if n := s.gets(); n != 0 { |
| + t.Fatalf("bucket gets = %d, want 0 for an unserved host", n) |
| + } |
| +} |
| + |
| +// A token cert is NOT a certificate for the host: it is self-signed and only |
| +// meaningful to a validating CA. It must never leak into an ordinary |
| +// handshake. |
| +func TestMirrorNeverServesATokenCertToAnOrdinaryHello(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + putBucket(t, s, mirrorHost+"+token", hostCertPEM(t, mirrorHost, time.Now().Add(24*time.Hour))) |
| + m := newCertMirror(s, nil) |
| + get, _ := gatedChain(t, m, autocert.DirCache(t.TempDir()), mirrorHost) |
| + m.allowed = func(string) bool { return true } |
| + if err := m.Refresh(context.Background()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := get(ecdsaHello(mirrorHost)); !errors.Is(err, errNotIssuer) { |
| + t.Fatalf("an ordinary hello must not be served the tls-alpn-01 token cert: %v", err) |
| + } |
| +} |
| + |
| +// The wildcard stays in front of everything: a covered name never reaches the |
| +// mirror at all (this is the composition RunWithActivatorReady builds). |
| +func TestWildcardWinsAheadOfTheMirror(t *testing.T) { |
| + const parent = "bab.oncarlos.com" |
| + s := release.NewDirStore(t.TempDir()) |
| + putBucket(t, s, mirrorHost, hostCertPEM(t, mirrorHost, time.Now().Add(80*24*time.Hour))) |
| + m := newCertMirror(s, nil) |
| + inner, _ := gatedChain(t, m, autocert.DirCache(t.TempDir()), mirrorHost) |
| + if err := m.Refresh(context.Background()); err != nil { |
| + t.Fatal(err) |
| + } |
| + w := newWildcardManager([]string{parent}, "", func() []string { return nil }, nil, "", autocert.DirCache(t.TempDir()), &fakeDNS{}) |
| + wildcard, _ := selfSignedWildcard(t, parent, time.Now().Add(80*24*time.Hour)) |
| + w.refresh() |
| + w.mu.Lock() |
| + w.certs[parent] = wildcard |
| + w.mu.Unlock() |
| + |
| + got, err := wrapGetCertificate(w, inner)(ecdsaHello(mirrorHost)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got != wildcard { |
| + t.Fatal("a wildcard-covered name must be served the wildcard, not the mirror's per-host cert") |
| + } |
| +} |
| + |
| +// ---- R2: challenges, in front of autocert's own handler --------------------- |
| + |
| +const mirrorTestToken = "0123456789abcdefghij0123456789abcdefghijkl" |
| + |
| +func challengeRequest(t *testing.T, h http.Handler, token string) *httptest.ResponseRecorder { |
| + t.Helper() |
| + r := httptest.NewRequest("GET", "http://"+mirrorHost+"/.well-known/acme-challenge/"+token, nil) |
| + w := httptest.NewRecorder() |
| + h.ServeHTTP(w, r) |
| + return w |
| +} |
| + |
| +// gatedHTTPHandler is the :80 chain of a non-issuer: the mirror's handler in |
| +// front of autocert's, which 403s every challenge on this box. |
| +func gatedHTTPHandler(t *testing.T, m *certMirror) http.Handler { |
| + t.Helper() |
| + e := testIssuerEdge(mirrorHost) |
| + e.issuer = func() (context.Context, bool) { return nil, false } |
| + mgr := offlineManager(t, autocert.DirCache(t.TempDir()), e.issuerHostPolicy()) |
| + fallback := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { |
| + w.Header().Set("X-Fallback", "yes") |
| + w.WriteHeader(http.StatusTeapot) |
| + }) |
| + return m.challengeHandler(mgr.HTTPHandler(fallback)) |
| +} |
| + |
| +// autocert's HTTPHandler runs its HostPolicy — the issuance gate — before it |
| +// ever looks a token up, so a non-issuer 403s a challenge it is holding the |
| +// answer to. The mirror answers first. |
| +func TestChallengeHandlerAnswersInFrontOfAutocert(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + putBucket(t, s, mirrorTestToken+"+http-01", []byte("keyauth")) |
| + m := newCertMirror(s, nil) |
| + if err := m.Refresh(context.Background()); err != nil { |
| + t.Fatal(err) |
| + } |
| + got := challengeRequest(t, gatedHTTPHandler(t, m), mirrorTestToken) |
| + if got.Code != 200 || got.Body.String() != "keyauth" { |
| + t.Fatalf("challenge = %d %q, want 200 \"keyauth\"", got.Code, got.Body.String()) |
| + } |
| +} |
| + |
| +// Everything that is not a challenge path is somebody else's request. |
| +func TestChallengeHandlerDelegatesEverythingElse(t *testing.T) { |
| + m := newCertMirror(release.NewDirStore(t.TempDir()), nil) |
| + r := httptest.NewRequest("GET", "http://"+mirrorHost+"/whatever", nil) |
| + w := httptest.NewRecorder() |
| + gatedHTTPHandler(t, m).ServeHTTP(w, r) |
| + if w.Header().Get("X-Fallback") != "yes" { |
| + t.Fatalf("non-challenge request = %d, want the fallback handler", w.Code) |
| + } |
| +} |
| + |
| +// A live validation is faster than a 30s poll: the token was written to the |
| +// bucket a second ago and Accept() has already been sent. A miss reads |
| +// through — once, then from a short-lived cache. |
| +func TestChallengeReadsThroughForLiveTokens(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + putBucket(t, s, mirrorTestToken+"+http-01", []byte("keyauth")) |
| + counting := &bucketGetCounter{Store: s} |
| + m := newCertMirror(counting, nil) // deliberately never refreshed |
| + h := gatedHTTPHandler(t, m) |
| + for range 3 { |
| + got := challengeRequest(t, h, mirrorTestToken) |
| + if got.Code != 200 || got.Body.String() != "keyauth" { |
| + t.Fatalf("read-through challenge = %d %q", got.Code, got.Body.String()) |
| + } |
| + } |
| + if n := counting.gets(); n != 1 { |
| + t.Fatalf("bucket gets = %d, want 1 — a live token is cached briefly after the first read", n) |
| + } |
| +} |
| + |
| +// A miss is negative-cached: a scanner walking /.well-known/acme-challenge/ |
| +// must not turn into one bucket GET per request. |
| +func TestChallengeReadThroughNegativeCachesMisses(t *testing.T) { |
| + counting := &bucketGetCounter{Store: release.NewDirStore(t.TempDir())} |
| + m := newCertMirror(counting, nil) |
| + h := gatedHTTPHandler(t, m) |
| + for range 5 { |
| + if got := challengeRequest(t, h, mirrorTestToken); got.Code == 200 { |
| + t.Fatal("there is no such token; the handler must not answer 200") |
| + } |
| + } |
| + if n := counting.gets(); n != 1 { |
| + t.Fatalf("bucket gets = %d, want 1 — misses are negative-cached", n) |
| + } |
| +} |
| + |
| +// Only names shaped like an ACME token are ever fetched: the read-through is |
| +// a bucket read driven by an unauthenticated URL path. |
| +func TestChallengeReadThroughRefusesNonTokenPaths(t *testing.T) { |
| + counting := &bucketGetCounter{Store: release.NewDirStore(t.TempDir())} |
| + m := newCertMirror(counting, nil) |
| + h := gatedHTTPHandler(t, m) |
| + for _, token := range []string{"short", "../../control/serial", "wildcard-account.key", strings.Repeat("a", 300)} { |
| + if got := challengeRequest(t, h, token); got.Code == 200 { |
| + t.Fatalf("%q answered 200", token) |
| + } |
| + } |
| + if n := counting.gets(); n != 0 { |
| + t.Fatalf("bucket gets = %d, want 0 — a name that cannot be an ACME token is never fetched", n) |
| + } |
| +} |
| + |
| +// Distinct token names defeat the negative cache, so the read-through carries |
| +// its own budget: a flood degrades to "no read-through", never to a bucket |
| +// GET per request. |
| +func TestChallengeReadThroughIsRateLimited(t *testing.T) { |
| + counting := &bucketGetCounter{Store: release.NewDirStore(t.TempDir())} |
| + m := newCertMirror(counting, nil) |
| + h := gatedHTTPHandler(t, m) |
| + for i := range 200 { |
| + challengeRequest(t, h, fmt.Sprintf("%s%03d", strings.Repeat("z", 39), i)) |
| + } |
| + if n := counting.gets(); n > readThroughBurst { |
| + t.Fatalf("bucket gets = %d under a flood, want <= %d", n, readThroughBurst) |
| + } |
| + if counting.gets() == 0 { |
| + t.Fatal("test bug: the read-through never ran at all") |
| + } |
| +} |
| + |
| +// bucketGetCounter counts Gets. |
| +type bucketGetCounter struct { |
| + release.Store |
| + mu sync.Mutex |
| + n int |
| +} |
| + |
| +func (c *bucketGetCounter) Get(ctx context.Context, key string) ([]byte, string, error) { |
| + c.mu.Lock() |
| + c.n++ |
| + c.mu.Unlock() |
| + return c.Store.Get(ctx, key) |
| +} |
| + |
| +func (c *bucketGetCounter) gets() int { |
| + c.mu.Lock() |
| + defer c.mu.Unlock() |
| + return c.n |
| +} |
| + |
| +// slowGetStore blocks every Get until release is closed. |
| +type slowGetStore struct { |
| + release.Store |
| + release chan struct{} |
| + mu sync.Mutex |
| + n int |
| +} |
| + |
| +func (s *slowGetStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| + s.mu.Lock() |
| + s.n++ |
| + s.mu.Unlock() |
| + <-s.release |
| + return s.Store.Get(ctx, key) |
| +} |
| + |
| +func (s *slowGetStore) gets() int { |
| + s.mu.Lock() |
| + defer s.mu.Unlock() |
| + return s.n |
| +} |
| + |
| +// A CA opens several connections for one validation. They share one bucket |
| +// read: the slow one is in flight, the rest wait on it. |
| +func TestChallengeReadThroughIsSingleFlighted(t *testing.T) { |
| + inner := release.NewDirStore(t.TempDir()) |
| + putBucket(t, inner, mirrorTestToken+"+http-01", []byte("keyauth")) |
| + s := &slowGetStore{Store: inner, release: make(chan struct{})} |
| + m := newCertMirror(s, nil) |
| + h := gatedHTTPHandler(t, m) |
| + |
| + var wg sync.WaitGroup |
| + got := make([]int, 6) |
| + for i := range got { |
| + wg.Add(1) |
| + go func() { |
| + defer wg.Done() |
| + got[i] = challengeRequest(t, h, mirrorTestToken).Code |
| + }() |
| + } |
| + // Let the waiters pile up behind the leader, then let it through. |
| + time.Sleep(50 * time.Millisecond) |
| + close(s.release) |
| + wg.Wait() |
| + |
| + for i, code := range got { |
| + if code != 200 { |
| + t.Fatalf("request %d = %d, want 200", i, code) |
| + } |
| + } |
| + if n := s.gets(); n != 1 { |
| + t.Fatalf("bucket gets = %d, want 1 — concurrent lookups share one read", n) |
| + } |
| +} |
| + |
| +// The loop is the whole delivery mechanism: it must pick material up without |
| +// anybody asking, keep going after a failure, and stop when told. |
| +func TestCertMirrorLoopPollsAndStops(t *testing.T) { |
| + prev := certMirrorInterval |
| + certMirrorInterval = 5 * time.Millisecond |
| + t.Cleanup(func() { certMirrorInterval = prev }) |
| + log.SetOutput(io.Discard) |
| + t.Cleanup(func() { log.SetOutput(os.Stderr) }) |
| + |
| + s := &listFailingStore{Store: release.NewDirStore(t.TempDir())} |
| + s.setFail(true) // the loop starts against a broken bucket |
| + m := newCertMirror(s, nil) |
| + stop := make(chan struct{}) |
| + done := make(chan struct{}) |
| + go func() { |
| + m.run(stop) |
| + close(done) |
| + }() |
| + |
| + putBucket(t, s.Store, "example.com", []byte("PEM")) |
| + s.setFail(false) |
| + deadline := time.Now().Add(2 * time.Second) |
| + for { |
| + if _, ok := m.Cert("example.com"); ok { |
| + break |
| + } |
| + if time.Now().After(deadline) { |
| + t.Fatal("the loop never picked the cert up") |
| + } |
| + time.Sleep(2 * time.Millisecond) |
| + } |
| + close(stop) |
| + select { |
| + case <-done: |
| + case <-time.After(2 * time.Second): |
| + t.Fatal("the loop did not stop") |
| + } |
| +} |
| + |
| +// autocert arms its renewal timers LAZILY, from GetCertificate: m.cert() |
| +// loads the cache and calls startRenew (autocert.go:464), and nothing else |
| +// ever does. A mirror that short-circuited every hello would therefore |
| +// silently stop an ISSUER from renewing anything it had not ordered in this |
| +// process — the certs would simply expire in 90 days. |
| +// |
| +// So the short-circuit is for the GATED case only, which is the case it |
| +// exists for: an ungated box's autocert can serve everything the mirror holds |
| +// anyway, because syncLocal has already put it in its cache. |
| +func TestMirrorLetsAnIssuersAutocertSeeTheHello(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + putBucket(t, s, mirrorHost, hostCertPEM(t, mirrorHost, time.Now().Add(80*24*time.Hour))) |
| + |
| + local := autocert.DirCache(t.TempDir()) |
| + counting := &countingCache{Cache: local, gets: map[string]int{}} |
| + m := newCertMirror(s, local) |
| + m.gated = func() bool { return false } // this box IS the issuer |
| + if err := m.Refresh(context.Background()); err != nil { |
| + t.Fatal(err) |
| + } |
| + e := testIssuerEdge(mirrorHost) // ungated host policy |
| + get := m.wrapGetCertificate(offlineManager(t, counting, e.issuerHostPolicy()).GetCertificate) |
| + if _, err := get(ecdsaHello(mirrorHost)); err != nil { |
| + t.Fatalf("the issuer must serve the mirrored cert (through autocert): %v", err) |
| + } |
| + if n := counting.count(mirrorHost); n == 0 { |
| + t.Fatal("autocert never saw the hello — its renewal timer for this host is never armed") |
| + } |
| +} |
| + |
| +// The mirror is still there behind autocert on an ungated box: a name |
| +// autocert cannot serve (and, being offline, cannot order) is rescued. |
| +func TestMirrorStillRescuesAnUngatedBox(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + putBucket(t, s, mirrorHost, hostCertPEM(t, mirrorHost, time.Now().Add(80*24*time.Hour))) |
| + m := newCertMirror(s, nil) |
| + m.gated = func() bool { return false } |
| + if err := m.Refresh(context.Background()); err != nil { |
| + t.Fatal(err) |
| + } |
| + e := testIssuerEdge(mirrorHost) |
| + get := m.wrapGetCertificate(offlineManager(t, autocert.DirCache(t.TempDir()), e.issuerHostPolicy()).GetCertificate) |
| + if _, err := get(ecdsaHello(mirrorHost)); err != nil { |
| + t.Fatalf("an ungated box whose order fails must still fall back to the mirror: %v", err) |
| + } |
| +} |
| + |
| +// A mirror with no gate wired (every test, and any future caller that |
| +// forgets) behaves as though this box were gated: it serves, which is always |
| +// safe, rather than assuming an issuance authority it was never given. |
| +func TestMirrorWithNoGateDefaultsToServingFirst(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + putBucket(t, s, mirrorHost, hostCertPEM(t, mirrorHost, time.Now().Add(80*24*time.Hour))) |
| + m := newCertMirror(s, nil) // no gate |
| + if err := m.Refresh(context.Background()); err != nil { |
| + t.Fatal(err) |
| + } |
| + local := autocert.DirCache(t.TempDir()) |
| + if err := local.Put(context.Background(), mirrorHost, hostCertPEM(t, mirrorHost, time.Now().Add(80*24*time.Hour))); err != nil { |
| + t.Fatal(err) |
| + } |
| + counting := &countingCache{Cache: local, gets: map[string]int{}} |
| + e := testIssuerEdge(mirrorHost) // ungated host policy: autocert WOULD serve |
| + get := m.wrapGetCertificate(offlineManager(t, counting, e.issuerHostPolicy()).GetCertificate) |
| + if _, err := get(ecdsaHello(mirrorHost)); err != nil { |
| + t.Fatal(err) |
| + } |
| + if n := counting.count(mirrorHost); n != 0 { |
| + t.Fatalf("autocert was consulted %d time(s) — with no gate the mirror answers first", n) |
| + } |
| +} |
diff --git a/internal/edge/edge.go b/internal/edge/edge.go| index 5574f1701..29c0a6568 100644 |
| --- a/internal/edge/edge.go |
| +++ b/internal/edge/edge.go |
| @@ -1788,6 +1788,11 @@ func RunWithActivatorReady(args []string, act Activator, cache *Cache, ready fun |
| if err != nil { |
| log.Fatalf("edge: %v", err) |
| } |
| + // The RAW local cache, kept before the publishing wrapper goes on. The |
| + // mirror writes INWARDS through this handle and reads it as its last |
| + // serving resort; going through the wrapper instead would have the issuer |
| + // re-publishing its own material to the bucket on every poll. |
| + localCerts := certCache |
| // Publish every issuance artifact to the control bucket, and gate |
| // issuance on being the box that may write there (certsync.go): per-host |
| // certs, the ACME account keys, HTTP-01 tokens, and — through the same |
| @@ -1796,6 +1801,23 @@ func RunWithActivatorReady(args []string, act Activator, cache *Cache, ready fun |
| // serve from, so it stays ungated and keeps issuing for itself. |
| certCache = e.armCertIssuance(certCache) |
| + // The other side of that decision (certsync.go): the mirror of |
| + // control/certs/, which is what makes the gate safe to shut. nil when |
| + // there is no deployment store — armCertIssuance has then cleared the |
| + // gate too, and this box issues and serves entirely for itself. |
| + mirror := e.newCertMirror(localCerts) |
| + if mirror != nil { |
| + // One synchronous pass before anything listens: a box that has just |
| + // booted must not spend its first 30 seconds handshaking with nothing, |
| + // and the wildcard manager's first pass (below) reads what this lands |
| + // in the local cache. |
| + ctx, cancel := context.WithTimeout(context.Background(), certMirrorBootTimeout) |
| + if err := mirror.Refresh(ctx); err != nil { |
| + log.Printf("edge: first cert mirror refresh failed: %v — serving from this box's own cache until a later poll lands", err) |
| + } |
| + cancel() |
| + } |
| + |
| m := &autocert.Manager{ |
| Prompt: autocert.AcceptTOS, |
| Cache: certCache, |
| @@ -1805,28 +1827,54 @@ func RunWithActivatorReady(args []string, act Activator, cache *Cache, ready fun |
| HostPolicy: e.issuerHostPolicy(), |
| } |
| tlsCfg := m.TLSConfig() |
| + // The handshake chain, built inside out: autocert, then the mirror (which |
| + // also carries the direct local-cache last resort behind it), then the |
| + // wildcard. See certMirror.wrapGetCertificate for why the mirror sits in |
| + // FRONT of autocert rather than behind it. |
| + getCert := tlsCfg.GetCertificate |
| + if mirror != nil { |
| + getCert = mirror.wrapGetCertificate(getCert) |
| + } |
| // Wildcard certs (wildcard.go, ACME DNS-01): covered names — one label |
| // under a per-account or configured parent — are served the wildcard; |
| // everything else (custom domains, canary hosts, parents not yet |
| // obtained) stays on autocert's per-host HTTP-01 above. nil = not |
| // configured = exactly the pre-wildcard edge. |
| if w := newWildcardFromEnv(e, certCache); w != nil { |
| - tlsCfg.GetCertificate = wrapGetCertificate(w, tlsCfg.GetCertificate) |
| + getCert = wrapGetCertificate(w, getCert) |
| e.OnReload(w.refresh) |
| + if mirror != nil { |
| + // A wildcard the ISSUER minted reaches this box's handshakes on the |
| + // next poll rather than on this manager's 12h tick. |
| + mirror.wake = w.refresh |
| + } |
| w.refresh() |
| go w.run() |
| log.Printf("edge: wildcard certs on (static %v, apps domain %q, delegation zone %q)", |
| splitDomains(os.Getenv("CARLOS_WILDCARD_DOMAINS")), os.Getenv("PLATFORM_APPS_DOMAIN"), w.delegationZone) |
| } |
| + tlsCfg.GetCertificate = getCert |
| + if mirror != nil { |
| + // Started last: every field the loop reads (wake above) is in place. |
| + go mirror.run(make(chan struct{})) // process-lifetime; never stopped |
| + } |
| // The non-ACME :80 handler. See httpFallbackHandler's doc comment for the |
| // Allowed-vs-lookupUpstream split. |
| httpFallback := e.httpFallbackHandler() |
| + // :80's ACME path, outermost first: the mirror answers any edge's HTTP-01 |
| + // challenge, autocert answers its own live orders, and everything else |
| + // falls through to the redirect handler. |
| + acmeAndFallback := m.HTTPHandler(httpFallback) |
| + if mirror != nil { |
| + acmeAndFallback = mirror.challengeHandler(acmeAndFallback) |
| + } |
| + |
| // ReadHeaderTimeout guards the public front door against Slowloris-style |
| // slow-header connection exhaustion. No ReadTimeout/WriteTimeout — those |
| // would break long-lived proxied WebSockets. |
| - httpSrv := &http.Server{Addr: *httpAddr, Handler: m.HTTPHandler(httpFallback), ReadHeaderTimeout: 10 * time.Second} |
| + httpSrv := &http.Server{Addr: *httpAddr, Handler: acmeAndFallback, ReadHeaderTimeout: 10 * time.Second} |
| httpsSrv := &http.Server{Addr: *httpsAddr, Handler: e, TLSConfig: tlsCfg, ReadHeaderTimeout: 10 * time.Second} |
| // :80 serves the ACME HTTP-01 challenge and redirects everything else. |
diff --git a/internal/edge/wildcard_test.go b/internal/edge/wildcard_test.go| index eead55f42..17b90d41d 100644 |
| --- a/internal/edge/wildcard_test.go |
| +++ b/internal/edge/wildcard_test.go |
| @@ -24,6 +24,13 @@ import ( |
| // selfSignedWildcard mints a throwaway *.parent cert valid for the given |
| // window — enough for matching/renewal/PEM tests, no ACME involved. |
| func selfSignedWildcard(t *testing.T, parent string, notAfter time.Time) (*tls.Certificate, *ecdsa.PrivateKey) { |
| + t.Helper() |
| + return selfSignedLeaf(t, "*."+parent, notAfter) |
| +} |
| + |
| +// selfSignedLeaf mints a throwaway cert for one DNS name (a host, or a |
| +// "*.parent" wildcard) valid until notAfter. |
| +func selfSignedLeaf(t *testing.T, dnsName string, notAfter time.Time) (*tls.Certificate, *ecdsa.PrivateKey) { |
| t.Helper() |
| key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) |
| if err != nil { |
| @@ -31,8 +38,8 @@ func selfSignedWildcard(t *testing.T, parent string, notAfter time.Time) (*tls.C |
| } |
| tmpl := &x509.Certificate{ |
| SerialNumber: big.NewInt(1), |
| - Subject: pkix.Name{CommonName: "*." + parent}, |
| - DNSNames: []string{"*." + parent}, |
| + Subject: pkix.Name{CommonName: dnsName}, |
| + DNSNames: []string{dnsName}, |
| NotBefore: time.Now().Add(-time.Hour), |
| NotAfter: notAfter, |
| } |