mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-22 11:13:26 +00:00
378ebcb250
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
2676 lines
87 KiB
Go
2676 lines
87 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/tokens"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/websocket"
|
|
"github.com/rcourtman/pulse-go-rewrite/pkg/discovery"
|
|
"github.com/rcourtman/pulse-go-rewrite/pkg/pbs"
|
|
"github.com/rcourtman/pulse-go-rewrite/pkg/proxmox"
|
|
"github.com/rs/zerolog/log"
|
|
)
|
|
|
|
// ConfigHandlers handles configuration-related API endpoints
|
|
type ConfigHandlers struct {
|
|
config *config.Config
|
|
persistence *config.ConfigPersistence
|
|
monitor *monitoring.Monitor
|
|
reloadFunc func() error
|
|
wsHub *websocket.Hub
|
|
tokenManager *tokens.TokenManager
|
|
}
|
|
|
|
// NewConfigHandlers creates a new ConfigHandlers instance
|
|
func NewConfigHandlers(cfg *config.Config, monitor *monitoring.Monitor, reloadFunc func() error, wsHub *websocket.Hub, tokenManager *tokens.TokenManager) *ConfigHandlers {
|
|
return &ConfigHandlers{
|
|
config: cfg,
|
|
persistence: config.NewConfigPersistence(cfg.DataPath),
|
|
monitor: monitor,
|
|
reloadFunc: reloadFunc,
|
|
wsHub: wsHub,
|
|
tokenManager: tokenManager,
|
|
}
|
|
}
|
|
|
|
// NodeConfigRequest represents a request to add/update a node
|
|
type NodeConfigRequest struct {
|
|
Type string `json:"type"` // "pve" or "pbs"
|
|
Name string `json:"name"`
|
|
Host string `json:"host"`
|
|
User string `json:"user,omitempty"`
|
|
Password string `json:"password,omitempty"`
|
|
TokenName string `json:"tokenName,omitempty"`
|
|
TokenValue string `json:"tokenValue,omitempty"`
|
|
Fingerprint string `json:"fingerprint,omitempty"`
|
|
VerifySSL bool `json:"verifySSL"`
|
|
MonitorVMs bool `json:"monitorVMs,omitempty"` // PVE only
|
|
MonitorContainers bool `json:"monitorContainers,omitempty"` // PVE only
|
|
MonitorStorage bool `json:"monitorStorage,omitempty"` // PVE only
|
|
MonitorBackups bool `json:"monitorBackups,omitempty"` // PVE only
|
|
MonitorDatastores bool `json:"monitorDatastores,omitempty"` // PBS only
|
|
MonitorSyncJobs bool `json:"monitorSyncJobs,omitempty"` // PBS only
|
|
MonitorVerifyJobs bool `json:"monitorVerifyJobs,omitempty"` // PBS only
|
|
MonitorPruneJobs bool `json:"monitorPruneJobs,omitempty"` // PBS only
|
|
MonitorGarbageJobs bool `json:"monitorGarbageJobs,omitempty"` // PBS only
|
|
}
|
|
|
|
// NodeResponse represents a node in API responses
|
|
type NodeResponse struct {
|
|
ID string `json:"id"`
|
|
Type string `json:"type"`
|
|
Name string `json:"name"`
|
|
Host string `json:"host"`
|
|
User string `json:"user,omitempty"`
|
|
HasPassword bool `json:"hasPassword"`
|
|
TokenName string `json:"tokenName,omitempty"`
|
|
HasToken bool `json:"hasToken"`
|
|
Fingerprint string `json:"fingerprint,omitempty"`
|
|
VerifySSL bool `json:"verifySSL"`
|
|
MonitorVMs bool `json:"monitorVMs,omitempty"`
|
|
MonitorContainers bool `json:"monitorContainers,omitempty"`
|
|
MonitorStorage bool `json:"monitorStorage,omitempty"`
|
|
MonitorBackups bool `json:"monitorBackups,omitempty"`
|
|
MonitorDatastores bool `json:"monitorDatastores,omitempty"`
|
|
MonitorSyncJobs bool `json:"monitorSyncJobs,omitempty"`
|
|
MonitorVerifyJobs bool `json:"monitorVerifyJobs,omitempty"`
|
|
MonitorPruneJobs bool `json:"monitorPruneJobs,omitempty"`
|
|
MonitorGarbageJobs bool `json:"monitorGarbageJobs,omitempty"`
|
|
Status string `json:"status"` // "connected", "disconnected", "error"
|
|
IsCluster bool `json:"isCluster,omitempty"`
|
|
ClusterName string `json:"clusterName,omitempty"`
|
|
ClusterEndpoints []config.ClusterEndpoint `json:"clusterEndpoints,omitempty"`
|
|
}
|
|
|
|
// detectPVECluster checks if a PVE node is part of a cluster and returns cluster information
|
|
func detectPVECluster(clientConfig proxmox.ClientConfig, nodeName string) (isCluster bool, clusterName string, clusterEndpoints []config.ClusterEndpoint) {
|
|
tempClient, err := proxmox.NewClient(clientConfig)
|
|
if err != nil {
|
|
log.Warn().Err(err).Msg("Failed to create client for cluster detection")
|
|
return false, "", nil
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
// Get full cluster status to find the actual cluster name
|
|
clusterStatus, err := tempClient.GetClusterStatus(ctx)
|
|
if err != nil {
|
|
log.Warn().Err(err).Msg("Failed to get cluster status")
|
|
return false, "", nil
|
|
}
|
|
|
|
// Find the cluster name and collect nodes
|
|
var clusterNodes []proxmox.ClusterStatus
|
|
for _, status := range clusterStatus {
|
|
if status.Type == "cluster" {
|
|
// This is the actual cluster name
|
|
clusterName = status.Name
|
|
log.Info().Str("cluster_name", clusterName).Msg("Found cluster name")
|
|
} else if status.Type == "node" {
|
|
clusterNodes = append(clusterNodes, status)
|
|
}
|
|
}
|
|
|
|
log.Info().Int("cluster_nodes", len(clusterNodes)).Msg("Got cluster nodes")
|
|
|
|
if len(clusterNodes) > 1 {
|
|
isCluster = true
|
|
log.Info().
|
|
Str("cluster", clusterName).
|
|
Str("node", nodeName).
|
|
Int("nodes", len(clusterNodes)).
|
|
Msg("Detected Proxmox cluster")
|
|
|
|
for _, clusterNode := range clusterNodes {
|
|
endpoint := config.ClusterEndpoint{
|
|
NodeID: clusterNode.ID,
|
|
NodeName: clusterNode.Name,
|
|
Host: clusterNode.Name,
|
|
Online: clusterNode.Online == 1,
|
|
LastSeen: time.Now(),
|
|
}
|
|
|
|
if clusterNode.IP != "" {
|
|
endpoint.IP = clusterNode.IP
|
|
}
|
|
|
|
clusterEndpoints = append(clusterEndpoints, endpoint)
|
|
}
|
|
|
|
// Fallback if we couldn't get the cluster name
|
|
if clusterName == "" {
|
|
clusterName = "Unknown Cluster"
|
|
}
|
|
}
|
|
|
|
return isCluster, clusterName, clusterEndpoints
|
|
}
|
|
|
|
// GetAllNodesForAPI returns all configured nodes for API responses
|
|
func (h *ConfigHandlers) GetAllNodesForAPI() []NodeResponse {
|
|
nodes := []NodeResponse{}
|
|
|
|
// Add PVE nodes
|
|
for i, pve := range h.config.PVEInstances {
|
|
node := NodeResponse{
|
|
ID: generateNodeID("pve", i),
|
|
Type: "pve",
|
|
Name: pve.Name,
|
|
Host: pve.Host,
|
|
User: pve.User,
|
|
HasPassword: pve.Password != "",
|
|
TokenName: pve.TokenName,
|
|
HasToken: pve.TokenValue != "",
|
|
Fingerprint: pve.Fingerprint,
|
|
VerifySSL: pve.VerifySSL,
|
|
MonitorVMs: pve.MonitorVMs,
|
|
MonitorContainers: pve.MonitorContainers,
|
|
MonitorStorage: pve.MonitorStorage,
|
|
MonitorBackups: pve.MonitorBackups,
|
|
Status: h.getNodeStatus("pve", pve.Name),
|
|
IsCluster: pve.IsCluster,
|
|
ClusterName: pve.ClusterName,
|
|
ClusterEndpoints: pve.ClusterEndpoints,
|
|
}
|
|
nodes = append(nodes, node)
|
|
}
|
|
|
|
// Add PBS nodes
|
|
for i, pbs := range h.config.PBSInstances {
|
|
node := NodeResponse{
|
|
ID: generateNodeID("pbs", i),
|
|
Type: "pbs",
|
|
Name: pbs.Name,
|
|
Host: pbs.Host,
|
|
User: pbs.User,
|
|
HasPassword: pbs.Password != "",
|
|
TokenName: pbs.TokenName,
|
|
HasToken: pbs.TokenValue != "",
|
|
Fingerprint: pbs.Fingerprint,
|
|
VerifySSL: pbs.VerifySSL,
|
|
MonitorDatastores: pbs.MonitorDatastores,
|
|
MonitorSyncJobs: pbs.MonitorSyncJobs,
|
|
MonitorVerifyJobs: pbs.MonitorVerifyJobs,
|
|
MonitorPruneJobs: pbs.MonitorPruneJobs,
|
|
MonitorGarbageJobs: pbs.MonitorGarbageJobs,
|
|
Status: h.getNodeStatus("pbs", pbs.Name),
|
|
}
|
|
nodes = append(nodes, node)
|
|
}
|
|
|
|
return nodes
|
|
}
|
|
|
|
// HandleGetNodes returns all configured nodes
|
|
func (h *ConfigHandlers) HandleGetNodes(w http.ResponseWriter, r *http.Request) {
|
|
nodes := h.GetAllNodesForAPI()
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(nodes)
|
|
}
|
|
|
|
// HandleAddNode adds a new node
|
|
func (h *ConfigHandlers) HandleAddNode(w http.ResponseWriter, r *http.Request) {
|
|
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
|
|
}
|
|
|
|
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 request
|
|
if req.Host == "" {
|
|
http.Error(w, "Host is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if req.Type != "pve" && req.Type != "pbs" {
|
|
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
|
|
}
|
|
|
|
// Add to appropriate list
|
|
if req.Type == "pve" {
|
|
// Ensure host has protocol
|
|
host := req.Host
|
|
if !strings.HasPrefix(host, "http://") && !strings.HasPrefix(host, "https://") {
|
|
host = "https://" + host
|
|
}
|
|
// Add port if missing
|
|
if !strings.Contains(host, ":8006") && !strings.Contains(host, ":443") {
|
|
if strings.HasPrefix(host, "https://") {
|
|
host = strings.Replace(host, "https://", "https://", 1)
|
|
if !strings.Contains(host[8:], ":") {
|
|
host += ":8006"
|
|
}
|
|
}
|
|
}
|
|
|
|
// Auto-generate name if not provided
|
|
if req.Name == "" {
|
|
// Extract hostname from URL
|
|
nameHost := host
|
|
if strings.HasPrefix(nameHost, "http://") {
|
|
nameHost = strings.TrimPrefix(nameHost, "http://")
|
|
}
|
|
if strings.HasPrefix(nameHost, "https://") {
|
|
nameHost = strings.TrimPrefix(nameHost, "https://")
|
|
}
|
|
// Remove port
|
|
if colonIndex := strings.Index(nameHost, ":"); colonIndex != -1 {
|
|
nameHost = nameHost[:colonIndex]
|
|
}
|
|
req.Name = nameHost
|
|
}
|
|
|
|
// Check if node is part of a cluster
|
|
clientConfig := config.CreateProxmoxConfigFromFields(host, req.User, req.Password, req.TokenName, req.TokenValue, req.Fingerprint, req.VerifySSL)
|
|
|
|
isCluster, clusterName, clusterEndpoints := detectPVECluster(clientConfig, req.Name)
|
|
|
|
if isCluster {
|
|
log.Info().
|
|
Str("cluster", clusterName).
|
|
Int("endpoints", len(clusterEndpoints)).
|
|
Msg("Detected Proxmox cluster, auto-discovering all nodes")
|
|
}
|
|
|
|
pve := config.PVEInstance{
|
|
Name: req.Name,
|
|
Host: host, // Use normalized host
|
|
User: req.User,
|
|
Password: req.Password,
|
|
TokenName: req.TokenName,
|
|
TokenValue: req.TokenValue,
|
|
Fingerprint: req.Fingerprint,
|
|
VerifySSL: req.VerifySSL,
|
|
MonitorVMs: req.MonitorVMs,
|
|
MonitorContainers: req.MonitorContainers,
|
|
MonitorStorage: req.MonitorStorage,
|
|
MonitorBackups: req.MonitorBackups,
|
|
IsCluster: isCluster,
|
|
ClusterName: clusterName,
|
|
ClusterEndpoints: clusterEndpoints,
|
|
}
|
|
h.config.PVEInstances = append(h.config.PVEInstances, pve)
|
|
|
|
if isCluster {
|
|
log.Info().
|
|
Str("cluster", clusterName).
|
|
Int("endpoints", len(clusterEndpoints)).
|
|
Msg("Added Proxmox cluster with auto-discovered endpoints")
|
|
}
|
|
} else {
|
|
// PBS node - ensure host has protocol and port
|
|
host := req.Host
|
|
if !strings.HasPrefix(host, "http://") && !strings.HasPrefix(host, "https://") {
|
|
host = "https://" + host
|
|
}
|
|
// Add default PBS port if missing
|
|
if !strings.Contains(host, ":8007") && !strings.Contains(host[8:], ":") {
|
|
host = host + ":8007"
|
|
}
|
|
|
|
// Auto-generate name if not provided
|
|
if req.Name == "" {
|
|
// Try to get the actual hostname from PBS
|
|
discovered := false
|
|
|
|
// Create a temporary PBS client to discover the hostname
|
|
pbsConfig := pbs.ClientConfig{
|
|
Host: host,
|
|
TokenName: req.TokenName,
|
|
TokenValue: req.TokenValue,
|
|
User: req.User,
|
|
Password: req.Password,
|
|
VerifySSL: req.VerifySSL,
|
|
Fingerprint: req.Fingerprint,
|
|
Timeout: 5 * time.Second,
|
|
}
|
|
|
|
if tempClient, err := pbs.NewClient(pbsConfig); err == nil {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
if nodeName, err := tempClient.GetNodeName(ctx); err == nil && nodeName != "" {
|
|
req.Name = nodeName
|
|
discovered = true
|
|
log.Info().Str("discoveredName", nodeName).Msg("Auto-discovered PBS hostname")
|
|
}
|
|
}
|
|
|
|
// Fallback to extracting from URL if discovery failed
|
|
if !discovered {
|
|
nameHost := host
|
|
if strings.HasPrefix(nameHost, "http://") {
|
|
nameHost = strings.TrimPrefix(nameHost, "http://")
|
|
}
|
|
if strings.HasPrefix(nameHost, "https://") {
|
|
nameHost = strings.TrimPrefix(nameHost, "https://")
|
|
}
|
|
// Remove port
|
|
if colonIndex := strings.Index(nameHost, ":"); colonIndex != -1 {
|
|
nameHost = nameHost[:colonIndex]
|
|
}
|
|
req.Name = nameHost
|
|
}
|
|
}
|
|
|
|
// 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 - don't store token fields
|
|
pbsUser = req.User
|
|
pbsPassword = req.Password
|
|
// Ensure user has realm for PBS
|
|
if pbsUser != "" && !strings.Contains(pbsUser, "@") {
|
|
pbsUser = pbsUser + "@pbs" // Default to @pbs realm if not specified
|
|
}
|
|
}
|
|
|
|
pbs := config.PBSInstance{
|
|
Name: req.Name,
|
|
Host: host,
|
|
User: pbsUser,
|
|
Password: pbsPassword,
|
|
TokenName: pbsTokenName,
|
|
TokenValue: pbsTokenValue,
|
|
Fingerprint: req.Fingerprint,
|
|
VerifySSL: req.VerifySSL,
|
|
MonitorBackups: true, // Enable by default for PBS
|
|
MonitorDatastores: req.MonitorDatastores,
|
|
MonitorSyncJobs: req.MonitorSyncJobs,
|
|
MonitorVerifyJobs: req.MonitorVerifyJobs,
|
|
MonitorPruneJobs: req.MonitorPruneJobs,
|
|
MonitorGarbageJobs: req.MonitorGarbageJobs,
|
|
}
|
|
h.config.PBSInstances = append(h.config.PBSInstances, pbs)
|
|
}
|
|
|
|
// Save configuration to disk using our persistence instance
|
|
if err := h.persistence.SaveNodesConfig(h.config.PVEInstances, h.config.PBSInstances); 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
|
|
}
|
|
}
|
|
|
|
w.WriteHeader(http.StatusCreated)
|
|
json.NewEncoder(w).Encode(map[string]string{"status": "success"})
|
|
}
|
|
|
|
// HandleTestConnection tests a node connection without saving
|
|
func (h *ConfigHandlers) HandleTestConnection(w http.ResponseWriter, r *http.Request) {
|
|
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
|
|
}
|
|
|
|
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 := req.Host
|
|
if strings.HasPrefix(host, "http://") {
|
|
host = strings.TrimPrefix(host, "http://")
|
|
}
|
|
if strings.HasPrefix(host, "https://") {
|
|
host = strings.TrimPrefix(host, "https://")
|
|
}
|
|
// Remove port
|
|
if colonIndex := strings.Index(host, ":"); colonIndex != -1 {
|
|
host = host[:colonIndex]
|
|
}
|
|
req.Name = host
|
|
}
|
|
|
|
if req.Type != "pve" && req.Type != "pbs" {
|
|
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
|
|
}
|
|
|
|
// Test connection based on type
|
|
if req.Type == "pve" {
|
|
// Ensure host has protocol
|
|
host := req.Host
|
|
if !strings.HasPrefix(host, "http://") && !strings.HasPrefix(host, "https://") {
|
|
host = "https://" + host
|
|
}
|
|
// Add port if missing
|
|
if !strings.Contains(host, ":8006") && !strings.Contains(host, ":443") {
|
|
if strings.HasPrefix(host, "https://") {
|
|
host = strings.Replace(host, "https://", "https://", 1)
|
|
if !strings.Contains(host[8:], ":") {
|
|
host += ":8006"
|
|
}
|
|
}
|
|
}
|
|
|
|
// Create a temporary client
|
|
clientConfig := proxmox.ClientConfig{
|
|
Host: host,
|
|
User: req.User,
|
|
Password: req.Password,
|
|
TokenName: req.TokenName, // Pass the full token ID
|
|
TokenValue: req.TokenValue,
|
|
VerifySSL: req.VerifySSL,
|
|
Fingerprint: req.Fingerprint,
|
|
}
|
|
|
|
tempClient, err := proxmox.NewClient(clientConfig)
|
|
if err != nil {
|
|
http.Error(w, fmt.Sprintf("Failed to create client: %v", err), 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, fmt.Sprintf("Connection failed: %v", err), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
isCluster, _, clusterEndpoints := detectPVECluster(clientConfig, req.Name)
|
|
|
|
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 {
|
|
// Ensure host has protocol for PBS
|
|
host := req.Host
|
|
if !strings.HasPrefix(host, "http://") && !strings.HasPrefix(host, "https://") {
|
|
host = "https://" + host
|
|
}
|
|
// Add port if missing (PBS defaults to port 8007)
|
|
if strings.HasPrefix(host, "https://") {
|
|
// Check if there's already a port specified after the protocol
|
|
hostWithoutProtocol := host[8:] // Remove "https://"
|
|
if !strings.Contains(hostWithoutProtocol, ":") {
|
|
// No port specified, add default PBS port
|
|
host += ":8007"
|
|
}
|
|
} else if strings.HasPrefix(host, "http://") {
|
|
// Check if there's already a port specified after the protocol
|
|
hostWithoutProtocol := host[7:] // Remove "http://"
|
|
if !strings.Contains(hostWithoutProtocol, ":") {
|
|
// No port specified, add default PBS port
|
|
host += ":8007"
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
clientConfig := pbs.ClientConfig{
|
|
Host: host,
|
|
User: pbsUser,
|
|
Password: req.Password,
|
|
TokenName: pbsTokenName,
|
|
TokenValue: req.TokenValue,
|
|
VerifySSL: req.VerifySSL,
|
|
Fingerprint: req.Fingerprint,
|
|
}
|
|
|
|
tempClient, err := pbs.NewClient(clientConfig)
|
|
if err != nil {
|
|
http.Error(w, fmt.Sprintf("Failed to create client: %v", err), 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, fmt.Sprintf("Connection failed: %v", err), 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)
|
|
}
|
|
}
|
|
|
|
// HandleUpdateNode updates an existing node
|
|
func (h *ConfigHandlers) HandleUpdateNode(w http.ResponseWriter, r *http.Request) {
|
|
nodeID := strings.TrimPrefix(r.URL.Path, "/api/config/nodes/")
|
|
if nodeID == "" {
|
|
http.Error(w, "Node ID required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
var req NodeConfigRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Parse node ID
|
|
parts := strings.Split(nodeID, "-")
|
|
if len(parts) != 2 {
|
|
http.Error(w, "Invalid node ID", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
nodeType := parts[0]
|
|
index := 0
|
|
if _, err := fmt.Sscanf(parts[1], "%d", &index); err != nil {
|
|
http.Error(w, "Invalid node ID", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Update the node
|
|
if nodeType == "pve" && index < len(h.config.PVEInstances) {
|
|
pve := &h.config.PVEInstances[index]
|
|
pve.Name = req.Name
|
|
pve.Host = req.Host
|
|
if req.User != "" {
|
|
pve.User = req.User
|
|
}
|
|
if req.Password != "" {
|
|
pve.Password = req.Password
|
|
}
|
|
if req.TokenName != "" {
|
|
pve.TokenName = req.TokenName
|
|
}
|
|
if req.TokenValue != "" {
|
|
pve.TokenValue = req.TokenValue
|
|
}
|
|
pve.Fingerprint = req.Fingerprint
|
|
pve.VerifySSL = req.VerifySSL
|
|
pve.MonitorVMs = req.MonitorVMs
|
|
pve.MonitorContainers = req.MonitorContainers
|
|
pve.MonitorStorage = req.MonitorStorage
|
|
pve.MonitorBackups = req.MonitorBackups
|
|
} else if nodeType == "pbs" && index < len(h.config.PBSInstances) {
|
|
pbs := &h.config.PBSInstances[index]
|
|
pbs.Name = req.Name
|
|
|
|
// Ensure PBS host has protocol and port
|
|
host := req.Host
|
|
if !strings.HasPrefix(host, "http://") && !strings.HasPrefix(host, "https://") {
|
|
host = "https://" + host
|
|
}
|
|
if !strings.Contains(host, ":8007") && !strings.Contains(host[8:], ":") {
|
|
host = host + ":8007"
|
|
}
|
|
pbs.Host = host
|
|
|
|
// Determine authentication method and clear the unused fields
|
|
if req.TokenName != "" && req.TokenValue != "" {
|
|
// Using token authentication - clear user/password
|
|
pbs.User = ""
|
|
pbs.Password = ""
|
|
pbs.TokenName = req.TokenName
|
|
pbs.TokenValue = req.TokenValue
|
|
} else if req.TokenName != "" {
|
|
// Token name provided without new value - keep existing token value
|
|
pbs.User = ""
|
|
pbs.Password = ""
|
|
pbs.TokenName = req.TokenName
|
|
} else if req.Password != "" {
|
|
// Using password authentication - clear token fields
|
|
pbs.TokenName = ""
|
|
pbs.TokenValue = ""
|
|
pbs.Password = req.Password
|
|
// Ensure user has realm for PBS
|
|
pbsUser := req.User
|
|
if req.User != "" && !strings.Contains(req.User, "@") {
|
|
pbsUser = req.User + "@pbs" // Default to @pbs realm if not specified
|
|
}
|
|
pbs.User = pbsUser
|
|
} else if req.User != "" {
|
|
// User provided without password - keep existing password if any
|
|
pbs.TokenName = ""
|
|
pbs.TokenValue = ""
|
|
// Ensure user has realm for PBS
|
|
pbsUser := req.User
|
|
if !strings.Contains(req.User, "@") {
|
|
pbsUser = req.User + "@pbs" // Default to @pbs realm if not specified
|
|
}
|
|
pbs.User = pbsUser
|
|
}
|
|
|
|
pbs.Fingerprint = req.Fingerprint
|
|
pbs.VerifySSL = req.VerifySSL
|
|
pbs.MonitorBackups = true // Enable by default for PBS
|
|
pbs.MonitorDatastores = req.MonitorDatastores
|
|
pbs.MonitorSyncJobs = req.MonitorSyncJobs
|
|
pbs.MonitorVerifyJobs = req.MonitorVerifyJobs
|
|
pbs.MonitorPruneJobs = req.MonitorPruneJobs
|
|
pbs.MonitorGarbageJobs = req.MonitorGarbageJobs
|
|
} else {
|
|
http.Error(w, "Node not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
// Save configuration to disk using our persistence instance
|
|
if err := h.persistence.SaveNodesConfig(h.config.PVEInstances, h.config.PBSInstances); 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
|
|
}
|
|
}
|
|
|
|
// Trigger discovery refresh after adding node
|
|
if h.monitor != nil && h.monitor.GetDiscoveryService() != nil {
|
|
log.Info().Msg("Triggering discovery refresh after adding node")
|
|
h.monitor.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.monitor.GetDiscoveryService().GetCachedResult()
|
|
if result != nil {
|
|
h.wsHub.BroadcastMessage(websocket.Message{
|
|
Type: "discovery_update",
|
|
Data: map[string]interface{}{
|
|
"servers": result.Servers,
|
|
"errors": result.Errors,
|
|
"timestamp": time.Now().Unix(),
|
|
},
|
|
Timestamp: time.Now().Format(time.RFC3339),
|
|
})
|
|
log.Info().Msg("Broadcasted discovery update after adding node")
|
|
}
|
|
}()
|
|
}
|
|
}
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
json.NewEncoder(w).Encode(map[string]string{"status": "success"})
|
|
}
|
|
|
|
// HandleDeleteNode deletes a node
|
|
func (h *ConfigHandlers) HandleDeleteNode(w http.ResponseWriter, r *http.Request) {
|
|
nodeID := strings.TrimPrefix(r.URL.Path, "/api/config/nodes/")
|
|
if nodeID == "" {
|
|
http.Error(w, "Node ID required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Parse node ID
|
|
parts := strings.Split(nodeID, "-")
|
|
if len(parts) != 2 {
|
|
http.Error(w, "Invalid node ID", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
nodeType := parts[0]
|
|
index := 0
|
|
if _, err := fmt.Sscanf(parts[1], "%d", &index); err != nil {
|
|
http.Error(w, "Invalid node ID", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Delete the node
|
|
if nodeType == "pve" && index < len(h.config.PVEInstances) {
|
|
h.config.PVEInstances = append(h.config.PVEInstances[:index], h.config.PVEInstances[index+1:]...)
|
|
} else if nodeType == "pbs" && index < len(h.config.PBSInstances) {
|
|
h.config.PBSInstances = append(h.config.PBSInstances[:index], h.config.PBSInstances[index+1:]...)
|
|
} else {
|
|
http.Error(w, "Node not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
// Save configuration to disk using our persistence instance
|
|
if err := h.persistence.SaveNodesConfig(h.config.PVEInstances, h.config.PBSInstances); 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
|
|
// This way we can get the deleted node's info for immediate discovery
|
|
var deletedNodeHost string
|
|
var deletedNodeType string = nodeType
|
|
|
|
// Get the host info of the deleted node for quick re-discovery
|
|
if nodeType == "pve" && index < len(h.config.PVEInstances) {
|
|
deletedNodeHost = h.config.PVEInstances[index].Host
|
|
} else if nodeType == "pbs" && index < len(h.config.PBSInstances) {
|
|
deletedNodeHost = h.config.PBSInstances[index].Host
|
|
}
|
|
|
|
// Extract IP and port from the host URL for targeted discovery
|
|
var targetIP string
|
|
var targetPort int
|
|
if deletedNodeHost != "" {
|
|
// Parse the host URL to get IP and port
|
|
hostURL := deletedNodeHost
|
|
hostURL = strings.TrimPrefix(hostURL, "https://")
|
|
hostURL = strings.TrimPrefix(hostURL, "http://")
|
|
parts := strings.Split(hostURL, ":")
|
|
if len(parts) > 0 {
|
|
targetIP = parts[0]
|
|
if len(parts) > 1 {
|
|
fmt.Sscanf(parts[1], "%d", &targetPort)
|
|
} else {
|
|
if deletedNodeType == "pve" {
|
|
targetPort = 8006
|
|
} else {
|
|
targetPort = 8007
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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")
|
|
|
|
// If we know the deleted node's details, immediately add it to discovered list
|
|
if targetIP != "" && targetPort > 0 {
|
|
// Create a synthetic discovery result with just the deleted node
|
|
immediateResult := map[string]interface{}{
|
|
"servers": []map[string]interface{}{
|
|
{
|
|
"ip": targetIP,
|
|
"port": targetPort,
|
|
"type": deletedNodeType,
|
|
"version": "Unknown",
|
|
"hostname": targetIP,
|
|
},
|
|
},
|
|
"errors": []string{},
|
|
"timestamp": time.Now().Unix(),
|
|
"immediate": true, // Flag to indicate this is immediate, not from a full scan
|
|
}
|
|
|
|
// Immediately broadcast the deleted node as discovered
|
|
h.wsHub.BroadcastMessage(websocket.Message{
|
|
Type: "discovery_update",
|
|
Data: immediateResult,
|
|
Timestamp: time.Now().Format(time.RFC3339),
|
|
})
|
|
log.Info().
|
|
Str("ip", targetIP).
|
|
Int("port", targetPort).
|
|
Str("type", deletedNodeType).
|
|
Msg("Immediately added deleted node to discovery")
|
|
}
|
|
|
|
// Schedule a full discovery scan in the background
|
|
go func() {
|
|
// Short delay to let the monitor stabilize
|
|
time.Sleep(500 * time.Millisecond)
|
|
|
|
// Trigger full discovery refresh
|
|
if h.monitor != nil && h.monitor.GetDiscoveryService() != nil {
|
|
h.monitor.GetDiscoveryService().ForceRefresh()
|
|
log.Info().Msg("Triggered background discovery refresh after node deletion")
|
|
}
|
|
}()
|
|
}
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
json.NewEncoder(w).Encode(map[string]string{"status": "success"})
|
|
}
|
|
|
|
// HandleTestExistingNode tests a connection for an existing node using stored credentials
|
|
func (h *ConfigHandlers) HandleTestExistingNode(w http.ResponseWriter, r *http.Request) {
|
|
nodeID := strings.TrimPrefix(r.URL.Path, "/api/config/nodes/")
|
|
nodeID = strings.TrimSuffix(nodeID, "/test")
|
|
if nodeID == "" {
|
|
http.Error(w, "Node ID required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Parse node ID
|
|
parts := strings.Split(nodeID, "-")
|
|
if len(parts) != 2 {
|
|
http.Error(w, "Invalid node ID", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
nodeType := parts[0]
|
|
index := 0
|
|
if _, err := fmt.Sscanf(parts[1], "%d", &index); err != nil {
|
|
http.Error(w, "Invalid node ID", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Get the node configuration
|
|
var host, user, password, tokenName, tokenValue, fingerprint string
|
|
var verifySSL bool
|
|
|
|
if nodeType == "pve" && index < len(h.config.PVEInstances) {
|
|
pve := h.config.PVEInstances[index]
|
|
host = pve.Host
|
|
user = pve.User
|
|
password = pve.Password
|
|
tokenName = pve.TokenName
|
|
tokenValue = pve.TokenValue
|
|
fingerprint = pve.Fingerprint
|
|
verifySSL = pve.VerifySSL
|
|
} else if nodeType == "pbs" && index < len(h.config.PBSInstances) {
|
|
pbs := h.config.PBSInstances[index]
|
|
host = pbs.Host
|
|
user = pbs.User
|
|
password = pbs.Password
|
|
tokenName = pbs.TokenName
|
|
tokenValue = pbs.TokenValue
|
|
fingerprint = pbs.Fingerprint
|
|
verifySSL = pbs.VerifySSL
|
|
} else {
|
|
http.Error(w, "Node not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
// Test the connection based on type
|
|
if nodeType == "pve" {
|
|
clientConfig := proxmox.ClientConfig{
|
|
Host: host,
|
|
User: user,
|
|
Password: password,
|
|
TokenName: tokenName,
|
|
TokenValue: tokenValue,
|
|
VerifySSL: verifySSL,
|
|
Fingerprint: fingerprint,
|
|
}
|
|
|
|
tempClient, err := proxmox.NewClient(clientConfig)
|
|
if err != nil {
|
|
http.Error(w, fmt.Sprintf("Failed to create client: %v", err), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
nodes, err := tempClient.GetNodes(ctx)
|
|
if err != nil {
|
|
http.Error(w, fmt.Sprintf("Connection failed: %v", err), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
response := map[string]interface{}{
|
|
"status": "success",
|
|
"message": fmt.Sprintf("Successfully connected to %d node(s)", len(nodes)),
|
|
"nodeCount": len(nodes),
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(response)
|
|
} else {
|
|
// PBS test
|
|
clientConfig := pbs.ClientConfig{
|
|
Host: host,
|
|
User: user,
|
|
Password: password,
|
|
TokenName: tokenName,
|
|
TokenValue: tokenValue,
|
|
VerifySSL: verifySSL,
|
|
Fingerprint: fingerprint,
|
|
}
|
|
|
|
tempClient, err := pbs.NewClient(clientConfig)
|
|
if err != nil {
|
|
http.Error(w, fmt.Sprintf("Failed to create client: %v", err), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
datastores, err := tempClient.GetDatastores(ctx)
|
|
if err != nil {
|
|
http.Error(w, fmt.Sprintf("Connection failed: %v", err), 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)
|
|
}
|
|
}
|
|
|
|
// HandleTestNodeConfig tests a node connection from provided configuration
|
|
func (h *ConfigHandlers) HandleTestNodeConfig(w http.ResponseWriter, r *http.Request) {
|
|
var req NodeConfigRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
var testResult map[string]interface{}
|
|
|
|
if req.Type == "pve" {
|
|
// Create a temporary client to test connection
|
|
clientConfig := proxmox.ClientConfig{
|
|
Host: req.Host,
|
|
User: req.User,
|
|
Password: req.Password,
|
|
TokenName: req.TokenName,
|
|
TokenValue: req.TokenValue,
|
|
VerifySSL: req.VerifySSL,
|
|
Fingerprint: req.Fingerprint,
|
|
}
|
|
client, err := proxmox.NewClient(clientConfig)
|
|
if err != nil {
|
|
testResult = map[string]interface{}{
|
|
"status": "error",
|
|
"message": fmt.Sprintf("Failed to create client: %v", err),
|
|
}
|
|
} 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": fmt.Sprintf("Connection failed: %v", err),
|
|
}
|
|
} 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" {
|
|
// Create a temporary client to test connection
|
|
clientConfig := pbs.ClientConfig{
|
|
Host: req.Host,
|
|
User: req.User,
|
|
Password: req.Password,
|
|
TokenName: req.TokenName,
|
|
TokenValue: req.TokenValue,
|
|
VerifySSL: req.VerifySSL,
|
|
Fingerprint: req.Fingerprint,
|
|
}
|
|
client, err := pbs.NewClient(clientConfig)
|
|
if err != nil {
|
|
testResult = map[string]interface{}{
|
|
"status": "error",
|
|
"message": fmt.Sprintf("Failed to create client: %v", err),
|
|
}
|
|
} 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": fmt.Sprintf("Connection failed: %v", err),
|
|
}
|
|
} else {
|
|
latency := time.Since(startTime).Milliseconds()
|
|
testResult = map[string]interface{}{
|
|
"status": "success",
|
|
"message": fmt.Sprintf("Connected to PBS instance"),
|
|
"latency": latency,
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
http.Error(w, "Invalid node type", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
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]
|
|
|
|
// Parse node ID
|
|
idParts := strings.Split(nodeID, "-")
|
|
if len(idParts) != 2 {
|
|
http.Error(w, "Invalid node ID", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
nodeType := idParts[0]
|
|
index := 0
|
|
if _, err := fmt.Sscanf(idParts[1], "%d", &index); err != nil {
|
|
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.config.PVEInstances) {
|
|
pve := h.config.PVEInstances[index]
|
|
|
|
// Create a temporary client to test connection
|
|
clientConfig := proxmox.ClientConfig{
|
|
Host: pve.Host,
|
|
User: pve.User,
|
|
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": fmt.Sprintf("Failed to create client: %v", err),
|
|
}
|
|
} 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": fmt.Sprintf("Connection failed: %v", err),
|
|
}
|
|
} 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.config.PBSInstances) {
|
|
pbsInstance := h.config.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": fmt.Sprintf("Failed to create client: %v", err),
|
|
}
|
|
} 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": fmt.Sprintf("Connection failed: %v", err),
|
|
}
|
|
} else {
|
|
latency := time.Since(startTime).Milliseconds()
|
|
testResult = map[string]interface{}{
|
|
"status": "success",
|
|
"message": "Connected to PBS",
|
|
"latency": latency,
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
testResult = map[string]interface{}{
|
|
"status": "error",
|
|
"message": "Node not found",
|
|
}
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(testResult)
|
|
}
|
|
|
|
// getNodeStatus returns the connection status for a node
|
|
func (h *ConfigHandlers) getNodeStatus(nodeType, nodeName string) string {
|
|
if h.monitor == nil {
|
|
return "disconnected"
|
|
}
|
|
|
|
// Get connection statuses from monitor
|
|
connectionStatus := h.monitor.GetConnectionStatuses()
|
|
|
|
key := fmt.Sprintf("%s-%s", nodeType, nodeName)
|
|
if connected, ok := connectionStatus[key]; ok && connected {
|
|
return "connected"
|
|
}
|
|
|
|
return "disconnected"
|
|
}
|
|
|
|
// HandleGetSystemSettings returns current system settings
|
|
func (h *ConfigHandlers) HandleGetSystemSettings(w http.ResponseWriter, r *http.Request) {
|
|
// Get current values from running config
|
|
settings := config.SystemSettings{
|
|
PollingInterval: int(h.config.PollingInterval.Seconds()),
|
|
BackendPort: h.config.BackendPort,
|
|
FrontendPort: h.config.FrontendPort,
|
|
AllowedOrigins: h.config.AllowedOrigins,
|
|
ConnectionTimeout: int(h.config.ConnectionTimeout.Seconds()),
|
|
UpdateChannel: h.config.UpdateChannel,
|
|
AutoUpdateEnabled: h.config.AutoUpdateEnabled,
|
|
AutoUpdateCheckInterval: int(h.config.AutoUpdateCheckInterval.Hours()),
|
|
AutoUpdateTime: h.config.AutoUpdateTime,
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(settings)
|
|
}
|
|
|
|
// HandleUpdateSystemSettings updates system settings in the unified config
|
|
func (h *ConfigHandlers) HandleUpdateSystemSettings(w http.ResponseWriter, r *http.Request) {
|
|
var settings config.SystemSettings
|
|
if err := json.NewDecoder(r.Body).Decode(&settings); err != nil {
|
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Update polling interval using our persistence
|
|
if settings.PollingInterval > 0 {
|
|
if err := config.UpdatePollingInterval(settings.PollingInterval); err != nil {
|
|
log.Error().Err(err).Msg("Failed to save polling interval")
|
|
http.Error(w, "Failed to save settings", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Update the running config
|
|
h.config.PollingInterval = time.Duration(settings.PollingInterval) * time.Second
|
|
|
|
// Trigger a monitor reload to apply the new polling interval
|
|
if h.reloadFunc != nil {
|
|
log.Info().Int("interval", settings.PollingInterval).Msg("Triggering monitor reload for new polling interval")
|
|
if err := h.reloadFunc(); err != nil {
|
|
log.Error().Err(err).Msg("Failed to reload monitor with new polling interval")
|
|
// Don't fail the request, the setting was saved
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update allowed origins if provided
|
|
if settings.AllowedOrigins != "" {
|
|
h.config.AllowedOrigins = settings.AllowedOrigins
|
|
// Update WebSocket hub with new origins
|
|
if h.wsHub != nil {
|
|
origins := strings.Split(settings.AllowedOrigins, ",")
|
|
for i := range origins {
|
|
origins[i] = strings.TrimSpace(origins[i])
|
|
}
|
|
h.wsHub.SetAllowedOrigins(origins)
|
|
}
|
|
}
|
|
|
|
// Update update-related settings
|
|
if settings.UpdateChannel != "" {
|
|
h.config.UpdateChannel = settings.UpdateChannel
|
|
}
|
|
h.config.AutoUpdateEnabled = settings.AutoUpdateEnabled
|
|
if settings.AutoUpdateCheckInterval > 0 {
|
|
h.config.AutoUpdateCheckInterval = time.Duration(settings.AutoUpdateCheckInterval) * time.Hour
|
|
}
|
|
if settings.AutoUpdateTime != "" {
|
|
h.config.AutoUpdateTime = settings.AutoUpdateTime
|
|
}
|
|
|
|
// Save settings to persistence
|
|
if err := h.persistence.SaveSystemSettings(settings); err != nil {
|
|
log.Error().Err(err).Msg("Failed to persist system settings")
|
|
// Continue anyway - settings are applied in memory
|
|
}
|
|
|
|
log.Info().
|
|
Int("pollingInterval", settings.PollingInterval).
|
|
Int("backendPort", settings.BackendPort).
|
|
Int("frontendPort", settings.FrontendPort).
|
|
Msg("Updated system settings in unified config")
|
|
|
|
// Trigger monitor reload to apply new settings
|
|
if h.reloadFunc != nil {
|
|
if err := h.reloadFunc(); err != nil {
|
|
log.Error().Err(err).Msg("Failed to reload monitor after system settings update")
|
|
// Continue anyway - settings are saved
|
|
} else {
|
|
log.Info().
|
|
Int("pollingInterval", settings.PollingInterval).
|
|
Msg("Monitor reloaded with new polling interval")
|
|
}
|
|
}
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"status": "success",
|
|
"message": fmt.Sprintf("Polling interval updated to %d seconds", settings.PollingInterval),
|
|
"pollingInterval": settings.PollingInterval,
|
|
})
|
|
}
|
|
|
|
// generateNodeID creates a unique ID for a node
|
|
func generateNodeID(nodeType string, index int) string {
|
|
return fmt.Sprintf("%s-%d", nodeType, index)
|
|
}
|
|
|
|
// ExportConfigRequest represents a request to export configuration
|
|
type ExportConfigRequest struct {
|
|
Passphrase string `json:"passphrase"`
|
|
}
|
|
|
|
// ImportConfigRequest represents a request to import configuration
|
|
type ImportConfigRequest struct {
|
|
Data string `json:"data"`
|
|
Passphrase string `json:"passphrase"`
|
|
}
|
|
|
|
// HandleExportConfig exports all configuration with encryption
|
|
func (h *ConfigHandlers) HandleExportConfig(w http.ResponseWriter, r *http.Request) {
|
|
var req ExportConfigRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
log.Error().Err(err).Msg("Failed to decode export request")
|
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if req.Passphrase == "" {
|
|
http.Error(w, "Passphrase is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Require strong passphrase (at least 12 characters)
|
|
if len(req.Passphrase) < 12 {
|
|
http.Error(w, "Passphrase must be at least 12 characters long", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Export configuration
|
|
exportedData, err := h.persistence.ExportConfig(req.Passphrase)
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("Failed to export configuration")
|
|
http.Error(w, "Failed to export configuration", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
log.Info().Msg("Configuration exported successfully")
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"status": "success",
|
|
"data": exportedData,
|
|
})
|
|
}
|
|
|
|
// HandleImportConfig imports configuration from encrypted export
|
|
func (h *ConfigHandlers) HandleImportConfig(w http.ResponseWriter, r *http.Request) {
|
|
var req ImportConfigRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
log.Error().Err(err).Msg("Failed to decode import request")
|
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if req.Passphrase == "" {
|
|
http.Error(w, "Passphrase is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if req.Data == "" {
|
|
http.Error(w, "Import data is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Import configuration
|
|
if err := h.persistence.ImportConfig(req.Data, req.Passphrase); err != nil {
|
|
log.Error().Err(err).Msg("Failed to import configuration")
|
|
http.Error(w, "Failed to import configuration: "+err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Reload configuration from disk
|
|
newConfig, err := config.Load()
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("Failed to reload configuration after import")
|
|
http.Error(w, "Configuration imported but failed to reload", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Update the config reference
|
|
*h.config = *newConfig
|
|
|
|
// 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 import")
|
|
http.Error(w, "Configuration imported but failed to apply changes", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Also reload alert and notification configs explicitly
|
|
// (the monitor reload only reloads nodes unless it's a full reload)
|
|
if h.monitor != nil {
|
|
// Reload alert configuration
|
|
if alertConfig, err := h.persistence.LoadAlertConfig(); err == nil {
|
|
h.monitor.GetAlertManager().UpdateConfig(*alertConfig)
|
|
log.Info().Msg("Reloaded alert configuration after import")
|
|
} else {
|
|
log.Warn().Err(err).Msg("Failed to reload alert configuration after import")
|
|
}
|
|
|
|
// Reload webhook configuration
|
|
if webhooks, err := h.persistence.LoadWebhooks(); err == nil {
|
|
// Clear existing webhooks and add new ones
|
|
notificationMgr := h.monitor.GetNotificationManager()
|
|
// Get current webhooks to clear them
|
|
for _, webhook := range notificationMgr.GetWebhooks() {
|
|
notificationMgr.DeleteWebhook(webhook.ID)
|
|
}
|
|
// Add imported webhooks
|
|
for _, webhook := range webhooks {
|
|
notificationMgr.AddWebhook(webhook)
|
|
}
|
|
log.Info().Int("count", len(webhooks)).Msg("Reloaded webhook configuration after import")
|
|
} else {
|
|
log.Warn().Err(err).Msg("Failed to reload webhook configuration after import")
|
|
}
|
|
|
|
// Reload email configuration
|
|
if emailConfig, err := h.persistence.LoadEmailConfig(); err == nil {
|
|
h.monitor.GetNotificationManager().SetEmailConfig(*emailConfig)
|
|
log.Info().Msg("Reloaded email configuration after import")
|
|
} else {
|
|
log.Warn().Err(err).Msg("Failed to reload email configuration after import")
|
|
}
|
|
}
|
|
|
|
log.Info().Msg("Configuration imported successfully")
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"status": "success",
|
|
"message": "Configuration imported successfully",
|
|
})
|
|
}
|
|
|
|
// HandleDiscoverServers handles network discovery of Proxmox/PBS servers
|
|
func (h *ConfigHandlers) HandleDiscoverServers(w http.ResponseWriter, r *http.Request) {
|
|
// Support both GET (for cached results) and POST (for manual scan)
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
// Return cached results from background discovery service
|
|
if discoveryService := h.monitor.GetDiscoveryService(); discoveryService != nil {
|
|
result, updated := discoveryService.GetCachedResult()
|
|
|
|
// Add metadata about the cache
|
|
response := map[string]interface{}{
|
|
"servers": result.Servers,
|
|
"errors": result.Errors,
|
|
"cached": true,
|
|
"updated": updated.Unix(),
|
|
"age": time.Since(updated).Seconds(),
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(response)
|
|
return
|
|
}
|
|
|
|
// No discovery service available, return empty result
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"servers": []interface{}{},
|
|
"errors": []string{},
|
|
"cached": false,
|
|
})
|
|
return
|
|
|
|
case http.MethodPost:
|
|
// Parse request
|
|
var req struct {
|
|
Subnet string `json:"subnet"` // CIDR notation or "auto"
|
|
UseCache bool `json:"use_cache"` // Whether to return cached results
|
|
}
|
|
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// If use_cache is true and we have cached results, return them
|
|
if req.UseCache {
|
|
if discoveryService := h.monitor.GetDiscoveryService(); discoveryService != nil {
|
|
result, updated := discoveryService.GetCachedResult()
|
|
|
|
response := map[string]interface{}{
|
|
"servers": result.Servers,
|
|
"errors": result.Errors,
|
|
"cached": true,
|
|
"updated": updated.Unix(),
|
|
"age": time.Since(updated).Seconds(),
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(response)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Check if discovery service is available for triggering a refresh
|
|
if discoveryService := h.monitor.GetDiscoveryService(); discoveryService != nil {
|
|
// Update subnet if provided
|
|
if req.Subnet != "" {
|
|
discoveryService.SetSubnet(req.Subnet)
|
|
}
|
|
|
|
// Trigger a refresh
|
|
discoveryService.ForceRefresh()
|
|
|
|
// Wait a moment for scan to start, then return current cache
|
|
time.Sleep(100 * time.Millisecond)
|
|
|
|
result, updated := discoveryService.GetCachedResult()
|
|
response := map[string]interface{}{
|
|
"servers": result.Servers,
|
|
"errors": result.Errors,
|
|
"cached": true,
|
|
"scanning": true,
|
|
"updated": updated.Unix(),
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(response)
|
|
return
|
|
}
|
|
|
|
// Fallback to direct scan if no discovery service
|
|
log.Info().Str("subnet", req.Subnet).Msg("Starting network discovery (fallback)")
|
|
|
|
scanner := discovery.NewScanner()
|
|
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
|
defer cancel()
|
|
|
|
result, err := scanner.DiscoverServers(ctx, req.Subnet)
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("Discovery failed")
|
|
http.Error(w, fmt.Sprintf("Discovery failed: %v", err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(result)
|
|
|
|
default:
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
}
|
|
}
|
|
|
|
// HandleSetupScript serves the setup script for Proxmox/PBS nodes
|
|
func (h *ConfigHandlers) HandleSetupScript(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
// Get query parameters
|
|
query := r.URL.Query()
|
|
serverType := query.Get("type") // "pve" or "pbs"
|
|
serverHost := query.Get("host")
|
|
pulseURL := query.Get("pulse_url") // URL of the Pulse server for auto-registration
|
|
backupPerms := query.Get("backup_perms") == "true" // Whether to add backup management permissions
|
|
|
|
// Default to PVE if not specified
|
|
if serverType == "" {
|
|
serverType = "pve"
|
|
}
|
|
|
|
// If pulse URL not provided, try to construct from request
|
|
if pulseURL == "" {
|
|
scheme := "http"
|
|
if r.TLS != nil {
|
|
scheme = "https"
|
|
}
|
|
pulseURL = fmt.Sprintf("%s://%s", scheme, r.Host)
|
|
}
|
|
|
|
// Extract hostname/IP from the host URL if provided
|
|
serverName := "your-server"
|
|
if serverHost != "" {
|
|
// Extract hostname/IP from URL
|
|
if match := strings.Contains(serverHost, "://"); match {
|
|
parts := strings.Split(serverHost, "://")
|
|
if len(parts) > 1 {
|
|
hostPart := strings.Split(parts[1], ":")[0]
|
|
serverName = hostPart
|
|
}
|
|
} else {
|
|
// Just a hostname/IP
|
|
serverName = strings.Split(serverHost, ":")[0]
|
|
}
|
|
}
|
|
|
|
// Extract Pulse IP from the pulse URL to make token name unique
|
|
pulseIP := "pulse"
|
|
if pulseURL != "" {
|
|
// Extract IP/hostname from Pulse URL
|
|
if match := strings.Contains(pulseURL, "://"); match {
|
|
parts := strings.Split(pulseURL, "://")
|
|
if len(parts) > 1 {
|
|
hostPart := strings.Split(parts[1], ":")[0]
|
|
// Replace dots with dashes for token name compatibility
|
|
pulseIP = strings.ReplaceAll(hostPart, ".", "-")
|
|
}
|
|
}
|
|
}
|
|
|
|
// Create unique token name based on Pulse IP and timestamp
|
|
// Adding timestamp ensures truly unique tokens even when running from same Pulse server
|
|
timestamp := time.Now().Unix()
|
|
tokenName := fmt.Sprintf("pulse-%s-%d", pulseIP, timestamp)
|
|
|
|
// Log the token name for debugging
|
|
log.Info().
|
|
Str("pulseURL", pulseURL).
|
|
Str("pulseIP", pulseIP).
|
|
Str("tokenName", tokenName).
|
|
Int64("timestamp", timestamp).
|
|
Msg("Generated unique token name for setup script")
|
|
|
|
var script string
|
|
|
|
if serverType == "pve" {
|
|
// Build storage permissions command if needed
|
|
storagePerms := ""
|
|
if backupPerms {
|
|
storagePerms = "\npveum aclmod /storage -user pulse-monitor@pam -role PVEDatastoreAdmin"
|
|
}
|
|
|
|
script = fmt.Sprintf(`#!/bin/bash
|
|
# Pulse Monitoring Setup Script for %s
|
|
# Generated: %s
|
|
|
|
echo "============================================"
|
|
echo " Pulse Monitoring Setup for Proxmox VE"
|
|
echo "============================================"
|
|
echo ""
|
|
|
|
# Check if running as root
|
|
if [ "$EUID" -ne 0 ]; then
|
|
echo "Please run this script as root"
|
|
exit 1
|
|
fi
|
|
|
|
# Check for old Pulse tokens and offer to clean them up
|
|
echo "Checking for existing Pulse monitoring tokens..."
|
|
OLD_TOKENS=$(pveum user token list pulse-monitor@pam 2>/dev/null | grep -E "│ pulse-[0-9\-]+-[0-9]+" | awk -F'│' '{print $2}' | sed 's/^ *//;s/ *$//' || true)
|
|
if [ ! -z "$OLD_TOKENS" ]; then
|
|
TOKEN_COUNT=$(echo "$OLD_TOKENS" | wc -l)
|
|
echo ""
|
|
echo "⚠️ Found $TOKEN_COUNT old Pulse monitoring token(s):"
|
|
echo "$OLD_TOKENS" | sed 's/^/ - /'
|
|
echo ""
|
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
echo "🗑️ CLEANUP OPTION"
|
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
echo "Would you like to remove these old tokens? Type 'y' for yes, 'n' for no: "
|
|
# Read from terminal, not from stdin (which is the piped script)
|
|
if [ -t 0 ]; then
|
|
# Running interactively
|
|
read -p "> " -n 1 -r REPLY
|
|
else
|
|
# Being piped - try to read from terminal if available
|
|
if read -p "> " -n 1 -r REPLY </dev/tty 2>/dev/null; then
|
|
# Successfully read from terminal
|
|
:
|
|
else
|
|
# No terminal available (e.g., in Docker without -t flag)
|
|
echo "(No terminal available for input - keeping existing tokens)"
|
|
REPLY="n"
|
|
fi
|
|
fi
|
|
echo ""
|
|
echo ""
|
|
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
|
echo "Removing old tokens..."
|
|
while IFS= read -r TOKEN; do
|
|
if [ ! -z "$TOKEN" ]; then
|
|
pveum user token remove pulse-monitor@pam "$TOKEN" 2>/dev/null && echo " ✓ Removed token: $TOKEN" || echo " ✗ Failed to remove: $TOKEN"
|
|
fi
|
|
done <<< "$OLD_TOKENS"
|
|
echo ""
|
|
else
|
|
echo "Keeping existing tokens."
|
|
fi
|
|
echo ""
|
|
fi
|
|
|
|
# Create monitoring user
|
|
echo "Creating monitoring user..."
|
|
pveum user add pulse-monitor@pam --comment "Pulse monitoring service" 2>/dev/null || echo "User already exists"
|
|
|
|
# Generate API token
|
|
echo "Generating API token..."
|
|
|
|
# Check if token already exists
|
|
TOKEN_EXISTED=false
|
|
if pveum user token list pulse-monitor@pam 2>/dev/null | grep -q "%s"; then
|
|
TOKEN_EXISTED=true
|
|
echo ""
|
|
echo "================================================================"
|
|
echo "WARNING: Token '%s' already exists!"
|
|
echo "================================================================"
|
|
echo ""
|
|
echo "To create a new token, first remove the existing one:"
|
|
echo " pveum user token remove pulse-monitor@pam %s"
|
|
echo ""
|
|
echo "Or create a token with a different name:"
|
|
echo " pveum user token add pulse-monitor@pam %s-$(date +%%s) --privsep 0"
|
|
echo ""
|
|
echo "Then use the new token ID in Pulse (e.g., pulse-monitor@pam!%s-1234567890)"
|
|
echo "================================================================"
|
|
echo ""
|
|
else
|
|
TOKEN_OUTPUT=$(pveum user token add pulse-monitor@pam %s --privsep 0)
|
|
echo ""
|
|
echo "================================================================"
|
|
echo "IMPORTANT: Copy the token value below - it's only shown once!"
|
|
echo "================================================================"
|
|
echo "$TOKEN_OUTPUT"
|
|
echo "================================================================"
|
|
echo ""
|
|
|
|
# Extract the token value for backward compatibility (if manual mode is needed)
|
|
TOKEN_VALUE=$(echo "$TOKEN_OUTPUT" | grep "│ value" | awk -F'│' '{print $3}' | tr -d ' ' | tail -1)
|
|
|
|
# Try secure auto-registration first
|
|
echo ""
|
|
echo "🔄 Starting secure auto-registration with Pulse..."
|
|
|
|
# Get the server's hostname
|
|
SERVER_HOSTNAME=$(hostname -f 2>/dev/null || hostname)
|
|
SERVER_IP=$(hostname -I | awk '{print $1}')
|
|
|
|
# Send registration to Pulse
|
|
PULSE_URL="%s"
|
|
|
|
# Check for HTTPS (warn but allow for local networks)
|
|
if [[ ! "$PULSE_URL" =~ ^https:// ]]; then
|
|
echo "⚠️ WARNING: Using HTTP - token will be sent unencrypted!"
|
|
echo " This is acceptable for local networks but not recommended for internet."
|
|
echo ""
|
|
fi
|
|
|
|
# Use secure registration - Pulse generates the token
|
|
echo "🔐 Using secure registration protocol..."
|
|
echo " Pulse will generate and return a token for this node."
|
|
echo ""
|
|
|
|
# Construct secure registration request
|
|
# Use the actual PVE host URL provided to the script
|
|
REGISTER_JSON=$(cat <<EOF
|
|
{
|
|
"type": "pve",
|
|
"host": "%s",
|
|
"serverName": "$SERVER_HOSTNAME",
|
|
"tokenId": "pulse-monitor@pam!%s",
|
|
"tokenValue": "$TOKEN_VALUE"
|
|
}
|
|
EOF
|
|
)
|
|
# Remove newlines from JSON
|
|
REGISTER_JSON=$(echo "$REGISTER_JSON" | tr -d '\n')
|
|
|
|
# Send registration (add X-Registration-Token header if provided)
|
|
REG_TOKEN="${PULSE_REG_TOKEN:-}"
|
|
if [ -n "$REG_TOKEN" ]; then
|
|
echo "🔑 Using registration token: $REG_TOKEN"
|
|
REGISTER_RESPONSE=$(curl -s -X POST "$PULSE_URL/api/auto-register" \
|
|
-H "Content-Type: application/json" \
|
|
-H "X-Registration-Token: $REG_TOKEN" \
|
|
-d "$REGISTER_JSON" 2>&1)
|
|
else
|
|
REGISTER_RESPONSE=$(curl -s -X POST "$PULSE_URL/api/auto-register" \
|
|
-H "Content-Type: application/json" \
|
|
-d "$REGISTER_JSON" 2>&1)
|
|
fi
|
|
|
|
if echo "$REGISTER_RESPONSE" | grep -q "success"; then
|
|
echo "✅ Successfully registered with Pulse!"
|
|
echo " The node should now appear in your Pulse dashboard."
|
|
echo ""
|
|
echo "📌 Note: You may need to refresh the page to see the new node."
|
|
else
|
|
echo "⚠️ Auto-registration failed. Manual configuration may be needed."
|
|
echo " Response: $REGISTER_RESPONSE"
|
|
echo ""
|
|
echo "📝 For manual setup:"
|
|
echo " 1. Copy the token value shown above"
|
|
echo " 2. Add this node manually in Pulse Settings"
|
|
fi
|
|
echo ""
|
|
fi
|
|
|
|
# Set up permissions
|
|
echo "Setting up permissions..."
|
|
pveum aclmod / -user pulse-monitor@pam -role PVEAuditor%s
|
|
|
|
echo ""
|
|
echo "✅ Setup complete!"
|
|
echo ""
|
|
echo "Add this server to Pulse with:"
|
|
echo " Token ID: pulse-monitor@pam!%s"
|
|
if [ "$TOKEN_EXISTED" = true ]; then
|
|
echo " Token Value: [Use your existing token or create a new one as shown above]"
|
|
else
|
|
echo " Token Value: [Copy from above]"
|
|
fi
|
|
echo " Host URL: %s"
|
|
echo ""
|
|
echo "If auto-registration is enabled but requires a token:"
|
|
echo " 1. Generate a registration token in Pulse Settings → Security"
|
|
echo " 2. Re-run this script with: PULSE_REG_TOKEN=your-token ./setup.sh"
|
|
echo ""
|
|
`, serverName, time.Now().Format("2006-01-02 15:04:05"),
|
|
tokenName, tokenName, tokenName, tokenName, tokenName, tokenName, // Lines 1714,1718,1722,1725,1727,1731
|
|
pulseURL, serverHost, tokenName, storagePerms, tokenName, serverHost) // Lines 1752,1771,1773,1813,1819,1825
|
|
|
|
} else { // PBS
|
|
script = fmt.Sprintf(`#!/bin/bash
|
|
# Pulse Monitoring Setup Script for PBS %s
|
|
# Generated: %s
|
|
|
|
echo "============================================"
|
|
echo " Pulse Monitoring Setup for PBS"
|
|
echo "============================================"
|
|
echo ""
|
|
|
|
# Check if running as root
|
|
if [ "$EUID" -ne 0 ]; then
|
|
echo "Please run this script as root"
|
|
exit 1
|
|
fi
|
|
|
|
# Check for old Pulse tokens and offer to clean them up
|
|
echo "Checking for existing Pulse monitoring tokens..."
|
|
# PBS outputs tokens differently than PVE - extract just the token names
|
|
OLD_TOKENS=$(proxmox-backup-manager user list-tokens pulse-monitor@pbs 2>/dev/null | grep -oE "pulse-[0-9\-]+-[0-9]+" | sort -u || true)
|
|
if [ ! -z "$OLD_TOKENS" ]; then
|
|
TOKEN_COUNT=$(echo "$OLD_TOKENS" | wc -l)
|
|
echo ""
|
|
echo "⚠️ Found $TOKEN_COUNT old Pulse monitoring token(s):"
|
|
echo "$OLD_TOKENS" | sed 's/^/ - /'
|
|
echo ""
|
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
echo "🗑️ CLEANUP OPTION"
|
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
echo "Would you like to remove these old tokens? Type 'y' for yes, 'n' for no: "
|
|
# Read from terminal, not from stdin (which is the piped script)
|
|
if [ -t 0 ]; then
|
|
# Running interactively
|
|
read -p "> " -n 1 -r REPLY
|
|
else
|
|
# Being piped - try to read from terminal if available
|
|
if read -p "> " -n 1 -r REPLY </dev/tty 2>/dev/null; then
|
|
# Successfully read from terminal
|
|
:
|
|
else
|
|
# No terminal available (e.g., in Docker without -t flag)
|
|
echo "(No terminal available for input - keeping existing tokens)"
|
|
REPLY="n"
|
|
fi
|
|
fi
|
|
echo ""
|
|
echo ""
|
|
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
|
echo "Removing old tokens..."
|
|
while IFS= read -r TOKEN; do
|
|
if [ ! -z "$TOKEN" ]; then
|
|
proxmox-backup-manager user delete-token pulse-monitor@pbs "$TOKEN" 2>/dev/null && echo " ✓ Removed token: $TOKEN" || echo " ✗ Failed to remove: $TOKEN"
|
|
fi
|
|
done <<< "$OLD_TOKENS"
|
|
echo ""
|
|
else
|
|
echo "Keeping existing tokens."
|
|
fi
|
|
echo ""
|
|
fi
|
|
|
|
# Create monitoring user
|
|
echo "Creating monitoring user..."
|
|
proxmox-backup-manager user create pulse-monitor@pbs 2>/dev/null || echo "User already exists"
|
|
|
|
# Generate API token
|
|
echo "Generating API token..."
|
|
|
|
# Check if token already exists (PBS tokens can be regenerated with same name)
|
|
echo ""
|
|
echo "================================================================"
|
|
echo "IMPORTANT: Copy the token value below - it's only shown once!"
|
|
echo "================================================================"
|
|
TOKEN_OUTPUT=$(proxmox-backup-manager user generate-token pulse-monitor@pbs %s 2>&1)
|
|
if echo "$TOKEN_OUTPUT" | grep -q "already exists"; then
|
|
echo "WARNING: Token '%s' already exists!"
|
|
echo ""
|
|
echo "You can either:"
|
|
echo "1. Delete the existing token first:"
|
|
echo " proxmox-backup-manager user delete-token pulse-monitor@pbs %s"
|
|
echo ""
|
|
echo "2. Or create a token with a different name:"
|
|
echo " proxmox-backup-manager user generate-token pulse-monitor@pbs %s-$(date +%%s)"
|
|
echo ""
|
|
echo "Then use the new token ID in Pulse (e.g., pulse-monitor@pbs!%s-1234567890)"
|
|
else
|
|
echo "$TOKEN_OUTPUT"
|
|
|
|
# Extract the token value for backward compatibility (if manual mode is needed)
|
|
TOKEN_VALUE=$(echo "$TOKEN_OUTPUT" | grep '"value"' | sed 's/.*"value"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/')
|
|
|
|
# Try secure auto-registration
|
|
echo ""
|
|
echo "🔄 Starting secure auto-registration with Pulse..."
|
|
|
|
# Get the server's hostname
|
|
SERVER_HOSTNAME=$(hostname -f 2>/dev/null || hostname)
|
|
SERVER_IP=$(hostname -I | awk '{print $1}')
|
|
|
|
# Send registration to Pulse
|
|
PULSE_URL="%s"
|
|
|
|
# Check for HTTPS (warn but allow for local networks)
|
|
if [[ ! "$PULSE_URL" =~ ^https:// ]]; then
|
|
echo "⚠️ WARNING: Using HTTP - token will be sent unencrypted!"
|
|
echo " This is acceptable for local networks but not recommended for internet."
|
|
echo ""
|
|
fi
|
|
|
|
# Use secure registration - send token that was just created
|
|
echo "🔐 Using secure registration protocol..."
|
|
echo ""
|
|
|
|
# Construct registration request with the token we just created
|
|
# Use the actual PBS host URL provided to the script
|
|
REGISTER_JSON=$(cat <<EOF
|
|
{
|
|
"type": "pbs",
|
|
"host": "%s",
|
|
"serverName": "$SERVER_HOSTNAME",
|
|
"tokenId": "pulse-monitor@pbs!%s",
|
|
"tokenValue": "$TOKEN_VALUE"
|
|
}
|
|
EOF
|
|
)
|
|
# Remove newlines from JSON
|
|
REGISTER_JSON=$(echo "$REGISTER_JSON" | tr -d '\n')
|
|
|
|
# Send registration (add X-Registration-Token header if provided)
|
|
REG_TOKEN="${PULSE_REG_TOKEN:-}"
|
|
if [ -n "$REG_TOKEN" ]; then
|
|
echo "🔑 Using registration token: $REG_TOKEN"
|
|
REGISTER_RESPONSE=$(curl -s -X POST "$PULSE_URL/api/auto-register" \
|
|
-H "Content-Type: application/json" \
|
|
-H "X-Registration-Token: $REG_TOKEN" \
|
|
-d "$REGISTER_JSON" 2>&1)
|
|
else
|
|
REGISTER_RESPONSE=$(curl -s -X POST "$PULSE_URL/api/auto-register" \
|
|
-H "Content-Type: application/json" \
|
|
-d "$REGISTER_JSON" 2>&1)
|
|
fi
|
|
|
|
if echo "$REGISTER_RESPONSE" | grep -q "success"; then
|
|
echo "✅ Successfully registered with Pulse!"
|
|
echo " The node should now appear in your Pulse dashboard."
|
|
echo ""
|
|
echo "📌 Note: You may need to refresh the page to see the new node."
|
|
else
|
|
echo "⚠️ Auto-registration failed. Manual configuration may be needed."
|
|
echo " Response: $REGISTER_RESPONSE"
|
|
echo ""
|
|
echo "📝 For manual setup:"
|
|
echo " 1. Copy the token value shown above"
|
|
echo " 2. Add this node manually in Pulse Settings"
|
|
fi
|
|
echo ""
|
|
fi
|
|
echo "================================================================"
|
|
echo ""
|
|
|
|
# Set up permissions
|
|
echo "Setting up permissions..."
|
|
proxmox-backup-manager acl update / Audit --auth-id pulse-monitor@pbs
|
|
proxmox-backup-manager acl update / Audit --auth-id 'pulse-monitor@pbs!%s'
|
|
|
|
echo ""
|
|
echo "✅ Setup complete!"
|
|
echo ""
|
|
echo "Add this server to Pulse with:"
|
|
echo " Token ID: pulse-monitor@pbs!%s"
|
|
echo " Token Value: [Check the output above for the token or instructions]"
|
|
echo " Host URL: https://$SERVER_IP:8007"
|
|
echo ""
|
|
echo "If auto-registration is enabled but requires a token:"
|
|
echo " 1. Generate a registration token in Pulse Settings → Security"
|
|
echo " 2. Re-run this script with: PULSE_REG_TOKEN=your-token ./setup.sh"
|
|
echo ""
|
|
`, serverName, time.Now().Format("2006-01-02 15:04:05"),
|
|
tokenName, tokenName, tokenName, tokenName, tokenName, // Lines 1808,1810,1814,1817,1819 (5 occurrences)
|
|
pulseURL, serverHost, tokenName, // Lines 1835,1853,1855: pulseURL, host URL, tokenId
|
|
tokenName, tokenName) // Lines 1897,1903: ACL, Token ID
|
|
}
|
|
|
|
// Set headers for script download
|
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=pulse-setup-%s.sh", serverType))
|
|
w.Write([]byte(script))
|
|
}
|
|
|
|
// AutoRegisterRequest represents a request from the setup script to auto-register a node
|
|
type AutoRegisterRequest struct {
|
|
Type string `json:"type"` // "pve" or "pbs"
|
|
Host string `json:"host"` // The host URL
|
|
TokenID string `json:"tokenId"` // Full token ID like pulse-monitor@pam!pulse-token
|
|
TokenValue string `json:"tokenValue,omitempty"` // DEPRECATED - for backward compatibility only
|
|
ServerName string `json:"serverName"` // Hostname or IP
|
|
// New secure fields
|
|
RequestToken bool `json:"requestToken,omitempty"` // If true, Pulse will generate and return a token
|
|
Username string `json:"username,omitempty"` // Username for creating token (e.g., "root@pam")
|
|
Password string `json:"password,omitempty"` // Password for authentication (never stored)
|
|
}
|
|
|
|
// HandleAutoRegister receives token details from the setup script and auto-configures the node
|
|
func (h *ConfigHandlers) HandleAutoRegister(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
// Check registration token if required
|
|
if os.Getenv("REQUIRE_REGISTRATION_TOKEN") == "true" || h.config.APIToken != "" {
|
|
regToken := r.Header.Get("X-Registration-Token")
|
|
apiToken := r.Header.Get("X-API-Token")
|
|
|
|
// If registration token is provided, validate it
|
|
if regToken != "" {
|
|
// Registration token takes precedence
|
|
// We'll validate it later after parsing the request to get node type
|
|
} else if h.config.APIToken != "" {
|
|
// Fall back to API token if configured
|
|
if apiToken == "" || apiToken != h.config.APIToken {
|
|
log.Warn().Str("ip", r.RemoteAddr).Msg("Unauthorized auto-register attempt - missing or invalid API token")
|
|
http.Error(w, "Registration requires valid registration token or API token", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
} else if os.Getenv("REQUIRE_REGISTRATION_TOKEN") == "true" {
|
|
// Registration token explicitly required but not provided
|
|
log.Warn().Str("ip", r.RemoteAddr).Msg("Registration token required but not provided")
|
|
http.Error(w, "Registration token required", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
// Store regToken for later validation (only if not empty)
|
|
if regToken != "" {
|
|
r = r.WithContext(context.WithValue(r.Context(), "regToken", regToken))
|
|
}
|
|
}
|
|
|
|
// Log source IP for security auditing
|
|
clientIP := r.RemoteAddr
|
|
if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
|
|
clientIP = forwarded
|
|
}
|
|
log.Info().Str("clientIP", clientIP).Msg("Auto-register request from")
|
|
|
|
// Read body first to debug
|
|
body, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("Failed to read request body")
|
|
http.Error(w, "Failed to read request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
log.Info().Msg("Auto-register request received")
|
|
|
|
var req AutoRegisterRequest
|
|
if err := json.Unmarshal(body, &req); err != nil {
|
|
log.Error().Err(err).Msg("Failed to decode auto-register request")
|
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Validate registration token if provided
|
|
if regToken := r.Context().Value("regToken"); regToken != nil {
|
|
if tokenStr, ok := regToken.(string); ok && tokenStr != "" {
|
|
if err := h.tokenManager.ValidateToken(tokenStr, req.Type); err != nil {
|
|
log.Warn().
|
|
Str("ip", clientIP).
|
|
Str("token", tokenStr).
|
|
Err(err).
|
|
Msg("Invalid registration token")
|
|
http.Error(w, fmt.Sprintf("Invalid registration token: %v", err), http.StatusUnauthorized)
|
|
return
|
|
}
|
|
log.Info().
|
|
Str("ip", clientIP).
|
|
Str("token", tokenStr).
|
|
Msg("Registration token validated successfully")
|
|
}
|
|
}
|
|
|
|
log.Info().
|
|
Str("type", req.Type).
|
|
Str("host", req.Host).
|
|
Str("tokenId", req.TokenID).
|
|
Bool("hasTokenValue", req.TokenValue != "").
|
|
Str("serverName", req.ServerName).
|
|
Msg("Processing auto-register request")
|
|
|
|
// Check if this is a new secure registration request
|
|
if req.RequestToken {
|
|
// New secure mode - generate token on Pulse side
|
|
if req.Type == "" || req.Host == "" || req.Username == "" || req.Password == "" {
|
|
log.Error().
|
|
Str("type", req.Type).
|
|
Str("host", req.Host).
|
|
Bool("hasUsername", req.Username != "").
|
|
Bool("hasPassword", req.Password != "").
|
|
Msg("Missing required fields for secure registration")
|
|
http.Error(w, "Missing required fields", http.StatusBadRequest)
|
|
return
|
|
}
|
|
// Handle secure registration
|
|
h.handleSecureAutoRegister(w, r, &req, clientIP)
|
|
return
|
|
}
|
|
|
|
// Legacy mode - validate old required fields
|
|
if req.Type == "" || req.Host == "" || req.TokenID == "" || req.TokenValue == "" {
|
|
log.Error().
|
|
Str("type", req.Type).
|
|
Str("host", req.Host).
|
|
Str("tokenId", req.TokenID).
|
|
Bool("hasToken", req.TokenValue != "").
|
|
Msg("Missing required fields")
|
|
http.Error(w, "Missing required fields", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Normalize the host URL
|
|
host := req.Host
|
|
if req.Type == "pve" {
|
|
if !strings.HasPrefix(host, "http://") && !strings.HasPrefix(host, "https://") {
|
|
host = "https://" + host
|
|
}
|
|
// Add port if missing
|
|
if !strings.Contains(host, ":8006") && !strings.Contains(host, ":443") {
|
|
if strings.HasPrefix(host, "https://") {
|
|
if !strings.Contains(host[8:], ":") {
|
|
host += ":8006"
|
|
}
|
|
}
|
|
}
|
|
} else if req.Type == "pbs" {
|
|
if !strings.HasPrefix(host, "http://") && !strings.HasPrefix(host, "https://") {
|
|
host = "https://" + host
|
|
}
|
|
// Add PBS port if missing
|
|
if !strings.Contains(host, ":8007") && !strings.Contains(host[8:], ":") {
|
|
host += ":8007"
|
|
}
|
|
}
|
|
|
|
// Create a node configuration
|
|
nodeConfig := NodeConfigRequest{
|
|
Type: req.Type,
|
|
Name: req.ServerName,
|
|
Host: host, // Use normalized host
|
|
TokenName: req.TokenID,
|
|
TokenValue: req.TokenValue,
|
|
VerifySSL: false, // Default to not verifying SSL for auto-registration
|
|
MonitorVMs: true,
|
|
MonitorContainers: true,
|
|
MonitorStorage: true,
|
|
MonitorBackups: true,
|
|
MonitorDatastores: true,
|
|
MonitorSyncJobs: true,
|
|
MonitorVerifyJobs: true,
|
|
MonitorPruneJobs: true,
|
|
MonitorGarbageJobs: false,
|
|
}
|
|
|
|
// Check if a node with this host already exists
|
|
existingIndex := -1
|
|
if req.Type == "pve" {
|
|
for i, node := range h.config.PVEInstances {
|
|
if node.Host == host { // Use normalized host for comparison
|
|
existingIndex = i
|
|
break
|
|
}
|
|
}
|
|
} else {
|
|
for i, node := range h.config.PBSInstances {
|
|
if node.Host == host { // Use normalized host for comparison
|
|
existingIndex = i
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// If node exists, update it; otherwise add new
|
|
if existingIndex >= 0 {
|
|
// Update existing node
|
|
if req.Type == "pve" {
|
|
instance := &h.config.PVEInstances[existingIndex]
|
|
// Clear password auth when switching to token auth
|
|
instance.User = ""
|
|
instance.Password = ""
|
|
instance.TokenName = nodeConfig.TokenName
|
|
instance.TokenValue = nodeConfig.TokenValue
|
|
|
|
// Check for cluster if not already detected
|
|
if !instance.IsCluster {
|
|
clientConfig := proxmox.ClientConfig{
|
|
Host: instance.Host,
|
|
TokenName: nodeConfig.TokenName,
|
|
TokenValue: nodeConfig.TokenValue,
|
|
VerifySSL: instance.VerifySSL,
|
|
}
|
|
|
|
isCluster, clusterName, clusterEndpoints := detectPVECluster(clientConfig, instance.Name)
|
|
if isCluster {
|
|
instance.IsCluster = true
|
|
instance.ClusterName = clusterName
|
|
instance.ClusterEndpoints = clusterEndpoints
|
|
log.Info().
|
|
Str("cluster", clusterName).
|
|
Int("endpoints", len(clusterEndpoints)).
|
|
Msg("Detected Proxmox cluster during auto-registration update")
|
|
}
|
|
}
|
|
// Keep other settings as they were
|
|
} else {
|
|
instance := &h.config.PBSInstances[existingIndex]
|
|
// Clear password auth when switching to token auth
|
|
instance.User = ""
|
|
instance.Password = ""
|
|
instance.TokenName = nodeConfig.TokenName
|
|
instance.TokenValue = nodeConfig.TokenValue
|
|
// Keep other settings as they were
|
|
}
|
|
log.Info().
|
|
Str("host", req.Host).
|
|
Str("type", req.Type).
|
|
Str("tokenName", nodeConfig.TokenName).
|
|
Bool("hasTokenValue", nodeConfig.TokenValue != "").
|
|
Msg("Updated existing node with new token")
|
|
} else {
|
|
// Add new node
|
|
if req.Type == "pve" {
|
|
// Check for cluster detection using helper
|
|
clientConfig := proxmox.ClientConfig{
|
|
Host: nodeConfig.Host,
|
|
TokenName: nodeConfig.TokenName,
|
|
TokenValue: nodeConfig.TokenValue,
|
|
VerifySSL: nodeConfig.VerifySSL,
|
|
}
|
|
|
|
isCluster, clusterName, clusterEndpoints := detectPVECluster(clientConfig, nodeConfig.Name)
|
|
|
|
newInstance := config.PVEInstance{
|
|
Name: nodeConfig.Name,
|
|
Host: nodeConfig.Host,
|
|
TokenName: nodeConfig.TokenName,
|
|
TokenValue: nodeConfig.TokenValue,
|
|
VerifySSL: nodeConfig.VerifySSL,
|
|
MonitorVMs: nodeConfig.MonitorVMs,
|
|
MonitorContainers: nodeConfig.MonitorContainers,
|
|
MonitorStorage: nodeConfig.MonitorStorage,
|
|
MonitorBackups: nodeConfig.MonitorBackups,
|
|
IsCluster: isCluster,
|
|
ClusterName: clusterName,
|
|
ClusterEndpoints: clusterEndpoints,
|
|
}
|
|
h.config.PVEInstances = append(h.config.PVEInstances, newInstance)
|
|
|
|
if isCluster {
|
|
log.Info().
|
|
Str("cluster", clusterName).
|
|
Int("endpoints", len(clusterEndpoints)).
|
|
Msg("Added Proxmox cluster via auto-registration")
|
|
}
|
|
} else {
|
|
newInstance := config.PBSInstance{
|
|
Name: nodeConfig.Name,
|
|
Host: nodeConfig.Host,
|
|
TokenName: nodeConfig.TokenName,
|
|
TokenValue: nodeConfig.TokenValue,
|
|
VerifySSL: nodeConfig.VerifySSL,
|
|
MonitorBackups: true, // Enable by default for PBS
|
|
MonitorDatastores: nodeConfig.MonitorDatastores,
|
|
MonitorSyncJobs: nodeConfig.MonitorSyncJobs,
|
|
MonitorVerifyJobs: nodeConfig.MonitorVerifyJobs,
|
|
MonitorPruneJobs: nodeConfig.MonitorPruneJobs,
|
|
MonitorGarbageJobs: nodeConfig.MonitorGarbageJobs,
|
|
}
|
|
h.config.PBSInstances = append(h.config.PBSInstances, newInstance)
|
|
}
|
|
log.Info().Str("host", req.Host).Str("type", req.Type).Msg("Added new node via auto-registration")
|
|
}
|
|
|
|
// Log what we're about to save
|
|
if req.Type == "pve" && len(h.config.PVEInstances) > 0 {
|
|
lastNode := h.config.PVEInstances[len(h.config.PVEInstances)-1]
|
|
log.Info().
|
|
Str("name", lastNode.Name).
|
|
Str("host", lastNode.Host).
|
|
Str("tokenName", lastNode.TokenName).
|
|
Bool("hasTokenValue", lastNode.TokenValue != "").
|
|
Msg("About to save PVE node")
|
|
}
|
|
|
|
// Save configuration
|
|
if err := h.persistence.SaveNodesConfig(h.config.PVEInstances, h.config.PBSInstances); err != nil {
|
|
log.Error().Err(err).Msg("Failed to save auto-registered node")
|
|
http.Error(w, "Failed to save configuration", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
log.Info().Msg("Configuration saved successfully")
|
|
|
|
// Reload monitor to pick up new configuration
|
|
if h.reloadFunc != nil {
|
|
log.Info().Msg("Reloading monitor after auto-registration")
|
|
go func() {
|
|
// Run reload in background to avoid blocking the response
|
|
if err := h.reloadFunc(); err != nil {
|
|
log.Error().Err(err).Msg("Failed to reload monitor after auto-registration")
|
|
} else {
|
|
log.Info().Msg("Monitor reloaded successfully after auto-registration")
|
|
}
|
|
}()
|
|
}
|
|
|
|
// Trigger a discovery refresh to remove the node from discovered list
|
|
if h.monitor != nil && h.monitor.GetDiscoveryService() != nil {
|
|
log.Info().Msg("Triggering discovery refresh after auto-registration")
|
|
h.monitor.GetDiscoveryService().ForceRefresh()
|
|
}
|
|
|
|
// Broadcast auto-registration success via WebSocket
|
|
fmt.Println("[AUTO-REGISTER] About to broadcast WebSocket message")
|
|
if h.wsHub != nil {
|
|
fmt.Println("[AUTO-REGISTER] WebSocket hub is available")
|
|
nodeInfo := map[string]interface{}{
|
|
"type": req.Type,
|
|
"host": req.Host,
|
|
"name": req.ServerName,
|
|
"tokenId": req.TokenID,
|
|
"hasToken": true,
|
|
"verifySSL": false,
|
|
"status": "connected",
|
|
}
|
|
|
|
// Broadcast the auto-registration success
|
|
fmt.Printf("[AUTO-REGISTER] Broadcasting message type: node_auto_registered for host: %s\n", req.Host)
|
|
h.wsHub.BroadcastMessage(websocket.Message{
|
|
Type: "node_auto_registered",
|
|
Data: nodeInfo,
|
|
Timestamp: time.Now().Format(time.RFC3339),
|
|
})
|
|
fmt.Println("[AUTO-REGISTER] Broadcast complete")
|
|
|
|
// Also broadcast a discovery update to refresh the UI
|
|
if h.monitor != nil && h.monitor.GetDiscoveryService() != nil {
|
|
result, _ := h.monitor.GetDiscoveryService().GetCachedResult()
|
|
if result != nil {
|
|
h.wsHub.BroadcastMessage(websocket.Message{
|
|
Type: "discovery_update",
|
|
Data: map[string]interface{}{
|
|
"servers": result.Servers,
|
|
"errors": result.Errors,
|
|
"timestamp": time.Now().Unix(),
|
|
},
|
|
Timestamp: time.Now().Format(time.RFC3339),
|
|
})
|
|
log.Info().Msg("Broadcasted discovery update after auto-registration")
|
|
}
|
|
}
|
|
|
|
log.Info().
|
|
Str("host", req.Host).
|
|
Str("name", req.ServerName).
|
|
Str("type", "node_auto_registered").
|
|
Msg("Broadcasted auto-registration success via WebSocket")
|
|
} else {
|
|
fmt.Println("[AUTO-REGISTER] ERROR: WebSocket hub is nil!")
|
|
log.Warn().Msg("WebSocket hub is nil, cannot broadcast auto-registration")
|
|
}
|
|
|
|
// Send success response
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"status": "success",
|
|
"message": fmt.Sprintf("Node %s auto-registered successfully", req.Host),
|
|
"nodeId": req.Host,
|
|
})
|
|
}
|
|
|
|
// handleSecureAutoRegister handles the new secure registration flow where Pulse generates the token
|
|
func (h *ConfigHandlers) handleSecureAutoRegister(w http.ResponseWriter, r *http.Request, req *AutoRegisterRequest, clientIP string) {
|
|
log.Info().
|
|
Str("type", req.Type).
|
|
Str("host", req.Host).
|
|
Str("username", req.Username).
|
|
Msg("Processing secure auto-register request")
|
|
|
|
// Generate a unique token name based on Pulse's IP/hostname
|
|
hostname, _ := os.Hostname()
|
|
if hostname == "" {
|
|
hostname = strings.Replace(clientIP, ".", "-", -1)
|
|
}
|
|
timestamp := time.Now().Unix()
|
|
tokenName := fmt.Sprintf("pulse-%s-%d", hostname, timestamp)
|
|
|
|
// Generate a secure random token value
|
|
tokenBytes := make([]byte, 16)
|
|
if _, err := rand.Read(tokenBytes); err != nil {
|
|
log.Error().Err(err).Msg("Failed to generate secure token")
|
|
http.Error(w, "Failed to generate token", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
tokenValue := fmt.Sprintf("%x-%x-%x-%x-%x",
|
|
tokenBytes[0:4], tokenBytes[4:6], tokenBytes[6:8], tokenBytes[8:10], tokenBytes[10:16])
|
|
|
|
// Normalize the host URL
|
|
host := req.Host
|
|
if req.Type == "pve" {
|
|
if !strings.HasPrefix(host, "http://") && !strings.HasPrefix(host, "https://") {
|
|
host = "https://" + host
|
|
}
|
|
if !strings.Contains(host, ":8006") && !strings.Contains(host, ":443") {
|
|
if strings.HasPrefix(host, "https://") {
|
|
host = strings.Replace(host, "https://", "", 1)
|
|
if !strings.Contains(host, ":") {
|
|
host = "https://" + host + ":8006"
|
|
} else {
|
|
host = "https://" + host
|
|
}
|
|
}
|
|
}
|
|
} else if req.Type == "pbs" {
|
|
if !strings.HasPrefix(host, "http://") && !strings.HasPrefix(host, "https://") {
|
|
host = "https://" + host
|
|
}
|
|
if !strings.Contains(host, ":8007") && !strings.Contains(host, ":443") {
|
|
if strings.HasPrefix(host, "https://") {
|
|
host = strings.Replace(host, "https://", "", 1)
|
|
if !strings.Contains(host, ":") {
|
|
host = "https://" + host + ":8007"
|
|
} else {
|
|
host = "https://" + host
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Create the token on the remote server
|
|
var fullTokenID string
|
|
var createErr error
|
|
|
|
if req.Type == "pve" {
|
|
// For PVE, create token via API
|
|
fullTokenID = fmt.Sprintf("pulse-monitor@pam!%s", tokenName)
|
|
// Note: This would require implementing token creation in the proxmox package
|
|
// For now, we'll return the token for the script to create
|
|
} else if req.Type == "pbs" {
|
|
// For PBS, create token via API
|
|
fullTokenID = fmt.Sprintf("pulse-monitor@pbs!%s", tokenName)
|
|
// Note: This would require implementing token creation in the pbs package
|
|
// For now, we'll return the token for the script to create
|
|
}
|
|
|
|
if createErr != nil {
|
|
log.Error().Err(createErr).Msg("Failed to create token on remote server")
|
|
http.Error(w, "Failed to create token on remote server", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Determine server name
|
|
serverName := req.ServerName
|
|
if serverName == "" {
|
|
// Extract from host
|
|
serverName = host
|
|
serverName = strings.TrimPrefix(serverName, "https://")
|
|
serverName = strings.TrimPrefix(serverName, "http://")
|
|
if idx := strings.Index(serverName, ":"); idx > 0 {
|
|
serverName = serverName[:idx]
|
|
}
|
|
}
|
|
|
|
// Add the node to configuration
|
|
if req.Type == "pve" {
|
|
pveNode := config.PVEInstance{
|
|
Name: serverName,
|
|
Host: host,
|
|
TokenName: fullTokenID,
|
|
TokenValue: tokenValue,
|
|
VerifySSL: false,
|
|
MonitorVMs: true,
|
|
MonitorContainers: true,
|
|
MonitorStorage: true,
|
|
MonitorBackups: true,
|
|
}
|
|
h.config.PVEInstances = append(h.config.PVEInstances, pveNode)
|
|
} else if req.Type == "pbs" {
|
|
pbsNode := config.PBSInstance{
|
|
Name: serverName,
|
|
Host: host,
|
|
TokenName: fullTokenID,
|
|
TokenValue: tokenValue,
|
|
VerifySSL: false,
|
|
MonitorBackups: true,
|
|
MonitorDatastores: true,
|
|
MonitorSyncJobs: true,
|
|
MonitorVerifyJobs: true,
|
|
MonitorPruneJobs: true,
|
|
}
|
|
h.config.PBSInstances = append(h.config.PBSInstances, pbsNode)
|
|
}
|
|
|
|
// Save configuration
|
|
if err := h.persistence.SaveNodesConfig(h.config.PVEInstances, h.config.PBSInstances); err != nil {
|
|
log.Error().Err(err).Msg("Failed to save auto-registered node")
|
|
http.Error(w, "Failed to save configuration", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Reload monitor
|
|
if h.reloadFunc != nil {
|
|
go func() {
|
|
if err := h.reloadFunc(); err != nil {
|
|
log.Error().Err(err).Msg("Failed to reload monitor after auto-registration")
|
|
}
|
|
}()
|
|
}
|
|
|
|
// Send success response with token details for script to create
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"status": "success",
|
|
"message": fmt.Sprintf("Node %s registered successfully", req.Host),
|
|
"nodeId": serverName,
|
|
"tokenId": fullTokenID,
|
|
"tokenValue": tokenValue,
|
|
"action": "create_token", // Tells the script to create this token
|
|
})
|
|
} |