P1-27: Add host flow — form + minted-token result page

GET /hosts/new renders the focused two-column form (hostname,
tags, repo URL/username/password). POST /hosts/new validates,
mints a one-time token via the new mintEnrollmentToken helper —
shared with the existing JSON /api/enrollment-tokens endpoint —
and re-renders the same page in result state showing:

  - the install command with RM_SERVER + RM_TOKEN filled in (and
    an inline copy-to-clipboard button),
  - an "awaiting agent connection" panel with the hostname
    pre-filled,
  - a troubleshooting list pointing at the most common reasons
    the agent doesn't appear,
  - back-to-dashboard / add-another-host links.

publicURL() resolves RM_BASE_URL first, falling back to scheme +
Host on the inbound request — useful for local smoke without a
proxy.

Browser-verified end-to-end: form submit → token minted → install
command renders with the right values from the form input.

template fn formatRelTime now accepts time.Time *or* *time.Time
so templates can pass either without fighting Go's lack of an
address-of operator.

Deferred: download-preconfigured-installer (a templated .sh with
the values baked in) — copy-paste covers v1; nice-to-have later.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-01 20:16:54 +01:00
parent 86f7c17d9d
commit 9795492f2e
7 changed files with 344 additions and 23 deletions
+29 -15
View File
@@ -195,37 +195,51 @@ func (s *Server) handleCreateEnrollmentToken(w stdhttp.ResponseWriter, r *stdhtt
writeJSONError(w, stdhttp.StatusBadRequest, "invalid_json", err.Error())
return
}
if req.RepoURL == "" || req.RepoPassword == "" {
token, expiresAt, err := s.mintEnrollmentToken(r.Context(), req.RepoURL, req.RepoUsername, req.RepoPassword)
switch err {
case nil:
writeJSON(w, stdhttp.StatusCreated, enrollOperatorResponse{Token: token, ExpiresAt: expiresAt})
case errMissingRepoCreds:
writeJSONError(w, stdhttp.StatusBadRequest, "missing_field",
"repo_url and repo_password are required so the agent can run backups on first connect")
return
default:
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "")
}
}
// errMissingRepoCreds is returned by mintEnrollmentToken when the
// operator hasn't supplied the URL+password pair the agent needs.
// Sentinel error so HTML and JSON handlers can map it to their own
// surface (form re-render with banner / 400 with code).
var errMissingRepoCreds = errAuth("missing_repo_creds")
// mintEnrollmentToken creates a fresh one-time enrollment token and
// stashes the AEAD-encrypted repo creds on its row. Returns the raw
// token (shown to the operator exactly once) and the expiry time.
//
// Shared by the JSON endpoint and the HTML "Add host" flow.
func (s *Server) mintEnrollmentToken(ctx context.Context, repoURL, repoUsername, repoPassword string) (string, time.Time, error) {
if repoURL == "" || repoPassword == "" {
return "", time.Time{}, errMissingRepoCreds
}
token, err := auth.NewToken()
if err != nil {
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "")
return
return "", time.Time{}, err
}
tokHash := auth.HashToken(token)
enc, err := s.encryptRepoCreds(repoCredsBlob{
RepoURL: req.RepoURL, RepoUsername: req.RepoUsername, RepoPassword: req.RepoPassword,
RepoURL: repoURL, RepoUsername: repoUsername, RepoPassword: repoPassword,
}, []byte("token:"+tokHash))
if err != nil {
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "")
return
return "", time.Time{}, err
}
const ttl = time.Hour
if err := s.deps.Store.CreateEnrollmentToken(r.Context(), tokHash, ttl, enc); err != nil {
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "")
return
if err := s.deps.Store.CreateEnrollmentToken(ctx, tokHash, ttl, enc); err != nil {
return "", time.Time{}, err
}
writeJSON(w, stdhttp.StatusCreated, enrollOperatorResponse{
Token: token,
ExpiresAt: time.Now().Add(ttl).UTC(),
})
return token, time.Now().Add(ttl).UTC(), nil
}
// rebindTokenCreds decrypts the creds attached to the token (if any),
+3
View File
@@ -133,6 +133,9 @@ func (s *Server) routes(r chi.Router) {
r.Post("/logout", s.handleUILogoutPost)
// HTMX action endpoint for "Run now" buttons on the dashboard.
r.Post("/hosts/{id}/run-backup", s.handleUIRunBackup)
// Add host flow.
r.Get("/hosts/new", s.handleUIAddHostGet)
r.Post("/hosts/new", s.handleUIAddHostPost)
}
}
+113
View File
@@ -5,6 +5,8 @@ import (
"io/fs"
"log/slog"
stdhttp "net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
@@ -163,6 +165,117 @@ func (s *Server) handleUIRunBackup(w stdhttp.ResponseWriter, r *stdhttp.Request)
w.WriteHeader(stdhttp.StatusNoContent)
}
// addHostPage carries the form state into the Add host template.
// In State A (form), Token is empty. In State B (result), Token is
// populated and the template renders the install command.
type addHostPage struct {
// Form fields — pre-populate the form on a re-render after a
// validation error.
Hostname string
Tags string
RepoURL string
RepoUsername string
// Server URL the operator should paste into the install
// command. Resolved from RM_BASE_URL falling back to the
// request's Host header.
ServerURL string
// Banner-level error shown above the form.
Error string
// Result state. When Token != "", the template renders the
// install command panel instead of the form.
Token string
ExpiresAt time.Time
}
// handleUIAddHostGet renders the empty Add host form.
func (s *Server) handleUIAddHostGet(w stdhttp.ResponseWriter, r *stdhttp.Request) {
u := s.requireUIUser(w, r)
if u == nil {
return
}
view := s.baseView(u, "dashboard")
view.Title = "Add host · restic-manager"
view.Page = addHostPage{ServerURL: s.publicURL(r)}
if err := s.deps.UI.Render(w, "add_host", view); err != nil {
slog.Error("ui: render add_host", "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
}
}
// handleUIAddHostPost validates the form, mints the enrolment token
// (with encrypted repo creds), and re-renders the same page in
// "result" state showing the install command.
func (s *Server) handleUIAddHostPost(w stdhttp.ResponseWriter, r *stdhttp.Request) {
u := s.requireUIUser(w, r)
if u == nil {
return
}
if err := r.ParseForm(); err != nil {
stdhttp.Error(w, "bad request", stdhttp.StatusBadRequest)
return
}
page := addHostPage{
Hostname: strings.TrimSpace(r.PostForm.Get("hostname")),
Tags: strings.TrimSpace(r.PostForm.Get("tags")),
RepoURL: strings.TrimSpace(r.PostForm.Get("repo_url")),
RepoUsername: strings.TrimSpace(r.PostForm.Get("repo_username")),
ServerURL: s.publicURL(r),
}
repoPassword := r.PostForm.Get("repo_password")
if page.Hostname == "" {
page.Error = "Hostname is required."
} else if page.RepoURL == "" || repoPassword == "" {
page.Error = "Repo URL and password are both required so the agent can back up the moment it comes online."
}
if page.Error == "" {
token, expires, err := s.mintEnrollmentToken(r.Context(), page.RepoURL, page.RepoUsername, repoPassword)
switch err {
case nil:
page.Token = token
page.ExpiresAt = expires
case errMissingRepoCreds:
page.Error = "Repo URL and password are both required."
default:
slog.Error("ui add_host: mint token", "err", err)
page.Error = "Couldnt mint a token — see the server log for details."
}
}
view := s.baseView(u, "dashboard")
view.Title = "Add host · restic-manager"
view.Page = page
status := stdhttp.StatusOK
if page.Error != "" {
status = stdhttp.StatusUnprocessableEntity
} else {
status = stdhttp.StatusCreated
}
w.WriteHeader(status)
if err := s.deps.UI.Render(w, "add_host", view); err != nil {
slog.Error("ui: render add_host", "err", err)
}
}
// publicURL is what the operator should paste into the install
// command. Prefers RM_BASE_URL (set by the operator's reverse
// proxy config) and falls back to scheme + Host of the inbound
// request — useful for local smoke without a proxy.
func (s *Server) publicURL(r *stdhttp.Request) string {
if s.deps.Cfg.BaseURL != "" {
return strings.TrimRight(s.deps.Cfg.BaseURL, "/")
}
scheme := "http"
if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
scheme = "https"
}
return scheme + "://" + r.Host
}
// userByID fetches the full store.User the UI session represents.
// Returns the user, ok-flag, error. Used by handlers that need the
// store-side row (e.g. for audit_log.user_id) rather than just the
+20 -6
View File
@@ -69,14 +69,28 @@ func formatBytes(n int64) template.HTML {
return template.HTML(fmt.Sprintf(`%s <span class="text-ink-mute text-[11px]">%s</span>`, num, unit))
}
// formatRelTime renders a *time.Time as a short relative string like
// "3m ago" / "2d ago" / "5w ago". Future times render as "in Xs".
// Nil pointer returns "—".
func formatRelTime(t *time.Time) string {
if t == nil || t.IsZero() {
// formatRelTime renders a time as a short relative string like
// "3m ago" / "2d ago" / "5w ago". Future times render as
// "in 5m"-style. Accepts *time.Time or time.Time so templates can
// pass either without fighting Go's lack of an address-of operator.
// Anything else returns "—".
func formatRelTime(v any) string {
var t time.Time
switch x := v.(type) {
case time.Time:
t = x
case *time.Time:
if x == nil {
return "—"
}
t = *x
default:
return "—"
}
d := time.Since(*t)
if t.IsZero() {
return "—"
}
d := time.Since(t)
suffix := "ago"
if d < 0 {
d = -d