mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-27 05:57:27 +00:00
919009d767
ConsolidatePVEInstances promoted VerifySSL false->true whenever a merged duplicate or standalone had it enabled, silently re-enabling a disabled Verify SSL certificate setting on every save, load and monitor reconciliation (#2140). The add and update handlers now record an explicit operator choice in VerifySSLExplicit; a merge never overrides it, while the historical promotion is kept when neither side recorded a choice so an existing secure connection is not downgraded. A merged fingerprint still pins the peer. Regression tests cover the standalone and duplicate-cluster merges and the reported PUT-save path through normalizePVEConfigState. Change-source: pulse-maintainer
2690 lines
98 KiB
Go
2690 lines
98 KiB
Go
package configapi
|
|
|
|
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/api/apicontext"
|
|
"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.
|
|
// Ordinary runtime tokens remain update-only. A Proxmox install token minted by
|
|
// a settings:write endpoint may additionally carry a bounded grant to create
|
|
// the declared PVE/PBS source: one create per canonical type, each one-shot,
|
|
// all of them sharing the token's single mint-age TTL and hostname binding.
|
|
type agentAutoRegContextKey struct{}
|
|
|
|
type agentAutoRegAuth struct {
|
|
tokenID string
|
|
installBootstrap bool
|
|
}
|
|
|
|
func getAgentAutoRegAuth(ctx context.Context) (agentAutoRegAuth, bool) {
|
|
v, ok := ctx.Value(agentAutoRegContextKey{}).(agentAutoRegAuth)
|
|
return v, ok
|
|
}
|
|
|
|
func isAgentAutoRegAuth(ctx context.Context) bool {
|
|
_, ok := getAgentAutoRegAuth(ctx)
|
|
return ok
|
|
}
|
|
|
|
const (
|
|
proxmoxInstallRegistrationCompletedKey = "proxmox_registration_completed"
|
|
proxmoxInstallRegistrationTypeKey = "proxmox_registration_type"
|
|
proxmoxInstallRegistrationHostnameKey = "proxmox_registration_hostname"
|
|
proxmoxInstallRegistrationHostKey = "proxmox_registration_host"
|
|
proxmoxInstallRegistrationNodeKey = "proxmox_registration_node"
|
|
proxmoxInstallRegistrationAtKey = "proxmox_registration_at"
|
|
// proxmoxInstallRegistrationConsumedTypesKey lists the canonical Proxmox
|
|
// types whose one-shot source-creation grant this token has already spent.
|
|
// Its presence is also the marker that distinguishes a record written by a
|
|
// per-type-aware server from a pre-#1644-followup record, where the bare
|
|
// proxmox_registration_completed flag meant "every type is spent".
|
|
proxmoxInstallRegistrationConsumedTypesKey = "proxmox_registration_consumed_types"
|
|
// agentInstallTokenIssuedAtKey records when an install token was minted so
|
|
// the one-shot source-creation grant can expire independently of the
|
|
// token's own (unbounded) reporting lifetime.
|
|
agentInstallTokenIssuedAtKey = "install_issued_at"
|
|
)
|
|
|
|
// canonicalAutoRegisterTypes is the fixed set of source types an install token
|
|
// may bootstrap. A combined PVE+PBS host — an officially supported deployment —
|
|
// consumes one grant per type, so the set doubles as the "everything is spent"
|
|
// expansion for legacy completion markers.
|
|
var canonicalAutoRegisterTypes = []string{"pve", "pbs"}
|
|
|
|
// proxmoxInstallRegistrationTypedKey suffixes a registration metadata key with
|
|
// the canonical type it describes, so a combined host keeps the PVE and PBS
|
|
// completion details side by side instead of overwriting one with the other.
|
|
func proxmoxInstallRegistrationTypedKey(baseKey, nodeType string) string {
|
|
return baseKey + "_" + strings.TrimSpace(nodeType)
|
|
}
|
|
|
|
// proxmoxInstallRegistrationConsumedTypeSet reports which canonical types have
|
|
// already spent their bootstrap grant on this token.
|
|
//
|
|
// tracked distinguishes the two record shapes. A record written by a per-type
|
|
// server carries proxmox_registration_consumed_types and the returned set is
|
|
// exactly what it lists. A record written before per-type consumption existed
|
|
// carries only proxmox_registration_completed=true, which meant the token was
|
|
// spent outright; those expand to every canonical type so an upgrade can never
|
|
// hand an already-used token a second create. A consumed-types value that
|
|
// parses to nothing usable falls back to the same conservative expansion.
|
|
func proxmoxInstallRegistrationConsumedTypeSet(record *config.APITokenRecord) (map[string]struct{}, bool) {
|
|
if record == nil {
|
|
return nil, false
|
|
}
|
|
consumed := make(map[string]struct{}, len(canonicalAutoRegisterTypes))
|
|
for _, raw := range strings.Split(record.Metadata[proxmoxInstallRegistrationConsumedTypesKey], ",") {
|
|
nodeType := strings.TrimSpace(raw)
|
|
if isCanonicalAutoRegisterType(nodeType) {
|
|
consumed[nodeType] = struct{}{}
|
|
}
|
|
}
|
|
if len(consumed) > 0 {
|
|
return consumed, true
|
|
}
|
|
if strings.EqualFold(strings.TrimSpace(record.Metadata[proxmoxInstallRegistrationCompletedKey]), "true") {
|
|
for _, nodeType := range canonicalAutoRegisterTypes {
|
|
consumed[nodeType] = struct{}{}
|
|
}
|
|
}
|
|
return consumed, false
|
|
}
|
|
|
|
// proxmoxInstallRegistrationConsumedForType reports whether requestedType has
|
|
// already spent its one-shot grant on this token.
|
|
func proxmoxInstallRegistrationConsumedForType(record *config.APITokenRecord, requestedType string) bool {
|
|
consumed, _ := proxmoxInstallRegistrationConsumedTypeSet(record)
|
|
_, spent := consumed[strings.TrimSpace(requestedType)]
|
|
return spent
|
|
}
|
|
|
|
// proxmoxInstallRegistrationConsumedTypesValue renders the consumed-type set
|
|
// plus requestedType in a stable order, so the metadata value does not churn
|
|
// with map iteration and stays readable in a token dump.
|
|
func proxmoxInstallRegistrationConsumedTypesValue(record *config.APITokenRecord, requestedType string) string {
|
|
consumed, _ := proxmoxInstallRegistrationConsumedTypeSet(record)
|
|
if consumed == nil {
|
|
consumed = make(map[string]struct{}, len(canonicalAutoRegisterTypes))
|
|
}
|
|
consumed[strings.TrimSpace(requestedType)] = struct{}{}
|
|
|
|
ordered := make([]string, 0, len(canonicalAutoRegisterTypes))
|
|
for _, nodeType := range canonicalAutoRegisterTypes {
|
|
if _, ok := consumed[nodeType]; ok {
|
|
ordered = append(ordered, nodeType)
|
|
}
|
|
}
|
|
return strings.Join(ordered, ",")
|
|
}
|
|
|
|
// proxmoxInstallBootstrapGrantTTL bounds how long after mint the one-shot
|
|
// Proxmox source-creation grant stays exercisable. Install tokens are minted
|
|
// without an expiry because the agent keeps reporting with them forever, so
|
|
// without this bound every host install token on a Proxmox box would carry a
|
|
// live create-a-source capability for the lifetime of the install. The window
|
|
// only has to cover copy-paste-and-run of the installer command.
|
|
const proxmoxInstallBootstrapGrantTTL = 24 * time.Hour
|
|
|
|
const ProxmoxInstallBootstrapGrantTTL = proxmoxInstallBootstrapGrantTTL
|
|
|
|
// proxmoxInstallGrantExpiredMessage is the distinct denial log for a grant
|
|
// that was otherwise valid but is past proxmoxInstallBootstrapGrantTTL. The
|
|
// request still takes the ordinary update-only path and its 403.
|
|
const proxmoxInstallGrantExpiredMessage = "Proxmox install bootstrap grant expired; install token stays update-only"
|
|
|
|
const ProxmoxInstallGrantExpiredMessage = proxmoxInstallGrantExpiredMessage
|
|
|
|
// proxmoxInstallGrantIssuedAt resolves the grant clock for a token record.
|
|
// Tokens minted after #1644 carry an explicit install_issued_at; older records
|
|
// fall back to the token's own creation timestamp. A record with neither is
|
|
// treated as having no grant clock at all, which fails the grant closed.
|
|
func proxmoxInstallGrantIssuedAt(record *config.APITokenRecord) (time.Time, bool) {
|
|
if record == nil {
|
|
return time.Time{}, false
|
|
}
|
|
if raw := strings.TrimSpace(record.Metadata[agentInstallTokenIssuedAtKey]); raw != "" {
|
|
if parsed, err := time.Parse(time.RFC3339, raw); err == nil {
|
|
return parsed.UTC(), true
|
|
}
|
|
}
|
|
if !record.CreatedAt.IsZero() {
|
|
return record.CreatedAt.UTC(), true
|
|
}
|
|
return time.Time{}, false
|
|
}
|
|
|
|
func proxmoxInstallGrantExpiredAt(record *config.APITokenRecord, now time.Time) bool {
|
|
issuedAt, ok := proxmoxInstallGrantIssuedAt(record)
|
|
if !ok {
|
|
return true
|
|
}
|
|
return now.UTC().Sub(issuedAt) >= proxmoxInstallBootstrapGrantTTL
|
|
}
|
|
|
|
// installTokenTypeGrantsBootstrap decides whether an install token minted for
|
|
// installType may bootstrap a source of requestedType. Proxmox-typed tokens
|
|
// stay pinned to their declared type. A generic host-agent install token may
|
|
// bootstrap either canonical Proxmox type: the unified installer auto-detects
|
|
// PVE/PBS on the target machine (#1644), and a host with both installed is an
|
|
// officially supported deployment, so it gets one create for each. The token is
|
|
// still bounded by the same settings:write mint requirement, the same per-type
|
|
// one-shot completion marker, and the same first-use hostname binding as a
|
|
// typed token — the binding is shared across types, so whichever type registers
|
|
// first pins the hostname for both.
|
|
func installTokenTypeGrantsBootstrap(installType, requestedType string) bool {
|
|
if installType == requestedType && isCanonicalAutoRegisterType(requestedType) {
|
|
return true
|
|
}
|
|
return installType == agentInstallTypeHost && isCanonicalAutoRegisterType(requestedType)
|
|
}
|
|
|
|
// proxmoxInstallGrantEligible applies every bootstrap-grant bound except the
|
|
// mint-age TTL. It is split out so an expired-but-otherwise-valid grant can be
|
|
// reported with its own denial log instead of being indistinguishable from a
|
|
// token that never held a grant.
|
|
func proxmoxInstallGrantEligible(record *config.APITokenRecord, req *AutoRegisterRequest) bool {
|
|
if record == nil || req == nil || strings.TrimSpace(req.Source) != "agent" {
|
|
return false
|
|
}
|
|
if proxmoxInstallRegistrationConsumedForType(record, req.Type) {
|
|
return false
|
|
}
|
|
if !installTokenTypeGrantsBootstrap(strings.TrimSpace(record.Metadata["install_type"]), strings.TrimSpace(req.Type)) {
|
|
return false
|
|
}
|
|
switch strings.TrimSpace(record.Metadata["issued_via"]) {
|
|
case agentInstallIssuedViaConfig, agentInstallIssuedViaHosted:
|
|
default:
|
|
return false
|
|
}
|
|
requestedHostname := strings.TrimSpace(req.ServerName)
|
|
if requestedHostname == "" {
|
|
return false
|
|
}
|
|
if boundHostname := strings.TrimSpace(record.Metadata["bound_hostname"]); boundHostname != "" {
|
|
return strings.EqualFold(boundHostname, requestedHostname)
|
|
}
|
|
return true
|
|
}
|
|
|
|
// proxmoxInstallGrantExpiredForRequest reports a grant that would have been
|
|
// exercisable but for the mint-age TTL.
|
|
func proxmoxInstallGrantExpiredForRequest(record *config.APITokenRecord, req *AutoRegisterRequest) bool {
|
|
return proxmoxInstallGrantEligible(record, req) &&
|
|
proxmoxInstallGrantExpiredAt(record, time.Now().UTC())
|
|
}
|
|
|
|
func canBootstrapProxmoxInstallRegistration(record *config.APITokenRecord, req *AutoRegisterRequest) bool {
|
|
return canBootstrapProxmoxInstallRegistrationAt(record, req, time.Now().UTC())
|
|
}
|
|
|
|
func canBootstrapProxmoxInstallRegistrationAt(record *config.APITokenRecord, req *AutoRegisterRequest, now time.Time) bool {
|
|
return proxmoxInstallGrantEligible(record, req) && !proxmoxInstallGrantExpiredAt(record, now)
|
|
}
|
|
|
|
func CanBootstrapProxmoxInstallRegistrationAt(record *config.APITokenRecord, req *AutoRegisterRequest, now time.Time) bool {
|
|
return canBootstrapProxmoxInstallRegistrationAt(record, req, now)
|
|
}
|
|
|
|
func ProxmoxInstallGrantEligible(record *config.APITokenRecord, req *AutoRegisterRequest) bool {
|
|
return proxmoxInstallGrantEligible(record, req)
|
|
}
|
|
|
|
// completedProxmoxInstallRegistrationMatches reports that this exact
|
|
// type-and-hostname registration is the one that already spent its grant, which
|
|
// makes a repeat completion a no-op rather than a denial. It is deliberately
|
|
// narrower than proxmoxInstallRegistrationConsumedForType: a spent grant for a
|
|
// different hostname is still a refusal.
|
|
func completedProxmoxInstallRegistrationMatches(record *config.APITokenRecord, req *AutoRegisterRequest) bool {
|
|
if record == nil || req == nil {
|
|
return false
|
|
}
|
|
requestedType := strings.TrimSpace(req.Type)
|
|
requestedHostname := strings.TrimSpace(req.ServerName)
|
|
|
|
consumed, tracked := proxmoxInstallRegistrationConsumedTypeSet(record)
|
|
if _, spent := consumed[requestedType]; !spent {
|
|
return false
|
|
}
|
|
if !tracked {
|
|
// Legacy record: the single completion block describes the one
|
|
// registration this token performed, whatever type it was for.
|
|
return strings.TrimSpace(record.Metadata[proxmoxInstallRegistrationTypeKey]) == requestedType &&
|
|
strings.EqualFold(
|
|
strings.TrimSpace(record.Metadata[proxmoxInstallRegistrationHostnameKey]),
|
|
requestedHostname,
|
|
)
|
|
}
|
|
completedHostname := strings.TrimSpace(
|
|
record.Metadata[proxmoxInstallRegistrationTypedKey(proxmoxInstallRegistrationHostnameKey, requestedType)],
|
|
)
|
|
return completedHostname != "" && strings.EqualFold(completedHostname, requestedHostname)
|
|
}
|
|
|
|
// prepareProxmoxInstallBootstrap binds an unused install token to the first
|
|
// fully validated host request that presents it.
|
|
func (h *ConfigHandlers) prepareProxmoxInstallBootstrap(ctx context.Context, req *AutoRegisterRequest) (bool, error) {
|
|
auth, ok := getAgentAutoRegAuth(ctx)
|
|
if !ok || !auth.installBootstrap || strings.TrimSpace(auth.tokenID) == "" {
|
|
return false, nil
|
|
}
|
|
|
|
config.Mu.Lock()
|
|
defer config.Mu.Unlock()
|
|
|
|
cfg := h.getConfig(ctx)
|
|
if cfg == nil {
|
|
return false, fmt.Errorf("configuration unavailable")
|
|
}
|
|
for i := range cfg.APITokens {
|
|
record := &cfg.APITokens[i]
|
|
if record.ID != auth.tokenID {
|
|
continue
|
|
}
|
|
if !canBootstrapProxmoxInstallRegistration(record, req) {
|
|
return false, nil
|
|
}
|
|
if strings.TrimSpace(record.Metadata["bound_hostname"]) != "" {
|
|
return true, nil
|
|
}
|
|
|
|
if record.Metadata == nil {
|
|
record.Metadata = make(map[string]string)
|
|
}
|
|
previousMetadata := make(map[string]string, len(record.Metadata))
|
|
for key, value := range record.Metadata {
|
|
previousMetadata[key] = value
|
|
}
|
|
record.Metadata["bound_hostname"] = strings.TrimSpace(req.ServerName)
|
|
record.Metadata["bound_at"] = time.Now().UTC().Format(time.RFC3339)
|
|
if persistence := h.getPersistence(ctx); persistence != nil {
|
|
if err := persistence.SaveAPITokens(cfg.APITokens); err != nil {
|
|
record.Metadata = previousMetadata
|
|
return false, fmt.Errorf("persist Proxmox install token host binding: %w", err)
|
|
}
|
|
}
|
|
return true, nil
|
|
}
|
|
return false, fmt.Errorf("Proxmox install token no longer exists")
|
|
}
|
|
|
|
func (h *ConfigHandlers) proxmoxInstallBootstrapAvailable(ctx context.Context, req *AutoRegisterRequest) bool {
|
|
auth, ok := getAgentAutoRegAuth(ctx)
|
|
if !ok || !auth.installBootstrap || strings.TrimSpace(auth.tokenID) == "" {
|
|
return false
|
|
}
|
|
|
|
config.Mu.RLock()
|
|
defer config.Mu.RUnlock()
|
|
cfg := h.getConfig(ctx)
|
|
if cfg == nil {
|
|
return false
|
|
}
|
|
for i := range cfg.APITokens {
|
|
record := &cfg.APITokens[i]
|
|
if record.ID == auth.tokenID {
|
|
return canBootstrapProxmoxInstallRegistration(record, req)
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// proxmoxInstallBootstrapConsumption records a grant consumption that can be
|
|
// undone when the work it authorized fails to persist. Consumption is durable
|
|
// before the source is written, so an unrecoverable SaveNodesConfig must put
|
|
// the grant back rather than leave a consumed grant with no source (or, worse,
|
|
// leave the grant live next to a persisted source).
|
|
//
|
|
// The undo is scoped to the keys this one consumption wrote. On a combined
|
|
// PVE+PBS host the two types spend their grants in sequence, so a failed PBS
|
|
// save must restore the PBS grant without resurrecting the PVE grant that the
|
|
// earlier leg already spent on a source that did persist.
|
|
type proxmoxInstallBootstrapConsumption struct {
|
|
tokenID string
|
|
proxmoxType string
|
|
// previousValues holds the pre-consumption value of every metadata key this
|
|
// consumption wrote. Keys that did not exist beforehand are listed in
|
|
// previouslyAbsent and are deleted rather than restored.
|
|
previousValues map[string]string
|
|
previouslyAbsent map[string]struct{}
|
|
consumed bool
|
|
}
|
|
|
|
// recordProxmoxInstallBootstrapConsumption writes the completion metadata for
|
|
// one canonical type and returns the undo record for it.
|
|
func recordProxmoxInstallBootstrapConsumption(record *config.APITokenRecord, req *AutoRegisterRequest, host, node string) *proxmoxInstallBootstrapConsumption {
|
|
if record.Metadata == nil {
|
|
record.Metadata = make(map[string]string)
|
|
}
|
|
requestedType := strings.TrimSpace(req.Type)
|
|
|
|
consumption := &proxmoxInstallBootstrapConsumption{
|
|
tokenID: record.ID,
|
|
proxmoxType: requestedType,
|
|
previousValues: make(map[string]string, 12),
|
|
previouslyAbsent: make(map[string]struct{}, 12),
|
|
consumed: true,
|
|
}
|
|
set := func(key, value string) {
|
|
if previous, ok := record.Metadata[key]; ok {
|
|
consumption.previousValues[key] = previous
|
|
} else {
|
|
consumption.previouslyAbsent[key] = struct{}{}
|
|
}
|
|
record.Metadata[key] = value
|
|
}
|
|
|
|
completedAt := time.Now().UTC().Format(time.RFC3339)
|
|
// The consumed-type list is the authoritative per-type ledger. Compute it
|
|
// before any other write so it reads the pre-consumption state.
|
|
consumedTypes := proxmoxInstallRegistrationConsumedTypesValue(record, requestedType)
|
|
set(proxmoxInstallRegistrationConsumedTypesKey, consumedTypes)
|
|
|
|
// Per-type completion details, so a combined host keeps both registrations.
|
|
set(proxmoxInstallRegistrationTypedKey(proxmoxInstallRegistrationHostnameKey, requestedType), strings.TrimSpace(req.ServerName))
|
|
set(proxmoxInstallRegistrationTypedKey(proxmoxInstallRegistrationHostKey, requestedType), strings.TrimSpace(host))
|
|
set(proxmoxInstallRegistrationTypedKey(proxmoxInstallRegistrationNodeKey, requestedType), strings.TrimSpace(node))
|
|
set(proxmoxInstallRegistrationTypedKey(proxmoxInstallRegistrationAtKey, requestedType), completedAt)
|
|
|
|
// The unsuffixed block stays as the most recent completion. It keeps
|
|
// existing operator tooling and any older Pulse binary reading this token
|
|
// store failing closed: to a pre-per-type server the bare completed flag
|
|
// still reads as "this token is spent".
|
|
set(proxmoxInstallRegistrationCompletedKey, "true")
|
|
set(proxmoxInstallRegistrationTypeKey, requestedType)
|
|
set(proxmoxInstallRegistrationHostnameKey, strings.TrimSpace(req.ServerName))
|
|
set(proxmoxInstallRegistrationHostKey, strings.TrimSpace(host))
|
|
set(proxmoxInstallRegistrationNodeKey, strings.TrimSpace(node))
|
|
set(proxmoxInstallRegistrationAtKey, completedAt)
|
|
|
|
return consumption
|
|
}
|
|
|
|
// restore undoes the metadata writes this consumption made, leaving every other
|
|
// key — including a sibling type's completed grant — untouched.
|
|
func (c *proxmoxInstallBootstrapConsumption) restore(metadata map[string]string) {
|
|
if metadata == nil {
|
|
return
|
|
}
|
|
for key, value := range c.previousValues {
|
|
metadata[key] = value
|
|
}
|
|
for key := range c.previouslyAbsent {
|
|
delete(metadata, key)
|
|
}
|
|
}
|
|
|
|
func (h *ConfigHandlers) completeProxmoxInstallBootstrap(ctx context.Context, req *AutoRegisterRequest, host, node string) (*proxmoxInstallBootstrapConsumption, error) {
|
|
auth, ok := getAgentAutoRegAuth(ctx)
|
|
if !ok || !auth.installBootstrap || strings.TrimSpace(auth.tokenID) == "" {
|
|
return nil, nil
|
|
}
|
|
|
|
persistence := h.getPersistence(ctx)
|
|
config.Mu.Lock()
|
|
defer config.Mu.Unlock()
|
|
|
|
cfg := h.getConfig(ctx)
|
|
if cfg == nil {
|
|
return nil, fmt.Errorf("configuration unavailable")
|
|
}
|
|
for i := range cfg.APITokens {
|
|
record := &cfg.APITokens[i]
|
|
if record.ID != auth.tokenID {
|
|
continue
|
|
}
|
|
if completedProxmoxInstallRegistrationMatches(record, req) {
|
|
return nil, nil
|
|
}
|
|
if !canBootstrapProxmoxInstallRegistration(record, req) {
|
|
return nil, fmt.Errorf("Proxmox install registration grant is no longer available")
|
|
}
|
|
consumption := recordProxmoxInstallBootstrapConsumption(record, req, host, node)
|
|
if persistence != nil {
|
|
if err := persistence.SaveAPITokens(cfg.APITokens); err != nil {
|
|
consumption.restore(record.Metadata)
|
|
return nil, fmt.Errorf("persist Proxmox install registration grant: %w", err)
|
|
}
|
|
}
|
|
return consumption, nil
|
|
}
|
|
return nil, fmt.Errorf("Proxmox install token no longer exists")
|
|
}
|
|
|
|
// rollbackProxmoxInstallBootstrap restores a grant consumed by
|
|
// completeProxmoxInstallBootstrap. It is best effort by necessity — the token
|
|
// store just failed us once — but it keeps the common case atomic: either the
|
|
// source is persisted and the grant is spent, or neither happened. Only the
|
|
// canonical type this consumption spent is restored; a sibling type already
|
|
// registered from the same install token stays consumed.
|
|
func (h *ConfigHandlers) rollbackProxmoxInstallBootstrap(ctx context.Context, consumption *proxmoxInstallBootstrapConsumption) {
|
|
if consumption == nil || !consumption.consumed {
|
|
return
|
|
}
|
|
|
|
persistence := h.getPersistence(ctx)
|
|
config.Mu.Lock()
|
|
defer config.Mu.Unlock()
|
|
|
|
cfg := h.getConfig(ctx)
|
|
if cfg == nil {
|
|
return
|
|
}
|
|
for i := range cfg.APITokens {
|
|
record := &cfg.APITokens[i]
|
|
if record.ID != consumption.tokenID {
|
|
continue
|
|
}
|
|
if record.Metadata == nil {
|
|
record.Metadata = make(map[string]string)
|
|
}
|
|
consumption.restore(record.Metadata)
|
|
if persistence != nil {
|
|
if err := persistence.SaveAPITokens(cfg.APITokens); err != nil {
|
|
log.Error().
|
|
Err(err).
|
|
Str("token_id", consumption.tokenID).
|
|
Str("type", consumption.proxmoxType).
|
|
Msg("Failed to restore Proxmox install registration grant after a failed source save")
|
|
return
|
|
}
|
|
}
|
|
log.Warn().
|
|
Str("token_id", consumption.tokenID).
|
|
Str("type", consumption.proxmoxType).
|
|
Msg("Restored Proxmox install registration grant after a failed source save")
|
|
return
|
|
}
|
|
}
|
|
|
|
// 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"`
|
|
// Name is the optional connection name the user typed in the add dialog.
|
|
// It rides the setup token so the auto-registration that the setup script
|
|
// triggers can name the connection what the user asked for, rather than
|
|
// deriving it from the node's hostname.
|
|
Name string `json:"name,omitempty"`
|
|
}
|
|
|
|
// setupDesiredNameContextKey carries the user's typed connection name from
|
|
// setup-token validation to the auto-registration path within one request.
|
|
type setupDesiredNameContextKey struct{}
|
|
|
|
func setupDesiredNameFromContext(ctx context.Context) string {
|
|
if ctx == nil {
|
|
return ""
|
|
}
|
|
if name, ok := ctx.Value(setupDesiredNameContextKey{}).(string); ok {
|
|
return strings.TrimSpace(name)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// 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)
|
|
req.Name = strings.TrimSpace(req.Name)
|
|
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
|
|
pulseURL := h.resolveConfigAgentInstallBaseURL(r, h.getConfig(r.Context()))
|
|
if pulseURL == "" {
|
|
writeConfigAgentInstallBaseURLUnavailable(w)
|
|
return
|
|
}
|
|
|
|
// 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: apicontext.OrgID(r.Context()),
|
|
DesiredName: req.Name,
|
|
}
|
|
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.
|
|
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 IsCanonicalAutoRegisterType(nodeType string) bool { return isCanonicalAutoRegisterType(nodeType) }
|
|
|
|
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 IsCanonicalAutoRegisterTokenID(nodeType string, tokenID string) bool {
|
|
return isCanonicalAutoRegisterTokenID(nodeType, tokenID)
|
|
}
|
|
|
|
func isCanonicalAutoRegisterSource(source string) bool {
|
|
switch strings.TrimSpace(source) {
|
|
case "agent", "script":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func IsCanonicalAutoRegisterSource(source string) bool { return isCanonicalAutoRegisterSource(source) }
|
|
|
|
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
|
|
}
|
|
|
|
func CanonicalAutoRegisterMatchMessage(reason string) string {
|
|
return canonicalAutoRegisterMatchMessage(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
|
|
}
|
|
|
|
// canonicalAutoRegisterCandidate is the identity view of an already-configured
|
|
// instance that canonical auto-registration matches an incoming registration
|
|
// against. PVE and PBS instances are projected onto this shape so one matcher
|
|
// serves both node types.
|
|
type canonicalAutoRegisterCandidate struct {
|
|
Host string
|
|
Name string
|
|
Fingerprint string
|
|
TokenName string
|
|
}
|
|
|
|
// canonicalAutoRegisterIdentity is the identity of the incoming registration.
|
|
// candidateHosts is the agent's preference-ordered address list, used to decide
|
|
// whether an identity-matched node keeps its stored host.
|
|
type canonicalAutoRegisterIdentity struct {
|
|
host string
|
|
serverName string
|
|
fingerprint string
|
|
tokenName string
|
|
candidateHosts []string
|
|
}
|
|
|
|
func pveAutoRegisterCandidate(instance config.PVEInstance) canonicalAutoRegisterCandidate {
|
|
return canonicalAutoRegisterCandidate{
|
|
Host: instance.Host,
|
|
Name: instance.Name,
|
|
Fingerprint: instance.Fingerprint,
|
|
TokenName: instance.TokenName,
|
|
}
|
|
}
|
|
|
|
func pbsAutoRegisterCandidate(instance config.PBSInstance) canonicalAutoRegisterCandidate {
|
|
return canonicalAutoRegisterCandidate{
|
|
Host: instance.Host,
|
|
Name: instance.Name,
|
|
Fingerprint: instance.Fingerprint,
|
|
TokenName: instance.TokenName,
|
|
}
|
|
}
|
|
|
|
// autoRegisterCandidates projects configured instances onto the identity view
|
|
// findCanonicalAutoRegisterMatch compares against.
|
|
func autoRegisterCandidates[T any](instances []T, view func(T) canonicalAutoRegisterCandidate) []canonicalAutoRegisterCandidate {
|
|
candidates := make([]canonicalAutoRegisterCandidate, len(instances))
|
|
for i, instance := range instances {
|
|
candidates[i] = view(instance)
|
|
}
|
|
return candidates
|
|
}
|
|
|
|
// findCanonicalAutoRegisterMatch reports which configured instance an incoming
|
|
// canonical auto-registration should update in place, and whether that instance
|
|
// keeps its stored host. It returns (-1, false) when the registration belongs to
|
|
// a node Pulse has not seen, which is the caller's signal to create one.
|
|
//
|
|
// The ladder, in order: exact host match, then — only for candidates whose TLS
|
|
// fingerprint does not conflict with the incoming one — resolved host identity
|
|
// (the same box reached by a different address), then DHCP continuity by node
|
|
// name plus token identity. nodeType ("pve"/"pbs") is log attribution only.
|
|
func findCanonicalAutoRegisterMatch(nodeType string, incoming canonicalAutoRegisterIdentity, candidates []canonicalAutoRegisterCandidate) (int, bool) {
|
|
for i, node := range candidates {
|
|
if node.Host == incoming.host {
|
|
return i, false
|
|
}
|
|
if config.ProxmoxFingerprintsConflict(node.Fingerprint, incoming.fingerprint) {
|
|
log.Warn().
|
|
Str("existingHost", node.Host).
|
|
Str("newHost", incoming.host).
|
|
Str("type", nodeType).
|
|
Msg("Canonical auto-register rejected inferred node match because TLS fingerprints conflict")
|
|
continue
|
|
}
|
|
if hostsShareResolvedIdentity(node.Host, incoming.host) {
|
|
preserveHost := shouldPreserveExistingAutoRegisterHost(node.Host, incoming.candidateHosts)
|
|
log.Info().
|
|
Str("existingHost", node.Host).
|
|
Str("newHost", incoming.host).
|
|
Bool("preserveHost", preserveHost).
|
|
Str("type", nodeType).
|
|
Msg(canonicalAutoRegisterMatchMessage("resolved host identity"))
|
|
return i, preserveHost
|
|
}
|
|
if incoming.serverName != "" &&
|
|
strings.EqualFold(strings.TrimSpace(node.Name), incoming.serverName) &&
|
|
node.TokenName == incoming.tokenName {
|
|
log.Info().
|
|
Str("existingHost", node.Host).
|
|
Str("newHost", incoming.host).
|
|
Str("type", nodeType).
|
|
Str("node", incoming.serverName).
|
|
Msg(canonicalAutoRegisterMatchMessage("DHCP continuity token identity"))
|
|
return i, false
|
|
}
|
|
}
|
|
return -1, false
|
|
}
|
|
|
|
// 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]
|
|
selectedIdentity := clusterMemberOverrideIdentity(selectedHost)
|
|
effectiveIdentity := clusterMemberOverrideIdentity(endpoint.EffectiveIP())
|
|
willAdoptSelectedAddress := selectedIdentity != "" &&
|
|
selectedIdentity != effectiveIdentity &&
|
|
shouldAdoptClusterMemberAutoRegisterAddress(endpoint.IPOverride, candidateHosts)
|
|
if willAdoptSelectedAddress && config.ProxmoxFingerprintsConflict(endpoint.Fingerprint, fingerprint) {
|
|
log.Warn().
|
|
Str("cluster", instance.Name).
|
|
Str("node", endpoint.NodeName).
|
|
Str("existingHost", endpoint.EffectiveIP()).
|
|
Str("newHost", selectedHost).
|
|
Msg("Canonical auto-register rejected inferred cluster member match because TLS fingerprints conflict")
|
|
return false
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
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 CanonicalAutoRegisterCompletionPayloadMessage() string {
|
|
return canonicalAutoRegisterCompletionPayloadMessage()
|
|
}
|
|
|
|
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 CanonicalAutoRegisterCheckMissingFieldsMessage(typeValue, host, serverName string) string {
|
|
return canonicalAutoRegisterCheckMissingFieldsMessage(typeValue, host, serverName)
|
|
}
|
|
|
|
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 CanonicalAutoRegisterMissingFieldsMessage(typeValue, host string, hasTokenID bool, serverName string) string {
|
|
return canonicalAutoRegisterMissingFieldsMessage(typeValue, host, hasTokenID, serverName)
|
|
}
|
|
|
|
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 CanonicalAutoUnregisterMissingFieldsMessage(typeValue, host, serverName string) string {
|
|
return canonicalAutoUnregisterMissingFieldsMessage(typeValue, host, serverName)
|
|
}
|
|
|
|
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 := apicontext.OrgID(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(), apicontext.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(), apicontext.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"`
|
|
SourceExists bool `json:"sourceExists"`
|
|
CanRegister bool `json:"canRegister"`
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
func (h *ConfigHandlers) autoRegisteredNodeConfigured(ctx context.Context, req *AutoRegisterRequest, candidates []string) bool {
|
|
if req == nil {
|
|
return false
|
|
}
|
|
cfg := h.getConfig(ctx)
|
|
if cfg == nil {
|
|
return false
|
|
}
|
|
switch req.Type {
|
|
case "pve":
|
|
for _, node := range cfg.PVEInstances {
|
|
if matched, _ := autoRegisterPVEInstanceMatchesCandidates(node, candidates); matched {
|
|
return true
|
|
}
|
|
}
|
|
case "pbs":
|
|
for _, node := range cfg.PBSInstances {
|
|
if autoRegisterHostMatchesCandidates(node.Host, candidates) {
|
|
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(), apicontext.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
|
|
// Carry the name the user typed in the add dialog to the
|
|
// registration path, same transport as the token's OrgID.
|
|
if trimmed := strings.TrimSpace(setupTokenRecord.DesiredName); trimmed != "" {
|
|
r = r.WithContext(context.WithValue(r.Context(), setupDesiredNameContextKey{}, trimmed))
|
|
}
|
|
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
|
|
// an existing Proxmox node. Ordinary tokens remain update-only. A
|
|
// settings-authorized Proxmox install token may additionally bootstrap one
|
|
// source of its declared type, bound to the first presenting hostname.
|
|
if !authenticated {
|
|
if apiToken, hasToken := explicitAPITokenFromRequest(r); hasToken {
|
|
config.Mu.Lock()
|
|
cfg := h.getConfig(r.Context())
|
|
if cfg != nil {
|
|
record, ok := cfg.ValidateAPIToken(apiToken)
|
|
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 := apicontext.OrgID(r.Context())
|
|
orgMismatch := requestOrgID != "" && requestOrgID != "default" &&
|
|
record.OrgID != "" && record.OrgID != requestOrgID
|
|
if !orgMismatch {
|
|
installBootstrap := canBootstrapProxmoxInstallRegistration(record, &req)
|
|
if !installBootstrap && proxmoxInstallGrantExpiredForRequest(record, &req) {
|
|
log.Warn().
|
|
Str("token_id", record.ID).
|
|
Str("type", req.Type).
|
|
Str("host", req.Host).
|
|
Str("grant_ttl", proxmoxInstallBootstrapGrantTTL.String()).
|
|
Msg(proxmoxInstallGrantExpiredMessage)
|
|
}
|
|
authenticated = true
|
|
r = r.WithContext(context.WithValue(r.Context(), agentAutoRegContextKey{}, agentAutoRegAuth{
|
|
tokenID: record.ID,
|
|
installBootstrap: installBootstrap,
|
|
}))
|
|
event := log.Info()
|
|
if req.CheckRegistration {
|
|
event = log.Debug()
|
|
}
|
|
message := "Auto-register authenticated via agent API token (update-only mode)"
|
|
if installBootstrap {
|
|
message = "Auto-register authenticated via Proxmox install token"
|
|
}
|
|
event.
|
|
Str("type", req.Type).
|
|
Str("host", req.Host).
|
|
Bool("install_bootstrap", installBootstrap).
|
|
Msg(message)
|
|
}
|
|
}
|
|
}
|
|
config.Mu.Unlock()
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// Resolve the source through the root security policy supplied at wiring time.
|
|
clientIP := h.runtimeDependencies().ClientIP(r)
|
|
requestEvent := log.Info()
|
|
if req.CheckRegistration {
|
|
requestEvent = log.Debug()
|
|
}
|
|
requestEvent.Str("clientIP", clientIP).Msg("Auto-register request from")
|
|
|
|
requestEvent = log.Info()
|
|
if req.CheckRegistration {
|
|
requestEvent = log.Debug()
|
|
}
|
|
requestEvent.
|
|
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) {
|
|
event := log.Info()
|
|
if req.CheckRegistration {
|
|
event = log.Debug()
|
|
}
|
|
event.
|
|
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
|
|
|
|
// Serialize checks and completions. Checks can bind or consume an install
|
|
// grant, and must not race the source mutation performed by completion.
|
|
h.autoRegisterMutex.Lock()
|
|
defer h.autoRegisterMutex.Unlock()
|
|
|
|
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
|
|
}
|
|
if _, err := h.prepareProxmoxInstallBootstrap(r.Context(), req); err != nil {
|
|
log.Error().
|
|
Err(err).
|
|
Str("type", req.Type).
|
|
Str("host", req.Host).
|
|
Msg("Failed to bind Proxmox install registration token")
|
|
http.Error(w, "Failed to persist Proxmox install registration authorization", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
sourceExists := h.autoRegisteredNodeConfigured(r.Context(), req, normalizedCandidates)
|
|
registered := h.autoRegisteredNodeExists(r.Context(), req, normalizedCandidates)
|
|
if registered {
|
|
actualName := h.findInstanceNameByHost(r.Context(), req.Type, normalizedCandidates[0])
|
|
if actualName == "" {
|
|
actualName = req.ServerName
|
|
}
|
|
if _, err := h.completeProxmoxInstallBootstrap(r.Context(), req, normalizedCandidates[0], actualName); err != nil {
|
|
log.Error().
|
|
Err(err).
|
|
Str("type", req.Type).
|
|
Str("host", req.Host).
|
|
Msg("Failed to consume Proxmox install registration grant for existing source")
|
|
http.Error(w, "Failed to finalize Proxmox install registration authorization", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
canRegister := sourceExists || !isAgentAutoRegAuth(r.Context()) ||
|
|
h.proxmoxInstallBootstrapAvailable(r.Context(), req)
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if err := json.NewEncoder(w).Encode(autoRegisterCheckResponse{
|
|
Registered: registered,
|
|
SourceExists: sourceExists,
|
|
CanRegister: canRegister,
|
|
}); 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
|
|
}
|
|
if _, err := h.prepareProxmoxInstallBootstrap(r.Context(), req); err != nil {
|
|
log.Error().
|
|
Err(err).
|
|
Str("type", req.Type).
|
|
Str("host", req.Host).
|
|
Msg("Failed to bind Proxmox install registration token")
|
|
http.Error(w, "Failed to persist Proxmox install registration authorization", http.StatusInternalServerError)
|
|
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)
|
|
|
|
incomingIdentity := canonicalAutoRegisterIdentity{
|
|
host: host,
|
|
serverName: serverName,
|
|
fingerprint: fingerprint,
|
|
tokenName: fullTokenID,
|
|
candidateHosts: candidateHosts,
|
|
}
|
|
|
|
// Add the node to configuration. A name typed in the add dialog rides the
|
|
// setup token as the desired name and wins over the node's own hostname;
|
|
// the dedup/adoption identity below still matches on serverName so
|
|
// renaming cannot fork an existing connection.
|
|
connectionName := serverName
|
|
if desired := setupDesiredNameFromContext(r.Context()); desired != "" {
|
|
connectionName = desired
|
|
}
|
|
created := false
|
|
if req.Type == "pve" {
|
|
pveDisplayName := h.disambiguateNodeName(r.Context(), connectionName, 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, preserveHost := findCanonicalAutoRegisterMatch("pve", incomingIdentity,
|
|
autoRegisterCandidates(h.getConfig(r.Context()).PVEInstances, pveAutoRegisterCandidate))
|
|
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
|
|
}
|
|
// Preserve the operator's TLS choice on an existing connection.
|
|
// The incoming value records only whether this registration
|
|
// captured a fingerprint; applying it unconditionally re-enabled a
|
|
// disabled "Verify SSL certificate" setting on every agent
|
|
// health-check re-registration (#2140). Heal only the legacy state
|
|
// where strict verification is on with no pin and no recorded
|
|
// operator choice, which can never connect to a self-signed
|
|
// endpoint (#1303).
|
|
if !instance.VerifySSLExplicit && instance.VerifySSL && instance.Fingerprint == "" {
|
|
instance.VerifySSL = false
|
|
}
|
|
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()) && !h.proxmoxInstallBootstrapAvailable(r.Context(), req) {
|
|
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(), connectionName, 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, preserveHost := findCanonicalAutoRegisterMatch("pbs", incomingIdentity,
|
|
autoRegisterCandidates(h.getConfig(r.Context()).PBSInstances, pbsAutoRegisterCandidate))
|
|
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
|
|
}
|
|
// Preserve the operator's TLS choice on an existing connection.
|
|
// The incoming value records only whether this registration
|
|
// captured a fingerprint; applying it unconditionally re-enabled a
|
|
// disabled "Verify SSL certificate" setting on every agent
|
|
// health-check re-registration (#2140). Heal only the legacy state
|
|
// where strict verification is on with no pin, which can never
|
|
// connect to a self-signed endpoint (#1303).
|
|
if instance.VerifySSL && instance.Fingerprint == "" {
|
|
instance.VerifySSL = false
|
|
}
|
|
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()) && !h.proxmoxInstallBootstrapAvailable(r.Context(), req) {
|
|
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
|
|
|
|
actualName := h.findInstanceNameByHost(r.Context(), req.Type, host)
|
|
if actualName == "" {
|
|
actualName = serverName
|
|
}
|
|
|
|
// Consume the one-shot install grant BEFORE the source is persisted. The
|
|
// other order leaves a repeatable create-a-source primitive whenever the
|
|
// token store write fails persistently: the node lands on disk while the
|
|
// grant stays unconsumed, so the same install token can be replayed. If
|
|
// the node save then fails, the consumption is rolled back so the failure
|
|
// stays atomic and a genuine retry still works.
|
|
consumption, err := h.completeProxmoxInstallBootstrap(r.Context(), req, host, actualName)
|
|
if err != nil {
|
|
log.Error().
|
|
Err(err).
|
|
Str("type", req.Type).
|
|
Str("host", host).
|
|
Msg("Failed to consume Proxmox install registration grant")
|
|
http.Error(w, "Failed to finalize Proxmox install registration authorization", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
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")
|
|
h.rollbackProxmoxInstallBootstrap(r.Context(), consumption)
|
|
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")
|
|
}
|
|
|
|
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)
|
|
|
|
}
|
|
|
|
func (h *ConfigHandlers) HandleCanonicalAutoRegister(w http.ResponseWriter, r *http.Request, req *AutoRegisterRequest, clientIP string) {
|
|
h.handleCanonicalAutoRegister(w, r, req, clientIP)
|
|
}
|
|
|
|
// 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: " + "/docs/SECURITY.md#critical-security-notice-for-container-deployments")
|
|
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,
|
|
}
|
|
}
|
|
|
|
func (h *ConfigHandlers) GetOrGenerateSSHKeys() SSHKeyPair { return h.getOrGenerateSSHKeys() }
|
|
|
|
// 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"`
|
|
Insecure bool `json:"insecure,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())
|
|
baseURL := h.resolveConfigAgentInstallBaseURL(r, cfg)
|
|
if baseURL == "" {
|
|
writeConfigAgentInstallBaseURLUnavailable(w)
|
|
return
|
|
}
|
|
|
|
rawToken := ""
|
|
if h.authConfiguredForAgentLifecycle(cfg) {
|
|
tokenName := fmt.Sprintf("proxmox-agent-%s-%d", installType, time.Now().Unix())
|
|
rawToken, _, err = issueAndPersistAgentInstallToken(cfg, persistence, issueAgentInstallTokenOptions{
|
|
TokenName: tokenName,
|
|
OwnerUserID: h.apiTokenOwnerUserIDForRequest(cfg, r),
|
|
Scopes: proxmoxAgentInstallScopes(req.EnableCommands),
|
|
Metadata: map[string]string{
|
|
"install_type": installType,
|
|
"issued_via": "config_agent_install_command",
|
|
agentInstallCommandPolicyIntentKey: agentInstallCommandPolicyIntent(req.EnableCommands),
|
|
},
|
|
})
|
|
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
|
|
}
|
|
}
|
|
|
|
command := buildProxmoxAgentInstallCommand(agentInstallCommandOptions{
|
|
BaseURL: baseURL,
|
|
Token: rawToken,
|
|
InstallType: installType,
|
|
IncludeInstallType: true,
|
|
EnableCommands: req.EnableCommands,
|
|
Insecure: req.Insecure,
|
|
})
|
|
|
|
log.Info().
|
|
Str("type", installType).
|
|
Bool("token_issued", rawToken != "").
|
|
Bool("enable_commands", req.EnableCommands).
|
|
Bool("insecure", req.Insecure).
|
|
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 !h.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: h.apiTokenOwnerUserIDForRequest(cfg, r),
|
|
Scopes: hostAgentInstallScopes(req.EnableCommands),
|
|
Metadata: map[string]string{
|
|
"install_type": agentInstallTypeHost,
|
|
"issued_via": agentInstallIssuedViaConfig,
|
|
agentInstallCommandPolicyIntentKey: agentInstallCommandPolicyIntent(req.EnableCommands),
|
|
},
|
|
})
|
|
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,
|
|
})
|
|
}
|