mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-14 18:56:52 +00:00
ProxMenux modifies the host: it rewrites configuration files, installs packages, enables services. Until now nobody could say afterwards what had changed, and showing the script does not answer that question — a four-hundred-line function may alter two values, and the reader has no way to know which two. This adds the two halves of an answer. The change journal records what ProxMenux does as it does it. Eleven bash primitives capture the previous state, apply the change and record it in the same step, writing to a spool that the Monitor reads back. One hundred and thirteen functions across twenty-five scripts are instrumented, covering post-install, shared storage, security tooling, container conversions, disk operations and the PVE 8 to 9 upgrade path. The page shows the difference — rotate 7 becoming rotate 14 — and never the script. Restore and backup scripts are deliberately left out: a restore puts the host back to a state some other script already recorded. The Audit and reports page answers the other half: what state is this host in, regardless of who put it there. Forty-three checks across seven areas read the host and classify each result as critical, warning, observation, conformant, unverified or not applicable, with the evidence they read attached to each one. A declared policy lets the reader say what this particular host is expected to do — which guests must have a backup, which storages are essential — so the report judges the host against its own intent rather than a generic template. An inventory records the hardware, network and guest topology behind those readings, a comparison shows what moved between two runs, and six report profiles produce a printable document scoped to what the reader needs. Everything is available in the eight supported languages. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
254 lines
13 KiB
JavaScript
254 lines
13 KiB
JavaScript
// Render the quick-diagnosis document from the real builder, in every
|
|
// language, without a browser or an API. A short report that throws on
|
|
// click is worse than a long one that prints.
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const { createRequire } = require('node:module');
|
|
const app = path.resolve(__dirname, '../AppImage');
|
|
const appRequire = createRequire(path.join(app, 'package.json'));
|
|
const ts = appRequire('typescript');
|
|
|
|
function load(rel, imports = {}) {
|
|
const source = fs.readFileSync(path.join(app, rel), 'utf8');
|
|
const compiled = ts.transpileModule(source, { compilerOptions: {
|
|
module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020,
|
|
}}).outputText;
|
|
const module = { exports: {} };
|
|
new Function('require', 'module', 'exports', compiled)(
|
|
name => imports[name] || appRequire(name), module, module.exports);
|
|
return module.exports;
|
|
}
|
|
|
|
global.window = { location: { origin: 'http://localhost:8008' } };
|
|
const shell = load('lib/report-shell.ts');
|
|
const evidence = load('lib/evidence-format.ts');
|
|
const diagrams = load('lib/report-diagrams.ts', { './report-shell': shell });
|
|
const presentation = load('lib/audit-presentation.ts', { './evidence-format': evidence });
|
|
const doc = load('lib/audit-document.ts', {
|
|
'./report-shell': shell, './report-diagrams': diagrams,
|
|
'./audit-presentation': presentation, './evidence-format': evidence,
|
|
});
|
|
|
|
const finding = (check_id, classification, area, extra = {}) => ({
|
|
check_id, classification, area, incomplete: false, summary_key: 'attention',
|
|
summary_params: { count: '3', total: '9' }, evidence: 'raw evidence',
|
|
affected: Array.from({ length: 25 }, (_, i) => ({
|
|
name: `object-${i}`, classification, reason_key: 'hostBackupStale',
|
|
})),
|
|
...extra,
|
|
});
|
|
|
|
const FINDINGS = [
|
|
finding('backup.host_recovery', 'critical', 'backup'),
|
|
finding('system.security_updates', 'warning', 'system'),
|
|
finding('guests.autostart', 'observation', 'guests'),
|
|
finding('storage.zfs_scrub_age', 'conformant', 'storage'),
|
|
{ ...finding('system.update_chain', 'unverified', 'system'), affected: [] },
|
|
];
|
|
|
|
for (const locale of ['en', 'es', 'de', 'fr', 'it', 'pt', 'sk', 'sv']) {
|
|
const messages = JSON.parse(
|
|
fs.readFileSync(path.join(app, 'messages', locale, 'common.json')));
|
|
const t = (key, values = {}) => {
|
|
let text = key.split('.').reduce((o, k) => o?.[k], messages) ?? key;
|
|
for (const [k, v] of Object.entries(values)) text = text.replaceAll(`{${k}}`, v);
|
|
return text;
|
|
};
|
|
const input = {
|
|
profile: 'diagnostic', findings: FINDINGS, t, locale,
|
|
run: { run_id: 'r1', started_at: 1788700000, finished_at: 1788700100,
|
|
status: 'partial', metadata: {} },
|
|
inventory: { sections: { identity: { node: 'fixture' } }, unavailable: {} },
|
|
};
|
|
const html = doc.buildAuditDocument(input);
|
|
|
|
// What it must contain: the findings that ask for a decision.
|
|
assert.ok(html.includes(t('audit.checks.backup.host_recovery.title')),
|
|
`${locale}: the critical finding is missing`);
|
|
assert.ok(html.includes(t('audit.checks.system.security_updates.title')),
|
|
`${locale}: the warning is missing`);
|
|
// And the blind spot it could not read.
|
|
assert.ok(html.includes(t('audit.document.diagnosticUnread')),
|
|
`${locale}: unread readings are not declared`);
|
|
// What it must not: conformant results, observations, the annex.
|
|
assert.ok(!html.includes(t('audit.checks.storage.zfs_scrub_age.title')),
|
|
`${locale}: a conformant result reached the quick diagnosis`);
|
|
assert.ok(!html.includes(t('audit.checks.guests.autostart.title')),
|
|
`${locale}: an observation reached the quick diagnosis`);
|
|
assert.ok(!html.includes('raw evidence'),
|
|
`${locale}: the technical annex reached the quick diagnosis`);
|
|
// Long tables are cut rather than printed whole.
|
|
assert.ok(html.includes('object-7') && !html.includes('object-9'),
|
|
`${locale}: affected rows are not capped at eight`);
|
|
assert.ok(html.includes(t('audit.document.diagnosticMoreRows', { count: '17' })),
|
|
`${locale}: the cut is not declared`);
|
|
assert.ok(!html.includes('undefined') && !html.includes('audit.document.'),
|
|
`${locale}: an untranslated key or an undefined value was rendered`);
|
|
}
|
|
|
|
// With nothing to decide it says so instead of printing an empty section.
|
|
const messages = JSON.parse(fs.readFileSync(path.join(app, 'messages/en/common.json')));
|
|
const t = (key, values = {}) => {
|
|
let text = key.split('.').reduce((o, k) => o?.[k], messages) ?? key;
|
|
for (const [k, v] of Object.entries(values)) text = text.replaceAll(`{${k}}`, v);
|
|
return text;
|
|
};
|
|
const clear = doc.buildAuditDocument({
|
|
profile: 'diagnostic', findings: [FINDINGS[3]], t, locale: 'en',
|
|
run: { run_id: 'r1', started_at: 1788700000, finished_at: 1788700100, metadata: {} },
|
|
inventory: { sections: { identity: { node: 'fixture' } }, unavailable: {} },
|
|
});
|
|
assert.ok(clear.includes(t('audit.document.diagnosticClear')),
|
|
'a host with nothing to decide is not told so');
|
|
assert.ok(!clear.includes(t('audit.document.diagnosticActions')),
|
|
'an empty findings section was printed');
|
|
|
|
// A disk finding shows what happened, not six rows repeating that
|
|
// something did. The columns are the inventory's own, so the finding and
|
|
// the observation table read as one account of the disk.
|
|
{
|
|
const messages = JSON.parse(fs.readFileSync(path.join(app, 'messages/es/common.json')));
|
|
const t = (key, values = {}) => {
|
|
let text = key.split('.').reduce((o, k) => o?.[k], messages) ?? key;
|
|
for (const [k, v] of Object.entries(values)) text = text.replaceAll(`{${k}}`, v);
|
|
return text;
|
|
};
|
|
const groups = presentation.presentFinding({
|
|
check_id: 'hardware.disk_errors', classification: 'warning',
|
|
area: 'hardware', evidence: null,
|
|
affected: [
|
|
{ name: 'sdh', type: 'io_error', severity: 'critical', count: 284364,
|
|
first_seen: '2026-05-20T23:03:39', last_seen: '2026-09-07T19:31:47',
|
|
message: 'ata8.00: error: { IDNF }', classification: 'warning',
|
|
reason_key: 'diskErrorsActive' },
|
|
{ name: 'sda', type: 'smart_error', severity: 'warning', count: 13,
|
|
first_seen: 1788000000, last_seen: 1788600000, message: 'read failed',
|
|
classification: 'warning', reason_key: 'diskWarningsActive' },
|
|
],
|
|
}, t, 'es', []);
|
|
|
|
assert.equal(groups.length, 2, 'events are not grouped by device');
|
|
assert.deepEqual(groups.map(g => g.title), ['sdh', 'sda']);
|
|
assert.deepEqual(groups[0].columns, [
|
|
t('audit.document.event'), t('audit.document.severity'),
|
|
t('audit.document.occurrences'), t('audit.document.firstSeen'),
|
|
t('audit.document.lastSeen'), t('audit.document.detail'),
|
|
], 'the finding does not use the inventory table columns');
|
|
const [type, severity, count, first, last, detail] = groups[0].rows[0].cells;
|
|
assert.equal(type, 'io_error');
|
|
assert.equal(severity, t('audit.classifications.critical'),
|
|
'the stored English severity reached a translated view');
|
|
assert.equal(count, '284364');
|
|
assert.ok(first.includes('2026') && last.includes('2026'),
|
|
'ISO timestamps were not rendered as dates');
|
|
assert.equal(detail, 'ata8.00: error: { IDNF }');
|
|
assert.equal(first, new Date(2026, 4, 20, 23, 3, 39).toLocaleString('es'),
|
|
'a local SQLite timestamp was converted as UTC');
|
|
// The other Monitor tables store epoch seconds; both forms must render.
|
|
const epochRow = groups[1].rows[0].cells;
|
|
assert.ok(epochRow[3].includes('2026') && epochRow[4].includes('2026'),
|
|
'epoch timestamps were not rendered as dates');
|
|
console.log('Disk findings: inventory columns, translated severity, both date forms.');
|
|
}
|
|
|
|
// "Could not be evaluated" describes the assessment, not the host. The
|
|
// reason is recorded against each source; it used to sit two collapsed
|
|
// panels below a line that explained nothing.
|
|
{
|
|
const messages = JSON.parse(fs.readFileSync(path.join(app, 'messages/es/common.json')));
|
|
const t = (key, values = {}) => {
|
|
let text = key.split('.').reduce((o, k) => o?.[k], messages) ?? key;
|
|
for (const [k, v] of Object.entries(values)) text = text.replaceAll(`{${k}}`, v);
|
|
return text;
|
|
};
|
|
// Exactly what .55 recorded: a backup destination that refused the
|
|
// connection, which is why the age of its copies is unverified.
|
|
const line = presentation.unreadSources([
|
|
{ source: 'cmd:["pvesm", "list", "local"]', collected_at: 1788728305 },
|
|
{ source: 'cmd:["pvesm", "list", "pbs"]', collected_at: 1788728305,
|
|
error: "exit 111: pbs: error fetching datastores - 500 Can't connect to\n192.168.0.72:8007 (Connection refused)" },
|
|
], t);
|
|
assert.ok(line.startsWith(t('audit.presentation.couldNotRead')),
|
|
'the line does not say that something could not be read');
|
|
assert.ok(line.includes('pvesm list pbs'),
|
|
'the command was left in its serialised form');
|
|
assert.ok(!line.includes('cmd:['), 'the raw source key leaked into the reader\'s view');
|
|
assert.ok(line.includes('Connection refused'), 'the reason was dropped');
|
|
assert.ok(!line.includes('\n'), 'a multi-line error was not flattened');
|
|
assert.ok(!line.includes('pvesm list local'),
|
|
'a source that was read fine was listed as unreadable');
|
|
assert.equal(presentation.unreadSources([{ source: 'x', collected_at: 1 }], t), '',
|
|
'a check whose sources all worked printed an empty notice');
|
|
assert.equal(presentation.unreadSources(undefined, t), '');
|
|
console.log('Unread sources: named, flattened, only the ones that failed.');
|
|
}
|
|
|
|
// Lynis repeats a warning once per thing it applies to. Ten promiscuous
|
|
// interfaces printed as ten rows saying "NETW-3015 · —" described none
|
|
// of them; collapsed, each row carries a warning and how often it was
|
|
// raised.
|
|
{
|
|
const messages = JSON.parse(fs.readFileSync(path.join(app, 'messages/es/common.json')));
|
|
const t = (key, values = {}) => {
|
|
let text = key.split('.').reduce((o, k) => o?.[k], messages) ?? key;
|
|
for (const [k, v] of Object.entries(values)) text = text.replaceAll(`{${k}}`, v);
|
|
return text;
|
|
};
|
|
const warn = (test, message, details = '') => ({
|
|
test, message, details, classification: 'observation', reason_key: 'lynisWarning' });
|
|
const finding = affected => ({ check_id: 'security.lynis_warnings',
|
|
classification: 'observation', area: 'security', evidence: null, affected });
|
|
|
|
const plain = presentation.presentFinding(finding([
|
|
warn('PKGS-7392', 'Found one or more vulnerable packages.'),
|
|
...Array.from({ length: 10 }, () => warn('NETW-3015', 'Found promiscuous interface')),
|
|
warn('MAIL-8818', 'SMTP banner discloses software'),
|
|
]), t, 'es', []);
|
|
assert.equal(plain.length, 1, 'warnings are still split into a group each');
|
|
assert.equal(plain[0].rows.length, 3, '12 warnings did not collapse to 3 rows');
|
|
assert.deepEqual(plain[0].columns, [t('audit.presentation.lynisTest'),
|
|
t('audit.presentation.lynisWarning'), t('audit.document.occurrences')],
|
|
'a detail column was printed with nothing to put in it');
|
|
const promiscuous = plain[0].rows.find(r => r.cells[0] === 'NETW-3015');
|
|
assert.equal(promiscuous.cells[2], '10', 'repetitions were not counted');
|
|
|
|
// Where Lynis names what it found, the names are kept and joined.
|
|
const named = presentation.presentFinding(finding([
|
|
warn('NETW-3015', 'Found promiscuous interface', 'ens4f0'),
|
|
warn('NETW-3015', 'Found promiscuous interface', 'eno1'),
|
|
]), t, 'es', []);
|
|
assert.equal(named[0].columns.length, 4, 'the detail column is missing');
|
|
assert.equal(named[0].rows[0].cells[3], 'ens4f0, eno1');
|
|
console.log('Lynis warnings: one row per warning, repetitions counted, names kept.');
|
|
}
|
|
|
|
// The inventory profile is the other short document: structure and
|
|
// configuration, with nothing assessed. An assessment summary counting
|
|
// nothing and a findings section listing nothing are two empty frames
|
|
// around the only thing its reader opened it for.
|
|
const structure = doc.buildAuditDocument({
|
|
profile: 'inventory', findings: [], t, locale: 'en',
|
|
run: { run_id: 'r1', started_at: 1788700000, finished_at: 1788700100, metadata: {} },
|
|
inventory: { sections: {
|
|
identity: { node: 'fixture', pve_version: '9.2.4' },
|
|
cluster: { member: false },
|
|
hardware: { cpu_model: 'Xeon', memory_total: 1, disks: [],
|
|
memory_modules: [], controllers: [] },
|
|
network: { bridges: {}, adapters: [] },
|
|
}, unavailable: {} },
|
|
});
|
|
assert.ok(structure.includes(t('audit.document.structureTitle')),
|
|
'the structure report is still titled as an audit');
|
|
assert.ok(!structure.includes(t('audit.document.executiveSummary')),
|
|
'an assessment summary counting nothing was printed');
|
|
assert.ok(!structure.includes(t('audit.document.findings')),
|
|
'a findings section listing nothing was printed');
|
|
assert.ok(!structure.includes(t('audit.presentation.annex')),
|
|
'the technical annex was printed with no evidence to carry');
|
|
assert.ok(structure.includes(t('audit.document.scope')),
|
|
'the structure report does not say what it covers');
|
|
console.log('Structure report: no assessment frames, own title, scope kept.');
|
|
|
|
console.log('Quick diagnosis: eight languages, only what needs deciding, capped tables, declared blind spots and cuts.');
|