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),