R-840: the config bundle — a signed agent_config_update brings a box's root-owned files (sudoers, wrappers, units)
gates / gates (push) Successful in 18s
gates / gates (push) Successful in 18s
felhom-os-apply gains mode 'bundle' (signed, verified by the wrapper itself against the root-owned signers file — or, when that file is missing, only the installer's pinned key, which it then creates) and --install-bundle (the installer's root entry). BUNDLE_FILES is the one table of paths; every check (visudo, sh/bash -n, python, unit sections, RuntimeDirectory guard, nft -c, the route itself) runs before the first write; a failed write or self-check puts every previous copy back. The trust root is never a bundle path (R17). scripts/build-config-bundle.py builds it reproducibly; release-agent.sh publishes it beside the binary. The agent reports the bundle record in system.config_bundle. felhom-opsign signs agent_config_update. 43 wrapper tests (22 mutants red), Go executor tests. 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:
@@ -0,0 +1,25 @@
|
||||
package hub
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// R-840: the agent reports the bundle record itself — "none" when no bundle ever reached the box (so an old wrapper
|
||||
// cannot hide that), "unknown" when it cannot be read, else the version and sha the root wrapper recorded.
|
||||
func TestReadBundleRecord(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "config-bundle.json")
|
||||
if got := string(readBundleRecord(p)); got != `{"version":"none"}` {
|
||||
t.Fatalf("absent: %s", got)
|
||||
}
|
||||
os.WriteFile(p, []byte("{broken"), 0o644)
|
||||
if got := string(readBundleRecord(p)); got != `{"version":"unknown"}` {
|
||||
t.Fatalf("broken: %s", got)
|
||||
}
|
||||
os.WriteFile(p, []byte(`{"format":1,"agent_version":"0.143.0","bundle_sha256":"abc","installed_at":"2026-10-04T20:00:00Z","authority":"signed","files":{"/x":"y"}}`), 0o644)
|
||||
if got := string(readBundleRecord(p)); got != `{"authority":"signed","bundle_sha256":"abc","installed_at":"2026-10-04T20:00:00Z","version":"0.143.0"}` {
|
||||
t.Fatalf("record: %s", got)
|
||||
}
|
||||
}
|
||||
+53
-24
@@ -92,30 +92,31 @@ type GuestNetReporter interface {
|
||||
// Collector builds a HostReport from read-only sources. All deps are behind narrow
|
||||
// interfaces for unit testing.
|
||||
type Collector struct {
|
||||
px proxmoxReader
|
||||
cf CloudflaredProber
|
||||
storage StorageObserver
|
||||
backups BackupReporter
|
||||
restoreTests RestoreTestReporter
|
||||
provenTests ProvenRestoreTestReporter
|
||||
pbs PBSReporter
|
||||
temp TempReader // slice 9: host CPU/chassis temp (nil-safe → nil temp)
|
||||
capProbe func(ctx context.Context) []capability.Status // v0.44.0: privileged-capability self-check (nil → empty)
|
||||
leafFP string // v0.48.0: served local-API leaf fp (static per process; "" when local API disabled)
|
||||
addrEnum AddressEnumerator // v0.119.0: host interface enumeration; nil => the REAL one (see collectAddresses)
|
||||
wg WireguardReporter // S3: offsite-tunnel status (nil → stanza omitted)
|
||||
pbsdr PBSDRReporter // slice 2: PBS DR tier bridge state (nil → stanza omitted)
|
||||
ctrlSup ControllerSupervisorReporter // R-523: in-guest controller supervisor (nil → stanza omitted)
|
||||
guestNet GuestNetReporter // R-54: per-guest network watchdog (nil → stanza omitted)
|
||||
selfUpdate SelfUpdateReporter // D1: agent self-update pending status (nil → false)
|
||||
mgmtPlane MgmtPlaneReporter // G1: management-plane health (nil → stanza omitted)
|
||||
oob OOBReporter // H1: operator-access health (nil → stanza omitted)
|
||||
system SystemReporter // R-852: the box versions (nil → API fields only)
|
||||
backupTarget func() ConfiguredBackupTarget // R-109: primary backup tier id (nil → recipe records unknown)
|
||||
hostID string
|
||||
agentVersion string
|
||||
logger *slog.Logger
|
||||
now func() time.Time
|
||||
px proxmoxReader
|
||||
cf CloudflaredProber
|
||||
storage StorageObserver
|
||||
backups BackupReporter
|
||||
restoreTests RestoreTestReporter
|
||||
provenTests ProvenRestoreTestReporter
|
||||
pbs PBSReporter
|
||||
temp TempReader // slice 9: host CPU/chassis temp (nil-safe → nil temp)
|
||||
capProbe func(ctx context.Context) []capability.Status // v0.44.0: privileged-capability self-check (nil → empty)
|
||||
leafFP string // v0.48.0: served local-API leaf fp (static per process; "" when local API disabled)
|
||||
addrEnum AddressEnumerator // v0.119.0: host interface enumeration; nil => the REAL one (see collectAddresses)
|
||||
wg WireguardReporter // S3: offsite-tunnel status (nil → stanza omitted)
|
||||
pbsdr PBSDRReporter // slice 2: PBS DR tier bridge state (nil → stanza omitted)
|
||||
ctrlSup ControllerSupervisorReporter // R-523: in-guest controller supervisor (nil → stanza omitted)
|
||||
guestNet GuestNetReporter // R-54: per-guest network watchdog (nil → stanza omitted)
|
||||
selfUpdate SelfUpdateReporter // D1: agent self-update pending status (nil → false)
|
||||
mgmtPlane MgmtPlaneReporter // G1: management-plane health (nil → stanza omitted)
|
||||
oob OOBReporter // H1: operator-access health (nil → stanza omitted)
|
||||
system SystemReporter // R-852: the box versions (nil → API fields only)
|
||||
bundleRecordPath string // R-840: test seam; "" = BundleRecordPath
|
||||
backupTarget func() ConfiguredBackupTarget // R-109: primary backup tier id (nil → recipe records unknown)
|
||||
hostID string
|
||||
agentVersion string
|
||||
logger *slog.Logger
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewCollector builds a collector. hostID echoes config.Hub.HostID; agentVersion is
|
||||
@@ -262,6 +263,33 @@ func (c *Collector) SetSystemReporter(r SystemReporter) *Collector {
|
||||
return c
|
||||
}
|
||||
|
||||
// BundleRecordPath is the root-owned record felhom-os-apply writes after a config bundle installs (R-840).
|
||||
const BundleRecordPath = "/etc/felhom/config-bundle.json"
|
||||
|
||||
// readBundleRecord returns the record's summary: version/sha/installed_at, "none" when the file is absent, "unknown"
|
||||
// when it cannot be read or parsed (never a guess).
|
||||
func readBundleRecord(path string) json.RawMessage {
|
||||
if path == "" {
|
||||
path = BundleRecordPath
|
||||
}
|
||||
b, err := os.ReadFile(path)
|
||||
if os.IsNotExist(err) {
|
||||
return json.RawMessage(`{"version":"none"}`)
|
||||
}
|
||||
var rec struct {
|
||||
AgentVersion string `json:"agent_version"`
|
||||
BundleSHA256 string `json:"bundle_sha256"`
|
||||
InstalledAt string `json:"installed_at"`
|
||||
Authority string `json:"authority"`
|
||||
}
|
||||
if err != nil || json.Unmarshal(b, &rec) != nil || rec.AgentVersion == "" {
|
||||
return json.RawMessage(`{"version":"unknown"}`)
|
||||
}
|
||||
out, _ := json.Marshal(map[string]string{"version": rec.AgentVersion, "bundle_sha256": rec.BundleSHA256,
|
||||
"installed_at": rec.InstalledAt, "authority": rec.Authority})
|
||||
return out
|
||||
}
|
||||
|
||||
func unknownIfEmpty(s string) string {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return "unknown"
|
||||
@@ -273,6 +301,7 @@ func unknownIfEmpty(s string) string {
|
||||
func (c *Collector) systemInfo(ctx context.Context, ns proxmox.NodeStatus) *SystemInfo {
|
||||
si := &SystemInfo{PVEVersion: unknownIfEmpty(ns.PVEVersion), KernelVersion: unknownIfEmpty(ns.KVersion),
|
||||
ReadAt: c.now().Format(time.RFC3339)}
|
||||
si.ConfigBundle = readBundleRecord(c.bundleRecordPath)
|
||||
if c.system == nil {
|
||||
si.FactsError = "no facts reader wired"
|
||||
return si
|
||||
|
||||
@@ -255,6 +255,10 @@ type SystemInfo struct {
|
||||
Facts json.RawMessage `json:"facts,omitempty"`
|
||||
FactsError string `json:"facts_error,omitempty"`
|
||||
ReadAt string `json:"read_at"`
|
||||
// ConfigBundle is the box's root-owned config bundle record (R-840, agent v0.143.0), read by the agent itself from
|
||||
// /etc/felhom/config-bundle.json (0644): {"version":"none"} on a box no bundle reached, so an OLD wrapper cannot
|
||||
// hide it. The wrapper's facts carry the same record plus the drift (files changed by hand).
|
||||
ConfigBundle json.RawMessage `json:"config_bundle,omitempty"`
|
||||
}
|
||||
|
||||
// HostMetrics is the host block, sourced from proxmox NodeStatus.
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
package osupdate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/signedjobs"
|
||||
)
|
||||
|
||||
// OpConfigUpdate is the signed op that brings a box's ROOT-OWNED files (sudoers, wrappers, units) to a release's config
|
||||
// bundle (R-840, `11` §5.4.2). The params pin the agent version and the bundle's sha256; the root wrapper re-verifies
|
||||
// the signature, the host binding, the nonce and the sha ITSELF — this executor is only the courier.
|
||||
const OpConfigUpdate = "agent_config_update"
|
||||
|
||||
// BundleFileName is the bundle's name beside the binary in the Gitea generic package felhom-agent/<version>/.
|
||||
const BundleFileName = "felhom-config-bundle.json"
|
||||
|
||||
var (
|
||||
bundleVersionRe = regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$`)
|
||||
bundleSHARe = regexp.MustCompile(`^[0-9a-f]{64}$`)
|
||||
)
|
||||
|
||||
// ConfigUpdateParams are the signed params (the wrapper reads the same two names out of the signed blob).
|
||||
type ConfigUpdateParams struct {
|
||||
AgentVersion string `json:"agent_version"`
|
||||
BundleSHA256 string `json:"bundle_sha256"`
|
||||
}
|
||||
|
||||
// BundleURL derives the bundle's URL from the agent binary's URL template (".../felhom-agent/{version}/felhom-agent").
|
||||
func BundleURL(binaryTemplate, version string) (string, error) {
|
||||
if !strings.HasSuffix(binaryTemplate, "/felhom-agent") || !strings.Contains(binaryTemplate, "{version}") {
|
||||
return "", fmt.Errorf("cannot derive the bundle URL from %q (want …/{version}/felhom-agent)", binaryTemplate)
|
||||
}
|
||||
t := strings.TrimSuffix(binaryTemplate, "felhom-agent") + BundleFileName
|
||||
return strings.ReplaceAll(t, "{version}", version), nil
|
||||
}
|
||||
|
||||
// ConfigUpdateExecutor runs a verified agent_config_update (signedjobs.Executor).
|
||||
type ConfigUpdateExecutor struct {
|
||||
Leg *Leg
|
||||
URLTemplate string // the agent binary's template (config.SelfUpdate.URLTemplate)
|
||||
Username string
|
||||
Token string
|
||||
HTTPClient *http.Client
|
||||
// AfterInstall runs after a bundle installed (the capability re-probe; nil = none).
|
||||
AfterInstall func(ctx context.Context)
|
||||
}
|
||||
|
||||
// Execute implements signedjobs.Executor.
|
||||
func (e ConfigUpdateExecutor) Execute(ctx context.Context, op string, params json.RawMessage) error {
|
||||
if op != OpConfigUpdate {
|
||||
return signedjobs.ErrNoExecutor
|
||||
}
|
||||
so, ok := signedjobs.SignedOpFrom(ctx)
|
||||
if !ok {
|
||||
return fmt.Errorf("agent_config_update: no signed envelope in the context — the wrapper could not verify it")
|
||||
}
|
||||
var p ConfigUpdateParams
|
||||
if err := json.Unmarshal(params, &p); err != nil {
|
||||
return fmt.Errorf("agent_config_update: bad params: %w", err)
|
||||
}
|
||||
if !bundleVersionRe.MatchString(p.AgentVersion) || !bundleSHARe.MatchString(p.BundleSHA256) {
|
||||
return fmt.Errorf("agent_config_update: params must pin agent_version (semver) and bundle_sha256 (64 hex)")
|
||||
}
|
||||
lg := e.Leg.log().With("op", OpConfigUpdate, "agent_version", p.AgentVersion)
|
||||
url, err := BundleURL(e.URLTemplate, p.AgentVersion)
|
||||
if err != nil {
|
||||
return fmt.Errorf("agent_config_update: %w", err)
|
||||
}
|
||||
dir := e.Leg.PlanDir
|
||||
if dir == "" {
|
||||
dir = DefaultPlanDir
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return fmt.Errorf("agent_config_update: plan dir: %w", err)
|
||||
}
|
||||
path := filepath.Join(dir, "bundle-"+p.AgentVersion+".json")
|
||||
start := time.Now()
|
||||
got, err := e.download(ctx, url, path)
|
||||
if err != nil {
|
||||
_ = os.Remove(path)
|
||||
return fmt.Errorf("agent_config_update: download %s: %w", url, err)
|
||||
}
|
||||
defer os.Remove(path)
|
||||
// The agent's own check is a courtesy (an early, clear error); the wrapper's is the gate.
|
||||
if got != p.BundleSHA256 {
|
||||
return fmt.Errorf("agent_config_update: the downloaded bundle's sha256 is %s, the signed job pins %s — nothing installed", got, p.BundleSHA256)
|
||||
}
|
||||
lg.Info("osupdate: config bundle downloaded; handing it to the root wrapper", "sha256", got[:16], "duration_ms", time.Since(start).Milliseconds())
|
||||
runID := "bundle-" + e.Leg.now().UTC().Format("20060102T150405Z")
|
||||
wr, err := e.Leg.call(ctx, runID, map[string]any{"release_id": "bundle-" + p.AgentVersion, "layer": LayerHost,
|
||||
"mode": "bundle", "bundle": path,
|
||||
"signed": map[string]string{"blob_b64": base64.StdEncoding.EncodeToString(so.Blob), "sig": string(so.Sig)}})
|
||||
if err != nil {
|
||||
return fmt.Errorf("agent_config_update: %w", err)
|
||||
}
|
||||
if wr.refused() {
|
||||
lg.Warn("osupdate: config bundle REFUSED by the wrapper — nothing changed", "refused", string(wr.Refused))
|
||||
return fmt.Errorf("agent_config_update: refused: %s", wr.Refused)
|
||||
}
|
||||
if wr.failed() {
|
||||
lg.Error("osupdate: config bundle FAILED — the wrapper put the previous files back", "failed", string(wr.Failed), "bundle", string(wr.Bundle))
|
||||
return fmt.Errorf("agent_config_update: failed (previous files restored): %s", wr.Failed)
|
||||
}
|
||||
lg.Warn("osupdate: config bundle INSTALLED", "bundle", string(wr.Bundle), "pass_seconds", wr.PassSeconds)
|
||||
if e.AfterInstall != nil {
|
||||
e.AfterInstall(ctx)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e ConfigUpdateExecutor) download(ctx context.Context, url, dest string) (string, error) {
|
||||
hc := e.HTTPClient
|
||||
if hc == nil {
|
||||
hc = &http.Client{Timeout: 2 * time.Minute}
|
||||
}
|
||||
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 := hc.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, 0o600)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
h := sha256.New()
|
||||
// 4 MB is the wrapper's own limit; read one byte more so an oversized bundle fails there, visibly.
|
||||
if _, err := io.Copy(io.MultiWriter(f, h), io.LimitReader(resp.Body, 4*1024*1024+1)); err != nil {
|
||||
f.Close()
|
||||
return "", err
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package osupdate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/reconcile"
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/signedjobs"
|
||||
)
|
||||
|
||||
// bundleWrapper plays felhom-os-apply for mode "bundle": it records the plan and the bundle file's bytes AT CALL TIME
|
||||
// (the executor deletes the file afterwards), and answers with rep.
|
||||
type bundleWrapper struct {
|
||||
t *testing.T
|
||||
rep string
|
||||
plans []map[string]any
|
||||
bodies [][]byte
|
||||
}
|
||||
|
||||
func (b *bundleWrapper) Run(_ context.Context, name string, args ...string) ([]byte, []byte, error) {
|
||||
if name != WrapperPath || len(args) != 2 || args[0] != "--plan" {
|
||||
b.t.Fatalf("unexpected command %s %v", name, args)
|
||||
}
|
||||
raw, err := os.ReadFile(args[1])
|
||||
if err != nil {
|
||||
b.t.Fatal(err)
|
||||
}
|
||||
var plan map[string]any
|
||||
_ = json.Unmarshal(raw, &plan)
|
||||
b.plans = append(b.plans, plan)
|
||||
body, _ := os.ReadFile(plan["bundle"].(string))
|
||||
b.bodies = append(b.bodies, body)
|
||||
return []byte("OSAPPLY-REPORT " + b.rep + "\n"), nil, nil
|
||||
}
|
||||
|
||||
func serveBundle(t *testing.T, body []byte) (*httptest.Server, string) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/felhom-agent/0.143.0/"+BundleFileName {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write(body)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
sum := sha256.Sum256(body)
|
||||
return srv, hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func bundleExec(t *testing.T, w *bundleWrapper, srvURL string) (ConfigUpdateExecutor, *bool) {
|
||||
l, _ := newLeg(t, &fakeWrapper{t: t}, nil)
|
||||
l.Runner = w
|
||||
called := false
|
||||
return ConfigUpdateExecutor{Leg: l, URLTemplate: srvURL + "/felhom-agent/{version}/felhom-agent",
|
||||
AfterInstall: func(context.Context) { called = true }}, &called
|
||||
}
|
||||
|
||||
func signedCtx() context.Context {
|
||||
return signedjobs.WithSignedOp(context.Background(), &reconcile.SignedOp{Blob: []byte(`{"op":"agent_config_update"}`), Sig: []byte("SIG")})
|
||||
}
|
||||
|
||||
func params(v, sha string) json.RawMessage {
|
||||
p, _ := json.Marshal(ConfigUpdateParams{AgentVersion: v, BundleSHA256: sha})
|
||||
return p
|
||||
}
|
||||
|
||||
// The courier hands the wrapper the bundle bytes it downloaded and the RAW signed envelope (the wrapper verifies both
|
||||
// itself), then runs the capability probe. Red-proof: drop "signed" from the plan → the plan check below fails.
|
||||
func TestConfigUpdate_PassesBundleAndEnvelopeToTheWrapper(t *testing.T) {
|
||||
body := []byte(`{"format":1,"agent_version":"0.143.0","files":[]}`)
|
||||
srv, sha := serveBundle(t, body)
|
||||
w := &bundleWrapper{t: t, rep: `{"mode":"bundle","bundle":{"agent_version":"0.143.0","written":["/etc/sudoers.d/felhom-agent"]}}`}
|
||||
e, called := bundleExec(t, w, srv.URL)
|
||||
if err := e.Execute(signedCtx(), OpConfigUpdate, params("0.143.0", sha)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p := w.plans[0]
|
||||
sg, _ := p["signed"].(map[string]any)
|
||||
if p["mode"] != "bundle" || p["layer"] != "host" || sg == nil || sg["sig"] != "SIG" ||
|
||||
sg["blob_b64"] != base64.StdEncoding.EncodeToString([]byte(`{"op":"agent_config_update"}`)) {
|
||||
t.Fatalf("plan = %v", p)
|
||||
}
|
||||
if string(w.bodies[0]) != string(body) || !strings.HasSuffix(p["bundle"].(string), "/bundle-0.143.0.json") {
|
||||
t.Fatalf("the wrapper got %q at %v", w.bodies[0], p["bundle"])
|
||||
}
|
||||
if !*called {
|
||||
t.Fatal("the capability probe must run after an install")
|
||||
}
|
||||
if _, err := os.Stat(p["bundle"].(string)); !os.IsNotExist(err) {
|
||||
t.Fatal("the downloaded bundle must be removed after the call")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigUpdate_WrongShaNeverReachesTheWrapper(t *testing.T) {
|
||||
srv, _ := serveBundle(t, []byte("tampered"))
|
||||
w := &bundleWrapper{t: t}
|
||||
e, called := bundleExec(t, w, srv.URL)
|
||||
err := e.Execute(signedCtx(), OpConfigUpdate, params("0.143.0", strings.Repeat("a", 64)))
|
||||
if err == nil || len(w.plans) != 0 || *called {
|
||||
t.Fatalf("err=%v plans=%d", err, len(w.plans))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigUpdate_RefusedAndFailedAreErrors(t *testing.T) {
|
||||
for _, rep := range []string{`{"refused":{"code":"R17","reason":"trust root"}}`, `{"failed":{"rc":3},"bundle":{"rolled_back":["/x"]}}`} {
|
||||
srv, sha := serveBundle(t, []byte("{}"))
|
||||
w := &bundleWrapper{t: t, rep: rep}
|
||||
e, called := bundleExec(t, w, srv.URL)
|
||||
if err := e.Execute(signedCtx(), OpConfigUpdate, params("0.143.0", sha)); err == nil || *called {
|
||||
t.Fatalf("%s: err=%v called=%v", rep, err, *called)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigUpdate_GuardsBeforeAnyDownload(t *testing.T) {
|
||||
w := &bundleWrapper{t: t}
|
||||
e, _ := bundleExec(t, w, "http://127.0.0.1:1")
|
||||
if err := e.Execute(signedCtx(), "agent_update", nil); !errors.Is(err, signedjobs.ErrNoExecutor) {
|
||||
t.Fatalf("another op must pass through the chain: %v", err)
|
||||
}
|
||||
if err := e.Execute(context.Background(), OpConfigUpdate, params("0.143.0", strings.Repeat("a", 64))); err == nil {
|
||||
t.Fatal("no envelope must refuse")
|
||||
}
|
||||
for _, p := range []json.RawMessage{params("0.143", strings.Repeat("a", 64)), params("0.143.0", "ABC"), json.RawMessage(`nope`)} {
|
||||
if err := e.Execute(signedCtx(), OpConfigUpdate, p); err == nil {
|
||||
t.Fatalf("bad params %s must refuse", p)
|
||||
}
|
||||
}
|
||||
if len(w.plans) != 0 {
|
||||
t.Fatal("no wrapper call on a refusal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBundleURL(t *testing.T) {
|
||||
u, err := BundleURL("https://gitea.dooplex.hu/api/packages/admin/generic/felhom-agent/{version}/felhom-agent", "0.143.0")
|
||||
if err != nil || u != "https://gitea.dooplex.hu/api/packages/admin/generic/felhom-agent/0.143.0/felhom-config-bundle.json" {
|
||||
t.Fatalf("%s %v", u, err)
|
||||
}
|
||||
if _, err := BundleURL("https://example/felhom-agent-{version}.bin", "0.143.0"); err == nil {
|
||||
t.Fatal("an underivable template must refuse")
|
||||
}
|
||||
}
|
||||
|
||||
func (b *bundleWrapper) RunStdin(ctx context.Context, _ io.Reader, name string, args ...string) ([]byte, []byte, error) {
|
||||
return b.Run(ctx, name, args...)
|
||||
}
|
||||
@@ -105,6 +105,7 @@ type WrapperReport struct {
|
||||
Undo bool `json:"undo"`
|
||||
LiveRestore json.RawMessage `json:"live_restore"`
|
||||
Facts json.RawMessage `json:"facts"`
|
||||
Bundle json.RawMessage `json:"bundle"` // the config bundle's result (R-840, mode "bundle")
|
||||
}
|
||||
|
||||
func (w WrapperReport) refused() bool { return len(w.Refused) > 0 && string(w.Refused) != "null" }
|
||||
|
||||
@@ -346,7 +346,7 @@ func TestWrapperSuite(t *testing.T) {
|
||||
t.Skip("python3 not available")
|
||||
}
|
||||
// the OS wrapper and (agent v0.142.0) the crash guard — both root programs in configs/ with their own suites
|
||||
for _, suite := range []string{"../../configs/test_felhom_os_apply.py", "../../configs/test_felhom_crash_guard.py"} {
|
||||
for _, suite := range []string{"../../configs/test_felhom_os_apply.py", "../../configs/test_felhom_crash_guard.py", "../../configs/test_felhom_config_bundle.py"} {
|
||||
cmd := exec.Command(py, "-B", suite)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
|
||||
@@ -51,6 +51,10 @@ const (
|
||||
// A Docker engine step in the customer guest (agent v0.142.0, `11` §5.8) — ring 1, and every undo. Destructive-class
|
||||
// (signed, operational key) like agent_update; the root wrapper re-verifies the same signature itself.
|
||||
ClassOSDockerStep OpClass = "os_docker_step"
|
||||
|
||||
// The config bundle (agent v0.143.0, R-840, `11` §5.4.2): the box's ROOT-OWNED files (sudoers, wrappers, units).
|
||||
// Destructive-class (signed, operational key) like agent_update; the root wrapper re-verifies the signature itself.
|
||||
ClassAgentConfigUpdate OpClass = "agent_config_update"
|
||||
)
|
||||
|
||||
// Disposition is the classifier verdict.
|
||||
@@ -119,7 +123,7 @@ func Classify(class OpClass, prov Provenance) Disposition {
|
||||
return Destructive
|
||||
case ClassKeyRotation:
|
||||
return Destructive
|
||||
case ClassAgentUpdate, ClassOSDockerStep:
|
||||
case ClassAgentUpdate, ClassOSDockerStep, ClassAgentConfigUpdate:
|
||||
// Never benign — no agent-internal provenance can make replacing the agent binary
|
||||
// unsigned-safe (a compromised process must not be able to self-bless an update).
|
||||
return Destructive
|
||||
|
||||
Reference in New Issue
Block a user