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 }