Web UI: optional HTTP basic auth via UI_USER/UI_PASS

Both must be set together and require LISTEN_UI; constant-time compares,
challenge on every endpoint. Startup warns when LISTEN_UI binds a
non-loopback address without auth. Bind address and port were already
covered by LISTEN_UI itself (host:port).
This commit is contained in:
mram
2026-09-22 18:53:00 +02:00
parent 0796092b80
commit 18a7336c02
8 changed files with 139 additions and 8 deletions
+5 -1
View File
@@ -568,6 +568,7 @@ func run(ctx context.Context, cfg config.Config, log *slog.Logger, logOut io.Wri
"listen_ollama", cfg.ListenOllama,
"listen_comfy", cfg.ListenComfy,
"listen_ui", orDisabled(cfg.ListenUI),
"ui_auth", cfg.UIUser != "",
"ollama_url", orDisabled(cfg.OllamaURL),
"comfy_url", orDisabled(cfg.ComfyURL),
"unload_timeout", cfg.UnloadTimeout,
@@ -823,7 +824,10 @@ func run(ctx context.Context, cfg config.Config, log *slog.Logger, logOut io.Wri
if err != nil {
return fmt.Errorf("listen ui on %s: %w", cfg.ListenUI, err)
}
uiSrv := &http.Server{Addr: cfg.ListenUI, Handler: uiHandler(handler)}
if host, _, err := net.SplitHostPort(cfg.ListenUI); err == nil && !isLoopbackHost(host) && cfg.UIUser == "" {
log.Warn("web UI is reachable from other machines WITHOUT auth; set UI_USER/UI_PASS or bind to 127.0.0.1", "addr", cfg.ListenUI)
}
uiSrv := &http.Server{Addr: cfg.ListenUI, Handler: uiHandler(handler, cfg.UIUser, cfg.UIPass)}
servers = append(servers, uiSrv)
log.Warn("listening", "consumer", "ui", "addr", cfg.ListenUI)
go func() { errCh <- uiSrv.Serve(ln) }()
+34 -3
View File
@@ -1,7 +1,9 @@
package main
import (
"crypto/subtle"
"fmt"
"net"
"net/http"
"strings"
@@ -10,9 +12,10 @@ import (
// uiHandler serves the web UI (LISTEN_UI): a single page mirroring
// --monitor, its status JSON (the same snapshot the control channel
// serves) and action endpoints for the update/reload buttons. There is no
// auth — like the control pipe, it is meant for localhost only.
func uiHandler(h control.Handler) http.Handler {
// serves) and action endpoints for the update/reload buttons. user/pass
// (UI_USER/UI_PASS) enable HTTP basic auth; both empty means open access,
// which is meant for localhost only.
func uiHandler(h control.Handler, user, pass string) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
@@ -45,9 +48,37 @@ func uiHandler(h control.Handler) http.Handler {
}
fmt.Fprint(w, msg)
})
if user != "" {
return basicAuth(mux, user, pass)
}
return mux
}
// basicAuth wraps next with HTTP basic auth (constant-time compares).
func basicAuth(next http.Handler, user, pass string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u, p, ok := r.BasicAuth()
if !ok ||
subtle.ConstantTimeCompare([]byte(u), []byte(user)) != 1 ||
subtle.ConstantTimeCompare([]byte(p), []byte(pass)) != 1 {
w.Header().Set("WWW-Authenticate", `Basic realm="gpu-turnstile"`)
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
// isLoopbackHost reports whether a listen host is localhost-only. An empty
// host (":7860") binds all interfaces and is therefore not loopback.
func isLoopbackHost(host string) bool {
if host == "localhost" {
return true
}
ip := net.ParseIP(host)
return ip != nil && ip.IsLoopback()
}
// uiPage is the whole web UI: a terminal-styled page that polls /api/status
// once a second and renders the same lines as --monitor. The buttons (and
// the u/r keys) hit the action endpoints, like the monitor's hotkeys.
+59 -1
View File
@@ -1,6 +1,7 @@
package main
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
@@ -18,7 +19,7 @@ func testUIHandler() *httptest.Server {
default:
return "ERR unknown command: " + cmd
}
})
}, "", "")
return httptest.NewServer(h)
}
@@ -112,3 +113,60 @@ func TestUIActions(t *testing.T) {
t.Errorf("GET action: %d, want 404", resp4.StatusCode)
}
}
func TestUIBasicAuth(t *testing.T) {
h := uiHandler(func(cmd string) string { return `OK {"version":"v9.9.9"}` }, "admin", "s3cret")
srv := httptest.NewServer(h)
defer srv.Close()
// No credentials: 401 with the auth challenge on page and API alike.
for _, path := range []string{"/", "/api/status"} {
resp, err := srv.Client().Get(srv.URL + path)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != 401 {
t.Errorf("GET %s without auth: %d, want 401", path, resp.StatusCode)
}
if resp.Header.Get("WWW-Authenticate") == "" {
t.Errorf("GET %s: no WWW-Authenticate header", path)
}
}
// Wrong credentials: 401. Right credentials: 200.
req, _ := http.NewRequest("GET", srv.URL+"/api/status", nil)
req.SetBasicAuth("admin", "wrong")
resp, err := srv.Client().Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != 401 {
t.Errorf("wrong password: %d, want 401", resp.StatusCode)
}
req, _ = http.NewRequest("GET", srv.URL+"/api/status", nil)
req.SetBasicAuth("admin", "s3cret")
resp, err = srv.Client().Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
body := make([]byte, 4096)
n, _ := resp.Body.Read(body)
if resp.StatusCode != 200 || strings.TrimSpace(string(body[:n])) != `{"version":"v9.9.9"}` {
t.Errorf("with auth: %d %q", resp.StatusCode, body[:n])
}
}
func TestIsLoopbackHost(t *testing.T) {
for host, want := range map[string]bool{
"127.0.0.1": true, "localhost": true, "::1": true,
"": false, "0.0.0.0": false, "192.168.1.10": false, "example.com": false,
} {
if got := isLoopbackHost(host); got != want {
t.Errorf("isLoopbackHost(%q) = %v, want %v", host, got, want)
}
}
}