Follow-up to #2 / PR #10. Discussed as out-of-scope there; filing here.
Summary
Add a global stale_ttl config option that lets ussher serve stale cached keys for a bounded window beyond cache_ttl when the upstream fetch fails. The cache file format already records each entry's write time (PR #8), so this is a pure logic addition in the HTTP client — no on-disk schema change.
Motivation
After PR #10, a transient upstream failure (DNS hiccup, GitHub 503, TLS handshake error, network blip) no longer crashes ussher — it logs and returns an empty slice. That's the right correctness fix, but it has an availability cost: every login during the outage falls through to "no keys", which sshd treats as a denial. For a tool whose job is to authoritatively answer "is this key currently authorized?", denying the answer "I don't know right now, but here's what I knew 3 minutes ago" is often worse than serving last-known-good keys until the upstream recovers.
stale_ttl lets operators opt into that trade: prefer availability for a bounded window when fresh data is unobtainable, knowing the trade-off (see security note below).
This is the same pattern as HTTP's stale-if-error (RFC 5861).
Proposed config
cache_ttl: 5m
stale_ttl: 1h
sources:
- url: https://github.com/dolph.keys
Default 0, which means disabled — current behavior is preserved on upgrade. Opting in is explicit.
stale_ttl is parsed the same way as cache_ttl (time.ParseDuration) and resolved through the same kind of fallback as Config.ResolveCacheTTL.
Semantics
Three windows for any cached entry, measured from setAt:
0 ─── cache_ttl ─── cache_ttl + stale_ttl ─── ∞
FRESH STALE-USABLE-ON-ERROR UNUSABLE
Decision tree on each GetURL / GetGHE call:
- Cache hit and
time.Since(setAt) < cache_ttl → FRESH: return cached keys without touching the network. (Existing behavior.)
- Otherwise attempt the fetch.
- Success → cache new bytes, return new keys. (Existing behavior.)
- Failure (transport error, body read error, non-2xx):
A "Failure" here is any of the conditions PR #10 made non-fatal: transport error, body read error, non-2xx response.
Security trade-off — call this out in the README
Stale serving means a key revoked on the upstream can still authenticate during the stale window if the upstream happens to be unreachable. The window is bounded by stale_ttl, so the operator gets to decide how much availability they're willing to trade for instant revocation:
stale_ttl: 0 (default) — strict: a failed upstream means no keys.
stale_ttl: 5m — modest grace: tolerates very short blips.
stale_ttl: 24h — strong availability bias: tolerates a full day of upstream outage at the cost of revocations taking up to a day to propagate during sustained failures.
Worth a paragraph in the cache_ttl README section explaining the interaction.
Implementation sketch
Touches the same files as PR #8 / PR #10:
config.go: add StaleTTL string to Config, plus (c *Config) ResolveStaleTTL() time.Duration mirroring ResolveCacheTTL. Default 0.
httpclient.go: Client carries staleTTL alongside ttl. NewHTTPClient(ttl, staleTTL time.Duration). Restructure GetURL so the cache lookup is reusable across the success path and the on-error fallback. GetGHE mirrors the same shape.
cmd.go: pass c.ResolveStaleTTL() through to NewHTTPClient.
README.md: extend the cache_ttl section with the stale_ttl knob and the security trade-off.
httpclient_test.go: extend with cases that exercise the stale-fallback branch — pre-populate cache with an aged entry, point client at a server returning 500, expect the cached keys back.
The cache file format from PR #8 already records setAt, so no schema change.
Acceptance criteria
Out of scope
Follow-up to #2 / PR #10. Discussed as out-of-scope there; filing here.
Summary
Add a global
stale_ttlconfig option that lets ussher serve stale cached keys for a bounded window beyondcache_ttlwhen the upstream fetch fails. The cache file format already records each entry's write time (PR #8), so this is a pure logic addition in the HTTP client — no on-disk schema change.Motivation
After PR #10, a transient upstream failure (DNS hiccup, GitHub 503, TLS handshake error, network blip) no longer crashes ussher — it logs and returns an empty slice. That's the right correctness fix, but it has an availability cost: every login during the outage falls through to "no keys", which sshd treats as a denial. For a tool whose job is to authoritatively answer "is this key currently authorized?", denying the answer "I don't know right now, but here's what I knew 3 minutes ago" is often worse than serving last-known-good keys until the upstream recovers.
stale_ttllets operators opt into that trade: prefer availability for a bounded window when fresh data is unobtainable, knowing the trade-off (see security note below).This is the same pattern as HTTP's
stale-if-error(RFC 5861).Proposed config
Default
0, which means disabled — current behavior is preserved on upgrade. Opting in is explicit.stale_ttlis parsed the same way ascache_ttl(time.ParseDuration) and resolved through the same kind of fallback asConfig.ResolveCacheTTL.Semantics
Three windows for any cached entry, measured from
setAt:Decision tree on each
GetURL/GetGHEcall:time.Since(setAt) < cache_ttl→ FRESH: return cached keys without touching the network. (Existing behavior.)time.Since(setAt) < cache_ttl + stale_ttl→ STALE-USABLE: logCache STALE: <url>, return the cached keys.[]string{}(current post-Stop killing the process on HTTP fetch errors (closes #2) #10 behavior).A "Failure" here is any of the conditions PR #10 made non-fatal: transport error, body read error, non-2xx response.
Security trade-off — call this out in the README
Stale serving means a key revoked on the upstream can still authenticate during the stale window if the upstream happens to be unreachable. The window is bounded by
stale_ttl, so the operator gets to decide how much availability they're willing to trade for instant revocation:stale_ttl: 0(default) — strict: a failed upstream means no keys.stale_ttl: 5m— modest grace: tolerates very short blips.stale_ttl: 24h— strong availability bias: tolerates a full day of upstream outage at the cost of revocations taking up to a day to propagate during sustained failures.Worth a paragraph in the
cache_ttlREADME section explaining the interaction.Implementation sketch
Touches the same files as PR #8 / PR #10:
config.go: addStaleTTL stringtoConfig, plus(c *Config) ResolveStaleTTL() time.DurationmirroringResolveCacheTTL. Default0.httpclient.go:ClientcarriesstaleTTLalongsidettl.NewHTTPClient(ttl, staleTTL time.Duration). RestructureGetURLso the cache lookup is reusable across the success path and the on-error fallback.GetGHEmirrors the same shape.cmd.go: passc.ResolveStaleTTL()through toNewHTTPClient.README.md: extend thecache_ttlsection with thestale_ttlknob and the security trade-off.httpclient_test.go: extend with cases that exercise the stale-fallback branch — pre-populate cache with an aged entry, point client at a server returning 500, expect the cached keys back.The cache file format from PR #8 already records
setAt, so no schema change.Acceptance criteria
Config.StaleTTLparses viatime.ParseDuration; default0when unset or unparseable.cache_ttl + stale_ttlofsetAtis returned and logged distinctly from fresh hits (e.g.Cache STALE).cache_ttl + stale_ttl), behavior matches current post-Stop killing the process on HTTP fetch errors (closes #2) #10 (logged failure, empty slice).Out of scope
ETag/If-Modified-Since). Different feature, lower-traffic refreshes; deserves its own issue if anyone wants it.stale_ttl. Same pattern extension as Per-source cache_ttl with source > global > default precedence #9 proposes forcache_ttl. Calling that out in a comment on Per-source cache_ttl with source > global > default precedence #9 so they can be designed together if both are accepted.