carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package edge
2
3import (
4 "context"
5 "errors"
6 "os"
7 "testing"
8 "time"
9
10 "github.com/carlosframework/platform/internal/release"
11)
12
13func sweepWarm(t *testing.T, s release.Store) *certWarm {
14 t.Helper()
15 return newCertWarm(func() []string { return nil }, nil, nil, nil, s)
16}
17
18// seedCertEntry writes one cert-prefix object and backdates its stamp, which
19// is how an object that has been sitting in the bucket for months looks.
20func seedCertEntry(t *testing.T, dir string, s release.Store, name string, body []byte, age time.Duration) {
21 t.Helper()
22 if _, err := s.Put(context.Background(), certKey(name), body, ""); err != nil {
23 t.Fatal(err)
24 }
25 when := time.Now().Add(-age)
26 if err := os.Chtimes(dir+"/"+certKey(name), when, when); err != nil {
27 t.Fatal(err)
28 }
29}
30
31func certGone(t *testing.T, s release.Store, name string) bool {
32 t.Helper()
33 _, _, err := s.Get(context.Background(), certKey(name))
34 return errors.Is(err, os.ErrNotExist)
35}
36
37func expiredPEM(t *testing.T, host string, expiredAgo time.Duration) []byte {
38 t.Helper()
39 cert, key := selfSignedLeaf(t, host, time.Now().Add(-expiredAgo))
40 pem, err := encodeWildcardPEM(cert, key)
41 if err != nil {
42 t.Fatal(err)
43 }
44 return pem
45}
46
47// #243: a spent token whose gated delete never ran. Age is a complete
48// predicate — a validation's whole life is seconds.
49func TestSweepTakesSpentChallengeTokens(t *testing.T) {
50 dir := t.TempDir()
51 s := release.NewDirStore(dir)
52 w := sweepWarm(t, s)
53 seedCertEntry(t, dir, s, "abc123"+httpTokenSuffix, []byte("key-authorization"), 2*time.Hour)
54 seedCertEntry(t, dir, s, "www.customer.test"+alpnTokenSuffix, []byte("whatever"), 2*time.Hour)
55 // A token from a validation that may still be running must survive.
56 seedCertEntry(t, dir, s, "fresh"+httpTokenSuffix, []byte("key-authorization"), time.Minute)
57
58 w.sweep(context.Background(), time.Now())
59
60 if !certGone(t, s, "abc123"+httpTokenSuffix) {
61 t.Error("a spent HTTP-01 token survived the sweep")
62 }
63 if !certGone(t, s, "www.customer.test"+alpnTokenSuffix) {
64 t.Error("a spent tls-alpn-01 token cert survived the sweep")
65 }
66 if certGone(t, s, "fresh"+httpTokenSuffix) {
67 t.Error("a token from a possibly-live validation was deleted — that burns the order")
68 }
69}
70
71// §3f's whole ruling. The predicate is the leaf's own expiry and nothing
72// else: a cert for a host nobody serves any more goes ONLY once it is long
73// dead, and a current cert is untouchable however old its object is.
74func TestSweepDeletesOnlyLongExpiredCertificates(t *testing.T) {
75 dir := t.TempDir()
76 s := release.NewDirStore(dir)
77 w := sweepWarm(t, s)
78 old := certSweepParseAge + 24*time.Hour
79
80 seedCertEntry(t, dir, s, "dead.customer.test", expiredPEM(t, "dead.customer.test", certSweepGrace+24*time.Hour), old)
81 // Expired, but not long enough ago. Patience costs bytes; haste costs
82 // an outage.
83 seedCertEntry(t, dir, s, "recent.customer.test", expiredPEM(t, "recent.customer.test", time.Hour), old)
84 // A CURRENT cert whose object happens to be ancient — a CA with long
85 // certs, or a renewal that has simply not been needed.
86 cert, key := selfSignedLeaf(t, "live.customer.test", time.Now().Add(365*24*time.Hour))
87 livePEM, err := encodeWildcardPEM(cert, key)
88 if err != nil {
89 t.Fatal(err)
90 }
91 seedCertEntry(t, dir, s, "live.customer.test", livePEM, old)
92
93 w.sweep(context.Background(), time.Now())
94
95 if !certGone(t, s, "dead.customer.test") {
96 t.Error("a certificate that expired a month ago survived")
97 }
98 if certGone(t, s, "recent.customer.test") {
99 t.Error("a certificate that expired an hour ago was deleted — the grace period is not being applied")
100 }
101 if certGone(t, s, "live.customer.test") {
102 t.Fatal("A CURRENT CERTIFICATE WAS DELETED. The predicate is expiry, never object age.")
103 }
104}
105
106// The account keys are the issuer's identity at the CA. They are written
107// once and never rewritten, so they are ALWAYS older than the parse floor —
108// which is exactly why they are excluded by name before anything else is
109// considered. Deleting one costs the deployment its ACME registration.
110func TestSweepNeverTouchesAccountKeys(t *testing.T) {
111 dir := t.TempDir()
112 s := release.NewDirStore(dir)
113 w := sweepWarm(t, s)
114 ancient := 5 * 365 * 24 * time.Hour
115 seedCertEntry(t, dir, s, autocertAccountKeyName, []byte("account key material"), ancient)
116 seedCertEntry(t, dir, s, wildcardAccountKeyName, []byte("account key material"), ancient)
117
118 w.sweep(context.Background(), time.Now())
119
120 for _, name := range []string{autocertAccountKeyName, wildcardAccountKeyName} {
121 if certGone(t, s, name) {
122 t.Fatalf("ACCOUNT KEY %s WAS DELETED — the deployment has lost its ACME registration", name)
123 }
124 }
125}
126
127// A sweep that deletes what it cannot understand is a sweep that deletes
128// tomorrow's format. Anything that will not read, will not parse, or has no
129// leaf is left exactly where it is.
130func TestSweepLeavesWhatItCannotUnderstand(t *testing.T) {
131 dir := t.TempDir()
132 s := release.NewDirStore(dir)
133 w := sweepWarm(t, s)
134 old := certSweepParseAge + 24*time.Hour
135 seedCertEntry(t, dir, s, "future-shape.customer.test", []byte("a format this build has never seen"), old)
136
137 w.sweep(context.Background(), time.Now())
138
139 if certGone(t, s, "future-shape.customer.test") {
140 t.Error("an entry this build could not parse was deleted")
141 }
142}
143
144// The pre-filter is what keeps this from being an N+1: nothing younger than
145// the parse floor is even read. A healthy deployment renews every ~60 days,
146// so the steady-state cost is one listing and zero reads.
147func TestSweepReadsNothingYoungerThanTheParseFloor(t *testing.T) {
148 dir := t.TempDir()
149 s := &bucketGetCounter{Store: release.NewDirStore(dir)}
150 w := sweepWarm(t, s)
151 for _, host := range []string{"a.customer.test", "b.customer.test", "c.customer.test"} {
152 seedCertEntry(t, dir, s, host, expiredPEM(t, host, time.Hour), 10*24*time.Hour)
153 }
154 before := s.gets()
155 w.sweep(context.Background(), time.Now())
156 if n := s.gets() - before; n != 0 {
157 t.Errorf("sweep spent %d get(s) on entries younger than the parse floor, want 0", n)
158 }
159}
160
161// The expiry half runs daily, not hourly: it is the half that costs reads,
162// and a certificate that has been dead for a month can wait a day. The token
163// half runs every pass, because it needs no reads at all.
164func TestSweepRunsTheExpiryHalfDailyAndTheTokenHalfEveryPass(t *testing.T) {
165 dir := t.TempDir()
166 s := release.NewDirStore(dir)
167 w := sweepWarm(t, s)
168 old := certSweepParseAge + 24*time.Hour
169 now := time.Now()
170
171 seedCertEntry(t, dir, s, "dead-one.customer.test", expiredPEM(t, "dead-one.customer.test", certSweepGrace+24*time.Hour), old)
172 w.sweep(context.Background(), now)
173 if !certGone(t, s, "dead-one.customer.test") {
174 t.Fatal("the first sweep did not run the expiry half")
175 }
176
177 // An hour later, a second dead cert appears and a token goes stale.
178 seedCertEntry(t, dir, s, "dead-two.customer.test", expiredPEM(t, "dead-two.customer.test", certSweepGrace+24*time.Hour), old)
179 seedCertEntry(t, dir, s, "tok"+httpTokenSuffix, []byte("key-authorization"), 2*time.Hour)
180 w.sweep(context.Background(), now.Add(time.Hour))
181 if certGone(t, s, "dead-two.customer.test") {
182 t.Error("the expiry half ran again an hour later — it is meant to be daily")
183 }
184 if !certGone(t, s, "tok"+httpTokenSuffix) {
185 t.Error("the token half did not run on the second pass — it is meant to run every pass")
186 }
187
188 // A day later it comes back round.
189 w.sweep(context.Background(), now.Add(certSweepInterval+time.Minute))
190 if !certGone(t, s, "dead-two.customer.test") {
191 t.Error("the expiry half did not run again after a day")
192 }
193}
194
195// A store with no last-modified stamps gives the sweep no age to judge by,
196// and guessing an object's age from its contents is exactly the inference
197// this file refuses to make. It does nothing at all rather than something
198// approximate.
199func TestSweepStandsDownWithoutStamps(t *testing.T) {
200 dir := t.TempDir()
201 inner := release.NewDirStore(dir)
202 w := sweepWarm(t, stamplessEdgeStore{inner})
203 seedCertEntry(t, dir, inner, "tok"+httpTokenSuffix, []byte("key-authorization"), 30*24*time.Hour)
204
205 w.sweep(context.Background(), time.Now())
206
207 if certGone(t, inner, "tok"+httpTokenSuffix) {
208 t.Error("a store with no stamps still had entries deleted")
209 }
210}
211
212type stamplessEdgeStore struct{ inner release.Store }
213
214func (s stamplessEdgeStore) Get(ctx context.Context, key string) ([]byte, string, error) {
215 return s.inner.Get(ctx, key)
216}
217func (s stamplessEdgeStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) {
218 return s.inner.Put(ctx, key, body, ifMatch)
219}
220func (s stamplessEdgeStore) List(ctx context.Context, prefix string) ([]string, error) {
221 return s.inner.List(ctx, prefix)
222}
223func (s stamplessEdgeStore) ListDirs(ctx context.Context, prefix string) ([]string, error) {
224 return s.inner.ListDirs(ctx, prefix)
225}
226func (s stamplessEdgeStore) Delete(ctx context.Context, key string) error {
227 return s.inner.Delete(ctx, key)
228}
229
230// A bug in the predicate deletes at most this many objects before somebody
231// reads the log, and a genuine backlog drains over days rather than in one
232// burst against the bucket.
233func TestSweepBoundsHowMuchOnePassCanDelete(t *testing.T) {
234 dir := t.TempDir()
235 s := release.NewDirStore(dir)
236 w := sweepWarm(t, s)
237 total := certSweepDeletesPerPass + 5
238 for i := 0; i < total; i++ {
239 seedCertEntry(t, dir, s, string(rune('a'+i%26))+string(rune('a'+i/26))+".tok"+httpTokenSuffix,
240 []byte("key-authorization"), 2*time.Hour)
241 }
242 w.sweep(context.Background(), time.Now())
243 left, err := s.List(context.Background(), certPrefix)
244 if err != nil {
245 t.Fatal(err)
246 }
247 if len(left) != total-certSweepDeletesPerPass {
248 t.Errorf("%d entries left after one pass, want %d — the per-pass bound is not holding",
249 len(left), total-certSweepDeletesPerPass)
250 }
251}
252
253// The sweep's work exists whether or not this deployment has custom
254// domains: spent challenge tokens and long-expired certificates are not the
255// warm SET's business. A pass over an empty set still sweeps — the gate check
256// deliberately comes before the empty-set return in pass().
257func TestPassWithNoWarmSetStillSweeps(t *testing.T) {
258 dir := t.TempDir()
259 s := release.NewDirStore(dir)
260 w := sweepWarm(t, s)
261 seedCertEntry(t, dir, s, "orphan"+httpTokenSuffix, []byte("key-authorization"), 2*time.Hour)
262
263 if d := w.pass(); d != warmInterval {
264 t.Fatalf("pass over an empty set returned %s", d)
265 }
266 if !certGone(t, s, "orphan"+httpTokenSuffix) {
267 t.Error("a deployment with no custom domains never sweeps its own token litter")
268 }
269}
270
271// A non-issuer sweeps nothing. Deleting cert material is the sharpest thing
272// this package does, and a box that is not the issuer has no authority to
273// do it.
274func TestSweepNeverRunsOnANonIssuer(t *testing.T) {
275 dir := t.TempDir()
276 s := release.NewDirStore(dir)
277 w := newCertWarm(func() []string { return nil }, nil, nil,
278 func() (context.Context, bool) { return nil, false }, s)
279 seedCertEntry(t, dir, s, "orphan"+httpTokenSuffix, []byte("key-authorization"), 2*time.Hour)
280
281 w.pass()
282
283 if certGone(t, s, "orphan"+httpTokenSuffix) {
284 t.Error("a stood-down box deleted cert material")
285 }
286}
287
288// A first-time backlog trips warmOrdersPerPass and pass() comes back every
289// minute until it clears. Housekeeping must not ride along sixty times an
290// hour: the token TTL is an hour and the expiry half is daily, so a listing
291// of the whole cert prefix per minute would be spent on nothing.
292func TestSweepDoesNotRunOnEveryCappedDrainPass(t *testing.T) {
293 dir := t.TempDir()
294 s := &listCounter{Store: release.NewDirStore(dir)}
295 w := sweepWarm(t, s)
296 now := time.Now()
297
298 w.sweep(context.Background(), now)
299 if s.lists != 1 {
300 t.Fatalf("first sweep listed %d time(s), want 1", s.lists)
301 }
302 w.sweep(context.Background(), now.Add(time.Minute))
303 w.sweep(context.Background(), now.Add(2*time.Minute))
304 if s.lists != 1 {
305 t.Errorf("sweep listed %d times across three passes a minute apart, want 1", s.lists)
306 }
307 w.sweep(context.Background(), now.Add(certSweepMinInterval+time.Second))
308 if s.lists != 2 {
309 t.Errorf("sweep listed %d times, want 2 — an ordinary pass an hour later must sweep", s.lists)
310 }
311}
312
313type listCounter struct {
314 release.Store
315 lists int
316}
317
318func (l *listCounter) ListMod(ctx context.Context, prefix string) ([]release.KeyMod, error) {
319 l.lists++
320 return l.Store.(release.ModLister).ListMod(ctx, prefix)
321}
322