3800b34a2b
CI / Test (rest) (pull_request) Successful in 29s
CI / Lint (pull_request) Successful in 32s
CI / Build (windows/amd64) (pull_request) Successful in 22s
CI / Test (store) (pull_request) Successful in 1m22s
CI / Test (server-http) (pull_request) Successful in 1m30s
CI / Build (linux/amd64) (pull_request) Successful in 22s
CI / Build (linux/arm64) (pull_request) Successful in 41s
Smoothes the rough edges that came up exercising a live deployment.
First-run bootstrap UI: /bootstrap renders a username + password form
that uses the in-memory token directly (operator no longer copies it
out of the log); /login redirects there while bootstrap is available.
Agent reliability: failJob synthetic envelopes so command.run early
returns no longer hang the server-side job; runtime probe of restic
restore --help drives --no-ownership instead of version sniffing
(0.18.x had it removed). Server unit re-shaped: ProtectSystem=full
plus ReadWritePaths=/etc/restic-manager, no ProtectHome — restore
can now write anywhere a user might want.
Restore wizard: default target is /root/rm-restore/<job-id>/ with
clearer help text. Re-init confirm input uses .field (was .input,
which doesn't exist — text was invisible).
NS-01 host delete: store DeleteHost, admin-band /hosts/{id}/delete
with hostname-confirm danger zone, audit, FK cascade, live WS close.
NS-02 enrollment-token recovery: outstanding-tokens panel on
/hosts/new, regenerate (preserves attachments) and revoke handlers
+ audit, store-level ListOutstandingEnrollmentTokens and
DeleteEnrollmentToken.
NS-03 repo init / probe surface: migration 0020 adds
hosts.repo_status + repo_status_error; WS handler projects every
init job's outcome onto the host row (idempotent already-initialised
collapses to ready); creds-save resets status and dispatches a fresh
probe; /hosts/{id}/repo/probe retry endpoint with banner.
NS-04 dashboard live + sort + filter: query-string filter
(q/status/repo_status/tag/sort/dir), 5s htmx live poll mirroring the
alerts pattern with a localStorage live toggle, sortable column
headers, filter row + clear.
Alerts page: ack'd-by line resolves user_id ULID to username.
Compose.yaml ignored — host-specific.
158 lines
4.7 KiB
Go
158 lines
4.7 KiB
Go
// 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)
|
|
}
|
|
}
|