testing: bootstrap UI, agent reliability, NS-01..04 + alert username
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.
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
// 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)
|
||||
}
|
||||
Reference in New Issue
Block a user