a042316d6d
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>
82 lines
2.7 KiB
Go
82 lines
2.7 KiB
Go
package proxmox
|
|
|
|
import (
|
|
"fmt"
|
|
"regexp"
|
|
)
|
|
|
|
// permRe extracts the offending privilege (and path) from a Proxmox permission
|
|
// message, e.g. "Permission check failed (/vms/9000, VM.Backup)" or
|
|
// "403 Permission check failed (/sdn/zones/localnetwork/vmbr0, SDN.Use)".
|
|
var permRe = regexp.MustCompile(`Permission check failed \(([^,]+),\s*([^)]+)\)`)
|
|
|
|
// APIError is returned for a non-2xx HTTP response from the Proxmox API. On a 403
|
|
// it parses the offending path + privilege so a role misconfiguration is
|
|
// diagnosable (the FelhomAgent role is exactly 16 privileges — see doc.go).
|
|
type APIError struct {
|
|
StatusCode int
|
|
Method string
|
|
Path string // request path
|
|
Body string // response body (trimmed)
|
|
// Populated from a permission-check message when present:
|
|
DeniedPath string // ACL path, e.g. "/vms/9000"
|
|
Privilege string // e.g. "VM.Backup"
|
|
}
|
|
|
|
func (e *APIError) Error() string {
|
|
if e.Privilege != "" {
|
|
return fmt.Sprintf("proxmox: %s %s -> HTTP %d: permission denied at %s (missing privilege %s)",
|
|
e.Method, e.Path, e.StatusCode, e.DeniedPath, e.Privilege)
|
|
}
|
|
return fmt.Sprintf("proxmox: %s %s -> HTTP %d: %s", e.Method, e.Path, e.StatusCode, e.Body)
|
|
}
|
|
|
|
// IsForbidden reports whether this was an HTTP 403.
|
|
func (e *APIError) IsForbidden() bool { return e.StatusCode == 403 }
|
|
|
|
// newAPIError builds an APIError, extracting privilege info from the body.
|
|
func newAPIError(statusCode int, method, path, body string) *APIError {
|
|
e := &APIError{StatusCode: statusCode, Method: method, Path: path, Body: trimBody(body)}
|
|
if m := permRe.FindStringSubmatch(body); m != nil {
|
|
e.DeniedPath = m[1]
|
|
e.Privilege = m[2]
|
|
}
|
|
return e
|
|
}
|
|
|
|
// TaskError is returned by WaitTask when a task stops with a non-OK exitstatus.
|
|
// The authorization failure for a mutating op surfaces here (in the task
|
|
// exitstatus), not at the HTTP POST — so callers must always WaitTask.
|
|
type TaskError struct {
|
|
UPID string
|
|
ExitStatus string // e.g. "403 Permission check failed (/vms/9000, VM.Backup)"
|
|
LogTail []string // last lines of the task log, for diagnosis
|
|
DeniedPath string
|
|
Privilege string
|
|
}
|
|
|
|
func (e *TaskError) Error() string {
|
|
if e.Privilege != "" {
|
|
return fmt.Sprintf("proxmox: task %s failed: permission denied at %s (missing privilege %s)",
|
|
e.UPID, e.DeniedPath, e.Privilege)
|
|
}
|
|
return fmt.Sprintf("proxmox: task %s failed: exitstatus %q", e.UPID, e.ExitStatus)
|
|
}
|
|
|
|
func newTaskError(upid, exitStatus string, logTail []string) *TaskError {
|
|
e := &TaskError{UPID: upid, ExitStatus: exitStatus, LogTail: logTail}
|
|
if m := permRe.FindStringSubmatch(exitStatus); m != nil {
|
|
e.DeniedPath = m[1]
|
|
e.Privilege = m[2]
|
|
}
|
|
return e
|
|
}
|
|
|
|
func trimBody(s string) string {
|
|
const max = 512
|
|
if len(s) > max {
|
|
return s[:max] + "…"
|
|
}
|
|
return s
|
|
}
|