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//. 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 }