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,154 @@
|
||||
package proxmox
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// doer is the minimal HTTP surface the client needs; *http.Client satisfies it.
|
||||
// Tests inject a mock to exercise decoding/error paths without a live host.
|
||||
type doer interface {
|
||||
Do(*http.Request) (*http.Response, error)
|
||||
}
|
||||
|
||||
// Config configures a Client (the API backend).
|
||||
type Config struct {
|
||||
// Endpoint is the API base, e.g. "https://127.0.0.1:8006". The "/api2/json"
|
||||
// suffix is added by the client.
|
||||
Endpoint string
|
||||
// Node is the Proxmox node name (e.g. "demo-felhom"). Confirm on the box
|
||||
// (GET /nodes), never hard-code — see proxmox-platform.md §1.
|
||||
Node string
|
||||
// Token is the full API token "USER@REALM!TOKENID=SECRET". Never logged.
|
||||
Token string
|
||||
// TLS selects how the host cert is trusted.
|
||||
TLS TLSConfig
|
||||
// HTTPTimeout bounds a single HTTP round-trip (not a whole task wait).
|
||||
// Defaults to 30s.
|
||||
HTTPTimeout time.Duration
|
||||
}
|
||||
|
||||
// Client is the API backend: a typed REST client for one Proxmox host. It is the
|
||||
// default path for everything the scoped token can do. It never shells out.
|
||||
type Client struct {
|
||||
base string // "<endpoint>/api2/json"
|
||||
node string
|
||||
token string
|
||||
http doer
|
||||
}
|
||||
|
||||
// NewClient builds an API client. It validates required config and constructs the
|
||||
// TLS-pinned transport.
|
||||
func NewClient(cfg Config) (*Client, error) {
|
||||
if cfg.Endpoint == "" {
|
||||
return nil, fmt.Errorf("proxmox: endpoint is required")
|
||||
}
|
||||
if cfg.Node == "" {
|
||||
return nil, fmt.Errorf("proxmox: node is required")
|
||||
}
|
||||
if cfg.Token == "" {
|
||||
return nil, fmt.Errorf("proxmox: API token is required")
|
||||
}
|
||||
tlsCfg, err := cfg.TLS.build()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
timeout := cfg.HTTPTimeout
|
||||
if timeout == 0 {
|
||||
timeout = 30 * time.Second
|
||||
}
|
||||
hc := &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: &http.Transport{TLSClientConfig: tlsCfg},
|
||||
}
|
||||
return &Client{
|
||||
base: strings.TrimRight(cfg.Endpoint, "/") + "/api2/json",
|
||||
node: cfg.Node,
|
||||
token: cfg.Token,
|
||||
http: hc,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Node returns the configured node name.
|
||||
func (c *Client) Node() string { return c.node }
|
||||
|
||||
// get performs GET <path> and decodes the {"data": ...} envelope into out.
|
||||
func (c *Client) get(ctx context.Context, path string, out any) error {
|
||||
return c.do(ctx, http.MethodGet, path, nil, out)
|
||||
}
|
||||
|
||||
// postForm performs a form-encoded POST/PUT and decodes the envelope into out.
|
||||
// out may be nil when the caller does not need the body.
|
||||
func (c *Client) postForm(ctx context.Context, method, path string, params url.Values, out any) error {
|
||||
var body io.Reader
|
||||
if params != nil {
|
||||
body = strings.NewReader(params.Encode())
|
||||
}
|
||||
return c.doBody(ctx, method, path, body, "application/x-www-form-urlencoded", out)
|
||||
}
|
||||
|
||||
func (c *Client) do(ctx context.Context, method, path string, body io.Reader, out any) error {
|
||||
return c.doBody(ctx, method, path, body, "", out)
|
||||
}
|
||||
|
||||
// doBody is the single HTTP chokepoint: builds the request, sets auth, executes,
|
||||
// maps non-2xx to APIError, and decodes the data envelope.
|
||||
func (c *Client) doBody(ctx context.Context, method, path string, body io.Reader, contentType string, out any) error {
|
||||
req, err := http.NewRequestWithContext(ctx, method, c.base+path, body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("proxmox: building request: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "PVEAPIToken="+c.token)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if contentType != "" {
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
}
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("proxmox: %s %s: %w", method, path, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("proxmox: reading %s %s response: %w", method, path, err)
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return newAPIError(resp.StatusCode, method, path, string(raw))
|
||||
}
|
||||
if out == nil {
|
||||
return nil
|
||||
}
|
||||
var env struct {
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &env); err != nil {
|
||||
return fmt.Errorf("proxmox: decoding %s %s envelope: %w", method, path, err)
|
||||
}
|
||||
if len(env.Data) == 0 || bytes.Equal(env.Data, []byte("null")) {
|
||||
return nil // no data (e.g. a sync PUT /config)
|
||||
}
|
||||
if err := json.Unmarshal(env.Data, out); err != nil {
|
||||
return fmt.Errorf("proxmox: decoding %s %s data: %w", method, path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// dataString runs a request expecting the "data" field to be a bare string
|
||||
// (the UPID returned by async mutating ops). Returns "" with no error when the
|
||||
// response carries no data (some sync ops).
|
||||
func (c *Client) dataString(ctx context.Context, method, path string, params url.Values) (string, error) {
|
||||
var s string
|
||||
if err := c.postForm(ctx, method, path, params, &s); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
Reference in New Issue
Block a user