Files
llmgw/internal/router/adminauth.go
T
Ilker ManapandClaude Opus 5.5 c0421bc7ee Guard the admin API and pprof with a token only root and the server can read
The admin listener answered any local process: models added, nodes
upgraded, engine keys listed, the heap dumped. /api and /debug now demand
the token the server writes 0600 to its state directory on first start;
ctl, the console and node add send it (sudo, LLMGW_ADMIN_TOKEN or
--token-file). pprof is off unless debug_pprof. admin_listen off loopback
is refused unless admin_remote. Dashboard pages, /metrics and /health are
unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 10:02:44 +02:00

72 lines
2.0 KiB
Go

package router
import (
"crypto/subtle"
"net/http"
"strings"
)
// AdminAuth demands the admin token for what the admin listener can change
// or reveal: its API, which adds models, upgrades nodes and hands out engine
// keys, and pprof, whose heap holds the API keys requests presented. Being
// on loopback kept browsers out; it did not keep out every other process and
// user on the machine.
//
// The token lives in a file only root and the server can read, so `sudo
// llmgw ctl` and the console have it and nobody else does. pprof is served
// only when asked for in the configuration, and then also behind the token.
// The read-only pages — the cluster dashboard reached by SSH tunnel — and
// /metrics and /health are left as they were.
func AdminAuth(token string, pprof bool, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasPrefix(r.URL.Path, "/debug/"):
if !pprof {
http.NotFound(w, r)
return
}
case strings.HasPrefix(r.URL.Path, "/api/"):
default:
next.ServeHTTP(w, r)
return
}
if !tokenMatches(token, r.Header.Get("Authorization")) {
w.Header().Set("WWW-Authenticate", `Bearer realm="llmgw-admin"`)
writeJSONError(w, http.StatusUnauthorized,
"the admin API needs its token: run as root (sudo llmgw ...), or set LLMGW_ADMIN_TOKEN")
return
}
next.ServeHTTP(w, r)
})
}
func tokenMatches(token, header string) bool {
got, ok := strings.CutPrefix(header, "Bearer ")
if !ok || token == "" {
return false
}
return subtle.ConstantTimeCompare([]byte(strings.TrimSpace(got)), []byte(token)) == 1
}
func writeJSONError(w http.ResponseWriter, code int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_, _ = w.Write([]byte(`{"error":` + quoteJSON(msg) + `}`))
}
func quoteJSON(s string) string {
var b strings.Builder
b.WriteByte('"')
for _, c := range s {
switch c {
case '"', '\\':
b.WriteByte('\\')
b.WriteRune(c)
default:
b.WriteRune(c)
}
}
b.WriteByte('"')
return b.String()
}