mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 18:45:53 +00:00
9e37d629ac
Telemetry could see only saved connections, so an install that tried to reach a node and could not was indistinguishable from one that never opened the add-node dialog. Both report zero configured connections and stall at the same activation stage. Fleet data shows that population is real and concentrated three to one in container deployments, and nothing recorded whether those installs attempted a connection at all. Record node connection test attempts and failures in a bounded, day-bucketed tally in the config directory, pruned to a 31-day retention window, and report both over the install-ID rotation window as node_test_attempts_30d and node_test_failures_30d. Recording starts only once a request carries a target and credentials, so an incomplete form is never counted as a node that could not be reached. A host string that turns out to be unusable does count, because the attempt was made and it failed. Only the add-node dialog endpoint is instrumented: instrumenting the unused test-config endpoint as well would double-count a single operator action. The tally holds counts alone. Hosts, credentials, and error text never enter it, which is why it is plain JSON rather than encrypted history.
2362 lines
75 KiB
Go
2362 lines
75 KiB
Go
package configapi
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/api/apicontext"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/mock"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/websocket"
|
|
"github.com/rcourtman/pulse-go-rewrite/pkg/pbs"
|
|
"github.com/rcourtman/pulse-go-rewrite/pkg/pmg"
|
|
"github.com/rcourtman/pulse-go-rewrite/pkg/proxmox"
|
|
"github.com/rs/zerolog/log"
|
|
)
|
|
|
|
// errNodeIDNotFound signals that resolveNodeID parsed a well-formed semantic
|
|
// ID ("pve:delly") but no matching instance is in the current config. Callers
|
|
// distinguish this from malformed IDs so they can emit 404 vs 400.
|
|
var errNodeIDNotFound = errors.New("node id not found")
|
|
|
|
// resolveNodeID accepts either the legacy array-index form ("pve-0") or the
|
|
// semantic form the unified connections aggregator emits ("pve:delly") and
|
|
// returns (nodeType, slice-index) suitable for indexing into cfg's per-type
|
|
// instance slices.
|
|
//
|
|
// Semantic-form lookup is by Name. Duplicate names are prevented at write
|
|
// time; if one somehow sneaks in, the first match wins.
|
|
func resolveNodeID(cfg *config.Config, raw string) (string, int, error) {
|
|
if raw == "" {
|
|
return "", 0, fmt.Errorf("empty node id")
|
|
}
|
|
|
|
if colonIdx := strings.Index(raw, ":"); colonIdx > 0 {
|
|
nodeType := raw[:colonIdx]
|
|
name := raw[colonIdx+1:]
|
|
if name == "" {
|
|
return "", 0, fmt.Errorf("empty name in node id %q", raw)
|
|
}
|
|
idx, found := findInstanceIndexByName(cfg, nodeType, name)
|
|
if !found {
|
|
return nodeType, 0, errNodeIDNotFound
|
|
}
|
|
return nodeType, idx, nil
|
|
}
|
|
|
|
parts := strings.Split(raw, "-")
|
|
if len(parts) != 2 {
|
|
return "", 0, fmt.Errorf("invalid node id %q", raw)
|
|
}
|
|
nodeType := parts[0]
|
|
var index int
|
|
if _, err := fmt.Sscanf(parts[1], "%d", &index); err != nil {
|
|
return "", 0, fmt.Errorf("invalid index in node id %q: %w", raw, err)
|
|
}
|
|
return nodeType, index, nil
|
|
}
|
|
|
|
func findInstanceIndexByName(cfg *config.Config, nodeType, name string) (int, bool) {
|
|
switch nodeType {
|
|
case "pve":
|
|
for i, inst := range cfg.PVEInstances {
|
|
if inst.Name == name {
|
|
return i, true
|
|
}
|
|
}
|
|
case "pbs":
|
|
for i, inst := range cfg.PBSInstances {
|
|
if inst.Name == name {
|
|
return i, true
|
|
}
|
|
}
|
|
case "pmg":
|
|
for i, inst := range cfg.PMGInstances {
|
|
if inst.Name == name {
|
|
return i, true
|
|
}
|
|
}
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
func (h *ConfigHandlers) handleGetNodes(w http.ResponseWriter, r *http.Request) {
|
|
// Check if mock mode is enabled
|
|
if mock.IsMockEnabled() {
|
|
// Return mock nodes for settings page
|
|
mockNodes := []NodeResponse{}
|
|
|
|
// Get ReadState from the monitor to extract node information.
|
|
monitor := h.getMonitor(r.Context())
|
|
readState := monitor.GetUnifiedReadState()
|
|
|
|
type mockNode struct {
|
|
Name string
|
|
Instance string
|
|
Status string
|
|
}
|
|
type mockPBS struct {
|
|
Name string
|
|
Host string
|
|
}
|
|
type mockPMG struct {
|
|
Name string
|
|
Host string
|
|
}
|
|
|
|
var clusterNodes []mockNode
|
|
var standaloneNodes []mockNode
|
|
var pbsEntries []mockPBS
|
|
var pmgEntries []mockPMG
|
|
|
|
if readState != nil {
|
|
for _, nv := range readState.Nodes() {
|
|
mn := mockNode{
|
|
Name: nv.Name(),
|
|
Instance: nv.Instance(),
|
|
Status: string(nv.Status()),
|
|
}
|
|
if mn.Instance == "mock-cluster" {
|
|
clusterNodes = append(clusterNodes, mn)
|
|
} else {
|
|
standaloneNodes = append(standaloneNodes, mn)
|
|
}
|
|
}
|
|
for _, pbsView := range readState.PBSInstances() {
|
|
pbsEntries = append(pbsEntries, mockPBS{Name: pbsView.Name(), Host: pbsView.Hostname()})
|
|
}
|
|
for _, pmgView := range readState.PMGInstances() {
|
|
pmgEntries = append(pmgEntries, mockPMG{Name: pmgView.Name(), Host: pmgView.Hostname()})
|
|
}
|
|
}
|
|
|
|
// If we have cluster nodes, create ONE config entry for the cluster
|
|
if len(clusterNodes) > 0 {
|
|
// Build cluster endpoints for cluster nodes only
|
|
var clusterEndpoints []config.ClusterEndpoint
|
|
for i, n := range clusterNodes {
|
|
clusterEndpoints = append(clusterEndpoints, config.ClusterEndpoint{
|
|
NodeName: n.Name,
|
|
Host: fmt.Sprintf("192.168.0.%d:8006", 100+i),
|
|
Online: n.Status == "online", // Set Online based on node status
|
|
})
|
|
}
|
|
|
|
// Create a single cluster entry (representing the cluster config)
|
|
clusterNode := NodeResponse{
|
|
ID: generateNodeID("pve", 0),
|
|
Type: "pve",
|
|
Name: "mock-cluster", // The cluster name
|
|
Host: "198.51.100.100:8006", // Primary entry point
|
|
User: "root@pam",
|
|
HasPassword: true,
|
|
TokenName: "pulse",
|
|
HasToken: true,
|
|
Fingerprint: "",
|
|
VerifySSL: false,
|
|
MonitorVMs: true,
|
|
MonitorContainers: true,
|
|
MonitorStorage: true,
|
|
MonitorBackups: true,
|
|
MonitorPhysicalDisks: nil, // nil = enabled by default
|
|
Status: "connected",
|
|
IsCluster: true,
|
|
ClusterName: "mock-cluster",
|
|
ClusterEndpoints: toClusterEndpointResponses(clusterEndpoints), // All cluster nodes
|
|
}.NormalizeCollections()
|
|
mockNodes = append(mockNodes, clusterNode)
|
|
}
|
|
|
|
// Add standalone nodes as individual entries
|
|
for i, node := range standaloneNodes {
|
|
standaloneNode := NodeResponse{
|
|
ID: generateNodeID("pve", len(mockNodes)+i),
|
|
Type: "pve",
|
|
Name: node.Name, // Use the actual node name
|
|
Host: fmt.Sprintf("192.168.0.%d:8006", 150+i), // Different IP range for standalone
|
|
User: "root@pam",
|
|
HasPassword: true,
|
|
TokenName: "pulse",
|
|
HasToken: true,
|
|
Fingerprint: "",
|
|
VerifySSL: false,
|
|
MonitorVMs: true,
|
|
MonitorContainers: true,
|
|
MonitorStorage: true,
|
|
MonitorBackups: true,
|
|
MonitorPhysicalDisks: nil, // nil = enabled by default
|
|
Status: "connected",
|
|
IsCluster: false, // Not part of a cluster
|
|
ClusterName: "",
|
|
ClusterEndpoints: nil,
|
|
}.NormalizeCollections()
|
|
mockNodes = append(mockNodes, standaloneNode)
|
|
}
|
|
|
|
// Add mock PBS instances
|
|
for i, pbs := range pbsEntries {
|
|
pbsNode := NodeResponse{
|
|
ID: generateNodeID("pbs", i),
|
|
Type: "pbs",
|
|
Name: pbs.Name,
|
|
Host: pbs.Host,
|
|
User: "pulse@pbs",
|
|
HasPassword: false,
|
|
TokenName: "pulse",
|
|
HasToken: true,
|
|
Fingerprint: "",
|
|
VerifySSL: false,
|
|
MonitorDatastores: true,
|
|
MonitorSyncJobs: true,
|
|
MonitorVerifyJobs: true,
|
|
MonitorPruneJobs: true,
|
|
MonitorGarbageJobs: true,
|
|
Status: "connected", // Always connected in mock mode
|
|
}.NormalizeCollections()
|
|
mockNodes = append(mockNodes, pbsNode)
|
|
}
|
|
|
|
// Add mock PMG instances
|
|
for i, pmg := range pmgEntries {
|
|
pmgNode := NodeResponse{
|
|
ID: generateNodeID("pmg", i),
|
|
Type: "pmg",
|
|
Name: pmg.Name,
|
|
Host: pmg.Host,
|
|
User: "root@pam",
|
|
HasPassword: true,
|
|
TokenName: "pulse",
|
|
HasToken: true,
|
|
Fingerprint: "",
|
|
VerifySSL: false,
|
|
Status: "connected", // Always connected in mock mode
|
|
}.NormalizeCollections()
|
|
mockNodes = append(mockNodes, pmgNode)
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(mockNodes)
|
|
return
|
|
}
|
|
|
|
nodes := h.GetAllNodesForAPI(r.Context())
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(nodes)
|
|
}
|
|
|
|
func (h *ConfigHandlers) handleAddNode(w http.ResponseWriter, r *http.Request) {
|
|
// Prevent node modifications in mock mode
|
|
if mock.IsMockEnabled() {
|
|
http.Error(w, "Cannot modify nodes in mock mode. Please disable mock mode first: /opt/pulse/scripts/toggle-mock.sh off", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
// Limit request body to 32KB to prevent memory exhaustion
|
|
r.Body = http.MaxBytesReader(w, r.Body, 32*1024)
|
|
|
|
var req NodeConfigRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
log.Error().Err(err).Msg("Failed to decode add node request")
|
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
req.normalizeTokenAliases()
|
|
|
|
log.Info().
|
|
Str("type", req.Type).
|
|
Str("name", req.Name).
|
|
Str("host", req.Host).
|
|
Str("user", req.User).
|
|
Str("tokenName", req.TokenName).
|
|
Bool("hasTokenValue", req.TokenValue != "").
|
|
Msg("Add node request received")
|
|
|
|
// Validate required fields
|
|
if req.Name == "" {
|
|
http.Error(w, "Name is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if req.Type == "" {
|
|
http.Error(w, "Type is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if req.Host == "" {
|
|
http.Error(w, "Host is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Validate host format (IP address or hostname with optional port)
|
|
host, port, err := extractHostAndPort(req.Host)
|
|
if err != nil {
|
|
http.Error(w, "Invalid host format", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// If it looks like an IP address, validate it strictly
|
|
// Check if it starts with a digit (likely an IP)
|
|
if len(host) > 0 && (host[0] >= '0' && host[0] <= '9') {
|
|
// Likely an IP address, validate strictly
|
|
if !validateIPAddress(host) {
|
|
http.Error(w, "Invalid IP address", http.StatusBadRequest)
|
|
return
|
|
}
|
|
} else if strings.Contains(host, ":") && strings.Contains(host, "[") {
|
|
// IPv6 address with brackets
|
|
ipv6 := strings.TrimPrefix(strings.TrimSuffix(host, "]"), "[")
|
|
if !validateIPAddress(ipv6) {
|
|
http.Error(w, "Invalid IPv6 address", http.StatusBadRequest)
|
|
return
|
|
}
|
|
} else if req.Type == "pbs" {
|
|
// Validate as hostname - no spaces or special characters
|
|
if strings.ContainsAny(host, " /\\<>|\"'`;") {
|
|
http.Error(w, "Invalid hostname", http.StatusBadRequest)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Validate port if provided
|
|
if port != "" && !validatePort(port) {
|
|
http.Error(w, "Invalid port number", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if req.Type != "pve" && req.Type != "pbs" && req.Type != "pmg" {
|
|
http.Error(w, "Invalid node type", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Check for authentication
|
|
hasAuth := (req.User != "" && req.Password != "") || (req.TokenName != "" && req.TokenValue != "")
|
|
if !hasAuth {
|
|
http.Error(w, "Authentication credentials required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
normalizedHost, err := normalizeNodeHost(req.Host, req.Type)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Check for duplicate nodes by HOST URL (not name!)
|
|
// Different physical hosts can share the same hostname (Issue #891).
|
|
// We disambiguate names later, but Host URLs must be unique.
|
|
switch req.Type {
|
|
case "pve":
|
|
for _, node := range h.getConfig(r.Context()).PVEInstances {
|
|
if node.Host == normalizedHost {
|
|
http.Error(w, "A node with this host URL already exists", http.StatusConflict)
|
|
return
|
|
}
|
|
}
|
|
case "pbs":
|
|
for _, node := range h.getConfig(r.Context()).PBSInstances {
|
|
if node.Host == normalizedHost {
|
|
http.Error(w, "A node with this host URL already exists", http.StatusConflict)
|
|
return
|
|
}
|
|
}
|
|
case "pmg":
|
|
for _, node := range h.getConfig(r.Context()).PMGInstances {
|
|
if node.Host == normalizedHost {
|
|
http.Error(w, "A node with this host URL already exists", http.StatusConflict)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add to appropriate list
|
|
if req.Type == "pve" {
|
|
if req.Password != "" && req.TokenName == "" && req.TokenValue == "" {
|
|
req.User = normalizePVEUser(req.User)
|
|
}
|
|
host := normalizedHost
|
|
|
|
// Check if node is part of a cluster (skip for test/invalid IPs)
|
|
var isCluster bool
|
|
var clusterName string
|
|
var clusterEndpoints []config.ClusterEndpoint
|
|
|
|
// Skip cluster detection for obviously test/invalid IPs
|
|
skipClusterDetection := strings.Contains(req.Host, "192.168.77.") ||
|
|
strings.Contains(req.Host, "192.168.88.") ||
|
|
strings.Contains(req.Host, "test-") ||
|
|
strings.Contains(req.Name, "test-") ||
|
|
strings.Contains(req.Name, "persist-") ||
|
|
strings.Contains(req.Name, "concurrent-")
|
|
|
|
if !skipClusterDetection {
|
|
verifySSL := false
|
|
if req.VerifySSL != nil {
|
|
verifySSL = *req.VerifySSL
|
|
}
|
|
clientConfig := config.CreateProxmoxConfigFromFields(host, req.User, req.Password, req.TokenName, req.TokenValue, req.Fingerprint, verifySSL)
|
|
isCluster, clusterName, clusterEndpoints = detectPVECluster(clientConfig, req.Name, nil)
|
|
}
|
|
|
|
// CLUSTER DEDUPLICATION: If this node is part of a cluster, merge it
|
|
// only when endpoint and TLS evidence identify an existing cluster.
|
|
// Cluster names and private addresses can repeat across organizations.
|
|
if isCluster && clusterName != "" {
|
|
candidateCluster := config.PVEInstance{
|
|
Name: req.Name,
|
|
Host: host,
|
|
Fingerprint: req.Fingerprint,
|
|
IsCluster: true,
|
|
ClusterName: clusterName,
|
|
ClusterEndpoints: clusterEndpoints,
|
|
}
|
|
for i := range h.getConfig(r.Context()).PVEInstances {
|
|
existingInstance := &h.getConfig(r.Context()).PVEInstances[i]
|
|
if config.PVEClusterInstancesShareIdentity(*existingInstance, candidateCluster) {
|
|
// Strong identity evidence confirms this is another view
|
|
// of the existing cluster, so merge its endpoints.
|
|
log.Info().
|
|
Str("cluster", clusterName).
|
|
Str("existingInstance", existingInstance.Name).
|
|
Str("newNode", req.Name).
|
|
Msg("New node belongs to already-configured cluster - merging as endpoint instead of creating duplicate")
|
|
|
|
// Merge any new endpoints from the detected cluster
|
|
existingEndpointMap := make(map[string]bool)
|
|
for _, ep := range existingInstance.ClusterEndpoints {
|
|
existingEndpointMap[ep.NodeName] = true
|
|
}
|
|
for _, newEp := range clusterEndpoints {
|
|
if !existingEndpointMap[newEp.NodeName] {
|
|
existingInstance.ClusterEndpoints = append(existingInstance.ClusterEndpoints, newEp)
|
|
log.Info().
|
|
Str("cluster", clusterName).
|
|
Str("endpoint", newEp.NodeName).
|
|
Msg("Added new endpoint to existing cluster")
|
|
}
|
|
}
|
|
|
|
// Save the updated configuration
|
|
if h.getPersistence(r.Context()) != nil {
|
|
h.normalizePVEConfigState(r.Context())
|
|
if err := h.getPersistence(r.Context()).SaveNodesConfig(h.getConfig(r.Context()).PVEInstances, h.getConfig(r.Context()).PBSInstances, h.getConfig(r.Context()).PMGInstances); err != nil {
|
|
log.Warn().Err(err).Msg("Failed to persist cluster endpoint merge")
|
|
}
|
|
}
|
|
|
|
// Reload the monitor to pick up the updated endpoints
|
|
if h.reloadFunc != nil {
|
|
if err := h.reloadFunc(); err != nil {
|
|
log.Warn().Err(err).Msg("Failed to reload monitor after cluster merge")
|
|
}
|
|
}
|
|
|
|
// Return success - the cluster is now updated with new endpoints
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"success": true,
|
|
"merged": true,
|
|
"cluster": clusterName,
|
|
"existingNode": existingInstance.Name,
|
|
"message": fmt.Sprintf("Node merged into existing cluster '%s' (already configured as '%s')", clusterName, existingInstance.Name),
|
|
"totalEndpoints": len(existingInstance.ClusterEndpoints),
|
|
})
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
if isCluster {
|
|
log.Info().
|
|
Str("cluster", clusterName).
|
|
Int("endpoints", len(clusterEndpoints)).
|
|
Msg("Detected new Proxmox cluster, auto-discovering all nodes")
|
|
}
|
|
|
|
// Use sensible defaults for boolean fields if not provided
|
|
verifySSL := false
|
|
if req.VerifySSL != nil {
|
|
verifySSL = *req.VerifySSL
|
|
}
|
|
monitorVMs := true // Default to true
|
|
if req.MonitorVMs != nil {
|
|
monitorVMs = *req.MonitorVMs
|
|
}
|
|
monitorContainers := true // Default to true
|
|
if req.MonitorContainers != nil {
|
|
monitorContainers = *req.MonitorContainers
|
|
}
|
|
monitorStorage := true // Default to true
|
|
if req.MonitorStorage != nil {
|
|
monitorStorage = *req.MonitorStorage
|
|
}
|
|
monitorBackups := true // Default to true
|
|
if req.MonitorBackups != nil {
|
|
monitorBackups = *req.MonitorBackups
|
|
}
|
|
|
|
// Disambiguate name if duplicate hostnames exist (Issue #891)
|
|
displayName := h.disambiguateNodeName(r.Context(), req.Name, host, "pve")
|
|
|
|
pve := config.PVEInstance{
|
|
Name: displayName,
|
|
Host: host, // Use normalized host
|
|
GuestURL: req.GuestURL,
|
|
User: req.User,
|
|
Password: req.Password,
|
|
TokenName: req.TokenName,
|
|
TokenValue: req.TokenValue,
|
|
Fingerprint: req.Fingerprint,
|
|
VerifySSL: verifySSL,
|
|
MonitorVMs: monitorVMs,
|
|
MonitorContainers: monitorContainers,
|
|
MonitorStorage: monitorStorage,
|
|
MonitorBackups: monitorBackups,
|
|
MonitorPhysicalDisks: req.MonitorPhysicalDisks,
|
|
PhysicalDiskPollingMinutes: 0,
|
|
TemperatureMonitoringEnabled: req.TemperatureMonitoringEnabled,
|
|
IsCluster: isCluster,
|
|
ClusterName: clusterName,
|
|
ClusterEndpoints: clusterEndpoints,
|
|
}
|
|
if req.PhysicalDiskPollingMinutes != nil {
|
|
pve.PhysicalDiskPollingMinutes = *req.PhysicalDiskPollingMinutes
|
|
}
|
|
|
|
h.getConfig(r.Context()).PVEInstances = append(h.getConfig(r.Context()).PVEInstances, pve)
|
|
h.normalizePVEConfigState(r.Context())
|
|
|
|
if isCluster {
|
|
log.Info().
|
|
Str("cluster", clusterName).
|
|
Int("endpoints", len(clusterEndpoints)).
|
|
Msg("Added Proxmox cluster with auto-discovered endpoints")
|
|
}
|
|
} else if req.Type == "pbs" {
|
|
host := normalizedHost
|
|
|
|
// Parse PBS authentication details
|
|
var pbsUser string
|
|
var pbsPassword string
|
|
var pbsTokenName string
|
|
var pbsTokenValue string
|
|
|
|
// Determine authentication method
|
|
if req.TokenName != "" && req.TokenValue != "" {
|
|
// Using token authentication - don't store user/password
|
|
pbsTokenName = req.TokenName
|
|
pbsTokenValue = req.TokenValue
|
|
// Token name might contain the full format (user@realm!tokenname)
|
|
// The backend PBS client will parse this
|
|
} else if req.Password != "" {
|
|
// Using password authentication - try to create a token via API
|
|
// This enables turnkey setup for Docker/containerized PBS
|
|
pulseURL := h.resolveConfigAgentInstallBaseURL(r, h.getConfig(r.Context()))
|
|
if pulseURL == "" {
|
|
writeConfigAgentInstallBaseURLUnavailable(w)
|
|
return
|
|
}
|
|
pbsUser = req.User
|
|
if pbsUser != "" && !strings.Contains(pbsUser, "@") {
|
|
pbsUser = pbsUser + "@pbs" // Default to @pbs realm if not specified
|
|
}
|
|
|
|
log.Info().
|
|
Str("host", host).
|
|
Str("user", pbsUser).
|
|
Msg("PBS: Attempting turnkey token creation via API")
|
|
|
|
// Try to create a token using the provided credentials
|
|
pbsClient, err := pbs.NewClient(pbs.ClientConfig{
|
|
Host: host,
|
|
User: pbsUser,
|
|
Password: req.Password,
|
|
VerifySSL: false, // Self-signed certs common
|
|
})
|
|
|
|
if err != nil {
|
|
log.Warn().Err(err).Str("host", host).Msg("PBS: Failed to connect for token creation, falling back to password auth")
|
|
// Fallback to password auth
|
|
pbsPassword = req.Password
|
|
} else {
|
|
tokenName := buildPulseMonitorTokenName(pulseURL)
|
|
|
|
tokenID, tokenSecret, err := pbsClient.SetupMonitoringAccess(context.Background(), tokenName)
|
|
if err != nil {
|
|
log.Warn().Err(err).Str("host", host).Msg("PBS: Failed to create token via API, falling back to password auth")
|
|
// Fallback to password auth
|
|
pbsPassword = req.Password
|
|
} else {
|
|
// Successfully created token - use it instead of password
|
|
pbsTokenName = tokenID
|
|
pbsTokenValue = tokenSecret
|
|
pbsUser = "" // Clear password auth fields
|
|
pbsPassword = ""
|
|
log.Info().
|
|
Str("host", host).
|
|
Str("tokenID", tokenID).
|
|
Msg("PBS: Successfully created monitoring token via API")
|
|
}
|
|
}
|
|
}
|
|
|
|
// Use sensible defaults for boolean fields if not provided
|
|
verifySSL := false
|
|
if req.VerifySSL != nil {
|
|
verifySSL = *req.VerifySSL
|
|
}
|
|
monitorBackups := true // Default to true for PBS
|
|
if req.MonitorBackups != nil {
|
|
monitorBackups = *req.MonitorBackups
|
|
}
|
|
monitorDatastores := true // Default to true for PBS
|
|
if req.MonitorDatastores != nil {
|
|
monitorDatastores = *req.MonitorDatastores
|
|
}
|
|
monitorSyncJobs := true // Default to true for PBS
|
|
if req.MonitorSyncJobs != nil {
|
|
monitorSyncJobs = *req.MonitorSyncJobs
|
|
}
|
|
monitorVerifyJobs := true // Default to true for PBS
|
|
if req.MonitorVerifyJobs != nil {
|
|
monitorVerifyJobs = *req.MonitorVerifyJobs
|
|
}
|
|
monitorPruneJobs := true // Default to true for PBS
|
|
if req.MonitorPruneJobs != nil {
|
|
monitorPruneJobs = *req.MonitorPruneJobs
|
|
}
|
|
monitorGarbageJobs := true // Default to true for PBS
|
|
if req.MonitorGarbageJobs != nil {
|
|
monitorGarbageJobs = *req.MonitorGarbageJobs
|
|
}
|
|
|
|
// Disambiguate name if duplicate hostnames exist (Issue #891)
|
|
pbsDisplayName := h.disambiguateNodeName(r.Context(), req.Name, host, "pbs")
|
|
|
|
pbs := config.PBSInstance{
|
|
Name: pbsDisplayName,
|
|
Host: host,
|
|
GuestURL: req.GuestURL,
|
|
User: pbsUser,
|
|
Password: pbsPassword,
|
|
TokenName: pbsTokenName,
|
|
TokenValue: pbsTokenValue,
|
|
Fingerprint: req.Fingerprint,
|
|
VerifySSL: verifySSL,
|
|
MonitorBackups: monitorBackups,
|
|
MonitorDatastores: monitorDatastores,
|
|
MonitorSyncJobs: monitorSyncJobs,
|
|
MonitorVerifyJobs: monitorVerifyJobs,
|
|
MonitorPruneJobs: monitorPruneJobs,
|
|
MonitorGarbageJobs: monitorGarbageJobs,
|
|
TemperatureMonitoringEnabled: req.TemperatureMonitoringEnabled,
|
|
}
|
|
h.getConfig(r.Context()).PBSInstances = append(h.getConfig(r.Context()).PBSInstances, pbs)
|
|
} else if req.Type == "pmg" {
|
|
host := normalizedHost
|
|
|
|
var pmgUser string
|
|
var pmgPassword string
|
|
var pmgTokenName string
|
|
var pmgTokenValue string
|
|
|
|
if req.TokenName != "" && req.TokenValue != "" {
|
|
pmgTokenName = req.TokenName
|
|
pmgTokenValue = req.TokenValue
|
|
} else if req.Password != "" {
|
|
pmgUser = req.User
|
|
pmgPassword = req.Password
|
|
if pmgUser != "" && !strings.Contains(pmgUser, "@") {
|
|
pmgUser = pmgUser + "@pmg"
|
|
}
|
|
}
|
|
|
|
// Use sensible defaults for boolean fields if not provided
|
|
verifySSL := false
|
|
if req.VerifySSL != nil {
|
|
verifySSL = *req.VerifySSL
|
|
}
|
|
|
|
// Check if any monitoring flags are explicitly set to true
|
|
anyMonitoringEnabled := (req.MonitorMailStats != nil && *req.MonitorMailStats) ||
|
|
(req.MonitorQueues != nil && *req.MonitorQueues) ||
|
|
(req.MonitorQuarantine != nil && *req.MonitorQuarantine) ||
|
|
(req.MonitorDomainStats != nil && *req.MonitorDomainStats)
|
|
|
|
// Default MonitorMailStats to true if no monitoring is explicitly enabled
|
|
monitorMailStats := true // Default to true
|
|
if req.MonitorMailStats != nil {
|
|
monitorMailStats = *req.MonitorMailStats
|
|
} else if anyMonitoringEnabled {
|
|
monitorMailStats = false // Don't default to true if other monitoring is enabled
|
|
}
|
|
|
|
monitorQueues := false
|
|
if req.MonitorQueues != nil {
|
|
monitorQueues = *req.MonitorQueues
|
|
}
|
|
monitorQuarantine := false
|
|
if req.MonitorQuarantine != nil {
|
|
monitorQuarantine = *req.MonitorQuarantine
|
|
}
|
|
monitorDomainStats := false
|
|
if req.MonitorDomainStats != nil {
|
|
monitorDomainStats = *req.MonitorDomainStats
|
|
}
|
|
|
|
// Disambiguate name if duplicate hostnames exist (Issue #891)
|
|
// Note: PMG uses similar logic to PBS - we check against PMG instances
|
|
pmgDisplayName := req.Name
|
|
for _, node := range h.getConfig(r.Context()).PMGInstances {
|
|
if strings.EqualFold(node.Name, req.Name) && node.Host != host {
|
|
parsed, err := url.Parse(host)
|
|
if err == nil && parsed.Host != "" {
|
|
pmgDisplayName = fmt.Sprintf("%s (%s)", req.Name, parsed.Hostname())
|
|
}
|
|
break
|
|
}
|
|
}
|
|
|
|
pmgInstance := config.PMGInstance{
|
|
Name: pmgDisplayName,
|
|
Host: host,
|
|
GuestURL: req.GuestURL,
|
|
User: pmgUser,
|
|
Password: pmgPassword,
|
|
TokenName: pmgTokenName,
|
|
TokenValue: pmgTokenValue,
|
|
Fingerprint: req.Fingerprint,
|
|
VerifySSL: verifySSL,
|
|
MonitorMailStats: monitorMailStats,
|
|
MonitorQueues: monitorQueues,
|
|
MonitorQuarantine: monitorQuarantine,
|
|
MonitorDomainStats: monitorDomainStats,
|
|
TemperatureMonitoringEnabled: req.TemperatureMonitoringEnabled,
|
|
}
|
|
h.getConfig(r.Context()).PMGInstances = append(h.getConfig(r.Context()).PMGInstances, pmgInstance)
|
|
}
|
|
|
|
// Save configuration to disk using our persistence instance
|
|
h.normalizePVEConfigState(r.Context())
|
|
if err := h.getPersistence(r.Context()).SaveNodesConfig(h.getConfig(r.Context()).PVEInstances, h.getConfig(r.Context()).PBSInstances, h.getConfig(r.Context()).PMGInstances); err != nil {
|
|
log.Error().Err(err).Msg("Failed to save nodes configuration")
|
|
http.Error(w, "Failed to save configuration", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Reload monitor with new configuration
|
|
if h.reloadFunc != nil {
|
|
if err := h.reloadFunc(); err != nil {
|
|
log.Error().Err(err).Msg("Failed to reload monitor")
|
|
http.Error(w, "Configuration saved but failed to apply changes", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
h.runtimeDependencies().AuditEvent(apicontext.OrgID(r.Context()), "node_added", h.runtimeDependencies().AuthUsername(h.getConfig(r.Context()), r), h.runtimeDependencies().ClientIP(r), r.URL.Path, true,
|
|
fmt.Sprintf("Added %s node %q", req.Type, req.Name))
|
|
|
|
w.WriteHeader(http.StatusCreated)
|
|
json.NewEncoder(w).Encode(map[string]string{"status": "success"})
|
|
}
|
|
|
|
// HandleTestConnection tests a node connection without saving
|
|
|
|
// nodeTestOutcomeRecorder captures the status a node connection test responded
|
|
// with, so the outcome can be tallied once without threading a result through
|
|
// every branch of the handler.
|
|
type nodeTestOutcomeRecorder struct {
|
|
http.ResponseWriter
|
|
status int
|
|
}
|
|
|
|
func newNodeTestOutcomeRecorder(w http.ResponseWriter) *nodeTestOutcomeRecorder {
|
|
return &nodeTestOutcomeRecorder{ResponseWriter: w, status: http.StatusOK}
|
|
}
|
|
|
|
func (rec *nodeTestOutcomeRecorder) WriteHeader(status int) {
|
|
rec.status = status
|
|
rec.ResponseWriter.WriteHeader(status)
|
|
}
|
|
|
|
// failed reports whether the test resolved to anything other than success. The
|
|
// success paths write a body without an explicit status, which is why the
|
|
// recorder starts at 200.
|
|
func (rec *nodeTestOutcomeRecorder) failed() bool {
|
|
return rec.status < 200 || rec.status >= 300
|
|
}
|
|
|
|
// recordNodeTestOutcome tallies one node connection test for telemetry. The
|
|
// tally is advisory, so a write failure is logged and never surfaced to the
|
|
// caller: a broken counter must not break a connection test.
|
|
func (h *ConfigHandlers) recordNodeTestOutcome(ctx context.Context, failed bool) {
|
|
persistence := h.getPersistence(ctx)
|
|
if persistence == nil {
|
|
return
|
|
}
|
|
if err := persistence.RecordNodeTestOutcome(failed, time.Now()); err != nil {
|
|
log.Warn().Err(err).Msg("Failed to record node connection test outcome")
|
|
}
|
|
}
|
|
|
|
func (h *ConfigHandlers) handleTestConnection(w http.ResponseWriter, r *http.Request) {
|
|
// Limit request body to 32KB to prevent memory exhaustion
|
|
r.Body = http.MaxBytesReader(w, r.Body, 32*1024)
|
|
|
|
var req NodeConfigRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
log.Error().Err(err).Msg("Failed to decode test connection request")
|
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
req.normalizeTokenAliases()
|
|
|
|
log.Info().
|
|
Str("type", req.Type).
|
|
Str("name", req.Name).
|
|
Str("host", req.Host).
|
|
Str("user", req.User).
|
|
Str("tokenName", req.TokenName).
|
|
Bool("hasTokenValue", req.TokenValue != "").
|
|
Msg("Test connection request received")
|
|
|
|
// Parse token format if needed
|
|
user := req.User
|
|
tokenName := req.TokenName
|
|
|
|
// If tokenName contains the full format (user@realm!tokenname), parse it
|
|
if strings.Contains(req.TokenName, "!") {
|
|
parts := strings.Split(req.TokenName, "!")
|
|
if len(parts) == 2 {
|
|
user = parts[0]
|
|
tokenName = parts[1]
|
|
}
|
|
}
|
|
// If user field contains the full format, extract just the user part
|
|
if strings.Contains(user, "!") {
|
|
parts := strings.Split(user, "!")
|
|
if len(parts) >= 1 {
|
|
user = parts[0]
|
|
}
|
|
}
|
|
|
|
log.Info().
|
|
Str("parsedUser", user).
|
|
Str("parsedTokenName", tokenName).
|
|
Msg("Parsed authentication details")
|
|
|
|
// Validate request
|
|
if req.Host == "" {
|
|
http.Error(w, "Host is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Auto-generate name if not provided for test
|
|
if req.Name == "" {
|
|
// Extract hostname from URL
|
|
host := strings.TrimPrefix(strings.TrimPrefix(req.Host, "http://"), "https://")
|
|
// Remove port
|
|
if colonIndex := strings.Index(host, ":"); colonIndex != -1 {
|
|
host = host[:colonIndex]
|
|
}
|
|
req.Name = host
|
|
}
|
|
|
|
if req.Type != "pve" && req.Type != "pbs" && req.Type != "pmg" {
|
|
http.Error(w, "Invalid node type", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Check for authentication
|
|
hasAuth := (user != "" && req.Password != "") || (tokenName != "" && req.TokenValue != "")
|
|
if !hasAuth {
|
|
http.Error(w, "Authentication credentials required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
normalizedHost, err := normalizeNodeHost(req.Host, req.Type)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Past this point the request carries a target and credentials, so it is a
|
|
// real attempt by someone to reach a node. Requests that never carried them
|
|
// are rejected above and stay out of the tally, which is what keeps an
|
|
// incomplete form from counting as a node that could not be reached. A host
|
|
// string that turns out to be unusable still counts: the attempt was made
|
|
// and it failed.
|
|
testRecorder := newNodeTestOutcomeRecorder(w)
|
|
w = testRecorder
|
|
defer func() { h.recordNodeTestOutcome(r.Context(), testRecorder.failed()) }()
|
|
|
|
// Test connection based on type
|
|
if req.Type == "pve" {
|
|
host := normalizedHost
|
|
|
|
// Create a temporary client
|
|
authUser := req.User
|
|
if req.Password != "" && req.TokenName == "" && req.TokenValue == "" {
|
|
authUser = normalizePVEUser(authUser)
|
|
req.User = authUser
|
|
}
|
|
verifySSL := false
|
|
if req.VerifySSL != nil {
|
|
verifySSL = *req.VerifySSL
|
|
}
|
|
clientConfig := proxmox.ClientConfig{
|
|
Host: host,
|
|
User: authUser,
|
|
Password: req.Password,
|
|
TokenName: req.TokenName, // Pass the full token ID
|
|
TokenValue: req.TokenValue,
|
|
VerifySSL: verifySSL,
|
|
Fingerprint: req.Fingerprint,
|
|
}
|
|
|
|
tempClient, err := proxmox.NewClient(clientConfig)
|
|
if err != nil {
|
|
http.Error(w, sanitizeErrorMessage(err, "create_client"), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Try to get nodes to test connection
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
nodes, err := tempClient.GetNodes(ctx)
|
|
if err != nil {
|
|
http.Error(w, sanitizeErrorMessage(err, "connection"), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
isCluster, _, clusterEndpoints := detectPVECluster(clientConfig, req.Name, nil)
|
|
|
|
response := map[string]interface{}{
|
|
"status": "success",
|
|
"message": fmt.Sprintf("Successfully connected to %d node(s)", len(nodes)),
|
|
"isCluster": isCluster,
|
|
"nodeCount": len(nodes),
|
|
}
|
|
|
|
if isCluster {
|
|
response["clusterNodeCount"] = len(clusterEndpoints)
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(response)
|
|
} else if req.Type == "pbs" {
|
|
host := normalizedHost
|
|
|
|
log.Info().
|
|
Str("processedHost", host).
|
|
Msg("PBS host after port processing")
|
|
|
|
// PBS test connection
|
|
// Parse PBS authentication details
|
|
pbsUser := user
|
|
pbsTokenName := tokenName
|
|
|
|
// Handle different token input formats
|
|
if req.TokenName != "" && req.TokenValue != "" {
|
|
// Check if token name contains the full format (user@realm!tokenname)
|
|
if strings.Contains(req.TokenName, "!") {
|
|
// Token name is in full format, leave it as is
|
|
// The PBS client will parse it
|
|
} else if pbsUser != "" && !strings.Contains(pbsUser, "@") {
|
|
// User provided separately without realm, add default realm
|
|
pbsUser = pbsUser + "@pbs"
|
|
}
|
|
} else if pbsUser != "" && !strings.Contains(pbsUser, "@") {
|
|
// Password auth: ensure user has realm
|
|
pbsUser = pbsUser + "@pbs" // Default to @pbs realm if not specified
|
|
}
|
|
|
|
verifySSL := false
|
|
if req.VerifySSL != nil {
|
|
verifySSL = *req.VerifySSL
|
|
}
|
|
clientConfig := pbs.ClientConfig{
|
|
Host: host,
|
|
User: pbsUser,
|
|
Password: req.Password,
|
|
TokenName: pbsTokenName,
|
|
TokenValue: req.TokenValue,
|
|
VerifySSL: verifySSL,
|
|
Fingerprint: req.Fingerprint,
|
|
}
|
|
|
|
tempClient, err := pbs.NewClient(clientConfig)
|
|
if err != nil {
|
|
http.Error(w, sanitizeErrorMessage(err, "create_client"), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Try to get datastores to test connection
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
datastores, err := tempClient.GetDatastores(ctx)
|
|
if err != nil {
|
|
http.Error(w, sanitizeErrorMessage(err, "connection"), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
response := map[string]interface{}{
|
|
"status": "success",
|
|
"message": fmt.Sprintf("Successfully connected. Found %d datastore(s)", len(datastores)),
|
|
"datastoreCount": len(datastores),
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(response)
|
|
} else {
|
|
host := normalizedHost
|
|
|
|
verifySSL := false
|
|
if req.VerifySSL != nil {
|
|
verifySSL = *req.VerifySSL
|
|
}
|
|
clientConfig := config.CreatePMGConfigFromFields(host, req.User, req.Password, req.TokenName, req.TokenValue, req.Fingerprint, verifySSL)
|
|
|
|
if req.Password != "" && req.TokenName == "" && req.TokenValue == "" {
|
|
if clientConfig.User != "" && !strings.Contains(clientConfig.User, "@") {
|
|
clientConfig.User = clientConfig.User + "@pmg"
|
|
}
|
|
} else if req.TokenName != "" && req.TokenValue != "" {
|
|
if user != "" {
|
|
normalizedUser := user
|
|
if !strings.Contains(normalizedUser, "@") {
|
|
normalizedUser = normalizedUser + "@pmg"
|
|
}
|
|
clientConfig.User = normalizedUser
|
|
}
|
|
}
|
|
|
|
tempClient, err := pmg.NewClient(clientConfig)
|
|
if err != nil {
|
|
http.Error(w, sanitizeErrorMessage(err, "create_client"), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
defer cancel()
|
|
|
|
version, err := tempClient.GetVersion(ctx)
|
|
if err != nil {
|
|
http.Error(w, sanitizeErrorMessage(err, "connection"), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
versionLabel := ""
|
|
if version != nil && strings.TrimSpace(version.Version) != "" {
|
|
versionLabel = strings.TrimSpace(version.Version)
|
|
if strings.TrimSpace(version.Release) != "" {
|
|
versionLabel = versionLabel + "-" + strings.TrimSpace(version.Release)
|
|
}
|
|
}
|
|
|
|
// Test actual metrics endpoints to ensure monitoring will work
|
|
warnings := []string{}
|
|
|
|
// Test mail statistics endpoint (core PMG functionality)
|
|
if _, err := tempClient.GetMailStatistics(ctx, "day"); err != nil {
|
|
warnings = append(warnings, "Mail statistics endpoint unavailable - check user permissions")
|
|
log.Warn().Err(err).Msg("PMG connection test: mail statistics check failed")
|
|
}
|
|
|
|
// Test cluster status endpoint
|
|
if _, err := tempClient.GetClusterStatus(ctx, true); err != nil {
|
|
warnings = append(warnings, "Cluster status endpoint unavailable")
|
|
log.Warn().Err(err).Msg("PMG connection test: cluster status check failed")
|
|
}
|
|
|
|
// Test quarantine endpoint
|
|
if _, err := tempClient.GetQuarantineStatus(ctx, "spam"); err != nil {
|
|
warnings = append(warnings, "Quarantine endpoint unavailable")
|
|
log.Warn().Err(err).Msg("PMG connection test: quarantine check failed")
|
|
}
|
|
|
|
message := "Connected to PMG instance"
|
|
if versionLabel != "" {
|
|
message = fmt.Sprintf("Connected to PMG instance (version %s)", versionLabel)
|
|
}
|
|
if len(warnings) > 0 {
|
|
message += " (some metrics may be unavailable - check logs for details)"
|
|
}
|
|
|
|
response := map[string]interface{}{
|
|
"status": "success",
|
|
"message": message,
|
|
}
|
|
|
|
if version != nil {
|
|
if version.Version != "" {
|
|
response["version"] = strings.TrimSpace(version.Version)
|
|
}
|
|
if version.Release != "" {
|
|
response["release"] = strings.TrimSpace(version.Release)
|
|
}
|
|
}
|
|
|
|
if len(warnings) > 0 {
|
|
response["warnings"] = warnings
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(response)
|
|
}
|
|
}
|
|
|
|
func applyPVEAuthUpdate(updated *config.PVEInstance, req NodeConfigRequest) error {
|
|
tokenNameProvided := req.TokenName != ""
|
|
tokenValueProvided := req.TokenValue != ""
|
|
|
|
switch {
|
|
case tokenNameProvided && tokenValueProvided:
|
|
updated.TokenName = req.TokenName
|
|
updated.TokenValue = req.TokenValue
|
|
updated.Password = ""
|
|
if req.User != "" {
|
|
updated.User = req.User
|
|
}
|
|
case tokenValueProvided:
|
|
if strings.TrimSpace(updated.TokenName) == "" {
|
|
return fmt.Errorf("token value requires an existing token ID or tokenName")
|
|
}
|
|
updated.TokenValue = req.TokenValue
|
|
updated.Password = ""
|
|
if req.User != "" {
|
|
updated.User = req.User
|
|
}
|
|
case tokenNameProvided:
|
|
if strings.TrimSpace(updated.TokenValue) == "" {
|
|
return fmt.Errorf("tokenName without tokenValue cannot enable token authentication")
|
|
}
|
|
if updated.TokenName != "" && req.TokenName != updated.TokenName {
|
|
return fmt.Errorf("tokenName changes require a new tokenValue")
|
|
}
|
|
updated.TokenName = req.TokenName
|
|
updated.Password = ""
|
|
if req.User != "" {
|
|
updated.User = req.User
|
|
}
|
|
case req.Password != "":
|
|
if req.User != "" {
|
|
updated.User = normalizePVEUser(req.User)
|
|
} else if updated.User != "" {
|
|
updated.User = normalizePVEUser(updated.User)
|
|
}
|
|
updated.Password = req.Password
|
|
updated.TokenName = ""
|
|
updated.TokenValue = ""
|
|
default:
|
|
if req.User != "" && updated.Password != "" && updated.TokenName == "" && updated.TokenValue == "" {
|
|
updated.User = normalizePVEUser(req.User)
|
|
} else if updated.User != "" {
|
|
updated.User = normalizePVEUser(updated.User)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func applyPBSAuthUpdate(updated *config.PBSInstance, req NodeConfigRequest) error {
|
|
tokenNameProvided := req.TokenName != ""
|
|
tokenValueProvided := req.TokenValue != ""
|
|
|
|
switch {
|
|
case tokenNameProvided && tokenValueProvided:
|
|
updated.TokenName = req.TokenName
|
|
updated.TokenValue = req.TokenValue
|
|
updated.User = ""
|
|
updated.Password = ""
|
|
case tokenValueProvided:
|
|
if strings.TrimSpace(updated.TokenName) == "" {
|
|
return fmt.Errorf("token value requires an existing token ID or tokenName")
|
|
}
|
|
updated.TokenValue = req.TokenValue
|
|
updated.User = ""
|
|
updated.Password = ""
|
|
case tokenNameProvided:
|
|
if strings.TrimSpace(updated.TokenValue) == "" {
|
|
return fmt.Errorf("tokenName without tokenValue cannot enable token authentication")
|
|
}
|
|
if updated.TokenName != "" && req.TokenName != updated.TokenName {
|
|
return fmt.Errorf("tokenName changes require a new tokenValue")
|
|
}
|
|
updated.TokenName = req.TokenName
|
|
updated.User = ""
|
|
updated.Password = ""
|
|
case req.Password != "":
|
|
updated.Password = req.Password
|
|
updated.User = normalizePBSUser(req.User)
|
|
updated.TokenName = ""
|
|
updated.TokenValue = ""
|
|
default:
|
|
if req.User != "" && updated.Password != "" && updated.TokenName == "" && updated.TokenValue == "" {
|
|
updated.User = normalizePBSUser(req.User)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func applyPMGAuthUpdate(updated *config.PMGInstance, req NodeConfigRequest) error {
|
|
tokenNameProvided := req.TokenName != ""
|
|
tokenValueProvided := req.TokenValue != ""
|
|
|
|
switch {
|
|
case tokenNameProvided && tokenValueProvided:
|
|
updated.TokenName = req.TokenName
|
|
updated.TokenValue = req.TokenValue
|
|
updated.User = ""
|
|
updated.Password = ""
|
|
case tokenValueProvided:
|
|
if strings.TrimSpace(updated.TokenName) == "" {
|
|
return fmt.Errorf("token value requires an existing token ID or tokenName")
|
|
}
|
|
updated.TokenValue = req.TokenValue
|
|
updated.User = ""
|
|
updated.Password = ""
|
|
case tokenNameProvided:
|
|
if strings.TrimSpace(updated.TokenValue) == "" {
|
|
return fmt.Errorf("tokenName without tokenValue cannot enable token authentication")
|
|
}
|
|
if updated.TokenName != "" && req.TokenName != updated.TokenName {
|
|
return fmt.Errorf("tokenName changes require a new tokenValue")
|
|
}
|
|
updated.TokenName = req.TokenName
|
|
updated.User = ""
|
|
updated.Password = ""
|
|
case req.Password != "":
|
|
if req.User != "" {
|
|
updated.User = normalizePMGUser(req.User)
|
|
}
|
|
updated.Password = req.Password
|
|
updated.TokenName = ""
|
|
updated.TokenValue = ""
|
|
default:
|
|
if req.User != "" && updated.Password != "" && updated.TokenName == "" && updated.TokenValue == "" {
|
|
updated.User = normalizePMGUser(req.User)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func normalizePBSUser(user string) string {
|
|
user = strings.TrimSpace(user)
|
|
if user == "" || strings.Contains(user, "@") {
|
|
return user
|
|
}
|
|
return user + "@pbs"
|
|
}
|
|
|
|
func NormalizePBSUser(user string) string { return normalizePBSUser(user) }
|
|
|
|
func normalizePMGUser(user string) string {
|
|
user = strings.TrimSpace(user)
|
|
if user == "" || strings.Contains(user, "@") {
|
|
return user
|
|
}
|
|
return user + "@pmg"
|
|
}
|
|
|
|
func NormalizePMGUser(user string) string { return normalizePMGUser(user) }
|
|
|
|
// HandleUpdateNode updates an existing node
|
|
// normalizeClusterEndpointIPOverride validates a user-supplied per-member
|
|
// connection address and returns the canonical stored form. Empty clears the
|
|
// override. Accepts an IP or hostname, optionally with a port; a pasted URL
|
|
// is reduced to its host[:port]. The stored value stays scheme-less and a
|
|
// bare IPv6 address stays unbracketed because clusterEndpointEffectiveURL
|
|
// builds the URL (and re-brackets) at poll time.
|
|
func normalizeClusterEndpointIPOverride(raw string) (string, error) {
|
|
value := strings.TrimSpace(raw)
|
|
if value == "" {
|
|
return "", nil
|
|
}
|
|
|
|
if strings.Contains(value, "://") {
|
|
parsed, err := url.Parse(value)
|
|
if err != nil || parsed.Host == "" {
|
|
return "", fmt.Errorf("invalid connection address")
|
|
}
|
|
value = parsed.Host
|
|
}
|
|
if strings.ContainsAny(value, "/\\?#@ \t") {
|
|
return "", fmt.Errorf("invalid connection address")
|
|
}
|
|
|
|
host := value
|
|
port := ""
|
|
if splitHost, splitPort, err := net.SplitHostPort(value); err == nil {
|
|
if _, perr := strconv.ParseUint(splitPort, 10, 16); perr != nil {
|
|
return "", fmt.Errorf("invalid connection address")
|
|
}
|
|
host = splitHost
|
|
port = splitPort
|
|
}
|
|
host = strings.Trim(host, "[]")
|
|
if host == "" {
|
|
return "", fmt.Errorf("invalid connection address")
|
|
}
|
|
|
|
if net.ParseIP(host) == nil && !isPlausibleHostname(host) {
|
|
return "", fmt.Errorf("invalid connection address")
|
|
}
|
|
if port != "" {
|
|
return net.JoinHostPort(host, port), nil
|
|
}
|
|
return host, nil
|
|
}
|
|
|
|
func isPlausibleHostname(host string) bool {
|
|
if len(host) == 0 || len(host) > 253 {
|
|
return false
|
|
}
|
|
for _, label := range strings.Split(host, ".") {
|
|
if label == "" || len(label) > 63 {
|
|
return false
|
|
}
|
|
for _, r := range label {
|
|
if r != '-' && r != '_' && (r < '0' || r > '9') && (r < 'a' || r > 'z') && (r < 'A' || r > 'Z') {
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// applyClusterEndpointOverrides returns a copy of endpoints with the
|
|
// requested per-member IPOverride values applied. Members not named in
|
|
// overrides are left untouched; naming an unknown member is an error so a
|
|
// stale UI cannot silently no-op.
|
|
func applyClusterEndpointOverrides(endpoints []config.ClusterEndpoint, overrides []ClusterEndpointOverrideRequest) ([]config.ClusterEndpoint, error) {
|
|
result := append([]config.ClusterEndpoint(nil), endpoints...)
|
|
for _, override := range overrides {
|
|
nodeName := strings.TrimSpace(override.NodeName)
|
|
if nodeName == "" {
|
|
return nil, fmt.Errorf("cluster member name is required")
|
|
}
|
|
value, err := normalizeClusterEndpointIPOverride(override.IPOverride)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid connection address for cluster member %q: enter an IP or hostname, optionally with a port", nodeName)
|
|
}
|
|
found := false
|
|
for i := range result {
|
|
if strings.EqualFold(strings.TrimSpace(result[i].NodeName), nodeName) {
|
|
result[i].IPOverride = value
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
return nil, fmt.Errorf("unknown cluster member %q", nodeName)
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func applyClusterNodeDisplayNameOverrides(instance config.PVEInstance, overrides []ClusterNodeDisplayNameOverrideRequest) (config.PVEInstance, error) {
|
|
instance.ClusterEndpoints = append([]config.ClusterEndpoint(nil), instance.ClusterEndpoints...)
|
|
instance.ClusterNodeIdentities = append([]config.PVEClusterNodeIdentity(nil), instance.ClusterNodeIdentities...)
|
|
seen := make(map[string]struct{}, len(overrides))
|
|
for _, override := range overrides {
|
|
identityID := strings.TrimSpace(override.NodeIdentity)
|
|
if identityID == "" {
|
|
return instance, fmt.Errorf("cluster node identity is required")
|
|
}
|
|
if _, duplicate := seen[identityID]; duplicate {
|
|
return instance, fmt.Errorf("cluster node identity %q was provided more than once", identityID)
|
|
}
|
|
seen[identityID] = struct{}{}
|
|
|
|
displayName, err := config.NormalizePVEClusterNodeDisplayName(override.DisplayName)
|
|
if err != nil {
|
|
return instance, fmt.Errorf("display name for cluster node %q %w", identityID, err)
|
|
}
|
|
|
|
found := false
|
|
for idx := range instance.ClusterNodeIdentities {
|
|
if instance.ClusterNodeIdentities[idx].ID != identityID {
|
|
continue
|
|
}
|
|
instance.ClusterNodeIdentities[idx].DisplayName = displayName
|
|
found = true
|
|
break
|
|
}
|
|
if !found {
|
|
return instance, fmt.Errorf("unknown cluster node identity %q", identityID)
|
|
}
|
|
}
|
|
return instance, nil
|
|
}
|
|
|
|
func (h *ConfigHandlers) handleUpdateNode(w http.ResponseWriter, r *http.Request) {
|
|
// Prevent node modifications in mock mode
|
|
if mock.IsMockEnabled() {
|
|
http.Error(w, "Cannot modify nodes in mock mode", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
nodeID := strings.TrimPrefix(r.URL.Path, "/api/config/nodes/")
|
|
if nodeID == "" {
|
|
http.Error(w, "Node ID required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Limit request body to 32KB to prevent memory exhaustion
|
|
r.Body = http.MaxBytesReader(w, r.Body, 32*1024)
|
|
|
|
var req NodeConfigRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
req.normalizeTokenAliases()
|
|
|
|
// Debug: Log the received temperatureMonitoringEnabled value
|
|
log.Info().
|
|
Str("nodeID", nodeID).
|
|
Interface("temperatureMonitoringEnabled", req.TemperatureMonitoringEnabled).
|
|
Msg("Received node update request")
|
|
|
|
nodeType, index, err := resolveNodeID(h.getConfig(r.Context()), nodeID)
|
|
if err != nil {
|
|
if errors.Is(err, errNodeIDNotFound) {
|
|
http.Error(w, "Node not found", http.StatusNotFound)
|
|
} else {
|
|
http.Error(w, "Invalid node ID", http.StatusBadRequest)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Update the node
|
|
if nodeType == "pve" && index < len(h.getConfig(r.Context()).PVEInstances) {
|
|
pve := &h.getConfig(r.Context()).PVEInstances[index]
|
|
current := pve.DeepCopy()
|
|
normalizedCurrent := []config.PVEInstance{current}
|
|
config.EnsurePVEClusterNodeIdentities(normalizedCurrent)
|
|
current = normalizedCurrent[0]
|
|
updated := current
|
|
|
|
// Only update name if provided
|
|
if req.Name != "" {
|
|
updated.Name = req.Name
|
|
}
|
|
|
|
if req.Host != "" {
|
|
host, err := normalizeNodeHost(req.Host, nodeType)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
updated.Host = host
|
|
}
|
|
|
|
if req.hasGuestURLField() {
|
|
updated.GuestURL = req.GuestURL
|
|
}
|
|
|
|
if len(req.ClusterEndpointOverrides) > 0 {
|
|
endpoints, err := applyClusterEndpointOverrides(updated.ClusterEndpoints, req.ClusterEndpointOverrides)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
updated.ClusterEndpoints = endpoints
|
|
}
|
|
if len(req.ClusterNodeDisplayNameOverrides) > 0 {
|
|
updated, err = applyClusterNodeDisplayNameOverrides(updated, req.ClusterNodeDisplayNameOverrides)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
}
|
|
|
|
if err := applyPVEAuthUpdate(&updated, req); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if req.hasFingerprintField() {
|
|
updated.Fingerprint = req.Fingerprint
|
|
}
|
|
if req.VerifySSL != nil {
|
|
updated.VerifySSL = *req.VerifySSL
|
|
}
|
|
if req.MonitorVMs != nil {
|
|
updated.MonitorVMs = *req.MonitorVMs
|
|
}
|
|
if req.MonitorContainers != nil {
|
|
updated.MonitorContainers = *req.MonitorContainers
|
|
}
|
|
if req.MonitorStorage != nil {
|
|
updated.MonitorStorage = *req.MonitorStorage
|
|
}
|
|
if req.MonitorBackups != nil {
|
|
updated.MonitorBackups = *req.MonitorBackups
|
|
}
|
|
if req.MonitorPhysicalDisks != nil {
|
|
updated.MonitorPhysicalDisks = req.MonitorPhysicalDisks
|
|
}
|
|
if req.PhysicalDiskPollingMinutes != nil {
|
|
updated.PhysicalDiskPollingMinutes = *req.PhysicalDiskPollingMinutes
|
|
}
|
|
if req.TemperatureMonitoringEnabled != nil {
|
|
updated.TemperatureMonitoringEnabled = req.TemperatureMonitoringEnabled
|
|
}
|
|
if req.Enabled != nil {
|
|
updated.Disabled = !*req.Enabled
|
|
}
|
|
|
|
*pve = updated
|
|
} else if nodeType == "pbs" && index < len(h.getConfig(r.Context()).PBSInstances) {
|
|
pbs := &h.getConfig(r.Context()).PBSInstances[index]
|
|
current := *pbs
|
|
updated := current
|
|
|
|
// Only update name if provided
|
|
if req.Name != "" {
|
|
updated.Name = req.Name
|
|
}
|
|
|
|
if req.Host != "" {
|
|
host, err := normalizeNodeHost(req.Host, nodeType)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
updated.Host = host
|
|
}
|
|
|
|
if req.hasGuestURLField() {
|
|
updated.GuestURL = req.GuestURL
|
|
}
|
|
|
|
if err := applyPBSAuthUpdate(&updated, req); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if req.hasFingerprintField() {
|
|
updated.Fingerprint = req.Fingerprint
|
|
}
|
|
if req.VerifySSL != nil {
|
|
updated.VerifySSL = *req.VerifySSL
|
|
}
|
|
if req.MonitorBackups != nil {
|
|
updated.MonitorBackups = *req.MonitorBackups
|
|
}
|
|
if req.MonitorDatastores != nil {
|
|
updated.MonitorDatastores = *req.MonitorDatastores
|
|
}
|
|
if req.MonitorSyncJobs != nil {
|
|
updated.MonitorSyncJobs = *req.MonitorSyncJobs
|
|
}
|
|
if req.MonitorVerifyJobs != nil {
|
|
updated.MonitorVerifyJobs = *req.MonitorVerifyJobs
|
|
}
|
|
if req.MonitorPruneJobs != nil {
|
|
updated.MonitorPruneJobs = *req.MonitorPruneJobs
|
|
}
|
|
if req.MonitorGarbageJobs != nil {
|
|
updated.MonitorGarbageJobs = *req.MonitorGarbageJobs
|
|
}
|
|
if req.TemperatureMonitoringEnabled != nil {
|
|
updated.TemperatureMonitoringEnabled = req.TemperatureMonitoringEnabled
|
|
}
|
|
// Update datastore exclusion list
|
|
if req.ExcludeDatastores != nil {
|
|
updated.ExcludeDatastores = req.ExcludeDatastores
|
|
}
|
|
if req.Enabled != nil {
|
|
updated.Disabled = !*req.Enabled
|
|
}
|
|
|
|
*pbs = updated
|
|
} else if nodeType == "pmg" && index < len(h.getConfig(r.Context()).PMGInstances) {
|
|
pmgInst := &h.getConfig(r.Context()).PMGInstances[index]
|
|
current := *pmgInst
|
|
updated := current
|
|
if req.Name != "" {
|
|
updated.Name = req.Name
|
|
}
|
|
|
|
if req.Host != "" {
|
|
host, err := normalizeNodeHost(req.Host, nodeType)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
updated.Host = host
|
|
}
|
|
|
|
if req.hasGuestURLField() {
|
|
updated.GuestURL = req.GuestURL
|
|
}
|
|
|
|
if err := applyPMGAuthUpdate(&updated, req); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if req.hasFingerprintField() {
|
|
updated.Fingerprint = req.Fingerprint
|
|
}
|
|
if req.VerifySSL != nil {
|
|
updated.VerifySSL = *req.VerifySSL
|
|
}
|
|
if req.MonitorMailStats != nil {
|
|
updated.MonitorMailStats = *req.MonitorMailStats
|
|
}
|
|
if req.MonitorQueues != nil {
|
|
updated.MonitorQueues = *req.MonitorQueues
|
|
}
|
|
if req.MonitorQuarantine != nil {
|
|
updated.MonitorQuarantine = *req.MonitorQuarantine
|
|
}
|
|
if req.MonitorDomainStats != nil {
|
|
updated.MonitorDomainStats = *req.MonitorDomainStats
|
|
}
|
|
if req.TemperatureMonitoringEnabled != nil {
|
|
updated.TemperatureMonitoringEnabled = req.TemperatureMonitoringEnabled
|
|
}
|
|
if req.Enabled != nil {
|
|
updated.Disabled = !*req.Enabled
|
|
}
|
|
|
|
*pmgInst = updated
|
|
} else {
|
|
http.Error(w, "Node not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
// Save configuration to disk using our persistence instance
|
|
h.normalizePVEConfigState(r.Context())
|
|
if err := h.getPersistence(r.Context()).SaveNodesConfig(h.getConfig(r.Context()).PVEInstances, h.getConfig(r.Context()).PBSInstances, h.getConfig(r.Context()).PMGInstances); err != nil {
|
|
log.Error().Err(err).Msg("Failed to save nodes configuration")
|
|
http.Error(w, "Failed to save configuration", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// IMPORTANT: Preserve alert overrides when updating nodes
|
|
// This fixes issue #440 where PBS alert thresholds were being reset
|
|
// Alert overrides are stored separately from node configuration
|
|
// and must be explicitly preserved during node updates
|
|
if h.getMonitor(r.Context()) != nil {
|
|
// Load current alert configuration to preserve overrides
|
|
alertConfig, err := h.getPersistence(r.Context()).LoadAlertConfig()
|
|
if err == nil && alertConfig != nil {
|
|
// For PBS nodes, we need to handle ID mapping
|
|
// PBS monitoring uses "pbs-<name>" but config uses "pbs-<index>"
|
|
// We need to preserve overrides by the monitoring ID
|
|
if nodeType == "pbs" && index < len(h.getConfig(r.Context()).PBSInstances) {
|
|
pbsName := h.getConfig(r.Context()).PBSInstances[index].Name
|
|
monitoringID := "pbs-" + pbsName
|
|
|
|
// Check if there are overrides for this PBS node
|
|
if alertConfig.Overrides != nil {
|
|
if _, exists := alertConfig.Overrides[monitoringID]; exists {
|
|
log.Debug().
|
|
Str("nodeID", nodeID).
|
|
Str("monitoringID", monitoringID).
|
|
Str("pbsName", pbsName).
|
|
Msg("Preserving PBS alert overrides using monitoring ID")
|
|
}
|
|
}
|
|
}
|
|
|
|
// Apply the alert configuration to preserve all overrides
|
|
h.getMonitor(r.Context()).GetAlertManager().UpdateConfig(*alertConfig)
|
|
log.Debug().
|
|
Str("nodeID", nodeID).
|
|
Str("nodeType", nodeType).
|
|
Msg("Preserved alert overrides after node update")
|
|
}
|
|
}
|
|
|
|
// Reload monitor with new configuration
|
|
if h.reloadFunc != nil {
|
|
if err := h.reloadFunc(); err != nil {
|
|
log.Error().Err(err).Msg("Failed to reload monitor")
|
|
http.Error(w, "Configuration saved but failed to apply changes", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Trigger discovery refresh after adding node
|
|
if h.getMonitor(r.Context()) != nil && h.getMonitor(r.Context()).GetDiscoveryService() != nil {
|
|
log.Info().Msg("Triggering discovery refresh after adding node")
|
|
h.getMonitor(r.Context()).GetDiscoveryService().ForceRefresh()
|
|
|
|
// Broadcast discovery update via WebSocket
|
|
if h.wsHub != nil {
|
|
// Wait a moment for discovery to complete
|
|
go func() {
|
|
time.Sleep(2 * time.Second)
|
|
result, _ := h.getMonitor(r.Context()).GetDiscoveryService().GetCachedResult()
|
|
if result != nil {
|
|
h.wsHub.BroadcastMessage(websocket.Message{
|
|
Type: "discovery_update",
|
|
Data: map[string]interface{}{
|
|
"servers": result.Servers,
|
|
"errors": result.LegacyErrors(),
|
|
"structured_errors": result.StructuredErrors,
|
|
"timestamp": time.Now().Unix(),
|
|
},
|
|
Timestamp: time.Now().Format(time.RFC3339),
|
|
})
|
|
log.Info().Msg("Broadcasted discovery update after adding node")
|
|
}
|
|
}()
|
|
}
|
|
}
|
|
|
|
h.runtimeDependencies().AuditEvent(apicontext.OrgID(r.Context()), "node_updated", h.runtimeDependencies().AuthUsername(h.getConfig(r.Context()), r), h.runtimeDependencies().ClientIP(r), r.URL.Path, true,
|
|
fmt.Sprintf("Updated node %s", nodeID))
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
json.NewEncoder(w).Encode(map[string]string{"status": "success"})
|
|
}
|
|
|
|
func mergeDiscoveredClusterEndpointConfiguration(existing, discovered []config.ClusterEndpoint) []config.ClusterEndpoint {
|
|
merged := append([]config.ClusterEndpoint(nil), discovered...)
|
|
used := make(map[int]struct{}, len(existing))
|
|
for idx := range merged {
|
|
matchUnique := func(predicate func(config.ClusterEndpoint) bool) int {
|
|
match := -1
|
|
for existingIdx, candidate := range existing {
|
|
if _, alreadyUsed := used[existingIdx]; alreadyUsed || !predicate(candidate) {
|
|
continue
|
|
}
|
|
if match >= 0 {
|
|
return -1
|
|
}
|
|
match = existingIdx
|
|
}
|
|
return match
|
|
}
|
|
match := -1
|
|
if merged[idx].NativeNodeID != 0 {
|
|
match = matchUnique(func(candidate config.ClusterEndpoint) bool {
|
|
return candidate.NativeNodeID != 0 && candidate.NativeNodeID == merged[idx].NativeNodeID
|
|
})
|
|
}
|
|
if match < 0 && merged[idx].NodeName != "" {
|
|
match = matchUnique(func(candidate config.ClusterEndpoint) bool {
|
|
return strings.EqualFold(strings.TrimSpace(candidate.NodeName), strings.TrimSpace(merged[idx].NodeName))
|
|
})
|
|
}
|
|
if match < 0 && merged[idx].IP != "" {
|
|
match = matchUnique(func(candidate config.ClusterEndpoint) bool {
|
|
return strings.TrimSpace(candidate.IP) == strings.TrimSpace(merged[idx].IP)
|
|
})
|
|
}
|
|
if match < 0 && merged[idx].Host != "" {
|
|
match = matchUnique(func(candidate config.ClusterEndpoint) bool {
|
|
return strings.EqualFold(strings.TrimSpace(candidate.Host), strings.TrimSpace(merged[idx].Host))
|
|
})
|
|
}
|
|
if match < 0 {
|
|
continue
|
|
}
|
|
used[match] = struct{}{}
|
|
old := existing[match]
|
|
merged[idx].NodeIdentity = old.NodeIdentity
|
|
if merged[idx].NativeNodeID == 0 {
|
|
merged[idx].NativeNodeID = old.NativeNodeID
|
|
}
|
|
if merged[idx].IPOverride == "" {
|
|
merged[idx].IPOverride = old.IPOverride
|
|
}
|
|
if merged[idx].GuestURL == "" {
|
|
merged[idx].GuestURL = old.GuestURL
|
|
}
|
|
if merged[idx].Fingerprint == "" {
|
|
merged[idx].Fingerprint = old.Fingerprint
|
|
}
|
|
}
|
|
return merged
|
|
}
|
|
|
|
// HandleDeleteNode deletes a node
|
|
|
|
func (h *ConfigHandlers) handleDeleteNode(w http.ResponseWriter, r *http.Request) {
|
|
log.Info().Msg("HandleDeleteNode called")
|
|
|
|
// Prevent node modifications in mock mode
|
|
if mock.IsMockEnabled() {
|
|
http.Error(w, "Cannot modify nodes in mock mode", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
nodeID := strings.TrimPrefix(r.URL.Path, "/api/config/nodes/")
|
|
if nodeID == "" {
|
|
http.Error(w, "Node ID required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
nodeType, index, err := resolveNodeID(h.getConfig(r.Context()), nodeID)
|
|
if err != nil {
|
|
if errors.Is(err, errNodeIDNotFound) {
|
|
http.Error(w, "Node not found", http.StatusNotFound)
|
|
} else {
|
|
http.Error(w, "Invalid node ID", http.StatusBadRequest)
|
|
}
|
|
return
|
|
}
|
|
|
|
log.Debug().
|
|
Str("nodeID", nodeID).
|
|
Str("nodeType", nodeType).
|
|
Int("index", index).
|
|
Int("pveCount", len(h.getConfig(r.Context()).PVEInstances)).
|
|
Int("pbsCount", len(h.getConfig(r.Context()).PBSInstances)).
|
|
Int("pmgCount", len(h.getConfig(r.Context()).PMGInstances)).
|
|
Msg("Attempting to delete node")
|
|
|
|
var deletedNodeHost string
|
|
|
|
// Delete the node
|
|
if nodeType == "pve" && index < len(h.getConfig(r.Context()).PVEInstances) {
|
|
deletedNodeHost = h.getConfig(r.Context()).PVEInstances[index].Host
|
|
log.Info().Str("nodeID", nodeID).Int("index", index).Msg("Deleting PVE node")
|
|
h.getConfig(r.Context()).PVEInstances = append(h.getConfig(r.Context()).PVEInstances[:index], h.getConfig(r.Context()).PVEInstances[index+1:]...)
|
|
} else if nodeType == "pbs" && index < len(h.getConfig(r.Context()).PBSInstances) {
|
|
deletedNodeHost = h.getConfig(r.Context()).PBSInstances[index].Host
|
|
log.Info().Str("nodeID", nodeID).Int("index", index).Msg("Deleting PBS node")
|
|
h.getConfig(r.Context()).PBSInstances = append(h.getConfig(r.Context()).PBSInstances[:index], h.getConfig(r.Context()).PBSInstances[index+1:]...)
|
|
} else if nodeType == "pmg" && index < len(h.getConfig(r.Context()).PMGInstances) {
|
|
deletedNodeHost = h.getConfig(r.Context()).PMGInstances[index].Host
|
|
log.Info().Str("nodeID", nodeID).Int("index", index).Msg("Deleting PMG node")
|
|
h.getConfig(r.Context()).PMGInstances = append(h.getConfig(r.Context()).PMGInstances[:index], h.getConfig(r.Context()).PMGInstances[index+1:]...)
|
|
} else {
|
|
log.Warn().
|
|
Str("nodeID", nodeID).
|
|
Str("nodeType", nodeType).
|
|
Int("index", index).
|
|
Int("pveCount", len(h.getConfig(r.Context()).PVEInstances)).
|
|
Int("pbsCount", len(h.getConfig(r.Context()).PBSInstances)).
|
|
Int("pmgCount", len(h.getConfig(r.Context()).PMGInstances)).
|
|
Msg("Node not found for deletion")
|
|
http.Error(w, "Node not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
// Save configuration to disk using our persistence instance
|
|
if err := h.getPersistence(r.Context()).SaveNodesConfigAllowEmpty(h.getConfig(r.Context()).PVEInstances, h.getConfig(r.Context()).PBSInstances, h.getConfig(r.Context()).PMGInstances); err != nil {
|
|
log.Error().Err(err).Msg("Failed to save nodes configuration")
|
|
http.Error(w, "Failed to save configuration", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Immediately trigger discovery scan BEFORE reloading monitor
|
|
// Capture node type for cleanup
|
|
var deletedNodeType string = nodeType
|
|
|
|
// deletedNodeHost already captured before removal when available
|
|
|
|
// Reload monitor with new configuration
|
|
if h.reloadFunc != nil {
|
|
if err := h.reloadFunc(); err != nil {
|
|
log.Error().Err(err).Msg("Failed to reload monitor")
|
|
http.Error(w, "Configuration saved but failed to apply changes", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Broadcast node deletion to refresh the frontend
|
|
if h.wsHub != nil {
|
|
// Send a node_deleted message to trigger a refresh of the nodes list
|
|
h.wsHub.BroadcastMessage(websocket.Message{
|
|
Type: "node_deleted",
|
|
Data: map[string]interface{}{
|
|
"nodeType": nodeType,
|
|
},
|
|
Timestamp: time.Now().Format(time.RFC3339),
|
|
})
|
|
log.Info().Msg("Broadcasted node deletion event")
|
|
|
|
// Trigger a full discovery scan in the background to update the discovery cache
|
|
// This ensures the next time discovery modal is opened, it shows fresh results
|
|
go func() {
|
|
// Short delay to let the monitor stabilize
|
|
time.Sleep(500 * time.Millisecond)
|
|
|
|
// Trigger full discovery refresh
|
|
if h.getMonitor(r.Context()) != nil && h.getMonitor(r.Context()).GetDiscoveryService() != nil {
|
|
h.getMonitor(r.Context()).GetDiscoveryService().ForceRefresh()
|
|
log.Info().Msg("Triggered background discovery refresh after node deletion")
|
|
}
|
|
}()
|
|
}
|
|
|
|
if deletedNodeType == "pve" && deletedNodeHost != "" {
|
|
}
|
|
|
|
h.runtimeDependencies().AuditEvent(apicontext.OrgID(r.Context()), "node_deleted", h.runtimeDependencies().AuthUsername(h.getConfig(r.Context()), r), h.runtimeDependencies().ClientIP(r), r.URL.Path, true,
|
|
fmt.Sprintf("Deleted %s node %s", nodeType, nodeID))
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
json.NewEncoder(w).Encode(map[string]string{"status": "success"})
|
|
}
|
|
|
|
// HandleRefreshClusterNodes re-detects cluster membership and updates endpoints
|
|
// This handles the case where nodes are added to a Proxmox cluster after initial configuration
|
|
|
|
func (h *ConfigHandlers) handleRefreshClusterNodes(w http.ResponseWriter, r *http.Request) {
|
|
// Prevent modifications in mock mode
|
|
if mock.IsMockEnabled() {
|
|
http.Error(w, "Cannot refresh cluster in mock mode", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
// Path format: /api/config/nodes/{id}/refresh-cluster
|
|
path := strings.TrimPrefix(r.URL.Path, "/api/config/nodes/")
|
|
path = strings.TrimSuffix(path, "/refresh-cluster")
|
|
nodeID := path
|
|
|
|
if nodeID == "" {
|
|
http.Error(w, "Node ID required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
nodeType, index, err := resolveNodeID(h.getConfig(r.Context()), nodeID)
|
|
if err != nil {
|
|
if errors.Is(err, errNodeIDNotFound) {
|
|
http.Error(w, "Node not found", http.StatusNotFound)
|
|
} else {
|
|
http.Error(w, "Invalid node ID", http.StatusBadRequest)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Only PVE nodes can have clusters
|
|
if nodeType != "pve" {
|
|
http.Error(w, "Only PVE nodes can be cluster members", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if index >= len(h.getConfig(r.Context()).PVEInstances) {
|
|
http.Error(w, "Node not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
pve := &h.getConfig(r.Context()).PVEInstances[index]
|
|
|
|
// Create client config for cluster detection
|
|
clientConfig := config.CreateProxmoxConfig(pve)
|
|
|
|
// Force cluster re-detection (ignore existing endpoints)
|
|
isCluster, clusterName, clusterEndpoints := detectPVECluster(clientConfig, pve.Name, pve.ClusterEndpoints)
|
|
|
|
if !isCluster {
|
|
http.Error(w, "Node is not part of a cluster", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if len(clusterEndpoints) == 0 {
|
|
http.Error(w, "Could not detect cluster nodes", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
oldEndpointCount := len(pve.ClusterEndpoints)
|
|
newEndpointCount := len(clusterEndpoints)
|
|
|
|
// Update cluster info
|
|
pve.IsCluster = true
|
|
if clusterName != "" && !strings.EqualFold(clusterName, "unknown cluster") {
|
|
pve.ClusterName = clusterName
|
|
}
|
|
pve.ClusterEndpoints = mergeDiscoveredClusterEndpointConfiguration(pve.ClusterEndpoints, clusterEndpoints)
|
|
|
|
// Save configuration
|
|
h.normalizePVEConfigState(r.Context())
|
|
if err := h.getPersistence(r.Context()).SaveNodesConfig(h.getConfig(r.Context()).PVEInstances, h.getConfig(r.Context()).PBSInstances, h.getConfig(r.Context()).PMGInstances); err != nil {
|
|
log.Error().Err(err).Msg("Failed to save nodes configuration after cluster refresh")
|
|
http.Error(w, "Failed to save configuration", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
log.Info().
|
|
Str("instance", pve.Name).
|
|
Str("cluster", pve.ClusterName).
|
|
Int("old_endpoints", oldEndpointCount).
|
|
Int("new_endpoints", newEndpointCount).
|
|
Msg("Refreshed cluster membership")
|
|
|
|
// Reload monitor with new configuration
|
|
if h.reloadFunc != nil {
|
|
if err := h.reloadFunc(); err != nil {
|
|
log.Error().Err(err).Msg("Failed to reload monitor after cluster refresh")
|
|
// Don't fail the request, config was saved successfully
|
|
}
|
|
}
|
|
|
|
// Broadcast update to refresh frontend
|
|
if h.wsHub != nil {
|
|
h.wsHub.BroadcastMessage(websocket.Message{
|
|
Type: "nodes_updated",
|
|
Data: map[string]interface{}{
|
|
"nodeType": "pve",
|
|
"action": "cluster_refresh",
|
|
},
|
|
Timestamp: time.Now().Format(time.RFC3339),
|
|
})
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"status": "success",
|
|
"clusterName": pve.ClusterName,
|
|
"oldNodeCount": oldEndpointCount,
|
|
"newNodeCount": newEndpointCount,
|
|
"nodesAdded": newEndpointCount - oldEndpointCount,
|
|
"clusterNodes": toClusterEndpointResponses(pve.ClusterEndpoints, pve.ClusterNodeIdentities),
|
|
})
|
|
}
|
|
|
|
// HandleTestNodeConfig tests a node connection from provided configuration
|
|
|
|
func (h *ConfigHandlers) handleTestNodeConfig(w http.ResponseWriter, r *http.Request) {
|
|
// Limit request body to 32KB to prevent memory exhaustion
|
|
r.Body = http.MaxBytesReader(w, r.Body, 32*1024)
|
|
|
|
var req NodeConfigRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
req.normalizeTokenAliases()
|
|
|
|
var testResult map[string]interface{}
|
|
|
|
if req.Type == "pve" {
|
|
// Create a temporary client to test connection
|
|
authUser := req.User
|
|
if req.Password != "" && req.TokenName == "" && req.TokenValue == "" {
|
|
authUser = normalizePVEUser(authUser)
|
|
req.User = authUser
|
|
}
|
|
verifySSL := false
|
|
if req.VerifySSL != nil {
|
|
verifySSL = *req.VerifySSL
|
|
}
|
|
clientConfig := proxmox.ClientConfig{
|
|
Host: req.Host,
|
|
User: authUser,
|
|
Password: req.Password,
|
|
TokenName: req.TokenName,
|
|
TokenValue: req.TokenValue,
|
|
VerifySSL: verifySSL,
|
|
Fingerprint: req.Fingerprint,
|
|
}
|
|
client, err := proxmox.NewClient(clientConfig)
|
|
if err != nil {
|
|
testResult = map[string]interface{}{
|
|
"status": "error",
|
|
"message": sanitizeErrorMessage(err, "create_client"),
|
|
}
|
|
} else {
|
|
// Test connection by getting nodes list
|
|
startTime := time.Now()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
if nodes, err := client.GetNodes(ctx); err != nil {
|
|
testResult = map[string]interface{}{
|
|
"status": "error",
|
|
"message": sanitizeErrorMessage(err, "connection"),
|
|
}
|
|
} else {
|
|
latency := time.Since(startTime).Milliseconds()
|
|
testResult = map[string]interface{}{
|
|
"status": "success",
|
|
"message": fmt.Sprintf("Connected to PVE cluster with %d nodes", len(nodes)),
|
|
"latency": latency,
|
|
}
|
|
}
|
|
}
|
|
} else if req.Type == "pbs" {
|
|
testResult = testProxmoxBackupConnection(req)
|
|
} else if req.Type == "pmg" {
|
|
testResult = testProxmoxMailGatewayConnection(req)
|
|
} else {
|
|
http.Error(w, "Invalid node type", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Return appropriate HTTP status based on test result
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if testResult["status"] == "error" {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
}
|
|
json.NewEncoder(w).Encode(testResult)
|
|
}
|
|
|
|
// HandleTestNode tests a node connection
|
|
|
|
func (h *ConfigHandlers) handleTestNode(w http.ResponseWriter, r *http.Request) {
|
|
path := strings.TrimPrefix(r.URL.Path, "/api/config/nodes/")
|
|
parts := strings.Split(path, "/")
|
|
if len(parts) != 2 || parts[1] != "test" {
|
|
http.Error(w, "Invalid path", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
nodeID := parts[0]
|
|
|
|
nodeType, index, err := resolveNodeID(h.getConfig(r.Context()), nodeID)
|
|
if err != nil {
|
|
if errors.Is(err, errNodeIDNotFound) {
|
|
http.Error(w, "Node not found", http.StatusNotFound)
|
|
} else {
|
|
http.Error(w, "Invalid node ID", http.StatusBadRequest)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Find the node to test
|
|
var testResult map[string]interface{}
|
|
|
|
if nodeType == "pve" && index < len(h.getConfig(r.Context()).PVEInstances) {
|
|
pve := h.getConfig(r.Context()).PVEInstances[index]
|
|
|
|
// Create a temporary client to test connection
|
|
authUser := pve.User
|
|
if pve.TokenName == "" && pve.TokenValue == "" {
|
|
authUser = normalizePVEUser(authUser)
|
|
}
|
|
clientConfig := proxmox.ClientConfig{
|
|
Host: pve.Host,
|
|
User: authUser,
|
|
Password: pve.Password,
|
|
TokenName: pve.TokenName,
|
|
TokenValue: pve.TokenValue,
|
|
VerifySSL: pve.VerifySSL,
|
|
Fingerprint: pve.Fingerprint,
|
|
}
|
|
client, err := proxmox.NewClient(clientConfig)
|
|
if err != nil {
|
|
testResult = map[string]interface{}{
|
|
"status": "error",
|
|
"message": sanitizeErrorMessage(err, "create_client"),
|
|
}
|
|
} else {
|
|
// Test connection by getting nodes list
|
|
startTime := time.Now()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
if nodes, err := client.GetNodes(ctx); err != nil {
|
|
testResult = map[string]interface{}{
|
|
"status": "error",
|
|
"message": sanitizeErrorMessage(err, "connection"),
|
|
}
|
|
} else {
|
|
latency := time.Since(startTime).Milliseconds()
|
|
testResult = map[string]interface{}{
|
|
"status": "success",
|
|
"message": fmt.Sprintf("Connected to PVE cluster with %d nodes", len(nodes)),
|
|
"latency": latency,
|
|
}
|
|
}
|
|
}
|
|
} else if nodeType == "pbs" && index < len(h.getConfig(r.Context()).PBSInstances) {
|
|
pbsInstance := h.getConfig(r.Context()).PBSInstances[index]
|
|
|
|
// Create a temporary client to test connection
|
|
clientConfig := pbs.ClientConfig{
|
|
Host: pbsInstance.Host,
|
|
User: pbsInstance.User,
|
|
Password: pbsInstance.Password,
|
|
TokenName: pbsInstance.TokenName,
|
|
TokenValue: pbsInstance.TokenValue,
|
|
VerifySSL: pbsInstance.VerifySSL,
|
|
Fingerprint: pbsInstance.Fingerprint,
|
|
}
|
|
client, err := pbs.NewClient(clientConfig)
|
|
if err != nil {
|
|
testResult = map[string]interface{}{
|
|
"status": "error",
|
|
"message": sanitizeErrorMessage(err, "create_client"),
|
|
}
|
|
} else {
|
|
// Test connection by getting datastores
|
|
startTime := time.Now()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
if _, err := client.GetDatastores(ctx); err != nil {
|
|
testResult = map[string]interface{}{
|
|
"status": "error",
|
|
"message": sanitizeErrorMessage(err, "connection"),
|
|
}
|
|
} else {
|
|
latency := time.Since(startTime).Milliseconds()
|
|
testResult = map[string]interface{}{
|
|
"status": "success",
|
|
"message": "Connected to PBS",
|
|
"latency": latency,
|
|
}
|
|
}
|
|
}
|
|
} else if nodeType == "pmg" && index < len(h.getConfig(r.Context()).PMGInstances) {
|
|
pmgInstance := h.getConfig(r.Context()).PMGInstances[index]
|
|
|
|
clientConfig := config.CreatePMGConfig(&pmgInstance)
|
|
if pmgInstance.Password != "" && pmgInstance.TokenName == "" && pmgInstance.TokenValue == "" {
|
|
if clientConfig.User != "" && !strings.Contains(clientConfig.User, "@") {
|
|
clientConfig.User = clientConfig.User + "@pmg"
|
|
}
|
|
}
|
|
|
|
client, err := pmg.NewClient(clientConfig)
|
|
if err != nil {
|
|
testResult = map[string]interface{}{
|
|
"status": "error",
|
|
"message": sanitizeErrorMessage(err, "create_client"),
|
|
}
|
|
} else {
|
|
startTime := time.Now()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
if version, err := client.GetVersion(ctx); err != nil {
|
|
testResult = map[string]interface{}{
|
|
"status": "error",
|
|
"message": sanitizeErrorMessage(err, "connection"),
|
|
}
|
|
} else {
|
|
latency := time.Since(startTime).Milliseconds()
|
|
versionLabel := ""
|
|
if version != nil && strings.TrimSpace(version.Version) != "" {
|
|
versionLabel = strings.TrimSpace(version.Version)
|
|
if strings.TrimSpace(version.Release) != "" {
|
|
versionLabel = versionLabel + "-" + strings.TrimSpace(version.Release)
|
|
}
|
|
}
|
|
|
|
message := "Connected to PMG instance"
|
|
if versionLabel != "" {
|
|
message = fmt.Sprintf("Connected to PMG instance (version %s)", versionLabel)
|
|
}
|
|
|
|
testResult = map[string]interface{}{
|
|
"status": "success",
|
|
"message": message,
|
|
"latency": latency,
|
|
}
|
|
|
|
if version != nil {
|
|
if version.Version != "" {
|
|
testResult["version"] = strings.TrimSpace(version.Version)
|
|
}
|
|
if version.Release != "" {
|
|
testResult["release"] = strings.TrimSpace(version.Release)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
testResult = map[string]interface{}{
|
|
"status": "error",
|
|
"message": "Node not found",
|
|
}
|
|
}
|
|
|
|
// Return appropriate HTTP status based on test result
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if testResult["status"] == "error" {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
}
|
|
json.NewEncoder(w).Encode(testResult)
|
|
}
|
|
|
|
// testProxmoxPlatformConnection runs the shared PBS/PMG node connection
|
|
// probe: build a client from the request credentials, exercise one read
|
|
// endpoint with a 10s timeout, and report latency. connect returns the
|
|
// platform-specific probe call.
|
|
func testProxmoxPlatformConnection(req NodeConfigRequest, successMsg string, connect func(verifySSL bool) (func(context.Context) error, error)) map[string]interface{} {
|
|
verifySSL := false
|
|
if req.VerifySSL != nil {
|
|
verifySSL = *req.VerifySSL
|
|
}
|
|
probe, err := connect(verifySSL)
|
|
if err != nil {
|
|
return map[string]interface{}{
|
|
"status": "error",
|
|
"message": sanitizeErrorMessage(err, "create_client"),
|
|
}
|
|
}
|
|
|
|
startTime := time.Now()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
if err := probe(ctx); err != nil {
|
|
return map[string]interface{}{
|
|
"status": "error",
|
|
"message": sanitizeErrorMessage(err, "connection"),
|
|
}
|
|
}
|
|
latency := time.Since(startTime).Milliseconds()
|
|
return map[string]interface{}{
|
|
"status": "success",
|
|
"message": successMsg,
|
|
"latency": latency,
|
|
}
|
|
}
|
|
|
|
func testProxmoxBackupConnection(req NodeConfigRequest) map[string]interface{} {
|
|
return testProxmoxPlatformConnection(req, "Connected to PBS instance", func(verifySSL bool) (func(context.Context) error, error) {
|
|
client, err := pbs.NewClient(pbs.ClientConfig{
|
|
Host: req.Host,
|
|
User: req.User,
|
|
Password: req.Password,
|
|
TokenName: req.TokenName,
|
|
TokenValue: req.TokenValue,
|
|
VerifySSL: verifySSL,
|
|
Fingerprint: req.Fingerprint,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return func(ctx context.Context) error {
|
|
_, err := client.GetDatastores(ctx)
|
|
return err
|
|
}, nil
|
|
})
|
|
}
|
|
|
|
func testProxmoxMailGatewayConnection(req NodeConfigRequest) map[string]interface{} {
|
|
return testProxmoxPlatformConnection(req, "Connected to PMG instance", func(verifySSL bool) (func(context.Context) error, error) {
|
|
client, err := pmg.NewClient(pmg.ClientConfig{
|
|
Host: req.Host,
|
|
User: req.User,
|
|
Password: req.Password,
|
|
TokenName: req.TokenName,
|
|
TokenValue: req.TokenValue,
|
|
VerifySSL: verifySSL,
|
|
Fingerprint: req.Fingerprint,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return func(ctx context.Context) error {
|
|
_, err := client.GetVersion(ctx)
|
|
return err
|
|
}, nil
|
|
})
|
|
}
|