3a9be73875
Replaces the per-drive 'pct set -mpN' bind with ONE permanent parent bind /mnt/felhom-drives plus host-side felhom-data swaps underneath it (propagates into the running guest live, no pct, no reboot; C1-immune; confined; fail-closed when absent). EnsureSharedParent installs a boot unit ordered Before=pve-guests. ReassertGuestBinds is now a pure host-side reconcile. /disks reports GuestPath + BoundUnderParent for the controller repoint+gate. Non-hollow tests + companions. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
193 lines
8.6 KiB
Go
193 lines
8.6 KiB
Go
package provision
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-agent/internal/guesthook"
|
|
"gitea.dooplex.hu/admin/felhom-agent/internal/proxmox"
|
|
)
|
|
|
|
// TokenMinter mints a per-guest local-API token, persisting only its hash. Satisfied by
|
|
// *localapi.TokenStore.
|
|
type TokenMinter interface {
|
|
Mint(vmid int) (string, error)
|
|
}
|
|
|
|
// defaults for the config-mount layout.
|
|
const (
|
|
// DefaultGuestPath is where the config mount appears INSIDE the guest (matches the
|
|
// controller's bootstrap.DefaultMountPath dir + the golden bootstrap unit).
|
|
DefaultGuestPath = "/etc/felhom-bootstrap"
|
|
// DefaultMountIndex is the mpN slot used for the config mount. It is intentionally high so
|
|
// it never collides with a bring-up data mount (mp0, mp1, …).
|
|
DefaultMountIndex = 9
|
|
// bootstrapFile is the file name inside the config mount.
|
|
bootstrapFile = "bootstrap.json"
|
|
// mappedRoot is the unprivileged-LXC host uid/gid that maps to the guest's root (spike
|
|
// gotcha 1): files chowned to this appear as root:root 0600 inside the guest.
|
|
mappedRoot = "100000:100000"
|
|
// stableParentDir is the permanent host dir bound once into the guest (intermediary mount model);
|
|
// the agent swaps drive felhom-data namespaces underneath it host-side. Mirrors
|
|
// localapi.StableParentDir (kept as a literal to avoid a provision→localapi import edge).
|
|
stableParentDir = "/mnt/felhom-drives"
|
|
// parentBindSlot is the dedicated mpN for the single permanent parent bind. mp8 — high enough to not
|
|
// collide with bring-up data mounts (mp0..), below the mp9 bootstrap.
|
|
parentBindSlot = "mp8"
|
|
)
|
|
|
|
// BackHalf populates a guest's bootstrap config mount host-side (F3). It mints the per-guest
|
|
// token, renders bootstrap.json, writes it 0600, chowns it to the mapped guest-root, and attaches
|
|
// it as a read-only bind mount via `pct set`. The bind-mount attach + chown are host-root ops
|
|
// (NOT API ops and NOT one of proxmox.Privileged's 3 exceptions) — they run through the shared
|
|
// Runner (direct as root, or `sudo -n` with the configs/felhom-agent.sudoers PROVISION entries).
|
|
type BackHalf struct {
|
|
tokens TokenMinter
|
|
runner proxmox.Runner
|
|
stateDir string // agent state dir; the per-guest config dir lives under <stateDir>/guests/<vmid>/bootstrap
|
|
logger *slog.Logger
|
|
}
|
|
|
|
// NewBackHalf builds the back-half. stateDir defaults to /var/lib/felhom-agent when empty.
|
|
func NewBackHalf(tokens TokenMinter, runner proxmox.Runner, stateDir string, logger *slog.Logger) *BackHalf {
|
|
if stateDir == "" {
|
|
stateDir = "/var/lib/felhom-agent"
|
|
}
|
|
if logger == nil {
|
|
logger = slog.Default()
|
|
}
|
|
return &BackHalf{tokens: tokens, runner: runner, stateDir: stateDir, logger: logger}
|
|
}
|
|
|
|
// Input is everything the back-half needs that is NOT secret. The per-guest token is minted here,
|
|
// never supplied by the caller.
|
|
type Input struct {
|
|
VMID int
|
|
Customer DocCustomer
|
|
Hub DocHub
|
|
Endpoint string // local-api bridge IP:port
|
|
Fingerprint string // agent leaf-cert SHA-256 (hex)
|
|
GuestPath string // in-guest mount path; "" → DefaultGuestPath
|
|
MountIndex int // mpN slot; 0 → DefaultMountIndex (note: mp0 is a valid slot but reserved for data)
|
|
}
|
|
|
|
// Result reports the placement of the config mount. It deliberately contains NO token (the secret
|
|
// lives only in the 0600 file + the token store's hash).
|
|
type Result struct {
|
|
VMID int
|
|
HostDir string // agent-owned host dir backing the bind mount
|
|
GuestPath string // in-guest mount path
|
|
MountKey string // mpN key used
|
|
}
|
|
|
|
// Provision runs the back-half for one already-brought-up guest. Order: mint → render → write →
|
|
// chown → attach. On any failure the partial host dir is left for inspection (it holds the 0600
|
|
// token file; it is not world-readable) and the error is returned. The token plaintext is NEVER
|
|
// logged and NEVER returned.
|
|
func (b *BackHalf) Provision(ctx context.Context, in Input) (Result, error) {
|
|
if in.VMID <= 0 {
|
|
return Result{}, fmt.Errorf("provision: needs a positive vmid")
|
|
}
|
|
if in.Endpoint == "" || in.Fingerprint == "" {
|
|
return Result{}, fmt.Errorf("provision: needs the local-api endpoint and leaf fingerprint")
|
|
}
|
|
if in.Customer.ID == "" {
|
|
return Result{}, fmt.Errorf("provision: needs a customer id (the hub config-pull target)")
|
|
}
|
|
if in.Hub.URL == "" || in.Hub.RetrievalPassword == "" {
|
|
return Result{}, fmt.Errorf("provision: needs hub url + retrieval passphrase (so the controller can pull its config)")
|
|
}
|
|
guestPath := in.GuestPath
|
|
if guestPath == "" {
|
|
guestPath = DefaultGuestPath
|
|
}
|
|
idx := in.MountIndex
|
|
if idx == 0 {
|
|
idx = DefaultMountIndex
|
|
}
|
|
mountKey := "mp" + strconv.Itoa(idx)
|
|
|
|
// 1. Mint the per-guest token (only its hash is persisted). The plaintext exists in `tok`
|
|
// until it is written into the mount below; it is never logged or returned.
|
|
tok, err := b.tokens.Mint(in.VMID)
|
|
if err != nil {
|
|
return Result{}, fmt.Errorf("provision: mint token: %w", err)
|
|
}
|
|
|
|
// 2. Render the stable bootstrap.json contract (with the token injected).
|
|
doc := Doc{
|
|
Schema: SchemaV2,
|
|
Customer: in.Customer,
|
|
Hub: in.Hub,
|
|
LocalAPI: DocLocalAPI{Endpoint: in.Endpoint, Fingerprint: in.Fingerprint, Token: tok},
|
|
}
|
|
rendered, err := doc.render()
|
|
if err != nil {
|
|
return Result{}, fmt.Errorf("provision: render bootstrap: %w", err)
|
|
}
|
|
|
|
// 3. Write it 0600 into the agent-owned per-guest config dir.
|
|
hostDir := filepath.Join(b.stateDir, "guests", strconv.Itoa(in.VMID), "bootstrap")
|
|
if err := os.MkdirAll(hostDir, 0o700); err != nil {
|
|
return Result{}, fmt.Errorf("provision: config dir: %w", err)
|
|
}
|
|
bootPath := filepath.Join(hostDir, bootstrapFile)
|
|
if err := os.WriteFile(bootPath, rendered, 0o600); err != nil {
|
|
return Result{}, fmt.Errorf("provision: write bootstrap: %w", err)
|
|
}
|
|
|
|
// 4. chown to the unprivileged-LXC mapped root so the guest reads it as root:root 0600
|
|
// (spike gotcha 1). Host-root op via the Runner.
|
|
if err := b.run(ctx, "chown", "-R", mappedRoot, hostDir); err != nil {
|
|
return Result{}, fmt.Errorf("provision: chown config mount: %w", err)
|
|
}
|
|
|
|
// 5. Attach the read-only bind mount via `pct set` (host-root op; bind mounts are root@pam
|
|
// only, so this cannot be the API token). The golden's baked unit consumes it on boot.
|
|
mpSpec := fmt.Sprintf("%s,mp=%s,ro=1", hostDir, guestPath)
|
|
if err := b.run(ctx, "pct", "set", strconv.Itoa(in.VMID), "-"+mountKey, mpSpec); err != nil {
|
|
return Result{}, fmt.Errorf("provision: attach config mount: %w", err)
|
|
}
|
|
|
|
// 6. Install + register the pre-start self-heal hook (C1 net): if a data drive is absent at a future
|
|
// boot, the hook creates a placeholder for its missing bind source so the guest still starts.
|
|
// Best-effort + non-fatal — it's defense-in-depth; a provision must not fail over the hook.
|
|
if err := guesthook.InstallSnippet(ctx, b.runner); err != nil {
|
|
b.logger.Warn("provision: pre-start hook snippet install failed (non-fatal)", "vmid", in.VMID, "err", err)
|
|
} else if err := guesthook.Register(ctx, b.runner, in.VMID); err != nil {
|
|
b.logger.Warn("provision: pre-start hook registration failed (non-fatal)", "vmid", in.VMID, "err", err)
|
|
}
|
|
|
|
// 7. Intermediary mount model: add the ONE permanent parent bind so enrolled data drives appear in
|
|
// the guest LIVE (the agent later binds each drive's felhom-data UNDER /mnt/felhom-drives host-side
|
|
// — no per-drive mp, no reboot). The host SHARED state + boot-persistence unit are the agent
|
|
// daemon's job (EnsureSharedParent at startup); here we ensure the dir exists (pct validates the
|
|
// bind source) and attach the parent bind. Best-effort + non-fatal.
|
|
if err := b.run(ctx, "mkdir", "-p", stableParentDir); err != nil {
|
|
b.logger.Warn("provision: stable parent dir create failed (non-fatal)", "vmid", in.VMID, "err", err)
|
|
} else {
|
|
parentSpec := fmt.Sprintf("%s,mp=%s", stableParentDir, stableParentDir)
|
|
if err := b.run(ctx, "pct", "set", strconv.Itoa(in.VMID), "-"+parentBindSlot, parentSpec); err != nil {
|
|
b.logger.Warn("provision: parent bind attach failed (non-fatal)", "vmid", in.VMID, "slot", parentBindSlot, "err", err)
|
|
}
|
|
}
|
|
|
|
b.logger.Info("provision: back-half complete",
|
|
"vmid", in.VMID, "mount", mountKey, "guest_path", guestPath, "endpoint", in.Endpoint)
|
|
// tok intentionally goes out of scope here — never logged, never returned.
|
|
return Result{VMID: in.VMID, HostDir: hostDir, GuestPath: guestPath, MountKey: mountKey}, nil
|
|
}
|
|
|
|
// run executes a host-root command through the Runner, wrapping a nonzero exit with stderr.
|
|
func (b *BackHalf) run(ctx context.Context, name string, args ...string) error {
|
|
_, stderr, err := b.runner.Run(ctx, name, args...)
|
|
if err != nil {
|
|
return fmt.Errorf("%s: %w: %s", name, err, string(stderr))
|
|
}
|
|
return nil
|
|
}
|