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:
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = "Couldn’t 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
|
||||
|
||||
Reference in New Issue
Block a user