Pasting the gateway's address into a browser returned 404, which looks like a broken endpoint rather than one that tells you how to use it. Two pages, and the split between them is a security boundary rather than a layout choice. The landing page is served without a key — it has to be, because the person reading it is the one who does not have one yet — so it contains no data at all: it says how to connect, not what is here. Model names stay behind /v1/models. It reconstructs the address from the forwarded headers so the copy-pasteable example names the address the client actually used rather than the internal one; those headers are reflected into displayed text and never used for a decision. The dashboard shows nodes, models, replicas, placement failures and usage, and lives on the admin listener only. That listener is loopback-bound and also serves pprof, which can dump the process heap — where API keys sit in plaintext — so reaching the page from elsewhere is an SSH tunnel's job. It is read-only: anything that mints a credential stays in `llmgw keys`, where it needs a shell rather than a browser session this project has no way to authenticate. Mounting the landing page uncovered a hole in the exemption list. An entry ending in "/" was a prefix rule, and "/" ends in "/", so exempting the landing page would have exempted every path there is — authentication still installed, still logging, and letting the whole API through. "/" is now exact, with a test enumerating what must and must not be exempt. Server-rendered templates and one stylesheet, embedded. The single-binary goal rules out a JavaScript toolchain and nothing on either page needs one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
107 lines
3.5 KiB
Go
107 lines
3.5 KiB
Go
package auth
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
)
|
|
|
|
// Middleware authenticates every request before it reaches the router.
|
|
//
|
|
// It is a wrapper rather than a check inside the handler so there is no path
|
|
// into the API that forgets to call it. Adding a route cannot accidentally add
|
|
// an unauthenticated one.
|
|
func Middleware(a *Authenticator, log *slog.Logger, exempt ...string) func(http.Handler) http.Handler {
|
|
if log == nil {
|
|
log = slog.Default()
|
|
}
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if isExempt(r.URL.Path, exempt) {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
|
|
p, err := a.Authenticate(r.Context(), BearerToken(r))
|
|
if err != nil {
|
|
writeAuthError(w, err)
|
|
// The key prefix, never the key: a rejected credential in a log
|
|
// is still a credential.
|
|
log.Debug("rejected a request", "path", r.URL.Path,
|
|
"reason", err, "remote", r.RemoteAddr)
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r.WithContext(WithPrincipal(r.Context(), p)))
|
|
})
|
|
}
|
|
}
|
|
|
|
// isExempt reports whether a path is served without a key.
|
|
//
|
|
// An entry ending in "/" is a prefix rule, with one exception that matters
|
|
// enormously: "/" itself is exact. Treating it as a prefix would match every
|
|
// path there is, so exempting the landing page would silently exempt the whole
|
|
// API — authentication still installed, still logging, and letting everything
|
|
// through.
|
|
//
|
|
// Metrics and pprof are not in this list because they live on the admin
|
|
// listener, a separate port precisely so it can be firewalled rather than
|
|
// authenticated.
|
|
func isExempt(path string, exempt []string) bool {
|
|
for _, e := range exempt {
|
|
if path == e {
|
|
return true
|
|
}
|
|
if e != "/" && strings.HasSuffix(e, "/") && strings.HasPrefix(path, e) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func writeAuthError(w http.ResponseWriter, err error) {
|
|
status, kind, msg := http.StatusUnauthorized, "invalid_request_error", "invalid api key"
|
|
switch {
|
|
case errors.Is(err, ErrMissing):
|
|
msg = "no api key was provided"
|
|
case errors.Is(err, ErrExpired):
|
|
msg = "this api key has expired"
|
|
case errors.Is(err, ErrRevoked):
|
|
// Deliberately the same message as an unknown key. Telling a caller
|
|
// that a key exists but was revoked confirms the key was real.
|
|
msg = "invalid api key"
|
|
case errors.Is(err, ErrInvalid):
|
|
default:
|
|
// A store failure is ours, not the caller's, and must not read as a
|
|
// rejected credential — that would send someone hunting for a key
|
|
// problem that does not exist.
|
|
status, kind, msg = http.StatusServiceUnavailable, "server_error",
|
|
"authentication is temporarily unavailable"
|
|
}
|
|
|
|
if status == http.StatusUnauthorized {
|
|
// What the OpenAI SDKs look for to stop retrying.
|
|
w.Header().Set("WWW-Authenticate", `Bearer realm="llmgw"`)
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"error": map[string]any{
|
|
"message": msg, "type": kind, "code": nil,
|
|
}})
|
|
}
|
|
|
|
// WriteForbiddenModel reports a model this key may not use.
|
|
//
|
|
// Separate from authentication because it happens later: the model is only
|
|
// known once the body has been parsed.
|
|
func WriteForbiddenModel(w http.ResponseWriter, model string) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusForbidden)
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"error": map[string]any{
|
|
"message": "this api key is not permitted to use model " + model,
|
|
"type": "invalid_request_error", "code": nil,
|
|
}})
|
|
}
|