mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-16 05:37:28 +00:00
- Add a process-wide gate on MCP tools/list backend connections. The per-request limit alone let N callers open N times as many. - Reject CONNECT and TRACE on native passthrough and on MCP and A2A targets with 405. - Scrub URL query strings and the target URL from a backend JSON-RPC error message before it reaches the caller. - Replace the value of redis.address in GET /admin/api/config. The users file path stays visible by design. - Escape line breaks in every log line so an upstream string cannot forge log entries. - Add stripClientAnthropicBeta. When true, POST /v1/messages does not forward the client anthropic-beta header. The default stays false.
187 lines
8.2 KiB
Go
187 lines
8.2 KiB
Go
package traefikllmgateway
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
)
|
|
|
|
// logSafe escapes the line breaks in msg. A value an upstream controls, such
|
|
// as a panic or error string, must not forge extra log lines.
|
|
func logSafe(msg string) string {
|
|
if !strings.ContainsAny(msg, "\r\n") {
|
|
return msg
|
|
}
|
|
return strings.NewReplacer("\r", "\\r", "\n", "\\n").Replace(msg)
|
|
}
|
|
|
|
// logf writes an info-level log line to stderr. No timestamps — Traefik
|
|
// adds its own. Never log key material.
|
|
func (g *Gateway) logf(format string, args ...any) {
|
|
fmt.Fprintf(os.Stderr, "llmgw[%s] INFO %s\n", g.name, logSafe(fmt.Sprintf(format, args...)))
|
|
}
|
|
|
|
// warnf writes a warning-level log line to stderr. No timestamps —
|
|
// Traefik adds its own. Never log key material.
|
|
//
|
|
// Use this for a condition an operator may want to know about but that
|
|
// the gateway resolved by itself, deterministically, with no request
|
|
// affected — a model id served by two providers, for instance. Those
|
|
// lines were ERROR until 2026-08-22, which put 144 of them in a healthy
|
|
// pod's boot log on a cluster with overlapping catalogs and made real
|
|
// errors harder to find. Reserve errorf for something actually broken.
|
|
//
|
|
// Also calls noteConfigWarning (F10, v0.3 dashboard task): while
|
|
// newGateway is still constructing g (collectingWarnings true), the
|
|
// formatted message is additionally collected into g.configWarnings —
|
|
// GET /admin/api/overview's Warnings field. A warnf call from
|
|
// request-handling code, after construction finished, is NOT collected —
|
|
// see noteConfigWarning's own doc comment for why.
|
|
func (g *Gateway) warnf(format string, args ...any) {
|
|
msg := fmt.Sprintf(format, args...)
|
|
fmt.Fprintf(os.Stderr, "llmgw[%s] WARN %s\n", g.name, logSafe(msg))
|
|
g.noteConfigWarning(msg)
|
|
}
|
|
|
|
// configWarningsCap bounds how many construction-time warnings
|
|
// g.configWarnings ever holds (F10, v0.3 dashboard task) — a
|
|
// misconfigured deployment logging hundreds of warnings during
|
|
// construction must not make GET /admin/api/overview's response itself
|
|
// unbounded. configWarningsDropped counts what did not fit, so the
|
|
// dashboard can say "and N more" instead of silently truncating.
|
|
const configWarningsCap = 50
|
|
|
|
// noteConfigWarning appends msg to g.configWarnings while newGateway is
|
|
// still collecting them — collectingWarnings is set true right after g is
|
|
// constructed and false just before newGateway returns (llmgateway.go),
|
|
// so every warnf call in that window is a CONSTRUCTION-time warning,
|
|
// exactly what GET /admin/api/overview's Warnings field surfaces. Once
|
|
// collectingWarnings is false (every warnf call from request-handling
|
|
// code, for the rest of the process lifetime), this is a no-op: such a
|
|
// warning already reached the operator via the stderr line warnf itself
|
|
// wrote, and an unbounded stream of runtime warnings must never keep
|
|
// growing this slice for the life of the process.
|
|
func (g *Gateway) noteConfigWarning(msg string) {
|
|
g.warnMu.Lock()
|
|
defer g.warnMu.Unlock()
|
|
if !g.collectingWarnings {
|
|
return
|
|
}
|
|
if len(g.configWarnings) >= configWarningsCap {
|
|
g.configWarningsDropped++
|
|
return
|
|
}
|
|
g.configWarnings = append(g.configWarnings, msg)
|
|
}
|
|
|
|
// configWarningsSnapshot returns a fresh copy of g.configWarnings — never
|
|
// nil, matching GET /admin/api/overview's own "never nil" contract for
|
|
// its Warnings field — and the dropped count, safe for concurrent use
|
|
// alongside noteConfigWarning.
|
|
func (g *Gateway) configWarningsSnapshot() (warnings []string, dropped int) {
|
|
g.warnMu.Lock()
|
|
defer g.warnMu.Unlock()
|
|
out := make([]string, len(g.configWarnings))
|
|
copy(out, g.configWarnings)
|
|
return out, g.configWarningsDropped
|
|
}
|
|
|
|
// errorf writes an error-level log line to stderr. No timestamps — Traefik
|
|
// adds its own. Never log key material.
|
|
func (g *Gateway) errorf(format string, args ...any) {
|
|
fmt.Fprintf(os.Stderr, "llmgw[%s] ERROR %s\n", g.name, logSafe(fmt.Sprintf(format, args...)))
|
|
}
|
|
|
|
// pricingWarn is g's own per-instance pricing-warning sink, passed to
|
|
// costMicrosKnownFor (pricing.go) so an unpriced-model warning always logs
|
|
// under this instance's `llmgw[name]` prefix (finding F11 / review-auth
|
|
// F8, 2026-09 review: a package-level warn func replaced on every New()
|
|
// call made the most recently constructed instance log every other
|
|
// instance's warnings).
|
|
//
|
|
// A plain method, not a stored closure field: it reads g.name fresh on
|
|
// every call, exactly like logf/warnf/errorf above, so there is nothing
|
|
// new to keep in sync across a config reload that rebuilds g.
|
|
func (g *Gateway) pricingWarn(msg string) {
|
|
if msg == warnCapMessage {
|
|
g.logf("%s", msg)
|
|
return
|
|
}
|
|
g.logf("pricing: no price configured for model %q; cost will be recorded as 0", msg)
|
|
// F3 hook 4 (v0.3 dashboard task): msg is the bare unpriced model id
|
|
// here (warnUnknownModelFor's own "warn(model)" call, pricing.go) —
|
|
// the cap-message branch above is a process-wide meta-warning, not
|
|
// about any one model, so it is deliberately excluded from the event
|
|
// feed. Route is the fixed "pricing" label: this fires deep inside
|
|
// cost accounting, with no request-scoped route name available.
|
|
g.recordEvent(gatewayEvent{
|
|
Model: msg, Route: routePricing, Kind: eventKindUnpriced,
|
|
Message: fmt.Sprintf("no price configured for model %q; cost recorded as 0", msg),
|
|
})
|
|
}
|
|
|
|
// logAuthEvent logs one authentication attempt, at the point one of
|
|
// authStore.identify's six call sites resolves it. A success logs the
|
|
// resolved user's name alongside the request's method and path. Neither
|
|
// line this function ever writes includes the presented API key itself —
|
|
// identify resolves a key to a *user and hands this function only that
|
|
// user's name, never the key it authenticated.
|
|
//
|
|
// A failure logs one of two distinct lines (security review round 2,
|
|
// 2026-08-22, important finding 5): if the caller's source IP
|
|
// (clientIP) is currently authThrottled, a distinct "throttle engaged"
|
|
// line naming the IP and the limit it exceeded — an operator seeing this
|
|
// knows immediately that a SOURCE, not just an individual request, needs
|
|
// attention, which the routine per-attempt line alone does not convey.
|
|
// Otherwise the routine failure line (method, path, remote address).
|
|
// Both lines are independently rate-limited (authStore.shouldLog
|
|
// ThrottleEngaged / shouldLogAuthFailure, auth.go — the identical
|
|
// technique limiter.logStoreError, limits.go, already applies to
|
|
// store-error lines) so an unauthenticated caller driving unlimited
|
|
// failed attempts can never drive unlimited synchronous stderr writes on
|
|
// the shared Traefik ingress — a slow log pipe would otherwise stall
|
|
// request handling for every tenant, not just the one failing auth. Each
|
|
// gate reports how many events it suppressed since its last written line
|
|
// (security review round 2, important finding 6): a single global gate
|
|
// would otherwise make several DISTINCT attacker IPs failing within one
|
|
// interval indistinguishable from one noisy IP — suppressedSuffix
|
|
// appends that count to the line when non-zero. The success line stays
|
|
// completely unbounded — see shouldLogAuthFailure's own doc comment for
|
|
// why that is safe.
|
|
func (g *Gateway) logAuthEvent(ok bool, userName string, r *http.Request) {
|
|
if !ok {
|
|
ip := clientIP(r)
|
|
if g.auth.authThrottled(ip) {
|
|
if log, suppressed := g.auth.shouldLogThrottleEngaged(); log {
|
|
g.logf("auth throttle: source %s exceeded %d failed attempts in the last %s%s", ip, authFailureLimit, authFailureWindow, suppressedSuffix(suppressed))
|
|
}
|
|
return
|
|
}
|
|
if log, suppressed := g.auth.shouldLogAuthFailure(); log {
|
|
g.logf("auth failed: %s %q from %s%s", r.Method, r.URL.Path, r.RemoteAddr, suppressedSuffix(suppressed))
|
|
}
|
|
return
|
|
}
|
|
g.logf("auth ok: user %q %s %q", userName, r.Method, r.URL.Path)
|
|
}
|
|
|
|
// suppressedSuffix formats n (a logGate's own suppressed-event count,
|
|
// auth.go) as a log-line suffix, or "" when there is nothing to report.
|
|
func suppressedSuffix(n int64) string {
|
|
if n <= 0 {
|
|
return ""
|
|
}
|
|
return fmt.Sprintf(" (%d more suppressed since last log)", n)
|
|
}
|
|
|
|
// authEventUserName extracts u's name for logAuthEvent's userName
|
|
// argument, or "" when u is nil — identify's failure path, which never
|
|
// resolves a user.
|
|
func authEventUserName(u *user) string {
|
|
if u == nil {
|
|
return ""
|
|
}
|
|
return u.name
|
|
}
|