diff --git a/.env.example b/.env.example index c1f2cb2..5a417b5 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,8 @@ # Docker deployment configuration (copy to .env) # The image is built locally by `docker compose build`; there is no registry tag. +# Address the web UI is published on. 127.0.0.1 keeps it host-local; set 0.0.0.0 +# to reach it from another machine or from Windows when ARTEX runs inside WSL. +ARTEX_BIND_ADDR=127.0.0.1 POSTGRES_USER=artex POSTGRES_PASSWORD=change-me-please POSTGRES_DB=artex diff --git a/README.md b/README.md index e4a7566..b8fe7c1 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,9 @@ The script will: detect / auto-install Docker → then let you choose **① all- - **② Local run**: choose a database (connect to an existing one / start one with Docker) → generates `config.json` → `go` builds the embedded single binary → starts. Once installed, open **http://localhost:8787** (on first entry, `/setup` sets the admin password). +`/setup` asks for a one-time setup token: while no password is set, the backend prints it at startup in its log (`docker compose logs artex | grep "Setup token"`, or the terminal running `./start.sh`). + +The web UI is published on `127.0.0.1` by default. To reach it from another machine, or from Windows when ARTEX runs inside WSL, set `ARTEX_BIND_ADDR=0.0.0.0` in `.env` (the setup token still protects the first-run claim), or keep loopback and use a reverse proxy or an SSH tunnel. ### Option 2: Docker Compose (manual) diff --git a/docker-compose.yml b/docker-compose.yml index 6e1dbef..b041f28 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -35,7 +35,7 @@ services: ARTEX_LLM_BASE_URL: ${ARTEX_LLM_BASE_URL:-} ARTEX_LLM_PROXY: ${ARTEX_LLM_PROXY:-} ports: - - "8787:8787" + - "${ARTEX_BIND_ADDR:-127.0.0.1}:8787:8787" - "127.0.0.1:8788:8788" volumes: # Bind the data directory (jwt.key, SQLite, etc.) to data/ in the project root, diff --git a/server/auth.go b/server/auth.go index abbdd71..a94971e 100644 --- a/server/auth.go +++ b/server/auth.go @@ -2,6 +2,8 @@ package server import ( "crypto/rand" + "crypto/subtle" + "encoding/hex" "fmt" "log" "math/big" @@ -154,7 +156,34 @@ func (s *Server) authStatus(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, map[string]any{"initialized": hash != ""}) } +// initSetupToken generates the one-time setup token when no admin password is set +// and writes it to the server log. Without it, anyone who can reach the port +// before the operator could claim the instance through POST /api/auth/init. +func (s *Server) initSetupToken() { + if s.m == nil || s.m.pg == nil { + return + } + if hash, _, err := s.m.pg.GetSetting(authPassKey); err == nil && hash != "" { + return + } + buf := make([]byte, 16) + if _, err := rand.Read(buf); err != nil { + log.Fatalf("[auth] generate setup token: %v", err) + } + s.setupToken = hex.EncodeToString(buf) + log.Printf("[auth] no admin password is set. Setup token for the /setup page: %s", s.setupToken) +} + +// checkSetupToken compares got with the active setup token in constant time. +// It fails when no token is active. +func (s *Server) checkSetupToken(got string) bool { + s.setupMu.Lock() + defer s.setupMu.Unlock() + return s.setupToken != "" && subtle.ConstantTimeCompare([]byte(got), []byte(s.setupToken)) == 1 +} + // POST /api/auth/init — sets the password for the first time; rejected if already set. +// Requires the setup token from the server log. func (s *Server) authInit(w http.ResponseWriter, r *http.Request) { pg := s.pg(w) if pg == nil { @@ -170,12 +199,18 @@ func (s *Server) authInit(w http.ResponseWriter, r *http.Request) { return } var req struct { - Password string `json:"password"` + Password string `json:"password"` + SetupToken string `json:"setup_token"` } if err := decode(r, &req); err != nil || req.Password == "" { writeErr(w, 400, "Password cannot be empty") return } + // 403, not 401: the frontend treats any 401 as an expired session and redirects to /login. + if !s.checkSetupToken(req.SetupToken) { + writeErr(w, 403, "Invalid setup token. The server log prints it at startup while no password is set") + return + } if msg := validatePassword(req.Password); msg != "" { writeErr(w, 400, msg) return @@ -197,6 +232,9 @@ func (s *Server) authInit(w http.ResponseWriter, r *http.Request) { writeErr(w, 403, "Password is already set") return } + s.setupMu.Lock() + s.setupToken = "" + s.setupMu.Unlock() tok, err := signJWT(s.jwtKey) if err != nil { writeErr(w, 500, "token generation failed") diff --git a/server/auth_test.go b/server/auth_test.go index 4bda0aa..752b5de 100644 --- a/server/auth_test.go +++ b/server/auth_test.go @@ -62,6 +62,62 @@ func TestAuthInitFailsClosedWhenDataSourceUnavailable(t *testing.T) { } } +// While no password is set, authInit must reject a missing or wrong setup token +// and accept the one generated at startup, once. +func TestAuthInitRequiresSetupToken(t *testing.T) { + m, err := NewManager(t.TempDir(), "") + if err != nil { + t.Skipf("postgres unavailable (%v)", err) + } + defer m.Close() + clearPassword := func() { + if _, err := m.pg.Exec(`DELETE FROM settings WHERE key=$1`, authPassKey); err != nil { + t.Fatal(err) + } + } + clearPassword() + defer clearPassword() + + s := &Server{m: m, jwtKey: []byte("0123456789abcdef0123456789abcdef")} + s.initSetupToken() + setupToken := s.setupToken + if setupToken == "" { + t.Fatal("no setup token generated while no password is set") + } + post := func(body string) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + s.authInit(w, httptest.NewRequest("POST", "/api/auth/init", strings.NewReader(body))) + return w + } + + for name, body := range map[string]string{ + "missing": `{"password":"correct horse battery"}`, + "empty": `{"password":"correct horse battery","setup_token":""}`, + "wrong": `{"password":"correct horse battery","setup_token":"0000"}`, + } { + if w := post(body); w.Code != 403 || strings.Contains(w.Body.String(), `"token"`) { + t.Fatalf("%s setup token: status=%d want 403 without a token; body=%s", name, w.Code, w.Body.String()) + } + } + if hash, _, err := m.pg.GetSetting(authPassKey); err != nil || hash != "" { + t.Fatalf("a rejected request changed the password state: hash set=%v err=%v", hash != "", err) + } + + ok := `{"password":"correct horse battery","setup_token":"` + setupToken + `"}` + if w := post(ok); w.Code != 200 || !strings.Contains(w.Body.String(), `"token"`) { + t.Fatalf("right setup token: status=%d want 200 with a token; body=%s", w.Code, w.Body.String()) + } + if hash, _, err := m.pg.GetSetting(authPassKey); err != nil || hash == "" { + t.Fatalf("password not stored: err=%v", err) + } + + // The token is spent: even with the password row removed, it is refused. + clearPassword() + if w := post(ok); w.Code != 403 { + t.Fatalf("reused setup token: status=%d want 403; body=%s", w.Code, w.Body.String()) + } +} + func TestValidatePassword(t *testing.T) { for _, tc := range []struct { name, pw string diff --git a/server/server.go b/server/server.go index a64b1da..65fbc45 100644 --- a/server/server.go +++ b/server/server.go @@ -48,6 +48,9 @@ type Server struct { scrubSecrets bool // drop ARTEX_PG_DSN and the API keys from the environment once read (binary only) + setupMu sync.Mutex + setupToken string // one-time token POST /api/auth/init requires; empty when none is active + // concMu serializes concurrency-cap decisions (admission + reconcile) so a // scheduler tick and an HTTP settings change / task creation can't both count // free slots off the same snapshot and over-promote past the limit. @@ -153,6 +156,7 @@ func New(ctx context.Context, m *Manager, skillDir string, dataDir string, keyDi profChatAgents: map[int64]*agent.ChatAgent{}, provByProfile: map[int64]*provEntry{}, llmHealth: newLLMHealthRegistry(m.pg), taskAgents: map[string]*taskAgentBundle{}, archiveWake: make(chan struct{}, 1)} + s.initSetupToken() s.initSideQuestions() // Circuit-breaker thresholds/cooldowns are hot parameters on the failure path; push the global retry policy to the Registry once at startup, // and again on every policy save (saveLLMRetryPolicy). diff --git a/web/src/app/(auth)/setup/page.tsx b/web/src/app/(auth)/setup/page.tsx index 9d83a6a..db57917 100644 --- a/web/src/app/(auth)/setup/page.tsx +++ b/web/src/app/(auth)/setup/page.tsx @@ -14,6 +14,7 @@ export default function SetupPage() { const router = useRouter(); const [password, setPassword] = useState(""); const [confirm, setConfirm] = useState(""); + const [setupToken, setSetupToken] = useState(""); const [error, setError] = useState(""); const [loading, setLoading] = useState(false); const [checking, setChecking] = useState(true); @@ -47,7 +48,7 @@ export default function SetupPage() { setLoading(true); setError(""); try { - const { token } = await api.initPassword(password); + const { token } = await api.initPassword(password, setupToken.trim()); auth.setToken(token); router.replace("/function/tasks"); } catch (err) { @@ -80,7 +81,7 @@ export default function SetupPage() {
{unavailable ? "The backend or database is temporarily unavailable. To avoid overwriting a password already set on this instance, setup is temporarily closed — restore the service and try again." - : "First time using ARTEX? Set a login password for the account (at least 8 characters)"} + : "First time using ARTEX? Enter the setup token from the server log, then set a login password for the account (at least 8 characters)"}
{unavailable ? ( @@ -92,6 +93,17 @@ export default function SetupPage() { ) : ( diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 1d5d782..e41d5d6 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -240,7 +240,8 @@ export const api = { // ---- auth ---- authStatus: () => get<{ initialized: boolean }>("/auth/status"), login: (username: string, password: string) => post<{ token: string }>("/auth/login", { username, password }), - initPassword: (password: string) => post<{ token: string }>("/auth/init", { password }), + initPassword: (password: string, setupToken: string) => + post<{ token: string }>("/auth/init", { password, setup_token: setupToken }), changePassword: (oldPassword: string, newPassword: string) => post<{ ok: boolean }>("/auth/change-password", { old_password: oldPassword, new_password: newPassword }),