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>
83 lines
2.7 KiB
Go
83 lines
2.7 KiB
Go
// Package web serves the two HTML surfaces: a landing page on the
|
|
// client-facing port and a read-only dashboard on the admin one.
|
|
//
|
|
// Server-rendered templates with one stylesheet, embedded in the binary. The
|
|
// single-binary goal rules out a JavaScript toolchain — a build step in CI, a
|
|
// node runtime on the runner, and megabytes of bundle — and nothing on either
|
|
// page needs one. The dashboard refreshes by reloading.
|
|
//
|
|
// The split between the two pages is a security boundary, not a layout choice.
|
|
// The landing page is served to anyone who can reach the address, so it
|
|
// contains no data at all: it says how to connect, not what is here. The
|
|
// dashboard shows the cluster and belongs on the admin listener, which is bound
|
|
// to loopback — see Dashboard.
|
|
package web
|
|
|
|
import (
|
|
"embed"
|
|
"fmt"
|
|
"html/template"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
//go:embed templates
|
|
var files embed.FS
|
|
|
|
var funcs = template.FuncMap{
|
|
"join": func(v []string) string {
|
|
if len(v) == 0 {
|
|
return "—"
|
|
}
|
|
return strings.Join(v, ", ")
|
|
},
|
|
"duration": func(sec int64) string {
|
|
return time.Duration(sec * int64(time.Second)).Round(time.Second).String()
|
|
},
|
|
"ago": func(t time.Time) string {
|
|
if t.IsZero() {
|
|
return "never"
|
|
}
|
|
return time.Since(t).Round(time.Second).String() + " ago"
|
|
},
|
|
// Spend is stored in micro-units so accumulation is exact; it is shown in
|
|
// whole units because that is the number an operator set a budget in.
|
|
"units": func(micros int64) string {
|
|
return fmt.Sprintf("%.2f", float64(micros)/1e6)
|
|
},
|
|
}
|
|
|
|
var tmpl = template.Must(template.New("").Funcs(funcs).ParseFS(files, "templates/*.html"))
|
|
|
|
// Static serves the stylesheet, mounted on both listeners because both pages
|
|
// use it and it contains nothing.
|
|
//
|
|
// One file rather than a directory server: exposing a file server over the
|
|
// embedded templates would also serve the templates themselves, which is
|
|
// harmless today and the kind of thing that stops being harmless later.
|
|
func Static() http.Handler {
|
|
css, err := files.ReadFile("templates/base.css")
|
|
if err != nil {
|
|
panic("web: the stylesheet is missing from the binary: " + err.Error())
|
|
}
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/_static/base.css" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/css; charset=utf-8")
|
|
w.Header().Set("Cache-Control", "public, max-age=3600")
|
|
_, _ = w.Write(css)
|
|
})
|
|
}
|
|
|
|
func render(w http.ResponseWriter, name string, data any) {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if err := tmpl.ExecuteTemplate(w, name, data); err != nil {
|
|
// The status is already written by then, so there is nothing honest to
|
|
// send; the page simply ends short.
|
|
http.Error(w, "template error", http.StatusInternalServerError)
|
|
}
|
|
}
|