Fix PVE token re-registration after agent reinstall

When the agent is reinstalled on a Proxmox host, it rotates the PVE API
token in Proxmox but the Pulse server's /api/setup-script-url endpoint
requires settings:write scope — agent tokens only have agent:report — so
the 401 aborted the update, leaving Pulse with a stale token and a
disconnected PVE node.

Three-part fix:
- server: accept agent API tokens on /api/auto-register for updating
  existing nodes (new nodes still require setup-token auth)
- agent: fall through instead of aborting when setup token fetch returns
  4xx; send X-API-Token header so the server can authenticate via the
  agent token instead
- update: allow HTTP auto-update URLs for RFC 1918 private network
  addresses (LAN installs without HTTPS no longer block auto-update)
This commit is contained in:
rcourtman 2026-04-18 21:44:42 +01:00
parent f754dfa9b9
commit b0b790cf55
4 changed files with 94 additions and 11 deletions

View file

@ -568,6 +568,14 @@ func isLoopbackHost(host string) bool {
return ip != nil && ip.IsLoopback()
}
func isPrivateNetworkHost(host string) bool {
if host == "" {
return false
}
ip := net.ParseIP(strings.Trim(host, "[]"))
return ip != nil && ip.IsPrivate()
}
func (u *Updater) validatePulseURL() error {
pulseURL := strings.TrimSpace(u.cfg.PulseURL)
if pulseURL == "" {
@ -593,10 +601,10 @@ func (u *Updater) validatePulseURL() error {
case "https":
return nil
case "http":
if u.cfg.InsecureSkipVerify || isLoopbackHost(parsed.Hostname()) {
if u.cfg.InsecureSkipVerify || isLoopbackHost(parsed.Hostname()) || isPrivateNetworkHost(parsed.Hostname()) {
return nil
}
return fmt.Errorf("HTTP Pulse URL is only allowed for localhost/loopback or when insecure mode is enabled")
return fmt.Errorf("HTTP Pulse URL is only allowed for localhost/loopback, private networks, or when insecure mode is enabled")
default:
return fmt.Errorf("unsupported Pulse URL scheme %q", parsed.Scheme)
}

View file

@ -26,6 +26,15 @@ import (
"github.com/rs/zerolog/log"
)
// agentAutoRegContextKey marks a request authenticated via agent:report token.
// When set, handleCanonicalAutoRegister restricts to updating existing nodes only.
type agentAutoRegContextKey struct{}
func isAgentAutoRegAuth(ctx context.Context) bool {
v, _ := ctx.Value(agentAutoRegContextKey{}).(bool)
return v
}
// HandleSetupScript serves the setup script for Proxmox/PBS nodes
func (h *ConfigHandlers) handleSetupScript(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
@ -646,6 +655,32 @@ func (h *ConfigHandlers) handleAutoRegister(w http.ResponseWriter, r *http.Reque
h.codeMutex.Unlock()
}
// Fallback: allow agents with a valid agent:report API token to re-register their
// Proxmox node. This is needed when an agent reinstalls — it rotates the PVE token
// but can't fetch a one-time setup token (that endpoint requires settings:write).
// For security this path is restricted to updating existing nodes only.
if !authenticated {
if apiToken, hasToken := explicitAPITokenFromRequest(r); hasToken {
config.Mu.RLock()
cfg := h.getConfig(r.Context())
isValid := cfg != nil && cfg.IsValidAPIToken(apiToken)
config.Mu.RUnlock()
if isValid {
config.Mu.Lock()
record, ok := cfg.ValidateAPIToken(apiToken)
config.Mu.Unlock()
if ok && record != nil && record.HasScope(config.ScopeAgentReport) {
authenticated = true
r = r.WithContext(context.WithValue(r.Context(), agentAutoRegContextKey{}, true))
log.Info().
Str("type", req.Type).
Str("host", req.Host).
Msg("Auto-register authenticated via agent API token (update-only mode)")
}
}
}
}
// Abort when no authentication succeeded. This applies even when API tokens
// are not configured to ensure one-time setup tokens are always required.
if !authenticated {
@ -859,6 +894,12 @@ func (h *ConfigHandlers) handleCanonicalAutoRegister(w http.ResponseWriter, r *h
instance.VerifySSL = pveNode.VerifySSL
log.Info().Str("host", host).Str("type", "pve").Msg(canonicalAutoRegisterMatchMessage("host; updated token in-place"))
} else {
// Agent-token auth is restricted to updating existing nodes only.
if isAgentAutoRegAuth(r.Context()) {
log.Warn().Str("host", host).Str("type", "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
}
if enforceMonitoredSystemLimitForConfigRegistration(w, r.Context(), h.getConfig(r.Context()), h.getMonitor(r.Context()), unifiedresources.MonitoredSystemCandidate{
Source: unifiedresources.SourceProxmox,
Type: unifiedresources.ResourceTypeAgent,
@ -934,6 +975,12 @@ func (h *ConfigHandlers) handleCanonicalAutoRegister(w http.ResponseWriter, r *h
instance.VerifySSL = pbsNode.VerifySSL
log.Info().Str("host", host).Str("type", "pbs").Msg(canonicalAutoRegisterMatchMessage("host; updated token in-place"))
} else {
// Agent-token auth is restricted to updating existing nodes only.
if isAgentAutoRegAuth(r.Context()) {
log.Warn().Str("host", host).Str("type", "pbs").Msg("Agent API token auth rejected for new PBS node registration")
http.Error(w, "agent token auth permits token updates for existing nodes only", http.StatusForbidden)
return
}
if enforceMonitoredSystemLimitForConfigRegistration(w, r.Context(), h.getConfig(r.Context()), h.getMonitor(r.Context()), unifiedresources.MonitoredSystemCandidate{
Source: unifiedresources.SourcePBS,
Type: unifiedresources.ResourceTypePBS,

View file

@ -4869,8 +4869,9 @@ func (r *Router) handleAgentVersion(w http.ResponseWriter, req *http.Request) {
// Return the server version - all agents should match the server version.
// Dev/git builds return "dev" so agents don't enter an infinite update loop
// (the agent skips updates when the server reports "dev").
versionInfo, err := updates.GetCurrentVersion()
version := "dev"
if versionInfo, err := updates.GetCurrentVersion(); err == nil && !versionInfo.IsDevelopment {
if err == nil && !versionInfo.IsDevelopment {
version = versionInfo.Version
}

View file

@ -547,7 +547,17 @@ func (p *ProxmoxSetup) orderedCandidateHosts(ctx context.Context, ptype proxmoxP
func (p *ProxmoxSetup) checkRegistrationWithPulse(ctx context.Context, ptype proxmoxProductType, hostURL string) (bool, error) {
setupToken, err := p.fetchSetupToken(ctx, ptype, hostURL)
if err != nil {
return false, fmt.Errorf("fetch setup token: %w", err)
var ce *clientError
var pe *permanentError
if errors.As(err, &pe) {
return false, fmt.Errorf("fetch setup token: %w", err)
}
if errors.As(err, &ce) {
// Fall through without a setup token; server will authenticate via X-API-Token.
setupToken = ""
} else {
return false, fmt.Errorf("fetch setup token: %w", err)
}
}
payload := autoRegisterRequest{
@ -570,6 +580,9 @@ func (p *ProxmoxSetup) checkRegistrationWithPulse(ctx context.Context, ptype pro
return false, fmt.Errorf("create registration-check request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
if token := strings.TrimSpace(p.apiToken); token != "" {
req.Header.Set("X-API-Token", token)
}
resp, err := p.httpClient.Do(req)
if err != nil {
@ -1143,6 +1156,9 @@ func (p *ProxmoxSetup) doRegisterRequest(ctx context.Context, body []byte, expec
return autoRegisterResponse{}, &permanentError{fmt.Errorf("create request: %w", err)}
}
req.Header.Set("Content-Type", "application/json")
if token := strings.TrimSpace(p.apiToken); token != "" {
req.Header.Set("X-API-Token", token)
}
resp, err := p.httpClient.Do(req)
if err != nil {
@ -1327,15 +1343,26 @@ func (p *ProxmoxSetup) registerWithPulse(ctx context.Context, ptype proxmoxProdu
lastErr = fmt.Errorf("fetch setup token: %w", err)
var ce *clientError
var pe *permanentError
if errors.As(lastErr, &ce) || errors.As(lastErr, &pe) {
if errors.As(lastErr, &pe) {
return autoRegisterResponse{}, lastErr
}
p.logger.Warn().Err(lastErr).
Int("attempt", attempt+1).
Int("max_attempts", len(backoffs)+1).
Str("type", string(ptype)).
Msg("Proxmox setup token fetch failed")
continue
if errors.As(lastErr, &ce) {
// The setup-script-url endpoint requires settings:write scope; agent tokens
// only have agent:report. Fall through without a setup token — the server
// will authenticate via the X-API-Token header instead (update-only path).
p.logger.Info().
Int("statusCode", ce.statusCode).
Str("type", string(ptype)).
Msg("Setup token fetch returned client error; falling back to agent API token auth")
setupToken = ""
} else {
p.logger.Warn().Err(lastErr).
Int("attempt", attempt+1).
Int("max_attempts", len(backoffs)+1).
Str("type", string(ptype)).
Msg("Proxmox setup token fetch failed")
continue
}
}
payload := autoRegisterRequest{