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>
72 lines
2.0 KiB
Go
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()
|
|
}
|