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
+10
View File
@@ -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)")
}
+24
View File
@@ -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
+3 -1
View File
@@ -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},