c9fa2e717b
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
158 lines
5.9 KiB
Go
158 lines
5.9 KiB
Go
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
|
|
}
|