mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-12 04:54:45 +00:00
Counters (always on when admin is enabled): - last seen per user and group, written asynchronously after admission, throttled to one write per scope per minute per replica - hourly provider and model attempts, failures and timeouts - failovers per provider, rejections per hour, and unpriced (402) refusals - cache hits, misses and cost saved - request counts per target and caller Opt-in counters under admin.stats (both default off): - userModel: usage per user and model - latency: latency histograms per provider and model All counters ride the existing Redis batches. A test measures three round trips per request with defaults and with every statistic on. A cache hit takes two. A model request is no longer counted for failed failover candidates. New admin endpoints: - usage/series and usage/totals - performance, with percentiles from the histograms - catalog, with the price billing actually uses and its source - consumers, with key source, grants and model access - config, with secrets redacted The overview gains features and a pricing summary. Usage gains lastSeen. usage/models gains offset and more detail fields. Reads are chunked at 1600 keys per round trip and capped at 64000 keys per request. Claude-Session: https://claude.ai/code/session_01V5jYJNxpDFdJkyyExpADrv
877 lines
36 KiB
Go
877 lines
36 KiB
Go
package traefikllmgateway
|
|
|
|
import (
|
|
"bufio"
|
|
"crypto/tls"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// respDialTimeout bounds connecting to the server when no connection
|
|
// exists yet. respCallTimeout bounds everything that happens on the
|
|
// connection during one do/pipeline call: the AUTH/SELECT handshake (on a
|
|
// freshly dialled connection) and the call's own command write and reply
|
|
// reads. It is computed ONCE per call and applied via a single
|
|
// conn.SetDeadline, not reset before each individual read or write — a
|
|
// server that accepts the connection and then never answers must cost
|
|
// this one call ~respCallTimeout, not respCallTimeout multiplied by every
|
|
// handshake and command step it happens to perform.
|
|
const (
|
|
respDialTimeout = 2 * time.Second
|
|
respCallTimeout = 2 * time.Second
|
|
)
|
|
|
|
// respMaxBulkLen caps a RESP2 bulk string's declared length. Without a
|
|
// cap, a malformed or malicious reply — "$9223372036854775807\r\n" — makes
|
|
// n+2 overflow into a negative slice length and panic in make([]byte,
|
|
// n+2); a reply merely claiming a huge-but-valid length would otherwise
|
|
// force a huge allocation. 64MiB is generous for anything this client
|
|
// actually reads (integer counter values and short simple/error strings).
|
|
const respMaxBulkLen = 64 << 20
|
|
|
|
// respMaxArrayLen caps a RESP2 array's declared element count. None of
|
|
// this client's commands (AUTH, SELECT, INCRBY, EXPIRE, GET) ever return
|
|
// an array reply, so 1024 is already generous headroom, not a limit tuned
|
|
// to real traffic.
|
|
const respMaxArrayLen = 1024
|
|
|
|
// respMaxLineLen caps a single RESP2 header line (the "+", "-", ":", "$",
|
|
// or "*" line preceding any body) read by readLine. Without a cap, a peer
|
|
// that never sends '\n' makes readLine buffer an unbounded number of
|
|
// bytes before erroring.
|
|
const respMaxLineLen = 64 << 10
|
|
|
|
// respPoolMin/respPoolMax bound the self-tuned connection pool size
|
|
// (house engineering rule: self-tuning over operator knobs, explicit
|
|
// override always wins — see RedisConfig.PoolSize, llmgateway.go's
|
|
// buildRedisClient). A single mutex-guarded connection was measured to be
|
|
// THE throughput ceiling under Yaegi interpretation (perf audit,
|
|
// 2026-08-2x): 1,458 req/s Redis-backed vs 7,811 req/s on memoryStore for
|
|
// the identical workload, latency scaling ~linearly with concurrency
|
|
// (869us@conc=1 -> 10.81ms@conc=16) — near-perfect serialization, since
|
|
// pipeline held c.mu for the whole round trip. A pool of independent
|
|
// connections, each handed to exactly one caller at a time, lets N
|
|
// callers run N round trips concurrently instead of queueing behind one.
|
|
// respPoolMax=8 is the exact size the audit's variants/resp_pool8.go
|
|
// prototype validated: at a realistic 200us Redis RTT, conc=16, p50
|
|
// 24.06ms -> 3.24ms (-86%), throughput 663 -> 4,670 req/s (+599%, ±1%
|
|
// over 3 reps); a pool=1 control matched HEAD exactly, proving the win is
|
|
// parallelism, not refactor noise. respPoolMin=4 keeps a low-core-count
|
|
// replica (GOMAXPROCS clamps low under a small CPU request/limit) from
|
|
// opening fewer connections than there are usable goroutine-scheduling
|
|
// slots to fill them.
|
|
const (
|
|
respPoolMin = 4
|
|
respPoolMax = 8
|
|
)
|
|
|
|
// respPoolConfigMax bounds RedisConfig.PoolSize (llmgateway.go's
|
|
// buildRedisClient rejects anything above it outright, mirroring the
|
|
// existing negative-DB/negative-poolSize checks): an unbounded override
|
|
// would let PoolSize: 1<<20 eagerly allocate over a million *respConn
|
|
// structs at construction (verified in review, 2026-08-2x) before a
|
|
// single request ever arrives. 256 is far beyond any real single-instance
|
|
// Redis/Dragonfly connection budget while staying well above
|
|
// respPoolMax, leaving room for a legitimately high explicit override.
|
|
const respPoolConfigMax = 256
|
|
|
|
// defaultRespPoolSize returns the self-tuned connection pool size: the
|
|
// host's GOMAXPROCS, clamped to [respPoolMin, respPoolMax]. Used whenever
|
|
// RedisConfig.PoolSize is left at its zero value; an operator who sets it
|
|
// explicitly always overrides this (newRESPClientPool, below).
|
|
func defaultRespPoolSize() int {
|
|
return clampPoolSize(runtime.GOMAXPROCS(0))
|
|
}
|
|
|
|
// clampPoolSize clamps n to [respPoolMin, respPoolMax]. Split out of
|
|
// defaultRespPoolSize so the clamp itself is testable against every
|
|
// branch directly, independent of the test process's own real
|
|
// GOMAXPROCS.
|
|
func clampPoolSize(n int) int {
|
|
switch {
|
|
case n < respPoolMin:
|
|
return respPoolMin
|
|
case n > respPoolMax:
|
|
return respPoolMax
|
|
default:
|
|
return n
|
|
}
|
|
}
|
|
|
|
// respErr is a RESP2 error reply ("-message\r\n"). It implements error so a
|
|
// caller inspecting a decoded reply can type-assert for it the same way as
|
|
// any other error.
|
|
//
|
|
// A struct wrapping the message, not a defined string type ("type respErr
|
|
// string"): yaegi v0.16.1 conflates a defined string type with plain
|
|
// string in a type assertion — v.(respErr) on an any holding a genuine
|
|
// plain string (e.g. decodeReply's "+OK" success reply, itself a string)
|
|
// incorrectly reports ok=true, so every successful handshakeOn call
|
|
// (AUTH/SELECT) was misread as a failed one under real Traefik. A struct
|
|
// has no such ambiguity: verified empirically under real Traefik (Task
|
|
// 15's integration suite, cross-replica rate limiting via Redis).
|
|
type respErr struct{ msg string }
|
|
|
|
// Error implements the error interface.
|
|
func (e respErr) Error() string { return e.msg }
|
|
|
|
// respConn is one connection in respClient's pool: a lazily dialled TCP
|
|
// connection plus its buffered reader, or the zero value for a slot that
|
|
// has never dialled yet (or whose previous connection was discarded after
|
|
// an error — see closeConn). Owned by exactly one caller at a time,
|
|
// between an acquire and its matching release back onto respClient.free.
|
|
type respConn struct {
|
|
conn net.Conn
|
|
r *bufio.Reader
|
|
}
|
|
|
|
// respClient is a minimal stdlib-only RESP2 client for a single Redis-
|
|
// compatible server: a pool of independently usable TCP connections, each
|
|
// lazily dialled on first use and reconnected at most once when a command
|
|
// round trip fails for a reason other than a timeout (see pipeline). It
|
|
// exists because the plugin runs interpreted under Yaegi with only the Go
|
|
// standard library available, so a full Redis client library is not an
|
|
// option.
|
|
//
|
|
// free holds every currently-idle connection slot; a caller acquires one
|
|
// (blocking, bounded by its call deadline, if the pool is momentarily
|
|
// exhausted) for its whole round trip and returns it via a deferred send
|
|
// back onto free — RESP is not multiplexed, so two callers interleaving
|
|
// writes on the SAME connection would corrupt both replies, but distinct
|
|
// connections have no such restriction and can run fully concurrently.
|
|
type respClient struct {
|
|
free chan *respConn
|
|
addr string
|
|
password string
|
|
db int
|
|
// tls wraps every dialled connection in TLS when true, verifying the
|
|
// server certificate against addr's host.
|
|
//
|
|
// Security audit run-1: this client could previously ONLY speak
|
|
// plaintext TCP — there was no TLS field, no scheme parsing and no
|
|
// rediss:// handling anywhere — so the AUTH password, the counter
|
|
// keys (which embed principal names verbatim, windowKey in limits.go)
|
|
// and whole cached response bodies were unprotectable in transit by
|
|
// ANY operator configuration. That is what distinguishes it from an
|
|
// operator who merely chose plaintext: the choice was foreclosed.
|
|
//
|
|
// Off by default, so an existing deployment behaves exactly as
|
|
// before; set redis.tls to opt in. Not a constructor parameter
|
|
// because newRESPClient/newRESPClientPool have ~50 call sites across
|
|
// the test suite and none of them needs it — buildRedisClient
|
|
// (llmgateway.go) sets it directly on the constructed client.
|
|
tls bool
|
|
}
|
|
|
|
// newRESPClient returns a respClient for addr, sized by defaultRespPoolSize
|
|
// — the self-tuned pool size used everywhere an explicit override
|
|
// (RedisConfig.PoolSize) is absent. It does not connect until the first
|
|
// do or pipeline call. password, if non-empty, is sent via AUTH on every
|
|
// new connection; db is always sent via SELECT on every new connection
|
|
// (including db 0), since a Redis-compatible proxy is not guaranteed to
|
|
// default a fresh connection to database 0.
|
|
func newRESPClient(addr, password string, db int) *respClient {
|
|
return newRESPClientPool(addr, password, db, defaultRespPoolSize())
|
|
}
|
|
|
|
// newRESPClientPool is newRESPClient with an explicit poolSize, used by
|
|
// llmgateway.go's buildRedisClient when RedisConfig.PoolSize is set
|
|
// (house rule: an explicit override always wins over the self-tuned
|
|
// default). poolSize below 1 is clamped to 1 — a zero or negative value
|
|
// would leave free empty, making every acquire block forever.
|
|
func newRESPClientPool(addr, password string, db, poolSize int) *respClient {
|
|
if poolSize < 1 {
|
|
poolSize = 1
|
|
}
|
|
c := &respClient{
|
|
addr: addr,
|
|
password: password,
|
|
db: db,
|
|
free: make(chan *respConn, poolSize),
|
|
}
|
|
for i := 0; i < poolSize; i++ {
|
|
c.free <- &respConn{}
|
|
}
|
|
return c
|
|
}
|
|
|
|
// Close closes every currently idle pooled connection (a non-blocking
|
|
// receive per slot, so it never waits on one still checked out by an
|
|
// in-flight caller — the same "don't call Close concurrently with
|
|
// in-flight use" contract any io.Closer implicitly carries) and always
|
|
// returns nil — closeConn already discards each connection's own Close
|
|
// error, matching every other close in this client.
|
|
//
|
|
// Review fix (Should-Fix 4, 2026-08-2x): the pool (perf finding 1) turned
|
|
// a discarded-instance leak of one idle connection into up to
|
|
// respPoolMax (8). Nothing in this codebase calls Close automatically —
|
|
// Traefik's plugin contract for a locally-loaded middleware is exactly
|
|
// `func New(...) (http.Handler, error)` (llmgateway.go); the returned
|
|
// value satisfies only http.Handler, with no Shutdown/Close interface
|
|
// Traefik itself checks for or invokes on a discarded instance (see
|
|
// Gateway.Close's own doc comment for the full picture, including why
|
|
// this repository has no evidence such a hook could even be wired to).
|
|
// Close exists so a respClient built and discarded outside that contract
|
|
// — tests, or a future framework/harness that does hold a lifecycle hook
|
|
// — has a correct, deterministic way to release pooled connections
|
|
// rather than depend solely on GC finalizing the underlying net.Conns.
|
|
func (c *respClient) Close() error {
|
|
for {
|
|
select {
|
|
case pc := <-c.free:
|
|
closeConn(pc)
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// do sends one command and returns its decoded reply. It is equivalent to
|
|
// pipeline with a single command.
|
|
func (c *respClient) do(args ...string) (any, error) {
|
|
replies, err := c.pipeline([][]string{args})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return replies[0], nil
|
|
}
|
|
|
|
// pipeline sends every command in cmds on one round trip and returns their
|
|
// decoded replies in the same order. It encodes cmds once and hands off to
|
|
// pipelineEncoded, which owns the connection/retry logic.
|
|
func (c *respClient) pipeline(cmds [][]string) ([]any, error) {
|
|
return c.pipelineEncoded(encodeCommands(cmds), len(cmds), pipelineMutates(cmds))
|
|
}
|
|
|
|
// pipelineEncoded runs pipeline's connection/retry logic over an
|
|
// already-encoded RESP2 payload representing n commands, over one pooled
|
|
// connection acquired for the duration of the call, and returns their
|
|
// decoded replies in the same order.
|
|
//
|
|
// One absolute deadline is computed here, at call entry, and shared by
|
|
// acquiring a connection, the first attempt, and (if it happens) the one
|
|
// retry — not a fresh respCallTimeout for each. On a non-timeout I/O error
|
|
// it closes the connection and retries the whole pipeline once against the
|
|
// same deadline, on the SAME pooled slot (a fresh dial reusing that slot,
|
|
// not a second slot acquired from the pool) — but ONLY when doing so is
|
|
// known safe (finding 5, 2026-09 review, fixing a double-apply bug; the
|
|
// wroteRequest-alone gate was itself corrected by the verify-core round-4
|
|
// fix below):
|
|
//
|
|
// - The failure happened before any byte of the request was written
|
|
// (attemptEncodedOn's wroteRequest is false — a dial failure, an
|
|
// ensureConnOn handshake failure, or the command write itself
|
|
// erroring). This is one stale-pooled-connection case: a connection
|
|
// idle in the pool that the peer silently closed sometimes fails on
|
|
// this very first write, before the server has seen anything from
|
|
// this attempt at all, so resending the identical pipeline against a
|
|
// freshly dialled connection cannot duplicate an effect that never
|
|
// happened.
|
|
// - Or the request was written and a reply was awaited, but no reply
|
|
// byte at all was ever read (attemptEncodedOn's readAny is false).
|
|
// A write into an already half-closed TCP connection commonly still
|
|
// succeeds into the kernel send buffer — the peer's close only
|
|
// surfaces on the READ, as an immediate EOF with zero bytes
|
|
// received. This is the OTHER, more common stale-pooled-connection
|
|
// case, and is exactly as safe to retry as the write-side one above:
|
|
// nothing was ever seen or applied server-side.
|
|
// - Or the request WAS written, some reply reading happened, but cmds
|
|
// contains no mutating command (pipelineMutates) — e.g. a pure
|
|
// getBatch/setEx/getBytes call. GET/SET/EXPIRE/AUTH/SELECT are
|
|
// idempotent: replaying one after a reply was lost (the connection
|
|
// reset mid-read, after the server already processed and answered)
|
|
// reproduces the same end state, so retrying is still safe even
|
|
// though the request definitely reached the wire.
|
|
//
|
|
// Once at least one reply byte has actually been read AND cmds contains a
|
|
// mutating command (INCRBY/INCRBYFLOAT — redisStore's incrMulti/
|
|
// incrAndGetMulti), a non-timeout failure is surfaced directly, with NO
|
|
// retry: the server has demonstrably started answering, so it may already
|
|
// have applied that INCRBY before the connection reset (a proxy failover,
|
|
// or the backend restarting mid-reply) — resending the whole pipeline in
|
|
// that case would apply it a second time, double-counting a rate/token/
|
|
// cost counter into a false 429 or doubled recorded spend. The caller's
|
|
// own failOpen/failClosed policy (limits.go) decides what happens to THIS
|
|
// attempt's failure; it must never see a duplicated increment instead.
|
|
//
|
|
// On a timeout specifically, it never retries either way: a server that
|
|
// accepted the connection and then went silent would just be given the
|
|
// same non-answer a second time, doubling the caller's wait for nothing.
|
|
// Any failure leaves that connection closed (closeConn) so the next call
|
|
// to acquire this slot lazily reconnects fresh rather than reusing a
|
|
// connection already known bad — the slot itself always returns to the
|
|
// pool via the deferred send below, whether or not its connection
|
|
// survived the call.
|
|
//
|
|
// payload is encoded once by the caller, so the retry re-sends the
|
|
// identical bytes without re-encoding cmds a second time. mutates is the
|
|
// caller's pipelineMutates verdict for the commands payload encodes —
|
|
// pipelineEncoded never sees the []string form, so the caller supplies it.
|
|
func (c *respClient) pipelineEncoded(payload []byte, n int, mutates bool) ([]any, error) {
|
|
deadline := time.Now().Add(respCallTimeout)
|
|
|
|
pc, err := c.acquire(deadline)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() { c.free <- pc }()
|
|
|
|
replies, wrote, readAny, err := c.attemptEncodedOn(pc, payload, n, deadline)
|
|
if err == nil {
|
|
return replies, nil
|
|
}
|
|
closeConn(pc)
|
|
if isTimeout(err) {
|
|
return nil, err
|
|
}
|
|
if wrote && readAny && mutates {
|
|
return nil, err
|
|
}
|
|
|
|
replies, _, _, err = c.attemptEncodedOn(pc, payload, n, deadline)
|
|
if err != nil {
|
|
closeConn(pc)
|
|
return nil, err
|
|
}
|
|
return replies, nil
|
|
}
|
|
|
|
// pipelineMutatingCommands names every RESP command this client ever sends
|
|
// that is NOT safe to blindly resend: each already changes server state on
|
|
// success, so replaying it after an ambiguous failure (the request bytes
|
|
// were written, but the reply for it — or a later command in the same
|
|
// pipeline — never came back) can double-apply it, unlike a GET/SET/
|
|
// EXPIRE/AUTH/SELECT retry, which merely reproduces the same end state.
|
|
// INCRBYFLOAT is not currently sent by this client (only INCRBY is —
|
|
// redis_store.go), but is listed for the same reason INCRBY is: an
|
|
// operator or a future caller adding it must not have to rediscover this
|
|
// gate. LPUSH (events.go's eventLog.push, F3 v0.3 dashboard task) joins
|
|
// the same list for the identical reason: replaying it after an ambiguous
|
|
// failure could push the same event twice.
|
|
var pipelineMutatingCommands = map[string]bool{
|
|
"INCRBY": true,
|
|
"INCRBYFLOAT": true,
|
|
"LPUSH": true,
|
|
}
|
|
|
|
// pipelineMutates reports whether any command in cmds is one of
|
|
// pipelineMutatingCommands — pipeline's own gate (finding 5, 2026-09
|
|
// review) for whether a failure discovered AFTER the request was already
|
|
// written to the wire is still safe to retry.
|
|
func pipelineMutates(cmds [][]string) bool {
|
|
for _, args := range cmds {
|
|
if len(args) == 0 {
|
|
continue
|
|
}
|
|
if pipelineMutatingCommands[args[0]] {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// acquire takes one idle connection slot from c.free, waiting no longer
|
|
// than deadline. The non-blocking check first is an optimization, not a
|
|
// correctness requirement of the blocking select below — with a buffered
|
|
// channel a receive that CAN succeed immediately always does, so this
|
|
// merely skips allocating a timer on the (overwhelmingly common) case
|
|
// where a slot is already free.
|
|
func (c *respClient) acquire(deadline time.Time) (*respConn, error) {
|
|
select {
|
|
case pc := <-c.free:
|
|
return pc, nil
|
|
default:
|
|
}
|
|
|
|
wait := time.Until(deadline)
|
|
if wait <= 0 {
|
|
return nil, respDeadlineExceededErr{}
|
|
}
|
|
t := time.NewTimer(wait)
|
|
defer t.Stop()
|
|
select {
|
|
case pc := <-c.free:
|
|
return pc, nil
|
|
case <-t.C:
|
|
return nil, respDeadlineExceededErr{}
|
|
}
|
|
}
|
|
|
|
// isTimeout reports whether err is (or wraps) a net.Error that timed out.
|
|
func isTimeout(err error) bool {
|
|
var netErr net.Error
|
|
return errors.As(err, &netErr) && netErr.Timeout()
|
|
}
|
|
|
|
// dialTimeoutFor returns the duration a dial starting now should use to
|
|
// avoid running past deadline: the smaller of respDialTimeout and the
|
|
// time remaining until deadline. Without this clamp, a dial always
|
|
// spending its own full respDialTimeout regardless of deadline would let
|
|
// a future change to respDialTimeout alone (leaving respCallTimeout
|
|
// untouched) silently widen how much of one call's budget a stalled dial
|
|
// can consume.
|
|
func dialTimeoutFor(deadline time.Time) time.Duration {
|
|
if remaining := time.Until(deadline); remaining < respDialTimeout {
|
|
return remaining
|
|
}
|
|
return respDialTimeout
|
|
}
|
|
|
|
// respDeadlineExceededErr signals that a call's deadline had already
|
|
// elapsed before an attempt could even acquire a pool slot or dial — e.g.
|
|
// this call queued behind every one of respClient's pool slots being busy
|
|
// for the whole budget while earlier calls were stuck. It implements
|
|
// net.Error so pipeline's isTimeout check treats it the same as any other
|
|
// timed-out I/O: no retry, since retrying an already-elapsed deadline can
|
|
// only fail the same way again.
|
|
type respDeadlineExceededErr struct{}
|
|
|
|
func (respDeadlineExceededErr) Error() string { return "resp: call deadline already elapsed" }
|
|
func (respDeadlineExceededErr) Timeout() bool { return true }
|
|
func (respDeadlineExceededErr) Temporary() bool { return true }
|
|
|
|
// attemptEncodedOn runs one full attempt of an already-encoded RESP2
|
|
// payload representing n commands over pc, connecting first if needed,
|
|
// with every read and write bound by deadline. Callers own pc exclusively
|
|
// for the duration of this call (acquired from c.free).
|
|
//
|
|
// wroteRequest is true as soon as every command has been fully written to
|
|
// pc.conn, regardless of what happens afterward (a reply read failing does
|
|
// not unset it) — pipeline's own retry gate (finding 5, 2026-09 review)
|
|
// uses this to tell "failed before the server could have seen anything"
|
|
// from "failed after the server may already have applied a mutating
|
|
// command", since only the first case is unconditionally safe to retry.
|
|
//
|
|
// readAny (verify-core fix, round 4) is true once at least one byte of
|
|
// the FIRST reply was actually consumed off pc.conn. Finding 5's original
|
|
// gate assumed a stale pooled connection the peer already closed always
|
|
// fails on the WRITE — wrong: a write into an already-half-closed TCP
|
|
// connection commonly still succeeds into the kernel send buffer, and
|
|
// the failure only surfaces on the read as an immediate EOF with zero
|
|
// bytes ever received. That is the stale-connection signature and it
|
|
// must still retry, even for a mutating pipeline, exactly like a
|
|
// pre-write failure does — nothing was ever seen or applied server-side.
|
|
// Once even one reply byte has arrived, the server has demonstrably
|
|
// started answering, so pipeline's caller no longer knows whether a
|
|
// mutating command already landed and must not resend it.
|
|
func (c *respClient) attemptEncodedOn(pc *respConn, payload []byte, n int, deadline time.Time) (replies []any, wroteRequest bool, readAny bool, err error) {
|
|
if err := c.ensureConnOn(pc, deadline); err != nil {
|
|
return nil, false, false, err
|
|
}
|
|
|
|
if _, err := pc.conn.Write(payload); err != nil {
|
|
return nil, false, false, fmt.Errorf("resp: write: %w", err)
|
|
}
|
|
|
|
replies = make([]any, n)
|
|
for i := 0; i < n; i++ {
|
|
if !readAny {
|
|
// Peek the very first byte of the very first reply before
|
|
// handing off to decodeReply: this is the one read whose
|
|
// outcome pipeline's retry gate needs isolated from every
|
|
// later one. ReadByte+UnreadByte consumes nothing overall —
|
|
// the byte goes straight back for decodeReply below — it
|
|
// only distinguishes "the connection had nothing left to
|
|
// give at all" (stale pooled conn, safe to retry) from
|
|
// "some reply data has started arriving" (readAny=true for
|
|
// the rest of this call, whatever happens next).
|
|
if _, peekErr := pc.r.ReadByte(); peekErr != nil {
|
|
return nil, true, false, fmt.Errorf("resp: read reply %d/%d: %w", i+1, n, peekErr)
|
|
}
|
|
if unreadErr := pc.r.UnreadByte(); unreadErr != nil {
|
|
// bufio.Reader.UnreadByte only fails when the preceding
|
|
// op was not itself a successful ReadByte, which cannot
|
|
// happen here — kept as a hard error rather than
|
|
// silently mis-tracking readAny, which the mutating-
|
|
// pipeline retry decision depends on for correctness.
|
|
return nil, true, true, fmt.Errorf("resp: read reply %d/%d: unread byte: %w", i+1, n, unreadErr)
|
|
}
|
|
readAny = true
|
|
}
|
|
v, err := decodeReply(pc.r, 0)
|
|
if err != nil {
|
|
return nil, true, readAny, fmt.Errorf("resp: read reply %d/%d: %w", i+1, n, err)
|
|
}
|
|
replies[i] = v
|
|
}
|
|
return replies, true, true, nil
|
|
}
|
|
|
|
// ensureConnOn dials, authenticates, and selects the database if pc has no
|
|
// live connection, then (whether freshly dialled or reused) sets deadline
|
|
// as the connection's single read/write deadline for the remainder of
|
|
// this call — including the handshake, when one runs. Callers own pc
|
|
// exclusively for the duration of this call.
|
|
func (c *respClient) ensureConnOn(pc *respConn, deadline time.Time) error {
|
|
if pc.conn != nil {
|
|
if err := pc.conn.SetDeadline(deadline); err != nil {
|
|
return fmt.Errorf("resp: set deadline: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
dialTimeout := dialTimeoutFor(deadline)
|
|
if dialTimeout <= 0 {
|
|
return fmt.Errorf("resp: dial %q: %w", c.addr, respDeadlineExceededErr{})
|
|
}
|
|
conn, err := net.DialTimeout("tcp", c.addr, dialTimeout) // #nosec G704 -- c.addr is the operator-configured redis address from middleware config, never request-derived
|
|
if err != nil {
|
|
return fmt.Errorf("resp: dial %q: %w", c.addr, err)
|
|
}
|
|
|
|
// Optional TLS (security audit run-1, respClient.tls). Wrapping here
|
|
// rather than at a separate dial keeps one dial path: everything
|
|
// below — SetDeadline, the AUTH/SELECT handshake, and the caller's
|
|
// own command round trip — then runs over the TLS stream instead of
|
|
// the bare socket, with no other call site needing to know.
|
|
//
|
|
// ServerName is the host half of the configured address, so the
|
|
// certificate is verified against the name the operator configured.
|
|
// InsecureSkipVerify is deliberately left false: an opt-in TLS mode
|
|
// that silently skipped verification would provide confidentiality
|
|
// against a passive observer while giving none against the active
|
|
// one, which is the attacker this exists for.
|
|
if c.tls {
|
|
host, _, splitErr := net.SplitHostPort(c.addr)
|
|
if splitErr != nil {
|
|
host = c.addr
|
|
}
|
|
tlsConn := tls.Client(conn, &tls.Config{
|
|
ServerName: host,
|
|
MinVersion: tls.VersionTLS12,
|
|
})
|
|
if dlErr := tlsConn.SetDeadline(deadline); dlErr != nil {
|
|
_ = tlsConn.Close()
|
|
return fmt.Errorf("resp: set deadline: %w", dlErr)
|
|
}
|
|
if hsErr := tlsConn.Handshake(); hsErr != nil {
|
|
_ = tlsConn.Close()
|
|
return fmt.Errorf("resp: tls handshake %q: %w", c.addr, hsErr)
|
|
}
|
|
conn = tlsConn
|
|
}
|
|
|
|
pc.conn = conn
|
|
pc.r = bufio.NewReader(conn)
|
|
|
|
if err := pc.conn.SetDeadline(deadline); err != nil {
|
|
closeConn(pc)
|
|
return fmt.Errorf("resp: set deadline: %w", err)
|
|
}
|
|
|
|
if c.password != "" {
|
|
if err := c.handshakeOn(pc, "AUTH", c.password); err != nil {
|
|
closeConn(pc)
|
|
return err
|
|
}
|
|
}
|
|
if err := c.handshakeOn(pc, "SELECT", strconv.Itoa(c.db)); err != nil {
|
|
closeConn(pc)
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// handshakeOn sends one connection-setup command (AUTH or SELECT) and
|
|
// requires a non-error reply. Callers own pc exclusively, with a live
|
|
// pc.conn whose deadline is already set.
|
|
func (c *respClient) handshakeOn(pc *respConn, cmd, arg string) error {
|
|
if _, err := pc.conn.Write(encodeCommands([][]string{{cmd, arg}})); err != nil {
|
|
return fmt.Errorf("resp: %s: write: %w", cmd, err)
|
|
}
|
|
v, err := decodeReply(pc.r, 0)
|
|
if err != nil {
|
|
return fmt.Errorf("resp: %s: read reply: %w", cmd, err)
|
|
}
|
|
if e, ok := v.(respErr); ok {
|
|
return fmt.Errorf("resp: %s failed: %w", cmd, e)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// closeConn closes and clears pc's connection, if any, discarding it —
|
|
// the connection itself is never reused after an error, only the slot pc
|
|
// occupies, which pipeline always returns to c.free regardless.
|
|
func closeConn(pc *respConn) {
|
|
if pc.conn == nil {
|
|
return
|
|
}
|
|
_ = pc.conn.Close()
|
|
pc.conn = nil
|
|
pc.r = nil
|
|
}
|
|
|
|
// encodeCommands renders every command in cmds as consecutive RESP2 arrays,
|
|
// one fmt.Appendf call per header/argument appending into a single growing
|
|
// slice — no strings.Builder and no string/[]byte conversion. Measured
|
|
// under yaegi v0.16.1: cheaper than either presizing (the interpreted size
|
|
// loop cost more than native slice regrowth) or appending raw bytes
|
|
// directly (append with a string operand copies it under yaegi).
|
|
//
|
|
// A 3-argument command — INCRBY key delta or EXPIRE key seconds, this
|
|
// client's two hottest commands by far (every counter increment sends
|
|
// one, sometimes both) — takes a fast path instead: one fmt.Appendf call
|
|
// building the whole "*3\r\n$len\r\narg\r\n..." frame at once, rather than
|
|
// one header call plus 3 more per-argument calls. Measured under yaegi
|
|
// v0.16.1 on the counter-increment pipeline this dominates: about 3%
|
|
// fewer bytes per request. Any other argument count keeps the general
|
|
// loop below — wire bytes are identical either way.
|
|
func encodeCommands(cmds [][]string) []byte {
|
|
var dst []byte
|
|
for _, args := range cmds {
|
|
if len(args) == 3 {
|
|
dst = fmt.Appendf(dst, "*3\r\n$%d\r\n%s\r\n$%d\r\n%s\r\n$%d\r\n%s\r\n",
|
|
len(args[0]), args[0], len(args[1]), args[1], len(args[2]), args[2])
|
|
continue
|
|
}
|
|
dst = fmt.Appendf(dst, "*%d\r\n", len(args))
|
|
for _, a := range args {
|
|
dst = fmt.Appendf(dst, "$%d\r\n%s\r\n", len(a), a)
|
|
}
|
|
}
|
|
return dst
|
|
}
|
|
|
|
// decodeReply reads and decodes one RESP2 reply from r. depth is 0 for a
|
|
// top-level reply and depth+1 for each element decodeArray recurses into;
|
|
// an array is only accepted at depth 0 (see the '*' case) — none of this
|
|
// client's commands (AUTH, SELECT, INCRBY, EXPIRE, GET) ever return a
|
|
// nested array, so a server or attacker sending one is malformed input,
|
|
// not legitimate traffic worth the recursion:
|
|
//
|
|
// - "+simple\r\n" -> string
|
|
// - "-error\r\n" -> respErr (implements error; not returned as err)
|
|
// - ":123\r\n" -> int64
|
|
// - "$len\r\n...\r\n" -> []byte, or nil for a null bulk ("$-1\r\n")
|
|
// - "*n\r\n..." -> []any of n decoded elements, or nil for a null
|
|
// array ("*-1\r\n"); only at depth 0
|
|
//
|
|
// The returned error is non-nil only for a transport or protocol failure
|
|
// (short read, malformed line, unknown type byte, an out-of-range or
|
|
// mistrailed bulk/array, a nested array) — a well-formed RESP error reply
|
|
// decodes successfully to a respErr value, not to a non-nil error, so a
|
|
// caller pipelining several commands can still read every reply after one
|
|
// of them fails server-side.
|
|
func decodeReply(r *bufio.Reader, depth int) (any, error) {
|
|
line, err := readLine(r)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if line == "" {
|
|
return nil, fmt.Errorf("resp: empty reply line")
|
|
}
|
|
|
|
prefix, rest := line[0], line[1:]
|
|
switch prefix {
|
|
case '+':
|
|
return rest, nil
|
|
case '-':
|
|
return respErr{msg: rest}, nil
|
|
case ':':
|
|
n, err := strconv.ParseInt(rest, 10, 64)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("resp: malformed integer reply %q: %w", rest, err)
|
|
}
|
|
return n, nil
|
|
case '$':
|
|
return decodeBulk(r, rest)
|
|
case '*':
|
|
if depth > 0 {
|
|
// A nested array reply — "*1\r\n*1\r\n..." repeated — could
|
|
// otherwise recurse to Go's stack limit (a fatal, unrecoverable
|
|
// error, not a panic recoverPanic could catch) with only a few
|
|
// megabytes of crafted wire input. No command this client sends
|
|
// ever gets one back, so reject it outright instead of
|
|
// recursing.
|
|
return nil, fmt.Errorf("resp: nested array reply not supported")
|
|
}
|
|
return decodeArray(r, rest, depth)
|
|
default:
|
|
return nil, fmt.Errorf("resp: unknown reply type %q", prefix)
|
|
}
|
|
}
|
|
|
|
// decodeBulk reads a bulk string body given its already-parsed "$" length
|
|
// field lenField, returning nil for a null bulk ("$-1"). It rejects a
|
|
// negative length other than -1 and a length exceeding respMaxBulkLen
|
|
// before allocating — n+2 would otherwise be able to overflow into a
|
|
// negative slice length for a maliciously huge declared length, panicking
|
|
// make([]byte, n+2) — and verifies the two bytes following the payload
|
|
// are the expected CRLF trailer.
|
|
func decodeBulk(r *bufio.Reader, lenField string) (any, error) {
|
|
n, err := strconv.Atoi(lenField)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("resp: malformed bulk length %q: %w", lenField, err)
|
|
}
|
|
if n == -1 {
|
|
return nil, nil
|
|
}
|
|
if n < -1 || n > respMaxBulkLen {
|
|
return nil, fmt.Errorf("resp: bulk length %d out of range (max %d)", n, respMaxBulkLen)
|
|
}
|
|
|
|
buf := make([]byte, n+2) // payload plus trailing CRLF; n is bounded above, so n+2 cannot overflow
|
|
if _, err := io.ReadFull(r, buf); err != nil {
|
|
return nil, fmt.Errorf("resp: read bulk body: %w", err)
|
|
}
|
|
if buf[n] != '\r' || buf[n+1] != '\n' {
|
|
return nil, fmt.Errorf("resp: bulk reply missing CRLF trailer")
|
|
}
|
|
return buf[:n], nil
|
|
}
|
|
|
|
// decodeArray reads n elements given the already-parsed "*" length field
|
|
// lenField, returning nil for a null array ("*-1"). It rejects a negative
|
|
// length other than -1 and a length exceeding respMaxArrayLen before
|
|
// allocating, for the same reason as decodeBulk's cap. depth is the
|
|
// depth decodeReply was called at for this array's own "*" line (always
|
|
// 0 — see decodeReply's '*' case); each element decodes at depth+1, so an
|
|
// element that is itself an array is rejected by decodeReply rather than
|
|
// recursed into.
|
|
func decodeArray(r *bufio.Reader, lenField string, depth int) (any, error) {
|
|
n, err := strconv.Atoi(lenField)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("resp: malformed array length %q: %w", lenField, err)
|
|
}
|
|
if n == -1 {
|
|
return nil, nil
|
|
}
|
|
if n < -1 || n > respMaxArrayLen {
|
|
return nil, fmt.Errorf("resp: array length %d out of range (max %d)", n, respMaxArrayLen)
|
|
}
|
|
|
|
arr := make([]any, n)
|
|
for i := 0; i < n; i++ {
|
|
v, err := decodeReply(r, depth+1)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("resp: array element %d/%d: %w", i+1, n, err)
|
|
}
|
|
arr[i] = v
|
|
}
|
|
return arr, nil
|
|
}
|
|
|
|
// setEx sets key to val with an expiry of ttl via RESP2 "SET key val EX
|
|
// seconds", used by the response cache (cache.go) to store a cached
|
|
// response. ttl is rounded up to whole seconds and floored at 1s — Redis's
|
|
// EX argument is whole seconds only — matching redisStore.incrMulti's EXPIRE
|
|
// rounding exactly. The payload is built directly with fmt.Appendf rather
|
|
// than going through do/encodeCommands: val (a cached response, often tens
|
|
// of KB) is framed straight from its own bytes — "%s" on a []byte writes
|
|
// it verbatim — avoiding both the string(val) copy the old []string-args
|
|
// call required and a second encoding pass.
|
|
func (c *respClient) setEx(key string, val []byte, ttl time.Duration) error {
|
|
ttlSeconds := int64(ttl / time.Second)
|
|
if ttl%time.Second != 0 {
|
|
ttlSeconds++
|
|
}
|
|
if ttlSeconds < 1 {
|
|
ttlSeconds = 1
|
|
}
|
|
|
|
ttlArg := strconv.FormatInt(ttlSeconds, 10)
|
|
payload := fmt.Appendf(nil, "*5\r\n$3\r\nSET\r\n$%d\r\n%s\r\n$%d\r\n%s\r\n$2\r\nEX\r\n$%d\r\n%s\r\n", len(key), key, len(val), val, len(ttlArg), ttlArg)
|
|
// SET is idempotent (not in pipelineMutatingCommands), so a retry after
|
|
// a lost reply reproduces the same end state.
|
|
replies, err := c.pipelineEncoded(payload, 1, false)
|
|
if err != nil {
|
|
return fmt.Errorf("resp: setEx %q: %w", key, err)
|
|
}
|
|
reply := replies[0]
|
|
if e, ok := reply.(respErr); ok {
|
|
return fmt.Errorf("resp: setEx %q: %w", key, e)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// getBytes reads key's value as a bulk reply. found is false for a missing
|
|
// key (RESP null bulk, "$-1") — not an error — mirroring redisStore.getMulti's
|
|
// treatment of a missing counter key.
|
|
func (c *respClient) getBytes(key string) ([]byte, bool, error) {
|
|
reply, err := c.do("GET", key)
|
|
if err != nil {
|
|
return nil, false, fmt.Errorf("resp: getBytes %q: %w", key, err)
|
|
}
|
|
if reply == nil {
|
|
return nil, false, nil
|
|
}
|
|
if e, ok := reply.(respErr); ok {
|
|
return nil, false, fmt.Errorf("resp: getBytes %q: %w", key, e)
|
|
}
|
|
b, ok := reply.([]byte)
|
|
if !ok {
|
|
return nil, false, fmt.Errorf("resp: getBytes %q: unexpected reply type %T", key, reply)
|
|
}
|
|
return b, true, nil
|
|
}
|
|
|
|
// getBatch reads every key in keys in one pipelined round trip — N GET
|
|
// commands sent together, replies read together — instead of len(keys)
|
|
// separate calls each paying their own respCallTimeout budget (the fix
|
|
// for GET /admin/api/usage otherwise serializing 6 GETs per user/group
|
|
// scope on the single shared connection). It returns one value per key,
|
|
// in the same order, with a missing key (RESP null bulk) mapped to 0,
|
|
// matching getBytes/redisStore.getMulti's convention for a single key. Any
|
|
// reply that is a RESP error, an unexpected reply type, or a non-integer
|
|
// value fails the whole batch — mirrored by the limiter's
|
|
// storeGetMulti/currentUsage as one fail-open/fail-closed decision for
|
|
// the scope, never a partial result mixing real and zero values.
|
|
func (c *respClient) getBatch(keys []string) ([]int64, error) {
|
|
cmds := make([][]string, len(keys))
|
|
for i, k := range keys {
|
|
cmds[i] = []string{"GET", k}
|
|
}
|
|
replies, err := c.pipeline(cmds)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("resp: getBatch: %w", err)
|
|
}
|
|
|
|
out := make([]int64, len(keys))
|
|
for i, reply := range replies {
|
|
if reply == nil {
|
|
continue // missing key -> 0, matching getBytes/get's convention
|
|
}
|
|
if e, ok := reply.(respErr); ok {
|
|
return nil, fmt.Errorf("resp: getBatch %q: %w", keys[i], e)
|
|
}
|
|
b, ok := reply.([]byte)
|
|
if !ok {
|
|
return nil, fmt.Errorf("resp: getBatch %q: unexpected reply type %T", keys[i], reply)
|
|
}
|
|
v, err := strconv.ParseInt(string(b), 10, 64)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("resp: getBatch %q: non-integer value %q: %w", keys[i], b, err)
|
|
}
|
|
out[i] = v
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// readLine reads one CRLF-terminated line from r, with the trailing CRLF
|
|
// (or bare LF) stripped. It reads a byte at a time so it can bail out
|
|
// after respMaxLineLen bytes without ever finding '\n' — a peer that
|
|
// never terminates a line must not make this buffer an unbounded number
|
|
// of bytes first and only reject the result afterward.
|
|
func readLine(r *bufio.Reader) (string, error) {
|
|
buf := make([]byte, 0, 64)
|
|
for {
|
|
b, err := r.ReadByte()
|
|
if err != nil {
|
|
return "", fmt.Errorf("resp: read line: %w", err)
|
|
}
|
|
if b == '\n' {
|
|
return strings.TrimSuffix(string(buf), "\r"), nil
|
|
}
|
|
if len(buf) >= respMaxLineLen {
|
|
return "", fmt.Errorf("resp: reply line exceeds %d bytes", respMaxLineLen)
|
|
}
|
|
buf = append(buf, b)
|
|
}
|
|
}
|