f0dfa689fe
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.
90 lines
2.8 KiB
Go
90 lines
2.8 KiB
Go
package http
|
||
|
||
import (
|
||
"fmt"
|
||
stdhttp "net/http"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
|
||
"gitea.dcglab.co.uk/steve/restic-manager/internal/api"
|
||
)
|
||
|
||
// agent_assets.go serves the agent binary (one per OS/arch) and the
|
||
// install scripts. The binaries live under <DataDir>/agent-binaries/,
|
||
// laid down by the release pipeline (or copied by hand for now).
|
||
// The install scripts live in <DataDir>/install/ alongside the
|
||
// systemd unit.
|
||
//
|
||
// Both endpoints are intentionally unauthenticated: the install
|
||
// payload is unprivileged on its own — it's the one-time enrollment
|
||
// token that grants access. Anyone can pull the binary; only
|
||
// someone with a valid token can use it productively.
|
||
//
|
||
// P1-31: signed-binary verification is deferred. Today we serve
|
||
// whatever the operator dropped on disk. Future work bumps this to
|
||
// minisign/cosign signed bundles.
|
||
|
||
// installAssetsRoutes adds /agent/binary and /install/* to r.
|
||
func (s *Server) handleAgentBinary(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||
osTag := r.URL.Query().Get("os")
|
||
archTag := r.URL.Query().Get("arch")
|
||
if osTag == "" || archTag == "" {
|
||
writeJSONError(w, stdhttp.StatusBadRequest, "missing_os_or_arch",
|
||
"query params os= and arch= are required")
|
||
return
|
||
}
|
||
if !validOS(osTag) || !validArch(archTag) {
|
||
writeJSONError(w, stdhttp.StatusBadRequest, "unsupported_target",
|
||
fmt.Sprintf("os=%q arch=%q not in {linux,windows} × {amd64,arm64}", osTag, archTag))
|
||
return
|
||
}
|
||
|
||
ext := ""
|
||
if osTag == "windows" {
|
||
ext = ".exe"
|
||
}
|
||
name := fmt.Sprintf("restic-manager-agent-%s-%s%s", osTag, archTag, ext)
|
||
path := filepath.Join(s.deps.Cfg.DataDir, "agent-binaries", name)
|
||
if _, err := os.Stat(path); err != nil {
|
||
writeJSONError(w, stdhttp.StatusNotFound, "binary_not_published",
|
||
fmt.Sprintf("agent binary for %s/%s not published on this server", osTag, archTag))
|
||
return
|
||
}
|
||
w.Header().Set("Content-Type", "application/octet-stream")
|
||
w.Header().Set("Content-Disposition", `attachment; filename="`+name+`"`)
|
||
stdhttp.ServeFile(w, r, path)
|
||
}
|
||
|
||
func (s *Server) handleInstallAsset(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||
// chi's TrimPrefix-like behaviour: r.URL.Path is "/install/<file>".
|
||
rel := strings.TrimPrefix(r.URL.Path, "/install/")
|
||
// Reject any path traversal — must be a flat filename.
|
||
if rel == "" || strings.ContainsAny(rel, "/\\") {
|
||
writeJSONError(w, stdhttp.StatusBadRequest, "bad_path", "")
|
||
return
|
||
}
|
||
path := filepath.Join(s.deps.Cfg.DataDir, "install", rel)
|
||
if _, err := os.Stat(path); err != nil {
|
||
writeJSONError(w, stdhttp.StatusNotFound, "not_found", "")
|
||
return
|
||
}
|
||
stdhttp.ServeFile(w, r, path)
|
||
}
|
||
|
||
func validOS(s string) bool {
|
||
switch api.HostOS(s) {
|
||
case api.OSLinux, api.OSWindows:
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
func validArch(s string) bool {
|
||
switch api.HostArch(s) {
|
||
case api.ArchAmd64, api.ArchArm64:
|
||
return true
|
||
}
|
||
return false
|
||
}
|