Files
llmgw/internal/web/landing.go
T
Ilker ManapandClaude Opus 5 1839832b8b
CI / test (push) Successful in 1m2s
Add a landing page and a read-only cluster dashboard
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>
2026-09-16 08:58:05 +02:00

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)
}