Files
felhom-agent/internal/guesthook/install.go
T

74 lines
3.4 KiB
Go

package guesthook
import (
"context"
"fmt"
"os"
"path/filepath"
"strconv"
"gitea.dooplex.hu/admin/felhom-agent/internal/proxmox"
)
// Install/registration of the pre-start hook. The wrapper lives in a PVE `snippets`-enabled storage dir
// (the `local` storage maps to /var/lib/vz/snippets) and is referenced per-guest by its volid.
const (
// SnippetDir is the local-storage snippets directory PVE serves hookscripts from.
SnippetDir = "/var/lib/vz/snippets"
// SnippetName is the wrapper filename.
SnippetName = "felhom-guest-hook.sh"
// HookVolID is the volid form `pct set --hookscript` expects.
HookVolID = "local:snippets/" + SnippetName
// AgentBin is the installed agent binary the wrapper delegates to.
AgentBin = "/usr/local/bin/felhom-agent"
)
// SnippetPath is the absolute path of the installed wrapper.
var SnippetPath = filepath.Join(SnippetDir, SnippetName)
// snippetBody is the tiny wrapper PVE execs as `<script> <vmid> <phase>`. It delegates to the agent
// binary so the heal LOGIC is the unit-tested Go, never duplicated (divergence-proof) shell. Executable.
// The wrapper NEVER exec's and ALWAYS exits 0 (CAMPAIGN-3 F10/rc255 belt): a hook that exits nonzero
// aborts the guest start. `exec` would surface the binary's exit code to PVE; instead we run it as a
// child, swallow any nonzero (missing/crashed binary, OOM-kill), and `exit 0` unconditionally. The Go
// side has its own recover + per-phase timeout — this is the second belt at the shell layer.
const snippetBody = `#!/bin/sh
# felhom-agent guest pre-start self-heal hook (C1 net). PVE calls: <script> <vmid> <phase>.
` + AgentBin + ` guest-hook "$1" "$2" || true
exit 0
`
// SnippetReady (R-861, agent v0.146.0) reports whether the pre-start hook is in place: a regular file at path whose
// content is exactly snippetBody. The hook is a FIXED, ROOT-OWNED file that arrives with the signed config bundle
// (configs/felhom-guest-hook.sh, pinned byte-identical by TestSnippetEqualsTheBundle). The agent no longer installs it:
// until v0.146.0 it `install`ed it from /tmp, and Proxmox runs a hookscript as root at every guest start — so the
// install grant was a root shell for a compromised agent. A missing or different hook is an error the caller logs; it
// then does NOT register the hook (a guest whose hookscript is missing does not start).
func SnippetReady(path string) error {
fi, err := os.Lstat(path)
if err != nil {
return fmt.Errorf("guesthook: %s is missing — it arrives with the signed config bundle (agent_config_update): %w", path, err)
}
if !fi.Mode().IsRegular() {
return fmt.Errorf("guesthook: %s is not a regular file", path)
}
b, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("guesthook: read %s: %w", path, err)
}
if string(b) != snippetBody {
return fmt.Errorf("guesthook: %s differs from this agent's hook — the next config bundle replaces it", path)
}
return nil
}
// Register points a guest at the pre-start hook (`pct set <vmid> --hookscript <volid>`). Idempotent —
// re-setting the same hookscript is a no-op. Safe on a running guest (a config edit, not a start, so no
// start-lock contention).
func Register(ctx context.Context, runner proxmox.Runner, vmid int) error {
if _, stderr, err := runner.Run(ctx, "pct", "set", strconv.Itoa(vmid), "--hookscript", HookVolID); err != nil {
return fmt.Errorf("guesthook: register hookscript on %d: %w: %s", vmid, err, string(stderr))
}
return nil
}