Files
traefik-llmgateway/redis_store.go
T
lukaszraczylo 33bd8bc2cc fix(admin): browser key entry, batched usage reads, hardened responses
Review fixes (2 Important + 4 folded):

1. GET /admin now serves the HTML shell with no authentication -
   a browser navigating directly to the URL cannot attach a custom
   header. The page reads an admin key from sessionStorage and sends
   it as x-api-key on /admin/api/* fetches, falling back to an inline
   key-entry form on a missing key or any 401/403. Both JSON routes
   keep their original 401/403/200 gate. Spec ss4 amended in place.

2. GET /admin/api/usage no longer serializes 6 GETs per user/group
   scope on the shared Redis connection. respClient gains getBatch
   (one pipelined round trip); counterStore gains getMulti; limiter
   gains storeGetMulti mirroring storeGet's fail-open/fail-closed
   policy. currentUsage now issues one getMulti per scope.

3. Provider baseUrl is stripped of userinfo and query before it is
   ever echoed in the overview JSON (sanitizeBaseURL), stronger than
   net/url's own Redacted (which leaves a bare username in place).

4. limiter.redisStatus now also returns the last failure's timestamp,
   so the dashboard can render "last error (Ns ago)" instead of a
   bare message that reads as currently-broken indefinitely.

5. Both JSON admin responses now set X-Content-Type-Options: nosniff,
   Cache-Control: no-store, and the same CSP the HTML page uses.

6. Added a test proving a file-sourced (replaceFileUsers) admin user
   gets the same access an inline admin user does.

go test ./... -count=1 -race, gofmt, vet, make yaegi-check all pass.
2026-08-20 19:46:35 +01:00

107 lines
3.8 KiB
Go

package traefikllmgateway
import (
"fmt"
"strconv"
"time"
)
// redisStore is a counterStore backed by a Redis-compatible server over
// respClient, so multiple Traefik instances share the same limit counters
// instead of each enforcing limits against its own in-process state.
type redisStore struct {
client *respClient
}
// newRedisStore returns a redisStore using client for storage.
func newRedisStore(client *respClient) *redisStore {
return &redisStore{client: client}
}
// incrBy implements counterStore: INCRBY key n, then EXPIRE key
// ttlSeconds, sent as one pipeline so the two commands share a single round
// trip. It returns INCRBY's resulting counter value; EXPIRE's reply is not
// otherwise inspected — a failed EXPIRE right after a successful INCRBY on
// the same key would only leave that key without a fresh TTL, not corrupt
// the count. ttl is rounded up to whole seconds (Redis EXPIRE's unit),
// with a floor of 1s so a sub-second ttl never turns into EXPIRE 0 (an
// immediate delete).
//
// Semantics are deliberately at-least-once, not exactly-once, both in the
// conservative direction (never under-counts): respClient.pipeline's
// reconnect-once retry can re-send this same INCRBY if the first attempt's
// reply was lost after the server already applied it (e.g. the connection
// dropped between the server processing INCRBY and the client reading its
// reply), which can over-count by n on that key; and the limiter's
// failOpen path can additionally count the same request in its in-process
// fallback store when a call to this method errors out after a partial
// success upstream. Both are accepted trade-offs — a rate/budget counter
// that occasionally over-counts by one request's worth is fail-safe (more
// restrictive than reality), never fail-open in the unsafe direction.
func (s *redisStore) incrBy(key string, n int64, ttl time.Duration) (int64, error) {
ttlSeconds := int64(ttl / time.Second)
if ttl%time.Second != 0 {
ttlSeconds++
}
if ttlSeconds < 1 {
ttlSeconds = 1
}
replies, err := s.client.pipeline([][]string{
{"INCRBY", key, strconv.FormatInt(n, 10)},
{"EXPIRE", key, strconv.FormatInt(ttlSeconds, 10)},
})
if err != nil {
return 0, fmt.Errorf("redisStore: incrBy %q: %w", key, err)
}
if len(replies) == 0 {
return 0, fmt.Errorf("redisStore: incrBy %q: empty pipeline reply", key)
}
if e, ok := replies[0].(error); ok {
return 0, fmt.Errorf("redisStore: incrBy %q: INCRBY failed: %w", key, e)
}
v, ok := replies[0].(int64)
if !ok {
return 0, fmt.Errorf("redisStore: incrBy %q: unexpected INCRBY reply type %T", key, replies[0])
}
return v, nil
}
// getMulti implements counterStore: one pipelined GET per key
// (respClient.getBatch), a single round trip regardless of len(keys) —
// used by limiter.currentUsage (admin dashboard, spec §4) to read a
// scope's six current-window counters without serializing six separate
// calls on the one shared connection.
func (s *redisStore) getMulti(keys []string) ([]int64, error) {
v, err := s.client.getBatch(keys)
if err != nil {
return nil, fmt.Errorf("redisStore: getMulti: %w", err)
}
return v, nil
}
// get implements counterStore: GET key, treating a missing key (RESP null
// bulk reply) as 0, matching memoryStore's behavior for an absent or
// expired counter.
func (s *redisStore) get(key string) (int64, error) {
reply, err := s.client.do("GET", key)
if err != nil {
return 0, fmt.Errorf("redisStore: get %q: %w", key, err)
}
if reply == nil {
return 0, nil
}
if e, ok := reply.(error); ok {
return 0, fmt.Errorf("redisStore: get %q: %w", key, e)
}
b, ok := reply.([]byte)
if !ok {
return 0, fmt.Errorf("redisStore: get %q: unexpected reply type %T", key, reply)
}
v, err := strconv.ParseInt(string(b), 10, 64)
if err != nil {
return 0, fmt.Errorf("redisStore: get %q: non-integer value %q: %w", key, b, err)
}
return v, nil
}