R-861: narrow the agent's root grants — exact sudo patterns, felhom-priv-apply content checker, fixed hook/parent files in the bundle, signed self-update verified as root, escrow root reads pinned
gates / gates (push) Successful in 20s
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
This commit is contained in:
@@ -3,6 +3,7 @@ package selfupdate
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/signedjobs"
|
||||
@@ -47,6 +49,7 @@ type Executor struct {
|
||||
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
|
||||
@@ -126,18 +129,70 @@ func (e *Executor) Execute(ctx context.Context, op string, params json.RawMessag
|
||||
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))
|
||||
// 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")
|
||||
}
|
||||
e.logger.Warn("agent_update: apply handed off; restart scheduled", "version", p.Version, "wrapper", trim(stdout))
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user