mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-12 04:54:45 +00:00
A model id served by two configured providers is resolved deterministically by the registry, the winner is named in the message, and no request is affected. Logging it at ERROR put 144 ERROR lines in a healthy pod's boot log on a cluster with overlapping openai, openai-audio, copilot and macstudio catalogs, which is exactly the noise that hides a real failure. Adds Gateway.warnf beside logf and errorf, and a warn field on modelRegistry carrying it. The field is set by the production caller after newModelRegistry returns rather than added as a fourth constructor parameter: 45 call sites pass a logger today, almost all of them tests passing a no-op, and threading a parameter through all of them would bury a one-line severity change in unrelated churn. A nil warn falls back to log, so a registry built without one keeps its current behaviour instead of panicking or dropping the line. registry.warnf takes an already-formatted string so both call shapes stay single-variadic-argument. Yaegi v0.16.1's CFG builder panics on a call to a struct FIELD of variadic func type with two or more variadic arguments, which is the restriction the log field beside it already documents. Verified under the real interpreter, not only compiled: yaegi-check drives a genuine two-provider collision and now prints WARN. The severity test fails if the call site reverts to log, so the level cannot drift back unnoticed. Only this message moves. Genuine failures — discovery errors, metadata capture panics, encoding failures — stay at ERROR.
97 lines
4.2 KiB
Go
97 lines
4.2 KiB
Go
package traefikllmgateway
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
)
|
|
|
|
// 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, 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.
|
|
func (g *Gateway) warnf(format string, args ...any) {
|
|
fmt.Fprintf(os.Stderr, "llmgw[%s] WARN %s\n", g.name, fmt.Sprintf(format, args...))
|
|
}
|
|
|
|
// 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, fmt.Sprintf(format, args...))
|
|
}
|
|
|
|
// 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
|
|
}
|