phase 1: agent install path — systemd unit, install.sh, asset endpoints

P1-14 deploy/install/restic-manager-agent.service: standard systemd
  unit with the usual hardening switches (NoNewPrivileges, Protect*,
  RestrictRealtime, MemoryDenyWriteExecute). Restart=always with a
  5s backoff. Runs as a dedicated unprivileged restic-manager-agent
  user; the install script creates it.

P1-29 deploy/install/install.sh: arch detection (amd64/arm64), pulls
  the agent binary from /agent/binary, creates the service user
  + dirs (/etc/restic-manager, /var/lib/restic-manager), runs
  enrollment via `agent -enroll-server -enroll-token`, lays down
  the systemd unit, enables and starts it.

  Honours the spec's "detect, don't auto-disable" rule for existing
  schedulers: scans systemd timers, /etc/cron.d/*, /etc/cron.daily/*,
  root crontab for restic-named entries and prints them with the
  exact disable command — operator decides.

P1-31 server endpoints to ship the agent installation payload:
  GET /agent/binary?os=linux&arch=amd64 → serves
    <DataDir>/agent-binaries/restic-manager-agent-linux-amd64
  GET /install/<file>                   → serves
    <DataDir>/install/<file>
  Both endpoints reject path traversal and return 404 if the file
  isn't published. Operators drop the binaries + service unit into
  these directories at release time. Signed-bundle verification is
  deferred to Phase 5 OSS readiness.

All tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-01 00:40:36 +01:00
parent 9cc0caff1e
commit 24ab071702
4 changed files with 296 additions and 0 deletions
+89
View File
@@ -0,0 +1,89 @@
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
}
+6
View File
@@ -93,6 +93,12 @@ func (s *Server) routes(r chi.Router) {
}))
}
// Agent binaries + install scripts. Open endpoints — content is
// unprivileged on its own, gating happens via the enrollment
// token. See agent_assets.go.
r.Get("/agent/binary", s.handleAgentBinary)
r.Get("/install/*", s.handleInstallAsset)
// UI handlers will hang off / — Phase 1 will add them.
r.Get("/", func(w stdhttp.ResponseWriter, _ *stdhttp.Request) {
_, _ = fmt.Fprint(w, "restic-manager — UI not yet implemented")