| 1 | // Package certwarm is the wire shape of the cert warm's durable outcome |
| 2 | // records (#299 §3e): control/cert-warm/<host>.json, written by whichever |
| 3 | // box holds the issuer lease, read by the console. |
| 4 | // |
| 5 | // The record exists so that "the platform tried to get this hostname a |
| 6 | // certificate and it is not working" can be SAID. Without it the warm's |
| 7 | // attempt counts live only in the issuer's process memory: they reset on |
| 8 | // every fleet roll and on every failover, so a domain that has been failing |
| 9 | // for a week is indistinguishable from one attached ninety seconds ago, and |
| 10 | // the console has no honest way to show anything but "issuing-cert" forever. |
| 11 | // |
| 12 | // One record per host, and ONLY while that host is failing. A successful |
| 13 | // warm deletes the record, so absence is the healthy case and the prefix |
| 14 | // holds nothing at all in a deployment where every domain is fine. Two |
| 15 | // things follow, both load-bearing: |
| 16 | // |
| 17 | // - The console's read is cheap by construction. Listing this prefix costs |
| 18 | // one object per BROKEN host, not one per domain, so folding it into the |
| 19 | // domains list does not reintroduce the per-claim cost the per-host door |
| 20 | // (#328) exists to avoid. |
| 21 | // - "No record" is never evidence of success on its own. It is also what a |
| 22 | // host that has never been warmed looks like, and what a wedged issuer |
| 23 | // leaves behind. Readers must treat absence as "nothing to report", and |
| 24 | // must age-bound what they do find — see Fresh. |
| 25 | // |
| 26 | // Unlike internal/edgesfile, this package owns the shape for BOTH sides |
| 27 | // rather than mirroring a writer that lives elsewhere. The reasoning there |
| 28 | // was that internal/host's edges-file writer was under heavy development and |
| 29 | // re-plumbing it onto a shared type would have fought it for no gain. Here |
| 30 | // writer and reader are being introduced together, in one change, so there |
| 31 | // is no incumbent to fight and no reason to maintain two decoders of one |
| 32 | // shape. |
| 33 | package certwarm |
| 34 | |
| 35 | import ( |
| 36 | "encoding/json" |
| 37 | "fmt" |
| 38 | "strings" |
| 39 | "time" |
| 40 | ) |
| 41 | |
| 42 | // Prefix is the flat prefix every record lives under. Inside control/, which |
| 43 | // the fleet door's aclRead allowlist denies by default — see |
| 44 | // TestDoorNeverServesCertMaterial, which pins this prefix with the cert |
| 45 | // material it sits beside. |
| 46 | const Prefix = "control/cert-warm/" |
| 47 | |
| 48 | // V is the wire version this build writes. A reader refuses V<1 (a record |
| 49 | // with no version is not one of ours) and accepts anything higher, on the |
| 50 | // same additive-wire rule the edges files use: a newer issuer may add |
| 51 | // fields, and an older console must degrade to "no verdict" rather than |
| 52 | // fail the whole fold. |
| 53 | const V = 1 |
| 54 | |
| 55 | // Failure classes. Stable machine words — a client may branch on them, so |
| 56 | // they are part of the wire and are never re-spelled. |
| 57 | const ( |
| 58 | // ClassRateLimited: the CA refused because the account's budget is |
| 59 | // spent. Materially different from every other failure because it is |
| 60 | // nobody's mistake and it will clear on its own, and because the wait is |
| 61 | // long enough (12h) that a member deserves to be told rather than shown |
| 62 | // a spinner. |
| 63 | ClassRateLimited = "rate-limited" |
| 64 | // ClassOther: everything else — a failed authorization, a CA outage, a |
| 65 | // network error. Deliberately NOT subdivided: the CA's own text is |
| 66 | // carried in Error, and inventing a taxonomy over strings we do not |
| 67 | // control would go stale the first time the CA rewords a problem |
| 68 | // document. |
| 69 | ClassOther = "other" |
| 70 | ) |
| 71 | |
| 72 | // Key is the bucket key for host's record. |
| 73 | // |
| 74 | // The host is used verbatim. Callers hand it the same normalized form the |
| 75 | // registry and the edges files store — lowercased and punycoded — because a |
| 76 | // record written under one spelling and read under another is a record that |
| 77 | // silently does not exist. |
| 78 | func Key(host string) string { return Prefix + host + ".json" } |
| 79 | |
| 80 | // HostFromKey is Key's inverse, for walking a listing. It returns "" for a |
| 81 | // key outside the prefix or without the .json suffix, so a stray object |
| 82 | // under the prefix is skipped rather than decoded as a hostname. |
| 83 | func HostFromKey(key string) string { |
| 84 | rest, ok := strings.CutPrefix(key, Prefix) |
| 85 | if !ok { |
| 86 | return "" |
| 87 | } |
| 88 | host, ok := strings.CutSuffix(rest, ".json") |
| 89 | if !ok || host == "" || strings.Contains(host, "/") { |
| 90 | return "" |
| 91 | } |
| 92 | return host |
| 93 | } |
| 94 | |
| 95 | // Record is one host's warm outcome. Every timestamp is unix seconds. |
| 96 | type Record struct { |
| 97 | V int `json:"v"` |
| 98 | Host string `json:"host"` |
| 99 | // Attempts is how many consecutive warm attempts have failed since the |
| 100 | // last success. It is the field the whole package exists for: it is |
| 101 | // carried in the bucket rather than in memory precisely so that a |
| 102 | // failover does not reset it to one and restart the clock on a domain |
| 103 | // that has been broken for days. |
| 104 | Attempts int `json:"attempts"` |
| 105 | // FirstFailedAt is when the current run of failures began — preserved |
| 106 | // across writes, so "failing since" survives an issuer moving. |
| 107 | FirstFailedAt int64 `json:"first_failed_at"` |
| 108 | // LastFailedAt is the most recent failure. |
| 109 | LastFailedAt int64 `json:"last_failed_at"` |
| 110 | // NextAttemptAt is the backoff floor: when the issuer will try again. |
| 111 | // Carried because it is the difference between "we have given up" and |
| 112 | // "we are waiting out a rate limit", which is the whole of what a member |
| 113 | // wants to know, and because Fresh needs it (see there). |
| 114 | NextAttemptAt int64 `json:"next_attempt_at"` |
| 115 | // Class is one of the Class* constants. |
| 116 | Class string `json:"class"` |
| 117 | // Error is the CA's or the client's own text, trimmed to ErrorMax. Shown |
| 118 | // to operators, never parsed. |
| 119 | Error string `json:"error,omitempty"` |
| 120 | // WrittenAt is when this record was written. A stamp without its age |
| 121 | // cannot be judged — see Fresh. |
| 122 | WrittenAt int64 `json:"written_at"` |
| 123 | } |
| 124 | |
| 125 | // ErrorMax bounds the carried error text. A CA problem document can be long, |
| 126 | // and an unbounded string from a remote party landing in a bucket object |
| 127 | // that the console renders is exactly the shape that turns one bad response |
| 128 | // into a large object written every hour. |
| 129 | const ErrorMax = 512 |
| 130 | |
| 131 | // Grace is how long past its own next-attempt time a record stays |
| 132 | // authoritative. |
| 133 | // |
| 134 | // The age bound exists because a wedged or stopped issuer keeps its records |
| 135 | // sitting in the bucket, frozen: without a bound the console would keep |
| 136 | // reporting "we tried 40 times and stopped" long after nothing is trying at |
| 137 | // all, which is a worse lie than saying nothing. Judged against |
| 138 | // NextAttemptAt rather than WrittenAt because the two differ by design — a |
| 139 | // rate-limited host is deliberately not retried for twelve hours, and a |
| 140 | // bound on write time alone would call that healthy silence "stale" every |
| 141 | // time. |
| 142 | // |
| 143 | // The limit this accepts, stated plainly: an issuer that wedges immediately |
| 144 | // after writing a rate-limited record is believed for up to fourteen hours. |
| 145 | // Closing that would need a per-pass heartbeat object, which is a second |
| 146 | // key and a second writer-table row to buy a sharper edge on an already |
| 147 | // rare case. |
| 148 | const Grace = 2 * time.Hour |
| 149 | |
| 150 | // Fresh reports whether this record still describes what the issuer is |
| 151 | // doing. A stale record is not deleted and not an error — it is simply not |
| 152 | // used as evidence, and the reader falls back to whatever it would have said |
| 153 | // with no record at all. |
| 154 | func (r Record) Fresh(now time.Time) bool { |
| 155 | deadline := r.WrittenAt |
| 156 | if r.NextAttemptAt > deadline { |
| 157 | deadline = r.NextAttemptAt |
| 158 | } |
| 159 | if deadline == 0 { |
| 160 | return false |
| 161 | } |
| 162 | return now.Before(time.Unix(deadline, 0).Add(Grace)) |
| 163 | } |
| 164 | |
| 165 | // Parse decodes one record. |
| 166 | func Parse(body []byte) (Record, error) { |
| 167 | var r Record |
| 168 | if err := json.Unmarshal(body, &r); err != nil { |
| 169 | return Record{}, fmt.Errorf("cert-warm record: %w", err) |
| 170 | } |
| 171 | if r.V < V { |
| 172 | return Record{}, fmt.Errorf("cert-warm record: unsupported version %d", r.V) |
| 173 | } |
| 174 | return r, nil |
| 175 | } |
| 176 | |
| 177 | // Encode serializes a record, stamping the version and the written-at time |
| 178 | // so no caller can forget either. |
| 179 | func (r Record) Encode(now time.Time) ([]byte, error) { |
| 180 | r.V = V |
| 181 | r.WrittenAt = now.Unix() |
| 182 | if len(r.Error) > ErrorMax { |
| 183 | r.Error = r.Error[:ErrorMax] |
| 184 | } |
| 185 | if r.Class == "" { |
| 186 | r.Class = ClassOther |
| 187 | } |
| 188 | return json.Marshal(r) |
| 189 | } |
| 190 | |