coverage~bigbes/sr-ht-ecore3bd158fbbearer/bearer.go

Coverage
93.7% 89/95 statements
Δ
Blob
d974ddd
1 // Package bearer validates a tokens.sr.ht working token, and is the one copy of
2 // that check for every service on the instance (SPEC ch. 6).
3 //
4 // A working token is an auth.BearerToken: the version, expiry, grants, client id
5 // and owner in BARE, HMAC-SHA256 over the lot, base64. It is the same format and
6 // the same key meta.sr.ht stamps its own personal access tokens with, so a
7 // service needs no new cryptographic stack to accept one — and, for the same
8 // reason, the signature alone does not say who issued the token. Only the client
9 // id does, which is why step 2 below exists at all.
10 //
11 // The four steps, in order, are SPEC ch. 6:
12 //
13 // 1. decode and verify (local, no network);
14 // 2. is this token ours? — if not, the *service's* policy decides;
15 // 3. does it grant the action being attempted?
16 // 4. if it is a registered token, is it still live? — the one step that talks
17 // to tokens.sr.ht, cached, and skipped entirely by a short token.
18 //
19 // The order is not an implementation detail. Every step that can refuse locally
20 // runs before the one that cannot, so the network is touched only for a token
21 // that has already proved it is well-formed, ours, unexpired and sufficient for
22 // what its holder is doing. A forged or expired credential never reaches the
23 // daemon, and an instance under a flood of junk tokens does not turn that flood
24 // into traffic against tokens.sr.ht.
25 //
26 // Usage:
27 //
28 // v, err := bearer.New(bearer.Options{
29 // Origin: conf.Get("tokens.sr.ht", "origin"),
30 // ClientID: "bench.sr.ht",
31 // NodeID: hostname,
32 // })
33 // ...
34 // tok, err := v.Validate(r.Context(), presented, "bench:upload")
35 //
36 // The process must have run crypto.InitCrypto before any of this: the signing
37 // key of step 1 and the network key that seals the internal authorization of
38 // step 4 both live in that package's globals. This is the same precondition
39 // every core-go authentication path carries and it is not checked here, because
40 // there is nothing this package could usefully do about it at request time.
41 //
42 // Resolving the owner is not this package's job. The token names a meta.sr.ht
43 // username; turning that into a local row is auth.LookupUser plus whatever the
44 // service's own plan does with a user it has never seen (SPEC ch. 6), and that
45 // differs per service in ways a shared validator should not have opinions about.
46 package bearer
47
48 import (
49 "context"
50 "errors"
51 "fmt"
52 "io"
53 "net/http"
54 "net/url"
55 "strconv"
56 "strings"
57 "sync"
58 "time"
59
60 "sourcecraft.dev/bigbes/sr-ht-core/auth"
61
62 "sourcecraft.dev/bigbes/sr-ht-ecore/grants"
63 "sourcecraft.dev/bigbes/sr-ht-ecore/internalauth"
64 )
65
66 // TokensClientID is the ClientID tokens.sr.ht stamps into every working token it
67 // seals, and the only thing that distinguishes one from a meta.sr.ht PAT: the
68 // two share a signing key, so a valid signature says the instance made the
69 // token and not which part of it did (SPEC ch. 1).
70 const TokensClientID = "tokens.sr.ht"
71
72 // DefaultCacheTTL is how long a revocation answer is reused when Options leaves
73 // CacheTTL at zero. Sixty seconds, the figure SPEC ch. 6 step 4 names.
74 const DefaultCacheTTL = 60 * time.Second
75
76 // defaultHTTPTimeout bounds a revocation check when the caller supplies no
77 // client of its own.
78 //
79 // It has to be short. This request sits on the hot path of an upload, behind a
80 // context the caller may not have given a deadline to, and the endpoint it calls
81 // answers from one indexed row — a healthy daemon replies in single-digit
82 // milliseconds. Five seconds is generous for that and still short enough that a
83 // hung tokens.sr.ht turns into 503s rather than into request-handler goroutines
84 // piling up across every service on the instance.
85 const defaultHTTPTimeout = 5 * time.Second
86
87 // revocationPath is the endpoint of SPEC ch. 5, joined to Origin.
88 const revocationPath = "/api/v1/revocations/"
89
90 // maxCacheEntries bounds the revocation cache. See (*Validator).remember for
91 // what happens at the bound and why that is the right thing to happen.
92 const maxCacheEntries = 4096
93
94 // The refusals of SPEC ch. 6, and the status each one is for a service.
95 //
96 // They are separate sentinels rather than one error with a code because the
97 // mapping is not uniform, and the interesting cases are the two that are not
98 // 401:
99 //
100 // - ErrInvalid — 401. The signature did not verify, the version is foreign, or
101 // the token has expired.
102 // - ErrNotOurs — the service's own policy, not a status. See Validate.
103 // - ErrForbidden — 403. The credential is good; it does not cover this action.
104 // Distinct from 401 because retrying with the same token is pointless and
105 // the holder needs to be told to ask for a wider grant, not to log in again.
106 // - ErrRevoked — 401. The credential was withdrawn. Deliberately not 403: the
107 // token is no longer a credential at all, and a client that sees 403 will
108 // keep presenting it.
109 // - ErrUnavailable — 503. tokens.sr.ht could not be asked.
110 //
111 // The 503 is the one that has to be defended, because "I could not check" reads
112 // so naturally as "so I will not accept it". Reading an unreachable daemon as a
113 // revocation would refuse every registered token on the instance for as long as
114 // tokens.sr.ht is down — turning a restart of a service that is deliberately off
115 // the hot path into an instance-wide outage of uploads. SPEC ch. 6 step 4 says
116 // 503 for exactly that reason, following core-go, which answers the same way
117 // when meta.sr.ht cannot be reached. 503 also says the true thing to a client:
118 // come back, this is us and it is temporary.
119 //
120 // This is not a decision to fail open. A short token was never checked against
121 // the daemon in the first place, and a registered one whose revocation cannot be
122 // confirmed is refused — with a status that keeps the operator's attention on
123 // the daemon instead of on a thousand clients being told their credentials are
124 // bad.
125 var (
126 // ErrInvalid: the presented string is not a token this instance sealed, or
127 // no longer is one. 401.
128 ErrInvalid = errors.New("bearer: token does not verify")
129
130 // ErrNotOurs: a well-formed token from another issuer, almost certainly a
131 // meta.sr.ht PAT. Returned together with the decoded token; the status is
132 // the service's to choose.
133 ErrNotOurs = errors.New("bearer: token was not issued by tokens.sr.ht")
134
135 // ErrForbidden: our token, valid, but it does not carry the action. 403.
136 ErrForbidden = errors.New("bearer: token does not grant this action")
137
138 // ErrRevoked: a registered token whose row is gone, revoked or expired. 401.
139 ErrRevoked = errors.New("bearer: token has been revoked")
140
141 // ErrUnavailable: the revocation check could not be completed. 503, never
142 // 401 — see above.
143 ErrUnavailable = errors.New("bearer: tokens.sr.ht could not be reached")
144 )
145
146 // Options configures a Validator.
147 type Options struct {
148 // Origin is where tokens.sr.ht answers, scheme and host, e.g.
149 // "https://tokens.srht.bigb.es". Required.
150 Origin string
151
152 // ClientID identifies the *calling* service in the internal authorization of
153 // step 4, e.g. "bench.sr.ht". Required — the daemon's guard refuses a blob
154 // that names neither this nor NodeID.
155 //
156 // It is a label, not a credential: the guard admits every internal service
157 // equally and this only decides what its log line says. Which is precisely
158 // why it should be right; it is what an operator has to go on when the
159 // revocation cache misbehaves.
160 ClientID string
161
162 // NodeID identifies the calling process or host, e.g. "bench-1". Required,
163 // for the same reason and with the same weight as ClientID.
164 NodeID string
165
166 // HTTPClient performs the revocation check. Nil means a client with
167 // defaultHTTPTimeout.
168 HTTPClient *http.Client
169
170 // CacheTTL is how long one revocation answer is reused. Zero means
171 // DefaultCacheTTL; negative is refused.
172 CacheTTL time.Duration
173
174 // Now is the clock the cache ages entries against. Nil means time.Now.
175 //
176 // It does not move the expiry check of step 1: auth.DecodeBearerToken reads
177 // the real clock itself and this package cannot reach inside it. A test that
178 // wants an expired token has to mint one that is genuinely in the past.
179 Now func() time.Time
180 }
181
182 // Validator runs the check of SPEC ch. 6 for one calling service. It is safe for
183 // concurrent use, which it has to be: a service holds exactly one and every
184 // request handler goes through it.
185 type Validator struct {
186 origin string
187 clientID string
188 nodeID string
189 client *http.Client
190 ttl time.Duration
191 now func() time.Time
192
193 mu sync.Mutex
194 cache map[int]verdict
195 }
196
197 // verdict is one cached answer from the revocation endpoint: whether the row was
198 // live, and when that answer stops being reusable.
199 //
200 // ErrUnavailable never becomes a verdict — a failure to ask is not an answer,
201 // and caching it would let one blip, one timeout, one restart pin every token
202 // that happened to be checked during it to failure for the whole TTL. That turns
203 // a moment of unavailability into a minute of it, and it does so silently,
204 // because the daemon is healthy again while the services are still refusing.
205 type verdict struct {
206 alive bool
207 until time.Time
208 }
209
210 // Token is what validation yields: who the holder is, what the token permits,
211 // and how long it lasts.
212 type Token struct {
213 // Username is the meta.sr.ht account the token was issued to. Resolving it
214 // to a local row is the service's job (auth.LookupUser).
215 Username string
216
217 // Grants is the parsed grant set.
218 //
219 // It is the zero value — which admits nothing — when the error is
220 // ErrNotOurs, and that is not an oversight. A foreign token's grant string is
221 // in whatever vocabulary its issuer uses, and meta.sr.ht's is core-go's
222 // auth.Grants ("git.sr.ht/OBJECTS:RW"), a different grammar that this one
223 // would reject as malformed. A service that accepts meta PATs must decode
224 // that string with auth.DecodeGrants; it can get at it by calling
225 // auth.DecodeBearerToken on the presented string, which is local and cheap.
226 Grants grants.Grants
227
228 // TokenID is the row id from the grant string's id: member, or 0 for a
229 // stateless token — one short enough that tokens.sr.ht never wrote it down
230 // and that therefore has no revocation to check (SPEC ch. 2).
231 TokenID int
232
233 // Expires is when the signature stops being honoured.
234 Expires time.Time
235 }
236
237 // Registered reports whether this token has a row at tokens.sr.ht — the
238 // difference between a credential its owner can revoke and one that can only be
239 // waited out.
240 3 func (t *Token) Registered() bool { return t.TokenID != 0 }
241
242 // Authorize is step 3 of SPEC ch. 6, split out so that it can be asked where the
243 // action is known — in the handler that implements it — rather than in the
244 // middleware that resolved the identity. See Inspect.
245 //
246 // It reports ErrForbidden and not ErrInvalid: the credential is good and the
247 // caller is who they say they are, and what is missing is a permission. That
248 // distinction is what stops an agent retrying forever with a token that will
249 // never grow the grant it needs.
250 285 func (t *Token) Authorize(action string) error {
251 285 if !t.Grants.Has(action) {
252 2 return fmt.Errorf("%w: %q is not in %q", ErrForbidden, action, t.Grants.String())
253 2 }
254 283 return nil
255 }
256
257 // New builds a Validator, refusing options that would only fail later, one
258 // request at a time, as an error about the network.
259 31 func New(opts Options) (*Validator, error) {
260 31 if opts.Origin == "" {
261 1 return nil, errors.New("bearer: Origin is required, e.g. https://tokens.srht.bigb.es")
262 1 }
263 30 u, err := url.Parse(opts.Origin)
264 30 if err != nil {
265 1 return nil, fmt.Errorf("bearer: Origin %q does not parse: %w", opts.Origin, err)
266 1 }
267 // An origin without a scheme and host is not one, and the failure it causes
268 // otherwise is a request error on the first registered token some service
269 // sees — days after the config was written, and reported as ErrUnavailable,
270 // which points the operator at the daemon rather than at the typo.
271 29 if (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
272 3 return nil, fmt.Errorf(
273 3 "bearer: Origin %q must be an absolute http(s) URL, e.g. https://tokens.srht.bigb.es",
274 3 opts.Origin)
275 3 }
276 26 if opts.ClientID == "" {
277 1 return nil, errors.New("bearer: ClientID is required: the internal guard refuses a blob without one")
278 1 }
279 25 if opts.NodeID == "" {
280 1 return nil, errors.New("bearer: NodeID is required: the internal guard refuses a blob without one")
281 1 }
282 24 if opts.CacheTTL < 0 {
283 1 return nil, fmt.Errorf("bearer: CacheTTL %s is negative; zero means %s", opts.CacheTTL, DefaultCacheTTL)
284 1 }
285
286 23 v := &Validator{
287 23 origin: strings.TrimSuffix(opts.Origin, "/"),
288 23 clientID: opts.ClientID,
289 23 nodeID: opts.NodeID,
290 23 client: opts.HTTPClient,
291 23 ttl: opts.CacheTTL,
292 23 now: opts.Now,
293 23 cache: make(map[int]verdict),
294 23 }
295 23 if v.client == nil {
296 23 v.client = &http.Client{Timeout: defaultHTTPTimeout}
297 23 }
298 23 if v.ttl == 0 {
299 23 v.ttl = DefaultCacheTTL
300 23 }
301 23 if v.now == nil {
302 18 v.now = time.Now
303 18 }
304 23 return v, nil
305 }
306
307 // Validate runs the four steps of SPEC ch. 6 against one presented token for one
308 // action, e.g. "bench:upload".
309 //
310 // presented is the bare credential, with any "Bearer " scheme already stripped.
311 //
312 // On success it returns the token and a nil error. On failure it returns one of
313 // this package's sentinels, wrapped with detail — test with errors.Is, and map
314 // it to a status with the table on those sentinels.
315 //
316 // The returned token is nil for every failure except ErrNotOurs. A token that
317 // failed validation is not a token, and handing one back invites a caller to use
318 // what it was just told not to; the exception is the whole point of step 2,
319 // where the token *did* validate and only the question of whose it is remains.
320 //
321 // # Step 2 is not decided here
322 //
323 // A token whose ClientID is not TokensClientID gets ErrNotOurs and the decoded
324 // token, and this package takes it no further. That is deliberate and SPEC ch. 6
325 // step 2 requires it: what to do with a foreign bearer token is per-service
326 // policy, not a property of the format. dolt accepts meta.sr.ht PATs today and
327 // must keep accepting them; bench and cover have no reason to. A validator that
328 // refused on its own behalf would break the first and look correct doing it,
329 // because the token really is not one of ours — it just is not this package's
330 // call.
331 //
332 // So a service that also accepts meta PATs writes:
333 //
334 // tok, err := v.Validate(ctx, presented, "dolt:push")
335 // if errors.Is(err, bearer.ErrNotOurs) {
336 // // ... its own meta-PAT path ...
337 // }
338 //
339 // and one that does not turns ErrNotOurs into 401 alongside ErrInvalid.
340 290 func (v *Validator) Validate(ctx context.Context, presented, action string) (*Token, error) {
341 290 tok, err := decodeOurs(presented)
342 290 if err != nil {
343 7 return tok, err
344 7 }
345 // Step 3 before step 4, deliberately: a token that does not carry the grant
346 // is refused without a round trip to the daemon. Inspect cannot keep this
347 // ordering — it has no action to check — which is the one cost of the split
348 // and the reason this method still exists for callers that know both.
349 283 if err := tok.Authorize(action); err != nil {
350 1 return nil, err
351 1 }
352 282 if err := v.revoked(ctx, tok); err != nil {
353 8 return nil, err
354 8 }
355 274 return tok, nil
356 }
357
358 // Inspect is Validate without step 3: it answers who a token belongs to, what it
359 // may do, and whether it is still live — and leaves the question of whether that
360 // covers *this* action to the caller.
361 //
362 // It exists because of where the two questions get answered in a sourcehut
363 // service. Identity is resolved once per request in middleware, upstream of the
364 // router: that is where the cookie plane and the bearer plane meet and where a
365 // principal is put on the context, and at that point nothing knows yet which
366 // route will run, so nothing knows the action. The action is known one layer
367 // down, in the handler that implements it. A validator that insisted on both at
368 // once would force every service either to invent an action before it has one,
369 // or to lift its bearer plane out of the middleware every other plane goes
370 // through — and the second is how a surface ends up with two different ideas of
371 // who is calling.
372 //
373 // So: call Inspect in the resolver and carry the Grants on the principal, then
374 // call Token.Authorize in the handler. Validate stays for callers that know both
375 // at one point, and is exactly those two calls.
376 //
377 // Everything Validate's doc comment says — about the sentinels, about step 2
378 // being per-service policy, and about the returned token being nil for every
379 // failure except ErrNotOurs — applies here unchanged.
380 3 func (v *Validator) Inspect(ctx context.Context, presented string) (*Token, error) {
381 3 tok, err := decodeOurs(presented)
382 3 if err != nil {
383 1 return tok, err
384 1 }
385 2 if err := v.revoked(ctx, tok); err != nil {
386 0 return nil, err
387 0 }
388 2 return tok, nil
389 }
390
391 // revoked is step 4: a registered token has a revocation to ask about, a
392 // stateless one has no row and so completes without any network at all — which
393 // is the common case under the default configuration.
394 284 func (v *Validator) revoked(ctx context.Context, tok *Token) error {
395 284 if tok.TokenID == 0 {
396 88 return nil
397 88 }
398 196 return v.checkRevocation(ctx, tok.TokenID)
399 }
400
401 // decodeOurs is steps 1 and 2 plus the grant parse: everything that can be
402 // decided from the token itself, with no clock but the real one and no network
403 // at all.
404 293 func decodeOurs(presented string) (*Token, error) {
405 293 // Step 1. Signature, version and expiry, all of it local.
406 293 //
407 293 // DecodeBearerToken returns nil for all three and distinguishes none of
408 293 // them, which is the right amount of detail to give a client anyway. It
409 293 // checks the expiry itself, against the real clock — so an expired token
410 293 // costs one HMAC and never becomes a request to anybody. That property is
411 293 // what makes it safe for this step to run before every other.
412 293 bt := auth.DecodeBearerToken(presented)
413 293 if bt == nil {
414 6 return nil, fmt.Errorf("%w: signature, version or expiry", ErrInvalid)
415 6 }
416
417 // Step 2. Ours, or somebody else's? Not our decision — see the doc comment.
418 287 if bt.ClientID != TokensClientID {
419 2 return &Token{
420 2 Username: bt.Username,
421 2 Expires: bt.Expires.Time(),
422 2 }, fmt.Errorf("%w: ClientID is %q, not %q", ErrNotOurs, bt.ClientID, TokensClientID)
423 2 }
424
425 285 g, err := grants.Parse(bt.Grants)
426 285 if err != nil {
427 0 // Only tokens.sr.ht seals a token with our ClientID, and it writes the
428 0 // grant string with the same parser that is failing here, so this is
429 0 // either a version skew between daemon and service or a bug in one of
430 0 // them. Either way it is not a credential this service can act on.
431 0 return nil, fmt.Errorf("%w: grants %q do not parse: %s", ErrInvalid, bt.Grants, err)
432 0 }
433
434 285 tok := &Token{
435 285 Username: bt.Username,
436 285 Grants: g,
437 285 TokenID: g.TokenID(),
438 285 Expires: bt.Expires.Time(),
439 285 }
440 285
441 285 return tok, nil
442 }
443
444 // Forget drops the cached revocation answer for one token id, so that the next
445 // validation asks the daemon again.
446 //
447 // It is for the case where a service learns out of band that an answer is stale
448 // — a webhook, an operator, a test — and wants the revocation to take effect now
449 // rather than at the end of the TTL. Forgetting an id that is not cached is a
450 // no-op, and forgetting one that is only costs a round trip.
451 34 func (v *Validator) Forget(id int) {
452 34 v.mu.Lock()
453 34 delete(v.cache, id)
454 34 v.mu.Unlock()
455 34 }
456
457 // checkRevocation is step 4: ask GET {Origin}/api/v1/revocations/{id}, through
458 // the cache.
459 //
460 // 204 is live, 404 is not, and everything else — a 500, a timeout, a refused
461 // connection, a proxy's HTML error page — is ErrUnavailable. The endpoint has
462 // exactly two answers by design (SPEC ch. 5), so anything that is neither is not
463 // a third answer; it is the absence of one.
464 //
465 // Two concurrent validations of the same id will both issue a request when the
466 // entry is cold. That is a duplicated round trip and nothing worse: the answers
467 // agree, the second write to the cache is idempotent, and collapsing them would
468 // buy one saved request in exchange for a dependency and a shared failure mode
469 // where a single slow call holds up every goroutine waiting behind it.
470 196 func (v *Validator) checkRevocation(ctx context.Context, id int) error {
471 196 if alive, ok := v.cached(id); ok {
472 130 if alive {
473 130 return nil
474 130 }
475 0 return fmt.Errorf("%w: token %d (cached)", ErrRevoked, id)
476 }
477
478 // The internal authorization is minted per request and cannot be cached: it
479 // is a fernet blob the daemon accepts only for thirty seconds, which is what
480 // stops a captured one being replayed for a week.
481 //
482 // Through internalauth rather than assembled here, so that this caller and
483 // every Guard on the instance read one definition of the payload. Minting it
484 // by hand next to a package whose whole purpose is to hold both ends of this
485 // handshake is the drift that package exists to prevent.
486 66 authorization, err := internalauth.Authorization(v.clientID, v.nodeID)
487 66 if err != nil {
488 0 return fmt.Errorf("%w: sealing the internal authorization: %s", ErrUnavailable, err)
489 0 }
490
491 66 url := v.origin + revocationPath + strconv.Itoa(id)
492 66 req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
493 66 if err != nil {
494 0 return fmt.Errorf("%w: building the request for %s: %s", ErrUnavailable, url, err)
495 0 }
496 66 req.Header.Set("Authorization", authorization)
497 66
498 66 resp, err := v.client.Do(req)
499 66 if err != nil {
500 2 return fmt.Errorf("%w: asking %s: %s", ErrUnavailable, url, err)
501 2 }
502 64 defer resp.Body.Close()
503 64 // Both answers are empty bodies, but read to the end anyway so the
504 64 // connection goes back to the pool instead of being dropped and redialled on
505 64 // every upload.
506 64 _, _ = io.Copy(io.Discard, resp.Body)
507 64
508 64 switch resp.StatusCode {
509 58 case http.StatusNoContent:
510 58 v.remember(id, true)
511 58 return nil
512 3 case http.StatusNotFound:
513 3 // 404 covers revoked, expired and unknown alike, and all three are
514 3 // permanent: no id ever goes back to being live. Caching it is therefore
515 3 // not a staleness risk in the way caching "live" is.
516 3 v.remember(id, false)
517 3 return fmt.Errorf("%w: token %d", ErrRevoked, id)
518 3 default:
519 3 return fmt.Errorf("%w: %s answered %s", ErrUnavailable, url, resp.Status)
520 }
521 }
522
523 // cached returns a still-valid answer for id, if there is one.
524 196 func (v *Validator) cached(id int) (alive, ok bool) {
525 196 now := v.now()
526 196
527 196 v.mu.Lock()
528 196 defer v.mu.Unlock()
529 196
530 196 e, ok := v.cache[id]
531 196 if !ok || !now.Before(e.until) {
532 66 return false, false
533 66 }
534 130 return e.alive, true
535 }
536
537 // remember stores an answer for CacheTTL, and keeps the cache bounded.
538 //
539 // The TTL is the trade SPEC ch. 6 makes on purpose and it should be stated
540 // plainly: a revocation takes up to CacheTTL to take effect across the instance.
541 // The alternative is asking the daemon on every request, which puts tokens.sr.ht
542 // back on the hot path of every upload and makes its availability the
543 // instance's — the exact coupling SPEC ch. 1 removes. Sixty seconds of a revoked
544 // token still working is the price of that, and the operator revoking it should
545 // be told to expect it.
546 //
547 // The bound is a sweep, then a drop. At maxCacheEntries the expired entries go
548 // first; if that does not get under the bound, the whole map goes. No LRU, no
549 // eviction list — every entry here is worth exactly one HTTP round trip to
550 // rebuild and they all expire within CacheTTL anyway, so the cost of throwing
551 // away a full cache is bounded and small, while the cost of a map that only ever
552 // grows is a leak in a process meant to run for months. In practice the bound
553 // never fires: an entry can only be created by a token that already passed an
554 // HMAC check, so the id space here is the daemon's real rows and not something a
555 // caller can inflate.
556 8253 func (v *Validator) remember(id int, alive bool) {
557 8253 now := v.now()
558 8253
559 8253 v.mu.Lock()
560 8253 defer v.mu.Unlock()
561 8253
562 8253 if len(v.cache) >= maxCacheEntries {
563 4096 for k, e := range v.cache {
564 4096 if !now.Before(e.until) {
565 0 delete(v.cache, k)
566 0 }
567 }
568 1 if len(v.cache) >= maxCacheEntries {
569 1 v.cache = make(map[int]verdict, maxCacheEntries)
570 1 }
571 }
572 8253 v.cache[id] = verdict{alive: alive, until: now.Add(v.ttl)}
573 }