Files
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

89 lines
3.1 KiB
Go

package proxmox
import (
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"encoding/hex"
"fmt"
"os"
"strings"
)
// TLSConfig describes how the client trusts the Proxmox host's certificate. The
// host serves a self-signed cert by default (proxmox-platform.md §3.1); we do NOT
// blanket-disable verification. Pick exactly one trust mechanism:
//
// - CAFile: path to a PEM bundle (the PVE CA / a real cert chain) — full verify.
// - Fingerprint: SHA-256 of the leaf cert (hex, colons optional). Verification is
// pinned to that exact cert — strong trust for a self-signed host without a CA.
// The /nodes API returns each node's ssl_fingerprint, which is what to pin.
// - InsecureSkipVerify: explicitly off by default. Only acceptable for a
// --selftest against 127.0.0.1; it is named honestly, not hidden behind a flag
// that sounds benign.
//
// If none is set, standard system verification applies (which will fail on a
// self-signed host — that is the safe default; the operator must pin).
type TLSConfig struct {
CAFile string
Fingerprint string
InsecureSkipVerify bool
}
func (t TLSConfig) build() (*tls.Config, error) {
switch {
case t.InsecureSkipVerify:
// Caller opted in explicitly and by an honestly-named field.
return &tls.Config{InsecureSkipVerify: true}, nil //nolint:gosec // documented, config-gated, off by default
case t.Fingerprint != "":
want, err := normalizeFingerprint(t.Fingerprint)
if err != nil {
return nil, err
}
// Pin to the leaf cert's SHA-256. We disable the default chain check (a
// self-signed cert has no CA) but enforce an exact-cert match instead, so
// this is pinning, not "skip verify".
return &tls.Config{
InsecureSkipVerify: true, //nolint:gosec // replaced by the pin check below
VerifyPeerCertificate: func(rawCerts [][]byte, _ [][]*x509.Certificate) error {
if len(rawCerts) == 0 {
return fmt.Errorf("proxmox: TLS pin: peer presented no certificate")
}
got := sha256.Sum256(rawCerts[0])
if hex.EncodeToString(got[:]) != want {
return fmt.Errorf("proxmox: TLS pin mismatch: server cert sha256 does not match configured fingerprint")
}
return nil
},
}, nil
case t.CAFile != "":
pem, err := os.ReadFile(t.CAFile)
if err != nil {
return nil, fmt.Errorf("proxmox: reading TLS CA file: %w", err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(pem) {
return nil, fmt.Errorf("proxmox: TLS CA file %q contained no usable certificates", t.CAFile)
}
return &tls.Config{RootCAs: pool}, nil
default:
return &tls.Config{}, nil // system roots; safe default
}
}
// normalizeFingerprint lowercases and strips colons/whitespace, validating that
// the result is a 64-char (32-byte) hex SHA-256.
func normalizeFingerprint(fp string) (string, error) {
s := strings.ToLower(strings.NewReplacer(":", "", " ", "", "\t", "").Replace(fp))
if len(s) != 64 {
return "", fmt.Errorf("proxmox: fingerprint must be a SHA-256 (64 hex chars), got %d", len(s))
}
if _, err := hex.DecodeString(s); err != nil {
return "", fmt.Errorf("proxmox: fingerprint is not valid hex: %w", err)
}
return s, nil
}