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
This commit is contained in:
Ettore Di Giacinto committed 2026-11-03 14:32:55 +00:00
1 parent 9e6d6520bd
commit b129694ffe
6 files changed
+247 -39

No files matched your search

+2
View File
@@ -50,6 +50,7 @@ type RunCMD struct {
AddonsOwnerAddress string `name:"addons-owner-address" env:"LOCALAI_ADDONS_OWNER_ADDRESS" help:"Address of the add-on owner carrier, as https://host:port with no path. Use a stable service name, never a pod address. Required for the owner and frontend roles" group:"addons"`
AddonsOwnerListen string `name:"addons-owner-listen" env:"LOCALAI_ADDONS_OWNER_LISTEN" help:"Owner role only: the address the carrier listens on. Empty listens on all interfaces, on the port of --addons-owner-address" group:"addons"`
AddonsCarrierDir string `name:"addons-carrier-dir" env:"LOCALAI_ADDONS_CARRIER_DIR" type:"path" help:"Directory with the carrier key set made by 'local-ai util addons-carrier-keys'. Required for the owner and frontend roles" group:"addons"`
LogClient bool `name:"log-client" env:"LOCALAI_LOG_CLIENT" default:"false" help:"Add the duration, client address, user agent and request id to the HTTP access log. Off by default. The address is the TCP peer, never read from X-Forwarded-For. Turn it on with --log-client" group:"logging"`
AutoUpgradeAddons bool `name:"auto-upgrade-addons" env:"LOCALAI_AUTO_UPGRADE_ADDONS" default:"false" help:"Upgrade installed add-ons on their own when a newer version appears in their library. Off by default; upgrades then stay manual" group:"addons"`
AddonsOfficialLibrary bool `name:"addons-official-library" env:"LOCALAI_ADDONS_OFFICIAL_LIBRARY" default:"true" help:"Register the official add-on library on the first start, when the add-on owner has no sources.json yet. A library the admin removes later stays removed. Turn it off with --addons-official-library=false" group:"addons"`
AddonsBackendLibrary bool `name:"addons-backend-library" env:"LOCALAI_ADDONS_BACKEND_LIBRARY" default:"true" help:"Register the backend library on the first start, when the add-on owner has no sources.json yet. A build from master reads it as the backend source; a tagged build reads the backend gallery until the release cutover. Turn it off to read the backend gallery only" group:"addons"`
@@ -394,6 +395,7 @@ func (r *RunCMD) Run(ctx *cliContext.Context) (result error) {
config.WithYAMLConfigPreload(r.PreloadModelsConfig),
config.WithSystemState(systemState),
config.WithContextSize(r.ContextSize),
config.WithLogClient(r.LogClient),
config.WithDebug(ctx.Debug || (ctx.LogLevel != nil && *ctx.LogLevel == "debug")),
config.WithGeneratedContentDir(r.GeneratedContentPath),
config.WithUploadDir(r.UploadPath),
+8
View File
@@ -142,6 +142,7 @@ type ApplicationConfig struct {
F16 bool
Debug bool
LogClient bool
EnableTracing bool
MaxConcurrentBackendRequests int
TracingMaxItems int
@@ -1071,6 +1072,13 @@ func WithDebug(debug bool) AppOption {
}
}
// WithLogClient adds the peer address and user agent to the HTTP access log.
func WithLogClient(on bool) AppOption {
return func(o *ApplicationConfig) {
o.LogClient = on
}
}
func WithTracingMaxItems(items int) AppOption {
return func(o *ApplicationConfig) {
o.TracingMaxItems = items
+117
View File
@@ -0,0 +1,117 @@
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()
}
+118
View File
@@ -0,0 +1,118 @@
package http
import (
"bytes"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"github.com/labstack/echo/v4"
"github.com/mudler/xlog"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("HTTP access log", func() {
var logs *bytes.Buffer
BeforeEach(func() {
logs = new(bytes.Buffer)
xlog.SetLogger(xlog.NewLoggerWithHandler(slog.NewJSONHandler(logs, nil), xlog.LogLevelDebug))
DeferCleanup(func() { xlog.SetLogger(xlog.NewLogger(xlog.LogLevelInfo, "text")) })
})
// serve sends one request through the access log and returns the log line.
serve := func(logClient bool, mutate func(*http.Request)) string {
logs.Reset()
e := echo.New()
e.Use(accessLog(logClient))
e.POST("/chat/completions", func(c echo.Context) error {
return echo.NewHTTPError(http.StatusUnauthorized, "no key")
})
req := httptest.NewRequest(http.MethodPost, "/chat/completions", nil)
req.RemoteAddr = "192.0.2.10:54321"
if mutate != nil {
mutate(req)
}
e.ServeHTTP(httptest.NewRecorder(), req)
return logs.String()
}
It("logs the peer address and the user agent", func() {
line := serve(true, func(r *http.Request) { r.Header.Set("User-Agent", "curl/8.5.0") })
Expect(line).To(ContainSubstring(`"remote":"192.0.2.10"`))
Expect(line).To(ContainSubstring(`"user_agent":"curl/8.5.0"`))
Expect(line).To(ContainSubstring(`"status":401`))
Expect(line).To(ContainSubstring(`"path":"/chat/completions"`))
})
It("ignores a forged X-Forwarded-For and X-Real-Ip", func() {
line := serve(true, func(r *http.Request) {
r.Header.Set("X-Forwarded-For", "203.0.113.99")
r.Header.Set("X-Real-Ip", "203.0.113.98")
})
Expect(line).To(ContainSubstring(`"remote":"192.0.2.10"`))
Expect(line).NotTo(ContainSubstring("203.0.113"))
})
It("truncates the user agent to 80 characters", func() {
line := serve(true, func(r *http.Request) { r.Header.Set("User-Agent", strings.Repeat("a", 500)) })
Expect(line).To(ContainSubstring(`"user_agent":"` + strings.Repeat("a", 80) + `"`))
})
It("replaces control characters in the user agent", func() {
Expect(sanitizeLogValue("evil\r\nINFO fake\tline\x00", 80)).To(Equal("evil INFO fake line "))
Expect(sanitizeLogValue(strings.Repeat("é", 100), 80)).To(HaveLen(160))
})
It("adds neither field when the option is off", func() {
line := serve(false, func(r *http.Request) { r.Header.Set("User-Agent", "curl/8.5.0") })
Expect(line).NotTo(ContainSubstring("remote"))
Expect(line).NotTo(ContainSubstring("user_agent"))
Expect(line).To(ContainSubstring(`"status":401`))
})
It("never logs credentials, query strings or cookies", func() {
line := serve(true, func(r *http.Request) {
r.URL.RawQuery = "api_key=sk-query-secret"
r.Header.Set("Authorization", "Bearer sk-header-secret")
r.Header.Set("Cookie", "session=cookie-secret")
})
Expect(line).NotTo(ContainSubstring("sk-header-secret"))
Expect(line).NotTo(ContainSubstring("Bearer"))
Expect(line).NotTo(ContainSubstring("sk-query-secret"))
Expect(line).NotTo(ContainSubstring("cookie-secret"))
Expect(line).To(ContainSubstring(`"path":"/chat/completions"`))
})
It("logs the duration and the request id when one is present", func() {
line := serve(true, func(r *http.Request) { r.Header.Set("X-Request-Id", "req-42\nforged") })
Expect(line).To(ContainSubstring(`"duration":`))
Expect(line).To(ContainSubstring(`"request_id":"req-42 forged"`))
Expect(serve(true, nil)).NotTo(ContainSubstring("request_id"))
})
It("falls back to the raw address when it has no port", func() {
line := serve(true, func(r *http.Request) { r.RemoteAddr = "@" })
Expect(line).To(ContainSubstring(`"remote":"@"`))
})
})
var _ = DescribeTable("peerAddr",
func(remote string, headers map[string]string, want string) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = remote
for k, v := range headers {
req.Header.Set(k, v)
}
Expect(peerAddr(req)).To(Equal(want))
},
Entry("ipv4 with port", "192.0.2.10:1234", nil, "192.0.2.10"),
Entry("ipv6 with port", "[2001:db8::1]:1234", nil, "2001:db8::1"),
Entry("no port", "192.0.2.10", nil, "192.0.2.10"),
Entry("spoofed X-Forwarded-For", "192.0.2.10:1234", map[string]string{"X-Forwarded-For": "203.0.113.7"}, "192.0.2.10"),
Entry("spoofed X-Forwarded-For chain", "192.0.2.10:1234", map[string]string{"X-Forwarded-For": "203.0.113.7, 198.51.100.1"}, "192.0.2.10"),
Entry("spoofed X-Real-Ip", "192.0.2.10:1234", map[string]string{"X-Real-Ip": "203.0.113.7"}, "192.0.2.10"),
Entry("spoofed Forwarded", "192.0.2.10:1234", map[string]string{"Forwarded": "for=203.0.113.7"}, "192.0.2.10"),
)
+1 -39
View File
@@ -338,45 +338,7 @@ func API(application *application.Application) (*echo.Echo, error) {
}
// Custom logger middleware using xlog
e.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
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
}
}
if isQuietPath && status == 200 {
xlog.Debug("HTTP request", "method", req.Method, "path", req.URL.Path, "status", status)
} else {
xlog.Info("HTTP request", "method", req.Method, "path", req.URL.Path, "status", status)
}
return err
}
})
e.Use(accessLog(application.ApplicationConfig().LogClient))
// Recover middleware
if !application.ApplicationConfig().Debug {
+1
View File
@@ -20,6 +20,7 @@ model, see [Model Configuration]({{% relref "advanced/model-configuration" %}}).
|-----------|---------|-------------|----------------------|
| `-h, --help` | | Show context-sensitive help | |
| `--log-level` | `info` | Set the level of logs to output [error,warn,info,debug,trace] | `$LOCALAI_LOG_LEVEL` |
| `--log-client` | `false` | `local-ai run` only. Add `duration`, `remote` (the TCP peer address), `user_agent` (control characters replaced, cut to 80 characters), and `request_id` (when the request has one) to the `HTTP request` log line, so you can tell who sent a request that was refused. The address never comes from `X-Forwarded-For` or `X-Real-Ip`, so behind a reverse proxy it is the proxy address. Authorization headers, cookies, query strings and bodies are never logged. | `$LOCALAI_LOG_CLIENT` |
| `--credentials-file` | | YAML file with credentials for private registries, galleries and download hosts. See [Private Registries and Galleries]({{% relref "advanced/private-sources" %}}) | `$LOCALAI_CREDENTIALS_FILE` |
| `--debug` | `false` | **DEPRECATED** - Use `--log-level=debug` instead. Enable debug logging | `$LOCALAI_DEBUG`, `$DEBUG` |