diff --git a/internal/agentupdate/update.go b/internal/agentupdate/update.go index 56ed8ae7e1..4d4159ee60 100644 --- a/internal/agentupdate/update.go +++ b/internal/agentupdate/update.go @@ -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) } diff --git a/internal/api/config_setup_handlers.go b/internal/api/config_setup_handlers.go index eb92f03a33..224b45e054 100644 --- a/internal/api/config_setup_handlers.go +++ b/internal/api/config_setup_handlers.go @@ -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, diff --git a/internal/api/router.go b/internal/api/router.go index 6e20488e05..2e0ecc141f 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -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 } diff --git a/internal/hostagent/proxmox_setup.go b/internal/hostagent/proxmox_setup.go index bfc37dbf15..a78a3db8f3 100644 --- a/internal/hostagent/proxmox_setup.go +++ b/internal/hostagent/proxmox_setup.go @@ -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{