From e8eccd20c280a1e200e8cea1a5b24d8a15c0d51f Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Fri, 1 May 2026 00:40:36 +0100 Subject: [PATCH] =?UTF-8?q?phase=201:=20agent=20install=20path=20=E2=80=94?= =?UTF-8?q?=20systemd=20unit,=20install.sh,=20asset=20endpoints?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 /agent-binaries/restic-manager-agent-linux-amd64 GET /install/ → serves /install/ 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) --- deploy/install/install.sh | 159 ++++++++++++++++++++ deploy/install/restic-manager-agent.service | 42 ++++++ internal/server/http/agent_assets.go | 89 +++++++++++ internal/server/http/server.go | 6 + 4 files changed, 296 insertions(+) create mode 100755 deploy/install/install.sh create mode 100644 deploy/install/restic-manager-agent.service create mode 100644 internal/server/http/agent_assets.go diff --git a/deploy/install/install.sh b/deploy/install/install.sh new file mode 100755 index 0000000..401f80f --- /dev/null +++ b/deploy/install/install.sh @@ -0,0 +1,159 @@ +#!/usr/bin/env bash +# install.sh — Linux installer for the restic-manager agent. +# +# Usage (paste in shell): +# curl -fsSL https://restic.lab.example/install.sh | \ +# sudo RM_SERVER=https://restic.lab.example RM_TOKEN= sh +# +# What it does: +# 1. detects arch (amd64 / arm64) +# 2. fetches the matching agent binary from the server +# 3. creates the restic-manager-agent service user +# 4. lays down /etc/restic-manager/, /var/lib/restic-manager/ +# 5. enrolls (POST /api/agents/enroll) using RM_TOKEN +# 6. installs the systemd unit, enables, starts +# 7. surfaces (but does NOT disable) any existing restic timers / +# cron entries so the operator can decide what to do +# +# Idempotent — safe to re-run; will refuse if already enrolled +# unless RM_FORCE_REENROLL=1 is set. + +set -euo pipefail + +: "${RM_SERVER:?must be set, e.g. https://restic.lab.example}" +: "${RM_TOKEN:?must be set, the one-time token from the operator UI}" +: "${RM_INSTALL_PREFIX:=/usr/local/bin}" +: "${RM_CONFIG_DIR:=/etc/restic-manager}" +: "${RM_STATE_DIR:=/var/lib/restic-manager}" +: "${RM_USER:=restic-manager-agent}" +: "${RM_GROUP:=restic-manager-agent}" +: "${RM_FORCE_REENROLL:=0}" + +require_root() { + if [ "$(id -u)" -ne 0 ]; then + echo "install.sh: must be run as root" >&2 + exit 1 + fi +} + +detect_arch() { + case "$(uname -m)" in + x86_64|amd64) echo amd64 ;; + aarch64|arm64) echo arm64 ;; + *) echo "unsupported architecture: $(uname -m)" >&2; exit 1 ;; + esac +} + +ensure_user() { + if ! getent group "$RM_GROUP" >/dev/null; then + groupadd --system "$RM_GROUP" + fi + if ! getent passwd "$RM_USER" >/dev/null; then + useradd --system --gid "$RM_GROUP" \ + --home-dir "$RM_STATE_DIR" --no-create-home \ + --shell /usr/sbin/nologin \ + "$RM_USER" + fi +} + +ensure_dirs() { + install -d -m 0750 -o "$RM_USER" -g "$RM_GROUP" "$RM_CONFIG_DIR" + install -d -m 0750 -o "$RM_USER" -g "$RM_GROUP" "$RM_STATE_DIR" +} + +detect_existing_schedulers() { + echo + echo "==> Scanning for existing restic schedules (we will NOT touch them)" + local found=0 + if command -v systemctl >/dev/null 2>&1; then + while IFS= read -r unit; do + [ -n "$unit" ] || continue + echo " [systemd] $unit" + echo " disable with: systemctl disable --now $unit" + found=1 + done < <(systemctl list-unit-files --no-legend --type=timer 2>/dev/null \ + | awk 'tolower($1) ~ /restic/ {print $1}') + fi + for f in /etc/cron.d/* /etc/cron.daily/* /etc/cron.hourly/* /etc/cron.weekly/*; do + [ -f "$f" ] || continue + if grep -qiI restic "$f" 2>/dev/null; then + echo " [cron] $f" + echo " review and remove or rename if you want this agent to take over" + found=1 + fi + done + if root_cron=$(crontab -l 2>/dev/null); then + if echo "$root_cron" | grep -qi restic; then + echo " [crontab -l] root crontab contains a restic entry" + echo " review with: crontab -l" + found=1 + fi + fi + if [ $found -eq 0 ]; then + echo " (none found)" + fi + echo +} + +download_agent() { + local arch out + arch=$(detect_arch) + out="$RM_INSTALL_PREFIX/restic-manager-agent" + + echo "==> Downloading restic-manager-agent (linux/$arch) from $RM_SERVER" + # The server's /agent/binary endpoint serves the matching binary + # for the requesting agent's arch. (P1-31; until then this URL + # may need to be a static download.) + curl -fsSL --retry 3 \ + "$RM_SERVER/agent/binary?os=linux&arch=$arch" \ + -o "$out.new" + chmod +x "$out.new" + mv -f "$out.new" "$out" + echo " installed $out ($(file -b "$out" | head -1))" +} + +enroll_agent() { + local cfg="$RM_CONFIG_DIR/agent.yaml" + if [ -s "$cfg" ] && [ "$RM_FORCE_REENROLL" != "1" ]; then + echo "==> $cfg already exists; skipping enrollment" + echo " (set RM_FORCE_REENROLL=1 to overwrite)" + return + fi + + echo "==> Enrolling agent with $RM_SERVER" + sudo -u "$RM_USER" \ + "$RM_INSTALL_PREFIX/restic-manager-agent" \ + -config "$cfg" \ + -enroll-server "$RM_SERVER" \ + -enroll-token "$RM_TOKEN" +} + +install_unit() { + local unit="/etc/systemd/system/restic-manager-agent.service" + echo "==> Installing systemd unit at $unit" + curl -fsSL --retry 3 \ + "$RM_SERVER/install/restic-manager-agent.service" \ + -o "$unit" + chmod 0644 "$unit" + systemctl daemon-reload + systemctl enable --now restic-manager-agent.service + echo " started; tail with: journalctl -fu restic-manager-agent" +} + +main() { + require_root + ensure_user + ensure_dirs + download_agent + detect_existing_schedulers + enroll_agent + install_unit + echo + echo "==> done." + echo " config: $RM_CONFIG_DIR/agent.yaml" + echo " binary: $RM_INSTALL_PREFIX/restic-manager-agent" + echo " service: systemctl status restic-manager-agent" + echo " logs: journalctl -fu restic-manager-agent" +} + +main "$@" diff --git a/deploy/install/restic-manager-agent.service b/deploy/install/restic-manager-agent.service new file mode 100644 index 0000000..2567559 --- /dev/null +++ b/deploy/install/restic-manager-agent.service @@ -0,0 +1,42 @@ +[Unit] +Description=restic-manager agent +Documentation=https://gitea.dcglab.co.uk/steve/restic-manager +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +ExecStart=/usr/local/bin/restic-manager-agent -config /etc/restic-manager/agent.yaml +Restart=always +RestartSec=5 + +# Run as a dedicated unprivileged user; the install script creates it. +User=restic-manager-agent +Group=restic-manager-agent + +# The agent reads its config and writes a small state file there. +# Anything else is read-only. +ReadWritePaths=/etc/restic-manager /var/lib/restic-manager + +# Hardening — restic itself needs filesystem read access to whatever +# paths it's backing up; we don't lock that down here. But everything +# else gets the standard systemd sandboxing toggles. +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=read-only +ProtectHostname=true +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectKernelLogs=true +ProtectControlGroups=true +ProtectClock=true +RestrictRealtime=true +RestrictSUIDSGID=true +RestrictNamespaces=true +LockPersonality=true +MemoryDenyWriteExecute=true +SystemCallArchitectures=native + +[Install] +WantedBy=multi-user.target diff --git a/internal/server/http/agent_assets.go b/internal/server/http/agent_assets.go new file mode 100644 index 0000000..2efbd8e --- /dev/null +++ b/internal/server/http/agent_assets.go @@ -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 /agent-binaries/, +// laid down by the release pipeline (or copied by hand for now). +// The install scripts live in /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/". + 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 +} diff --git a/internal/server/http/server.go b/internal/server/http/server.go index c1a6ff2..1b505cc 100644 --- a/internal/server/http/server.go +++ b/internal/server/http/server.go @@ -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")