// bootstrap_handler.go — public landing page for the first-run admin // flow. While the server has no users and still holds the in-memory // one-shot bootstrap token printed at startup, /bootstrap renders a // form that takes a username + password and creates the first admin. // // The operator never sees or types the token: the server already has // it in memory, so the UI handler uses it directly. The token printed // to stderr remains a break-glass fallback for the JSON // /api/bootstrap path. // // Routes (wired in server.go): // // GET /bootstrap → handleUIBootstrapGet // POST /bootstrap → handleUIBootstrapPost // // Both routes self-disable the moment a user row exists; subsequent // hits redirect to /login. package http import ( "log/slog" stdhttp "net/http" "time" "github.com/oklog/ulid/v2" "gitea.dcglab.co.uk/steve/restic-manager/internal/auth" "gitea.dcglab.co.uk/steve/restic-manager/internal/store" ) type bootstrapPage struct { Username string Error string } func (s *Server) handleUIBootstrapGet(w stdhttp.ResponseWriter, r *stdhttp.Request) { if !s.bootstrapAvailable(r) { stdhttp.Redirect(w, r, "/login", stdhttp.StatusSeeOther) return } s.renderBootstrap(w, r, "", "") } func (s *Server) handleUIBootstrapPost(w stdhttp.ResponseWriter, r *stdhttp.Request) { if !s.bootstrapAvailable(r) { stdhttp.Redirect(w, r, "/login", stdhttp.StatusSeeOther) return } if err := r.ParseForm(); err != nil { stdhttp.Error(w, "bad request", stdhttp.StatusBadRequest) return } username := r.PostForm.Get("username") pw := r.PostForm.Get("password") pw2 := r.PostForm.Get("password_confirm") if username == "" { s.renderBootstrap(w, r, username, "Pick a username.") return } if pw == "" || pw2 == "" || pw != pw2 || len(pw) < 12 { s.renderBootstrap(w, r, username, "Passwords must match and be at least 12 characters.") return } hash, err := auth.HashPassword(pw) if err != nil { slog.Error("bootstrap: hash password", "err", err) stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) return } now := time.Now().UTC() u := store.User{ ID: ulid.Make().String(), Username: username, PasswordHash: hash, Role: store.RoleAdmin, CreatedAt: now, } if err := s.deps.Store.CreateUser(r.Context(), u); err != nil { slog.Error("bootstrap: create user", "err", err) s.renderBootstrap(w, r, username, "Could not create the administrator account. Check the server logs.") return } // Clear the in-memory token so /api/bootstrap also stops accepting // further calls. CountUsers > 0 already gates both surfaces, but // blanking the token kills the constant-time-compare branch as // well — defence in depth, plus stops the token from sitting in // process memory longer than necessary. s.deps.BootstrapToken = "" _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{ ID: ulid.Make().String(), UserID: &u.ID, Actor: "system", Action: "auth.bootstrap", TS: now, }) // Mint a session so the new admin lands authenticated on /. rawSession, err := auth.NewToken() if err != nil { slog.Error("bootstrap: session token", "err", err) stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) return } if err := s.deps.Store.CreateSession(r.Context(), store.Session{ UserID: u.ID, CreatedAt: now, ExpiresAt: now.Add(sessionTTL), IP: r.RemoteAddr, UA: r.UserAgent(), }, auth.HashToken(rawSession)); err != nil { slog.Error("bootstrap: create session", "err", err) stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) return } _ = s.deps.Store.MarkUserLogin(r.Context(), u.ID, now) stdhttp.SetCookie(w, &stdhttp.Cookie{ Name: sessionCookieName, Value: rawSession, Path: "/", HttpOnly: true, Secure: s.deps.Cfg.CookieSecure, SameSite: stdhttp.SameSiteLaxMode, Expires: now.Add(sessionTTL), }) stdhttp.Redirect(w, r, "/", stdhttp.StatusSeeOther) } // bootstrapAvailable reports whether a fresh-install bootstrap can // still proceed: a one-shot token is held in memory and no user rows // exist yet. func (s *Server) bootstrapAvailable(r *stdhttp.Request) bool { if s.deps.BootstrapToken == "" { return false } n, err := s.deps.Store.CountUsers(r.Context()) if err != nil { slog.Error("bootstrap: count users", "err", err) return false } return n == 0 } func (s *Server) renderBootstrap(w stdhttp.ResponseWriter, r *stdhttp.Request, username, errMsg string) { view := s.baseView(r, nil) view.Title = "Welcome · restic-manager" view.Page = bootstrapPage{Username: username, Error: errMsg} if err := s.deps.UI.Render(w, "bootstrap", view); err != nil { slog.Error("ui bootstrap: render", "err", err) stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) } }