| 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() |