Allow one Proxmox bootstrap per canonical type on combined hosts (#1644)

A host running both PVE and PBS is a deployment the docs call officially
supported, and the agent's RunAll registers each product in turn from the
one install token. The bootstrap grant recorded consumption per TOKEN, so
the PVE leg spent it, the PBS leg came back canRegister=false, the agent
wrote a proxmox-pbs-registration-blocked marker, and install.sh printed an
ERROR banner over a PVE source that had registered perfectly well.

- server: consumption is now recorded per canonical type. One PVE create
  and one PBS create per token, each still one-shot — a second create of
  the same type takes the same 403. The bounds that are not per type stay
  singular: the 24h mint-age clock and the first-use bound_hostname are
  shared, so whichever type registers first pins the hostname for both and
  the second type cannot be aimed at another machine. The per-type ledger
  lives in proxmox_registration_consumed_types; a record carrying only
  proxmox_registration_completed=true predates it and still reads as every
  type consumed, so upgrading cannot revive a token already spent in the
  field. The unsuffixed completion block keeps tracking the most recent
  completion, which also leaves an older binary reading the same store
  failing closed.

- rollback: the consume-before-persist undo is scoped to the keys one
  consumption wrote, so a failed PBS source save restores the PBS grant
  without resurrecting the PVE grant that already produced a source.

- agent: RunAll no longer lets one product's failure speak for the host. It
  attempts and returns the remaining products, errors only when every
  detected product failed, and publishes the detected products in a
  proxmox-detected-types state marker.

- installer: report_proxmox_registration_outcome reads that marker, waits
  for an outcome from each detected product, and prints a success or denial
  line per product instead of one verdict. Agents predating the marker keep
  the old first-outcome-wins timing so a single-product host does not wait
  out the window. The blocked-marker path now only fires on genuine refusals.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
courtmanr@gmail.com 2026-07-28 14:03:13 +01:00
parent 4d972a68ee
commit 72599bd1ec
11 changed files with 1173 additions and 85 deletions

View file

@ -2175,12 +2175,20 @@ Agent` secondary handoff against the live setup wizard instead of relying
startup and periodic registration checks must call `/api/auto-register`
directly; `/api/setup-script-url` remains `settings:write`. Ordinary agent
tokens stay update-only, while a server-minted install token may consume
one initial registration after durable first-host binding and serialized
completion: a PVE/PBS-typed token for its declared type, and a generic
`host` install token for one detected canonical Proxmox type (#1644). A
one initial registration per canonical Proxmox type after durable first-host
binding and serialized completion: a PVE/PBS-typed token for its declared
type, and a generic `host` install token for each detected canonical type
(#1644). A host running both PVE and PBS is an officially supported
deployment, so one install token bootstraps one PVE source and one PBS
source; a second create of a type already consumed still fails closed. A
denied registration grant must fail loudly on the agent side through an
error-level log, a returned setup error, and an installer-readable
`proxmox-<type>-registration-blocked` state marker.
`proxmox-<type>-registration-blocked` state marker. One product's refusal
must not erase another's success: `RunAll` still attempts and returns the
remaining products, publishes the detected products in a
`proxmox-detected-types` state marker, and errors only when every detected
product failed, so the installer reports an outcome per product instead of
one verdict for the whole host.
22. A rejected legacy RBAC import must not destroy the store. The import is
transactional and leaves the legacy files in place, so a failure leaves the
@ -5341,9 +5349,9 @@ one source of the declared type. The server binds that grant to the first
fully validated presenting hostname before local Proxmox credentials are
mutated, serializes checks with completion, persists the completed type,
hostname, selected host, node, and time, and then permanently returns the token
to update-only behavior. A malformed request, wrong type, different hostname,
reused grant, rejected/rotated token, or arbitrary agent token must fail closed
with visible authorization diagnostics.
to update-only behavior for that type. A malformed request, wrong type,
different hostname, reused grant, rejected/rotated token, or arbitrary agent
token must fail closed with visible authorization diagnostics.
A positive registration preflight for an already-existing source consumes the
fresh-install grant without rotating local credentials; a negative preflight
keeps it available for the immediately following initial completion.
@ -5351,15 +5359,36 @@ keeps it available for the immediately following initial completion.
The bootstrap exception covers both install-token shapes the settings-write
control plane actually mints. A PVE/PBS-typed agent-install token stays pinned
to its declared type. A generic `host` install token minted by the Settings →
Infrastructure installer additionally holds the same bounded grant for one
Infrastructure installer additionally holds the same bounded grant for each
canonical Proxmox type, because the unified installer auto-detects PVE/PBS on
the target machine and the agent presents the detected type at registration
(#1644). The host-token grant keeps every existing bound: settings-authorized
mint via `issued_via`, first-presenting-hostname binding before local Proxmox
credentials are mutated, serialized completion, and one-shot consumption — a
host token that completes a PVE bootstrap cannot later bootstrap PBS, and
credentials are mutated, serialized completion, and one-shot consumption — and
non-Proxmox request types never hold a grant.
Consumption is recorded per canonical type, not per token. A host with both PVE
and PBS installed is an officially supported deployment, and the agent's
`RunAll` registers each product in turn from the one install token; recording a
single consumption for the whole token refused the second product and made the
installer print a failure banner over a successful first registration (#1644).
One install token therefore bootstraps one PVE source and one PBS source, each
still one-shot: a second create of an already-consumed type fails closed with
the same 403. The bounds that are not per type stay singular — the 24-hour
mint-age clock and the first-use `bound_hostname` are shared, so whichever type
registers first pins the hostname for both and the second type cannot be
redirected at a different machine.
The consumed types are recorded in `proxmox_registration_consumed_types`, and
its presence is what distinguishes a per-type record from a token spent under
the previous per-token semantics. A record carrying only
`proxmox_registration_completed=true` predates the per-type ledger and means the
whole token was spent, so on upgrade it must be read as every canonical type
consumed; reading it as "one type spent, the other free" would hand a fresh
create to every already-used install token in the field. The unsuffixed
completion block continues to describe the most recent completion so an older
Pulse binary reading the same token store also fails closed.
The grant is time-bounded independently of the token that carries it. Install
tokens are minted without an expiry because the agent reports with them for the
life of the install, so the bootstrap grant carries its own 24-hour clock from
@ -5375,7 +5404,9 @@ failed source save rolls the consumption back. The opposite order leaves a
persistently failing token store able to write a source while the grant stays
live, which is a repeatable create-a-source primitive under one install token.
Either the source is persisted and the grant is spent, or neither happened and
the install can be retried.
the install can be retried. The rollback is scoped to the type being consumed:
on a combined host a failed PBS save restores the PBS grant and must not
resurrect the PVE grant that already produced a persisted source.
A host install token that has already auto-registered its Proxmox source
carries a `bound_hostname` written by the registration bootstrap, with no
@ -5395,6 +5426,19 @@ marker is cleared once a later run finds the source registered or proceeds to
register. The shell installer reads that marker after agent health
verification and surfaces the denial with remediation in its own output, so
the failure is visible at install time instead of buried in the agent journal.
With per-type grants that path is reserved for genuine refusals: the second
product of a combined host is no longer denied for having spent the token's
only grant.
The installer's verdict is per product, not per host. `RunAll` publishes the
products it detected in a `proxmox-detected-types` state marker, and the
installer waits for an outcome from each of those products before reporting,
then prints a success or a denial line for each one. Without that list a
combined host reported whichever marker appeared first, which produced either a
premature success or an error banner that buried a completed registration.
Agents that predate the marker keep the previous first-outcome-wins timing, so
a PVE-only host never waits out the timeout for a PBS outcome that will not
arrive.
Token-optional installations retain the setup-token bootstrap path. Ordinary
hosts never enter this Proxmox registration loop, while PVE, PBS, mixed-product

View file

@ -1626,8 +1626,16 @@ payload shape change when the portal presents compact client rows.
(#1644), because the Settings → Infrastructure installer mints `host`
tokens while the unified installer auto-detects PVE/PBS on the target.
Non-canonical request types never hold the grant, and the grant keeps its
settings-authorized `issued_via` requirement, first-hostname binding,
serialized completion, and single consumption across types.
settings-authorized `issued_via` requirement, first-hostname binding, and
serialized completion. Consumption is recorded per canonical type: one PVE
create and one PBS create per token, each still one-shot, because a host
running both products is an officially supported deployment and the agent
registers each one separately from the same install token (#1644). The
hostname binding and the mint-age clock are not per type — the first type to
register pins `bound_hostname` for both. `proxmox_registration_consumed_types`
carries the per-type ledger; a record with only
`proxmox_registration_completed=true` predates it and reads as every
canonical type consumed, so upgrading cannot revive a spent token.
That grant is also time-bounded on its own clock: install tokens are minted
without an expiry, so the bootstrap capability expires 24 hours after mint
(`install_issued_at`, falling back to the record's creation time, and fails
@ -1637,7 +1645,9 @@ payload shape change when the portal presents compact client rows.
Consumption ordering is part of the contract: the grant is consumed and
persisted before the created source is written, and a failed source save
rolls the consumption back, so a failing token store can never leave a
persisted source next to a live grant.
persisted source next to a live grant. The rollback restores only the type
being consumed, so a failed second-product save on a combined host cannot
revive the first product's already-spent grant.
The exec binding contract follows from that grant: a `bound_hostname`
written by auto-register (no `bound_agent_id`, no binding version) is a
clean first use of the command channel, so a matching hostname binds the
@ -8519,9 +8529,13 @@ credentials on a matching existing PVE/PBS source. Only an unused install token
minted through the config or hosted agent-install command may create a new
source, and only when its server-authored `install_type` matches. First use
binds the token after request validation to the presenting hostname; successful
serialized completion persists consumption and registration identity metadata.
Malformed requests do not bind the grant. Reuse, cross-type or cross-host use,
and ordinary agent tokens return `403` for a missing source.
serialized completion persists consumption and registration identity metadata
for the canonical type that was created. Malformed requests do not bind the
grant. Reuse of an already-consumed type, cross-type use by a type-pinned
token, cross-host use, and ordinary agent tokens return `403` for a missing
source. A generic `host` install token gets one create per canonical type, so
the PBS leg of a combined PVE+PBS host succeeds while a second create of either
type does not.
Authentication failure at the canonical endpoint remains a warning/error
diagnostic rather than being suppressed as an expected setup-artifact denial.

View file

@ -313,8 +313,19 @@ TLS floor in the dynamic config.
window on the agent state directory, surfaces the reason recorded in a
`proxmox-<type>-registration-blocked` marker as an installer error with
remediation, reports success on a `proxmox-<type>-registered` marker, and
clears both marker families when resetting Proxmox state for a fresh
clears those marker families when resetting Proxmox state for a fresh
registration.
That verdict is per Proxmox product, not per host. A machine running both
PVE and PBS registers each product separately and now holds one bootstrap
grant per canonical type (#1644), so the installer must read the agent's
`proxmox-detected-types` marker, wait for an outcome from every product it
names, and print a success or denial line for each. It must not let the
first marker it finds decide the whole install: a blocked second product
cannot suppress the first product's confirmed registration, and a confirmed
first product cannot mask the second's denial. Agents that predate the
detected-types marker keep the previous first-outcome-wins timing so a
single-product host does not wait out the full window, and the reset path
clears the detected-types marker alongside the rest.
Existing-agent update commands copied from the settings UI must use the
installer-owned `--update` mode rather than serializing a fresh enrollment
token into platform notice links. In `--update` mode, `scripts/install.sh`

View file

@ -4727,11 +4727,15 @@ public-demo bootstrap signal instead of inferring demo posture from headers,
The shared `internal/api/config_setup_handlers.go` Proxmox registration fix is
adjacent lifecycle/API authority only. Runtime agents now use
`/api/auto-register` directly, ordinary agent tokens remain update-only, and a
server-minted Proxmox install token may consume one type- and host-bound initial
source grant. This does not grant backup, snapshot, restore, retention,
replication, datastore-management, or recovery-policy authority. The
`settings:write` boundary on setup-artifact generation and all existing
storage/recovery permission and evidence contracts remain unchanged.
server-minted Proxmox install token may consume one host-bound initial source
grant per canonical Proxmox type, so a combined PVE+PBS machine registers both
products from one install token (#1644). Widening that grant from one create
per token to one create per type does not widen it past source registration:
it still grants backup, snapshot, restore, retention, replication,
datastore-management, and recovery-policy authority not at all, and the second
create is bound to the same hostname and the same 24-hour mint-age window as
the first. The `settings:write` boundary on setup-artifact generation and all
existing storage/recovery permission and evidence contracts remain unchanged.
That same adjacent platform-connections boundary now also assumes direct
TrueNAS and VMware connection writes fail closed while canonical
monitored-system usage is unavailable. Storage and recovery may depend on the

View file

@ -1539,3 +1539,112 @@ func TestExtractHostIP(t *testing.T) {
})
}
}
// TestHandleAutoRegisterInstallGrantIsPerCanonicalType pins the wire contract
// for #1644 on a combined PVE+PBS host: the install token's source-creation
// grant is spent per canonical type, so the PBS leg still answers
// canRegister=true and completes after the PVE leg consumed its own grant,
// while a repeat of either type is refused. Consumption used to be recorded per
// token, which turned the second product of a supported deployment into a 403
// and an installer error banner over a successful first registration.
func TestHandleAutoRegisterInstallGrantIsPerCanonicalType(t *testing.T) {
stubAutoRegisterNetworkDeps(t)
rawToken := "install-per-type-grant.12345678"
record := newTokenRecord(t, rawToken, []string{config.ScopeAgentReport}, map[string]string{
"install_type": agentInstallTypeHost,
"issued_via": agentInstallIssuedViaConfig,
})
cfg := &config.Config{
DataPath: t.TempDir(),
APITokens: []config.APITokenRecord{record},
}
handler := newTestConfigHandlers(t, cfg)
for _, leg := range []struct {
nodeType string
host string
tokenID string
}{
{nodeType: "pve", host: "https://per-type.local:8006", tokenID: "pulse-monitor@pve!pulse-per-type"},
{nodeType: "pbs", host: "https://per-type.local:8007", tokenID: "pulse-monitor@pbs!pulse-per-type"},
} {
checkRec := runAgentAutoRegister(t, handler, rawToken, AutoRegisterRequest{
Type: leg.nodeType,
Host: leg.host,
ServerName: "per-type",
Source: "agent",
CheckRegistration: true,
})
if checkRec.Code != http.StatusOK {
t.Fatalf("%s registration check status = %d, body=%s", leg.nodeType, checkRec.Code, checkRec.Body.String())
}
var check autoRegisterCheckResponse
if err := json.Unmarshal(checkRec.Body.Bytes(), &check); err != nil {
t.Fatalf("decode %s registration check: %v", leg.nodeType, err)
}
if !check.CanRegister {
t.Fatalf("%s leg reported canRegister=false; the per-type grant was not available", leg.nodeType)
}
registerRec := runAgentAutoRegister(t, handler, rawToken, AutoRegisterRequest{
Type: leg.nodeType,
Host: leg.host,
TokenID: leg.tokenID,
TokenValue: "proxmox-secret-" + leg.nodeType,
ServerName: "per-type",
Source: "agent",
})
if registerRec.Code != http.StatusOK {
t.Fatalf("%s registration status = %d, body=%s", leg.nodeType, registerRec.Code, registerRec.Body.String())
}
}
if len(cfg.PVEInstances) != 1 || len(cfg.PBSInstances) != 1 {
t.Fatalf("combined host sources = %d PVE / %d PBS, want 1 each", len(cfg.PVEInstances), len(cfg.PBSInstances))
}
// Both grants are now spent. A third create of either type is refused, and
// the read-only check reports the refusal before the agent touches local
// Proxmox credentials.
for _, nodeType := range []string{"pve", "pbs"} {
port := "8006"
if nodeType == "pbs" {
port = "8007"
}
host := "https://per-type-replay.local:" + port
checkRec := runAgentAutoRegister(t, handler, rawToken, AutoRegisterRequest{
Type: nodeType,
Host: host,
ServerName: "per-type",
Source: "agent",
CheckRegistration: true,
})
if checkRec.Code != http.StatusOK {
t.Fatalf("%s replay check status = %d, body=%s", nodeType, checkRec.Code, checkRec.Body.String())
}
var check autoRegisterCheckResponse
if err := json.Unmarshal(checkRec.Body.Bytes(), &check); err != nil {
t.Fatalf("decode %s replay check: %v", nodeType, err)
}
if check.CanRegister {
t.Fatalf("%s replay check reported canRegister=true for a spent grant", nodeType)
}
replayRec := runAgentAutoRegister(t, handler, rawToken, AutoRegisterRequest{
Type: nodeType,
Host: host,
TokenID: "pulse-monitor@" + nodeType + "!pulse-per-type-replay",
TokenValue: "proxmox-secret-replay",
ServerName: "per-type",
Source: "agent",
})
if replayRec.Code != http.StatusForbidden {
t.Fatalf("%s replay status = %d, want 403; body=%s", nodeType, replayRec.Code, replayRec.Body.String())
}
}
if len(cfg.PVEInstances) != 1 || len(cfg.PBSInstances) != 1 {
t.Fatalf("spent grants created extra sources: %d PVE / %d PBS", len(cfg.PVEInstances), len(cfg.PBSInstances))
}
}

View file

@ -30,8 +30,9 @@ import (
// 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 one bounded, one-time grant
// to create the declared PVE/PBS source.
// 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 {
@ -56,12 +57,90 @@ const (
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
@ -106,9 +185,12 @@ func proxmoxInstallGrantExpiredAt(record *config.APITokenRecord, now time.Time)
// 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 the token is still bounded by the
// same settings:write mint requirement, one-shot completion marker, and
// first-use hostname binding as a typed token.
// 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
@ -124,7 +206,7 @@ func proxmoxInstallGrantEligible(record *config.APITokenRecord, req *AutoRegiste
if record == nil || req == nil || strings.TrimSpace(req.Source) != "agent" {
return false
}
if strings.EqualFold(strings.TrimSpace(record.Metadata[proxmoxInstallRegistrationCompletedKey]), "true") {
if proxmoxInstallRegistrationConsumedForType(record, req.Type) {
return false
}
if !installTokenTypeGrantsBootstrap(strings.TrimSpace(record.Metadata["install_type"]), strings.TrimSpace(req.Type)) {
@ -160,16 +242,35 @@ func canBootstrapProxmoxInstallRegistrationAt(record *config.APITokenRecord, req
return proxmoxInstallGrantEligible(record, req) && !proxmoxInstallGrantExpiredAt(record, now)
}
// 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 ||
!strings.EqualFold(strings.TrimSpace(record.Metadata[proxmoxInstallRegistrationCompletedKey]), "true") {
if record == nil || req == nil {
return false
}
return strings.TrimSpace(record.Metadata[proxmoxInstallRegistrationTypeKey]) == strings.TrimSpace(req.Type) &&
strings.EqualFold(
strings.TrimSpace(record.Metadata[proxmoxInstallRegistrationHostnameKey]),
strings.TrimSpace(req.ServerName),
)
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
@ -245,12 +346,86 @@ func (h *ConfigHandlers) proxmoxInstallBootstrapAvailable(ctx context.Context, r
// 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
previousMetadata map[string]string
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) == "" {
@ -276,30 +451,14 @@ func (h *ConfigHandlers) completeProxmoxInstallBootstrap(ctx context.Context, re
if !canBootstrapProxmoxInstallRegistration(record, req) {
return nil, fmt.Errorf("Proxmox install registration grant is no longer available")
}
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[proxmoxInstallRegistrationCompletedKey] = "true"
record.Metadata[proxmoxInstallRegistrationTypeKey] = strings.TrimSpace(req.Type)
record.Metadata[proxmoxInstallRegistrationHostnameKey] = strings.TrimSpace(req.ServerName)
record.Metadata[proxmoxInstallRegistrationHostKey] = strings.TrimSpace(host)
record.Metadata[proxmoxInstallRegistrationNodeKey] = strings.TrimSpace(node)
record.Metadata[proxmoxInstallRegistrationAtKey] = time.Now().UTC().Format(time.RFC3339)
consumption := recordProxmoxInstallBootstrapConsumption(record, req, host, node)
if persistence != nil {
if err := persistence.SaveAPITokens(cfg.APITokens); err != nil {
record.Metadata = previousMetadata
consumption.restore(record.Metadata)
return nil, fmt.Errorf("persist Proxmox install registration grant: %w", err)
}
}
return &proxmoxInstallBootstrapConsumption{
tokenID: record.ID,
previousMetadata: previousMetadata,
consumed: true,
}, nil
return consumption, nil
}
return nil, fmt.Errorf("Proxmox install token no longer exists")
}
@ -307,7 +466,9 @@ func (h *ConfigHandlers) completeProxmoxInstallBootstrap(ctx context.Context, re
// 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.
// 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
@ -326,22 +487,23 @@ func (h *ConfigHandlers) rollbackProxmoxInstallBootstrap(ctx context.Context, co
if record.ID != consumption.tokenID {
continue
}
restored := make(map[string]string, len(consumption.previousMetadata))
for key, value := range consumption.previousMetadata {
restored[key] = value
if record.Metadata == nil {
record.Metadata = make(map[string]string)
}
record.Metadata = restored
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
}

View file

@ -18,7 +18,34 @@ import (
// install tokens, while install.sh auto-detects Proxmox on the target machine
// and presents type "pve"/"pbs" to /api/auto-register. The bootstrap grant
// must accept a host-issued install token for a canonical Proxmox type, while
// staying one-shot and hostname-bound.
// staying one-shot per type and hostname-bound.
//
// A host with both PVE and PBS installed is an officially supported deployment
// and the agent's RunAll registers each product in turn from the one install
// token, so the grant is consumed per canonical type: one PVE create and one
// PBS create, each still one-shot, both under the token's single mint-age TTL
// and its single first-use hostname binding.
// assertProxmoxGrantConsumedTypes fails unless the token's consumed-type ledger
// is exactly want.
func assertProxmoxGrantConsumedTypes(t *testing.T, record *config.APITokenRecord, want ...string) {
t.Helper()
consumed, tracked := proxmoxInstallRegistrationConsumedTypeSet(record)
if !tracked && len(want) > 0 {
t.Fatalf("token has no per-type consumption ledger (%s=%q); want %v",
proxmoxInstallRegistrationConsumedTypesKey,
record.Metadata[proxmoxInstallRegistrationConsumedTypesKey],
want)
}
if len(consumed) != len(want) {
t.Fatalf("consumed types = %v, want %v", consumed, want)
}
for _, nodeType := range want {
if _, ok := consumed[nodeType]; !ok {
t.Fatalf("consumed types = %v, want %v", consumed, want)
}
}
}
func mintHostInstallToken(t *testing.T, handler *ConfigHandlers) string {
t.Helper()
@ -97,23 +124,342 @@ func TestIssue1644HostInstallTokenBootstrapsDetectedProxmoxSource(t *testing.T)
if !strings.EqualFold(cfg.APITokens[0].Metadata[proxmoxInstallRegistrationCompletedKey], "true") {
t.Fatal("host-token pve registration did not consume the install grant")
}
assertProxmoxGrantConsumedTypes(t, &cfg.APITokens[0], "pve")
// The grant stays one-shot: after the pve bootstrap completes, the same
// token cannot bootstrap another source of any type.
// The pve grant is spent: the same token cannot create a second pve source.
reuseRec := runAgentAutoRegister(t, handler, rawToken, AutoRegisterRequest{
Type: "pbs",
Host: "https://pbs-host.local:8007",
TokenID: "pulse-monitor@pbs!pulse-pbs-host",
Type: "pve",
Host: "https://pve-second.local:8006",
TokenID: "pulse-monitor@pve!pulse-pve-second",
TokenValue: "other-proxmox-secret",
ServerName: "pve-host",
Source: "agent",
})
if reuseRec.Code != http.StatusForbidden {
t.Fatalf("consumed host-token grant reuse status = %d, want 403; body=%s", reuseRec.Code, reuseRec.Body.String())
t.Fatalf("consumed pve grant reuse status = %d, want 403; body=%s", reuseRec.Code, reuseRec.Body.String())
}
if len(cfg.PVEInstances) != 1 {
t.Fatalf("PVE instances = %d, want 1 after the consumed pve grant was refused", len(cfg.PVEInstances))
}
}
// TestIssue1644CombinedProxmoxHostRegistersBothTypes covers the deployment the
// docs call officially supported: PVE and PBS on one machine, discovered by the
// agent's RunAll and registered in sequence from a single host install token.
// Consumption used to be recorded per token, so the PBS leg was refused and the
// installer printed an ERROR banner over a perfectly good PVE registration.
func TestIssue1644CombinedProxmoxHostRegistersBothTypes(t *testing.T) {
stubAutoRegisterNetworkDeps(t)
cfg := &config.Config{
DataPath: t.TempDir(),
AuthUser: "admin",
AuthPass: "hashed-password",
}
handler := newTestConfigHandlers(t, cfg)
rawToken := mintHostInstallToken(t, handler)
pveRec := runAgentAutoRegister(t, handler, rawToken, AutoRegisterRequest{
Type: "pve",
Host: "https://combined.local:8006",
TokenID: "pulse-monitor@pve!pulse-combined",
TokenValue: "pve-secret",
ServerName: "combined",
Source: "agent",
})
if pveRec.Code != http.StatusOK {
t.Fatalf("combined-host pve registration status = %d, body=%s", pveRec.Code, pveRec.Body.String())
}
assertProxmoxGrantConsumedTypes(t, &cfg.APITokens[0], "pve")
// The agent's second RunAll leg checks first, exactly as runForType does.
checkRec := runAgentAutoRegister(t, handler, rawToken, AutoRegisterRequest{
Type: "pbs",
Host: "https://combined.local:8007",
ServerName: "combined",
Source: "agent",
CheckRegistration: true,
})
if checkRec.Code != http.StatusOK {
t.Fatalf("combined-host pbs check status = %d, body=%s", checkRec.Code, checkRec.Body.String())
}
var check autoRegisterCheckResponse
if err := json.Unmarshal(checkRec.Body.Bytes(), &check); err != nil {
t.Fatalf("decode pbs registration check: %v", err)
}
if !check.CanRegister {
t.Fatal("pbs leg of a combined host reported canRegister=false after the pve leg consumed its grant")
}
pbsRec := runAgentAutoRegister(t, handler, rawToken, AutoRegisterRequest{
Type: "pbs",
Host: "https://combined.local:8007",
TokenID: "pulse-monitor@pbs!pulse-combined",
TokenValue: "pbs-secret",
ServerName: "combined",
Source: "agent",
})
if pbsRec.Code != http.StatusOK {
t.Fatalf("combined-host pbs registration status = %d, body=%s", pbsRec.Code, pbsRec.Body.String())
}
if len(cfg.PVEInstances) != 1 || len(cfg.PBSInstances) != 1 {
t.Fatalf("combined host sources = %d PVE / %d PBS, want 1 each", len(cfg.PVEInstances), len(cfg.PBSInstances))
}
assertProxmoxGrantConsumedTypes(t, &cfg.APITokens[0], "pve", "pbs")
// Both per-type completion blocks survive; neither leg overwrote the other.
metadata := cfg.APITokens[0].Metadata
for nodeType, wantHost := range map[string]string{
"pve": "https://combined.local:8006",
"pbs": "https://combined.local:8007",
} {
key := proxmoxInstallRegistrationTypedKey(proxmoxInstallRegistrationHostKey, nodeType)
if got := metadata[key]; got != wantHost {
t.Fatalf("%s = %q, want %q", key, got, wantHost)
}
}
// Every grant is now spent: neither type can create a second source.
for _, spent := range []AutoRegisterRequest{
{
Type: "pve", Host: "https://combined-2.local:8006",
TokenID: "pulse-monitor@pve!pulse-combined-2", TokenValue: "pve-secret-2",
ServerName: "combined", Source: "agent",
},
{
Type: "pbs", Host: "https://combined-2.local:8007",
TokenID: "pulse-monitor@pbs!pulse-combined-2", TokenValue: "pbs-secret-2",
ServerName: "combined", Source: "agent",
},
} {
rec := runAgentAutoRegister(t, handler, rawToken, spent)
if rec.Code != http.StatusForbidden {
t.Fatalf("second %s create status = %d, want 403; body=%s", spent.Type, rec.Code, rec.Body.String())
}
}
if len(cfg.PVEInstances) != 1 || len(cfg.PBSInstances) != 1 {
t.Fatalf("spent grants created extra sources: %d PVE / %d PBS", len(cfg.PVEInstances), len(cfg.PBSInstances))
}
}
// TestIssue1644PerTypeGrantKeepsSingleHostnameBinding pins that per-type
// consumption did not turn one hostname binding into two. The first type to
// register pins the hostname for the whole token, so the second type cannot be
// redirected at a different machine.
func TestIssue1644PerTypeGrantKeepsSingleHostnameBinding(t *testing.T) {
stubAutoRegisterNetworkDeps(t)
cfg := &config.Config{
DataPath: t.TempDir(),
AuthUser: "admin",
AuthPass: "hashed-password",
}
handler := newTestConfigHandlers(t, cfg)
rawToken := mintHostInstallToken(t, handler)
pveRec := runAgentAutoRegister(t, handler, rawToken, AutoRegisterRequest{
Type: "pve",
Host: "https://bound-combined.local:8006",
TokenID: "pulse-monitor@pve!pulse-bound-combined",
TokenValue: "pve-secret",
ServerName: "bound-combined",
Source: "agent",
})
if pveRec.Code != http.StatusOK {
t.Fatalf("pve registration status = %d, body=%s", pveRec.Code, pveRec.Body.String())
}
if got := cfg.APITokens[0].Metadata["bound_hostname"]; got != "bound-combined" {
t.Fatalf("bound hostname = %q, want %q", got, "bound-combined")
}
otherHostRec := runAgentAutoRegister(t, handler, rawToken, AutoRegisterRequest{
Type: "pbs",
Host: "https://elsewhere.local:8007",
TokenID: "pulse-monitor@pbs!pulse-elsewhere",
TokenValue: "pbs-secret",
ServerName: "elsewhere",
Source: "agent",
})
if otherHostRec.Code != http.StatusForbidden {
t.Fatalf("pbs grant on a different hostname status = %d, want 403; body=%s", otherHostRec.Code, otherHostRec.Body.String())
}
if len(cfg.PBSInstances) != 0 {
t.Fatalf("PBS instances = %d, want 0 after consumed grant", len(cfg.PBSInstances))
t.Fatalf("PBS instances = %d, want 0 after a cross-hostname refusal", len(cfg.PBSInstances))
}
if got := cfg.APITokens[0].Metadata["bound_hostname"]; got != "bound-combined" {
t.Fatalf("bound hostname after refusal = %q, want %q", got, "bound-combined")
}
// The pbs grant is live, just pinned to the bound host — proving the
// refusal above came from the binding and not from a spent grant.
boundRec := runAgentAutoRegister(t, handler, rawToken, AutoRegisterRequest{
Type: "pbs",
Host: "https://bound-combined.local:8007",
TokenID: "pulse-monitor@pbs!pulse-bound-combined",
TokenValue: "pbs-secret",
ServerName: "bound-combined",
Source: "agent",
})
if boundRec.Code != http.StatusOK {
t.Fatalf("pbs registration on the bound host status = %d, body=%s", boundRec.Code, boundRec.Body.String())
}
assertProxmoxGrantConsumedTypes(t, &cfg.APITokens[0], "pve", "pbs")
}
// TestIssue1644LegacyCompletionMarkerConsumesEveryType covers upgrade: a token
// spent under the old per-token semantics carries only
// proxmox_registration_completed=true. Reading that as "pve is spent, pbs is
// free" would hand every already-used install token in the field a fresh
// create, so the bare marker still means the whole token is spent.
func TestIssue1644LegacyCompletionMarkerConsumesEveryType(t *testing.T) {
stubAutoRegisterNetworkDeps(t)
rawToken := "issue-1644-legacy-consumed.12345678"
record := newTokenRecord(t, rawToken, []string{config.ScopeAgentReport}, map[string]string{
"install_type": agentInstallTypeHost,
"issued_via": agentInstallIssuedViaConfig,
"bound_hostname": "legacy-host",
proxmoxInstallRegistrationCompletedKey: "true",
proxmoxInstallRegistrationTypeKey: "pve",
proxmoxInstallRegistrationHostnameKey: "legacy-host",
})
if got := record.Metadata[proxmoxInstallRegistrationConsumedTypesKey]; got != "" {
t.Fatalf("legacy fixture carries a per-type ledger (%q); the upgrade assertion would be vacuous", got)
}
for _, nodeType := range []string{"pve", "pbs"} {
req := &AutoRegisterRequest{
Type: nodeType,
Host: "https://legacy-host.local:8006",
ServerName: "legacy-host",
Source: "agent",
}
if canBootstrapProxmoxInstallRegistration(&record, req) {
t.Fatalf("legacy completed token still authorized a %s bootstrap", nodeType)
}
}
cfg := &config.Config{
DataPath: t.TempDir(),
APITokens: []config.APITokenRecord{record},
}
handler := newTestConfigHandlers(t, cfg)
rec := runAgentAutoRegister(t, handler, rawToken, AutoRegisterRequest{
Type: "pbs",
Host: "https://legacy-host.local:8007",
TokenID: "pulse-monitor@pbs!pulse-legacy-host",
TokenValue: "pbs-secret",
ServerName: "legacy-host",
Source: "agent",
})
if rec.Code != http.StatusForbidden {
t.Fatalf("legacy consumed token pbs registration status = %d, want 403; body=%s", rec.Code, rec.Body.String())
}
if len(cfg.PBSInstances) != 0 {
t.Fatalf("PBS instances = %d, want 0 for a legacy-consumed token", len(cfg.PBSInstances))
}
}
// TestIssue1644FailedSourceSaveRollsBackOnlyTheConsumedType pins that the
// consume-before-persist rollback is scoped to the type being consumed. A PBS
// save failure must put the PBS grant back without resurrecting the PVE grant
// that already produced a persisted source.
func TestIssue1644FailedSourceSaveRollsBackOnlyTheConsumedType(t *testing.T) {
stubAutoRegisterNetworkDeps(t)
dataPath := t.TempDir()
cfg := &config.Config{
DataPath: dataPath,
AuthUser: "admin",
AuthPass: "hashed-password",
}
handler := newTestConfigHandlers(t, cfg)
rawToken := mintHostInstallToken(t, handler)
pveRec := runAgentAutoRegister(t, handler, rawToken, AutoRegisterRequest{
Type: "pve",
Host: "https://split-rollback.local:8006",
TokenID: "pulse-monitor@pve!pulse-split-rollback",
TokenValue: "pve-secret",
ServerName: "split-rollback",
Source: "agent",
})
if pveRec.Code != http.StatusOK {
t.Fatalf("pve registration status = %d, body=%s", pveRec.Code, pveRec.Body.String())
}
assertProxmoxGrantConsumedTypes(t, &cfg.APITokens[0], "pve")
// Block only the nodes store so the pbs source cannot be persisted.
nodesFile := filepath.Join(dataPath, "nodes.enc")
if err := os.RemoveAll(nodesFile); err != nil {
t.Fatalf("clear nodes config: %v", err)
}
if err := os.MkdirAll(nodesFile, 0o755); err != nil {
t.Fatalf("block nodes config persistence: %v", err)
}
pbsRec := runAgentAutoRegister(t, handler, rawToken, AutoRegisterRequest{
Type: "pbs",
Host: "https://split-rollback.local:8007",
TokenID: "pulse-monitor@pbs!pulse-split-rollback",
TokenValue: "pbs-secret",
ServerName: "split-rollback",
Source: "agent",
})
if pbsRec.Code != http.StatusInternalServerError {
t.Fatalf("blocked pbs source save status = %d, want 500; body=%s", pbsRec.Code, pbsRec.Body.String())
}
// The pbs grant came back; the pve grant stayed spent.
assertProxmoxGrantConsumedTypes(t, &cfg.APITokens[0], "pve")
if got := cfg.APITokens[0].Metadata[proxmoxInstallRegistrationTypedKey(proxmoxInstallRegistrationHostKey, "pbs")]; got != "" {
t.Fatalf("rolled-back pbs consumption left completion detail %q behind", got)
}
if got := cfg.APITokens[0].Metadata[proxmoxInstallRegistrationTypedKey(proxmoxInstallRegistrationHostKey, "pve")]; got != "https://split-rollback.local:8006" {
t.Fatalf("pbs rollback disturbed the pve completion detail: %q", got)
}
if got := cfg.APITokens[0].Metadata[proxmoxInstallRegistrationTypeKey]; got != "pve" {
t.Fatalf("pbs rollback left the latest-completion block on %q, want the restored pve block", got)
}
persisted, err := config.NewConfigPersistence(dataPath).LoadAPITokens()
if err != nil {
t.Fatalf("load persisted API tokens: %v", err)
}
if len(persisted) != 1 {
t.Fatalf("persisted API tokens = %d, want 1", len(persisted))
}
assertProxmoxGrantConsumedTypes(t, &persisted[0], "pve")
// A second pve create is still refused: the rollback restored one type.
replayRec := runAgentAutoRegister(t, handler, rawToken, AutoRegisterRequest{
Type: "pve",
Host: "https://split-rollback-2.local:8006",
TokenID: "pulse-monitor@pve!pulse-split-rollback-2",
TokenValue: "pve-secret-2",
ServerName: "split-rollback",
Source: "agent",
})
if replayRec.Code != http.StatusForbidden {
t.Fatalf("pve replay after a pbs rollback status = %d, want 403; body=%s", replayRec.Code, replayRec.Body.String())
}
// With the nodes store working again the restored pbs grant completes once.
if err := os.Remove(nodesFile); err != nil {
t.Fatalf("unblock nodes config persistence: %v", err)
}
retryRec := runAgentAutoRegister(t, handler, rawToken, AutoRegisterRequest{
Type: "pbs",
Host: "https://split-rollback.local:8007",
TokenID: "pulse-monitor@pbs!pulse-split-rollback",
TokenValue: "pbs-secret",
ServerName: "split-rollback",
Source: "agent",
})
if retryRec.Code != http.StatusOK {
t.Fatalf("pbs retry after restored persistence status = %d, body=%s", retryRec.Code, retryRec.Body.String())
}
assertProxmoxGrantConsumedTypes(t, &cfg.APITokens[0], "pve", "pbs")
}
func TestIssue1644HostInstallTokenGrantStaysHostnameBound(t *testing.T) {

View file

@ -329,6 +329,40 @@ func (p *ProxmoxSetup) registrationBlockedFilePath(ptype proxmoxProductType) str
return filepath.Join(p.stateDir, fmt.Sprintf("proxmox-%s-registration-blocked", ptype))
}
// detectedTypesFilePath names the marker RunAll writes listing the canonical
// Proxmox products it is registering on this host, one per line.
//
// Without it the installer cannot tell a PVE-only host from a combined PVE+PBS
// host, so it has to report the first outcome it sees and call the install
// done. On a combined host — an officially supported deployment that now
// consumes one bootstrap grant per type — that produced a partial verdict:
// either a success banner before PBS had landed, or an error banner for PBS
// that buried a perfectly good PVE registration (#1644).
func (p *ProxmoxSetup) detectedTypesFilePath() string {
return filepath.Join(p.stateDir, "proxmox-detected-types")
}
// writeDetectedTypesMarker publishes the types RunAll is about to register so
// the installer waits for an outcome from each of them, and only from them.
func (p *ProxmoxSetup) writeDetectedTypesMarker(types []proxmoxProductType) {
if len(types) == 0 {
return
}
if err := p.collector.MkdirAll(p.stateDir, proxmoxStateDirPerm); err != nil {
p.logger.Debug().Err(err).Str("dir", p.stateDir).Msg("Failed to create state dir for detected-types marker")
return
}
contents := strings.Join(proxmoxProductTypesToStrings(types), "\n") + "\n"
path := p.detectedTypesFilePath()
if err := p.collector.WriteFile(path, []byte(contents), proxmoxStateFilePerm); err != nil {
p.logger.Debug().Err(err).Msg("Failed to write detected-types marker")
return
}
if err := p.collector.Chmod(path, proxmoxStateFilePerm); err != nil {
p.logger.Debug().Err(err).Msg("Failed to enforce detected-types marker permissions")
}
}
func (p *ProxmoxSetup) writeRegistrationBlockedMarker(ptype proxmoxProductType, reason string) {
if err := os.MkdirAll(p.stateDir, proxmoxStateDirPerm); err != nil {
p.logger.Debug().Err(err).Str("dir", p.stateDir).Msg("Failed to create state dir for registration-blocked marker")
@ -442,8 +476,15 @@ func (p *ProxmoxSetup) Run(ctx context.Context) (*ProxmoxSetupResult, error) {
// RunAll detects and registers ALL Proxmox products on this system.
// This supports hosts with both PVE and PBS installed (a common and officially
// supported configuration). Each type gets its own registration and state tracking.
// supported configuration). Each type gets its own registration and state tracking,
// and the server grants one bootstrap create per canonical type, so a combined
// host registers both from a single install token (#1644).
// Returns results for all types that were processed (skipping already-registered ones).
//
// One type failing does not abandon the others: the remaining types are still
// attempted and their results returned. An error comes back only when every
// detected type failed, so a fully failed Proxmox setup is still loud while a
// partial one leaves the successful registrations reportable.
func (p *ProxmoxSetup) RunAll(ctx context.Context) ([]*ProxmoxSetupResult, error) {
pulseURL, err := agenttarget.NormalizePulseURL(p.pulseURL, p.authoritative, p.allowPlaintextHTTP)
if err != nil {
@ -461,6 +502,7 @@ func (p *ProxmoxSetup) RunAll(ctx context.Context) ([]*ProxmoxSetupResult, error
// If a specific type is forced, only run that
if forcedType != "" {
p.writeDetectedTypesMarker([]proxmoxProductType{forcedType})
result, err := p.runForType(ctx, forcedType)
if err != nil {
return nil, fmt.Errorf("setup proxmox type %s: %w", p.proxmoxType, err)
@ -478,12 +520,17 @@ func (p *ProxmoxSetup) RunAll(ctx context.Context) ([]*ProxmoxSetupResult, error
}
p.logger.Info().Strs("types", proxmoxProductTypesToStrings(types)).Msg("Auto-detected Proxmox products")
p.writeDetectedTypesMarker(types)
// Register each type
var failures []error
var failedTypes []string
for _, ptype := range types {
result, err := p.runForType(ctx, ptype)
if err != nil {
p.logger.Error().Err(err).Str("type", string(ptype)).Msg("Failed to setup Proxmox type")
failures = append(failures, fmt.Errorf("setup proxmox type %s: %w", ptype, err))
failedTypes = append(failedTypes, string(ptype))
continue // Don't fail completely, try other types
}
if result != nil {
@ -491,6 +538,16 @@ func (p *ProxmoxSetup) RunAll(ctx context.Context) ([]*ProxmoxSetupResult, error
}
}
if len(failures) == len(types) {
return results, errors.Join(failures...)
}
if len(failures) > 0 {
p.logger.Warn().
Strs("failed_types", failedTypes).
Strs("detected_types", proxmoxProductTypesToStrings(types)).
Msg("Some Proxmox products failed to set up; the remaining products were registered")
}
return results, nil
}

View file

@ -1733,3 +1733,180 @@ func (m *mockTransport) RoundTrip(req *http.Request) (*http.Response, error) {
Header: make(http.Header),
}, nil
}
// combinedProxmoxHostServer answers the RunAll traffic of a host running both
// PVE and PBS. blockedTypes are the products whose bootstrap grant the server
// refuses, so the same fixture drives both the clean combined install and the
// partial-refusal case.
func combinedProxmoxHostServer(t *testing.T, blockedTypes map[string]bool) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/auto-register" {
t.Errorf("unexpected path %s", r.URL.Path)
http.Error(w, "unexpected path", http.StatusNotFound)
return
}
var payload map[string]any
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Errorf("decode auto-register payload: %v", err)
http.Error(w, "bad payload", http.StatusBadRequest)
return
}
regType, _ := payload["type"].(string)
host, _ := payload["host"].(string)
w.Header().Set("Content-Type", "application/json")
if check, _ := payload["checkRegistration"].(bool); check {
_, _ = fmt.Fprintf(w, `{"registered":false,"sourceExists":false,"canRegister":%t}`, !blockedTypes[regType])
return
}
if blockedTypes[regType] {
http.Error(w, "agent token auth permits token updates for existing nodes only", http.StatusForbidden)
return
}
tokenUser := proxmoxUserPVE
if regType == "pbs" {
tokenUser = proxmoxUserPBS
}
_, _ = fmt.Fprintf(w,
`{"status":"success","message":"registered","action":"use_token","type":"%s","source":"agent","host":"%s","nodeId":"node-1","nodeName":"node-1","tokenId":"%s!%s","tokenValue":"v-%s"}`,
regType, host, tokenUser, "pulse-node-1", regType)
}))
}
// combinedProxmoxHostCollector mimics a machine with both Proxmox products
// installed, recording every state-file write so the markers RunAll publishes
// can be asserted without touching the real state dir.
func combinedProxmoxHostCollector(writes map[string]string) *mockCollector {
mc := &mockCollector{}
mc.lookPathFn = func(file string) (string, error) { return "/usr/sbin/" + file, nil }
mc.statFn = func(string) (os.FileInfo, error) { return nil, os.ErrNotExist }
mc.mkdirAllFn = func(string, os.FileMode) error { return nil }
mc.chmodFn = func(string, os.FileMode) error { return nil }
mc.writeFileFn = func(filename string, data []byte, _ os.FileMode) error {
writes[filepath.Base(filename)] = string(data)
return nil
}
mc.dialTimeoutFn = func(string, string, time.Duration) (net.Conn, error) {
return &mockConn{localAddr: &net.UDPAddr{IP: net.ParseIP("10.0.0.1")}}, nil
}
mc.commandCombinedOutputFn = func(_ context.Context, name string, arg ...string) (string, error) {
if name == "pveum" && len(arg) > 2 && arg[1] == "token" && arg[2] == "add" {
return "│ value │ v-pve │", nil
}
if name == "proxmox-backup-manager" && len(arg) > 1 && arg[1] == "generate-token" {
return `{"value":"v-pbs"}`, nil
}
return "", nil
}
return mc
}
// TestRunAllCombinedHostRegistersBothTypes covers the officially supported
// PVE+PBS-on-one-machine deployment (#1644). The server now grants one
// bootstrap create per canonical type, so both legs must register, and RunAll
// must publish the detected-types marker the installer reads to decide how many
// outcomes to wait for before reporting.
func TestRunAllCombinedHostRegistersBothTypes(t *testing.T) {
server := combinedProxmoxHostServer(t, nil)
defer server.Close()
writes := make(map[string]string)
mc := combinedProxmoxHostCollector(writes)
p := NewProxmoxSetup(zerolog.Nop(), server.Client(), mc, server.URL, "install-token", "", "node-1", "", "/state", false)
p.retryBackoffs = []time.Duration{}
results, err := p.RunAll(context.Background())
if err != nil {
t.Fatalf("RunAll: %v", err)
}
if len(results) != 2 {
t.Fatalf("results = %d, want one per detected product: %#v", len(results), results)
}
registered := map[string]bool{}
for _, result := range results {
if !result.Registered {
t.Fatalf("%s was not registered: %#v", result.ProxmoxType, result)
}
registered[result.ProxmoxType] = true
}
if !registered["pve"] || !registered["pbs"] {
t.Fatalf("registered types = %v, want both pve and pbs", registered)
}
if got := writes["proxmox-detected-types"]; got != "pve\npbs\n" {
t.Fatalf("detected-types marker = %q, want both products one per line", got)
}
for _, marker := range []string{"proxmox-pve-registered", "proxmox-pbs-registered"} {
if _, ok := writes[marker]; !ok {
t.Fatalf("RunAll did not write %s; writes = %v", marker, writes)
}
}
}
// TestRunAllReportsPartialProxmoxFailure pins that one product's genuine
// refusal no longer erases the other's success: the working product is still
// registered and returned, the refusal is still recorded in its own blocked
// marker, and RunAll does not report the whole setup as failed.
func TestRunAllReportsPartialProxmoxFailure(t *testing.T) {
server := combinedProxmoxHostServer(t, map[string]bool{"pbs": true})
defer server.Close()
stateDir := t.TempDir()
writes := make(map[string]string)
mc := combinedProxmoxHostCollector(writes)
var logs strings.Builder
p := NewProxmoxSetup(zerolog.New(&logs), server.Client(), mc, server.URL, "install-token", "", "node-1", "", stateDir, false)
p.retryBackoffs = []time.Duration{}
results, err := p.RunAll(context.Background())
if err != nil {
t.Fatalf("RunAll returned an error for a partially successful combined host: %v", err)
}
if len(results) != 1 || results[0].ProxmoxType != "pve" || !results[0].Registered {
t.Fatalf("results = %#v, want the pve registration to survive the pbs refusal", results)
}
if _, ok := writes["proxmox-pve-registered"]; !ok {
t.Fatalf("pve registration marker missing; writes = %v", writes)
}
blocked, readErr := os.ReadFile(filepath.Join(stateDir, "proxmox-pbs-registration-blocked"))
if readErr != nil {
t.Fatalf("read pbs registration-blocked marker: %v", readErr)
}
if !strings.Contains(string(blocked), "Settings -> Infrastructure") {
t.Fatalf("pbs blocked marker lacks operator remediation: %s", blocked)
}
if _, err := os.Stat(filepath.Join(stateDir, "proxmox-pve-registration-blocked")); !os.IsNotExist(err) {
t.Fatalf("pve was marked blocked despite registering (stat err = %v)", err)
}
if !strings.Contains(logs.String(), "the remaining products were registered") {
t.Fatalf("partial failure summary missing from logs: %s", logs.String())
}
}
// TestRunAllFailsWhenEveryDetectedTypeFails keeps a fully denied combined host
// loud: with nothing registered there is no partial success to protect, so the
// caller must see an error rather than an empty, silent result set.
func TestRunAllFailsWhenEveryDetectedTypeFails(t *testing.T) {
server := combinedProxmoxHostServer(t, map[string]bool{"pve": true, "pbs": true})
defer server.Close()
stateDir := t.TempDir()
writes := make(map[string]string)
mc := combinedProxmoxHostCollector(writes)
p := NewProxmoxSetup(zerolog.Nop(), server.Client(), mc, server.URL, "install-token", "", "node-1", "", stateDir, false)
p.retryBackoffs = []time.Duration{}
results, err := p.RunAll(context.Background())
if err == nil {
t.Fatal("RunAll returned nil error when every detected Proxmox product was refused")
}
for _, want := range []string{"pve", "pbs"} {
if !strings.Contains(err.Error(), want) {
t.Fatalf("RunAll error %v does not name the failed %s product", err, want)
}
}
if len(results) != 0 {
t.Fatalf("results = %#v, want none", results)
}
}

View file

@ -1192,38 +1192,101 @@ EOF
# registration result in installer output. The agent records a denied
# registration grant in a proxmox-<type>-registration-blocked marker file so
# the failure is not buried in its journal (#1644).
#
# A host with both PVE and PBS installed registers each product separately, so
# the outcome is reported per product. The agent publishes the products it
# detected in proxmox-detected-types; with that list the installer waits for an
# outcome from every product before deciding, instead of letting whichever one
# lands first speak for the whole install. Agents that predate the list keep the
# old first-outcome-wins timing.
report_proxmox_registration_outcome() {
local state_dir="$1"
local max_iterations=15
local interval=2
local iteration=0
local blocked_file=""
local detected_file=""
local awaited_types=""
local detected_types=""
local types_known="false"
local line=""
local pending=""
local saw_outcome="false"
local blocked_any="false"
local unconfirmed_any="false"
local ptype=""
if [[ "$ENABLE_PROXMOX" != "true" || -z "$state_dir" ]]; then
return 0
fi
detected_file="${state_dir}/proxmox-detected-types"
awaited_types="pve pbs"
log_info "Waiting for Proxmox registration result..."
while [ $iteration -lt $max_iterations ]; do
for ptype in pve pbs; do
blocked_file="${state_dir}/proxmox-${ptype}-registration-blocked"
if [ -f "$blocked_file" ]; then
log_error "Proxmox ${ptype} registration failed:"
while IFS= read -r line; do log_error " $line"; done < "$blocked_file"
return 1
# Re-read each pass: the agent writes the list once it has probed the
# host, which can be after the first poll. Only known product names are
# accepted, so the marker can never inject a path or a glob into the
# state-file lookups below.
if [ -f "$detected_file" ]; then
detected_types=""
while IFS= read -r line || [[ -n "$line" ]]; do
case "${line%$'\r'}" in
pve|pbs) detected_types="${detected_types}${line%$'\r'} " ;;
esac
done < "$detected_file"
if [[ -n "$detected_types" ]]; then
awaited_types="$detected_types"
types_known="true"
fi
fi
pending=""
saw_outcome="false"
for ptype in $awaited_types; do
if [ -f "${state_dir}/proxmox-${ptype}-registered" ] || [ -f "${state_dir}/proxmox-${ptype}-registration-blocked" ]; then
saw_outcome="true"
else
pending="${pending}${ptype} "
fi
done
if [ -f "${state_dir}/proxmox-pve-registered" ] || [ -f "${state_dir}/proxmox-pbs-registered" ]; then
log_info "Proxmox node registered with Pulse."
return 0
if [[ -z "$pending" ]]; then
break
fi
if [[ "$types_known" != "true" && "$saw_outcome" == "true" ]]; then
break
fi
sleep $interval
iteration=$((iteration + 1))
done
log_warn "Proxmox registration was not confirmed within ~$((max_iterations * interval))s. The agent keeps retrying in the background."
log_warn "Check the agent logs for Proxmox registration status if the node does not appear in Pulse."
for ptype in $awaited_types; do
if [ -f "${state_dir}/proxmox-${ptype}-registration-blocked" ]; then
blocked_any="true"
log_error "Proxmox ${ptype} registration failed:"
while IFS= read -r line; do log_error " $line"; done < "${state_dir}/proxmox-${ptype}-registration-blocked"
elif [ -f "${state_dir}/proxmox-${ptype}-registered" ]; then
log_info "Proxmox ${ptype} node registered with Pulse."
elif [[ "$types_known" == "true" ]]; then
unconfirmed_any="true"
log_warn "Proxmox ${ptype} registration was not confirmed within ~$((max_iterations * interval))s. The agent keeps retrying in the background."
fi
done
if [[ "$types_known" != "true" && "$saw_outcome" != "true" ]]; then
unconfirmed_any="true"
log_warn "Proxmox registration was not confirmed within ~$((max_iterations * interval))s. The agent keeps retrying in the background."
fi
if [[ "$unconfirmed_any" == "true" ]]; then
log_warn "Check the agent logs for Proxmox registration status if the node does not appear in Pulse."
fi
if [[ "$blocked_any" == "true" ]]; then
return 1
fi
return 0
}
@ -1862,6 +1925,7 @@ clear_proxmox_state_if_needed() {
rm -f "${STATE_DIR}/proxmox-pbs-registered" 2>/dev/null || true
rm -f "${STATE_DIR}/proxmox-pve-registration-blocked" 2>/dev/null || true
rm -f "${STATE_DIR}/proxmox-pbs-registration-blocked" 2>/dev/null || true
rm -f "${STATE_DIR}/proxmox-detected-types" 2>/dev/null || true
}
write_connection_state_value() {

View file

@ -4645,7 +4645,7 @@ func TestInstallSHSurfacesBlockedProxmoxRegistration(t *testing.T) {
t.Fatal("complete_installation_flow does not report the Proxmox registration outcome")
}
clearFn := extractInstallShellFunction(t, "clear_proxmox_state_if_needed")
for _, marker := range []string{"proxmox-pve-registration-blocked", "proxmox-pbs-registration-blocked"} {
for _, marker := range []string{"proxmox-pve-registration-blocked", "proxmox-pbs-registration-blocked", "proxmox-detected-types"} {
if !strings.Contains(clearFn, marker) {
t.Fatalf("clear_proxmox_state_if_needed does not clear %s", marker)
}
@ -4703,10 +4703,110 @@ func TestInstallSHSurfacesBlockedProxmoxRegistration(t *testing.T) {
if rc != 0 {
t.Fatalf("registered marker returned rc=%d:\n%s", rc, out)
}
if !strings.Contains(out, "Proxmox node registered with Pulse.") {
if !strings.Contains(out, "Proxmox pve node registered with Pulse.") {
t.Fatalf("registered marker did not report success:\n%s", out)
}
})
// A host with both PVE and PBS installed registers each product separately
// (#1644). The installer must report each outcome rather than letting the
// first marker it finds speak for the whole install.
t.Run("combined host reports both products", func(t *testing.T) {
stateDir := t.TempDir()
writeMarker(t, stateDir, "proxmox-detected-types", "pve\npbs\n")
writeMarker(t, stateDir, "proxmox-pve-registered", "ok\n")
writeMarker(t, stateDir, "proxmox-pbs-registered", "ok\n")
out, rc := runReport(t, stateDir)
if rc != 0 {
t.Fatalf("combined host returned rc=%d:\n%s", rc, out)
}
for _, want := range []string{"Proxmox pve node registered with Pulse.", "Proxmox pbs node registered with Pulse."} {
if !strings.Contains(out, want) {
t.Fatalf("combined host output missing %q:\n%s", want, out)
}
}
if strings.Contains(out, "[ERROR]") {
t.Fatalf("fully registered combined host reported an error:\n%s", out)
}
})
// The regression behind #1644: PVE registered, PBS was refused, and the
// installer printed a bare ERROR banner that read as a failed install.
// Both outcomes must now show up, each attributed to its own product.
t.Run("combined host keeps a successful product visible next to a refusal", func(t *testing.T) {
stateDir := t.TempDir()
reason := "Pulse at https://pulse.local has no PBS source for https://pbs.local:8007 and this agent's token cannot create one."
writeMarker(t, stateDir, "proxmox-detected-types", "pve\npbs\n")
writeMarker(t, stateDir, "proxmox-pve-registered", "ok\n")
writeMarker(t, stateDir, "proxmox-pbs-registration-blocked", reason+"\n")
out, rc := runReport(t, stateDir)
if rc == 0 {
t.Fatalf("refused pbs registration reported overall success:\n%s", out)
}
if !strings.Contains(out, "Proxmox pve node registered with Pulse.") {
t.Fatalf("pve success was erased by the pbs refusal:\n%s", out)
}
if !strings.Contains(out, "Proxmox pbs registration failed:") || !strings.Contains(out, reason) {
t.Fatalf("pbs refusal not surfaced with its reason:\n%s", out)
}
})
// Only the detected products are awaited. A PVE-only host must not sit out
// the full timeout waiting for a PBS outcome that will never arrive.
t.Run("detected types bound what is awaited", func(t *testing.T) {
stateDir := t.TempDir()
writeMarker(t, stateDir, "proxmox-detected-types", "pve\n")
writeMarker(t, stateDir, "proxmox-pve-registered", "ok\n")
out, rc := runReport(t, stateDir)
if rc != 0 {
t.Fatalf("pve-only host returned rc=%d:\n%s", rc, out)
}
if strings.Contains(out, "pbs") {
t.Fatalf("pve-only host reported on pbs:\n%s", out)
}
if strings.Contains(out, "not confirmed") {
t.Fatalf("pve-only host warned about an unconfirmed registration:\n%s", out)
}
})
// Agents older than the detected-types marker keep the previous timing:
// the first outcome that appears ends the wait.
t.Run("missing detected types falls back to first outcome", func(t *testing.T) {
stateDir := t.TempDir()
writeMarker(t, stateDir, "proxmox-pbs-registered", "ok\n")
out, rc := runReport(t, stateDir)
if rc != 0 {
t.Fatalf("legacy agent marker returned rc=%d:\n%s", rc, out)
}
if !strings.Contains(out, "Proxmox pbs node registered with Pulse.") {
t.Fatalf("legacy agent marker did not report success:\n%s", out)
}
if strings.Contains(out, "not confirmed") {
t.Fatalf("legacy agent marker warned about an unconfirmed registration:\n%s", out)
}
})
t.Run("no outcome warns once", func(t *testing.T) {
stateDir := t.TempDir()
out, rc := runReport(t, stateDir)
if rc != 0 {
t.Fatalf("unconfirmed registration returned rc=%d:\n%s", rc, out)
}
if !strings.Contains(out, "not confirmed") || !strings.Contains(out, "Check the agent logs") {
t.Fatalf("unconfirmed registration did not warn:\n%s", out)
}
})
}
func writeMarker(t *testing.T, stateDir, name, contents string) {
t.Helper()
if err := os.WriteFile(filepath.Join(stateDir, name), []byte(contents), 0o600); err != nil {
t.Fatalf("write %s: %v", name, err)
}
}
// TestInstallSHWarnAgentTokenRejectedIsActionable pins the actionable recovery