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,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
|
||||
}
|
||||
Reference in New Issue
Block a user