mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-15 00:22:28 +00:00
d56659a402
The Add Pulse Agent flow composed a fixed scope list in the frontend, so ticking Enable Pulse command execution added --enable-commands to the install command while the token it shipped with never carried agent:exec, and the command channel rejected every registration (issues #1586, #1564, confirmed by a reporter's server log). Even with the scope, the generic token had no binding metadata, so the first-use binding gate would have refused it next. The install token is now minted through POST /api/agent-install-command with type 'host'. The server decides the scopes from enableCommands at mint time, stamps the install_type/issued_via metadata that makes the token eligible for first-use command-channel binding (canBindProxmoxAgentInstallExecToken renamed canBindAgentInstallExecToken and extended to the host install type), and returns the sanitized token record. The frontend regenerates the token when the checkbox toggles, since scopes cannot be upgraded on an existing token, and revokes the superseded token so toggling does not accumulate orphans. Contract-Neutral: install-token mint bugfix (#1586, #1564): checkbox-promised exec scope now real; agent-lifecycle contract delta deferred because the contract docs carry another agent's uncommitted WIP on the shared tree
1933 lines
67 KiB
Go
1933 lines
67 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ed25519"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"encoding/pem"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"golang.org/x/crypto/ssh"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/system"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/websocket"
|
|
internalauth "github.com/rcourtman/pulse-go-rewrite/pkg/auth"
|
|
"github.com/rs/zerolog/log"
|
|
)
|
|
|
|
// agentAutoRegContextKey marks a request authenticated via agent:report token.
|
|
// When set, handleCanonicalAutoRegister restricts to updating existing nodes only.
|
|
type agentAutoRegContextKey struct{}
|
|
|
|
func isAgentAutoRegAuth(ctx context.Context) bool {
|
|
v, _ := ctx.Value(agentAutoRegContextKey{}).(bool)
|
|
return v
|
|
}
|
|
|
|
// 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 := strings.TrimSpace(query.Get("type")) // "pve" or "pbs"
|
|
serverHost := strings.TrimSpace(query.Get("host"))
|
|
pulseURL := strings.TrimSpace(query.Get("pulse_url")) // URL of the Pulse server for auto-registration
|
|
backupPerms := query.Get("backup_perms") == "true" // Whether to add backup management permissions
|
|
setupToken := strings.TrimSpace(query.Get("setup_token")) // Temporary setup token for auto-registration
|
|
|
|
// Validate required parameters
|
|
if serverType == "" {
|
|
http.Error(w, "Missing required parameter: type (must be 'pve' or 'pbs')", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if !isCanonicalAutoRegisterType(serverType) {
|
|
http.Error(w, "type must be 'pve' or 'pbs'", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if serverHost == "" {
|
|
http.Error(w, "Missing required parameter: host", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if safeHost, err := sanitizeInstallerURL(serverHost); err != nil {
|
|
http.Error(w, fmt.Sprintf("Invalid host parameter: %v", err), http.StatusBadRequest)
|
|
return
|
|
} else {
|
|
serverHost = safeHost
|
|
}
|
|
if normalizedHost, err := normalizeNodeHost(serverHost, serverType); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
} else {
|
|
serverHost = normalizedHost
|
|
}
|
|
if pulseURL == "" {
|
|
http.Error(w, "Missing required parameter: pulse_url", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if safeURL, err := sanitizeInstallerURL(pulseURL); err != nil {
|
|
http.Error(w, fmt.Sprintf("Invalid pulse_url parameter: %v", err), http.StatusBadRequest)
|
|
return
|
|
} else {
|
|
pulseURL = safeURL
|
|
}
|
|
if backupPerms && serverType != "pve" {
|
|
http.Error(w, "backup_perms is only supported for type 'pve'", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if sanitizedToken, err := sanitizeSetupAuthToken(setupToken); err != nil {
|
|
http.Error(w, fmt.Sprintf("Invalid setup_token parameter: %v", err), http.StatusBadRequest)
|
|
return
|
|
} else {
|
|
setupToken = sanitizedToken
|
|
}
|
|
// Ensure validated pulseURL stays normalized before it reaches generated script state.
|
|
if safeURL, err := sanitizeInstallerURL(pulseURL); err == nil {
|
|
pulseURL = safeURL
|
|
}
|
|
|
|
log.Info().
|
|
Str("type", serverType).
|
|
Str("host", serverHost).
|
|
Bool("has_auth", h.getConfig(r.Context()).AuthUser != "" || h.getConfig(r.Context()).AuthPass != "" || h.getConfig(r.Context()).HasAPITokens()).
|
|
Msg("HandleSetupScript called")
|
|
|
|
// The setup script is now public; authentication happens via setup token.
|
|
// No need to check auth here since the script will prompt for a code
|
|
|
|
serverName := deriveSetupScriptServerName(serverHost)
|
|
|
|
pulseTokenScope := pulseTokenSuffix(pulseURL)
|
|
tokenName := buildPulseMonitorTokenName(pulseURL)
|
|
tokenMatchPrefix := tokenName
|
|
|
|
// Log the token name for debugging
|
|
log.Info().
|
|
Str("pulseURL", pulseURL).
|
|
Str("pulseTokenScope", pulseTokenScope).
|
|
Str("tokenName", tokenName).
|
|
Msg("Generated deterministic token name for setup script")
|
|
|
|
artifact := buildSetupScriptInstallArtifact(
|
|
pulseURL,
|
|
serverType,
|
|
serverHost,
|
|
pulseURL,
|
|
backupPerms,
|
|
setupToken,
|
|
0,
|
|
)
|
|
|
|
// Get or generate SSH public key for temperature monitoring
|
|
sshKeys := h.getOrGenerateSSHKeys()
|
|
|
|
storagePerms := ""
|
|
storageRepairPerms := ""
|
|
if backupPerms {
|
|
storagePerms = `
|
|
pveum aclmod /storage -user pulse-monitor@pve -role PVEDatastoreAdmin
|
|
if [ "$TOKEN_CREATED" = true ]; then
|
|
pveum aclmod /storage -token "$PULSE_TOKEN_ID" -role PVEDatastoreAdmin
|
|
fi`
|
|
storageRepairPerms = `
|
|
pveum aclmod /storage -user pulse-monitor@pve -role PVEDatastoreAdmin
|
|
if pulse_pve_token_exists; then
|
|
pveum aclmod /storage -token "$PULSE_TOKEN_ID" -role PVEDatastoreAdmin
|
|
fi`
|
|
}
|
|
|
|
script := renderSetupScript(serverType, setupScriptRenderContext{
|
|
ServerName: serverName,
|
|
PulseURL: pulseURL,
|
|
ServerHost: serverHost,
|
|
SetupToken: setupToken,
|
|
TokenName: tokenName,
|
|
TokenMatchPrefix: tokenMatchPrefix,
|
|
StoragePerms: storagePerms,
|
|
StorageRepairPerms: storageRepairPerms,
|
|
SensorsPublicKey: sshKeys.SensorsPublicKey,
|
|
Artifact: artifact,
|
|
})
|
|
|
|
// Serve setup scripts as canonical shell-script downloads instead of generic text.
|
|
w.Header().Set("Content-Type", "text/x-shellscript; charset=utf-8")
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", buildSetupScriptFileName(serverType)))
|
|
w.Write([]byte(script))
|
|
}
|
|
|
|
type setupScriptURLRequest struct {
|
|
Type string `json:"type"`
|
|
Host string `json:"host"`
|
|
BackupPerms bool `json:"backupPerms"`
|
|
}
|
|
|
|
// generateSetupTokenRecord generates a secure hex token that satisfies sanitizeSetupAuthToken.
|
|
func (h *ConfigHandlers) generateSetupTokenRecord() string {
|
|
// 16 bytes => 32 hex characters which matches the sanitizer's lower bound.
|
|
const tokenBytes = 16
|
|
buf := make([]byte, tokenBytes)
|
|
if _, err := rand.Read(buf); err == nil {
|
|
return hex.EncodeToString(buf)
|
|
}
|
|
|
|
// rand.Read should never fail, but if it does fall back to timestamp-based token.
|
|
log.Warn().Msg("fallback setup token generator used due to entropy failure")
|
|
return fmt.Sprintf("%d", time.Now().UnixNano())
|
|
}
|
|
|
|
// HandleSetupScriptURL generates a one-time setup token and URL for the setup script.
|
|
func (h *ConfigHandlers) handleSetupScriptURL(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
// Limit request body to 8KB to prevent memory exhaustion
|
|
r.Body = http.MaxBytesReader(w, r.Body, 8*1024)
|
|
|
|
// Parse request
|
|
var req setupScriptURLRequest
|
|
decoder := json.NewDecoder(r.Body)
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(&req); err != nil {
|
|
http.Error(w, "Invalid request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if decoder.More() {
|
|
http.Error(w, "Invalid request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
|
http.Error(w, "Invalid request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
req.Type = strings.TrimSpace(req.Type)
|
|
req.Host = strings.TrimSpace(req.Host)
|
|
if !isCanonicalAutoRegisterType(req.Type) {
|
|
http.Error(w, "type must be 'pve' or 'pbs'", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if req.BackupPerms && req.Type != "pve" {
|
|
http.Error(w, "backupPerms is only supported for type 'pve'", http.StatusBadRequest)
|
|
return
|
|
}
|
|
normalizedHost, err := normalizeNodeHost(req.Host, req.Type)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
req.Host = normalizedHost
|
|
|
|
// Generate a temporary setup token for setup-script bootstrap transport.
|
|
token := h.generateSetupTokenRecord()
|
|
tokenHash := internalauth.HashAPIToken(token)
|
|
|
|
// Store the token with expiry (5 minutes)
|
|
expiry := time.Now().Add(5 * time.Minute)
|
|
h.codeMutex.Lock()
|
|
h.setupTokens[tokenHash] = &SetupTokenRecord{
|
|
ExpiresAt: expiry,
|
|
Used: false,
|
|
NodeType: req.Type,
|
|
Host: req.Host,
|
|
OrgID: GetOrgID(r.Context()),
|
|
}
|
|
h.codeMutex.Unlock()
|
|
|
|
log.Info().
|
|
Str("token_hash", safePrefixForLog(tokenHash, 8)+"...").
|
|
Time("expiry", expiry).
|
|
Str("type", req.Type).
|
|
Msg("Generated temporary setup token")
|
|
|
|
// Build the canonical bootstrap artifact for setup-script transport.
|
|
pulseURL := resolveLoopbackAwarePublicBaseURL(r, h.getConfig(r.Context()))
|
|
artifact := buildSetupScriptInstallArtifact(
|
|
pulseURL,
|
|
req.Type,
|
|
req.Host,
|
|
pulseURL,
|
|
req.BackupPerms,
|
|
token,
|
|
expiry.Unix(),
|
|
)
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(artifact)
|
|
}
|
|
|
|
// AutoRegisterRequest represents a request from the setup script or agent to auto-register a node
|
|
type AutoRegisterRequest struct {
|
|
Type string `json:"type"` // "pve" or "pbs"
|
|
Host string `json:"host"` // The preferred host URL
|
|
CandidateHosts []string `json:"candidateHosts,omitempty"` // Alternate host URLs the server can try from its own network view
|
|
TokenID string `json:"tokenId,omitempty"` // Full token ID like pulse-monitor@pve!pulse-token
|
|
TokenValue string `json:"tokenValue,omitempty"` // The token value for the node
|
|
ServerName string `json:"serverName,omitempty"` // Hostname or IP
|
|
AuthToken string `json:"authToken,omitempty"` // One-time setup token from setup/install flows
|
|
Source string `json:"source,omitempty"` // "agent" or "script" - indicates how the node was registered
|
|
CheckRegistration bool `json:"checkRegistration,omitempty"` // Check whether a matching registered node already exists without completing registration
|
|
}
|
|
|
|
// AutoRegisterResponse is the canonical success shape for /api/auto-register.
|
|
type AutoRegisterResponse struct {
|
|
Status string `json:"status"`
|
|
Message string `json:"message"`
|
|
Action string `json:"action"`
|
|
Type string `json:"type"`
|
|
Source string `json:"source"`
|
|
Host string `json:"host"`
|
|
NodeID string `json:"nodeId"`
|
|
NodeName string `json:"nodeName"`
|
|
TokenID string `json:"tokenId"`
|
|
TokenValue string `json:"tokenValue,omitempty"`
|
|
}
|
|
|
|
// AutoUnregisterRequest represents a request from the setup script to tear down
|
|
// a script-managed node connection after local credentials have been removed.
|
|
type AutoUnregisterRequest struct {
|
|
Type string `json:"type"` // "pve" or "pbs"
|
|
Host string `json:"host"` // The canonical node host URL
|
|
TokenID string `json:"tokenId,omitempty"` // Full token ID like pulse-monitor@pve!pulse-token
|
|
ServerName string `json:"serverName,omitempty"` // Hostname or IP
|
|
AuthToken string `json:"authToken,omitempty"` // One-time setup token from the setup flow
|
|
Source string `json:"source,omitempty"` // Must be "script"
|
|
}
|
|
|
|
// AutoUnregisterResponse is the canonical success shape for /api/auto-unregister.
|
|
type AutoUnregisterResponse struct {
|
|
Status string `json:"status"`
|
|
Message string `json:"message"`
|
|
Action string `json:"action"`
|
|
Type string `json:"type"`
|
|
Source string `json:"source"`
|
|
Host string `json:"host"`
|
|
NodeID string `json:"nodeId,omitempty"`
|
|
NodeName string `json:"nodeName,omitempty"`
|
|
Removed bool `json:"removed"`
|
|
}
|
|
|
|
func isCanonicalAutoRegisterType(nodeType string) bool {
|
|
switch strings.TrimSpace(nodeType) {
|
|
case "pve", "pbs":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func isCanonicalAutoRegisterTokenID(nodeType string, tokenID string) bool {
|
|
trimmedType := strings.TrimSpace(nodeType)
|
|
trimmedTokenID := strings.TrimSpace(tokenID)
|
|
if !isCanonicalAutoRegisterType(trimmedType) || trimmedTokenID == "" {
|
|
return false
|
|
}
|
|
prefix := "pulse-monitor@" + trimmedType + "!"
|
|
if !strings.HasPrefix(trimmedTokenID, prefix) {
|
|
return false
|
|
}
|
|
suffix := strings.TrimSpace(strings.TrimPrefix(trimmedTokenID, prefix))
|
|
return strings.HasPrefix(suffix, "pulse-") && suffix != "pulse-"
|
|
}
|
|
|
|
func isCanonicalAutoRegisterSource(source string) bool {
|
|
switch strings.TrimSpace(source) {
|
|
case "agent", "script":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func normalizeAutoRegisterHostCandidates(nodeType, primary string, alternates []string) ([]string, error) {
|
|
candidates := make([]string, 0, len(alternates)+1)
|
|
seen := make(map[string]struct{}, len(alternates)+1)
|
|
addCandidate := func(raw string) error {
|
|
if strings.TrimSpace(raw) == "" {
|
|
return nil
|
|
}
|
|
normalized, err := normalizeNodeHost(raw, nodeType)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, exists := seen[normalized]; exists {
|
|
return nil
|
|
}
|
|
seen[normalized] = struct{}{}
|
|
candidates = append(candidates, normalized)
|
|
return nil
|
|
}
|
|
|
|
if err := addCandidate(primary); err != nil {
|
|
return nil, err
|
|
}
|
|
for _, candidate := range alternates {
|
|
if err := addCandidate(candidate); err != nil {
|
|
log.Debug().
|
|
Str("type", nodeType).
|
|
Str("candidate", candidate).
|
|
Err(err).
|
|
Msg("Ignoring invalid auto-register host candidate")
|
|
}
|
|
}
|
|
|
|
if len(candidates) == 0 {
|
|
return nil, fmt.Errorf("host is required")
|
|
}
|
|
return candidates, nil
|
|
}
|
|
|
|
func selectAutoRegisterHost(nodeType, primary string, alternates []string) (string, string, bool, []string, error) {
|
|
candidates, err := normalizeAutoRegisterHostCandidates(nodeType, primary, alternates)
|
|
if err != nil {
|
|
return "", "", false, nil, err
|
|
}
|
|
|
|
for idx, candidate := range candidates {
|
|
fingerprint, err := fetchTLSFingerprint(candidate)
|
|
if err != nil || strings.TrimSpace(fingerprint) == "" {
|
|
log.Debug().
|
|
Str("type", nodeType).
|
|
Str("candidate", candidate).
|
|
Err(err).
|
|
Msg("Auto-register candidate not reachable for fingerprint capture")
|
|
continue
|
|
}
|
|
if idx > 0 {
|
|
log.Info().
|
|
Str("type", nodeType).
|
|
Str("selectedHost", candidate).
|
|
Str("requestedHost", candidates[0]).
|
|
Msg("Auto-register switched to fallback host candidate reachable from Pulse")
|
|
}
|
|
return candidate, fingerprint, true, candidates, nil
|
|
}
|
|
|
|
return candidates[0], "", false, candidates, nil
|
|
}
|
|
|
|
func canonicalAutoRegisterMatchMessage(reason string) string {
|
|
return "Canonical auto-register matched existing node by " + reason
|
|
}
|
|
|
|
// shouldPreserveExistingAutoRegisterHost reports whether an identity-matched
|
|
// node keeps its stored host on re-registration. The agent orders
|
|
// candidateHosts by preference, so an existing host that appears anywhere in
|
|
// that list was deliberately outranked by the selected host and is replaced.
|
|
// A host absent from the list is an admin-managed endpoint the agent cannot
|
|
// see; keep it.
|
|
func shouldPreserveExistingAutoRegisterHost(existingHost string, candidateHosts []string) bool {
|
|
existing := strings.TrimSpace(existingHost)
|
|
for _, candidate := range candidateHosts {
|
|
if strings.EqualFold(strings.TrimSpace(candidate), existing) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// clusterMemberOverrideIdentity canonicalizes a connection address (bare
|
|
// host, host:port, or URL) to lowercase host:port for comparisons, defaulting
|
|
// to the PVE API port. Mirrors config's cluster endpoint identity
|
|
// normalization.
|
|
func clusterMemberOverrideIdentity(value string) string {
|
|
v := strings.TrimSpace(value)
|
|
if v == "" {
|
|
return ""
|
|
}
|
|
if !strings.Contains(v, "://") {
|
|
v = "https://" + v
|
|
}
|
|
parsed, err := url.Parse(v)
|
|
if err != nil || parsed.Hostname() == "" {
|
|
return ""
|
|
}
|
|
host := strings.ToLower(parsed.Hostname())
|
|
if ip := net.ParseIP(host); ip != nil {
|
|
host = ip.String()
|
|
}
|
|
port := parsed.Port()
|
|
if port == "" {
|
|
port = config.DefaultPVEPort
|
|
}
|
|
return net.JoinHostPort(host, port)
|
|
}
|
|
|
|
// findCanonicalAutoRegisterClusterMember locates the cluster member endpoint
|
|
// an auto-registering agent corresponds to when no top-level instance host
|
|
// matched. Endpoint address identity against the agent's candidate list is
|
|
// authoritative; a corosync node-name match (agent hostnames may be FQDNs
|
|
// while corosync names are short) is accepted only when it is unambiguous
|
|
// across every configured instance.
|
|
func findCanonicalAutoRegisterClusterMember(instances []config.PVEInstance, serverName string, candidateHosts []string) (int, int, bool) {
|
|
for i := range instances {
|
|
for j := range instances[i].ClusterEndpoints {
|
|
if autoRegisterClusterEndpointMatchesCandidates(instances[i].ClusterEndpoints[j], candidateHosts) {
|
|
return i, j, true
|
|
}
|
|
}
|
|
}
|
|
|
|
name := strings.ToLower(strings.TrimSpace(serverName))
|
|
if name == "" {
|
|
return -1, -1, false
|
|
}
|
|
short := name
|
|
if dot := strings.IndexByte(short, '.'); dot > 0 {
|
|
short = short[:dot]
|
|
}
|
|
matchInstance, matchEndpoint, matches := -1, -1, 0
|
|
for i := range instances {
|
|
for j := range instances[i].ClusterEndpoints {
|
|
nodeName := strings.ToLower(strings.TrimSpace(instances[i].ClusterEndpoints[j].NodeName))
|
|
if nodeName == "" {
|
|
continue
|
|
}
|
|
if nodeName == name || nodeName == short {
|
|
matchInstance, matchEndpoint = i, j
|
|
matches++
|
|
}
|
|
}
|
|
}
|
|
if matches == 1 {
|
|
return matchInstance, matchEndpoint, true
|
|
}
|
|
return -1, -1, false
|
|
}
|
|
|
|
// shouldAdoptClusterMemberAutoRegisterAddress reports whether a member-matched
|
|
// re-registration may replace the endpoint's connection override. Discovered
|
|
// member Host/IP are rebuilt from cluster status on every re-discovery, so an
|
|
// empty override always adopts the agent's Pulse-verified address. An existing
|
|
// override is replaced only when it appears in the agent's candidate list
|
|
// (deliberately outranked, the member analogue of
|
|
// shouldPreserveExistingAutoRegisterHost); an override the agent cannot see is
|
|
// admin-managed and kept.
|
|
func shouldAdoptClusterMemberAutoRegisterAddress(existingOverride string, candidateHosts []string) bool {
|
|
override := clusterMemberOverrideIdentity(existingOverride)
|
|
if override == "" {
|
|
return true
|
|
}
|
|
for _, candidate := range candidateHosts {
|
|
if identity := clusterMemberOverrideIdentity(candidate); identity != "" && identity == override {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// adoptCanonicalAutoRegisterClusterMember handles re-registration of an agent
|
|
// whose node is a non-primary cluster member rather than a top-level instance:
|
|
// the cluster connection already covers it, so instead of creating a
|
|
// standalone duplicate (which ConsolidatePVEInstances would fold back in,
|
|
// discarding the agent's address), adopt the Pulse-verified selected host as
|
|
// the member's connection override. A covered member must not replace the
|
|
// cluster's credentials with its own per-node token; the only credential
|
|
// writes are a same-token-identity secret refresh (the agent rotates its
|
|
// token in place on reinstall, so the stored secret is already dead) and a
|
|
// full promotion onto a cluster that has no credentials at all.
|
|
func (h *ConfigHandlers) adoptCanonicalAutoRegisterClusterMember(ctx context.Context, serverName, selectedHost, fingerprint, tokenID, tokenValue string, candidateHosts []string, source string) bool {
|
|
instances := h.getConfig(ctx).PVEInstances
|
|
i, j, ok := findCanonicalAutoRegisterClusterMember(instances, serverName, candidateHosts)
|
|
if !ok {
|
|
return false
|
|
}
|
|
instance := &instances[i]
|
|
endpoint := &instance.ClusterEndpoints[j]
|
|
|
|
switch {
|
|
case tokenID != "" && instance.TokenName == tokenID:
|
|
if tokenValue != "" {
|
|
instance.TokenValue = tokenValue
|
|
}
|
|
case tokenID != "" && instance.TokenName == "" && instance.TokenValue == "" && instance.User == "" && instance.Password == "":
|
|
instance.TokenName = tokenID
|
|
instance.TokenValue = tokenValue
|
|
}
|
|
if instance.Source == "" && strings.TrimSpace(source) != "" {
|
|
instance.Source = strings.TrimSpace(source)
|
|
}
|
|
|
|
selectedIdentity := clusterMemberOverrideIdentity(selectedHost)
|
|
effectiveIdentity := clusterMemberOverrideIdentity(endpoint.EffectiveIP())
|
|
switch {
|
|
case selectedIdentity == "":
|
|
return true
|
|
case selectedIdentity == effectiveIdentity:
|
|
// The member's effective address already routes to the agent's
|
|
// selection; just refresh the certificate pin below.
|
|
case shouldAdoptClusterMemberAutoRegisterAddress(endpoint.IPOverride, candidateHosts):
|
|
override, err := normalizeClusterEndpointIPOverride(selectedHost)
|
|
if err != nil || override == "" {
|
|
return true
|
|
}
|
|
log.Info().
|
|
Str("cluster", instances[i].Name).
|
|
Str("node", endpoint.NodeName).
|
|
Str("previousOverride", endpoint.IPOverride).
|
|
Str("override", override).
|
|
Msg(canonicalAutoRegisterMatchMessage("cluster member endpoint; adopted agent connection address"))
|
|
endpoint.IPOverride = override
|
|
default:
|
|
// Admin-managed override the agent cannot see; keep it and leave the
|
|
// fingerprint pinned to that address.
|
|
log.Info().
|
|
Str("cluster", instances[i].Name).
|
|
Str("node", endpoint.NodeName).
|
|
Str("override", endpoint.IPOverride).
|
|
Msg(canonicalAutoRegisterMatchMessage("cluster member endpoint; preserved admin-managed override"))
|
|
return true
|
|
}
|
|
|
|
// The fingerprint was captured from selectedHost, which is now the
|
|
// member's effective address; a reinstall may have regenerated the node's
|
|
// TLS certificate, so refresh the pin whenever one was captured.
|
|
if strings.TrimSpace(fingerprint) != "" {
|
|
endpoint.Fingerprint = fingerprint
|
|
}
|
|
return true
|
|
}
|
|
|
|
func canonicalAutoRegisterCompletionPayloadMessage() string {
|
|
return "Incomplete canonical auto-register token completion payload"
|
|
}
|
|
|
|
func canonicalAutoRegisterCheckMissingFieldsMessage(typeValue string, host string, serverName string) string {
|
|
missing := make([]string, 0, 3)
|
|
if strings.TrimSpace(typeValue) == "" {
|
|
missing = append(missing, "type")
|
|
}
|
|
if strings.TrimSpace(host) == "" {
|
|
missing = append(missing, "host")
|
|
}
|
|
if strings.TrimSpace(serverName) == "" {
|
|
missing = append(missing, "serverName")
|
|
}
|
|
if len(missing) == 0 {
|
|
return "Missing required canonical auto-register check fields"
|
|
}
|
|
return "Missing required canonical auto-register check fields: " + strings.Join(missing, ", ")
|
|
}
|
|
|
|
func canonicalAutoRegisterMissingFieldsMessage(typeValue string, host string, hasTokenID bool, serverName string) string {
|
|
missing := make([]string, 0, 4)
|
|
if strings.TrimSpace(typeValue) == "" {
|
|
missing = append(missing, "type")
|
|
}
|
|
if strings.TrimSpace(host) == "" {
|
|
missing = append(missing, "host")
|
|
}
|
|
if !hasTokenID {
|
|
missing = append(missing, "tokenId/tokenValue")
|
|
}
|
|
if strings.TrimSpace(serverName) == "" {
|
|
missing = append(missing, "serverName")
|
|
}
|
|
if len(missing) == 0 {
|
|
return "Missing required canonical auto-register fields"
|
|
}
|
|
return "Missing required canonical auto-register fields: " + strings.Join(missing, ", ")
|
|
}
|
|
|
|
func canonicalAutoRegisterNodeIdentity(req *AutoRegisterRequest, actualName string, host string) string {
|
|
eventName := strings.TrimSpace(actualName)
|
|
if eventName == "" {
|
|
eventName = strings.TrimSpace(req.ServerName)
|
|
}
|
|
if eventName == "" {
|
|
eventName = strings.TrimSpace(host)
|
|
}
|
|
return eventName
|
|
}
|
|
|
|
func canonicalAutoRegisterSuccessMessage(nodeName string, host string) string {
|
|
trimmedNodeName := strings.TrimSpace(nodeName)
|
|
trimmedHost := strings.TrimSpace(host)
|
|
if trimmedNodeName == "" {
|
|
return fmt.Sprintf("Node registered successfully at %s", trimmedHost)
|
|
}
|
|
if trimmedHost == "" {
|
|
return fmt.Sprintf("Node %s registered successfully", trimmedNodeName)
|
|
}
|
|
return fmt.Sprintf("Node %s registered successfully at %s", trimmedNodeName, trimmedHost)
|
|
}
|
|
|
|
func canonicalAutoUnregisterMissingFieldsMessage(typeValue string, host string, serverName string) string {
|
|
missing := make([]string, 0, 3)
|
|
if strings.TrimSpace(typeValue) == "" {
|
|
missing = append(missing, "type")
|
|
}
|
|
if strings.TrimSpace(host) == "" {
|
|
missing = append(missing, "host")
|
|
}
|
|
if strings.TrimSpace(serverName) == "" {
|
|
missing = append(missing, "serverName")
|
|
}
|
|
if len(missing) == 0 {
|
|
return "Missing required canonical auto-unregister fields"
|
|
}
|
|
return "Missing required canonical auto-unregister fields: " + strings.Join(missing, ", ")
|
|
}
|
|
|
|
func canonicalAutoUnregisterSuccessMessage(nodeName string, host string, removed bool) string {
|
|
if !removed {
|
|
return fmt.Sprintf("No matching node is currently configured for %s", strings.TrimSpace(host))
|
|
}
|
|
trimmedNodeName := strings.TrimSpace(nodeName)
|
|
trimmedHost := strings.TrimSpace(host)
|
|
if trimmedNodeName == "" {
|
|
return fmt.Sprintf("Node removed successfully from %s", trimmedHost)
|
|
}
|
|
if trimmedHost == "" {
|
|
return fmt.Sprintf("Node %s removed successfully", trimmedNodeName)
|
|
}
|
|
return fmt.Sprintf("Node %s removed successfully from %s", trimmedNodeName, trimmedHost)
|
|
}
|
|
|
|
func buildCanonicalAutoUnregisterResponse(req *AutoUnregisterRequest, host string, actualName string, removed bool) AutoUnregisterResponse {
|
|
action := "noop"
|
|
if removed {
|
|
action = "remove_connection"
|
|
}
|
|
return AutoUnregisterResponse{
|
|
Status: "success",
|
|
Message: canonicalAutoUnregisterSuccessMessage(actualName, host, removed),
|
|
Action: action,
|
|
Type: strings.TrimSpace(req.Type),
|
|
Source: strings.TrimSpace(req.Source),
|
|
Host: strings.TrimSpace(host),
|
|
NodeID: strings.TrimSpace(actualName),
|
|
NodeName: strings.TrimSpace(actualName),
|
|
Removed: removed,
|
|
}
|
|
}
|
|
|
|
func (h *ConfigHandlers) authenticateSetupScriptTeardownRequest(r *http.Request, nodeType string, authToken string) (*http.Request, bool) {
|
|
setupToken := strings.TrimSpace(authToken)
|
|
if setupToken == "" {
|
|
return r, false
|
|
}
|
|
|
|
requestOrgID := GetOrgID(r.Context())
|
|
if !h.ValidateSetupTokenForOrg(setupToken, requestOrgID) {
|
|
return r, false
|
|
}
|
|
|
|
tokenHash := internalauth.HashAPIToken(setupToken)
|
|
now := time.Now()
|
|
|
|
h.codeMutex.Lock()
|
|
defer h.codeMutex.Unlock()
|
|
|
|
record, exists := h.setupTokens[tokenHash]
|
|
if exists {
|
|
if strings.TrimSpace(record.NodeType) != "" && strings.TrimSpace(record.NodeType) != strings.TrimSpace(nodeType) {
|
|
return r, false
|
|
}
|
|
if !record.Used && now.Before(record.ExpiresAt) {
|
|
record.Used = true
|
|
graceExpiry := recentSetupTokenGraceExpiry(record, now)
|
|
h.recentSetupTokens[tokenHash] = buildRecentSetupTokenRecord(record, graceExpiry)
|
|
}
|
|
if record.OrgID != "" {
|
|
r = r.WithContext(context.WithValue(r.Context(), OrgIDContextKey, record.OrgID))
|
|
}
|
|
return r, true
|
|
}
|
|
|
|
recentRecord, exists := h.recentSetupTokens[tokenHash]
|
|
if !exists || !now.Before(recentRecord.ExpiresAt) {
|
|
return r, false
|
|
}
|
|
if recentRecord.NodeType != "" && recentRecord.NodeType != strings.TrimSpace(nodeType) {
|
|
return r, false
|
|
}
|
|
if recentRecord.OrgID != "" {
|
|
r = r.WithContext(context.WithValue(r.Context(), OrgIDContextKey, recentRecord.OrgID))
|
|
}
|
|
|
|
return r, true
|
|
}
|
|
|
|
func autoUnregisterNodeMatch(nodeHost string, candidateHosts []string, nodeTokenID string, requestedTokenID string) bool {
|
|
if !autoRegisterHostMatchesCandidates(nodeHost, candidateHosts) {
|
|
return false
|
|
}
|
|
trimmedRequestedTokenID := strings.TrimSpace(requestedTokenID)
|
|
if trimmedRequestedTokenID == "" {
|
|
return true
|
|
}
|
|
return strings.TrimSpace(nodeTokenID) == trimmedRequestedTokenID
|
|
}
|
|
|
|
func (h *ConfigHandlers) removeAutoRegisteredNode(ctx context.Context, req *AutoUnregisterRequest, candidateHosts []string) (removed bool, actualName string, removedHost string) {
|
|
cfg := h.getConfig(ctx)
|
|
if cfg == nil {
|
|
return false, "", ""
|
|
}
|
|
|
|
switch strings.TrimSpace(req.Type) {
|
|
case "pve":
|
|
hostOnlyFallback := -1
|
|
for i, node := range cfg.PVEInstances {
|
|
if autoUnregisterNodeMatch(node.Host, candidateHosts, node.TokenName, req.TokenID) {
|
|
actualName = node.Name
|
|
removedHost = node.Host
|
|
cfg.PVEInstances = append(cfg.PVEInstances[:i], cfg.PVEInstances[i+1:]...)
|
|
h.normalizePVEConfigState(ctx)
|
|
return true, actualName, removedHost
|
|
}
|
|
if hostOnlyFallback == -1 && autoRegisterHostMatchesCandidates(node.Host, candidateHosts) && node.Source == "script" {
|
|
hostOnlyFallback = i
|
|
}
|
|
}
|
|
if hostOnlyFallback >= 0 {
|
|
node := cfg.PVEInstances[hostOnlyFallback]
|
|
actualName = node.Name
|
|
removedHost = node.Host
|
|
cfg.PVEInstances = append(cfg.PVEInstances[:hostOnlyFallback], cfg.PVEInstances[hostOnlyFallback+1:]...)
|
|
h.normalizePVEConfigState(ctx)
|
|
return true, actualName, removedHost
|
|
}
|
|
case "pbs":
|
|
hostOnlyFallback := -1
|
|
for i, node := range cfg.PBSInstances {
|
|
if autoUnregisterNodeMatch(node.Host, candidateHosts, node.TokenName, req.TokenID) {
|
|
actualName = node.Name
|
|
removedHost = node.Host
|
|
cfg.PBSInstances = append(cfg.PBSInstances[:i], cfg.PBSInstances[i+1:]...)
|
|
return true, actualName, removedHost
|
|
}
|
|
if hostOnlyFallback == -1 && autoRegisterHostMatchesCandidates(node.Host, candidateHosts) && node.Source == "script" {
|
|
hostOnlyFallback = i
|
|
}
|
|
}
|
|
if hostOnlyFallback >= 0 {
|
|
node := cfg.PBSInstances[hostOnlyFallback]
|
|
actualName = node.Name
|
|
removedHost = node.Host
|
|
cfg.PBSInstances = append(cfg.PBSInstances[:hostOnlyFallback], cfg.PBSInstances[hostOnlyFallback+1:]...)
|
|
return true, actualName, removedHost
|
|
}
|
|
}
|
|
|
|
return false, "", ""
|
|
}
|
|
|
|
func (h *ConfigHandlers) notifyAutoUnregisterSuccess(ctx context.Context, req *AutoUnregisterRequest) {
|
|
if h.getMonitor(ctx) != nil && h.getMonitor(ctx).GetDiscoveryService() != nil {
|
|
log.Info().Msg("Triggering discovery refresh after auto-unregister")
|
|
h.getMonitor(ctx).GetDiscoveryService().ForceRefresh()
|
|
}
|
|
|
|
if h.wsHub == nil {
|
|
return
|
|
}
|
|
|
|
h.wsHub.BroadcastMessage(websocket.Message{
|
|
Type: "node_deleted",
|
|
Data: map[string]interface{}{
|
|
"nodeType": strings.TrimSpace(req.Type),
|
|
},
|
|
Timestamp: time.Now().Format(time.RFC3339),
|
|
})
|
|
|
|
if h.getMonitor(ctx) != nil && h.getMonitor(ctx).GetDiscoveryService() != nil {
|
|
if result, _ := h.getMonitor(ctx).GetDiscoveryService().GetCachedResult(); result != nil {
|
|
h.wsHub.BroadcastMessage(websocket.Message{
|
|
Type: "discovery_update",
|
|
Data: map[string]interface{}{
|
|
"servers": result.Servers,
|
|
"errors": result.LegacyErrors(),
|
|
"structured_errors": result.StructuredErrors,
|
|
"timestamp": time.Now().Unix(),
|
|
},
|
|
Timestamp: time.Now().Format(time.RFC3339),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
type autoRegisterCheckResponse struct {
|
|
Registered bool `json:"registered"`
|
|
}
|
|
|
|
func autoRegisterHostMatchesCandidates(existingHost string, candidates []string) bool {
|
|
trimmedExisting := strings.TrimSpace(existingHost)
|
|
if trimmedExisting == "" {
|
|
return false
|
|
}
|
|
for _, candidate := range candidates {
|
|
trimmedCandidate := strings.TrimSpace(candidate)
|
|
if trimmedCandidate == "" {
|
|
continue
|
|
}
|
|
if trimmedExisting == trimmedCandidate || hostsShareResolvedIdentity(trimmedExisting, trimmedCandidate) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func autoRegisterClusterEndpointMatchesCandidates(endpoint config.ClusterEndpoint, candidates []string) bool {
|
|
for _, endpointHost := range []string{
|
|
endpoint.Host,
|
|
endpoint.GuestURL,
|
|
endpoint.IPOverride,
|
|
endpoint.IP,
|
|
} {
|
|
if autoRegisterHostMatchesCandidates(endpointHost, candidates) {
|
|
return true
|
|
}
|
|
if normalized, err := normalizeNodeHost(endpointHost, "pve"); err == nil && autoRegisterHostMatchesCandidates(normalized, candidates) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func autoRegisterPVEInstanceMatchesCandidates(node config.PVEInstance, candidates []string) (matched bool, primary bool) {
|
|
if autoRegisterHostMatchesCandidates(node.Host, candidates) {
|
|
return true, true
|
|
}
|
|
for _, endpoint := range node.ClusterEndpoints {
|
|
if autoRegisterClusterEndpointMatchesCandidates(endpoint, candidates) {
|
|
return true, false
|
|
}
|
|
}
|
|
return false, false
|
|
}
|
|
|
|
func autoRegisteredPVEInstanceExists(instances []config.PVEInstance, connStatuses map[string]bool, candidates []string) bool {
|
|
for _, node := range instances {
|
|
matched, primary := autoRegisterPVEInstanceMatchesCandidates(node, candidates)
|
|
if !matched {
|
|
continue
|
|
}
|
|
// A non-primary cluster endpoint is already covered by the cluster
|
|
// connection. Letting endpoint agents rotate the shared PVE token can
|
|
// invalidate the primary configured connection.
|
|
if primary && isKnownDisconnected(connStatuses, "pve", node.Name, node.Host) {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (h *ConfigHandlers) autoRegisteredNodeExists(ctx context.Context, req *AutoRegisterRequest, candidates []string) bool {
|
|
if req == nil {
|
|
return false
|
|
}
|
|
|
|
// Snapshot connection statuses once; nil if monitor not available yet.
|
|
var connStatuses map[string]bool
|
|
if m := h.getMonitor(ctx); m != nil {
|
|
connStatuses = m.GetConnectionStatuses()
|
|
}
|
|
|
|
switch req.Type {
|
|
case "pve":
|
|
return autoRegisteredPVEInstanceExists(h.getConfig(ctx).PVEInstances, connStatuses, candidates)
|
|
case "pbs":
|
|
for _, node := range h.getConfig(ctx).PBSInstances {
|
|
if autoRegisterHostMatchesCandidates(node.Host, candidates) {
|
|
if isKnownDisconnected(connStatuses, "pbs", node.Name, node.Host) {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// isKnownDisconnected reports whether the monitor has a definitive "unhealthy"
|
|
// entry for the given instance. Returns false (i.e., assume connected) when
|
|
// there is no monitor data yet so we don't mistakenly trigger re-registration
|
|
// on every cold startup before the monitor has had a chance to poll.
|
|
//
|
|
// Key format must match the PollProvider connectionKey functions:
|
|
// - pve: bare instance name (e.g. "delly")
|
|
// - pbs: "pbs-" + instance name
|
|
func isKnownDisconnected(connStatuses map[string]bool, instanceType, name, host string) bool {
|
|
if connStatuses == nil {
|
|
return false
|
|
}
|
|
instanceName := strings.TrimSpace(name)
|
|
if instanceName == "" {
|
|
instanceName = strings.TrimSpace(host)
|
|
}
|
|
if instanceName == "" {
|
|
return false
|
|
}
|
|
var key string
|
|
switch instanceType {
|
|
case "pve":
|
|
key = instanceName // PVE connectionKey returns bare name
|
|
case "pbs":
|
|
key = "pbs-" + instanceName
|
|
default:
|
|
key = instanceType + "-" + instanceName
|
|
}
|
|
connected, known := connStatuses[key]
|
|
return known && !connected
|
|
}
|
|
|
|
func buildCanonicalAutoRegisterResponse(req *AutoRegisterRequest, host string, actualName string, tokenID string, tokenValue string) AutoRegisterResponse {
|
|
nodeName := canonicalAutoRegisterNodeIdentity(req, actualName, host)
|
|
return AutoRegisterResponse{
|
|
Status: "success",
|
|
Message: canonicalAutoRegisterSuccessMessage(nodeName, host),
|
|
Action: "use_token",
|
|
Type: req.Type,
|
|
Source: req.Source,
|
|
Host: host,
|
|
NodeID: nodeName,
|
|
NodeName: nodeName,
|
|
TokenID: tokenID,
|
|
TokenValue: strings.TrimSpace(tokenValue),
|
|
}
|
|
}
|
|
|
|
func buildAutoRegisterEventData(req *AutoRegisterRequest, host string, actualName string, tokenID string) map[string]interface{} {
|
|
eventName := canonicalAutoRegisterNodeIdentity(req, actualName, host)
|
|
|
|
return map[string]interface{}{
|
|
"type": req.Type,
|
|
"source": req.Source,
|
|
"host": host,
|
|
"name": eventName,
|
|
"nodeId": eventName,
|
|
"nodeName": eventName,
|
|
"tokenId": tokenID,
|
|
"hasToken": true,
|
|
"verifySSL": true,
|
|
"status": "connected",
|
|
}
|
|
}
|
|
|
|
func autoRegisterWebSocketEventType(created bool) string {
|
|
if created {
|
|
return "node_auto_registered"
|
|
}
|
|
return "nodes_changed"
|
|
}
|
|
|
|
func (h *ConfigHandlers) notifyAutoRegistrationSuccess(ctx context.Context, req *AutoRegisterRequest, host string, actualName string, tokenID string, created bool) {
|
|
if h.getMonitor(ctx) != nil && h.getMonitor(ctx).GetDiscoveryService() != nil {
|
|
log.Info().Msg("Triggering discovery refresh after auto-registration")
|
|
h.getMonitor(ctx).GetDiscoveryService().ForceRefresh()
|
|
}
|
|
|
|
if h.wsHub == nil {
|
|
log.Warn().Msg("WebSocket hub is nil, cannot broadcast auto-registration")
|
|
return
|
|
}
|
|
|
|
nodeInfo := buildAutoRegisterEventData(req, host, actualName, tokenID)
|
|
h.wsHub.BroadcastMessage(websocket.Message{
|
|
Type: autoRegisterWebSocketEventType(created),
|
|
Data: nodeInfo,
|
|
Timestamp: time.Now().Format(time.RFC3339),
|
|
})
|
|
|
|
if h.getMonitor(ctx) != nil && h.getMonitor(ctx).GetDiscoveryService() != nil {
|
|
result, _ := h.getMonitor(ctx).GetDiscoveryService().GetCachedResult()
|
|
if result != nil {
|
|
h.wsHub.BroadcastMessage(websocket.Message{
|
|
Type: "discovery_update",
|
|
Data: map[string]interface{}{
|
|
"servers": result.Servers,
|
|
"errors": result.LegacyErrors(),
|
|
"structured_errors": result.StructuredErrors,
|
|
"timestamp": time.Now().Unix(),
|
|
},
|
|
Timestamp: time.Now().Format(time.RFC3339),
|
|
})
|
|
log.Info().Msg("Broadcasted discovery update after auto-registration")
|
|
}
|
|
}
|
|
|
|
log.Info().
|
|
Str("host", host).
|
|
Str("name", actualName).
|
|
Str("type", autoRegisterWebSocketEventType(created)).
|
|
Msg("Broadcasted auto-registration success via WebSocket")
|
|
}
|
|
|
|
// HandleAutoUnregister removes a script-managed node connection after the host
|
|
// has removed the Pulse-created credentials locally.
|
|
func (h *ConfigHandlers) handleAutoUnregister(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
var req AutoUnregisterRequest
|
|
body, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("Failed to read auto-unregister request body")
|
|
http.Error(w, "Failed to read request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if err := json.Unmarshal(body, &req); err != nil {
|
|
log.Error().Err(err).Str("body", string(body)).Msg("Failed to parse auto-unregister request")
|
|
http.Error(w, "Invalid request format", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
req.Type = strings.TrimSpace(req.Type)
|
|
req.Host = strings.TrimSpace(req.Host)
|
|
req.TokenID = strings.TrimSpace(req.TokenID)
|
|
req.ServerName = strings.TrimSpace(req.ServerName)
|
|
req.AuthToken = strings.TrimSpace(req.AuthToken)
|
|
req.Source = strings.TrimSpace(req.Source)
|
|
|
|
if req.Source == "" {
|
|
http.Error(w, "source is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if req.Source != "script" {
|
|
http.Error(w, "source must be 'script'", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if !isCanonicalAutoRegisterType(req.Type) {
|
|
http.Error(w, "type must be 'pve' or 'pbs'", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if req.Type == "" || req.Host == "" || req.ServerName == "" {
|
|
http.Error(w, canonicalAutoUnregisterMissingFieldsMessage(req.Type, req.Host, req.ServerName), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if req.TokenID != "" && !isCanonicalAutoRegisterTokenID(req.Type, req.TokenID) {
|
|
http.Error(w, "tokenId must be a canonical Pulse-managed token id", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
normalizedCandidates, err := normalizeAutoRegisterHostCandidates(req.Type, req.Host, nil)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
req.Host = normalizedCandidates[0]
|
|
|
|
updatedRequest, authenticated := h.authenticateSetupScriptTeardownRequest(r, req.Type, req.AuthToken)
|
|
if !authenticated {
|
|
if req.AuthToken == "" {
|
|
http.Error(w, "Pulse setup token required", http.StatusUnauthorized)
|
|
} else {
|
|
http.Error(w, "Invalid or expired setup token", http.StatusUnauthorized)
|
|
}
|
|
return
|
|
}
|
|
r = updatedRequest
|
|
|
|
removed, actualName, removedHost := h.removeAutoRegisteredNode(r.Context(), &req, normalizedCandidates)
|
|
if removed {
|
|
if err := h.getPersistence(r.Context()).SaveNodesConfigAllowEmpty(
|
|
h.getConfig(r.Context()).PVEInstances,
|
|
h.getConfig(r.Context()).PBSInstances,
|
|
h.getConfig(r.Context()).PMGInstances,
|
|
); err != nil {
|
|
log.Error().Err(err).Msg("Failed to save auto-unregistered node configuration")
|
|
http.Error(w, "Failed to save configuration", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if h.reloadFunc != nil {
|
|
if err := h.reloadFunc(); err != nil {
|
|
log.Error().Err(err).Msg("Failed to reload monitor after auto-unregister")
|
|
http.Error(w, "Configuration saved but failed to apply changes", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
h.notifyAutoUnregisterSuccess(r.Context(), &req)
|
|
} else {
|
|
removedHost = req.Host
|
|
actualName = req.ServerName
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if err := json.NewEncoder(w).Encode(buildCanonicalAutoUnregisterResponse(&req, removedHost, actualName, removed)); err != nil {
|
|
log.Error().Err(err).Msg("Failed to encode auto-unregister response")
|
|
}
|
|
}
|
|
|
|
// nodesConfigFingerprint computes a deterministic SHA-256 fingerprint of the
|
|
// PVE / PBS / PMG instance lists that would be persisted by SaveNodesConfig.
|
|
// Used by handleCanonicalAutoRegister to detect no-op re-registrations: when
|
|
// an already-known agent re-announces itself the auto-register handler still
|
|
// runs the full add-then-consolidate path, which produces the exact same
|
|
// persisted state. Writing+reloading on those no-ops triggers a full monitor
|
|
// rebuild that briefly empties the resource store, producing a visible
|
|
// flicker for every connected WebSocket client. Comparing fingerprints lets
|
|
// us short-circuit and skip both the save and the reload.
|
|
func nodesConfigFingerprint(pve []config.PVEInstance, pbs []config.PBSInstance, pmg []config.PMGInstance) string {
|
|
payload := struct {
|
|
PVE []config.PVEInstance `json:"pve"`
|
|
PBS []config.PBSInstance `json:"pbs"`
|
|
PMG []config.PMGInstance `json:"pmg"`
|
|
}{pve, pbs, pmg}
|
|
data, err := json.Marshal(payload)
|
|
if err != nil {
|
|
// Fall back to a non-comparable sentinel so callers treat this as
|
|
// "changed" and persist+reload defensively.
|
|
return fmt.Sprintf("fingerprint-error:%d:%d:%d:%v", len(pve), len(pbs), len(pmg), err)
|
|
}
|
|
sum := sha256.Sum256(data)
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// Parse request body first so the setup token can be validated early.
|
|
var req AutoRegisterRequest
|
|
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
|
|
}
|
|
|
|
if err := json.Unmarshal(body, &req); err != nil {
|
|
log.Error().Err(err).Str("body", string(body)).Msg("Failed to parse auto-register request")
|
|
http.Error(w, "Invalid request format", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Check authentication through the one-time setup token carried in authToken.
|
|
authenticated := false
|
|
setupToken := strings.TrimSpace(req.AuthToken)
|
|
|
|
log.Debug().
|
|
Bool("hasAuthToken", setupToken != "").
|
|
Bool("hasConfigToken", h.getConfig(r.Context()).HasAPITokens()).
|
|
Msg("Checking authentication for auto-register")
|
|
|
|
// First check for the one-time setup token in the request.
|
|
if setupToken != "" {
|
|
tokenHash := internalauth.HashAPIToken(setupToken)
|
|
log.Debug().
|
|
Bool("hasSetupToken", true).
|
|
Str("tokenHash", safePrefixForLog(tokenHash, 8)+"...").
|
|
Msg("Checking auth token as one-time setup token")
|
|
h.codeMutex.Lock()
|
|
setupTokenRecord, exists := h.setupTokens[tokenHash]
|
|
log.Debug().
|
|
Bool("exists", exists).
|
|
Int("totalTokens", len(h.setupTokens)).
|
|
Msg("Setup token lookup result")
|
|
if exists && !setupTokenRecord.Used && time.Now().Before(setupTokenRecord.ExpiresAt) {
|
|
// Validate that the token matches the node type.
|
|
// Note: We don't validate the host anymore as it may differ between
|
|
// what's entered in the UI and what's provided in the setup script URL
|
|
if setupTokenRecord.NodeType == req.Type {
|
|
setupTokenRecord.Used = true // Mark as used immediately.
|
|
|
|
// Inject OrgID from the setup token into context for subsequent processing.
|
|
if setupTokenRecord.OrgID != "" {
|
|
ctx := context.WithValue(r.Context(), OrgIDContextKey, setupTokenRecord.OrgID)
|
|
r = r.WithContext(ctx)
|
|
}
|
|
// Allow a short grace period for follow-up actions without keeping tokens alive too long.
|
|
graceExpiry := recentSetupTokenGraceExpiry(setupTokenRecord, time.Now())
|
|
h.recentSetupTokens[tokenHash] = buildRecentSetupTokenRecord(setupTokenRecord, graceExpiry)
|
|
authenticated = true
|
|
log.Info().
|
|
Str("type", req.Type).
|
|
Str("host", req.Host).
|
|
Bool("via_authToken", req.AuthToken != "").
|
|
Msg("Auto-register authenticated via setup token")
|
|
} else {
|
|
log.Warn().
|
|
Str("expected_type", setupTokenRecord.NodeType).
|
|
Str("got_type", req.Type).
|
|
Msg("Setup token validation failed - type mismatch")
|
|
}
|
|
} else if exists && setupTokenRecord.Used {
|
|
log.Warn().Msg("Setup token already used")
|
|
} else if exists {
|
|
log.Warn().Msg("Setup token expired")
|
|
} else {
|
|
log.Warn().Msg("Invalid setup token - not in setup token registry")
|
|
}
|
|
h.codeMutex.Unlock()
|
|
}
|
|
|
|
// Fallback: allow agents with a valid agent:report API token to re-register their
|
|
// Proxmox node. This is needed when an agent reinstalls — it rotates the PVE token
|
|
// but can't fetch a one-time setup token (that endpoint requires settings:write).
|
|
// For security this path is restricted to updating existing nodes only.
|
|
if !authenticated {
|
|
if apiToken, hasToken := explicitAPITokenFromRequest(r); hasToken {
|
|
config.Mu.RLock()
|
|
cfg := h.getConfig(r.Context())
|
|
isValid := cfg != nil && cfg.IsValidAPIToken(apiToken)
|
|
config.Mu.RUnlock()
|
|
if isValid {
|
|
config.Mu.Lock()
|
|
record, ok := cfg.ValidateAPIToken(apiToken)
|
|
config.Mu.Unlock()
|
|
if ok && record != nil && record.HasScope(config.ScopeAgentReport) {
|
|
// Reject cross-org tokens: if the request context has an explicit
|
|
// non-default org, the token must belong to that same org.
|
|
requestOrgID := GetOrgID(r.Context())
|
|
orgMismatch := requestOrgID != "" && requestOrgID != "default" &&
|
|
record.OrgID != "" && record.OrgID != requestOrgID
|
|
if !orgMismatch {
|
|
authenticated = true
|
|
r = r.WithContext(context.WithValue(r.Context(), agentAutoRegContextKey{}, true))
|
|
log.Info().
|
|
Str("type", req.Type).
|
|
Str("host", req.Host).
|
|
Msg("Auto-register authenticated via agent API token (update-only mode)")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Abort when no authentication succeeded. This applies even when API tokens
|
|
// are not configured to ensure one-time setup tokens are always required.
|
|
if !authenticated {
|
|
log.Warn().
|
|
Str("ip", r.RemoteAddr).
|
|
Bool("has_setup_token", setupToken != "").
|
|
Msg("Unauthorized auto-register attempt rejected")
|
|
|
|
if setupToken == "" {
|
|
http.Error(w, "Pulse setup token required", http.StatusUnauthorized)
|
|
} else {
|
|
http.Error(w, "Invalid or expired setup token", http.StatusUnauthorized)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Log source IP for security auditing
|
|
clientIP := r.RemoteAddr
|
|
// Only trust X-Forwarded-For if request comes from a trusted proxy
|
|
peerIP := extractRemoteIP(clientIP)
|
|
if isTrustedProxyIP(peerIP) {
|
|
if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
|
|
clientIP = forwarded
|
|
}
|
|
}
|
|
log.Info().Str("clientIP", clientIP).Msg("Auto-register request from")
|
|
|
|
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")
|
|
|
|
h.handleCanonicalAutoRegister(w, r, &req, clientIP)
|
|
}
|
|
|
|
// handleCanonicalAutoRegister handles the canonical /api/auto-register flow.
|
|
func (h *ConfigHandlers) handleCanonicalAutoRegister(w http.ResponseWriter, r *http.Request, req *AutoRegisterRequest, clientIP string) {
|
|
log.Info().
|
|
Str("type", req.Type).
|
|
Str("host", req.Host).
|
|
Msg("Processing canonical auto-register request")
|
|
|
|
typeValue := strings.TrimSpace(req.Type)
|
|
hostValue := strings.TrimSpace(req.Host)
|
|
tokenID := strings.TrimSpace(req.TokenID)
|
|
tokenValue := strings.TrimSpace(req.TokenValue)
|
|
serverName := strings.TrimSpace(req.ServerName)
|
|
registrationSource := strings.TrimSpace(req.Source)
|
|
hasTokenID := tokenID != ""
|
|
hasTokenValue := tokenValue != ""
|
|
|
|
if registrationSource == "" {
|
|
http.Error(w, "source is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if !isCanonicalAutoRegisterSource(registrationSource) {
|
|
http.Error(w, "source must be 'agent' or 'script'", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if !isCanonicalAutoRegisterType(typeValue) {
|
|
http.Error(w, "type must be 'pve' or 'pbs'", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
req.Type = typeValue
|
|
req.Host = hostValue
|
|
req.TokenID = tokenID
|
|
req.TokenValue = tokenValue
|
|
req.ServerName = serverName
|
|
req.Source = registrationSource
|
|
|
|
if req.CheckRegistration {
|
|
if typeValue == "" || hostValue == "" || serverName == "" {
|
|
missingMessage := canonicalAutoRegisterCheckMissingFieldsMessage(typeValue, hostValue, serverName)
|
|
log.Error().
|
|
Str("type", req.Type).
|
|
Str("host", req.Host).
|
|
Str("serverName", req.ServerName).
|
|
Msg(missingMessage)
|
|
http.Error(w, missingMessage, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
normalizedCandidates, err := normalizeAutoRegisterHostCandidates(req.Type, req.Host, req.CandidateHosts)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if err := json.NewEncoder(w).Encode(autoRegisterCheckResponse{
|
|
Registered: h.autoRegisteredNodeExists(r.Context(), req, normalizedCandidates),
|
|
}); err != nil {
|
|
log.Error().Err(err).Msg("Failed to encode auto-register registration check response")
|
|
}
|
|
return
|
|
}
|
|
|
|
if hasTokenID != hasTokenValue {
|
|
log.Error().
|
|
Bool("hasTokenID", hasTokenID).
|
|
Bool("hasTokenValue", hasTokenValue).
|
|
Msg(canonicalAutoRegisterCompletionPayloadMessage())
|
|
http.Error(w, "tokenId and tokenValue must be provided together", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if typeValue == "" || hostValue == "" || !hasTokenID || serverName == "" {
|
|
missingMessage := canonicalAutoRegisterMissingFieldsMessage(typeValue, hostValue, hasTokenID, serverName)
|
|
log.Error().
|
|
Str("type", req.Type).
|
|
Str("host", req.Host).
|
|
Str("serverName", req.ServerName).
|
|
Bool("hasTokenID", hasTokenID).
|
|
Bool("hasTokenValue", hasTokenValue).
|
|
Msg(missingMessage)
|
|
http.Error(w, missingMessage, http.StatusBadRequest)
|
|
return
|
|
}
|
|
if !isCanonicalAutoRegisterTokenID(typeValue, tokenID) {
|
|
http.Error(w, "tokenId must be a canonical Pulse-managed token id", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
host, fingerprint, verifySSL, candidateHosts, err := selectAutoRegisterHost(req.Type, req.Host, req.CandidateHosts)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
log.Info().
|
|
Str("requestedHost", req.Host).
|
|
Str("selectedHost", host).
|
|
Strs("candidateHosts", candidateHosts).
|
|
Bool("verifySSL", verifySSL).
|
|
Msg("Resolved canonical auto-register host candidates")
|
|
|
|
fullTokenID := req.TokenID
|
|
tokenValue = req.TokenValue
|
|
log.Info().
|
|
Str("host", host).
|
|
Str("tokenID", fullTokenID).
|
|
Str("source", registrationSource).
|
|
Msg("Using caller-supplied token for canonical /api/auto-register completion")
|
|
|
|
// Snapshot the pre-mutation persisted-state fingerprint so we can detect
|
|
// no-op auto-registrations and skip the SaveNodesConfig + monitor reload
|
|
// that would otherwise wipe the live resource store for every connected
|
|
// client. See nodesConfigFingerprint for the full rationale.
|
|
preCfg := h.getConfig(r.Context())
|
|
beforeFingerprint := nodesConfigFingerprint(preCfg.PVEInstances, preCfg.PBSInstances, preCfg.PMGInstances)
|
|
|
|
// Add the node to configuration
|
|
created := false
|
|
if req.Type == "pve" {
|
|
pveDisplayName := h.disambiguateNodeName(r.Context(), serverName, host, "pve")
|
|
pveNode := config.PVEInstance{
|
|
Name: pveDisplayName,
|
|
Host: host,
|
|
TokenName: fullTokenID,
|
|
TokenValue: tokenValue,
|
|
Fingerprint: fingerprint,
|
|
VerifySSL: verifySSL,
|
|
MonitorVMs: true,
|
|
MonitorContainers: true,
|
|
MonitorStorage: true,
|
|
MonitorBackups: true,
|
|
Source: registrationSource,
|
|
}
|
|
// Deduplicate by host to keep canonical auto-registration idempotent on reruns.
|
|
existingIndex := -1
|
|
preserveHost := false
|
|
for i, node := range h.getConfig(r.Context()).PVEInstances {
|
|
if node.Host == host {
|
|
existingIndex = i
|
|
break
|
|
}
|
|
if hostsShareResolvedIdentity(node.Host, host) {
|
|
existingIndex = i
|
|
preserveHost = shouldPreserveExistingAutoRegisterHost(node.Host, candidateHosts)
|
|
log.Info().
|
|
Str("existingHost", node.Host).
|
|
Str("newHost", host).
|
|
Bool("preserveHost", preserveHost).
|
|
Str("type", "pve").
|
|
Msg(canonicalAutoRegisterMatchMessage("resolved host identity"))
|
|
break
|
|
}
|
|
if serverName != "" &&
|
|
strings.EqualFold(strings.TrimSpace(node.Name), serverName) &&
|
|
node.TokenName == pveNode.TokenName {
|
|
existingIndex = i
|
|
log.Info().
|
|
Str("existingHost", node.Host).
|
|
Str("newHost", host).
|
|
Str("type", "pve").
|
|
Str("node", serverName).
|
|
Msg(canonicalAutoRegisterMatchMessage("DHCP continuity token identity"))
|
|
break
|
|
}
|
|
}
|
|
if existingIndex >= 0 {
|
|
instance := &h.getConfig(r.Context()).PVEInstances[existingIndex]
|
|
if !preserveHost {
|
|
instance.Host = host
|
|
}
|
|
instance.User = ""
|
|
instance.Password = ""
|
|
instance.TokenName = pveNode.TokenName
|
|
instance.TokenValue = pveNode.TokenValue
|
|
if pveNode.Source != "" {
|
|
instance.Source = pveNode.Source
|
|
}
|
|
// Update TLS fingerprint only when one was captured; a failed
|
|
// FetchFingerprint must not erase a previously valid pin.
|
|
if pveNode.Fingerprint != "" {
|
|
instance.Fingerprint = pveNode.Fingerprint
|
|
}
|
|
instance.VerifySSL = pveNode.VerifySSL
|
|
log.Info().Str("host", host).Str("type", "pve").Msg(canonicalAutoRegisterMatchMessage("host; updated token in-place"))
|
|
} else if h.adoptCanonicalAutoRegisterClusterMember(r.Context(), serverName, host, fingerprint, fullTokenID, tokenValue, candidateHosts, registrationSource) {
|
|
// A non-primary cluster member: the cluster connection already
|
|
// covers this node, so no instance is created and the shared
|
|
// cluster token is left untouched.
|
|
log.Info().Str("host", host).Str("type", "pve").Msg(canonicalAutoRegisterMatchMessage("cluster member endpoint"))
|
|
} else {
|
|
// Agent-token auth is restricted to updating existing nodes only.
|
|
if isAgentAutoRegAuth(r.Context()) {
|
|
log.Warn().Str("host", host).Str("type", "pve").Msg("Agent API token auth rejected for new PVE node registration")
|
|
http.Error(w, "agent token auth permits token updates for existing nodes only", http.StatusForbidden)
|
|
return
|
|
}
|
|
h.getConfig(r.Context()).PVEInstances = append(h.getConfig(r.Context()).PVEInstances, pveNode)
|
|
h.normalizePVEConfigState(r.Context())
|
|
created = true
|
|
}
|
|
} else if req.Type == "pbs" {
|
|
pbsDisplayName := h.disambiguateNodeName(r.Context(), serverName, host, "pbs")
|
|
pbsNode := config.PBSInstance{
|
|
Name: pbsDisplayName,
|
|
Host: host,
|
|
TokenName: fullTokenID,
|
|
TokenValue: tokenValue,
|
|
Fingerprint: fingerprint,
|
|
VerifySSL: verifySSL,
|
|
MonitorBackups: true,
|
|
MonitorDatastores: true,
|
|
MonitorSyncJobs: true,
|
|
MonitorVerifyJobs: true,
|
|
MonitorPruneJobs: true,
|
|
Source: registrationSource,
|
|
}
|
|
// Deduplicate by host to keep canonical auto-registration idempotent on reruns.
|
|
existingIndex := -1
|
|
preserveHost := false
|
|
for i, node := range h.getConfig(r.Context()).PBSInstances {
|
|
if node.Host == host {
|
|
existingIndex = i
|
|
break
|
|
}
|
|
if hostsShareResolvedIdentity(node.Host, host) {
|
|
existingIndex = i
|
|
preserveHost = shouldPreserveExistingAutoRegisterHost(node.Host, candidateHosts)
|
|
log.Info().
|
|
Str("existingHost", node.Host).
|
|
Str("newHost", host).
|
|
Bool("preserveHost", preserveHost).
|
|
Str("type", "pbs").
|
|
Msg(canonicalAutoRegisterMatchMessage("resolved host identity"))
|
|
break
|
|
}
|
|
if serverName != "" &&
|
|
strings.EqualFold(strings.TrimSpace(node.Name), serverName) &&
|
|
node.TokenName == pbsNode.TokenName {
|
|
existingIndex = i
|
|
log.Info().
|
|
Str("existingHost", node.Host).
|
|
Str("newHost", host).
|
|
Str("type", "pbs").
|
|
Str("node", serverName).
|
|
Msg(canonicalAutoRegisterMatchMessage("DHCP continuity token identity"))
|
|
break
|
|
}
|
|
}
|
|
if existingIndex >= 0 {
|
|
instance := &h.getConfig(r.Context()).PBSInstances[existingIndex]
|
|
if !preserveHost {
|
|
instance.Host = host
|
|
}
|
|
instance.User = ""
|
|
instance.Password = ""
|
|
instance.TokenName = pbsNode.TokenName
|
|
instance.TokenValue = pbsNode.TokenValue
|
|
if pbsNode.Source != "" {
|
|
instance.Source = pbsNode.Source
|
|
}
|
|
// Update TLS fingerprint only when one was captured; a failed
|
|
// FetchFingerprint must not erase a previously valid pin.
|
|
if pbsNode.Fingerprint != "" {
|
|
instance.Fingerprint = pbsNode.Fingerprint
|
|
}
|
|
instance.VerifySSL = pbsNode.VerifySSL
|
|
log.Info().Str("host", host).Str("type", "pbs").Msg(canonicalAutoRegisterMatchMessage("host; updated token in-place"))
|
|
} else {
|
|
// Agent-token auth is restricted to updating existing nodes only.
|
|
if isAgentAutoRegAuth(r.Context()) {
|
|
log.Warn().Str("host", host).Str("type", "pbs").Msg("Agent API token auth rejected for new PBS node registration")
|
|
http.Error(w, "agent token auth permits token updates for existing nodes only", http.StatusForbidden)
|
|
return
|
|
}
|
|
h.getConfig(r.Context()).PBSInstances = append(h.getConfig(r.Context()).PBSInstances, pbsNode)
|
|
created = true
|
|
}
|
|
}
|
|
|
|
// Save configuration
|
|
h.normalizePVEConfigState(r.Context())
|
|
postCfg := h.getConfig(r.Context())
|
|
afterFingerprint := nodesConfigFingerprint(postCfg.PVEInstances, postCfg.PBSInstances, postCfg.PMGInstances)
|
|
configChanged := beforeFingerprint != afterFingerprint
|
|
|
|
if configChanged {
|
|
if err := h.getPersistence(r.Context()).SaveNodesConfig(postCfg.PVEInstances, postCfg.PBSInstances, postCfg.PMGInstances); err != nil {
|
|
log.Error().Err(err).Msg("Failed to save auto-registered node")
|
|
http.Error(w, "Failed to save configuration", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
} else {
|
|
log.Debug().
|
|
Str("host", host).
|
|
Str("type", req.Type).
|
|
Msg("Auto-register resulted in no persisted-config change; skipping SaveNodesConfig and monitor reload")
|
|
}
|
|
|
|
actualName := h.findInstanceNameByHost(r.Context(), req.Type, host)
|
|
if actualName == "" {
|
|
actualName = serverName
|
|
}
|
|
h.markAutoRegistered(req.Type, actualName)
|
|
|
|
// Reload monitor only when the persisted state actually changed. A full
|
|
// reload stops the multi-tenant monitor and rebuilds it from scratch,
|
|
// which momentarily empties the WebSocket-visible resource store; for
|
|
// no-op re-registrations there is nothing for the monitor to pick up.
|
|
if configChanged && h.reloadFunc != nil {
|
|
go func() {
|
|
if err := h.reloadFunc(); err != nil {
|
|
log.Error().Err(err).Msg("Failed to reload monitor after auto-registration")
|
|
}
|
|
}()
|
|
}
|
|
|
|
h.notifyAutoRegistrationSuccess(r.Context(), req, host, actualName, fullTokenID, created)
|
|
|
|
response := buildCanonicalAutoRegisterResponse(req, host, actualName, fullTokenID, tokenValue)
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(response)
|
|
|
|
}
|
|
|
|
// SSHKeyPair holds the sensors SSH public key for temperature monitoring.
|
|
type SSHKeyPair struct {
|
|
SensorsPublicKey string
|
|
}
|
|
|
|
// getOrGenerateSSHKeys returns the SSH public key for temperature monitoring
|
|
// If keys don't exist, they are generated automatically
|
|
// SECURITY: Blocks key generation when running in containers unless dev mode override is enabled
|
|
func (h *ConfigHandlers) getOrGenerateSSHKeys() SSHKeyPair {
|
|
// CRITICAL SECURITY CHECK: Never generate SSH keys in containers (unless dev mode)
|
|
// Container compromise = SSH key compromise = root access to Proxmox
|
|
devModeAllowSSH := os.Getenv("PULSE_DEV_ALLOW_CONTAINER_SSH") == "true"
|
|
isContainer := os.Getenv("PULSE_DOCKER") == "true" || system.InContainer()
|
|
|
|
if isContainer && !devModeAllowSSH {
|
|
log.Error().Msg("SECURITY BLOCK: SSH key generation disabled in containerized deployments")
|
|
log.Error().Msg("Temperature monitoring via SSH is disabled in containerized deployments")
|
|
log.Error().Msg("See: " + shippedSecurityContainerNoticeDocAnchor)
|
|
log.Error().Msg("To test SSH keys in dev/lab only: PULSE_DEV_ALLOW_CONTAINER_SSH=true (NEVER in production!)")
|
|
return SSHKeyPair{}
|
|
}
|
|
|
|
if devModeAllowSSH && isContainer {
|
|
log.Warn().Msg("⚠️ DEV MODE: SSH key generation ENABLED in container - FOR TESTING ONLY")
|
|
log.Warn().Msg("⚠️ This grants root SSH access from container - NEVER use in production!")
|
|
}
|
|
|
|
homeDir, err := os.UserHomeDir()
|
|
if err != nil {
|
|
log.Warn().Err(err).Msg("Could not determine home directory for SSH keys")
|
|
return SSHKeyPair{}
|
|
}
|
|
|
|
sshDir := filepath.Join(homeDir, ".ssh")
|
|
|
|
// Generate/load sensors key (for temperature collection)
|
|
sensorsPrivPath := filepath.Join(sshDir, "id_ed25519_sensors")
|
|
sensorsPubPath := filepath.Join(sshDir, "id_ed25519_sensors.pub")
|
|
sensorsKey := h.generateOrLoadSSHKey(sshDir, sensorsPrivPath, sensorsPubPath, "sensors")
|
|
|
|
return SSHKeyPair{
|
|
SensorsPublicKey: sensorsKey,
|
|
}
|
|
}
|
|
|
|
// generateOrLoadSSHKey generates or loads a single SSH keypair
|
|
func (h *ConfigHandlers) generateOrLoadSSHKey(sshDir, privateKeyPath, publicKeyPath, keyType string) string {
|
|
// Check if public key already exists
|
|
if pubKeyBytes, err := os.ReadFile(publicKeyPath); err == nil {
|
|
publicKey := strings.TrimSpace(string(pubKeyBytes))
|
|
log.Info().Str("keyPath", publicKeyPath).Str("type", keyType).Msg("Using existing SSH public key")
|
|
return publicKey
|
|
}
|
|
|
|
// Key doesn't exist - generate one
|
|
log.Info().Str("sshDir", sshDir).Str("type", keyType).Msg("Generating new SSH keypair for temperature monitoring")
|
|
|
|
// Create .ssh directory if it doesn't exist
|
|
if err := os.MkdirAll(sshDir, 0700); err != nil {
|
|
log.Error().Err(err).Str("sshDir", sshDir).Msg("Failed to create .ssh directory")
|
|
return ""
|
|
}
|
|
|
|
// Generate Ed25519 key pair (more secure and faster than RSA)
|
|
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("Failed to generate Ed25519 key")
|
|
return ""
|
|
}
|
|
|
|
// Save private key in OpenSSH format
|
|
privateKeyFile, err := os.OpenFile(privateKeyPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
|
|
if err != nil {
|
|
log.Error().Err(err).Str("path", privateKeyPath).Msg("Failed to create private key file")
|
|
return ""
|
|
}
|
|
defer privateKeyFile.Close()
|
|
|
|
// Marshal Ed25519 private key to OpenSSH format
|
|
privKeyBytes, err := ssh.MarshalPrivateKey(privateKey, "")
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("Failed to marshal private key")
|
|
return ""
|
|
}
|
|
if err := pem.Encode(privateKeyFile, privKeyBytes); err != nil {
|
|
log.Error().Err(err).Msg("Failed to write private key")
|
|
return ""
|
|
}
|
|
|
|
// Generate public key in OpenSSH format
|
|
sshPublicKey, err := ssh.NewPublicKey(publicKey)
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("Failed to generate public key")
|
|
return ""
|
|
}
|
|
|
|
publicKeyBytes := ssh.MarshalAuthorizedKey(sshPublicKey)
|
|
publicKeyString := strings.TrimSpace(string(publicKeyBytes))
|
|
|
|
// Save public key
|
|
if err := os.WriteFile(publicKeyPath, publicKeyBytes, 0644); err != nil {
|
|
log.Error().Err(err).Str("path", publicKeyPath).Msg("Failed to write public key")
|
|
return ""
|
|
}
|
|
|
|
log.Info().
|
|
Str("privateKey", privateKeyPath).
|
|
Str("publicKey", publicKeyPath).
|
|
Msg("Successfully generated SSH keypair")
|
|
|
|
return publicKeyString
|
|
}
|
|
|
|
// AgentInstallCommandRequest represents a request for an agent install command
|
|
type AgentInstallCommandRequest struct {
|
|
Type string `json:"type"` // "pve", "pbs", or "host"
|
|
EnableCommands bool `json:"enableCommands,omitempty"`
|
|
Name string `json:"name,omitempty"` // optional token name (host installs)
|
|
}
|
|
|
|
// AgentInstallCommandResponse contains the generated install command
|
|
type AgentInstallCommandResponse struct {
|
|
Command string `json:"command"`
|
|
Token string `json:"token"`
|
|
Record *apiTokenDTO `json:"record,omitempty"`
|
|
}
|
|
|
|
// HandleAgentInstallCommand generates an API token and install command for agent-based Proxmox setup
|
|
func (h *ConfigHandlers) handleAgentInstallCommand(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
var req AgentInstallCommandRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if strings.EqualFold(strings.TrimSpace(req.Type), agentInstallTypeHost) {
|
|
h.handleHostAgentInstallToken(w, r, req)
|
|
return
|
|
}
|
|
|
|
installType, err := normalizeProxmoxInstallType(req.Type)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
cfg := h.getConfig(r.Context())
|
|
persistence := h.getPersistence(r.Context())
|
|
|
|
rawToken := ""
|
|
if authConfiguredForAgentLifecycle(cfg) {
|
|
tokenName := fmt.Sprintf("proxmox-agent-%s-%d", installType, time.Now().Unix())
|
|
rawToken, _, err = issueAndPersistAgentInstallToken(cfg, persistence, issueAgentInstallTokenOptions{
|
|
TokenName: tokenName,
|
|
OwnerUserID: apiTokenOwnerUserIDForRequest(cfg, r),
|
|
Metadata: map[string]string{
|
|
"install_type": installType,
|
|
"issued_via": "config_agent_install_command",
|
|
},
|
|
})
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, errAgentInstallTokenGeneration):
|
|
log.Error().Err(err).Msg("Failed to generate API token for agent install")
|
|
http.Error(w, "Failed to generate API token", http.StatusInternalServerError)
|
|
case errors.Is(err, errAgentInstallTokenRecord):
|
|
log.Error().Err(err).Str("token_name", tokenName).Msg("Failed to construct API token record")
|
|
http.Error(w, "Failed to generate token", http.StatusInternalServerError)
|
|
case errors.Is(err, errAgentInstallTokenPersist):
|
|
log.Error().Err(err).Msg("Failed to persist API tokens after creation")
|
|
http.Error(w, "Failed to save token", http.StatusInternalServerError)
|
|
default:
|
|
log.Error().Err(err).Msg("Failed to create API token for agent install")
|
|
http.Error(w, "Failed to generate API token", http.StatusInternalServerError)
|
|
}
|
|
return
|
|
}
|
|
}
|
|
|
|
baseURL := resolveConfigAgentInstallBaseURL(r, cfg)
|
|
command := buildProxmoxAgentInstallCommand(agentInstallCommandOptions{
|
|
BaseURL: baseURL,
|
|
Token: rawToken,
|
|
InstallType: installType,
|
|
IncludeInstallType: true,
|
|
EnableCommands: req.EnableCommands,
|
|
})
|
|
|
|
log.Info().
|
|
Str("type", installType).
|
|
Bool("token_issued", rawToken != "").
|
|
Bool("enable_commands", req.EnableCommands).
|
|
Msg("Generated agent install command")
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(AgentInstallCommandResponse{
|
|
Command: command,
|
|
Token: rawToken,
|
|
})
|
|
}
|
|
|
|
// handleHostAgentInstallToken mints an install token for the generic unified
|
|
// host agent flow (Settings > Infrastructure > Add Pulse Agent). The exec
|
|
// scope must be decided here at mint time, because scopes cannot be upgraded
|
|
// on an existing token; the frontend regenerates the token when the operator
|
|
// toggles command execution. The install_type metadata makes the token
|
|
// eligible for first-use binding in canBindAgentInstallExecToken, without
|
|
// which the command channel rejects it even with the scope present (#1586).
|
|
func (h *ConfigHandlers) handleHostAgentInstallToken(w http.ResponseWriter, r *http.Request, req AgentInstallCommandRequest) {
|
|
cfg := h.getConfig(r.Context())
|
|
persistence := h.getPersistence(r.Context())
|
|
|
|
if !authConfiguredForAgentLifecycle(cfg) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(AgentInstallCommandResponse{})
|
|
return
|
|
}
|
|
|
|
tokenName := strings.TrimSpace(req.Name)
|
|
if tokenName == "" {
|
|
tokenName = fmt.Sprintf("host-agent-%d", time.Now().Unix())
|
|
}
|
|
rawToken, record, err := issueAndPersistAgentInstallToken(cfg, persistence, issueAgentInstallTokenOptions{
|
|
TokenName: tokenName,
|
|
OwnerUserID: apiTokenOwnerUserIDForRequest(cfg, r),
|
|
Scopes: hostAgentInstallScopes(req.EnableCommands),
|
|
Metadata: map[string]string{
|
|
"install_type": agentInstallTypeHost,
|
|
"issued_via": agentInstallIssuedViaConfig,
|
|
},
|
|
})
|
|
if err != nil {
|
|
log.Error().Err(err).Str("token_name", tokenName).Msg("Failed to create host agent install token")
|
|
http.Error(w, "Failed to generate API token", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
log.Info().
|
|
Str("type", agentInstallTypeHost).
|
|
Bool("enable_commands", req.EnableCommands).
|
|
Msg("Generated host agent install token")
|
|
|
|
dto := toAPITokenDTO(*record)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(AgentInstallCommandResponse{
|
|
Token: rawToken,
|
|
Record: &dto,
|
|
})
|
|
}
|