mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-12 04:54:45 +00:00
513 lines
19 KiB
Go
513 lines
19 KiB
Go
package traefikllmgateway
|
|
|
|
import (
|
|
"bufio"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"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 (which, with the
|
|
// mutex serializing every caller behind one connection, previously turned
|
|
// one hung server into a many-times-respCallTimeout stall per request).
|
|
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
|
|
|
|
// 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 handshakeLocked 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 }
|
|
|
|
// respClient is a minimal stdlib-only RESP2 client for a single Redis-
|
|
// compatible server: one mutex-guarded TCP connection, 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.
|
|
//
|
|
// mu serializes every operation — do and pipeline hold it for their whole
|
|
// round trip, including any reconnect — since RESP is not multiplexed:
|
|
// interleaving two callers' writes on one connection would corrupt both
|
|
// replies.
|
|
type respClient struct {
|
|
conn net.Conn
|
|
r *bufio.Reader
|
|
addr string
|
|
password string
|
|
mu sync.Mutex
|
|
db int
|
|
}
|
|
|
|
// newRESPClient returns a respClient for addr. 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 &respClient{addr: addr, password: password, db: db}
|
|
}
|
|
|
|
// 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.
|
|
//
|
|
// One absolute deadline is computed here, at call entry, and shared by
|
|
// both the first attempt and (if it happens) the one retry — not a fresh
|
|
// respCallTimeout for each. On a non-timeout I/O error (connection reset,
|
|
// EOF from a peer that closed mid-command) it closes the connection and
|
|
// retries the whole pipeline once against the same deadline. On a timeout
|
|
// specifically, it does not retry: 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. Either way, a failure
|
|
// leaves the connection closed so the next call lazily reconnects fresh
|
|
// rather than retrying against a connection already known bad.
|
|
func (c *respClient) pipeline(cmds [][]string) ([]any, error) {
|
|
deadline := time.Now().Add(respCallTimeout)
|
|
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
replies, err := c.attemptPipelineLocked(cmds, deadline)
|
|
if err == nil {
|
|
return replies, nil
|
|
}
|
|
c.closeLocked()
|
|
if isTimeout(err) {
|
|
return nil, err
|
|
}
|
|
|
|
replies, err = c.attemptPipelineLocked(cmds, deadline)
|
|
if err != nil {
|
|
c.closeLocked()
|
|
return nil, err
|
|
}
|
|
return replies, nil
|
|
}
|
|
|
|
// 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 dial — e.g. this call queued
|
|
// behind respClient's mutex for the whole budget while an earlier call
|
|
// was 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 }
|
|
|
|
// attemptPipelineLocked runs one full attempt of cmds over c's connection,
|
|
// connecting first if needed, with every read and write bound by
|
|
// deadline. Callers must hold c.mu.
|
|
func (c *respClient) attemptPipelineLocked(cmds [][]string, deadline time.Time) ([]any, error) {
|
|
if err := c.ensureConnLocked(deadline); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var buf strings.Builder
|
|
for _, args := range cmds {
|
|
buf.WriteString(encodeCommand(args))
|
|
}
|
|
if _, err := io.WriteString(c.conn, buf.String()); err != nil {
|
|
return nil, fmt.Errorf("resp: write: %w", err)
|
|
}
|
|
|
|
replies := make([]any, len(cmds))
|
|
for i := range cmds {
|
|
v, err := decodeReply(c.r, 0)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("resp: read reply %d/%d: %w", i+1, len(cmds), err)
|
|
}
|
|
replies[i] = v
|
|
}
|
|
return replies, nil
|
|
}
|
|
|
|
// ensureConnLocked dials, authenticates, and selects the database if c 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 must hold c.mu.
|
|
func (c *respClient) ensureConnLocked(deadline time.Time) error {
|
|
if c.conn != nil {
|
|
if err := c.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)
|
|
}
|
|
c.conn = conn
|
|
c.r = bufio.NewReader(conn)
|
|
|
|
if err := c.conn.SetDeadline(deadline); err != nil {
|
|
c.closeLocked()
|
|
return fmt.Errorf("resp: set deadline: %w", err)
|
|
}
|
|
|
|
if c.password != "" {
|
|
if err := c.handshakeLocked("AUTH", c.password); err != nil {
|
|
c.closeLocked()
|
|
return err
|
|
}
|
|
}
|
|
if err := c.handshakeLocked("SELECT", strconv.Itoa(c.db)); err != nil {
|
|
c.closeLocked()
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// handshakeLocked sends one connection-setup command (AUTH or SELECT) and
|
|
// requires a non-error reply. Callers must hold c.mu, have a live c.conn,
|
|
// and have already set its deadline.
|
|
func (c *respClient) handshakeLocked(cmd, arg string) error {
|
|
if _, err := io.WriteString(c.conn, encodeCommand([]string{cmd, arg})); err != nil {
|
|
return fmt.Errorf("resp: %s: write: %w", cmd, err)
|
|
}
|
|
v, err := decodeReply(c.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
|
|
}
|
|
|
|
// closeLocked closes and clears c's connection, if any. Callers must hold
|
|
// c.mu.
|
|
func (c *respClient) closeLocked() {
|
|
if c.conn == nil {
|
|
return
|
|
}
|
|
_ = c.conn.Close()
|
|
c.conn = nil
|
|
c.r = nil
|
|
}
|
|
|
|
// encodeCommand renders args as a RESP2 command array:
|
|
// "*N\r\n$len\r\narg\r\n..." for each arg.
|
|
func encodeCommand(args []string) string {
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, "*%d\r\n", len(args))
|
|
for _, a := range args {
|
|
fmt.Fprintf(&b, "$%d\r\n%s\r\n", len(a), a)
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// 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.incrBy's EXPIRE
|
|
// rounding exactly. val is converted to a string via a bare string(val): a
|
|
// Go string is just a byte sequence, so this conversion, and
|
|
// encodeCommand's own len(a)-based framing, are lossless for arbitrary
|
|
// binary data, not just UTF-8 text.
|
|
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
|
|
}
|
|
|
|
reply, err := c.do("SET", key, string(val), "EX", strconv.FormatInt(ttlSeconds, 10))
|
|
if err != nil {
|
|
return fmt.Errorf("resp: setEx %q: %w", key, err)
|
|
}
|
|
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.get'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.get'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)
|
|
}
|
|
}
|