a781e95c94
Three small follow-ups from review:
1. Restore target is now operator-editable. Default value is the
literal '\$HOME/rm-restore/<job-id>/' (agent expands \$HOME at
run time using os.UserHomeDir(); also handles \${HOME} and ~/
prefixes). Operator can replace with any absolute path.
- ui_restore.go validates the input is either absolute or starts
with one of the recognised prefixes; other env-var refs (\$PATH
etc.) are deliberately rejected so operator paths can't pick up
arbitrary agent env values.
- host_restore.html replaces the read-only mono-text display with
a real <input>; help text spells out that \$HOME resolves
agent-side and <job-id> is substituted on dispatch.
- install.sh + the systemd unit prep /root/rm-restore so the
default works under the sandbox: ReadWritePaths gains a soft
'-/root/rm-restore' entry (the '-' makes the bind-mount soft-fail
if missing, but install.sh pre-creates it root-owned 0700).
2. --no-ownership flag now gated on restic version. The flag was
added in restic 0.17 and 0.16 rejects it. Previously dropped it
wholesale — that meant new-dir restores silently preserved
ownership against design intent on 0.17+. Now the agent threads
its detected restic version (sysinfo already collects it) through
runner.Config -> restic.Env, and RunRestore appends --no-ownership
only when AtLeastVersion(0, 17) returns true. 0.16 hosts still
restore with original uid/gid; help text in the wizard explicitly
notes this. The previous 'Original ownership is preserved' copy
was wrong for new-dir mode and is corrected.
3. golangci-lint misspell locale switched US -> UK and the codebase
swept (73 corrections, mostly behaviour/serialise/recognise/honour).
Wire-format ErrorCode 'unauthorized' -> 'unauthorised' is a tiny
contract change but the agent doesn't parse those codes today and
no external API consumers exist yet. Tests passed before + after.
Tests:
- internal/restic/version_test.go covers Env.AtLeastVersion across
edge cases (empty, exact match, patch above, minor below, non-
numeric) and expandHome on \$HOME / \${HOME} / ~/, plus
pass-through for absolute paths and refusal of other env vars.
- ui_restore_test updated: TargetDir now starts '\$HOME/rm-restore/'
with the job_id substituted into the placeholder.
Live verified on the smoke env: default target restored to
/root/rm-restore/<job-id>/ as the agent's expanded \$HOME (2 files,
14 bytes); custom override '/tmp/custom-restore/<job-id>/' restored
into the agent's PrivateTmp namespace (1 file, 6 bytes); both jobs
'succeeded', exit 0.
448 lines
13 KiB
Go
448 lines
13 KiB
Go
package http
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"log/slog"
|
|
stdhttp "net/http"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/oklog/ulid/v2"
|
|
|
|
"gitea.dcglab.co.uk/steve/restic-manager/internal/api"
|
|
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/ui"
|
|
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
|
|
)
|
|
|
|
// ui_restore.go — restore wizard backend (P3-01).
|
|
//
|
|
// GET /hosts/{id}/restore wizard step 1 (snapshot picker)
|
|
// GET /hosts/{id}/snapshots/{sid}/restore wizard with snapshot pre-selected
|
|
// GET /hosts/{id}/restore/tree HTMX partial: one tree node + children
|
|
// POST /hosts/{id}/restore dispatch the restore job
|
|
|
|
// hostRestorePage is the model for the wizard template.
|
|
type hostRestorePage struct {
|
|
hostChromeData
|
|
|
|
// Snapshot picker rows; rendered by the template into the step-1
|
|
// table. Limited to most-recent N (the operator can refine on
|
|
// snapshot ID if they need an older one — out of scope for v1).
|
|
Snapshots []store.Snapshot
|
|
|
|
// Selected is non-nil iff a snapshot has been chosen — either via
|
|
// the deep-link path /hosts/{id}/snapshots/{sid}/restore or by a
|
|
// previous form submission that the wizard re-rendered.
|
|
Selected *store.Snapshot
|
|
|
|
// Default target dir — surfaced in the step-3 radio card.
|
|
DefaultTargetDir string
|
|
|
|
// Online mirrors Hub.Connected so the dispatch button can be
|
|
// disabled at render time when the agent is offline.
|
|
Online bool
|
|
|
|
// Error is shown as a banner above the wizard. Re-render-friendly:
|
|
// the operator's snapshot/path/target choices survive the round-trip.
|
|
Error string
|
|
|
|
// Form fields preserved on validation re-render. The template
|
|
// reads these to pre-tick checkboxes etc; the names match the
|
|
// POST form keys.
|
|
FormPaths []string // "/etc/nginx/sites-available/alfa.conf"
|
|
FormInPlace bool
|
|
FormTargetDir string
|
|
FormConfirmHN string // typed-confirm input value
|
|
}
|
|
|
|
// handleUIRestoreGet renders the wizard. URL variants:
|
|
// - /hosts/{id}/restore — step 1 = pick snapshot
|
|
// - /hosts/{id}/snapshots/{sid}/restore — snapshot pre-selected
|
|
func (s *Server) handleUIRestoreGet(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
|
u := s.requireUIUser(w, r)
|
|
if u == nil {
|
|
return
|
|
}
|
|
hostID := chi.URLParam(r, "id")
|
|
host, err := s.deps.Store.GetHost(r.Context(), hostID)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
stdhttp.NotFound(w, r)
|
|
return
|
|
}
|
|
slog.Error("ui restore: get host", "host_id", hostID, "err", err)
|
|
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
page := hostRestorePage{
|
|
hostChromeData: s.loadHostChrome(r, *host, "snapshots", "restore"),
|
|
DefaultTargetDir: defaultRestoreTargetDir(),
|
|
Online: s.deps.Hub.Connected(host.ID),
|
|
}
|
|
snaps, err := s.deps.Store.ListSnapshotsByHost(r.Context(), hostID)
|
|
if err != nil {
|
|
slog.Error("ui restore: list snapshots", "host_id", hostID, "err", err)
|
|
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
|
return
|
|
}
|
|
if len(snaps) > 100 {
|
|
snaps = snaps[:100]
|
|
}
|
|
page.Snapshots = snaps
|
|
|
|
// Snapshot deep-link variant — if the URL carries a sid, prefill it.
|
|
if sid := chi.URLParam(r, "sid"); sid != "" {
|
|
for i := range snaps {
|
|
if snaps[i].ID == sid || snaps[i].ShortID == sid {
|
|
p := snaps[i]
|
|
page.Selected = &p
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
view := s.baseView(u)
|
|
view.Title = "Restore · " + host.Name
|
|
view.Page = page
|
|
if err := s.deps.UI.Render(w, "host_restore", view); err != nil {
|
|
slog.Error("ui restore: render", "err", err)
|
|
}
|
|
}
|
|
|
|
// handleUIRestorePost validates the form and dispatches the restore
|
|
// job. On validation error re-renders the wizard with the error
|
|
// banner + the operator's input intact.
|
|
func (s *Server) handleUIRestorePost(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
|
u := s.requireUIUser(w, r)
|
|
if u == nil {
|
|
return
|
|
}
|
|
hostID := chi.URLParam(r, "id")
|
|
host, err := s.deps.Store.GetHost(r.Context(), hostID)
|
|
if err != nil {
|
|
stdhttp.NotFound(w, r)
|
|
return
|
|
}
|
|
if err := r.ParseForm(); err != nil {
|
|
stdhttp.Error(w, "bad form", stdhttp.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
snapshotID := strings.TrimSpace(r.PostForm.Get("snapshot_id"))
|
|
paths := r.PostForm["paths"] // multiple checkbox values
|
|
inPlace := r.PostForm.Get("target_mode") == "in_place"
|
|
targetDir := strings.TrimSpace(r.PostForm.Get("target_dir"))
|
|
confirmHN := strings.TrimSpace(r.PostForm.Get("confirm_hostname"))
|
|
|
|
rerender := func(errMsg string, status int) {
|
|
page := hostRestorePage{
|
|
hostChromeData: s.loadHostChrome(r, *host, "snapshots", "restore"),
|
|
DefaultTargetDir: defaultRestoreTargetDir(),
|
|
Online: s.deps.Hub.Connected(host.ID),
|
|
Error: errMsg,
|
|
FormPaths: paths,
|
|
FormInPlace: inPlace,
|
|
FormTargetDir: targetDir,
|
|
FormConfirmHN: confirmHN,
|
|
}
|
|
snaps, _ := s.deps.Store.ListSnapshotsByHost(r.Context(), hostID)
|
|
if len(snaps) > 100 {
|
|
snaps = snaps[:100]
|
|
}
|
|
page.Snapshots = snaps
|
|
for i := range snaps {
|
|
if snaps[i].ID == snapshotID || snaps[i].ShortID == snapshotID {
|
|
ss := snaps[i]
|
|
page.Selected = &ss
|
|
break
|
|
}
|
|
}
|
|
view := s.baseView(u)
|
|
view.Title = "Restore · " + host.Name
|
|
view.Page = page
|
|
w.WriteHeader(status)
|
|
_ = s.deps.UI.Render(w, "host_restore", view)
|
|
}
|
|
|
|
if snapshotID == "" {
|
|
rerender("Pick a snapshot first.", stdhttp.StatusUnprocessableEntity)
|
|
return
|
|
}
|
|
cleanPaths := make([]string, 0, len(paths))
|
|
for _, p := range paths {
|
|
p = strings.TrimSpace(p)
|
|
if p == "" {
|
|
continue
|
|
}
|
|
if !strings.HasPrefix(p, "/") {
|
|
rerender("Paths must be absolute (start with /).", stdhttp.StatusUnprocessableEntity)
|
|
return
|
|
}
|
|
cleanPaths = append(cleanPaths, p)
|
|
}
|
|
if len(cleanPaths) == 0 {
|
|
rerender("Pick at least one file or directory to restore.", stdhttp.StatusUnprocessableEntity)
|
|
return
|
|
}
|
|
|
|
if inPlace {
|
|
if confirmHN != host.Name {
|
|
rerender("Type the host name exactly to confirm an in-place (overwrite) restore.",
|
|
stdhttp.StatusUnprocessableEntity)
|
|
return
|
|
}
|
|
} else {
|
|
// New-directory mode: trust the operator's chosen target.
|
|
// Empty falls back to the default. Validate it's either
|
|
// absolute or starts with $HOME / ~/ (the agent expands
|
|
// these at run time).
|
|
if targetDir == "" {
|
|
targetDir = defaultRestoreTargetDir()
|
|
}
|
|
if !looksLikeRestoreTarget(targetDir) {
|
|
rerender("Target must be an absolute path, or start with $HOME or ~/.",
|
|
stdhttp.StatusUnprocessableEntity)
|
|
return
|
|
}
|
|
}
|
|
|
|
if !s.deps.Hub.Connected(host.ID) {
|
|
rerender("Agent is offline. Try again when it reconnects.",
|
|
stdhttp.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
// Build a new job id up-front so we can substitute it into the
|
|
// new-directory target path. The agent will additionally expand
|
|
// $HOME / ~/ before invoking restic.
|
|
jobID := ulid.Make().String()
|
|
finalTarget := ""
|
|
if !inPlace {
|
|
finalTarget = strings.ReplaceAll(targetDir, "<job-id>", jobID)
|
|
}
|
|
|
|
now := time.Now().UTC()
|
|
if err := s.deps.Store.CreateJob(r.Context(), store.Job{
|
|
ID: jobID,
|
|
HostID: host.ID,
|
|
Kind: string(api.JobRestore),
|
|
ActorKind: "user",
|
|
ActorID: &u.ID,
|
|
CreatedAt: now,
|
|
}); err != nil {
|
|
slog.Error("ui restore: create job", "err", err)
|
|
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
payload := api.CommandRunPayload{
|
|
JobID: jobID,
|
|
Kind: api.JobRestore,
|
|
Restore: &api.RestorePayload{
|
|
SnapshotID: snapshotID,
|
|
Paths: cleanPaths,
|
|
InPlace: inPlace,
|
|
TargetDir: finalTarget,
|
|
},
|
|
}
|
|
env, err := api.Marshal(api.MsgCommandRun, jobID, payload)
|
|
if err != nil {
|
|
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
|
return
|
|
}
|
|
if err := s.deps.Hub.Send(r.Context(), host.ID, env); err != nil {
|
|
slog.Warn("ui restore: dispatch failed", "err", err)
|
|
rerender("Couldn't deliver the restore command (agent went offline).",
|
|
stdhttp.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
|
|
ID: ulid.Make().String(),
|
|
UserID: &u.ID,
|
|
Actor: "user",
|
|
Action: "host.restore",
|
|
TargetKind: ptr("host"),
|
|
TargetID: &host.ID,
|
|
TS: now,
|
|
})
|
|
|
|
// HTMX redirect (or vanilla redirect) to the live job log.
|
|
jobURL := "/jobs/" + jobID
|
|
if r.Header.Get("HX-Request") == "true" {
|
|
w.Header().Set("HX-Redirect", jobURL)
|
|
w.WriteHeader(stdhttp.StatusNoContent)
|
|
return
|
|
}
|
|
stdhttp.Redirect(w, r, jobURL, stdhttp.StatusSeeOther)
|
|
}
|
|
|
|
// hostRestoreTreePage is the data shape for the tree-node HTMX partial.
|
|
type hostRestoreTreePage struct {
|
|
HostID string
|
|
SnapshotID string
|
|
Path string
|
|
Children []treeChildView
|
|
Error string
|
|
}
|
|
|
|
// treeChildView is one row of the tree (a direct child of Path).
|
|
type treeChildView struct {
|
|
Name string
|
|
Type string // dir | file | symlink
|
|
Path string // full path, used in the checkbox value
|
|
Size int64
|
|
IsDir bool
|
|
}
|
|
|
|
// handleUIRestoreTree is the HTMX-served partial that loads one
|
|
// directory's children. Called when the operator clicks an expand
|
|
// chevron in the wizard's tree browser. Caches via fetchTreeWithCache.
|
|
func (s *Server) handleUIRestoreTree(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
|
u := s.requireUIUser(w, r)
|
|
if u == nil {
|
|
return
|
|
}
|
|
hostID := chi.URLParam(r, "id")
|
|
host, err := s.deps.Store.GetHost(r.Context(), hostID)
|
|
if err != nil {
|
|
stdhttp.NotFound(w, r)
|
|
return
|
|
}
|
|
q := r.URL.Query()
|
|
snapshotID := strings.TrimSpace(q.Get("snapshot"))
|
|
pathArg := strings.TrimSpace(q.Get("path"))
|
|
if pathArg == "" {
|
|
pathArg = "/"
|
|
}
|
|
if snapshotID == "" {
|
|
stdhttp.Error(w, "snapshot required", stdhttp.StatusBadRequest)
|
|
return
|
|
}
|
|
if !s.deps.Hub.Connected(host.ID) {
|
|
// Render the partial with an error message rather than 503ing
|
|
// — the wizard renders the error inline next to the failed node.
|
|
page := hostRestoreTreePage{
|
|
HostID: host.ID, SnapshotID: snapshotID, Path: pathArg,
|
|
Error: "agent offline",
|
|
}
|
|
view := s.baseView(u)
|
|
view.Page = page
|
|
_ = s.deps.UI.RenderPartial(w, "tree_node", view)
|
|
return
|
|
}
|
|
|
|
sessionID := sessionIDFromCookie(r)
|
|
ctx, cancel := context.WithTimeout(r.Context(), 35*time.Second)
|
|
defer cancel()
|
|
|
|
result, err := s.fetchTreeWithCache(ctx, sessionID, host.ID, snapshotID, pathArg)
|
|
if err != nil {
|
|
page := hostRestoreTreePage{
|
|
HostID: host.ID, SnapshotID: snapshotID, Path: pathArg,
|
|
Error: err.Error(),
|
|
}
|
|
view := s.baseView(u)
|
|
view.Page = page
|
|
_ = s.deps.UI.RenderPartial(w, "tree_node", view)
|
|
return
|
|
}
|
|
if result.Error != "" {
|
|
page := hostRestoreTreePage{
|
|
HostID: host.ID, SnapshotID: snapshotID, Path: pathArg,
|
|
Error: result.Error,
|
|
}
|
|
view := s.baseView(u)
|
|
view.Page = page
|
|
_ = s.deps.UI.RenderPartial(w, "tree_node", view)
|
|
return
|
|
}
|
|
|
|
children := make([]treeChildView, 0, len(result.Entries))
|
|
for _, e := range result.Entries {
|
|
full := joinTreePath(pathArg, e.Name)
|
|
children = append(children, treeChildView{
|
|
Name: e.Name, Type: e.Type, Path: full,
|
|
Size: e.Size,
|
|
IsDir: e.Type == "dir",
|
|
})
|
|
}
|
|
// Stable order: dirs first, then files, alphabetically.
|
|
sort.SliceStable(children, func(i, j int) bool {
|
|
if children[i].IsDir != children[j].IsDir {
|
|
return children[i].IsDir
|
|
}
|
|
return children[i].Name < children[j].Name
|
|
})
|
|
|
|
page := hostRestoreTreePage{
|
|
HostID: host.ID, SnapshotID: snapshotID, Path: pathArg,
|
|
Children: children,
|
|
}
|
|
view := s.baseView(u)
|
|
view.Page = page
|
|
if err := s.deps.UI.RenderPartial(w, "tree_node", view); err != nil {
|
|
slog.Warn("ui restore tree: render partial", "err", err)
|
|
}
|
|
}
|
|
|
|
// defaultRestoreTargetDir is the placeholder shown on the step-3
|
|
// New-directory radio card and the value used when the operator
|
|
// leaves the field blank. $HOME resolves agent-side (typically /root
|
|
// for the systemd-as-root unit); <job-id> is substituted at dispatch.
|
|
// The systemd unit pins ReadWritePaths to include the agent user's
|
|
// home/rm-restore subdir so this default actually works under the
|
|
// sandbox.
|
|
func defaultRestoreTargetDir() string {
|
|
return "$HOME/rm-restore/<job-id>/"
|
|
}
|
|
|
|
// looksLikeRestoreTarget validates the operator-supplied target dir
|
|
// is a shape the agent can sensibly resolve. We accept absolute
|
|
// paths and a couple of agent-side expansions ($HOME, ~/). Other env
|
|
// vars are deliberately rejected — operator-supplied paths shouldn't
|
|
// be able to pick up arbitrary agent env values.
|
|
func looksLikeRestoreTarget(p string) bool {
|
|
if p == "" {
|
|
return false
|
|
}
|
|
switch {
|
|
case strings.HasPrefix(p, "/"):
|
|
return true
|
|
case strings.HasPrefix(p, "$HOME/"), p == "$HOME":
|
|
return true
|
|
case strings.HasPrefix(p, "${HOME}/"), p == "${HOME}":
|
|
return true
|
|
case strings.HasPrefix(p, "~/"), p == "~":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// sessionIDFromCookie returns the operator's session cookie value,
|
|
// used as the cache key scope for the tree-list cache. Unauthenticated
|
|
// requests don't reach this point, so an empty cookie value would
|
|
// only happen if requireUIUser is bypassed in tests — fall back to
|
|
// the request remote addr for those cases.
|
|
func sessionIDFromCookie(r *stdhttp.Request) string {
|
|
if c, err := r.Cookie(sessionCookieName); err == nil && c.Value != "" {
|
|
return c.Value
|
|
}
|
|
return r.RemoteAddr
|
|
}
|
|
|
|
// joinTreePath combines a directory path and a child name into an
|
|
// absolute snapshot-relative path, normalising any duplicate slashes.
|
|
func joinTreePath(dir, name string) string {
|
|
if dir == "" || dir == "/" {
|
|
return "/" + name
|
|
}
|
|
return strings.TrimRight(dir, "/") + "/" + name
|
|
}
|
|
|
|
// satisfy unused-import if compile order shifts.
|
|
var _ = ui.User{}
|