// ui_enrollment_tokens.go — NS-02 token-recovery handlers. // // Today the only handle on a freshly-minted enrolment token is its // /hosts/pending/{token} URL, which lives in the operator's browser // tab. Closing that tab loses the install snippet — the row stays // alive in the DB until TTL expiry but invisible to the UI. These // handlers close the gap with two operations exposed on the // Add-host page: // // POST /hosts/enrollment-tokens/{hash}/regenerate // POST /hosts/enrollment-tokens/{hash}/revoke // // Hash here is the *token_hash* (sha256 hex of the raw token), which // is opaque on its own — it is not the credential, just an identifier // for the row. We chose regenerate over "show original token" because // only hashes are persisted; the raw token has been gone since the // original /hosts/new POST. package http import ( "encoding/json" "errors" "log/slog" stdhttp "net/http" "github.com/go-chi/chi/v5" "github.com/oklog/ulid/v2" "gitea.dcglab.co.uk/steve/restic-manager/internal/store" ) // handleUIEnrollmentTokenRegenerate revokes the row keyed by token // hash and mints a fresh raw token with the same attachments // (encrypted repo creds, initial paths). Redirects to the new // /hosts/pending/{newToken} so the operator lands directly on the // install snippet. func (s *Server) handleUIEnrollmentTokenRegenerate(w stdhttp.ResponseWriter, r *stdhttp.Request) { user, ok := s.requireUser(r) if !ok { writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorised", "") return } oldHash := chi.URLParam(r, "hash") if oldHash == "" { stdhttp.Error(w, "missing hash", stdhttp.StatusBadRequest) return } att, err := s.deps.Store.GetEnrollmentTokenAttachments(r.Context(), oldHash) if err != nil { if errors.Is(err, store.ErrNotFound) { // Already expired/consumed/revoked — bounce back without // fanfare so a stale form re-submit doesn't loud-fail. stdhttp.Redirect(w, r, "/hosts/new", stdhttp.StatusSeeOther) return } slog.Error("regen: load attachments", "err", err) stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) return } var blob repoCredsBlob if att.EncRepoCreds != "" { plain, err := s.deps.AEAD.Decrypt(att.EncRepoCreds, []byte("token:"+oldHash)) if err != nil { slog.Error("regen: decrypt", "err", err) stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) return } _ = json.Unmarshal(plain, &blob) } // Mint the new row first; only revoke the old one once the fresh // row exists. If something fails between, the operator at worst // sees both rows side-by-side on the list page (and can revoke the // stale one manually) — much better than nuking the old row and // failing the mint, leaving them with nothing. newToken, _, err := s.mintEnrollmentToken(r.Context(), blob.RepoURL, blob.RepoUsername, blob.RepoPassword, att.InitialPaths) if err != nil { slog.Error("regen: mint new", "err", err) stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) return } if err := s.deps.Store.DeleteEnrollmentToken(r.Context(), oldHash); err != nil && !errors.Is(err, store.ErrNotFound) { slog.Warn("regen: delete old", "old_hash", oldHash, "err", err) // Fall through — the new row is good; operator can revoke the // stale row from the list if the orphan row bothers them. } uid := user.ID short := oldHash if len(short) > 12 { short = short[:12] } _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{ ID: ulid.Make().String(), UserID: &uid, Actor: "user", Action: "enrollment_token.regenerated", TargetKind: ptr("enrollment_token"), TargetID: &short, TS: nowUTC(), }) stdhttp.Redirect(w, r, "/hosts/pending/"+newToken, stdhttp.StatusSeeOther) } // handleUIEnrollmentTokenRevoke deletes the token row outright. // Redirects to /hosts/new where the list re-renders without the row. func (s *Server) handleUIEnrollmentTokenRevoke(w stdhttp.ResponseWriter, r *stdhttp.Request) { user, ok := s.requireUser(r) if !ok { writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorised", "") return } hash := chi.URLParam(r, "hash") if hash == "" { stdhttp.Error(w, "missing hash", stdhttp.StatusBadRequest) return } if err := s.deps.Store.DeleteEnrollmentToken(r.Context(), hash); err != nil && !errors.Is(err, store.ErrNotFound) { slog.Error("revoke: delete", "err", err) stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) return } uid := user.ID short := hash if len(short) > 12 { short = short[:12] } _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{ ID: ulid.Make().String(), UserID: &uid, Actor: "user", Action: "enrollment_token.revoked", TargetKind: ptr("enrollment_token"), TargetID: &short, TS: nowUTC(), }) stdhttp.Redirect(w, r, "/hosts/new", stdhttp.StatusSeeOther) }