diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 8f232e4da..cf1f0cab3 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -1504,7 +1504,15 @@ also validate the returned canonical `type`, normalized `host`, and live registration cannot drift onto a stale or mismatched bootstrap response. Install-time PVE auto-registration must also create privilege-separated Pulse-managed monitor tokens and mirror effective ACLs to the concrete token id -rather than relying on user-only grants or shared-token inheritance. A +rather than relying on user-only grants or shared-token inheritance. That same +install-time token creation must extract the token secret deterministically: it +must request the machine-readable `pveum ... --output-format json` form first +and parse the `value` field, falling back to the legacy box-drawing table +layout only when an older pveum rejects the JSON flag — matching the hardened +web-setup render path (`internal/api/setup_script_render.go`) so token capture +does not silently fail or mis-parse when pveum's table formatting drifts across +versions/locales. `scripts/installtests/root_install_sh_test.go` is the owned +proof surface for that install-time extraction. A non-empty `expires` field alone is not sufficient; the installer must reject bootstrap responses whose expiry is already in the past. That same bootstrap consumer must also fail closed unless the runtime-owned setup metadata is diff --git a/install.sh b/install.sh index 79403f5a2..6e654c53d 100755 --- a/install.sh +++ b/install.sh @@ -2029,6 +2029,41 @@ create_lxc_container() { exit 0 } +# Extract the PVE API token secret from `pveum user token add` output. Prefers +# the deterministic JSON form (`pveum ... --output-format json`) and falls back +# to the legacy box-drawing table layout, mirroring the hardened web-setup +# render path (internal/api/setup_script_render.go) so token capture does not +# silently fail or mis-parse when pveum's table formatting drifts across +# versions/locales (#1312). +extract_pve_token_value() { + local token_output="$1" + local token_value="" + + token_value=$(printf '%s\n' "$token_output" | sed -n 's/.*"value"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1) + if [[ -n "$token_value" ]]; then + printf '%s\n' "$token_value" + return 0 + fi + + # Legacy table form: normalize the box-drawing column separator to a plain + # pipe (byte-wise via sed, so it is locale-independent) before splitting, so + # extraction works regardless of the host locale. + printf '%s\n' "$token_output" | sed 's/│/|/g' | awk -F'|' ' + function trim(value) { + gsub(/^[[:space:]]+|[[:space:]]+$/, "", value) + return value + } + { + key = trim($2) + value = trim($3) + if (key == "value" && value != "") { + print value + exit + } + } + ' +} + auto_register_pve_node() { local ctid="$1" local pulse_ip="$2" @@ -2333,9 +2368,16 @@ PY local token_output="" set +e - token_output=$(pveum user token add pulse-monitor@pve "$token_name" --privsep 1 2>&1) + token_output=$(pveum user token add pulse-monitor@pve "$token_name" --privsep 1 --output-format json 2>&1) local token_status=$? set -e + # Older pveum builds reject --output-format; fall back to the legacy table form. + if [[ $token_status -ne 0 ]] && printf '%s\n' "$token_output" | grep -Eqi 'unknown option|unknown command|no such option|unable to parse option|output-format'; then + set +e + token_output=$(pveum user token add pulse-monitor@pve "$token_name" --privsep 1 2>&1) + token_status=$? + set -e + fi if [[ $token_status -ne 0 ]]; then AUTO_NODE_REGISTER_ERROR="failed to create token" print_warn "Unable to create monitoring API token; skipping automatic node registration" @@ -2343,7 +2385,7 @@ PY fi local token_value - token_value=$(awk -F'│' '/[[:space:]]value[[:space:]]/{col=$3; gsub(/^[[:space:]]+|[[:space:]]+$/, "", col); print col}' <<<"$token_output" | tail -n1 | tr -d '\r') + token_value=$(extract_pve_token_value "$token_output" | tail -n1 | tr -d '\r') if [[ -z "$token_value" ]]; then AUTO_NODE_REGISTER_ERROR="token value unavailable" print_warn "Failed to extract token value from pveum output; skipping automatic node registration" diff --git a/scripts/installtests/root_install_sh_test.go b/scripts/installtests/root_install_sh_test.go index 8e8da4b82..f82d8cf6d 100644 --- a/scripts/installtests/root_install_sh_test.go +++ b/scripts/installtests/root_install_sh_test.go @@ -723,6 +723,85 @@ func TestRootInstallUninstallWiresSensorProxyCleanup(t *testing.T) { } } +// TestRootInstallExtractsPveTokenValue guards the #44/#1312 token-extraction +// hardening for the install-time auto-register path: token capture must prefer +// the deterministic `pveum --output-format json` form and parse the `value` +// field, while still recovering from the legacy box-drawing table layout that +// older pveum builds emit, so it does not silently fail or mis-parse when +// pveum's table formatting drifts. +func TestRootInstallExtractsPveTokenValue(t *testing.T) { + fn := extractRootInstallShellFunction(t, "extract_pve_token_value") + + const secret = "12345678-1234-1234-1234-1234567890ab" + + jsonOutput := `{"full-tokenid":"pulse-monitor@pve!pulse-x","info":{"privsep":"1"},"value":"` + secret + `"}` + tableOutput := "" + + "┌──────────────┬──────────────────────────────────────┐\n" + + "│ key │ value │\n" + + "╞══════════════╪══════════════════════════════════════╡\n" + + "│ full-tokenid │ pulse-monitor@pve!pulse-x │\n" + + "├──────────────┼──────────────────────────────────────┤\n" + + "│ info │ {\"privsep\":\"1\"} │\n" + + "├──────────────┼──────────────────────────────────────┤\n" + + "│ value │ " + secret + " │\n" + + "└──────────────┴──────────────────────────────────────┘\n" + + cases := []struct { + name string + output string + want string + }{ + {"json", jsonOutput, secret}, + {"table", tableOutput, secret}, + {"garbage", "no token here\n", ""}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + script := fn + "\nextract_pve_token_value \"$TOKEN_OUTPUT\"\n" + cmd := exec.Command("bash", "-c", script) + cmd.Env = append(os.Environ(), "TOKEN_OUTPUT="+tc.output) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("extract_pve_token_value failed: %v\n%s", err, out) + } + if got := strings.TrimSpace(string(out)); got != tc.want { + t.Fatalf("extract_pve_token_value(%s) = %q, want %q", tc.name, got, tc.want) + } + }) + } +} + +// TestRootInstallAutoRegisterPrefersJsonTokenForm pins that the install-time +// auto-register path requests the JSON form first and keeps the legacy table +// form only as an explicit fallback (so the secure-installer contract pin on +// the bare form stays satisfied). +func TestRootInstallAutoRegisterPrefersJsonTokenForm(t *testing.T) { + content, err := os.ReadFile(filepath.Join("..", "..", "install.sh")) + if err != nil { + t.Fatalf("read root install.sh: %v", err) + } + script := string(content) + + required := []string{ + `pveum user token add pulse-monitor@pve "$token_name" --privsep 1 --output-format json 2>&1`, + `pveum user token add pulse-monitor@pve "$token_name" --privsep 1 2>&1`, + `token_value=$(extract_pve_token_value "$token_output"`, + `extract_pve_token_value() {`, + } + for _, needle := range required { + if !strings.Contains(script, needle) { + t.Fatalf("install.sh missing hardened token-extraction contract: %s", needle) + } + } + + jsonIdx := strings.Index(script, `--privsep 1 --output-format json 2>&1`) + bareIdx := strings.Index(script, "\n token_output=$(pveum user token add pulse-monitor@pve \"$token_name\" --privsep 1 2>&1)") + if jsonIdx < 0 || bareIdx < 0 || jsonIdx > bareIdx { + t.Fatalf("expected JSON token form to precede the legacy table fallback (json=%d bare=%d)", jsonIdx, bareIdx) + } +} + func TestRootInstallDeployAgentScriptsDeploysSignatureSidecars(t *testing.T) { extractDir := t.TempDir() installDir := t.TempDir()