Harden install-time PVE token extraction with JSON-first parsing (#44/#1312)

The install-time auto-register path (auto_register_pve_node) parsed the API
token secret out of pveum's box-drawing table output with a fragile awk
column-split. The web-setup render path was already hardened to request
'pveum ... --output-format json' first and parse the value field, but this
secondary install.sh path was never ported.

auto_register_pve_node now requests --output-format json first (falling back
to the bare --privsep 1 form only when an older pveum rejects the JSON flag,
which keeps the secure-installer contract pin on that form satisfied) and
extracts the secret via a new extract_pve_token_value helper: JSON value-field
parse first, then a locale-independent box-drawing table fallback (normalizes
the column separator to a plain pipe byte-wise before splitting, so it works
regardless of host locale). This mirrors the hardened render path and removes
the silent-failure / mis-parse risk when pveum table formatting drifts.

Functional + contract tests in root_install_sh_test.go; deployment-installability
contract documents the deterministic extraction. The host-agent path
(internal/hostagent/proxmox_setup.go setupPVEToken) carries an agent-lifecycle
token-permission proof obligation and is left for a governed lane.
This commit is contained in:
rcourtman 2026-06-09 15:47:12 +01:00
parent 2ff2b18c74
commit 16f791a656
3 changed files with 132 additions and 3 deletions

View file

@ -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

View file

@ -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"

View file

@ -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()