CI / test (push) Successful in 1m2s
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>
78 lines
2.2 KiB
Go
78 lines
2.2 KiB
Go
package web
|
|
|
|
import (
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// LandingData is what the landing page shows. It is deliberately almost
|
|
// nothing: this page is served without a key, to anyone who can reach the
|
|
// address.
|
|
type LandingData struct {
|
|
BaseURL string
|
|
AuthEnabled bool
|
|
Version string
|
|
Now string
|
|
}
|
|
|
|
// Landing serves the page at "/" on the client-facing port.
|
|
//
|
|
// Without it that path is a 404, which is what someone sees when they paste the
|
|
// address into a browser — an endpoint that looks broken rather than one that
|
|
// tells them how to use it.
|
|
//
|
|
// It lists no models and no cluster state. Everything that says what is here
|
|
// stays behind the key, including /v1/models.
|
|
func Landing(version string, authEnabled bool) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
// Only the root: a catch-all would turn every mistyped path into this
|
|
// page, hiding genuine 404s from anyone debugging a client.
|
|
if r.URL.Path != "/" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
render(w, "landing", LandingData{
|
|
BaseURL: baseURL(r),
|
|
AuthEnabled: authEnabled,
|
|
Version: version,
|
|
Now: time.Now().UTC().Format(time.RFC3339),
|
|
})
|
|
}
|
|
}
|
|
|
|
// baseURL reconstructs the address the client actually used.
|
|
//
|
|
// Behind a reverse proxy the scheme and host this process sees are the internal
|
|
// ones, so the forwarded headers are what make the copy-pasteable example on
|
|
// the page correct. They are attacker-controlled when nothing sets them, which
|
|
// is why they are only reflected into a displayed URL and never used for a
|
|
// decision.
|
|
func baseURL(r *http.Request) string {
|
|
scheme := "http"
|
|
switch {
|
|
case r.TLS != nil:
|
|
scheme = "https"
|
|
case r.Header.Get("X-Forwarded-Proto") != "":
|
|
scheme = firstValue(r.Header.Get("X-Forwarded-Proto"))
|
|
}
|
|
|
|
host := r.Host
|
|
if fwd := r.Header.Get("X-Forwarded-Host"); fwd != "" {
|
|
host = firstValue(fwd)
|
|
}
|
|
if host == "" {
|
|
host = "localhost:8080"
|
|
}
|
|
return scheme + "://" + host
|
|
}
|
|
|
|
// firstValue takes the first entry of a comma-separated forwarded header: a
|
|
// chain of proxies appends rather than replaces.
|
|
func firstValue(v string) string {
|
|
if i := strings.IndexByte(v, ','); i >= 0 {
|
|
v = v[:i]
|
|
}
|
|
return strings.TrimSpace(v)
|
|
}
|