Add-host: durable pending page + polled awaiting-agent panel

Two issues from a smoke session:
1. The awaiting-agent panel never refreshed — operator had to go
   back to the dashboard to see the host had connected.
2. Generated passwords were displayed only on the POST response.
   Navigating away (or even an accidental tab close) lost them
   permanently, so the operator couldn't update the rest-server's
   htpasswd.

Both are the same fix: convert the POST-rendered transient
"result state" into a durable GET page at /hosts/pending/{token}.

* New route GET /hosts/pending/{token} renders the install-command +
  htpasswd snippet view. Password is decrypted from the (still-
  encrypted-at-rest) token row on every render — operator can
  refresh, bookmark, navigate away and come back. Once the agent
  enrols, the page redirects to /hosts/{id}; once the token
  expires, redirect to /hosts/new.
* New route GET /hosts/pending/{token}/awaiting returns a polled
  HTML fragment that the pending page swaps in every 2s via HTMX.
  States: awaiting (keep polling) | connected (show "Open host →"
  + "View schedules" CTAs, polling stops) | expired (mint-new
  link, polling stops). Polling stops naturally because only the
  awaiting state's wrapper carries the hx-trigger attribute.
* POST /hosts/new now 303-redirects to /hosts/pending/{token}
  on success; validation errors keep re-rendering the form with
  banner.

Supporting changes:
* New store helper Store.GetEnrollmentTokenStatus(tokenHash) for
  the polling endpoint — returns {expires_at, consumed_at,
  consumed_host} in one round-trip without dragging in the
  attachments-decryption path.
* New ui.Renderer.RenderPartial(w, name, data) for HTMX fragment
  responses (no layout wrap). Picks an arbitrary page's template
  set as the lookup point — every page parses the full common-
  paths list, so they all see every partial.
* add_host.html stripped to form-only; pending_host.html owns the
  result-state UI; awaiting_agent.html is the polled partial.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-02 12:59:24 +01:00
parent 8fb1c100fd
commit c1f85da55f
8 changed files with 464 additions and 238 deletions
+6
View File
@@ -151,6 +151,12 @@ func (s *Server) routes(r chi.Router) {
// Add host flow.
r.Get("/hosts/new", s.handleUIAddHostGet)
r.Post("/hosts/new", s.handleUIAddHostPost)
// Durable post-Add-host page (operator can refresh / come
// back; password decrypted from the token row each render).
// Polled fragment under /awaiting flips to "connected" once
// the agent enrols.
r.Get("/hosts/pending/{token}", s.handleUIPendingHost)
r.Get("/hosts/pending/{token}/awaiting", s.handleUIPendingAwaiting)
// Host detail (Snapshots tab is the default).
r.Get("/hosts/{id}", s.handleUIHostDetail)
// Schedules tab + create/edit/delete forms.
+147 -45
View File
@@ -4,6 +4,7 @@ import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"io/fs"
"log/slog"
@@ -284,9 +285,12 @@ func (s *Server) handleUIInitRepo(w stdhttp.ResponseWriter, r *stdhttp.Request)
stdhttp.Redirect(w, r, target, stdhttp.StatusSeeOther)
}
// 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.
// addHostPage carries the Add-host form state. The result-state
// (showing the install command + htpasswd snippet) lives at
// /hosts/pending/{token} and uses pendingHostPage instead, so the
// operator can refresh / bookmark / come back later — the password
// is decrypted from the still-alive token row on every render
// rather than living only in a one-shot rendered response.
type addHostPage struct {
// Form fields — pre-populate the form on a re-render after a
// validation error.
@@ -294,32 +298,22 @@ type addHostPage struct {
Tags string
RepoURL string
RepoUsername string
// Paths is the textarea-as-typed default-paths input. One path
// per line, blanks ignored.
Paths string
ServerURL string
Error 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
// RepoPassword is the password the agent will use against the
// rest-server. When the operator left the password field blank
// we generate one server-side; PasswordGenerated tracks which
// path produced it so the result page can label it appropriately.
// Either way it's surfaced on the result page exactly once,
// inside the htpasswd snippet — same one-time-view rule as the
// enrolment token. Reload = gone.
// pendingHostPage is the GET /hosts/pending/{token} view. Lives
// for as long as the token does (1h ttl); once the agent enrols,
// the handler redirects to /hosts/{host_id} and this page is gone.
type pendingHostPage struct {
Token string
ServerURL string
ExpiresAt time.Time
RepoURL string
RepoUsername string
RepoPassword string
PasswordGenerated bool
InitialPaths []string
}
// handleUIAddHostGet renders the empty Add host form.
@@ -338,8 +332,9 @@ func (s *Server) handleUIAddHostGet(w stdhttp.ResponseWriter, r *stdhttp.Request
}
// 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.
// (with encrypted repo creds), and 303-redirects to the persistent
// pending-host page. On validation errors we re-render the form
// with the operator's typed input intact and a banner.
func (s *Server) handleUIAddHostPost(w stdhttp.ResponseWriter, r *stdhttp.Request) {
u := s.requireUIUser(w, r)
if u == nil {
@@ -365,9 +360,6 @@ func (s *Server) handleUIAddHostPost(w stdhttp.ResponseWriter, r *stdhttp.Reques
page.Error = "Repo URL is required so the agent can back up the moment it comes online."
}
// If the operator didn't type a password, mint one. We surface it
// once on the result page (inside the htpasswd snippet) so they
// can paste it into the rest-server's htpasswd file.
if page.Error == "" && repoPassword == "" {
gen, err := generateRepoPassword()
if err != nil {
@@ -375,19 +367,15 @@ func (s *Server) handleUIAddHostPost(w stdhttp.ResponseWriter, r *stdhttp.Reques
page.Error = "Couldnt generate a password — see the server log for details."
} else {
repoPassword = gen
page.PasswordGenerated = true
}
}
defaultPaths := splitPaths(page.Paths)
if page.Error == "" {
token, expires, err := s.mintEnrollmentToken(r.Context(), page.RepoURL, page.RepoUsername, repoPassword, defaultPaths)
token, _, err := s.mintEnrollmentToken(r.Context(), page.RepoURL, page.RepoUsername, repoPassword, splitPaths(page.Paths))
switch err {
case nil:
page.Token = token
page.ExpiresAt = expires
page.RepoPassword = repoPassword
stdhttp.Redirect(w, r, "/hosts/pending/"+token, stdhttp.StatusSeeOther)
return
case errMissingRepoCreds:
page.Error = "Repo URL and password are both required."
default:
@@ -399,18 +387,132 @@ func (s *Server) handleUIAddHostPost(w stdhttp.ResponseWriter, r *stdhttp.Reques
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)
w.WriteHeader(stdhttp.StatusUnprocessableEntity)
if err := s.deps.UI.Render(w, "add_host", view); err != nil {
slog.Error("ui: render add_host", "err", err)
}
}
// handleUIPendingHost serves the durable Add-host result page —
// shown after a successful POST /hosts/new and reachable until the
// agent enrols (the page redirects to /hosts/{id} once that
// happens) or the token expires (1h ttl). The password is
// re-decrypted from the encrypted token row on every render so
// the operator can refresh, bookmark, navigate away and come back.
func (s *Server) handleUIPendingHost(w stdhttp.ResponseWriter, r *stdhttp.Request) {
u := s.requireUIUser(w, r)
if u == nil {
return
}
rawToken := chi.URLParam(r, "token")
if rawToken == "" {
stdhttp.NotFound(w, r)
return
}
tokHash := auth.HashToken(rawToken)
status, err := s.deps.Store.GetEnrollmentTokenStatus(r.Context(), tokHash)
if err != nil {
stdhttp.Redirect(w, r, "/hosts/new", stdhttp.StatusSeeOther)
return
}
if status.ConsumedHost != nil {
stdhttp.Redirect(w, r, "/hosts/"+*status.ConsumedHost, stdhttp.StatusSeeOther)
return
}
if time.Now().After(status.ExpiresAt) {
stdhttp.Redirect(w, r, "/hosts/new", stdhttp.StatusSeeOther)
return
}
att, err := s.deps.Store.GetEnrollmentTokenAttachments(r.Context(), tokHash)
if err != nil {
slog.Warn("ui pending: load attachments", "err", err)
stdhttp.Redirect(w, r, "/hosts/new", stdhttp.StatusSeeOther)
return
}
page := pendingHostPage{
Token: rawToken,
ServerURL: s.publicURL(r),
ExpiresAt: status.ExpiresAt,
InitialPaths: att.InitialPaths,
}
if att.EncRepoCreds != "" {
plain, err := s.deps.AEAD.Decrypt(att.EncRepoCreds, []byte("token:"+tokHash))
if err != nil {
slog.Error("ui pending: decrypt creds", "err", err)
} else {
var blob repoCredsBlob
if err := json.Unmarshal(plain, &blob); err == nil {
page.RepoURL = blob.RepoURL
page.RepoUsername = blob.RepoUsername
page.RepoPassword = blob.RepoPassword
}
}
}
view := s.baseView(u, "dashboard")
view.Title = "Pending host · restic-manager"
view.Page = page
if err := s.deps.UI.Render(w, "pending_host", view); err != nil {
slog.Error("ui: render pending_host", "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
}
}
// handleUIPendingAwaiting is the polled fragment that the pending-
// host page swaps in every couple of seconds to detect "agent
// connected". Returns either the still-awaiting partial (with the
// HTMX poll trigger preserved) or the connected partial (no poll —
// includes a meta-refresh to /hosts/{id} so the operator lands on
// the host detail).
func (s *Server) handleUIPendingAwaiting(w stdhttp.ResponseWriter, r *stdhttp.Request) {
if u := s.requireUIUser(w, r); u == nil {
return
}
rawToken := chi.URLParam(r, "token")
if rawToken == "" {
stdhttp.Error(w, "missing token", stdhttp.StatusBadRequest)
return
}
status, err := s.deps.Store.GetEnrollmentTokenStatus(r.Context(), auth.HashToken(rawToken))
page := awaitingFragment{Token: rawToken, ExpiresAt: status.ExpiresAt}
switch {
case errors.Is(err, store.ErrNotFound):
page.State = "expired"
case err != nil:
slog.Warn("ui awaiting: lookup", "err", err)
page.State = "expired"
case status.ConsumedHost != nil:
page.State = "connected"
page.HostID = *status.ConsumedHost
if h, err := s.deps.Store.GetHost(r.Context(), *status.ConsumedHost); err == nil {
page.HostName = h.Name
page.LastSeenAt = h.LastSeenAt
}
case time.Now().After(status.ExpiresAt):
page.State = "expired"
default:
page.State = "awaiting"
}
if err := s.deps.UI.RenderPartial(w, "awaiting_agent", ui.ViewData{Page: page}); err != nil {
slog.Error("ui: render awaiting_agent", "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
}
}
// awaitingFragment carries the state for the polled awaiting-agent
// partial. State == awaiting | connected | expired drives both the
// copy block and whether HTMX keeps polling.
type awaitingFragment struct {
State string
Token string
ExpiresAt time.Time
HostID string
HostName string
LastSeenAt *time.Time
}
// hostDetailPage carries everything the host detail template needs.
type hostDetailPage struct {
Host store.Host
+18
View File
@@ -90,6 +90,7 @@ func New() (*Renderer, error) {
"templates/partials/nav.html",
"templates/partials/host_row.html",
"templates/partials/toast.html",
"templates/partials/awaiting_agent.html",
}
pageEntries, err := fs.Glob(web.FS, "templates/pages/*.html")
@@ -126,6 +127,23 @@ func (r *Renderer) Render(w io.Writer, page string, data ViewData) error {
return t.ExecuteTemplate(w, layoutFor(page), data)
}
// RenderPartial writes a named partial template to w *without* the
// layout wrap. Used by HTMX endpoints that swap fragments into
// already-rendered pages. The partial is looked up in any page's
// template set (every page parses the full common-paths list, so
// they all see every partial). Pick "dashboard" arbitrarily as the
// lookup point — partials are layout-agnostic.
func (r *Renderer) RenderPartial(w io.Writer, name string, data ViewData) error {
t, ok := r.pages["dashboard"]
if !ok {
return fmt.Errorf("ui: renderer has no pages registered")
}
if data.Version == "" {
data.Version = "dev"
}
return t.ExecuteTemplate(w, name, data)
}
// layoutFor picks the layout name for a page. Login + bootstrap go
// chrome-less; everything else uses the standard navigation chrome.
func layoutFor(page string) string {
+44
View File
@@ -116,6 +116,50 @@ func (s *Store) GetEnrollmentTokenAttachments(ctx context.Context, tokenHash str
return out, nil
}
// EnrollmentTokenStatus is what the awaiting-agent panel polls for
// after Add-host. Returned by GetEnrollmentTokenStatus; the
// consuming code branches on Consumed + the (optional) ConsumedHost.
type EnrollmentTokenStatus struct {
ExpiresAt time.Time
ConsumedAt *time.Time
ConsumedHost *string
}
// GetEnrollmentTokenStatus reports whether a token has been
// consumed yet (the agent has called /api/agents/enroll). Returns
// ErrNotFound if the token is unknown — the polling endpoint maps
// that to "token expired or invalid; stop polling".
func (s *Store) GetEnrollmentTokenStatus(ctx context.Context, tokenHash string) (EnrollmentTokenStatus, error) {
row := s.db.QueryRowContext(ctx,
`SELECT expires_at, consumed_at, consumed_host
FROM enrollment_tokens WHERE token_hash = ?`,
tokenHash)
var (
expiresAt string
consumedAt, host sql.NullString
)
if err := row.Scan(&expiresAt, &consumedAt, &host); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return EnrollmentTokenStatus{}, ErrNotFound
}
return EnrollmentTokenStatus{}, fmt.Errorf("store: get enrollment token status: %w", err)
}
out := EnrollmentTokenStatus{}
if t, err := time.Parse(time.RFC3339Nano, expiresAt); err == nil {
out.ExpiresAt = t
}
if consumedAt.Valid {
if t, err := time.Parse(time.RFC3339Nano, consumedAt.String); err == nil {
out.ConsumedAt = &t
}
}
if host.Valid {
s := host.String
out.ConsumedHost = &s
}
return out, nil
}
// PurgeExpiredEnrollmentTokens deletes long-expired token rows. Tokens
// retained for ~24h after expiry so audit traces still resolve them.
func (s *Store) PurgeExpiredEnrollmentTokens(ctx context.Context) (int64, error) {
File diff suppressed because one or more lines are too long
+91 -192
View File
@@ -6,211 +6,110 @@
<div class="crumbs"><a href="/">Dashboard</a><span class="sep">/</span><span class="text-ink-mid">Add host</span></div>
{{if eq $page.Token ""}}
<h1 class="text-2xl font-medium tracking-[-0.012em] mt-2.5">Add a host</h1>
<p class="text-pretty text-ink-mute text-[13px] mt-1.5 max-w-[580px]">
Mints a one-time enrolment token (TTL 1 hour) and binds the repo
credentials to it. The token can only be used once — generate a fresh
one if it expires or you typed something wrong.
</p>
{{/* ============================================================
State A · form
============================================================ */}}
<h1 class="text-2xl font-medium tracking-[-0.012em] mt-2.5">Add a host</h1>
<p class="text-pretty text-ink-mute text-[13px] mt-1.5 max-w-[580px]">
Mints a one-time enrolment token (TTL 1 hour) and binds the repo
credentials to it. The token can only be used once — generate a fresh
one if it expires or you typed something wrong.
</p>
{{if $page.Error}}
<div class="mt-6 px-4 py-3 rounded-[5px] text-[13px]"
style="background: color-mix(in oklch, var(--bad), transparent 88%);
border: 1px solid color-mix(in oklch, var(--bad), transparent 70%);
color: oklch(0.85 0.10 25);">
{{$page.Error}}
</div>
{{end}}
<form method="post" action="/hosts/new" class="grid grid-cols-12 gap-8 mt-7">
<div class="col-span-7 panel rounded-[7px] px-8 py-7">
<h3 class="text-[13px] font-semibold uppercase tracking-[0.08em] text-ink-mute mb-4">Host</h3>
<div class="mb-5">
<label class="field-label" for="ah-name">Hostname</label>
<input id="ah-name" name="hostname" type="text" class="field mono" autofocus required value="{{$page.Hostname}}">
<div class="field-help">Becomes the hosts display name. Most operators use the boxs actual hostname so logs line up.</div>
</div>
<div class="mb-7">
<label class="field-label" for="ah-tags">Tags <span class="text-ink-fade font-normal">· optional, comma-separated</span></label>
<input id="ah-tags" name="tags" type="text" class="field mono" placeholder="prod, db" value="{{$page.Tags}}">
<div class="field-help">Free-form. Used for filtering and grouping on the dashboard.</div>
</div>
<h3 class="text-[13px] font-semibold uppercase tracking-[0.08em] text-ink-mute mb-4 pt-6 border-t border-line-soft">Initial schedule <span class="text-ink-fade font-normal">· manual</span></h3>
<div class="mb-7">
<label class="field-label" for="ah-paths">Paths <span class="text-ink-fade font-normal">· one per line</span></label>
<textarea id="ah-paths" name="paths" rows="3" class="field mono"
style="resize: vertical;"
placeholder="/etc&#10;/home&#10;/var/lib/postgresql">{{$page.Paths}}</textarea>
<div class="field-help">
These paths become an <strong>initial manual schedule</strong> on the new host — manual = no cron, only fires when you click <span class="mono text-ink-mid">Run&nbsp;now</span>. You can edit this schedule (or add automated ones alongside it) from the host's <strong>Schedules</strong> tab. Leave blank to skip — the host will enrol but can't back up until you add a schedule.
</div>
</div>
<h3 class="text-[13px] font-semibold uppercase tracking-[0.08em] text-ink-mute mb-4 pt-6 border-t border-line-soft">Restic repository</h3>
<div class="mb-5">
<label class="field-label" for="ah-url">Repo URL</label>
<input id="ah-url" name="repo_url" type="text" class="field mono" required
placeholder="rest:https://restic.lab/host-name/"
value="{{$page.RepoURL}}">
<div class="field-help">Whatever <span class="mono text-ink-mid">restic -r</span> would accept. Most fleets terminate at a <span class="mono text-ink-mid">restic/rest-server</span>; <span class="mono text-ink-mid">s3:</span> and <span class="mono text-ink-mid">b2:</span> URLs work equally well.</div>
</div>
<div class="mb-5">
<label class="field-label" for="ah-user">Repo username <span class="text-ink-fade font-normal">· optional</span></label>
<input id="ah-user" name="repo_username" type="text" class="field mono" value="{{$page.RepoUsername}}">
<div class="field-help">For <span class="mono text-ink-mid">rest-server</span> with htpasswd, this is the per-host user.</div>
</div>
<div class="mb-7">
<label class="field-label" for="ah-pass">Repo password <span class="text-ink-fade font-normal">· optional — leave blank to generate</span></label>
<input id="ah-pass" name="repo_password" type="password" class="field">
<div class="field-help">Encrypted at rest using the servers AEAD key, pushed to the agent only over the authenticated WebSocket. Leave blank and well mint a 24-byte URL-safe random password and surface it once on the next page (alongside the <span class="mono text-ink-mid">htpasswd</span> snippet youll need to run on the rest-server).</div>
</div>
<div class="flex gap-2 pt-5 border-t border-line-soft">
<button type="submit" class="btn btn-primary btn-lg">Mint token &amp; show install command</button>
<a href="/" class="btn btn-lg">Cancel</a>
</div>
</div>
<aside class="col-span-5">
<div class="text-[11px] uppercase tracking-[0.1em] text-ink-fade mb-3">What happens next</div>
<ol class="list-none p-0 m-0 space-y-4">
<li class="relative pl-9">
<span class="absolute left-0 top-0 w-[22px] h-[22px] border border-line rounded-full text-[11px] leading-[20px] text-center text-ink-mute mono">1</span>
<div class="text-[13px] text-ink font-medium">You get a one-time install command</div>
<div class="text-[12px] text-ink-mute mt-1 leading-[1.55]">A <span class="mono text-ink-mid">curl … | sh</span> snippet with the server URL and a 1h token baked in.</div>
</li>
<li class="relative pl-9">
<span class="absolute left-0 top-0 w-[22px] h-[22px] border border-line rounded-full text-[11px] leading-[20px] text-center text-ink-mute mono">2</span>
<div class="text-[13px] text-ink font-medium">You run it on the box you want to back up</div>
<div class="text-[12px] text-ink-mute mt-1 leading-[1.55]">Installer creates a service user, drops the agent binary, registers a sandboxed systemd unit, and enrols.</div>
</li>
<li class="relative pl-9">
<span class="absolute left-0 top-0 w-[22px] h-[22px] border border-line rounded-full text-[11px] leading-[20px] text-center text-ink-mute mono">3</span>
<div class="text-[13px] text-ink font-medium">The host appears on the dashboard within seconds</div>
<div class="text-[12px] text-ink-mute mt-1 leading-[1.55]">Server pushes the encrypted repo creds over the WS on first <span class="mono text-ink-mid">hello</span>; agent decrypts and persists to <span class="mono text-ink-mid">secrets.enc</span>.</div>
</li>
<li class="relative pl-9">
<span class="absolute left-0 top-0 w-[22px] h-[22px] border border-line rounded-full text-[11px] leading-[20px] text-center text-ink-mute mono">4</span>
<div class="text-[13px] text-ink font-medium">You hit “Run backup now”</div>
<div class="text-[12px] text-ink-mute mt-1 leading-[1.55]">First snapshot lands in the repo. Subsequent ones run on whatever schedule you set (Phase 2).</div>
</li>
</ol>
<div class="mt-8 panel rounded-[6px] px-4 py-3.5">
<div class="text-[11px] uppercase tracking-[0.08em] font-semibold text-warn mb-1.5">Prerequisite</div>
<p class="text-pretty text-[12px] text-ink-mid leading-[1.55]">
<span class="mono text-ink">restic</span> ≥ 0.16 must already be installed on the target host. The agent does not install it for you — different distros, different package managers, too much surface area to maintain.
</p>
</div>
</aside>
</form>
{{else}}
{{/* ============================================================
State B · token minted
============================================================ */}}
<div class="flex items-center gap-3 mt-2.5">
<h1 class="text-2xl font-medium tracking-[-0.012em]">Token minted</h1>
<span class="mono text-[11px] px-2 py-0.5 rounded-[3px]"
style="background: color-mix(in oklch, var(--ok), transparent 88%);
color: var(--ok);
border: 1px solid color-mix(in oklch, var(--ok), transparent 70%);">
expires {{relTime $page.ExpiresAt}}
</span>
{{if $page.Error}}
<div class="mt-6 px-4 py-3 rounded-[5px] text-[13px]"
style="background: color-mix(in oklch, var(--bad), transparent 88%);
border: 1px solid color-mix(in oklch, var(--bad), transparent 70%);
color: oklch(0.85 0.10 25);">
{{$page.Error}}
</div>
<p class="text-pretty text-ink-mute text-[13px] mt-1.5 max-w-[580px]">
Run the snippet below on the target box. The host will appear on the
dashboard within a few seconds of the agent connecting.
</p>
{{end}}
{{if and $page.RepoUsername $page.RepoPassword}}
<div class="snippet mt-6 panel" style="border-color: color-mix(in oklch, var(--warn), transparent 60%);">
<div class="snippet-head" style="background: color-mix(in oklch, var(--warn), transparent 92%);">
<span>
Run on the rest-server box first
{{if $page.PasswordGenerated}}
<span class="mono text-[10.5px] px-1.5 py-0.5 ml-2 rounded-[3px]"
style="background: color-mix(in oklch, var(--ok), transparent 88%); color: var(--ok); border: 1px solid color-mix(in oklch, var(--ok), transparent 70%);">password generated</span>
{{end}}
<span class="text-ink-fade ml-2">· this is the only time youll see the password</span>
</span>
<div class="flex gap-2">
<button type="button" class="btn"
data-snippet="echo '{{$page.RepoPassword}}' | sudo htpasswd -B -i /path/to/htpasswd {{$page.RepoUsername}}"
onclick="navigator.clipboard.writeText(this.dataset.snippet); this.textContent='Copied'; setTimeout(()=>this.textContent='Copy', 2000);">Copy</button>
<form method="post" action="/hosts/new" class="grid grid-cols-12 gap-8 mt-7">
<div class="col-span-7 panel rounded-[7px] px-8 py-7">
<h3 class="text-[13px] font-semibold uppercase tracking-[0.08em] text-ink-mute mb-4">Host</h3>
<div class="mb-5">
<label class="field-label" for="ah-name">Hostname</label>
<input id="ah-name" name="hostname" type="text" class="field mono" autofocus required value="{{$page.Hostname}}">
<div class="field-help">Becomes the host's display name. Most operators use the box's actual hostname so logs line up.</div>
</div>
<div class="mb-7">
<label class="field-label" for="ah-tags">Tags <span class="text-ink-fade font-normal">· optional, comma-separated</span></label>
<input id="ah-tags" name="tags" type="text" class="field mono" placeholder="prod, db" value="{{$page.Tags}}">
<div class="field-help">Free-form. Used for filtering and grouping on the dashboard.</div>
</div>
<h3 class="text-[13px] font-semibold uppercase tracking-[0.08em] text-ink-mute mb-4 pt-6 border-t border-line-soft">Initial schedule <span class="text-ink-fade font-normal">· manual</span></h3>
<div class="mb-7">
<label class="field-label" for="ah-paths">Paths <span class="text-ink-fade font-normal">· one per line</span></label>
<textarea id="ah-paths" name="paths" rows="3" class="field mono"
style="resize: vertical;"
placeholder="/etc&#10;/home&#10;/var/lib/postgresql">{{$page.Paths}}</textarea>
<div class="field-help">
These paths become an <strong>initial manual schedule</strong> on the new host — manual = no cron, only fires when you click <span class="mono text-ink-mid">Run&nbsp;now</span>. You can edit this schedule (or add automated ones alongside it) from the host's <strong>Schedules</strong> tab. Leave blank to skip — the host will enrol but can't back up until you add a schedule.
</div>
</div>
<pre>echo '<span class="var">{{$page.RepoPassword}}</span>' | sudo htpasswd -B -i <span class="var">/path/to/htpasswd</span> <span class="var">{{$page.RepoUsername}}</span></pre>
<div class="px-4 pt-1 pb-3 text-[12px] text-ink-mute leading-[1.55]">
Replace <span class="mono text-ink-mid">/path/to/htpasswd</span> with whatever your <span class="mono text-ink-mid">restic/rest-server</span> reads (typically the file passed via <span class="mono text-ink-mid">--htpasswd-file</span>, or <span class="mono text-ink-mid">/data/.htpasswd</span> in the official Docker image). The <span class="mono text-ink-mid">-i</span> flag reads the password from stdin so it never appears in your shells process list. Then either send <span class="mono text-ink-mid">SIGHUP</span> to the rest-server process or restart the container to pick up the new entry.
</div>
</div>
{{end}}
<div class="snippet mt-4 panel">
<div class="snippet-head">
<span>Install command · paste-and-run on the host youre backing up</span>
<div class="flex gap-2">
<button type="button" class="btn"
data-snippet="curl -fsSL {{$page.ServerURL}}/install/install.sh | sudo RM_SERVER={{$page.ServerURL}} RM_TOKEN={{$page.Token}} bash"
onclick="navigator.clipboard.writeText(this.dataset.snippet); this.textContent='Copied'; setTimeout(()=>this.textContent='Copy', 2000);">Copy</button>
</div>
<h3 class="text-[13px] font-semibold uppercase tracking-[0.08em] text-ink-mute mb-4 pt-6 border-t border-line-soft">Restic repository</h3>
<div class="mb-5">
<label class="field-label" for="ah-url">Repo URL</label>
<input id="ah-url" name="repo_url" type="text" class="field mono" required
placeholder="rest:https://restic.lab/host-name/"
value="{{$page.RepoURL}}">
<div class="field-help">Whatever <span class="mono text-ink-mid">restic -r</span> would accept. Most fleets terminate at a <span class="mono text-ink-mid">restic/rest-server</span>; <span class="mono text-ink-mid">s3:</span> and <span class="mono text-ink-mid">b2:</span> URLs work equally well.</div>
</div>
<pre>curl -fsSL <span class="var">{{$page.ServerURL}}/install/install.sh</span> | sudo \
RM_SERVER=<span class="var">{{$page.ServerURL}}</span> \
RM_TOKEN=<span class="var">{{$page.Token}}</span> bash</pre>
<div class="mb-5">
<label class="field-label" for="ah-user">Repo username <span class="text-ink-fade font-normal">· optional</span></label>
<input id="ah-user" name="repo_username" type="text" class="field mono" value="{{$page.RepoUsername}}">
<div class="field-help">For <span class="mono text-ink-mid">rest-server</span> with htpasswd, this is the per-host user.</div>
</div>
<div class="mb-7">
<label class="field-label" for="ah-pass">Repo password <span class="text-ink-fade font-normal">· optional — leave blank to generate</span></label>
<input id="ah-pass" name="repo_password" type="password" class="field">
<div class="field-help">Encrypted at rest using the server's AEAD key, pushed to the agent only over the authenticated WebSocket. Leave blank and we'll mint a 24-byte URL-safe random password — you'll see it on the next page (and can come back to it from the dashboard's pending-host link until the agent connects).</div>
</div>
<div class="flex gap-2 pt-5 border-t border-line-soft">
<button type="submit" class="btn btn-primary btn-lg">Mint token &amp; show install command</button>
<a href="/" class="btn btn-lg">Cancel</a>
</div>
</div>
<div class="grid grid-cols-12 gap-6 mt-7">
<aside class="col-span-5">
<div class="text-[11px] uppercase tracking-[0.1em] text-ink-fade mb-3">What happens next</div>
<div class="col-span-7 panel rounded-[7px] px-7 py-6">
<div class="text-[11px] uppercase tracking-[0.1em] text-ink-fade mb-3">Awaiting agent connection</div>
<div class="flex items-center gap-3">
<span class="dot dot-offline pulse"></span>
<span class="mono text-[14px] text-ink">{{if $page.Hostname}}{{$page.Hostname}}{{else}}new host{{end}}</span>
<span class="text-[12px] text-ink-mute">— enrolment will mark this online</span>
</div>
<div class="mt-4 px-3 py-2.5 rounded-[5px] mono text-[11.5px] text-ink-mute leading-[1.7]"
style="background: var(--bg); border: 1px solid var(--line-soft);">
<div>{{$page.ExpiresAt.Format "15:04:05.000"}} <span class="text-ink-mid">server</span> token minted · 1h ttl</div>
<div class="text-ink-fade"> awaiting POST /api/agents/enroll …</div>
</div>
<p class="mt-4 text-[12.5px] text-ink-mid leading-[1.6]">
Enrolment will create a <span class="mono text-ink">manual</span> schedule from the paths above. Find it (and add automated ones) under
<span class="mono text-ink">Host &gt; Schedules</span> once the agent connects.
<ol class="list-none p-0 m-0 space-y-4">
<li class="relative pl-9">
<span class="absolute left-0 top-0 w-[22px] h-[22px] border border-line rounded-full text-[11px] leading-[20px] text-center text-ink-mute mono">1</span>
<div class="text-[13px] text-ink font-medium">You get a one-time install command</div>
<div class="text-[12px] text-ink-mute mt-1 leading-[1.55]">A <span class="mono text-ink-mid">curl … | bash</span> snippet with the server URL and a 1h token baked in.</div>
</li>
<li class="relative pl-9">
<span class="absolute left-0 top-0 w-[22px] h-[22px] border border-line rounded-full text-[11px] leading-[20px] text-center text-ink-mute mono">2</span>
<div class="text-[13px] text-ink font-medium">You run it on the box you want to back up</div>
<div class="text-[12px] text-ink-mute mt-1 leading-[1.55]">Installer drops the agent binary as root, registers a sandboxed systemd unit, and enrols.</div>
</li>
<li class="relative pl-9">
<span class="absolute left-0 top-0 w-[22px] h-[22px] border border-line rounded-full text-[11px] leading-[20px] text-center text-ink-mute mono">3</span>
<div class="text-[13px] text-ink font-medium">The host appears on the dashboard within seconds</div>
<div class="text-[12px] text-ink-mute mt-1 leading-[1.55]">Server pushes the encrypted repo creds over the WS on first <span class="mono text-ink-mid">hello</span>; agent decrypts and persists to <span class="mono text-ink-mid">secrets.enc</span>.</div>
</li>
<li class="relative pl-9">
<span class="absolute left-0 top-0 w-[22px] h-[22px] border border-line rounded-full text-[11px] leading-[20px] text-center text-ink-mute mono">4</span>
<div class="text-[13px] text-ink font-medium">You hit “Run now” on the manual schedule</div>
<div class="text-[12px] text-ink-mute mt-1 leading-[1.55]">First snapshot lands in the repo. Add automated schedules from the host's Schedules tab whenever you're ready.</div>
</li>
</ol>
<div class="mt-8 panel rounded-[6px] px-4 py-3.5">
<div class="text-[11px] uppercase tracking-[0.08em] font-semibold text-warn mb-1.5">Prerequisite</div>
<p class="text-pretty text-[12px] text-ink-mid leading-[1.55]">
<span class="mono text-ink">restic</span> ≥ 0.16 must already be installed on the target host. The agent does not install it for you — different distros, different package managers, too much surface area to maintain.
</p>
</div>
</aside>
<aside class="col-span-5">
<div class="text-[11px] uppercase tracking-[0.1em] text-ink-fade mb-3">If the agent doesnt appear</div>
<ul class="list-none p-0 m-0 text-[13px] text-ink-mid leading-[1.55]">
<li class="py-2 border-b border-line-soft text-pretty">Check the box can reach <span class="mono text-ink">{{$page.ServerURL}}</span> over HTTPS.</li>
<li class="py-2 border-b border-line-soft text-pretty">Check <span class="mono text-ink">restic --version</span> ≥ 0.16 — the installer wont bail on this, but backups will fail.</li>
<li class="py-2 border-b border-line-soft text-pretty">Check <span class="mono text-ink">journalctl -u restic-manager-agent -n 50</span> on the target box.</li>
<li class="py-2 text-pretty">Token expired? <a href="/hosts/new" class="underline underline-offset-4 decoration-line">Mint a new one</a> — theyre cheap.</li>
</ul>
</aside>
</div>
<div class="mt-7 flex gap-2">
<a href="/" class="btn btn-lg">← Back to dashboard</a>
<a href="/hosts/new" class="btn btn-lg">Add another host</a>
</div>
{{end}}
</form>
</div>
{{end}}
+98
View File
@@ -0,0 +1,98 @@
{{define "title"}}{{.Title}}{{end}}
{{define "content"}}
{{$page := .Page}}
<div class="max-w-[1280px] mx-auto px-8 pt-9 pb-24">
<div class="crumbs">
<a href="/">Dashboard</a><span class="sep">/</span>
<a href="/hosts/new">Add host</a><span class="sep">/</span>
<span class="text-ink-mid">pending</span>
</div>
<div class="flex items-center gap-3 mt-2.5">
<h1 class="text-2xl font-medium tracking-[-0.012em]">Pending host</h1>
<span class="mono text-[11px] px-2 py-0.5 rounded-[3px]"
style="background: color-mix(in oklch, var(--ok), transparent 88%);
color: var(--ok);
border: 1px solid color-mix(in oklch, var(--ok), transparent 70%);">
expires {{relTime $page.ExpiresAt}}
</span>
</div>
<p class="text-pretty text-ink-mute text-[13px] mt-1.5 max-w-[680px]">
Token's still alive — refresh the page or come back later until the
agent enrols. Credentials are decrypted from the (still-encrypted-at-rest)
token row each render, so you can recover them if you've already lost
the snippets below. Once the agent connects this page redirects to
the host detail.
</p>
{{if and $page.RepoUsername $page.RepoPassword}}
<div class="snippet mt-6 panel" style="border-color: color-mix(in oklch, var(--warn), transparent 60%);">
<div class="snippet-head" style="background: color-mix(in oklch, var(--warn), transparent 92%);">
<span>
Run on the rest-server box first
<span class="text-ink-fade ml-2">· paste-and-run after replacing the htpasswd path</span>
</span>
<div class="flex gap-2">
<button type="button" class="btn"
data-snippet="echo '{{$page.RepoPassword}}' | sudo htpasswd -B -i /path/to/htpasswd {{$page.RepoUsername}}"
onclick="navigator.clipboard.writeText(this.dataset.snippet); this.textContent='Copied'; setTimeout(()=>this.textContent='Copy', 2000);">Copy</button>
</div>
</div>
<pre>echo '<span class="var">{{$page.RepoPassword}}</span>' | sudo htpasswd -B -i <span class="var">/path/to/htpasswd</span> <span class="var">{{$page.RepoUsername}}</span></pre>
<div class="px-4 pt-1 pb-3 text-[12px] text-ink-mute leading-[1.55]">
Replace <span class="mono text-ink-mid">/path/to/htpasswd</span> with whatever your <span class="mono text-ink-mid">restic/rest-server</span> reads (typically the file passed via <span class="mono text-ink-mid">--htpasswd-file</span>, or <span class="mono text-ink-mid">/data/.htpasswd</span> in the official Docker image). The <span class="mono text-ink-mid">-i</span> flag reads the password from stdin so it never appears in your shell's process list. Then either send <span class="mono text-ink-mid">SIGHUP</span> to the rest-server process or restart the container to pick up the new entry.
</div>
</div>
{{end}}
<div class="snippet mt-4 panel">
<div class="snippet-head">
<span>Install command · paste-and-run on the host you're backing up</span>
<div class="flex gap-2">
<button type="button" class="btn"
data-snippet="curl -fsSL {{$page.ServerURL}}/install/install.sh | sudo RM_SERVER={{$page.ServerURL}} RM_TOKEN={{$page.Token}} bash"
onclick="navigator.clipboard.writeText(this.dataset.snippet); this.textContent='Copied'; setTimeout(()=>this.textContent='Copy', 2000);">Copy</button>
</div>
</div>
<pre>curl -fsSL <span class="var">{{$page.ServerURL}}/install/install.sh</span> | sudo \
RM_SERVER=<span class="var">{{$page.ServerURL}}</span> \
RM_TOKEN=<span class="var">{{$page.Token}}</span> bash</pre>
</div>
<div class="grid grid-cols-12 gap-6 mt-7">
<div class="col-span-7"
hx-get="/hosts/pending/{{$page.Token}}/awaiting"
hx-trigger="load, every 2s"
hx-swap="outerHTML">
{{/* Fallback content; HTMX swaps this out within 2s of load. */}}
<div class="panel rounded-[7px] px-7 py-6">
<div class="text-[11px] uppercase tracking-[0.1em] text-ink-fade mb-3">Awaiting agent connection</div>
<div class="flex items-center gap-3">
<span class="dot dot-offline pulse"></span>
<span class="text-[12px] text-ink-mute">checking…</span>
</div>
</div>
</div>
<aside class="col-span-5">
<div class="text-[11px] uppercase tracking-[0.1em] text-ink-fade mb-3">If the agent doesn't appear</div>
<ul class="list-none p-0 m-0 text-[13px] text-ink-mid leading-[1.55]">
<li class="py-2 border-b border-line-soft text-pretty">Check the box can reach <span class="mono text-ink">{{$page.ServerURL}}</span>.</li>
<li class="py-2 border-b border-line-soft text-pretty">Check <span class="mono text-ink">restic --version</span> ≥ 0.16 — the installer won't bail on this, but backups will fail.</li>
<li class="py-2 border-b border-line-soft text-pretty">Check <span class="mono text-ink">journalctl -u restic-manager-agent -n 50</span> on the target box.</li>
<li class="py-2 text-pretty">Token expired? <a href="/hosts/new" class="underline underline-offset-4 decoration-line">Mint a new one</a> — they're cheap.</li>
</ul>
</aside>
</div>
<div class="mt-7 flex gap-2">
<a href="/" class="btn btn-lg">← Back to dashboard</a>
<a href="/hosts/new" class="btn btn-lg">Add another host</a>
</div>
</div>
{{end}}
@@ -0,0 +1,59 @@
{{define "awaiting_agent"}}
{{$page := .Page}}
{{/*
Polled status fragment for the Add-host pending page. Wrapper
carries the HTMX poll trigger only while State == "awaiting" —
state == "connected" or "expired" both stop polling. The wrapper
is what HTMX swaps via hx-swap=outerHTML, so the trigger getting
removed is what stops the loop.
*/}}
<div class="col-span-7"
{{if eq $page.State "awaiting"}}hx-get="/hosts/pending/{{$page.Token}}/awaiting"
hx-trigger="every 2s"
hx-swap="outerHTML"{{end}}>
{{if eq $page.State "connected"}}
<div class="panel rounded-[7px] px-7 py-6"
style="border-color: color-mix(in oklch, var(--ok), transparent 60%);">
<div class="text-[11px] uppercase tracking-[0.1em] font-semibold text-ok mb-3">Agent connected</div>
<div class="flex items-center gap-3">
<span class="dot dot-online"></span>
<span class="mono text-[14px] text-ink">{{$page.HostName}}</span>
<span class="text-[12px] text-ink-mute">— enrolled, online{{if $page.LastSeenAt}}, last heartbeat {{relTime $page.LastSeenAt}}{{end}}</span>
</div>
<div class="mt-4 flex gap-2">
<a href="/hosts/{{$page.HostID}}" class="btn btn-primary">Open host →</a>
<a href="/hosts/{{$page.HostID}}/schedules" class="btn">View schedules</a>
</div>
</div>
{{else if eq $page.State "expired"}}
<div class="panel rounded-[7px] px-7 py-6"
style="border-color: color-mix(in oklch, var(--bad), transparent 60%);">
<div class="text-[11px] uppercase tracking-[0.1em] font-semibold text-bad mb-3">Token expired</div>
<p class="text-[13px] text-ink-mid leading-[1.55]">
The 1-hour window has elapsed without the agent connecting.
Mint a fresh token and run the new install command.
</p>
<div class="mt-4">
<a href="/hosts/new" class="btn btn-primary">Mint a new token</a>
</div>
</div>
{{else}}
<div class="panel rounded-[7px] px-7 py-6">
<div class="text-[11px] uppercase tracking-[0.1em] text-ink-fade mb-3">Awaiting agent connection</div>
<div class="flex items-center gap-3">
<span class="dot dot-offline pulse"></span>
<span class="text-[12px] text-ink-mute">— polling every 2s; this page redirects to the host detail when enrolment lands.</span>
</div>
<div class="mt-4 px-3 py-2.5 rounded-[5px] mono text-[11.5px] text-ink-mute leading-[1.7]"
style="background: var(--bg); border: 1px solid var(--line-soft);">
<div>token expires <span class="text-ink-mid">{{relTime $page.ExpiresAt}}</span></div>
<div class="text-ink-fade">awaiting POST /api/agents/enroll …</div>
</div>
</div>
{{end}}
</div>
{{end}}