e8eccd20c2
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>
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
|
||
}
|