6ab1e7c56c
gates / gates (push) Successful in 20s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
239 lines
8.6 KiB
Go
239 lines
8.6 KiB
Go
package selfupdate
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"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 <StateDir>/selfupdate/felhom-agent-<version>,
|
|
// 3. verifies the download against the SIGNED sha256 (mismatch → refuse, remove, error),
|
|
// 4. hands the staged binary to `felhom-selfupdate-guarded apply <staged> <sha>` 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
|
|
planDir string // felhom-os-apply's plan dir; "" = defaultPlanDir (tests set a temp dir)
|
|
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)
|
|
}
|
|
|
|
// R-861 (v0.146.0): hand the SIGNED job to the root wrapper felhom-os-apply (mode agent_update). It verifies the
|
|
// operator's signature itself (root-owned signers file, this host, the window, the nonce), re-hashes the staged
|
|
// file against the SIGNED sha, and only then runs the A/B flip (felhom-selfupdate-guarded apply, no longer in the
|
|
// agent's sudoers). Until v0.146.0 the agent passed the sha to the flip itself, so a compromised agent could
|
|
// install any binary — and the binary is what the escrow ceremony and the guest hook run as root.
|
|
so, ok := signedjobs.SignedOpFrom(ctx)
|
|
if !ok {
|
|
return fmt.Errorf("agent_update: no signed envelope in the context — the root wrapper could not verify it")
|
|
}
|
|
planDir := e.planDir
|
|
if planDir == "" {
|
|
planDir = defaultPlanDir
|
|
}
|
|
if err := os.MkdirAll(planDir, 0o700); err != nil {
|
|
return fmt.Errorf("agent_update: plan dir: %w", err)
|
|
}
|
|
plan, _ := json.Marshal(map[string]any{"release_id": "agent-" + p.Version, "layer": "host", "mode": "agent_update",
|
|
"staged": staged, "signed": map[string]string{"blob_b64": base64.StdEncoding.EncodeToString(so.Blob), "sig": string(so.Sig)}})
|
|
planPath := filepath.Join(planDir, "plan-agentupdate-"+p.Version+".json")
|
|
if err := os.WriteFile(planPath, plan, 0o600); err != nil {
|
|
return fmt.Errorf("agent_update: write plan: %w", err)
|
|
}
|
|
defer os.Remove(planPath)
|
|
e.logger.Warn("agent_update: handing the signed job to the root wrapper (felhom-os-apply agent_update)", "staged", staged, "version", p.Version)
|
|
stdout, stderr, err := e.runner.Run(ctx, osApplyPath, "--plan", planPath)
|
|
rep := parseOSApplyReport(stdout)
|
|
var r struct {
|
|
Refused json.RawMessage `json:"refused"`
|
|
Failed json.RawMessage `json:"failed"`
|
|
AgentUpdate json.RawMessage `json:"agent_update"`
|
|
}
|
|
jerr := json.Unmarshal([]byte(rep), &r)
|
|
refused := len(r.Refused) > 0 && string(r.Refused) != "null"
|
|
if err != nil || jerr != nil || refused || len(r.Failed) > 0 || len(r.AgentUpdate) == 0 {
|
|
return fmt.Errorf("agent_update: the root wrapper did not apply it: %v (report: %s; stderr: %s)", err, trimStr(rep), trim(stderr))
|
|
}
|
|
e.logger.Warn("agent_update: signed update verified as root and handed off; restart scheduled", "version", p.Version, "report", trimStr(rep))
|
|
return nil
|
|
}
|
|
|
|
// osApplyPath is the root wrapper that verifies the signed agent_update (R-861). Fixed, never config-overridable.
|
|
const osApplyPath = "/usr/local/sbin/felhom-os-apply"
|
|
|
|
// defaultPlanDir is felhom-os-apply's ONLY plan directory (PLAN_DIR there; osupdate.DefaultPlanDir here).
|
|
const defaultPlanDir = "/var/lib/felhom-agent/os"
|
|
|
|
// parseOSApplyReport returns the JSON after the wrapper's last "OSAPPLY-REPORT " line ("" when there is none).
|
|
func parseOSApplyReport(stdout []byte) string {
|
|
rep := ""
|
|
for _, line := range strings.Split(string(stdout), "\n") {
|
|
if strings.HasPrefix(line, "OSAPPLY-REPORT ") {
|
|
rep = strings.TrimPrefix(line, "OSAPPLY-REPORT ")
|
|
}
|
|
}
|
|
return rep
|
|
}
|
|
|
|
func trimStr(s string) string {
|
|
if len(s) > 400 {
|
|
return s[:400] + "…"
|
|
}
|
|
return s
|
|
}
|
|
|
|
// 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
|
|
}
|