Fix Proxmox registration for host-token installs

The Settings > Infrastructure installer mints generic host install
tokens, but install.sh auto-detects Proxmox and the agent presents type
pve/pbs at /api/auto-register. The bootstrap grant required an exact
install_type match, so every generic install on a Proxmox node was
denied source creation and the denial was a single buried journal warn.

Four-part fix (#1644):
- server: extend the one-shot bootstrap grant to host-issued install
  tokens presenting a canonical Proxmox type. Typed tokens stay pinned,
  the grant keeps its settings-write mint requirement, first-hostname
  binding, serialized completion, and single consumption across types.
- agent: a canRegister=false denial now logs at error level, returns a
  setup error, and records the operator-facing reason in a
  proxmox-<type>-registration-blocked state marker.
- installer: report the Proxmox registration outcome in install output
  by reading the registered/blocked markers, and poll the server lookup
  for a bounded retry window before warning that registration was not
  confirmed (readyz flips before the first report cycle).
- setup script: the auto-register transport now captures the HTTP
  status alongside the body (no -f), making the invalid-setup-token
  branch reachable via 401/403 instead of a dead server-string grep,
  and operator guidance names Settings -> Infrastructure instead of the
  retired Nodes page (also updated in docs/PBS.md and the pinned
  assertions in contract, setup-script, and repoctl docs tests).

Regression proof: internal/api/issue1644_host_install_token_proxmox_test.go
plus new install.sh proofs for the retry window and blocked-marker
surfacing, and the updated hostagent blocked-registration test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
courtmanr@gmail.com
2026-07-28 11:21:01 +01:00
parent 74a47f6bd9
commit ac43506e6e
15 changed files with 684 additions and 77 deletions
+1 -1
View File
@@ -68,7 +68,7 @@ Example (what the UI generates):
curl -fsSL "http://<pulse-ip>:7655/api/setup-script?type=pbs&host=https://<pbs-ip>:8007&pulse_url=http://<pulse-ip>:7655" | { if [ "$(id -u)" -eq 0 ]; then PULSE_SETUP_TOKEN="<setup-token>" bash; elif command -v sudo >/dev/null 2>&1; then sudo env PULSE_SETUP_TOKEN="<setup-token>" bash; else echo "Root privileges required. Run as root (su -) and retry." >&2; exit 1; fi; }
```
Pulse generates that full command for you from **Settings → Nodes**, including
Pulse generates that full command for you from **Settings → Infrastructure**, including
the one-time setup token. The script creates a `pulse-monitor@pbs` user,
generates a scoped API token, and registers the server with Pulse.
@@ -2158,9 +2158,13 @@ Agent` secondary handoff against the live setup wizard instead of relying
21. Keep Proxmox runtime health and bootstrap authority separate. Agent-token
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 PVE/PBS install token may
consume one declared-type initial registration after durable first-host
binding and serialized completion.
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
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.
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
@@ -5317,11 +5321,33 @@ 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.
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
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
non-Proxmox request types never hold a grant.
A blocked registration is not a silent skip. When the pre-registration check
reports `canRegister=false`, the agent must log at error level, return a setup
error to its caller, and record the operator-facing reason in a
`proxmox-<type>-registration-blocked` marker in its state directory; the
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.
Token-optional installations retain the setup-token bootstrap path. Ordinary
hosts never enter this Proxmox registration loop, while PVE, PBS, mixed-product
hosts, restarts, and hosted-tenant install tokens retain the same authority
split. `internal/hostagent/proxmox_setup_test.go`,
`internal/api/config_handlers_auto_register_test.go`, and
`internal/api/proxmox_install_registration_test.go` prove the recurring health,
first-install, type/host binding, one-time consumption, rejection, and
concurrent-completion contracts.
`internal/api/config_handlers_auto_register_test.go`,
`internal/api/proxmox_install_registration_test.go`, and
`internal/api/issue1644_host_install_token_proxmox_test.go` prove the recurring
health, first-install, type/host binding, one-time consumption, rejection,
host-token bootstrap, and concurrent-completion contracts.
@@ -1619,6 +1619,15 @@ payload shape change when the portal presents compact client rows.
match the primary configured endpoint may still drive disconnected-source
repair.
That same contract now owns stale-marker verification as well: setup-token-authenticated `checkRegistration` requests may omit token completion fields and must answer `{registered:boolean}` from canonical candidate-host matching so runtime repair can distinguish real registrations from stale local marker files without rotating tokens first.
That same shared boundary owns the install-token bootstrap type rule: the
one-shot Proxmox source-creation grant carried by agent-install tokens
accepts either a matching declared `install_type` of `pve`/`pbs` or a
generic `host` install token presenting one canonical Proxmox type
(#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.
That same contract owns auto-register WebSocket event intent: a successful
completion that creates a new PVE/PBS node may broadcast
`node_auto_registered`, but successful idempotent matches or credential
@@ -1727,6 +1736,13 @@ payload shape change when the portal presents compact client rows.
`${HOST_URL%/}/api2/json/nodes` with `PVEAPIToken` authentication before
it posts the canonical `/api/auto-register` payload. Smoke-check failure is
a manual-completion state, not a successful auto-registration response.
Rendered auto-register transport must capture the response body and HTTP
status together (no `-f` body suppression), and the invalid-setup-token
branch must key off a 401/403 status instead of grepping for server error
strings the backend does not emit, so the auth-failure guidance is actually
reachable (#1644). Operator-facing manual-completion and rerun guidance in
the rendered scripts must name the live `Settings → Infrastructure` page,
not the retired Nodes page.
82. `internal/api/slo.go` shared with `performance-and-scalability`: the SLO endpoint is both an API contract surface and a protected performance hot-path boundary.
83. `internal/api/system_settings.go` shared with `security-privacy`: the system settings telemetry and auth controls are both a security/privacy control surface and a canonical API payload contract boundary.
84. `internal/api/unified_agent.go` shared with `agent-lifecycle`: unified agent download and installer handlers are both an agent lifecycle control surface and a canonical API payload contract boundary.
@@ -303,6 +303,18 @@ TLS floor in the dynamic config.
and token-file paths without storing the token value, update rewrites the
same secure service shape, and uninstall removes the discovered canonical
directory rather than only `/var/lib/pulse-agent`.
Post-install verification must not declare server registration
unconfirmed from a single lookup: the local `/readyz` gate flips before the
agent's first report cycle completes, so the installer polls the server
lookup for a bounded retry window before warning, while a definitive
401/403 token rejection still short-circuits immediately (#1644).
When Proxmox integration is enabled, the installer must also report the
agent's Proxmox registration outcome in its own output: it waits a bounded
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
registration.
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`
@@ -4115,6 +4115,12 @@ That same dependency also assumes those generated setup scripts fail closed on
auto-register HTTP and transport failures, so adjacent setup flows do not
reinterpret shared backend stderr or HTTP-failure output as a successful
registration payload.
That same dependency also assumes those generated setup scripts capture the
auto-register HTTP status alongside the response body and key the
invalid-setup-token branch off a 401/403 status, and that their manual
completion and rerun guidance names the live Settings → Infrastructure page,
so adjacent setup flows surface reachable auth-failure guidance instead of a
dead server-string grep and a retired page name (#1644).
That same shared dependency also assumes generated setup scripts preserve
setup-token auth guidance, so adjacent setup flows do not regress back to
stale API-token instructions after the backend has already standardized on the
@@ -456,7 +456,7 @@ func TestPVESetupScript_UsesFailFastRetryGuidance(t *testing.T) {
if containsString(script, `echo " curl -sSL \"$SETUP_SCRIPT_URL\" | bash"`) || containsString(script, `echo " curl -sSL \"$PULSE_URL/api/setup-script?type=pve&host=YOUR_PVE_URL&pulse_url=$PULSE_URL\" | bash"`) {
t.Fatalf("expected stale non-fail-fast guidance to be removed, got: %s", truncate(script, 900))
}
if containsString(script, `echo "Manual setup steps:"`) || containsString(script, `echo " 2. In Pulse: Settings → Nodes → Add Node (enter token from above)"`) {
if containsString(script, `echo "Manual setup steps:"`) || containsString(script, `echo " 2. In Pulse: Settings → Infrastructure → Add Node (enter token from above)"`) {
t.Fatalf("expected stale off-host manual token flow to be removed from setup script, got: %s", truncate(script, 900))
}
}
@@ -780,8 +780,20 @@ func TestPVESetupScript_FailsClosedOnAutoRegisterSuccessDetection(t *testing.T)
}
script := rr.Body.String()
if !containsString(script, `curl -fsS -X POST "$PULSE_URL/api/auto-register"`) {
t.Fatalf("expected fail-fast auto-register transport in setup script, got: %s", truncate(script, 900))
if !containsString(script, `curl -sS -w $'\n%{http_code}' -X POST "$PULSE_URL/api/auto-register"`) {
t.Fatalf("expected status-capturing auto-register transport in setup script, got: %s", truncate(script, 900))
}
if containsString(script, `curl -fsS -X POST "$PULSE_URL/api/auto-register"`) {
t.Fatalf("expected body-suppressing fail-fast auto-register transport to be removed, got: %s", truncate(script, 900))
}
if !containsString(script, `REGISTER_STATUS="${REGISTER_OUTPUT##*$'\n'}"`) {
t.Fatalf("expected auto-register HTTP status extraction in setup script, got: %s", truncate(script, 900))
}
if !containsString(script, `elif [ "$REGISTER_STATUS" = "401" ] || [ "$REGISTER_STATUS" = "403" ]; then`) {
t.Fatalf("expected reachable auth-failure status branch in setup script, got: %s", truncate(script, 900))
}
if containsString(script, `grep -q "Authentication required"`) {
t.Fatalf("expected unreachable Authentication required grep branch to be removed, got: %s", truncate(script, 900))
}
if !containsString(script, `"source":"script"`) {
t.Fatalf("expected PVE setup script to use canonical /api/auto-register source marker, got: %s", truncate(script, 900))
@@ -801,7 +813,7 @@ func TestPVESetupScript_FailsClosedOnAutoRegisterSuccessDetection(t *testing.T)
if !containsString(script, `echo "The provided Pulse setup token was invalid or expired"`) {
t.Fatalf("expected invalid setup-token guidance in setup script, got: %s", truncate(script, 900))
}
if !containsString(script, `echo "Get a fresh setup token from Pulse Settings → Nodes and rerun this script."`) {
if !containsString(script, `echo "Get a fresh setup token from Pulse Settings → Infrastructure and rerun this script."`) {
t.Fatalf("expected fresh setup-token rerun guidance in setup script, got: %s", truncate(script, 900))
}
if !containsString(script, `SETUP_TOKEN_INVALID=true`) {
@@ -813,13 +825,13 @@ func TestPVESetupScript_FailsClosedOnAutoRegisterSuccessDetection(t *testing.T)
if !containsString(script, `echo "Pulse setup token authentication failed."`) {
t.Fatalf("expected PVE auth-failure completion guidance in setup script, got: %s", truncate(script, 900))
}
if !containsString(script, `echo "📝 Use the token details below in Pulse Settings → Nodes to finish registration."`) {
if !containsString(script, `echo "📝 Use the token details below in Pulse Settings → Infrastructure to finish registration."`) {
t.Fatalf("expected canonical PVE auto-register failure continuation guidance in setup script, got: %s", truncate(script, 900))
}
if !containsString(script, `if [ "$AUTO_REG_SUCCESS" != true ] && [ "$SETUP_TOKEN_INVALID" != true ]; then`) {
t.Fatalf("expected PVE manual footer to stay disabled on auth failure, got: %s", truncate(script, 900))
}
if !containsString(script, `echo "⚠️ Auto-registration failed. Finish registration manually in Pulse Settings → Nodes."`) {
if !containsString(script, `echo "⚠️ Auto-registration failed. Finish registration manually in Pulse Settings → Infrastructure."`) {
t.Fatalf("expected canonical PVE auto-register failure summary guidance in setup script, got: %s", truncate(script, 900))
}
if containsString(script, `echo "To enable auto-registration, add your API token to the setup URL"`) {
@@ -876,7 +888,7 @@ func TestPVESetupScript_FailsClosedOnAutoRegisterSuccessDetection(t *testing.T)
if !containsString(script, `echo "Add this server to Pulse with:"`) {
t.Fatalf("expected canonical PVE manual-add heading in setup script, got: %s", truncate(script, 900))
}
if !containsString(script, `echo "Use these details in Pulse Settings → Nodes to finish registration."`) {
if !containsString(script, `echo "Use these details in Pulse Settings → Infrastructure to finish registration."`) {
t.Fatalf("expected canonical PVE manual registration continuation guidance in setup script, got: %s", truncate(script, 900))
}
if !containsString(script, `echo " Host URL: $SERVER_HOST"`) {
@@ -1295,8 +1307,20 @@ func TestPBSSetupScript_FailsClosedOnAutoRegisterSuccessDetection(t *testing.T)
}
script := rr.Body.String()
if !containsString(script, `curl -fsS -X POST "$PULSE_URL/api/auto-register"`) {
t.Fatalf("expected fail-fast PBS auto-register transport in setup script, got: %s", truncate(script, 900))
if !containsString(script, `curl -sS -w $'\n%{http_code}' -X POST "$PULSE_URL/api/auto-register"`) {
t.Fatalf("expected status-capturing PBS auto-register transport in setup script, got: %s", truncate(script, 900))
}
if containsString(script, `curl -fsS -X POST "$PULSE_URL/api/auto-register"`) {
t.Fatalf("expected body-suppressing fail-fast PBS auto-register transport to be removed, got: %s", truncate(script, 900))
}
if !containsString(script, `REGISTER_STATUS="${REGISTER_OUTPUT##*$'\n'}"`) {
t.Fatalf("expected PBS auto-register HTTP status extraction in setup script, got: %s", truncate(script, 900))
}
if !containsString(script, `elif [ "$REGISTER_STATUS" = "401" ] || [ "$REGISTER_STATUS" = "403" ]; then`) {
t.Fatalf("expected reachable PBS auth-failure status branch in setup script, got: %s", truncate(script, 900))
}
if containsString(script, `grep -q "Authentication required"`) {
t.Fatalf("expected unreachable PBS Authentication required grep branch to be removed, got: %s", truncate(script, 900))
}
if !containsString(script, `"source":"script"`) {
t.Fatalf("expected PBS setup script to use canonical /api/auto-register source marker, got: %s", truncate(script, 900))
@@ -1349,7 +1373,7 @@ func TestPBSSetupScript_FailsClosedOnAutoRegisterSuccessDetection(t *testing.T)
if !containsString(script, `echo "The provided Pulse setup token was invalid or expired"`) {
t.Fatalf("expected invalid PBS setup-token guidance in setup script, got: %s", truncate(script, 900))
}
if !containsString(script, `echo "Get a fresh setup token from Pulse Settings → Nodes and rerun this script."`) {
if !containsString(script, `echo "Get a fresh setup token from Pulse Settings → Infrastructure and rerun this script."`) {
t.Fatalf("expected fresh PBS setup-token rerun guidance in setup script, got: %s", truncate(script, 900))
}
if !containsString(script, `SETUP_TOKEN_INVALID=true`) {
@@ -1361,16 +1385,16 @@ func TestPBSSetupScript_FailsClosedOnAutoRegisterSuccessDetection(t *testing.T)
if !containsString(script, `echo "Pulse setup token authentication failed."`) {
t.Fatalf("expected PBS auth-failure completion guidance in setup script, got: %s", truncate(script, 900))
}
if !containsString(script, `echo "📝 Use the token details below in Pulse Settings → Nodes to finish registration."`) {
if !containsString(script, `echo "📝 Use the token details below in Pulse Settings → Infrastructure to finish registration."`) {
t.Fatalf("expected canonical PBS auto-register failure continuation guidance in setup script, got: %s", truncate(script, 900))
}
if !containsString(script, `if [ "$AUTO_REG_SUCCESS" != true ] && [ "$SETUP_TOKEN_INVALID" != true ]; then`) {
t.Fatalf("expected PBS manual footer to stay disabled on auth failure, got: %s", truncate(script, 900))
}
if strings.Count(script, `echo "📝 Use the token details below in Pulse Settings → Nodes to finish registration."`) < 2 {
if strings.Count(script, `echo "📝 Use the token details below in Pulse Settings → Infrastructure to finish registration."`) < 2 {
t.Fatalf("expected PBS request-failure and response-failure branches to share canonical manual continuation guidance, got: %s", truncate(script, 900))
}
if !containsString(script, `echo "⚠️ Auto-registration failed. Finish registration manually in Pulse Settings → Nodes."`) {
if !containsString(script, `echo "⚠️ Auto-registration failed. Finish registration manually in Pulse Settings → Infrastructure."`) {
t.Fatalf("expected canonical PBS auto-register failure summary guidance in setup script, got: %s", truncate(script, 900))
}
if containsString(script, `echo "To enable auto-registration, add your API token to the setup URL"`) {
@@ -1421,7 +1445,7 @@ func TestPBSSetupScript_FailsClosedOnAutoRegisterSuccessDetection(t *testing.T)
if !containsString(script, `echo "Finish registration in Pulse using the manual setup details below."`) {
t.Fatalf("expected truthful PBS manual registration guidance in setup script, got: %s", truncate(script, 900))
}
if !containsString(script, `echo "Use these details in Pulse Settings → Nodes to finish registration."`) {
if !containsString(script, `echo "Use these details in Pulse Settings → Infrastructure to finish registration."`) {
t.Fatalf("expected canonical PBS manual registration continuation guidance in setup script, got: %s", truncate(script, 900))
}
if !containsString(script, `echo " Host URL: $HOST_URL"`) {
+15 -1
View File
@@ -58,6 +58,20 @@ const (
proxmoxInstallRegistrationAtKey = "proxmox_registration_at"
)
// 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 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.
func installTokenTypeGrantsBootstrap(installType, requestedType string) bool {
if installType == requestedType && isCanonicalAutoRegisterType(requestedType) {
return true
}
return installType == agentInstallTypeHost && isCanonicalAutoRegisterType(requestedType)
}
func canBootstrapProxmoxInstallRegistration(record *config.APITokenRecord, req *AutoRegisterRequest) bool {
if record == nil || req == nil || strings.TrimSpace(req.Source) != "agent" {
return false
@@ -65,7 +79,7 @@ func canBootstrapProxmoxInstallRegistration(record *config.APITokenRecord, req *
if strings.EqualFold(strings.TrimSpace(record.Metadata[proxmoxInstallRegistrationCompletedKey]), "true") {
return false
}
if strings.TrimSpace(record.Metadata["install_type"]) != strings.TrimSpace(req.Type) {
if !installTokenTypeGrantsBootstrap(strings.TrimSpace(record.Metadata["install_type"]), strings.TrimSpace(req.Type)) {
return false
}
switch strings.TrimSpace(record.Metadata["issued_via"]) {
+19 -10
View File
@@ -6673,7 +6673,7 @@ func TestContract_SetupScriptEmbedsFailFastGuidance(t *testing.T) {
if strings.Contains(script, `echo " curl -sSL \"$SETUP_SCRIPT_URL\" | bash"`) || strings.Contains(script, `echo " curl -sSL \"$PULSE_URL/api/setup-script?type=pve&host=YOUR_PVE_URL&pulse_url=$PULSE_URL\" | bash"`) {
t.Fatalf("setup script preserved stale non-fail-fast guidance: %s", script)
}
if strings.Contains(script, `echo "Manual setup steps:"`) || strings.Contains(script, `echo " 2. In Pulse: Settings → Nodes → Add Node (enter token from above)"`) {
if strings.Contains(script, `echo "Manual setup steps:"`) || strings.Contains(script, `echo " 2. In Pulse: Settings → Infrastructure → Add Node (enter token from above)"`) {
t.Fatalf("setup script preserved stale off-host manual token flow: %s", script)
}
if !strings.Contains(script, `done <<< "$OLD_TOKENS_PVE"`) {
@@ -6762,8 +6762,17 @@ func TestContract_SetupScriptEmbedsFailFastGuidance(t *testing.T) {
if strings.Contains(script, `grep -q "success"`) {
t.Fatalf("setup script preserved broad success substring detection: %s", script)
}
if !strings.Contains(script, `curl -fsS -X POST "$PULSE_URL/api/auto-register"`) {
t.Fatalf("setup script missing fail-fast auto-register transport: %s", script)
if !strings.Contains(script, `curl -sS -w $'\n%{http_code}' -X POST "$PULSE_URL/api/auto-register"`) {
t.Fatalf("setup script missing status-capturing auto-register transport: %s", script)
}
if strings.Contains(script, `curl -fsS -X POST "$PULSE_URL/api/auto-register"`) {
t.Fatalf("setup script preserved body-suppressing fail-fast auto-register transport: %s", script)
}
if !strings.Contains(script, `elif [ "$REGISTER_STATUS" = "401" ] || [ "$REGISTER_STATUS" = "403" ]; then`) {
t.Fatalf("setup script missing reachable auth-failure status branch: %s", script)
}
if strings.Contains(script, `grep -q "Authentication required"`) {
t.Fatalf("setup script preserved unreachable Authentication required grep branch: %s", script)
}
if !strings.Contains(script, `curl -fsS -X POST "$PULSE_URL/api/auto-unregister"`) {
t.Fatalf("setup script missing fail-fast auto-unregister transport: %s", script)
@@ -6786,7 +6795,7 @@ func TestContract_SetupScriptEmbedsFailFastGuidance(t *testing.T) {
if !strings.Contains(script, `echo "The provided Pulse setup token was invalid or expired"`) {
t.Fatalf("setup script missing invalid setup-token guidance: %s", script)
}
if !strings.Contains(script, `echo "Get a fresh setup token from Pulse Settings → Nodes and rerun this script."`) {
if !strings.Contains(script, `echo "Get a fresh setup token from Pulse Settings → Infrastructure and rerun this script."`) {
t.Fatalf("setup script missing fresh setup-token rerun guidance: %s", script)
}
if !strings.Contains(script, `SETUP_TOKEN_INVALID=true`) {
@@ -6798,7 +6807,7 @@ func TestContract_SetupScriptEmbedsFailFastGuidance(t *testing.T) {
if !strings.Contains(script, `if [ "$AUTO_REG_SUCCESS" != true ] && [ "$SETUP_TOKEN_INVALID" != true ]; then`) {
t.Fatalf("setup script missing PVE auth-failure footer guard: %s", script)
}
if !strings.Contains(script, `echo "📝 Use the token details below in Pulse Settings → Nodes to finish registration."`) {
if !strings.Contains(script, `echo "📝 Use the token details below in Pulse Settings → Infrastructure to finish registration."`) {
t.Fatalf("setup script missing canonical auto-register failure continuation guidance: %s", script)
}
if strings.Contains(script, `echo "To enable auto-registration, add your API token to the setup URL"`) {
@@ -6843,10 +6852,10 @@ func TestContract_SetupScriptEmbedsFailFastGuidance(t *testing.T) {
if !strings.Contains(script, `echo "Add this server to Pulse with:"`) {
t.Fatalf("setup script missing canonical manual-add heading: %s", script)
}
if !strings.Contains(script, `echo "Use these details in Pulse Settings → Nodes to finish registration."`) {
if !strings.Contains(script, `echo "Use these details in Pulse Settings → Infrastructure to finish registration."`) {
t.Fatalf("setup script missing canonical manual-add continuation guidance: %s", script)
}
if !strings.Contains(script, `echo "⚠️ Auto-registration failed. Finish registration manually in Pulse Settings → Nodes."`) {
if !strings.Contains(script, `echo "⚠️ Auto-registration failed. Finish registration manually in Pulse Settings → Infrastructure."`) {
t.Fatalf("setup script missing canonical auto-register failure summary: %s", script)
}
if !strings.Contains(script, `echo " Host URL: $SERVER_HOST"`) {
@@ -6943,7 +6952,7 @@ fi`) {
if !strings.Contains(pbsScript, `echo "The provided Pulse setup token was invalid or expired"`) {
t.Fatalf("setup script missing invalid PBS setup-token guidance: %s", pbsScript)
}
if !strings.Contains(pbsScript, `echo "Get a fresh setup token from Pulse Settings → Nodes and rerun this script."`) {
if !strings.Contains(pbsScript, `echo "Get a fresh setup token from Pulse Settings → Infrastructure and rerun this script."`) {
t.Fatalf("setup script missing fresh PBS setup-token rerun guidance: %s", pbsScript)
}
if !strings.Contains(pbsScript, `SETUP_TOKEN_INVALID=true`) {
@@ -7032,10 +7041,10 @@ fi`) {
if strings.Contains(pbsScript, `echo "To enable auto-registration, rerun with a valid Pulse setup token"`) {
t.Fatalf("setup script preserved stale split PBS setup-token auth guidance: %s", pbsScript)
}
if !strings.Contains(pbsScript, `echo "⚠️ Auto-registration failed. Finish registration manually in Pulse Settings → Nodes."`) {
if !strings.Contains(pbsScript, `echo "⚠️ Auto-registration failed. Finish registration manually in Pulse Settings → Infrastructure."`) {
t.Fatalf("setup script missing canonical PBS auto-register failure summary: %s", pbsScript)
}
if strings.Count(pbsScript, `echo "📝 Use the token details below in Pulse Settings → Nodes to finish registration."`) < 2 {
if strings.Count(pbsScript, `echo "📝 Use the token details below in Pulse Settings → Infrastructure to finish registration."`) < 2 {
t.Fatalf("setup script missing canonical PBS request-failure/manual-response continuity: %s", pbsScript)
}
if strings.Contains(pbsScript, `echo "⚠️ Auto-registration failed. Manual configuration may be needed."`) {
@@ -0,0 +1,214 @@
package api
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
)
// Issue #1644: the Settings > Infrastructure installer mints generic host
// 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.
func mintHostInstallToken(t *testing.T, handler *ConfigHandlers) string {
t.Helper()
installReq := httptest.NewRequest(
http.MethodPost,
"/api/agent-install-command",
strings.NewReader(`{"type":"host","name":"issue-1644-host"}`),
)
installReq.Host = "pulse.example:7655"
installRec := httptest.NewRecorder()
handler.HandleAgentInstallCommand(installRec, installReq)
if installRec.Code != http.StatusOK {
t.Fatalf("host install token mint status = %d, body=%s", installRec.Code, installRec.Body.String())
}
var install AgentInstallCommandResponse
if err := json.Unmarshal(installRec.Body.Bytes(), &install); err != nil {
t.Fatalf("decode host install token response: %v", err)
}
if strings.TrimSpace(install.Token) == "" {
t.Fatal("host install token mint omitted runtime token")
}
return install.Token
}
func TestIssue1644HostInstallTokenBootstrapsDetectedProxmoxSource(t *testing.T) {
stubAutoRegisterNetworkDeps(t)
cfg := &config.Config{
DataPath: t.TempDir(),
AuthUser: "admin",
AuthPass: "hashed-password",
}
handler := newTestConfigHandlers(t, cfg)
rawToken := mintHostInstallToken(t, handler)
if len(cfg.APITokens) != 1 {
t.Fatalf("API tokens = %d, want 1", len(cfg.APITokens))
}
if got := cfg.APITokens[0].Metadata["install_type"]; got != agentInstallTypeHost {
t.Fatalf("minted install_type = %q, want %q", got, agentInstallTypeHost)
}
// The agent's pre-registration check must report canRegister=true so
// runForType proceeds instead of aborting with a blocked registration.
checkRec := runAgentAutoRegister(t, handler, rawToken, AutoRegisterRequest{
Type: "pve",
Host: "https://pve-host.local:8006",
ServerName: "pve-host",
Source: "agent",
CheckRegistration: true,
})
if checkRec.Code != http.StatusOK {
t.Fatalf("registration 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 registration check: %v", err)
}
if !check.CanRegister {
t.Fatalf("host install token presenting pve reported canRegister=%v, want true", check.CanRegister)
}
registerRec := runAgentAutoRegister(t, handler, rawToken, AutoRegisterRequest{
Type: "pve",
Host: "https://pve-host.local:8006",
TokenID: "pulse-monitor@pve!pulse-pve-host",
TokenValue: "proxmox-secret",
ServerName: "pve-host",
Source: "agent",
})
if registerRec.Code != http.StatusOK {
t.Fatalf("host-token pve registration status = %d, body=%s", registerRec.Code, registerRec.Body.String())
}
if len(cfg.PVEInstances) != 1 {
t.Fatalf("PVE instances = %d, want 1", len(cfg.PVEInstances))
}
if !strings.EqualFold(cfg.APITokens[0].Metadata[proxmoxInstallRegistrationCompletedKey], "true") {
t.Fatal("host-token pve registration did not consume the install grant")
}
// The grant stays one-shot: after the pve bootstrap completes, the same
// token cannot bootstrap another source of any type.
reuseRec := runAgentAutoRegister(t, handler, rawToken, AutoRegisterRequest{
Type: "pbs",
Host: "https://pbs-host.local:8007",
TokenID: "pulse-monitor@pbs!pulse-pbs-host",
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())
}
if len(cfg.PBSInstances) != 0 {
t.Fatalf("PBS instances = %d, want 0 after consumed grant", len(cfg.PBSInstances))
}
}
func TestIssue1644HostInstallTokenGrantStaysHostnameBound(t *testing.T) {
stubAutoRegisterNetworkDeps(t)
rawToken := "issue-1644-host-bound.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)
firstRec := runAgentAutoRegister(t, handler, rawToken, AutoRegisterRequest{
Type: "pve",
Host: "https://pve-bound.local:8006",
TokenID: "pulse-monitor@pve!pulse-pve-bound",
TokenValue: "proxmox-secret",
ServerName: "pve-bound",
Source: "agent",
})
if firstRec.Code != http.StatusOK {
t.Fatalf("bound host-token registration status = %d, body=%s", firstRec.Code, firstRec.Body.String())
}
if got := cfg.APITokens[0].Metadata["bound_hostname"]; got != "pve-bound" {
t.Fatalf("bound hostname = %q, want %q", got, "pve-bound")
}
otherHostRec := runAgentAutoRegister(t, handler, rawToken, AutoRegisterRequest{
Type: "pve",
Host: "https://pve-other.local:8006",
TokenID: "pulse-monitor@pve!pulse-pve-other",
TokenValue: "proxmox-secret",
ServerName: "pve-other",
Source: "agent",
})
if otherHostRec.Code != http.StatusForbidden {
t.Fatalf("cross-host host-token registration status = %d, want 403; body=%s", otherHostRec.Code, otherHostRec.Body.String())
}
if len(cfg.PVEInstances) != 1 {
t.Fatalf("PVE instances = %d, want 1 after cross-host rejection", len(cfg.PVEInstances))
}
}
func TestIssue1644HostInstallTokenRejectsNonCanonicalType(t *testing.T) {
rawToken := "issue-1644-host-bad-type.12345678"
record := newTokenRecord(t, rawToken, []string{config.ScopeAgentReport}, map[string]string{
"install_type": agentInstallTypeHost,
"issued_via": agentInstallIssuedViaConfig,
})
req := &AutoRegisterRequest{
Type: "host",
ServerName: "some-host",
Source: "agent",
}
if canBootstrapProxmoxInstallRegistration(&record, req) {
t.Fatal("host install token must not hold a bootstrap grant for non-Proxmox types")
}
}
func runIssue1644AutoRegisterRaw(t *testing.T, handler *ConfigHandlers, rawToken string, body []byte) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "/api/auto-register", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-API-Token", rawToken)
rec := httptest.NewRecorder()
handler.HandleAutoRegister(rec, req)
return rec
}
func TestIssue1644TypedInstallTokenStaysPinnedToItsType(t *testing.T) {
rawToken := "issue-1644-typed-pinned.12345678"
record := newTokenRecord(t, rawToken, []string{config.ScopeAgentReport}, map[string]string{
"install_type": "pve",
"issued_via": agentInstallIssuedViaConfig,
})
cfg := &config.Config{
DataPath: t.TempDir(),
APITokens: []config.APITokenRecord{record},
}
handler := newTestConfigHandlers(t, cfg)
payload, err := json.Marshal(AutoRegisterRequest{
Type: "pbs",
Host: "https://pbs-pinned.local:8007",
TokenID: "pulse-monitor@pbs!pulse-pbs-pinned",
TokenValue: "proxmox-secret",
ServerName: "pbs-pinned",
Source: "agent",
})
if err != nil {
t.Fatalf("marshal pinned-type payload: %v", err)
}
rec := runIssue1644AutoRegisterRaw(t, handler, rawToken, payload)
if rec.Code != http.StatusForbidden {
t.Fatalf("pve-typed token presenting pbs status = %d, want 403; body=%s", rec.Code, rec.Body.String())
}
}
+40 -34
View File
@@ -696,7 +696,7 @@ smoke_test_pve_token() {
echo "⚠️ Created API token, but the local Proxmox API smoke check failed."
echo " Response: $SMOKE_OUTPUT"
echo ""
echo "📝 Use the token details below in Pulse Settings → Nodes after resolving the API connectivity issue."
echo "📝 Use the token details below in Pulse Settings → Infrastructure after resolving the API connectivity issue."
TOKEN_READY=false
AUTO_REG_SUCCESS=false
return 1
@@ -741,34 +741,37 @@ attempt_auto_registration() {
REGISTER_JSON='{"type":"pve","host":"'"$HOST_URL"'","serverName":"'"$SERVER_HOSTNAME"'","tokenId":"'"$PULSE_TOKEN_ID"'","tokenValue":"'"$TOKEN_VALUE"'","authToken":"'"$PULSE_SETUP_TOKEN"'","source":"script"}'
REGISTER_RESPONSE=$(echo "$REGISTER_JSON" | curl -fsS -X POST "$PULSE_URL/api/auto-register" \
# No -f: the response body and HTTP status are both needed so a rejected
# setup token (401/403) can be told apart from other failures.
REGISTER_OUTPUT=$(echo "$REGISTER_JSON" | curl -sS -w $'\n%%{http_code}' -X POST "$PULSE_URL/api/auto-register" \
-H "Content-Type: application/json" \
-d @- 2>&1)
REGISTER_RC=$?
REGISTER_STATUS="${REGISTER_OUTPUT##*$'\n'}"
REGISTER_RESPONSE="${REGISTER_OUTPUT%%$'\n'*}"
AUTO_REG_SUCCESS=false
if [ "$REGISTER_RC" -ne 0 ]; then
echo "⚠️ Auto-registration request failed before success confirmation."
echo " Response: $REGISTER_RESPONSE"
echo ""
echo "📝 Use the token details below in Pulse Settings → Nodes to finish registration."
echo "📝 Use the token details below in Pulse Settings → Infrastructure to finish registration."
elif [ "$REGISTER_STATUS" = "401" ] || [ "$REGISTER_STATUS" = "403" ]; then
SETUP_TOKEN_INVALID=true
echo "Error: Auto-registration failed - authentication required"
echo " Response: $REGISTER_RESPONSE"
echo ""
echo "The provided Pulse setup token was invalid or expired"
echo "Get a fresh setup token from Pulse Settings → Infrastructure and rerun this script."
elif echo "$REGISTER_RESPONSE" | grep -Eq '"status"[[:space:]]*:[[:space:]]*"success"'; then
AUTO_REG_SUCCESS=true
echo "Successfully registered with Pulse monitoring."
echo ""
else
if echo "$REGISTER_RESPONSE" | grep -q "Authentication required"; then
SETUP_TOKEN_INVALID=true
echo "Error: Auto-registration failed - authentication required"
echo ""
echo "The provided Pulse setup token was invalid or expired"
echo "Get a fresh setup token from Pulse Settings → Nodes and rerun this script."
else
echo "⚠️ Auto-registration failed. Finish registration manually in Pulse Settings → Nodes."
echo " Response: $REGISTER_RESPONSE"
echo ""
echo "📝 Use the token details below in Pulse Settings → Nodes to finish registration."
fi
echo "⚠️ Auto-registration failed. Finish registration manually in Pulse Settings → Infrastructure."
echo " Response: $REGISTER_RESPONSE"
echo ""
echo "📝 Use the token details below in Pulse Settings → Infrastructure to finish registration."
fi
}
@@ -1488,7 +1491,7 @@ elif [ "$TOKEN_CREATED" != true ]; then
echo ""
elif [ "$SETUP_TOKEN_INVALID" = true ]; then
echo "Pulse setup token authentication failed."
echo "Get a fresh setup token from Pulse Settings → Nodes and rerun this script."
echo "Get a fresh setup token from Pulse Settings → Infrastructure and rerun this script."
echo ""
elif [ "$TOKEN_READY" != true ]; then
echo "Pulse monitoring token setup could not be completed."
@@ -1512,7 +1515,7 @@ if [ "$AUTO_REG_SUCCESS" != true ] && [ "$SETUP_TOKEN_INVALID" != true ]; then
fi
echo " Host URL: $SERVER_HOST"
echo ""
echo "Use these details in Pulse Settings → Nodes to finish registration."
echo "Use these details in Pulse Settings → Infrastructure to finish registration."
echo ""
fi
fi
@@ -1706,12 +1709,16 @@ else
# Construct registration request with setup token
REGISTER_JSON='{"type":"pbs","host":"'"$HOST_URL"'","serverName":"'"$SERVER_HOSTNAME"'","tokenId":"'"$PULSE_TOKEN_ID"'","tokenValue":"'"$TOKEN_VALUE"'","authToken":"'"$PULSE_SETUP_TOKEN"'","source":"script"}'
# Send registration with setup token
# Send registration with setup token. No -f: the response body and
# HTTP status are both needed so a rejected setup token (401/403)
# can be told apart from other failures.
REGISTER_ATTEMPTED=true
REGISTER_RESPONSE=$(echo "$REGISTER_JSON" | curl -fsS -X POST "$PULSE_URL/api/auto-register" \
REGISTER_OUTPUT=$(echo "$REGISTER_JSON" | curl -sS -w $'\n%%{http_code}' -X POST "$PULSE_URL/api/auto-register" \
-H "Content-Type: application/json" \
-d @- 2>&1)
REGISTER_RC=$?
REGISTER_STATUS="${REGISTER_OUTPUT##*$'\n'}"
REGISTER_RESPONSE="${REGISTER_OUTPUT%%$'\n'*}"
else
echo "⚠️ Auto-registration skipped: no setup token provided"
AUTO_REG_SUCCESS=false
@@ -1726,23 +1733,22 @@ else
echo "⚠️ Auto-registration request failed before success confirmation."
echo " Response: $REGISTER_RESPONSE"
echo ""
echo "📝 Use the token details below in Pulse Settings → Nodes to finish registration."
echo "📝 Use the token details below in Pulse Settings → Infrastructure to finish registration."
elif [ "$REGISTER_STATUS" = "401" ] || [ "$REGISTER_STATUS" = "403" ]; then
SETUP_TOKEN_INVALID=true
echo "Error: Auto-registration failed - authentication required"
echo " Response: $REGISTER_RESPONSE"
echo ""
echo "The provided Pulse setup token was invalid or expired"
echo "Get a fresh setup token from Pulse Settings → Infrastructure and rerun this script."
elif echo "$REGISTER_RESPONSE" | grep -Eq '"status"[[:space:]]*:[[:space:]]*"success"'; then
AUTO_REG_SUCCESS=true
echo "Successfully registered with Pulse monitoring."
else
if echo "$REGISTER_RESPONSE" | grep -q "Authentication required"; then
SETUP_TOKEN_INVALID=true
echo "Error: Auto-registration failed - authentication required"
echo ""
echo "The provided Pulse setup token was invalid or expired"
echo "Get a fresh setup token from Pulse Settings → Nodes and rerun this script."
else
echo "⚠️ Auto-registration failed. Finish registration manually in Pulse Settings → Nodes."
echo " Response: $REGISTER_RESPONSE"
echo ""
echo "📝 Use the token details below in Pulse Settings → Nodes to finish registration."
fi
echo "⚠️ Auto-registration failed. Finish registration manually in Pulse Settings → Infrastructure."
echo " Response: $REGISTER_RESPONSE"
echo ""
echo "📝 Use the token details below in Pulse Settings → Infrastructure to finish registration."
fi
echo ""
fi
@@ -1766,7 +1772,7 @@ elif [ "$TOKEN_CREATED" != true ]; then
echo ""
elif [ "$SETUP_TOKEN_INVALID" = true ]; then
echo "Pulse setup token authentication failed."
echo "Get a fresh setup token from Pulse Settings → Nodes and rerun this script."
echo "Get a fresh setup token from Pulse Settings → Infrastructure and rerun this script."
echo ""
elif [ "$TOKEN_READY" != true ]; then
echo "Pulse monitoring token setup could not be completed."
@@ -1790,7 +1796,7 @@ if [ "$AUTO_REG_SUCCESS" != true ] && [ "$SETUP_TOKEN_INVALID" != true ]; then
fi
echo " Host URL: $HOST_URL"
echo ""
echo "Use these details in Pulse Settings → Nodes to finish registration."
echo "Use these details in Pulse Settings → Infrastructure to finish registration."
echo ""
fi
fi
+33 -3
View File
@@ -322,6 +322,29 @@ func (p *ProxmoxSetup) pbsStateFilePath() string {
return filepath.Join(p.stateDir, "proxmox-pbs-registered")
}
// registrationBlockedFilePath is the marker the installer reads to surface a
// denied registration grant in its own output instead of leaving the failure
// buried in the agent journal (#1644).
func (p *ProxmoxSetup) registrationBlockedFilePath(ptype proxmoxProductType) string {
return filepath.Join(p.stateDir, fmt.Sprintf("proxmox-%s-registration-blocked", ptype))
}
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")
return
}
if err := os.WriteFile(p.registrationBlockedFilePath(ptype), []byte(reason+"\n"), proxmoxStateFilePerm); err != nil {
p.logger.Debug().Err(err).Str("type", string(ptype)).Msg("Failed to write registration-blocked marker")
}
}
func (p *ProxmoxSetup) clearRegistrationBlockedMarker(ptype proxmoxProductType) {
if err := os.Remove(p.registrationBlockedFilePath(ptype)); err != nil && !os.IsNotExist(err) {
p.logger.Debug().Err(err).Str("type", string(ptype)).Msg("Failed to remove registration-blocked marker")
}
}
func parseProxmoxProductType(rawType string) proxmoxProductType {
switch strings.ToLower(strings.TrimSpace(rawType)) {
case string(proxmoxProductPVE):
@@ -567,17 +590,24 @@ func (p *ProxmoxSetup) runForType(ctx context.Context, ptype proxmoxProductType)
if !p.isTypeRegistered(ptype) {
p.markTypeAsRegistered(ptype)
}
p.clearRegistrationBlockedMarker(ptype)
p.logger.Info().Str("type", string(ptype)).Msg("Proxmox type already registered, skipping")
return nil, nil
}
if !registration.canRegister {
p.logger.Warn().
reason := fmt.Sprintf(
"Pulse at %s has no %s source for %s and this agent's token cannot create one. Local Proxmox credentials were left unchanged. Generate a fresh agent install command in Pulse Settings -> Infrastructure and re-run it on this host.",
p.pulseURL, strings.ToUpper(string(ptype)), hostURL,
)
p.writeRegistrationBlockedMarker(ptype, reason)
p.logger.Error().
Str("type", string(ptype)).
Str("host", hostURL).
Bool("source_exists", registration.sourceExists).
Msg("Pulse has no matching Proxmox source and this agent token cannot create one; leaving local credentials unchanged and requiring a fresh install command")
return nil, nil
Msg("Proxmox registration blocked: Pulse has no matching source and this agent token cannot create one")
return nil, fmt.Errorf("proxmox %s registration blocked: %s", ptype, reason)
}
p.clearRegistrationBlockedMarker(ptype)
if p.isTypeRegistered(ptype) {
p.logger.Info().
Str("type", string(ptype)).
+16 -4
View File
@@ -11,6 +11,7 @@ import (
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -386,6 +387,7 @@ func TestRunAllLeavesCredentialsUnchangedWhenAgentCannotCreateMissingSource(t *t
return "", nil
},
}
stateDir := t.TempDir()
setup := NewProxmoxSetup(
logger,
server.Client(),
@@ -395,12 +397,15 @@ func TestRunAllLeavesCredentialsUnchangedWhenAgentCannotCreateMissingSource(t *t
"pve",
"node-1",
"10.0.0.9",
t.TempDir(),
stateDir,
false,
)
results, err := setup.RunAll(context.Background())
if err != nil {
t.Fatalf("RunAll(): %v", err)
if err == nil {
t.Fatal("RunAll() returned nil error for a blocked registration; the denial must be loud (#1644)")
}
if !strings.Contains(err.Error(), "registration blocked") {
t.Fatalf("RunAll() error = %v, want blocked-registration diagnostic", err)
}
if len(results) != 0 {
t.Fatalf("missing source returned setup results %#v", results)
@@ -411,9 +416,16 @@ func TestRunAllLeavesCredentialsUnchangedWhenAgentCannotCreateMissingSource(t *t
if setupArtifactRequests != 0 {
t.Fatalf("setup-script-url requests = %d, want 0", setupArtifactRequests)
}
if got := logs.String(); !strings.Contains(got, "leaving local credentials unchanged") {
if got := logs.String(); !strings.Contains(got, "Proxmox registration blocked") {
t.Fatalf("missing-source diagnostic absent from logs: %s", got)
}
marker, readErr := os.ReadFile(filepath.Join(stateDir, "proxmox-pve-registration-blocked"))
if readErr != nil {
t.Fatalf("read registration-blocked marker: %v", readErr)
}
if !strings.Contains(string(marker), "Settings -> Infrastructure") {
t.Fatalf("registration-blocked marker lacks operator remediation: %s", marker)
}
}
func TestRegisterWithPulse_AcceptsServerSelectedFallbackHost(t *testing.T) {
@@ -18,7 +18,7 @@ func TestSetupBootstrapDocsStayOnCanonicalArtifactContract(t *testing.T) {
pbsDoc := readRepoFile(t, pbsRel)
assertContainsAll(t, pbsRel, pbsDoc, []string{
`curl -fsSL "http://<pulse-ip>:7655/api/setup-script?type=pbs&host=https://<pbs-ip>:8007&pulse_url=http://<pulse-ip>:7655" | { if [ "$(id -u)" -eq 0 ]; then PULSE_SETUP_TOKEN="<setup-token>" bash; elif command -v sudo >/dev/null 2>&1; then sudo env PULSE_SETUP_TOKEN="<setup-token>" bash; else echo "Root privileges required. Run as root (su -) and retry." >&2; exit 1; fi; }`,
"Pulse generates that full command for you from **Settings → Nodes**",
"Pulse generates that full command for you from **Settings → Infrastructure**",
})
assertContainsNone(t, pbsRel, pbsDoc, []string{
`curl -sSL "http://<pulse-ip>:7655/api/setup-script?type=pbs&host=https://<pbs-ip>:8007&pulse_url=http://<pulse-ip>:7655" | bash`,
+75 -2
View File
@@ -548,6 +548,37 @@ verify_agent_server_registration() {
return 1
}
# verify_agent_server_registration_with_retry polls the server-side lookup for
# a short window before declaring registration unconfirmed. The local /readyz
# endpoint flips before the agent's first report cycle completes, so a single
# immediate lookup routinely misses a perfectly healthy registration (#1644).
# Return codes mirror verify_agent_server_registration.
verify_agent_server_registration_with_retry() {
local max_attempts=10
local interval=3
local attempt=0
local reg_rc=1
if [[ -z "$PULSE_URL" ]]; then
return 1
fi
while [ $attempt -lt $max_attempts ]; do
verify_agent_server_registration
reg_rc=$?
# 0 = confirmed; 2 = token rejected, which is definitive and will not
# change with more polling.
if [[ $reg_rc -eq 0 || $reg_rc -eq 2 ]]; then
return $reg_rc
fi
attempt=$((attempt + 1))
if [ $attempt -lt $max_attempts ]; then
sleep $interval
fi
done
return 1
}
resolve_agent_health_url() {
if [[ "$HEALTH_ADDR_SET" == "true" && -z "$HEALTH_ADDR" ]]; then
return 1
@@ -604,7 +635,7 @@ verify_agent_started() {
if [[ -z "$health_url" ]]; then
while [ $iteration -lt $max_iterations ]; do
if agent_process_running; then
verify_agent_server_registration
verify_agent_server_registration_with_retry
local reg_rc=$?
if [[ $reg_rc -eq 0 ]]; then
log_info "Agent process is running and registered with Pulse."
@@ -630,7 +661,7 @@ verify_agent_started() {
while [ $iteration -lt $max_iterations ]; do
# Check the readiness endpoint first — this is the definitive signal
if curl -sf --max-time 2 "$health_url" >/dev/null 2>&1; then
verify_agent_server_registration
verify_agent_server_registration_with_retry
local reg_rc=$?
if [[ $reg_rc -eq 0 ]]; then
log_info "Agent is running, healthy, and registered with Pulse."
@@ -1157,6 +1188,45 @@ EOF
chmod +x "$script_path"
}
# report_proxmox_registration_outcome surfaces the agent's Proxmox
# 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).
report_proxmox_registration_outcome() {
local state_dir="$1"
local max_iterations=15
local interval=2
local iteration=0
local blocked_file=""
local ptype=""
if [[ "$ENABLE_PROXMOX" != "true" || -z "$state_dir" ]]; then
return 0
fi
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
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
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."
return 0
}
complete_installation_flow() {
local state_dir="$1"
local install_success_message="$2"
@@ -1165,6 +1235,7 @@ complete_installation_flow() {
save_connection_info "$state_dir"
if verify_agent_started; then
report_proxmox_registration_outcome "$state_dir" || true
if [[ "$UPGRADE_MODE" == "true" ]]; then
log_info "$upgrade_success_message"
json_event "complete" "updated" "Installation updated"
@@ -1789,6 +1860,8 @@ clear_proxmox_state_if_needed() {
rm -f "${STATE_DIR}/proxmox-registered" 2>/dev/null || true
rm -f "${STATE_DIR}/proxmox-pve-registered" 2>/dev/null || true
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
}
write_connection_state_value() {
+165
View File
@@ -2,6 +2,7 @@ package installtests
import (
"encoding/json"
"errors"
"io"
"net"
"net/http"
@@ -4532,6 +4533,170 @@ func TestInstallSHVerifyAgentServerRegistrationDetectsRejectedToken(t *testing.T
}
}
// TestInstallSHRegistrationRetryWindowOutlastsFirstReportCycle verifies that
// the post-install registration check polls for a window instead of warning
// after a single lookup: /readyz flips before the agent's first report cycle
// completes, so an immediate one-shot lookup routinely misses a healthy
// registration (issue #1644). A rejected token still short-circuits because
// more polling cannot change a definitive 401/403.
func TestInstallSHRegistrationRetryWindowOutlastsFirstReportCycle(t *testing.T) {
urlEncode := extractInstallShellFunction(t, "url_encode")
curlWithPulseToken := extractInstallShellFunction(t, "curl_with_pulse_token")
verifyFn := extractInstallShellFunction(t, "verify_agent_server_registration")
retryFn := extractInstallShellFunction(t, "verify_agent_server_registration_with_retry")
verifyStarted := extractInstallShellFunction(t, "verify_agent_started")
if !strings.Contains(verifyStarted, "verify_agent_server_registration_with_retry") {
t.Fatal("verify_agent_started does not use the registration retry window")
}
cases := []struct {
name string
// statuses is consumed one per lookup; the last value repeats.
statuses []int
wantRC string
}{
{"registered after report cycle", []int{http.StatusNotFound, http.StatusNotFound, http.StatusOK}, "rc=0"},
{"rejected token short-circuits", []int{http.StatusUnauthorized}, "rc=2"},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
var mu sync.Mutex
lookups := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.URL.Path, "/api/agents/agent/lookup") {
http.Error(w, "not found", http.StatusNotFound)
return
}
mu.Lock()
idx := lookups
lookups++
mu.Unlock()
if idx >= len(tc.statuses) {
idx = len(tc.statuses) - 1
}
w.WriteHeader(tc.statuses[idx])
if tc.statuses[idx] == http.StatusOK {
_, _ = w.Write([]byte(`{"success":true,"agent":{"id":"agent-1644"}}`))
} else {
_, _ = w.Write([]byte(`{"error":"agent_not_found"}`))
}
}))
defer server.Close()
script := `
PULSE_URL="` + server.URL + `"
PULSE_TOKEN="install-token"
HOSTNAME_OVERRIDE="pve-1644"
INSECURE="false"
CURL_CA_BUNDLE=""
sleep() { :; }
` + curlWithPulseToken + `
` + urlEncode + `
` + verifyFn + `
` + retryFn + `
verify_agent_server_registration_with_retry
echo "rc=$?"
`
out, err := exec.Command("bash", "-c", script).CombinedOutput()
if err != nil {
t.Fatalf("bash: %v\n%s", err, out)
}
if !strings.Contains(string(out), tc.wantRC) {
t.Fatalf("case %q: want %s, got:\n%s", tc.name, tc.wantRC, out)
}
if tc.wantRC == "rc=2" {
mu.Lock()
got := lookups
mu.Unlock()
if got != 1 {
t.Fatalf("rejected token was retried %d times, want 1 lookup", got)
}
}
})
}
}
// TestInstallSHSurfacesBlockedProxmoxRegistration verifies the installer reads
// the agent's proxmox-<type>-registration-blocked marker and prints the denial
// in its own output instead of leaving it buried in the agent journal (#1644).
func TestInstallSHSurfacesBlockedProxmoxRegistration(t *testing.T) {
logInfo := extractInstallShellFunction(t, "log_info")
logWarn := extractInstallShellFunction(t, "log_warn")
logError := extractInstallShellFunction(t, "log_error")
reportFn := extractInstallShellFunction(t, "report_proxmox_registration_outcome")
completeFlow := extractInstallShellFunction(t, "complete_installation_flow")
if !strings.Contains(completeFlow, `report_proxmox_registration_outcome "$state_dir"`) {
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"} {
if !strings.Contains(clearFn, marker) {
t.Fatalf("clear_proxmox_state_if_needed does not clear %s", marker)
}
}
runReport := func(t *testing.T, stateDir string) (string, int) {
t.Helper()
script := `
NON_INTERACTIVE="false"
ENABLE_PROXMOX="true"
redact_token() { printf '%s' "$1"; }
sleep() { :; }
` + logInfo + `
` + logWarn + `
` + logError + `
` + reportFn + `
report_proxmox_registration_outcome "` + stateDir + `"
`
out, err := exec.Command("bash", "-c", script).CombinedOutput()
rc := 0
if err != nil {
var exitErr *exec.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("bash: %v\n%s", err, out)
}
rc = exitErr.ExitCode()
}
return string(out), rc
}
t.Run("blocked marker surfaces reason", func(t *testing.T) {
stateDir := t.TempDir()
reason := "Pulse at https://pulse.local has no PVE source for https://pve.local:8006 and this agent's token cannot create one."
if err := os.WriteFile(filepath.Join(stateDir, "proxmox-pve-registration-blocked"), []byte(reason+"\n"), 0o600); err != nil {
t.Fatalf("write blocked marker: %v", err)
}
out, rc := runReport(t, stateDir)
if rc == 0 {
t.Fatalf("blocked registration reported success:\n%s", out)
}
if !strings.Contains(out, "[ERROR]") || !strings.Contains(out, "registration failed") {
t.Fatalf("blocked registration not surfaced as an error:\n%s", out)
}
if !strings.Contains(out, reason) {
t.Fatalf("blocked registration output missing agent-recorded reason:\n%s", out)
}
})
t.Run("registered marker reports success", func(t *testing.T) {
stateDir := t.TempDir()
if err := os.WriteFile(filepath.Join(stateDir, "proxmox-pve-registered"), []byte("ok\n"), 0o600); err != nil {
t.Fatalf("write registered marker: %v", err)
}
out, rc := runReport(t, stateDir)
if rc != 0 {
t.Fatalf("registered marker returned rc=%d:\n%s", rc, out)
}
if !strings.Contains(out, "Proxmox node registered with Pulse.") {
t.Fatalf("registered marker did not report success:\n%s", out)
}
})
}
// TestInstallSHWarnAgentTokenRejectedIsActionable pins the actionable recovery
// copy the installer prints when the server rejects the agent's token.
func TestInstallSHWarnAgentTokenRejectedIsActionable(t *testing.T) {