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:
2026-06-10 09:47:42 +02:00
parent fae11020a5
commit 3fecf4c713
18 changed files with 2203 additions and 11 deletions
+74
View File
@@ -29,9 +29,83 @@ type Config struct {
Storage StorageConfig `json:"storage"`
Backup BackupConfig `json:"backup"`
Escrow EscrowConfig `json:"escrow"`
LocalAPI LocalAPIConfig `json:"local_api"`
LogLevel string `json:"log_level"` // debug|info|warn|error (default info)
}
// LocalAPIConfig configures the per-guest local API server (doc 03 §6, slice 8A). The
// controller (inside its LXC) reaches the agent over the local bridge; the agent is the
// per-guest authorization gate — it maps a per-guest bearer token → VMID and authorizes
// every call against THAT guest only (self-scoped; never a caller-supplied id). Disabled
// unless Enable is set AND ListenAddr is non-empty (so a host that doesn't yet provision
// controllers runs the daemon without it).
//
// Defense-in-depth (spike gotcha 5): ListenAddr should be the host's BRIDGE IP (not
// 0.0.0.0), and a host firewall rule should limit the port to the guest bridge subnet
// (configs/felhom-localapi-firewall.example). The per-guest token remains the gate; the
// bind + firewall narrow exposure but are not the authorization.
type LocalAPIConfig struct {
Enable bool `json:"enable"`
ListenAddr string `json:"listen_addr"` // bridge IP:port, e.g. "192.168.0.162:8443"
// CertFile/KeyFile hold the agent's self-signed leaf served to controllers. Generated
// (persisted) on first start if absent, so the leaf SHA-256 fingerprint — baked into each
// guest's bootstrap for pinning — is STABLE across agent restarts.
CertFile string `json:"cert_file"` // default <token_store_dir>/local-api.crt
KeyFile string `json:"key_file"` // default <token_store_dir>/local-api.key
// TokenStore is the durable, hashed token→guest map (only a HASH of each token is
// persisted; the plaintext exists transiently at mint→write-to-mount, then is discarded).
TokenStore string `json:"token_store"` // default /var/lib/felhom-agent/local-tokens.log
}
// Default local-API file locations (under the agent's state dir).
const (
defaultLocalAPITokenStore = "/var/lib/felhom-agent/local-tokens.log"
defaultLocalAPICert = "/var/lib/felhom-agent/local-api.crt"
defaultLocalAPIKey = "/var/lib/felhom-agent/local-api.key"
)
// Enabled reports whether the local-API server should run.
func (l LocalAPIConfig) Enabled() bool {
return l.Enable && strings.TrimSpace(l.ListenAddr) != ""
}
// TokenStorePath returns the configured token-store path (default applied).
func (l LocalAPIConfig) TokenStorePath() string {
if l.TokenStore != "" {
return l.TokenStore
}
return defaultLocalAPITokenStore
}
// CertPath/KeyPath return the configured leaf cert/key paths (defaults applied).
func (l LocalAPIConfig) CertPath() string {
if l.CertFile != "" {
return l.CertFile
}
return defaultLocalAPICert
}
func (l LocalAPIConfig) KeyPath() string {
if l.KeyFile != "" {
return l.KeyFile
}
return defaultLocalAPIKey
}
// Validate checks the local-API config is usable when enabled.
func (l LocalAPIConfig) Validate() error {
if !l.Enable {
return nil
}
if strings.TrimSpace(l.ListenAddr) == "" {
return fmt.Errorf("config: local_api.listen_addr is required when local_api.enable is set (use the host bridge IP:port, e.g. 192.168.0.162:8443)")
}
if _, _, err := net.SplitHostPort(l.ListenAddr); err != nil {
return fmt.Errorf("config: local_api.listen_addr %q is not host:port: %w", l.ListenAddr, err)
}
return nil
}
// EscrowConfig configures PBS recovery-code escrow creation (slice 7, doc 03 §8a). Enrollment-time
// only (not the steady-state daemon). The default posture is zero-knowledge (Felhom holds the
// opaque blob, the customer holds the recovery code).