Integrate reviewed token-aware permission help and composed webhook proof

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-10-05 05:36:35 +01:00
11 changed files with 650 additions and 26 deletions
+9 -1
View File
@@ -5,7 +5,7 @@ Pulse agents incorporate several security mechanisms to ensure that the code run
**Start with the least privilege that answers your monitoring question.** For
Proxmox VE, PBS, and PMG, that is usually no agent at all: API-only monitoring
with a read-only token covers inventory, status, and metrics, and the
generated setup script creates a privilege-separated monitoring user for it
generated setup script creates a privilege-separated monitoring token for it
(see [Proxmox Deployment Choices](#proxmox-deployment-choices)). Install a
host agent only where you want data the platform API cannot provide, and on
Linux consider the supported
@@ -89,6 +89,14 @@ You do not need a Pulse agent on every Proxmox-related host just to see basic
cluster inventory and utilization. Start with the least-privilege path that
answers your monitoring question:
**API-only does not mean guest-agent-free.** VM guest filesystem and memory
collection can still send active requests on the channel used by freeze-enabled
backups. Read permissions and the absence of a host agent do not prove backup
safety. For an affected installation, follow the
[backup safety precaution](VM_DISK_MONITORING.md#backup-safety), including the
monitoring/alert outage while Pulse is stopped; do not add permissions or send
manual probes during a freeze or thaw.
| Goal | Recommended path | Host privilege needed? |
|---|---|---|
| PVE/PBS/PMG inventory, node status, VM/container status, storage usage, and normal Proxmox API metrics | Add the Proxmox connection with a read-only or narrowly scoped API token | No |
+32 -12
View File
@@ -636,23 +636,43 @@ remove credentials, cookies, secret URLs and private host, network or personal
information. See [Getting Help](#-getting-help) for safe evidence collection.
### Check Permissions (Proxmox)
If Pulse can't see VMs or storage, check the user permissions on Proxmox:
```bash
pveum user permissions <user>@pam
```
At minimum, ensure the user/token has read access for inventory and metrics:
- `Sys.Audit`
- `Datastore.Audit`
If Pulse cannot see VMs, storage or guest readings, start with the **user,
realm and token ID configured for the affected Pulse connection**, not a
guessed account or your administrator login. Use the existing connection
settings; do not dump the server configuration or expose the token secret.
For VM guest agent features on PVE 9+, prefer:
With privilege separation enabled, the token's effective access is the
**intersection of the user and token permissions**: both must allow the
required privilege on the affected resource path. Ask the Proxmox
administrator to inspect both sides, including inherited ACLs and their
propagation, for that VM, storage or node. An administrator session or
user-only permission listing does not prove the token has access.
- `VM.GuestAgent.Audit` — required for disk usage and guest info
- `VM.GuestAgent.FileRead` — required for accurate memory monitoring (excludes buff/cache)
Inspect the existing denial and compare it with the relevant read privileges:
For PVE 8 only, use `VM.Monitor` instead of the `VM.GuestAgent.*` privileges.
- Inventory and metrics: `Sys.Audit` and `Datastore.Audit`, on the relevant
node/storage scope.
- VM filesystem usage and guest information on PVE 9+: `VM.GuestAgent.Audit`.
- Guest memory through `/proc/meminfo` on PVE 9+: also
`VM.GuestAgent.FileRead`.
- On PVE 8, `VM.Monitor` is the legacy guest-agent fallback, not an extra
requirement for PVE 9+.
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.
The built-in `PVEAuditor` role cannot be modified. Where a read privilege is
actually missing, use the existing narrow custom role and matching scoped
user/token ACLs. **Do not disable privilege separation, grant Administrator or
add guest execution/write privileges** to diagnose missing data. Do not rerun
setup or replace a token as a permissions test: setup can rotate an existing
credential. Make any necessary access repair in your normal maintenance
window, outside backups, then observe normal polling without manual
guest-agent probes. `Sys.Modify` is not a read-only monitoring privilege; see
[pending-update access](#proxmox-pending-update-access) before considering it.
Keep token secrets and full permission listings private. A report needs only
the relevant missing privilege and a redacted denial, not private identities,
resource paths or the complete ACL tree. Successful inventory does not prove
that guest readings are fresh, or that a guest has thawed.
**Rocky Linux / RHEL VMs**: File-read restrictions in `/etc/sysconfig/qemu-ga`
can explain missing guest memory; they do not by themselves establish why disk
+6
View File
@@ -128,6 +128,12 @@ procedure; a reboot interrupts its workloads.
Use the dedicated account and token actually configured for that Pulse
connection, not a guessed account name. Review their effective privileges for
the affected VM; a host-root diagnostic does not test the API token's access.
With privilege separation enabled, access is the intersection of user and token
permissions: both must allow the required read on the affected VM. Do not disable
privilege separation or recreate the token to diagnose a missing reading. See
[Check permissions](TROUBLESHOOTING.md#check-permissions-proxmox) for the scoped
inspection and repair boundary. A permitted read does not prove disk freshness,
responsiveness or thaw.
| Collection | Proxmox VE 9+ | Proxmox VE 8 |
| --- | --- | --- |
@@ -0,0 +1,350 @@
// Fresh proof of the composed heading and multiline instructions, not a replay
// of an old browser operation or installed notification-delivery acceptance.
const assert = require('node:assert/strict');
const crypto = require('node:crypto');
const fs = require('node:fs');
const path = require('node:path');
const { chromium, webkit } = require('playwright');
const root = '/workspace/frontend-modern';
const output = '/workspace/tmp/webhook-composed-proof-stable';
const hash = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
const runtimePath = 'frontend-modern/src/components/Alerts/WebhookConfigForm.tsx';
const expectedHash = '0286a39d8405d214df78d8806f1792991a46e0583ab20d1b1fafb276b3c9160d';
const integration = '5e967c49f55d0fc2423e9d7ba63c7ed250069a87';
const sourceFiles = [
runtimePath,
'frontend-modern/src/components/Alerts/WebhookConfig.tsx',
'frontend-modern/src/components/Alerts/useWebhookConfigState.ts',
'frontend-modern/src/utils/alertWebhookPresentation.ts',
'frontend-modern/src/index.css',
'frontend-modern/package-lock.json',
'tests/integration/package-lock.json',
'internal/notifications/webhook_templates.go',
'frontend-modern/browser-tests/webhook-composed.html',
'frontend-modern/browser-tests/webhook-composed.tsx',
'frontend-modern/browser-tests/webhook-composed.cjs',
];
// Read the committed server catalogue, not a second copy of its instructions.
// This bounded extractor is only for the selected public fixture fields, not
// a Go evaluator or a substitute for a backend response/receiver test.
const templateSource = fs.readFileSync(
'/workspace/internal/notifications/webhook_templates.go',
'utf8',
);
const starts = [...templateSource.matchAll(/^\s*Service:\s*"([^"]+)"/gm)];
const templates = ['generic', 'discord', 'telegram'].map((service) => {
const i = starts.findIndex((match) => match[1] === service);
assert.ok(i >= 0, `catalogue service ${service}`);
const block = templateSource.slice(
starts[i].index,
starts[i + 1]?.index ?? templateSource.length,
);
const value = (field, optional = false) => {
const match = block.match(new RegExp(`^\\s*${field}:\\s*("(?:[^"\\\\]|\\\\.)*")`, 'm'));
assert.ok(optional || match, `catalogue field ${service}.${field}`);
return match ? JSON.parse(match[1]) : '';
};
const payload = block.match(/^\s*PayloadTemplate:\s*`([\s\S]*?)`,/m);
assert.ok(payload, `catalogue payload ${service}`);
assert.match(block, /Headers:\s*map\[string\]string\{"Content-Type": "application\/json"\}/);
return {
service,
label: value('Label'),
name: value('Name'),
description: value('Description'),
urlPattern: value('URLPattern'),
method: value('Method'),
headers: { 'Content-Type': 'application/json' },
mentionPlaceholder: value('MentionPlaceholder', true),
mentionHelp: value('MentionHelp', true),
payloadTemplate: payload[1],
instructions: value('Instructions'),
};
});
(async () => {
fs.mkdirSync(output, { recursive: true });
const playwrightVersion = require('playwright/package.json').version;
const declared = JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json'))
.packages['node_modules/@playwright/test'].version;
assert.equal(playwrightVersion, declared);
assert.equal(hash(path.join('/workspace', runtimePath)), expectedHash);
const files = Object.fromEntries(
sourceFiles.map((file) => [file, hash(path.join('/workspace', file))]),
);
const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js'));
const server = await createServer({
root,
configFile: path.join(root, 'vite.config.ts'),
cacheDir: path.join(output, 'vite-cache'),
optimizeDeps: { noDiscovery: true, include: [] },
server: { host: '127.0.0.1', port: 5307, strictPort: true, watch: null },
});
const origin = 'http://127.0.0.1:5307';
const results = [],
screenshots = [],
observations = [];
let browser,
phase = 'server',
completed = false;
try {
await server.listen();
for (const [name, engine, width, dark] of [
['chromium-desktop-light', chromium, 1440, false],
['chromium-desktop-dark', chromium, 1440, true],
['webkit-phone-light', webkit, 390, false],
['webkit-phone-dark', webkit, 390, true],
]) {
phase = name;
browser = await engine.launch(
engine === chromium
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
: { headless: true },
);
const page = await browser.newPage({
viewport: { width, height: width < 500 ? 844 : 900 },
isMobile: width < 500,
hasTouch: width < 500,
locale: 'en-GB',
timezoneId: 'UTC',
});
page.setDefaultTimeout(10000);
page.setDefaultNavigationTimeout(45000);
const errors = [],
unexpectedRequests = [],
requests = [],
previewSockets = [],
checks = [];
page.on('pageerror', (error) => errors.push(error.message));
page.on('console', (message) => {
if (message.type() === 'error') errors.push(message.text());
});
await page.addInitScript((dark) => {
const apply = () => document.documentElement.classList.toggle('dark', dark);
if (document.documentElement) apply();
else document.addEventListener('DOMContentLoaded', apply, { once: true });
}, dark);
// Vite's own HMR client uses this fixed loopback endpoint. Closing it
// creates a real console error even though the form renders correctly.
// Permit that preview-only socket, not application/provider sockets;
// keep the zero-error assertion rather than filtering its diagnostic.
await page.routeWebSocket(/.*/, (socket) => {
const url = new URL(socket.url());
if (url.origin === 'ws://127.0.0.1:5307' && url.pathname === '/') {
previewSockets.push(url.pathname);
socket.connectToServer();
} else {
unexpectedRequests.push({ origin: url.origin, path: url.pathname, method: 'WebSocket' });
socket.close();
}
});
await page.route('**/*', async (route) => {
const request = route.request(),
url = new URL(request.url());
if (url.origin !== origin || request.method() !== 'GET') {
unexpectedRequests.push({ url: request.url(), method: request.method() });
return route.abort();
}
if (!url.pathname.startsWith('/api/')) return route.continue();
requests.push(url.pathname);
if (url.pathname === '/api/notifications/webhook-templates')
return route.fulfill({ json: templates });
unexpectedRequests.push({ url: request.url(), method: request.method() });
return route.abort();
});
await page.goto(`${origin}/browser-tests/webhook-composed.html`, {
waitUntil: 'networkidle',
});
await page.getByRole('button', { name: '+ Add Webhook', exact: true }).click();
await page
.getByRole('button', { name: 'Discord Discord server webhook', exact: true })
.waitFor();
for (const service of ['discord', 'telegram']) {
phase = `${name}: ${service}`;
const template = templates.find((candidate) => candidate.service === service);
if (service === 'telegram') {
const toggle = page.getByRole('button', { name: 'Discord →', exact: true });
await toggle.focus();
await page.keyboard.press('Enter');
}
const choice = page.getByRole('button', {
name: `${template.label} ${template.description}`,
exact: true,
});
if (width < 500) await choice.tap();
else {
await choice.focus();
await page.keyboard.press('Enter');
}
const heading = page.getByRole('heading', { name: 'Setup Instructions', exact: true });
await heading.waitFor();
const panel = heading.locator('..'),
instructions = panel.locator('p');
assert.equal(await instructions.textContent(), template.instructions);
assert.equal(
await page.getByLabel('Webhook URL', { exact: true }).getAttribute('placeholder'),
template.urlPattern,
);
await heading.evaluate((element) => element.scrollIntoView({ block: 'start' }));
await page.evaluate(async () => {
await document.fonts.ready;
await new Promise((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(resolve)),
);
});
const layout = await panel.evaluate((element) => {
const heading = element.querySelector('h4'),
p = element.querySelector('p');
const style = getComputedStyle(heading),
pStyle = getComputedStyle(p);
const walker = document.createTreeWalker(p, NodeFilter.SHOW_TEXT);
const node = walker.nextNode();
if (!node || node.textContent !== p.textContent)
throw new Error('Instructions must remain one complete text node');
let offset = 0;
const linePositions = p.textContent.split('\n').map((line) => {
const range = document.createRange();
range.setStart(node, offset);
range.setEnd(node, offset + 2);
offset += line.length + 1;
const bounds = range.getBoundingClientRect();
const fragments = [...range.getClientRects()].map((rect) => ({
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
}));
// A union bound can include a zero-width newline on the preceding
// line. Measure the numbered glyphs, not that whitespace fragment.
const glyphs = fragments.filter((rect) => rect.width > 0 && rect.height > 0);
if (glyphs.length !== 1)
throw new Error('Each numbered prefix needs one painted rectangle');
return {
number: line.slice(0, 2),
x: glyphs[0].x,
y: glyphs[0].y,
bounds: { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height },
fragments,
};
});
const canvas = document.createElement('canvas');
canvas.width = canvas.height = 1;
const context = canvas.getContext('2d');
context.fillStyle = style.color;
context.fillRect(0, 0, 1, 1);
const ancestorOpacity = [];
for (let current = heading; current; current = current.parentElement)
ancestorOpacity.push(getComputedStyle(current).opacity);
return {
headingClass: heading.className,
color: style.color,
alpha: context.getImageData(0, 0, 1, 1).data[3],
ancestorOpacity,
whiteSpace: pStyle.whiteSpace,
overflowWrap: pStyle.overflowWrap,
linePositions,
panelOverflow: element.scrollWidth - element.clientWidth,
documentOverflow: document.documentElement.scrollWidth - innerWidth,
};
});
observations.push({ name, service, layout });
const file = `${name}-${service}.png`;
await page.screenshot({ path: path.join(output, file), fullPage: true });
screenshots.push({ file, sha256: hash(path.join(output, file)), width, dark, service });
assert.ok(layout.headingClass.split(' ').includes('text-blue-900'));
assert.ok(layout.headingClass.split(' ').includes('dark:text-blue-100'));
assert.equal(layout.alpha, 255, 'heading colour is not translucent');
assert.ok(layout.ancestorOpacity.every((opacity) => opacity === '1'));
assert.equal(layout.whiteSpace, 'pre-line');
assert.equal(layout.overflowWrap, 'break-word');
assert.equal(layout.linePositions.length, service === 'discord' ? 4 : 5);
layout.linePositions.forEach((line, i, lines) => {
assert.equal(line.number, `${i + 1}.`);
if (i)
assert.ok(line.y > lines[i - 1].y + 5, 'numbered steps need separate rendered lines');
assert.ok(
Math.abs(line.x - lines[0].x) <= 1,
'each numbered step starts at the left edge',
);
});
assert.ok(layout.panelOverflow <= 1);
assert.ok(layout.documentOverflow <= 1);
assert.equal(await page.getByLabel('Notification operations').textContent(), '0');
checks.push({
service,
templateInstructionsExact: true,
layout,
noNotificationOperation: true,
});
}
await page.getByRole('button', { name: 'Cancel', exact: true }).click();
await page.getByRole('button', { name: '+ Add Webhook', exact: true }).waitFor();
assert.equal(
await page.getByRole('heading', { name: 'Setup Instructions', exact: true }).count(),
0,
);
assert.equal(await page.getByLabel('Notification operations').textContent(), '0');
assert.deepEqual(errors, []);
assert.deepEqual(unexpectedRequests, []);
results.push({
name,
browserVersion: browser.version(),
width,
dark,
phoneEmulated: width < 500,
checks,
cancelReturnsToAdd: true,
requests,
previewSockets,
errors,
unexpectedRequests,
});
await browser.close();
browser = undefined;
}
completed = true;
} catch (error) {
fs.writeFileSync(
path.join(output, 'failure.json'),
JSON.stringify(
{ phase, error: error.message, files, results, screenshots, observations },
null,
2,
),
);
throw error;
} finally {
if (browser) await browser.close();
await server.close();
}
if (completed) {
fs.writeFileSync(
path.join(output, 'result.json'),
JSON.stringify(
{
integration,
playwrightVersion,
declaredPlaywrightVersion: declared,
files,
results,
screenshots,
observations,
verifiedAt: new Date().toISOString(),
browserAndServerClosed: true,
limits:
'Production WebhookConfig/form/state/CSS in a standalone offline fixture. Source-exact public template fields, synthetic API. No full app navigation, real credentials, receiver, Test/save/delivery, native phone or installed acceptance.',
},
null,
2,
),
);
console.log(
`Verified ${results.length} theme/viewport states and ${screenshots.length} source-exact service captures; browser and server closed.`,
);
}
})().catch((error) => {
console.error(error);
process.exitCode = 1;
});
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Composed webhook instructions verification</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./webhook-composed.tsx"></script>
</body>
</html>
@@ -0,0 +1,27 @@
// Production form and state, source-exact template text supplied by the runner.
// No real notification receiver, credentials, save or Test operation.
import { createSignal } from 'solid-js';
import { render } from 'solid-js/web';
import { WebhookConfig } from '../src/components/Alerts/WebhookConfig';
import '../src/index.css';
function Fixture() {
const [operations, setOperations] = createSignal(0);
const unexpectedOperation = () => setOperations((count) => count + 1);
return (
<main class="mx-auto min-h-screen max-w-4xl space-y-4 bg-surface p-4 text-base-content">
<h1 class="text-lg font-semibold">Webhook setup</h1>
<p class="text-sm text-muted">Offline presentation check. No notification is sent.</p>
<WebhookConfig
webhooks={[]}
onAdd={unexpectedOperation}
onUpdate={unexpectedOperation}
onDelete={unexpectedOperation}
onTest={unexpectedOperation}
/>
<output aria-label="Notification operations">{operations()}</output>
</main>
);
}
render(() => <Fixture />, document.getElementById('root')!);
@@ -0,0 +1,43 @@
{
"version": 1,
"base_sha": "5e967c49f55d0fc2423e9d7ba63c7ed250069a87",
"verified_at": "2026-10-05T04:08:20.487Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/Alerts/WebhookConfigForm.tsx"
],
"content_sha256": {
"frontend-modern/src/components/Alerts/WebhookConfigForm.tsx": "0286a39d8405d214df78d8806f1792991a46e0583ab20d1b1fafb276b3c9160d"
},
"routes": [
"/browser-tests/webhook-composed.html (production Notifications WebhookConfig/form/state/CSS; standalone offline fixture)"
],
"viewports": [
{
"width": 1440,
"height": 900
},
{
"width": 390,
"height": 844
}
],
"states": [
"Chromium 141.0.7390.37 desktop light and dark; WebKit 26.0 emulated phone light and dark, Playwright 1.56.1 matching integration lock",
"Discord four numbered instructions and Telegram five numbered instructions exactly match the committed server catalogue in each theme/layout",
"Setup Instructions heading is solid blue-900 in light mode and blue-100 in dark mode, alpha 255 and no ancestor opacity; both improvements are composed in the verified hash",
"Each numbered prefix has a distinct painted text rectangle and left edge; pre-line whitespace and break-word wrapping preserve long Telegram URLs without panel/document horizontal overflow",
"All eight full-page captures inspected: headings, step separation, long URLs and phone form layout readable; no page/console errors or unexpected API/provider requests"
],
"interactions": [
"Open Add Webhook and select Discord using keyboard on desktop or tap on emulated phone",
"Open the service selector using keyboard Enter and switch to Telegram; source-exact instructions and URL placeholder update, existing typed/default name is preserved",
"Cancel returns to Add Webhook and removes instructions; zero add/update/delete/Test callbacks or non-GET API requests throughout"
],
"proof": {
"script": "frontend-modern/browser-tests/webhook-composed.cjs",
"script_sha256": "99f74d9ef98188510a68d47cd9508ab7001673523c83d9f3ea54c542fc94313e",
"template_source_sha256": "07b04d7ccf37e15dac8fa333c487122d013f6a364cd8dc5ce5a7ecbbb20b5243",
"limits": "Production WebhookConfig/form/state/CSS in a standalone offline fixture. Source-exact public template fields, synthetic API. No full app navigation, real credentials, receiver, Test/save/delivery, native phone or installed acceptance."
}
}
@@ -5,7 +5,7 @@ Pulse agents incorporate several security mechanisms to ensure that the code run
**Start with the least privilege that answers your monitoring question.** For
Proxmox VE, PBS, and PMG, that is usually no agent at all: API-only monitoring
with a read-only token covers inventory, status, and metrics, and the
generated setup script creates a privilege-separated monitoring user for it
generated setup script creates a privilege-separated monitoring token for it
(see [Proxmox Deployment Choices](#proxmox-deployment-choices)). Install a
host agent only where you want data the platform API cannot provide, and on
Linux consider the supported
@@ -89,6 +89,14 @@ You do not need a Pulse agent on every Proxmox-related host just to see basic
cluster inventory and utilization. Start with the least-privilege path that
answers your monitoring question:
**API-only does not mean guest-agent-free.** VM guest filesystem and memory
collection can still send active requests on the channel used by freeze-enabled
backups. Read permissions and the absence of a host agent do not prove backup
safety. For an affected installation, follow the
[backup safety precaution](VM_DISK_MONITORING.md#backup-safety), including the
monitoring/alert outage while Pulse is stopped; do not add permissions or send
manual probes during a freeze or thaw.
| Goal | Recommended path | Host privilege needed? |
|---|---|---|
| PVE/PBS/PMG inventory, node status, VM/container status, storage usage, and normal Proxmox API metrics | Add the Proxmox connection with a read-only or narrowly scoped API token | No |
+32 -12
View File
@@ -636,23 +636,43 @@ remove credentials, cookies, secret URLs and private host, network or personal
information. See [Getting Help](#-getting-help) for safe evidence collection.
### Check Permissions (Proxmox)
If Pulse can't see VMs or storage, check the user permissions on Proxmox:
```bash
pveum user permissions <user>@pam
```
At minimum, ensure the user/token has read access for inventory and metrics:
- `Sys.Audit`
- `Datastore.Audit`
If Pulse cannot see VMs, storage or guest readings, start with the **user,
realm and token ID configured for the affected Pulse connection**, not a
guessed account or your administrator login. Use the existing connection
settings; do not dump the server configuration or expose the token secret.
For VM guest agent features on PVE 9+, prefer:
With privilege separation enabled, the token's effective access is the
**intersection of the user and token permissions**: both must allow the
required privilege on the affected resource path. Ask the Proxmox
administrator to inspect both sides, including inherited ACLs and their
propagation, for that VM, storage or node. An administrator session or
user-only permission listing does not prove the token has access.
- `VM.GuestAgent.Audit` — required for disk usage and guest info
- `VM.GuestAgent.FileRead` — required for accurate memory monitoring (excludes buff/cache)
Inspect the existing denial and compare it with the relevant read privileges:
For PVE 8 only, use `VM.Monitor` instead of the `VM.GuestAgent.*` privileges.
- Inventory and metrics: `Sys.Audit` and `Datastore.Audit`, on the relevant
node/storage scope.
- VM filesystem usage and guest information on PVE 9+: `VM.GuestAgent.Audit`.
- Guest memory through `/proc/meminfo` on PVE 9+: also
`VM.GuestAgent.FileRead`.
- On PVE 8, `VM.Monitor` is the legacy guest-agent fallback, not an extra
requirement for PVE 9+.
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.
The built-in `PVEAuditor` role cannot be modified. Where a read privilege is
actually missing, use the existing narrow custom role and matching scoped
user/token ACLs. **Do not disable privilege separation, grant Administrator or
add guest execution/write privileges** to diagnose missing data. Do not rerun
setup or replace a token as a permissions test: setup can rotate an existing
credential. Make any necessary access repair in your normal maintenance
window, outside backups, then observe normal polling without manual
guest-agent probes. `Sys.Modify` is not a read-only monitoring privilege; see
[pending-update access](#proxmox-pending-update-access) before considering it.
Keep token secrets and full permission listings private. A report needs only
the relevant missing privilege and a redacted denial, not private identities,
resource paths or the complete ACL tree. Successful inventory does not prove
that guest readings are fresh, or that a guest has thawed.
**Rocky Linux / RHEL VMs**: File-read restrictions in `/etc/sysconfig/qemu-ga`
can explain missing guest memory; they do not by themselves establish why disk
@@ -128,6 +128,12 @@ procedure; a reboot interrupts its workloads.
Use the dedicated account and token actually configured for that Pulse
connection, not a guessed account name. Review their effective privileges for
the affected VM; a host-root diagnostic does not test the API token's access.
With privilege separation enabled, access is the intersection of user and token
permissions: both must allow the required read on the affected VM. Do not disable
privilege separation or recreate the token to diagnose a missing reading. See
[Check permissions](TROUBLESHOOTING.md#check-permissions-proxmox) for the scoped
inspection and repair boundary. A permitted read does not prove disk freshness,
responsiveness or thaw.
| Collection | Proxmox VE 9+ | Proxmox VE 8 |
| --- | --- | --- |
@@ -0,0 +1,124 @@
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import { renderDocMarkdown } from '../docMarkdown';
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../../..');
function renderGuide(name: string): HTMLElement {
const article = document.createElement('article');
article.innerHTML = renderDocMarkdown(
readFileSync(path.join(repoRoot, 'frontend-modern/public/docs', `${name}.md`), 'utf8'),
name,
);
return article;
}
function section(article: HTMLElement, id: string): HTMLElement {
const heading = article.querySelector(`#${id}`);
expect(heading, `missing permission guide section ${id}`).not.toBeNull();
return followingContent(heading!);
}
function followingContent(heading: Element): HTMLElement {
const content = document.createElement('div');
for (let element = heading.nextElementSibling; element; element = element.nextElementSibling) {
if (/^H[1-3]$/.test(element.tagName)) break;
content.appendChild(element.cloneNode(true));
}
return content;
}
const prose = (element: HTMLElement): string =>
(element.textContent ?? '').replace(/\s+/g, ' ').trim();
describe('shipped Proxmox permission diagnosis', () => {
it('checks the configured user and token on the affected resource, not an administrator', () => {
const content = prose(section(renderGuide('TROUBLESHOOTING'), 'check-permissions-proxmox'));
expect(content).toContain(
'user, realm and token ID configured for the affected Pulse connection',
);
expect(content).toContain('intersection of the user and token permissions');
expect(content).toContain(
'both must allow the required privilege on the affected resource path',
);
expect(content).toContain('inherited ACLs and their propagation');
expect(content).toContain('administrator session or user-only permission listing');
expect(content).toContain('does not prove the token has access');
});
it('removes the literal placeholder command that redirects and overwrites local files', () => {
const content = section(renderGuide('TROUBLESHOOTING'), 'check-permissions-proxmox');
// The old <user>@pam snippet passes bash -n, but redirects from `user`
// into `@pam` instead of passing a user ID. Inspection needs no command.
expect(content.querySelector('pre')).toBeNull();
expect(prose(content)).not.toContain('pveum user permissions');
expect(prose(content)).toContain('Inspect the existing denial');
});
it('preserves least privilege and avoids rotating credentials or probing a frozen guest', () => {
const content = prose(section(renderGuide('TROUBLESHOOTING'), 'check-permissions-proxmox'));
expect(content).toContain('Do not disable privilege separation');
expect(content).toContain('grant Administrator');
expect(content).toContain('add guest execution/write privileges');
expect(content).toContain('Do not rerun setup or replace a token as a permissions test');
expect(content).toContain('normal maintenance window, outside backups');
expect(content).toContain('normal polling without manual guest-agent probes');
expect(content).toContain('Keep token secrets and full permission listings private');
expect(content).toContain('missing privilege and a redacted denial');
expect(content).toContain('Sys.Modify is not a read-only monitoring privilege');
});
it('distinguishes active guest reads from passive inventory before recommending permissions', () => {
const content = section(renderGuide('AGENT_SECURITY'), 'proxmox-deployment-choices');
expect(prose(content)).toContain('API-only does not mean guest-agent-free');
expect(prose(content)).toContain(
'active requests on the channel used by freeze-enabled backups',
);
expect(prose(content)).toContain(
'Read permissions and the absence of a host agent do not prove backup safety',
);
const warning = Array.from(content.querySelectorAll('p')).find((paragraph) =>
paragraph.textContent?.includes('API-only does not mean guest-agent-free'),
);
const table = content.querySelector('table');
expect(table).not.toBeNull();
expect(warning?.compareDocumentPosition(table!)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
expect(warning?.querySelector('a')?.getAttribute('href')).toBe(
'/docs/VM_DISK_MONITORING#backup-safety',
);
});
it('keeps the disk-specific entry point token-aware without promising a fresh reading', () => {
const heading = Array.from(renderGuide('VM_DISK_MONITORING').querySelectorAll('h2')).find(
(element) => element.textContent === '⚙️ Permissions',
);
expect(heading).toBeDefined();
const content = followingContent(heading!);
const text = prose(content);
expect(text).toContain('intersection of user and token permissions');
expect(text).toContain('on the affected VM');
expect(text).toContain('Do not disable privilege separation or recreate the token');
expect(text).toContain('does not prove disk freshness, responsiveness or thaw');
expect(content.querySelector('a')?.getAttribute('href')).toBe(
'/docs/TROUBLESHOOTING#check-permissions-proxmox',
);
});
it('matches the existing generated token and ACL contract without running setup', () => {
// Static binding only: neither this test nor the docs create a real token.
const setup = readFileSync(
path.join(repoRoot, 'internal/api/configapi/setup_script_render.go'),
'utf8',
);
expect(setup).toContain('--privsep 1');
expect(setup).toContain('pveum aclmod / -user pulse-monitor@pve -role PVEAuditor');
expect(setup).toContain('pveum aclmod / -token "$PULSE_TOKEN_ID" -role PVEAuditor');
for (const name of ['TROUBLESHOOTING', 'VM_DISK_MONITORING', 'AGENT_SECURITY']) {
expect(readFileSync(path.join(repoRoot, 'docs', `${name}.md`))).toEqual(
readFileSync(path.join(repoRoot, 'frontend-modern/public/docs', `${name}.md`)),
);
}
});
});