mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 04:33:17 +00:00
Integrate reviewed token-aware permission help and composed webhook proof
Change-source: pulse-maintainer
This commit is contained in:
@@ -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
@@ -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,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 |
|
||||
|
||||
@@ -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`)),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user