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. Lookup is dual-path: // // 1. /agent-binaries/ (or /install/) — // operator-managed override; lets the operator hot-patch a // pre-release agent without rebuilding the server image. // 2. /agent-binaries/ — read-only, baked // into the server image at build time (P5-03). This is what // makes a fresh container Just Work without first-run staging. // // 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. The image is the // unit of trust; pull-by-digest is the verification primitive. // Future work bumps standalone-binary delivery to minisign/cosign. // 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, ok := s.resolveBundledAsset("agent-binaries", name) if !ok { 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/". 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, ok := s.resolveBundledAsset("install", rel) if !ok { writeJSONError(w, stdhttp.StatusNotFound, "not_found", "") return } stdhttp.ServeFile(w, r, path) } // resolveBundledAsset looks up an asset by (subdir, name). DataDir // wins so an operator can override the image-baked copy by dropping // a file into //. If neither path resolves, // returns ("", false). func (s *Server) resolveBundledAsset(subdir, name string) (string, bool) { candidates := []string{ filepath.Join(s.deps.Cfg.DataDir, subdir, name), } if s.deps.Cfg.BundledAssetsDir != "" { candidates = append(candidates, filepath.Join(s.deps.Cfg.BundledAssetsDir, subdir, name)) } for _, p := range candidates { if _, err := os.Stat(p); err == nil { return p, true } } return "", false } 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 }