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