Pulse/pkg/proxmox/client.go
Pulse Monitor 378ebcb250 Major improvements to security, alerts, and ease of use
Security enhancements:
- Fixed critical issue: PBS tokens no longer logged in plaintext
- PVE tokens now properly masked in all log outputs
- Enhanced token security documentation

Alert system fixes:
- Fixed storage alerts not working due to threshold being 0
- Added automatic defaults preservation for alert thresholds
- Storage alerts now properly trigger at 85% usage

Node management improvements:
- Fixed node deletion causing 'Node not found' errors
- Added instant discovery refresh when nodes are deleted
- Added manual refresh buttons for discovery
- Fixed PBS token cleanup in auto-registration scripts
- Fixed /dev/tty errors when running scripts in Docker containers

Bug fixes:
- Fixed CPU MHz field type mismatch causing JSON unmarshal errors
- Suppressed non-critical container snapshot API errors
- Fixed auto-registration using Docker internal IPs instead of actual host IPs

Documentation updates:
- Added comprehensive security documentation
- Streamlined setup documentation focusing on ease of use
- Removed marketing language and consolidated repetitive content

Frontend improvements:
- Added WebSocket support for real-time node updates
- Added discovery refresh buttons in Settings
- Improved node deletion feedback
2025-08-11 13:59:58 +00:00

831 lines
No EOL
21 KiB
Go

package proxmox
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/rcourtman/pulse-go-rewrite/pkg/tlsutil"
"github.com/rs/zerolog/log"
)
// FlexInt handles JSON fields that can be int, float, or string (for cpulimit support)
type FlexInt int
func (f *FlexInt) UnmarshalJSON(data []byte) error {
// Try to unmarshal as int first
var i int
if err := json.Unmarshal(data, &i); err == nil {
*f = FlexInt(i)
return nil
}
// Try as float (handles cpulimit like 1.5)
var fl float64
if err := json.Unmarshal(data, &fl); err == nil {
*f = FlexInt(int(fl))
return nil
}
// If that fails, try as string
var s string
if err := json.Unmarshal(data, &s); err != nil {
return err
}
// Parse string to float first (handles "1.5" format from cpulimit)
floatVal, err := strconv.ParseFloat(s, 64)
if err != nil {
return err
}
// Convert to int
*f = FlexInt(int(floatVal))
return nil
}
// Client represents a Proxmox VE API client
type Client struct {
baseURL string
httpClient *http.Client
auth auth
config ClientConfig
}
// ClientConfig holds configuration for the Proxmox client
type ClientConfig struct {
Host string
User string
Password string
TokenName string
TokenValue string
Fingerprint string
VerifySSL bool
Timeout time.Duration
}
// auth represents authentication details
type auth struct {
user string
realm string
ticket string
csrfToken string
tokenName string
tokenValue string
expiresAt time.Time
}
// NewClient creates a new Proxmox VE API client
func NewClient(cfg ClientConfig) (*Client, error) {
var user, realm string
// For token authentication, we don't need user@realm format
if cfg.TokenName != "" && cfg.TokenValue != "" {
// Extract user and realm from token name (format: user@realm!tokenname)
if strings.Contains(cfg.TokenName, "@") && strings.Contains(cfg.TokenName, "!") {
parts := strings.Split(cfg.TokenName, "!")
if len(parts) == 2 {
userRealm := parts[0]
userRealmParts := strings.Split(userRealm, "@")
if len(userRealmParts) == 2 {
user = userRealmParts[0]
realm = userRealmParts[1]
}
}
}
} else {
// For password authentication, parse user and realm from User field
parts := strings.Split(cfg.User, "@")
if len(parts) != 2 {
return nil, fmt.Errorf("invalid user format, expected user@realm")
}
user = parts[0]
realm = parts[1]
}
// Create HTTP client with proper TLS configuration
httpClient := tlsutil.CreateHTTPClient(cfg.VerifySSL, cfg.Fingerprint)
// Override timeout if specified
if cfg.Timeout > 0 {
httpClient.Timeout = cfg.Timeout
}
// Extract just the token name part for API token authentication
tokenName := cfg.TokenName
if cfg.TokenName != "" && strings.Contains(cfg.TokenName, "!") {
parts := strings.Split(cfg.TokenName, "!")
if len(parts) == 2 {
tokenName = parts[1] // Just the token name part (e.g., "pulse-token")
}
}
log.Info().
Str("user", user).
Str("realm", realm).
Str("tokenName", tokenName).
Str("originalTokenName", cfg.TokenName).
Bool("hasTokenValue", cfg.TokenValue != "").
Msg("Parsed authentication details")
client := &Client{
baseURL: strings.TrimSuffix(cfg.Host, "/") + "/api2/json",
httpClient: httpClient,
config: cfg,
auth: auth{
user: user,
realm: realm,
tokenName: tokenName,
tokenValue: cfg.TokenValue,
},
}
// Authenticate if using password
if cfg.Password != "" && cfg.TokenName == "" {
if err := client.authenticate(context.Background()); err != nil {
return nil, fmt.Errorf("authentication failed: %w", err)
}
}
return client, nil
}
// authenticate performs password-based authentication
func (c *Client) authenticate(ctx context.Context) error {
data := url.Values{
"username": {c.auth.user + "@" + c.auth.realm},
"password": {c.config.Password},
}
req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/access/ticket", strings.NewReader(data.Encode()))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode == 401 || resp.StatusCode == 403 {
return fmt.Errorf("authentication failed (status %d): %s", resp.StatusCode, string(body))
}
return fmt.Errorf("authentication failed: %s", string(body))
}
var result struct {
Data struct {
Ticket string `json:"ticket"`
CSRFPreventionToken string `json:"CSRFPreventionToken"`
} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return err
}
c.auth.ticket = result.Data.Ticket
c.auth.csrfToken = result.Data.CSRFPreventionToken
c.auth.expiresAt = time.Now().Add(2 * time.Hour) // PVE tickets expire after 2 hours
return nil
}
// request performs an API request
func (c *Client) request(ctx context.Context, method, path string, data url.Values) (*http.Response, error) {
// Re-authenticate if needed
if c.config.Password != "" && c.auth.tokenName == "" && time.Now().After(c.auth.expiresAt) {
if err := c.authenticate(ctx); err != nil {
return nil, fmt.Errorf("re-authentication failed: %w", err)
}
}
var body io.Reader
if data != nil {
body = strings.NewReader(data.Encode())
}
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, body)
if err != nil {
return nil, err
}
// Set headers
if data != nil {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
// Set authentication
if c.auth.tokenName != "" && c.auth.tokenValue != "" {
// API token authentication
authHeader := fmt.Sprintf("PVEAPIToken=%s@%s!%s=%s",
c.auth.user, c.auth.realm, c.auth.tokenName, c.auth.tokenValue)
req.Header.Set("Authorization", authHeader)
// NEVER log the actual token value - only log that we're using token auth
maskedHeader := fmt.Sprintf("PVEAPIToken=%s@%s!%s=***",
c.auth.user, c.auth.realm, c.auth.tokenName)
log.Debug().
Str("authHeader", maskedHeader).
Str("url", req.URL.String()).
Msg("Setting API token authentication")
} else if c.auth.ticket != "" {
// Ticket authentication
req.Header.Set("Cookie", "PVEAuthCookie="+c.auth.ticket)
if method != "GET" && c.auth.csrfToken != "" {
req.Header.Set("CSRFPreventionToken", c.auth.csrfToken)
}
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
// Check for errors
if resp.StatusCode >= 400 {
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
// Create base error
err := fmt.Errorf("API error %d: %s", resp.StatusCode, string(body))
// Wrap with appropriate error type
if resp.StatusCode == 401 || resp.StatusCode == 403 {
// Import errors package at top of file
return nil, fmt.Errorf("authentication error: %w", err)
}
return nil, err
}
return resp, nil
}
// get performs a GET request
func (c *Client) get(ctx context.Context, path string) (*http.Response, error) {
return c.request(ctx, "GET", path, nil)
}
// post performs a POST request
func (c *Client) post(ctx context.Context, path string, data url.Values) (*http.Response, error) {
return c.request(ctx, "POST", path, data)
}
// Node represents a Proxmox VE node
type Node struct {
Node string `json:"node"`
Status string `json:"status"`
CPU float64 `json:"cpu"`
MaxCPU int `json:"maxcpu"`
Mem uint64 `json:"mem"`
MaxMem uint64 `json:"maxmem"`
Disk uint64 `json:"disk"`
MaxDisk uint64 `json:"maxdisk"`
Uptime uint64 `json:"uptime"`
Level string `json:"level"`
}
// NodeStatus represents detailed node status
type NodeStatus struct {
LoadAvg []interface{} `json:"loadavg"` // Can be float64 or string
KernelVersion string `json:"kversion"`
PVEVersion string `json:"pveversion"`
CPUInfo *CPUInfo `json:"cpuinfo"`
RootFS *RootFS `json:"rootfs"`
}
// RootFS represents root filesystem information
type RootFS struct {
Total uint64 `json:"total"`
Used uint64 `json:"used"`
Free uint64 `json:"avail"`
}
// CPUInfo represents CPU information
type CPUInfo struct {
Model string `json:"model"`
Cores int `json:"cores"`
Sockets int `json:"sockets"`
MHz interface{} `json:"mhz"` // Can be string or number
}
// GetMHzString returns MHz as a string
func (c *CPUInfo) GetMHzString() string {
if c.MHz == nil {
return ""
}
switch v := c.MHz.(type) {
case string:
return v
case float64:
return fmt.Sprintf("%.0f", v)
case int:
return fmt.Sprintf("%d", v)
default:
return fmt.Sprintf("%v", v)
}
}
// GetNodes returns all nodes in the cluster
func (c *Client) GetNodes(ctx context.Context) ([]Node, error) {
resp, err := c.get(ctx, "/nodes")
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result struct {
Data []Node `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return result.Data, nil
}
// GetNodeStatus returns detailed status for a specific node
func (c *Client) GetNodeStatus(ctx context.Context, node string) (*NodeStatus, error) {
resp, err := c.get(ctx, fmt.Sprintf("/nodes/%s/status", node))
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result struct {
Data NodeStatus `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return &result.Data, nil
}
// VM represents a Proxmox VE virtual machine
type VM struct {
VMID int `json:"vmid"`
Name string `json:"name"`
Node string `json:"node"`
Status string `json:"status"`
CPU float64 `json:"cpu"`
CPUs int `json:"cpus"`
Mem uint64 `json:"mem"`
MaxMem uint64 `json:"maxmem"`
Disk uint64 `json:"disk"`
MaxDisk uint64 `json:"maxdisk"`
NetIn uint64 `json:"netin"`
NetOut uint64 `json:"netout"`
DiskRead uint64 `json:"diskread"`
DiskWrite uint64 `json:"diskwrite"`
Uptime uint64 `json:"uptime"`
Template int `json:"template"`
Tags string `json:"tags"`
Lock string `json:"lock"`
}
// Container represents a Proxmox VE LXC container
type Container struct {
VMID int `json:"vmid"`
Name string `json:"name"`
Node string `json:"node"`
Status string `json:"status"`
CPU float64 `json:"cpu"`
CPUs FlexInt `json:"cpus"`
Mem uint64 `json:"mem"`
MaxMem uint64 `json:"maxmem"`
Swap uint64 `json:"swap"`
MaxSwap uint64 `json:"maxswap"`
Disk uint64 `json:"disk"`
MaxDisk uint64 `json:"maxdisk"`
NetIn uint64 `json:"netin"`
NetOut uint64 `json:"netout"`
DiskRead uint64 `json:"diskread"`
DiskWrite uint64 `json:"diskwrite"`
Uptime uint64 `json:"uptime"`
Template int `json:"template"`
Tags string `json:"tags"`
Lock string `json:"lock"`
}
// Storage represents a Proxmox VE storage
type Storage struct {
Storage string `json:"storage"`
Type string `json:"type"`
Content string `json:"content"`
Active int `json:"active"`
Enabled int `json:"enabled"`
Shared int `json:"shared"`
Total uint64 `json:"total"`
Used uint64 `json:"used"`
Available uint64 `json:"avail"`
}
// StorageContent represents content in a storage
type StorageContent struct {
Volid string `json:"volid"`
Content string `json:"content"`
CTime int64 `json:"ctime"`
Format string `json:"format"`
Size uint64 `json:"size"`
VMID int `json:"vmid"`
Notes string `json:"notes"`
Protected int `json:"protected"`
Encryption string `json:"encryption"`
Verification map[string]interface{} `json:"verification"` // PBS verification info
Verified int `json:"verified"` // Simple verified flag
}
// Snapshot represents a VM or container snapshot
type Snapshot struct {
Name string `json:"name"`
Description string `json:"description"`
SnapTime int64 `json:"snaptime"`
Parent string `json:"parent"`
VMID int `json:"vmid"`
}
// GetVMs returns all VMs on a specific node
func (c *Client) GetVMs(ctx context.Context, node string) ([]VM, error) {
resp, err := c.get(ctx, fmt.Sprintf("/nodes/%s/qemu", node))
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result struct {
Data []VM `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return result.Data, nil
}
// GetContainers returns all LXC containers on a specific node
func (c *Client) GetContainers(ctx context.Context, node string) ([]Container, error) {
resp, err := c.get(ctx, fmt.Sprintf("/nodes/%s/lxc", node))
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result struct {
Data []Container `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return result.Data, nil
}
// GetStorage returns storage information for a specific node
func (c *Client) GetStorage(ctx context.Context, node string) ([]Storage, error) {
resp, err := c.get(ctx, fmt.Sprintf("/nodes/%s/storage", node))
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result struct {
Data []Storage `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return result.Data, nil
}
// GetAllStorage returns storage information across all nodes
func (c *Client) GetAllStorage(ctx context.Context) ([]Storage, error) {
resp, err := c.get(ctx, "/storage")
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result struct {
Data []Storage `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return result.Data, nil
}
// Task represents a Proxmox task
type Task struct {
UPID string `json:"upid"`
Node string `json:"node"`
PID int `json:"pid"`
PStart int64 `json:"pstart"`
StartTime int64 `json:"starttime"`
Type string `json:"type"`
ID string `json:"id"`
User string `json:"user"`
Status string `json:"status,omitempty"`
EndTime int64 `json:"endtime,omitempty"`
}
// GetNodeTasks gets tasks for a specific node
func (c *Client) GetNodeTasks(ctx context.Context, node string) ([]Task, error) {
resp, err := c.get(ctx, fmt.Sprintf("/nodes/%s/tasks", node))
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result struct {
Data []Task `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return result.Data, nil
}
// GetBackupTasks gets all backup tasks across all nodes
func (c *Client) GetBackupTasks(ctx context.Context) ([]Task, error) {
// First get all nodes
nodes, err := c.GetNodes(ctx)
if err != nil {
return nil, err
}
var allTasks []Task
for _, node := range nodes {
if node.Status != "online" {
continue
}
tasks, err := c.GetNodeTasks(ctx, node.Node)
if err != nil {
// Log error but continue with other nodes
continue
}
// Filter for backup tasks
for _, task := range tasks {
if task.Type == "vzdump" {
allTasks = append(allTasks, task)
}
}
}
return allTasks, nil
}
// GetStorageContent returns the content of a specific storage
func (c *Client) GetStorageContent(ctx context.Context, node, storage string) ([]StorageContent, error) {
resp, err := c.get(ctx, fmt.Sprintf("/nodes/%s/storage/%s/content", node, storage))
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result struct {
Data []StorageContent `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
// Filter for backup content only
var backups []StorageContent
for _, content := range result.Data {
if content.Content == "backup" || content.Content == "vztmpl" {
backups = append(backups, content)
}
}
return backups, nil
}
// GetVMSnapshots returns snapshots for a specific VM
func (c *Client) GetVMSnapshots(ctx context.Context, node string, vmid int) ([]Snapshot, error) {
resp, err := c.get(ctx, fmt.Sprintf("/nodes/%s/qemu/%d/snapshot", node, vmid))
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result struct {
Data []Snapshot `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
// Filter out the 'current' snapshot which is not a real snapshot
var snapshots []Snapshot
for _, snap := range result.Data {
if snap.Name != "current" {
snap.VMID = vmid
snapshots = append(snapshots, snap)
}
}
return snapshots, nil
}
// GetContainerSnapshots returns snapshots for a specific container
func (c *Client) GetContainerSnapshots(ctx context.Context, node string, vmid int) ([]Snapshot, error) {
resp, err := c.get(ctx, fmt.Sprintf("/nodes/%s/lxc/%d/snapshot", node, vmid))
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result struct {
Data []Snapshot `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
// Filter out the 'current' snapshot which is not a real snapshot
var snapshots []Snapshot
for _, snap := range result.Data {
if snap.Name != "current" {
snap.VMID = vmid
snapshots = append(snapshots, snap)
}
}
return snapshots, nil
}
// ClusterStatus represents the cluster status response
type ClusterStatus struct {
Type string `json:"type"` // "cluster" or "node"
ID string `json:"id"` // Node ID or cluster name
Name string `json:"name"` // Node name
IP string `json:"ip"` // Node IP address
Local int `json:"local"` // 1 if this is the local node
Nodeid int `json:"nodeid"` // Node ID in cluster
Online int `json:"online"` // 1 if online
Level string `json:"level"` // Connection level
Quorate int `json:"quorate"` // 1 if cluster has quorum
}
// GetClusterStatus returns the cluster status including all nodes
func (c *Client) GetClusterStatus(ctx context.Context) ([]ClusterStatus, error) {
resp, err := c.get(ctx, "/cluster/status")
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result struct {
Data []ClusterStatus `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return result.Data, nil
}
// IsClusterMember checks if this node is part of a cluster
func (c *Client) IsClusterMember(ctx context.Context) (bool, error) {
status, err := c.GetClusterStatus(ctx)
if err != nil {
return false, err
}
// If we have more than one node entry, it's a cluster
nodeCount := 0
for _, s := range status {
if s.Type == "node" {
nodeCount++
}
}
return nodeCount > 1, nil
}
// GetClusterNodes returns all nodes in the cluster with their connection info
func (c *Client) GetClusterNodes(ctx context.Context) ([]ClusterStatus, error) {
status, err := c.GetClusterStatus(ctx)
if err != nil {
return nil, err
}
var nodes []ClusterStatus
for _, s := range status {
if s.Type == "node" {
nodes = append(nodes, s)
}
}
return nodes, nil
}
// GetVMConfig returns the configuration for a specific VM
func (c *Client) GetVMConfig(ctx context.Context, node string, vmid int) (map[string]interface{}, error) {
resp, err := c.get(ctx, fmt.Sprintf("/nodes/%s/qemu/%d/config", node, vmid))
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result struct {
Data map[string]interface{} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return result.Data, nil
}
// GetVMAgentInfo returns guest agent information for a VM if available
func (c *Client) GetVMAgentInfo(ctx context.Context, node string, vmid int) (map[string]interface{}, error) {
resp, err := c.get(ctx, fmt.Sprintf("/nodes/%s/qemu/%d/agent/get-osinfo", node, vmid))
if err != nil {
// Guest agent might not be installed or running
return nil, err
}
defer resp.Body.Close()
var result struct {
Data map[string]interface{} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return result.Data, nil
}
// GetVMStatus returns detailed VM status including balloon info
func (c *Client) GetVMStatus(ctx context.Context, node string, vmid int) (*VMStatus, error) {
resp, err := c.get(ctx, fmt.Sprintf("/nodes/%s/qemu/%d/status/current", node, vmid))
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result struct {
Data VMStatus `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return &result.Data, nil
}
// VMStatus represents detailed VM status
type VMStatus struct {
Status string `json:"status"`
CPU float64 `json:"cpu"`
CPUs int `json:"cpus"`
Mem uint64 `json:"mem"`
MaxMem uint64 `json:"maxmem"`
Balloon uint64 `json:"balloon"`
BalloonMin uint64 `json:"balloon_min"`
FreeMem uint64 `json:"freemem"`
MemInfo *struct {
Used uint64 `json:"used"`
Free uint64 `json:"free"`
Total uint64 `json:"total"`
} `json:"meminfo,omitempty"`
Disk uint64 `json:"disk"`
MaxDisk uint64 `json:"maxdisk"`
DiskRead uint64 `json:"diskread"`
DiskWrite uint64 `json:"diskwrite"`
NetIn uint64 `json:"netin"`
NetOut uint64 `json:"netout"`
Uptime uint64 `json:"uptime"`
Agent int `json:"agent"`
}