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:
@@ -49,7 +49,8 @@ override file values. Invalid values fail at startup.
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `LISTEN_OLLAMA` | `:11434` | Listener for Ollama-compatible clients |
|
| `LISTEN_OLLAMA` | `:11434` | Listener for Ollama-compatible clients |
|
||||||
| `LISTEN_COMFY` | `:8188` | Listener for ComfyUI 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 |
|
| `OLLAMA_URL` | _(empty = disabled)_ | Ollama upstream; set to enable the Ollama consumer |
|
||||||
| `COMFY_URL` | _(empty = disabled)_ | ComfyUI upstream; set to enable the ComfyUI 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 |
|
| `UNLOAD_TIMEOUT` | `60s` | Wait for Ollama to unload before an image job |
|
||||||
|
|||||||
@@ -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_OLLAMA` | `:11434` | listener for Ollama-compatible clients |
|
||||||
| `LISTEN_COMFY` | `:8188` | listener for ComfyUI 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 |
|
| `OLLAMA_URL` | _(empty = disabled)_ | Ollama upstream; set to enable the Ollama consumer |
|
||||||
| `COMFY_URL` | _(empty = disabled)_ | ComfyUI upstream; set to enable the ComfyUI consumer |
|
| `COMFY_URL` | _(empty = disabled)_ | ComfyUI upstream; set to enable the ComfyUI consumer |
|
||||||
| `UNLOAD_TIMEOUT` | `60s` | wait for Ollama to unload |
|
| `UNLOAD_TIMEOUT` | `60s` | wait for Ollama to unload |
|
||||||
|
|||||||
@@ -568,6 +568,7 @@ func run(ctx context.Context, cfg config.Config, log *slog.Logger, logOut io.Wri
|
|||||||
"listen_ollama", cfg.ListenOllama,
|
"listen_ollama", cfg.ListenOllama,
|
||||||
"listen_comfy", cfg.ListenComfy,
|
"listen_comfy", cfg.ListenComfy,
|
||||||
"listen_ui", orDisabled(cfg.ListenUI),
|
"listen_ui", orDisabled(cfg.ListenUI),
|
||||||
|
"ui_auth", cfg.UIUser != "",
|
||||||
"ollama_url", orDisabled(cfg.OllamaURL),
|
"ollama_url", orDisabled(cfg.OllamaURL),
|
||||||
"comfy_url", orDisabled(cfg.ComfyURL),
|
"comfy_url", orDisabled(cfg.ComfyURL),
|
||||||
"unload_timeout", cfg.UnloadTimeout,
|
"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 {
|
if err != nil {
|
||||||
return fmt.Errorf("listen ui on %s: %w", cfg.ListenUI, err)
|
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)
|
servers = append(servers, uiSrv)
|
||||||
log.Warn("listening", "consumer", "ui", "addr", cfg.ListenUI)
|
log.Warn("listening", "consumer", "ui", "addr", cfg.ListenUI)
|
||||||
go func() { errCh <- uiSrv.Serve(ln) }()
|
go func() { errCh <- uiSrv.Serve(ln) }()
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/subtle"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -10,9 +12,10 @@ import (
|
|||||||
|
|
||||||
// uiHandler serves the web UI (LISTEN_UI): a single page mirroring
|
// uiHandler serves the web UI (LISTEN_UI): a single page mirroring
|
||||||
// --monitor, its status JSON (the same snapshot the control channel
|
// --monitor, its status JSON (the same snapshot the control channel
|
||||||
// serves) and action endpoints for the update/reload buttons. There is no
|
// serves) and action endpoints for the update/reload buttons. user/pass
|
||||||
// auth — like the control pipe, it is meant for localhost only.
|
// (UI_USER/UI_PASS) enable HTTP basic auth; both empty means open access,
|
||||||
func uiHandler(h control.Handler) http.Handler {
|
// which is meant for localhost only.
|
||||||
|
func uiHandler(h control.Handler, user, pass string) http.Handler {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.URL.Path != "/" {
|
if r.URL.Path != "/" {
|
||||||
@@ -45,9 +48,37 @@ func uiHandler(h control.Handler) http.Handler {
|
|||||||
}
|
}
|
||||||
fmt.Fprint(w, msg)
|
fmt.Fprint(w, msg)
|
||||||
})
|
})
|
||||||
|
if user != "" {
|
||||||
|
return basicAuth(mux, user, pass)
|
||||||
|
}
|
||||||
return mux
|
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
|
// 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
|
// 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.
|
// the u/r keys) hit the action endpoints, like the monitor's hotkeys.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -18,7 +19,7 @@ func testUIHandler() *httptest.Server {
|
|||||||
default:
|
default:
|
||||||
return "ERR unknown command: " + cmd
|
return "ERR unknown command: " + cmd
|
||||||
}
|
}
|
||||||
})
|
}, "", "")
|
||||||
return httptest.NewServer(h)
|
return httptest.NewServer(h)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,3 +113,60 @@ func TestUIActions(t *testing.T) {
|
|||||||
t.Errorf("GET action: %d, want 404", resp4.StatusCode)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ type Config struct {
|
|||||||
ListenOllama string `env:"LISTEN_OLLAMA"`
|
ListenOllama string `env:"LISTEN_OLLAMA"`
|
||||||
ListenComfy string `env:"LISTEN_COMFY"`
|
ListenComfy string `env:"LISTEN_COMFY"`
|
||||||
ListenUI string `env:"LISTEN_UI"`
|
ListenUI string `env:"LISTEN_UI"`
|
||||||
|
UIUser string `env:"UI_USER"`
|
||||||
|
UIPass string `env:"UI_PASS"`
|
||||||
OllamaURL string `env:"OLLAMA_URL"`
|
OllamaURL string `env:"OLLAMA_URL"`
|
||||||
ComfyURL string `env:"COMFY_URL"`
|
ComfyURL string `env:"COMFY_URL"`
|
||||||
UnloadTimeout time.Duration `env:"UNLOAD_TIMEOUT"`
|
UnloadTimeout time.Duration `env:"UNLOAD_TIMEOUT"`
|
||||||
@@ -198,6 +200,8 @@ func Load(getenv func(string) string) (Config, error) {
|
|||||||
{"LISTEN_OLLAMA", &cfg.ListenOllama},
|
{"LISTEN_OLLAMA", &cfg.ListenOllama},
|
||||||
{"LISTEN_COMFY", &cfg.ListenComfy},
|
{"LISTEN_COMFY", &cfg.ListenComfy},
|
||||||
{"LISTEN_UI", &cfg.ListenUI},
|
{"LISTEN_UI", &cfg.ListenUI},
|
||||||
|
{"UI_USER", &cfg.UIUser},
|
||||||
|
{"UI_PASS", &cfg.UIPass},
|
||||||
{"OLLAMA_URL", &cfg.OllamaURL},
|
{"OLLAMA_URL", &cfg.OllamaURL},
|
||||||
{"COMFY_URL", &cfg.ComfyURL},
|
{"COMFY_URL", &cfg.ComfyURL},
|
||||||
{"WARM_MODEL", &cfg.WarmModel},
|
{"WARM_MODEL", &cfg.WarmModel},
|
||||||
@@ -320,6 +324,12 @@ func Load(getenv func(string) string) (Config, error) {
|
|||||||
default:
|
default:
|
||||||
return cfg, fmt.Errorf("LOG_FORMAT: must be \"text\" or \"json\"")
|
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 == "" {
|
if cfg.ComfyCmd != "" && cfg.ComfyURL == "" {
|
||||||
return cfg, fmt.Errorf("COMFY_CMD requires COMFY_URL to be set (the proxy needs somewhere to forward)")
|
return cfg, fmt.Errorf("COMFY_CMD requires COMFY_URL to be set (the proxy needs somewhere to forward)")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
func TestParseEnvFile(t *testing.T) {
|
||||||
input := `# comment
|
input := `# comment
|
||||||
OLLAMA_URL=http://host:11435
|
OLLAMA_URL=http://host:11435
|
||||||
|
|||||||
@@ -25,7 +25,9 @@ func sampleEntries(logFile string) []sampleEntry {
|
|||||||
return []sampleEntry{
|
return []sampleEntry{
|
||||||
{"LISTEN_OLLAMA", ":11434", "Listen address for Ollama-compatible clients (gpu-turnstile poses as Ollama here)", false},
|
{"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_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},
|
{"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},
|
{"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},
|
{"WARM_MODEL", "", "Optional model to reload after an image job (default: empty = none)", false},
|
||||||
|
|||||||
Reference in New Issue
Block a user