| index a12c68c58..a802607e9 100644 |
| --- a/internal/edge/certsync.go |
| +++ b/internal/edge/certsync.go |
| @@ -35,6 +35,7 @@ import ( |
| "errors" |
| "fmt" |
| "log" |
| + "net" |
| "net/http" |
| "os" |
| "path" |
| @@ -44,6 +45,7 @@ import ( |
| |
| "golang.org/x/crypto/acme" |
| "golang.org/x/crypto/acme/autocert" |
| + "golang.org/x/net/idna" |
| |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| @@ -102,9 +104,15 @@ var errNotIssuer = errors.New("acme: this box is not the deployment's certificat |
| // serving out of its own on-disk cache, since autocert consults HostPolicy |
| // before its cache lookup: |
| // |
| -// - fleet boxes (CARLOS_FLEET): the housekeeper never contends there, and |
| -// the fleet store door's ACL denies control/certs/** by design, so no |
| -// future mirror can rescue them either; |
| +// - fleet boxes (CARLOS_FLEET) carrying no deployment bucket of their own: |
| +// the housekeeper never contends there, and there is nothing to mirror |
| +// from. The fleet store DOOR is not what decides this and never was: the |
| +// edge does not use the door at all — enableStaticFromEnv reads |
| +// CARLOS_DEPLOYMENT_BUCKET / CARLOS_DEPLOYMENT_DIR and opens the store |
| +// directly, and the door is the agent's client. A grandfathered fleet box |
| +// that still carries the bucket env therefore reads control/certs/ over |
| +// its own IAM, and is gated AND mirrored like any other non-issuer — a |
| +// transition state, resolved by the rollout dropping that env; |
| // - boxes with neither CARLOS_DEPLOYMENT_BUCKET nor CARLOS_DEPLOYMENT_DIR; |
| // - boxes with a bucket but no CARLOS_RELEASE_PUBKEY, where |
| // enableStaticFromEnv leaves staticStore nil. |
| @@ -418,6 +426,9 @@ type certMirror struct { |
| |
| mu sync.RWMutex |
| snap *certSnapshot |
| + // partialPolls counts polls that could not read every object, for the |
| + // first-and-every-tenth log cadence. |
| + partialPolls int |
| |
| // synced remembers, per name, the mirrored bytes last reconciled into the |
| // local cache, so a steady state costs no local I/O at all. |
| @@ -429,8 +440,8 @@ type certMirror struct { |
| rtMu sync.Mutex |
| rt map[string]readThroughEntry |
| rtInflight map[string]chan struct{} |
| - rtTokens float64 |
| - rtLast time.Time |
| + rtHTTP rtBudget // :80, the HTTP-01 challenge path |
| + rtALPN rtBudget // 443, the tls-alpn-01 token-cert path |
| lastRTLog time.Time |
| |
| // localCerts memoes the last-resort reads of this box's own cache; |
| @@ -487,7 +498,10 @@ func (m *certMirror) Refresh(ctx context.Context) error { |
| if err != nil { |
| return fmt.Errorf("list %s: %w", certPrefix, err) |
| } |
| + prev := m.snapshot() |
| next := make(map[string][]byte, len(keys)) |
| + var tried, failed int |
| + var lastErr error |
| for _, key := range keys { |
| name, ok := strings.CutPrefix(key, certPrefix) |
| // publishableCertName is the same filter the publish side applies: |
| @@ -496,18 +510,36 @@ func (m *certMirror) Refresh(ctx context.Context) error { |
| if !ok || !publishableCertName(name) { |
| continue |
| } |
| + tried++ |
| body, _, err := m.s.Get(ctx, key) |
| - if errors.Is(err, os.ErrNotExist) { |
| + switch { |
| + case 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) |
| + case err != nil: |
| + // ONE bad object is not a bad poll. Aborting here would discard the |
| + // whole rebuild — every unrelated cert that landed this tick with |
| + // it — and skip syncLocal and the wildcard wake; a single |
| + // permanently unreadable key would freeze the mirror forever. Carry |
| + // the previous copy of this name forward and keep going. |
| + failed++ |
| + lastErr = err |
| + if body, ok := prev.entries[name]; ok { |
| + next[name] = body |
| + } |
| + continue |
| } |
| next[name] = body |
| } |
| - prev := m.snapshot() |
| + if tried > 0 && failed == tried { |
| + // Nothing at all could be read: that is a bucket fault, not an object |
| + // fault, and it is reported like a failed List — last-good stands. |
| + return fmt.Errorf("get %s*: every one of %d object(s) failed, last: %w", certPrefix, tried, lastErr) |
| + } |
| + if failed > 0 { |
| + m.logPartialPoll(failed, tried, lastErr) |
| + } |
| snap := buildCertSnapshot(prev, next) |
| m.mu.Lock() |
| m.snap = snap |
| @@ -521,6 +553,19 @@ func (m *certMirror) Refresh(ctx context.Context) error { |
| return nil |
| } |
| |
| +// logPartialPoll reports unreadable objects on the loop's own first-and-every- |
| +// tenth cadence: a permanently bad key would otherwise say the same thing |
| +// every 30 seconds forever. |
| +func (m *certMirror) logPartialPoll(failed, tried int, err error) { |
| + m.mu.Lock() |
| + m.partialPolls++ |
| + n := m.partialPolls |
| + m.mu.Unlock() |
| + if n == 1 || n%10 == 0 { |
| + log.Printf("certsync: %d of %d object(s) under %s unreadable this poll (#%d, last: %v) — the previous copy of each is carried forward", failed, tried, certPrefix, n, err) |
| + } |
| +} |
| + |
| // 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. |
| @@ -530,6 +575,13 @@ func buildCertSnapshot(prev *certSnapshot, entries map[string][]byte) *certSnaps |
| if !certShapedName(name) { |
| continue |
| } |
| + if strings.HasSuffix(name, alpnTokenSuffix) { |
| + // tls-alpn-01 token certs are never served from a snapshot — see |
| + // alpnTokenCert — so parsing them here would be work done every |
| + // poll for an answer nothing may use. Leaving them unparsed also |
| + // makes serving a stale one structurally impossible. |
| + 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 |
| @@ -596,7 +648,9 @@ func accountKeyName(name string) bool { |
| // 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). |
| +// (wildcard-<parent>.pem) and tls-alpn-01 token certs (<host>+token) — the |
| +// last of which only the live read-through parses (buildCertSnapshot skips |
| +// them). |
| func certShapedName(name string) bool { |
| return !accountKeyName(name) && !strings.HasSuffix(name, httpTokenSuffix) |
| } |
| @@ -818,7 +872,10 @@ func wildcardMaterialChanged(prev, next *certSnapshot) bool { |
| // 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, ".")) |
| + // Converted ONCE, here, and used by every layer below: the mirror |
| + // lookup, the tls-alpn-01 lookup and the local cache read all have to |
| + // agree with the name autocert published under. |
| + name := serverName(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 |
| @@ -855,6 +912,24 @@ func (m *certMirror) wrapGetCertificate(inner func(*tls.ClientHelloInfo) (*tls.C |
| } |
| } |
| |
| +// serverName normalizes a client-supplied name to the cache name autocert |
| +// would have used: lowercased, no trailing dot, and punycode via |
| +// idna.Lookup.ToASCII — the same conversion autocert applies before its own |
| +// cache lookup (autocert.go:307), because some clients (curl among them) send |
| +// unicode SNI verbatim and a cert is always published under the A-label. |
| +// |
| +// A name IDNA refuses is kept in its lowercased form rather than dropped: |
| +// every layer below treats it as an ordinary miss, which is what it is, and |
| +// the refusal belongs to autocert (which rejects it outright) rather than to |
| +// the mirror. |
| +func serverName(sni string) string { |
| + name := strings.ToLower(strings.TrimSuffix(sni, ".")) |
| + if ascii, err := idna.Lookup.ToASCII(name); err == nil { |
| + return ascii |
| + } |
| + return name |
| +} |
| + |
| // isGated reads the issuance gate, defaulting to "gated" (see the field). |
| func (m *certMirror) isGated() bool { |
| return m.gated == nil || m.gated() |
| @@ -1034,7 +1109,7 @@ func (m *certMirror) challengeHandler(next http.Handler) http.Handler { |
| } |
| // 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 { |
| + if body, ok := m.challengeResponse(r.Context(), requestHost(r.Host), path.Base(r.URL.Path)); ok { |
| w.Header().Set("Content-Type", "text/plain; charset=utf-8") |
| _, _ = w.Write(body) |
| return |
| @@ -1048,17 +1123,39 @@ func (m *certMirror) challengeHandler(next http.Handler) http.Handler { |
| // 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) { |
| +func (m *certMirror) challengeResponse(ctx context.Context, host, token string) ([]byte, bool) { |
| if body, ok := m.Challenge(token); ok { |
| + // Already polled: a map hit costs nothing and the token is the CA's own |
| + // 128+ bits of entropy, so it needs no host bound of its own. |
| return body, true |
| } |
| if !validChallengeToken(token) { |
| return nil, false |
| } |
| - body, _, ok := m.readThrough(ctx, token+httpTokenSuffix) |
| + // The LIVE lookup is bounded by the host under validation, exactly as |
| + // autocert bounds its own token lookup (HTTPHandler runs hostPolicy on |
| + // r.Host first, autocert.go:405). A real CA always sends the domain it is |
| + // validating, so the bound costs a genuine validation nothing — and |
| + // without it an unauthenticated :80 flood sustains a bucket GET per |
| + // distinct path, forever, from anywhere. |
| + if m.allowed != nil && !m.allowed(host) { |
| + return nil, false |
| + } |
| + body, _, ok := m.readThrough(ctx, token+httpTokenSuffix, &m.rtHTTP) |
| return body, ok |
| } |
| |
| +// requestHost is the Host header without its port, normalized the same way |
| +// SNI is (serverName) so it meets Edge.Allowed's exact registry map in the |
| +// shape routes are stored in. |
| +func requestHost(hostport string) string { |
| + host := hostport |
| + if h, _, err := net.SplitHostPort(host); err == nil { |
| + host = h |
| + } |
| + return serverName(host) |
| +} |
| + |
| // 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 |
| @@ -1091,14 +1188,17 @@ func (m *certMirror) alpnTokenCert(ctx context.Context, host string) *tls.Certif |
| 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) |
| + // Deliberately NOT from the poll's snapshot. Unlike an HTTP-01 entry — |
| + // whose name carries the order's unique token — a token CERT is keyed by |
| + // hostname alone, so the previous order's entry for the same host is a |
| + // live collision, not a stale copy of the same answer. Serving it hands |
| + // the CA the wrong key authorization and burns the order. The snapshot is |
| + // a poll behind at best, and far worse if a gated Delete left litter |
| + // behind, so this material is only ever read live. |
| + _, cert, _ := m.readThrough(ctx, host+alpnTokenSuffix, &m.rtALPN) |
| if usableFor(cert, host) { |
| return cert |
| } |
| @@ -1112,21 +1212,48 @@ const ( |
| // 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). |
| + // the entry (or it has been withdrawn, which is also the answer). It |
| + // applies to names that are unique per order — HTTP-01 tokens. |
| 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 |
| + // readThroughBurst / readThroughRefill are one budget's rate: 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. |
| + // |
| + // There is one budget PER LISTENER (rtHTTP, rtALPN), never one shared: |
| + // :80's challenge path is reachable by anyone, and a flood there must not |
| + // spend the budget 443's tls-alpn-01 rescue needs. They fail |
| + // independently or not at all. |
| readThroughBurst = 20 |
| readThroughRefill = 5 // per second |
| ) |
| |
| +// rtBudget is one token bucket. Guarded by certMirror.rtMu. |
| +type rtBudget struct { |
| + tokens float64 |
| + last time.Time |
| +} |
| + |
| +// spend takes one token, reporting whether there was one. Caller holds rtMu. |
| +func (b *rtBudget) spend(now time.Time) bool { |
| + if b.last.IsZero() { |
| + b.tokens = readThroughBurst |
| + } else { |
| + b.tokens = min(readThroughBurst, b.tokens+now.Sub(b.last).Seconds()*readThroughRefill) |
| + } |
| + b.last = now |
| + if b.tokens < 1 { |
| + return false |
| + } |
| + b.tokens-- |
| + return true |
| +} |
| + |
| type readThroughEntry struct { |
| body []byte |
| cert *tls.Certificate |
| @@ -1134,10 +1261,24 @@ type readThroughEntry struct { |
| at time.Time |
| } |
| |
| -func (e readThroughEntry) fresh() bool { |
| +// readThroughTokenTTL is how long a tls-alpn-01 token CERT may be remembered. |
| +// Seconds, not a poll: the name is the bare hostname, so the next order for |
| +// that host reuses it, and two orders can be seconds apart. A var so tests |
| +// can zero it. |
| +var readThroughTokenTTL = 3 * time.Second |
| + |
| +// readThroughTTL is how long a HIT for name may be reused. |
| +func readThroughTTL(name string) time.Duration { |
| + if strings.HasSuffix(name, alpnTokenSuffix) { |
| + return readThroughTokenTTL |
| + } |
| + return readThroughHitTTL |
| +} |
| + |
| +func (e readThroughEntry) fresh(name string) bool { |
| ttl := readThroughMissTTL |
| if e.ok { |
| - ttl = readThroughHitTTL |
| + ttl = readThroughTTL(name) |
| } |
| return time.Since(e.at) < ttl |
| } |
| @@ -1147,9 +1288,9 @@ func (e readThroughEntry) fresh() bool { |
| // 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) { |
| +func (m *certMirror) readThrough(ctx context.Context, name string, budget *rtBudget) ([]byte, *tls.Certificate, bool) { |
| m.rtMu.Lock() |
| - if e, ok := m.rt[name]; ok && e.fresh() { |
| + if e, ok := m.rt[name]; ok && e.fresh(name) { |
| m.rtMu.Unlock() |
| return e.body, e.cert, e.ok |
| } |
| @@ -1167,7 +1308,7 @@ func (m *certMirror) readThrough(ctx context.Context, name string) ([]byte, *tls |
| m.rtMu.Unlock() |
| return e.body, e.cert, e.ok |
| } |
| - if !m.allowFetchLocked() { |
| + if !budget.spend(time.Now()) { |
| m.rtMu.Unlock() |
| return nil, nil, false |
| } |
| @@ -1221,20 +1362,3 @@ func (m *certMirror) logReadThroughError(name string, err error) { |
| 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 |
| -} |