Files
LocalAI/core/http/access_log.go
T
Ettore Di Giacinto b129694ffe feat(http): add an opt-in client address to the access log
Operators could not tell which client sends requests that are refused
with 401. Add --log-client (LOCALAI_LOG_CLIENT, off by default). When
set, the HTTP request log line also carries duration, remote, user_agent
and, when present, request_id.

The address is the TCP peer. It is never read from X-Forwarded-For,
X-Real-Ip or Forwarded, because LocalAI has no trusted-proxy setting and
any client can forge those headers. Behind a reverse proxy the value is
the proxy address. Authorization headers, cookies, query strings and
bodies are never logged. User agent and request id are cut to a fixed
length and have control characters replaced.

Assisted-by: Claude:claude-sonnet-5-5 go gofmt
2026-11-03 14:32:55 +00:00

118 lines
3.4 KiB
Go

package http
import (
"errors"
"net"
"net/http"
"strings"
"time"
"unicode"
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/pkg/diagnostics"
"github.com/mudler/xlog"
)
// maxLoggedUserAgent caps the user agent in the access log so a client
// cannot flood the log with one header.
const maxLoggedUserAgent = 80
// maxLoggedRequestID caps the request id for the same reason.
const maxLoggedRequestID = 64
// accessLog logs one line per request. When logClient is true the line also
// carries the peer address and the user agent, so an unauthorized caller can
// be identified.
func accessLog(logClient bool) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
start := time.Now()
req := c.Request()
res := c.Response()
err := next(c)
// Echo's central HTTPErrorHandler runs *after* this middleware
// returns, so res.Status still reads the default 200 here when a
// handler returned an error without writing a response. Mirror
// echo.DefaultHTTPErrorHandler's status derivation so the access
// log reflects the status the client actually receives — without
// this, every silent handler error logs as 200.
status := res.Status
if err != nil && !res.Committed {
status = http.StatusInternalServerError
var he *echo.HTTPError
if errors.As(err, &he) {
status = he.Code
}
}
// Fix for #7989: Reduce log verbosity of Web UI polling, resources API, and health checks
// These paths are logged at DEBUG level (hidden by default) instead of INFO.
isQuietPath := false
for _, path := range quietPaths {
if req.URL.Path == path {
isQuietPath = true
break
}
}
// URL.Path never holds the query string, so keys passed as
// ?api_key=... stay out of the log.
attrs := []any{"method", req.Method, "path", req.URL.Path, "status", status}
if logClient {
attrs = append(attrs, "duration", time.Since(start).String(), "remote", peerAddr(req), "user_agent", sanitizeLogValue(req.UserAgent(), maxLoggedUserAgent))
if id := requestID(req, res); id != "" {
attrs = append(attrs, "request_id", id)
}
}
if isQuietPath && status == 200 {
xlog.Debug("HTTP request", attrs...)
} else {
xlog.Info("HTTP request", attrs...)
}
return err
}
}
}
// peerAddr returns the TCP peer address without the port. It does not use
// echo's Context.RealIP: with no IPExtractor configured, RealIP trusts the
// X-Forwarded-For and X-Real-Ip headers, which any client can forge. LocalAI
// has no trusted-proxy setting, so the peer address is the only safe value.
func peerAddr(req *http.Request) string {
host, _, err := net.SplitHostPort(req.RemoteAddr)
if err != nil {
return req.RemoteAddr
}
return host
}
// requestID returns the diagnostic id the server set on the response, or the
// X-Request-Id the caller sent, or an empty string.
func requestID(req *http.Request, res *echo.Response) string {
id := res.Header().Get(diagnostics.HeaderDiagnosticID)
if id == "" {
id = req.Header.Get(echo.HeaderXRequestID)
}
return sanitizeLogValue(id, maxLoggedRequestID)
}
// sanitizeLogValue replaces control characters with a space and truncates
// the value to max characters.
func sanitizeLogValue(ua string, max int) string {
var b strings.Builder
n := 0
for _, r := range ua {
if n == max {
break
}
if unicode.IsControl(r) {
r = ' '
}
b.WriteRune(r)
n++
}
return b.String()
}