carlos / platform Public

console: the CLI tables get indexes, and the sweep gets a throttle

purgeCLIAuth ran on every login start, and without an index on expires_at
that is a full scan of both tables per request — against a database held
to one writer that every console page also queues behind. Now indexed,
and run at most once a minute, which is all housekeeping needs when every
read already checks expiry itself.

PurgeExpired sweeps these tables too, so the console's general
housekeeping covers them rather than only the login path.

The token row's remote address comes out with the listing: it is what a
person deciding "is that terminal mine?" actually has to go on, and the
issuance notice needs it too.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Paul Campbell dde4e569e329a655d9126c01588ba3294b960e16 parent 71b3115
2 files changed, +52 −19
  • internal/console/authstore.go +44 −16
  • internal/console/store.go +8 −3
diff --git a/internal/console/authstore.go b/internal/console/authstore.go
index ff01927e4..bef0fc0d3 100644
--- a/internal/console/authstore.go
+++ b/internal/console/authstore.go
@@ -27,6 +27,7 @@ import (
"errors"
"fmt"
"strings"
+ "sync/atomic"
"time"
)
@@ -49,14 +50,15 @@ const (
CLITokenTTL = 90 * 24 * time.Hour
// userCodeLen is the length of the typed code, before the grouping dash.
- // Eight characters of a 31-symbol alphabet is ~39.6 bits, which with a
- // ten-minute life and a rate-limited approval endpoint is far more than
- // guessing survives.
+ // Eight characters of the 30-symbol alphabet below is ~39 bits
+ // (8·log2(30) = 39.3), which with a ten-minute life and a rate-limited
+ // approval endpoint is far more than guessing survives.
userCodeLen = 8
// userCodeAlphabet has no 0/O, 1/I/L or U (which turns into V in some
// hands and is the usual source of accidental profanity in code
- // generators). What is left is unambiguous read aloud down a phone.
+ // generators). What is left is 30 symbols, unambiguous read aloud down a
+ // phone.
userCodeAlphabet = "23456789ABCDEFGHJKMNPQRSTVWXYZ"
)
@@ -87,12 +89,13 @@ type CLIAuthRequest struct {
// is the token's sha256, which is safe to put in a page and in a form: it
// names the row without being the bearer secret.
type CLIToken struct {
- ID string
- Address string
- Label string
- CreatedAt time.Time
- ExpiresAt time.Time
- LastUsed time.Time
+ ID string
+ Address string
+ Label string
+ RemoteAddr string
+ CreatedAt time.Time
+ ExpiresAt time.Time
+ LastUsed time.Time
}
// migrateCLIAuth creates the CLI-identity tables. Additive and idempotent,
@@ -124,6 +127,11 @@ func (s *Store) migrateCLIAuth() error {
last_used INTEGER NOT NULL DEFAULT 0
);`,
`CREATE INDEX IF NOT EXISTS cli_tokens_address ON cli_tokens (address);`,
+ // Expiry is swept on the write path, and a sweep without these is a
+ // full table scan on a database with one writer that every console
+ // page also queues behind.
+ `CREATE INDEX IF NOT EXISTS cli_tokens_expires ON cli_tokens (expires_at);`,
+ `CREATE INDEX IF NOT EXISTS cli_auth_requests_expires ON cli_auth_requests (expires_at);`,
} {
if _, err := s.db.Exec(stmt); err != nil {
return err
@@ -178,7 +186,7 @@ func NormalizeUserCode(in string) string {
// Housekeeping on the write path, the same idiom handleContinue uses: expired
// requests and tokens are dropped here rather than by a sweeper nobody runs.
func (s *Store) NewCLIAuth(label, remoteAddr string, now time.Time) (userCode, deviceCode string, err error) {
- s.purgeCLIAuth(now)
+ s.PurgeCLIAuth(now)
deviceCode, err = randomToken()
if err != nil {
return "", "", err
@@ -205,9 +213,29 @@ VALUES (?, ?, ?, ?, ?, ?, ?)`,
return "", "", fmt.Errorf("console: could not mint a free login code")
}
-// purgeCLIAuth drops what has aged out. Best-effort: losing a sweep must
-// never fail the login it is housekeeping for.
-func (s *Store) purgeCLIAuth(now time.Time) {
+// lastCLIPurge throttles the housekeeping sweep. Process-global rather than
+// per-Store because a process runs one console; the only cost of a skipped
+// sweep is rows living a little longer, since every read already checks
+// expiry itself. Without the throttle a flood of login starts turns into a
+// delete-per-request against a database with SetMaxOpenConns(1) — which is
+// every console page's queue too.
+var lastCLIPurge atomic.Int64
+
+// cliPurgeEvery bounds how often the sweep runs.
+const cliPurgeEvery = 60 * time.Second
+
+// PurgeCLIAuth drops what has aged out, at most once every cliPurgeEvery.
+// Best-effort: losing a sweep must never fail the login it is housekeeping
+// for. Exported so PurgeExpired — the console's general housekeeping — sweeps
+// these tables too.
+func (s *Store) PurgeCLIAuth(now time.Time) {
+ last := lastCLIPurge.Load()
+ if now.Unix()-last < int64(cliPurgeEvery/time.Second) {
+ return
+ }
+ if !lastCLIPurge.CompareAndSwap(last, now.Unix()) {
+ return // another request is doing it
+ }
s.db.Exec(`DELETE FROM cli_auth_requests WHERE expires_at < ?`, now.Unix())
s.db.Exec(`DELETE FROM cli_tokens WHERE expires_at < ?`, now.Unix())
}
@@ -367,7 +395,7 @@ func (s *Store) CLITokenAddress(rawToken string, now time.Time) (string, bool) {
// listing the tokens page renders so an owner can see, and kill, every
// terminal that speaks as them.
func (s *Store) CLITokens(address string) ([]CLIToken, error) {
- rows, err := s.db.Query(`SELECT token_hash, address, label, created_at, expires_at, last_used
+ rows, err := s.db.Query(`SELECT token_hash, address, label, remote_addr, created_at, expires_at, last_used
FROM cli_tokens WHERE address = ? ORDER BY created_at DESC, token_hash`, address)
if err != nil {
return nil, err
@@ -377,7 +405,7 @@ FROM cli_tokens WHERE address = ? ORDER BY created_at DESC, token_hash`, address
for rows.Next() {
var t CLIToken
var created, expires, used int64
- if err := rows.Scan(&t.ID, &t.Address, &t.Label, &created, &expires, &used); err != nil {
+ if err := rows.Scan(&t.ID, &t.Address, &t.Label, &t.RemoteAddr, &created, &expires, &used); err != nil {
return nil, err
}
t.CreatedAt = time.Unix(created, 0).UTC()
diff --git a/internal/console/store.go b/internal/console/store.go
index 86b483dca..2b6e13149 100644
--- a/internal/console/store.go
+++ b/internal/console/store.go
@@ -1039,13 +1039,18 @@ func (s *Store) DeleteSession(rawToken string) error {
return err
}
-// PurgeExpired drops expired sessions and confirmation tokens.
+// PurgeExpired drops expired sessions and confirmation tokens — and, on the
+// same pass, the CLI login requests and tokens that have aged out (throttled
+// inside PurgeCLIAuth, which is also called from the login path).
func (s *Store) PurgeExpired(now time.Time) error {
if _, err := s.db.Exec(`DELETE FROM sessions WHERE expires_at < ?`, now.Unix()); err != nil {
return err
}
- _, err := s.db.Exec(`DELETE FROM confirmations WHERE expires_at < ?`, now.Unix())
- return err
+ if _, err := s.db.Exec(`DELETE FROM confirmations WHERE expires_at < ?`, now.Unix()); err != nil {
+ return err
+ }
+ s.PurgeCLIAuth(now)
+ return nil
}
// ---- confirmations (returning sign-in links) --------------------------------