Prefer PVE 9 guest-agent privileges in setup

Refs #1466
This commit is contained in:
rcourtman 2026-07-03 11:15:51 +01:00
parent 806fd7409c
commit 28cee7c467
11 changed files with 125 additions and 44 deletions

View file

@ -137,15 +137,16 @@ pveum user permissions <user>@pam
At minimum, ensure the user/token has read access for inventory and metrics:
- `Sys.Audit`
- `VM.Monitor`
- `Datastore.Audit`
For VM guest agent features on PVE 9+, also ensure:
For VM guest agent features on PVE 9+, prefer:
- `VM.GuestAgent.Audit` — required for disk usage and guest info
- `VM.GuestAgent.FileRead` — required for accurate memory monitoring (excludes buff/cache)
Note: The built-in `PVEAuditor` role cannot be modified. Create a custom role (e.g. `PulseMonitor`) with the above privileges added, and assign it to your Pulse API token.
For PVE 8 only, use `VM.Monitor` instead of the `VM.GuestAgent.*` privileges.
Note: The built-in `PVEAuditor` role cannot be modified. Create a custom role (e.g. `PulseMonitor`) with the above privileges added, and assign it to your Pulse API token. After upgrading to PVE 9, add the `VM.GuestAgent.*` privileges and remove legacy `VM.Monitor` from the custom role.
**Rocky Linux / RHEL VMs**: The default qemu-guest-agent configuration may block file-read RPCs (`guest-file-open`, `guest-file-read`, `guest-file-close`). If memory or disk data is missing for these VMs, check `/etc/sysconfig/qemu-ga` and ensure those operations are not blocked, then restart the agent. Refer to your distro's qemu-guest-agent documentation for the exact config syntax.

View file

@ -97,6 +97,11 @@ that binary, not separate customer-facing agent products.
## Shared Boundaries
PVE node setup shared boundaries that render or copy `PulseMonitor`
permissions must treat `VM.GuestAgent.Audit` plus `VM.GuestAgent.FileRead` as
the PVE 9+ primary contract, with `VM.Monitor` retained only as the legacy PVE
8 fallback when guest-agent privileges are unavailable.
`internal/dockeragent/` is lifecycle-adjacent for agent binary/update trust
and owns the Docker / Podman collection module used by `pulse-agent`, but
Docker runtime capability truth is monitoring-owned. Lifecycle consumers must
@ -3179,7 +3184,10 @@ presentation in the extracted node setup surface
`useNodeModalState.ts`): the copied PVE permission snippet must stay
aligned with the canonical backend setup script, including comma-joined
privilege transport and non-destructive `PulseMonitor` role updates, instead
of shipping a stale local fork.
of shipping a stale local fork. PVE 9+ guest-agent privileges
`VM.GuestAgent.Audit` and `VM.GuestAgent.FileRead` are the primary setup
contract; legacy `VM.Monitor` may appear only as the PVE 8 fallback when the
guest-agent privilege probe is unavailable.
That same node setup modal owner must also route Proxmox agent-install command
generation through the canonical `NodesAPI.getAgentInstallCommand` client for
both PVE and PBS, instead of mixing client-mediated and ad hoc raw POST

View file

@ -136,6 +136,12 @@ product API routes free of maintainer commercial analytics.
## Shared Boundaries
PVE setup API consumers, generated scripts, runtime setup, installer setup, and
browser manual guidance must share one `PulseMonitor` privilege contract:
prefer `VM.GuestAgent.Audit` plus `VM.GuestAgent.FileRead` on PVE 9+, and use
legacy `VM.Monitor` only as the PVE 8 fallback when guest-agent privileges are
unavailable.
Mobile onboarding QR/deep-link payload readiness is a shared backend/frontend
contract. `internal/api/onboarding_handlers.go` owns the `409
onboarding_not_ready` diagnostic response when Remote Access, relay
@ -1440,7 +1446,11 @@ payload shape change when the portal presents compact client rows.
`PVEDatastoreAdmin` ACLs to both `pulse-monitor@pve` and the concrete token
id. PBS scripts must grant `Audit` to both `pulse-monitor@pbs` and the
concrete token id. Browser/runtime setup callers must not fork this into
token-shared, token-unprivileged, or user-only ACL variants.
token-shared, token-unprivileged, or user-only ACL variants. For PVE,
`PulseMonitor` privilege selection must prefer `VM.GuestAgent.Audit` plus
`VM.GuestAgent.FileRead` on PVE 9+ and reserve `VM.Monitor` for the legacy
PVE 8 fallback, so API-generated scripts, runtime setup, installer setup,
and browser manual guidance stay on one privilege contract.
67. `internal/api/enterprise_extension_rbac_admin.go` shared with `organization-settings`: RBAC admin extension endpoints are both an organization settings control surface and a canonical API payload contract boundary.
68. `internal/api/licensing_bridge.go` shared with `cloud-paid`: commercial licensing bridge handlers carry both API payload contract and cloud-paid entitlement boundary ownership.
69. `internal/api/licensing_handlers.go` shared with `cloud-paid`: commercial licensing handlers carry both API payload contract and cloud-paid entitlement boundary ownership.

View file

@ -1733,15 +1733,19 @@ 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. 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
rather than relying on user-only grants or shared-token inheritance. Its
`PulseMonitor` role setup must prefer `VM.GuestAgent.Audit` plus
`VM.GuestAgent.FileRead` when those PVE 9+ privileges are available, and fall
back to legacy `VM.Monitor` only when the guest-agent privilege probe is
unavailable. 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

@ -556,8 +556,8 @@ export const NodeModalSetupGuideSection: Component<NodeModalSetupGuideSectionPro
</div>
<p class="text-muted text-xs mt-1">
Note: PVEAuditor gives read-only API access. PulseMonitor adds Sys.Audit
plus either VM.Monitor (PVE 8) or VM.GuestAgent.Audit (PVE 9+) for disk and
guest metrics. PVEDatastoreAdmin on /storage adds backup visibility.
plus VM.GuestAgent.Audit/FileRead (PVE 9+) or VM.Monitor (PVE 8) for disk
and guest metrics. PVEDatastoreAdmin on /storage adds backup visibility.
</p>
</div>

View file

@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest';
import { PVE_MANUAL_PERMISSION_COMMAND } from '../nodeModalModel';
describe('nodeModalModel', () => {
it('prefers PVE 9 guest-agent privileges before legacy VM.Monitor', () => {
expect(PVE_MANUAL_PERMISSION_COMMAND).toContain('VM.GuestAgent.Audit');
expect(PVE_MANUAL_PERMISSION_COMMAND).toContain('VM.GuestAgent.FileRead');
expect(PVE_MANUAL_PERMISSION_COMMAND).toContain('VM.Monitor');
const guestBranch = PVE_MANUAL_PERMISSION_COMMAND.indexOf(
'if [ "$HAS_GUEST_AGENT_AUDIT" = true ]; then',
);
const monitorBranch = PVE_MANUAL_PERMISSION_COMMAND.indexOf(
'pveum role add PulseTmpVMMonitor -privs VM.Monitor',
);
expect(guestBranch).toBeGreaterThanOrEqual(0);
expect(monitorBranch).toBeGreaterThanOrEqual(0);
expect(guestBranch).toBeLessThan(monitorBranch);
});
});

View file

@ -64,6 +64,7 @@ import nodeModalAuthenticationSectionSource from '../NodeModalAuthenticationSect
import nodeModalMonitoringSectionSource from '../NodeModalMonitoringSection.tsx?raw';
import nodeModalSetupGuideSectionSource from '../NodeModalSetupGuideSection.tsx?raw';
import nodeModalStatusFooterSource from '../NodeModalStatusFooter.tsx?raw';
import nodeModalModelSource from '../nodeModalModel.ts?raw';
import nodeModalStateSource from '../useNodeModalState.ts?raw';
import availabilityTargetSlotSource from '../ConnectionEditor/CredentialSlots/AvailabilityTargetSlot.tsx?raw';
import trueNASCredentialSlotSource from '../ConnectionEditor/CredentialSlots/TrueNASCredentialSlot.tsx?raw';
@ -1291,6 +1292,19 @@ describe('settings architecture guardrails', () => {
expect(nodeModalSetupGuideSectionSource).toContain('Existing source repair');
expect(nodeModalSetupGuideSectionSource).toContain('Audit/Repair');
expect(nodeModalSetupGuideSectionSource).toContain('without rotating the current API token');
expect(nodeModalSetupGuideSectionSource).toContain('VM.GuestAgent.Audit/FileRead');
expect(nodeModalModelSource).toContain('HAS_GUEST_AGENT_AUDIT');
expect(nodeModalModelSource).toContain('VM.GuestAgent.FileRead');
expect(nodeModalModelSource).toContain('PulseTmpVMMonitor');
const guestAgentBranch = nodeModalModelSource.indexOf(
'if [ "$HAS_GUEST_AGENT_AUDIT" = true ]; then',
);
const monitorFallback = nodeModalModelSource.indexOf(
'pveum role add PulseTmpVMMonitor -privs VM.Monitor',
);
expect(guestAgentBranch).toBeGreaterThanOrEqual(0);
expect(monitorFallback).toBeGreaterThanOrEqual(0);
expect(guestAgentBranch).toBeLessThan(monitorFallback);
expect(nodeModalSetupGuideSectionSource).toContain('Docker inside Proxmox LXCs');
expect(nodeModalSetupGuideSectionSource).toContain(
'PULSE_ENABLE_PROXMOX_GUEST_DOCKER_INVENTORY=true',

View file

@ -56,24 +56,31 @@ else
fi
fi
# VM guest agent / monitor privileges
VM_PRIV=""
if pveum role list 2>/dev/null | grep -q "VM.Monitor"; then
VM_PRIV="VM.Monitor"
elif pveum role list 2>/dev/null | grep -q "VM.GuestAgent.Audit"; then
VM_PRIV="VM.GuestAgent.Audit"
else
if pveum role add PulseTmpVMMonitor -privs VM.Monitor 2>/dev/null; then
VM_PRIV="VM.Monitor"
pveum role delete PulseTmpVMMonitor 2>/dev/null
elif pveum role add PulseTmpGuestAudit -privs VM.GuestAgent.Audit 2>/dev/null; then
VM_PRIV="VM.GuestAgent.Audit"
pveum role delete PulseTmpGuestAudit 2>/dev/null
fi
# VM guest agent (PVE 9+) / monitor (PVE 8) privileges
HAS_GUEST_AGENT_AUDIT=false
if pveum role list 2>/dev/null | grep -q "VM.GuestAgent.Audit"; then
HAS_GUEST_AGENT_AUDIT=true
elif pveum role add PulseTmpGuestAudit -privs VM.GuestAgent.Audit 2>/dev/null; then
HAS_GUEST_AGENT_AUDIT=true
pveum role delete PulseTmpGuestAudit 2>/dev/null
fi
if [ -n "$VM_PRIV" ]; then
EXTRA_PRIVS+=("$VM_PRIV")
if [ "$HAS_GUEST_AGENT_AUDIT" = true ]; then
EXTRA_PRIVS+=("VM.GuestAgent.Audit")
if pveum role list 2>/dev/null | grep -q "VM.GuestAgent.FileRead"; then
EXTRA_PRIVS+=("VM.GuestAgent.FileRead")
elif pveum role add PulseTmpGuestFileRead -privs VM.GuestAgent.FileRead 2>/dev/null; then
EXTRA_PRIVS+=("VM.GuestAgent.FileRead")
pveum role delete PulseTmpGuestFileRead 2>/dev/null
fi
else
if pveum role list 2>/dev/null | grep -q "VM.Monitor"; then
EXTRA_PRIVS+=("VM.Monitor")
elif pveum role add PulseTmpVMMonitor -privs VM.Monitor 2>/dev/null; then
EXTRA_PRIVS+=("VM.Monitor")
pveum role delete PulseTmpVMMonitor 2>/dev/null
fi
fi
if [ \${#EXTRA_PRIVS[@]} -gt 0 ]; then

View file

@ -2314,14 +2314,6 @@ PY
pveum role delete PulseSysAuditProbe >/dev/null 2>&1 || true
fi
local has_vm_monitor=false
if pveum role list 2>/dev/null | grep -q "VM.Monitor"; then
has_vm_monitor=true
elif pveum role add PulseVmMonitorProbe -privs VM.Monitor >/dev/null 2>&1; then
has_vm_monitor=true
pveum role delete PulseVmMonitorProbe >/dev/null 2>&1 || true
fi
local has_guest_audit=false
if pveum role list 2>/dev/null | grep -q "VM.GuestAgent.Audit"; then
has_guest_audit=true
@ -2330,10 +2322,25 @@ PY
pveum role delete PulseGuestAuditProbe >/dev/null 2>&1 || true
fi
if [[ "$has_vm_monitor" == true ]]; then
extra_privs+=("VM.Monitor")
elif [[ "$has_guest_audit" == true ]]; then
if [[ "$has_guest_audit" == true ]]; then
extra_privs+=("VM.GuestAgent.Audit")
if pveum role list 2>/dev/null | grep -q "VM.GuestAgent.FileRead"; then
extra_privs+=("VM.GuestAgent.FileRead")
elif pveum role add PulseGuestFileReadProbe -privs VM.GuestAgent.FileRead >/dev/null 2>&1; then
extra_privs+=("VM.GuestAgent.FileRead")
pveum role delete PulseGuestFileReadProbe >/dev/null 2>&1 || true
fi
else
local has_vm_monitor=false
if pveum role list 2>/dev/null | grep -q "VM.Monitor"; then
has_vm_monitor=true
elif pveum role add PulseVmMonitorProbe -privs VM.Monitor >/dev/null 2>&1; then
has_vm_monitor=true
pveum role delete PulseVmMonitorProbe >/dev/null 2>&1 || true
fi
if [[ "$has_vm_monitor" == true ]]; then
extra_privs+=("VM.Monitor")
fi
fi
if [[ ${#extra_privs[@]} -gt 0 ]]; then

View file

@ -345,6 +345,10 @@ func TestRootInstallScriptAutoRegisterUsesSecureContractShape(t *testing.T) {
`pveum aclmod /storage -token "$token_id" -role PVEDatastoreAdmin`,
`priv_string="$(IFS=,; echo "${extra_privs[*]}")"`,
`pveum role modify PulseMonitor -privs "$priv_string"`,
`pveum role add PulseGuestFileReadProbe -privs VM.GuestAgent.FileRead`,
`extra_privs+=("VM.GuestAgent.Audit")`,
`extra_privs+=("VM.GuestAgent.FileRead")`,
`extra_privs+=("VM.Monitor")`,
`slug = re.sub(r"[^a-z0-9]+", "-", host)`,
`print(f"pulse-{slug}")`,
}
@ -356,6 +360,11 @@ func TestRootInstallScriptAutoRegisterUsesSecureContractShape(t *testing.T) {
if strings.Contains(script, `local token_name="pulse-${pulse_host_slug}-$(date +%s)"`) {
t.Fatalf("root install.sh preserved stale timestamp-suffixed Proxmox token naming")
}
guestBranch := strings.Index(script, `if [[ "$has_guest_audit" == true ]]; then`)
monitorBranch := strings.Index(script, `if [[ "$has_vm_monitor" == true ]]; then`)
if guestBranch < 0 || monitorBranch < 0 || guestBranch > monitorBranch {
t.Fatalf("root install.sh must prefer VM.GuestAgent.* privileges before legacy VM.Monitor")
}
forbidden := []string{
`local bootstrap_token=""`,
`X-Setup-Token: $bootstrap_token`,

View file

@ -2917,7 +2917,7 @@ class SubsystemLookupTest(unittest.TestCase):
{
"heading": "## Shared Boundaries",
"path": "internal/api/access_control_handlers.go",
"line": 1146,
"line": 1152,
"heading_line": 137,
}
],