slice 8A (agent half): local-API server + provisioning back-half (v0.10.0)
internal/localapi: per-guest local-API server (doc 03 §6) — 7 self-scoped endpoints, hashed per-guest token store, persisted self-signed leaf with stable SHA-256 pin, optional 6th daemon goroutine. internal/provision: back-half — mint token, render bootstrap.json (no registry cred), write 0600, chown 100000:100000, attach pct-set bind mount (host-side, F3, no pct exec). --selftest=provision. build-golden.sh bakes the controller image + bootstrap unit. sudoers FELHOM_PROVISION; firewall narrowing artifact. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
package provision
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// 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 == "" || in.Customer.Domain == "" {
|
||||
return Result{}, fmt.Errorf("provision: needs customer id and domain (so the controller skips setup)")
|
||||
}
|
||||
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: SchemaV1,
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package provision
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// recRunner records every command issued (to assert chown + pct set ran with correct args).
|
||||
type recRunner struct {
|
||||
mu sync.Mutex
|
||||
cmds [][]string
|
||||
fail string // if a command's name == fail, return an error
|
||||
}
|
||||
|
||||
func (r *recRunner) Run(_ context.Context, name string, args ...string) ([]byte, []byte, error) {
|
||||
r.mu.Lock()
|
||||
r.cmds = append(r.cmds, append([]string{name}, args...))
|
||||
r.mu.Unlock()
|
||||
if name == r.fail {
|
||||
return nil, []byte("boom"), io.ErrUnexpectedEOF
|
||||
}
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
func (r *recRunner) find(name string) []string {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
for _, c := range r.cmds {
|
||||
if c[0] == name {
|
||||
return c
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// mintMinter returns a fixed token and records the vmid it was minted for.
|
||||
type mintMinter struct {
|
||||
token string
|
||||
vmids []int
|
||||
}
|
||||
|
||||
func (m *mintMinter) Mint(vmid int) (string, error) { m.vmids = append(m.vmids, vmid); return m.token, nil }
|
||||
|
||||
func testLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) }
|
||||
|
||||
func newInput() Input {
|
||||
return Input{
|
||||
VMID: 8200,
|
||||
Customer: DocCustomer{ID: "cust-8200", Domain: "cust8200.felhom.eu", Name: "Teszt"},
|
||||
Hub: DocHub{URL: "https://hub.felhom.eu", APIKey: "HUBKEY", HostID: "demo-felhom-01"},
|
||||
Endpoint: "192.168.0.162:8443",
|
||||
Fingerprint: "ab12cd",
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvision_WritesChownsAndAttaches(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
runner := &recRunner{}
|
||||
minter := &mintMinter{token: "SECRET-TOKEN-XYZ"}
|
||||
bh := NewBackHalf(minter, runner, dir, testLogger())
|
||||
|
||||
res, err := bh.Provision(context.Background(), newInput())
|
||||
if err != nil {
|
||||
t.Fatalf("provision: %v", err)
|
||||
}
|
||||
|
||||
// token minted for the right guest
|
||||
if len(minter.vmids) != 1 || minter.vmids[0] != 8200 {
|
||||
t.Fatalf("mint vmids: %v", minter.vmids)
|
||||
}
|
||||
|
||||
// bootstrap.json written 0600, contains the token + customer, valid contract
|
||||
bootPath := filepath.Join(res.HostDir, "bootstrap.json")
|
||||
info, err := os.Stat(bootPath)
|
||||
if err != nil {
|
||||
t.Fatalf("stat bootstrap: %v", err)
|
||||
}
|
||||
// Unix perms are not modeled on Windows; the 0600 is enforced on the Linux target (where the
|
||||
// agent runs). Assert only where the OS honors it.
|
||||
if runtime.GOOS != "windows" {
|
||||
if perm := info.Mode().Perm(); perm != 0o600 {
|
||||
t.Fatalf("bootstrap perms: got %o want 600", perm)
|
||||
}
|
||||
}
|
||||
raw, _ := os.ReadFile(bootPath)
|
||||
var doc Doc
|
||||
if err := json.Unmarshal(raw, &doc); err != nil {
|
||||
t.Fatalf("bootstrap not valid JSON: %v", err)
|
||||
}
|
||||
if doc.Schema != SchemaV1 || doc.Customer.ID != "cust-8200" || doc.LocalAPI.Token != "SECRET-TOKEN-XYZ" {
|
||||
t.Fatalf("bootstrap content wrong: %+v", doc)
|
||||
}
|
||||
if doc.LocalAPI.Endpoint != "192.168.0.162:8443" || doc.LocalAPI.Fingerprint != "ab12cd" {
|
||||
t.Fatalf("local_api wrong: %+v", doc.LocalAPI)
|
||||
}
|
||||
|
||||
// chown to the mapped guest-root ran on the host dir
|
||||
chown := runner.find("chown")
|
||||
if chown == nil || chown[1] != "-R" || chown[2] != "100000:100000" || chown[3] != res.HostDir {
|
||||
t.Fatalf("chown command wrong: %v", chown)
|
||||
}
|
||||
|
||||
// pct set attached the read-only bind mount at the default high slot
|
||||
pct := runner.find("pct")
|
||||
if pct == nil {
|
||||
t.Fatal("pct set not called")
|
||||
}
|
||||
joined := strings.Join(pct, " ")
|
||||
if !strings.Contains(joined, "set 8200 -mp9") || !strings.Contains(joined, res.HostDir+",mp=/etc/felhom-bootstrap,ro=1") {
|
||||
t.Fatalf("pct set command wrong: %v", pct)
|
||||
}
|
||||
if res.MountKey != "mp9" || res.GuestPath != "/etc/felhom-bootstrap" {
|
||||
t.Fatalf("result placement wrong: %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
// The Result must never carry the token, and the token must not appear in any field returned to
|
||||
// the caller (secret discipline — only the 0600 file + the store hash hold it).
|
||||
func TestProvision_ResultHasNoToken(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
bh := NewBackHalf(&mintMinter{token: "SECRET-TOKEN-XYZ"}, &recRunner{}, dir, testLogger())
|
||||
res, err := bh.Provision(context.Background(), newInput())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
blob, _ := json.Marshal(res)
|
||||
if strings.Contains(string(blob), "SECRET-TOKEN-XYZ") {
|
||||
t.Fatalf("token leaked into the Result: %s", blob)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvision_RejectsIncompleteInput(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
bh := NewBackHalf(&mintMinter{token: "t"}, &recRunner{}, dir, testLogger())
|
||||
bad := Input{VMID: 8200} // no endpoint/fingerprint/customer
|
||||
if _, err := bh.Provision(context.Background(), bad); err == nil {
|
||||
t.Fatal("expected an error for incomplete input")
|
||||
}
|
||||
}
|
||||
|
||||
// A failed chown surfaces an error (and does not proceed to attach).
|
||||
func TestProvision_ChownFailureStops(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
runner := &recRunner{fail: "chown"}
|
||||
bh := NewBackHalf(&mintMinter{token: "t"}, runner, dir, testLogger())
|
||||
if _, err := bh.Provision(context.Background(), newInput()); err == nil {
|
||||
t.Fatal("expected chown failure to surface")
|
||||
}
|
||||
if runner.find("pct") != nil {
|
||||
t.Fatal("pct set ran despite a chown failure")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// Package provision implements the slice-8A provisioning BACK HALF: after the slice-7 bring-up
|
||||
// front half (restore → identity → size → start), the agent mints a per-guest local-API token,
|
||||
// renders the stable bootstrap.json contract, and populates a read-only config mount the golden's
|
||||
// baked controller-bootstrap unit consumes (F3: host-side only, no pct exec).
|
||||
//
|
||||
// The agent NEVER enters the guest and NEVER puts a registry credential in the guest (the
|
||||
// controller image is baked into the golden — configs/build-golden.sh). The only secret written
|
||||
// into the guest is the per-guest local-API token, in the 0600 bootstrap.json on the config mount.
|
||||
package provision
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// SchemaV1 is the stable agent→controller contract version. It MUST stay byte-compatible with the
|
||||
// controller's internal/bootstrap.SchemaV1 / Bootstrap shape (cross-repo contract; doc_test.go
|
||||
// pins the key set, mirroring the controller's bootstrap_test.go).
|
||||
const SchemaV1 = "felhom.bootstrap/v1"
|
||||
|
||||
// Doc is the bootstrap.json the agent emits. Field names + json tags MUST match the controller's
|
||||
// internal/bootstrap.Bootstrap exactly. It carries ONLY what the controller needs to come up
|
||||
// configured and reach the agent's local API — no registry credential (image is baked).
|
||||
type Doc struct {
|
||||
Schema string `json:"schema"`
|
||||
Customer DocCustomer `json:"customer"`
|
||||
Hub DocHub `json:"hub"`
|
||||
LocalAPI DocLocalAPI `json:"local_api"`
|
||||
}
|
||||
|
||||
type DocCustomer struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Domain string `json:"domain"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
type DocHub struct {
|
||||
URL string `json:"url"`
|
||||
APIKey string `json:"api_key"`
|
||||
HostID string `json:"host_id"`
|
||||
}
|
||||
|
||||
type DocLocalAPI struct {
|
||||
Endpoint string `json:"endpoint"` // host bridge IP:port
|
||||
Fingerprint string `json:"fingerprint"` // agent leaf-cert SHA-256 (hex) to pin
|
||||
Token string `json:"token"` // per-guest bearer; SECRET — written 0600 only
|
||||
}
|
||||
|
||||
// render marshals the doc as indented JSON (the bytes written into the config mount).
|
||||
func (d Doc) render() ([]byte, error) {
|
||||
return json.MarshalIndent(d, "", " ")
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package provision
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The bootstrap.json key set is a CROSS-REPO contract: it must match felhom-controller's
|
||||
// internal/bootstrap.Bootstrap exactly. This test pins the emitted key set; the controller's
|
||||
// bootstrap_test.go ingests the same shape. A drift here (or there) breaks provisioning.
|
||||
func TestDoc_ContractKeySet(t *testing.T) {
|
||||
d := Doc{
|
||||
Schema: SchemaV1,
|
||||
Customer: DocCustomer{ID: "c", Name: "n", Domain: "d", Email: "e"},
|
||||
Hub: DocHub{URL: "u", APIKey: "k", HostID: "h"},
|
||||
LocalAPI: DocLocalAPI{Endpoint: "ep", Fingerprint: "fp", Token: "tok"},
|
||||
}
|
||||
b, err := d.render()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m map[string]json.RawMessage
|
||||
if err := json.Unmarshal(b, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertKeys(t, "top", m, []string{"schema", "customer", "hub", "local_api"})
|
||||
|
||||
var full struct {
|
||||
Customer map[string]json.RawMessage `json:"customer"`
|
||||
Hub map[string]json.RawMessage `json:"hub"`
|
||||
LocalAPI map[string]json.RawMessage `json:"local_api"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &full); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertKeys(t, "customer", full.Customer, []string{"id", "name", "domain", "email"})
|
||||
assertKeys(t, "hub", full.Hub, []string{"url", "api_key", "host_id"})
|
||||
assertKeys(t, "local_api", full.LocalAPI, []string{"endpoint", "fingerprint", "token"})
|
||||
|
||||
if SchemaV1 != "felhom.bootstrap/v1" {
|
||||
t.Fatalf("schema drift: %q", SchemaV1)
|
||||
}
|
||||
}
|
||||
|
||||
func assertKeys(t *testing.T, label string, m map[string]json.RawMessage, want []string) {
|
||||
t.Helper()
|
||||
got := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
got = append(got, k)
|
||||
}
|
||||
sort.Strings(got)
|
||||
sort.Strings(want)
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("%s: key set %v, want %v", label, got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("%s: key set %v, want %v", label, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user