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>
89 lines
3.1 KiB
Go
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
|
|
}
|