Files
felhom-agent/internal/proxmox/errors.go
T
admin a042316d6d 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>
2026-06-08 14:34:32 +02:00

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
}