From 18a7336c02a889c475c0446d2e653ff907adb83f Mon Sep 17 00:00:00 2001 From: mram Date: Tue, 22 Sep 2026 18:53:00 +0200 Subject: [PATCH] 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). --- README.md | 3 +- SPEC.md | 3 +- cmd/gpu-turnstile/main.go | 6 +++- cmd/gpu-turnstile/webui.go | 37 ++++++++++++++++++-- cmd/gpu-turnstile/webui_test.go | 60 ++++++++++++++++++++++++++++++++- internal/config/config.go | 10 ++++++ internal/config/config_test.go | 24 +++++++++++++ internal/config/sample.go | 4 ++- 8 files changed, 139 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index ce4779f..827aada 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,8 @@ override file values. Invalid values fail at startup. |---|---|---| | `LISTEN_OLLAMA` | `:11434` | Listener for Ollama-compatible clients | | `LISTEN_COMFY` | `:8188` | Listener for ComfyUI clients | -| `LISTEN_UI` | _(empty = disabled)_ | Web UI listener mirroring `--monitor` (live status, update/reload buttons). No auth — keep it on localhost | +| `LISTEN_UI` | _(empty = disabled)_ | Web UI listener (`host:port`) mirroring `--monitor` (live status, update/reload buttons). Keep it on `127.0.0.1` unless you set auth | +| `UI_USER` / `UI_PASS` | _(empty = no auth)_ | HTTP basic auth for the web UI; must be set together | | `OLLAMA_URL` | _(empty = disabled)_ | Ollama upstream; set to enable the Ollama consumer | | `COMFY_URL` | _(empty = disabled)_ | ComfyUI upstream; set to enable the ComfyUI consumer | | `UNLOAD_TIMEOUT` | `60s` | Wait for Ollama to unload before an image job | diff --git a/SPEC.md b/SPEC.md index 7781eeb..629cbcf 100644 --- a/SPEC.md +++ b/SPEC.md @@ -221,7 +221,8 @@ override file values. A missing file is fine; a malformed one is fatal. |---|---|---| | `LISTEN_OLLAMA` | `:11434` | listener for Ollama-compatible clients | | `LISTEN_COMFY` | `:8188` | listener for ComfyUI clients | -| `LISTEN_UI` | _(empty = disabled)_ | web UI listener mirroring `--monitor` (live status, update/reload buttons); unauthenticated — keep it on localhost | +| `LISTEN_UI` | _(empty = disabled)_ | web UI listener (`host:port`) mirroring `--monitor` (live status, update/reload buttons); keep it on `127.0.0.1` unless auth is set | +| `UI_USER` / `UI_PASS` | _(empty = no auth)_ | HTTP basic auth for the web UI; must be set together | | `OLLAMA_URL` | _(empty = disabled)_ | Ollama upstream; set to enable the Ollama consumer | | `COMFY_URL` | _(empty = disabled)_ | ComfyUI upstream; set to enable the ComfyUI consumer | | `UNLOAD_TIMEOUT` | `60s` | wait for Ollama to unload | diff --git a/cmd/gpu-turnstile/main.go b/cmd/gpu-turnstile/main.go index 073b0c3..c4a984a 100644 --- a/cmd/gpu-turnstile/main.go +++ b/cmd/gpu-turnstile/main.go @@ -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) }() diff --git a/cmd/gpu-turnstile/webui.go b/cmd/gpu-turnstile/webui.go index 950decd..d8e2800 100644 --- a/cmd/gpu-turnstile/webui.go +++ b/cmd/gpu-turnstile/webui.go @@ -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. diff --git a/cmd/gpu-turnstile/webui_test.go b/cmd/gpu-turnstile/webui_test.go index 345ca0d..f41920e 100644 --- a/cmd/gpu-turnstile/webui_test.go +++ b/cmd/gpu-turnstile/webui_test.go @@ -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) + } + } +} diff --git a/internal/config/config.go b/internal/config/config.go index a040d99..f238b55 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -20,6 +20,8 @@ type Config struct { ListenOllama string `env:"LISTEN_OLLAMA"` ListenComfy string `env:"LISTEN_COMFY"` ListenUI string `env:"LISTEN_UI"` + UIUser string `env:"UI_USER"` + UIPass string `env:"UI_PASS"` OllamaURL string `env:"OLLAMA_URL"` ComfyURL string `env:"COMFY_URL"` UnloadTimeout time.Duration `env:"UNLOAD_TIMEOUT"` @@ -198,6 +200,8 @@ func Load(getenv func(string) string) (Config, error) { {"LISTEN_OLLAMA", &cfg.ListenOllama}, {"LISTEN_COMFY", &cfg.ListenComfy}, {"LISTEN_UI", &cfg.ListenUI}, + {"UI_USER", &cfg.UIUser}, + {"UI_PASS", &cfg.UIPass}, {"OLLAMA_URL", &cfg.OllamaURL}, {"COMFY_URL", &cfg.ComfyURL}, {"WARM_MODEL", &cfg.WarmModel}, @@ -320,6 +324,12 @@ func Load(getenv func(string) string) (Config, error) { default: return cfg, fmt.Errorf("LOG_FORMAT: must be \"text\" or \"json\"") } + if (cfg.UIUser == "") != (cfg.UIPass == "") { + return cfg, fmt.Errorf("UI_USER and UI_PASS must be set together (both empty = no auth)") + } + if cfg.UIUser != "" && cfg.ListenUI == "" { + return cfg, fmt.Errorf("UI_USER/UI_PASS have no effect without LISTEN_UI") + } if cfg.ComfyCmd != "" && cfg.ComfyURL == "" { return cfg, fmt.Errorf("COMFY_CMD requires COMFY_URL to be set (the proxy needs somewhere to forward)") } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 402ee54..a86ff78 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -97,6 +97,30 @@ func TestComfyCmdRequiresURL(t *testing.T) { } } +func TestUIAuthPairing(t *testing.T) { + env := func(set map[string]string) func(string) string { + return func(k string) string { return set[k] } + } + // Only one of UI_USER/UI_PASS: error. + _, err := Load(env(map[string]string{"OLLAMA_URL": "http://x", "LISTEN_UI": "127.0.0.1:7860", "UI_USER": "admin"})) + if err == nil || !strings.Contains(err.Error(), "UI_USER and UI_PASS must be set together") { + t.Fatalf("err = %v, want pairing error", err) + } + // Auth without LISTEN_UI: error. + _, err = Load(env(map[string]string{"OLLAMA_URL": "http://x", "UI_USER": "admin", "UI_PASS": "x"})) + if err == nil || !strings.Contains(err.Error(), "no effect without LISTEN_UI") { + t.Fatalf("err = %v, want LISTEN_UI error", err) + } + // Both with LISTEN_UI: loads. + cfg, err := Load(env(map[string]string{"OLLAMA_URL": "http://x", "LISTEN_UI": "127.0.0.1:7860", "UI_USER": "admin", "UI_PASS": "x"})) + if err != nil { + t.Fatalf("auth pair with LISTEN_UI must load: %v", err) + } + if cfg.UIUser != "admin" || cfg.UIPass != "x" { + t.Errorf("got %q/%q", cfg.UIUser, cfg.UIPass) + } +} + func TestParseEnvFile(t *testing.T) { input := `# comment OLLAMA_URL=http://host:11435 diff --git a/internal/config/sample.go b/internal/config/sample.go index 6ce569a..a4816e7 100644 --- a/internal/config/sample.go +++ b/internal/config/sample.go @@ -25,7 +25,9 @@ func sampleEntries(logFile string) []sampleEntry { return []sampleEntry{ {"LISTEN_OLLAMA", ":11434", "Listen address for Ollama-compatible clients (gpu-turnstile poses as Ollama here)", false}, {"LISTEN_COMFY", ":8188", "Listen address for ComfyUI clients (gpu-turnstile poses as ComfyUI here)", false}, - {"LISTEN_UI", "127.0.0.1:7860", "Web UI listen address: live status like --monitor, with update/reload buttons (default: empty = disabled; keep it on localhost — there is no auth)", false}, + {"LISTEN_UI", "127.0.0.1:7860", "Web UI listen address (host:port): live status like --monitor, with update/reload buttons (default: empty = disabled; bind 127.0.0.1 unless you set auth)", false}, + {"UI_USER", "", "HTTP basic auth for the web UI (UI_USER and UI_PASS must be set together; both empty = no auth)", false}, + {"UI_PASS", "", "See UI_USER", false}, {"OLLAMA_URL", "http://127.0.0.1:11434", "Ollama upstream URL; setting it enables the Ollama consumer (default: empty = disabled)", false}, {"COMFY_URL", "http://127.0.0.1:8188", "ComfyUI upstream URL; setting it enables the ComfyUI consumer (default: empty = disabled)", false}, {"WARM_MODEL", "", "Optional model to reload after an image job (default: empty = none)", false},