package selfupdate import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "io" "log/slog" "net/http" "os" "path/filepath" "time" "gitea.dooplex.hu/admin/felhom-agent/internal/signedjobs" ) // WrapperRunner shells the guarded wrapper's verbs via `sudo -n` (satisfied by *proxmox.ExecRunner // in RunnerSudo mode). Seam so the executor + commit-manager tests never actually invoke sudo. type WrapperRunner interface { Run(ctx context.Context, name string, args ...string) (stdout, stderr []byte, err error) } // wrapperPath is the fixed guarded-wrapper path (never config-overridable — path-fixedness is the // security property). const wrapperPath = "/usr/local/sbin/felhom-selfupdate-guarded" // updateParams is the verified params of an agent_update op — the operator-pinned target. type updateParams struct { Version string `json:"version"` SHA256 string `json:"sha256"` } // Executor is the agent_update signed-op consumer. Given a gate-VERIFIED agent_update op it: // 1. builds the download URL from config + the signed version, // 2. downloads to /selfupdate/felhom-agent-, // 3. verifies the download against the SIGNED sha256 (mismatch → refuse, remove, error), // 4. hands the staged binary to `felhom-selfupdate-guarded apply ` via sudo -n. // // The wrapper re-verifies the sha as root and performs the A/B flip + detached restart, so this // process may die shortly after the apply call returns (the S2b detached restart usually lets it // survive to log). Rollback is never this executor's job — a crash-looping new binary is reverted // by systemd + the wrapper. type Executor struct { urlTemplate string username string token string stateDir string runner WrapperRunner httpClient *http.Client logger *slog.Logger } // Config is the executor's dependencies (from config.SelfUpdateConfig + the sudo runner). type Config struct { URLTemplate string Username string Token string StateDir string Runner WrapperRunner HTTPClient *http.Client // nil → a 5-minute-timeout default Logger *slog.Logger } // NewExecutor builds the agent_update executor. func NewExecutor(cfg Config) *Executor { hc := cfg.HTTPClient if hc == nil { hc = &http.Client{Timeout: 5 * time.Minute} } return &Executor{ urlTemplate: cfg.URLTemplate, username: cfg.Username, token: cfg.Token, stateDir: cfg.StateDir, runner: cfg.Runner, httpClient: hc, logger: orDefaultLogger(cfg.Logger), } } // Execute implements signedjobs.Executor for the agent_update op class. func (e *Executor) Execute(ctx context.Context, op string, params json.RawMessage) error { if op != opAgentUpdate { return signedjobs.ErrNoExecutor // not ours — leave queued for the owning executor } var p updateParams if err := json.Unmarshal(params, &p); err != nil { return fmt.Errorf("agent_update: bad params: %w", err) } if !versionRe.MatchString(p.Version) { return fmt.Errorf("agent_update: refusing — version %q is not bare semver", p.Version) } if !sha256Re.MatchString(p.SHA256) { return fmt.Errorf("agent_update: refusing — sha256 is not 64 lowercase hex") } if e.runner == nil { return fmt.Errorf("agent_update: no wrapper runner configured") } e.logger.Debug("agent_update: op invariants passed (semver + sha format)", "version", p.Version) dir := stagingDir(e.stateDir) if err := os.MkdirAll(dir, 0o750); err != nil { return fmt.Errorf("agent_update: staging dir: %w", err) } staged := filepath.Join(dir, "felhom-agent-"+p.Version) url := interpolateURL(e.urlTemplate, p.Version) e.logger.Warn("agent_update: downloading operator-signed binary", "version", p.Version, "url", url, "sha256", p.SHA256) dlStart := time.Now() got, err := e.download(ctx, url, staged) if err != nil { _ = os.Remove(staged) return fmt.Errorf("agent_update: download %s: %w", url, err) } e.logger.Debug("agent_update: download complete", "version", p.Version, "sha_match", got == p.SHA256, "duration_ms", time.Since(dlStart).Milliseconds()) // The signed sha is the ONLY integrity root — verify BEFORE anything touches the live binary. if got != p.SHA256 { _ = os.Remove(staged) return fmt.Errorf("agent_update: sha256 mismatch — got %s want %s (refusing; agent untouched)", got, p.SHA256) } if err := os.Chmod(staged, 0o755); err != nil { _ = os.Remove(staged) return fmt.Errorf("agent_update: chmod staged: %w", err) } // Hand off to the root wrapper. It re-verifies the sha, flips A/B, writes the pending marker, // and schedules the detached restart. After this the new binary starts; the commit is the // Manager's job once it has dwelled cleanly. e.logger.Warn("agent_update: handing staged binary to the guarded wrapper", "staged", staged, "version", p.Version) stdout, stderr, err := e.runner.Run(ctx, wrapperPath, "apply", staged, p.SHA256) if err != nil { return fmt.Errorf("agent_update: wrapper apply failed: %w (stderr: %s)", err, string(stderr)) } e.logger.Warn("agent_update: apply handed off; restart scheduled", "version", p.Version, "wrapper", trim(stdout)) return nil } // download streams url → dest (0644, fsync'd) and returns the lowercase-hex sha256 of the bytes. func (e *Executor) download(ctx context.Context, url, dest string) (string, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return "", err } if e.username != "" || e.token != "" { req.SetBasicAuth(e.username, e.token) } resp, err := e.httpClient.Do(req) if err != nil { return "", err } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return "", fmt.Errorf("HTTP %d", resp.StatusCode) } f, err := os.OpenFile(dest, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644) if err != nil { return "", err } h := sha256.New() if _, err := io.Copy(io.MultiWriter(f, h), resp.Body); err != nil { f.Close() return "", err } if err := f.Sync(); err != nil { f.Close() return "", err } if err := f.Close(); err != nil { return "", err } return hex.EncodeToString(h.Sum(nil)), nil } func trim(b []byte) string { s := string(b) if len(s) > 200 { s = s[:200] } return s }