feat(agent): scaffold + proxmox interaction layer (slice 1)
Stand up the felhom-agent project (module gitea.dooplex.hu/admin/felhom-agent, binary felhom-agent) and the internal/proxmox package: the typed library every other agent module calls to talk to Proxmox. - API-first Client (hand-rolled REST over net/http, PVEAPIToken auth) with typed read ops (version/nodes/status/lxc/config/storage) and async mutating ops (restore/vzdump/snapshot/rollback/delete-snapshot/setconfig/start/stop), each returning a UPID. WaitTask polls task status until stopped and asserts exitstatus OK (authz can surface at task exec, not the POST — phase1-2 §1.3). - Fenced Privileged (root-CLI) backend for the THREE proven exceptions only (keyctl pct create, USB mount/fstab, SMART/sensors); each cites why it can't be the API. Fence is structural (Client never shells out, Privileged never HTTPs) and asserted in routing_test.go. - TLS: SHA-256 leaf-cert pinning or CA file; insecure mode explicit + off by default. No blanket verification disable. - 403 -> privilege-named APIError; failed task -> privilege-named TaskError. - JSON config + env overrides (token never logged); slog logging. - cmd/felhom-agent --selftest (read-only health report) + gated --selftest=task (reversible snapshot/rollback/delete exercise of WaitTask). No daemon loop yet. - Types grounded in the spike findings and exact JSON shapes captured live from demo-felhom (PVE 9.2.2). Unit tests use a mock transport + runner. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
// Package config loads the felhom-agent configuration the proxmox layer needs.
|
||||
//
|
||||
// Format: a JSON file (stdlib-only — no YAML dep, consistent with the agent's
|
||||
// "pure stdlib" constraint), with per-field environment overrides. Secrets (the
|
||||
// API token) are never logged; see Config.Redacted.
|
||||
//
|
||||
// OPEN item (noted in the slice reply): the controller/hub use YAML; if matching
|
||||
// that house style is preferred over the zero-dependency constraint, the loader
|
||||
// can swap to yaml.v3 without touching call sites.
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Config is the agent configuration. Only the fields the proxmox interaction
|
||||
// layer needs are present in this slice.
|
||||
type Config struct {
|
||||
Proxmox ProxmoxConfig `json:"proxmox"`
|
||||
Privileged PrivilegedConfig `json:"privileged"`
|
||||
LogLevel string `json:"log_level"` // debug|info|warn|error (default info)
|
||||
}
|
||||
|
||||
// ProxmoxConfig configures the API client.
|
||||
type ProxmoxConfig struct {
|
||||
// Endpoint defaults to https://127.0.0.1:8006 (agent runs on the host).
|
||||
Endpoint string `json:"endpoint"`
|
||||
// Node is the Proxmox node name; confirm on the box (GET /nodes).
|
||||
Node string `json:"node"`
|
||||
// Token is the full API token "USER@REALM!TOKENID=SECRET".
|
||||
//
|
||||
// Provisioning note: this is a privilege-SEPARATED token. Its role
|
||||
// (FelhomAgent, 16 privileges) must be granted on BOTH the user AND the token
|
||||
// for the same path, or the intersection is empty and every call 403s
|
||||
// (phase1-2 §1.2). Role setup is out-of-band; the agent only consumes the token.
|
||||
Token string `json:"token"`
|
||||
// TLS trust to the host's (self-signed) cert.
|
||||
TLS TLSTrust `json:"tls"`
|
||||
}
|
||||
|
||||
// TLSTrust mirrors proxmox.TLSConfig (kept dependency-free here).
|
||||
type TLSTrust struct {
|
||||
CAFile string `json:"ca_file"`
|
||||
Fingerprint string `json:"fingerprint"` // SHA-256 of the host leaf cert
|
||||
InsecureSkipVerify bool `json:"insecure_skip_verify"` // off by default; selftest-only
|
||||
}
|
||||
|
||||
// PrivilegedConfig configures the fenced root-CLI runner.
|
||||
type PrivilegedConfig struct {
|
||||
// Mode: "sudo" (default — non-root agent + narrow sudoers) or "direct".
|
||||
Mode string `json:"mode"`
|
||||
// SudoPath overrides the sudo binary (default "sudo").
|
||||
SudoPath string `json:"sudo_path"`
|
||||
}
|
||||
|
||||
// Default returns a Config pre-populated with sane defaults.
|
||||
func Default() Config {
|
||||
return Config{
|
||||
Proxmox: ProxmoxConfig{Endpoint: "https://127.0.0.1:8006"},
|
||||
Privileged: PrivilegedConfig{Mode: "sudo"},
|
||||
LogLevel: "info",
|
||||
}
|
||||
}
|
||||
|
||||
// Load reads the config file at path (if non-empty) over the defaults, then
|
||||
// applies environment overrides. A missing path with all-env config is allowed.
|
||||
func Load(path string) (Config, error) {
|
||||
cfg := Default()
|
||||
if path != "" {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return cfg, fmt.Errorf("config: reading %s: %w", path, err)
|
||||
}
|
||||
if err := json.Unmarshal(b, &cfg); err != nil {
|
||||
return cfg, fmt.Errorf("config: parsing %s: %w", path, err)
|
||||
}
|
||||
}
|
||||
applyEnv(&cfg)
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// applyEnv overlays FELHOM_AGENT_* environment variables. Useful for the token in
|
||||
// particular (keep the secret out of the file on disk if desired).
|
||||
func applyEnv(cfg *Config) {
|
||||
if v := os.Getenv("FELHOM_AGENT_PROXMOX_ENDPOINT"); v != "" {
|
||||
cfg.Proxmox.Endpoint = v
|
||||
}
|
||||
if v := os.Getenv("FELHOM_AGENT_PROXMOX_NODE"); v != "" {
|
||||
cfg.Proxmox.Node = v
|
||||
}
|
||||
if v := os.Getenv("FELHOM_AGENT_PROXMOX_TOKEN"); v != "" {
|
||||
cfg.Proxmox.Token = v
|
||||
}
|
||||
if v := os.Getenv("FELHOM_AGENT_PROXMOX_TLS_CA_FILE"); v != "" {
|
||||
cfg.Proxmox.TLS.CAFile = v
|
||||
}
|
||||
if v := os.Getenv("FELHOM_AGENT_PROXMOX_TLS_FINGERPRINT"); v != "" {
|
||||
cfg.Proxmox.TLS.Fingerprint = v
|
||||
}
|
||||
if v := os.Getenv("FELHOM_AGENT_PROXMOX_TLS_INSECURE"); v != "" {
|
||||
if b, err := strconv.ParseBool(v); err == nil {
|
||||
cfg.Proxmox.TLS.InsecureSkipVerify = b
|
||||
}
|
||||
}
|
||||
if v := os.Getenv("FELHOM_AGENT_LOG_LEVEL"); v != "" {
|
||||
cfg.LogLevel = v
|
||||
}
|
||||
}
|
||||
|
||||
// Validate checks the config is usable for talking to the API.
|
||||
func (c Config) Validate() error {
|
||||
if c.Proxmox.Endpoint == "" {
|
||||
return fmt.Errorf("config: proxmox.endpoint is required")
|
||||
}
|
||||
if c.Proxmox.Node == "" {
|
||||
return fmt.Errorf("config: proxmox.node is required (confirm with `pvesh get /nodes`)")
|
||||
}
|
||||
if c.Proxmox.Token == "" {
|
||||
return fmt.Errorf("config: proxmox.token is required (set proxmox.token or FELHOM_AGENT_PROXMOX_TOKEN)")
|
||||
}
|
||||
if !strings.Contains(c.Proxmox.Token, "!") || !strings.Contains(c.Proxmox.Token, "=") {
|
||||
return fmt.Errorf("config: proxmox.token must be USER@REALM!TOKENID=SECRET")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Redacted returns a copy safe to log: the token secret is masked.
|
||||
func (c Config) Redacted() Config {
|
||||
if c.Proxmox.Token != "" {
|
||||
c.Proxmox.Token = redactToken(c.Proxmox.Token)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// redactToken keeps the public "USER@REALM!TOKENID=" prefix and masks the secret.
|
||||
func redactToken(tok string) string {
|
||||
if i := strings.LastIndex(tok, "="); i >= 0 {
|
||||
return tok[:i+1] + "********"
|
||||
}
|
||||
return "********"
|
||||
}
|
||||
Reference in New Issue
Block a user