From da8a480eff3afd1f3a91134b535f2a6e0ecbe633 Mon Sep 17 00:00:00 2001 From: MacRimi Date: Tue, 8 Sep 2026 21:06:04 +0200 Subject: [PATCH] Add audit and reports page, and a change journal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .github/scripts/build_translation_cache.py | 10 +- AppImage/components/audit-changes.tsx | 323 ++ AppImage/components/audit-comparison.tsx | 241 ++ AppImage/components/audit-evidence.tsx | 113 + AppImage/components/audit-finding-data.tsx | 34 + AppImage/components/audit-inventory.tsx | 760 ++++ AppImage/components/audit-policy.tsx | 419 ++ AppImage/components/audit-report.tsx | 459 ++- AppImage/components/lxc-app-panel.tsx | 72 +- AppImage/components/proxmox-dashboard.tsx | 1 + AppImage/components/security.tsx | 40 +- AppImage/components/virtual-machines.tsx | 79 +- AppImage/lib/audit-document.ts | 965 +++++ AppImage/lib/audit-presentation.ts | 325 ++ AppImage/lib/evidence-format.ts | 290 ++ AppImage/lib/report-diagrams.ts | 556 +++ AppImage/lib/report-shell.ts | 496 +++ AppImage/messages/de/common.json | 978 ++++- AppImage/messages/en/common.json | 946 ++++- AppImage/messages/es/common.json | 972 ++++- AppImage/messages/fr/common.json | 974 ++++- AppImage/messages/it/common.json | 978 ++++- AppImage/messages/pt/common.json | 974 ++++- AppImage/messages/sk/common.json | 1040 ++++- AppImage/messages/sv/common.json | 974 ++++- AppImage/scripts/audit_checks.py | 467 ++- AppImage/scripts/audit_checks_pve.py | 3653 ++++++++++++++++- AppImage/scripts/audit_inventory.py | 844 ++++ AppImage/scripts/audit_policy.py | 361 ++ AppImage/scripts/audit_profiles.py | 125 + AppImage/scripts/audit_store.py | 331 +- AppImage/scripts/auth_manager.py | 2 + AppImage/scripts/build_appimage.sh | 6 + AppImage/scripts/changes_journal.py | 367 ++ AppImage/scripts/flask_audit_routes.py | 232 +- AppImage/scripts/flask_auth_routes.py | 16 + AppImage/scripts/flask_server.py | 13 +- AppImage/scripts/health_monitor.py | 16 +- AppImage/scripts/lxc_apps.py | 186 +- AppImage/scripts/notification_events.py | 184 +- AppImage/scripts/notification_manager.py | 27 +- AppImage/scripts/notification_templates.py | 76 + AppImage/scripts/security_manager.py | 39 +- .../scripts/tests/test_auth_manager_setup.py | 100 + .../test_cluster_guest_storage_ownership.py | 129 + .../tests/test_kernel_trace_notifications.py | 86 + .../tests/test_lxc_app_notification_batch.py | 140 + ...t_notification_burst_toggle_inheritance.py | 66 + lang/de.json | 86 +- lang/es.json | 86 +- lang/fr.json | 70 +- lang/it.json | 68 +- lang/pt.json | 68 +- lang/sk.json | 16 +- lang/sv.json | 16 +- scripts/emergency_repair.sh | 11 +- scripts/global/common-functions.sh | 23 +- scripts/global/pci_passthrough_helpers.sh | 43 +- scripts/global/pmx_journal.sh | 416 ++ scripts/global/remove-banner-pve-v3.sh | 7 +- scripts/global/remove-banner-pve8.sh | 20 +- scripts/global/update-pve8.sh | 35 +- scripts/global/update-pve9_2.sh | 38 +- scripts/global/utils-install-functions.sh | 29 +- scripts/global/vm_storage_helpers.sh | 12 +- scripts/lxc/lxc-privileged-to-unprivileged.sh | 21 +- scripts/lxc/lxc-unprivileged-to-privileged.sh | 18 +- scripts/menus/config_menu.sh | 23 +- scripts/menus/network_menu.sh | 12 +- scripts/post_install/auto_post_install.sh | 177 +- .../post_install/customizable_post_install.sh | 385 +- scripts/post_install/uninstall-tools.sh | 179 +- scripts/security/fail2ban_installer.sh | 82 +- scripts/security/lynis_installer.sh | 27 +- scripts/share/disk_host.sh | 63 +- scripts/share/iscsi_host.sh | 25 +- scripts/share/local-shared-manager.sh | 13 + scripts/share/lxc-mount-manager_minimal.sh | 41 +- scripts/share/nfs_client.sh | 34 +- scripts/share/nfs_host.sh | 44 +- scripts/share/nfs_lxc_server.sh | 38 +- scripts/share/samba_client.sh | 35 +- scripts/share/samba_host.sh | 55 +- scripts/share/samba_lxc_server.sh | 24 +- scripts/storage/add_controller_nvme_vm.sh | 41 +- scripts/storage/disk-passthrough_ct.sh | 34 +- scripts/storage/format-disk.sh | 16 +- scripts/utilities/export_vm_ova_ovf.sh | 14 + scripts/utilities/import_vm_ova_ovf.sh | 17 +- scripts/utilities/upgrade_pve8_to_pve9.sh | 37 +- tests/journal/verify_journal_migration.py | 296 ++ .../lxc_updates/test_docker_delegated_ui.cjs | 10 + tests/test_audit_api.py | 113 + tests/test_audit_catalog.py | 653 +++ tests/test_audit_diagnostic_document.cjs | 253 ++ tests/test_audit_policy.cjs | 122 + tests/test_audit_policy.py | 126 + tests/test_audit_policy_api.py | 64 + tests/test_audit_presentation.cjs | 159 + tests/test_audit_presentation.py | 75 + tests/test_audit_report.py | 585 +++ tests/test_audit_summary.cjs | 81 + 102 files changed, 24118 insertions(+), 1403 deletions(-) create mode 100644 AppImage/components/audit-changes.tsx create mode 100644 AppImage/components/audit-comparison.tsx create mode 100644 AppImage/components/audit-evidence.tsx create mode 100644 AppImage/components/audit-finding-data.tsx create mode 100644 AppImage/components/audit-inventory.tsx create mode 100644 AppImage/components/audit-policy.tsx create mode 100644 AppImage/lib/audit-document.ts create mode 100644 AppImage/lib/audit-presentation.ts create mode 100644 AppImage/lib/evidence-format.ts create mode 100644 AppImage/lib/report-diagrams.ts create mode 100644 AppImage/lib/report-shell.ts create mode 100644 AppImage/scripts/audit_inventory.py create mode 100644 AppImage/scripts/audit_policy.py create mode 100644 AppImage/scripts/audit_profiles.py create mode 100644 AppImage/scripts/changes_journal.py create mode 100644 AppImage/scripts/tests/test_cluster_guest_storage_ownership.py create mode 100644 AppImage/scripts/tests/test_kernel_trace_notifications.py create mode 100644 AppImage/scripts/tests/test_lxc_app_notification_batch.py create mode 100644 AppImage/scripts/tests/test_notification_burst_toggle_inheritance.py create mode 100644 scripts/global/pmx_journal.sh create mode 100644 tests/journal/verify_journal_migration.py create mode 100644 tests/test_audit_api.py create mode 100644 tests/test_audit_catalog.py create mode 100644 tests/test_audit_diagnostic_document.cjs create mode 100644 tests/test_audit_policy.cjs create mode 100644 tests/test_audit_policy.py create mode 100644 tests/test_audit_policy_api.py create mode 100644 tests/test_audit_presentation.cjs create mode 100644 tests/test_audit_presentation.py create mode 100644 tests/test_audit_report.py create mode 100644 tests/test_audit_summary.cjs diff --git a/.github/scripts/build_translation_cache.py b/.github/scripts/build_translation_cache.py index 394d2784..047b2b06 100644 --- a/.github/scripts/build_translation_cache.py +++ b/.github/scripts/build_translation_cache.py @@ -179,7 +179,15 @@ def translate_google_web(text: str, dest_lang: str, context: str, timeout: int) req = Request(url, headers={"User-Agent": "ProxMenux translation cache builder"}) with urlopen(req, timeout=timeout) as response: payload = json.loads(response.read().decode("utf-8")) - return "".join(part[0] for part in payload[0] if part and part[0]) + parts = [part[0] for part in payload[0] if part and part[0]] + # The endpoint returns one segment per sentence and drops the blank that + # separated them, so joining verbatim glues a period to the next word. + joined = "" + for part in parts: + if joined and joined[-1] in ".?!" and part[:1].isalpha() and part[:1].isupper(): + joined += " " + joined += part + return joined def translate_appimage( diff --git a/AppImage/components/audit-changes.tsx b/AppImage/components/audit-changes.tsx new file mode 100644 index 00000000..9a98ea99 --- /dev/null +++ b/AppImage/components/audit-changes.tsx @@ -0,0 +1,323 @@ +"use client" + +import { useCallback, useEffect, useMemo, useState } from "react" +import { Card, CardContent, CardHeader, CardTitle } from "./ui/card" +import { Badge } from "./ui/badge" +import { + ChevronDown, ChevronRight, FileCode, HelpCircle, Loader2, Package, + Play, Settings2, +} from "lucide-react" +import { fetchApi } from "../lib/api-config" +import { useT, useI18n } from "../lib/i18n/provider" + +/** + * What ProxMenux changed on this host. + * + * The complaint this answers is not that the tool changes things: it is + * that afterwards nobody can say what it changed. Showing the script + * does not answer it either — a function of four hundred lines may alter + * two values, and the reader cannot tell which two. So what is shown + * here is the difference and nothing else. + * + * Two distinctions are kept in front of the reader, because both bear on + * what they can do about what they are looking at: whether ProxMenux + * authored the change or merely ran something the user asked for, and + * how much of the previous state is actually known. + */ + +interface Change { + id: number + recorded_at: number + class: string + operation: string + source: string + function: string + function_version: string + target: string + before_ref: string + after_ref: string + capture: string + revert: string + exactness: string + result: string + recoverable: boolean + detail: Record + diff?: { + available: boolean; reason?: string + added?: number; removed?: number; truncated?: boolean; hunks?: string[] + } | null +} + +interface Summary { + total: number + by_class: Record + functions: Array<{ + function: string; source: string; version: string + changes: number; last_change: number; first_change: number + }> + journal_started: number | null +} + +const CLASS_STYLE: Record = { + configuration: { chip: "bg-blue-500/10 text-blue-400 border-blue-400/20", Icon: Settings2 }, + installation: { chip: "bg-green-500/10 text-green-500 border-green-500/20", Icon: Package }, + execution: { chip: "bg-muted text-muted-foreground border-border", Icon: Play }, + registration: { chip: "bg-muted text-muted-foreground border-border", Icon: HelpCircle }, +} + +export function AuditChanges() { + const t = useT() + const { language } = useI18n() + const [changes, setChanges] = useState([]) + const [summary, setSummary] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [open, setOpen] = useState>(new Set()) + const [filter, setFilter] = useState("all") + + const load = useCallback(async () => { + try { + const res: any = await fetchApi("/api/audit/changes?limit=500") + if (res?.success) { + setChanges(res.changes || []) + setSummary(res.summary || null) + setError(null) + } else { + setError(res?.message || t("audit.changes.failed")) + } + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + } finally { + setLoading(false) + } + }, [t]) + + useEffect(() => { load() }, [load]) + + const toggle = (id: number) => setOpen((prev) => { + const next = new Set(prev) + next.has(id) ? next.delete(id) : next.add(id) + return next + }) + + const visible = useMemo( + () => changes.filter((c) => filter === "all" || c.class === filter), + [changes, filter], + ) + + const when = (epoch: number) => new Date(epoch * 1000).toLocaleString(language) + + if (loading) { + return ( +
+ {t("audit.changes.loading")} +
+ ) + } + if (error) return

{error}

+ + return ( +
+ + +

{t("audit.changes.intro")}

+ {/* A host with nothing recorded should say why, rather than + looking like a host nothing has touched. */} + {summary && summary.total === 0 && ( +

{t("audit.changes.empty")}

+ )} + {summary && summary.journal_started && ( +

+ {t("audit.changes.since", { date: when(summary.journal_started) })} +

+ )} +
+ {(["all", "configuration", "installation", "execution", "registration"] as const) + .filter((key) => key === "all" || summary?.by_class?.[key]) + .map((key) => ( + + ))} +
+
+
+ + {summary && summary.functions.length > 0 && ( + + + + {t("audit.changes.byFunction")} + + + + {summary.functions.map((fn) => ( + + ))} + + + )} + +
+ {visible.map((change) => { + const style = CLASS_STYLE[change.class] || CLASS_STYLE.registration + const Icon = style.Icon + const expanded = open.has(change.id) + const installed = String(change.detail?.installed || "") + return ( + + + + {expanded && ( + +
+ {t("audit.changes.function")}:{" "} + {change.function || "—"} + {change.function_version && ` v${change.function_version}`} + + {t("audit.changes.source")}:{" "} + {change.source || "—"} + {t("audit.changes.reversibility")}:{" "} + {t(`audit.changes.exactness.${change.exactness}`)} +
+ + {installed && ( +
+

+ {t("audit.changes.packagesAdded")} +

+
+ {installed.split(/\s+/).filter(Boolean).map((pkg) => ( + + {pkg} + + ))} +
+
+ )} + + {change.class === "execution" && Boolean(change.detail?.command) && ( +
+

+ {t("audit.changes.commandRun")} +

+
+                        {String(change.detail.command)}
+                      
+

+ {t("audit.changes.executionNote")} +

+
+ )} + + {change.diff && ( +
+

+ {t("audit.changes.difference")} +

+ {change.diff.available ? ( + <> +
+                            {(change.diff.hunks || []).map((line: string, i: number) => (
+                              
{line}
+ ))} +
+ {change.diff.truncated && ( +

+ {t("audit.changes.diffTruncated")} +

+ )} + + ) : ( +

+ {t("audit.changes.diffUnavailable")} +

+ )} +
+ )} + + {change.capture === "unknown" && ( +

+ {t("audit.changes.unknownNote")} +

+ )} +
+ )} +
+ ) + })} + {visible.length === 0 && summary && summary.total > 0 && ( +

{t("audit.changes.noneInFilter")}

+ )} +
+
+ ) +} diff --git a/AppImage/components/audit-comparison.tsx b/AppImage/components/audit-comparison.tsx new file mode 100644 index 00000000..55a33f5a --- /dev/null +++ b/AppImage/components/audit-comparison.tsx @@ -0,0 +1,241 @@ +"use client" + +import { useCallback, useEffect, useState } from "react" +import { Badge } from "./ui/badge" +import { Button } from "./ui/button" +import { + ChevronDown, ChevronRight, Flag, Loader2, MinusCircle, + PlusCircle, ShieldOff, TrendingUp, +} from "lucide-react" +import { fetchApi } from "../lib/api-config" +import { useT, useI18n } from "../lib/i18n/provider" + +/** + * How this assessment differs from an earlier one. + * + * A single run says what the host is like now. It cannot say whether + * that is better or worse than last week, which is the question anyone + * maintaining a machine actually asks — and the one that turns an audit + * from a snapshot into a record. + * + * The distinction the engine draws and this view keeps: a finding that + * stopped being reported because the host was fixed is not the same as + * one that stopped because somebody accepted it. Both leave the list; + * only the first is progress, and merging them would tell the reader a + * problem went away when the decision was to live with it. + * + * It sits inside the assessment rather than in a view of its own, + * because "what changed since last time" is context for the run being + * read, not a separate place to visit. + */ + +interface Finding { + check_id: string + area: string + classification: string +} + +interface Comparison { + from: string + to: string + new: Finding[] + resolved: Finding[] + accepted: Finding[] + unchanged: Finding[] + retired: Finding[] + unverified: Finding[] +} + +const GROUPS = [ + { key: "new", Icon: PlusCircle, tone: "text-amber-500" }, + { key: "resolved", Icon: MinusCircle, tone: "text-green-500" }, + { key: "accepted", Icon: ShieldOff, tone: "text-indigo-400" }, + { key: "retired", Icon: Flag, tone: "text-muted-foreground" }, +] as const + +export function AuditComparison({ runId, isBaseline, onBaselineSet }: { + runId: string + isBaseline: boolean + onBaselineSet: () => void +}) { + const t = useT() + const { language } = useI18n() + const [comparison, setComparison] = useState(null) + const [baseline, setBaseline] = useState(null) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [open, setOpen] = useState(false) + const [error, setError] = useState(null) + + // fetchApi turns a non-2xx response into an Error whose message is the + // backend's own English prose and whose `body` carries the parsed + // payload. Both paths therefore go through here: only the reason code + // crosses into a view that exists in eight languages. + const reason = (source: any): string => { + const code = String(source?.reason ?? source?.body?.reason ?? "") + const key = `audit.comparison.reasons.${code}` + const translated = t(key) + return translated !== key ? translated : t("audit.comparison.failed") + } + + const load = useCallback(async () => { + setLoading(true) + try { + // Asked for separately: a host with a single run answers the + // comparison with an error, and inside a Promise.all that error + // takes the status with it — leaving no way to tell a first + // assessment from a comparison that genuinely failed, which is + // how "two runs are required" reached a reader who had simply + // never chosen a reference. + const status: any = await fetchApi("/api/audit/status").catch(() => null) + setBaseline(status?.baseline || null) + + // With no reference chosen there is nothing to compare against, + // and asking anyway answered 400 — an error in the browser console + // for the ordinary state of a host assessed for the first time. + let diff: any = null + let failure: unknown = null + if (status?.baseline) { + try { + diff = await fetchApi(`/api/audit/compare?to=${encodeURIComponent(runId)}`) + } catch (e) { + failure = e + } + } + setComparison(diff?.success ? diff : null) + // Having no reference run yet is the ordinary state of a host + // assessed for the first time, and the view already says so. + const failed = failure ?? (diff?.success === false ? diff : null) + setError(failed && status?.baseline ? reason(failed) : null) + } finally { + setLoading(false) + } + }, [runId]) + + useEffect(() => { load() }, [load]) + + const markBaseline = async () => { + setSaving(true) + try { + const res: any = await fetchApi("/api/audit/baseline", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ run_id: runId }), + }) + if (res?.success) { onBaselineSet(); await load() } + else setError(reason(res)) + } catch (e) { + setError(reason(e)) + } finally { + setSaving(false) + } + } + + if (loading) { + return ( +

+ + {t("audit.comparison.loading")} +

+ ) + } + + const counts = comparison + ? GROUPS.map((g) => ({ ...g, n: (comparison[g.key] || []).length })) + .filter((g) => g.n > 0) + : [] + const comparable = comparison && comparison.from !== comparison.to + + return ( +
+ {error &&

{error}

} + + {!comparable ? ( +

+ {baseline ? t("audit.comparison.isBaseline") : t("audit.comparison.noBaseline")} +

+ ) : ( + <> + + + {open && ( +
+ {GROUPS.filter((g) => (comparison[g.key] || []).length > 0).map( + ({ key, Icon, tone }) => ( +
+

+ + {t(`audit.comparison.${key}`)} + + — {t(`audit.comparison.${key}Note`)} + +

+
+ {(comparison[key] || []).map((f) => ( + + {t(`audit.checks.${f.check_id}.title`)} + + ))} +
+
+ ), + )} + {(comparison.unchanged || []).length > 0 && ( +

+ {t("audit.comparison.unchanged", { + count: String(comparison.unchanged.length), + })} +

+ )} +
+ )} + + )} + + {/* Choosing a reference is what makes every later run comparable, + so the action lives beside the comparison it enables. */} + {!isBaseline && ( + + )} +
+ ) +} diff --git a/AppImage/components/audit-evidence.tsx b/AppImage/components/audit-evidence.tsx new file mode 100644 index 00000000..45ff664c --- /dev/null +++ b/AppImage/components/audit-evidence.tsx @@ -0,0 +1,113 @@ +"use client" + +import { parseEvidence, type EvidenceBlock } from "../lib/evidence-format" + +/** + * Renders a finding's evidence as tables and labelled values. + * + * The evidence exists so a reader can verify the conclusion for + * themselves. A JSON dump technically contains the same facts but asks + * the reader to parse it first, which is the part they came here to + * avoid. + */ +export function AuditEvidence({ evidence, locale }: { + evidence: string | null + locale: string +}) { + const blocks = parseEvidence(evidence, locale) + if (blocks.length === 0) return null + + return ( +
+ {blocks.map((block, i) => )} +
+ ) +} + +function Block({ block }: { block: EvidenceBlock }) { + const title = block.title + ?

{block.title}

+ : null + + if (block.kind === "table") { + return ( +
+ {title} + {/* Wide evidence scrolls inside its own box so the page itself + never scrolls sideways. */} + {/* Evidence is the widest thing on the page; on a narrow screen + it stacks like the rest rather than scrolling sideways. */} +
+ {block.rows.map((row, i) => ( +
+ {row.map((cell, j) => cell === "—" || cell === "" ? null : ( +
+ + {block.columns[j]} + + {cell} +
+ ))} +
+ ))} +
+
+ + + + {block.columns.map((c) => ( + + ))} + + + + {block.rows.map((row, i) => ( + + {row.map((cell, j) => ( + + ))} + + ))} + +
+ {c} +
+ {cell} +
+
+
+ ) + } + + if (block.kind === "pairs") { + return ( +
+ {title} +
+ {block.entries.map(([label, value]) => ( +
+
{label}
+
{value}
+
+ ))} +
+
+ ) + } + + return ( +
+ {title} + {block.lines.length > 0 && ( +
    + {block.lines.map((line, i) => ( +
  • {line}
  • + ))} +
+ )} +
+ ) +} diff --git a/AppImage/components/audit-finding-data.tsx b/AppImage/components/audit-finding-data.tsx new file mode 100644 index 00000000..b4497a2c --- /dev/null +++ b/AppImage/components/audit-finding-data.tsx @@ -0,0 +1,34 @@ +"use client" + +import { presentFinding, type PresentedFinding, type AuditTranslate } from "../lib/audit-presentation" + +export function AuditFindingData({ finding, t, locale }: { + finding: PresentedFinding; t: AuditTranslate; locale: string +}) { + return
{presentFinding(finding, t, locale).map((group, index) => +
+

{group.title}

+ {group.note &&

{group.note}

} + {/* Wide on a screen that has the width; stacked where it does + not, so a heading stays beside the value it belongs to instead + of scrolling away from it. */} +
+ + {group.columns.map((column, i) => + )} + {group.rows.map((row, i) => + {row.cells.map((cell, j) => )} + )} +
{column}
{cell}
+
+
{group.rows.map((row, i) => +
+ {row.cells.map((cell, j) => cell === "" || cell === "—" ? null : ( +
+ {group.columns[j]} + {cell} +
+ ))} +
)}
+
)}
+} diff --git a/AppImage/components/audit-inventory.tsx b/AppImage/components/audit-inventory.tsx new file mode 100644 index 00000000..eb4e322c --- /dev/null +++ b/AppImage/components/audit-inventory.tsx @@ -0,0 +1,760 @@ +"use client" + +import { useCallback, useEffect, useMemo, useState } from "react" +import { Card, CardContent, CardHeader, CardTitle } from "./ui/card" +import { Badge } from "./ui/badge" +import { + Activity, Boxes, ChevronDown, ChevronRight, CircuitBoard, Cpu, HardDrive, + Loader2, MemoryStick, Network, Package, Plug, Server, Share2, Wrench, +} from "lucide-react" +import { fetchApi } from "../lib/api-config" +import { useT, useI18n } from "../lib/i18n/provider" +import { subscriptionLabel } from "../lib/audit-presentation" + +interface UplinkHop { kind: string; id: string; mode?: string; role?: string } +interface GuestDisk { + slot: string; storage: string | null; volume: string + size: string; passthrough?: boolean +} +interface GuestNic { + slot: string; name: string; bridge: string; mac: string; vlan: string + uplink: UplinkHop[] | null +} +interface GuestBackup { job: string; storage: string; schedule: string; retention: string } +interface Guest { + vmid: number; type: string; name: string; cores: string; memory: string + ostype: string; onboot: boolean; tags: string; protected: boolean + unprivileged: boolean | null; features: string | null + agent: boolean | null; cpu: string | null + disks: GuestDisk[]; interfaces: GuestNic[]; backups: GuestBackup[] +} +interface Inventory { + collected_at: number + node: string + unavailable: Record + sections: { + identity?: Record + cluster?: any + hardware?: any + storages?: any[] + guests?: Guest[] + passthrough?: any[] + applications?: any[] + custom_links?: any[] + proxmenux?: { optimizations: any[]; pending_updates: any[] } + network?: { bridges: Record } | null + latency?: { window: string; targets: any[] } | null + } +} + +const GiB = 1024 ** 3 + +function bytes(value: number | null | undefined): string { + if (!value || value <= 0) return "—" + const units = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"] + let n = value, i = 0 + while (n >= 1024 && i < units.length - 1) { n /= 1024; i++ } + return `${n >= 100 || i < 2 ? Math.round(n) : n.toFixed(1)} ${units[i]}` +} + +function Field({ label, value }: { label: string; value: React.ReactNode }) { + if (value === null || value === undefined || value === "") return null + return ( +
+

{label}

+

{value}

+
+ ) +} + +/** + * One table shape for the whole view. + * + * The inventory is read across as much as down — a disk's model beside + * its bus beside its wear — so the sections that enumerate things share + * a single table rather than each inventing its own row layout. + * + * On a narrow screen the same rows are stacked instead. A disk table is + * eight columns wide; sideways scrolling technically fits it on a phone, + * but reading a row then means dragging back and forth to pair each + * value with its heading. Stacked, the heading travels with the value. + */ +function DataTable({ columns, rows }: { + columns: string[] + rows: React.ReactNode[][] +}) { + if (rows.length === 0) return null + return ( + <> +
+ + + + {columns.map((c, i) => ( + + ))} + + + + {rows.map((row, i) => ( + + {row.map((cell, j) => ( + + ))} + + ))} + +
{c}
{cell}
+
+ +
+ {rows.map((row, i) => ( +
+ {row.map((cell, j) => { + // A cell with nothing in it would leave a heading standing + // alone, which reads as missing data rather than as absent. + if (cell === null || cell === undefined || cell === "" || cell === "—") return null + return ( +
+ {columns[j]} + + {cell} + +
+ ) + })} +
+ ))} +
+ + ) +} + +function Mono({ children }: { children: React.ReactNode }) { + return {children} +} + +function Section({ + icon, title, count, children, note, +}: { + icon: React.ReactNode; title: string; count?: number + children: React.ReactNode; note?: string +}) { + const [open, setOpen] = useState(true) + return ( + + + + + {open && ( + + {note &&

{note}

} + {children} +
+ )} +
+ ) +} + +/** A heading inside a section, matching the printed document's. */ +function Sub({ icon, title, note }: { + icon: React.ReactNode; title: string; note?: string +}) { + return ( +

+ {icon}{title} + {note && — {note}} +

+ ) +} + +// The uplink is the point of the network section: a bridge on its own +// says nothing, the path from it to the wire is what an operator needs. +function Uplink({ hops }: { hops: UplinkHop[] | null }) { + const t = useT() + if (hops === null) { + return {t("audit.inventory.unresolved")} + } + if (hops.length === 0) { + return {t("audit.inventory.noUplink")} + } + return ( + + {hops.map((h, i) => ( + + {i > 0 && } + + {h.id}{h.mode ? ` · ${h.mode}` : ""} + + + ))} + + ) +} + +export function AuditInventory({ profile = "full" }: { profile?: string }) { + const t = useT() + const { language } = useI18n() + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [openGuest, setOpenGuest] = useState>(new Set()) + + const load = useCallback(async () => { + try { + const res: any = await fetchApi( + `/api/audit/inventory?profile=${encodeURIComponent(profile)}`) + if (res?.success) { setData(res.inventory); setError(null) } + else setError(res?.message || t("audit.inventory.failed")) + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + } finally { setLoading(false) } + }, [t, profile]) + + useEffect(() => { load() }, [load]) + + const apps = useMemo(() => { + const byGuest = new Map() + for (const a of data?.sections.applications || []) { + if (!byGuest.has(a.vmid)) byGuest.set(a.vmid, []) + byGuest.get(a.vmid)!.push(a) + } + return byGuest + }, [data]) + + if (loading) { + return ( +
+ {t("audit.inventory.loading")} +
+ ) + } + if (error) return

{error}

+ if (!data) return null + + const s = data.sections + const hw = s.hardware || {} + const mem = hw.memory || {} + const when = (value: number | string | null | undefined) => { + if (!value) return "—" + const date = typeof value === "number" + ? new Date(value * 1000) + : new Date(/[Z+]|[+-]\d\d:?\d\d$/.test(value) ? value : `${value}Z`) + return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString(language) + } + const toggle = (vmid: number) => setOpenGuest((prev) => { + const next = new Set(prev) + next.has(vmid) ? next.delete(vmid) : next.add(vmid) + return next + }) + + return ( +
+

+ {t("audit.inventory.collectedAt", { + when: new Date(data.collected_at * 1000).toLocaleString(language), + })} +

+ + {/* Sections that could not be read are named, so an empty list is + never mistaken for a section that was read and found nothing. */} + {Object.keys(data.unavailable || {}).length > 0 && ( + + +

+ {t("audit.inventory.unavailable")} +

+ {Object.entries(data.unavailable).map(([k, v]) => ( +

+ {k} — {v} +

+ ))} +
+
+ )} + + {s.identity && ( +
} title={t("audit.inventory.identity")}> +
+ + + + + + +
+
+ )} + + {"cluster" in s && ( +
} title={t("audit.document.cluster")} + count={s.cluster ? (s.cluster.nodes || []).length : undefined} + note={s.cluster ? undefined : t("audit.document.standaloneNote")}> + {s.cluster && ( + <> +
+ + + {t(s.cluster.quorate ? "audit.document.quorate" + : "audit.document.inquorate")} + + )} /> + +
+ [ + + {n.name} + {n.local && + {" "}({t("audit.document.thisNode")})} + , + {n.nodeid || "—"}, + {n.ring0_addr || "—"}, + {n.ring1_addr || "—"}, + n.online === false + ? + {t("audit.document.unreachable")} + : n.online === true + ? {t("audit.document.member")} + : "—", + ])} + /> + + )} +
+ )} + + {s.hardware && ( +
} title={t("audit.document.architecture")}> +
+ + + + + + + + +
+ + {(mem.modules || []).length > 0 && ( + <> + } + title={t("audit.document.memoryModules")} + note={t("audit.document.slotsFilled", { + used: String(mem.populated ?? 0), + total: String(mem.slots ?? mem.populated ?? 0), + })} /> + [ + {m.locator || "—"}, m.size || "—", m.type || "—", + m.form_factor || "—", m.speed || "—", + [m.manufacturer, m.part_number].filter(Boolean).join(" · ") || "—", + ])} + /> + + )} + + {(hw.controllers || []).length > 0 && ( + <> + } + title={t("audit.document.controllers")} /> + [ + {c.slot}, c.class, c.name, + ])} + /> + + )} +
+ )} + + {(hw.disks || []).length > 0 && ( +
} title={t("audit.document.storageDevices")} + count={(hw.disks || []).length}> + { + const ok = ["passed", "healthy", "ok"].includes(String(d.health).toLowerCase()) + return [ + {d.name}, + d.model || "—", + {d.serial || "—"}, + bytes(d.size_bytes), + `${(d.bus || "—").toUpperCase()} · ${d.rotational ? "HDD" : "SSD"}`, + ok + ? + {t("audit.document.healthy")} + : d.health && d.health !== "unknown" + ? + {d.health} + : "—", + typeof d.power_on_hours === "number" && d.power_on_hours > 0 + ? t("audit.document.years", { years: (d.power_on_hours / 8760).toFixed(1) }) + : "—", + (d.observations || []).length + ? + {d.observations.length} + : , + ] + })} + /> + + {(hw.disks || []).some((d: any) => (d.observations || []).length > 0) && ( + <> + } + title={t("audit.document.observations")} + note={t("audit.document.observationsNote")} /> + {(hw.disks || []).filter((d: any) => (d.observations || []).length).map((d: any) => ( +
+

+ {d.name} {d.model} +

+ [ + o.type || "—", + + {o.severity + ? t(`audit.classifications.${ + o.severity === "critical" ? "critical" : "warning"}`) + : "—"}, + String(o.count ?? "—"), + when(o.first_seen), when(o.last_seen), + {o.message || ""}, + ])} + /> +
+ ))} + + )} +
+ )} + + {(s.network || (hw.adapters || []).length > 0) && ( +
} title={t("audit.inventory.network")} + count={Object.keys(s.network?.bridges || {}).length || undefined}> + {(hw.adapters || []).length > 0 && ( + <> + } + title={t("audit.document.physicalAdapters")} /> + [ + {a.name}, + a.state === "up" + ? + {a.state} + : {a.state || "—"}, + a.speed_mbps + ? (a.speed_mbps >= 1000 ? `${a.speed_mbps / 1000} Gb/s` : `${a.speed_mbps} Mb/s`) + : "—", + {a.mac || "—"}, a.driver || "—", {a.pci || "—"}, + ])} + /> + + )} + {s.network && ( + <> + } + title={t("audit.document.bridges")} /> +
+ {Object.entries(s.network.bridges || {}).map(([id, b]: [string, any]) => ( +
+ {id} + + + {b.vlan_interface && ( + VLAN {b.vlan_interface} + )} + + {(s.guests || []).filter((g) => + (g.interfaces || []).some((n) => n.bridge === id)).length} + {" "}{t("audit.inventory.guests").toLowerCase()} + +
+ ))} +
+ + )} +
+ )} + + {s.latency?.targets?.length ? ( +
} title={t("audit.document.latency")} + note={t("audit.document.latencyNote")}> + [ + + {t(`audit.document.target.${target.target}`)}, + target.min_ms != null ? `${target.min_ms} ms` : "—", + target.avg_ms != null ? `${target.avg_ms} ms` : "—", + target.max_ms != null ? `${target.max_ms} ms` : "—", + target.packet_loss != null ? `${target.packet_loss} %` : "—", + String(target.samples), + ])} + /> +
+ ) : null} + + {s.storages && ( +
} title={t("audit.inventory.storage")} + count={(s.storages || []).length}> + [ + {st.id}, st.type, + {st.content}, + st.shared + ? {t("audit.inventory.yes")} + : , + + {st.server || st.path || "—"}, + String((s.guests || []).filter((g) => + (g.disks || []).some((d) => d.storage === st.id)).length), + ])} + /> +
+ )} + + {s.guests && ( +
} title={t("audit.inventory.guests")} + count={(s.guests || []).length}> +
+ {(s.guests || []).map((g) => { + const open = openGuest.has(g.vmid) + const guestApps = apps.get(g.vmid) || [] + return ( +
+ + + {open && ( +
+
+ + + + {g.type === "lxc" && ( + + )} + {g.type === "lxc" && } + {g.type === "qemu" && ( + + )} + {g.type === "qemu" && } +
+ + {g.disks.length > 0 && ( +
+ } + title={t("audit.inventory.disks")} /> + [ + {d.slot}, + d.storage + ? {d.storage} + : + {t("audit.inventory.passthrough")}, + {d.volume}, + d.size || "—", + ])} + /> +
+ )} + + {g.interfaces.length > 0 && ( +
+ } + title={t("audit.inventory.interfaces")} /> +
+ {g.interfaces.map((n) => ( +
+ {n.slot} + + {n.bridge} + + + {n.vlan && VLAN {n.vlan}} + {n.mac && {n.mac}} +
+ ))} +
+
+ )} + +
+ } + title={t("audit.inventory.protection")} /> + {g.backups.length === 0 ? ( +

+ {t("audit.inventory.noBackupDetail")}

+ ) : ( + [ + {b.job}, + {b.storage}, + b.schedule || "—", b.retention || "—", + ])} + /> + )} +
+ + {guestApps.length > 0 && ( +
+ } + title={t("audit.inventory.applications")} /> +
+ {guestApps.map((a: any, i: number) => ( +
+ {a.name} + {a.version || t("audit.inventory.versionUnknown")} + {a.update_available && ( + + {a.available} + + )} + {(a.ports || []).map((p: any, j: number) => ( + + {p.scheme}:{p.port}{p.path} + + ))} +
+ ))} +
+
+ )} +
+ )} +
+ ) + })} +
+
+ )} + + {(s.passthrough || []).length > 0 && ( +
} title={t("audit.inventory.passthroughTitle")} + count={(s.passthrough || []).length}> + [ + {p.vmid}, + p.guest || "—", + {p.slot || "—"}, + {p.address || "—"}, + (p.iommu_groups || []).join(", ") || "—", + // Everything in a group moves together, so a shared group is + // what decides whether the passthrough is possible. + (p.shared_group_devices || []).length + ? + {p.shared_group_devices.length} + : , + ])} + /> +
+ )} + + {s.proxmenux && ( +
} title={t("audit.inventory.proxmenux")} + count={(s.proxmenux.optimizations || []).length}> + { + const pending = (s.proxmenux!.pending_updates || []) + .find((u: any) => u.key === o.key) + return [ + {o.key}, + o.version || "—", + pending + ? + {t("audit.document.updateAvailable", { version: String(pending.available) })} + + : {t("audit.document.current")}, + ] + })} + /> +
+ )} +
+ ) +} diff --git a/AppImage/components/audit-policy.tsx b/AppImage/components/audit-policy.tsx new file mode 100644 index 00000000..c2583525 --- /dev/null +++ b/AppImage/components/audit-policy.tsx @@ -0,0 +1,419 @@ +"use client" + +import { useCallback, useEffect, useMemo, useState } from "react" +import { Card, CardContent, CardHeader, CardTitle } from "./ui/card" +import { Badge } from "./ui/badge" +import { Button } from "./ui/button" +import { Boxes, CheckCircle2, HardDrive, Loader2, Settings2, SlidersHorizontal } from "lucide-react" +import { + Select, SelectContent, SelectItem, SelectTrigger, SelectValue, +} from "./ui/select" +import { fetchApi } from "../lib/api-config" +import { useT } from "../lib/i18n/provider" + +/** + * Declares what is expected of this host. + * + * An assessment can see what the host does; it cannot see what it is + * for. Everything on this page answers a question the host has no way of + * answering itself — does this guest need a backup, must this one come + * back by itself, is this storage essential — and each answer is what + * turns an observation in the report into a warning, or takes it out of + * the count entirely. + * + * Nothing here is required. A host with no declaration produces a + * complete report; it just describes rather than judges. + */ + +interface Guest { vmid: number; name: string; type: string } +interface Storage { id: string; type: string } +interface GuestRule { + backup?: string; autostart?: string + recovery_objective_hours?: number; note?: string +} +interface Policy { + guests: Record + storages: Record + defaults: Record + thresholds: Record +} +interface Vocabulary { + expectations: string[] + roles: string[] + thresholds: Record +} + +const EMPTY: Policy = { guests: {}, storages: {}, defaults: {}, thresholds: {} } + +function PolicySelect({ value, options, prefix, onChange, inherited, inheritedKey, label, disabled }: { + value: string; options: string[]; prefix: string + onChange: (value: string) => void; inherited: string; inheritedKey?: string + label: (key: string) => string; disabled?: boolean +}) { + // One component behind every dropdown on this tab, so it is also the + // one place that decides they look like the rest of the interface. + return ( + + ) +} + +export function AuditPolicy() { + const t = useT() + const [policy, setPolicy] = useState(EMPTY) + const [vocabulary, setVocabulary] = useState(null) + const [guests, setGuests] = useState([]) + const [storages, setStorages] = useState([]) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [dirty, setDirty] = useState(false) + const [error, setError] = useState(null) + const [saved, setSaved] = useState(false) + const [revision, setRevision] = useState(null) + const [conflict, setConflict] = useState(false) + // The declaration is what turns an observation into a warning, so the + // form stays locked until the reader says they are changing it. + const [editing, setEditing] = useState(false) + const locked = !editing || saving || !revision || conflict + + const load = useCallback(async () => { + setLoading(true) + setRevision(null) + try { + const [current, inventory]: any[] = await Promise.all([ + fetchApi("/api/audit/policy"), + // The declaration is about this host's own guests and storages, + // so they are listed rather than typed in by identifier. + fetchApi("/api/audit/inventory?profile=inventory"), + ]) + if (current?.success) { + setPolicy({ ...EMPTY, ...current.policy }) + setVocabulary(current.vocabulary) + setRevision(current.summary.revision) + setDirty(false); setSaved(false); setConflict(false) + setError(null) + } else { + setError(current?.message || t("audit.policy.failed")) + } + const sections = inventory?.inventory?.sections + setGuests((sections?.guests || []).map((g: any) => ({ + vmid: g.vmid, name: g.name, type: g.type, + }))) + setStorages((sections?.storages || []).map((s: any) => ({ + id: s.id, type: s.type, + }))) + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + } finally { + setLoading(false) + } + }, [t]) + + useEffect(() => { load() }, [load]) + + const setGuestRule = (vmid: number, field: keyof GuestRule, value: unknown) => { + setPolicy((prev) => { + const guests = { ...prev.guests } + const rule: GuestRule = { ...(guests[String(vmid)] || {}) } + // Absence inherits; explicit "unspecified" overrides the site default. + if (value === "inherit" || value === "" || value === undefined) { + delete rule[field] + } else { + ;(rule as Record)[field] = value + } + if (Object.keys(rule).length === 0) delete guests[String(vmid)] + else guests[String(vmid)] = rule + return { ...prev, guests } + }) + setDirty(true); setSaved(false) + } + + const setStorageRole = (id: string, role: string) => { + setPolicy((prev) => { + const storages = { ...prev.storages } + if (role === "inherit" || !role) delete storages[id] + else storages[id] = { role } + return { ...prev, storages } + }) + setDirty(true); setSaved(false) + } + + const setThreshold = (name: string, raw: string) => { + setPolicy((prev) => { + const thresholds = { ...prev.thresholds } + const value = Number(raw) + if (!raw.trim()) delete thresholds[name] + else thresholds[name] = value + return { ...prev, thresholds } + }) + setDirty(true); setSaved(false) + } + + const save = async () => { + if (!revision || saving || conflict) return + setSaving(true) + try { + const res: any = await fetchApi("/api/audit/policy", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ...policy, expected_revision: revision }), + }) + if (res?.success) { + setRevision(res.summary.revision) + setDirty(false); setSaved(true); setError(null); setEditing(false) + } + else setError(res?.message || t("audit.policy.failed")) + } catch (e) { + if ((e as { status?: number }).status === 409) { + setConflict(true) + setError(t("audit.policy.conflict")) + } else setError(e instanceof Error ? e.message : String(e)) + } finally { + setSaving(false) + } + } + + const declared = useMemo( + () => Object.keys(policy.guests).length + Object.keys(policy.storages).length + + Object.keys(policy.thresholds).length + Object.keys(policy.defaults).length, + [policy], + ) + + if (loading) { + return ( +
+ {t("audit.policy.loading")} +
+ ) + } + + const expectations = vocabulary?.expectations || ["required", "not_required", "unspecified"] + const roles = vocabulary?.roles || ["essential", "optional", "unspecified"] + + return ( +
{ event.preventDefault(); void save() }}> + {error &&

{error}

} + {(conflict || !revision) && } + + +

{t("audit.policy.intro")}

+
+ + {t("audit.policy.declaredCount", { count: String(declared) })} + + {saved && {t("audit.policy.saved")}} + {editing ? ( +
+ + +
+ ) : ( + + )} +
+
+
+ +
+ + + + + + {t("audit.inventory.guests")} + + + +

{t("audit.policy.guestsNote")}

+ {guests.map((guest) => { + const rule = policy.guests[String(guest.vmid)] || {} + return ( +
+
+ + {guest.vmid} + + + {guest.name || "—"} + + + {guest.type} + +
+ + + +
+ ) + })} + {guests.length === 0 && ( +

{t("audit.policy.noGuests")}

+ )} +
+
+ + + + + {t("audit.inventory.storage")} + + + +

{t("audit.policy.storagesNote")}

+ {storages.map((storage) => ( +
+
+ + {storage.id} + + {storage.type} +
+ setStorageRole(storage.id, v)} + /> +
+ ))} +
+
+ + + + + {t("audit.policy.thresholds")} + + + +

{t("audit.policy.thresholdsNote")}

+
+ {Object.entries(vocabulary?.thresholds || {}).map(([name, shipped]) => ( + + ))} +
+
+
+
+
+ ) +} diff --git a/AppImage/components/audit-report.tsx b/AppImage/components/audit-report.tsx index 8a8d9b65..f24e2da0 100644 --- a/AppImage/components/audit-report.tsx +++ b/AppImage/components/audit-report.tsx @@ -10,22 +10,42 @@ import { } from "./ui/dialog" import { AlertTriangle, CheckCircle2, ChevronDown, ChevronRight, ClipboardCheck, - Loader2, MinusCircle, Play, RotateCcw, ShieldOff, XCircle, + FileText, HelpCircle, Info, Loader2, MinusCircle, Play, RotateCcw, + ShieldOff, XCircle, } from "lucide-react" import { fetchApi } from "../lib/api-config" import { useT } from "../lib/i18n/provider" +import { AuditInventory } from "./audit-inventory" +import { AuditPolicy } from "./audit-policy" +import { AuditChanges } from "./audit-changes" +import { AuditComparison } from "./audit-comparison" +import { Label } from "./ui/label" +import { + Select, SelectContent, SelectItem, SelectTrigger, SelectValue, +} from "./ui/select" +import { unreadSources } from "../lib/audit-presentation" +import { openAuditDocument, openReportWindow } from "../lib/audit-document" +import { AuditEvidence } from "./audit-evidence" +import { AuditFindingData } from "./audit-finding-data" +import { affectedDescription, resultBreakdown } from "../lib/audit-presentation" +import { useI18n } from "../lib/i18n/provider" interface Finding { check_id: string area: string severity: string - state: string + classification: string + decision?: string summary_key: string | null summary_params: Record affected: Array> evidence: string | null remediable_by: string | null - exception?: { reason: string; accepted_by: string; accepted_at: number } | null + incomplete?: boolean + collected_at?: number + check_version?: number + sources?: Array<{ source: string; collected_at: number; error?: string }> + exception?: { reason: string; accepted_by: string; accepted_at: number; expires_at?: number | null } | null } interface Run { @@ -35,46 +55,80 @@ interface Run { finished_at: number | null status: string checks_total: number + checks_expected: number + error?: string | null + is_baseline?: number | boolean + // Recorded by the engine: the sources it read and the declaration it + // judged against. The document states the latter in its scope. + metadata?: { policy?: { + declared?: boolean; guests_declared?: number + storages_declared?: number; thresholds_declared?: string[] + } } | null } // Findings are ordered by how much they demand attention, not by area. // Someone triaging wants the worst thing first regardless of where it // lives; grouping by area is the reading order of the printed document. -const STATE_RANK: Record = { - fail: 0, warn: 1, accepted: 2, pass: 3, not_applicable: 4, +// +// One scale, worst first. There is no second ordering by severity any +// more: gravity is the classification, so a finding cannot be a critical +// observation or an informational failure. +const CLASS_RANK: Record = { + critical: 0, warning: 1, observation: 2, unverified: 3, + accepted: 4, conformant: 5, not_applicable: 6, } -const STATE_STYLE: Record = { - fail: { chip: "bg-red-500/10 text-red-500 border-red-500/20", Icon: XCircle }, - warn: { chip: "bg-amber-500/10 text-amber-500 border-amber-500/20", Icon: AlertTriangle }, - accepted: { chip: "bg-muted text-muted-foreground border-border", Icon: ShieldOff }, - pass: { chip: "bg-green-500/10 text-green-500 border-green-500/20", Icon: CheckCircle2 }, +// Only the first two are problems. An observation is drawn in a neutral +// tone on purpose: colouring planning information like a fault is what +// made ordinary configurations read as defects. +const CLASS_STYLE: Record = { + critical: { chip: "bg-red-500/10 text-red-500 border-red-500/20", Icon: XCircle }, + warning: { chip: "bg-amber-500/10 text-amber-500 border-amber-500/20", Icon: AlertTriangle }, + observation: { chip: "bg-blue-500/10 text-blue-400 border-blue-400/20", Icon: Info }, + unverified: { chip: "bg-muted text-muted-foreground border-border", Icon: HelpCircle }, + accepted: { chip: "bg-indigo-500/10 text-indigo-400 border-indigo-400/20", Icon: ShieldOff }, + conformant: { chip: "bg-green-500/10 text-green-500 border-green-500/20", Icon: CheckCircle2 }, not_applicable: { chip: "bg-muted text-muted-foreground border-border", Icon: MinusCircle }, } +/** What a finding reads as once the reader's decision is applied. */ +function shownAs(f: { classification: string; decision?: string }): string { + return f.decision === "accepted" ? "accepted" : f.classification +} + // An assessment older than this stops describing the current system, so // the age is surfaced before any count rather than as a footnote. const STALE_AFTER_DAYS = 30 +const SUMMARY_BADGE_CLASS = "h-6 gap-1.5 whitespace-nowrap px-2.5 py-0 text-xs" export function AuditReport() { const t = useT() + const { language } = useI18n() + // Assessment and inventory answer different questions and are + // read differently: one is triaged, the other is read through. + const [view, setView] = useState<"assessment" | "inventory" | "changes" | "policy">("assessment") const [running, setRunning] = useState(false) const [latest, setLatest] = useState(null) const [findings, setFindings] = useState([]) const [summary, setSummary] = useState>({}) const [areaFilter, setAreaFilter] = useState("all") const [expanded, setExpanded] = useState>(new Set()) - const [showResolved, setShowResolved] = useState(false) const [error, setError] = useState(null) const [loading, setLoading] = useState(true) const [accepting, setAccepting] = useState(null) const [reason, setReason] = useState("") const [expiryDays, setExpiryDays] = useState("") const [saving, setSaving] = useState(false) + const [progress, setProgress] = useState({ completed: 0, total: 0 }) + // The profile decides which question the page answers, so it governs + // both what an assessment runs and what the inventory documents. + const [profile, setProfile] = useState("full") + const [profiles, setProfiles] = useState>([]) + const [building, setBuilding] = useState(false) const loadRun = useCallback(async (runId: string) => { try { - const data: any = await fetchApi(`/api/audit/runs/${runId}`) + const data: any = await fetchApi(`/api/audit/runs/${runId}?effective=1`) if (data?.success) setFindings(data.findings || []) } catch (e) { setError(e instanceof Error ? e.message : String(e)) @@ -86,6 +140,7 @@ export function AuditReport() { const data: any = await fetchApi("/api/audit/status") if (!data?.success) return setRunning(Boolean(data.running)) + setProgress({ completed: data.progress?.completed || 0, total: data.progress?.total || 0 }) setSummary(data.summary || {}) setLatest(data.latest || null) if (data.latest?.run_id) await loadRun(data.latest.run_id) @@ -97,8 +152,29 @@ export function AuditReport() { } }, [loadRun]) + useEffect(() => { + fetchApi("/api/audit/profiles") + .then((d: any) => { if (d?.success) setProfiles(d.profiles || []) }) + .catch(() => { /* the page works on the default profile */ }) + }, []) + useEffect(() => { refresh() }, [refresh]) + // Expiry changes a decision, not the assessment. One local timer and + // a focus refresh keep it current without periodic scans or idle polling. + useEffect(() => { + const expiry = findings.flatMap((f) => f.exception?.expires_at ? [f.exception.expires_at] : []) + if (!expiry.length) return + const delay = Math.max(100, Math.min(2147483647, Math.min(...expiry) * 1000 - Date.now() + 100)) + const id = setTimeout(refresh, delay) + return () => clearTimeout(id) + }, [findings, refresh]) + useEffect(() => { + const onFocus = () => { void refresh() } + window.addEventListener("focus", onFocus) + return () => window.removeEventListener("focus", onFocus) + }, [refresh]) + // While an assessment is in flight the page polls; once it settles the // interval is dropped so an idle tab does not keep waking the backend. useEffect(() => { @@ -112,7 +188,7 @@ export function AuditReport() { try { const data: any = await fetchApi("/api/audit/run", { method: "POST", - body: JSON.stringify({ profile: "full" }), + body: JSON.stringify({ profile }), }) if (data?.success) setRunning(true) else setError(data?.message || t("audit.errors.runFailed")) @@ -130,6 +206,7 @@ export function AuditReport() { try { const body: Record = { check_id: accepting.check_id, + run_id: latest?.run_id, reason: reason.trim(), } if (expiryDays) body.expires_in_days = Number(expiryDays) @@ -163,17 +240,20 @@ export function AuditReport() { [findings], ) - const visible = useMemo(() => { - const quiet = new Set(["pass", "not_applicable"]) - return findings + // Every check is listed, worst first. Hiding what passed made the + // reader guess whether a check was clean or had not run, which is + // exactly the distinction this page exists to keep. + const visible = useMemo(() => + findings .filter((f) => areaFilter === "all" || f.area === areaFilter) - .filter((f) => showResolved || !quiet.has(f.state)) .sort((a, b) => - (STATE_RANK[a.state] ?? 9) - (STATE_RANK[b.state] ?? 9) || - a.check_id.localeCompare(b.check_id)) - }, [findings, areaFilter, showResolved]) + (CLASS_RANK[shownAs(a)] ?? 9) - (CLASS_RANK[shownAs(b)] ?? 9) || + a.check_id.localeCompare(b.check_id)), + [findings, areaFilter]) const acceptedCount = summary.accepted || 0 + const unverifiedChecks = findings.filter( + (f) => f.classification === "unverified" || f.incomplete) const ageDays = latest?.finished_at ? Math.floor((Date.now() / 1000 - latest.finished_at) / 86400) : null @@ -184,6 +264,7 @@ export function AuditReport() { // correctly under another. A check that failed to evaluate has no // per-check entry, hence the shared fallback. const summaryOf = (f: Finding) => { + if (f.check_id === "backup.last_backup_age" && f.affected.length) return resultBreakdown(f, t) if (!f.summary_key) return "" const params = Object.fromEntries( Object.entries(f.summary_params || {}).map(([k, v]) => [k, String(v)]), @@ -193,6 +274,11 @@ export function AuditReport() { return text === key ? t("audit.summaryFallback") : text } + const notApplicableText = (f: Finding) => { + if (f.summary_key) return "" + return f.classification === "not_applicable" ? t("audit.notApplicableScope") : "" + } + const toggle = (id: string) => { setExpanded((prev) => { const next = new Set(prev) @@ -210,15 +296,179 @@ export function AuditReport() { ) } + // The document carries both halves, so the inventory is fetched at the + // moment it is produced rather than kept in memory for a button that + // may never be pressed. + const generateDocument = async () => { + // The window is opened on the click itself, before the inventory is + // fetched, so the popup blocker sees the user gesture. It shows a + // spinner while the document is composed. + const target = openReportWindow(t("audit.document.building")) + setBuilding(true) + try { + const inv: any = await fetchApi( + `/api/audit/inventory?profile=${encodeURIComponent(profile)}`) + openAuditDocument({ + profile, + run: latest, + findings, + inventory: inv?.success ? inv.inventory : null, + t, + locale: language, + }, target) + } catch (e) { + target?.close() + setError(e instanceof Error ? e.message : String(e)) + } finally { + setBuilding(false) + } + } + + const documentButton = ( + + ) + + const profilePicker = profiles.length > 0 ? ( +
+ + +
+ ) : null + + const viewTabs = ( +
+ {(["assessment", "inventory", "changes", "policy"] as const).map((key) => ( + + ))} +
+ ) + + // Changes and policy carry no profile: one is what was done to this + // host, the other what is expected of it, and neither narrows by report. + if (view === "changes") { + return ( +
+
+
+ +

{t("audit.title")}

+
+
+ {viewTabs} +
+
+ +
+ ) + } + + if (view === "policy") { + return ( +
+
+
+ +

{t("audit.title")}

+
+
+ {viewTabs} +
+
+ +
+ ) + } + + if (view === "inventory") { + return ( +
+ {/* A row that cannot wrap has nowhere to put the controls but + beside the title, which then squeezes into two lines. Title + and controls are separate rows until there is width for both. */} +
+
+ +

{t("audit.title")}

+
+
+
+ {profilePicker}{documentButton} +
+ {viewTabs} +
+
+ +
+ ) + } + return (
+
+
+ +

{t("audit.title")}

+
+
+
+ {profilePicker}{documentButton} +
+ {viewTabs} +
+
- - - {t("audit.title")} - {/* Stated before any count: an assessment nobody has run, or one run months ago, does not describe this host today. */} {!latest ? ( @@ -233,6 +483,29 @@ export function AuditReport() {

)}

{t("audit.readOnlyNotice")}

+ {running &&

+ {t("audit.progress", { completed: String(progress.completed), total: String(progress.total) })} +

} + {/* A reading that could not be taken is information, not an + alarm: it says the report is narrower than usual, and + colouring it like a finding puts it above warnings the + reader has to act on. A run that failed outright is the + one thing here that does interrupt. */} + {latest && (latest.status === "partial" || latest.status === "failed") && ( +
+

{t(`audit.runStates.${latest.status}`)}

+ {unverifiedChecks.length > 0 &&

+ {t("audit.unverifiedChecks", { checks: unverifiedChecks + .map((f) => t(`audit.checks.${f.check_id}.title`)).join(" · ") })} +

} +
+ )} + {latest?.error &&

{latest.error}

}
{/* The count of accepted risks stays visible even when the @@ -324,9 +603,10 @@ export function AuditReport() {
{visible.map((f) => { - const { chip, Icon } = STATE_STYLE[f.state] || STATE_STYLE.not_applicable + const shown = shownAs(f) + const { chip, Icon } = CLASS_STYLE[shown] || CLASS_STYLE.not_applicable const open = expanded.has(f.check_id) - const muted = f.state === "accepted" || f.state === "not_applicable" + const muted = shown === "accepted" || shown === "not_applicable" return ( toggle(f.check_id)} aria-expanded={open} - className="w-full text-left p-4 flex items-start gap-3 hover:bg-background/40 transition-colors rounded-lg" + className="w-full text-left p-4 flex items-start gap-3 rounded-lg hover:bg-white/5 transition-colors cursor-pointer" > {open ? : } - {t(`audit.states.${f.state}`)} + {t(`audit.classifications.${shown}`)}
@@ -353,15 +633,28 @@ export function AuditReport() { {t(`audit.areas.${f.area}`)} + {f.incomplete && + {t("audit.incomplete")} + } {f.affected.length > 0 && ( - {t("audit.affectedCount", { count: String(f.affected.length) })} + {affectedDescription(f, t)} )}
- {f.summary_key && ( + + {(f.summary_key || notApplicableText(f)) && (

- {summaryOf(f)} + {f.summary_key ? summaryOf(f) : notApplicableText(f)} +

+ )} + {/* "Could not be evaluated" describes the assessment, not + the host. What could not be read is recorded against + each source, and belongs here rather than two + collapsed panels below. */} + {unreadSources(f.sources, t) && ( +

+ {unreadSources(f.sources, t)}

)}
@@ -387,41 +680,42 @@ export function AuditReport() {

{f.exception.accepted_by} ·{" "} {new Date(f.exception.accepted_at * 1000).toLocaleDateString()} + {f.exception.expires_at && <> · {t("audit.expires", { + when: new Date(f.exception.expires_at * 1000).toLocaleString(), + })}}

)} - {f.affected.length > 0 && ( -
-

- {t("audit.detail.affected")} -

-
- {f.affected.map((o, i) => ( - - {Object.values(o).filter(Boolean).join(" · ")} - - ))} -
-
- )} + {/* Some checks carry a useful, structured positive reading + in their evidence even when nothing is affected. The + presenter returns no groups for checks without such a + view, so rendering it unconditionally does not create + empty space. */} + {f.evidence && ( -
-

- {t("audit.detail.evidence")} -

- {/* Wide command output scrolls inside its own box so - the page itself never scrolls sideways. */} -
-                        {f.evidence}
-                      
-
+
+ + {t("audit.presentation.technical")} + + +
)} + {f.sources && f.sources.length > 0 &&
+ {t("audit.detail.sources")} +
    + {f.sources.map((source) =>
  • + {source.source} · {new Date(source.collected_at * 1000).toLocaleString()} + {source.error && · {source.error}} +
  • )} +
+
} {/* Only an active finding can be accepted, and only an accepted one can be returned to the active set. */} - {(f.state === "fail" || f.state === "warn") && ( + {(f.classification === "critical" || f.classification === "warning") + && !f.decision && !f.incomplete && ( - - )} )} {!helperExists && !helperUsesWebUpdater && ( diff --git a/AppImage/lib/audit-document.ts b/AppImage/lib/audit-document.ts new file mode 100644 index 00000000..6f5cd83c --- /dev/null +++ b/AppImage/lib/audit-document.ts @@ -0,0 +1,965 @@ +/** + * The Audit & Report document. + * + * Built on the shell the SMART, Lynis and latency reports share, so a + * reader who has seen one of those recognises this one: the same header + * and report identifier, the same numbered sections, the same action bar + * that disappears when the page is printed. + * + * What the document adds is structure. On screen findings are ordered by + * severity because the reader is triaging; on paper the node is + * described first — how it is built, what it connects to, what it holds + * — and only then judged, because a finding about a bridge means little + * to someone who has not been shown the bridge. The diagrams carry the + * relations the inventory resolves: a list of interfaces and a list of + * guests do not say which path a guest's traffic takes to the wire. + * + * The closing section states the scope: what the report covers and what + * it does not. That statement is what makes the document usable as + * evidence rather than a screenshot. + */ + +import { + REPORT_CSS_AUDIT, callout, card, esc, grid, heading, openReportWindow, + renderReport, reportId, section, table, writeReport, icon, +} from "./report-shell" +import { + clusterDiagram, findingsChart, latencyChart, networkDiagram, + nodeArchitectureDiagram, storageDiagram, +} from "./report-diagrams" +import { parseEvidence } from "./evidence-format" +import { presentFinding, auditInstant, auditLabel, resultBreakdown, unreadSources, subscriptionLabel } from "./audit-presentation" + +type Translate = (key: string, params?: Record) => string + +export interface DocumentInput { + profile: string + run: { + run_id: string; started_at: number; finished_at: number | null + // What the engine recorded about the declaration it judged against. + metadata?: { policy?: { + declared?: boolean; guests_declared?: number + storages_declared?: number; thresholds_declared?: string[] + } } | null + } | null + findings: Array<{ + check_id: string; area: string; severity: string + classification: string; decision?: string + summary_key: string | null; summary_params: Record + affected: Array>; evidence: string | null + incomplete?: boolean + sources?: Array<{ source: string; collected_at?: number; error?: string }> + exception?: { reason: string; accepted_by: string; accepted_at: number } | null + }> + inventory: any | null + t: Translate + locale: string +} + +// One scale, worst first. An observation is drawn in a neutral blue +// rather than an alarm colour: it describes the host, it is not a fault. +const ORDER = ["critical", "warning", "observation", "unverified", + "accepted", "conformant", "not_applicable"] + +const CLASS_COLOR: Record = { + critical: "#dc2626", warning: "#ca8a04", observation: "#3b82f6", + unverified: "#94a3b8", accepted: "#4f46e5", conformant: "#16a34a", + not_applicable: "#cbd5e1", +} + +/** What a finding reads as once the reader's decision is applied. */ +function shownAs(f: { classification: string; decision?: string }): string { + return f.decision === "accepted" ? "accepted" : f.classification +} + +function bytes(value: number | null | undefined): string { + if (!value || value <= 0) return "—" + const units = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"] + let n = value, i = 0 + while (n >= 1024 && i < units.length - 1) { n /= 1024; i++ } + return `${n >= 100 || i < 2 ? Math.round(n) : n.toFixed(1)} ${units[i]}` +} + +/** Instants reach the document as epoch seconds or as an ISO string, + * depending on which store recorded them. */ +function when(value: number | string | null | undefined, locale: string): string { + return auditInstant(value, locale) +} + +function chip(state: string, label: string): string { + const mark = state === "critical" ? "×" : state === "warning" ? "!" : state === "conformant" ? "✓" : state === "observation" ? "ⓘ" : state === "unverified" ? "?" : "−" + return ` ${esc(label)}` +} + +function summaryOf(f: DocumentInput["findings"][number], t: Translate, + breakdown = false): string { + // The per-result breakdown belongs beside the table it describes. In + // the one-line findings summary it replaced the sentence with a bare + // count, which read as a broken cell next to every other row. + if (breakdown && f.check_id === "backup.last_backup_age" && f.affected.length) { + return resultBreakdown(f, t) + } + // A check that found nothing to apply to used to carry an English + // sentence written by the engine; it now says so in the reader's own. + if (!f.summary_key) { + return f.classification === "not_applicable" ? t("audit.notApplicableScope") : "" + } + const params: Record = {} + for (const [k, v] of Object.entries(f.summary_params || {})) params[k] = String(v) + const key = `audit.checks.${f.check_id}.summary.${f.summary_key}` + const text = t(key, params) + return text === key ? t("audit.summaryFallback") : text +} + +/** + * Evidence, rendered as the reader would want to read it rather than as + * the check happened to serialise it. + */ +function evidenceHtml(evidence: string | null, locale: string, + compact?: { t: Translate; rows?: number; lines?: number; blocks?: number }): string { + let blocks = parseEvidence(evidence, locale) + if (blocks.length === 0) return "" + let omitted = false + if (compact?.blocks && blocks.length > compact.blocks) { + blocks = blocks.slice(0, compact.blocks) + omitted = true + } + const parts = blocks.map((block) => { + const heading = block.title + ? `

${esc(block.title)}

` : "" + if (block.kind === "table") { + const rows = compact?.rows && block.rows.length > compact.rows + ? (omitted = true, block.rows.slice(0, compact.rows)) : block.rows + if (block.columns.length > 6) { + return heading + rows.map(row => `
` + table([], block.columns.map((column, i) => [esc(column), esc(row[i])])) + `
`).join("") + } + return heading + table(block.columns, rows.map((r) => r.map(esc))) + } + if (block.kind === "pairs") { + const entries = compact?.rows && block.entries.length > compact.rows + ? (omitted = true, block.entries.slice(0, compact.rows)) : block.entries + return heading + table([], entries.map(([k, v]) => + [`${esc(k)}`, esc(v)])) + } + const lines = compact?.lines && block.lines.length > compact.lines + ? (omitted = true, block.lines.slice(0, compact.lines)) : block.lines + return heading + (lines.length + ? `
    ${lines.map((l) => + `
  • ${esc(l)}
  • `).join("")}
` : "") + }) + const notice = omitted && compact + ? `

${esc(auditLabel(compact.t, "evidenceExcerpt"))}

` : "" + return `
${parts.join("")}${notice}
` +} + +// --------------------------------------------------------------------------- +// Assessment summary +// --------------------------------------------------------------------------- + +function executiveSummary(input: DocumentInput, n: number): string { + const { findings, t, locale } = input + const counts: Record = {} + for (const f of findings) { + const shown = shownAs(f) + counts[shown] = (counts[shown] || 0) + 1 + } + + const fails = counts.critical || 0 + const warns = counts.warning || 0 + // Coverage measures verified checks, not whether their result is favourable. + // Decisions/acceptances never turn missing evidence into verified evidence. + const applicable = findings.filter(f => f.classification !== "not_applicable") + const verifiedChecks = applicable.filter(f => !f.incomplete && + ["critical", "warning", "observation", "conformant", "accepted"].includes(f.classification)) + const verified = verifiedChecks.length + const incomplete = verified < applicable.length || !!(input.run && !input.run.finished_at) + const coverage = applicable.length ? verified / applicable.length * 100 : 0 + const coverageValue = applicable.length ? `${verified}/${applicable.length}` : "—" + + const byArea: Record> = {} + for (const f of findings) { + const shown = shownAs(f) + byArea[f.area] = byArea[f.area] || {} + byArea[f.area][shown] = (byArea[f.area][shown] || 0) + 1 + } + + const chart = findingsChart(byArea, (a) => t(`audit.areas.${a}`), CLASS_COLOR, ORDER) + const legend = ORDER.filter((c) => counts[c]).map((s) => + ` + ${esc(t(`audit.classifications.${s}`))}`).join("") + + const body = ` +
+
+ +
${coverageValue} + ${esc(auditLabel(t, "verified"))}
+
+
+

${icon("summary", 22, "#64748b")}${esc(t("audit.document.verdictHeading"))}

+ ${!findings.length ? `

${esc(t("audit.document.verdictText.none"))}

` : !applicable.length ? `

${esc(auditLabel(t, "noApplicable"))}

` : ""} + ${incomplete ? `

${esc(auditLabel(t, "incomplete"))}

` : ""} +

${esc(auditLabel(t, "verificationScope"))}

+

+ ${esc(t("audit.document.runAt", { date: when(input.run?.started_at, locale) }))} +

+
+
+
${[ + card(t("audit.classifications.critical"), String(fails), + { center: true, color: CLASS_COLOR.critical }), + card(t("audit.classifications.warning"), String(warns), + { center: true, color: CLASS_COLOR.warning }), + card(t("audit.classifications.observation"), String(counts.observation || 0), + { center: true, color: CLASS_COLOR.observation }), + card(t("audit.classifications.conformant"), String(counts.conformant || 0), + { center: true, color: CLASS_COLOR.conformant }), + ...["unverified", "accepted", "not_applicable"].filter(c => counts[c]).map(c => card(t(`audit.classifications.${c}`), String(counts[c]), {center: true, color: CLASS_COLOR[c]})), + ].join("")}
+ ${chart ? `
+

${esc(t("audit.document.chartNote"))}

${chart} +
${legend}
+
` : ""}` + const overview = findings.filter(f => !["conformant", "not_applicable"].includes(shownAs(f))).sort((a,b) => ORDER.indexOf(shownAs(a)) - ORDER.indexOf(shownAs(b))) + const listing = overview.length ? heading(auditLabel(t, "overview"), "findings") + table( + [auditLabel(t, "result"), t("audit.document.name"), auditLabel(t, "fact")], + overview.map(f => [chip(shownAs(f), t(`audit.classifications.${shownAs(f)}`)), + `${esc(t(`audit.checks.${f.check_id}.title`))}`, esc(summaryOf(f,t))])) : "" + // The list and ring share the same records, so the numerator is auditable. + const checkList = (id: string, title: string, checks: DocumentInput["findings"]) => { + if (!checks.length) return "" + const sorted = [...checks].sort((a, b) => + t(`audit.areas.${a.area}`).localeCompare(t(`audit.areas.${b.area}`), locale) || + t(`audit.checks.${a.check_id}.title`).localeCompare(t(`audit.checks.${b.check_id}.title`), locale)) + return `
` + + heading(`${title} · ${checks.length}`, "summary") + table( + [auditLabel(t, "checkName"), t("audit.document.area"), auditLabel(t, "result")], + sorted.map(f => [ + `${esc(t(`audit.checks.${f.check_id}.title`))}`, + esc(t(`audit.areas.${f.area}`)), chip(shownAs(f), t(`audit.classifications.${shownAs(f)}`)), + ])) + `
` + } + const checked = checkList("verified-checks", auditLabel(t, "verifiedChecks"), verifiedChecks) + const unverified = checkList("unverified-checks", auditLabel(t, "unverifiedChecks"), + applicable.filter(f => !verifiedChecks.includes(f))) + const notApplicable = checkList("not-applicable-checks", t("audit.classifications.not_applicable"), + findings.filter(f => f.classification === "not_applicable")) + return section(n, t("audit.document.executiveSummary"), body + checked + unverified + notApplicable + listing, "summary") +} + +// --------------------------------------------------------------------------- +// Identity and cluster +// --------------------------------------------------------------------------- + +function identitySection(input: DocumentInput, n: number): string { + const s = input.inventory?.sections || {} + const id = s.identity + const { t } = input + if (!id) return "" + const hw = s.hardware || {} + const body = grid(3, [ + card(t("audit.inventory.node"), esc(id.node)), + card(t("audit.inventory.pveVersion"), esc(String(id.pve_version || "—").match(/pve-manager\/([^/]+)/)?.[1] || id.pve_version)), + card(t("audit.inventory.kernel"), esc(id.kernel)), + card(t("audit.inventory.subscription"), esc(subscriptionLabel(t, id.subscription))), + card(t("audit.inventory.cluster"), esc(id.cluster || t("audit.inventory.standalone"))), + card(t("audit.document.system"), + esc([hw.system?.manufacturer, hw.system?.product].filter(Boolean).join(" ") || "—")), + ]) + return section(n, t("audit.document.nodeIdentity"), body, "node") +} + +function clusterSection(input: DocumentInput, n: number): string { + const s = input.inventory?.sections || {} + const { t } = input + if (!("cluster" in s)) return "" + const cluster = s.cluster + + if (!cluster) { + return section(n, t("audit.document.cluster"), + callout("info", t("audit.inventory.standalone"), + esc(t("audit.document.standaloneNote"))), "cluster") + } + + const diagram = clusterDiagram(cluster, { + thisNode: t("audit.document.thisNode"), + unreachable: t("audit.document.unreachable"), + links: t("audit.document.corosyncLinks"), + }) + const rows = (cluster.nodes || []).map((node: any) => [ + esc(node.name) + (node.local + ? ` (${esc(t("audit.document.thisNode"))})` : ""), + esc(node.nodeid || "—"), + esc(node.ring0_addr || "—"), + esc(node.ring1_addr || "—"), + node.online === false + ? chip("warn", t("audit.document.unreachable")) + : node.online === true ? chip("pass", t("audit.document.member")) : "—", + ]) + const body = ` + ${grid(3, [ + card(t("audit.inventory.cluster"), esc(cluster.name)), + card(t("audit.document.quorum"), cluster.quorate == null + ? "—" : chip(cluster.quorate ? "pass" : "fail", + t(cluster.quorate ? "audit.document.quorate" : "audit.document.inquorate"))), + card(t("audit.document.votes"), + esc(`${cluster.total_votes ?? "—"} / ${cluster.expected_votes ?? "—"}`)), + ])} + ${diagram ? `
+

${esc(t("audit.document.clusterDiagramNote"))}

${diagram} +
` : ""} + ${table([t("audit.document.nodeName"), "nodeid", "ring0", "ring1", + t("audit.document.state")], rows)}` + return section(n, t("audit.document.cluster"), body, "cluster") +} + +// --------------------------------------------------------------------------- +// How the node is built +// --------------------------------------------------------------------------- + +function architectureSection(input: DocumentInput, n: number): string { + const s = input.inventory?.sections || {} + const hw = s.hardware + const { t } = input + if (!hw) return "" + + const cpu = hw.cpu || {} + const mem = hw.memory || {} + const diagram = nodeArchitectureDiagram(hw, s.identity || {}, { + chassis: t("audit.document.board"), + processor: t("audit.document.processor"), + memory: t("audit.document.memory"), + controllers: t("audit.document.controllers"), + disks: t("audit.document.disks"), + adapters: t("audit.document.adapters"), + slotsUsed: t("audit.document.slotsUsed"), + cores: t("audit.document.cores"), + threads: t("audit.document.threads"), + empty: t("audit.document.emptySlot"), + }) + + const identityRows = [ + [t("audit.document.manufacturer"), esc(hw.system?.manufacturer || "—")], + [t("audit.document.product"), esc(hw.system?.product || "—")], + [t("audit.document.serial"), esc(hw.system?.serial || "—")], + [t("audit.document.board"), + esc([hw.board?.manufacturer, hw.board?.product].filter(Boolean).join(" ") || "—")], + ["BIOS", esc([hw.bios?.vendor, hw.bios?.version, hw.bios?.date] + .filter(Boolean).join(" · ") || "—")], + ] + + const memoryRows = (mem.modules || []).map((m: any) => [ + esc(m.locator || "—"), esc(m.size || "—"), esc(m.type || "—"), + esc(m.form_factor || "—"), esc(m.speed || "—"), + esc([m.manufacturer, m.part_number].filter(Boolean).join(" · ") || "—"), + ]) + + const controllerRows = (hw.controllers || []).map((c: any) => [ + `${esc(c.slot)}`, + esc(c.class), esc(c.name), + ]) + + const body = ` + ${grid(4, [ + card(t("audit.document.processor"), esc(cpu.model || "—")), + card(t("audit.document.topology"), + esc(`${cpu.sockets || 1} × ${cpu.cores_per_socket || "?"} / ${cpu.threads || "?"}`)), + card(t("audit.document.memory"), esc(bytes(hw.memory_bytes))), + card(t("audit.document.iommuGroups"), esc(String(hw.iommu_groups ?? "—"))), + ])} + ${diagram ? `
+

${esc(t("audit.document.architectureNote"))}

${diagram} +
` : ""} + ${heading(t("audit.document.systemIdentity"), "node")} + ${table([t("audit.document.field"), t("audit.document.value")], identityRows)} + ${memoryRows.length ? ` + ${heading(t("audit.document.memoryModules"), "memory", + t("audit.document.slotsFilled", { used: String(mem.populated ?? 0), + total: String(mem.slots ?? mem.populated ?? 0) }))} + ${table([t("audit.document.slot"), t("audit.document.size"), t("audit.document.type"), + t("audit.document.formFactor"), t("audit.document.speed"), + t("audit.document.manufacturer")], memoryRows)}` : ""} + ${controllerRows.length ? ` + ${heading(t("audit.document.controllers"), "controller")} + ${table(["PCI", t("audit.document.class"), t("audit.document.device")], controllerRows)}` : ""}` + return section(n, t("audit.document.architecture"), body, "architecture") +} + +// --------------------------------------------------------------------------- +// Disks, with what has been observed of them +// --------------------------------------------------------------------------- + +function disksSection(input: DocumentInput, n: number): string { + const s = input.inventory?.sections || {} + const disks = s.hardware?.disks || [] + const { t, locale } = input + if (!disks.length) return "" + + const rows = disks.map((d: any) => { + const life = typeof d.power_on_hours === "number" && d.power_on_hours > 0 + ? t("audit.document.years", { years: (d.power_on_hours / 8760).toFixed(1) }) + : "—" + // smartctl reports the overall assessment as "PASSED"; the Monitor + // normalises some devices to "healthy". + const ok = ["passed", "healthy", "ok"].includes(String(d.health).toLowerCase()) + const health = ok ? chip("pass", t("audit.document.healthy")) + : d.health && d.health !== "unknown" ? chip("warn", esc(d.health)) : "—" + return [ + `${esc(d.name)}`, + esc(d.model || "—"), + `${esc(d.serial || "—")}`, + esc(bytes(d.size_bytes)), + esc(d.bus ? d.bus.toUpperCase() : "—") + (d.rotational ? " · HDD" : " · SSD"), + health, + esc(life), + d.observations?.length + ? chip("warn", String(d.observations.length)) + : ``, + ] + }) + + // Observations are the disk's history. SMART reports what is true now; + // the log reports what happened. A disk that recovered still recorded + // the event, and that pattern is what precedes a failure. + const withEvents = disks.filter((d: any) => (d.observations || []).length) + const observations = withEvents.map((d: any) => { + const entries = d.observations.map((o: any) => [ + esc(o.type || "—"), + // The stored severity is an English database value, and this page + // exists in eight languages. + o.severity === "critical" + ? chip("fail", esc(t("audit.classifications.critical"))) + : o.severity ? chip("warn", esc(t("audit.classifications.warning"))) : "—", + esc(String(o.count ?? "—")), + esc(when(o.first_seen, locale)), + esc(when(o.last_seen, locale)), + `${esc(o.message || "")}`, + ]) + return `${heading(d.name, "disks", d.model || undefined)} + ${table([t("audit.document.event"), t("audit.document.severity"), + t("audit.document.occurrences"), t("audit.document.firstSeen"), + t("audit.document.lastSeen"), t("audit.document.detail")], entries)}` + }).join("") + + const body = ` + ${table([t("audit.document.device"), t("audit.document.model"), + t("audit.document.serial"), t("audit.document.size"), + t("audit.document.bus"), "SMART", t("audit.document.serviceLife"), + t("audit.document.events")], rows)} + ${heading(t("audit.document.observations"), "observation")} + ${withEvents.length + ? `

${esc(t("audit.document.observationsNote"))}

${observations}` + : callout("ok", t("audit.document.noObservations"), + esc(t("audit.document.noObservationsNote")))}` + return section(n, t("audit.document.storageDevices"), body, "disks") +} + +// --------------------------------------------------------------------------- +// Network +// --------------------------------------------------------------------------- + +function chain(hops: Array<{ id: string; mode?: string }> | null, t: Translate): string { + if (hops === null) return `${esc(t("audit.inventory.unresolved"))}` + if (hops.length === 0) return `${esc(t("audit.inventory.noUplink"))}` + return hops.map((h) => esc(h.id + (h.mode ? ` · ${h.mode}` : ""))) + .join('') +} + +function networkSection(input: DocumentInput, n: number): string { + const s = input.inventory?.sections || {} + const { t } = input + const net = s.network + const guests = s.guests || [] + const adapters = s.hardware?.adapters || [] + if (!net && !adapters.length) return "" + + const diagram = net?.bridges + ? networkDiagram(net.bridges, guests, { + nic: t("audit.document.adapters"), bond: t("audit.document.bond"), + bridge: t("audit.document.bridge"), guests: t("audit.inventory.guests"), + }) + : "" + + const adapterRows = adapters.map((a: any) => [ + `${esc(a.name)}`, + a.state === "up" ? chip("pass", esc(a.state)) : chip("unknown", esc(a.state || "—")), + a.speed_mbps + ? esc(a.speed_mbps >= 1000 ? `${a.speed_mbps / 1000} Gb/s` : `${a.speed_mbps} Mb/s`) + : "—", + `${esc(a.mac || "—")}`, + esc(a.driver || "—"), + `${esc(a.pci || "—")}`, + ]) + + const bridgeRows = Object.entries(net?.bridges || {}).map(([id, b]: [string, any]) => [ + `${esc(id)}`, + chain(b.uplink ?? null, t), + esc(String(guests.filter((g: any) => + (g.interfaces || []).some((i: any) => i.bridge === id)).length)), + ]) + + const body = ` + ${diagram ? `
+

${esc(t("audit.document.networkDiagramNote"))}

${diagram} +
` : ""} + ${adapterRows.length ? ` + ${heading(t("audit.document.physicalAdapters"), "adapter")} + ${table([t("audit.document.interface"), t("audit.document.state"), + t("audit.document.speed"), "MAC", t("audit.document.driver"), "PCI"], + adapterRows)}` : ""} + ${bridgeRows.length ? ` + ${heading(t("audit.document.bridges"), "bridge")} + ${table([t("audit.document.bridge"), t("audit.document.uplink"), + t("audit.inventory.guests")], bridgeRows)}` : ""}` + return section(n, t("audit.document.network"), body, "network") +} + +function latencySection(input: DocumentInput, n: number): string { + const s = input.inventory?.sections || {} + const { t } = input + const latency = s.latency + if (!latency?.targets?.length) return "" + + // The legend reads in the reader's language, like the table under it. + const named = latency.targets.map((target: any) => ({ + ...target, label: t(`audit.document.target.${target.target}`), + })) + const chart = latencyChart(named, { + ms: t("audit.document.milliseconds"), hours: t("audit.document.hours"), + }) + const ms = (v: number | null | undefined) => + typeof v === "number" ? `${v} ms` : "—" + const rows = latency.targets.map((target: any) => [ + `${esc(t(`audit.document.target.${target.target}`))}`, + esc(ms(target.min_ms)), esc(ms(target.avg_ms)), esc(ms(target.max_ms)), + esc(typeof target.packet_loss === "number" ? `${target.packet_loss} %` : "—"), + esc(String(target.samples)), + ]) + + const body = ` + ${chart ? `
+

${esc(t("audit.document.latencyNote"))}

${chart} +
` : ""} + ${table([t("audit.document.target.label"), t("audit.document.minimum"), + t("audit.document.average"), t("audit.document.maximum"), + t("audit.document.packetLoss"), t("audit.document.samples")], rows)}` + return section(n, t("audit.document.latency"), body, "latency") +} + +// --------------------------------------------------------------------------- +// Storage and protection +// --------------------------------------------------------------------------- + +function storageSection(input: DocumentInput, n: number): string { + const s = input.inventory?.sections || {} + const { t } = input + const storages = s.storages || [] + const guests = s.guests || [] + if (!storages.length) return "" + + const diagram = storageDiagram(guests, { + guests: t("audit.inventory.guests"), storage: t("audit.document.storage"), + backup: t("audit.document.backupDestination"), + unprotected: auditLabel(t,"noJob"), + }) + + const rows = storages.map((st: any) => [ + `${esc(st.id)}`, + esc(st.type), + esc(st.content || "—"), + st.shared ? chip("pass", t("audit.document.shared")) : ``, + esc(st.server || st.path || "—"), + esc(String(guests.filter((g: any) => + (g.disks || []).some((d: any) => d.storage === st.id)).length)), + ]) + + const unprotected = guests.filter((g: any) => !(g.backups || []).length) + const selected = guests.length - unprotected.length + const fraction = guests.length ? selected / guests.length * 100 : 0 + const capacityFinding = input.findings.find(f => f.check_id === "storage.connected_storage") + let capacityRows: any[] = [] + try { capacityRows = JSON.parse(capacityFinding?.evidence || "{}").storages || [] } catch { /* Raw evidence stays in the appendix. */ } + const capacity = capacityRows.filter(r => Number(r.total) > 0 && r.used != null).map(r => { + const ratio = Math.max(0, Math.min(100, Number(r.used) / Number(r.total) * 100)) + return `
${esc(r.storage)}${esc(bytes(Number(r.used)))} / ${esc(bytes(Number(r.total)))}
` + }).join("") + const body = ` + ${guests.length ? `

${icon("storage")}${esc(auditLabel(t,"coverage"))}

+
+
${selected} / ${guests.length} · ${esc(auditLabel(t,"scheduled"))}${unprotected.length} · ${esc(auditLabel(t,"noJob"))}
+

${esc(auditLabel(t,"copyScope"))}

` : ""} + ${diagram ? `
+

${esc(t("audit.document.storageDiagramNote"))}

${diagram} +
` : ""} + ${table([t("audit.document.storage"), t("audit.document.type"), + t("audit.document.content"), t("audit.document.shared"), + t("audit.document.location"), t("audit.inventory.guests")], rows)} + ${capacity ? heading(auditLabel(t,"capacity"), "storage") + capacity : ""} + ${unprotected.length + ? callout("info", t("audit.document.unprotectedGuests", + { count: String(unprotected.length) }), + esc(unprotected.map((g: any) => `${g.vmid} ${g.name}`).join(" · "))) + : callout("info", auditLabel(t,"scheduled"), + esc(auditLabel(t,"copyScope")))}` + return section(n, t("audit.document.storageAndProtection"), body, "storage") +} + +// --------------------------------------------------------------------------- +// Guests, passthrough, managed software +// --------------------------------------------------------------------------- + +function guestsSection(input: DocumentInput, n: number): string { + const s = input.inventory?.sections || {} + const guests = s.guests || [] + const { t } = input + if (!guests.length) return "" + + const rows = guests.map((g: any) => [ + `${esc(String(g.vmid))}`, + esc(g.name || "—"), + g.type === "lxc" ? "LXC" : "VM", + esc(String(g.cores || "—")), + esc(g.memory ? bytes(Number(g.memory) * 1024 * 1024) : "—"), + esc([...new Set((g.disks || []).map((d: any) => d.storage).filter(Boolean))].join(", ") || "—"), + esc([...new Set((g.interfaces || []).map((i: any) => i.bridge).filter(Boolean))].join(", ") || "—"), + (g.backups || []).length + ? esc((g.backups || []).map((b: any) => b.storage).join(", ")) + : esc(auditLabel(t,"noJob")), + ]) + return section(n, t("audit.inventory.guests"), + table([t("audit.document.vmid"), t("audit.document.name"), t("audit.document.kind"), + t("audit.document.cores"), t("audit.document.memory"), + t("audit.document.storage"), t("audit.document.bridge"), + t("audit.document.backup")], rows), "guests") +} + +function passthroughSection(input: DocumentInput, n: number): string { + const s = input.inventory?.sections || {} + const devices = s.passthrough || [] + const { t } = input + if (!devices.length) return "" + const rows = devices.map((d: any) => [ + esc(String(d.vmid)), + esc(d.guest || "—"), + esc(d.slot || "—"), + `${esc(d.address || "—")}`, + esc((d.iommu_groups || []).join(", ") || "—"), + (d.shared_group_devices || []).length + ? chip("warn", String(d.shared_group_devices.length)) + : ``, + ]) + return section(n, t("audit.inventory.passthrough"), + table([t("audit.document.vmid"), t("audit.document.name"), t("audit.document.slot"), + t("audit.document.device"), t("audit.document.iommuGroup"), + auditLabel(t,"otherDevices")], rows), "passthrough") +} + +function proxmenuxSection(input: DocumentInput, n: number): string { + const s = input.inventory?.sections || {} + const { t } = input + const pmx = s.proxmenux + const apps = s.applications || [] + if (!pmx && !apps.length) return "" + + const toolRows = (pmx?.optimizations || []).map((tool: any) => { + const pending = (pmx?.pending_updates || []).find((u: any) => u.key === tool.key) + return [ + esc(tool.key.replace(/_/g, " ")), esc(tool.version === "True" || tool.version === "False" ? auditLabel(t,"unversioned") : tool.version || auditLabel(t,"unversioned")), + pending ? chip("warn", t("audit.document.updateAvailable", + { version: String(pending.available) })) + : esc(auditLabel(t,"noPendingRecorded")), + ] + }) + const appRows = apps.map((a: any) => [ + esc(a.name || "—"), esc(String(a.vmid ?? "—")), + esc(a.version || t("audit.inventory.versionUnknown")), + ]) + + const body = ` + ${toolRows.length ? ` + ${heading(t("audit.inventory.proxmenux"), "software")} + ${table([t("audit.document.name"), t("audit.document.version"), + t("audit.document.state")], toolRows)}` : ""} + ${appRows.length ? ` + ${heading(t("audit.inventory.applications"), "software")} + ${table([t("audit.document.name"), t("audit.document.vmid"), + t("audit.document.version")], appRows)}` : ""}` + return section(n, t("audit.document.managedSoftware"), body, "software") +} + +// --------------------------------------------------------------------------- +// Findings in full +// --------------------------------------------------------------------------- + +function findingsSection(input: DocumentInput, n: number): string { + const { findings, t, locale } = input + if (findings.length === 0) return "" + const areas = Array.from(new Set(findings.map((f) => f.area))).sort() + const parts: string[] = [] + + for (const area of areas) { + // Within an area the reader still wants the worst first. + const rows = findings.filter((f) => f.area === area) + .sort((a, b) => ORDER.indexOf(shownAs(a)) - ORDER.indexOf(shownAs(b))) + parts.push(`${heading(t(`audit.areas.${area}`))}`) + for (const f of rows) { + const groups = presentFinding(f, t, locale, input.inventory?.sections?.guests || []) + const bits = [ + `
`, + chip(shownAs(f), t(`audit.classifications.${shownAs(f)}`)), + `${esc(t(`audit.checks.${f.check_id}.title`))}`, + f.incomplete ? chip("unknown", t("audit.document.incomplete")) : "", + `
`, + ] + const summary = summaryOf(f, t, true) + if (summary) bits.push(`

${esc(summary)}

`) + // "Could not be evaluated" describes the assessment, not the host. + const unread = unreadSources(f.sources, t) + if (unread) bits.push(`

${esc(unread)}

`) + bits.push(`

${esc(t(`audit.checks.${f.check_id}.rationale`))}

`) + if (f.exception) { + bits.push(`

${esc(t("audit.detail.acceptedRisk"))}: ` + + `${esc(f.exception.reason)} — ${esc(f.exception.accepted_by)}, ` + + `${esc(when(f.exception.accepted_at, locale))}

`) + } + for (const group of groups) { + bits.push(heading(group.title), group.note ? `

${esc(group.note)}

` : "", table(group.columns, group.rows.map(row => row.cells.map(esc)))) + } + if (f.evidence && shownAs(f) === "conformant" && groups.length === 0) { + bits.push(heading(auditLabel(t, "evidenceObserved"), "scope"), + evidenceHtml(f.evidence, locale, { t, rows: 4, lines: 5, blocks: 3 })) + } else if (f.evidence && f.classification !== "not_applicable") { + bits.push(`

${esc(auditLabel(t,"detailsLink"))}: ${esc(f.check_id)}

`) + } + const rowCount = groups.reduce((total, group) => total + group.rows.length, 0) + parts.push(`
${bits.join("\n")}
`) + } + } + return section(n, t("audit.document.findings"), parts.join("\n"), "findings") +} + + +// --------------------------------------------------------------------------- +// Quick diagnosis +// --------------------------------------------------------------------------- + +/** How many affected rows a diagnostic prints before it stops counting. */ +const DIAGNOSTIC_ROW_CAP = 8 + +/** + * What the host is asking its administrator to decide, and nothing else. + * + * The full report answers "what is this machine"; this one answers "what + * do I do now". Everything conformant is left out on purpose: a document + * that prints thirty passing checks to reach five failing ones makes the + * five harder to find, which is the opposite of a diagnosis. + */ +function diagnosticSummary(input: DocumentInput, n: number): string { + const { findings, t, locale } = input + const acting = findings.filter((f) => ["critical", "warning"].includes(shownAs(f))) + const counts = ["critical", "warning"].map((c) => ({ + key: c, total: findings.filter((f) => shownAs(f) === c).length, + })) + const node = input.inventory?.sections?.identity?.node || t("audit.document.unknownNode") + const ran = input.run?.finished_at ?? input.run?.started_at + + const body = grid(4, [ + card(t("audit.document.node"), esc(String(node))), + card(t("audit.document.generated"), esc(when(ran, locale))), + ...counts.map((c) => card(t(`audit.classifications.${c.key}`), String(c.total))), + ]) + const verdict = acting.length + ? `

${esc(t("audit.document.diagnosticActing", { count: String(acting.length) }))}

` + : `

${esc(t("audit.document.diagnosticClear"))}

` + return section(n, t("audit.document.diagnosticTitle"), body + verdict, "summary") +} + +/** + * Each finding that asks for a decision, with the evidence needed to + * take it and no more. Long tables are cut: thirty identical rows say + * the same thing the first eight already said, and the reader who wants + * every one of them wants the full report. + */ +function actionsSection(input: DocumentInput, n: number): string { + const { findings, t, locale } = input + const acting = findings + .filter((f) => ["critical", "warning"].includes(shownAs(f))) + .sort((a, b) => ORDER.indexOf(shownAs(a)) - ORDER.indexOf(shownAs(b))) + if (!acting.length) return "" + + const parts = acting.map((f) => { + const bits = [ + `
`, + chip(shownAs(f), t(`audit.classifications.${shownAs(f)}`)), + `${esc(t(`audit.checks.${f.check_id}.title`))}`, + `${esc(t(`audit.areas.${f.area}`))}`, + `
`, + ] + const summary = summaryOf(f, t) + if (summary) bits.push(`

${esc(summary)}

`) + const unread = unreadSources(f.sources, t) + if (unread) bits.push(`

${esc(unread)}

`) + bits.push(`

${esc(t(`audit.checks.${f.check_id}.rationale`))}

`) + for (const group of presentFinding(f, t, locale, input.inventory?.sections?.guests || [])) { + const shown = group.rows.slice(0, DIAGNOSTIC_ROW_CAP) + bits.push(heading(group.title), + table(group.columns, shown.map((row) => row.cells.map(esc)))) + if (group.rows.length > shown.length) { + bits.push(`

${esc(t("audit.document.diagnosticMoreRows", + { count: String(group.rows.length - shown.length) }))}

`) + } + } + return `
${bits.join("\n")}
` + }) + // The same heading the full report uses: naming the section after what + // the reader is expected to do with it was a judgement the document + // has no business making. + return section(n, t("audit.document.findings"), parts.join("\n"), "findings") +} + +/** + * Readings that could not be taken. Kept because a diagnosis that hides + * its own blind spots is worse than one that names them. + */ +function unreadSection(input: DocumentInput, n: number): string { + const { findings, t } = input + const unread = findings.filter((f) => f.classification === "unverified") + if (!unread.length) return "" + const rows = unread.map((f) => [ + esc(t(`audit.checks.${f.check_id}.title`)), + esc(t(`audit.areas.${f.area}`)), + esc(summaryOf(f, t)), + ]) + return section(n, t("audit.document.diagnosticUnread"), + table([t("audit.presentation.checkName"), t("audit.document.area"), + auditLabel(t, "fact")], rows), "scope") +} + +// --------------------------------------------------------------------------- +// Scope +// --------------------------------------------------------------------------- + +function evidenceSection(input: DocumentInput, n: number): string { + // Passing checks carry a compact evidence excerpt beside their result. + // The appendix is reserved for findings whose evidence an operator may + // need to investigate, which keeps a useful report from becoming dozens + // of pages of successful raw probes. + const rows = input.findings.filter(f => f.evidence && + !["conformant", "not_applicable"].includes(shownAs(f))) + if (!rows.length) return "" + return section(n, auditLabel(input.t,"annex"), `

${esc(auditLabel(input.t,"annexScope"))}

` + rows.map(f => + `
` + heading(input.t(`audit.checks.${f.check_id}.title`), "scope", f.check_id) + + evidenceHtml(f.evidence, input.locale) + `
`).join(""), "scope") +} + +function scopeSection(input: DocumentInput, n: number): string { + const { t, inventory } = input + const missing = Object.entries(inventory?.unavailable || {}) + // The engine records which declaration it judged against. A report + // that omits it reads identically whether the host was measured + // against stated expectations or against none, and those are two + // different reports about the same machine. + const policy = input.run?.metadata?.policy + const declared = policy?.declared + ? t("audit.document.policyDeclared", { + guests: String(policy.guests_declared ?? 0), + storages: String(policy.storages_declared ?? 0), + thresholds: String((policy.thresholds_declared || []).length), + }) + : t("audit.document.policyNone") + const body = ` +
+

${esc(t("audit.document.scopeText", + { profile: t(`audit.profile.${input.profile}`) }))}

+
    +
  • ${esc(t("audit.document.scopeLocal"))}
  • +
  • ${esc(auditLabel(t,"readOnlyScope"))}
  • +
  • ${esc(t("audit.document.scopeMoment"))}
  • +
  • ${esc(declared)}
  • +
+ ${missing.length ? ` +

${esc(t("audit.document.notRead"))}

+
    ${missing.map(([k, v]) => + `
  • ${esc(k)}: ${esc(String(v))}
  • `).join("")}
` : ""} +
` + return section(n, t("audit.document.scope"), body, "scope") +} + +// --------------------------------------------------------------------------- + +export function buildAuditDocument(input: DocumentInput): string { + const { t, locale } = input + const node = input.inventory?.sections?.identity?.node || t("audit.document.unknownNode") + const id = reportId("AUDIT") + + // The quick diagnosis is a different document, not the same one with + // sections withheld: it opens on what needs a decision instead of on + // what the machine is, and it prints no inventory, no diagrams and no + // annex. Everything is still assessed — only the printing is short. + // Structure and configuration, with nothing assessed. The profile runs + // no checks, so an assessment summary above it counted nothing and a + // findings section below it listed nothing: two empty frames around + // the only thing the reader opened this for. + const builders = input.profile === "inventory" + ? [ + identitySection, clusterSection, architectureSection, disksSection, + networkSection, latencySection, storageSection, guestsSection, + passthroughSection, proxmenuxSection, scopeSection, + ] + : input.profile === "diagnostic" + ? [diagnosticSummary, actionsSection, unreadSection, scopeSection] + : [ + executiveSummary, identitySection, clusterSection, architectureSection, + disksSection, networkSection, latencySection, storageSection, guestsSection, + passthroughSection, proxmenuxSection, findingsSection, scopeSection, evidenceSection, + ] + + // A section a profile did not ask for produces nothing, and the + // numbering closes over the gap rather than skipping a number. Each + // builder is therefore called once the previous one is known to have + // produced something, not in a pass of its own. + const sections: string[] = [] + for (const build of builders) { + const html = build(input, sections.length + 1) + if (html) sections.push(html) + } + const body = sections.join("\n").replace(//g, '') + + // A document that assesses nothing should not be titled as an audit. + const documentKey = input.profile === "diagnostic" ? "diagnostic" + : input.profile === "inventory" ? "structure" : "" + return renderReport({ + title: documentKey ? t(`audit.document.${documentKey}Title`) : t("audit.document.title"), + subtitle: documentKey ? t(`audit.document.${documentKey}Subtitle`, { node }) + : t("audit.document.subtitle", { node }), + topBarSubtitle: node, + meta: [ + [t("audit.document.node"), node], + [t("audit.document.profile"), t(`audit.profile.${input.profile}`)], + [t("audit.document.generated"), new Date().toLocaleString(locale)], + ], + reportId: id, + logoUrl: `${window.location.origin}/images/proxmenux-logo.png`, + footerLeft: `ProxMenux · ${t("audit.document.title")} · ${node}`, + footerRight: `${id} · ${new Date().toLocaleDateString(locale)}`, + lang: locale, + extraCss: REPORT_CSS_AUDIT + `@page { @bottom-left { content: "ProxMenux · ${esc(String(node)).replace(/["\\\n\r]/g, " ")}"; font-size: 8pt; color: #64748b; } @bottom-right { content: counter(page) " / " counter(pages); font-size: 8pt; color: #64748b; } }`, + body, + }) +} + +/** + * The window is opened by the caller on the click itself so the popup + * blocker sees the gesture; the document is written into it once the + * inventory has been fetched. + */ +export function openAuditDocument(input: DocumentInput, target: Window | null): void { + writeReport(target, buildAuditDocument(input)) +} + +export { openReportWindow } diff --git a/AppImage/lib/audit-presentation.ts b/AppImage/lib/audit-presentation.ts new file mode 100644 index 00000000..a39eb08b --- /dev/null +++ b/AppImage/lib/audit-presentation.ts @@ -0,0 +1,325 @@ +/** Descriptive view model shared by the Monitor and the printable report. + * Raw evidence remains separate. This layer never proposes an action. + */ +import { splitLeadingJson, durationOf, formatValue } from "./evidence-format" + +export type AuditTranslate = (key: string, params?: Record) => string +export interface PresentedFinding { + check_id: string + classification: string + affected: Array> + evidence: string | null +} +export interface AuditGroup { + title: string + note?: string + columns: string[] + rows: Array<{ cells: string[]; classification: string }> +} +export const auditLabel = (t: AuditTranslate, key: string) => t(`audit.presentation.${key}`) + +/** El estado que devuelve `pvesubscription get`, en palabras del lector. */ +export function subscriptionLabel(t: AuditTranslate, status?: string | null): string { + const key = (status || "").trim().toLowerCase() + if (!key) return "" + const known = ["notfound", "active", "invalid", "expired", "suspended", "new", "unknown"] + return known.includes(key) ? t(`audit.inventory.subscriptionStatus.${key}`) : (status as string) +} + +/** + * What a check could not read, in the reader's own words. + * + * A check that reports "could not be evaluated" and stops there + * describes the assessment rather than the host: the reason is recorded + * against each source, but it sat two collapsed sections below a line + * that explained nothing. This is what the finding says out loud + * instead. + */ +export function unreadSources( + sources: Array<{ source: string; error?: string }> | undefined, + t: AuditTranslate, +): string { + const failed = (sources || []).filter((s) => s.error) + if (!failed.length) return "" + const named = failed.map((s) => { + // Sources are recorded as they were invoked — `cmd:["pvesm", …]`. + // The reader wants the command, not its serialisation. + let name = s.source + if (name.startsWith("cmd:")) { + try { + name = (JSON.parse(name.slice(4)) as string[]).join(" ") + } catch { + name = name.slice(4) + } + } + return `${name} — ${String(s.error).replace(/\s+/g, " ").trim()}` + }) + return `${auditLabel(t, "couldNotRead")}: ${named.join(" · ")}` +} + +export function auditDuration(hours: number, locale: string): string { + return durationOf(hours, locale) +} + +export function evidenceRecords(evidence: string | null): Array> { + const parsed = splitLeadingJson((evidence || "").trim()) + return parsed && Array.isArray(parsed[0]) ? parsed[0].filter(x => x && typeof x === "object") : [] +} + +const clean = (v: unknown): string => v === undefined || v === null || v === "-" ? "" : String(v) + +/** + * Instants in the audit have two deliberate forms: epoch seconds from + * Proxmox, and local ISO timestamps from the Monitor's SQLite stores. + * A timezone-less SQLite value is already local wall time; treating it + * as UTC shifts it a second time in the printable report. + */ +function auditDate(value: unknown): Date | null { + if (value === undefined || value === null || value === "") return null + if (typeof value === "number" || /^\d+(?:\.\d+)?$/.test(String(value))) { + const date = new Date(Number(value) * 1000) + return Number.isNaN(date.getTime()) ? null : date + } + const text = String(value).trim() + const local = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?$/.exec(text) + const date = local + ? new Date(Number(local[1]), Number(local[2]) - 1, Number(local[3]), + Number(local[4]), Number(local[5]), Number(local[6]), + Number((local[7] || "0").slice(0, 3).padEnd(3, "0"))) + : new Date(text) + return Number.isNaN(date.getTime()) ? null : date +} + +export function auditInstant(value: unknown, locale: string): string { + const date = auditDate(value) + return date ? date.toLocaleString(locale) : clean(value) || "—" +} + +export function presentFinding(f: PresentedFinding, t: AuditTranslate, locale: string, + guests: Array<{ vmid: number; name?: string; type?: string }> = []): AuditGroup[] { + const label = (key: string) => auditLabel(t, key) + const records = evidenceRecords(f.evidence) + const parsedEvidence = splitLeadingJson((f.evidence || "").trim()) + const evidenceObject = parsedEvidence && parsedEvidence[0] && + typeof parsedEvidence[0] === "object" && !Array.isArray(parsedEvidence[0]) + ? parsedEvidence[0] as Record : null + const guest = (o: Record) => { + const id = o.vmid ?? o.guest + const known = guests.find(g => String(g.vmid) === String(id)) + const type = o.type || known?.type + const prefix = type === "qemu" || type === "vm" ? "VM" : type === "lxc" || type === "ct" ? "LXC" : label("guest") + const name = clean(o.name || known?.name) + return `${name ? name + " · " : ""}${prefix} ${id}` + } + const resource = (o: Record) => o.vmid !== undefined || o.guest !== undefined + ? guest(o) : clean(o.name || o.device || o.storage || o.pool || o.bond || o.interface || o.job || o.package || o.test) || label("host") + // A decision the reader took stands in front of the technical result: + // an object excluded by policy is not a finding at a low gravity, it + // is one that was taken out of the question. + const status = (o: Record) => + clean(o.decision) || clean(o.classification) || f.classification + const state = (o: Record) => t(`audit.classifications.${status(o)}`) + const reason = (o: Record) => { + const key = `audit.presentation.reasons.${clean(o.reason_key)}` + const translated = t(key) + return translated !== key ? translated : t(`audit.checks.${f.check_id}.title`) + } + const group = (title: string, columns: string[], objects: Array>, + cells: (o: Record) => string[]): AuditGroup => ({ + title, columns, rows: objects.map(o => ({ cells: cells(o), classification: status(o) })), + }) + if (f.check_id === "storage.connected_storage") { + const storages = Array.isArray(evidenceObject?.storages) + ? evidenceObject!.storages.filter((o: unknown) => o && typeof o === "object") : [] + if (storages.length) { + const objects = storages.map((row: Record) => { + const finding = f.affected.find(o => o.storage === row.storage) + return { ...row, classification: finding?.classification || + (["active", "available", "namespace_restricted"].includes(clean(row.status)) + ? "conformant" : "unverified") } + }) + return [group("PVE", [t("audit.document.storage"), t("audit.document.type"), + t("audit.document.state"), label("capacity"), label("fact")], objects, o => { + const dependencyCount = Array.isArray(o.dependencies) ? o.dependencies.length : 0 + const jobCount = Array.isArray(o.jobs) ? o.jobs.length : 0 + const observed = [dependencyCount ? `${dependencyCount} ${t("audit.inventory.guests")}` : "", + jobCount ? `${jobCount} ${t("audit.document.backup")}` : ""].filter(Boolean).join(" · ") || "—" + const capacity = o.capacity_known && o.used_percent !== undefined + ? `${new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }).format(Number(o.used_percent))} %` + : "—" + return [clean(o.storage), clean(o.type), clean(o.status) || t("audit.classifications.unverified"), + capacity, observed] + })] + } + } + if (f.check_id === "storage.thin_pool_overprovisioning" && records.length) { + const objects = records.map(row => { + const related = f.affected.filter(o => o.pool === row.pool) + const classification = related.some(o => o.classification === "warning") ? "warning" + : related.some(o => o.classification === "observation") ? "observation" : "conformant" + return { ...row, classification, + fact: related.map(reason).filter((v, i, all) => all.indexOf(v) === i).join(" · ") } + }) + const pct = (value: unknown) => Number.isFinite(Number(value)) + ? `${new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }).format(Number(value))} %` : "—" + return [group(label("records"), [label("resource"), label("capacity"), label("data"), label("metadata"), label("fact")], + objects, o => { + const allocation = Number.isFinite(Number(o.allocation_percent)) + ? `${pct(o.allocation_percent)} (${formatValue("allocated_bytes", Number(o.allocated_bytes), locale)} / ${formatValue("pool_bytes", Number(o.pool_bytes), locale)})` : "—" + return [clean(o.pool), allocation, pct(o.data_percent), pct(o.metadata_percent), + clean(o.fact) || state(o)] + })] + } + if (f.check_id === "backup.guest_coverage") { + const excluded = f.affected.filter(o => o.reason_key === "dataExcludedFromBackup") + const notSelected = f.affected.filter(o => o.reason_key !== "dataExcludedFromBackup") + const groups = [] + if (notSelected.length) groups.push(group(label("unscheduled"), [label("guest"), label("result")], + notSelected, o => [guest(o), state(o)])) + if (excluded.length) { + const ids = [...new Set(excluded.map(o => o.vmid))] + groups.push(group(`${label("excludedDisks")} · ${excluded.length} / ${ids.length} ${label("guests")}`, + [label("guest"), label("disks"), label("result")], ids.map(vmid => ({...excluded.find(o => o.vmid === vmid)!, vmid})), + o => [guest(o), excluded.filter(d => d.vmid === o.vmid).map(d => clean(d.volume)).join(", "), state(o)])) + } + return groups + } + if (f.check_id === "backup.last_backup_age") { + return ["critical", "warning", "observation", "unverified"].flatMap(classification => { + const objects = f.affected.filter(o => status(o) === classification) + if (!objects.length) return [] + const result = group(t(`audit.classifications.${classification}`), + [label("guest"), label("destination"), label("lastCopy"), label("backupAge"), label("backupLimit"), label("fact")], objects, o => { + const row = records.find(r => String(r.vmid) === String(o.vmid) && ( + o.storage === "any" || r.expected_storage === o.storage || + r.expected_storage === "any visible destination (no explicit target)" || + r.storage === o.storage)) + const basis = row?.age_policy === "declared recovery objective" ? label("limitDeclared") + : row?.age_policy === "schedule and grace" ? label("limitSchedule") + : typeof row?.age_policy === "string" && row.age_policy.startsWith("fallback;") ? label("limitReference") : "" + const limit = row?.max_age_hours != null && Number.isFinite(Number(row.max_age_hours)) + ? [auditDuration(Number(row.max_age_hours), locale), basis].filter(Boolean).join(" · ") : "—" + return [guest(o), o.storage === "any" ? label("noDestination") : clean(o.storage), + row?.last_backup ? new Date(Number(row.last_backup) * 1000).toLocaleString(locale) : classification === "unverified" ? t("audit.classifications.unverified") : label("notFound"), + row?.age_hours != null && Number.isFinite(Number(row.age_hours)) ? auditDuration(Number(row.age_hours), locale) : "—", + limit, + reason(o)] + }) + // Shared limits retain their origin without repeating it for every guest. + const shared = [1,4,5].filter(index => result.rows.length > 1 && result.rows.every(row => row.cells[index] === result.rows[0].cells[index])) + result.note = shared.map(index => `${result.columns[index]}: ${result.rows[0].cells[index]}`).join(" · ") + result.columns = result.columns.filter((_,index) => !shared.includes(index)) + result.rows.forEach(row => { row.cells = row.cells.filter((_,index) => !shared.includes(index)) }) + return [result] + }) + } + if (f.check_id === "backup.job_results") { + // The PVE task list may contain dozens of repetitions of the same + // failed job. One row per task obscures the useful facts, so retain + // the count, time range, final status and latest UPID per guest. + const merged = new Map>() + for (const item of f.affected) { + // If PVE supplied neither an id field nor a guest-bearing UPID, + // keep the task separate rather than combining unrelated failures. + const identity = clean(item.vmid) || clean(item.upid || item.job) + const key = `${identity}\u0000${clean(item.status)}` + const known = merged.get(key) + const currentMs = auditDate(item.when)?.getTime() ?? 0 + if (!known) { + merged.set(key, { ...item, count: 1, first_seen: item.when, + last_seen: item.when, latest_job: item.upid || item.job, + _first_ms: currentMs, _last_ms: currentMs }) + continue + } + known.count = Number(known.count || 0) + 1 + if (currentMs && (!Number(known._first_ms) || currentMs < Number(known._first_ms))) { + known._first_ms = currentMs + known.first_seen = item.when + } + if (currentMs >= Number(known._last_ms || 0)) { + known._last_ms = currentMs + known.last_seen = item.when + known.latest_job = item.upid || item.job + } + } + const objects = [...merged.values()].sort((a, b) => + Number(b._last_ms || 0) - Number(a._last_ms || 0)) + return objects.length ? [group(t("audit.classifications.warning"), + [label("guest"), label("occurrences"), t("audit.document.firstSeen"), + t("audit.document.lastSeen"), label("detail"), label("technical")], + objects, o => [o.vmid === undefined || o.vmid === null ? "—" : guest(o), + clean(o.count) || "1", auditInstant(o.first_seen, locale), + auditInstant(o.last_seen, locale), clean(o.status) || reason(o), + clean(o.latest_job) || "—"])] : [] + } + // The inventory already presents these events properly: what happened, + // how severe, how often, when it started, when it last happened and + // what the kernel actually said. Six rows reading "sdh · still + // reporting errors" described none of that, so the finding shows the + // same table the inventory does, grouped by device. + if (f.check_id === "hardware.disk_errors") { + const devices = Array.from(new Set(f.affected.map(o => clean(o.name)))) + return devices.map(device => group(device, + [t("audit.document.event"), t("audit.document.severity"), + t("audit.document.occurrences"), t("audit.document.firstSeen"), + t("audit.document.lastSeen"), t("audit.document.detail")], + f.affected.filter(o => clean(o.name) === device), + o => [clean(o.type) || "—", + clean(o.severity) ? t(`audit.classifications.${ + o.severity === "critical" ? "critical" : "warning"}`) : "—", + clean(o.count) || "—", auditInstant(o.first_seen, locale), auditInstant(o.last_seen, locale), + clean(o.message) || "—"])) + } + // Lynis repeats a warning once per thing it applies to: ten + // promiscuous interfaces are ten identical records. Printed one per + // row under a heading that already said the same sentence, thirteen + // warnings filled seventeen rows and a column whose only content was + // the identifier repeated from the heading beside it. Collapsed to one + // row per distinct warning, with how many times it was raised and what + // it named where Lynis said so. + if (f.check_id === "security.lynis_warnings") { + const seen = new Map[]>() + for (const o of f.affected) { + const key = `${clean(o.test)}\u0000${clean(o.message)}` + seen.set(key, [...(seen.get(key) || []), o]) + } + const entries = [...seen.values()] + const detailed = entries.some(items => items.some(o => clean(o.details))) + // `occurrences` is worded for the middle of a sentence; the column + // header the disk table already uses reads correctly on its own. + const columns = [label("lynisTest"), label("lynisWarning"), + t("audit.document.occurrences")] + return [group(label("records"), detailed ? [...columns, label("detail")] : columns, + entries.map(items => items[0]), (o) => { + const items = seen.get(`${clean(o.test)}\u0000${clean(o.message)}`) || [o] + const cells = [clean(o.test) || "—", clean(o.message) || label("noDescription"), + String(items.length)] + if (!detailed) return cells + const named = [...new Set(items.map(i => clean(i.details)).filter(Boolean))] + return [...cells, named.join(", ") || "—"] + })] + } + return f.affected.length ? [group(label("records"), [label("resource"), label("fact"), label("result")], f.affected, o => { + const details = [clean(o.volume), clean(o.version), o.hours !== undefined ? auditDuration(Number(o.hours), locale) : ""].filter(Boolean).join(" · ") + return [resource(o), [reason(o), details].filter(Boolean).join(" · "), state(o)] + })] : [] +} + +export function affectedDescription(f: PresentedFinding, t: AuditTranslate): string { + const label = (key: string) => auditLabel(t, key) + if (f.check_id === "backup.guest_coverage") { + const disks = f.affected.filter(o => o.reason_key === "dataExcludedFromBackup").length + const guests = f.affected.length - disks + return [guests ? `${guests} ${label("unscheduled")}` : "", disks ? `${disks} ${label("excludedDisks")}` : ""].filter(Boolean).join(" · ") + } + return f.affected.length ? `${f.affected.length} ${label(f.check_id === "security.lynis_warnings" ? "occurrences" : "records")}` : "" +} + +export function resultBreakdown(f: PresentedFinding, t: AuditTranslate): string { + const counts = new Map() + for (const item of f.affected) { + const key = clean(item.classification) || f.classification + counts.set(key, (counts.get(key) || 0) + 1) + } + return [...counts].map(([key, count]) => `${count} · ${t(`audit.classifications.${key}`)}`).join(" / ") +} diff --git a/AppImage/lib/evidence-format.ts b/AppImage/lib/evidence-format.ts new file mode 100644 index 00000000..0b884459 --- /dev/null +++ b/AppImage/lib/evidence-format.ts @@ -0,0 +1,290 @@ +/** + * Turns a finding's evidence into something a reader can read. + * + * Checks record evidence in whatever shape suits what they examined: + * some serialise a list of objects, some an object of lists, some write + * a few lines of text. Printing that verbatim shows the reader a JSON + * dump and asks them to parse it — which defeats the purpose of evidence, + * which is to let someone verify a conclusion without trusting it. + * + * The parser recognises those shapes and returns blocks: a table for a + * list of records, labelled pairs for a single record, plain lines for + * the rest. Field names are humanised and values are formatted according + * to what the name says they are — a `_bytes` suffix is a size, `_hours` + * a duration, an `_at` an instant — so the reader sees "1.2 TiB" where + * the check wrote 1319413953331. + * + * Nothing is discarded: text the parser does not recognise is passed + * through as lines, because evidence that has been silently dropped is + * worse than evidence that is ugly. + */ + +export type EvidenceBlock = + | { kind: "table"; title?: string; columns: string[]; rows: string[][] } + | { kind: "pairs"; title?: string; entries: Array<[string, string]> } + | { kind: "text"; title?: string; lines: string[] } + +/** `max_age_hours` reads as "Max age hours"; `vmid` stays "VMID". */ +const ACRONYMS: Record = { + vmid: "VMID", id: "ID", cpu: "CPU", pci: "PCI", iommu: "IOMMU", + smart: "SMART", zfs: "ZFS", arc: "ARC", ssh: "SSH", lxc: "LXC", + pve: "PVE", pbs: "PBS", nfs: "NFS", url: "URL", os: "OS", ram: "RAM", +} + +export function humanise(field: string): string { + const parts = field.replace(/[_-]+/g, " ").trim().split(/\s+/) + if (parts.length === 0) return field + return parts + .map((word, i) => { + const known = ACRONYMS[word.toLowerCase()] + if (known) return known + return i === 0 ? word.charAt(0).toUpperCase() + word.slice(1) : word + }) + .join(" ") +} + +function sizeOf(value: number): string { + const units = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"] + let n = Math.abs(value), i = 0 + while (n >= 1024 && i < units.length - 1) { n /= 1024; i++ } + const shown = n >= 100 || i < 2 ? Math.round(n) : Number(n.toFixed(1)) + return `${value < 0 ? "-" : ""}${shown} ${units[i]}` +} + +export function durationOf(hours: number, locale: string): string { + if (!Number.isFinite(hours) || hours < 0) return "—" + const total = Math.round(hours * 60) + const days = Math.floor(total / 1440), h = Math.floor(total % 1440 / 60) + const unit = (v: number, name: string) => new Intl.NumberFormat(locale, { + style: "unit", unit: name, unitDisplay: "short", maximumFractionDigits: 0, + }).format(v) + return [days ? unit(days, "day") : "", h || days ? unit(h, "hour") : "", unit(total % 60, "minute")].filter(Boolean).join(" ") +} + +/** + * Formats one value using what its field name says it is. The name is + * the only type information a check leaves behind, so it is what the + * formatter reads. + */ +export function formatValue(field: string, value: unknown, locale: string, + units?: string): string { + if (value === null || value === undefined || value === "") return "—" + // A recorded `false` is an answer, and it used to render as the same + // dash as "nothing was recorded": a table of seven archives that are + // definitely gone read as seven about which nothing was known. + if (typeof value === "boolean") return value ? "✓" : "✗" + + const name = field.toLowerCase() + if (typeof value === "number") { + // The field name is read before the record's declared units: a + // record of sizes still carries a timestamp and a percentage, and + // those are not sizes. + if (name.endsWith("_hours") || name === "hours") return durationOf(value, locale) + if (name.endsWith("_days") || name === "days") return `${Number(value.toFixed(1))} d` + if (name.endsWith("_percent") || name.endsWith("_pct")) { + return `${Number(value.toFixed(1))} %` + } + // A check records instants as epoch seconds under names like + // `last_backup` or `collected_at`, so both the name and the + // magnitude have to agree before a number is shown as a date. + const temporal = /(^|_)(at|time|date|seen|since|backup|run|checked|updated)$/ + if (temporal.test(name) && Number.isFinite(value) + && value > 1_000_000_000 && value < 4_000_000_000) { + return new Date(value * 1000).toLocaleString(locale) + } + if (name.endsWith("_bytes") || name === "bytes" || name.endsWith("_size") + || units === "bytes") { + return sizeOf(value) + } + return new Intl.NumberFormat(locale, { maximumFractionDigits: 2 }).format(value) + } + + if (Array.isArray(value)) { + if (value.length === 0) return "—" + const shown = value.slice(0, 3).map((v) => { + if (v === null || typeof v !== "object") return String(v) + // Inside a cell, name each record by whatever identifies it + // rather than spelling out every field. + const record = v as Record + const key = ["vmid", "id", "name", "device", "storage", "volume", "job"] + .find((k) => record[k] !== undefined) + return key ? String(record[key]) + : Object.entries(record).map(([k, x]) => `${humanise(k)} ${String(x)}`).join(" ") + }) + return shown.join(", ") + (value.length > 3 ? ` +${value.length - 3}` : "") + } + + if (typeof value === "object") { + return Object.entries(value as Record) + .map(([k, v]) => `${humanise(k)}: ${formatValue(k, v, locale)}`) + .join(" · ") + } + + return String(value) +} + +function isRecordList(value: unknown): value is Array> { + return Array.isArray(value) && value.length > 0 && + value.every((v) => v !== null && typeof v === "object" && !Array.isArray(v)) +} + +function tableFrom(records: Array>, locale: string, + title?: string): EvidenceBlock[] { + // Union of the keys, in first-seen order: records from one check are + // uniform in practice, but a missing key must not shift a column. + const columns: string[] = [] + for (const record of records) { + for (const key of Object.keys(record)) { + if (!columns.includes(key)) columns.push(key) + } + } + const cells = records.map((r) => { + const units = typeof r.units === "string" ? r.units : undefined + return Object.fromEntries( + columns.map((c) => [c, formatValue(c, r[c], locale, units)])) + }) + + // A column holding the same value in every row is a property of the + // whole set, not of any row. Stating it once keeps the table narrow + // enough to read; it only pays off once the table is already wide. + const constant: Array<[string, string]> = [] + const varying = columns.filter((c) => { + if (columns.length <= 6 || records.length < 2) return true + const first = cells[0][c] + if (!cells.every((row) => row[c] === first) || first === "—") return true + constant.push([humanise(c), first]) + return false + }) + + const rows = cells.map((row) => varying.map((c) => row[c])) + return constant.length + ? [{ kind: "pairs" as const, title, entries: constant }, + { kind: "table" as const, columns: varying.map(humanise), rows }] + : [{ kind: "table" as const, title, columns: varying.map(humanise), rows }] +} + +function blocksFromValue(value: unknown, locale: string, + title?: string): EvidenceBlock[] { + if (isRecordList(value)) return tableFrom(value, locale, title) + + if (Array.isArray(value)) { + return value.length + ? [{ kind: "text", title, lines: value.map((v) => formatValue("", v, locale)) }] + : [] + } + + if (value !== null && typeof value === "object") { + const blocks: EvidenceBlock[] = [] + const pairs: Array<[string, string]> = [] + const record = value as Record + const units = typeof record.units === "string" ? record.units : undefined + for (const [key, inner] of Object.entries(record)) { + // A nested list of records earns its own table under its own name; + // everything else stays a labelled pair. + if (isRecordList(inner)) { + blocks.push(...tableFrom(inner, locale, humanise(key))) + } else { + pairs.push([humanise(key), formatValue(key, inner, locale, units)]) + } + } + if (pairs.length) blocks.unshift({ kind: "pairs", title, entries: pairs }) + return blocks + } + + return [{ kind: "text", title, lines: [formatValue("", value, locale)] }] +} + +/** + * Text evidence: lines like `label:` introduce the indented lines under + * them, which is the shape checks write by hand. + */ +function blocksFromText(text: string, locale: string): EvidenceBlock[] { + const lines = text.split("\n") + const blocks: EvidenceBlock[] = [] + let title: string | undefined + let buffer: string[] = [] + + const flush = () => { + const kept = buffer.filter((l) => l.trim()) + const joined = kept.join("\n").trim() + // A section introduced by a heading gets the same treatment as + // evidence that is JSON from the first character. + let sectionTitle = title + let source = joined + if (joined && !/^[[{]/.test(joined)) { + const at = joined.search(/:\s*[[{]/) + // Only a short prefix is a label; a paragraph that happens to + // mention a bracket is prose. + if (at > 0 && at < 80) { + sectionTitle = title || joined.slice(0, at).trim() + source = joined.slice(joined.indexOf(joined[at] === ":" ? ":" : ":", at) + 1).trim() + } + } + const split = source ? splitLeadingJson(source) : null + if (split) { + const [value, rest] = split + blocks.push(...blocksFromValue(value, locale, sectionTitle)) + if (rest) blocks.push({ kind: "text", lines: rest.split("\n") }) + buffer = [] + return + } + if (kept.length || title) blocks.push({ kind: "text", title, lines: kept }) + buffer = [] + } + + for (const line of lines) { + const heading = /^(\S[^:]*):\s*$/.exec(line) + if (heading) { + flush() + title = heading[1] + continue + } + buffer.push(line.replace(/^\s{1,4}/, "")) + } + flush() + return blocks.filter((b) => b.kind !== "text" || b.lines.length || b.title) +} + +/** + * Splits a leading JSON document from whatever text follows it, by + * balancing brackets outside of strings. Checks routinely serialise + * their records and then add a line qualifying them, and both halves + * are evidence. + */ +export function splitLeadingJson(text: string): [unknown, string] | null { + const open = text[0] + if (open !== "{" && open !== "[") return null + const close = open === "{" ? "}" : "]" + let depth = 0, inString = false, escaped = false, end = -1 + for (let i = 0; i < text.length; i++) { + const c = text[i] + if (escaped) { escaped = false; continue } + if (c === "\\") { escaped = true; continue } + if (c === '"') { inString = !inString; continue } + if (inString) continue + if (c === open) depth++ + else if (c === close && --depth === 0) { end = i + 1; break } + } + if (end < 0) return null + try { + return [JSON.parse(text.slice(0, end)), text.slice(end).trim()] + } catch { + return null + } +} + +/** Parses one finding's evidence into blocks a reader can read. */ +export function parseEvidence(evidence: string | null, + locale = "en"): EvidenceBlock[] { + if (!evidence) return [] + const text = evidence.trim() + if (!text) return [] + + const split = splitLeadingJson(text) + if (split) { + const [value, rest] = split + const blocks = blocksFromValue(value, locale) + return rest ? blocks.concat(blocksFromText(rest, locale)) : blocks + } + return blocksFromText(text, locale) +} diff --git a/AppImage/lib/report-diagrams.ts b/AppImage/lib/report-diagrams.ts new file mode 100644 index 00000000..cee026b9 --- /dev/null +++ b/AppImage/lib/report-diagrams.ts @@ -0,0 +1,556 @@ +/** + * Inline SVG diagrams for the audit report. + * + * The inventory already resolves how the pieces of a node connect; a + * diagram is what makes those relations legible at a glance. Drawn as + * SVG with no dependency so the document stays self-contained and prints + * as vector rather than as a screenshot. + * + * Colours come from the report stylesheet's palette so a diagram reads + * as part of the document and not as an embedded picture. + */ + +import { esc } from "./report-shell" + +const INK = "#0f172a" +const MUTED = "#64748b" +const LINE = "#94a3b8" +const FILL = "#f8fafc" +const EDGE = "#e2e8f0" +const ACCENT = "#06b6d4" +const WARN = "#ca8a04" + +/** + * Approximate width of a string at a given size. + * + * SVG has no layout: text drawn wider than its box simply spills over + * it. Measuring properly needs the font metrics, which are not available + * while composing the document, so widths are estimated per character + * class — narrow, wide and everything else — which is close enough to + * decide where to cut. + */ +function textWidth(text: string, size: number, bold = false): number { + let units = 0 + for (const c of text) { + if ("iljI.,:;'|! ".includes(c)) units += 0.30 + else if ("mwMW@".includes(c)) units += 0.92 + else if (c >= "A" && c <= "Z") units += 0.68 + else if (c >= "0" && c <= "9") units += 0.56 + else units += 0.54 + } + return units * size * (bold ? 1.06 : 1) +} + +/** Cuts a label to what fits, marking the cut. */ +function fit(text: string, width: number, size: number, bold = false): string { + if (textWidth(text, size, bold) <= width) return text + let out = text + while (out.length > 1 && textWidth(out + "…", size, bold) > width) { + out = out.slice(0, -1) + } + return out.trimEnd() + "…" +} + +/** + * Processor models carry trademark noise and a clock the diagram states + * elsewhere. The part a reader identifies the chip by is the family and + * the model number. + */ +export function shortenCpu(model: string): string { + return (model || "") + .replace(/\((?:R|TM|r|tm)\)/g, "") + .replace(/\b(CPU|Processor)\b/gi, "") + .replace(/\s*@.*$/, "") + .replace(/\s{2,}/g, " ") + .trim() +} + +interface Node { id: string; label: string; sub?: string; tone?: "plain" | "accent" | "warn" } + +function box(x: number, y: number, w: number, h: number, n: Node): string { + const stroke = n.tone === "accent" ? ACCENT : n.tone === "warn" ? WARN : EDGE + const inner = w - 12 + return ` + + ${esc(fit(n.label, inner, 11, true))} + ${n.sub ? `${esc(fit(n.sub, inner, 9))}` : ""} + ` +} + +function arrow(x1: number, y1: number, x2: number, y2: number): string { + return `` +} + +const DEFS = ` + + + +` + +function svg(width: number, height: number, body: string): string { + // A viewBox with no fixed width lets the diagram scale to the column on + // screen and to the page when printed, without a second layout. + return `${DEFS}${body}` +} + +/** + * Network path: physical interfaces, the bond that groups them when one + * exists, each bridge and the guests attached to it. This is the chain a + * reader would otherwise reconstruct by hand from three separate lists. + */ +export function networkDiagram( + bridges: Record, + guests: Array<{ vmid: number; name: string; interfaces: Array<{ bridge: string }> }>, + labels: { nic: string; bond: string; bridge: string; guests: string }, +): string { + const entries = Object.entries(bridges || {}) + if (entries.length === 0) return "" + + const COL_W = 132, BOX_H = 34, GAP_Y = 12, PAD = 12 + const rows: Array<{ nics: Node[]; bond: Node | null; bridge: Node; count: number }> = [] + + for (const [id, b] of entries) { + const hops = (b.uplink || []) as Array<{ kind: string; id: string; mode?: string }> + const bond = hops.find((h) => h.kind === "bond") + const nics = hops.filter((h) => h.kind === "nic") + const attached = guests.filter((g) => + (g.interfaces || []).some((n) => n.bridge === id)).length + rows.push({ + nics: nics.length ? nics.map((n) => ({ id: n.id, label: n.id })) + : [{ id: `${id}-none`, label: "—", tone: "warn" as const }], + bond: bond ? { id: bond.id, label: bond.id, sub: bond.mode, tone: "accent" as const } : null, + bridge: { id, label: id, tone: "accent" as const }, + count: attached, + }) + } + + // A host with no bond has no bond column: keeping the caption over an + // empty lane invites the reader to look for something that is not + // there, and leaves the diagram a quarter wider than it needs to be. + const hasBond = rows.some((r) => r.bond) + const bridgeCol = hasBond ? 2 : 1 + const guestsCol = bridgeCol + 1 + + const height = PAD * 2 + rows.reduce((h, r) => + h + Math.max(r.nics.length, 1) * (BOX_H + GAP_Y), 0) + const width = COL_W * (guestsCol + 1) + PAD * 2 + + let y = PAD + const parts: string[] = [] + // Column captions + const captions = hasBond + ? [labels.nic, labels.bond, labels.bridge, labels.guests] + : [labels.nic, labels.bridge, labels.guests] + parts.push(captions.map((c, i) => + `${esc(c.toUpperCase())}`).join("")) + y += 8 + + for (const row of rows) { + const block = Math.max(row.nics.length, 1) * (BOX_H + GAP_Y) + const midY = y + block / 2 - BOX_H / 2 + + row.nics.forEach((n, i) => { + const ny = y + i * (BOX_H + GAP_Y) + parts.push(box(PAD, ny, COL_W - 20, BOX_H, n)) + const target = row.bond ? PAD + COL_W : PAD + COL_W * bridgeCol + parts.push(arrow(PAD + COL_W - 20, ny + BOX_H / 2, target, midY + BOX_H / 2)) + }) + + if (row.bond) { + parts.push(box(PAD + COL_W, midY, COL_W - 20, BOX_H, row.bond)) + parts.push(arrow(PAD + COL_W * bridgeCol - 20, midY + BOX_H / 2, + PAD + COL_W * bridgeCol, midY + BOX_H / 2)) + } + parts.push(box(PAD + COL_W * bridgeCol, midY, COL_W - 20, BOX_H, row.bridge)) + parts.push(arrow(PAD + COL_W * guestsCol - 20, midY + BOX_H / 2, + PAD + COL_W * guestsCol, midY + BOX_H / 2)) + parts.push(box(PAD + COL_W * guestsCol, midY, COL_W - 20, BOX_H, + { id: `${row.bridge.id}-g`, label: String(row.count), sub: labels.guests })) + y += block + } + return svg(width, height + 8, parts.join("")) +} + +/** + * Where each guest's disks live, and what protects them. Storages and + * backup destinations are drawn once with the guests that depend on + * them, which is what turns two lists into a dependency picture. + */ +export function storageDiagram( + guests: Array<{ + vmid: number; name: string + disks: Array<{ storage: string | null }> + backups: Array<{ storage: string }> + }>, + labels: { guests: string; storage: string; backup: string; unprotected: string }, +): string { + const storages = new Map() + const destinations = new Map() + let unprotected = 0 + + for (const g of guests || []) { + for (const storage of new Set((g.disks || []).map(d => d.storage).filter(Boolean))) { + if (storage) storages.set(storage, (storages.get(storage) || 0) + 1) + } + if ((g.backups || []).length === 0) unprotected += 1 + for (const storage of new Set((g.backups || []).map(b => b.storage))) { + destinations.set(storage, (destinations.get(storage) || 0) + 1) + } + } + if (storages.size === 0) return "" + + const COL_W = 168, BOX_H = 34, GAP_Y = 12, PAD = 12 + const left = [...storages.entries()].sort() + const right = [...destinations.entries()].sort() + const lanes = Math.max(left.length, right.length + (unprotected ? 1 : 0), 1) + const height = PAD * 2 + 10 + lanes * (BOX_H + GAP_Y) + const width = COL_W * 3 + PAD * 2 + + const parts: string[] = [] + parts.push([labels.storage, labels.guests, labels.backup].map((c, i) => + `${esc(c.toUpperCase())}`).join("")) + + const centreY = PAD + 8 + (lanes * (BOX_H + GAP_Y)) / 2 - BOX_H / 2 + parts.push(box(PAD + COL_W, centreY, COL_W - 24, BOX_H, { + id: "guests", label: String((guests || []).length), sub: labels.guests, tone: "accent", + })) + + left.forEach(([name, count], i) => { + const y = PAD + 8 + i * (BOX_H + GAP_Y) + parts.push(box(PAD, y, COL_W - 24, BOX_H, { id: name, label: name, sub: `${count}` })) + parts.push(arrow(PAD + COL_W - 24, y + BOX_H / 2, PAD + COL_W, centreY + BOX_H / 2)) + }) + + right.forEach(([name, count], i) => { + const y = PAD + 8 + i * (BOX_H + GAP_Y) + parts.push(box(PAD + COL_W * 2, y, COL_W - 24, BOX_H, + { id: name, label: name, sub: `${count}` })) + parts.push(arrow(PAD + COL_W * 2 - 24, centreY + BOX_H / 2, PAD + COL_W * 2, y + BOX_H / 2)) + }) + + if (unprotected > 0) { + const y = PAD + 8 + right.length * (BOX_H + GAP_Y) + parts.push(box(PAD + COL_W * 2, y, COL_W - 24, BOX_H, { + id: "unprotected", label: String(unprotected), sub: labels.unprotected, + })) + parts.push(arrow(PAD + COL_W * 2 - 24, centreY + BOX_H / 2, PAD + COL_W * 2, y + BOX_H / 2)) + } + return svg(width, height, parts.join("")) +} + +/** + * Findings per area and state, as a stacked bar. A table of counts is + * exact but does not show where the weight of the assessment sits. + */ +export function findingsChart( + byArea: Record>, + areaLabel: (a: string) => string, + stateColor: Record, + order: string[], +): string { + const areas = Object.keys(byArea).sort() + if (areas.length === 0) return "" + const ROW_H = 26, PAD = 12, LABEL_W = 128, BAR_W = 320 + const max = Math.max(...areas.map((a) => + order.reduce((s, st) => s + (byArea[a][st] || 0), 0)), 1) + const height = PAD * 2 + areas.length * ROW_H + const width = LABEL_W + BAR_W + PAD * 2 + 30 + + const parts = areas.map((a, i) => { + const y = PAD + i * ROW_H + let x = LABEL_W + const total = order.reduce((s, st) => s + (byArea[a][st] || 0), 0) + const segs = order.filter((st) => byArea[a][st]).map((st) => { + const w = (byArea[a][st] / max) * BAR_W + const seg = `${esc(st)}: ${byArea[a][st]}` + x += w + return seg + }).join("") + return `${esc(areaLabel(a))}${segs} + ${total}` + }) + return svg(width, height, parts.join("")) +} + +/** + * How the node is built: the chassis and what is seated in it. + * + * Read left to right as the machine is assembled — processor and memory + * on the board, the controllers the board exposes, and what hangs off + * each controller. Drawn as nested frames rather than as a graph, + * because containment is what the reader is being told: this disk is + * behind that controller, these modules sit in those slots. + */ +export function nodeArchitectureDiagram( + hw: any, + identity: { node?: string; pve_version?: string }, + labels: { + chassis: string; processor: string; memory: string + controllers: string; disks: string; adapters: string + slotsUsed: string; cores: string; threads: string; empty: string + }, +): string { + if (!hw) return "" + + const PAD = 14, W = 860 + const parts: string[] = [] + let y = PAD + 18 + + const frame = (title: string, x: number, w: number, top: number, h: number) => { + parts.push(` + + ${esc(title.toUpperCase())}`) + } + + const chip = (x: number, top: number, w: number, h: number, + title: string, lines: string[], tone: "plain" | "accent" | "warn" = "plain") => { + const stroke = tone === "accent" ? ACCENT : tone === "warn" ? WARN : EDGE + const inner = w - 12 + parts.push(` + ${esc(fit(title, inner, 10.5, true))}` + + lines.map((l, i) => `${esc(fit(l, inner, 9))}`).join("")) + } + + // Board: processor and memory slots. + const cpu = hw.cpu || {} + const mem = hw.memory || {} + const modules: any[] = mem.modules || [] + const slots = mem.slots || modules.length + const boardH = 76 + frame(labels.chassis, PAD, W - PAD * 2, y, boardH) + + const model = shortenCpu(cpu.model) || labels.processor + const cpuW = Math.min(250, Math.max(150, textWidth(model, 10.5, true) + 20)) + chip(PAD + 14, y + 14, cpuW, 48, model, [ + `${cpu.sockets || 1} × ${cpu.cores_per_socket || "?"} ${labels.cores}`, + `${cpu.threads || "?"} ${labels.threads}`, + ], "accent") + + // One tile per slot, so an empty slot is as visible as a filled one. + // The tiles share what the processor leaves, gaps included, so a board + // with many slots narrows them rather than dropping the last one. + const count = Math.max(slots, modules.length, 1) + const slotArea = W - PAD * 2 - cpuW - 42 + const tileW = Math.min(96, Math.max(34, (slotArea - (count - 1) * 6) / count)) + for (let i = 0; i < count; i++) { + const m = modules[i] + const x = PAD + 28 + cpuW + i * (tileW + 6) + if (x + tileW > W - PAD - 8) break + chip(x, y + 14, tileW, 48, m ? String(m.size || "") : labels.empty, + m ? [String(m.type || ""), String(m.speed || "")] : [], + m ? "plain" : "warn") + } + parts.push(`${esc(labels.memory)}: ${mem.populated || 0}/${slots || "?"} ${esc(labels.slotsUsed)}`) + y += boardH + 26 + + // Controllers, with what each one carries underneath. + const controllers: any[] = hw.controllers || [] + const disks: any[] = hw.disks || [] + const adapters: any[] = hw.adapters || [] + const byBus = new Map() + for (const d of disks) { + const bus = d.bus || labels.disks + byBus.set(bus, [...(byBus.get(bus) || []), d]) + } + + const groups: Array<{ title: string; sub: string; items: string[] }> = [] + for (const [bus, list] of [...byBus.entries()].sort()) { + const kind = bus === "nvme" ? "Non-Volatile memory controller" + : bus === "sata" ? "SATA controller" : "" + const count = controllers.filter((c) => c.class === kind).length + groups.push({ + title: bus.toUpperCase(), + sub: count ? `${count} ${labels.controllers.toLowerCase()}` : labels.controllers.toLowerCase(), + items: list.map((d) => `${d.name} · ${d.rotational ? "HDD" : "SSD"}`), + }) + } + if (adapters.length) { + groups.push({ + title: labels.adapters.toUpperCase(), + sub: `${adapters.length}`, + items: adapters.map((a) => + `${a.name}${a.speed_mbps ? ` · ${a.speed_mbps >= 1000 + ? `${a.speed_mbps / 1000}G` : `${a.speed_mbps}M`}` : ""}`), + }) + } + if (groups.length === 0) return svg(W, y + PAD, parts.join("")) + + const colW = (W - PAD * 2 - (groups.length - 1) * 10) / groups.length + const rows = Math.max(...groups.map((g) => g.items.length)) + const groupH = 34 + Math.min(rows, 8) * 15 + 10 + groups.forEach((g, i) => { + const x = PAD + i * (colW + 10) + parts.push(` + + ${esc(fit(g.title, colW - 12, 10, true))}` + + g.items.slice(0, 8).map((item, j) => + `${esc(fit(item, colW - 20, 9.5))}`).join("") + + (g.items.length > 8 + ? `+${g.items.length - 8}` + : "")) + // Tie each group back to the board it hangs from. + parts.push(``) + }) + return svg(W, y + groupH + PAD, parts.join("")) +} + +/** + * Cluster membership: every configured node, which one this report + * describes, and whether the node currently sees it. + */ +export function clusterDiagram( + cluster: any, + labels: { thisNode: string; unreachable: string; links: string }, +): string { + if (!cluster || !(cluster.nodes || []).length) return "" + const nodes: any[] = cluster.nodes + const PAD = 16, BOX_W = 132, BOX_H = 46, GAP = 16 + const perRow = Math.min(nodes.length, 5) + const rowCount = Math.ceil(nodes.length / perRow) + const width = PAD * 2 + perRow * BOX_W + (perRow - 1) * GAP + const busY = PAD + 22 + const height = busY + 26 + rowCount * (BOX_H + 26) + PAD + + const parts: string[] = [] + // The corosync ring, drawn as the bus every node attaches to. + parts.push(` + ${esc( + `${cluster.name} · ${cluster.links || 1} ${labels.links}`.toUpperCase())}`) + + nodes.forEach((n, i) => { + const row = Math.floor(i / perRow), col = i % perRow + const x = PAD + col * (BOX_W + GAP) + const y = busY + 26 + row * (BOX_H + 26) + parts.push(``) + const offline = n.online === false + const stroke = offline ? WARN : n.local ? ACCENT : EDGE + parts.push(` + ${esc(n.name)} + ${esc(n.ring0_addr || "")} + ${esc( + offline ? labels.unreachable : n.local ? labels.thisNode : `id ${n.nodeid}`)}`) + }) + return svg(width, height, parts.join("")) +} + +/** + * Latency over the reported window, one line per target. + * + * Averages say what is normal; the shape says whether it stayed that + * way. A table of min/avg/max cannot show a link that was fine except + * for twenty minutes, which is the reading the chart exists for. + */ +export function latencyChart( + targets: Array<{ + target: string; label?: string + series: Array<{ t: number; v: number; max?: number | null }> + }>, + labels: { ms: string; hours: string }, +): string { + const drawn = targets.filter((t) => (t.series || []).length > 1) + if (drawn.length === 0) return "" + + const PAD = 12, LEFT = 46, BOTTOM = 24, W = 760, H = 210 + const plotW = W - LEFT - PAD, plotH = H - PAD - BOTTOM + const all = drawn.flatMap((t) => t.series) + const times = all.map((s) => s.t) + const t0 = Math.min(...times), t1 = Math.max(...times) + // The ceiling covers the peaks, so the chart cannot disagree with the + // maximum the table reports. + const peak = Math.max(...all.map((s) => Math.max(s.v, s.max ?? 0)), 1) + const top = niceCeiling(peak) + + const colors = [ACCENT, "#7c3aed", "#ca8a04"] + const x = (t: number) => LEFT + (t1 === t0 ? plotW : ((t - t0) / (t1 - t0)) * plotW) + const y = (v: number) => PAD + plotH - (Math.min(v, top) / top) * plotH + + const parts: string[] = [] + for (let i = 0; i <= 4; i++) { + const value = (top / 4) * i + const gy = y(value) + parts.push(` + ${axisLabel(value)}`) + } + parts.push(`${esc(labels.ms)}`) + + drawn.forEach((t, i) => { + const color = colors[i % colors.length] + const points = t.series + // The band spans each sample's peak, the line its average: one shows + // what the link usually does, the other what it did at worst. + if (points.some((s) => typeof s.max === "number")) { + const area = points.map((s, j) => + `${j === 0 ? "M" : "L"}${x(s.t).toFixed(1)} ${y(s.max ?? s.v).toFixed(1)}`).join(" ") + const back = points.slice().reverse().map((s) => + `L${x(s.t).toFixed(1)} ${y(s.v).toFixed(1)}`).join(" ") + parts.push(``) + } + const line = points + .map((s, j) => `${j === 0 ? "M" : "L"}${x(s.t).toFixed(1)} ${y(s.v).toFixed(1)}`) + .join(" ") + parts.push(``) + + const legendX = LEFT + i * 150 + parts.push(` + ${esc(fit(t.label || t.target, 130, 9))}`) + }) + + const span = Math.max(1, Math.round((t1 - t0) / 3600)) + parts.push(`${esc(`${span} ${labels.hours}`)}`) + return svg(W, H, parts.join("")) +} + +/** A ceiling that divides into four readable gridlines. */ +function niceCeiling(peak: number): number { + const magnitude = Math.pow(10, Math.floor(Math.log10(peak))) + for (const step of [1, 2, 2.5, 5, 10]) { + const candidate = step * magnitude + if (candidate >= peak) return candidate + } + return 10 * magnitude +} + +function axisLabel(value: number): string { + if (value === 0) return "0" + // Gridlines land on quarters of the ceiling, so halves are common; + // rounding them away would put a label where the line is not. + return String(Number(value.toFixed(Number.isInteger(value) ? 0 : 1))) +} diff --git a/AppImage/lib/report-shell.ts b/AppImage/lib/report-shell.ts new file mode 100644 index 00000000..55450510 --- /dev/null +++ b/AppImage/lib/report-shell.ts @@ -0,0 +1,496 @@ +/** + * Shared shell for ProxMenux Monitor reports. + * + * The SMART, latency and audit reports are one family: same header with + * the product mark and a report identifier, numbered sections, the same + * cards, tables and callouts, the same dark action bar on screen that + * disappears when printing. This module holds that common language so a + * new report joins the family instead of inventing its own. + * + * The stylesheet is the one the SMART report established, kept verbatim + * so the two documents are indistinguishable side by side. + */ + +export const REPORT_CSS = ` * { margin: 0; padding: 0; box-sizing: border-box; } + body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: #1a1a2e; background: #fff; font-size: 13px; line-height: 1.5; } + @page { margin: 10mm; size: A4; } + + /* === SCREEN: responsive layout === */ + @media screen { + body { max-width: 1000px; margin: 0 auto; padding: 24px 32px; padding-top: 64px; overflow-x: hidden; } + } + @media screen and (max-width: 640px) { + body { padding: 16px; padding-top: 64px; } + .grid-4 { grid-template-columns: 1fr 1fr; } + .grid-3 { grid-template-columns: 1fr 1fr; } + .rpt-header { flex-direction: column; gap: 12px; align-items: flex-start; } + .rpt-header-right { text-align: left; } + .exec-box { flex-wrap: wrap; } + .card-c .card-value { font-size: 16px; } + } + + /* === PRINT: force desktop A4 layout from any device === */ + @media print { + html, body { margin: 0 !important; padding: 0 !important; width: 100% !important; max-width: none !important; } + .no-print { display: none !important; } + .top-bar { display: none !important; } + .page-break { page-break-before: always; } + * { -webkit-print-color-adjust: exact !important; print-color-adjust: exact !important; } + body { font-size: 11px; padding-top: 0 !important; } + /* Force desktop grid layout regardless of viewport */ + .grid-4 { grid-template-columns: 1fr 1fr 1fr 1fr !important; } + .grid-3 { grid-template-columns: 1fr 1fr 1fr !important; } + .grid-2 { grid-template-columns: 1fr 1fr !important; } + .rpt-header { flex-direction: row !important; align-items: center !important; } + .rpt-header-right { text-align: right !important; } + .exec-box { flex-wrap: nowrap !important; } + .card-c .card-value { font-size: 20px !important; } + /* Page break control */ + .section { page-break-inside: avoid; break-inside: avoid; margin-bottom: 15px; } + .exec-box { page-break-inside: avoid; break-inside: avoid; } + .card { page-break-inside: avoid; break-inside: avoid; } + .grid-2, .grid-3, .grid-4 { page-break-inside: avoid; break-inside: avoid; } + .section-title { page-break-after: avoid; break-after: avoid; } + .attr-tbl tr { page-break-inside: avoid; break-inside: avoid; } + .attr-tbl thead { display: table-header-group; } + .rpt-footer { page-break-inside: avoid; break-inside: avoid; margin-top: 20px; } + svg { max-width: 100%; height: auto; } + /* Darken light grays for PDF readability */ + .rpt-header-left p, .rpt-header-right { color: #374151; } + .rpt-header-right .rid { color: #4b5563; } + .exec-text p { color: #374151; } + .card-label { color: #4b5563; } + .rpt-footer { color: #4b5563; } + [style*="color:#64748b"] { color: #374151 !important; } + [style*="color:#94a3b8"] { color: #4b5563 !important; } + [style*="color: #64748b"] { color: #374151 !important; } + [style*="color: #94a3b8"] { color: #4b5563 !important; } + [style*="color:#16a34a"], [style*="color: #16a34a"] { color: #16a34a !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; } + [style*="color:#dc2626"] { color: #dc2626 !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; } + [style*="color:#ca8a04"] { color: #ca8a04 !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; } + .health-ring, .card-value, .f-tag { -webkit-print-color-adjust: exact; print-color-adjust: exact; } + } + + /* Top bar for screen only */ + .top-bar { + position: fixed; top: 0; left: 0; right: 0; background: #0f172a; color: #e2e8f0; + padding: 12px 16px; display: flex; align-items: center; justify-content: space-between; z-index: 100; + font-size: 13px; + } + .top-bar-left { display: flex; align-items: center; gap: 12px; } + .top-bar-title { font-weight: 600; } + .top-bar-subtitle { font-size: 11px; color: #94a3b8; } + .top-bar button { + background: #06b6d4; color: #fff; border: none; padding: 8px 12px; border-radius: 6px; + font-size: 14px; font-weight: 600; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; + } + .top-bar button:hover { background: #0891b2; } + .top-bar .btn-group { display: flex; gap: 8px; } + .top-bar button svg { width: 18px; height: 18px; display: block; } + + /* Header */ + .rpt-header { + display: flex; align-items: center; justify-content: space-between; + padding: 18px 0; border-bottom: 3px solid #0f172a; margin-bottom: 22px; + } + .rpt-header-left { display: flex; align-items: center; gap: 14px; } + .rpt-header-left img { height: 44px; width: auto; } + .rpt-header-left h1 { font-size: 22px; font-weight: 700; color: #0f172a; } + .rpt-header-left p { font-size: 11px; color: #64748b; } + .rpt-header-right { text-align: right; font-size: 11px; color: #64748b; line-height: 1.6; } + .rpt-header-right .rid { font-family: monospace; font-size: 10px; color: #94a3b8; } + + /* Sections */ + .section { margin-bottom: 22px; } + .section-title { + font-size: 14px; font-weight: 700; color: #0f172a; text-transform: uppercase; + letter-spacing: 0.05em; padding-bottom: 5px; border-bottom: 2px solid #e2e8f0; margin-bottom: 12px; + } + + /* Executive summary */ + .exec-box { + display: flex; align-items: flex-start; gap: 20px; padding: 20px; + background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; margin-bottom: 16px; + } + .health-ring { + width: 96px; height: 96px; border-radius: 50%; display: flex; flex-direction: column; + align-items: center; justify-content: center; border: 4px solid; flex-shrink: 0; + } + .health-icon { font-size: 32px; line-height: 1; } + .health-lbl { font-size: 11px; font-weight: 700; letter-spacing: 0.05em; margin-top: 4px; } + .exec-text { flex: 1; min-width: 200px; } + .exec-text h3 { font-size: 16px; margin-bottom: 4px; } + .exec-text p { font-size: 12px; color: #64748b; line-height: 1.5; } + + /* Grids */ + .grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 8px; } + .grid-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px; margin-bottom: 8px; } + .grid-4 { display: grid; grid-template-columns: 1fr 1fr 1fr 1fr; gap: 8px; margin-bottom: 8px; } + .card { padding: 10px 12px; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; } + .card-label { font-size: 10px; font-weight: 600; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 2px; } + .card-value { font-size: 13px; font-weight: 600; color: #0f172a; } + .card-c { text-align: center; } + .card-c .card-value { font-size: 20px; font-weight: 800; } + + /* Tags */ + .f-tag { font-size: 9px; padding: 2px 6px; border-radius: 4px; font-weight: 600; } + + /* Tables */ + .attr-tbl { width: 100%; border-collapse: collapse; font-size: 11px; } + .attr-tbl th { text-align: left; padding: 6px 4px; font-size: 10px; color: #64748b; font-weight: 600; border-bottom: 2px solid #e2e8f0; background: #f1f5f9; } + .attr-tbl td { padding: 5px 4px; border-bottom: 1px solid #f1f5f9; color: #1e293b; } + .attr-tbl tr:hover { background: #f8fafc; } + .attr-tbl .col-name { word-break: break-word; } + .attr-tbl .col-raw { font-family: monospace; font-size: 10px; } + + /* Attribute explanation rows: full-width below the data row */ + .attr-explain-row td { padding-top: 0 !important; } + .attr-explain-row:hover { background: transparent; } + + /* Recommendations */ + .rec-item { display: flex; align-items: flex-start; gap: 12px; padding: 12px; border-radius: 6px; margin-bottom: 8px; } + .rec-icon { font-size: 18px; flex-shrink: 0; width: 24px; text-align: center; } + .rec-item strong { display: block; margin-bottom: 2px; } + .rec-item p { font-size: 12px; color: #64748b; margin: 0; } + .rec-ok { background: #dcfce7; border: 1px solid #86efac; } + .rec-ok .rec-icon { color: #16a34a; } + .rec-warn { background: #fef3c7; border: 1px solid #fcd34d; } + .rec-warn .rec-icon { color: #ca8a04; } + .rec-critical { background: #fee2e2; border: 1px solid #fca5a5; } + .rec-critical .rec-icon { color: #dc2626; } + .rec-info { background: #e0f2fe; border: 1px solid #7dd3fc; } + .rec-info .rec-icon { color: #0284c7; } + + /* Footer */ + .rpt-footer { + margin-top: 32px; padding-top: 12px; border-top: 1px solid #e2e8f0; + display: flex; justify-content: space-between; font-size: 10px; color: #94a3b8; + } + + /* NOTE: No mobile-specific layout overrides — print layout is always A4/desktop + regardless of the device generating the PDF. The @media print block above + handles all necessary print adjustments. */` + +/** + * Additions the assessment document needs on top of the shared sheet: + * state chips, findings, evidence and a frame for diagrams. Kept apart + * from REPORT_CSS so the inherited stylesheet stays byte-identical to + * the one the other reports use. + */ +export const REPORT_CSS_AUDIT = ` + .diagram { border: 1px solid #e2e8f0; border-radius: 8px; padding: 14px; + background: #ffffff; margin: 6px 0 14px; overflow-x: auto; } + .diagram-note { font-size: 10.5px; color: #64748b; margin: 0 0 10px; } + .chip { display: inline-block; padding: 2px 9px; border-radius: 999px; + font-size: 10px; font-weight: 700; letter-spacing: 0.04em; + text-transform: uppercase; white-space: nowrap; } + .chip.critical { background: #fee2e2; color: #991b1b; } + .chip.warning { background: #fef3c7; color: #92400e; } + .chip.observation { background: #dbeafe; color: #1e40af; } + .chip.conformant { background: #dcfce7; color: #166534; } + .chip.accepted { background: #e0e7ff; color: #3730a3; } + .chip.unverified, .chip.not_applicable { background: #f1f5f9; color: #475569; } + .finding { border: 1px solid #e2e8f0; border-left: 3px solid #cbd5e1; + border-radius: 6px; padding: 11px 13px; margin-bottom: 9px; + page-break-inside: avoid; break-inside: avoid; } + .finding.critical { border-left-color: #dc2626; } + .finding.warning { border-left-color: #ca8a04; } + .finding.observation { border-left-color: #3b82f6; } + .finding.conformant { border-left-color: #16a34a; } + .finding.accepted { border-left-color: #4f46e5; } + .finding-head { display: flex; align-items: center; gap: 9px; flex-wrap: wrap; } + .finding-head .title { font-weight: 700; font-size: 12.5px; color: #0f172a; } + .finding-head .cid { font-size: 10px; color: #94a3b8; font-family: ui-monospace, + SFMono-Regular, Menlo, monospace; } + .finding p { margin: 6px 0 0; font-size: 12px; color: #334155; } + .finding .rationale { font-size: 11px; color: #64748b; } + .evidence { margin-top: 8px; background: #f8fafc; border: 1px solid #e2e8f0; + border-radius: 5px; padding: 8px 10px; font-family: ui-monospace, + SFMono-Regular, Menlo, monospace; font-size: 10px; color: #475569; + white-space: pre-wrap; word-break: break-word; max-height: 260px; + overflow: hidden; } + /* The document is laid out for a page, but it is opened on phones + too. Wide content keeps its own scroller so the page itself never + moves sideways, and the header stacks instead of colliding. */ + @media screen and (max-width: 640px) { + .rpt-header { flex-direction: column; align-items: flex-start; gap: 10px; } + .rpt-header-right { text-align: left; } + .attr-tbl { display: block; overflow-x: auto; white-space: nowrap; } + .attr-tbl td, .attr-tbl th { white-space: normal; } + .diagram { padding: 8px; } + .top-bar-subtitle { display: none; } + } + .evidence-block { margin-top: 8px; } + .evidence-block .attr-tbl { font-size: 10.5px; margin: 4px 0 8px; } + .evidence-title { font-size: 11px; font-weight: 700; color: #334155; + margin: 8px 0 2px; } + .evidence-list { margin: 4px 0 8px; padding-left: 18px; font-size: 10.5px; + color: #475569; } + .evidence-list li { margin-bottom: 2px; word-break: break-word; } + .evidence-excerpt-note { margin:7px 0 0 !important; padding-top:6px; + border-top:1px solid #e2e8f0; font-size:10px !important; + color:#64748b !important; } + /* The inherited title is a block; the mark sits on its baseline. */ + .section-title { display: flex; align-items: center; } + .sub-title { display: flex; align-items: center; font-size: 12px; + margin: 14px 0 6px; color: #0f172a; } + .muted { color: #64748b; } + .sep { color: #94a3b8; padding: 0 6px; } + .scope { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; + padding: 14px 16px; font-size: 11.5px; color: #475569; } + .scope ul { margin: 6px 0 0; padding-left: 18px; } + .audit-counters { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; margin:14px 0; } + .assessment-incomplete { font-weight:600; color:#475569; } + .coverage-panel { border:1px solid #dbeafe; border-radius:8px; padding:14px; margin-bottom:14px; background:#f8fafc; } + .coverage-panel h3 { font-size:13px; margin-bottom:10px; } + .audit-meter { height:9px; background:#e2e8f0; border-radius:5px; overflow:hidden; margin:8px 0; } + .audit-meter > span { display:block; height:100%; background:#3b82f6; } + .coverage-labels { display:flex; justify-content:space-between; gap:15px; font-size:11px; margin-bottom:8px; } + .capacity-item { display:grid; grid-template-columns:1fr 1fr; gap:4px 15px; margin:10px 0; font-size:11px; break-inside:avoid; } + .capacity-item > span { text-align:right; } + .capacity-item .audit-meter { grid-column:1 / -1; } + .technical-ref { font-size:10px !important; } + .technical-entry { margin-bottom:18px; } + .technical-entry > .sub-title { break-after:avoid-page; page-break-after:avoid; } + .audit-table-scroll { max-width:100%; min-width:0; overflow-x:auto; } + .evidence-record { margin:8px 0 16px; } + .evidence-record td:first-child { width:28%; color:#64748b; } + .evidence-record td { overflow-wrap:anywhere; } + .evidence-block .attr-tbl { table-layout:fixed; width:100%; } + .evidence-block .attr-tbl td, .evidence-block .attr-tbl th { overflow-wrap:anywhere; word-break:normal; } + .finding .attr-tbl { font-size:11px; } + .finding .attr-tbl td { overflow-wrap:anywhere; } + .finding .sub-title { break-after:avoid; } + .health-ring .health-icon svg { margin-right:0 !important; } + .audit-verification-ring { position:relative; width:126px; height:126px; flex:0 0 126px; color:#64748b; } + .audit-verification-ring > svg { display:block; width:100%; height:100%; } + .audit-verification-value { position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center; text-align:center; color:inherit; } + .audit-verification-value strong { font-size:25px; line-height:1.3; } + .audit-verification-value span { font-size:11px; max-width:100px; overflow-wrap:anywhere; } + .audit-result-heading { display:flex; align-items:center; gap:8px; } + .audit-result-heading svg { flex-shrink:0; } + a { color:#2563eb; text-decoration:none; } + @media print { + .audit-table-scroll { overflow:visible; } + .audit-verification-ring, .exec-text p.muted { color:#374151; } + .section, .finding { break-inside:auto; page-break-inside:auto; } + .finding-short { break-inside:avoid-page; page-break-inside:avoid; } + .section-title, .sub-title, .finding-head { break-after:avoid-page; page-break-after:avoid; } + .finding-head + p { break-after:avoid-page; } + .attr-tbl { overflow:visible !important; } + .attr-tbl thead { display:table-header-group; } + .attr-tbl tr { break-inside:avoid-page; page-break-inside:avoid; } + .technical-entry p, .finding p { orphans:3; widows:3; } + .audit-counters, .coverage-panel { break-inside:avoid; } + .diagram { break-inside: avoid; page-break-inside: avoid; } + .chip, .finding { -webkit-print-color-adjust: exact; print-color-adjust: exact; } + .evidence { max-height: none; } + } +` + +/** Report identifiers follow the family format: prefix and a base-36 stamp. */ +export function reportId(prefix: string): string { + return `${prefix}-${Date.now().toString(36).toUpperCase()}` +} + +export function esc(value: unknown): string { + return String(value ?? "") + .replace(/&/g, "&").replace(//g, ">") + .replace(/"/g, """) +} + +/** Icon-only actions, as in the rest of the family: the browser's print + * dialog exposes "Save as PDF" as a destination, so one button covers both. */ +const PRINT_ICON = `` + +export interface ShellOptions { + title: string + subtitle: string + /** Right-hand header rows, rendered in order. */ + meta: Array<[string, string]> + reportId: string + logoUrl: string + topBarSubtitle?: string + footerLeft: string + footerRight: string + lang: string + body: string + /** Extra stylesheet appended after the shared one. */ + extraCss?: string +} + +export function renderReport(o: ShellOptions): string { + const metaRows = o.meta + .filter(([, v]) => v) + .map(([k, v]) => `
${esc(k)}: ${esc(v)}
`).join("\n") + + return ` + + + + +${esc(o.title)}${o.topBarSubtitle ? ` - ${esc(o.topBarSubtitle)}` : ""} + + + + + +
+
+ ${esc(o.title)} + ${esc(o.topBarSubtitle || "")} +
+
+ +
+
+ +
+
+ ProxMenux +
+

${esc(o.title)}

+

${esc(o.subtitle)}

+
+
+
+ ${metaRows} +
ID: ${esc(o.reportId)}
+
+
+ +${o.body} + + + +` +} + +/** + * Section marks. + * + * A document of twelve sections is navigated by flicking through it, and + * a shape is found faster than a word is read. Drawn in the title's own + * grey at a single stroke weight so they mark the section without + * competing with the states, which are the only colour that carries + * meaning here. + */ +const ICON_PATHS: Record = { + summary: '', + node: '', + cluster: '', + architecture: '', + disks: '', + network: '', + storage: '', + guests: '', + passthrough: '', + software: '', + findings: '', + scope: '', + memory: '', + controller: '', + adapter: '', + bridge: '', + observation: '', + latency: '', +} + +/** An inline mark, sized to sit on the line of the text it precedes. */ +export function icon(name: keyof typeof ICON_PATHS | string, size = 16, + color = "#64748b"): string { + const path = ICON_PATHS[name] + if (!path) return "" + return `` +} + +export function section(index: number, title: string, body: string, + mark?: string): string { + return `
+
${mark ? icon(mark) : ""}${index}. ${esc(title)}
+ ${body} +
` +} + +/** A heading inside a section, carrying its own mark. */ +export function heading(title: string, mark?: string, note?: string): string { + return `

${mark ? icon(mark, 14) : ""}${esc(title)}${ + note ? ` — ${esc(note)}` : ""}

` +} + +export function card(label: string, value: string, opts: { center?: boolean; color?: string } = {}): string { + const cls = opts.center ? "card card-c" : "card" + const style = opts.color ? ` style="color:${opts.color}"` : "" + return `
+
${esc(label)}
+
${value}
+
` +} + +export function grid(columns: 2 | 3 | 4, cards: string[]): string { + return `
${cards.join("")}
` +} + +/** Callout in the family's four tones: ok, warn, critical, info. */ +export function callout(tone: "ok" | "warn" | "critical" | "info", + title: string, body: string): string { + const icon = { ok: "✓", warn: "⚠", critical: "✗", info: "ⓘ" }[tone] + return `
+
${icon}
+
${esc(title)}

${body}

+
` +} + +export function table(headers: string[], rows: string[][]): string { + // No headers means the first column labels the second: a record read + // down rather than across. + const head = headers.length + ? `${headers.map((h) => `${esc(h)}`).join("")}` + : "" + return `${head} + ${rows.map((r) => `${r.map((c) => ``).join("")}`).join("")} +
${c}
` +} + +/** + * Opens the report window on the click itself, before any data is + * fetched, so the popup blocker sees the user gesture. The spinner is + * what the reader looks at while the document is composed. + */ +export function openReportWindow(loadingText: string): Window | null { + const w = window.open("about:blank", "_blank") + if (w) { + w.document.write(`

${esc(loadingText)}

`) + } + return w +} + +/** + * Hands the composed document to the window that was opened on the click. + * + * The window is *navigated* to the document rather than written into. + * Writing into an about:blank window leaves it, as far as the browser is + * concerned, still on about:blank — no navigation happened — and an + * installed web app then shows none of its own chrome, so on a phone the + * report opens with no way back to the page that launched it. Navigating + * to a blob URL is a real navigation, and the app supplies its close and + * back controls exactly as it does for the other reports. + */ +export function writeReport(target: Window | null, html: string): void { + const url = URL.createObjectURL(new Blob([html], { type: "text/html" })) + if (target && !target.closed) { + target.location.href = url + return + } + // The window was blocked or the reader closed it while the document + // was being composed. + window.open(url, "_blank") +} diff --git a/AppImage/messages/de/common.json b/AppImage/messages/de/common.json index 0ac30a91..8e165ea1 100644 --- a/AppImage/messages/de/common.json +++ b/AppImage/messages/de/common.json @@ -309,7 +309,7 @@ "shortTest": "Kurztest", "longTest": "Langer Test (1-4 Stunden)", "extendedTest": "Erweiterter Test", - "testHelp": "Ein kurzer Test dauert etwa 2 Minuten.Der erweiterte Test läuft im Hintergrund und kann auf großen Datenträgern mehrere Stunden dauern.Das Ergebnis wird nach Abschluss auf der Registerkarte „Verlauf“ angezeigt.", + "testHelp": "Ein kurzer Test dauert etwa 2 Minuten. Der erweiterte Test läuft im Hintergrund und kann auf großen Datenträgern mehrere Stunden dauern. Das Ergebnis wird nach Abschluss auf der Registerkarte „Verlauf“ angezeigt.", "startFailed": "Der Test konnte nicht gestartet werden", "short": "Kurz", "extended": "Erweitert", @@ -1103,7 +1103,7 @@ "backupStartFailed": "Sicherung konnte nicht gestartet werden: {message}", "controlFailed": "Fehler bei {action} VM {vmid}: {message}", "saveNotesFailed": "Fehler beim Speichern der Notizen. Bitte versuchen Sie es erneut.", - "appNotFound": "Diese Anwendung ist nicht mehr verfügbar.Aktualisieren Sie die Seite und versuchen Sie es erneut.", + "appNotFound": "Diese Anwendung ist nicht mehr verfügbar. Aktualisieren Sie die Seite und versuchen Sie es erneut.", "saveCustomCommandFailed": "Der benutzerdefinierte Aktualisierungsbefehl konnte nicht gespeichert werden: {message}", "removeCustomCommandConfirm": "Den benutzerdefinierten Aktualisierungsbefehl für „{name}“ entfernen?", "removeCustomCommandFailed": "Der benutzerdefinierte Aktualisierungsbefehl konnte nicht entfernt werden: {message}", @@ -1220,7 +1220,15 @@ "humanWeekly": "Wöchentlich ({day} {time})", "humanMonthly": "Monatlich (Tag {day} um {time})", "humanHourly": "Stündlich", - "weekdays": "['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag']" + "weekdays": [ + "Sonntag", + "Montag", + "Dienstag", + "Mittwoch", + "Donnerstag", + "Freitag", + "Samstag" + ] }, "cronChip": { "detected": "Host-Cron erkannt", @@ -1373,8 +1381,7 @@ "postApplyAllOk": "{count} Paket(e) erfolgreich angewendet – nichts ausstehend.", "postApplyNothingPending": "Nichts ausstehend – alles ist auf dem neuesten Stand.", "postApplyPartial": "{pending} Paket(e) stehen nach der Ausführung noch aus.", - "postApplyPartialSubline": "{applied} angewendet.Einige Aktualisierungen wurden nicht abgeschlossen – sehen Sie sich die Terminalausgabe oben an.", - "updatedWithDockerImage": "Aktualisiert mit seinem Docker-Bild." + "postApplyPartialSubline": "{applied} angewendet. Einige Aktualisierungen wurden nicht abgeschlossen – sehen Sie sich die Terminalausgabe oben an." }, "bulkUpdate": { "title": "Sammelaktualisierung", @@ -1431,7 +1438,7 @@ "installedViaLabel": "Installiert über", "installedVersionLabel": "Installierte Version", "installedVersionRegexLabel": "Regex der installierten Version (Erfassungsgruppe)", - "installedRegexPlaceholder": "z.B. v?(\\d+\\.\\d+\\.\\d+)", + "installedRegexPlaceholder": "z. B. v?(\\d+\\.\\d+\\.\\d+)", "installedRegexAlt": "Installierter_regex", "regexCaptureGroupLabel": "Regex (mit Capture-Gruppe)", "regexPlaceholderVersion": "Version:\\s*(\\d+\\.\\d+\\.\\d+)", @@ -1494,7 +1501,7 @@ "dockerMovingTag": "beweglicher Tag", "dockerMovingTagHelp": "Bewegliche Tags enthalten keine Version. Stattdessen über den Digest bei Docker-Image-Updates verfolgen.", "tagRegexLabel": "Tag-Regex (mit Capture-Gruppe)", - "tagRegexPlaceholder": "z.B. v?(\\d+\\.\\d+\\.\\d+)", + "tagRegexPlaceholder": "z. B. v?(\\d+\\.\\d+\\.\\d+)", "tagRegexBare": "v?(\\d+\\.\\d+\\.\\d+)", "installedStatus": "Installiert", "checkingStatus": "Überprüfung…", @@ -1602,14 +1609,9 @@ "notificationsEnabled": "Upstream-Update-Benachrichtigungen EIN – zum Stummschalten klicken", "notificationsMuted": "Upstream-Update-Benachrichtigungen stummgeschaltet – zum Aktivieren klicken", "notifyUpstreamLabel": "Benachrichtigen Sie mich, wenn eine neue Upstream-Version verfügbar ist", - "notifyUpstreamHelp": "Sendet „app_update_available“ an die Kanäle, die in Einstellungen → Benachrichtigungen aktiviert sind.Deaktivieren Sie diese Option, wenn diese App auf Ihrer Box nicht aktualisiert werden kann.", + "notifyUpstreamHelp": "Sendet „app_update_available“ an die Kanäle, die in Einstellungen → Benachrichtigungen aktiviert sind. Deaktivieren Sie diese Option, wenn diese App auf Ihrer Box nicht aktualisiert werden kann.", "excludeFromBadgeLabel": "Vom LXC-Aktualisierungszähler ausschließen", - "excludeFromBadgeHelp": "Zählen Sie diese App nicht im Abzeichen „Aggregate Updates“ auf der LXC-Listenkarte.Nützlich, wenn Sie absichtlich an eine bestimmte Version gebunden sind (Tracker-Anforderung, Kompatibilitätsstopp).Hat keinen Einfluss auf den eigenen Status der App-Registerkarte oder die ausgehende Benachrichtigung.", - "dockerDetectedWithWorkloads": "Docker mit {count} Containeranwendung(en) erkannt", - "dockerWorkloadsHeading": "Läuft innerhalb von Docker", - "runsInsideDocker": "Aktualisiert mit seinem Docker-Bild", - "upstreamDelegatedTitle": "Die verfügbare Version stammt aus dem Bild Docker", - "upstreamDelegatedHelp": "Diese Anwendung wird in einem Container ausgeführt, sodass die verfügbare Version unabhängig davon ist, was ihr Image auflöst – keine separate Upstream-Prüfung und ein Update wird einmal gemeldet.Aktualisieren Sie es über sein Image auf der Registerkarte „Updates“." + "excludeFromBadgeHelp": "Zählen Sie diese App nicht im Abzeichen „Aggregate Updates“ auf der LXC-Listenkarte. Nützlich, wenn Sie absichtlich an eine bestimmte Version gebunden sind (Tracker-Anforderung, Kompatibilitätsstopp).Hat keinen Einfluss auf den eigenen Status der App-Registerkarte oder die ausgehende Benachrichtigung." }, "statusFilter": { "ariaLabel": "Virtuelle Maschinen und Container filtern", @@ -1889,6 +1891,7 @@ "system_reboot": "Systemneustart", "system_restore_completed": "Host-Wiederherstellung abgeschlossen", "system_problem": "Systemproblem erkannt", + "kernel_warning": "Kernelwarnungen und Diagnosespuren", "service_fail": "Der Dienst ist fehlgeschlagen", "oom_kill": "Prozessabbruch aufgrund von Speichermangel", "service_fail_batch": "Mehrere Dienstausfälle", @@ -4915,6 +4918,154 @@ "customLinkDeleteError": "Delete failed" }, "audit": { + "presentation": { + "backupAge": "Alter der Sicherung", + "backupLimit": "Verwendeter Grenzwert", + "limitDeclared": "Vom Benutzer festgelegte Frist", + "limitSchedule": "Zeitplan + Toleranz", + "limitReference": "Referenzfrist", + "verifiedChecks": "Verifizierte Prüfungen", + "unverifiedChecks": "Nicht verifizierte Prüfungen", + "checkName": "Prüfung", + "verified": "Verifiziert", + "verificationScope": "Verifizierte anwendbare Prüfungen. Die Abdeckung beschreibt weder den Zustand noch die Sicherheit des Servers.", + "noApplicable": "Keine anwendbaren Prüfungen in dieser Auswertung.", + "noJob": "Ohne geplanten Auftrag", + "guest": "Gast", + "guests": "Gäste", + "host": "Host", + "resource": "Ressource", + "data": "Daten", + "metadata": "Metadaten", + "result": "Ergebnis", + "fact": "Beobachtung", + "records": "Einträge", + "unscheduled": "Gäste ohne geplanten Auftrag", + "excludedDisks": "ausgeschlossene Datenträger", + "disks": "Datenträger", + "destination": "Ziel", + "lastCopy": "Letzte gespeicherte Sicherung", + "ageLimit": "Alter / Grenzwert", + "noDestination": "Kein Ziel konfiguriert", + "notFound": "Keine Sicherung im untersuchten Umfang gefunden", + "promiscuous": "Schnittstellen im Promiscuous-Modus", + "noDescription": "Beschreibung nicht verfügbar", + "occurrence": "Vorkommen", + "occurrences": "Vorkommen", + "detail": "Detail", + "technical": "Technische Nachweise", + "annex": "Technischer Anhang", + "overview": "Ergebnisübersicht", + "incomplete": "Unvollständige Bewertung", + "assessment": "Bewertung", + "noGlobalScore": "Die Ergebnisse beschreiben einzelne Kriterien; es wird keine globale Sicherheitsbewertung berechnet.", + "coverage": "Abdeckung geplanter Sicherungen", + "scheduled": "Mit geplantem Auftrag", + "copyScope": "Ein konfigurierter Auftrag belegt keine gespeicherte oder wiederherstellbare Sicherung.", + "detailsLink": "Nachweisreferenz", + "noSubscription": "Kein Abonnement erfasst", + "otherDevices": "Weitere Geräte in der Gruppe", + "unversioned": "Version nicht erfasst", + "notInstalled": "Nicht installiert", + "noPendingRecorded": "Keine ausstehende Aktualisierung erfasst", + "originalEvidence": "Originalnachweise der Quelle", + "evidenceObserved": "Beobachtete Nachweise", + "evidenceExcerpt": "Kompakte Ansicht. Die vollständigen Quellnachweise bleiben mit dieser Bewertung gespeichert.", + "annexScope": "Vollständige Quellnachweise für Ergebnisse, die Aufmerksamkeit erfordern, eine Beobachtung festhalten oder nicht geprüft werden konnten.", + "readOnlyScope": "Die Bewertung ändert keine Konfiguration. Abfragen und bei Bedarf Lynis können Protokolle oder Berichte erzeugen.", + "capacity": "Kapazität", + "used": "Belegt", + "free": "Frei", + "reasons": { + "agentNotDeclared": "Kein Gastagent in der Konfiguration deklariert", + "arcConflictingSettings": "Persistente Einstellungen widersprechen einander", + "arcMinAboveMax": "Die untere ARC-Grenze liegt über der oberen", + "arcPendingReboot": "Persistente Einstellung weicht vom geladenen Parameter ab", + "arrayDegraded": "Läuft mit weniger Geräten als gebaut", + "arrayNotActive": "Nicht aktiv", + "arrayRebuilding": "Zu wenige Geräte, im Wiederaufbau", + "backupRunFailed": "Der Lauf endete mit einem Fehler", + "backupRunRecovered": "Schlug früher fehl, ein späterer Lauf war erfolgreich", + "bondNoMembersUp": "Ausgefallen, und kein Mitglied des Bonds ist aktiv", + "bondRedundancyLost": "Ausgefallen; der Bond behält andere Verbindungen", + "bootEspMissingNewest": "Trägt nicht den neuesten Kernel, den die anderen tragen", + "bootEspOutOfSync": "Nicht im Gleichstand mit den anderen: sie würde einen anderen Kernel starten", + "bootSingleEsp": "Eine Bootpartition konfiguriert", + "bootToolReported": "Von proxmox-boot-tool gemeldet", + "cephCheckRaised": "Von Ceph gemeldet", + "channelIncomplete": "Aktiviert, aber ein Teil der Konfiguration fehlt", + "clusterInquorate": "Ohne Quorum: Änderungen am Cluster werden abgelehnt", + "clusterMemberAbsent": "Konfigurierter Knoten, den der Cluster nicht sieht", + "clusterSingleLink": "Ein einziger Corosync-Link deklariert", + "dataExcludedFromBackup": "Von der Sicherung des Gastes ausgeschlossene Daten", + "deliveryFailing": "Jüngste Zustellungen gingen nicht hinaus", + "destinationUnavailable": "Konfiguriertes Ziel nicht verfügbar", + "diskErrorsActive": "Fehler innerhalb des Prüfzeitraums aufgezeichnet", + "diskErrorsPast": "Meldete früher Fehler, keinen im verwendeten Zeitfenster", + "diskWarningsActive": "Gerätewarnung innerhalb des Prüfzeitraums aufgezeichnet", + "diskWarningsPast": "Meldete früher Gerätewarnungen, keine im verwendeten Zeitfenster", + "essentialServiceDown": "Ein Dienst, den Proxmox zum Antworten braucht, ist nicht aktiv", + "exemptByPolicy": "Als nicht erforderlich erklärt und daher außerhalb der Zählung", + "expectedButUncovered": "Als sicherungspflichtig erklärt, und kein aktivierter Auftrag wählt ihn aus", + "expectedToAutostart": "Als mit dem Host startend erklärt, tut es aber nicht", + "filesystemExhausted": "Kein Speicherplatz mehr frei", + "filesystemNearlyFull": "Auf oder über der Prüfschwelle für Speicherplatz", + "filesystemReadOnly": "Der Kernel meldet diesen Mount als schreibgeschützt: er nimmt keine Schreibvorgänge mehr an", + "haManagerNotReady": "Weder aktiv noch untätig: kann keinen Dienst übernehmen", + "haNoMaster": "Kein Manager: nichts entscheidet, wo ein Dienst laufen soll", + "haServiceError": "Im Fehlerzustand und nicht mehr verwaltet", + "haServiceTransitioning": "Im Übergang", + "hostArchiveMissing": "Der Auftragseintrag nennt ein Archiv, das nicht mehr gespeichert ist", + "hostBackupJobFailed": "Der Auftrag endete mit einem Fehler", + "hostBackupStale": "Älter als die verwendete Altersgrenze", + "hostBackupUnscheduled": "Gespeichert, ohne Zeitplan für eine weitere Kopie", + "hostNoRetrievableCopy": "Keine Kopie, über die diese Prüfung noch Auskunft geben kann", + "indexesStale": "Die Paketindizes wurden zuletzt nicht kürzlich aktualisiert", + "inodesExhausted": "Keine Inodes mehr frei", + "inodesNearlyExhausted": "Auf oder über der Prüfschwelle für Inodes", + "kernelAwaitingReboot": "Installiert und nicht der laufende Kernel", + "lynisReportStale": "Der Lynis-Bericht ist {days} Tag(e) alt, älter als das verwendete Referenzalter", + "lynisWarning": "Von der Lynis-Prüfung erfasst", + "multipathNoPath": "Kein Pfad mehr", + "multipathPathDown": "Bedient über weniger Pfade", + "noAutostart": "Startet nicht mit dem Host", + "noConfigurationReference": "Kein Verweis in den geprüften Konfigurationen", + "noJobSelectsGuest": "Kein aktivierter Sicherungsauftrag wählt ihn aus", + "noPhysicalPort": "Führt keinen physischen Port", + "noStoredBackup": "Keine gespeicherte Sicherung gefunden", + "noStoredBackupUnscheduled": "Kein geplanter Auftrag; keine Sicherung gefunden", + "olderThanFallback": "Die Sicherung überschreitet die Referenzfrist.", + "olderThanObjective": "Die Sicherung überschreitet die vom Benutzer festgelegte Frist.", + "olderThanSchedule": "Die Sicherung überschreitet das geplante Intervall einschließlich Toleranz.", + "overprovisioned": "Vergibt mehr virtuelle Kapazität, als der Pool besitzt", + "packageAwaitingRestart": "Installiert und fordert einen Neustart", + "pastServiceLife": "Über der zur Planung verwendeten Lebensdauerschwelle", + "pinnedToHostCpu": "An das Prozessormodell des Hosts gebunden", + "poolDeviceErrors": "Gerät zählt Lese-, Schreib- oder Prüfsummenfehler", + "poolNotOnline": "Nicht online", + "rebootMarkerWithoutPackages": "Etwas hat den Neustart-Marker geschrieben, ohne ein Paket zu nennen", + "recoveryKeyLocalOnly": "Backup-Verschlüsselungsschlüssel nur auf diesem Knoten, gemäß hinterlegtem Verwahrungsmodus", + "replicationDisabled": "Pausiert", + "replicationFailing": "Der letzte Lauf meldete einen Fehler", + "replicationNeverRan": "Hat noch nie eine Synchronisierung abgeschlossen", + "replicationOverdue": "Älter, als der eigene Kalender des Auftrags zulässt", + "retentionNotDeclared": "Keine Aufbewahrung erklärt; jede Kopie bleibt erhalten", + "retentionOnServer": "Wird auf dem Sicherungsserver bereinigt, unter Aufträgen, die dieser Knoten nicht lesen kann", + "runsPrivileged": "Läuft privilegiert und teilt den Benutzernamensraum des Hosts", + "scrubOverdue": "Letzter abgeschlossener Scrub älter als die Prüfschwelle", + "storageNearlyFull": "Auf oder über der Kapazitätsschwelle zur Prüfung", + "storageUnreachable": "Nicht erreichbar", + "thinDataPressure": "Geschriebene Daten nahe der Kapazität des Pools", + "thinMetadataPressure": "Metadaten fast voll, was den Pool schreibgeschützt macht", + "unitFailed": "systemd hat die Wiederholungsversuche eingestellt", + "verificationFailedOnly": "Die Prüfung las die neueste Kopie und sie war nicht intakt; keine andere Kopie dieses Gasts wurde geprüft", + "verificationFailedWithFallback": "Die Prüfung las die neueste Kopie und sie war nicht intakt; eine frühere Kopie wurde geprüft", + "verificationNotRun": "Kein Prüfauftrag hat diese Kopie zurückgelesen" + }, + "lynisTest": "Test", + "lynisWarning": "Warnung", + "couldNotRead": "Nicht lesbar" + }, "title": "Audit & Report", "loading": "Loading assessment…", "run": "Run assessment", @@ -4923,9 +5074,8 @@ "lastRun": "Last assessed on {when}", "stale": "{days} days ago", "readOnlyNotice": "The assessment only reads the host. It makes no changes.", + "unverifiedChecks": "Nicht erhoben: {checks}. Jede nennt in ihrer Evidenz, was sie nicht lesen konnte.", "noFindings": "No findings match the current filter.", - "showPassing": "Show passing checks", - "hidePassing": "Hide passing checks", "affectedCount": "{count} affected", "acceptedNotice": "{count} accepted risk(s) recorded on this host.", "states": { @@ -4933,7 +5083,8 @@ "warn": "Warning", "accepted": "Accepted risk", "pass": "Passed", - "not_applicable": "Not applicable" + "not_applicable": "Not applicable", + "unknown": "Nicht geprüft" }, "areas": { "all": "All", @@ -4949,7 +5100,8 @@ "why": "Context", "evidence": "Evidence", "affected": "Affected", - "acceptedRisk": "Accepted risk" + "acceptedRisk": "Accepted risk", + "sources": "Quellen und Erfassungszeitpunkte" }, "errors": { "runFailed": "The assessment could not be started." @@ -4958,69 +5110,434 @@ "backup": { "guest_coverage": { "title": "Backup coverage", - "rationale": "Compares the guest inventory against what each backup job selects. A job selects by VMID list, by pool, or with `all 1`, minus its `exclude` list. Jobs carrying `enabled 0` are not considered.", + "rationale": "Aktivierte Sicherungsaufträge auf diesem Knoten, die von jedem ausgewählten Gäste und die davon ausgeschlossenen Gastdaten. Eine konfigurierte Abdeckung belegt nicht, dass eine verwendbare Sicherung existiert. Ob ein nicht ausgewählter Gast geschützt werden sollte, ergibt sich aus der erklärten Richtlinie.", "summary": { - "noJobs": "No backup job is defined on this node", - "covered": "All {total} guests are covered by a backup job", - "uncovered": "{count} of {total} guests are not covered by any enabled backup job" + "noJobs": "Für die {total} Gäste dieses Knotens ist kein Sicherungsauftrag definiert", + "covered": "Alle {total} Gäste werden von einem aktivierten Sicherungsauftrag ausgewählt", + "uncovered": "{count} von {total} Gästen werden von keinem aktivierten Sicherungsauftrag ausgewählt", + "excludedData": "{count} Datenträger- oder Mountpoint-Ausschlüsse prüfen", + "uncoveredExpected": "{required} Gäste, für die eine Sicherung erklärt wurde, wählt kein aktivierter Auftrag aus", + "evaluationFailed": "The check could not be evaluated" + } + }, + "last_backup_age": { + "title": "Age of stored backups", + "rationale": "Alter: vergangene Zeit seit der letzten gespeicherten Sicherung. Verwendeter Grenzwert: das Referenzalter, mit dem diese Sicherung verglichen wird.", + "summary": { + "recent": "Alle {total} Gast-/Zielprüfungen erfüllen die angegebene Altersrichtlinie", + "stale": "{count} of {total} guests have no backup from the last 30 days", + "noBackups": "No stored backup matches a guest on this node", + "attention": "{count} von {total} Gast-/Zielprüfungen erfordern eine Überprüfung", + "evaluationFailed": "The check could not be evaluated" + } + }, + "retention_defined": { + "title": "Backup retention", + "rationale": "Die Aufbewahrung, wie Proxmox sie auflöst: Einstellung des Auftrags, dann des Speichers, dann der Knotenstandard. Aufbewahrung, die ein Sicherungsserver anwendet, ist von diesem Knoten aus nicht lesbar.", + "summary": { + "allDefined": "Alle {total} Aufträge lösen eine Aufbewahrungseinstellung auf", + "missing": "{count} of {total} enabled job(s) declare no retention", + "notDeclared": "{count} von {total} Aufträgen behalten jede Kopie: für sie ist keine Aufbewahrung erklärt", + "onServer": "{count} von {total} Aufträgen schreiben auf einen Sicherungsserver, der sie mit eigenen Aufträgen bereinigt", + "evaluationFailed": "The check could not be evaluated" + } + }, + "verification_state": { + "title": "Sicherungsprüfung", + "rationale": "Das Prüfergebnis, das Proxmox Backup Server für die neueste Kopie jedes Gasts festhält, und ob eine frühere Kopie desselben Gasts geprüft wurde. Die Prüfung liest eine gespeicherte Kopie zurück; sie ist keine Wiederherstellung.", + "summary": { + "allVerified": "Die neueste Kopie aller {total} Gäste wurde als unversehrt geprüft", + "failed": "{failed} neueste Kopien bestanden die Prüfung nicht, von {total} untersuchten", + "notVerified": "{pending} von {total} neuesten Kopien wurden nicht geprüft", + "evaluationFailed": "Der Prüfstatus war nicht lesbar" + } + }, + "job_results": { + "title": "Ergebnisse der Sicherungsläufe", + "rationale": "Wie der jüngste aufgezeichnete Lauf jedes Gasts endete, aus dem Aufgabenprotokoll des Knotens. Nur der letzte Lauf wird bewertet. Das Protokoll wird begrenzt aufbewahrt.", + "summary": { + "allSucceeded": "Alle {total} aufgezeichneten Sicherungsläufe endeten fehlerfrei", + "someFailed": "{count} von {total} aufgezeichneten Sicherungsläufen endeten mit einem Fehler", + "evaluationFailed": "Das Aufgabenprotokoll war nicht lesbar", + "recovered": "{count} von {total} Gästen schlugen in einem früheren Lauf fehl und waren seither erfolgreich" + } + }, + "host_recovery": { + "title": "Wiederherstellung des Hosts", + "rationale": "Host-Backups, wie ProxMenux sie aufzeichnet: jeder ausgeführte Auftrag, wann, ob erfolgreich, das Ziel und ob diese Kopie noch dort liegt. Ein Auftrag auf einen Backup-Server nennt keinen lokalen Pfad. Verschlüsselungsschlüssel werden nur nach Anzahl und hinterlegtem Verwahrungsmodus genannt.", + "summary": { + "noHostBackup": "Es ist kein Backup der Host-Konfiguration gespeichert und kein Timer erzeugt eines", + "protected": "Die Konfiguration des Knotens selbst liegt in {total} Archiv(en) innerhalb der verwendeten Altersgrenze", + "attention": "{count} Feststellung(en) zu {total} Einträgen der Host-Konfiguration", + "scheduledOnly": "Backups der Host-Konfiguration sind über {count} Timer geplant; lokal ist kein Archiv gespeichert" } } }, "system": { "pending_reboot": { "title": "Restart state", - "rationale": "Checks for `/var/run/reboot-required` and, when present, the packages listed in `/var/run/reboot-required.pkgs`. Also reports the running kernel.", + "rationale": "Der Marker `/var/run/reboot-required` und die in `/var/run/reboot-required.pkgs` gelisteten Pakete. Sein Fehlen beweist nicht, dass nichts neu gestartet werden muss.", "summary": { - "none": "No restart is pending", - "pending": "The host has a pending restart" + "none": "Nichts hat einen Neustart angefordert", + "pending": "{count} Elemente sind installiert und warten auf einen Neustart", + "evaluationFailed": "The check could not be evaluated" } }, "enterprise_repo_without_subscription": { "title": "Enterprise repository", - "rationale": "Looks for references to `enterprise.proxmox.com` in `/etc/apt/sources.list` and `sources.list.d`, and contrasts the result with the status returned by `pvesubscription get`.", + "rationale": "Verweise auf `enterprise.proxmox.com` in `/etc/apt/sources.list` und `sources.list.d`, gegenüber dem Status aus `pvesubscription get`.", "summary": { "notEnabled": "The enterprise repository is not enabled", "subscribed": "The enterprise repository is backed by a subscription", - "unsubscribed": "The enterprise repository is enabled without an active subscription" + "unsubscribed": "The enterprise repository is enabled without an active subscription", + "evaluationFailed": "The check could not be evaluated" + } + }, + "memory_overcommit": { + "title": "Memory allocation", + "rationale": "Die `memory`-Obergrenze jeder Gastkonfiguration gegenüber MemTotal, laufende Gäste getrennt gezählt. Container verbrauchen bis zu dieser Grenze; virtuelle Maschinen ohne Ballooning reservieren sie.", + "summary": { + "withinRatio": "Guests are allocated {percent}% of host memory", + "aboveRatio": "Guests are allocated {percent}% of host memory", + "evaluationFailed": "The check could not be evaluated" + } + }, + "time_synchronisation": { + "title": "Time synchronisation", + "rationale": "NTP und NTPSynchronized, wie `timedatectl` sie meldet. Clusterzugehörigkeit, Zertifikatsprüfung und Protokollreihenfolge hängen von übereinstimmenden Uhren ab. Ein anderer Mechanismus kann die Uhr führen.", + "summary": { + "synchronised": "The clock is synchronised with a time source", + "disabled": "Time synchronisation is disabled", + "notSynchronised": "Time synchronisation is enabled but the clock is not synchronised", + "evaluationFailed": "The check could not be evaluated" + } + }, + "kernel_current": { + "title": "Running kernel", + "rationale": "Der laufende Kernel gegenüber dem, den der Host als Nächstes starten würde, wie `proxmox-boot-tool` ihn meldet. Ein lediglich installierter neuerer Kernel kann bewusst zurückgehalten sein; ein Unterschied nach einem Neustart bedeutet einen Start, der nicht griff.", + "summary": { + "current": "Der laufende Kernel {version} ist der, den der Host als Nächstes starten würde", + "newerAvailable": "The host runs {running} while {newest} is installed", + "newerSelected": "Der Host führt {running} aus und würde beim nächsten Neustart {selected} starten", + "wouldDowngrade": "Der Host führt {running} aus, würde beim nächsten Neustart aber das ältere {selected} starten", + "bootTargetUnknown": "Der Host führt {version} aus; der für den nächsten Start gewählte Kernel war nicht lesbar", + "evaluationFailed": "The check could not be evaluated" + } + }, + "security_updates": { + "title": "Security updates", + "rationale": "Ausstehende Pakete, deren Quelle ein Sicherheits-Repository ist, aus einem simulierten `apt-get upgrade`. Die Zahl gibt wieder, was apt meldet, nicht die Schwere dessen, was jedes Paket behebt.", + "summary": { + "none": "No package updates are pending", + "noSecurity": "{total} update(s) pending, none from a security repository", + "pending": "{count} of {total} pending update(s) come from a security repository", + "evaluationFailed": "The check could not be evaluated" + } + }, + "journal_size": { + "title": "Journal size", + "rationale": "Das Journal auf der Platte gegenüber der für es geltenden Obergrenze: SystemMaxUse, sofern gesetzt, sonst der journald-Standard von einem Zehntel des Dateisystems, auf dem es liegt.", + "summary": { + "bounded": "Das Journal belegt {size} und bleibt unter seiner wirksamen Obergrenze", + "large": "The journal holds {size} on disk", + "nearCap": "Das Journal belegt {size} und liegt bei {percent}% seiner wirksamen Obergrenze", + "capUnknown": "Das Journal belegt {size}; die wirksame Obergrenze war nicht zu ermitteln", + "evaluationFailed": "The check could not be evaluated" + } + }, + "swap_configured": { + "title": "Swap", + "rationale": "Aktive Auslagerungsbereiche laut `swapon` und deren Summe gegenüber dem Hostspeicher. Kein Verhältnis zum Arbeitsspeicher ist irgendwo vorgeschrieben; Speicherdruck wird an anderer Stelle gemessen.", + "summary": { + "active": "{size} of swap is active", + "none": "No swap area is active", + "evaluationFailed": "The check could not be evaluated" + } + }, + "filesystem_capacity": { + "title": "Kapazität der Host-Dateisysteme", + "rationale": "Speicherplatz und Inodes der Dateisysteme, die der Host selbst braucht: Wurzeldateisystem, /var, /var/log und der Pfad des lokalen Speichers. Ein Dateisystem mit freiem Platz und ohne Inodes fällt genauso aus wie ein volles.", + "summary": { + "withinLimits": "Die {total} Host-Dateisysteme liegen unter ihren Prüfschwellen", + "pressure": "{count} Messwerte liegen auf oder über ihrer Prüfschwelle", + "evaluationFailed": "Die Belegung der Dateisysteme war nicht lesbar" + } + }, + "update_chain": { + "title": "Alter der APT-Paketindizes", + "rationale": "Wann APT zuletzt einen Paketindex auf diesem Host abgelegt hat. Ein Repository, das „nicht geändert\" antwortet, lässt seinen Index unberührt. Die Erreichbarkeit der Repositories wird nicht geprüft.", + "summary": { + "current": "Die Paketindizes wurden vor {days} Tag(en) aktualisiert", + "stale": "Die Paketindizes wurden zuletzt vor {days} Tag(en) aktualisiert", + "indexAgeUnknown": "Das Alter der Paketindizes war nicht zu bestimmen" + } + }, + "notification_delivery": { + "title": "Letztes Benachrichtigungsergebnis", + "rationale": "Aktivierte Kanäle und ihr jeweils letztes gespeichertes Zustellergebnis. Ohne Verlauf bleibt die Zustellung ungeprüft; ein früherer Fehler mit anschließendem Erfolg gilt nicht als aktueller Fehler. Es wird keine Testbenachrichtigung gesendet.", + "summary": { + "delivering": "Die letzte protokollierte Zustellung war bei allen {total} aktivierten Kanälen erfolgreich", + "failing": "{count} von {total} aktivierten Kanälen haben ein Konfigurationsproblem oder einen Fehler bei der letzten Zustellung", + "noChannels": "Kein Benachrichtigungskanal ist aktiviert", + "evaluationFailed": "Der Zustellverlauf war nicht lesbar" + } + }, + "cluster_quorum": { + "title": "Cluster-Quorum", + "rationale": "Das Quorum, wie der Cluster es meldet, die konfigurierten Knoten gegenüber den derzeit gesehenen und die Anzahl deklarierter Corosync-Links. Die Links werden aus der Konfiguration gelesen, nicht geprüft.", + "summary": { + "standalone": "Dieser Knoten gehört zu keinem Cluster", + "quorate": "Der Cluster hat Quorum mit {total} konfigurierten Knoten über {links} Corosync-Link(s)", + "attention": "{count} Feststellung(en) zu {total} konfigurierten Knoten", + "evaluationFailed": "Der Cluster-Status konnte nicht gelesen werden" + } + }, + "boot_loader": { + "title": "Bootloader", + "rationale": "Die EFI-Systempartitionen, die proxmox-boot-tool meldet, und die Kernel, die jede trägt. Keine Partition wird eingehängt und kein Start versucht.", + "summary": { + "synchronised": "Die {total} Bootpartitionen tragen dieselben Kernel", + "attention": "{count} von {total} Bootpartitionen bedürfen der Prüfung", + "evaluationFailed": "The check could not be evaluated" + } + }, + "failed_units": { + "title": "Wesentliche Dienste und fehlgeschlagene Units", + "rationale": "Units, die systemd nach erschöpften Neustarts aufgegeben hat, und die Dienste, die Proxmox überhaupt zum Antworten braucht, namentlich gelesen, weil ein inaktiver Dienst nicht immer als fehlgeschlagen gilt. Was eine Unit tut, wird hier nicht ausgelegt.", + "summary": { + "allRunning": "Die {total} wesentlichen Dienste sind aktiv und keine Unit ist fehlgeschlagen", + "attention": "{count} Feststellung(en) unter den Units", + "evaluationFailed": "The check could not be evaluated" + } + }, + "ha_state": { + "title": "Hochverfügbarkeit", + "rationale": "Der HA-Master, der Ressourcenmanager jedes Knotens und der Zustand jedes verwalteten Dienstes, aus `ha-manager status`. Über das Quorum berichtet die Cluster-Prüfung. Kein Dienst wird gestartet, gestoppt oder migriert.", + "summary": { + "managed": "Die {total} verwalteten Dienste sind auf {nodes} Knoten in einem gesetzten Zustand", + "attention": "{count} Feststellung(en) zu {total} verwalteten Diensten", + "evaluationFailed": "The check could not be evaluated" } } }, "guests": { "privileged_containers": { "title": "Container privileges", - "rationale": "Reviews each container configuration. Proxmox marks unprivileged containers with `unprivileged: 1`; its absence indicates a privileged container, which shares the host user namespace.", + "rationale": "Die Einstellung `unprivileged` jeder Containerkonfiguration. Ihr Fehlen bedeutet, dass der Container den Benutzernamensraum des Hosts teilt, was manche Arbeitslasten benötigen.", "summary": { "allUnprivileged": "All {total} containers are unprivileged", - "privileged": "{count} of {total} containers run privileged" + "privileged": "{count} of {total} containers run privileged", + "evaluationFailed": "The check could not be evaluated" } }, "qemu_without_agent": { "title": "Guest agent on virtual machines", - "rationale": "Reviews each virtual machine configuration for `agent: 1`. The guest agent enables ordered shutdown, filesystem quiescing for snapshots and real disk usage reporting.", + "rationale": "Die Einstellung `agent` in jeder Konfiguration einer virtuellen Maschine. Die Einstellung besagt, dass der Agent deklariert ist, nicht dass er antwortet.", "summary": { "allHaveAgent": "All {total} virtual machines declare the guest agent", - "missingAgent": "{count} of {total} virtual machines do not declare the guest agent" + "missingAgent": "{count} of {total} virtual machines do not declare the guest agent", + "evaluationFailed": "The check could not be evaluated" + } + }, + "autostart": { + "title": "Automatic start", + "rationale": "Die Einstellung `onboot` jedes Gastes, ohne Vorlagen und von HA verwaltete Gäste. Ob ein Gast von selbst zurückkehren soll, ergibt sich aus der erklärten Richtlinie.", + "summary": { + "allAutostart": "All {total} guests start with the host", + "notAutostart": "{count} of {total} guests do not start with the host", + "evaluationFailed": "The check could not be evaluated" + } + }, + "stuck_snapshots": { + "title": "Snapshot state", + "rationale": "Zustand und Alter der Snapshots sowie aktive Aufgaben. Ein kürzlicher oder undatierter Vorgang gilt nicht als unterbrochen.", + "summary": { + "noSnapshots": "No guest holds snapshots", + "allComplete": "The {total} snapshot(s) are complete", + "stuck": "{count} of {total} snapshot(s) were left mid-operation", + "evaluationFailed": "The check could not be evaluated" + } + }, + "cpu_host_type": { + "title": "Virtual CPU model", + "rationale": "Der Wert `cpu` jeder virtuellen Maschine. `host` gibt den Funktionsumfang des physischen Prozessors weiter, was die Knoten einschränkt, auf die der Gast migrieren kann. Unverträglichkeit mit einem bestimmten Ziel wird hier nicht ermittelt.", + "summary": { + "none": "None of the {total} virtual machines is pinned to the host processor", + "pinned": "{count} of {total} virtual machines are pinned to the host processor", + "evaluationFailed": "The check could not be evaluated" + } + }, + "replication_state": { + "title": "Replication", + "rationale": "Replikationsaufträge aus der API: Fehlerzahl, letzter Fehler, letzte Synchronisierung und der Kalender, den jeder Auftrag angibt. Pausierte Aufträge werden als solche ausgewiesen.", + "summary": { + "healthy": "The {total} replication job(s) report no error", + "failing": "{count} of {total} replication job(s) report an error", + "statusUnavailable": "Replication jobs are defined but their status could not be read", + "evaluationFailed": "The check could not be evaluated" } } }, "security": { "host_firewall_enabled": { - "title": "Firewall state", - "rationale": "Checks `enable: 1` in `/etc/pve/firewall/cluster.fw` and in the node's `host.fw`. Proxmox applies node rules only while the datacenter switch is on.", + "title": "Konfigurierte Firewall-Aktivierung", + "rationale": "Die Option `enable` in der Firewall des Rechenzentrums und in der des Knotens sowie die Anzahl geschriebener Regeln. Proxmox wendet die Regeln des Knotens nur bei eingeschaltetem Rechenzentrums-Schalter an. Diese Optionen sagen nicht, was eine Regel filtert.", "summary": { - "bothEnabled": "The firewall is enabled at datacenter and node level", - "datacenterOff": "The firewall is disabled at datacenter level, so node rules are not applied", - "nodeOff": "The firewall is enabled at datacenter level but not on this node" + "bothEnabled": "Die Firewall-Aktivierung ist auf Datacenter- und Knotenebene konfiguriert", + "datacenterOff": "Die Firewall ist auf Datacenter-Ebene deaktiviert; Knotenregeln werden daher nicht angewendet", + "nodeOff": "Die Firewall ist auf Datacenter-Ebene aktiviert, aber nicht auf diesem Knoten", + "evaluationFailed": "The check could not be evaluated" + } + }, + "lynis_warnings": { + "title": "Lynis warnings", + "rationale": "Warnungen der letzten Lynis-Prüfung, jeweils mit Test-Kennung, und das Alter dieser Prüfung. Eine Prüfung läuft nur, wenn Lynis installiert ist und kein vollständiger Bericht vorliegt. Vorschläge sind nicht enthalten.", + "summary": { + "none": "The last Lynis audit recorded no warnings", + "found": "The last Lynis audit recorded {count} warning(s)", + "incomplete": "The Lynis report is incomplete", + "evaluationFailed": "The check could not be evaluated", + "noneStale": "Die letzte Lynis-Prüfung verzeichnete keine Warnungen, und ihr Bericht ist {days} Tag(e) alt", + "foundStale": "Die letzte Lynis-Prüfung verzeichnete {count} Warnung(en), und ihr Bericht ist {days} Tag(e) alt" + } + }, + "certificate_expiry": { + "title": "Certificate validity", + "rationale": "Das Ablaufdatum des Zertifikats, das pveproxy aus /etc/pve/local ausliefert. Ein eigenes Zertifikat hat Vorrang vor dem von Proxmox erzeugten.", + "summary": { + "valid": "The certificate is valid for {days} more day(s)", + "expiring": "The certificate expires in {days} day(s)", + "expired": "The certificate expired {days} day(s) ago", + "evaluationFailed": "The check could not be evaluated" + } + }, + "ssh_root_login": { + "title": "SSH root access", + "rationale": "PermitRootLogin in der effektiven `sshd -T`-Konfiguration samt den kombinierten Authentifizierungsmethoden. Proxmox liefert `yes` aus, was ein Passwort zulässt.", + "summary": { + "password": "Root may sign in over SSH with a password", + "keyOnly": "Root may sign in over SSH with a key only", + "denied": "Root may not sign in over SSH", + "evaluationFailed": "The check could not be evaluated" } } }, "storage": { "orphaned_volumes": { "title": "Volume assignment", - "rationale": "Compares the volumes returned by `pvesm list` against the existing guest configurations. Only non-shared storage is examined: on shared storage a volume may belong to a guest running on another node.", + "rationale": "Gastvolumes auf lokalem Speicher gegenüber den Verweisen in aktuellen, ausstehenden und Snapshot-Konfigurationen. Sicherungen, ISOs und Vorlagen bleiben außerhalb des Vergleichs. Ein Volume ohne Verweis ist ein Kandidat zur Prüfung.", "summary": { "none": "No orphaned volumes were found", - "found": "{count} volume(s) belong to no existing guest" + "found": "{count} Volumes ohne Verweis in den geprüften Konfigurationen", + "evaluationFailed": "The check could not be evaluated" + } + }, + "zfs_arc_max": { + "title": "ZFS ARC limit", + "rationale": "Effektive Werte von c_min, c_max und ARC-Größe, der geladene Modulparameter und die persistenten Einstellungen in /etc/modprobe.d. Ein konfigurierter Wert von null wählt den Modulstandard; der ARC ist eine Obergrenze, und der belegte Speicher ist rückgewinnbar.", + "summary": { + "bounded": "Die ARC-Grenze liegt bei {percent}% des Hostspeichers, und der belegte Speicher ist rückgewinnbar", + "high": "The ARC may use {percent}% of host memory", + "unset": "The ARC has no explicit limit set", + "conflicting": "{count} ARC-Einstellungen widersprechen einander", + "pending": "Eine persistente ARC-Einstellung weicht vom Wert des laufenden Moduls ab", + "evaluationFailed": "The check could not be evaluated" + } + }, + "zfs_scrub_age": { + "title": "ZFS scrub", + "rationale": "Der letzte abgeschlossene Scrub, den `zpool status` je Pool meldet. Ein Resilver ist kein Scrub. Ein kürzlich erstellter Pool hatte noch keine Gelegenheit dazu.", + "summary": { + "recent": "The {total} pool(s) were scrubbed within the last 35 days", + "overdue": "{count} of {total} pool(s) have not been scrubbed in 35 days", + "neverScrubbed": "{count} pool(s) record no scrub", + "evaluationFailed": "The check could not be evaluated" + } + }, + "thin_pool_overprovisioning": { + "title": "Thin pool allocation", + "rationale": "Virtuelle Kapazität, die jeder LVM-Thin-Pool vergibt, gegenüber seiner eigenen Größe, sowie der tatsächlich geschriebene Anteil, Daten und Metadaten getrennt.", + "summary": { + "withinRatio": "Die {total} Thin-Pools liegen unter den angewendeten Prüfgrenzen", + "aboveRatio": "{count} von {total} Thin-Pools vergeben mehr Kapazität, als sie besitzen", + "pressure": "{pressure} von {total} Thin-Pools füllen ihre Daten oder Metadaten fast aus", + "evaluationFailed": "The check could not be evaluated" + } + }, + "connected_storage": { + "title": "Verbundene Speicher", + "rationale": "Verfügbarkeit, wie PVE sie meldet, bekannte Kapazität und aktuelle Abhängigkeiten aller auf diesem Knoten aktivierten Speicher. Entfernte Interna werden nicht geprüft und Schreibzugriff nicht getestet. Die Kapazität wird genannt, wo PVE sie kennt, und bleibt sonst leer.", + "summary": { + "available": "PVE meldet alle {total} Speicher als verfügbar; interne Remote-Komponenten und Schreibzugriff wurden nicht geprüft", + "attention": "{count} von {total} Speichern müssen geprüft werden", + "evaluationFailed": "The check could not be evaluated" + } + }, + "pool_integrity": { + "title": "Integrität und Redundanz der Pools", + "rationale": "Der Zustand jedes ZFS-Pools und die Lese-, Schreib- und Prüfsummenzähler seiner Geräte. Die Zähler sind kumulativ seit dem letzten `zpool clear`.", + "summary": { + "healthy": "Alle {total} Pools sind online, ohne erfasste Gerätefehler", + "degraded": "{count} Befunde in {total} Pools", + "evaluationFailed": "Der Pool-Zustand war nicht lesbar" + } + }, + "ceph_health": { + "title": "Ceph-Zustand", + "rationale": "Cephs eigener Gesundheitsstatus und die von ihm benannten Prüfungen. Seine Tests werden nicht neu umgesetzt und kein Pool, keine Placement-Gruppe und kein OSD separat abgefragt.", + "summary": { + "healthy": "Ceph meldet HEALTH_OK", + "degraded": "Ceph meldet {state}, mit {count} benannten Prüfung(en)", + "evaluationFailed": "The check could not be evaluated" + } + }, + "array_integrity": { + "title": "Software-RAID und Multipath", + "rationale": "mdadm-Arrays und Multipath-Maps, gelesen aus /proc/mdstat und, wo das Werkzeug installiert ist, aus `multipath -ll`. ZFS-Pools meldet ihre eigene Prüfung.", + "summary": { + "intact": "Die {total} Arrays und Maps halten ihre Redundanz", + "degraded": "{count} von {total} Arrays oder Maps fehlt sie", + "evaluationFailed": "The check could not be evaluated" + } + } + }, + "hardware": { + "disk_service_life": { + "title": "Disk service life", + "rationale": "Betriebsstunden und verbleibende Abnutzung aus den SMART-Werten des Monitors, mit dem Datum jeder Messung. Das Alter ist Planungsinformation; Medienfehler und Gerätewarnungen meldet die Zustandsüberwachung.", + "summary": { + "withinLife": "Die {total} Datenträgermessungen liegen unter der Fünfjahresschwelle", + "pastLife": "{count} von {total} Datenträgermessungen liegen über fünf Betriebsjahren", + "noReadings": "Keine Festplatte meldet verwertbare SMART-Werte ({skipped} ohne Messwerte)", + "evaluationFailed": "The check could not be evaluated" + } + }, + "disk_errors": { + "title": "Festplattenfehler", + "rationale": "Auf den Datenträgern erkannte und vom Health-Monitor aufgezeichnete Fehler.", + "summary": { + "recorded": "{count} von {total} Datenträgern mit Eintrag haben vermerkte Ereignisse", + "noEvents": "Kein Festplattenereignis mehr zu bewerten: keines aufgezeichnet oder alle verworfen" + } + } + }, + "network": { + "bond_members": { + "title": "Bond members", + "rationale": "Der MII-Status jedes Bond-Mitglieds aus /proc/net/bonding und wie viele Verbindungen bleiben. In active-backup meldet sich ein Reservemitglied als aktiv und überträgt nichts.", + "summary": { + "allUp": "All members of the {total} bond(s) are up", + "membersDown": "{count} bond member(s) are not up", + "evaluationFailed": "The check could not be evaluated" + } + }, + "bridge_without_ports": { + "title": "Bridge ports", + "rationale": "Die Portkonfiguration jeder Bridge. Eine Bridge ohne physischen Port bedient ein internes oder geroutetes Netz.", + "summary": { + "allConnected": "The {total} bridge(s) carry a port", + "isolated": "{count} of {total} bridge(s) carry no port", + "evaluationFailed": "The check could not be evaluated" } } } @@ -5041,6 +5558,389 @@ "expiry365": "1 year", "cancel": "Cancel", "confirm": "Accept risk" - } + }, + "incomplete": "Unvollständige Nachweise", + "progress": "{completed} von {total} geprüft", + "expires": "Läuft ab: {when}", + "runStates": { + "partial": "Die Prüfung lief durch; einige Messungen waren nicht möglich.", + "failed": "Bewertung unterbrochen oder fehlgeschlagen. Prüfen Sie die Nachweise vor der Verwendung." + }, + "severities": { + "CRITICAL": "Kritisch", + "WARNING": "Warnung", + "INFO": "Information", + "OK": "OK" + }, + "viewSwitch": { + "ariaLabel": "Switch between assessment and inventory", + "assessment": "Assessment", + "inventory": "Inventory", + "policy": "Richtlinie", + "changes": "Änderungen" + }, + "inventory": { + "loading": "Loading inventory…", + "failed": "The inventory could not be composed.", + "collectedAt": "Composed on {when}", + "unavailable": "Not read in this inventory", + "unresolved": "path not resolved", + "noUplink": "no uplink", + "identity": "Node identity", + "node": "Node", + "pveVersion": "Proxmox VE", + "kernel": "Kernel", + "subscription": "Subscription", + "cluster": "Cluster", + "standalone": "Not in a cluster", + "hardware": "Hardware", + "system": "System", + "serial": "Serial number", + "bios": "BIOS", + "cpu": "Processor", + "topology": "Layout", + "cpuLayout": "{sockets} socket(s) × {cores} cores = {threads} threads", + "virtualisation": "Virtualisation", + "memory": "Memory", + "iommuGroups": "IOMMU groups", + "network": "Network", + "storage": "Storage", + "name": "Name", + "type": "Type", + "content": "Content", + "shared": "Shared", + "yes": "Yes", + "no": "No", + "guests": "Guests", + "ostype": "Operating system", + "onboot": "Starts with host", + "tags": "Tags", + "privilege": "Privilege", + "privileged": "Privileged", + "unprivileged": "Unprivileged", + "features": "Features", + "agent": "Guest agent", + "cpuModel": "CPU model", + "disks": "Disks", + "interfaces": "Network interfaces", + "protection": "Backup", + "passthrough": "Passthrough", + "noBackup": "No backup", + "noBackupDetail": "No enabled backup job selects this guest.", + "applications": "Applications", + "versionUnknown": "version not detected", + "passthroughTitle": "PCI passthrough", + "iommuGroup": "IOMMU group {group}", + "sharedGroup": "{count} more device(s) in the same group", + "proxmenux": "ProxMenux optimizations", + "latency": "Netzwerklatenz", + "subscriptionStatus": { + "notfound": "Kein Abonnement vorhanden", + "active": "Aktiv", + "invalid": "Ungültig", + "expired": "Abgelaufen", + "suspended": "Ausgesetzt", + "new": "Aktivierung ausstehend", + "unknown": "Unbekannt" + } + }, + "profile": { + "label": "Report", + "full": "Full audit", + "inventory": "Inventory", + "security": "Security review", + "backup": "Backup assurance", + "capacity": "Capacity and wear", + "diagnostic": "Schnelldiagnose" + }, + "document": { + "action": "Bericht erzeugen", + "title": "Auditbericht", + "subtitle": "Struktur, Konfiguration und Bewertung von {node}", + "generated": "Erstellt", + "assessed": "Assessed", + "runId": "Assessment reference", + "print": "Print or save as PDF", + "summaryByArea": "Summary by area", + "area": "Area", + "inventoryAnnex": "Inventory annex", + "scopeTitle": "Scope of this report", + "scopeBody": "This report describes the Proxmox VE node named above, as observed from the node itself at the time stated. It does not cover the interior of the guests beyond what they declare, network equipment outside the host, physical infrastructure, or any dependency not visible from this node. Findings marked as not determined were not measured and are not evidence of absence.", + "building": "Bericht wird zusammengestellt…", + "node": "Knoten", + "profile": "Profil", + "unknownNode": "nicht identifizierter Knoten", + "executiveSummary": "Zusammenfassung der Bewertung", + "assessment": "Bewertung", + "verdictHeading": "Ergebnis dieses Durchlaufs", + "verdict": { + "critical": "ACHTUNG", + "warning": "PRÜFEN", + "conformant": "IN ORDNUNG" + }, + "verdictText": { + "critical": "{fail} Prüfungen melden einen fehlgeschlagenen Zustand und {warn} einen zu prüfenden Zustand, von {total} bewerteten.", + "warning": "Keine Prüfung meldet einen fehlgeschlagenen Zustand. {warn} von {total} melden einen zu prüfenden Zustand.", + "conformant": "Die {total} Prüfungen dieses Profils schließen ohne fehlgeschlagenen oder zu prüfenden Zustand ab.", + "none": "Dieses Profil führt keine Prüfungen aus. Das Dokument beschreibt den Knoten, ohne ihn zu bewerten." + }, + "runAt": "Ausgeführt am {date}", + "chartNote": "Prüfungen nach Bereich und Ergebnis.", + "nodeIdentity": "Knotenidentität", + "system": "System", + "cluster": "Cluster", + "standaloneNote": "Dieser Knoten gehört zu keinem Cluster: er hält seine eigene Konfiguration und seine Gäste migrieren nicht auf einen anderen Knoten.", + "clusterDiagramNote": "Konfigurierte Knoten und die corosync-Links, die sie verbinden.", + "thisNode": "dieser Knoten", + "unreachable": "nicht gesehen", + "member": "Mitglied", + "corosyncLinks": "Links", + "quorum": "Quorum", + "quorate": "mit Quorum", + "inquorate": "ohne Quorum", + "votes": "Stimmen", + "nodeName": "Knoten", + "architecture": "Systemarchitektur", + "architectureNote": "Wie der Knoten aufgebaut ist: Prozessor und Speicher auf dem Board und was an jedem Controller hängt.", + "systemIdentity": "Systemidentität", + "board": "Board", + "processor": "Prozessor", + "topology": "Sockel × Kerne / Threads", + "memory": "Speicher", + "cores": "Kerne", + "threads": "Threads", + "memoryModules": "Speichermodule", + "slot": "Steckplatz", + "slotsUsed": "belegte Steckplätze", + "slotsFilled": "{used} von {total} Steckplätzen belegt", + "emptySlot": "leer", + "formFactor": "Bauform", + "speed": "Geschwindigkeit", + "manufacturer": "Hersteller", + "product": "Modell", + "serial": "Seriennummer", + "controllers": "Controller", + "class": "Klasse", + "device": "Gerät", + "iommuGroups": "IOMMU-Gruppen", + "iommuGroup": "IOMMU-Gruppe", + "field": "Feld", + "value": "Wert", + "size": "Größe", + "type": "Typ", + "storageDevices": "Speichergeräte", + "disks": "Festplatten", + "model": "Modell", + "bus": "Bus", + "serviceLife": "Betriebsstunden", + "healthy": "in Ordnung", + "years": "{years} Jahre", + "events": "Ereignisse", + "observations": "Beobachtungen", + "observationsNote": "Aufgezeichnete Ereignisse. SMART meldet den aktuellen Zustand; dieses Protokoll meldet, was geschehen ist, einschließlich Ereignissen, von denen sich die Festplatte erholt hat.", + "noObservations": "Keine Ereignisse aufgezeichnet", + "noObservationsNote": "Keine Festplatte hat einen Fehler aufgezeichnet, seit der Monitor sie beobachtet.", + "event": "Ereignis", + "severity": "Schweregrad", + "occurrences": "Vorkommen", + "firstSeen": "Zuerst gesehen", + "lastSeen": "Zuletzt gesehen", + "detail": "Detail", + "network": "Netzwerk", + "adapters": "Adapter", + "bond": "Bond", + "bridge": "Bridge", + "bridges": "Bridges", + "physicalAdapters": "Physische Adapter", + "interface": "Schnittstelle", + "driver": "Treiber", + "state": "Status", + "networkDiagramNote": "Weg vom Kabel zu jedem Gast: physischer Adapter, Bond sofern vorhanden, Bridge und angebundene Gäste.", + "storageAndProtection": "Speicher und Absicherung", + "storage": "Speicher", + "content": "Inhalt", + "shared": "Gemeinsam", + "location": "Ort", + "backupDestination": "Backup-Ziel", + "unprotected": "ohne Backup", + "storageDiagramNote": "Wo die Gastfestplatten liegen und welches Ziel sie sichert.", + "unprotectedGuests": "{count} Gäste ohne Backup-Auftrag", + "allProtected": "Jeder Gast ist von einem Backup-Auftrag erfasst", + "allProtectedNote": "Die Erfassung besagt, dass ein Auftrag den Gast auswählt; das Ergebnis der Backups wird getrennt bewertet.", + "vmid": "VMID", + "name": "Name", + "kind": "Art", + "backup": "Backup", + "none": "keines", + "managedSoftware": "Von ProxMenux verwaltete Software", + "version": "Version", + "source": "Quelle", + "current": "aktuell", + "updateAvailable": "Update auf {version}", + "findings": "Ergebnisse im Detail", + "incomplete": "teilweise", + "scope": "Umfang dieses Berichts", + "scopeText": "Dieses Dokument berichtet über das Profil {profile} auf dem in der Kopfzeile genannten Knoten, zum Zeitpunkt des Durchlaufs.", + "scopeLocal": "Es umfasst nur diesen Knoten. Gäste auf anderen Knoten und deren Konfiguration liegen außerhalb.", + "scopeReadOnly": "Alle Prüfungen lesen bereits vorhandene Konfiguration und Zustände; keine verändert den Host.", + "scopeMoment": "Es beschreibt den Zustand zum Zeitpunkt des Durchlaufs, nicht einen Zeitraum.", + "notRead": "Nicht lesbare Quellen:", + "uplink": "Uplink", + "conformance": "{pass} von {total} konform", + "latency": "Netzwerklatenz", + "latencyNote": "Latenz im angegebenen Zeitfenster, eine Linie je Ziel.", + "milliseconds": "ms", + "hours": "Std.", + "minimum": "Minimum", + "average": "Mittelwert", + "maximum": "Maximum", + "packetLoss": "Paketverlust", + "samples": "Messwerte", + "target": { + "label": "Ziel", + "gateway": "Gateway", + "cloudflare": "Cloudflare (1.1.1.1)", + "google": "Google (8.8.8.8)" + }, + "actionShort": "Bericht", + "policyDeclared": "Bewertet wurde gegen eine erklärte Richtlinie: {guests} Gäste, {storages} Speicher und {thresholds} Schwellenwerte angegeben.", + "policyNone": "Es wurde keine Richtlinie erklärt; eine Abwesenheit, die dieser Bericht nicht deuten kann, erscheint als Beobachtung und nie als Warnung.", + "diagnosticTitle": "Schnelldiagnose", + "diagnosticSubtitle": "Kritische Ergebnisse und Warnungen auf {node}", + "diagnosticActing": "{count} kritische Ergebnisse und Warnungen.", + "diagnosticClear": "Kein kritisches Ergebnis und keine Warnung. Beobachtungen und konforme Ergebnisse stehen in der vollständigen Prüfung.", + "diagnosticUnread": "Nicht durchführbare Messungen", + "diagnosticMoreRows": "{count} weitere Zeile(n), in der vollständigen Prüfung.", + "structureTitle": "Struktur und Konfiguration", + "structureSubtitle": "Wie {node} aufgebaut und konfiguriert ist" + }, + "results": "Ergebnisse", + "classifications": { + "critical": "Kritisch", + "warning": "Warnung", + "observation": "Beobachtung", + "conformant": "Konform", + "unverified": "Nicht verifiziert", + "not_applicable": "Nicht zutreffend", + "accepted": "Akzeptiertes Risiko", + "by_design": "Per Richtlinie ausgenommen" + }, + "policy": { + "inherit": "{value} (Standard)", + "inheritUnset": "Standard", + "conflict": "Die Festlegung wurde in einer anderen Sitzung geändert. Dein Entwurf wurde nicht gespeichert.", + "reload": "Gespeicherte Festlegung neu laden (Entwurf verwerfen)", + "intro": "Eine Bewertung sieht, was dieser Host tut, nicht wozu er da ist. Was hier erklärt wird, macht aus einer Beobachtung eine Warnung oder nimmt sie aus der Zählung. Nichts ist Pflicht: ohne Erklärung beschreibt der Bericht, statt zu urteilen.", + "loading": "Erklärung wird gelesen…", + "failed": "Die Erklärung war nicht lesbar", + "saved": "Gespeichert", + "declaredCount": "{count} Erklärungen", + "guestsNote": "Erforderlich meldet Fehlendes als Warnung; nicht angegeben meldet es als Beobachtung; nicht erforderlich lässt es aus der Zählung heraus.", + "storagesNote": "Ein nicht erreichbarer Speicher ist kritisch, wenn er als wesentlich erklärt ist oder einem laufenden Gast dient, eine Warnung, wenn seine Rolle nicht erklärt ist, und eine Beobachtung, wenn er als optional erklärt ist.", + "thresholds": "Schwellenwerte", + "thresholdsNote": "Leer bedeutet den ausgelieferten Wert, der als Platzhalter erscheint.", + "backup": "Sicherung", + "autostart": "Autostart", + "objective": "Wiederherstellungsziel", + "objectivePlaceholder": "Stunden", + "noGuests": "Dieser Knoten hält keine Gäste.", + "expectation": { + "required": "Erforderlich", + "not_required": "Nicht erforderlich", + "unspecified": "Nicht erklärt" + }, + "role": { + "essential": "Wesentlich", + "optional": "Optional", + "unspecified": "Nicht erklärt" + }, + "threshold": { + "storage_usage_percent": "Prüfschwelle Speicherkapazität (%)", + "thin_pool_usage_percent": "Prüfschwelle Thin-Pool-Füllung (%)", + "thin_overprovision_ratio": "Thin-Überbuchungsverhältnis", + "zfs_scrub_days": "ZFS-Scrub-Intervall (Tage)", + "backup_fallback_days": "Ersatzwert Sicherungsalter (Tage)", + "backup_schedule_grace_ratio": "Kalendertoleranz (Verhältnis)", + "certificate_expiry_days": "Hinweis auf Zertifikatsablauf (Tage)", + "memory_overcommit_ratio": "Speicherüberbuchungsverhältnis", + "disk_service_life_hours": "Lebensdauer der Festplatte (Stunden)", + "lynis_report_days": "Alter des Lynis-Berichts (Tage)", + "package_index_days": "Alter der Paketindizes (Tage)", + "journal_usage_percent": "Journal gegen seine Obergrenze (%)", + "filesystem_usage_percent": "Prüfschwelle Dateisystemplatz (%)", + "filesystem_inode_percent": "Prüfschwelle Dateisystem-Inodes (%)", + "disk_error_recent_days": "Zeitfenster für aktuelle Festplattenfehler (Tage)" + } + }, + "changes": { + "loading": "Änderungsjournal wird gelesen…", + "failed": "Das Änderungsjournal war nicht lesbar", + "intro": "Was ProxMenux auf diesem Host geändert hat und was vor jeder Änderung vorhanden war. Gezeigt wird die Differenz, nicht das Skript, das sie angewandt hat.", + "empty": "Auf diesem Host wurde noch nichts aufgezeichnet.", + "since": "Aufzeichnung seit {date}. Früher Angewandtes erscheint als angewandt, ohne den ersetzten Zustand.", + "byFunction": "Nach Funktion", + "count": "{count} Änderungen", + "function": "Funktion", + "source": "Skript", + "reversibility": "Rückgängig machen", + "difference": "Unterschied", + "diffTruncated": "Der Unterschied ist länger als das Gezeigte.", + "diffUnavailable": "Der ersetzte Inhalt ist nicht mehr gespeichert, der Unterschied lässt sich nicht zeigen.", + "packagesAdded": "Hinzugefügte Pakete", + "commandRun": "Ausgeführter Befehl", + "executionNote": "ProxMenux hat dies auf Anforderung ausgeführt; was sich änderte, entschied der Befehl, nicht ProxMenux.", + "unknownNote": "Dies wurde vor dem Journal angewandt, der ersetzte Zustand wurde nie erfasst.", + "noneInFilter": "Keine Änderung dieser Art.", + "class": { + "all": "Alle", + "configuration": "Konfiguration", + "installation": "Installationen", + "execution": "Ausführungen", + "registration": "Angewandt" + }, + "operation": { + "write_file": "Datei ersetzt", + "edit_file": "Datei bearbeitet", + "remove_file": "Datei entfernt", + "install_package": "Installiert", + "enable_service": "Dienst aktiviert", + "disable_service": "Dienst deaktiviert", + "run_command": "Ausgeführt", + "applied": "Angewandt", + "removed": "Entfernt", + "unknown": "Änderung" + }, + "capture": { + "unknown": "Vorheriger Zustand unbekannt" + }, + "exactness": { + "exact": "Stellt genau das Vorherige wieder her", + "partial": "Teilweise: Abhängigkeiten können bleiben oder mitgehen", + "none": "Aus dem Journal nicht rückgängig zu machen" + } + }, + "comparison": { + "loading": "Vergleich mit dem Referenzlauf…", + "failed": "Der Referenzlauf konnte nicht gesetzt werden", + "since": "Seit {date}", + "previousRun": "dem vorigen Lauf", + "noChange": "Keine Änderung", + "isBaseline": "Dieser Lauf ist die Referenz, mit der die anderen verglichen werden.", + "noBaseline": "Es wurde noch kein Referenzlauf gewählt, es gibt also nichts zu vergleichen.", + "setBaseline": "Als Referenz verwenden", + "unchanged": "{count} Prüfungen ergaben dasselbe wie zuvor.", + "new": "Neu", + "newNote": "jetzt gemeldet, vorher nicht", + "resolved": "Behoben", + "resolvedNote": "nicht mehr gemeldet, und niemand hat sie akzeptiert", + "accepted": "Akzeptiert", + "acceptedNote": "zählen nicht mehr, weil ein Risiko akzeptiert wurde, nicht weil sich der Host geändert hat", + "retired": "Nicht mehr bewertet", + "retiredNote": "vorher vorhanden, in diesem Lauf nicht; nichts hat bestätigt, dass sie aufgehört haben", + "reasons": { + "insufficient_runs": "Ein Vergleich braucht einen Referenzlauf und einen späteren; bisher ist nur einer aufgezeichnet" + } + }, + "notApplicableScope": "Im geprüften Umfang gibt es nichts, worauf diese Prüfung zutrifft." } } diff --git a/AppImage/messages/en/common.json b/AppImage/messages/en/common.json index 303bb507..b2f8de1b 100644 --- a/AppImage/messages/en/common.json +++ b/AppImage/messages/en/common.json @@ -1890,6 +1890,7 @@ "system_reboot": "System rebooting", "system_restore_completed": "Host restore completed", "system_problem": "System problem detected", + "kernel_warning": "Kernel warnings and diagnostic traces", "service_fail": "Service failed", "oom_kill": "Out-of-memory process kill", "service_fail_batch": "Multiple service failures", @@ -4983,6 +4984,154 @@ "customLinkDeleteError": "Delete failed" }, "audit": { + "presentation": { + "backupAge": "Backup age", + "backupLimit": "Limit used", + "limitDeclared": "User-declared limit", + "limitSchedule": "Schedule + margin", + "limitReference": "Reference period", + "verifiedChecks": "Verified checks", + "unverifiedChecks": "Unverified checks", + "checkName": "Check", + "verified": "Verified", + "verificationScope": "Applicable checks verified. Coverage does not express the health or security of the server.", + "noApplicable": "No applicable checks in this assessment.", + "noJob": "No scheduled job", + "guest": "Guest", + "guests": "guests", + "host": "Host", + "resource": "Resource", + "data": "Data", + "metadata": "Metadata", + "result": "Result", + "fact": "Observed fact", + "records": "records", + "unscheduled": "guests without a scheduled job", + "excludedDisks": "excluded disks", + "disks": "Disks", + "destination": "Destination", + "lastCopy": "Latest stored copy", + "ageLimit": "Age / limit", + "noDestination": "No configured destination", + "notFound": "No copy found in the inspected scope", + "promiscuous": "Interfaces in promiscuous mode", + "noDescription": "Description not available", + "occurrence": "Occurrence", + "occurrences": "occurrences", + "detail": "Detail", + "technical": "Technical evidence", + "annex": "Technical appendix", + "overview": "Findings overview", + "incomplete": "Incomplete assessment", + "assessment": "Assessment", + "noGlobalScore": "Results describe separate criteria; no overall security score is calculated.", + "coverage": "Scheduled backup coverage", + "scheduled": "With a scheduled job", + "copyScope": "A configured job does not establish that a stored or restorable copy exists.", + "detailsLink": "Evidence reference", + "noSubscription": "No subscription found", + "otherDevices": "Other devices in the group", + "unversioned": "Version not recorded", + "notInstalled": "Not installed", + "noPendingRecorded": "No pending update recorded", + "originalEvidence": "Original source evidence", + "evidenceObserved": "Observed evidence", + "evidenceExcerpt": "Compact view. The complete source evidence remains stored with this assessment.", + "annexScope": "Complete source evidence for results that require attention, record an observation or could not be verified.", + "readOnlyScope": "The assessment does not change configuration. Read commands and, when needed, Lynis may generate logs or reports.", + "capacity": "Capacity", + "used": "Used", + "free": "Free", + "reasons": { + "agentNotDeclared": "No guest agent declared in the configuration", + "arcConflictingSettings": "Persistent settings disagree with each other", + "arcMinAboveMax": "The lower ARC bound is above the upper one", + "arcPendingReboot": "Persistent setting differs from the loaded parameter", + "arrayDegraded": "Running on fewer devices than it was built with", + "arrayNotActive": "Not active", + "arrayRebuilding": "Short of devices and rebuilding", + "backupRunFailed": "The run ended with an error", + "backupRunRecovered": "Failed earlier, and a later run succeeded", + "bondNoMembersUp": "Down, and no member of the bond is up", + "bondRedundancyLost": "Down; the bond keeps other links", + "bootEspMissingNewest": "Does not carry the newest kernel the others do", + "bootEspOutOfSync": "Out of step with the others: it would start a different kernel", + "bootSingleEsp": "One boot partition configured", + "bootToolReported": "Reported by proxmox-boot-tool", + "cephCheckRaised": "Raised by Ceph", + "channelIncomplete": "Enabled but missing part of its configuration", + "clusterInquorate": "Without quorum: changes to the cluster are refused", + "clusterMemberAbsent": "Configured member not seen by the cluster", + "clusterSingleLink": "One corosync link declared", + "dataExcludedFromBackup": "Data excluded from the guest's backup", + "deliveryFailing": "Recent deliveries did not go out", + "destinationUnavailable": "Configured destination unavailable", + "diskErrorsActive": "Error recorded within the review window", + "diskErrorsPast": "Errors reported earlier, none in the window in use", + "diskWarningsActive": "Device warning recorded within the review window", + "diskWarningsPast": "Device warnings reported earlier, none in the window in use", + "essentialServiceDown": "A service Proxmox needs to answer is not active", + "exemptByPolicy": "Declared as not requiring this, so it is outside the count", + "expectedButUncovered": "Declared as requiring a backup, and no enabled job selects it", + "expectedToAutostart": "Declared as expected to start with the host, and does not", + "filesystemExhausted": "No space left", + "filesystemNearlyFull": "At or above the space review threshold", + "filesystemReadOnly": "The kernel reports this mount read-only: it has stopped accepting writes", + "haManagerNotReady": "Neither active nor idle: cannot take a service", + "haNoMaster": "No manager: nothing decides where a service should run", + "haServiceError": "Left in an error state and no longer managed", + "haServiceTransitioning": "Between states", + "hostArchiveMissing": "The job record names an archive that is no longer stored", + "hostBackupJobFailed": "The job ended with an error", + "hostBackupStale": "Older than the age limit in use", + "hostBackupUnscheduled": "Stored, with no schedule producing a further copy", + "hostNoRetrievableCopy": "No copy this check can still account for", + "indexesStale": "Package indexes have not been refreshed recently", + "inodesExhausted": "No inodes left", + "inodesNearlyExhausted": "At or above the inode review threshold", + "kernelAwaitingReboot": "Installed and not the running kernel", + "lynisReportStale": "The Lynis report is {days} day(s) old, older than the reference age in use", + "lynisWarning": "Recorded by the Lynis audit", + "multipathNoPath": "No path left", + "multipathPathDown": "Serving on fewer paths", + "noAutostart": "Does not start with the host", + "noConfigurationReference": "No reference found in the configurations examined", + "noJobSelectsGuest": "No enabled backup job selects it", + "noPhysicalPort": "Carries no physical port", + "noStoredBackup": "No stored copy found", + "noStoredBackupUnscheduled": "No scheduled job; no stored copy found", + "olderThanFallback": "The backup exceeds the reference period.", + "olderThanObjective": "The backup exceeds the user-declared limit.", + "olderThanSchedule": "The backup exceeds the scheduled interval plus its margin.", + "overprovisioned": "Hands out more virtual capacity than the pool holds", + "packageAwaitingRestart": "Installed and asking for a restart", + "pastServiceLife": "Past the service-life threshold used for planning", + "pinnedToHostCpu": "Pinned to the host processor model", + "poolDeviceErrors": "Device counting read, write or checksum errors", + "poolNotOnline": "Not online", + "rebootMarkerWithoutPackages": "Something wrote the restart marker without naming a package", + "recoveryKeyLocalOnly": "Backup encryption key held only on this node, per the recorded escrow mode", + "replicationDisabled": "Paused", + "replicationFailing": "Last run reported an error", + "replicationNeverRan": "Has never completed a synchronisation", + "replicationOverdue": "Older than the job's own schedule allows", + "retentionNotDeclared": "No retention declared; every copy is kept", + "retentionOnServer": "Pruned on the backup server, under jobs this node cannot read", + "runsPrivileged": "Runs privileged, sharing the host user namespace", + "scrubOverdue": "Last completed scrub older than the review threshold", + "storageNearlyFull": "At or above the capacity review threshold", + "storageUnreachable": "Not reachable", + "thinDataPressure": "Written data close to the pool's capacity", + "thinMetadataPressure": "Metadata close to full, which takes the pool read-only", + "unitFailed": "systemd stopped retrying it", + "verificationFailedOnly": "Verification read the newest copy and it was not intact; no other copy of this guest has verified", + "verificationFailedWithFallback": "Verification read the newest copy and it was not intact; an earlier copy did verify", + "verificationNotRun": "No verification job has read this copy back" + }, + "lynisTest": "Test", + "lynisWarning": "Warning", + "couldNotRead": "Could not be read" + }, "title": "Audit & Report", "loading": "Loading assessment…", "run": "Run assessment", @@ -4991,9 +5140,8 @@ "lastRun": "Last assessed on {when}", "stale": "{days} days ago", "readOnlyNotice": "The assessment only reads the host. It makes no changes.", + "unverifiedChecks": "Not taken: {checks}. Each says in its own evidence what it could not read.", "noFindings": "No findings match the current filter.", - "showPassing": "Show passing checks", - "hidePassing": "Hide passing checks", "affectedCount": "{count} affected", "acceptedNotice": "{count} accepted risk(s) recorded on this host.", "states": { @@ -5001,7 +5149,8 @@ "warn": "Warning", "accepted": "Accepted risk", "pass": "Passed", - "not_applicable": "Not applicable" + "not_applicable": "Not applicable", + "unknown": "Not verified" }, "areas": { "all": "All", @@ -5017,7 +5166,8 @@ "why": "Context", "evidence": "Evidence", "affected": "Affected", - "acceptedRisk": "Accepted risk" + "acceptedRisk": "Accepted risk", + "sources": "Sources and collection dates" }, "errors": { "runFailed": "The assessment could not be started." @@ -5026,69 +5176,434 @@ "backup": { "guest_coverage": { "title": "Backup coverage", - "rationale": "Compares the guest inventory against what each backup job selects. A job selects by VMID list, by pool, or with `all 1`, minus its `exclude` list. Jobs carrying `enabled 0` are not considered.", + "rationale": "Enabled backup jobs on this node, the guests each one selects, and guest data excluded from them. Configured coverage does not prove that a usable backup exists. Whether an unselected guest was meant to be protected comes from the declared policy.", "summary": { - "noJobs": "No backup job is defined on this node", - "covered": "All {total} guests are covered by a backup job", - "uncovered": "{count} of {total} guests are not covered by any enabled backup job" + "noJobs": "No backup job is defined on this node for the {total} guests it holds", + "covered": "Every one of the {total} guests is selected by an enabled backup job", + "uncovered": "{count} of {total} guests are not selected by any enabled backup job", + "excludedData": "{count} disk or mount point exclusion(s) need review", + "uncoveredExpected": "{required} guests declared as requiring a backup are not selected by any enabled job", + "evaluationFailed": "The check could not be evaluated" + } + }, + "last_backup_age": { + "title": "Age of stored backups", + "rationale": "Age: time elapsed since the latest stored backup. Limit used: the reference age against which that backup is compared.", + "summary": { + "recent": "All {total} guest/destination checks meet the stated age policy", + "stale": "{count} of {total} guests have no backup from the last 30 days", + "noBackups": "No stored backup matches a guest on this node", + "attention": "{count} of {total} guest/destination checks need review", + "evaluationFailed": "The check could not be evaluated" + } + }, + "retention_defined": { + "title": "Backup retention", + "rationale": "Retention as Proxmox resolves it: the job's setting, then the storage's, then the node default. Retention applied by a backup server is not readable from this node.", + "summary": { + "allDefined": "All {total} jobs resolve a retention setting", + "missing": "{count} of {total} enabled job(s) declare no retention", + "notDeclared": "{count} of {total} jobs keep every copy: no retention is declared for them", + "onServer": "{count} of {total} jobs write to a backup server, which prunes them under its own jobs", + "evaluationFailed": "The check could not be evaluated" + } + }, + "verification_state": { + "title": "Backup verification", + "rationale": "The verification result Proxmox Backup Server records for each guest's newest copy, and whether an earlier copy of the same guest verified. Verification reads a stored copy back; it is not a restore.", + "summary": { + "allVerified": "The newest copy of all {total} guests has been verified intact", + "failed": "{failed} newest copies failed verification, of {total} examined", + "notVerified": "{pending} of {total} newest copies have not been verified", + "evaluationFailed": "The verification state could not be read" + } + }, + "job_results": { + "title": "Backup run results", + "rationale": "How each guest's most recent recorded run ended, from the node's task log. Only the latest run is graded. The log is retained for a limited period.", + "summary": { + "allSucceeded": "All {total} recorded backup runs ended without error", + "someFailed": "{count} of {total} recorded backup runs ended with an error", + "evaluationFailed": "The task log could not be read", + "recovered": "{count} of {total} guests failed an earlier run and succeeded since" + } + }, + "host_recovery": { + "title": "Host recovery", + "rationale": "Host backups as ProxMenux records them: each job it ran, when, whether it succeeded, the destination it wrote to and whether that copy is still there. A job writing to a backup server names no local path. Encryption keys are reported by count and recorded escrow mode only.", + "summary": { + "noHostBackup": "No host configuration backup is stored and no timer produces one", + "protected": "The node's own configuration is stored in {total} archive(s), within the age limit in use", + "attention": "{count} finding(s) across {total} host configuration record(s)", + "scheduledOnly": "Host configuration backups are scheduled through {count} timer(s); no archive is stored locally" } } }, "system": { "pending_reboot": { "title": "Restart state", - "rationale": "Checks for `/var/run/reboot-required` and, when present, the packages listed in `/var/run/reboot-required.pkgs`. Also reports the running kernel.", + "rationale": "The `/var/run/reboot-required` marker and the packages listed in `/var/run/reboot-required.pkgs`. Its absence does not prove that nothing needs restarting.", "summary": { - "none": "No restart is pending", - "pending": "The host has a pending restart" + "none": "Nothing has asked for a restart", + "pending": "{count} items are installed and waiting for a restart", + "evaluationFailed": "The check could not be evaluated" } }, "enterprise_repo_without_subscription": { "title": "Enterprise repository", - "rationale": "Looks for references to `enterprise.proxmox.com` in `/etc/apt/sources.list` and `sources.list.d`, and contrasts the result with the status returned by `pvesubscription get`.", + "rationale": "References to `enterprise.proxmox.com` in `/etc/apt/sources.list` and `sources.list.d`, against the status returned by `pvesubscription get`.", "summary": { "notEnabled": "The enterprise repository is not enabled", "subscribed": "The enterprise repository is backed by a subscription", - "unsubscribed": "The enterprise repository is enabled without an active subscription" + "unsubscribed": "The enterprise repository is enabled without an active subscription", + "evaluationFailed": "The check could not be evaluated" + } + }, + "memory_overcommit": { + "title": "Memory allocation", + "rationale": "The `memory` ceiling of every guest configuration against MemTotal, with running guests counted separately. Containers consume up to that limit; virtual machines without ballooning reserve it.", + "summary": { + "withinRatio": "Guests are allocated {percent}% of host memory", + "aboveRatio": "Guests are allocated {percent}% of host memory", + "evaluationFailed": "The check could not be evaluated" + } + }, + "time_synchronisation": { + "title": "Time synchronisation", + "rationale": "NTP and NTPSynchronized as `timedatectl` reports them. Cluster membership, certificate validation and log ordering depend on agreeing clocks. Another mechanism may be disciplining the clock.", + "summary": { + "synchronised": "The clock is synchronised with a time source", + "disabled": "Time synchronisation is disabled", + "notSynchronised": "Time synchronisation is enabled but the clock is not synchronised", + "evaluationFailed": "The check could not be evaluated" + } + }, + "kernel_current": { + "title": "Running kernel", + "rationale": "The running kernel against the one the host would start next, as `proxmox-boot-tool` reports it. A newer kernel merely installed may be held deliberately; the two differing after a restart is a boot that did not take.", + "summary": { + "current": "The running kernel {version} is the one the host would start next", + "newerAvailable": "The host runs {running} while {newest} is installed", + "newerSelected": "The host runs {running} and would start {selected} at the next restart", + "wouldDowngrade": "The host runs {running} but would start the older {selected} at the next restart", + "bootTargetUnknown": "The host runs {version}; the kernel selected for the next boot could not be read", + "evaluationFailed": "The check could not be evaluated" + } + }, + "security_updates": { + "title": "Security updates", + "rationale": "Pending packages whose origin is a security repository, from a simulated `apt-get upgrade`. The count reflects what apt reports, not the severity of what each package fixes.", + "summary": { + "none": "No package updates are pending", + "noSecurity": "{total} update(s) pending, none from a security repository", + "pending": "{count} of {total} pending update(s) come from a security repository", + "evaluationFailed": "The check could not be evaluated" + } + }, + "journal_size": { + "title": "Journal size", + "rationale": "The journal on disk against the cap that applies to it: SystemMaxUse where it is set, and otherwise journald's default of a tenth of the filesystem it lives on.", + "summary": { + "bounded": "The journal holds {size}, within its effective cap", + "large": "The journal holds {size} on disk", + "nearCap": "The journal holds {size} and is at {percent}% of its effective cap", + "capUnknown": "The journal holds {size}; its effective cap could not be determined", + "evaluationFailed": "The check could not be evaluated" + } + }, + "swap_configured": { + "title": "Swap", + "rationale": "Active swap areas from `swapon` and their total against host memory. No ratio to memory is required by anything; memory pressure is measured elsewhere.", + "summary": { + "active": "{size} of swap is active", + "none": "No swap area is active", + "evaluationFailed": "The check could not be evaluated" + } + }, + "filesystem_capacity": { + "title": "Host filesystem capacity", + "rationale": "Space and inodes on the filesystems the host itself needs: the root filesystem, /var, /var/log and the local storage path. A filesystem with free space and no inodes left fails the same way as a full one.", + "summary": { + "withinLimits": "The {total} host filesystems are within their review thresholds", + "pressure": "{count} readings are at or above their review threshold", + "evaluationFailed": "Filesystem usage could not be read" + } + }, + "update_chain": { + "title": "APT package index age", + "rationale": "When APT last placed a package index on this host. A repository that answers \"not modified\" leaves its index untouched. Repository reachability is not tested.", + "summary": { + "current": "Package indexes were refreshed {days} day(s) ago", + "stale": "Package indexes were last refreshed {days} day(s) ago", + "indexAgeUnknown": "The age of the package indexes could not be determined" + } + }, + "notification_delivery": { + "title": "Latest notification outcome", + "rationale": "Enabled channels and their latest retained delivery result. No history means delivery is unverified; an earlier failure followed by a success is not a current failure. No test notification is sent.", + "summary": { + "delivering": "The latest recorded delivery succeeded for all {total} enabled channels", + "failing": "{count} of {total} enabled channels have a configuration problem or a failed latest delivery", + "noChannels": "No notification channel is enabled", + "evaluationFailed": "The delivery history could not be read" + } + }, + "cluster_quorum": { + "title": "Cluster quorum", + "rationale": "Quorum as the cluster reports it, the configured nodes against those currently seen, and the number of corosync links declared. Links are read from the configuration, not probed.", + "summary": { + "standalone": "This node is not a member of a cluster", + "quorate": "The cluster is quorate with {total} configured node(s) over {links} corosync link(s)", + "attention": "{count} finding(s) across {total} configured node(s)", + "evaluationFailed": "Cluster status could not be read" + } + }, + "boot_loader": { + "title": "Boot loader", + "rationale": "The EFI system partitions proxmox-boot-tool reports and the kernels each one carries. No partition is mounted and no boot is attempted.", + "summary": { + "synchronised": "The {total} boot partitions carry the same kernels", + "attention": "{count} of {total} boot partitions need review", + "evaluationFailed": "The check could not be evaluated" + } + }, + "failed_units": { + "title": "Essential services and failed units", + "rationale": "Units systemd has given up on after exhausting its restarts, and the services Proxmox needs to answer at all, read by name because an inactive service is not always a failed one. What each unit does is not interpreted here.", + "summary": { + "allRunning": "The {total} essential services are active and no unit has failed", + "attention": "{count} finding(s) among the units", + "evaluationFailed": "The check could not be evaluated" + } + }, + "ha_state": { + "title": "High availability", + "rationale": "The HA master, each node's resource manager and the state of every managed service, from `ha-manager status`. Quorum is reported by the cluster check. No service is started, stopped or migrated.", + "summary": { + "managed": "The {total} managed services are in a settled state across {nodes} node(s)", + "attention": "{count} finding(s) across {total} managed service(s)", + "evaluationFailed": "The check could not be evaluated" } } }, "guests": { "privileged_containers": { "title": "Container privileges", - "rationale": "Reviews each container configuration. Proxmox marks unprivileged containers with `unprivileged: 1`; its absence indicates a privileged container, which shares the host user namespace.", + "rationale": "The `unprivileged` setting of each container configuration. Its absence means the container shares the host user namespace, which some workloads require.", "summary": { "allUnprivileged": "All {total} containers are unprivileged", - "privileged": "{count} of {total} containers run privileged" + "privileged": "{count} of {total} containers run privileged", + "evaluationFailed": "The check could not be evaluated" } }, "qemu_without_agent": { "title": "Guest agent on virtual machines", - "rationale": "Reviews each virtual machine configuration for `agent: 1`. The guest agent enables ordered shutdown, filesystem quiescing for snapshots and real disk usage reporting.", + "rationale": "The `agent` setting in each virtual machine configuration. The setting says the agent is declared, not that it answers.", "summary": { "allHaveAgent": "All {total} virtual machines declare the guest agent", - "missingAgent": "{count} of {total} virtual machines do not declare the guest agent" + "missingAgent": "{count} of {total} virtual machines do not declare the guest agent", + "evaluationFailed": "The check could not be evaluated" + } + }, + "autostart": { + "title": "Automatic start", + "rationale": "The `onboot` setting of each guest, excluding templates and guests managed by HA. Whether a guest is expected to come back by itself comes from the declared policy.", + "summary": { + "allAutostart": "All {total} guests start with the host", + "notAutostart": "{count} of {total} guests do not start with the host", + "evaluationFailed": "The check could not be evaluated" + } + }, + "stuck_snapshots": { + "title": "Snapshot state", + "rationale": "Snapshot state, age and active tasks. A recent or undated operation is not treated as interrupted.", + "summary": { + "noSnapshots": "No guest holds snapshots", + "allComplete": "The {total} snapshot(s) are complete", + "stuck": "{count} of {total} snapshot(s) were left mid-operation", + "evaluationFailed": "The check could not be evaluated" + } + }, + "cpu_host_type": { + "title": "Virtual CPU model", + "rationale": "The `cpu` value of each virtual machine. `host` exposes the physical processor's feature set, which constrains the nodes the guest can migrate to. Incompatibility with a particular destination is not read here.", + "summary": { + "none": "None of the {total} virtual machines is pinned to the host processor", + "pinned": "{count} of {total} virtual machines are pinned to the host processor", + "evaluationFailed": "The check could not be evaluated" + } + }, + "replication_state": { + "title": "Replication", + "rationale": "Replication jobs from the API: failure count, last error, last synchronisation and the calendar each job declares. Paused jobs are reported as such.", + "summary": { + "healthy": "The {total} replication job(s) report no error", + "failing": "{count} of {total} replication job(s) report an error", + "statusUnavailable": "Replication jobs are defined but their status could not be read", + "evaluationFailed": "The check could not be evaluated" } } }, "security": { "host_firewall_enabled": { - "title": "Firewall state", - "rationale": "Checks `enable: 1` in `/etc/pve/firewall/cluster.fw` and in the node's `host.fw`. Proxmox applies node rules only while the datacenter switch is on.", + "title": "Configured firewall activation", + "rationale": "The `enable` option in the datacenter firewall and in the node's own, and how many rules are written. Proxmox applies the node's rules only while the datacenter switch is on. These options do not say what any rule filters.", "summary": { - "bothEnabled": "The firewall is enabled at datacenter and node level", + "bothEnabled": "Datacenter and node firewall activation is configured", "datacenterOff": "The firewall is disabled at datacenter level, so node rules are not applied", - "nodeOff": "The firewall is enabled at datacenter level but not on this node" + "nodeOff": "The firewall is enabled at datacenter level but not on this node", + "evaluationFailed": "The check could not be evaluated" + } + }, + "lynis_warnings": { + "title": "Lynis warnings", + "rationale": "Warnings from the last Lynis audit, each with its test identifier, and how old that audit is. An audit is run only where Lynis is installed and no complete report exists. Suggestions are not included.", + "summary": { + "none": "The last Lynis audit recorded no warnings", + "found": "The last Lynis audit recorded {count} warning(s)", + "incomplete": "The Lynis report is incomplete", + "evaluationFailed": "The check could not be evaluated", + "noneStale": "The last Lynis audit recorded no warnings, and its report is {days} day(s) old", + "foundStale": "The last Lynis audit recorded {count} warning(s), and its report is {days} day(s) old" + } + }, + "certificate_expiry": { + "title": "Certificate validity", + "rationale": "The expiry date of the certificate pveproxy serves from /etc/pve/local. A custom certificate takes precedence over the one Proxmox generates.", + "summary": { + "valid": "The certificate is valid for {days} more day(s)", + "expiring": "The certificate expires in {days} day(s)", + "expired": "The certificate expired {days} day(s) ago", + "evaluationFailed": "The check could not be evaluated" + } + }, + "ssh_root_login": { + "title": "SSH root access", + "rationale": "PermitRootLogin in the effective `sshd -T` configuration, with the authentication methods it combines with. Proxmox ships `yes`, which accepts a password.", + "summary": { + "password": "Root may sign in over SSH with a password", + "keyOnly": "Root may sign in over SSH with a key only", + "denied": "Root may not sign in over SSH", + "evaluationFailed": "The check could not be evaluated" } } }, "storage": { "orphaned_volumes": { "title": "Volume assignment", - "rationale": "Compares the volumes returned by `pvesm list` against the existing guest configurations. Only non-shared storage is examined: on shared storage a volume may belong to a guest running on another node.", + "rationale": "Guest volumes on local storage against the references in current, pending and snapshot configurations. Backups, ISOs and templates are outside the comparison. An unreferenced volume is a candidate for review.", "summary": { "none": "No orphaned volumes were found", - "found": "{count} volume(s) belong to no existing guest" + "found": "{count} volume(s) have no reference in the inspected configurations", + "evaluationFailed": "The check could not be evaluated" + } + }, + "zfs_arc_max": { + "title": "ZFS ARC limit", + "rationale": "Effective c_min, c_max and ARC size, the loaded module parameter and the persistent settings in /etc/modprobe.d. A configured value of zero selects the module default; the ARC is a ceiling, and the memory under it is reclaimable.", + "summary": { + "bounded": "The ARC limit is {percent}% of host memory, and the memory under it is reclaimable", + "high": "The ARC may use {percent}% of host memory", + "unset": "The ARC has no explicit limit set", + "conflicting": "{count} ARC settings do not agree with each other", + "pending": "A persistent ARC setting differs from the value the running module carries", + "evaluationFailed": "The check could not be evaluated" + } + }, + "zfs_scrub_age": { + "title": "ZFS scrub", + "rationale": "The last completed scrub recorded by `zpool status` for each pool. A resilver is not a scrub. A pool created recently has not had time for one.", + "summary": { + "recent": "The {total} pool(s) were scrubbed within the last 35 days", + "overdue": "{count} of {total} pool(s) have not been scrubbed in 35 days", + "neverScrubbed": "{count} pool(s) record no scrub", + "evaluationFailed": "The check could not be evaluated" + } + }, + "thin_pool_overprovisioning": { + "title": "Thin pool allocation", + "rationale": "Virtual capacity handed out by each LVM thin pool against the pool's own size, and how much of the pool its volumes have actually written, data and metadata separately.", + "summary": { + "withinRatio": "The {total} thin pools are below the applied review thresholds", + "aboveRatio": "{count} of {total} thin pools hand out more capacity than they hold", + "pressure": "{pressure} of {total} thin pools are close to filling their data or metadata", + "evaluationFailed": "The check could not be evaluated" + } + }, + "connected_storage": { + "title": "Connected storage", + "rationale": "Availability as PVE reports it, known capacity and current dependencies of every storage enabled on this node. Remote internals are not probed and write access is not tested. Capacity is reported where PVE knows it and left blank where it does not.", + "summary": { + "available": "PVE reports all {total} storage(s) available; remote internals and write access were not tested", + "attention": "{count} of {total} storage(s) need review", + "evaluationFailed": "The check could not be evaluated" + } + }, + "pool_integrity": { + "title": "Pool integrity and redundancy", + "rationale": "The state of each ZFS pool and the read, write and checksum counters of its devices. Counters are cumulative since the last `zpool clear`.", + "summary": { + "healthy": "All {total} pools are online with no device errors recorded", + "degraded": "{count} findings across {total} pools", + "evaluationFailed": "Pool state could not be read" + } + }, + "ceph_health": { + "title": "Ceph health", + "rationale": "Ceph's own health status and the checks it named. Its tests are not reimplemented and no pool, placement group or OSD is queried separately.", + "summary": { + "healthy": "Ceph reports HEALTH_OK", + "degraded": "Ceph reports {state}, with {count} named check(s)", + "evaluationFailed": "The check could not be evaluated" + } + }, + "array_integrity": { + "title": "Software RAID and multipath", + "rationale": "mdadm arrays and multipath maps, read from /proc/mdstat and, where the tool is installed, `multipath -ll`. ZFS pools are reported by their own check.", + "summary": { + "intact": "The {total} arrays and maps hold their redundancy", + "degraded": "{count} of {total} arrays or maps are short of it", + "evaluationFailed": "The check could not be evaluated" + } + } + }, + "hardware": { + "disk_service_life": { + "title": "Disk service life", + "rationale": "Powered-on hours and remaining wear from the Monitor's SMART readings, with the date of each reading. Age is planning information; media errors and device warnings are reported by the health monitor.", + "summary": { + "withinLife": "The {total} disk readings are within the five-year planning threshold", + "pastLife": "{count} of {total} disk readings are past five years of service", + "noReadings": "No disk reported usable SMART counters ({skipped} without readings)", + "evaluationFailed": "The check could not be evaluated" + } + }, + "disk_errors": { + "title": "Disk errors", + "rationale": "Errors detected on the disks and recorded by the health monitor.", + "summary": { + "recorded": "{count} of {total} disks with a record have events noted", + "noEvents": "No disk event left to grade: none recorded, or every one dismissed" + } + } + }, + "network": { + "bond_members": { + "title": "Bond members", + "rationale": "The MII status of each bond member from /proc/net/bonding, and how many links remain. In active-backup a standby member reports as up and carries no traffic.", + "summary": { + "allUp": "All members of the {total} bond(s) are up", + "membersDown": "{count} bond member(s) are not up", + "evaluationFailed": "The check could not be evaluated" + } + }, + "bridge_without_ports": { + "title": "Bridge ports", + "rationale": "The port configuration of each bridge. A bridge without a physical port carries an internal or routed network.", + "summary": { + "allConnected": "The {total} bridge(s) carry a port", + "isolated": "{count} of {total} bridge(s) carry no port", + "evaluationFailed": "The check could not be evaluated" } } } @@ -5109,6 +5624,389 @@ "expiry365": "1 year", "cancel": "Cancel", "confirm": "Accept risk" - } + }, + "incomplete": "Incomplete evidence", + "progress": "Checked {completed} of {total}", + "expires": "Expires: {when}", + "runStates": { + "partial": "The assessment ran; some readings could not be taken.", + "failed": "Assessment interrupted or failed. Review the evidence before relying on these results." + }, + "severities": { + "CRITICAL": "Critical", + "WARNING": "Warning", + "INFO": "Information", + "OK": "OK" + }, + "viewSwitch": { + "ariaLabel": "Switch between assessment and inventory", + "assessment": "Assessment", + "inventory": "Inventory", + "policy": "Policy", + "changes": "Changes" + }, + "inventory": { + "loading": "Loading inventory…", + "failed": "The inventory could not be composed.", + "collectedAt": "Composed on {when}", + "unavailable": "Not read in this inventory", + "unresolved": "path not resolved", + "noUplink": "no uplink", + "identity": "Node identity", + "node": "Node", + "pveVersion": "Proxmox VE", + "kernel": "Kernel", + "subscription": "Subscription", + "cluster": "Cluster", + "standalone": "Not in a cluster", + "hardware": "Hardware", + "system": "System", + "serial": "Serial number", + "bios": "BIOS", + "cpu": "Processor", + "topology": "Layout", + "cpuLayout": "{sockets} socket(s) × {cores} cores = {threads} threads", + "virtualisation": "Virtualisation", + "memory": "Memory", + "iommuGroups": "IOMMU groups", + "network": "Network", + "storage": "Storage", + "name": "Name", + "type": "Type", + "content": "Content", + "shared": "Shared", + "yes": "Yes", + "no": "No", + "guests": "Guests", + "ostype": "Operating system", + "onboot": "Starts with host", + "tags": "Tags", + "privilege": "Privilege", + "privileged": "Privileged", + "unprivileged": "Unprivileged", + "features": "Features", + "agent": "Guest agent", + "cpuModel": "CPU model", + "disks": "Disks", + "interfaces": "Network interfaces", + "protection": "Backup", + "passthrough": "Passthrough", + "noBackup": "No backup", + "noBackupDetail": "No enabled backup job selects this guest.", + "applications": "Applications", + "versionUnknown": "version not detected", + "passthroughTitle": "PCI passthrough", + "iommuGroup": "IOMMU group {group}", + "sharedGroup": "{count} more device(s) in the same group", + "proxmenux": "ProxMenux optimizations", + "latency": "Network latency", + "subscriptionStatus": { + "notfound": "No subscription found", + "active": "Active", + "invalid": "Invalid", + "expired": "Expired", + "suspended": "Suspended", + "new": "Pending activation", + "unknown": "Unknown" + } + }, + "profile": { + "label": "Report", + "full": "Full audit", + "inventory": "Inventory", + "security": "Security review", + "backup": "Backup assurance", + "capacity": "Capacity and wear", + "diagnostic": "Quick diagnosis" + }, + "document": { + "action": "Generate report", + "title": "Audit Report", + "subtitle": "Structure, configuration and assessment of {node}", + "generated": "Generated", + "assessed": "Assessed", + "runId": "Assessment reference", + "print": "Print or save as PDF", + "summaryByArea": "Summary by area", + "area": "Area", + "inventoryAnnex": "Inventory annex", + "scopeTitle": "Scope of this report", + "scopeBody": "This report describes the Proxmox VE node named above, as observed from the node itself at the time stated. It does not cover the interior of the guests beyond what they declare, network equipment outside the host, physical infrastructure, or any dependency not visible from this node. Findings marked as not determined were not measured and are not evidence of absence.", + "building": "Composing the report…", + "node": "Node", + "profile": "Profile", + "unknownNode": "unidentified node", + "executiveSummary": "Assessment summary", + "assessment": "Assessment", + "verdictHeading": "Result of this run", + "verdict": { + "critical": "ATTENTION", + "warning": "REVIEW", + "conformant": "IN ORDER" + }, + "verdictText": { + "critical": "{fail} checks report a failed condition and {warn} report a condition to review, out of {total} evaluated.", + "warning": "No check reports a failed condition. {warn} of {total} report a condition to review.", + "conformant": "The {total} checks in this profile complete without a failed or reviewable condition.", + "none": "This profile runs no checks. The document describes the node without assessing it." + }, + "runAt": "Run on {date}", + "chartNote": "Checks by area and result.", + "nodeIdentity": "Node identity", + "system": "System", + "cluster": "Cluster", + "standaloneNote": "This node is not part of a cluster: it holds its own configuration and its guests do not migrate to another node.", + "clusterDiagramNote": "Configured nodes and the corosync links that join them.", + "thisNode": "this node", + "unreachable": "not seen", + "member": "member", + "corosyncLinks": "links", + "quorum": "Quorum", + "quorate": "with quorum", + "inquorate": "without quorum", + "votes": "Votes", + "nodeName": "Node", + "architecture": "System architecture", + "architectureNote": "How the node is assembled: processor and memory on the board, and what hangs off each controller.", + "systemIdentity": "System identity", + "board": "Board", + "processor": "Processor", + "topology": "Sockets × cores / threads", + "memory": "Memory", + "cores": "cores", + "threads": "threads", + "memoryModules": "Memory modules", + "slot": "Slot", + "slotsUsed": "slots used", + "slotsFilled": "{used} of {total} slots populated", + "emptySlot": "empty", + "formFactor": "Format", + "speed": "Speed", + "manufacturer": "Manufacturer", + "product": "Model", + "serial": "Serial", + "controllers": "Controllers", + "class": "Class", + "device": "Device", + "iommuGroups": "IOMMU groups", + "iommuGroup": "IOMMU group", + "field": "Field", + "value": "Value", + "size": "Size", + "type": "Type", + "storageDevices": "Storage devices", + "disks": "Disks", + "model": "Model", + "bus": "Bus", + "serviceLife": "Service life", + "healthy": "healthy", + "years": "{years} years", + "events": "Events", + "observations": "Observations", + "observationsNote": "Recorded events. SMART reports the present state; this log reports what happened, including events the disk recovered from.", + "noObservations": "No events recorded", + "noObservationsNote": "No disk has recorded an error since the Monitor began observing them.", + "event": "Event", + "severity": "Severity", + "occurrences": "Occurrences", + "firstSeen": "First seen", + "lastSeen": "Last seen", + "detail": "Detail", + "network": "Network", + "adapters": "Adapters", + "bond": "Bond", + "bridge": "Bridge", + "bridges": "Bridges", + "physicalAdapters": "Physical adapters", + "interface": "Interface", + "driver": "Driver", + "state": "State", + "networkDiagramNote": "Path from the wire to each guest: physical adapter, bond where one groups them, bridge and attached guests.", + "storageAndProtection": "Storage and protection", + "storage": "Storage", + "content": "Content", + "shared": "Shared", + "location": "Location", + "backupDestination": "Backup destination", + "unprotected": "unprotected", + "storageDiagramNote": "Where guest disks live and which destination backs them up.", + "unprotectedGuests": "{count} guests with no backup job", + "allProtected": "Every guest is covered by a backup job", + "allProtectedNote": "Coverage says a job selects the guest; the backup results are assessed separately.", + "vmid": "VMID", + "name": "Name", + "kind": "Kind", + "backup": "Backup", + "none": "none", + "managedSoftware": "Software managed by ProxMenux", + "version": "Version", + "source": "Source", + "current": "up to date", + "updateAvailable": "update to {version}", + "findings": "Findings in detail", + "incomplete": "partial", + "scope": "Scope of this report", + "scopeText": "This document reports the {profile} profile on the node named in the header, at the moment of the run.", + "scopeLocal": "It covers this node only. Guests on other nodes and their configuration are outside it.", + "scopeReadOnly": "Every check reads configuration and state that already exists; none modifies the host.", + "scopeMoment": "It describes the state at the time of the run, not a period of time.", + "notRead": "Sources that could not be read:", + "uplink": "Uplink", + "conformance": "{pass} of {total} conformant", + "latency": "Network latency", + "latencyNote": "Latency measured over the reported window, one line per target.", + "milliseconds": "ms", + "hours": "h", + "minimum": "Minimum", + "average": "Average", + "maximum": "Maximum", + "packetLoss": "Packet loss", + "samples": "Samples", + "target": { + "label": "Target", + "gateway": "Gateway", + "cloudflare": "Cloudflare (1.1.1.1)", + "google": "Google (8.8.8.8)" + }, + "actionShort": "Report", + "policyDeclared": "It was judged against a declared policy: {guests} guests, {storages} storages and {thresholds} thresholds stated.", + "policyNone": "No policy was declared, so an absence this report could not interpret is stated as an observation and never as a warning.", + "diagnosticTitle": "Quick diagnosis", + "diagnosticSubtitle": "Critical results and warnings on {node}", + "diagnosticActing": "{count} critical result(s) and warning(s).", + "diagnosticClear": "No critical result and no warning. Observations and conformant results are in the full audit.", + "diagnosticUnread": "Readings that could not be taken", + "diagnosticMoreRows": "{count} further row(s), in the full audit.", + "structureTitle": "Structure and configuration", + "structureSubtitle": "How {node} is built and configured" + }, + "results": "Results", + "classifications": { + "critical": "Critical", + "warning": "Warning", + "observation": "Observation", + "conformant": "Conformant", + "unverified": "Unverified", + "not_applicable": "Not applicable", + "accepted": "Accepted risk", + "by_design": "Excluded by policy" + }, + "policy": { + "inherit": "{value} (default)", + "inheritUnset": "Default", + "conflict": "The declaration changed in another session. Your draft has not been saved.", + "reload": "Reload saved declaration (discard draft)", + "intro": "An assessment sees what this host does, not what it is for. What is declared here is what turns an observation into a warning, or takes it out of the count. Nothing is required: with no declaration the report describes rather than judges.", + "loading": "Reading the declaration…", + "failed": "The declaration could not be read", + "saved": "Saved", + "declaredCount": "{count} declarations", + "guestsNote": "Required reports what is missing as a warning; unspecified reports it as an observation; not required leaves it out of the count.", + "storagesNote": "An unreachable storage is critical where it is declared essential or serves a running guest, a warning where its role is not declared, and an observation where it is declared optional.", + "thresholds": "Thresholds", + "thresholdsNote": "Empty means the shipped value, shown as the placeholder.", + "backup": "Backup", + "autostart": "Autostart", + "objective": "Recovery objective", + "objectivePlaceholder": "hours", + "noGuests": "This node holds no guests.", + "expectation": { + "required": "Required", + "not_required": "Not required", + "unspecified": "Not stated" + }, + "role": { + "essential": "Essential", + "optional": "Optional", + "unspecified": "Not stated" + }, + "threshold": { + "storage_usage_percent": "Storage capacity review (%)", + "thin_pool_usage_percent": "Thin pool fill review (%)", + "thin_overprovision_ratio": "Thin overprovisioning ratio", + "zfs_scrub_days": "ZFS scrub interval (days)", + "backup_fallback_days": "Backup age fallback (days)", + "backup_schedule_grace_ratio": "Schedule grace (ratio)", + "certificate_expiry_days": "Certificate expiry notice (days)", + "memory_overcommit_ratio": "Memory overcommit ratio", + "disk_service_life_hours": "Disk service life (hours)", + "lynis_report_days": "Lynis report age (days)", + "package_index_days": "Package index age (days)", + "journal_usage_percent": "Journal against its cap (%)", + "filesystem_usage_percent": "Filesystem space review (%)", + "filesystem_inode_percent": "Filesystem inode review (%)", + "disk_error_recent_days": "Recent disk error window (days)" + } + }, + "changes": { + "loading": "Reading the change journal…", + "failed": "The change journal could not be read", + "intro": "What ProxMenux changed on this host and what was there before each change. The difference is shown, not the script that applied it.", + "empty": "Nothing has been recorded on this host yet.", + "since": "Recording since {date}. Anything applied before that appears as applied, without the state it replaced.", + "byFunction": "By function", + "count": "{count} changes", + "function": "Function", + "source": "Script", + "reversibility": "Undoing this", + "difference": "Difference", + "diffTruncated": "The difference is longer than what is shown.", + "diffUnavailable": "The content that was replaced is no longer stored, so the difference cannot be shown.", + "packagesAdded": "Packages added", + "commandRun": "Command run", + "executionNote": "ProxMenux ran this on request; what it changed was decided by the command, not by ProxMenux.", + "unknownNote": "This was applied before the journal existed, so what it replaced was never captured.", + "noneInFilter": "No change of this kind.", + "class": { + "all": "All", + "configuration": "Configuration", + "installation": "Installations", + "execution": "Executions", + "registration": "Applied" + }, + "operation": { + "write_file": "File replaced", + "edit_file": "File edited", + "remove_file": "File removed", + "install_package": "Installed", + "enable_service": "Service enabled", + "disable_service": "Service disabled", + "run_command": "Ran", + "applied": "Applied", + "removed": "Removed", + "unknown": "Change" + }, + "capture": { + "unknown": "Previous state unknown" + }, + "exactness": { + "exact": "Restores exactly what was there", + "partial": "Partial: dependencies may remain or be removed with it", + "none": "Cannot be undone from the journal" + } + }, + "comparison": { + "loading": "Comparing with the reference run…", + "failed": "The reference run could not be set", + "since": "Since {date}", + "previousRun": "the previous run", + "noChange": "No change", + "isBaseline": "This run is the reference the others are compared against.", + "noBaseline": "No reference run has been chosen yet, so there is nothing to compare against.", + "setBaseline": "Use as reference", + "unchanged": "{count} checks reported the same result as before.", + "new": "New", + "newNote": "reported now and not before", + "resolved": "Resolved", + "resolvedNote": "no longer reported, and nobody accepted them", + "accepted": "Accepted", + "acceptedNote": "no longer counted because a risk was accepted, not because the host changed", + "retired": "No longer assessed", + "retiredNote": "present before and absent from this run; nothing verified they stopped", + "reasons": { + "insufficient_runs": "A comparison needs a reference run and a later one; only one is recorded so far" + } + }, + "notApplicableScope": "Nothing in the inspected scope this check applies to." } } diff --git a/AppImage/messages/es/common.json b/AppImage/messages/es/common.json index 6fc37c1e..2ef1fe18 100644 --- a/AppImage/messages/es/common.json +++ b/AppImage/messages/es/common.json @@ -1103,7 +1103,7 @@ "backupStartFailed": "No se pudo iniciar la copia de seguridad: {message}", "controlFailed": "Error en {action} VM {vmid}: {message}", "saveNotesFailed": "Error al guardar notas. Por favor inténtalo de nuevo.", - "appNotFound": "Esta aplicación ya no está disponible.Actualiza la página y vuelve a intentarlo.", + "appNotFound": "Esta aplicación ya no está disponible. Actualiza la página y vuelve a intentarlo.", "saveCustomCommandFailed": "No se pudo guardar el comando de actualización personalizado: {message}", "removeCustomCommandConfirm": "¿Eliminar el comando de actualización personalizado para \"{name}\"?", "removeCustomCommandFailed": "No se pudo eliminar el comando de actualización personalizado: {message}", @@ -1220,7 +1220,15 @@ "humanWeekly": "Semanal ({day} {time})", "humanMonthly": "Mensual (día {day} a las {time})", "humanHourly": "cada hora", - "weekdays": "['domingo', 'lunes', 'martes', 'miércoles', 'jueves', 'viernes', 'sábado']" + "weekdays": [ + "domingo", + "lunes", + "martes", + "miércoles", + "jueves", + "viernes", + "sábado" + ] }, "cronChip": { "detected": "cron del host detectado", @@ -1263,7 +1271,7 @@ "helperUnavailableChoice": "El actualizador de Helper-Scripts seleccionado ya no está disponible.", "disableUpdater": "Desactivar", "disableUpdaterConfirm": "¿Desactivar el método de actualización de {name}? Los planes y las programaciones conservarán sus selecciones, pero esta aplicación no se ejecutará hasta que vuelvas a configurar un método.", - "noUpdaterSelected": "No has seleccionado un método de actualización.", + "noUpdaterSelected": "No se ha configurado un método de actualización.", "helperMethodDescription": "ProxMenux ejecuta el actualizador detectado dentro de este LXC y muestra su registro y resultado. Este lo mantiene Proxmox VE Helper-Scripts.", "customMethodDescription": "Puedes utilizar el actualizador de la aplicación, un gestor de paquetes o un binario nuevo. El comando se ejecuta dentro de este LXC con privilegios de administrador.", "updaterInstructions": "Revisa las indicaciones del actualizador.", @@ -1373,8 +1381,7 @@ "postApplyAllOk": "{count} paquete(s) aplicados correctamente — nada pendiente.", "postApplyNothingPending": "Nada pendiente — todo actualizado.", "postApplyPartial": "{pending} paquete(s) siguen pendientes tras la ejecución.", - "postApplyPartialSubline": "{applied} aplicados. Algunas actualizaciones no finalizaron — revisa la salida del terminal.", - "updatedWithDockerImage": "actualizado con su imagen Docker." + "postApplyPartialSubline": "{applied} aplicados. Algunas actualizaciones no finalizaron — revisa la salida del terminal." }, "bulkUpdate": { "title": "Actualización en bloque", @@ -1593,7 +1600,7 @@ "restoreButton": "Restaurar", "registerButton": "Registrar", "removeButton": "Eliminar", - "checkButton": "Controlar", + "checkButton": "Comprobar", "editFieldsButton": "Editar campos", "alsoDetectedContainer": "También detectado en este contenedor.", "addAnotherApplication": "Registrar otra aplicación", @@ -1604,12 +1611,7 @@ "notifyUpstreamLabel": "Notificarme cuando haya una nueva versión disponible", "notifyUpstreamHelp": "Envía `app_update_available` a los canales activos en Ajustes → Notificaciones. Desactívalo si esta app no se puede actualizar en tu instalación.", "excludeFromBadgeLabel": "Excluir del contador de actualizaciones del LXC", - "excludeFromBadgeHelp": "No sumar esta app al contador agregado de actualizaciones del card del LXC. Útil cuando mantienes una versión concreta a propósito (requisito de tracker, compatibilidad). No afecta al estado que se muestra en la pestaña App ni al envío de la notificación.", - "dockerDetectedWithWorkloads": "Docker detectado con {count} aplicaciones en contenedores", - "dockerWorkloadsHeading": "Ejecutando dentro de Docker", - "runsInsideDocker": "actualizado con su imagen Docker", - "upstreamDelegatedTitle": "La versión disponible proviene de su imagen Docker", - "upstreamDelegatedHelp": "esta aplicación se ejecuta en un contenedor, por lo que la versión disponible es cualquiera que resuelva su imagen: no hay verificación ascendente por separado y se informa una actualización una vez.Actualízalo desde su imagen en la pestaña Actualizaciones." + "excludeFromBadgeHelp": "No sumar esta app al contador agregado de actualizaciones del card del LXC. Útil cuando mantienes una versión concreta a propósito (requisito de tracker, compatibilidad). No afecta al estado que se muestra en la pestaña App ni al envío de la notificación." }, "statusFilter": { "ariaLabel": "Filtrar máquinas virtuales y contenedores", @@ -1889,6 +1891,7 @@ "system_reboot": "Reinicio del sistema", "system_restore_completed": "Restauración del host completada", "system_problem": "Problema del sistema detectado", + "kernel_warning": "Advertencias y trazas de diagnóstico del kernel", "service_fail": "Servicio fallido", "oom_kill": "Eliminación del proceso por falta de memoria", "service_fail_batch": "Múltiples fallas de servicio", @@ -4915,6 +4918,154 @@ "customLinkDeleteError": "Error al eliminar" }, "audit": { + "presentation": { + "backupAge": "Antigüedad de la copia", + "backupLimit": "Límite utilizado", + "limitDeclared": "Plazo declarado por el usuario", + "limitSchedule": "Programación + margen", + "limitReference": "Plazo de referencia", + "verifiedChecks": "Comprobaciones verificadas", + "unverifiedChecks": "Comprobaciones sin verificar", + "checkName": "Comprobación", + "verified": "Verificadas", + "verificationScope": "Comprobaciones aplicables verificadas. La cobertura no expresa la salud ni la seguridad del servidor.", + "noApplicable": "No hay comprobaciones aplicables en esta ejecución.", + "noJob": "Sin trabajo programado", + "guest": "Invitado", + "guests": "invitados", + "host": "Host", + "resource": "Recurso", + "data": "Datos", + "metadata": "Metadatos", + "result": "Resultado", + "fact": "Dato observado", + "records": "registros", + "unscheduled": "invitados sin trabajo programado", + "excludedDisks": "discos excluidos", + "disks": "Discos", + "destination": "Destino", + "lastCopy": "Última copia almacenada", + "ageLimit": "Antigüedad / límite", + "noDestination": "Sin destino configurado", + "notFound": "Sin copia encontrada en el alcance examinado", + "promiscuous": "Interfaces en modo promiscuo", + "noDescription": "Descripción no disponible", + "occurrence": "Ocurrencia", + "occurrences": "ocurrencias", + "detail": "Detalle", + "technical": "Evidencia técnica", + "annex": "Anexo técnico", + "overview": "Resumen de hallazgos", + "incomplete": "Evaluación incompleta", + "assessment": "Evaluación", + "noGlobalScore": "Los resultados describen criterios independientes; no se calcula una puntuación global de seguridad.", + "coverage": "Cobertura de backups programados", + "scheduled": "Con trabajo programado", + "copyScope": "Un trabajo configurado no demuestra que exista una copia almacenada o restaurable.", + "detailsLink": "Referencia de evidencia", + "noSubscription": "No consta suscripción", + "otherDevices": "Otros dispositivos en el grupo", + "unversioned": "Versión no registrada", + "notInstalled": "No instalada", + "noPendingRecorded": "No consta actualización pendiente", + "originalEvidence": "Evidencia original de la fuente", + "evidenceObserved": "Evidencia observada", + "evidenceExcerpt": "Vista compacta. La evidencia completa de la fuente permanece guardada con esta evaluación.", + "annexScope": "Evidencia completa de la fuente para los resultados que requieren atención, registran una observación o no pudieron verificarse.", + "readOnlyScope": "La evaluación no cambia la configuración. Las consultas y, cuando es necesario, Lynis pueden generar registros o informes.", + "capacity": "Capacidad", + "used": "Ocupado", + "free": "Libre", + "reasons": { + "agentNotDeclared": "Sin agente invitado declarado en la configuración", + "arcConflictingSettings": "Los ajustes persistentes no coinciden entre sí", + "arcMinAboveMax": "El límite inferior del ARC está por encima del superior", + "arcPendingReboot": "El ajuste persistente difiere del parámetro cargado", + "arrayDegraded": "Funciona con menos dispositivos de los que se construyó", + "arrayNotActive": "No está activo", + "arrayRebuilding": "Corto de dispositivos y reconstruyéndose", + "backupRunFailed": "La ejecución terminó con error", + "backupRunRecovered": "Falló antes, y una ejecución posterior terminó bien", + "bondNoMembersUp": "Caído, y ningún miembro del bond está activo", + "bondRedundancyLost": "Caído; el bond conserva otros enlaces", + "bootEspMissingNewest": "No lleva el kernel más nuevo que sí llevan las otras", + "bootEspOutOfSync": "Desincronizada con las demás: arrancaría un kernel distinto", + "bootSingleEsp": "Una partición de arranque configurada", + "bootToolReported": "Comunicado por proxmox-boot-tool", + "cephCheckRaised": "Levantada por Ceph", + "channelIncomplete": "Habilitado pero le falta parte de su configuración", + "clusterInquorate": "Sin quórum: se rechazan los cambios en el clúster", + "clusterMemberAbsent": "Nodo configurado que el clúster no ve", + "clusterSingleLink": "Un solo enlace de corosync declarado", + "dataExcludedFromBackup": "Datos excluidos del backup del invitado", + "deliveryFailing": "Las entregas recientes no salieron", + "destinationUnavailable": "Destino configurado no disponible", + "diskErrorsActive": "Error registrado dentro del periodo revisado", + "diskErrorsPast": "Comunicó errores antes, ninguno en la ventana en uso", + "diskWarningsActive": "Aviso del dispositivo registrado dentro del periodo revisado", + "diskWarningsPast": "Comunicó avisos del dispositivo antes, ninguno en la ventana en uso", + "essentialServiceDown": "Un servicio que Proxmox necesita para responder no está activo", + "exemptByPolicy": "Marcado como no obligatorio, así que queda fuera del cómputo", + "expectedButUncovered": "Backup marcado como obligatorio, y ningún trabajo activo lo selecciona", + "expectedToAutostart": "Arranque con el host marcado como obligatorio, y no arranca", + "filesystemExhausted": "Sin espacio disponible", + "filesystemNearlyFull": "En el umbral de revisión de espacio o por encima", + "filesystemReadOnly": "El kernel informa de este montaje como solo lectura: ha dejado de aceptar escrituras", + "haManagerNotReady": "Ni activo ni inactivo: no puede hacerse cargo de un servicio", + "haNoMaster": "Sin gestor: nada decide dónde debe ejecutarse un servicio", + "haServiceError": "En estado de error y ya no gestionado", + "haServiceTransitioning": "Entre estados", + "hostArchiveMissing": "El registro del trabajo nombra un archivo que ya no está almacenado", + "hostBackupJobFailed": "El trabajo terminó con error", + "hostBackupStale": "Más antiguo que el límite de antigüedad en uso", + "hostBackupUnscheduled": "Almacenado, sin programación que genere otro", + "hostNoRetrievableCopy": "Ninguna copia de la que esta comprobación pueda dar cuenta", + "indexesStale": "Los índices de paquetes no se han actualizado recientemente", + "inodesExhausted": "Sin inodos disponibles", + "inodesNearlyExhausted": "En el umbral de revisión de inodos o por encima", + "kernelAwaitingReboot": "Instalado y no es el kernel en ejecución", + "lynisReportStale": "El informe de Lynis tiene {days} día(s), más que la antigüedad de referencia en uso", + "lynisWarning": "Registrado por la auditoría de Lynis", + "multipathNoPath": "Sin ningún camino", + "multipathPathDown": "Sirviendo por menos caminos", + "noAutostart": "No arranca con el host", + "noConfigurationReference": "Sin referencia en las configuraciones examinadas", + "noJobSelectsGuest": "Ningún trabajo de backup activo lo selecciona", + "noPhysicalPort": "No lleva ningún puerto físico", + "noStoredBackup": "No se encontró una copia almacenada", + "noStoredBackupUnscheduled": "Sin trabajo programado; no se encontró una copia", + "olderThanFallback": "La copia supera el plazo de referencia.", + "olderThanObjective": "La copia supera el plazo declarado por el usuario.", + "olderThanSchedule": "La copia supera el intervalo programado más su margen.", + "overprovisioned": "Reparte más capacidad virtual de la que tiene el pool", + "packageAwaitingRestart": "Instalado y solicitando un reinicio", + "pastServiceLife": "Por encima del umbral de vida útil usado para planificar", + "pinnedToHostCpu": "Fijado al modelo de procesador del host", + "poolDeviceErrors": "Dispositivo con errores de lectura, escritura o suma de verificación", + "poolNotOnline": "No está en línea", + "rebootMarkerWithoutPackages": "Algo escribió el marcador de reinicio sin nombrar ningún paquete", + "recoveryKeyLocalOnly": "Clave de cifrado del backup guardada solo en este nodo, según el modo de custodia registrado", + "replicationDisabled": "Pausado", + "replicationFailing": "La última ejecución informó de un error", + "replicationNeverRan": "Nunca ha completado una sincronización", + "replicationOverdue": "Más antigua de lo que permite el calendario del propio trabajo", + "retentionNotDeclared": "Sin retención declarada; se conservan todas las copias", + "retentionOnServer": "Se poda en el servidor de backup, con trabajos que este nodo no puede leer", + "runsPrivileged": "Se ejecuta con privilegios, compartiendo el espacio de nombres de usuario del host", + "scrubOverdue": "Último scrub completado más antiguo que el umbral de revisión", + "storageNearlyFull": "En el umbral de revisión de capacidad o por encima", + "storageUnreachable": "No accesible", + "thinDataPressure": "Datos escritos cerca de la capacidad del pool", + "thinMetadataPressure": "Metadatos casi llenos, lo que deja el pool en solo lectura", + "unitFailed": "systemd dejó de reintentarla", + "verificationFailedOnly": "La verificación leyó la copia más reciente y no resultó íntegra; ninguna otra copia de este invitado se ha verificado", + "verificationFailedWithFallback": "La verificación leyó la copia más reciente y no resultó íntegra; una copia anterior sí se verificó", + "verificationNotRun": "Ningún trabajo de verificación ha leído esta copia" + }, + "lynisTest": "Test", + "lynisWarning": "Aviso", + "couldNotRead": "No se pudo leer" + }, "title": "Auditoría e informes", "loading": "Cargando evaluación…", "run": "Ejecutar evaluación", @@ -4923,9 +5074,8 @@ "lastRun": "Última evaluación el {when}", "stale": "hace {days} días", "readOnlyNotice": "La evaluación solo lee el host. No realiza ningún cambio.", + "unverifiedChecks": "No se pudieron tomar: {checks}. Cada una dice en su evidencia qué no pudo leer.", "noFindings": "Ningún hallazgo coincide con el filtro actual.", - "showPassing": "Mostrar comprobaciones correctas", - "hidePassing": "Ocultar comprobaciones correctas", "affectedCount": "{count} afectados", "acceptedNotice": "{count} riesgo(s) aceptado(s) registrados en este host.", "states": { @@ -4933,7 +5083,8 @@ "warn": "Aviso", "accepted": "Riesgo aceptado", "pass": "Correcto", - "not_applicable": "No aplicable" + "not_applicable": "No aplicable", + "unknown": "Sin verificar" }, "areas": { "all": "Todas", @@ -4949,7 +5100,8 @@ "why": "Contexto", "evidence": "Evidencia", "affected": "Afectados", - "acceptedRisk": "Riesgo aceptado" + "acceptedRisk": "Riesgo aceptado", + "sources": "Fuentes y fechas de recopilación" }, "errors": { "runFailed": "No se pudo iniciar la evaluación." @@ -4958,69 +5110,434 @@ "backup": { "guest_coverage": { "title": "Cobertura de backups", - "rationale": "Compara el inventario de invitados con la selección de cada trabajo de backup. Un trabajo selecciona por lista de VMID, por pool o con `all 1`, restando su lista `exclude`. Los trabajos con `enabled 0` no se consideran.", + "rationale": "Trabajos de backup habilitados en este nodo, los invitados que selecciona cada uno y los datos del invitado excluidos de ellos. La cobertura configurada no demuestra que exista una copia utilizable. Si un invitado no seleccionado debía protegerse lo indica la política declarada.", "summary": { - "noJobs": "No hay ningún trabajo de backup definido en este nodo", - "covered": "Los {total} invitados están cubiertos por un trabajo de backup", - "uncovered": "{count} de {total} invitados no están cubiertos por ningún trabajo de backup activo" + "noJobs": "No hay ningún trabajo de backup definido en este nodo para los {total} invitados que alberga", + "covered": "Los {total} invitados están seleccionados por un trabajo de backup activo", + "uncovered": "{count} de {total} invitados no están seleccionados por ningún trabajo de backup activo", + "excludedData": "Hay {count} exclusiones de discos o montajes que revisar", + "uncoveredExpected": "{required} invitados con backup obligatorio no están seleccionados por ningún trabajo activo", + "evaluationFailed": "No se pudo evaluar la comprobación" + } + }, + "last_backup_age": { + "title": "Antigüedad de las copias almacenadas", + "rationale": "Antigüedad: tiempo transcurrido desde la última copia almacenada. Límite utilizado: antigüedad de referencia con la que se compara esa copia.", + "summary": { + "recent": "Las {total} comprobaciones de máquina/destino cumplen el criterio de antigüedad indicado", + "stale": "{count} de {total} invitados no tienen ninguna copia de los últimos 30 días", + "noBackups": "Ninguna copia almacenada corresponde a un invitado de este nodo", + "attention": "{count} de {total} comprobaciones de máquina/destino requieren revisión", + "evaluationFailed": "No se pudo evaluar la comprobación" + } + }, + "retention_defined": { + "title": "Retención de backups", + "rationale": "La retención tal como la resuelve Proxmox: el ajuste del trabajo, después el del almacenamiento y después el valor por defecto del nodo. La retención que aplica un servidor de backup no se puede leer desde este nodo.", + "summary": { + "allDefined": "Los {total} trabajos resuelven un ajuste de retención", + "missing": "{count} de {total} trabajo(s) habilitados no declaran retención", + "notDeclared": "{count} de {total} trabajos conservan todas las copias: no tienen retención declarada", + "onServer": "{count} de {total} trabajos escriben en un servidor de backup, que las poda con sus propios trabajos", + "evaluationFailed": "No se pudo evaluar la comprobación" + } + }, + "verification_state": { + "title": "Verificación de las copias", + "rationale": "El resultado de verificación que Proxmox Backup Server registra para la copia más reciente de cada invitado, y si una copia anterior del mismo invitado se verificó. La verificación lee una copia almacenada; no es una restauración.", + "summary": { + "allVerified": "La copia más reciente de los {total} invitados se ha verificado íntegra", + "failed": "{failed} copias recientes no superan la verificación, de {total} examinadas", + "notVerified": "{pending} de {total} copias recientes no se han verificado", + "evaluationFailed": "No se ha podido leer el estado de verificación" + } + }, + "job_results": { + "title": "Resultado de las ejecuciones de backup", + "rationale": "Cómo terminó la ejecución más reciente de cada invitado, según el registro de tareas del nodo. Solo se gradúa la última. El registro se conserva un tiempo limitado.", + "summary": { + "allSucceeded": "Las {total} ejecuciones de backup registradas terminaron sin error", + "someFailed": "{count} de {total} ejecuciones de backup registradas terminaron con error", + "evaluationFailed": "No se ha podido leer el registro de tareas", + "recovered": "{count} de {total} invitados fallaron en una ejecución anterior y han terminado bien desde entonces" + } + }, + "host_recovery": { + "title": "Recuperación del host", + "rationale": "Backups del host tal como los registra ProxMenux: cada trabajo que ejecutó, cuándo, si terminó bien, el destino donde escribió y si esa copia sigue ahí. Un trabajo que escribe en un servidor de backup no nombra ninguna ruta local. Las claves de cifrado se exponen solo por recuento y modo de custodia registrado.", + "summary": { + "noHostBackup": "No hay ningún backup de la configuración del host almacenado ni temporizador que lo genere", + "protected": "La configuración del propio nodo está guardada en {total} archivo(s), dentro del límite de antigüedad en uso", + "attention": "{count} hallazgo(s) sobre {total} registro(s) de configuración del host", + "scheduledOnly": "Los backups de la configuración del host están programados en {count} temporizador(es); no hay ningún archivo almacenado localmente" } } }, "system": { "pending_reboot": { "title": "Estado de reinicio", - "rationale": "Comprueba la presencia de `/var/run/reboot-required` y, cuando existe, los paquetes listados en `/var/run/reboot-required.pkgs`. Informa también del kernel en ejecución.", + "rationale": "El marcador `/var/run/reboot-required` y los paquetes listados en `/var/run/reboot-required.pkgs`. Su ausencia no demuestra que no haya nada pendiente de reiniciar.", "summary": { - "none": "No hay ningún reinicio pendiente", - "pending": "El host tiene un reinicio pendiente" + "none": "Nada ha solicitado un reinicio", + "pending": "{count} elementos están instalados y esperan un reinicio", + "evaluationFailed": "No se pudo evaluar la comprobación" } }, "enterprise_repo_without_subscription": { "title": "Repositorio enterprise", - "rationale": "Busca referencias a `enterprise.proxmox.com` en `/etc/apt/sources.list` y en `sources.list.d`, y contrasta el resultado con el estado que devuelve `pvesubscription get`.", + "rationale": "Referencias a `enterprise.proxmox.com` en `/etc/apt/sources.list` y `sources.list.d`, frente al estado que devuelve `pvesubscription get`.", "summary": { "notEnabled": "El repositorio enterprise no está habilitado", "subscribed": "El repositorio enterprise cuenta con suscripción", - "unsubscribed": "El repositorio enterprise está habilitado sin suscripción activa" + "unsubscribed": "El repositorio enterprise está habilitado sin suscripción activa", + "evaluationFailed": "No se pudo evaluar la comprobación" + } + }, + "memory_overcommit": { + "title": "Asignación de memoria", + "rationale": "El techo de `memory` de cada configuración de invitado frente a MemTotal, contando aparte los invitados en ejecución. Los contenedores consumen hasta ese límite; las máquinas virtuales sin ballooning lo reservan.", + "summary": { + "withinRatio": "Los invitados tienen asignado el {percent}% de la memoria del host", + "aboveRatio": "Los invitados tienen asignado el {percent}% de la memoria del host", + "evaluationFailed": "No se pudo evaluar la comprobación" + } + }, + "time_synchronisation": { + "title": "Sincronización horaria", + "rationale": "NTP y NTPSynchronized tal como los informa `timedatectl`. La pertenencia al clúster, la validación de certificados y el orden de los registros dependen de relojes coincidentes. Otro mecanismo puede estar disciplinando el reloj.", + "summary": { + "synchronised": "El reloj está sincronizado con una fuente horaria", + "disabled": "La sincronización horaria está deshabilitada", + "notSynchronised": "La sincronización horaria está habilitada pero el reloj no está sincronizado", + "evaluationFailed": "No se pudo evaluar la comprobación" + } + }, + "kernel_current": { + "title": "Kernel en ejecución", + "rationale": "El kernel en ejecución frente al que el host arrancaría a continuación, según lo informa `proxmox-boot-tool`. Un kernel más nuevo simplemente instalado puede estar retenido a propósito; que difieran después de reiniciar indica un arranque que no tomó efecto.", + "summary": { + "current": "El kernel en ejecución {version} es el que el host arrancaría a continuación", + "newerAvailable": "El host ejecuta {running} mientras que {newest} está instalado", + "newerSelected": "El host ejecuta {running} y arrancaría {selected} en el próximo reinicio", + "wouldDowngrade": "El host ejecuta {running} pero arrancaría el más antiguo {selected} en el próximo reinicio", + "bootTargetUnknown": "El host ejecuta {version}; no se ha podido leer el kernel seleccionado para el próximo arranque", + "evaluationFailed": "No se pudo evaluar la comprobación" + } + }, + "security_updates": { + "title": "Actualizaciones de seguridad", + "rationale": "Paquetes pendientes cuyo origen es un repositorio de seguridad, a partir de un `apt-get upgrade` simulado. La cifra refleja lo que informa apt, no la gravedad de lo que corrige cada paquete.", + "summary": { + "none": "No hay actualizaciones de paquetes pendientes", + "noSecurity": "{total} actualización(es) pendientes, ninguna de un repositorio de seguridad", + "pending": "{count} de {total} actualización(es) pendientes provienen de un repositorio de seguridad", + "evaluationFailed": "No se pudo evaluar la comprobación" + } + }, + "journal_size": { + "title": "Tamaño del journal", + "rationale": "El journal en disco frente al límite que le aplica: SystemMaxUse cuando está definido y, si no, el valor por defecto de journald, la décima parte del sistema de archivos donde reside.", + "summary": { + "bounded": "El journal ocupa {size}, dentro de su límite efectivo", + "large": "El journal ocupa {size} en disco", + "nearCap": "El journal ocupa {size} y está al {percent}% de su límite efectivo", + "capUnknown": "El journal ocupa {size}; no se ha podido determinar su límite efectivo", + "evaluationFailed": "No se pudo evaluar la comprobación" + } + }, + "swap_configured": { + "title": "Swap", + "rationale": "Áreas de swap activas según `swapon` y su total frente a la memoria del host. Ninguna norma exige una proporción respecto a la RAM; la presión de memoria se mide en otro sitio.", + "summary": { + "active": "Hay {size} de swap activo", + "none": "No hay ningún área de swap activa", + "evaluationFailed": "No se pudo evaluar la comprobación" + } + }, + "filesystem_capacity": { + "title": "Capacidad de los sistemas de archivos del host", + "rationale": "Espacio e inodos de los sistemas de archivos que necesita el propio host: la raíz, /var, /var/log y la ruta del almacenamiento local. Un sistema de archivos con espacio libre y sin inodos falla igual que uno lleno.", + "summary": { + "withinLimits": "Los {total} sistemas de archivos del host están dentro de sus umbrales de revisión", + "pressure": "{count} lecturas están en el umbral de revisión o por encima", + "evaluationFailed": "No se ha podido leer la ocupación de los sistemas de archivos" + } + }, + "update_chain": { + "title": "Antigüedad de los índices APT", + "rationale": "Cuándo colocó APT por última vez un índice de paquetes en este host. Un repositorio que responde «sin cambios» deja su índice intacto. No se comprueba la conexión a los repositorios.", + "summary": { + "current": "Los índices de paquetes se actualizaron hace {days} día(s)", + "stale": "Los índices de paquetes se actualizaron por última vez hace {days} día(s)", + "indexAgeUnknown": "No se ha podido determinar la antigüedad de los índices de paquetes" + } + }, + "notification_delivery": { + "title": "Último resultado de notificación", + "rationale": "Canales habilitados y resultado de su última entrega registrada. Sin historial, la entrega queda sin verificar; un fallo anterior seguido de un éxito no se considera un fallo actual. No se envían notificaciones de prueba.", + "summary": { + "delivering": "La última entrega registrada fue correcta en los {total} canales habilitados", + "failing": "{count} de {total} canales habilitados presentan un problema de configuración o un fallo en su última entrega", + "noChannels": "No hay ningún canal de notificación habilitado", + "evaluationFailed": "No se ha podido leer el historial de entregas" + } + }, + "cluster_quorum": { + "title": "Quórum del clúster", + "rationale": "El quórum tal como lo informa el clúster, los nodos configurados frente a los que se ven en este momento y el número de enlaces de corosync declarados. Los enlaces se leen de la configuración, no se sondean.", + "summary": { + "standalone": "Este nodo no pertenece a ningún clúster", + "quorate": "El clúster tiene quórum con {total} nodo(s) configurado(s) sobre {links} enlace(s) de corosync", + "attention": "{count} hallazgo(s) sobre {total} nodo(s) configurado(s)", + "evaluationFailed": "No se pudo leer el estado del clúster" + } + }, + "boot_loader": { + "title": "Cargador de arranque", + "rationale": "Las particiones EFI que informa proxmox-boot-tool y los kernels que lleva cada una. No se monta ninguna partición ni se intenta ningún arranque.", + "summary": { + "synchronised": "Las {total} particiones de arranque llevan los mismos kernels", + "attention": "{count} de {total} particiones de arranque requieren revisión", + "evaluationFailed": "No se pudo evaluar la comprobación" + } + }, + "failed_units": { + "title": "Servicios esenciales y unidades fallidas", + "rationale": "Unidades que systemd ha dado por perdidas tras agotar sus reintentos, y los servicios que Proxmox necesita para responder, leídos por nombre porque un servicio inactivo no siempre consta como fallido. Aquí no se interpreta qué hace cada unidad.", + "summary": { + "allRunning": "Los {total} servicios esenciales están activos y ninguna unidad ha fallado", + "attention": "{count} hallazgo(s) entre las unidades", + "evaluationFailed": "No se pudo evaluar la comprobación" + } + }, + "ha_state": { + "title": "Alta disponibilidad", + "rationale": "El maestro de HA, el gestor de recursos de cada nodo y el estado de cada servicio gestionado, según `ha-manager status`. Del quórum informa la comprobación del clúster. No se arranca, para ni migra ningún servicio.", + "summary": { + "managed": "Los {total} servicios gestionados están en un estado asentado en {nodes} nodo(s)", + "attention": "{count} hallazgo(s) sobre {total} servicio(s) gestionado(s)", + "evaluationFailed": "No se pudo evaluar la comprobación" } } }, "guests": { "privileged_containers": { "title": "Privilegios de los contenedores", - "rationale": "Revisa la configuración de cada contenedor. Proxmox marca los contenedores sin privilegios con `unprivileged: 1`; su ausencia indica un contenedor privilegiado, que comparte el espacio de nombres de usuario del host.", + "rationale": "El ajuste `unprivileged` de cada configuración de contenedor. Su ausencia significa que el contenedor comparte el espacio de nombres de usuario del host, algo que ciertas cargas necesitan.", "summary": { "allUnprivileged": "Los {total} contenedores son sin privilegios", - "privileged": "{count} de {total} contenedores se ejecutan con privilegios" + "privileged": "{count} de {total} contenedores se ejecutan con privilegios", + "evaluationFailed": "No se pudo evaluar la comprobación" } }, "qemu_without_agent": { "title": "Agente invitado en máquinas virtuales", - "rationale": "Revisa la configuración de cada máquina virtual en busca de `agent: 1`. El agente invitado habilita el apagado ordenado, la congelación del sistema de archivos para instantáneas y el informe de uso real de disco.", + "rationale": "El ajuste `agent` en la configuración de cada máquina virtual. El ajuste indica que el agente está declarado, no que responda.", "summary": { "allHaveAgent": "Las {total} máquinas virtuales declaran el agente invitado", - "missingAgent": "{count} de {total} máquinas virtuales no declaran el agente invitado" + "missingAgent": "{count} de {total} máquinas virtuales no declaran el agente invitado", + "evaluationFailed": "No se pudo evaluar la comprobación" + } + }, + "autostart": { + "title": "Arranque automático", + "rationale": "El ajuste `onboot` de cada invitado, excluidas las plantillas y los invitados gestionados por HA. Si se espera que un invitado vuelva por sí solo lo indica la política declarada.", + "summary": { + "allAutostart": "Los {total} invitados arrancan con el host", + "notAutostart": "{count} de {total} invitados no arrancan con el host", + "evaluationFailed": "No se pudo evaluar la comprobación" + } + }, + "stuck_snapshots": { + "title": "Estado de las instantáneas", + "rationale": "Estado y antigüedad de los snapshots y tareas activas. Una operación reciente o sin fecha verificable no se considera interrumpida.", + "summary": { + "noSnapshots": "Ningún invitado tiene instantáneas", + "allComplete": "Las {total} instantánea(s) están completas", + "stuck": "{count} de {total} instantánea(s) quedaron a medias", + "evaluationFailed": "No se pudo evaluar la comprobación" + } + }, + "cpu_host_type": { + "title": "Modelo de CPU virtual", + "rationale": "El valor `cpu` de cada máquina virtual. `host` expone el conjunto de instrucciones del procesador físico, lo que limita los nodos a los que el invitado puede migrar. La incompatibilidad con un destino concreto no se determina aquí.", + "summary": { + "none": "Ninguna de las {total} máquinas virtuales está fijada al procesador del host", + "pinned": "{count} de {total} máquinas virtuales están fijadas al procesador del host", + "evaluationFailed": "No se pudo evaluar la comprobación" + } + }, + "replication_state": { + "title": "Replicación", + "rationale": "Trabajos de replicación desde la API: número de fallos, último error, última sincronización y el calendario que declara cada trabajo. Los trabajos pausados se indican como tales.", + "summary": { + "healthy": "Los {total} trabajo(s) de replicación no reportan error", + "failing": "{count} de {total} trabajo(s) de replicación reportan error", + "statusUnavailable": "Hay trabajos de replicación definidos pero no se pudo leer su estado", + "evaluationFailed": "No se pudo evaluar la comprobación" } } }, "security": { "host_firewall_enabled": { - "title": "Estado del firewall", - "rationale": "Comprueba `enable: 1` en `/etc/pve/firewall/cluster.fw` y en el `host.fw` del nodo. Proxmox aplica las reglas del nodo solo mientras el interruptor del centro de datos está activo.", + "title": "Activación configurada del firewall", + "rationale": "La opción `enable` en el firewall del centro de datos y en el del propio nodo, y cuántas reglas hay escritas. Proxmox aplica las reglas del nodo solo mientras el interruptor del centro de datos está activado. Estas opciones no indican qué filtra cada regla.", "summary": { - "bothEnabled": "El firewall está habilitado en el centro de datos y en el nodo", + "bothEnabled": "La activación del firewall está configurada en el centro de datos y en el nodo", "datacenterOff": "El firewall está deshabilitado en el centro de datos, así que las reglas del nodo no se aplican", - "nodeOff": "El firewall está habilitado en el centro de datos pero no en este nodo" + "nodeOff": "El firewall está habilitado en el centro de datos pero no en este nodo", + "evaluationFailed": "No se pudo evaluar la comprobación" + } + }, + "lynis_warnings": { + "title": "Avisos de Lynis", + "rationale": "Avisos de la última auditoría de Lynis, cada uno con su identificador de test, y la antigüedad de esa auditoría. Solo se ejecuta una auditoría cuando Lynis está instalado y no hay ningún informe completo. Las sugerencias no se incluyen.", + "summary": { + "none": "La última auditoría de Lynis no registró avisos", + "found": "La última auditoría de Lynis registró {count} aviso(s)", + "incomplete": "El informe de Lynis está incompleto", + "evaluationFailed": "No se pudo evaluar la comprobación", + "noneStale": "La última auditoría de Lynis no registró avisos, y su informe tiene {days} día(s)", + "foundStale": "La última auditoría de Lynis registró {count} aviso(s), y su informe tiene {days} día(s)" + } + }, + "certificate_expiry": { + "title": "Validez del certificado", + "rationale": "La fecha de caducidad del certificado que pveproxy sirve desde /etc/pve/local. Un certificado propio tiene prioridad sobre el que genera Proxmox.", + "summary": { + "valid": "El certificado es válido {days} día(s) más", + "expiring": "El certificado caduca en {days} día(s)", + "expired": "El certificado caducó hace {days} día(s)", + "evaluationFailed": "No se pudo evaluar la comprobación" + } + }, + "ssh_root_login": { + "title": "Acceso SSH de root", + "rationale": "PermitRootLogin en la configuración efectiva de `sshd -T`, junto con los métodos de autenticación con los que se combina. Proxmox se entrega con `yes`, que admite contraseña.", + "summary": { + "password": "root puede iniciar sesión por SSH con contraseña", + "keyOnly": "root puede iniciar sesión por SSH solo con clave", + "denied": "root no puede iniciar sesión por SSH", + "evaluationFailed": "No se pudo evaluar la comprobación" } } }, "storage": { "orphaned_volumes": { "title": "Asignación de volúmenes", - "rationale": "Compara los volúmenes que devuelve `pvesm list` con las configuraciones de invitado existentes. Solo examina almacenamiento no compartido: en almacenamiento compartido un volumen puede pertenecer a un invitado que se ejecuta en otro nodo.", + "rationale": "Volúmenes de invitado en el almacenamiento local frente a las referencias de las configuraciones actuales, pendientes y de snapshots. Los backups, ISOs y plantillas quedan fuera de la comparación. Un volumen sin referencia es un candidato a revisión.", "summary": { "none": "No se han encontrado volúmenes huérfanos", - "found": "{count} volumen(es) no pertenecen a ningún invitado existente" + "found": "Hay {count} volúmenes sin referencia en las configuraciones examinadas", + "evaluationFailed": "No se pudo evaluar la comprobación" + } + }, + "zfs_arc_max": { + "title": "Límite de ARC de ZFS", + "rationale": "Valores efectivos de c_min, c_max y tamaño del ARC, el parámetro del módulo cargado y los ajustes persistentes de /etc/modprobe.d. Un valor configurado de cero selecciona el valor por defecto del módulo; el ARC es un techo y la memoria que ocupa es recuperable.", + "summary": { + "bounded": "El límite del ARC es el {percent}% de la memoria del host, y la memoria que ocupa es recuperable", + "high": "El ARC puede usar el {percent}% de la memoria del host", + "unset": "El ARC no tiene un límite explícito", + "conflicting": "{count} ajustes del ARC no coinciden entre sí", + "pending": "Un ajuste persistente del ARC difiere del valor que lleva el módulo en ejecución", + "evaluationFailed": "No se pudo evaluar la comprobación" + } + }, + "zfs_scrub_age": { + "title": "Scrub de ZFS", + "rationale": "El último scrub completado que registra `zpool status` en cada pool. Un resilver no es un scrub. Un pool creado hace poco no ha tenido tiempo de ejecutar uno.", + "summary": { + "recent": "Los {total} pool(s) se han scrubbeado en los últimos 35 días", + "overdue": "{count} de {total} pool(s) llevan más de 35 días sin scrub", + "neverScrubbed": "{count} pool(s) no registran ningún scrub", + "evaluationFailed": "No se pudo evaluar la comprobación" + } + }, + "thin_pool_overprovisioning": { + "title": "Asignación de los thin pools", + "rationale": "Capacidad virtual repartida por cada thin pool de LVM frente al tamaño del propio pool, y cuánto han escrito realmente sus volúmenes, datos y metadatos por separado.", + "summary": { + "withinRatio": "Los {total} thin pools están por debajo de los umbrales de revisión aplicados", + "aboveRatio": "{count} de {total} thin pools reparten más capacidad de la que tienen", + "pressure": "{pressure} de {total} thin pools están cerca de llenar sus datos o metadatos", + "evaluationFailed": "No se pudo evaluar la comprobación" + } + }, + "connected_storage": { + "title": "Almacenamientos conectados", + "rationale": "Disponibilidad según la informa PVE, capacidad conocida y dependencias actuales de todos los almacenamientos habilitados en este nodo. No se prueban los componentes internos remotos ni el acceso de escritura. La capacidad se expone donde PVE la conoce y queda en blanco donde no.", + "summary": { + "available": "PVE indica que los {total} almacenamientos están disponibles; no se han probado sus componentes internos remotos ni el acceso de escritura", + "attention": "{count} de {total} almacenamientos requieren revisión", + "evaluationFailed": "No se pudo evaluar la comprobación" + } + }, + "pool_integrity": { + "title": "Integridad y redundancia de los pools", + "rationale": "El estado de cada pool ZFS y los contadores de lectura, escritura y suma de verificación de sus dispositivos. Los contadores son acumulativos desde el último `zpool clear`.", + "summary": { + "healthy": "Los {total} pools están en línea y sin errores de dispositivo registrados", + "degraded": "{count} hallazgos en {total} pools", + "evaluationFailed": "No se ha podido leer el estado de los pools" + } + }, + "ceph_health": { + "title": "Salud de Ceph", + "rationale": "El estado de salud del propio Ceph y las comprobaciones que nombra. Sus pruebas no se reimplementan y no se consulta ningún pool, grupo de colocación ni OSD por separado.", + "summary": { + "healthy": "Ceph informa HEALTH_OK", + "degraded": "Ceph informa {state}, con {count} comprobación(es) nombrada(s)", + "evaluationFailed": "No se pudo evaluar la comprobación" + } + }, + "array_integrity": { + "title": "RAID software y multipath", + "rationale": "Arrays de mdadm y mapas de multipath, leídos de /proc/mdstat y, donde la herramienta esté instalada, de `multipath -ll`. Los pools de ZFS los informa su propia comprobación.", + "summary": { + "intact": "Los {total} arrays y mapas conservan su redundancia", + "degraded": "{count} de {total} arrays o mapas están cortos de ella", + "evaluationFailed": "No se pudo evaluar la comprobación" + } + } + }, + "hardware": { + "disk_service_life": { + "title": "Vida útil de los discos", + "rationale": "Horas de funcionamiento y desgaste restante a partir de las lecturas SMART del monitor, con la fecha de cada lectura. La antigüedad es información de planificación; los errores de medio y los avisos del dispositivo los informa el monitor de salud.", + "summary": { + "withinLife": "Las {total} lecturas de discos no superan el umbral orientativo de cinco años", + "pastLife": "{count} de {total} lecturas de discos superan cinco años de funcionamiento", + "noReadings": "Ningún disco expone contadores SMART utilizables ({skipped} sin lecturas)", + "evaluationFailed": "No se pudo evaluar la comprobación" + } + }, + "disk_errors": { + "title": "Errores de disco", + "rationale": "Errores detectados en los discos y registrados por el monitor de salud.", + "summary": { + "recorded": "{count} de {total} discos con registro tienen eventos anotados", + "noEvents": "No queda ningún evento de disco que graduar: ninguno registrado, o todos descartados" + } + } + }, + "network": { + "bond_members": { + "title": "Miembros de los bonds", + "rationale": "El estado MII de cada miembro del bond según /proc/net/bonding y cuántos enlaces quedan. En active-backup un miembro de reserva figura activo y no transporta tráfico.", + "summary": { + "allUp": "Todos los miembros de los {total} bond(s) están activos", + "membersDown": "{count} miembro(s) de bond no están activos", + "evaluationFailed": "No se pudo evaluar la comprobación" + } + }, + "bridge_without_ports": { + "title": "Puertos de los puentes", + "rationale": "La configuración de puertos de cada bridge. Un bridge sin puerto físico da servicio a una red interna o enrutada.", + "summary": { + "allConnected": "Los {total} puente(s) llevan un puerto", + "isolated": "{count} de {total} puente(s) no llevan puerto", + "evaluationFailed": "No se pudo evaluar la comprobación" } } } @@ -5041,6 +5558,389 @@ "expiry365": "1 año", "cancel": "Cancelar", "confirm": "Aceptar riesgo" - } + }, + "incomplete": "Evidencia incompleta", + "progress": "Comprobadas {completed} de {total}", + "expires": "Caduca: {when}", + "runStates": { + "partial": "La evaluación se completó; algunas lecturas no se pudieron tomar.", + "failed": "Evaluación interrumpida o fallida. Revisa la evidencia antes de utilizar los resultados." + }, + "severities": { + "CRITICAL": "Crítico", + "WARNING": "Advertencia", + "INFO": "Información", + "OK": "Correcto" + }, + "viewSwitch": { + "ariaLabel": "Cambiar entre evaluación e inventario", + "assessment": "Evaluación", + "inventory": "Inventario", + "policy": "Política", + "changes": "Cambios" + }, + "inventory": { + "loading": "Cargando inventario…", + "failed": "No se pudo componer el inventario.", + "collectedAt": "Compuesto el {when}", + "unavailable": "No leído en este inventario", + "unresolved": "ruta no resuelta", + "noUplink": "sin enlace", + "identity": "Identidad del nodo", + "node": "Nodo", + "pveVersion": "Proxmox VE", + "kernel": "Kernel", + "subscription": "Suscripción", + "cluster": "Clúster", + "standalone": "No pertenece a un clúster", + "hardware": "Hardware", + "system": "Sistema", + "serial": "Número de serie", + "bios": "BIOS", + "cpu": "Procesador", + "topology": "Distribución", + "cpuLayout": "{sockets} zócalo(s) × {cores} núcleos = {threads} hilos", + "virtualisation": "Virtualización", + "memory": "Memoria", + "iommuGroups": "Grupos IOMMU", + "network": "Red", + "storage": "Almacenamiento", + "name": "Nombre", + "type": "Tipo", + "content": "Contenido", + "shared": "Compartido", + "yes": "Sí", + "no": "No", + "guests": "Invitados", + "ostype": "Sistema operativo", + "onboot": "Arranca con el host", + "tags": "Etiquetas", + "privilege": "Privilegios", + "privileged": "Privilegiado", + "unprivileged": "Sin privilegios", + "features": "Features", + "agent": "Agente invitado", + "cpuModel": "Modelo de CPU", + "disks": "Discos", + "interfaces": "Interfaces de red", + "protection": "Backup", + "passthrough": "Passthrough", + "noBackup": "Sin backup", + "noBackupDetail": "Ningún trabajo de backup activo selecciona este invitado.", + "applications": "Aplicaciones", + "versionUnknown": "versión no detectada", + "passthroughTitle": "Passthrough PCI", + "iommuGroup": "Grupo IOMMU {group}", + "sharedGroup": "{count} dispositivo(s) más en el mismo grupo", + "proxmenux": "Optimizaciones de ProxMenux", + "latency": "Latencia de red", + "subscriptionStatus": { + "notfound": "No consta suscripción", + "active": "Activa", + "invalid": "No válida", + "expired": "Caducada", + "suspended": "Suspendida", + "new": "Pendiente de activar", + "unknown": "Desconocido" + } + }, + "profile": { + "label": "Informe", + "full": "Auditoría completa", + "inventory": "Inventario", + "security": "Revisión de seguridad", + "backup": "Garantía de backups", + "capacity": "Capacidad y desgaste", + "diagnostic": "Diagnóstico rápido" + }, + "document": { + "action": "Generar informe", + "title": "Informe de auditoría", + "subtitle": "Estructura, configuración y evaluación de {node}", + "generated": "Generado", + "assessed": "Evaluado", + "runId": "Referencia de la evaluación", + "print": "Imprimir o guardar como PDF", + "summaryByArea": "Resumen por área", + "area": "Área", + "inventoryAnnex": "Anexo de inventario", + "scopeTitle": "Alcance de este informe", + "scopeBody": "Este informe describe el nodo Proxmox VE indicado arriba, tal como se observa desde el propio nodo en la fecha señalada. No cubre el interior de los invitados más allá de lo que declaran, ni el equipamiento de red externo al host, ni la infraestructura física, ni ninguna dependencia no visible desde este nodo. Los hallazgos marcados como no determinados no se midieron y no constituyen prueba de ausencia.", + "building": "Componiendo el informe…", + "node": "Nodo", + "profile": "Perfil", + "unknownNode": "nodo sin identificar", + "executiveSummary": "Resumen de la evaluación", + "assessment": "Evaluación", + "verdictHeading": "Resultado de esta ejecución", + "verdict": { + "critical": "ATENCIÓN", + "warning": "REVISAR", + "conformant": "EN ORDEN" + }, + "verdictText": { + "critical": "{fail} comprobaciones informan de una condición fallida y {warn} de una condición a revisar, de {total} evaluadas.", + "warning": "Ninguna comprobación informa de una condición fallida. {warn} de {total} informan de una condición a revisar.", + "conformant": "Las {total} comprobaciones de este perfil se completan sin condiciones fallidas ni revisables.", + "none": "Este perfil no ejecuta comprobaciones. El documento describe el nodo sin evaluarlo." + }, + "runAt": "Ejecutado el {date}", + "chartNote": "Comprobaciones por área y resultado.", + "nodeIdentity": "Identidad del nodo", + "system": "Sistema", + "cluster": "Clúster", + "standaloneNote": "Este nodo no forma parte de un clúster: mantiene su propia configuración y sus invitados no migran a otro nodo.", + "clusterDiagramNote": "Nodos configurados y los enlaces corosync que los unen.", + "thisNode": "este nodo", + "unreachable": "no visto", + "member": "miembro", + "corosyncLinks": "enlaces", + "quorum": "Quórum", + "quorate": "con quórum", + "inquorate": "sin quórum", + "votes": "Votos", + "nodeName": "Nodo", + "architecture": "Arquitectura del sistema", + "architectureNote": "Cómo está montado el nodo: procesador y memoria en la placa, y qué cuelga de cada controladora.", + "systemIdentity": "Identidad del sistema", + "board": "Placa", + "processor": "Procesador", + "topology": "Zócalos × núcleos / hilos", + "memory": "Memoria", + "cores": "núcleos", + "threads": "hilos", + "memoryModules": "Módulos de memoria", + "slot": "Ranura", + "slotsUsed": "ranuras ocupadas", + "slotsFilled": "{used} de {total} ranuras ocupadas", + "emptySlot": "vacía", + "formFactor": "Formato", + "speed": "Velocidad", + "manufacturer": "Fabricante", + "product": "Modelo", + "serial": "Número de serie", + "controllers": "Controladoras", + "class": "Clase", + "device": "Dispositivo", + "iommuGroups": "Grupos IOMMU", + "iommuGroup": "Grupo IOMMU", + "field": "Campo", + "value": "Valor", + "size": "Tamaño", + "type": "Tipo", + "storageDevices": "Dispositivos de almacenamiento", + "disks": "Discos", + "model": "Modelo", + "bus": "Bus", + "serviceLife": "Horas de servicio", + "healthy": "correcto", + "years": "{years} años", + "events": "Eventos", + "observations": "Observaciones", + "observationsNote": "Eventos registrados. SMART informa del estado actual; este registro informa de lo ocurrido, incluidos los eventos de los que el disco se recuperó.", + "noObservations": "Sin eventos registrados", + "noObservationsNote": "Ningún disco ha registrado errores desde que el monitor los observa.", + "event": "Evento", + "severity": "Gravedad", + "occurrences": "Repeticiones", + "firstSeen": "Primera vez", + "lastSeen": "Última vez", + "detail": "Detalle", + "network": "Red", + "adapters": "Adaptadores", + "bond": "Bond", + "bridge": "Bridge", + "bridges": "Bridges", + "physicalAdapters": "Adaptadores físicos", + "interface": "Interfaz", + "driver": "Controlador", + "state": "Estado", + "networkDiagramNote": "Camino desde el cable hasta cada invitado: adaptador físico, bond cuando los agrupa, bridge e invitados conectados.", + "storageAndProtection": "Almacenamiento y protección", + "storage": "Almacenamiento", + "content": "Contenido", + "shared": "Compartido", + "location": "Ubicación", + "backupDestination": "Destino de backups", + "unprotected": "sin backup", + "storageDiagramNote": "Dónde residen los discos de los invitados y qué destino los respalda.", + "unprotectedGuests": "{count} invitados sin trabajo de backup", + "allProtected": "Todos los invitados están cubiertos por un trabajo de backup", + "allProtectedNote": "La cobertura indica que un trabajo selecciona al invitado; el resultado de los backups se evalúa aparte.", + "vmid": "VMID", + "name": "Nombre", + "kind": "Tipo", + "backup": "Backup", + "none": "ninguno", + "managedSoftware": "Software gestionado por ProxMenux", + "version": "Versión", + "source": "Origen", + "current": "al día", + "updateAvailable": "actualizar a {version}", + "findings": "Hallazgos en detalle", + "incomplete": "parcial", + "scope": "Alcance de este informe", + "scopeText": "Este documento informa del perfil {profile} sobre el nodo indicado en la cabecera, en el momento de la ejecución.", + "scopeLocal": "Cubre únicamente este nodo. Los invitados de otros nodos y su configuración quedan fuera.", + "scopeReadOnly": "Todas las comprobaciones leen configuración y estado ya existentes; ninguna modifica el host.", + "scopeMoment": "Describe el estado en el momento de la ejecución, no un periodo de tiempo.", + "notRead": "Fuentes que no se han podido leer:", + "uplink": "Enlace ascendente", + "conformance": "{pass} de {total} conformes", + "latency": "Latencia de red", + "latencyNote": "Latencia medida en la ventana indicada, una línea por objetivo.", + "milliseconds": "ms", + "hours": "h", + "minimum": "Mínimo", + "average": "Media", + "maximum": "Máximo", + "packetLoss": "Pérdida de paquetes", + "samples": "Muestras", + "target": { + "label": "Objetivo", + "gateway": "Puerta de enlace", + "cloudflare": "Cloudflare (1.1.1.1)", + "google": "Google (8.8.8.8)" + }, + "actionShort": "Informe", + "policyDeclared": "Se ha evaluado frente a una política declarada: {guests} invitados, {storages} almacenamientos y {thresholds} umbrales indicados.", + "policyNone": "No hay política declarada, así que una ausencia que este informe no puede interpretar se indica como observación y nunca como advertencia.", + "diagnosticTitle": "Diagnóstico rápido", + "diagnosticSubtitle": "Resultados críticos y advertencias de {node}", + "diagnosticActing": "{count} resultado(s) crítico(s) y advertencia(s).", + "diagnosticClear": "Ningún resultado crítico ni advertencia. Las observaciones y los resultados conformes están en la auditoría completa.", + "diagnosticUnread": "Lecturas que no se pudieron tomar", + "diagnosticMoreRows": "{count} fila(s) más, en la auditoría completa.", + "structureTitle": "Estructura y configuración", + "structureSubtitle": "Cómo está construido y configurado {node}" + }, + "results": "Resultados", + "classifications": { + "critical": "Crítico", + "warning": "Advertencia", + "observation": "Observación", + "conformant": "Conforme", + "unverified": "Sin verificar", + "not_applicable": "No aplicable", + "accepted": "Riesgo aceptado", + "by_design": "Excluido por política" + }, + "policy": { + "inherit": "{value} (por defecto)", + "inheritUnset": "Por defecto", + "conflict": "La declaración ha cambiado en otra sesión. Tu borrador no se ha guardado.", + "reload": "Recargar declaración guardada (descartar borrador)", + "intro": "Una evaluación ve lo que hace este host, no para qué sirve. Lo que se declara aquí es lo que convierte una observación en advertencia, o la saca del cómputo. Nada es obligatorio: sin declaración el informe describe en vez de juzgar.", + "loading": "Leyendo la declaración…", + "failed": "No se ha podido leer la declaración", + "saved": "Guardado", + "declaredCount": "{count} declaraciones", + "guestsNote": "Por defecto aplica lo declarado para todo el sitio, y si no hay nada declarado informa como observación lo que falte; obligatorio lo informa como advertencia; no obligatorio lo deja fuera del cómputo.", + "storagesNote": "Un almacenamiento inaccesible es crítico si está marcado esencial o sirve a un invitado en marcha, advertencia si su papel está sin especificar, y observación si está marcado opcional.", + "thresholds": "Umbrales", + "thresholdsNote": "Vacío significa el valor de fábrica, que se muestra como marcador.", + "backup": "Backup", + "autostart": "Arranque", + "objective": "Objetivo de recuperación", + "objectivePlaceholder": "horas", + "noGuests": "Este nodo no alberga invitados.", + "expectation": { + "required": "Obligatorio", + "not_required": "No obligatorio", + "unspecified": "Sin especificar" + }, + "role": { + "essential": "Esencial", + "optional": "Opcional", + "unspecified": "Sin especificar" + }, + "threshold": { + "storage_usage_percent": "Revisión de capacidad de almacenamiento (%)", + "thin_pool_usage_percent": "Revisión de ocupación de thin pool (%)", + "thin_overprovision_ratio": "Ratio de sobreaprovisionamiento thin", + "zfs_scrub_days": "Intervalo de scrub de ZFS (días)", + "backup_fallback_days": "Plazo de respaldo para la antigüedad (días)", + "backup_schedule_grace_ratio": "Margen sobre el calendario (ratio)", + "certificate_expiry_days": "Aviso de caducidad de certificado (días)", + "memory_overcommit_ratio": "Ratio de sobreasignación de memoria", + "disk_service_life_hours": "Vida útil del disco (horas)", + "lynis_report_days": "Antigüedad del informe de Lynis (días)", + "package_index_days": "Antigüedad de los índices de paquetes (días)", + "journal_usage_percent": "Journal frente a su tope (%)", + "filesystem_usage_percent": "Revisión de espacio del sistema de archivos (%)", + "filesystem_inode_percent": "Revisión de inodos del sistema de archivos (%)", + "disk_error_recent_days": "Ventana de errores de disco recientes (días)" + } + }, + "changes": { + "loading": "Leyendo el registro de cambios…", + "failed": "No se ha podido leer el registro de cambios", + "intro": "Qué ha cambiado ProxMenux en este host y qué había antes de cada cambio. Se muestra la diferencia, no el script que la aplicó.", + "empty": "Todavía no se ha registrado nada en este host.", + "since": "Registrando desde el {date}. Lo aplicado antes figura como aplicado, sin el estado al que sustituyó.", + "byFunction": "Por función", + "count": "{count} cambios", + "function": "Función", + "source": "Script", + "reversibility": "Deshacer esto", + "difference": "Diferencia", + "diffTruncated": "La diferencia es más larga de lo que se muestra.", + "diffUnavailable": "El contenido sustituido ya no está almacenado, así que no se puede mostrar la diferencia.", + "packagesAdded": "Paquetes añadidos", + "commandRun": "Comando ejecutado", + "executionNote": "ProxMenux lo ejecutó a petición; lo que cambió lo decidió el comando, no ProxMenux.", + "unknownNote": "Esto se aplicó antes de que existiera el registro, así que nunca se capturó a qué sustituyó.", + "noneInFilter": "No hay ningún cambio de este tipo.", + "class": { + "all": "Todos", + "configuration": "Configuración", + "installation": "Instalaciones", + "execution": "Ejecuciones", + "registration": "Aplicado" + }, + "operation": { + "write_file": "Fichero reemplazado", + "edit_file": "Fichero editado", + "remove_file": "Fichero eliminado", + "install_package": "Instalado", + "enable_service": "Servicio habilitado", + "disable_service": "Servicio deshabilitado", + "run_command": "Ejecutado", + "applied": "Aplicado", + "removed": "Eliminado", + "unknown": "Cambio" + }, + "capture": { + "unknown": "Estado anterior desconocido" + }, + "exactness": { + "exact": "Restaura exactamente lo que había", + "partial": "Parcial: pueden quedar dependencias o irse con ello", + "none": "No se puede deshacer desde el registro" + } + }, + "comparison": { + "loading": "Comparando con la ejecución de referencia…", + "failed": "No se ha podido fijar la ejecución de referencia", + "since": "Desde el {date}", + "previousRun": "la ejecución anterior", + "noChange": "Sin cambios", + "isBaseline": "Esta ejecución es la referencia con la que se comparan las demás.", + "noBaseline": "Todavía no se ha elegido una ejecución de referencia, así que no hay con qué comparar.", + "setBaseline": "Usar como referencia", + "unchanged": "{count} comprobaciones dieron el mismo resultado que antes.", + "new": "Nuevos", + "newNote": "se informan ahora y antes no", + "resolved": "Resueltos", + "resolvedNote": "ya no se informan, y nadie los aceptó", + "accepted": "Aceptados", + "acceptedNote": "dejan de contar porque se aceptó un riesgo, no porque el host cambiara", + "retired": "Ya no se evalúan", + "retiredNote": "estaban antes y no en esta ejecución; nada verificó que dejaran de darse", + "reasons": { + "insufficient_runs": "Comparar exige una ejecución de referencia y otra posterior; por ahora solo hay una registrada" + } + }, + "notApplicableScope": "Nada en el alcance examinado al que esta comprobación aplique." } } diff --git a/AppImage/messages/fr/common.json b/AppImage/messages/fr/common.json index 17120c4b..0c5b7654 100644 --- a/AppImage/messages/fr/common.json +++ b/AppImage/messages/fr/common.json @@ -309,7 +309,7 @@ "shortTest": "Test court", "longTest": "Test long (1 à 4 heures)", "extendedTest": "Test étendu", - "testHelp": "Un court test prend environ 2 minutes.Le test étendu s'exécute en arrière-plan et peut prendre plusieurs heures sur des disques volumineux.Le résultat apparaîtra dans l'onglet Historique une fois terminé.", + "testHelp": "Un court test prend environ 2 minutes. Le test étendu s'exécute en arrière-plan et peut prendre plusieurs heures sur des disques volumineux. Le résultat apparaîtra dans l'onglet Historique une fois terminé.", "startFailed": "Échec du démarrage du test", "short": "Court", "extended": "Étendu", @@ -1103,7 +1103,7 @@ "backupStartFailed": "Échec du démarrage de la sauvegarde : {message}", "controlFailed": "Échec sur {action} VM {vmid} : {message}", "saveNotesFailed": "Erreur lors de l'enregistrement des notes. Veuillez réessayer.", - "appNotFound": "Cette application n'est plus disponible.Actualisez la page et réessayez.", + "appNotFound": "Cette application n'est plus disponible. Actualisez la page et réessayez.", "saveCustomCommandFailed": "Impossible d'enregistrer la commande de mise à jour personnalisée : {message}", "removeCustomCommandConfirm": "Supprimer la commande de mise à jour personnalisée pour \"{name}\" ?", "removeCustomCommandFailed": "Impossible de supprimer la commande de mise à jour personnalisée : {message}", @@ -1220,7 +1220,15 @@ "humanWeekly": "hebdomadaire ({day} {time})", "humanMonthly": "mensuel (jour {day} à {time})", "humanHourly": "Horaire", - "weekdays": "['Dimanche', 'Lundi', 'Mardi', 'Mercredi', 'Jeudi', 'Vendredi', 'Samedi']" + "weekdays": [ + "Dimanche", + "Lundi", + "Mardi", + "Mercredi", + "Jeudi", + "Vendredi", + "Samedi" + ] }, "cronChip": { "detected": "cron hôte détecté", @@ -1373,8 +1381,7 @@ "postApplyAllOk": "{count} package(s) appliqué(s) avec succès – rien en attente.", "postApplyNothingPending": "Rien en attente – tout est à jour.", "postApplyPartial": "{pending} package(s) toujours en attente après l'exécution.", - "postApplyPartialSubline": "{applied} appliqué.Certaines mises à jour n'ont pas été terminées – consultez la sortie du terminal ci-dessus.", - "updatedWithDockerImage": "mis à jour avec son image Docker." + "postApplyPartialSubline": "{applied} appliqué. Certaines mises à jour n'ont pas été terminées – consultez la sortie du terminal ci-dessus." }, "bulkUpdate": { "title": "Mise à jour groupée", @@ -1602,14 +1609,9 @@ "notificationsEnabled": "Notifications de mise à jour en amont activées – cliquez pour désactiver le son", "notificationsMuted": "Notifications de mise à jour en amont MUTED – cliquez pour activer", "notifyUpstreamLabel": "Me prévenir lorsqu'une nouvelle version en amont est disponible", - "notifyUpstreamHelp": "envoie `app_update_available` aux canaux activés dans Paramètres → Notifications.Désactivez-la si cette application ne peut pas être mise à jour sur votre box.", + "notifyUpstreamHelp": "envoie `app_update_available` aux canaux activés dans Paramètres → Notifications. Désactivez-la si cette application ne peut pas être mise à jour sur votre box.", "excludeFromBadgeLabel": "exclure du compteur de mises à jour LXC", - "excludeFromBadgeHelp": "Ne comptez pas cette application dans le badge de mises à jour globales sur la carte de liste LXC.Utile lorsque vous êtes volontairement épinglé à une version spécifique (exigence de suivi, gel de la compatibilité).N'affecte pas l'état de l'onglet Application ni la notification sortante.", - "dockerDetectedWithWorkloads": "Docker détecté avec {count} application(s) conteneurisée(s)", - "dockerWorkloadsHeading": "Exécution à l'intérieur de Docker", - "runsInsideDocker": "mis à jour avec son image Docker", - "upstreamDelegatedTitle": "la version disponible provient de son image Docker", - "upstreamDelegatedHelp": "Cette application s'exécute dans un conteneur, donc la version disponible est celle que son image résout : pas de vérification distincte en amont et une mise à jour signalée une fois.Mettez-le à jour à partir de son image dans l'onglet Mises à jour." + "excludeFromBadgeHelp": "Ne comptez pas cette application dans le badge de mises à jour globales sur la carte de liste LXC.Utile lorsque vous êtes volontairement épinglé à une version spécifique (exigence de suivi, gel de la compatibilité).N'affecte pas l'état de l'onglet Application ni la notification sortante." }, "statusFilter": { "ariaLabel": "Filtrer les machines virtuelles et les conteneurs", @@ -1889,6 +1891,7 @@ "system_reboot": "Redémarrage du système", "system_restore_completed": "Restauration de l'hôte terminée", "system_problem": "Problème système détecté", + "kernel_warning": "Avertissements et traces de diagnostic du noyau", "service_fail": "Le service a échoué", "oom_kill": "Arrêt du processus en cas de manque de mémoire", "service_fail_batch": "Plusieurs échecs de service", @@ -4915,6 +4918,154 @@ "customLinkDeleteError": "Delete failed" }, "audit": { + "presentation": { + "backupAge": "Ancienneté de la copie", + "backupLimit": "Limite utilisée", + "limitDeclared": "Délai défini par l’utilisateur", + "limitSchedule": "Planification + marge", + "limitReference": "Délai de référence", + "verifiedChecks": "Vérifications effectuées", + "unverifiedChecks": "Vérifications non effectuées", + "checkName": "Vérification", + "verified": "Vérifiées", + "verificationScope": "Vérifications applicables effectuées. La couverture ne représente ni l’état ni la sécurité du serveur.", + "noApplicable": "Aucune vérification applicable dans cette évaluation.", + "noJob": "Sans tâche planifiée", + "guest": "Invité", + "guests": "invités", + "host": "Hôte", + "resource": "Ressource", + "data": "Données", + "metadata": "Métadonnées", + "result": "Résultat", + "fact": "Fait observé", + "records": "entrées", + "unscheduled": "invités sans tâche planifiée", + "excludedDisks": "disques exclus", + "disks": "Disques", + "destination": "Destination", + "lastCopy": "Dernière sauvegarde stockée", + "ageLimit": "Ancienneté / limite", + "noDestination": "Aucune destination configurée", + "notFound": "Aucune sauvegarde trouvée dans le périmètre examiné", + "promiscuous": "Interfaces en mode promiscuité", + "noDescription": "Description indisponible", + "occurrence": "Occurrence", + "occurrences": "occurrences", + "detail": "Détail", + "technical": "Preuves techniques", + "annex": "Annexe technique", + "overview": "Synthèse des constats", + "incomplete": "Évaluation incomplète", + "assessment": "Évaluation", + "noGlobalScore": "Les résultats décrivent des critères distincts ; aucun score global de sécurité n’est calculé.", + "coverage": "Couverture des sauvegardes planifiées", + "scheduled": "Avec une tâche planifiée", + "copyScope": "Une tâche configurée ne prouve pas qu’une sauvegarde stockée ou restaurable existe.", + "detailsLink": "Référence des preuves", + "noSubscription": "Aucun abonnement enregistré", + "otherDevices": "Autres périphériques du groupe", + "unversioned": "Version non enregistrée", + "notInstalled": "Non installée", + "noPendingRecorded": "Aucune mise à jour en attente enregistrée", + "originalEvidence": "Preuves originales de la source", + "evidenceObserved": "Éléments observés", + "evidenceExcerpt": "Vue compacte. Les éléments de preuve complets restent enregistrés avec cette évaluation.", + "annexScope": "Éléments de preuve complets pour les résultats qui demandent une attention, consignent une observation ou n'ont pas pu être vérifiés.", + "readOnlyScope": "L’évaluation ne modifie pas la configuration. Les requêtes et, si nécessaire, Lynis peuvent générer des journaux ou rapports.", + "capacity": "Capacité", + "used": "Utilisé", + "free": "Libre", + "reasons": { + "agentNotDeclared": "Aucun agent invité déclaré dans la configuration", + "arcConflictingSettings": "Les réglages persistants ne concordent pas", + "arcMinAboveMax": "La borne basse de l'ARC dépasse la borne haute", + "arcPendingReboot": "Le réglage persistant diffère du paramètre chargé", + "arrayDegraded": "Fonctionne avec moins de périphériques qu'à sa création", + "arrayNotActive": "Non actif", + "arrayRebuilding": "Manque de périphériques et en reconstruction", + "backupRunFailed": "L'exécution s'est terminée par une erreur", + "backupRunRecovered": "A échoué auparavant, et une exécution ultérieure a réussi", + "bondNoMembersUp": "Hors service, et aucun membre du bond n'est actif", + "bondRedundancyLost": "Hors service ; le bond conserve d'autres liens", + "bootEspMissingNewest": "Ne porte pas le noyau le plus récent que portent les autres", + "bootEspOutOfSync": "Désynchronisée des autres : elle démarrerait un noyau différent", + "bootSingleEsp": "Une partition d'amorçage configurée", + "bootToolReported": "Signalé par proxmox-boot-tool", + "cephCheckRaised": "Signalée par Ceph", + "channelIncomplete": "Activé mais une partie de sa configuration manque", + "clusterInquorate": "Sans quorum : les modifications du cluster sont refusées", + "clusterMemberAbsent": "Nœud configuré que le cluster ne voit pas", + "clusterSingleLink": "Un seul lien corosync déclaré", + "dataExcludedFromBackup": "Données exclues de la sauvegarde de l'invité", + "deliveryFailing": "Les envois récents ne sont pas partis", + "destinationUnavailable": "Destination configurée indisponible", + "diskErrorsActive": "Erreur enregistrée pendant la période examinée", + "diskErrorsPast": "A signalé des erreurs auparavant, aucune dans la fenêtre en vigueur", + "diskWarningsActive": "Avertissement du périphérique enregistré pendant la période examinée", + "diskWarningsPast": "A signalé des avertissements du périphérique auparavant, aucun dans la fenêtre en vigueur", + "essentialServiceDown": "Un service dont Proxmox a besoin pour répondre n'est pas actif", + "exemptByPolicy": "Déclaré comme n'en ayant pas besoin, donc hors du décompte", + "expectedButUncovered": "Déclaré comme devant être sauvegardé, et aucune tâche activée ne le sélectionne", + "expectedToAutostart": "Déclaré comme devant démarrer avec l'hôte, ce qu'il ne fait pas", + "filesystemExhausted": "Plus d'espace disponible", + "filesystemNearlyFull": "Au seuil d'examen d'espace ou au-delà", + "filesystemReadOnly": "Le noyau signale ce montage en lecture seule : il n'accepte plus d'écritures", + "haManagerNotReady": "Ni actif ni au repos : ne peut prendre en charge un service", + "haNoMaster": "Aucun gestionnaire : rien ne décide où un service doit tourner", + "haServiceError": "En état d'erreur et plus géré", + "haServiceTransitioning": "En transition", + "hostArchiveMissing": "L'enregistrement du travail nomme une archive qui n'est plus stockée", + "hostBackupJobFailed": "Le travail s'est terminé en erreur", + "hostBackupStale": "Plus ancien que la limite d'ancienneté en vigueur", + "hostBackupUnscheduled": "Stocké, sans planification produisant une copie ultérieure", + "hostNoRetrievableCopy": "Aucune copie dont cette vérification puisse encore rendre compte", + "indexesStale": "Les index de paquets n'ont pas été actualisés récemment", + "inodesExhausted": "Plus d'inodes disponibles", + "inodesNearlyExhausted": "Au seuil d'examen d'inodes ou au-delà", + "kernelAwaitingReboot": "Installé et n'est pas le noyau en cours", + "lynisReportStale": "Le rapport Lynis a {days} jour(s), plus que l'ancienneté de référence en vigueur", + "lynisWarning": "Relevé par l'audit Lynis", + "multipathNoPath": "Plus aucun chemin", + "multipathPathDown": "Sert par moins de chemins", + "noAutostart": "Ne démarre pas avec l'hôte", + "noConfigurationReference": "Aucune référence dans les configurations examinées", + "noJobSelectsGuest": "Aucune tâche de sauvegarde activée ne le sélectionne", + "noPhysicalPort": "Ne porte aucun port physique", + "noStoredBackup": "Aucune sauvegarde stockée trouvée", + "noStoredBackupUnscheduled": "Aucune tâche planifiée ; aucune copie trouvée", + "olderThanFallback": "La copie dépasse le délai de référence.", + "olderThanObjective": "La copie dépasse le délai défini par l’utilisateur.", + "olderThanSchedule": "La copie dépasse l’intervalle planifié et sa marge.", + "overprovisioned": "Distribue plus de capacité virtuelle que le pool n'en possède", + "packageAwaitingRestart": "Installé et demandant un redémarrage", + "pastServiceLife": "Au-delà du seuil de durée de vie utilisé pour la planification", + "pinnedToHostCpu": "Fixé au modèle de processeur de l'hôte", + "poolDeviceErrors": "Périphérique comptant des erreurs de lecture, d'écriture ou de somme de contrôle", + "poolNotOnline": "Hors ligne", + "rebootMarkerWithoutPackages": "Quelque chose a écrit le marqueur de redémarrage sans nommer de paquet", + "recoveryKeyLocalOnly": "Clé de chiffrement de sauvegarde conservée uniquement sur ce nœud, selon le mode enregistré", + "replicationDisabled": "En pause", + "replicationFailing": "La dernière exécution a signalé une erreur", + "replicationNeverRan": "N'a jamais terminé de synchronisation", + "replicationOverdue": "Plus ancienne que ne l'autorise le calendrier de la tâche", + "retentionNotDeclared": "Aucune rétention déclarée ; toutes les copies sont conservées", + "retentionOnServer": "Élaguée sur le serveur de sauvegarde, sous des tâches que ce nœud ne peut pas lire", + "runsPrivileged": "S'exécute en mode privilégié, partageant l'espace de noms utilisateur de l'hôte", + "scrubOverdue": "Dernier scrub terminé plus ancien que le seuil d'examen", + "storageNearlyFull": "Au seuil d'examen de capacité ou au-delà", + "storageUnreachable": "Injoignable", + "thinDataPressure": "Données écrites proches de la capacité du pool", + "thinMetadataPressure": "Métadonnées presque pleines, ce qui met le pool en lecture seule", + "unitFailed": "systemd a cessé de la relancer", + "verificationFailedOnly": "La vérification a lu la copie la plus récente et elle n'était pas intacte ; aucune autre copie de cet invité n'a été vérifiée", + "verificationFailedWithFallback": "La vérification a lu la copie la plus récente et elle n'était pas intacte ; une copie antérieure a été vérifiée", + "verificationNotRun": "Aucune tâche de vérification n'a relu cette copie" + }, + "lynisTest": "Test", + "lynisWarning": "Avertissement", + "couldNotRead": "N'a pas pu être lu" + }, "title": "Audit & Report", "loading": "Loading assessment…", "run": "Run assessment", @@ -4923,9 +5074,8 @@ "lastRun": "Last assessed on {when}", "stale": "{days} days ago", "readOnlyNotice": "The assessment only reads the host. It makes no changes.", + "unverifiedChecks": "Non relevés : {checks}. Chacun indique dans ses évidences ce qu'il n'a pas pu lire.", "noFindings": "No findings match the current filter.", - "showPassing": "Show passing checks", - "hidePassing": "Hide passing checks", "affectedCount": "{count} affected", "acceptedNotice": "{count} accepted risk(s) recorded on this host.", "states": { @@ -4933,7 +5083,8 @@ "warn": "Warning", "accepted": "Accepted risk", "pass": "Passed", - "not_applicable": "Not applicable" + "not_applicable": "Not applicable", + "unknown": "Non vérifié" }, "areas": { "all": "All", @@ -4949,7 +5100,8 @@ "why": "Context", "evidence": "Evidence", "affected": "Affected", - "acceptedRisk": "Accepted risk" + "acceptedRisk": "Accepted risk", + "sources": "Sources et dates de collecte" }, "errors": { "runFailed": "The assessment could not be started." @@ -4958,69 +5110,434 @@ "backup": { "guest_coverage": { "title": "Backup coverage", - "rationale": "Compares the guest inventory against what each backup job selects. A job selects by VMID list, by pool, or with `all 1`, minus its `exclude` list. Jobs carrying `enabled 0` are not considered.", + "rationale": "Tâches de sauvegarde activées sur ce nœud, les invités que chacune sélectionne et les données d'invité qui en sont exclues. Une couverture configurée ne prouve pas qu'une sauvegarde exploitable existe. Qu'un invité non sélectionné dût être protégé relève de la politique déclarée.", "summary": { - "noJobs": "No backup job is defined on this node", - "covered": "All {total} guests are covered by a backup job", - "uncovered": "{count} of {total} guests are not covered by any enabled backup job" + "noJobs": "Aucune tâche de sauvegarde n'est définie sur ce nœud pour les {total} invités qu'il héberge", + "covered": "Les {total} invités sont sélectionnés par une tâche de sauvegarde activée", + "uncovered": "{count} invités sur {total} ne sont sélectionnés par aucune tâche de sauvegarde activée", + "excludedData": "{count} exclusions de disques ou montages à vérifier", + "uncoveredExpected": "{required} invités déclarés comme devant être sauvegardés ne sont sélectionnés par aucune tâche activée", + "evaluationFailed": "The check could not be evaluated" + } + }, + "last_backup_age": { + "title": "Age of stored backups", + "rationale": "Ancienneté : temps écoulé depuis la dernière copie stockée. Limite utilisée : ancienneté de référence à laquelle cette copie est comparée.", + "summary": { + "recent": "Les {total} vérifications invité/destination respectent le critère d’ancienneté indiqué", + "stale": "{count} of {total} guests have no backup from the last 30 days", + "noBackups": "No stored backup matches a guest on this node", + "attention": "{count} vérifications invité/destination sur {total} nécessitent un examen", + "evaluationFailed": "The check could not be evaluated" + } + }, + "retention_defined": { + "title": "Backup retention", + "rationale": "La rétention telle que Proxmox la résout : réglage de la tâche, puis du stockage, puis la valeur par défaut du nœud. La rétention appliquée par un serveur de sauvegarde n'est pas lisible depuis ce nœud.", + "summary": { + "allDefined": "Les {total} tâches résolvent un réglage de rétention", + "missing": "{count} of {total} enabled job(s) declare no retention", + "notDeclared": "{count} tâches sur {total} conservent toutes les copies : aucune rétention n'est déclarée", + "onServer": "{count} tâches sur {total} écrivent sur un serveur de sauvegarde, qui les élague avec ses propres tâches", + "evaluationFailed": "The check could not be evaluated" + } + }, + "verification_state": { + "title": "Vérification des sauvegardes", + "rationale": "Le résultat de vérification que Proxmox Backup Server enregistre pour la copie la plus récente de chaque invité, et si une copie antérieure du même invité a été vérifiée. La vérification relit une copie stockée ; ce n'est pas une restauration.", + "summary": { + "allVerified": "La copie la plus récente des {total} invités a été vérifiée intègre", + "failed": "{failed} copies récentes ont échoué à la vérification, sur {total} examinées", + "notVerified": "{pending} des {total} copies récentes n'ont pas été vérifiées", + "evaluationFailed": "L'état de vérification n'a pas pu être lu" + } + }, + "job_results": { + "title": "Résultats des exécutions de sauvegarde", + "rationale": "Comment s'est terminée l'exécution la plus récente de chaque invité, d'après le journal des tâches du nœud. Seule la dernière est évaluée. Le journal n'est conservé qu'un temps limité.", + "summary": { + "allSucceeded": "Les {total} exécutions de sauvegarde enregistrées se sont terminées sans erreur", + "someFailed": "{count} des {total} exécutions de sauvegarde enregistrées se sont terminées par une erreur", + "evaluationFailed": "Le journal des tâches n'a pas pu être lu", + "recovered": "{count} invités sur {total} ont échoué lors d'une exécution antérieure et ont réussi depuis" + } + }, + "host_recovery": { + "title": "Récupération de l'hôte", + "rationale": "Sauvegardes de l'hôte telles que ProxMenux les enregistre : chaque travail exécuté, quand, s'il a réussi, la destination et si cette copie s'y trouve encore. Un travail écrivant vers un serveur de sauvegarde ne nomme aucun chemin local. Les clés de chiffrement ne sont rapportées que par nombre et mode de conservation enregistré.", + "summary": { + "noHostBackup": "Aucune sauvegarde de la configuration de l'hôte n'est stockée et aucune minuterie n'en produit", + "protected": "La configuration du nœud lui-même est stockée dans {total} archive(s), dans la limite d'ancienneté en vigueur", + "attention": "{count} constat(s) sur {total} enregistrement(s) de configuration de l'hôte", + "scheduledOnly": "Les sauvegardes de la configuration de l'hôte sont planifiées par {count} minuterie(s) ; aucune archive n'est stockée localement" } } }, "system": { "pending_reboot": { "title": "Restart state", - "rationale": "Checks for `/var/run/reboot-required` and, when present, the packages listed in `/var/run/reboot-required.pkgs`. Also reports the running kernel.", + "rationale": "Le marqueur `/var/run/reboot-required` et les paquets listés dans `/var/run/reboot-required.pkgs`. Son absence ne prouve pas qu'il n'y a rien à redémarrer.", "summary": { - "none": "No restart is pending", - "pending": "The host has a pending restart" + "none": "Rien n'a demandé de redémarrage", + "pending": "{count} éléments sont installés et attendent un redémarrage", + "evaluationFailed": "The check could not be evaluated" } }, "enterprise_repo_without_subscription": { "title": "Enterprise repository", - "rationale": "Looks for references to `enterprise.proxmox.com` in `/etc/apt/sources.list` and `sources.list.d`, and contrasts the result with the status returned by `pvesubscription get`.", + "rationale": "Références à `enterprise.proxmox.com` dans `/etc/apt/sources.list` et `sources.list.d`, face au statut renvoyé par `pvesubscription get`.", "summary": { "notEnabled": "The enterprise repository is not enabled", "subscribed": "The enterprise repository is backed by a subscription", - "unsubscribed": "The enterprise repository is enabled without an active subscription" + "unsubscribed": "The enterprise repository is enabled without an active subscription", + "evaluationFailed": "The check could not be evaluated" + } + }, + "memory_overcommit": { + "title": "Memory allocation", + "rationale": "Le plafond `memory` de chaque configuration d'invité face à MemTotal, les invités en cours comptés séparément. Les conteneurs consomment jusqu'à cette limite ; les machines virtuelles sans ballooning la réservent.", + "summary": { + "withinRatio": "Guests are allocated {percent}% of host memory", + "aboveRatio": "Guests are allocated {percent}% of host memory", + "evaluationFailed": "The check could not be evaluated" + } + }, + "time_synchronisation": { + "title": "Time synchronisation", + "rationale": "NTP et NTPSynchronized tels que `timedatectl` les rapporte. L'appartenance au cluster, la validation des certificats et l'ordre des journaux dépendent d'horloges concordantes. Un autre mécanisme peut discipliner l'horloge.", + "summary": { + "synchronised": "The clock is synchronised with a time source", + "disabled": "Time synchronisation is disabled", + "notSynchronised": "Time synchronisation is enabled but the clock is not synchronised", + "evaluationFailed": "The check could not be evaluated" + } + }, + "kernel_current": { + "title": "Running kernel", + "rationale": "Le noyau en cours face à celui que l'hôte démarrerait ensuite, tel que `proxmox-boot-tool` le rapporte. Un noyau plus récent seulement installé peut être retenu délibérément ; un écart après un redémarrage indique un démarrage qui n'a pas pris.", + "summary": { + "current": "Le noyau en cours {version} est celui que l'hôte démarrerait ensuite", + "newerAvailable": "The host runs {running} while {newest} is installed", + "newerSelected": "L'hôte exécute {running} et démarrerait {selected} au prochain redémarrage", + "wouldDowngrade": "L'hôte exécute {running} mais démarrerait le plus ancien {selected} au prochain redémarrage", + "bootTargetUnknown": "L'hôte exécute {version} ; le noyau choisi pour le prochain démarrage n'a pas pu être lu", + "evaluationFailed": "The check could not be evaluated" + } + }, + "security_updates": { + "title": "Security updates", + "rationale": "Paquets en attente dont l'origine est un dépôt de sécurité, à partir d'un `apt-get upgrade` simulé. Le nombre reflète ce que rapporte apt, non la gravité de ce que chaque paquet corrige.", + "summary": { + "none": "No package updates are pending", + "noSecurity": "{total} update(s) pending, none from a security repository", + "pending": "{count} of {total} pending update(s) come from a security repository", + "evaluationFailed": "The check could not be evaluated" + } + }, + "journal_size": { + "title": "Journal size", + "rationale": "Le journal sur disque face au plafond qui s'y applique : SystemMaxUse lorsqu'il est défini, sinon la valeur par défaut de journald, un dixième du système de fichiers qui l'héberge.", + "summary": { + "bounded": "Le journal occupe {size}, en deçà de son plafond effectif", + "large": "The journal holds {size} on disk", + "nearCap": "Le journal occupe {size} et atteint {percent}% de son plafond effectif", + "capUnknown": "Le journal occupe {size} ; son plafond effectif n'a pas pu être déterminé", + "evaluationFailed": "The check could not be evaluated" + } + }, + "swap_configured": { + "title": "Swap", + "rationale": "Zones d'échange actives selon `swapon` et leur total face à la mémoire de l'hôte. Aucune règle n'impose de proportion par rapport à la RAM ; la pression mémoire se mesure ailleurs.", + "summary": { + "active": "{size} of swap is active", + "none": "No swap area is active", + "evaluationFailed": "The check could not be evaluated" + } + }, + "filesystem_capacity": { + "title": "Capacité des systèmes de fichiers de l'hôte", + "rationale": "Espace et inodes des systèmes de fichiers dont l'hôte a besoin : la racine, /var, /var/log et le chemin du stockage local. Un système de fichiers avec de l'espace libre et sans inodes tombe en panne comme un système plein.", + "summary": { + "withinLimits": "Les {total} systèmes de fichiers de l'hôte restent sous leurs seuils d'examen", + "pressure": "{count} relevés atteignent ou dépassent leur seuil d'examen", + "evaluationFailed": "L'occupation des systèmes de fichiers n'a pas pu être lue" + } + }, + "update_chain": { + "title": "Âge des index de paquets APT", + "rationale": "Quand APT a placé pour la dernière fois un index de paquets sur cet hôte. Un dépôt qui répond « non modifié » laisse son index intact. L'accessibilité des dépôts n'est pas testée.", + "summary": { + "current": "Les index de paquets ont été actualisés il y a {days} jour(s)", + "stale": "Les index de paquets ont été actualisés il y a {days} jour(s)", + "indexAgeUnknown": "L'ancienneté des index de paquets n'a pas pu être déterminée" + } + }, + "notification_delivery": { + "title": "Dernier résultat de notification", + "rationale": "Canaux activés et résultat de leur dernier envoi conservé. Sans historique, la remise reste non vérifiée ; un échec suivi d'un succès n'est pas considéré comme un échec actuel. Aucune notification de test n'est envoyée.", + "summary": { + "delivering": "Le dernier envoi enregistré a réussi pour les {total} canaux activés", + "failing": "{count} des {total} canaux activés présentent un problème de configuration ou un échec lors du dernier envoi", + "noChannels": "Aucun canal de notification n'est activé", + "evaluationFailed": "L'historique des envois n'a pas pu être lu" + } + }, + "cluster_quorum": { + "title": "Quorum du cluster", + "rationale": "Le quorum tel que le cluster le rapporte, les nœuds configurés face à ceux actuellement vus, et le nombre de liens corosync déclarés. Les liens sont lus dans la configuration, non sondés.", + "summary": { + "standalone": "Ce nœud n'appartient à aucun cluster", + "quorate": "Le cluster a le quorum avec {total} nœud(s) configuré(s) sur {links} lien(s) corosync", + "attention": "{count} constat(s) sur {total} nœud(s) configuré(s)", + "evaluationFailed": "L'état du cluster n'a pas pu être lu" + } + }, + "boot_loader": { + "title": "Chargeur d'amorçage", + "rationale": "Les partitions système EFI que rapporte proxmox-boot-tool et les noyaux que chacune porte. Aucune partition n'est montée et aucun amorçage n'est tenté.", + "summary": { + "synchronised": "Les {total} partitions d'amorçage portent les mêmes noyaux", + "attention": "{count} partitions d'amorçage sur {total} demandent un examen", + "evaluationFailed": "The check could not be evaluated" + } + }, + "failed_units": { + "title": "Services essentiels et unités en échec", + "rationale": "Unités que systemd a abandonnées après avoir épuisé ses relances, et services dont Proxmox a besoin pour répondre, lus par leur nom car un service inactif n'est pas toujours en échec. Ce que fait chaque unité n'est pas interprété ici.", + "summary": { + "allRunning": "Les {total} services essentiels sont actifs et aucune unité n'est en échec", + "attention": "{count} constat(s) parmi les unités", + "evaluationFailed": "The check could not be evaluated" + } + }, + "ha_state": { + "title": "Haute disponibilité", + "rationale": "Le maître HA, le gestionnaire de ressources de chaque nœud et l'état de chaque service géré, d'après `ha-manager status`. Le quorum relève de la vérification du cluster. Aucun service n'est démarré, arrêté ni migré.", + "summary": { + "managed": "Les {total} services gérés sont dans un état stabilisé sur {nodes} nœud(s)", + "attention": "{count} constat(s) sur {total} service(s) géré(s)", + "evaluationFailed": "The check could not be evaluated" } } }, "guests": { "privileged_containers": { "title": "Container privileges", - "rationale": "Reviews each container configuration. Proxmox marks unprivileged containers with `unprivileged: 1`; its absence indicates a privileged container, which shares the host user namespace.", + "rationale": "Le réglage `unprivileged` de chaque configuration de conteneur. Son absence signifie que le conteneur partage l'espace de noms utilisateur de l'hôte, ce dont certaines charges ont besoin.", "summary": { "allUnprivileged": "All {total} containers are unprivileged", - "privileged": "{count} of {total} containers run privileged" + "privileged": "{count} of {total} containers run privileged", + "evaluationFailed": "The check could not be evaluated" } }, "qemu_without_agent": { "title": "Guest agent on virtual machines", - "rationale": "Reviews each virtual machine configuration for `agent: 1`. The guest agent enables ordered shutdown, filesystem quiescing for snapshots and real disk usage reporting.", + "rationale": "Le réglage `agent` dans la configuration de chaque machine virtuelle. Le réglage indique que l'agent est déclaré, non qu'il répond.", "summary": { "allHaveAgent": "All {total} virtual machines declare the guest agent", - "missingAgent": "{count} of {total} virtual machines do not declare the guest agent" + "missingAgent": "{count} of {total} virtual machines do not declare the guest agent", + "evaluationFailed": "The check could not be evaluated" + } + }, + "autostart": { + "title": "Automatic start", + "rationale": "Le réglage `onboot` de chaque invité, hors modèles et invités gérés par HA. Qu'un invité doive revenir de lui-même relève de la politique déclarée.", + "summary": { + "allAutostart": "All {total} guests start with the host", + "notAutostart": "{count} of {total} guests do not start with the host", + "evaluationFailed": "The check could not be evaluated" + } + }, + "stuck_snapshots": { + "title": "Snapshot state", + "rationale": "État et ancienneté des snapshots ainsi que les tâches actives. Une opération récente ou sans date vérifiable n'est pas considérée comme interrompue.", + "summary": { + "noSnapshots": "No guest holds snapshots", + "allComplete": "The {total} snapshot(s) are complete", + "stuck": "{count} of {total} snapshot(s) were left mid-operation", + "evaluationFailed": "The check could not be evaluated" + } + }, + "cpu_host_type": { + "title": "Virtual CPU model", + "rationale": "La valeur `cpu` de chaque machine virtuelle. `host` expose le jeu d'instructions du processeur physique, ce qui restreint les nœuds vers lesquels l'invité peut migrer. L'incompatibilité avec une destination précise n'est pas déterminée ici.", + "summary": { + "none": "None of the {total} virtual machines is pinned to the host processor", + "pinned": "{count} of {total} virtual machines are pinned to the host processor", + "evaluationFailed": "The check could not be evaluated" + } + }, + "replication_state": { + "title": "Replication", + "rationale": "Tâches de réplication issues de l'API : nombre d'échecs, dernière erreur, dernière synchronisation et le calendrier que chaque tâche déclare. Les tâches en pause sont signalées comme telles.", + "summary": { + "healthy": "The {total} replication job(s) report no error", + "failing": "{count} of {total} replication job(s) report an error", + "statusUnavailable": "Replication jobs are defined but their status could not be read", + "evaluationFailed": "The check could not be evaluated" } } }, "security": { "host_firewall_enabled": { - "title": "Firewall state", - "rationale": "Checks `enable: 1` in `/etc/pve/firewall/cluster.fw` and in the node's `host.fw`. Proxmox applies node rules only while the datacenter switch is on.", + "title": "Activation configurée du pare-feu", + "rationale": "L'option `enable` dans le pare-feu du centre de données et dans celui du nœud, et le nombre de règles écrites. Proxmox n'applique les règles du nœud que si l'interrupteur du centre de données est actif. Ces options n'indiquent pas ce que filtre une règle.", "summary": { - "bothEnabled": "The firewall is enabled at datacenter and node level", - "datacenterOff": "The firewall is disabled at datacenter level, so node rules are not applied", - "nodeOff": "The firewall is enabled at datacenter level but not on this node" + "bothEnabled": "L'activation du pare-feu est configurée au niveau du datacenter et du nœud", + "datacenterOff": "Le pare-feu est désactivé au niveau du datacenter ; les règles du nœud ne sont donc pas appliquées", + "nodeOff": "Le pare-feu est activé au niveau du datacenter, mais pas sur ce nœud", + "evaluationFailed": "The check could not be evaluated" + } + }, + "lynis_warnings": { + "title": "Lynis warnings", + "rationale": "Avertissements du dernier audit Lynis, chacun avec son identifiant de test, et l'ancienneté de cet audit. Un audit n'est lancé que si Lynis est installé et qu'aucun rapport complet n'existe. Les suggestions ne sont pas incluses.", + "summary": { + "none": "The last Lynis audit recorded no warnings", + "found": "The last Lynis audit recorded {count} warning(s)", + "incomplete": "The Lynis report is incomplete", + "evaluationFailed": "The check could not be evaluated", + "noneStale": "Le dernier audit Lynis n'a relevé aucun avertissement, et son rapport a {days} jour(s)", + "foundStale": "Le dernier audit Lynis a relevé {count} avertissement(s), et son rapport a {days} jour(s)" + } + }, + "certificate_expiry": { + "title": "Certificate validity", + "rationale": "La date d'expiration du certificat que pveproxy sert depuis /etc/pve/local. Un certificat personnalisé prime sur celui que Proxmox génère.", + "summary": { + "valid": "The certificate is valid for {days} more day(s)", + "expiring": "The certificate expires in {days} day(s)", + "expired": "The certificate expired {days} day(s) ago", + "evaluationFailed": "The check could not be evaluated" + } + }, + "ssh_root_login": { + "title": "SSH root access", + "rationale": "PermitRootLogin dans la configuration effective de `sshd -T`, avec les méthodes d'authentification associées. Proxmox est livré avec `yes`, qui accepte un mot de passe.", + "summary": { + "password": "Root may sign in over SSH with a password", + "keyOnly": "Root may sign in over SSH with a key only", + "denied": "Root may not sign in over SSH", + "evaluationFailed": "The check could not be evaluated" } } }, "storage": { "orphaned_volumes": { "title": "Volume assignment", - "rationale": "Compares the volumes returned by `pvesm list` against the existing guest configurations. Only non-shared storage is examined: on shared storage a volume may belong to a guest running on another node.", + "rationale": "Volumes d'invité sur le stockage local face aux références des configurations actuelles, en attente et de snapshots. Les sauvegardes, ISO et modèles restent hors de la comparaison. Un volume sans référence est un candidat à examiner.", "summary": { "none": "No orphaned volumes were found", - "found": "{count} volume(s) belong to no existing guest" + "found": "{count} volumes sans référence dans les configurations examinées", + "evaluationFailed": "The check could not be evaluated" + } + }, + "zfs_arc_max": { + "title": "ZFS ARC limit", + "rationale": "Valeurs effectives de c_min, c_max et taille de l'ARC, le paramètre de module chargé et les réglages persistants de /etc/modprobe.d. Une valeur configurée à zéro sélectionne la valeur par défaut du module ; l'ARC est un plafond et la mémoire qu'il occupe est récupérable.", + "summary": { + "bounded": "La limite de l'ARC représente {percent}% de la mémoire de l'hôte, et la mémoire qu'elle occupe est récupérable", + "high": "The ARC may use {percent}% of host memory", + "unset": "The ARC has no explicit limit set", + "conflicting": "{count} réglages ARC ne concordent pas entre eux", + "pending": "Un réglage ARC persistant diffère de la valeur portée par le module en cours", + "evaluationFailed": "The check could not be evaluated" + } + }, + "zfs_scrub_age": { + "title": "ZFS scrub", + "rationale": "Le dernier scrub terminé que `zpool status` rapporte pour chaque pool. Un resilver n'est pas un scrub. Un pool créé récemment n'a pas encore eu l'occasion d'en effectuer un.", + "summary": { + "recent": "The {total} pool(s) were scrubbed within the last 35 days", + "overdue": "{count} of {total} pool(s) have not been scrubbed in 35 days", + "neverScrubbed": "{count} pool(s) record no scrub", + "evaluationFailed": "The check could not be evaluated" + } + }, + "thin_pool_overprovisioning": { + "title": "Thin pool allocation", + "rationale": "Capacité virtuelle distribuée par chaque pool léger LVM face à la taille du pool, et la part réellement écrite par ses volumes, données et métadonnées séparément.", + "summary": { + "withinRatio": "Les {total} thin pools restent sous les seuils d'examen appliqués", + "aboveRatio": "{count} pools légers sur {total} distribuent plus de capacité qu'ils n'en possèdent", + "pressure": "{pressure} pools légers sur {total} approchent le remplissage de leurs données ou métadonnées", + "evaluationFailed": "The check could not be evaluated" + } + }, + "connected_storage": { + "title": "Stockages connectés", + "rationale": "Disponibilité telle que PVE la rapporte, capacité connue et dépendances actuelles de tous les stockages activés sur ce nœud. Les composants internes distants ne sont pas sondés et l'accès en écriture n'est pas testé. La capacité est indiquée là où PVE la connaît et laissée vide sinon.", + "summary": { + "available": "PVE indique que les {total} stockages sont disponibles ; les composants internes distants et l'accès en écriture n'ont pas été testés", + "attention": "{count} stockages sur {total} nécessitent un examen", + "evaluationFailed": "The check could not be evaluated" + } + }, + "pool_integrity": { + "title": "Intégrité et redondance des pools", + "rationale": "L'état de chaque pool ZFS et les compteurs de lecture, d'écriture et de somme de contrôle de ses périphériques. Les compteurs sont cumulatifs depuis le dernier `zpool clear`.", + "summary": { + "healthy": "Les {total} pools sont en ligne, sans erreur de périphérique relevée", + "degraded": "{count} constats sur {total} pools", + "evaluationFailed": "L'état des pools n'a pas pu être lu" + } + }, + "ceph_health": { + "title": "Santé de Ceph", + "rationale": "L'état de santé de Ceph lui-même et les vérifications qu'il nomme. Ses tests ne sont pas réimplémentés et aucun pool, groupe de placement ou OSD n'est interrogé séparément.", + "summary": { + "healthy": "Ceph rapporte HEALTH_OK", + "degraded": "Ceph rapporte {state}, avec {count} vérification(s) nommée(s)", + "evaluationFailed": "The check could not be evaluated" + } + }, + "array_integrity": { + "title": "RAID logiciel et multipath", + "rationale": "Grappes mdadm et cartes multipath, lues dans /proc/mdstat et, là où l'outil est installé, dans `multipath -ll`. Les pools ZFS relèvent de leur propre vérification.", + "summary": { + "intact": "Les {total} grappes et cartes conservent leur redondance", + "degraded": "{count} grappes ou cartes sur {total} en manquent", + "evaluationFailed": "The check could not be evaluated" + } + } + }, + "hardware": { + "disk_service_life": { + "title": "Disk service life", + "rationale": "Heures de fonctionnement et usure restante d'après les relevés SMART du moniteur, avec la date de chaque relevé. L'âge est une information de planification ; les erreurs de support et les avertissements du périphérique sont rapportés par le moniteur de santé.", + "summary": { + "withinLife": "Les {total} relevés de disques ne dépassent pas le seuil indicatif de cinq ans", + "pastLife": "{count} relevés de disques sur {total} dépassent cinq ans de service", + "noReadings": "Aucun disque ne rapporte de compteurs SMART exploitables ({skipped} sans relevés)", + "evaluationFailed": "The check could not be evaluated" + } + }, + "disk_errors": { + "title": "Erreurs de disque", + "rationale": "Erreurs détectées sur les disques et enregistrées par le moniteur de santé.", + "summary": { + "recorded": "{count} disques sur {total} avec enregistrement ont des événements notés", + "noEvents": "Plus aucun événement de disque à évaluer : aucun enregistré, ou tous écartés" + } + } + }, + "network": { + "bond_members": { + "title": "Bond members", + "rationale": "L'état MII de chaque membre du bond selon /proc/net/bonding et le nombre de liens restants. En active-backup, un membre de secours se déclare actif et ne transporte rien.", + "summary": { + "allUp": "All members of the {total} bond(s) are up", + "membersDown": "{count} bond member(s) are not up", + "evaluationFailed": "The check could not be evaluated" + } + }, + "bridge_without_ports": { + "title": "Bridge ports", + "rationale": "La configuration des ports de chaque bridge. Un bridge sans port physique dessert un réseau interne ou routé.", + "summary": { + "allConnected": "The {total} bridge(s) carry a port", + "isolated": "{count} of {total} bridge(s) carry no port", + "evaluationFailed": "The check could not be evaluated" } } } @@ -5041,6 +5558,389 @@ "expiry365": "1 year", "cancel": "Cancel", "confirm": "Accept risk" - } + }, + "incomplete": "Preuves incomplètes", + "progress": "{completed} sur {total} vérifiés", + "expires": "Expire le {when}", + "runStates": { + "partial": "L'évaluation s'est déroulée ; certains relevés n'ont pas pu être pris.", + "failed": "Évaluation interrompue ou échouée. Examinez les preuves avant d’utiliser les résultats." + }, + "severities": { + "CRITICAL": "Critique", + "WARNING": "Avertissement", + "INFO": "Information", + "OK": "OK" + }, + "viewSwitch": { + "ariaLabel": "Switch between assessment and inventory", + "assessment": "Assessment", + "inventory": "Inventory", + "policy": "Politique", + "changes": "Modifications" + }, + "inventory": { + "loading": "Loading inventory…", + "failed": "The inventory could not be composed.", + "collectedAt": "Composed on {when}", + "unavailable": "Not read in this inventory", + "unresolved": "path not resolved", + "noUplink": "no uplink", + "identity": "Node identity", + "node": "Node", + "pveVersion": "Proxmox VE", + "kernel": "Kernel", + "subscription": "Subscription", + "cluster": "Cluster", + "standalone": "Not in a cluster", + "hardware": "Hardware", + "system": "System", + "serial": "Serial number", + "bios": "BIOS", + "cpu": "Processor", + "topology": "Layout", + "cpuLayout": "{sockets} socket(s) × {cores} cores = {threads} threads", + "virtualisation": "Virtualisation", + "memory": "Memory", + "iommuGroups": "IOMMU groups", + "network": "Network", + "storage": "Storage", + "name": "Name", + "type": "Type", + "content": "Content", + "shared": "Shared", + "yes": "Yes", + "no": "No", + "guests": "Guests", + "ostype": "Operating system", + "onboot": "Starts with host", + "tags": "Tags", + "privilege": "Privilege", + "privileged": "Privileged", + "unprivileged": "Unprivileged", + "features": "Features", + "agent": "Guest agent", + "cpuModel": "CPU model", + "disks": "Disks", + "interfaces": "Network interfaces", + "protection": "Backup", + "passthrough": "Passthrough", + "noBackup": "No backup", + "noBackupDetail": "No enabled backup job selects this guest.", + "applications": "Applications", + "versionUnknown": "version not detected", + "passthroughTitle": "PCI passthrough", + "iommuGroup": "IOMMU group {group}", + "sharedGroup": "{count} more device(s) in the same group", + "proxmenux": "ProxMenux optimizations", + "latency": "Latence réseau", + "subscriptionStatus": { + "notfound": "Aucun abonnement", + "active": "Actif", + "invalid": "Non valide", + "expired": "Expiré", + "suspended": "Suspendu", + "new": "En attente d'activation", + "unknown": "Inconnu" + } + }, + "profile": { + "label": "Report", + "full": "Full audit", + "inventory": "Inventory", + "security": "Security review", + "backup": "Backup assurance", + "capacity": "Capacity and wear", + "diagnostic": "Diagnostic rapide" + }, + "document": { + "action": "Générer le rapport", + "title": "Rapport d'audit", + "subtitle": "Structure, configuration et évaluation de {node}", + "generated": "Généré", + "assessed": "Assessed", + "runId": "Assessment reference", + "print": "Print or save as PDF", + "summaryByArea": "Summary by area", + "area": "Area", + "inventoryAnnex": "Inventory annex", + "scopeTitle": "Scope of this report", + "scopeBody": "This report describes the Proxmox VE node named above, as observed from the node itself at the time stated. It does not cover the interior of the guests beyond what they declare, network equipment outside the host, physical infrastructure, or any dependency not visible from this node. Findings marked as not determined were not measured and are not evidence of absence.", + "building": "Composition du rapport…", + "node": "Nœud", + "profile": "Profil", + "unknownNode": "nœud non identifié", + "executiveSummary": "Résumé de l'évaluation", + "assessment": "Évaluation", + "verdictHeading": "Résultat de cette exécution", + "verdict": { + "critical": "ATTENTION", + "warning": "À REVOIR", + "conformant": "CONFORME" + }, + "verdictText": { + "critical": "{fail} contrôles signalent une condition en échec et {warn} une condition à revoir, sur {total} évalués.", + "warning": "Aucun contrôle ne signale de condition en échec. {warn} sur {total} signalent une condition à revoir.", + "conformant": "Les {total} contrôles de ce profil s'achèvent sans condition en échec ni à revoir.", + "none": "Ce profil n'exécute aucun contrôle. Le document décrit le nœud sans l'évaluer." + }, + "runAt": "Exécuté le {date}", + "chartNote": "Contrôles par domaine et par résultat.", + "nodeIdentity": "Identité du nœud", + "system": "Système", + "cluster": "Cluster", + "standaloneNote": "Ce nœud ne fait pas partie d'un cluster : il conserve sa propre configuration et ses invités ne migrent pas vers un autre nœud.", + "clusterDiagramNote": "Nœuds configurés et liens corosync qui les relient.", + "thisNode": "ce nœud", + "unreachable": "non vu", + "member": "membre", + "corosyncLinks": "liens", + "quorum": "Quorum", + "quorate": "avec quorum", + "inquorate": "sans quorum", + "votes": "Votes", + "nodeName": "Nœud", + "architecture": "Architecture du système", + "architectureNote": "Comment le nœud est assemblé : processeur et mémoire sur la carte, et ce qui dépend de chaque contrôleur.", + "systemIdentity": "Identité du système", + "board": "Carte mère", + "processor": "Processeur", + "topology": "Sockets × cœurs / threads", + "memory": "Mémoire", + "cores": "cœurs", + "threads": "threads", + "memoryModules": "Modules de mémoire", + "slot": "Emplacement", + "slotsUsed": "emplacements occupés", + "slotsFilled": "{used} emplacements occupés sur {total}", + "emptySlot": "vide", + "formFactor": "Format", + "speed": "Vitesse", + "manufacturer": "Fabricant", + "product": "Modèle", + "serial": "Numéro de série", + "controllers": "Contrôleurs", + "class": "Classe", + "device": "Périphérique", + "iommuGroups": "Groupes IOMMU", + "iommuGroup": "Groupe IOMMU", + "field": "Champ", + "value": "Valeur", + "size": "Taille", + "type": "Type", + "storageDevices": "Périphériques de stockage", + "disks": "Disques", + "model": "Modèle", + "bus": "Bus", + "serviceLife": "Heures de service", + "healthy": "sain", + "years": "{years} ans", + "events": "Événements", + "observations": "Observations", + "observationsNote": "Événements enregistrés. SMART rapporte l'état actuel ; ce journal rapporte ce qui s'est produit, y compris les événements dont le disque s'est rétabli.", + "noObservations": "Aucun événement enregistré", + "noObservationsNote": "Aucun disque n'a enregistré d'erreur depuis que le moniteur les observe.", + "event": "Événement", + "severity": "Gravité", + "occurrences": "Occurrences", + "firstSeen": "Première fois", + "lastSeen": "Dernière fois", + "detail": "Détail", + "network": "Réseau", + "adapters": "Adaptateurs", + "bond": "Bond", + "bridge": "Bridge", + "bridges": "Bridges", + "physicalAdapters": "Adaptateurs physiques", + "interface": "Interface", + "driver": "Pilote", + "state": "État", + "networkDiagramNote": "Chemin du câble jusqu'à chaque invité : adaptateur physique, bond lorsqu'il les regroupe, bridge et invités raccordés.", + "storageAndProtection": "Stockage et protection", + "storage": "Stockage", + "content": "Contenu", + "shared": "Partagé", + "location": "Emplacement", + "backupDestination": "Destination des sauvegardes", + "unprotected": "sans sauvegarde", + "storageDiagramNote": "Où résident les disques des invités et quelle destination les sauvegarde.", + "unprotectedGuests": "{count} invités sans tâche de sauvegarde", + "allProtected": "Tous les invités sont couverts par une tâche de sauvegarde", + "allProtectedNote": "La couverture indique qu'une tâche sélectionne l'invité ; le résultat des sauvegardes est évalué séparément.", + "vmid": "VMID", + "name": "Nom", + "kind": "Type", + "backup": "Sauvegarde", + "none": "aucune", + "managedSoftware": "Logiciels gérés par ProxMenux", + "version": "Version", + "source": "Source", + "current": "à jour", + "updateAvailable": "mettre à jour vers {version}", + "findings": "Constats en détail", + "incomplete": "partiel", + "scope": "Portée de ce rapport", + "scopeText": "Ce document rend compte du profil {profile} sur le nœud indiqué dans l'en-tête, au moment de l'exécution.", + "scopeLocal": "Il ne couvre que ce nœud. Les invités des autres nœuds et leur configuration en sont exclus.", + "scopeReadOnly": "Tous les contrôles lisent une configuration et un état déjà existants ; aucun ne modifie l'hôte.", + "scopeMoment": "Il décrit l'état au moment de l'exécution, et non une période.", + "notRead": "Sources illisibles :", + "uplink": "Liaison montante", + "conformance": "{pass} sur {total} conformes", + "latency": "Latence réseau", + "latencyNote": "Latence mesurée sur la fenêtre indiquée, une courbe par cible.", + "milliseconds": "ms", + "hours": "h", + "minimum": "Minimum", + "average": "Moyenne", + "maximum": "Maximum", + "packetLoss": "Perte de paquets", + "samples": "Relevés", + "target": { + "label": "Cible", + "gateway": "Passerelle", + "cloudflare": "Cloudflare (1.1.1.1)", + "google": "Google (8.8.8.8)" + }, + "actionShort": "Rapport", + "policyDeclared": "L'évaluation s'est faite face à une politique déclarée : {guests} invités, {storages} stockages et {thresholds} seuils indiqués.", + "policyNone": "Aucune politique n'a été déclarée ; une absence que ce rapport ne peut interpréter est indiquée comme observation et jamais comme avertissement.", + "diagnosticTitle": "Diagnostic rapide", + "diagnosticSubtitle": "Résultats critiques et avertissements sur {node}", + "diagnosticActing": "{count} résultat(s) critique(s) et avertissement(s).", + "diagnosticClear": "Aucun résultat critique ni avertissement. Les observations et les résultats conformes figurent dans l'audit complet.", + "diagnosticUnread": "Relevés impossibles à prendre", + "diagnosticMoreRows": "{count} ligne(s) de plus, dans l'audit complet.", + "structureTitle": "Structure et configuration", + "structureSubtitle": "Comment {node} est construit et configuré" + }, + "results": "Résultats", + "classifications": { + "critical": "Critique", + "warning": "Avertissement", + "observation": "Observation", + "conformant": "Conforme", + "unverified": "Non vérifié", + "not_applicable": "Non applicable", + "accepted": "Risque accepté", + "by_design": "Exclu par la politique" + }, + "policy": { + "inherit": "{value} (par défaut)", + "inheritUnset": "Par défaut", + "conflict": "La déclaration a été modifiée dans une autre session. Votre brouillon n’a pas été enregistré.", + "reload": "Recharger la déclaration enregistrée (abandonner le brouillon)", + "intro": "Une évaluation voit ce que fait cet hôte, pas à quoi il sert. Ce qui est déclaré ici transforme une observation en avertissement, ou la retire du décompte. Rien n'est obligatoire : sans déclaration, le rapport décrit au lieu de juger.", + "loading": "Lecture de la déclaration…", + "failed": "La déclaration n'a pas pu être lue", + "saved": "Enregistré", + "declaredCount": "{count} déclarations", + "guestsNote": "Requis signale ce qui manque comme un avertissement ; non déclaré le signale comme une observation ; non requis le laisse hors du décompte.", + "storagesNote": "Un stockage inaccessible est critique lorsqu'il est déclaré essentiel ou sert un invité en marche, un avertissement lorsque son rôle n'est pas déclaré, et une observation lorsqu'il est déclaré optionnel.", + "thresholds": "Seuils", + "thresholdsNote": "Vide signifie la valeur d'origine, affichée en repère.", + "backup": "Sauvegarde", + "autostart": "Démarrage auto", + "objective": "Objectif de reprise", + "objectivePlaceholder": "heures", + "noGuests": "Ce nœud n'héberge aucun invité.", + "expectation": { + "required": "Requis", + "not_required": "Non requis", + "unspecified": "Non déclaré" + }, + "role": { + "essential": "Essentiel", + "optional": "Optionnel", + "unspecified": "Non déclaré" + }, + "threshold": { + "storage_usage_percent": "Seuil d'examen de capacité (%)", + "thin_pool_usage_percent": "Seuil d'examen de remplissage du pool léger (%)", + "thin_overprovision_ratio": "Ratio de surallocation légère", + "zfs_scrub_days": "Intervalle de scrub ZFS (jours)", + "backup_fallback_days": "Délai de repli pour l'ancienneté (jours)", + "backup_schedule_grace_ratio": "Marge sur le calendrier (ratio)", + "certificate_expiry_days": "Préavis d'expiration du certificat (jours)", + "memory_overcommit_ratio": "Ratio de surallocation mémoire", + "disk_service_life_hours": "Durée de vie du disque (heures)", + "lynis_report_days": "Ancienneté du rapport Lynis (jours)", + "package_index_days": "Ancienneté des index de paquets (jours)", + "journal_usage_percent": "Journal face à son plafond (%)", + "filesystem_usage_percent": "Seuil d'examen d'espace (%)", + "filesystem_inode_percent": "Seuil d'examen d'inodes (%)", + "disk_error_recent_days": "Fenêtre des erreurs de disque récentes (jours)" + } + }, + "changes": { + "loading": "Lecture du journal des modifications…", + "failed": "Le journal des modifications n'a pas pu être lu", + "intro": "Ce que ProxMenux a changé sur cet hôte et ce qui existait avant chaque changement. C'est la différence qui est montrée, non le script qui l'a appliquée.", + "empty": "Rien n'a encore été enregistré sur cet hôte.", + "since": "Enregistrement depuis le {date}. Ce qui a été appliqué avant figure comme appliqué, sans l'état remplacé.", + "byFunction": "Par fonction", + "count": "{count} modifications", + "function": "Fonction", + "source": "Script", + "reversibility": "Annuler ceci", + "difference": "Différence", + "diffTruncated": "La différence est plus longue que ce qui est montré.", + "diffUnavailable": "Le contenu remplacé n'est plus stocké, la différence ne peut pas être montrée.", + "packagesAdded": "Paquets ajoutés", + "commandRun": "Commande exécutée", + "executionNote": "ProxMenux l'a exécutée à la demande ; ce qui a changé a été décidé par la commande, pas par ProxMenux.", + "unknownNote": "Ceci a été appliqué avant l'existence du journal ; l'état remplacé n'a jamais été capturé.", + "noneInFilter": "Aucune modification de ce type.", + "class": { + "all": "Toutes", + "configuration": "Configuration", + "installation": "Installations", + "execution": "Exécutions", + "registration": "Appliqué" + }, + "operation": { + "write_file": "Fichier remplacé", + "edit_file": "Fichier modifié", + "remove_file": "Fichier supprimé", + "install_package": "Installé", + "enable_service": "Service activé", + "disable_service": "Service désactivé", + "run_command": "Exécuté", + "applied": "Appliqué", + "removed": "Supprimé", + "unknown": "Modification" + }, + "capture": { + "unknown": "État précédent inconnu" + }, + "exactness": { + "exact": "Restaure exactement ce qui était là", + "partial": "Partiel : des dépendances peuvent rester ou partir avec", + "none": "Ne peut pas être annulé depuis le journal" + } + }, + "comparison": { + "loading": "Comparaison avec l'exécution de référence…", + "failed": "L'exécution de référence n'a pas pu être définie", + "since": "Depuis le {date}", + "previousRun": "l'exécution précédente", + "noChange": "Aucun changement", + "isBaseline": "Cette exécution est la référence à laquelle les autres sont comparées.", + "noBaseline": "Aucune exécution de référence n'a encore été choisie, il n'y a donc rien à comparer.", + "setBaseline": "Utiliser comme référence", + "unchanged": "{count} contrôles ont donné le même résultat qu'avant.", + "new": "Nouveaux", + "newNote": "signalés maintenant et pas avant", + "resolved": "Résolus", + "resolvedNote": "plus signalés, et personne ne les a acceptés", + "accepted": "Acceptés", + "acceptedNote": "ne comptent plus parce qu'un risque a été accepté, non parce que l'hôte a changé", + "retired": "Plus évalués", + "retiredNote": "présents avant et absents de cette exécution ; rien n'a vérifié qu'ils avaient cessé", + "reasons": { + "insufficient_runs": "Une comparaison exige une exécution de référence et une postérieure ; une seule est enregistrée pour l'instant" + } + }, + "notApplicableScope": "Rien dans le périmètre examiné auquel cette vérification s'applique." } } diff --git a/AppImage/messages/it/common.json b/AppImage/messages/it/common.json index 477fabd9..0612724a 100644 --- a/AppImage/messages/it/common.json +++ b/AppImage/messages/it/common.json @@ -309,7 +309,7 @@ "shortTest": "Test breve", "longTest": "Test lungo (1-4 ore)", "extendedTest": "Test esteso", - "testHelp": "un breve test dura circa 2 minuti.Il test esteso viene eseguito in background e può richiedere diverse ore su dischi di grandi dimensioni.Il risultato verrà visualizzato nella scheda Cronologia al termine.", + "testHelp": "un breve test dura circa 2 minuti. Il test esteso viene eseguito in background e può richiedere diverse ore su dischi di grandi dimensioni. Il risultato verrà visualizzato nella scheda Cronologia al termine.", "startFailed": "Impossibile avviare il test", "short": "Corto", "extended": "Esteso", @@ -1103,7 +1103,7 @@ "backupStartFailed": "Impossibile avviare il backup: {message}", "controlFailed": "Impossibile eseguire {action} VM {vmid}: {message}", "saveNotesFailed": "Errore durante il salvataggio delle note. Per favore riprova.", - "appNotFound": "questa applicazione non è più disponibile.Aggiorna la pagina e riprova.", + "appNotFound": "questa applicazione non è più disponibile. Aggiorna la pagina e riprova.", "saveCustomCommandFailed": "impossibile salvare il comando di aggiornamento personalizzato: {message}", "removeCustomCommandConfirm": "rimuovere il comando di aggiornamento personalizzato per \"{name}\"?", "removeCustomCommandFailed": "impossibile rimuovere il comando di aggiornamento personalizzato: {message}", @@ -1217,10 +1217,18 @@ "saveFailed": "impossibile salvare gli aggiornamenti pianificati.", "deleteFailed": "impossibile rimuovere gli aggiornamenti pianificati.", "humanDaily": "tutti i giorni alle {time}", - "humanWeekly": "testo tecnico dell'interfaccia utente per una dashboard di gestione Proxmox.Traduzione: settimanale ({day} {time})", - "humanMonthly": "testo tecnico dell'interfaccia utente per una dashboard di gestione Proxmox.Traduzione: mensile (giorno {day} alle {time})", + "humanWeekly": "testo tecnico dell'interfaccia utente per una dashboard di gestione Proxmox. Traduzione: settimanale ({day} {time})", + "humanMonthly": "testo tecnico dell'interfaccia utente per una dashboard di gestione Proxmox. Traduzione: mensile (giorno {day} alle {time})", "humanHourly": "ogni ora", - "weekdays": "['Domenica', 'Lunedì', 'Martedì', 'Mercoledì', 'Giovedì', 'Venerdì', 'Sabato']" + "weekdays": [ + "Domenica", + "Lunedì", + "Martedì", + "Mercoledì", + "Giovedì", + "Venerdì", + "Sabato" + ] }, "cronChip": { "detected": "rilevato il cron dell'host", @@ -1373,8 +1381,7 @@ "postApplyAllOk": "pacchetto/i {count} applicato correttamente: nulla in sospeso.", "postApplyNothingPending": "nulla in sospeso: tutto è aggiornato.", "postApplyPartial": "{pending} pacchetto/i ancora in sospeso dopo l'esecuzione.", - "postApplyPartialSubline": "{applied} applicato.Alcuni aggiornamenti non sono stati completati: esamina l'output del terminale sopra.", - "updatedWithDockerImage": "aggiornato con la sua immagine Docker." + "postApplyPartialSubline": "{applied} applicato. Alcuni aggiornamenti non sono stati completati: esamina l'output del terminale sopra." }, "bulkUpdate": { "title": "Aggiornamento in blocco", @@ -1602,14 +1609,9 @@ "notificationsEnabled": "notifiche di aggiornamento upstream attivate: fai clic per disattivare l'audio", "notificationsMuted": "notifiche di aggiornamento upstream MUTED: fare clic per abilitare", "notifyUpstreamLabel": "avvisami quando è disponibile una nuova versione upstream", - "notifyUpstreamHelp": "invia `app_update_available` ai canali abilitati in Impostazioni → Notifiche.Disattiva se questa app non può essere aggiornata sul tuo box.", + "notifyUpstreamHelp": "invia `app_update_available` ai canali abilitati in Impostazioni → Notifiche. Disattiva se questa app non può essere aggiornata sul tuo box.", "excludeFromBadgeLabel": "esclusione dal contatore degli aggiornamenti LXC", - "excludeFromBadgeHelp": "non contare questa app nel badge degli aggiornamenti aggregati sulla scheda dell'elenco LXC.Utile quando sei bloccato di proposito su una versione specifica (requisito del tracker, blocco della compatibilità).Non influisce sullo stato della scheda App o sulla notifica in uscita.", - "dockerDetectedWithWorkloads": "Docker rilevato con {count} applicazioni containerizzate", - "dockerWorkloadsHeading": "correre all'interno di Docker", - "runsInsideDocker": "aggiornato con la sua immagine Docker", - "upstreamDelegatedTitle": "la versione disponibile proviene dalla sua immagine Docker", - "upstreamDelegatedHelp": "questa applicazione viene eseguita in un contenitore, quindi la versione disponibile è qualunque cosa risolva la sua immagine: nessun controllo upstream separato e un aggiornamento segnalato una volta.Aggiornalo dalla sua immagine nella scheda Aggiornamenti." + "excludeFromBadgeHelp": "non contare questa app nel badge degli aggiornamenti aggregati sulla scheda dell'elenco LXC.Utile quando sei bloccato di proposito su una versione specifica (requisito del tracker, blocco della compatibilità).Non influisce sullo stato della scheda App o sulla notifica in uscita." }, "statusFilter": { "ariaLabel": "Filtra macchine virtuali e container", @@ -1889,6 +1891,7 @@ "system_reboot": "Riavvio del sistema", "system_restore_completed": "Ripristino dell'host completato", "system_problem": "Rilevato problema di sistema", + "kernel_warning": "Avvisi e tracce diagnostiche del kernel", "service_fail": "Il servizio non è riuscito", "oom_kill": "Interruzione del processo per memoria insufficiente", "service_fail_batch": "Diversi errori di servizio", @@ -4915,6 +4918,154 @@ "customLinkDeleteError": "Delete failed" }, "audit": { + "presentation": { + "backupAge": "Età della copia", + "backupLimit": "Limite utilizzato", + "limitDeclared": "Periodo definito dall’utente", + "limitSchedule": "Pianificazione + margine", + "limitReference": "Periodo di riferimento", + "verifiedChecks": "Controlli verificati", + "unverifiedChecks": "Controlli non verificati", + "checkName": "Controllo", + "verified": "Verificate", + "verificationScope": "Verifiche applicabili completate. La copertura non indica lo stato né la sicurezza del server.", + "noApplicable": "Nessuna verifica applicabile in questa valutazione.", + "noJob": "Senza attività pianificata", + "guest": "Guest", + "guests": "guest", + "host": "Host", + "resource": "Risorsa", + "data": "Dati", + "metadata": "Metadati", + "result": "Risultato", + "fact": "Dato osservato", + "records": "voci", + "unscheduled": "guest senza attività pianificata", + "excludedDisks": "dischi esclusi", + "disks": "Dischi", + "destination": "Destinazione", + "lastCopy": "Ultimo backup archiviato", + "ageLimit": "Età / limite", + "noDestination": "Nessuna destinazione configurata", + "notFound": "Nessun backup trovato nell’ambito esaminato", + "promiscuous": "Interfacce in modalità promiscua", + "noDescription": "Descrizione non disponibile", + "occurrence": "Occorrenza", + "occurrences": "occorrenze", + "detail": "Dettaglio", + "technical": "Evidenze tecniche", + "annex": "Appendice tecnica", + "overview": "Riepilogo dei risultati", + "incomplete": "Valutazione incompleta", + "assessment": "Valutazione", + "noGlobalScore": "I risultati descrivono criteri distinti; non viene calcolato un punteggio globale di sicurezza.", + "coverage": "Copertura dei backup pianificati", + "scheduled": "Con attività pianificata", + "copyScope": "Un’attività configurata non dimostra l’esistenza di un backup archiviato o ripristinabile.", + "detailsLink": "Riferimento alle evidenze", + "noSubscription": "Nessun abbonamento registrato", + "otherDevices": "Altri dispositivi nel gruppo", + "unversioned": "Versione non registrata", + "notInstalled": "Non installata", + "noPendingRecorded": "Nessun aggiornamento in attesa registrato", + "originalEvidence": "Evidenze originali della fonte", + "evidenceObserved": "Evidenze osservate", + "evidenceExcerpt": "Vista compatta. Le evidenze complete della fonte restano memorizzate con questa valutazione.", + "annexScope": "Evidenze complete della fonte per i risultati che richiedono attenzione, registrano un'osservazione o non hanno potuto essere verificati.", + "readOnlyScope": "La valutazione non modifica la configurazione. Le interrogazioni e, se necessario, Lynis possono generare log o rapporti.", + "capacity": "Capacità", + "used": "Utilizzato", + "free": "Libero", + "reasons": { + "agentNotDeclared": "Nessun agente guest dichiarato nella configurazione", + "arcConflictingSettings": "Le impostazioni persistenti non concordano", + "arcMinAboveMax": "Il limite inferiore dell'ARC supera quello superiore", + "arcPendingReboot": "L'impostazione persistente differisce dal parametro caricato", + "arrayDegraded": "Funziona con meno dispositivi di quelli con cui è stato creato", + "arrayNotActive": "Non attivo", + "arrayRebuilding": "A corto di dispositivi e in ricostruzione", + "backupRunFailed": "L'esecuzione è terminata con un errore", + "backupRunRecovered": "È fallito in passato, e un'esecuzione successiva è riuscita", + "bondNoMembersUp": "Non attivo, e nessun membro del bond è attivo", + "bondRedundancyLost": "Non attivo; il bond conserva altri collegamenti", + "bootEspMissingNewest": "Non porta il kernel più recente che portano le altre", + "bootEspOutOfSync": "Non allineata alle altre: avvierebbe un kernel diverso", + "bootSingleEsp": "Una partizione di avvio configurata", + "bootToolReported": "Segnalato da proxmox-boot-tool", + "cephCheckRaised": "Segnalata da Ceph", + "channelIncomplete": "Abilitato ma manca parte della sua configurazione", + "clusterInquorate": "Senza quorum: le modifiche al cluster vengono rifiutate", + "clusterMemberAbsent": "Nodo configurato che il cluster non vede", + "clusterSingleLink": "Un solo collegamento corosync dichiarato", + "dataExcludedFromBackup": "Dati esclusi dal backup del guest", + "deliveryFailing": "Le consegne recenti non sono uscite", + "destinationUnavailable": "Destinazione configurata non disponibile", + "diskErrorsActive": "Errore registrato nel periodo esaminato", + "diskErrorsPast": "Ha segnalato errori in passato, nessuno nella finestra in uso", + "diskWarningsActive": "Avviso del dispositivo registrato nel periodo esaminato", + "diskWarningsPast": "Ha segnalato avvisi del dispositivo in passato, nessuno nella finestra in uso", + "essentialServiceDown": "Un servizio di cui Proxmox ha bisogno per rispondere non è attivo", + "exemptByPolicy": "Dichiarato come non necessario, quindi fuori dal conteggio", + "expectedButUncovered": "Dichiarato come da proteggere, e nessun processo attivo lo seleziona", + "expectedToAutostart": "Dichiarato come da avviare con l'host, e non lo fa", + "filesystemExhausted": "Nessuno spazio disponibile", + "filesystemNearlyFull": "Alla soglia di verifica dello spazio o oltre", + "filesystemReadOnly": "Il kernel riporta questo mount come di sola lettura: non accetta più scritture", + "haManagerNotReady": "Né attivo né inattivo: non può prendere in carico un servizio", + "haNoMaster": "Nessun gestore: nulla decide dove debba girare un servizio", + "haServiceError": "In stato di errore e non più gestito", + "haServiceTransitioning": "In transizione", + "hostArchiveMissing": "Il record del lavoro nomina un archivio che non è più memorizzato", + "hostBackupJobFailed": "Il lavoro è terminato con errore", + "hostBackupStale": "Più vecchio del limite di età in uso", + "hostBackupUnscheduled": "Memorizzato, senza pianificazione che produca un'altra copia", + "hostNoRetrievableCopy": "Nessuna copia di cui questo controllo possa ancora dar conto", + "indexesStale": "Gli indici dei pacchetti non sono stati aggiornati di recente", + "inodesExhausted": "Nessun inode disponibile", + "inodesNearlyExhausted": "Alla soglia di verifica degli inode o oltre", + "kernelAwaitingReboot": "Installato e non è il kernel in esecuzione", + "lynisReportStale": "Il report di Lynis ha {days} giorno/i, più dell'età di riferimento in uso", + "lynisWarning": "Registrato dall'audit di Lynis", + "multipathNoPath": "Nessun percorso rimasto", + "multipathPathDown": "Serve su meno percorsi", + "noAutostart": "Non si avvia con l'host", + "noConfigurationReference": "Nessun riferimento nelle configurazioni esaminate", + "noJobSelectsGuest": "Nessun processo di backup attivo lo seleziona", + "noPhysicalPort": "Non porta alcuna porta fisica", + "noStoredBackup": "Nessun backup archiviato trovato", + "noStoredBackupUnscheduled": "Nessuna attività pianificata; nessun backup trovato", + "olderThanFallback": "La copia supera il periodo di riferimento.", + "olderThanObjective": "La copia supera il periodo definito dall’utente.", + "olderThanSchedule": "La copia supera l’intervallo pianificato più il margine.", + "overprovisioned": "Distribuisce più capacità virtuale di quella del pool", + "packageAwaitingRestart": "Installato e in attesa di un riavvio", + "pastServiceLife": "Oltre la soglia di vita utile usata per la pianificazione", + "pinnedToHostCpu": "Vincolato al modello di processore dell'host", + "poolDeviceErrors": "Dispositivo che conta errori di lettura, scrittura o checksum", + "poolNotOnline": "Non è in linea", + "rebootMarkerWithoutPackages": "Qualcosa ha scritto il marcatore di riavvio senza nominare alcun pacchetto", + "recoveryKeyLocalOnly": "Chiave di cifratura del backup conservata solo su questo nodo, secondo la modalità di custodia registrata", + "replicationDisabled": "In pausa", + "replicationFailing": "L'ultima esecuzione ha segnalato un errore", + "replicationNeverRan": "Non ha mai completato una sincronizzazione", + "replicationOverdue": "Più vecchia di quanto consenta il calendario del processo", + "retentionNotDeclared": "Nessuna ritenzione dichiarata; si conservano tutte le copie", + "retentionOnServer": "Potata sul server di backup, con processi che questo nodo non può leggere", + "runsPrivileged": "Viene eseguito con privilegi, condividendo lo spazio dei nomi utente dell'host", + "scrubOverdue": "Ultimo scrub completato più vecchio della soglia di verifica", + "storageNearlyFull": "Alla soglia di verifica della capacità o oltre", + "storageUnreachable": "Non raggiungibile", + "thinDataPressure": "Dati scritti vicini alla capacità del pool", + "thinMetadataPressure": "Metadati quasi pieni, il che rende il pool di sola lettura", + "unitFailed": "systemd ha smesso di ritentarla", + "verificationFailedOnly": "La verifica ha letto la copia più recente e non era integra; nessun'altra copia di questo ospite è stata verificata", + "verificationFailedWithFallback": "La verifica ha letto la copia più recente e non era integra; una copia precedente è stata verificata", + "verificationNotRun": "Nessun processo di verifica ha riletto questa copia" + }, + "lynisTest": "Test", + "lynisWarning": "Avviso", + "couldNotRead": "Non è stato possibile leggere" + }, "title": "Audit & Report", "loading": "Loading assessment…", "run": "Run assessment", @@ -4923,9 +5074,8 @@ "lastRun": "Last assessed on {when}", "stale": "{days} days ago", "readOnlyNotice": "The assessment only reads the host. It makes no changes.", + "unverifiedChecks": "Non effettuate: {checks}. Ciascuna dice nella propria evidenza cosa non ha potuto leggere.", "noFindings": "No findings match the current filter.", - "showPassing": "Show passing checks", - "hidePassing": "Hide passing checks", "affectedCount": "{count} affected", "acceptedNotice": "{count} accepted risk(s) recorded on this host.", "states": { @@ -4933,7 +5083,8 @@ "warn": "Warning", "accepted": "Accepted risk", "pass": "Passed", - "not_applicable": "Not applicable" + "not_applicable": "Not applicable", + "unknown": "Non verificato" }, "areas": { "all": "All", @@ -4949,7 +5100,8 @@ "why": "Context", "evidence": "Evidence", "affected": "Affected", - "acceptedRisk": "Accepted risk" + "acceptedRisk": "Accepted risk", + "sources": "Fonti e date di raccolta" }, "errors": { "runFailed": "The assessment could not be started." @@ -4958,69 +5110,434 @@ "backup": { "guest_coverage": { "title": "Backup coverage", - "rationale": "Compares the guest inventory against what each backup job selects. A job selects by VMID list, by pool, or with `all 1`, minus its `exclude` list. Jobs carrying `enabled 0` are not considered.", + "rationale": "Processi di backup abilitati su questo nodo, i guest che ciascuno seleziona e i dati del guest esclusi da essi. Una copertura configurata non dimostra che esista un backup utilizzabile. Se un guest non selezionato doveva essere protetto lo indica la politica dichiarata.", "summary": { - "noJobs": "No backup job is defined on this node", - "covered": "All {total} guests are covered by a backup job", - "uncovered": "{count} of {total} guests are not covered by any enabled backup job" + "noJobs": "Non è definito alcun processo di backup su questo nodo per i {total} guest che ospita", + "covered": "Tutti i {total} guest sono selezionati da un processo di backup attivo", + "uncovered": "{count} guest su {total} non sono selezionati da alcun processo di backup attivo", + "excludedData": "{count} esclusioni di dischi o mount point da verificare", + "uncoveredExpected": "{required} guest dichiarati come da proteggere non sono selezionati da alcun processo attivo", + "evaluationFailed": "The check could not be evaluated" + } + }, + "last_backup_age": { + "title": "Age of stored backups", + "rationale": "Età: tempo trascorso dall’ultima copia archiviata. Limite utilizzato: età di riferimento con cui viene confrontata la copia.", + "summary": { + "recent": "Tutte le {total} verifiche guest/destinazione rispettano il criterio di anzianità indicato", + "stale": "{count} of {total} guests have no backup from the last 30 days", + "noBackups": "No stored backup matches a guest on this node", + "attention": "{count} verifiche guest/destinazione su {total} richiedono attenzione", + "evaluationFailed": "The check could not be evaluated" + } + }, + "retention_defined": { + "title": "Backup retention", + "rationale": "La ritenzione come la risolve Proxmox: impostazione del processo, poi dello storage, poi il valore predefinito del nodo. La ritenzione applicata da un server di backup non è leggibile da questo nodo.", + "summary": { + "allDefined": "Tutti i {total} processi risolvono un'impostazione di ritenzione", + "missing": "{count} of {total} enabled job(s) declare no retention", + "notDeclared": "{count} processi su {total} conservano ogni copia: non hanno una ritenzione dichiarata", + "onServer": "{count} processi su {total} scrivono su un server di backup, che le pota con i propri processi", + "evaluationFailed": "The check could not be evaluated" + } + }, + "verification_state": { + "title": "Verifica dei backup", + "rationale": "Il risultato di verifica che Proxmox Backup Server registra per la copia più recente di ogni ospite, e se una copia precedente dello stesso ospite è stata verificata. La verifica rilegge una copia memorizzata; non è un ripristino.", + "summary": { + "allVerified": "La copia più recente di tutti i {total} guest è stata verificata integra", + "failed": "{failed} copie recenti non hanno superato la verifica, su {total} esaminate", + "notVerified": "{pending} di {total} copie recenti non sono state verificate", + "evaluationFailed": "Non è stato possibile leggere lo stato di verifica" + } + }, + "job_results": { + "title": "Esiti delle esecuzioni di backup", + "rationale": "Come si è conclusa l'esecuzione più recente di ogni ospite, dal registro attività del nodo. Viene valutata solo l'ultima. Il registro si conserva per un periodo limitato.", + "summary": { + "allSucceeded": "Tutte le {total} esecuzioni di backup registrate sono terminate senza errori", + "someFailed": "{count} di {total} esecuzioni di backup registrate sono terminate con un errore", + "evaluationFailed": "Non è stato possibile leggere il registro attività", + "recovered": "{count} di {total} ospiti sono falliti in un'esecuzione precedente e da allora sono riusciti" + } + }, + "host_recovery": { + "title": "Ripristino dell'host", + "rationale": "Backup dell'host come li registra ProxMenux: ogni lavoro eseguito, quando, se è riuscito, la destinazione e se quella copia è ancora lì. Un lavoro che scrive su un server di backup non nomina alcun percorso locale. Le chiavi di cifratura sono riportate solo per numero e modalità di custodia registrata.", + "summary": { + "noHostBackup": "Non è memorizzato alcun backup della configurazione dell'host né esiste un timer che lo produca", + "protected": "La configurazione del nodo stesso è memorizzata in {total} archivio/i, entro il limite di età in uso", + "attention": "{count} rilievo/i su {total} record di configurazione dell'host", + "scheduledOnly": "I backup della configurazione dell'host sono pianificati tramite {count} timer; nessun archivio è memorizzato localmente" } } }, "system": { "pending_reboot": { "title": "Restart state", - "rationale": "Checks for `/var/run/reboot-required` and, when present, the packages listed in `/var/run/reboot-required.pkgs`. Also reports the running kernel.", + "rationale": "Il marcatore `/var/run/reboot-required` e i pacchetti elencati in `/var/run/reboot-required.pkgs`. La sua assenza non dimostra che non ci sia nulla da riavviare.", "summary": { - "none": "No restart is pending", - "pending": "The host has a pending restart" + "none": "Nulla ha richiesto un riavvio", + "pending": "{count} elementi sono installati e attendono un riavvio", + "evaluationFailed": "The check could not be evaluated" } }, "enterprise_repo_without_subscription": { "title": "Enterprise repository", - "rationale": "Looks for references to `enterprise.proxmox.com` in `/etc/apt/sources.list` and `sources.list.d`, and contrasts the result with the status returned by `pvesubscription get`.", + "rationale": "Riferimenti a `enterprise.proxmox.com` in `/etc/apt/sources.list` e `sources.list.d`, rispetto allo stato restituito da `pvesubscription get`.", "summary": { "notEnabled": "The enterprise repository is not enabled", "subscribed": "The enterprise repository is backed by a subscription", - "unsubscribed": "The enterprise repository is enabled without an active subscription" + "unsubscribed": "The enterprise repository is enabled without an active subscription", + "evaluationFailed": "The check could not be evaluated" + } + }, + "memory_overcommit": { + "title": "Memory allocation", + "rationale": "Il tetto `memory` di ogni configurazione guest rispetto a MemTotal, con i guest in esecuzione conteggiati a parte. I container consumano fino a quel limite; le macchine virtuali senza ballooning lo riservano.", + "summary": { + "withinRatio": "Guests are allocated {percent}% of host memory", + "aboveRatio": "Guests are allocated {percent}% of host memory", + "evaluationFailed": "The check could not be evaluated" + } + }, + "time_synchronisation": { + "title": "Time synchronisation", + "rationale": "NTP e NTPSynchronized come li riporta `timedatectl`. L'appartenenza al cluster, la validazione dei certificati e l'ordine dei log dipendono da orologi concordi. Un altro meccanismo può regolare l'orologio.", + "summary": { + "synchronised": "The clock is synchronised with a time source", + "disabled": "Time synchronisation is disabled", + "notSynchronised": "Time synchronisation is enabled but the clock is not synchronised", + "evaluationFailed": "The check could not be evaluated" + } + }, + "kernel_current": { + "title": "Running kernel", + "rationale": "Il kernel in esecuzione rispetto a quello che l'host avvierebbe successivamente, come lo riporta `proxmox-boot-tool`. Un kernel più recente solo installato può essere trattenuto di proposito; una differenza dopo un riavvio indica un avvio che non ha avuto effetto.", + "summary": { + "current": "Il kernel in esecuzione {version} è quello che l'host avvierebbe successivamente", + "newerAvailable": "The host runs {running} while {newest} is installed", + "newerSelected": "L'host esegue {running} e avvierebbe {selected} al prossimo riavvio", + "wouldDowngrade": "L'host esegue {running} ma avvierebbe il più vecchio {selected} al prossimo riavvio", + "bootTargetUnknown": "L'host esegue {version}; non è stato possibile leggere il kernel scelto per il prossimo avvio", + "evaluationFailed": "The check could not be evaluated" + } + }, + "security_updates": { + "title": "Security updates", + "rationale": "Pacchetti in sospeso la cui origine è un repository di sicurezza, da un `apt-get upgrade` simulato. Il numero riflette quanto riporta apt, non la gravità di ciò che ogni pacchetto corregge.", + "summary": { + "none": "No package updates are pending", + "noSecurity": "{total} update(s) pending, none from a security repository", + "pending": "{count} of {total} pending update(s) come from a security repository", + "evaluationFailed": "The check could not be evaluated" + } + }, + "journal_size": { + "title": "Journal size", + "rationale": "Il journal su disco rispetto al tetto che gli si applica: SystemMaxUse quando è impostato, altrimenti il predefinito di journald, un decimo del filesystem su cui risiede.", + "summary": { + "bounded": "Il journal occupa {size}, entro il suo tetto effettivo", + "large": "The journal holds {size} on disk", + "nearCap": "Il journal occupa {size} ed è al {percent}% del suo tetto effettivo", + "capUnknown": "Il journal occupa {size}; non è stato possibile determinarne il tetto effettivo", + "evaluationFailed": "The check could not be evaluated" + } + }, + "swap_configured": { + "title": "Swap", + "rationale": "Aree di swap attive secondo `swapon` e il loro totale rispetto alla memoria dell'host. Nessuna regola impone una proporzione rispetto alla RAM; la pressione di memoria si misura altrove.", + "summary": { + "active": "{size} of swap is active", + "none": "No swap area is active", + "evaluationFailed": "The check could not be evaluated" + } + }, + "filesystem_capacity": { + "title": "Capacità dei filesystem dell'host", + "rationale": "Spazio e inode dei filesystem di cui l'host ha bisogno: la radice, /var, /var/log e il percorso dello storage locale. Un filesystem con spazio libero e senza inode si guasta esattamente come uno pieno.", + "summary": { + "withinLimits": "I {total} filesystem dell'host restano sotto le loro soglie di verifica", + "pressure": "{count} letture sono alla soglia di verifica o oltre", + "evaluationFailed": "Non è stato possibile leggere l'occupazione dei filesystem" + } + }, + "update_chain": { + "title": "Età degli indici dei pacchetti APT", + "rationale": "Quando APT ha collocato per l'ultima volta un indice di pacchetti su questo host. Un repository che risponde «non modificato» lascia il proprio indice intatto. La raggiungibilità dei repository non viene verificata.", + "summary": { + "current": "Gli indici dei pacchetti sono stati aggiornati {days} giorno/i fa", + "stale": "Gli indici dei pacchetti sono stati aggiornati l'ultima volta {days} giorno/i fa", + "indexAgeUnknown": "Non è stato possibile determinare l'anzianità degli indici dei pacchetti" + } + }, + "notification_delivery": { + "title": "Ultimo risultato di notifica", + "rationale": "Canali abilitati e risultato dell'ultima consegna registrata per ciascuno. Senza storico, la consegna resta non verificata; un errore seguito da un successo non è un errore attuale. Non vengono inviate notifiche di prova.", + "summary": { + "delivering": "L'ultima consegna registrata è riuscita per tutti i {total} canali abilitati", + "failing": "{count} di {total} canali abilitati presentano un problema di configurazione o un errore nell'ultima consegna", + "noChannels": "Nessun canale di notifica è abilitato", + "evaluationFailed": "Non è stato possibile leggere lo storico delle consegne" + } + }, + "cluster_quorum": { + "title": "Quorum del cluster", + "rationale": "Il quorum come lo riporta il cluster, i nodi configurati rispetto a quelli attualmente visti e il numero di collegamenti corosync dichiarati. I collegamenti sono letti dalla configurazione, non sondati.", + "summary": { + "standalone": "Questo nodo non appartiene ad alcun cluster", + "quorate": "Il cluster ha il quorum con {total} nodo/i configurato/i su {links} collegamento/i corosync", + "attention": "{count} rilievo/i su {total} nodo/i configurato/i", + "evaluationFailed": "Non è stato possibile leggere lo stato del cluster" + } + }, + "boot_loader": { + "title": "Bootloader", + "rationale": "Le partizioni di sistema EFI che riporta proxmox-boot-tool e i kernel che ciascuna porta. Nessuna partizione viene montata e nessun avvio viene tentato.", + "summary": { + "synchronised": "Le {total} partizioni di avvio portano gli stessi kernel", + "attention": "{count} di {total} partizioni di avvio richiedono una revisione", + "evaluationFailed": "The check could not be evaluated" + } + }, + "failed_units": { + "title": "Servizi essenziali e unità fallite", + "rationale": "Unità che systemd ha abbandonato dopo aver esaurito i riavvii, e i servizi di cui Proxmox ha bisogno per rispondere, letti per nome perché un servizio inattivo non risulta sempre fallito. Cosa faccia ogni unità non viene interpretato qui.", + "summary": { + "allRunning": "I {total} servizi essenziali sono attivi e nessuna unità è fallita", + "attention": "{count} rilievo/i tra le unità", + "evaluationFailed": "The check could not be evaluated" + } + }, + "ha_state": { + "title": "Alta disponibilità", + "rationale": "Il master HA, il gestore di risorse di ogni nodo e lo stato di ogni servizio gestito, da `ha-manager status`. Del quorum riferisce il controllo del cluster. Nessun servizio viene avviato, fermato o migrato.", + "summary": { + "managed": "I {total} servizi gestiti sono in uno stato assestato su {nodes} nodo/i", + "attention": "{count} rilievo/i su {total} servizio/i gestito/i", + "evaluationFailed": "The check could not be evaluated" } } }, "guests": { "privileged_containers": { "title": "Container privileges", - "rationale": "Reviews each container configuration. Proxmox marks unprivileged containers with `unprivileged: 1`; its absence indicates a privileged container, which shares the host user namespace.", + "rationale": "L'impostazione `unprivileged` di ogni configurazione container. La sua assenza significa che il container condivide lo spazio dei nomi utente dell'host, cosa che alcuni carichi richiedono.", "summary": { "allUnprivileged": "All {total} containers are unprivileged", - "privileged": "{count} of {total} containers run privileged" + "privileged": "{count} of {total} containers run privileged", + "evaluationFailed": "The check could not be evaluated" } }, "qemu_without_agent": { "title": "Guest agent on virtual machines", - "rationale": "Reviews each virtual machine configuration for `agent: 1`. The guest agent enables ordered shutdown, filesystem quiescing for snapshots and real disk usage reporting.", + "rationale": "L'impostazione `agent` nella configurazione di ogni macchina virtuale. L'impostazione indica che l'agente è dichiarato, non che risponda.", "summary": { "allHaveAgent": "All {total} virtual machines declare the guest agent", - "missingAgent": "{count} of {total} virtual machines do not declare the guest agent" + "missingAgent": "{count} of {total} virtual machines do not declare the guest agent", + "evaluationFailed": "The check could not be evaluated" + } + }, + "autostart": { + "title": "Automatic start", + "rationale": "L'impostazione `onboot` di ogni guest, esclusi i template e i guest gestiti da HA. Se un guest debba tornare da solo lo indica la politica dichiarata.", + "summary": { + "allAutostart": "All {total} guests start with the host", + "notAutostart": "{count} of {total} guests do not start with the host", + "evaluationFailed": "The check could not be evaluated" + } + }, + "stuck_snapshots": { + "title": "Snapshot state", + "rationale": "Stato e anzianità degli snapshot e attività in corso. Un'operazione recente o senza data verificabile non è considerata interrotta.", + "summary": { + "noSnapshots": "No guest holds snapshots", + "allComplete": "The {total} snapshot(s) are complete", + "stuck": "{count} of {total} snapshot(s) were left mid-operation", + "evaluationFailed": "The check could not be evaluated" + } + }, + "cpu_host_type": { + "title": "Virtual CPU model", + "rationale": "Il valore `cpu` di ogni macchina virtuale. `host` espone il set di istruzioni del processore fisico, il che limita i nodi verso cui il guest può migrare. L'incompatibilità con una destinazione precisa non viene determinata qui.", + "summary": { + "none": "None of the {total} virtual machines is pinned to the host processor", + "pinned": "{count} of {total} virtual machines are pinned to the host processor", + "evaluationFailed": "The check could not be evaluated" + } + }, + "replication_state": { + "title": "Replication", + "rationale": "Processi di replica dalle API: numero di errori, ultimo errore, ultima sincronizzazione e il calendario dichiarato da ciascun processo. I processi in pausa sono indicati come tali.", + "summary": { + "healthy": "The {total} replication job(s) report no error", + "failing": "{count} of {total} replication job(s) report an error", + "statusUnavailable": "Replication jobs are defined but their status could not be read", + "evaluationFailed": "The check could not be evaluated" } } }, "security": { "host_firewall_enabled": { - "title": "Firewall state", - "rationale": "Checks `enable: 1` in `/etc/pve/firewall/cluster.fw` and in the node's `host.fw`. Proxmox applies node rules only while the datacenter switch is on.", + "title": "Attivazione configurata del firewall", + "rationale": "L'opzione `enable` nel firewall del centro dati e in quello del nodo, e quante regole sono scritte. Proxmox applica le regole del nodo solo con l'interruttore del centro dati attivo. Queste opzioni non indicano cosa filtri una regola.", "summary": { - "bothEnabled": "The firewall is enabled at datacenter and node level", - "datacenterOff": "The firewall is disabled at datacenter level, so node rules are not applied", - "nodeOff": "The firewall is enabled at datacenter level but not on this node" + "bothEnabled": "L'attivazione del firewall è configurata a livello di datacenter e nodo", + "datacenterOff": "Il firewall è disattivato a livello di datacenter, quindi le regole del nodo non vengono applicate", + "nodeOff": "Il firewall è attivato a livello di datacenter ma non su questo nodo", + "evaluationFailed": "The check could not be evaluated" + } + }, + "lynis_warnings": { + "title": "Lynis warnings", + "rationale": "Avvisi dell'ultimo audit di Lynis, ciascuno con il proprio identificatore di test, e l'età di quell'audit. Un audit viene eseguito solo se Lynis è installato e non esiste alcun report completo. I suggerimenti non sono inclusi.", + "summary": { + "none": "The last Lynis audit recorded no warnings", + "found": "The last Lynis audit recorded {count} warning(s)", + "incomplete": "The Lynis report is incomplete", + "evaluationFailed": "The check could not be evaluated", + "noneStale": "L'ultimo audit di Lynis non ha registrato avvisi, e il suo report ha {days} giorno/i", + "foundStale": "L'ultimo audit di Lynis ha registrato {count} avviso/i, e il suo report ha {days} giorno/i" + } + }, + "certificate_expiry": { + "title": "Certificate validity", + "rationale": "La data di scadenza del certificato che pveproxy serve da /etc/pve/local. Un certificato personalizzato ha la precedenza su quello generato da Proxmox.", + "summary": { + "valid": "The certificate is valid for {days} more day(s)", + "expiring": "The certificate expires in {days} day(s)", + "expired": "The certificate expired {days} day(s) ago", + "evaluationFailed": "The check could not be evaluated" + } + }, + "ssh_root_login": { + "title": "SSH root access", + "rationale": "PermitRootLogin nella configurazione effettiva di `sshd -T`, con i metodi di autenticazione a cui si combina. Proxmox viene consegnato con `yes`, che accetta una password.", + "summary": { + "password": "Root may sign in over SSH with a password", + "keyOnly": "Root may sign in over SSH with a key only", + "denied": "Root may not sign in over SSH", + "evaluationFailed": "The check could not be evaluated" } } }, "storage": { "orphaned_volumes": { "title": "Volume assignment", - "rationale": "Compares the volumes returned by `pvesm list` against the existing guest configurations. Only non-shared storage is examined: on shared storage a volume may belong to a guest running on another node.", + "rationale": "Volumi dei guest sullo storage locale rispetto ai riferimenti nelle configurazioni correnti, in sospeso e di snapshot. Backup, ISO e template restano fuori dal confronto. Un volume senza riferimento è un candidato alla verifica.", "summary": { "none": "No orphaned volumes were found", - "found": "{count} volume(s) belong to no existing guest" + "found": "{count} volumi senza riferimenti nelle configurazioni esaminate", + "evaluationFailed": "The check could not be evaluated" + } + }, + "zfs_arc_max": { + "title": "ZFS ARC limit", + "rationale": "Valori effettivi di c_min, c_max e dimensione dell'ARC, il parametro del modulo caricato e le impostazioni persistenti in /etc/modprobe.d. Un valore configurato a zero seleziona il predefinito del modulo; l'ARC è un tetto e la memoria che occupa è recuperabile.", + "summary": { + "bounded": "Il limite dell'ARC è il {percent}% della memoria dell'host, e la memoria che occupa è recuperabile", + "high": "The ARC may use {percent}% of host memory", + "unset": "The ARC has no explicit limit set", + "conflicting": "{count} impostazioni dell'ARC non concordano tra loro", + "pending": "Un'impostazione persistente dell'ARC differisce dal valore del modulo in esecuzione", + "evaluationFailed": "The check could not be evaluated" + } + }, + "zfs_scrub_age": { + "title": "ZFS scrub", + "rationale": "L'ultimo scrub completato registrato da `zpool status` per ogni pool. Un resilver non è uno scrub. Un pool creato di recente non ha ancora avuto occasione di eseguirne uno.", + "summary": { + "recent": "The {total} pool(s) were scrubbed within the last 35 days", + "overdue": "{count} of {total} pool(s) have not been scrubbed in 35 days", + "neverScrubbed": "{count} pool(s) record no scrub", + "evaluationFailed": "The check could not be evaluated" + } + }, + "thin_pool_overprovisioning": { + "title": "Thin pool allocation", + "rationale": "Capacità virtuale distribuita da ogni thin pool LVM rispetto alla dimensione del pool, e quanto i suoi volumi hanno effettivamente scritto, dati e metadati separatamente.", + "summary": { + "withinRatio": "I {total} thin pool sono al di sotto delle soglie di verifica applicate", + "aboveRatio": "{count} thin pool su {total} distribuiscono più capacità di quella che possiedono", + "pressure": "{pressure} thin pool su {total} sono vicini a riempire dati o metadati", + "evaluationFailed": "The check could not be evaluated" + } + }, + "connected_storage": { + "title": "Archivi collegati", + "rationale": "Disponibilità come la riporta PVE, capacità nota e dipendenze attuali di tutti gli storage abilitati su questo nodo. I componenti interni remoti non vengono sondati e l'accesso in scrittura non viene testato. La capacità è riportata dove PVE la conosce e resta vuota dove non la conosce.", + "summary": { + "available": "PVE indica tutti i {total} archivi come disponibili; i componenti interni remoti e l'accesso in scrittura non sono stati verificati", + "attention": "{count} archivi su {total} richiedono una verifica", + "evaluationFailed": "The check could not be evaluated" + } + }, + "pool_integrity": { + "title": "Integrità e ridondanza dei pool", + "rationale": "Lo stato di ogni pool ZFS e i contatori di lettura, scrittura e checksum dei suoi dispositivi. I contatori sono cumulativi dall'ultimo `zpool clear`.", + "summary": { + "healthy": "Tutti i {total} pool sono in linea, senza errori di dispositivo rilevati", + "degraded": "{count} rilievi su {total} pool", + "evaluationFailed": "Non è stato possibile leggere lo stato dei pool" + } + }, + "ceph_health": { + "title": "Salute di Ceph", + "rationale": "Lo stato di salute di Ceph stesso e i controlli che nomina. I suoi test non vengono reimplementati e nessun pool, gruppo di posizionamento o OSD viene interrogato separatamente.", + "summary": { + "healthy": "Ceph riporta HEALTH_OK", + "degraded": "Ceph riporta {state}, con {count} controllo/i nominato/i", + "evaluationFailed": "The check could not be evaluated" + } + }, + "array_integrity": { + "title": "RAID software e multipath", + "rationale": "Array mdadm e mappe multipath, letti da /proc/mdstat e, dove lo strumento è installato, da `multipath -ll`. I pool ZFS li riporta il loro controllo.", + "summary": { + "intact": "I {total} array e mappe conservano la loro ridondanza", + "degraded": "{count} di {total} array o mappe ne sono a corto", + "evaluationFailed": "The check could not be evaluated" + } + } + }, + "hardware": { + "disk_service_life": { + "title": "Disk service life", + "rationale": "Ore di funzionamento e usura residua dalle letture SMART del monitor, con la data di ciascuna lettura. L'età è informazione di pianificazione; gli errori di supporto e gli avvisi del dispositivo li riporta il monitor di salute.", + "summary": { + "withinLife": "Le {total} letture dei dischi non superano la soglia indicativa di cinque anni", + "pastLife": "{count} di {total} letture dei dischi superano cinque anni di servizio", + "noReadings": "Nessun disco espone contatori SMART utilizzabili ({skipped} senza letture)", + "evaluationFailed": "The check could not be evaluated" + } + }, + "disk_errors": { + "title": "Errori dei dischi", + "rationale": "Errori rilevati sui dischi e registrati dal monitor di salute.", + "summary": { + "recorded": "{count} di {total} dischi con record hanno eventi annotati", + "noEvents": "Nessun evento del disco da valutare: nessuno registrato, o tutti scartati" + } + } + }, + "network": { + "bond_members": { + "title": "Bond members", + "rationale": "Lo stato MII di ogni membro del bond da /proc/net/bonding e quanti collegamenti restano. In active-backup un membro di riserva risulta attivo e non trasporta traffico.", + "summary": { + "allUp": "All members of the {total} bond(s) are up", + "membersDown": "{count} bond member(s) are not up", + "evaluationFailed": "The check could not be evaluated" + } + }, + "bridge_without_ports": { + "title": "Bridge ports", + "rationale": "La configurazione delle porte di ogni bridge. Un bridge senza porta fisica serve una rete interna o instradata.", + "summary": { + "allConnected": "The {total} bridge(s) carry a port", + "isolated": "{count} of {total} bridge(s) carry no port", + "evaluationFailed": "The check could not be evaluated" } } } @@ -5041,6 +5558,389 @@ "expiry365": "1 year", "cancel": "Cancel", "confirm": "Accept risk" - } + }, + "incomplete": "Evidenze incomplete", + "progress": "Verificati {completed} di {total}", + "expires": "Scade: {when}", + "runStates": { + "partial": "La valutazione è stata completata; alcune letture non si sono potute fare.", + "failed": "Valutazione interrotta o non riuscita. Esamina le evidenze prima di usare i risultati." + }, + "severities": { + "CRITICAL": "Critico", + "WARNING": "Avviso", + "INFO": "Informazione", + "OK": "OK" + }, + "viewSwitch": { + "ariaLabel": "Switch between assessment and inventory", + "assessment": "Assessment", + "inventory": "Inventory", + "policy": "Politica", + "changes": "Modifiche" + }, + "inventory": { + "loading": "Loading inventory…", + "failed": "The inventory could not be composed.", + "collectedAt": "Composed on {when}", + "unavailable": "Not read in this inventory", + "unresolved": "path not resolved", + "noUplink": "no uplink", + "identity": "Node identity", + "node": "Node", + "pveVersion": "Proxmox VE", + "kernel": "Kernel", + "subscription": "Subscription", + "cluster": "Cluster", + "standalone": "Not in a cluster", + "hardware": "Hardware", + "system": "System", + "serial": "Serial number", + "bios": "BIOS", + "cpu": "Processor", + "topology": "Layout", + "cpuLayout": "{sockets} socket(s) × {cores} cores = {threads} threads", + "virtualisation": "Virtualisation", + "memory": "Memory", + "iommuGroups": "IOMMU groups", + "network": "Network", + "storage": "Storage", + "name": "Name", + "type": "Type", + "content": "Content", + "shared": "Shared", + "yes": "Yes", + "no": "No", + "guests": "Guests", + "ostype": "Operating system", + "onboot": "Starts with host", + "tags": "Tags", + "privilege": "Privilege", + "privileged": "Privileged", + "unprivileged": "Unprivileged", + "features": "Features", + "agent": "Guest agent", + "cpuModel": "CPU model", + "disks": "Disks", + "interfaces": "Network interfaces", + "protection": "Backup", + "passthrough": "Passthrough", + "noBackup": "No backup", + "noBackupDetail": "No enabled backup job selects this guest.", + "applications": "Applications", + "versionUnknown": "version not detected", + "passthroughTitle": "PCI passthrough", + "iommuGroup": "IOMMU group {group}", + "sharedGroup": "{count} more device(s) in the same group", + "proxmenux": "ProxMenux optimizations", + "latency": "Latenza di rete", + "subscriptionStatus": { + "notfound": "Nessun abbonamento", + "active": "Attivo", + "invalid": "Non valido", + "expired": "Scaduto", + "suspended": "Sospeso", + "new": "In attesa di attivazione", + "unknown": "Sconosciuto" + } + }, + "profile": { + "label": "Report", + "full": "Full audit", + "inventory": "Inventory", + "security": "Security review", + "backup": "Backup assurance", + "capacity": "Capacity and wear", + "diagnostic": "Diagnosi rapida" + }, + "document": { + "action": "Genera rapporto", + "title": "Rapporto di audit", + "subtitle": "Struttura, configurazione e valutazione di {node}", + "generated": "Generato", + "assessed": "Assessed", + "runId": "Assessment reference", + "print": "Print or save as PDF", + "summaryByArea": "Summary by area", + "area": "Area", + "inventoryAnnex": "Inventory annex", + "scopeTitle": "Scope of this report", + "scopeBody": "This report describes the Proxmox VE node named above, as observed from the node itself at the time stated. It does not cover the interior of the guests beyond what they declare, network equipment outside the host, physical infrastructure, or any dependency not visible from this node. Findings marked as not determined were not measured and are not evidence of absence.", + "building": "Composizione del rapporto…", + "node": "Nodo", + "profile": "Profilo", + "unknownNode": "nodo non identificato", + "executiveSummary": "Riepilogo della valutazione", + "assessment": "Valutazione", + "verdictHeading": "Esito di questa esecuzione", + "verdict": { + "critical": "ATTENZIONE", + "warning": "DA RIVEDERE", + "conformant": "IN ORDINE" + }, + "verdictText": { + "critical": "{fail} controlli segnalano una condizione fallita e {warn} una condizione da rivedere, su {total} valutati.", + "warning": "Nessun controllo segnala una condizione fallita. {warn} su {total} segnalano una condizione da rivedere.", + "conformant": "I {total} controlli di questo profilo si completano senza condizioni fallite né da rivedere.", + "none": "Questo profilo non esegue controlli. Il documento descrive il nodo senza valutarlo." + }, + "runAt": "Eseguito il {date}", + "chartNote": "Controlli per area e per esito.", + "nodeIdentity": "Identità del nodo", + "system": "Sistema", + "cluster": "Cluster", + "standaloneNote": "Questo nodo non fa parte di un cluster: mantiene la propria configurazione e i suoi guest non migrano su un altro nodo.", + "clusterDiagramNote": "Nodi configurati e collegamenti corosync che li uniscono.", + "thisNode": "questo nodo", + "unreachable": "non visto", + "member": "membro", + "corosyncLinks": "collegamenti", + "quorum": "Quorum", + "quorate": "con quorum", + "inquorate": "senza quorum", + "votes": "Voti", + "nodeName": "Nodo", + "architecture": "Architettura del sistema", + "architectureNote": "Come è assemblato il nodo: processore e memoria sulla scheda, e cosa dipende da ciascun controller.", + "systemIdentity": "Identità del sistema", + "board": "Scheda madre", + "processor": "Processore", + "topology": "Socket × core / thread", + "memory": "Memoria", + "cores": "core", + "threads": "thread", + "memoryModules": "Moduli di memoria", + "slot": "Slot", + "slotsUsed": "slot occupati", + "slotsFilled": "{used} slot occupati su {total}", + "emptySlot": "vuoto", + "formFactor": "Formato", + "speed": "Velocità", + "manufacturer": "Produttore", + "product": "Modello", + "serial": "Numero di serie", + "controllers": "Controller", + "class": "Classe", + "device": "Dispositivo", + "iommuGroups": "Gruppi IOMMU", + "iommuGroup": "Gruppo IOMMU", + "field": "Campo", + "value": "Valore", + "size": "Dimensione", + "type": "Tipo", + "storageDevices": "Dispositivi di archiviazione", + "disks": "Dischi", + "model": "Modello", + "bus": "Bus", + "serviceLife": "Ore di servizio", + "healthy": "integro", + "years": "{years} anni", + "events": "Eventi", + "observations": "Osservazioni", + "observationsNote": "Eventi registrati. SMART riporta lo stato attuale; questo registro riporta ciò che è accaduto, inclusi gli eventi da cui il disco si è ripreso.", + "noObservations": "Nessun evento registrato", + "noObservationsNote": "Nessun disco ha registrato errori da quando il monitor li osserva.", + "event": "Evento", + "severity": "Gravità", + "occurrences": "Occorrenze", + "firstSeen": "Prima volta", + "lastSeen": "Ultima volta", + "detail": "Dettaglio", + "network": "Rete", + "adapters": "Adattatori", + "bond": "Bond", + "bridge": "Bridge", + "bridges": "Bridge", + "physicalAdapters": "Adattatori fisici", + "interface": "Interfaccia", + "driver": "Driver", + "state": "Stato", + "networkDiagramNote": "Percorso dal cavo a ciascun guest: adattatore fisico, bond quando li raggruppa, bridge e guest collegati.", + "storageAndProtection": "Archiviazione e protezione", + "storage": "Archiviazione", + "content": "Contenuto", + "shared": "Condiviso", + "location": "Percorso", + "backupDestination": "Destinazione dei backup", + "unprotected": "senza backup", + "storageDiagramNote": "Dove risiedono i dischi dei guest e quale destinazione li salva.", + "unprotectedGuests": "{count} guest senza processo di backup", + "allProtected": "Ogni guest è coperto da un processo di backup", + "allProtectedNote": "La copertura indica che un processo seleziona il guest; l'esito dei backup è valutato separatamente.", + "vmid": "VMID", + "name": "Nome", + "kind": "Tipo", + "backup": "Backup", + "none": "nessuno", + "managedSoftware": "Software gestito da ProxMenux", + "version": "Versione", + "source": "Origine", + "current": "aggiornato", + "updateAvailable": "aggiorna a {version}", + "findings": "Rilievi in dettaglio", + "incomplete": "parziale", + "scope": "Ambito di questo rapporto", + "scopeText": "Questo documento riporta il profilo {profile} sul nodo indicato nell'intestazione, al momento dell'esecuzione.", + "scopeLocal": "Copre solo questo nodo. I guest di altri nodi e la loro configurazione ne restano fuori.", + "scopeReadOnly": "Tutti i controlli leggono configurazione e stato già esistenti; nessuno modifica l'host.", + "scopeMoment": "Descrive lo stato al momento dell'esecuzione, non un periodo di tempo.", + "notRead": "Fonti non leggibili:", + "uplink": "Uplink", + "conformance": "{pass} su {total} conformi", + "latency": "Latenza di rete", + "latencyNote": "Latenza misurata nella finestra indicata, una linea per destinazione.", + "milliseconds": "ms", + "hours": "h", + "minimum": "Minimo", + "average": "Media", + "maximum": "Massimo", + "packetLoss": "Perdita di pacchetti", + "samples": "Campioni", + "target": { + "label": "Destinazione", + "gateway": "Gateway", + "cloudflare": "Cloudflare (1.1.1.1)", + "google": "Google (8.8.8.8)" + }, + "actionShort": "Rapporto", + "policyDeclared": "È stato valutato rispetto a una politica dichiarata: {guests} guest, {storages} storage e {thresholds} soglie indicate.", + "policyNone": "Non è stata dichiarata alcuna politica, quindi un'assenza che questo rapporto non può interpretare è indicata come osservazione e mai come avviso.", + "diagnosticTitle": "Diagnosi rapida", + "diagnosticSubtitle": "Risultati critici e avvisi su {node}", + "diagnosticActing": "{count} risultato/i critico/i e avviso/i.", + "diagnosticClear": "Nessun risultato critico né avviso. Le osservazioni e i risultati conformi sono nell'audit completo.", + "diagnosticUnread": "Letture non eseguibili", + "diagnosticMoreRows": "{count} riga/righe in più, nell'audit completo.", + "structureTitle": "Struttura e configurazione", + "structureSubtitle": "Com'è costruito e configurato {node}" + }, + "results": "Risultati", + "classifications": { + "critical": "Critico", + "warning": "Avviso", + "observation": "Osservazione", + "conformant": "Conforme", + "unverified": "Non verificato", + "not_applicable": "Non applicabile", + "accepted": "Rischio accettato", + "by_design": "Escluso dalla politica" + }, + "policy": { + "inherit": "{value} (predefinito)", + "inheritUnset": "Predefinito", + "conflict": "La dichiarazione è stata modificata in un’altra sessione. La bozza non è stata salvata.", + "reload": "Ricarica la dichiarazione salvata (scarta la bozza)", + "intro": "Una valutazione vede cosa fa questo host, non a cosa serve. Ciò che si dichiara qui trasforma un'osservazione in avviso, o la toglie dal conteggio. Nulla è obbligatorio: senza dichiarazione il rapporto descrive invece di giudicare.", + "loading": "Lettura della dichiarazione…", + "failed": "Non è stato possibile leggere la dichiarazione", + "saved": "Salvato", + "declaredCount": "{count} dichiarazioni", + "guestsNote": "Richiesto segnala come avviso ciò che manca; non dichiarato lo segnala come osservazione; non richiesto lo lascia fuori dal conteggio.", + "storagesNote": "Uno storage irraggiungibile è critico quando è dichiarato essenziale o serve un ospite in esecuzione, un avviso quando il suo ruolo non è dichiarato, e un'osservazione quando è dichiarato opzionale.", + "thresholds": "Soglie", + "thresholdsNote": "Vuoto indica il valore di fabbrica, mostrato come segnaposto.", + "backup": "Backup", + "autostart": "Avvio automatico", + "objective": "Obiettivo di ripristino", + "objectivePlaceholder": "ore", + "noGuests": "Questo nodo non ospita guest.", + "expectation": { + "required": "Richiesto", + "not_required": "Non richiesto", + "unspecified": "Non dichiarato" + }, + "role": { + "essential": "Essenziale", + "optional": "Opzionale", + "unspecified": "Non dichiarato" + }, + "threshold": { + "storage_usage_percent": "Soglia di verifica della capacità (%)", + "thin_pool_usage_percent": "Soglia di verifica del riempimento del thin pool (%)", + "thin_overprovision_ratio": "Rapporto di sovrallocazione thin", + "zfs_scrub_days": "Intervallo di scrub ZFS (giorni)", + "backup_fallback_days": "Termine di ripiego per l'anzianità (giorni)", + "backup_schedule_grace_ratio": "Margine sul calendario (rapporto)", + "certificate_expiry_days": "Preavviso di scadenza del certificato (giorni)", + "memory_overcommit_ratio": "Rapporto di sovrallocazione della memoria", + "disk_service_life_hours": "Vita utile del disco (ore)", + "lynis_report_days": "Anzianità del report Lynis (giorni)", + "package_index_days": "Anzianità degli indici dei pacchetti (giorni)", + "journal_usage_percent": "Journal rispetto al suo tetto (%)", + "filesystem_usage_percent": "Soglia di verifica dello spazio (%)", + "filesystem_inode_percent": "Soglia di verifica degli inode (%)", + "disk_error_recent_days": "Finestra degli errori disco recenti (giorni)" + } + }, + "changes": { + "loading": "Lettura del registro delle modifiche…", + "failed": "Non è stato possibile leggere il registro delle modifiche", + "intro": "Cosa ha cambiato ProxMenux su questo host e cosa c'era prima di ogni modifica. Viene mostrata la differenza, non lo script che l'ha applicata.", + "empty": "Su questo host non è ancora stato registrato nulla.", + "since": "Registrazione dal {date}. Ciò che è stato applicato prima risulta applicato, senza lo stato sostituito.", + "byFunction": "Per funzione", + "count": "{count} modifiche", + "function": "Funzione", + "source": "Script", + "reversibility": "Annullare questo", + "difference": "Differenza", + "diffTruncated": "La differenza è più lunga di quanto mostrato.", + "diffUnavailable": "Il contenuto sostituito non è più archiviato, quindi la differenza non si può mostrare.", + "packagesAdded": "Pacchetti aggiunti", + "commandRun": "Comando eseguito", + "executionNote": "ProxMenux lo ha eseguito su richiesta; ciò che è cambiato lo ha deciso il comando, non ProxMenux.", + "unknownNote": "È stato applicato prima che esistesse il registro, quindi ciò che ha sostituito non è mai stato catturato.", + "noneInFilter": "Nessuna modifica di questo tipo.", + "class": { + "all": "Tutte", + "configuration": "Configurazione", + "installation": "Installazioni", + "execution": "Esecuzioni", + "registration": "Applicato" + }, + "operation": { + "write_file": "File sostituito", + "edit_file": "File modificato", + "remove_file": "File rimosso", + "install_package": "Installato", + "enable_service": "Servizio abilitato", + "disable_service": "Servizio disabilitato", + "run_command": "Eseguito", + "applied": "Applicato", + "removed": "Rimosso", + "unknown": "Modifica" + }, + "capture": { + "unknown": "Stato precedente sconosciuto" + }, + "exactness": { + "exact": "Ripristina esattamente ciò che c'era", + "partial": "Parziale: le dipendenze possono restare o andarsene con esso", + "none": "Non si può annullare dal registro" + } + }, + "comparison": { + "loading": "Confronto con l'esecuzione di riferimento…", + "failed": "Non è stato possibile impostare l'esecuzione di riferimento", + "since": "Dal {date}", + "previousRun": "l'esecuzione precedente", + "noChange": "Nessun cambiamento", + "isBaseline": "Questa esecuzione è il riferimento con cui si confrontano le altre.", + "noBaseline": "Non è stata ancora scelta un'esecuzione di riferimento, quindi non c'è nulla con cui confrontare.", + "setBaseline": "Usa come riferimento", + "unchanged": "{count} controlli hanno dato lo stesso risultato di prima.", + "new": "Nuovi", + "newNote": "segnalati ora e prima no", + "resolved": "Risolti", + "resolvedNote": "non più segnalati, e nessuno li ha accettati", + "accepted": "Accettati", + "acceptedNote": "non contano più perché è stato accettato un rischio, non perché l'host sia cambiato", + "retired": "Non più valutati", + "retiredNote": "presenti prima e assenti in questa esecuzione; nulla ha verificato che siano cessati", + "reasons": { + "insufficient_runs": "Un confronto richiede un'esecuzione di riferimento e una successiva; finora ne è registrata una sola" + } + }, + "notApplicableScope": "Nulla nell'ambito esaminato a cui questo controllo si applichi." } } diff --git a/AppImage/messages/pt/common.json b/AppImage/messages/pt/common.json index 519be362..deac9602 100644 --- a/AppImage/messages/pt/common.json +++ b/AppImage/messages/pt/common.json @@ -309,7 +309,7 @@ "shortTest": "Teste curto", "longTest": "Teste longo (1-4 horas)", "extendedTest": "Teste estendido", - "testHelp": "Um pequeno teste leva cerca de 2 minutos.O teste estendido é executado em segundo plano e pode levar várias horas em discos grandes.O resultado aparecerá na aba Histórico quando terminar.", + "testHelp": "Um pequeno teste leva cerca de 2 minutos. O teste estendido é executado em segundo plano e pode levar várias horas em discos grandes. O resultado aparecerá na aba Histórico quando terminar.", "startFailed": "Falha ao iniciar o teste", "short": "Curto", "extended": "Estendido", @@ -1103,7 +1103,7 @@ "backupStartFailed": "Falha ao iniciar o backup: {message}", "controlFailed": "Falha ao {action} VM {vmid}: {message}", "saveNotesFailed": "Erro ao salvar notas. Por favor, tente novamente.", - "appNotFound": "Este aplicativo não está mais disponível.Atualize a página e tente novamente.", + "appNotFound": "Este aplicativo não está mais disponível. Atualize a página e tente novamente.", "saveCustomCommandFailed": "Não foi possível salvar o comando de atualização personalizado: {message}", "removeCustomCommandConfirm": "Remover o comando de atualização personalizado para \"{name}\"?", "removeCustomCommandFailed": "não foi possível remover o comando de atualização personalizado: {message}", @@ -1220,7 +1220,15 @@ "humanWeekly": "Semanal ({day} {time})", "humanMonthly": "Mensalmente (dia {day} às {time})", "humanHourly": "de hora em hora", - "weekdays": "['Domingo', 'Segunda-feira', 'Terça-feira', 'Quarta-feira', 'Quinta-feira', 'Sexta-feira', 'Sábado']" + "weekdays": [ + "Domingo", + "Segunda-feira", + "Terça-feira", + "Quarta-feira", + "Quinta-feira", + "Sexta-feira", + "Sábado" + ] }, "cronChip": { "detected": "cron do host detectado", @@ -1373,8 +1381,7 @@ "postApplyAllOk": "{count} pacote(s) aplicado(s) com sucesso — nada pendente.", "postApplyNothingPending": "Nada pendente — tudo está atualizado.", "postApplyPartial": "{pending} pacote(s) ainda pendente(s) após a execução.", - "postApplyPartialSubline": "{applied} aplicado.Algumas atualizações não foram concluídas – revise a saída do terminal acima.", - "updatedWithDockerImage": "Atualizado com sua imagem Docker." + "postApplyPartialSubline": "{applied} aplicado. Algumas atualizações não foram concluídas – revise a saída do terminal acima." }, "bulkUpdate": { "title": "Atualização em bloco", @@ -1602,14 +1609,9 @@ "notificationsEnabled": "Notificações de atualização upstream ATIVADAS – clique para silenciar", "notificationsMuted": "notificações de atualização upstream silenciadas – clique para ativar", "notifyUpstreamLabel": "Notifique-me quando uma nova versão upstream estiver disponível", - "notifyUpstreamHelp": "Envia `app_update_available` para os canais habilitados em Configurações → Notificações.Desligue se este aplicativo não puder ser atualizado em sua caixa.", + "notifyUpstreamHelp": "Envia `app_update_available` para os canais habilitados em Configurações → Notificações. Desligue se este aplicativo não puder ser atualizado em sua caixa.", "excludeFromBadgeLabel": "Excluir do contador de atualizações LXC", - "excludeFromBadgeHelp": "não conte este aplicativo no selo de atualizações agregadas no cartão de lista LXC.Útil quando você está fixado em uma versão específica propositalmente (requisito do rastreador, congelamento de compatibilidade).Não afeta o próprio estado da guia Aplicativo ou a notificação de saída.", - "dockerDetectedWithWorkloads": "Docker detectado com {count} aplicativos em contêineres", - "dockerWorkloadsHeading": "Executando dentro de Docker", - "runsInsideDocker": "Atualizado com sua imagem Docker", - "upstreamDelegatedTitle": "a versão disponível vem de sua imagem Docker", - "upstreamDelegatedHelp": "Este aplicativo é executado em um contêiner, portanto, a versão disponível é aquela que sua imagem resolve – nenhuma verificação upstream separada e uma atualização relatada uma vez.Atualize-o a partir de sua imagem na guia Atualizações." + "excludeFromBadgeHelp": "não conte este aplicativo no selo de atualizações agregadas no cartão de lista LXC.Útil quando você está fixado em uma versão específica propositalmente (requisito do rastreador, congelamento de compatibilidade).Não afeta o próprio estado da guia Aplicativo ou a notificação de saída." }, "statusFilter": { "ariaLabel": "Filtrar máquinas virtuais e contêineres", @@ -1889,6 +1891,7 @@ "system_reboot": "Reinicialização do sistema", "system_restore_completed": "Restauração do host concluída", "system_problem": "Problema no sistema detectado", + "kernel_warning": "Avisos e rastreios de diagnóstico do kernel", "service_fail": "Falha no serviço", "oom_kill": "Eliminação de processo sem memória", "service_fail_batch": "Várias falhas de serviço", @@ -4915,6 +4918,154 @@ "customLinkDeleteError": "Delete failed" }, "audit": { + "presentation": { + "backupAge": "Idade da cópia", + "backupLimit": "Limite utilizado", + "limitDeclared": "Prazo definido pelo utilizador", + "limitSchedule": "Agendamento + margem", + "limitReference": "Prazo de referência", + "verifiedChecks": "Verificações concluídas", + "unverifiedChecks": "Verificações não concluídas", + "checkName": "Verificação", + "verified": "Verificadas", + "verificationScope": "Verificações aplicáveis concluídas. A cobertura não representa o estado nem a segurança do servidor.", + "noApplicable": "Não há verificações aplicáveis nesta avaliação.", + "noJob": "Sem tarefa agendada", + "guest": "Convidado", + "guests": "convidados", + "host": "Host", + "resource": "Recurso", + "data": "Dados", + "metadata": "Metadados", + "result": "Resultado", + "fact": "Dado observado", + "records": "registos", + "unscheduled": "convidados sem tarefa agendada", + "excludedDisks": "discos excluídos", + "disks": "Discos", + "destination": "Destino", + "lastCopy": "Última cópia armazenada", + "ageLimit": "Antiguidade / limite", + "noDestination": "Sem destino configurado", + "notFound": "Nenhuma cópia encontrada no âmbito examinado", + "promiscuous": "Interfaces em modo promíscuo", + "noDescription": "Descrição indisponível", + "occurrence": "Ocorrência", + "occurrences": "ocorrências", + "detail": "Detalhe", + "technical": "Evidência técnica", + "annex": "Anexo técnico", + "overview": "Resumo dos resultados", + "incomplete": "Avaliação incompleta", + "assessment": "Avaliação", + "noGlobalScore": "Os resultados descrevem critérios distintos; não é calculada uma pontuação global de segurança.", + "coverage": "Cobertura de backups agendados", + "scheduled": "Com tarefa agendada", + "copyScope": "Uma tarefa configurada não comprova que exista uma cópia armazenada ou restaurável.", + "detailsLink": "Referência da evidência", + "noSubscription": "Sem subscrição registada", + "otherDevices": "Outros dispositivos no grupo", + "unversioned": "Versão não registada", + "notInstalled": "Não instalada", + "noPendingRecorded": "Sem atualização pendente registada", + "originalEvidence": "Evidência original da fonte", + "evidenceObserved": "Evidência observada", + "evidenceExcerpt": "Vista compacta. A evidência completa da fonte permanece guardada com esta avaliação.", + "annexScope": "Evidência completa da fonte para resultados que requerem atenção, registam uma observação ou não puderam ser verificados.", + "readOnlyScope": "A avaliação não altera a configuração. As consultas e, quando necessário, o Lynis podem gerar registos ou relatórios.", + "capacity": "Capacidade", + "used": "Ocupado", + "free": "Livre", + "reasons": { + "agentNotDeclared": "Sem agente de convidado declarado na configuração", + "arcConflictingSettings": "As definições persistentes não coincidem", + "arcMinAboveMax": "O limite inferior do ARC está acima do superior", + "arcPendingReboot": "A definição persistente difere do parâmetro carregado", + "arrayDegraded": "Funciona com menos dispositivos do que aqueles com que foi criado", + "arrayNotActive": "Não está ativo", + "arrayRebuilding": "Sem dispositivos suficientes e em reconstrução", + "backupRunFailed": "A execução terminou com erro", + "backupRunRecovered": "Falhou antes, e uma execução posterior terminou bem", + "bondNoMembersUp": "Em baixo, e nenhum membro do bond está ativo", + "bondRedundancyLost": "Em baixo; o bond mantém outras ligações", + "bootEspMissingNewest": "Não leva o kernel mais recente que as outras levam", + "bootEspOutOfSync": "Dessincronizada das restantes: arrancaria um kernel diferente", + "bootSingleEsp": "Uma partição de arranque configurada", + "bootToolReported": "Comunicado pelo proxmox-boot-tool", + "cephCheckRaised": "Levantada pelo Ceph", + "channelIncomplete": "Ativado mas falta parte da sua configuração", + "clusterInquorate": "Sem quórum: as alterações ao cluster são recusadas", + "clusterMemberAbsent": "Nó configurado que o cluster não vê", + "clusterSingleLink": "Uma única ligação corosync declarada", + "dataExcludedFromBackup": "Dados excluídos do backup do convidado", + "deliveryFailing": "As entregas recentes não saíram", + "destinationUnavailable": "Destino configurado indisponível", + "diskErrorsActive": "Erro registado no período analisado", + "diskErrorsPast": "Comunicou erros antes, nenhum na janela em uso", + "diskWarningsActive": "Aviso do dispositivo registado no período analisado", + "diskWarningsPast": "Comunicou avisos do dispositivo antes, nenhum na janela em uso", + "essentialServiceDown": "Um serviço de que o Proxmox precisa para responder não está ativo", + "exemptByPolicy": "Declarado como não necessitando disto, pelo que fica fora da contagem", + "expectedButUncovered": "Declarado como necessitando backup, e nenhuma tarefa ativa o seleciona", + "expectedToAutostart": "Declarado como devendo arrancar com o anfitrião, e não arranca", + "filesystemExhausted": "Sem espaço disponível", + "filesystemNearlyFull": "No limiar de revisão de espaço ou acima", + "filesystemReadOnly": "O kernel reporta esta montagem como só de leitura: deixou de aceitar escritas", + "haManagerNotReady": "Nem ativo nem inativo: não pode assumir um serviço", + "haNoMaster": "Sem gestor: nada decide onde um serviço deve correr", + "haServiceError": "Em estado de erro e já não gerido", + "haServiceTransitioning": "Em transição", + "hostArchiveMissing": "O registo do trabalho nomeia um arquivo que já não está armazenado", + "hostBackupJobFailed": "O trabalho terminou com erro", + "hostBackupStale": "Mais antigo do que o limite de idade em uso", + "hostBackupUnscheduled": "Armazenado, sem agendamento que produza outra cópia", + "hostNoRetrievableCopy": "Nenhuma cópia de que esta verificação possa dar conta", + "indexesStale": "Os índices de pacotes não foram atualizados recentemente", + "inodesExhausted": "Sem inodes disponíveis", + "inodesNearlyExhausted": "No limiar de revisão de inodes ou acima", + "kernelAwaitingReboot": "Instalado e não é o kernel em execução", + "lynisReportStale": "O relatório do Lynis tem {days} dia(s), mais do que a idade de referência em uso", + "lynisWarning": "Registado pela auditoria do Lynis", + "multipathNoPath": "Sem qualquer caminho", + "multipathPathDown": "A servir por menos caminhos", + "noAutostart": "Não arranca com o anfitrião", + "noConfigurationReference": "Sem referência nas configurações examinadas", + "noJobSelectsGuest": "Nenhuma tarefa de backup ativa o seleciona", + "noPhysicalPort": "Não tem qualquer porta física", + "noStoredBackup": "Nenhuma cópia armazenada encontrada", + "noStoredBackupUnscheduled": "Sem tarefa agendada; nenhuma cópia encontrada", + "olderThanFallback": "A cópia ultrapassa o prazo de referência.", + "olderThanObjective": "A cópia ultrapassa o prazo definido pelo utilizador.", + "olderThanSchedule": "A cópia ultrapassa o intervalo agendado mais a margem.", + "overprovisioned": "Distribui mais capacidade virtual do que a pool possui", + "packageAwaitingRestart": "Instalado e a pedir reinício", + "pastServiceLife": "Acima do limiar de vida útil usado para planeamento", + "pinnedToHostCpu": "Fixado ao modelo de processador do anfitrião", + "poolDeviceErrors": "Dispositivo a contar erros de leitura, escrita ou soma de verificação", + "poolNotOnline": "Não está online", + "rebootMarkerWithoutPackages": "Algo escreveu o marcador de reinício sem nomear qualquer pacote", + "recoveryKeyLocalOnly": "Chave de cifragem do backup guardada apenas neste nó, conforme o modo de custódia registado", + "replicationDisabled": "Em pausa", + "replicationFailing": "A última execução reportou um erro", + "replicationNeverRan": "Nunca concluiu uma sincronização", + "replicationOverdue": "Mais antiga do que o calendário da própria tarefa permite", + "retentionNotDeclared": "Sem retenção declarada; todas as cópias são guardadas", + "retentionOnServer": "Podada no servidor de backup, sob tarefas que este nó não consegue ler", + "runsPrivileged": "Executa com privilégios, partilhando o espaço de nomes de utilizador do anfitrião", + "scrubOverdue": "Último scrub concluído mais antigo do que o limiar de revisão", + "storageNearlyFull": "No limiar de revisão de capacidade ou acima", + "storageUnreachable": "Inacessível", + "thinDataPressure": "Dados escritos próximos da capacidade da pool", + "thinMetadataPressure": "Metadados quase cheios, o que deixa a pool só de leitura", + "unitFailed": "o systemd deixou de a tentar", + "verificationFailedOnly": "A verificação leu a cópia mais recente e não estava íntegra; nenhuma outra cópia deste convidado foi verificada", + "verificationFailedWithFallback": "A verificação leu a cópia mais recente e não estava íntegra; uma cópia anterior foi verificada", + "verificationNotRun": "Nenhuma tarefa de verificação releu esta cópia" + }, + "lynisTest": "Teste", + "lynisWarning": "Aviso", + "couldNotRead": "Não foi possível ler" + }, "title": "Audit & Report", "loading": "Loading assessment…", "run": "Run assessment", @@ -4923,9 +5074,8 @@ "lastRun": "Last assessed on {when}", "stale": "{days} days ago", "readOnlyNotice": "The assessment only reads the host. It makes no changes.", + "unverifiedChecks": "Não obtidas: {checks}. Cada uma diz na sua evidência o que não conseguiu ler.", "noFindings": "No findings match the current filter.", - "showPassing": "Show passing checks", - "hidePassing": "Hide passing checks", "affectedCount": "{count} affected", "acceptedNotice": "{count} accepted risk(s) recorded on this host.", "states": { @@ -4933,7 +5083,8 @@ "warn": "Warning", "accepted": "Accepted risk", "pass": "Passed", - "not_applicable": "Not applicable" + "not_applicable": "Not applicable", + "unknown": "Não verificado" }, "areas": { "all": "All", @@ -4949,7 +5100,8 @@ "why": "Context", "evidence": "Evidence", "affected": "Affected", - "acceptedRisk": "Accepted risk" + "acceptedRisk": "Accepted risk", + "sources": "Fontes e datas de coleta" }, "errors": { "runFailed": "The assessment could not be started." @@ -4958,69 +5110,434 @@ "backup": { "guest_coverage": { "title": "Backup coverage", - "rationale": "Compares the guest inventory against what each backup job selects. A job selects by VMID list, by pool, or with `all 1`, minus its `exclude` list. Jobs carrying `enabled 0` are not considered.", + "rationale": "Tarefas de backup ativadas neste nó, os convidados que cada uma seleciona e os dados do convidado excluídos delas. Uma cobertura configurada não prova que exista uma cópia utilizável. Se um convidado não selecionado devia ser protegido, indica-o a política declarada.", "summary": { - "noJobs": "No backup job is defined on this node", - "covered": "All {total} guests are covered by a backup job", - "uncovered": "{count} of {total} guests are not covered by any enabled backup job" + "noJobs": "Não há nenhuma tarefa de backup definida neste nó para os {total} convidados que aloja", + "covered": "Todos os {total} convidados são selecionados por uma tarefa de backup ativa", + "uncovered": "{count} de {total} convidados não são selecionados por nenhuma tarefa de backup ativa", + "excludedData": "Há {count} exclusões de discos ou montagens para revisar", + "uncoveredExpected": "{required} convidados declarados como necessitando backup não são selecionados por nenhuma tarefa ativa", + "evaluationFailed": "The check could not be evaluated" + } + }, + "last_backup_age": { + "title": "Age of stored backups", + "rationale": "Idade: tempo decorrido desde a última cópia armazenada. Limite utilizado: idade de referência com a qual essa cópia é comparada.", + "summary": { + "recent": "As {total} verificações de máquina/destino cumprem o critério de antiguidade indicado", + "stale": "{count} of {total} guests have no backup from the last 30 days", + "noBackups": "No stored backup matches a guest on this node", + "attention": "{count} de {total} verificações de máquina/destino requerem revisão", + "evaluationFailed": "The check could not be evaluated" + } + }, + "retention_defined": { + "title": "Backup retention", + "rationale": "A retenção tal como o Proxmox a resolve: definição da tarefa, depois do armazenamento, depois o valor por omissão do nó. A retenção aplicada por um servidor de backup não é legível a partir deste nó.", + "summary": { + "allDefined": "As {total} tarefas resolvem uma definição de retenção", + "missing": "{count} of {total} enabled job(s) declare no retention", + "notDeclared": "{count} de {total} tarefas guardam todas as cópias: não têm retenção declarada", + "onServer": "{count} de {total} tarefas escrevem num servidor de backup, que as poda com as suas próprias tarefas", + "evaluationFailed": "The check could not be evaluated" + } + }, + "verification_state": { + "title": "Verificação dos backups", + "rationale": "O resultado de verificação que o Proxmox Backup Server regista para a cópia mais recente de cada convidado, e se uma cópia anterior do mesmo convidado foi verificada. A verificação lê uma cópia armazenada; não é um restauro.", + "summary": { + "allVerified": "A cópia mais recente dos {total} convidados foi verificada íntegra", + "failed": "{failed} cópias recentes falharam a verificação, de {total} examinadas", + "notVerified": "{pending} de {total} cópias recentes não foram verificadas", + "evaluationFailed": "Não foi possível ler o estado de verificação" + } + }, + "job_results": { + "title": "Resultados das execuções de backup", + "rationale": "Como terminou a execução mais recente de cada convidado, segundo o registo de tarefas do nó. Só a última é avaliada. O registo é conservado por um período limitado.", + "summary": { + "allSucceeded": "As {total} execuções de backup registadas terminaram sem erro", + "someFailed": "{count} de {total} execuções de backup registadas terminaram com erro", + "evaluationFailed": "Não foi possível ler o registo de tarefas", + "recovered": "{count} de {total} convidados falharam numa execução anterior e desde então terminaram bem" + } + }, + "host_recovery": { + "title": "Recuperação do host", + "rationale": "Backups do host tal como o ProxMenux os regista: cada trabalho executado, quando, se terminou bem, o destino e se essa cópia ainda lá está. Um trabalho que escreve num servidor de backup não nomeia qualquer caminho local. As chaves de cifragem são reportadas apenas por contagem e modo de custódia registado.", + "summary": { + "noHostBackup": "Não há backup da configuração do host armazenado nem temporizador que o produza", + "protected": "A configuração do próprio nó está guardada em {total} arquivo(s), dentro do limite de idade em uso", + "attention": "{count} constatação(ões) sobre {total} registo(s) de configuração do host", + "scheduledOnly": "Os backups da configuração do host estão agendados através de {count} temporizador(es); não há qualquer arquivo armazenado localmente" } } }, "system": { "pending_reboot": { "title": "Restart state", - "rationale": "Checks for `/var/run/reboot-required` and, when present, the packages listed in `/var/run/reboot-required.pkgs`. Also reports the running kernel.", + "rationale": "O marcador `/var/run/reboot-required` e os pacotes listados em `/var/run/reboot-required.pkgs`. A sua ausência não demonstra que não haja nada por reiniciar.", "summary": { - "none": "No restart is pending", - "pending": "The host has a pending restart" + "none": "Nada solicitou um reinício", + "pending": "{count} elementos estão instalados e aguardam um reinício", + "evaluationFailed": "The check could not be evaluated" } }, "enterprise_repo_without_subscription": { "title": "Enterprise repository", - "rationale": "Looks for references to `enterprise.proxmox.com` in `/etc/apt/sources.list` and `sources.list.d`, and contrasts the result with the status returned by `pvesubscription get`.", + "rationale": "Referências a `enterprise.proxmox.com` em `/etc/apt/sources.list` e `sources.list.d`, face ao estado devolvido por `pvesubscription get`.", "summary": { "notEnabled": "The enterprise repository is not enabled", "subscribed": "The enterprise repository is backed by a subscription", - "unsubscribed": "The enterprise repository is enabled without an active subscription" + "unsubscribed": "The enterprise repository is enabled without an active subscription", + "evaluationFailed": "The check could not be evaluated" + } + }, + "memory_overcommit": { + "title": "Memory allocation", + "rationale": "O teto `memory` de cada configuração de convidado face a MemTotal, com os convidados em execução contados à parte. Os contentores consomem até esse limite; as máquinas virtuais sem ballooning reservam-no.", + "summary": { + "withinRatio": "Guests are allocated {percent}% of host memory", + "aboveRatio": "Guests are allocated {percent}% of host memory", + "evaluationFailed": "The check could not be evaluated" + } + }, + "time_synchronisation": { + "title": "Time synchronisation", + "rationale": "NTP e NTPSynchronized tal como o `timedatectl` os reporta. A pertença ao cluster, a validação de certificados e a ordem dos registos dependem de relógios concordantes. Outro mecanismo pode estar a disciplinar o relógio.", + "summary": { + "synchronised": "The clock is synchronised with a time source", + "disabled": "Time synchronisation is disabled", + "notSynchronised": "Time synchronisation is enabled but the clock is not synchronised", + "evaluationFailed": "The check could not be evaluated" + } + }, + "kernel_current": { + "title": "Running kernel", + "rationale": "O kernel em execução face àquele que o anfitrião arrancaria a seguir, conforme o `proxmox-boot-tool` o reporta. Um kernel mais recente apenas instalado pode estar retido de propósito; uma diferença após reiniciar indica um arranque que não vingou.", + "summary": { + "current": "O kernel em execução {version} é o que o anfitrião arrancaria a seguir", + "newerAvailable": "The host runs {running} while {newest} is installed", + "newerSelected": "O anfitrião executa {running} e arrancaria {selected} no próximo reinício", + "wouldDowngrade": "O anfitrião executa {running} mas arrancaria o mais antigo {selected} no próximo reinício", + "bootTargetUnknown": "O anfitrião executa {version}; não foi possível ler o kernel escolhido para o próximo arranque", + "evaluationFailed": "The check could not be evaluated" + } + }, + "security_updates": { + "title": "Security updates", + "rationale": "Pacotes pendentes cuja origem é um repositório de segurança, a partir de um `apt-get upgrade` simulado. O número reflete o que o apt reporta, não a gravidade do que cada pacote corrige.", + "summary": { + "none": "No package updates are pending", + "noSecurity": "{total} update(s) pending, none from a security repository", + "pending": "{count} of {total} pending update(s) come from a security repository", + "evaluationFailed": "The check could not be evaluated" + } + }, + "journal_size": { + "title": "Journal size", + "rationale": "O journal em disco face ao limite que se lhe aplica: SystemMaxUse quando está definido e, caso contrário, o valor por omissão do journald, um décimo do sistema de ficheiros onde reside.", + "summary": { + "bounded": "O journal ocupa {size}, dentro do seu limite efetivo", + "large": "The journal holds {size} on disk", + "nearCap": "O journal ocupa {size} e está a {percent}% do seu limite efetivo", + "capUnknown": "O journal ocupa {size}; não foi possível determinar o seu limite efetivo", + "evaluationFailed": "The check could not be evaluated" + } + }, + "swap_configured": { + "title": "Swap", + "rationale": "Áreas de swap ativas segundo o `swapon` e o seu total face à memória do anfitrião. Nenhuma regra exige uma proporção relativa à RAM; a pressão de memória mede-se noutro lugar.", + "summary": { + "active": "{size} of swap is active", + "none": "No swap area is active", + "evaluationFailed": "The check could not be evaluated" + } + }, + "filesystem_capacity": { + "title": "Capacidade dos sistemas de ficheiros do anfitrião", + "rationale": "Espaço e inodes dos sistemas de ficheiros de que o anfitrião precisa: a raiz, /var, /var/log e o caminho do armazenamento local. Um sistema de ficheiros com espaço livre e sem inodes falha tal como um cheio.", + "summary": { + "withinLimits": "Os {total} sistemas de ficheiros do anfitrião mantêm-se abaixo dos seus limiares de revisão", + "pressure": "{count} leituras estão no limiar de revisão ou acima", + "evaluationFailed": "Não foi possível ler a ocupação dos sistemas de ficheiros" + } + }, + "update_chain": { + "title": "Idade dos índices de pacotes APT", + "rationale": "Quando o APT colocou pela última vez um índice de pacotes neste host. Um repositório que responde «não modificado» deixa o seu índice intacto. A acessibilidade dos repositórios não é testada.", + "summary": { + "current": "Os índices de pacotes foram atualizados há {days} dia(s)", + "stale": "Os índices de pacotes foram atualizados pela última vez há {days} dia(s)", + "indexAgeUnknown": "Não foi possível determinar a antiguidade dos índices de pacotes" + } + }, + "notification_delivery": { + "title": "Último resultado de notificação", + "rationale": "Canais ativados e resultado da última entrega registada para cada um. Sem histórico, a entrega fica por verificar; uma falha seguida de sucesso não é considerada uma falha atual. Não são enviadas notificações de teste.", + "summary": { + "delivering": "A última entrega registada foi bem-sucedida nos {total} canais ativados", + "failing": "{count} de {total} canais ativados apresentam um problema de configuração ou uma falha na última entrega", + "noChannels": "Não há qualquer canal de notificação ativado", + "evaluationFailed": "Não foi possível ler o histórico de entregas" + } + }, + "cluster_quorum": { + "title": "Quórum do cluster", + "rationale": "O quórum tal como o cluster o reporta, os nós configurados face aos atualmente vistos e o número de ligações corosync declaradas. As ligações são lidas da configuração, não sondadas.", + "summary": { + "standalone": "Este nó não pertence a nenhum cluster", + "quorate": "O cluster tem quórum com {total} nó(s) configurado(s) sobre {links} ligação(ões) corosync", + "attention": "{count} constatação(ões) sobre {total} nó(s) configurado(s)", + "evaluationFailed": "Não foi possível ler o estado do cluster" + } + }, + "boot_loader": { + "title": "Carregador de arranque", + "rationale": "As partições de sistema EFI que o proxmox-boot-tool reporta e os kernels que cada uma leva. Nenhuma partição é montada e nenhum arranque é tentado.", + "summary": { + "synchronised": "As {total} partições de arranque levam os mesmos kernels", + "attention": "{count} de {total} partições de arranque requerem revisão", + "evaluationFailed": "The check could not be evaluated" + } + }, + "failed_units": { + "title": "Serviços essenciais e unidades falhadas", + "rationale": "Unidades que o systemd deu por perdidas depois de esgotar as tentativas, e os serviços de que o Proxmox precisa para responder, lidos pelo nome porque um serviço inativo nem sempre consta como falhado. O que cada unidade faz não é interpretado aqui.", + "summary": { + "allRunning": "Os {total} serviços essenciais estão ativos e nenhuma unidade falhou", + "attention": "{count} constatação(ões) entre as unidades", + "evaluationFailed": "The check could not be evaluated" + } + }, + "ha_state": { + "title": "Alta disponibilidade", + "rationale": "O mestre de HA, o gestor de recursos de cada nó e o estado de cada serviço gerido, segundo `ha-manager status`. Do quórum dá conta a verificação do cluster. Nenhum serviço é iniciado, parado ou migrado.", + "summary": { + "managed": "Os {total} serviços geridos estão num estado assente em {nodes} nó(s)", + "attention": "{count} constatação(ões) sobre {total} serviço(s) gerido(s)", + "evaluationFailed": "The check could not be evaluated" } } }, "guests": { "privileged_containers": { "title": "Container privileges", - "rationale": "Reviews each container configuration. Proxmox marks unprivileged containers with `unprivileged: 1`; its absence indicates a privileged container, which shares the host user namespace.", + "rationale": "A definição `unprivileged` de cada configuração de contentor. A sua ausência significa que o contentor partilha o espaço de nomes de utilizador do anfitrião, algo que certas cargas necessitam.", "summary": { "allUnprivileged": "All {total} containers are unprivileged", - "privileged": "{count} of {total} containers run privileged" + "privileged": "{count} of {total} containers run privileged", + "evaluationFailed": "The check could not be evaluated" } }, "qemu_without_agent": { "title": "Guest agent on virtual machines", - "rationale": "Reviews each virtual machine configuration for `agent: 1`. The guest agent enables ordered shutdown, filesystem quiescing for snapshots and real disk usage reporting.", + "rationale": "A definição `agent` na configuração de cada máquina virtual. A definição indica que o agente está declarado, não que responda.", "summary": { "allHaveAgent": "All {total} virtual machines declare the guest agent", - "missingAgent": "{count} of {total} virtual machines do not declare the guest agent" + "missingAgent": "{count} of {total} virtual machines do not declare the guest agent", + "evaluationFailed": "The check could not be evaluated" + } + }, + "autostart": { + "title": "Automatic start", + "rationale": "A definição `onboot` de cada convidado, excluindo modelos e convidados geridos por HA. Se se espera que um convidado volte por si só, indica-o a política declarada.", + "summary": { + "allAutostart": "All {total} guests start with the host", + "notAutostart": "{count} of {total} guests do not start with the host", + "evaluationFailed": "The check could not be evaluated" + } + }, + "stuck_snapshots": { + "title": "Snapshot state", + "rationale": "Estado e antiguidade dos snapshots e tarefas ativas. Uma operação recente ou sem data verificável não é considerada interrompida.", + "summary": { + "noSnapshots": "No guest holds snapshots", + "allComplete": "The {total} snapshot(s) are complete", + "stuck": "{count} of {total} snapshot(s) were left mid-operation", + "evaluationFailed": "The check could not be evaluated" + } + }, + "cpu_host_type": { + "title": "Virtual CPU model", + "rationale": "O valor `cpu` de cada máquina virtual. `host` expõe o conjunto de instruções do processador físico, o que limita os nós para onde o convidado pode migrar. A incompatibilidade com um destino concreto não se determina aqui.", + "summary": { + "none": "None of the {total} virtual machines is pinned to the host processor", + "pinned": "{count} of {total} virtual machines are pinned to the host processor", + "evaluationFailed": "The check could not be evaluated" + } + }, + "replication_state": { + "title": "Replication", + "rationale": "Tarefas de replicação a partir da API: número de falhas, último erro, última sincronização e o calendário que cada tarefa declara. As tarefas em pausa são assinaladas como tal.", + "summary": { + "healthy": "The {total} replication job(s) report no error", + "failing": "{count} of {total} replication job(s) report an error", + "statusUnavailable": "Replication jobs are defined but their status could not be read", + "evaluationFailed": "The check could not be evaluated" } } }, "security": { "host_firewall_enabled": { - "title": "Firewall state", - "rationale": "Checks `enable: 1` in `/etc/pve/firewall/cluster.fw` and in the node's `host.fw`. Proxmox applies node rules only while the datacenter switch is on.", + "title": "Ativação configurada da firewall", + "rationale": "A opção `enable` na firewall do centro de dados e na do próprio nó, e quantas regras estão escritas. O Proxmox aplica as regras do nó apenas com o interruptor do centro de dados ligado. Estas opções não indicam o que cada regra filtra.", "summary": { - "bothEnabled": "The firewall is enabled at datacenter and node level", - "datacenterOff": "The firewall is disabled at datacenter level, so node rules are not applied", - "nodeOff": "The firewall is enabled at datacenter level but not on this node" + "bothEnabled": "A ativação da firewall está configurada ao nível do centro de dados e do nó", + "datacenterOff": "A firewall está desativada ao nível do centro de dados, pelo que as regras do nó não são aplicadas", + "nodeOff": "A firewall está ativada ao nível do centro de dados, mas não neste nó", + "evaluationFailed": "The check could not be evaluated" + } + }, + "lynis_warnings": { + "title": "Lynis warnings", + "rationale": "Avisos da última auditoria do Lynis, cada um com o seu identificador de teste, e a idade dessa auditoria. Só é executada uma auditoria se o Lynis estiver instalado e não existir relatório completo. As sugestões não são incluídas.", + "summary": { + "none": "The last Lynis audit recorded no warnings", + "found": "The last Lynis audit recorded {count} warning(s)", + "incomplete": "The Lynis report is incomplete", + "evaluationFailed": "The check could not be evaluated", + "noneStale": "A última auditoria do Lynis não registou avisos, e o seu relatório tem {days} dia(s)", + "foundStale": "A última auditoria do Lynis registou {count} aviso(s), e o seu relatório tem {days} dia(s)" + } + }, + "certificate_expiry": { + "title": "Certificate validity", + "rationale": "A data de validade do certificado que o pveproxy serve a partir de /etc/pve/local. Um certificado próprio tem precedência sobre o que o Proxmox gera.", + "summary": { + "valid": "The certificate is valid for {days} more day(s)", + "expiring": "The certificate expires in {days} day(s)", + "expired": "The certificate expired {days} day(s) ago", + "evaluationFailed": "The check could not be evaluated" + } + }, + "ssh_root_login": { + "title": "SSH root access", + "rationale": "PermitRootLogin na configuração efetiva de `sshd -T`, com os métodos de autenticação com que se combina. O Proxmox é entregue com `yes`, que aceita palavra-passe.", + "summary": { + "password": "Root may sign in over SSH with a password", + "keyOnly": "Root may sign in over SSH with a key only", + "denied": "Root may not sign in over SSH", + "evaluationFailed": "The check could not be evaluated" } } }, "storage": { "orphaned_volumes": { "title": "Volume assignment", - "rationale": "Compares the volumes returned by `pvesm list` against the existing guest configurations. Only non-shared storage is examined: on shared storage a volume may belong to a guest running on another node.", + "rationale": "Volumes de convidado no armazenamento local face às referências nas configurações atuais, pendentes e de snapshots. Backups, ISOs e modelos ficam fora da comparação. Um volume sem referência é um candidato a revisão.", "summary": { "none": "No orphaned volumes were found", - "found": "{count} volume(s) belong to no existing guest" + "found": "Há {count} volumes sem referência nas configurações examinadas", + "evaluationFailed": "The check could not be evaluated" + } + }, + "zfs_arc_max": { + "title": "ZFS ARC limit", + "rationale": "Valores efetivos de c_min, c_max e tamanho do ARC, o parâmetro de módulo carregado e as definições persistentes em /etc/modprobe.d. Um valor configurado a zero seleciona o valor por omissão do módulo; o ARC é um teto e a memória que ocupa é recuperável.", + "summary": { + "bounded": "O limite do ARC é {percent}% da memória do anfitrião, e a memória que ocupa é recuperável", + "high": "The ARC may use {percent}% of host memory", + "unset": "The ARC has no explicit limit set", + "conflicting": "{count} definições do ARC não coincidem entre si", + "pending": "Uma definição persistente do ARC difere do valor que o módulo em execução transporta", + "evaluationFailed": "The check could not be evaluated" + } + }, + "zfs_scrub_age": { + "title": "ZFS scrub", + "rationale": "O último scrub concluído que o `zpool status` regista em cada pool. Um resilver não é um scrub. Uma pool criada há pouco ainda não teve ocasião de executar um.", + "summary": { + "recent": "The {total} pool(s) were scrubbed within the last 35 days", + "overdue": "{count} of {total} pool(s) have not been scrubbed in 35 days", + "neverScrubbed": "{count} pool(s) record no scrub", + "evaluationFailed": "The check could not be evaluated" + } + }, + "thin_pool_overprovisioning": { + "title": "Thin pool allocation", + "rationale": "Capacidade virtual distribuída por cada thin pool LVM face ao tamanho do próprio pool, e quanto os seus volumes escreveram realmente, dados e metadados em separado.", + "summary": { + "withinRatio": "Os {total} thin pools encontram-se abaixo dos limites de revisão aplicados", + "aboveRatio": "{count} de {total} thin pools distribuem mais capacidade do que possuem", + "pressure": "{pressure} de {total} thin pools estão perto de encher os seus dados ou metadados", + "evaluationFailed": "The check could not be evaluated" + } + }, + "connected_storage": { + "title": "Armazenamentos ligados", + "rationale": "Disponibilidade tal como o PVE a reporta, capacidade conhecida e dependências atuais de todos os armazenamentos ativados neste nó. Os componentes internos remotos não são sondados nem o acesso de escrita é testado. A capacidade é apresentada onde o PVE a conhece e fica em branco onde não.", + "summary": { + "available": "O PVE indica que os {total} armazenamentos estão disponíveis; os componentes internos remotos e o acesso de escrita não foram testados", + "attention": "{count} de {total} armazenamentos requerem revisão", + "evaluationFailed": "The check could not be evaluated" + } + }, + "pool_integrity": { + "title": "Integridade e redundância das pools", + "rationale": "O estado de cada pool ZFS e os contadores de leitura, escrita e soma de verificação dos seus dispositivos. Os contadores são cumulativos desde o último `zpool clear`.", + "summary": { + "healthy": "As {total} pools estão online, sem erros de dispositivo registados", + "degraded": "{count} constatações em {total} pools", + "evaluationFailed": "Não foi possível ler o estado das pools" + } + }, + "ceph_health": { + "title": "Saúde do Ceph", + "rationale": "O estado de saúde do próprio Ceph e as verificações que nomeia. Os seus testes não são reimplementados e nenhum pool, grupo de colocação ou OSD é consultado à parte.", + "summary": { + "healthy": "O Ceph reporta HEALTH_OK", + "degraded": "O Ceph reporta {state}, com {count} verificação(ões) nomeada(s)", + "evaluationFailed": "The check could not be evaluated" + } + }, + "array_integrity": { + "title": "RAID por software e multipath", + "rationale": "Arrays mdadm e mapas multipath, lidos de /proc/mdstat e, onde a ferramenta esteja instalada, de `multipath -ll`. Os pools ZFS são reportados pela sua própria verificação.", + "summary": { + "intact": "Os {total} arrays e mapas mantêm a sua redundância", + "degraded": "{count} de {total} arrays ou mapas estão sem ela", + "evaluationFailed": "The check could not be evaluated" + } + } + }, + "hardware": { + "disk_service_life": { + "title": "Disk service life", + "rationale": "Horas de funcionamento e desgaste restante a partir das leituras SMART do monitor, com a data de cada leitura. A idade é informação de planeamento; os erros de suporte e os avisos do dispositivo são reportados pelo monitor de saúde.", + "summary": { + "withinLife": "As {total} leituras de discos não excedem o limiar indicativo de cinco anos", + "pastLife": "{count} de {total} leituras de discos excedem cinco anos de funcionamento", + "noReadings": "Nenhum disco expõe contadores SMART utilizáveis ({skipped} sem leituras)", + "evaluationFailed": "The check could not be evaluated" + } + }, + "disk_errors": { + "title": "Erros de disco", + "rationale": "Erros detetados nos discos e registados pelo monitor de saúde.", + "summary": { + "recorded": "{count} de {total} discos com registo têm eventos anotados", + "noEvents": "Não resta qualquer evento de disco para avaliar: nenhum registado, ou todos descartados" + } + } + }, + "network": { + "bond_members": { + "title": "Bond members", + "rationale": "O estado MII de cada membro do bond a partir de /proc/net/bonding e quantas ligações restam. Em active-backup, um membro de reserva figura como ativo e não transporta tráfego.", + "summary": { + "allUp": "All members of the {total} bond(s) are up", + "membersDown": "{count} bond member(s) are not up", + "evaluationFailed": "The check could not be evaluated" + } + }, + "bridge_without_ports": { + "title": "Bridge ports", + "rationale": "A configuração de portas de cada bridge. Uma bridge sem porta física serve uma rede interna ou encaminhada.", + "summary": { + "allConnected": "The {total} bridge(s) carry a port", + "isolated": "{count} of {total} bridge(s) carry no port", + "evaluationFailed": "The check could not be evaluated" } } } @@ -5041,6 +5558,389 @@ "expiry365": "1 year", "cancel": "Cancel", "confirm": "Accept risk" - } + }, + "incomplete": "Evidência incompleta", + "progress": "Verificadas {completed} de {total}", + "expires": "Expira em {when}", + "runStates": { + "partial": "A avaliação foi concluída; algumas leituras não puderam ser feitas.", + "failed": "Avaliação interrompida ou falhou. Revise a evidência antes de usar os resultados." + }, + "severities": { + "CRITICAL": "Crítico", + "WARNING": "Aviso", + "INFO": "Informação", + "OK": "OK" + }, + "viewSwitch": { + "ariaLabel": "Switch between assessment and inventory", + "assessment": "Assessment", + "inventory": "Inventory", + "policy": "Política", + "changes": "Alterações" + }, + "inventory": { + "loading": "Loading inventory…", + "failed": "The inventory could not be composed.", + "collectedAt": "Composed on {when}", + "unavailable": "Not read in this inventory", + "unresolved": "path not resolved", + "noUplink": "no uplink", + "identity": "Node identity", + "node": "Node", + "pveVersion": "Proxmox VE", + "kernel": "Kernel", + "subscription": "Subscription", + "cluster": "Cluster", + "standalone": "Not in a cluster", + "hardware": "Hardware", + "system": "System", + "serial": "Serial number", + "bios": "BIOS", + "cpu": "Processor", + "topology": "Layout", + "cpuLayout": "{sockets} socket(s) × {cores} cores = {threads} threads", + "virtualisation": "Virtualisation", + "memory": "Memory", + "iommuGroups": "IOMMU groups", + "network": "Network", + "storage": "Storage", + "name": "Name", + "type": "Type", + "content": "Content", + "shared": "Shared", + "yes": "Yes", + "no": "No", + "guests": "Guests", + "ostype": "Operating system", + "onboot": "Starts with host", + "tags": "Tags", + "privilege": "Privilege", + "privileged": "Privileged", + "unprivileged": "Unprivileged", + "features": "Features", + "agent": "Guest agent", + "cpuModel": "CPU model", + "disks": "Disks", + "interfaces": "Network interfaces", + "protection": "Backup", + "passthrough": "Passthrough", + "noBackup": "No backup", + "noBackupDetail": "No enabled backup job selects this guest.", + "applications": "Applications", + "versionUnknown": "version not detected", + "passthroughTitle": "PCI passthrough", + "iommuGroup": "IOMMU group {group}", + "sharedGroup": "{count} more device(s) in the same group", + "proxmenux": "ProxMenux optimizations", + "latency": "Latência de rede", + "subscriptionStatus": { + "notfound": "Sem subscrição", + "active": "Ativa", + "invalid": "Inválida", + "expired": "Expirada", + "suspended": "Suspensa", + "new": "Aguarda ativação", + "unknown": "Desconhecido" + } + }, + "profile": { + "label": "Report", + "full": "Full audit", + "inventory": "Inventory", + "security": "Security review", + "backup": "Backup assurance", + "capacity": "Capacity and wear", + "diagnostic": "Diagnóstico rápido" + }, + "document": { + "action": "Gerar relatório", + "title": "Relatório de auditoria", + "subtitle": "Estrutura, configuração e avaliação de {node}", + "generated": "Gerado", + "assessed": "Assessed", + "runId": "Assessment reference", + "print": "Print or save as PDF", + "summaryByArea": "Summary by area", + "area": "Area", + "inventoryAnnex": "Inventory annex", + "scopeTitle": "Scope of this report", + "scopeBody": "This report describes the Proxmox VE node named above, as observed from the node itself at the time stated. It does not cover the interior of the guests beyond what they declare, network equipment outside the host, physical infrastructure, or any dependency not visible from this node. Findings marked as not determined were not measured and are not evidence of absence.", + "building": "A compor o relatório…", + "node": "Nó", + "profile": "Perfil", + "unknownNode": "nó não identificado", + "executiveSummary": "Resumo da avaliação", + "assessment": "Avaliação", + "verdictHeading": "Resultado desta execução", + "verdict": { + "critical": "ATENÇÃO", + "warning": "REVER", + "conformant": "EM ORDEM" + }, + "verdictText": { + "critical": "{fail} verificações relatam uma condição falhada e {warn} uma condição a rever, de {total} avaliadas.", + "warning": "Nenhuma verificação relata uma condição falhada. {warn} de {total} relatam uma condição a rever.", + "conformant": "As {total} verificações deste perfil concluem sem condições falhadas nem a rever.", + "none": "Este perfil não executa verificações. O documento descreve o nó sem o avaliar." + }, + "runAt": "Executado a {date}", + "chartNote": "Verificações por área e resultado.", + "nodeIdentity": "Identidade do nó", + "system": "Sistema", + "cluster": "Cluster", + "standaloneNote": "Este nó não faz parte de um cluster: mantém a sua própria configuração e os seus convidados não migram para outro nó.", + "clusterDiagramNote": "Nós configurados e as ligações corosync que os unem.", + "thisNode": "este nó", + "unreachable": "não visto", + "member": "membro", + "corosyncLinks": "ligações", + "quorum": "Quórum", + "quorate": "com quórum", + "inquorate": "sem quórum", + "votes": "Votos", + "nodeName": "Nó", + "architecture": "Arquitetura do sistema", + "architectureNote": "Como o nó está montado: processador e memória na placa, e o que depende de cada controladora.", + "systemIdentity": "Identidade do sistema", + "board": "Placa", + "processor": "Processador", + "topology": "Sockets × núcleos / threads", + "memory": "Memória", + "cores": "núcleos", + "threads": "threads", + "memoryModules": "Módulos de memória", + "slot": "Ranhura", + "slotsUsed": "ranhuras ocupadas", + "slotsFilled": "{used} de {total} ranhuras ocupadas", + "emptySlot": "vazia", + "formFactor": "Formato", + "speed": "Velocidade", + "manufacturer": "Fabricante", + "product": "Modelo", + "serial": "Número de série", + "controllers": "Controladoras", + "class": "Classe", + "device": "Dispositivo", + "iommuGroups": "Grupos IOMMU", + "iommuGroup": "Grupo IOMMU", + "field": "Campo", + "value": "Valor", + "size": "Tamanho", + "type": "Tipo", + "storageDevices": "Dispositivos de armazenamento", + "disks": "Discos", + "model": "Modelo", + "bus": "Bus", + "serviceLife": "Horas de serviço", + "healthy": "íntegro", + "years": "{years} anos", + "events": "Eventos", + "observations": "Observações", + "observationsNote": "Eventos registados. O SMART relata o estado atual; este registo relata o que aconteceu, incluindo eventos de que o disco recuperou.", + "noObservations": "Sem eventos registados", + "noObservationsNote": "Nenhum disco registou erros desde que o monitor os observa.", + "event": "Evento", + "severity": "Gravidade", + "occurrences": "Ocorrências", + "firstSeen": "Primeira vez", + "lastSeen": "Última vez", + "detail": "Detalhe", + "network": "Rede", + "adapters": "Adaptadores", + "bond": "Bond", + "bridge": "Bridge", + "bridges": "Bridges", + "physicalAdapters": "Adaptadores físicos", + "interface": "Interface", + "driver": "Controlador", + "state": "Estado", + "networkDiagramNote": "Caminho do cabo até cada convidado: adaptador físico, bond quando os agrupa, bridge e convidados ligados.", + "storageAndProtection": "Armazenamento e proteção", + "storage": "Armazenamento", + "content": "Conteúdo", + "shared": "Partilhado", + "location": "Localização", + "backupDestination": "Destino dos backups", + "unprotected": "sem backup", + "storageDiagramNote": "Onde residem os discos dos convidados e que destino os salvaguarda.", + "unprotectedGuests": "{count} convidados sem tarefa de backup", + "allProtected": "Todos os convidados estão cobertos por uma tarefa de backup", + "allProtectedNote": "A cobertura indica que uma tarefa seleciona o convidado; o resultado dos backups é avaliado à parte.", + "vmid": "VMID", + "name": "Nome", + "kind": "Tipo", + "backup": "Backup", + "none": "nenhum", + "managedSoftware": "Software gerido pelo ProxMenux", + "version": "Versão", + "source": "Origem", + "current": "atualizado", + "updateAvailable": "atualizar para {version}", + "findings": "Constatações em detalhe", + "incomplete": "parcial", + "scope": "Âmbito deste relatório", + "scopeText": "Este documento reporta o perfil {profile} no nó indicado no cabeçalho, no momento da execução.", + "scopeLocal": "Cobre apenas este nó. Os convidados de outros nós e a sua configuração ficam de fora.", + "scopeReadOnly": "Todas as verificações leem configuração e estado já existentes; nenhuma modifica o anfitrião.", + "scopeMoment": "Descreve o estado no momento da execução, não um período de tempo.", + "notRead": "Fontes que não foi possível ler:", + "uplink": "Ligação ascendente", + "conformance": "{pass} de {total} conformes", + "latency": "Latência de rede", + "latencyNote": "Latência medida na janela indicada, uma linha por destino.", + "milliseconds": "ms", + "hours": "h", + "minimum": "Mínimo", + "average": "Média", + "maximum": "Máximo", + "packetLoss": "Perda de pacotes", + "samples": "Amostras", + "target": { + "label": "Destino", + "gateway": "Gateway", + "cloudflare": "Cloudflare (1.1.1.1)", + "google": "Google (8.8.8.8)" + }, + "actionShort": "Relatório", + "policyDeclared": "Foi avaliado face a uma política declarada: {guests} convidados, {storages} armazenamentos e {thresholds} limiares indicados.", + "policyNone": "Não há política declarada, pelo que uma ausência que este relatório não consegue interpretar é indicada como observação e nunca como aviso.", + "diagnosticTitle": "Diagnóstico rápido", + "diagnosticSubtitle": "Resultados críticos e avisos em {node}", + "diagnosticActing": "{count} resultado(s) crítico(s) e aviso(s).", + "diagnosticClear": "Nenhum resultado crítico nem aviso. As observações e os resultados conformes estão na auditoria completa.", + "diagnosticUnread": "Leituras que não foi possível obter", + "diagnosticMoreRows": "{count} linha(s) adicionais, na auditoria completa.", + "structureTitle": "Estrutura e configuração", + "structureSubtitle": "Como {node} está construído e configurado" + }, + "results": "Resultados", + "classifications": { + "critical": "Crítico", + "warning": "Aviso", + "observation": "Observação", + "conformant": "Conforme", + "unverified": "Não verificado", + "not_applicable": "Não aplicável", + "accepted": "Risco aceite", + "by_design": "Excluído pela política" + }, + "policy": { + "inherit": "{value} (predefinição)", + "inheritUnset": "Predefinição", + "conflict": "A declaração foi alterada noutra sessão. O rascunho não foi guardado.", + "reload": "Recarregar a declaração guardada (descartar rascunho)", + "intro": "Uma avaliação vê o que este anfitrião faz, não para que serve. O que aqui se declara transforma uma observação em aviso, ou retira-a da contagem. Nada é obrigatório: sem declaração o relatório descreve em vez de julgar.", + "loading": "A ler a declaração…", + "failed": "Não foi possível ler a declaração", + "saved": "Guardado", + "declaredCount": "{count} declarações", + "guestsNote": "Requerido comunica como aviso o que falta; não declarado comunica-o como observação; não requerido deixa-o fora da contagem.", + "storagesNote": "Um armazenamento inacessível é crítico quando está declarado essencial ou serve um convidado em execução, aviso quando o seu papel não está declarado, e observação quando está declarado opcional.", + "thresholds": "Limiares", + "thresholdsNote": "Vazio significa o valor de origem, mostrado como marcador.", + "backup": "Backup", + "autostart": "Arranque", + "objective": "Objetivo de recuperação", + "objectivePlaceholder": "horas", + "noGuests": "Este nó não aloja convidados.", + "expectation": { + "required": "Necessário", + "not_required": "Não necessário", + "unspecified": "Não declarado" + }, + "role": { + "essential": "Essencial", + "optional": "Opcional", + "unspecified": "Não declarado" + }, + "threshold": { + "storage_usage_percent": "Limiar de revisão de capacidade (%)", + "thin_pool_usage_percent": "Limiar de revisão de enchimento da thin pool (%)", + "thin_overprovision_ratio": "Rácio de sobreaprovisionamento thin", + "zfs_scrub_days": "Intervalo de scrub do ZFS (dias)", + "backup_fallback_days": "Prazo de recurso para a antiguidade (dias)", + "backup_schedule_grace_ratio": "Margem sobre o calendário (rácio)", + "certificate_expiry_days": "Aviso de validade do certificado (dias)", + "memory_overcommit_ratio": "Rácio de sobreatribuição de memória", + "disk_service_life_hours": "Vida útil do disco (horas)", + "lynis_report_days": "Antiguidade do relatório Lynis (dias)", + "package_index_days": "Antiguidade dos índices de pacotes (dias)", + "journal_usage_percent": "Journal face ao seu limite (%)", + "filesystem_usage_percent": "Limiar de revisão de espaço (%)", + "filesystem_inode_percent": "Limiar de revisão de inodes (%)", + "disk_error_recent_days": "Janela de erros de disco recentes (dias)" + } + }, + "changes": { + "loading": "A ler o registo de alterações…", + "failed": "Não foi possível ler o registo de alterações", + "intro": "O que o ProxMenux alterou neste host e o que existia antes de cada alteração. Mostra-se a diferença, não o script que a aplicou.", + "empty": "Ainda não foi registado nada neste anfitrião.", + "since": "A registar desde {date}. O que foi aplicado antes figura como aplicado, sem o estado que substituiu.", + "byFunction": "Por função", + "count": "{count} alterações", + "function": "Função", + "source": "Script", + "reversibility": "Desfazer isto", + "difference": "Diferença", + "diffTruncated": "A diferença é maior do que o mostrado.", + "diffUnavailable": "O conteúdo substituído já não está guardado, pelo que a diferença não pode ser mostrada.", + "packagesAdded": "Pacotes adicionados", + "commandRun": "Comando executado", + "executionNote": "O ProxMenux executou-o a pedido; o que mudou foi decidido pelo comando, não pelo ProxMenux.", + "unknownNote": "Isto foi aplicado antes de existir o registo, pelo que nunca se capturou o que substituiu.", + "noneInFilter": "Não há alterações deste tipo.", + "class": { + "all": "Todas", + "configuration": "Configuração", + "installation": "Instalações", + "execution": "Execuções", + "registration": "Aplicado" + }, + "operation": { + "write_file": "Ficheiro substituído", + "edit_file": "Ficheiro editado", + "remove_file": "Ficheiro removido", + "install_package": "Instalado", + "enable_service": "Serviço ativado", + "disable_service": "Serviço desativado", + "run_command": "Executado", + "applied": "Aplicado", + "removed": "Removido", + "unknown": "Alteração" + }, + "capture": { + "unknown": "Estado anterior desconhecido" + }, + "exactness": { + "exact": "Restaura exatamente o que lá estava", + "partial": "Parcial: podem ficar dependências ou sair com ele", + "none": "Não pode ser desfeito a partir do registo" + } + }, + "comparison": { + "loading": "A comparar com a execução de referência…", + "failed": "Não foi possível definir a execução de referência", + "since": "Desde {date}", + "previousRun": "a execução anterior", + "noChange": "Sem alterações", + "isBaseline": "Esta execução é a referência com que as outras são comparadas.", + "noBaseline": "Ainda não foi escolhida uma execução de referência, pelo que não há com que comparar.", + "setBaseline": "Usar como referência", + "unchanged": "{count} verificações deram o mesmo resultado que antes.", + "new": "Novos", + "newNote": "reportados agora e antes não", + "resolved": "Resolvidos", + "resolvedNote": "já não são reportados, e ninguém os aceitou", + "accepted": "Aceites", + "acceptedNote": "deixam de contar porque se aceitou um risco, não porque o anfitrião tenha mudado", + "retired": "Já não avaliados", + "retiredNote": "estavam antes e não nesta execução; nada verificou que tivessem cessado", + "reasons": { + "insufficient_runs": "Uma comparação exige uma execução de referência e outra posterior; até agora só há uma registada" + } + }, + "notApplicableScope": "Nada no âmbito examinado a que esta verificação se aplique." } } diff --git a/AppImage/messages/sk/common.json b/AppImage/messages/sk/common.json index 645b18de..1f395f1d 100644 --- a/AppImage/messages/sk/common.json +++ b/AppImage/messages/sk/common.json @@ -308,7 +308,7 @@ "shortTest": "Krátky test", "longTest": "Dlhý test (1-4 hodiny)", "extendedTest": "Rozšírený test", - "testHelp": "Krátky test trvá asi 2 minúty.Rozšírený test prebieha na pozadí a na veľkých diskoch môže trvať niekoľko hodín.Po dokončení sa výsledok zobrazí na karte História.", + "testHelp": "Krátky test trvá asi 2 minúty. Rozšírený test prebieha na pozadí a na veľkých diskoch môže trvať niekoľko hodín. Po dokončení sa výsledok zobrazí na karte História.", "startFailed": "Test sa nepodarilo spustiť", "short": "Krátky", "extended": "Rozšírený", @@ -1241,7 +1241,15 @@ "humanWeekly": "Týždenne ({day} o {time})", "humanMonthly": "Mesačne ({day}. deň o {time})", "humanHourly": "Každú hodinu", - "weekdays": "['nedeľa', 'pondelok', 'utorok', 'streda', 'štvrtok', 'piatok', 'sobota']" + "weekdays": [ + "nedeľa", + "pondelok", + "utorok", + "streda", + "štvrtok", + "piatok", + "sobota" + ] }, "cronChip": { "detected": "zistený cron na serveri", @@ -1394,8 +1402,7 @@ "postApplyAllOk": "{count} balíkov bolo úspešne použitých – nič sa nečaká.", "postApplyNothingPending": "Nič sa nečaká – všetko je aktuálne.", "postApplyPartial": "{pending} balíkov, ktoré po spustení stále čakajú.", - "postApplyPartialSubline": "Použilo sa {applied}. Niektoré aktualizácie sa nedokončili – skontrolujte výstup terminálu vyššie.", - "updatedWithDockerImage": "Aktualizované s obrázkom Docker." + "postApplyPartialSubline": "Použilo sa {applied}. Niektoré aktualizácie sa nedokončili – skontrolujte výstup terminálu vyššie." }, "bulkUpdate": { "title": "Hromadná aktualizácia", @@ -1623,14 +1630,9 @@ "notificationsEnabled": "Upozornenia na upstream aktualizácie sú ZAPNUTÉ – kliknutím ich stlmíte", "notificationsMuted": "Upstream upozornenia na aktualizácie MUTED – kliknutím aktivujete", "notifyUpstreamLabel": "Upozorniť ma, keď bude k dispozícii nová upstream verzia", - "notifyUpstreamHelp": "Odošle `app_update_available` do kanálov povolených v Nastaveniach → Upozornenia.Vypnite, ak túto aplikáciu nie je možné aktualizovať na vašom boxe.", + "notifyUpstreamHelp": "Odošle `app_update_available` do kanálov povolených v Nastaveniach → Upozornenia. Vypnite, ak túto aplikáciu nie je možné aktualizovať na vašom boxe.", "excludeFromBadgeLabel": "Vylúčiť z počítadla aktualizácií LXC", - "excludeFromBadgeHelp": "Nezapočítajte túto aplikáciu do odznaku súhrnných aktualizácií na karte zoznamu LXC.Užitočné, keď ste úmyselne pripnutý ku konkrétnej verzii (požiadavka na sledovanie, zmrazenie kompatibility).Nemá vplyv na vlastný stav karty Aplikácia ani na odchádzajúce upozornenie.", - "dockerDetectedWithWorkloads": "Docker bolo zistené s {count} kontajnerovými aplikáciami", - "dockerWorkloadsHeading": "Beží vnútri Docker", - "runsInsideDocker": "Aktualizované s obrázkom Docker", - "upstreamDelegatedTitle": "Dostupná verzia pochádza z jej obrázka Docker", - "upstreamDelegatedHelp": "Táto aplikácia beží v kontajneri, takže dostupná verzia je bez ohľadu na jej rozlíšenie – žiadna samostatná kontrola proti prúdu a jedna aktualizácia nahlásená raz.Aktualizujte ho z obrázka na karte Aktualizácie." + "excludeFromBadgeHelp": "Nezapočítajte túto aplikáciu do odznaku súhrnných aktualizácií na karte zoznamu LXC.Užitočné, keď ste úmyselne pripnutý ku konkrétnej verzii (požiadavka na sledovanie, zmrazenie kompatibility).Nemá vplyv na vlastný stav karty Aplikácia ani na odchádzajúce upozornenie." } }, "settings": { @@ -1888,6 +1890,7 @@ "system_reboot": "Systém sa reštartuje", "system_restore_completed": "Obnova servera bola dokončená", "system_problem": "Zistený problém so systémom", + "kernel_warning": "Upozornenia a diagnostické stopy jadra", "service_fail": "Služba zlyhala", "oom_kill": "Proces bol ukončený pre nedostatok pamäte", "service_fail_batch": "Zlyhanie viacerých služieb", @@ -4850,7 +4853,73 @@ "connectionTimeout": "Čas na pripojenie vypršal. Skontrolujte sieť a skúste to znova.", "vpnTip": "Ak používate VPN, skontrolujte, či je pripojenie stabilné.", "websocketError": "Chyba WebSocket spojenia", - "commandDescriptions": "['Zobraziť všetky Proxmox servery', 'Zobraziť VM na serveri', 'Zobraziť LXC kontajnery na serveri', 'Zobraziť úložiská na serveri', 'Zobraziť sieťové rozhrania', 'Zobraziť všetky QEMU/KVM virtuálne stroje', 'Spustiť virtuálny stroj', 'Zastaviť virtuálny stroj', 'Bezpečne vypnúť virtuálny stroj', 'Zobraziť stav VM', 'Zobraziť nastavenia VM', 'Vytvoriť Snapshot VM', 'Zobraziť všetky LXC kontajnery', 'Spustiť LXC kontajner', 'Zastaviť LXC kontajner', 'Otvoriť konzolu LXC kontajnera', 'Zobraziť nastavenia kontajnera', 'Zobraziť stav úložísk', 'Zobraziť obsah úložiska', 'Otestovať výkon Proxmox systému', 'Zobraziť verziu Proxmox VE', 'Skontrolovať stav klastra', 'Zobraziť stav klastra', 'Zobraziť servery v klastri', 'Zobraziť stav ZFS poolu', 'Zobraziť všetky ZFS pooly', 'Zobraziť všetky ZFS datasety', 'Zobraziť podrobnosti o súboroch', 'Prejsť do iného priečinka', 'Vytvoriť priečinok', 'Odstrániť priečinok aj s obsahom', 'Kopírovať súbory alebo priečinky', 'Presunúť alebo premenovať súbory', 'Zobraziť obsah súboru', 'Hľadať text v súbore', 'Hľadať súbory podľa názvu', 'Zmeniť oprávnenia súboru', 'Zmeniť vlastníka súboru', 'Rozbaliť archív tar.gz', 'Vytvoriť archív tar.gz', 'Zobraziť využitie diskov', 'Zobraziť veľkosť priečinkov', 'Zobraziť využitie pamäte', 'Zobraziť bežiace procesy', 'Nájsť bežiaci proces', 'Vynútene ukončiť proces', 'Skontrolovať stav služby', 'Spustiť službu', 'Zastaviť službu', 'Reštartovať službu', 'Aktualizovať balíky Debianu/Ubuntu', 'Nainštalovať balík pre Debian/Ubuntu', 'Odstrániť balík', 'Zobraziť bežiace kontajnery', 'Zobraziť Docker images', 'Otvoriť shell v kontajneri', 'Zobraziť IP adresy', 'Otestovať sieťové pripojenie', 'Zobraziť HTTP hlavičky', 'Stiahnuť súbor', 'Pripojiť sa cez SSH', 'Kopírovať súbor cez SSH', 'Sledovať log v reálnom čase', 'Zobraziť históriu príkazov', 'Vyčistiť obrazovku terminálu']" + "commandDescriptions": [ + "Zobraziť všetky Proxmox servery", + "Zobraziť VM na serveri", + "Zobraziť LXC kontajnery na serveri", + "Zobraziť úložiská na serveri", + "Zobraziť sieťové rozhrania", + "Zobraziť všetky QEMU/KVM virtuálne stroje", + "Spustiť virtuálny stroj", + "Zastaviť virtuálny stroj", + "Bezpečne vypnúť virtuálny stroj", + "Zobraziť stav VM", + "Zobraziť nastavenia VM", + "Vytvoriť Snapshot VM", + "Zobraziť všetky LXC kontajnery", + "Spustiť LXC kontajner", + "Zastaviť LXC kontajner", + "Otvoriť konzolu LXC kontajnera", + "Zobraziť nastavenia kontajnera", + "Zobraziť stav úložísk", + "Zobraziť obsah úložiska", + "Otestovať výkon Proxmox systému", + "Zobraziť verziu Proxmox VE", + "Skontrolovať stav klastra", + "Zobraziť stav klastra", + "Zobraziť servery v klastri", + "Zobraziť stav ZFS poolu", + "Zobraziť všetky ZFS pooly", + "Zobraziť všetky ZFS datasety", + "Zobraziť podrobnosti o súboroch", + "Prejsť do iného priečinka", + "Vytvoriť priečinok", + "Odstrániť priečinok aj s obsahom", + "Kopírovať súbory alebo priečinky", + "Presunúť alebo premenovať súbory", + "Zobraziť obsah súboru", + "Hľadať text v súbore", + "Hľadať súbory podľa názvu", + "Zmeniť oprávnenia súboru", + "Zmeniť vlastníka súboru", + "Rozbaliť archív tar.gz", + "Vytvoriť archív tar.gz", + "Zobraziť využitie diskov", + "Zobraziť veľkosť priečinkov", + "Zobraziť využitie pamäte", + "Zobraziť bežiace procesy", + "Nájsť bežiaci proces", + "Vynútene ukončiť proces", + "Skontrolovať stav služby", + "Spustiť službu", + "Zastaviť službu", + "Reštartovať službu", + "Aktualizovať balíky Debianu/Ubuntu", + "Nainštalovať balík pre Debian/Ubuntu", + "Odstrániť balík", + "Zobraziť bežiace kontajnery", + "Zobraziť Docker images", + "Otvoriť shell v kontajneri", + "Zobraziť IP adresy", + "Otestovať sieťové pripojenie", + "Zobraziť HTTP hlavičky", + "Stiahnuť súbor", + "Pripojiť sa cez SSH", + "Kopírovať súbor cez SSH", + "Sledovať log v reálnom čase", + "Zobraziť históriu príkazov", + "Vyčistiť obrazovku terminálu" + ] }, "scriptTerminal": { "processing": "Spracúvam...", @@ -4915,6 +4984,154 @@ "customLinkDeleteError": "Odkaz sa nepodarilo odstrániť" }, "audit": { + "presentation": { + "backupAge": "Vek zálohy", + "backupLimit": "Použitý limit", + "limitDeclared": "Lehota určená používateľom", + "limitSchedule": "Plán + tolerancia", + "limitReference": "Referenčná lehota", + "verifiedChecks": "Overené kontroly", + "unverifiedChecks": "Neoverené kontroly", + "checkName": "Kontrola", + "verified": "Overené", + "verificationScope": "Overené uplatniteľné kontroly. Pokrytie nevyjadruje stav ani bezpečnosť servera.", + "noApplicable": "V tomto hodnotení nie sú žiadne uplatniteľné kontroly.", + "noJob": "Bez plánovanej úlohy", + "guest": "Hosť", + "guests": "hostia", + "host": "Hostiteľ", + "resource": "Prostriedok", + "data": "Dáta", + "metadata": "Metadáta", + "result": "Výsledok", + "fact": "Zistený údaj", + "records": "záznamy", + "unscheduled": "hostia bez plánovanej úlohy", + "excludedDisks": "vylúčené disky", + "disks": "Disky", + "destination": "Cieľ", + "lastCopy": "Posledná uložená záloha", + "ageLimit": "Vek / limit", + "noDestination": "Bez nastaveného cieľa", + "notFound": "V skúmanom rozsahu sa nenašla záloha", + "promiscuous": "Rozhrania v promiskuitnom režime", + "noDescription": "Popis nie je dostupný", + "occurrence": "Výskyt", + "occurrences": "výskyty", + "detail": "Podrobnosť", + "technical": "Technické podklady", + "annex": "Technická príloha", + "overview": "Prehľad zistení", + "incomplete": "Neúplné vyhodnotenie", + "assessment": "Vyhodnotenie", + "noGlobalScore": "Výsledky opisujú samostatné kritériá; celkové skóre bezpečnosti sa nepočíta.", + "coverage": "Pokrytie plánovanými zálohami", + "scheduled": "S plánovanou úlohou", + "copyScope": "Nastavená úloha nepotvrdzuje existenciu uloženej ani obnoviteľnej zálohy.", + "detailsLink": "Odkaz na podklady", + "noSubscription": "Predplatné nie je evidované", + "otherDevices": "Ďalšie zariadenia v skupine", + "unversioned": "Verzia nie je evidovaná", + "notInstalled": "Nie je nainštalované", + "noPendingRecorded": "Čakajúca aktualizácia nie je evidovaná", + "originalEvidence": "Pôvodné podklady zo zdroja", + "evidenceObserved": "Pozorované podklady", + "evidenceExcerpt": "Kompaktné zobrazenie. Úplné zdrojové podklady zostávajú uložené s týmto vyhodnotením.", + "annexScope": "Úplné zdrojové podklady pre výsledky, ktoré vyžadujú pozornosť, zaznamenávajú pozorovanie alebo ich nebolo možné overiť.", + "readOnlyScope": "Vyhodnotenie nemení konfiguráciu. Dopyty a v prípade potreby Lynis môžu vytvoriť záznamy alebo správy.", + "capacity": "Kapacita", + "used": "Využité", + "free": "Voľné", + "reasons": { + "agentNotDeclared": "V konfigurácii nie je deklarovaný agent hosťa", + "arcConflictingSettings": "Trvalé nastavenia si navzájom odporujú", + "arcMinAboveMax": "Dolná hranica ARC je nad hornou", + "arcPendingReboot": "Trvalé nastavenie sa líši od načítaného parametra", + "arrayDegraded": "Beží s menším počtom zariadení, než s akým bolo vytvorené", + "arrayNotActive": "Nie je aktívne", + "arrayRebuilding": "Chýbajú zariadenia a prestavuje sa", + "backupRunFailed": "Beh skončil chybou", + "backupRunRecovered": "Skôr zlyhala a neskoršie spustenie uspelo", + "bondNoMembersUp": "Nefunkčné a žiadny člen bondu nie je aktívny", + "bondRedundancyLost": "Nefunkčné; bond si ponecháva ďalšie spojenia", + "bootEspMissingNewest": "Nenesie najnovšie jadro, ktoré nesú ostatné", + "bootEspOutOfSync": "Nie je v zhode s ostatnými: spustil by iné jadro", + "bootSingleEsp": "Jeden nakonfigurovaný zavádzací oddiel", + "bootToolReported": "Nahlásené nástrojom proxmox-boot-tool", + "cephCheckRaised": "Vyvolané Cephom", + "channelIncomplete": "Povolený, no chýba časť jeho konfigurácie", + "clusterInquorate": "Bez kvóra: zmeny klastra sa odmietajú", + "clusterMemberAbsent": "Nakonfigurovaný uzol, ktorý klaster nevidí", + "clusterSingleLink": "Deklarované jediné spojenie corosync", + "dataExcludedFromBackup": "Údaje vylúčené zo zálohy hosťa", + "deliveryFailing": "Nedávne doručenia neodišli", + "destinationUnavailable": "Nastavený cieľ nie je dostupný", + "diskErrorsActive": "Chyba zaznamenaná v kontrolovanom období", + "diskErrorsPast": "Hlásil chyby skôr, žiadnu v používanom okne", + "diskWarningsActive": "Upozornenie zariadenia zaznamenané v kontrolovanom období", + "diskWarningsPast": "Hlásil upozornenia zariadenia skôr, žiadne v používanom okne", + "essentialServiceDown": "Služba, ktorú Proxmox potrebuje na odpoveď, nie je aktívna", + "exemptByPolicy": "Označené ako nevyžadované, preto je mimo počtu", + "expectedButUncovered": "Označený ako vyžadujúci zálohu a nevyberá ho žiadna povolená úloha", + "expectedToAutostart": "Označený ako štartujúci s hostiteľom, no neštartuje", + "filesystemExhausted": "Nezostáva žiadne miesto", + "filesystemNearlyFull": "Na prahu preverenia miesta alebo nad ním", + "filesystemReadOnly": "Jadro hlási tento pripojený bod ako len na čítanie: prestal prijímať zápisy", + "haManagerNotReady": "Ani aktívny, ani nečinný: nemôže prevziať službu", + "haNoMaster": "Bez správcu: nič nerozhoduje, kde má služba bežať", + "haServiceError": "V chybovom stave a už nespravovaná", + "haServiceTransitioning": "V prechode", + "hostArchiveMissing": "Záznam úlohy uvádza archív, ktorý už nie je uložený", + "hostBackupJobFailed": "Úloha skončila chybou", + "hostBackupStale": "Staršie než používaný vekový limit", + "hostBackupUnscheduled": "Uložené, bez plánu na ďalšiu kópiu", + "hostNoRetrievableCopy": "Žiadna kópia, o ktorej by táto kontrola vedela", + "indexesStale": "Indexy balíkov neboli nedávno aktualizované", + "inodesExhausted": "Nezostávajú žiadne inody", + "inodesNearlyExhausted": "Na prahu preverenia inodov alebo nad ním", + "kernelAwaitingReboot": "Nainštalované a nie je to bežiace jadro", + "lynisReportStale": "Správa Lynisu má {days} dní, viac než používaný referenčný vek", + "lynisWarning": "Zaznamenané kontrolou Lynis", + "multipathNoPath": "Nezostáva žiadna cesta", + "multipathPathDown": "Obsluhuje po menej cestách", + "noAutostart": "Neštartuje s hostiteľom", + "noConfigurationReference": "Bez odkazu v preverených konfiguráciách", + "noJobSelectsGuest": "Nevyberá ho žiadna povolená zálohovacia úloha", + "noPhysicalPort": "Nemá žiadny fyzický port", + "noStoredBackup": "Uložená záloha sa nenašla", + "noStoredBackupUnscheduled": "Bez plánovanej úlohy; záloha sa nenašla", + "olderThanFallback": "Záloha prekračuje referenčnú lehotu.", + "olderThanObjective": "Záloha prekračuje lehotu určenú používateľom.", + "olderThanSchedule": "Záloha prekračuje naplánovaný interval vrátane tolerancie.", + "overprovisioned": "Rozdáva viac virtuálnej kapacity, než pool má", + "packageAwaitingRestart": "Nainštalované a žiada reštart", + "pastServiceLife": "Nad prahom životnosti používaným na plánovanie", + "pinnedToHostCpu": "Viazané na model procesora hostiteľa", + "poolDeviceErrors": "Zariadenie počíta chyby čítania, zápisu alebo kontrolného súčtu", + "poolNotOnline": "Nie je online", + "rebootMarkerWithoutPackages": "Niečo zapísalo značku reštartu bez uvedenia balíka", + "recoveryKeyLocalOnly": "Šifrovací kľúč zálohy uložený len na tomto uzle, podľa zaznamenaného režimu úschovy", + "replicationDisabled": "Pozastavené", + "replicationFailing": "Posledný beh hlásil chybu", + "replicationNeverRan": "Nikdy nedokončila synchronizáciu", + "replicationOverdue": "Staršia, než pripúšťa vlastný kalendár úlohy", + "retentionNotDeclared": "Bez deklarovanej retencie; uchovávajú sa všetky kópie", + "retentionOnServer": "Čistí sa na zálohovacom serveri, úlohami, ktoré tento uzol nevidí", + "runsPrivileged": "Beží privilegovane a zdieľa menný priestor používateľov hostiteľa", + "scrubOverdue": "Posledný dokončený scrub je starší než prah preverenia", + "storageNearlyFull": "Na prahu preverenia kapacity alebo nad ním", + "storageUnreachable": "Nedostupné", + "thinDataPressure": "Zapísané dáta blízko kapacity poolu", + "thinMetadataPressure": "Metadáta takmer plné, čo prepne pool len na čítanie", + "unitFailed": "systemd prestal o ňu pokúšať", + "verificationFailedOnly": "Overenie prečítalo najnovšiu kópiu a nebola neporušená; žiadna iná kópia tohto hosťa overená nebola", + "verificationFailedWithFallback": "Overenie prečítalo najnovšiu kópiu a nebola neporušená; staršia kópia overená bola", + "verificationNotRun": "Túto kópiu nespätne neprečítala žiadna overovacia úloha" + }, + "lynisTest": "Test", + "lynisWarning": "Upozornenie", + "couldNotRead": "Nepodarilo sa prečítať" + }, "title": "Audit & Report", "loading": "Loading assessment…", "run": "Run assessment", @@ -4923,9 +5140,8 @@ "lastRun": "Last assessed on {when}", "stale": "{days} days ago", "readOnlyNotice": "The assessment only reads the host. It makes no changes.", + "unverifiedChecks": "Nevykonané: {checks}. Každá vo svojich dôkazoch uvádza, čo sa jej nepodarilo prečítať.", "noFindings": "No findings match the current filter.", - "showPassing": "Show passing checks", - "hidePassing": "Hide passing checks", "affectedCount": "{count} affected", "acceptedNotice": "{count} accepted risk(s) recorded on this host.", "states": { @@ -4933,7 +5149,8 @@ "warn": "Warning", "accepted": "Accepted risk", "pass": "Passed", - "not_applicable": "Not applicable" + "not_applicable": "Not applicable", + "unknown": "Neoverené" }, "areas": { "all": "All", @@ -4949,7 +5166,8 @@ "why": "Context", "evidence": "Evidence", "affected": "Affected", - "acceptedRisk": "Accepted risk" + "acceptedRisk": "Accepted risk", + "sources": "Zdroje a časy zberu" }, "errors": { "runFailed": "The assessment could not be started." @@ -4958,69 +5176,434 @@ "backup": { "guest_coverage": { "title": "Backup coverage", - "rationale": "Compares the guest inventory against what each backup job selects. A job selects by VMID list, by pool, or with `all 1`, minus its `exclude` list. Jobs carrying `enabled 0` are not considered.", + "rationale": "Povolené zálohovacie úlohy na tomto uzle, hostia, ktorých každá vyberá, a údaje hosťa z nich vylúčené. Nakonfigurované pokrytie nedokazuje, že existuje použiteľná záloha. Či mal byť nevybraný hosť chránený, hovorí deklarovaná politika.", "summary": { - "noJobs": "No backup job is defined on this node", - "covered": "All {total} guests are covered by a backup job", - "uncovered": "{count} of {total} guests are not covered by any enabled backup job" + "noJobs": "Pre {total} hostí na tomto uzle nie je definovaná žiadna zálohovacia úloha", + "covered": "Všetkých {total} hostí vyberá povolená zálohovacia úloha", + "uncovered": "{count} z {total} hostí nevyberá žiadna povolená zálohovacia úloha", + "excludedData": "Skontrolujte {count} vylúčení diskov alebo prípojných bodov", + "uncoveredExpected": "{required} hostí označených ako vyžadujúcich zálohu nevyberá žiadna povolená úloha", + "evaluationFailed": "The check could not be evaluated" + } + }, + "last_backup_age": { + "title": "Age of stored backups", + "rationale": "Vek: čas uplynutý od poslednej uloženej zálohy. Použitý limit: referenčný vek, s ktorým sa táto záloha porovnáva.", + "summary": { + "recent": "Všetkých {total} kontrol stroja/cieľa spĺňa uvedené kritérium veku zálohy", + "stale": "{count} of {total} guests have no backup from the last 30 days", + "noBackups": "No stored backup matches a guest on this node", + "attention": "{count} z {total} kontrol stroja/cieľa vyžaduje preverenie", + "evaluationFailed": "The check could not be evaluated" + } + }, + "retention_defined": { + "title": "Backup retention", + "rationale": "Retencia tak, ako ju rieši Proxmox: nastavenie úlohy, potom úložiska, potom predvolená hodnota uzla. Retenciu, ktorú uplatňuje zálohovací server, z tohto uzla prečítať nemožno.", + "summary": { + "allDefined": "Všetkých {total} úloh má vyriešené nastavenie retencie", + "missing": "{count} of {total} enabled job(s) declare no retention", + "notDeclared": "{count} z {total} úloh uchováva každú kópiu: nemajú deklarovanú retenciu", + "onServer": "{count} z {total} úloh zapisuje na zálohovací server, ktorý ich čistí vlastnými úlohami", + "evaluationFailed": "The check could not be evaluated" + } + }, + "verification_state": { + "title": "Overenie záloh", + "rationale": "Výsledok overenia, ktorý Proxmox Backup Server zaznamenáva pre najnovšiu kópiu každého hosťa, a či bola overená staršia kópia toho istého hosťa. Overenie číta uloženú kópiu; nie je to obnova.", + "summary": { + "allVerified": "Najnovšia kópia všetkých {total} hostí bola overená ako neporušená", + "failed": "{failed} najnovších kópií neprešlo overením, z {total} preskúmaných", + "notVerified": "{pending} z {total} najnovších kópií nebolo overených", + "evaluationFailed": "Stav overenia sa nepodarilo prečítať" + } + }, + "job_results": { + "title": "Výsledky zálohovacích behov", + "rationale": "Ako skončilo najnovšie zaznamenané spustenie každého hosťa, podľa protokolu úloh uzla. Hodnotí sa len posledné. Protokol sa uchováva obmedzený čas.", + "summary": { + "allSucceeded": "Všetkých {total} zaznamenaných zálohovacích behov skončilo bez chyby", + "someFailed": "{count} z {total} zaznamenaných zálohovacích behov skončilo chybou", + "evaluationFailed": "Protokol úloh sa nepodarilo prečítať", + "recovered": "{count} z {total} hostí zlyhalo pri skoršom spustení a odvtedy uspelo" + } + }, + "host_recovery": { + "title": "Obnova hostiteľa", + "rationale": "Zálohy hostiteľa tak, ako ich zaznamenáva ProxMenux: každá spustená úloha, kedy, či uspela, cieľ a či tá kópia stále existuje. Úloha zapisujúca na zálohovací server neuvádza lokálnu cestu. Šifrovacie kľúče sa uvádzajú len počtom a zaznamenaným režimom úschovy.", + "summary": { + "noHostBackup": "Nie je uložená žiadna záloha konfigurácie hostiteľa ani časovač, ktorý by ju vytváral", + "protected": "Konfigurácia samotného uzla je uložená v {total} archíve(och), v rámci používaného vekového limitu", + "attention": "{count} zistenie(a) k {total} záznamom konfigurácie hostiteľa", + "scheduledOnly": "Zálohy konfigurácie hostiteľa sú naplánované cez {count} časovač(e); lokálne nie je uložený žiadny archív" } } }, "system": { "pending_reboot": { "title": "Restart state", - "rationale": "Checks for `/var/run/reboot-required` and, when present, the packages listed in `/var/run/reboot-required.pkgs`. Also reports the running kernel.", + "rationale": "Značka `/var/run/reboot-required` a balíky uvedené v `/var/run/reboot-required.pkgs`. Jej neprítomnosť nedokazuje, že netreba nič reštartovať.", "summary": { - "none": "No restart is pending", - "pending": "The host has a pending restart" + "none": "Nič si nevyžiadalo reštart", + "pending": "{count} položiek je nainštalovaných a čaká na reštart", + "evaluationFailed": "The check could not be evaluated" } }, "enterprise_repo_without_subscription": { "title": "Enterprise repository", - "rationale": "Looks for references to `enterprise.proxmox.com` in `/etc/apt/sources.list` and `sources.list.d`, and contrasts the result with the status returned by `pvesubscription get`.", + "rationale": "Odkazy na `enterprise.proxmox.com` v `/etc/apt/sources.list` a `sources.list.d` oproti stavu, ktorý vracia `pvesubscription get`.", "summary": { "notEnabled": "The enterprise repository is not enabled", "subscribed": "The enterprise repository is backed by a subscription", - "unsubscribed": "The enterprise repository is enabled without an active subscription" + "unsubscribed": "The enterprise repository is enabled without an active subscription", + "evaluationFailed": "The check could not be evaluated" + } + }, + "memory_overcommit": { + "title": "Memory allocation", + "rationale": "Strop `memory` každej konfigurácie hosťa oproti MemTotal, pričom bežiaci hostia sa počítajú osobitne. Kontajnery spotrebúvajú po tento limit; virtuálne stroje bez ballooningu ho rezervujú.", + "summary": { + "withinRatio": "Guests are allocated {percent}% of host memory", + "aboveRatio": "Guests are allocated {percent}% of host memory", + "evaluationFailed": "The check could not be evaluated" + } + }, + "time_synchronisation": { + "title": "Time synchronisation", + "rationale": "NTP a NTPSynchronized tak, ako ich hlási `timedatectl`. Členstvo v klastri, overovanie certifikátov a poradie záznamov závisia od zhodných hodín. Hodiny môže riadiť aj iný mechanizmus.", + "summary": { + "synchronised": "The clock is synchronised with a time source", + "disabled": "Time synchronisation is disabled", + "notSynchronised": "Time synchronisation is enabled but the clock is not synchronised", + "evaluationFailed": "The check could not be evaluated" + } + }, + "kernel_current": { + "title": "Running kernel", + "rationale": "Bežiace jadro oproti tomu, ktoré by hostiteľ spustil nabudúce, ako ho hlási `proxmox-boot-tool`. Novšie iba nainštalované jadro môže byť zámerne pozdržané; rozdiel po reštarte znamená štart, ktorý sa neuplatnil.", + "summary": { + "current": "Bežiace jadro {version} je to, ktoré by hostiteľ spustil nabudúce", + "newerAvailable": "The host runs {running} while {newest} is installed", + "newerSelected": "Hostiteľ beží na {running} a pri najbližšom reštarte by spustil {selected}", + "wouldDowngrade": "Hostiteľ beží na {running}, ale pri najbližšom reštarte by spustil staršie {selected}", + "bootTargetUnknown": "Hostiteľ beží na {version}; jadro zvolené pre najbližší štart sa nepodarilo prečítať", + "evaluationFailed": "The check could not be evaluated" + } + }, + "security_updates": { + "title": "Security updates", + "rationale": "Čakajúce balíky, ktorých zdrojom je bezpečnostný repozitár, zo simulovaného `apt-get upgrade`. Počet odráža to, čo hlási apt, nie závažnosť toho, čo každý balík opravuje.", + "summary": { + "none": "No package updates are pending", + "noSecurity": "{total} update(s) pending, none from a security repository", + "pending": "{count} of {total} pending update(s) come from a security repository", + "evaluationFailed": "The check could not be evaluated" + } + }, + "journal_size": { + "title": "Journal size", + "rationale": "Journal na disku oproti stropu, ktorý sa naň vzťahuje: SystemMaxUse, ak je nastavený, inak predvolená hodnota journald, desatina súborového systému, na ktorom leží.", + "summary": { + "bounded": "Journal zaberá {size}, v rámci svojho účinného stropu", + "large": "The journal holds {size} on disk", + "nearCap": "Journal zaberá {size} a je na {percent}% svojho účinného stropu", + "capUnknown": "Journal zaberá {size}; jeho účinný strop sa nepodarilo určiť", + "evaluationFailed": "The check could not be evaluated" + } + }, + "swap_configured": { + "title": "Swap", + "rationale": "Aktívne odkladacie oblasti podľa `swapon` a ich súčet oproti pamäti hostiteľa. Žiadne pravidlo nevyžaduje pomer k RAM; tlak na pamäť sa meria inde.", + "summary": { + "active": "{size} of swap is active", + "none": "No swap area is active", + "evaluationFailed": "The check could not be evaluated" + } + }, + "filesystem_capacity": { + "title": "Kapacita súborových systémov hostiteľa", + "rationale": "Miesto a inody súborových systémov, ktoré hostiteľ sám potrebuje: koreň, /var, /var/log a cesta lokálneho úložiska. Súborový systém s voľným miestom a bez inodov zlyhá rovnako ako plný.", + "summary": { + "withinLimits": "{total} súborových systémov hostiteľa je pod svojimi prahmi preverenia", + "pressure": "{count} meraní je na prahu preverenia alebo nad ním", + "evaluationFailed": "Obsadenosť súborových systémov sa nepodarilo prečítať" + } + }, + "update_chain": { + "title": "Vek indexov balíkov APT", + "rationale": "Kedy APT naposledy umiestnil na tomto hostiteľovi index balíkov. Repozitár, ktorý odpovie „nezmenené\", ponechá svoj index nedotknutý. Dostupnosť repozitárov sa netestuje.", + "summary": { + "current": "Indexy balíkov boli aktualizované pred {days} dňom/dňami", + "stale": "Indexy balíkov boli naposledy aktualizované pred {days} dňom/dňami", + "indexAgeUnknown": "Vek indexov balíkov sa nepodarilo určiť" + } + }, + "notification_delivery": { + "title": "Posledný výsledok upozornenia", + "rationale": "Povolené kanály a výsledok ich posledného uloženého doručenia. Bez histórie zostáva doručenie neoverené; skoršie zlyhanie nasledované úspechom sa nepovažuje za aktuálne zlyhanie. Testovacie upozornenia sa neposielajú.", + "summary": { + "delivering": "Posledné zaznamenané doručenie bolo úspešné vo všetkých {total} povolených kanáloch", + "failing": "{count} z {total} povolených kanálov má problém s konfiguráciou alebo zlyhanie posledného doručenia", + "noChannels": "Nie je povolený žiadny kanál upozornení", + "evaluationFailed": "História doručení sa nepodarila prečítať" + } + }, + "cluster_quorum": { + "title": "Kvórum klastra", + "rationale": "Kvórum tak, ako ho hlási klaster, nakonfigurované uzly oproti aktuálne viditeľným a počet deklarovaných spojení corosync. Spojenia sa čítajú z konfigurácie, nesondujú sa.", + "summary": { + "standalone": "Tento uzol nie je členom žiadneho klastra", + "quorate": "Klaster má kvórum s {total} nakonfigurovanými uzlami cez {links} spojenie(a) corosync", + "attention": "{count} zistenie(a) k {total} nakonfigurovaným uzlom", + "evaluationFailed": "Stav klastra sa nepodarilo prečítať" + } + }, + "boot_loader": { + "title": "Zavádzač systému", + "rationale": "Systémové oddiely EFI, ktoré hlási proxmox-boot-tool, a jadrá, ktoré každý nesie. Žiadny oddiel sa nepripája a o zavedenie sa nepokúša.", + "summary": { + "synchronised": "{total} zavádzacích oddielov nesie rovnaké jadrá", + "attention": "{count} z {total} zavádzacích oddielov si vyžaduje kontrolu", + "evaluationFailed": "The check could not be evaluated" + } + }, + "failed_units": { + "title": "Základné služby a zlyhané jednotky", + "rationale": "Jednotky, ktoré systemd vzdal po vyčerpaní reštartov, a služby, ktoré Proxmox potrebuje, aby vôbec odpovedal, čítané podľa mena, lebo neaktívna služba nie je vždy zlyhaná. Čo ktorá jednotka robí, sa tu nevykladá.", + "summary": { + "allRunning": "{total} základných služieb je aktívnych a žiadna jednotka nezlyhala", + "attention": "{count} zistenie(a) medzi jednotkami", + "evaluationFailed": "The check could not be evaluated" + } + }, + "ha_state": { + "title": "Vysoká dostupnosť", + "rationale": "Master HA, správca zdrojov každého uzla a stav každej spravovanej služby, z `ha-manager status`. O kvóre informuje kontrola klastra. Žiadna služba sa nespúšťa, nezastavuje ani nemigruje.", + "summary": { + "managed": "{total} spravovaných služieb je v ustálenom stave na {nodes} uzloch", + "attention": "{count} zistenie(a) k {total} spravovaným službám", + "evaluationFailed": "The check could not be evaluated" } } }, "guests": { "privileged_containers": { "title": "Container privileges", - "rationale": "Reviews each container configuration. Proxmox marks unprivileged containers with `unprivileged: 1`; its absence indicates a privileged container, which shares the host user namespace.", + "rationale": "Nastavenie `unprivileged` každej konfigurácie kontajnera. Jeho absencia znamená, že kontajner zdieľa menný priestor používateľov hostiteľa, čo niektoré záťaže potrebujú.", "summary": { "allUnprivileged": "All {total} containers are unprivileged", - "privileged": "{count} of {total} containers run privileged" + "privileged": "{count} of {total} containers run privileged", + "evaluationFailed": "The check could not be evaluated" } }, "qemu_without_agent": { "title": "Guest agent on virtual machines", - "rationale": "Reviews each virtual machine configuration for `agent: 1`. The guest agent enables ordered shutdown, filesystem quiescing for snapshots and real disk usage reporting.", + "rationale": "Nastavenie `agent` v konfigurácii každého virtuálneho stroja. Nastavenie hovorí, že agent je deklarovaný, nie že odpovedá.", "summary": { "allHaveAgent": "All {total} virtual machines declare the guest agent", - "missingAgent": "{count} of {total} virtual machines do not declare the guest agent" + "missingAgent": "{count} of {total} virtual machines do not declare the guest agent", + "evaluationFailed": "The check could not be evaluated" + } + }, + "autostart": { + "title": "Automatic start", + "rationale": "Nastavenie `onboot` každého hosťa, okrem šablón a hostí spravovaných HA. Či sa má hosť vrátiť sám, hovorí deklarovaná politika.", + "summary": { + "allAutostart": "All {total} guests start with the host", + "notAutostart": "{count} of {total} guests do not start with the host", + "evaluationFailed": "The check could not be evaluated" + } + }, + "stuck_snapshots": { + "title": "Snapshot state", + "rationale": "Stav a vek snapshotov a aktívne úlohy. Nedávna operácia alebo operácia bez overiteľného dátumu sa nepovažuje za prerušenú.", + "summary": { + "noSnapshots": "No guest holds snapshots", + "allComplete": "The {total} snapshot(s) are complete", + "stuck": "{count} of {total} snapshot(s) were left mid-operation", + "evaluationFailed": "The check could not be evaluated" + } + }, + "cpu_host_type": { + "title": "Virtual CPU model", + "rationale": "Hodnota `cpu` každého virtuálneho stroja. `host` sprístupňuje inštrukčnú sadu fyzického procesora, čo obmedzuje uzly, na ktoré môže hosť migrovať. Nezlučiteľnosť s konkrétnym cieľom sa tu neurčuje.", + "summary": { + "none": "None of the {total} virtual machines is pinned to the host processor", + "pinned": "{count} of {total} virtual machines are pinned to the host processor", + "evaluationFailed": "The check could not be evaluated" + } + }, + "replication_state": { + "title": "Replication", + "rationale": "Replikačné úlohy z API: počet zlyhaní, posledná chyba, posledná synchronizácia a kalendár, ktorý každá úloha deklaruje. Pozastavené úlohy sú označené ako také.", + "summary": { + "healthy": "The {total} replication job(s) report no error", + "failing": "{count} of {total} replication job(s) report an error", + "statusUnavailable": "Replication jobs are defined but their status could not be read", + "evaluationFailed": "The check could not be evaluated" } } }, "security": { "host_firewall_enabled": { - "title": "Firewall state", - "rationale": "Checks `enable: 1` in `/etc/pve/firewall/cluster.fw` and in the node's `host.fw`. Proxmox applies node rules only while the datacenter switch is on.", + "title": "Nakonfigurovaná aktivácia firewallu", + "rationale": "Voľba `enable` vo firewalle dátového centra a v tom uzla, a koľko pravidiel je zapísaných. Proxmox uplatňuje pravidlá uzla len pri zapnutom prepínači dátového centra. Tieto voľby nehovoria, čo pravidlo filtruje.", "summary": { - "bothEnabled": "The firewall is enabled at datacenter and node level", - "datacenterOff": "The firewall is disabled at datacenter level, so node rules are not applied", - "nodeOff": "The firewall is enabled at datacenter level but not on this node" + "bothEnabled": "Aktivácia firewallu je nakonfigurovaná na úrovni dátového centra aj uzla", + "datacenterOff": "Firewall je na úrovni dátového centra vypnutý, takže pravidlá uzla sa neuplatňujú", + "nodeOff": "Firewall je zapnutý na úrovni dátového centra, ale nie na tomto uzle", + "evaluationFailed": "The check could not be evaluated" + } + }, + "lynis_warnings": { + "title": "Lynis warnings", + "rationale": "Upozornenia z poslednej kontroly Lynis, každé s identifikátorom testu, a vek tejto kontroly. Kontrola sa spustí len vtedy, keď je Lynis nainštalovaný a neexistuje úplná správa. Návrhy nie sú zahrnuté.", + "summary": { + "none": "The last Lynis audit recorded no warnings", + "found": "The last Lynis audit recorded {count} warning(s)", + "incomplete": "The Lynis report is incomplete", + "evaluationFailed": "The check could not be evaluated", + "noneStale": "Posledná kontrola Lynisu nezaznamenala upozornenia a jej správa má {days} dní", + "foundStale": "Posledná kontrola Lynisu zaznamenala {count} upozornení a jej správa má {days} dní" + } + }, + "certificate_expiry": { + "title": "Certificate validity", + "rationale": "Dátum platnosti certifikátu, ktorý pveproxy poskytuje z /etc/pve/local. Vlastný certifikát má prednosť pred tým, ktorý generuje Proxmox.", + "summary": { + "valid": "The certificate is valid for {days} more day(s)", + "expiring": "The certificate expires in {days} day(s)", + "expired": "The certificate expired {days} day(s) ago", + "evaluationFailed": "The check could not be evaluated" + } + }, + "ssh_root_login": { + "title": "SSH root access", + "rationale": "PermitRootLogin v efektívnej konfigurácii `sshd -T` spolu s metódami overenia. Proxmox sa dodáva s `yes`, ktoré prijíma heslo.", + "summary": { + "password": "Root may sign in over SSH with a password", + "keyOnly": "Root may sign in over SSH with a key only", + "denied": "Root may not sign in over SSH", + "evaluationFailed": "The check could not be evaluated" } } }, "storage": { "orphaned_volumes": { "title": "Volume assignment", - "rationale": "Compares the volumes returned by `pvesm list` against the existing guest configurations. Only non-shared storage is examined: on shared storage a volume may belong to a guest running on another node.", + "rationale": "Zväzky hostí na lokálnom úložisku oproti odkazom v aktuálnych, čakajúcich a snapshotových konfiguráciách. Zálohy, ISO a šablóny zostávajú mimo porovnania. Zväzok bez odkazu je kandidát na preverenie.", "summary": { "none": "No orphaned volumes were found", - "found": "{count} volume(s) belong to no existing guest" + "found": "{count} zväzkov nemá odkaz v kontrolovaných konfiguráciách", + "evaluationFailed": "The check could not be evaluated" + } + }, + "zfs_arc_max": { + "title": "ZFS ARC limit", + "rationale": "Efektívne hodnoty c_min, c_max a veľkosti ARC, načítaný parameter modulu a trvalé nastavenia v /etc/modprobe.d. Nakonfigurovaná nula volí predvolenú hodnotu modulu; ARC je strop a pamäť pod ním je znovu získateľná.", + "summary": { + "bounded": "Limit ARC je {percent}% pamäte hostiteľa a pamäť pod ním je znovu získateľná", + "high": "The ARC may use {percent}% of host memory", + "unset": "The ARC has no explicit limit set", + "conflicting": "{count} nastavení ARC si navzájom odporuje", + "pending": "Trvalé nastavenie ARC sa líši od hodnoty, ktorú nesie bežiaci modul", + "evaluationFailed": "The check could not be evaluated" + } + }, + "zfs_scrub_age": { + "title": "ZFS scrub", + "rationale": "Posledný dokončený scrub, ktorý `zpool status` uvádza pre každý pool. Resilver nie je scrub. Nedávno vytvorený pool naň ešte nemal príležitosť.", + "summary": { + "recent": "The {total} pool(s) were scrubbed within the last 35 days", + "overdue": "{count} of {total} pool(s) have not been scrubbed in 35 days", + "neverScrubbed": "{count} pool(s) record no scrub", + "evaluationFailed": "The check could not be evaluated" + } + }, + "thin_pool_overprovisioning": { + "title": "Thin pool allocation", + "rationale": "Virtuálna kapacita rozdelená každým tenkým poolom LVM oproti veľkosti samotného poolu a to, koľko jeho zväzky skutočne zapísali, dáta a metadáta osobitne.", + "summary": { + "withinRatio": "Všetkých {total} thin poolov je pod použitými prahmi kontroly", + "aboveRatio": "{count} z {total} tenkých poolov rozdáva viac kapacity, než má", + "pressure": "{pressure} z {total} tenkých poolov je blízko zaplneniu dát alebo metadát", + "evaluationFailed": "The check could not be evaluated" + } + }, + "connected_storage": { + "title": "Pripojené úložiská", + "rationale": "Dostupnosť tak, ako ju hlási PVE, známa kapacita a súčasné závislosti všetkých úložísk povolených na tomto uzle. Vzdialené vnútorné časti sa nesondujú a zápisový prístup sa netestuje. Kapacita sa uvádza tam, kde ju PVE pozná, inak zostáva prázdna.", + "summary": { + "available": "PVE hlási všetkých {total} úložísk ako dostupných; vzdialené interné súčasti ani zápis sa netestovali", + "attention": "{count} z {total} úložísk vyžaduje kontrolu", + "evaluationFailed": "The check could not be evaluated" + } + }, + "pool_integrity": { + "title": "Integrita a redundancia poolov", + "rationale": "Stav každého ZFS poolu a počítadlá čítania, zápisu a kontrolného súčtu jeho zariadení. Počítadlá sú kumulatívne od posledného `zpool clear`.", + "summary": { + "healthy": "Všetkých {total} poolov je online, bez zaznamenaných chýb zariadení", + "degraded": "{count} zistení v {total} pooloch", + "evaluationFailed": "Stav poolov sa nepodarilo prečítať" + } + }, + "ceph_health": { + "title": "Stav Cephu", + "rationale": "Vlastný stav Cephu a kontroly, ktoré pomenoval. Jeho testy sa neimplementujú nanovo a žiadny pool, umiestňovacia skupina ani OSD sa nedotazuje osobitne.", + "summary": { + "healthy": "Ceph hlási HEALTH_OK", + "degraded": "Ceph hlási {state}, s {count} pomenovanými kontrolami", + "evaluationFailed": "The check could not be evaluated" + } + }, + "array_integrity": { + "title": "Softvérový RAID a multipath", + "rationale": "Polia mdadm a mapy multipath, čítané z /proc/mdstat a, kde je nástroj nainštalovaný, z `multipath -ll`. Pooly ZFS hlási ich vlastná kontrola.", + "summary": { + "intact": "{total} polí a máp si zachováva redundanciu", + "degraded": "{count} z {total} polí alebo máp ju stratilo", + "evaluationFailed": "The check could not be evaluated" + } + } + }, + "hardware": { + "disk_service_life": { + "title": "Disk service life", + "rationale": "Prevádzkové hodiny a zostávajúca životnosť z hodnôt SMART, ktoré má monitor, s dátumom každého merania. Vek je informácia na plánovanie; chyby média a upozornenia zariadenia hlási monitor stavu.", + "summary": { + "withinLife": "{total} meraní diskov neprekračuje päťročnú orientačnú hranicu", + "pastLife": "{count} z {total} meraní diskov prekračuje päť rokov prevádzky", + "noReadings": "Žiadny disk neposkytuje použiteľné SMART hodnoty ({skipped} bez meraní)", + "evaluationFailed": "The check could not be evaluated" + } + }, + "disk_errors": { + "title": "Chyby diskov", + "rationale": "Chyby zistené na diskoch a zaznamenané monitorom stavu.", + "summary": { + "recorded": "{count} z {total} diskov so záznamom má zaznamenané udalosti", + "noEvents": "Nezostáva žiadna disková udalosť na vyhodnotenie: žiadna zaznamenaná alebo všetky zamietnuté" + } + } + }, + "network": { + "bond_members": { + "title": "Bond members", + "rationale": "Stav MII každého člena bondu z /proc/net/bonding a koľko spojení zostáva. V režime active-backup sa záložný člen hlási ako aktívny a neprenáša prevádzku.", + "summary": { + "allUp": "All members of the {total} bond(s) are up", + "membersDown": "{count} bond member(s) are not up", + "evaluationFailed": "The check could not be evaluated" + } + }, + "bridge_without_ports": { + "title": "Bridge ports", + "rationale": "Konfigurácia portov každého bridge. Bridge bez fyzického portu obsluhuje internú alebo smerovanú sieť.", + "summary": { + "allConnected": "The {total} bridge(s) carry a port", + "isolated": "{count} of {total} bridge(s) carry no port", + "evaluationFailed": "The check could not be evaluated" } } } @@ -5041,6 +5624,389 @@ "expiry365": "1 year", "cancel": "Cancel", "confirm": "Accept risk" - } + }, + "incomplete": "Neúplné dôkazy", + "progress": "Overené {completed} z {total}", + "expires": "Platí do: {when}", + "runStates": { + "partial": "Vyhodnotenie prebehlo; niektoré merania sa nepodarilo vykonať.", + "failed": "Hodnotenie bolo prerušené alebo zlyhalo. Pred použitím výsledkov skontrolujte dôkazy." + }, + "severities": { + "CRITICAL": "Kritické", + "WARNING": "Upozornenie", + "INFO": "Informácia", + "OK": "OK" + }, + "viewSwitch": { + "ariaLabel": "Switch between assessment and inventory", + "assessment": "Assessment", + "inventory": "Inventory", + "policy": "Politika", + "changes": "Zmeny" + }, + "inventory": { + "loading": "Loading inventory…", + "failed": "The inventory could not be composed.", + "collectedAt": "Composed on {when}", + "unavailable": "Not read in this inventory", + "unresolved": "path not resolved", + "noUplink": "no uplink", + "identity": "Node identity", + "node": "Node", + "pveVersion": "Proxmox VE", + "kernel": "Kernel", + "subscription": "Subscription", + "cluster": "Cluster", + "standalone": "Not in a cluster", + "hardware": "Hardware", + "system": "System", + "serial": "Serial number", + "bios": "BIOS", + "cpu": "Processor", + "topology": "Layout", + "cpuLayout": "{sockets} socket(s) × {cores} cores = {threads} threads", + "virtualisation": "Virtualisation", + "memory": "Memory", + "iommuGroups": "IOMMU groups", + "network": "Network", + "storage": "Storage", + "name": "Name", + "type": "Type", + "content": "Content", + "shared": "Shared", + "yes": "Yes", + "no": "No", + "guests": "Guests", + "ostype": "Operating system", + "onboot": "Starts with host", + "tags": "Tags", + "privilege": "Privilege", + "privileged": "Privileged", + "unprivileged": "Unprivileged", + "features": "Features", + "agent": "Guest agent", + "cpuModel": "CPU model", + "disks": "Disks", + "interfaces": "Network interfaces", + "protection": "Backup", + "passthrough": "Passthrough", + "noBackup": "No backup", + "noBackupDetail": "No enabled backup job selects this guest.", + "applications": "Applications", + "versionUnknown": "version not detected", + "passthroughTitle": "PCI passthrough", + "iommuGroup": "IOMMU group {group}", + "sharedGroup": "{count} more device(s) in the same group", + "proxmenux": "ProxMenux optimizations", + "latency": "Sieťová odozva", + "subscriptionStatus": { + "notfound": "Predplatné sa nenašlo", + "active": "Aktívne", + "invalid": "Neplatné", + "expired": "Vypršané", + "suspended": "Pozastavené", + "new": "Čaká na aktiváciu", + "unknown": "Neznáme" + } + }, + "profile": { + "label": "Report", + "full": "Full audit", + "inventory": "Inventory", + "security": "Security review", + "backup": "Backup assurance", + "capacity": "Capacity and wear", + "diagnostic": "Rýchla diagnostika" + }, + "document": { + "action": "Vytvoriť správu", + "title": "Správa z auditu", + "subtitle": "Štruktúra, konfigurácia a vyhodnotenie uzla {node}", + "generated": "Vygenerované", + "assessed": "Assessed", + "runId": "Assessment reference", + "print": "Print or save as PDF", + "summaryByArea": "Summary by area", + "area": "Area", + "inventoryAnnex": "Inventory annex", + "scopeTitle": "Scope of this report", + "scopeBody": "This report describes the Proxmox VE node named above, as observed from the node itself at the time stated. It does not cover the interior of the guests beyond what they declare, network equipment outside the host, physical infrastructure, or any dependency not visible from this node. Findings marked as not determined were not measured and are not evidence of absence.", + "building": "Zostavuje sa správa…", + "node": "Uzol", + "profile": "Profil", + "unknownNode": "neidentifikovaný uzol", + "executiveSummary": "Zhrnutie vyhodnotenia", + "assessment": "Vyhodnotenie", + "verdictHeading": "Výsledok tohto behu", + "verdict": { + "critical": "POZOR", + "warning": "PRESKÚMAŤ", + "conformant": "V PORIADKU" + }, + "verdictText": { + "critical": "{fail} kontrol hlási zlyhaný stav a {warn} stav na preskúmanie, z {total} vyhodnotených.", + "warning": "Žiadna kontrola nehlási zlyhaný stav. {warn} z {total} hlási stav na preskúmanie.", + "conformant": "Všetkých {total} kontrol tohto profilu sa dokončilo bez zlyhaného stavu a bez stavu na preskúmanie.", + "none": "Tento profil nespúšťa žiadne kontroly. Dokument uzol popisuje, ale nevyhodnocuje ho." + }, + "runAt": "Spustené {date}", + "chartNote": "Kontroly podľa oblasti a výsledku.", + "nodeIdentity": "Identita uzla", + "system": "Systém", + "cluster": "Klaster", + "standaloneNote": "Tento uzol nie je súčasťou klastra: má vlastnú konfiguráciu a jeho hostia nemigrujú na iný uzol.", + "clusterDiagramNote": "Nakonfigurované uzly a linky corosync, ktoré ich spájajú.", + "thisNode": "tento uzol", + "unreachable": "nevidený", + "member": "člen", + "corosyncLinks": "linky", + "quorum": "Kvórum", + "quorate": "s kvórom", + "inquorate": "bez kvóra", + "votes": "Hlasy", + "nodeName": "Uzol", + "architecture": "Architektúra systému", + "architectureNote": "Ako je uzol zostavený: procesor a pamäť na doske a čo visí na každom radiči.", + "systemIdentity": "Identita systému", + "board": "Doska", + "processor": "Procesor", + "topology": "Sokety × jadrá / vlákna", + "memory": "Pamäť", + "cores": "jadrá", + "threads": "vlákna", + "memoryModules": "Pamäťové moduly", + "slot": "Slot", + "slotsUsed": "obsadené sloty", + "slotsFilled": "{used} z {total} slotov obsadených", + "emptySlot": "prázdny", + "formFactor": "Formát", + "speed": "Rýchlosť", + "manufacturer": "Výrobca", + "product": "Model", + "serial": "Sériové číslo", + "controllers": "Radiče", + "class": "Trieda", + "device": "Zariadenie", + "iommuGroups": "Skupiny IOMMU", + "iommuGroup": "Skupina IOMMU", + "field": "Pole", + "value": "Hodnota", + "size": "Veľkosť", + "type": "Typ", + "storageDevices": "Úložné zariadenia", + "disks": "Disky", + "model": "Model", + "bus": "Zbernica", + "serviceLife": "Hodiny prevádzky", + "healthy": "v poriadku", + "years": "{years} rokov", + "events": "Udalosti", + "observations": "Pozorovania", + "observationsNote": "Zaznamenané udalosti. SMART hlási aktuálny stav; tento záznam hlási, čo sa stalo, vrátane udalostí, z ktorých sa disk zotavil.", + "noObservations": "Žiadne zaznamenané udalosti", + "noObservationsNote": "Odkedy ich monitor sleduje, žiadny disk nezaznamenal chybu.", + "event": "Udalosť", + "severity": "Závažnosť", + "occurrences": "Výskyty", + "firstSeen": "Prvýkrát", + "lastSeen": "Naposledy", + "detail": "Detail", + "network": "Sieť", + "adapters": "Adaptéry", + "bond": "Bond", + "bridge": "Bridge", + "bridges": "Bridge", + "physicalAdapters": "Fyzické adaptéry", + "interface": "Rozhranie", + "driver": "Ovládač", + "state": "Stav", + "networkDiagramNote": "Cesta od kábla ku každému hosťovi: fyzický adaptér, bond ak ich zoskupuje, bridge a pripojení hostia.", + "storageAndProtection": "Úložisko a ochrana", + "storage": "Úložisko", + "content": "Obsah", + "shared": "Zdieľané", + "location": "Umiestnenie", + "backupDestination": "Cieľ záloh", + "unprotected": "bez zálohy", + "storageDiagramNote": "Kde ležia disky hostí a ktorý cieľ ich zálohuje.", + "unprotectedGuests": "{count} hostí bez zálohovacej úlohy", + "allProtected": "Každý hosť je pokrytý zálohovacou úlohou", + "allProtectedNote": "Pokrytie znamená, že úloha hosťa vyberá; výsledok záloh sa vyhodnocuje osobitne.", + "vmid": "VMID", + "name": "Názov", + "kind": "Druh", + "backup": "Záloha", + "none": "žiadna", + "managedSoftware": "Softvér spravovaný ProxMenux", + "version": "Verzia", + "source": "Zdroj", + "current": "aktuálne", + "updateAvailable": "aktualizovať na {version}", + "findings": "Zistenia podrobne", + "incomplete": "čiastočné", + "scope": "Rozsah tejto správy", + "scopeText": "Tento dokument informuje o profile {profile} na uzle uvedenom v hlavičke, v čase behu.", + "scopeLocal": "Zahŕňa iba tento uzol. Hostia na iných uzloch a ich konfigurácia sú mimo neho.", + "scopeReadOnly": "Všetky kontroly čítajú už existujúcu konfiguráciu a stav; žiadna hostiteľa nemení.", + "scopeMoment": "Popisuje stav v čase behu, nie časové obdobie.", + "notRead": "Zdroje, ktoré sa nepodarilo prečítať:", + "uplink": "Uplink", + "conformance": "{pass} z {total} v súlade", + "latency": "Sieťová odozva", + "latencyNote": "Odozva meraná v uvedenom okne, jedna čiara na cieľ.", + "milliseconds": "ms", + "hours": "h", + "minimum": "Minimum", + "average": "Priemer", + "maximum": "Maximum", + "packetLoss": "Strata paketov", + "samples": "Merania", + "target": { + "label": "Cieľ", + "gateway": "Brána", + "cloudflare": "Cloudflare (1.1.1.1)", + "google": "Google (8.8.8.8)" + }, + "actionShort": "Správa", + "policyDeclared": "Hodnotilo sa voči deklarovanej politike: {guests} hostí, {storages} úložísk a {thresholds} prahov.", + "policyNone": "Nebola deklarovaná žiadna politika, preto sa absencia, ktorú táto správa nevie vyložiť, uvádza ako pozorovanie a nikdy ako upozornenie.", + "diagnosticTitle": "Rýchla diagnostika", + "diagnosticSubtitle": "Kritické výsledky a upozornenia na {node}", + "diagnosticActing": "{count} kritických výsledkov a upozornení.", + "diagnosticClear": "Žiadny kritický výsledok ani upozornenie. Pozorovania a vyhovujúce výsledky sú v úplnom audite.", + "diagnosticUnread": "Merania, ktoré sa nepodarilo vykonať", + "diagnosticMoreRows": "{count} ďalších riadkov, v úplnom audite.", + "structureTitle": "Štruktúra a konfigurácia", + "structureSubtitle": "Ako je {node} postavený a nakonfigurovaný" + }, + "results": "Výsledky", + "classifications": { + "critical": "Kritické", + "warning": "Upozornenie", + "observation": "Pozorovanie", + "conformant": "V súlade", + "unverified": "Neoverené", + "not_applicable": "Neaplikovateľné", + "accepted": "Prijaté riziko", + "by_design": "Vylúčené politikou" + }, + "policy": { + "inherit": "{value} (predvolené)", + "inheritUnset": "Predvolené", + "conflict": "Deklarácia sa zmenila v inej relácii. Koncept nebol uložený.", + "reload": "Načítať uloženú deklaráciu (zahodiť koncept)", + "intro": "Vyhodnotenie vidí, čo tento hostiteľ robí, nie na čo slúži. To, čo sa tu deklaruje, mení pozorovanie na upozornenie alebo ho vyníma z počtu. Nič nie je povinné: bez deklarácie správa popisuje namiesto toho, aby súdila.", + "loading": "Načítava sa deklarácia…", + "failed": "Deklaráciu sa nepodarilo prečítať", + "saved": "Uložené", + "declaredCount": "{count} deklarácií", + "guestsNote": "Vyžadované hlási chýbajúce ako varovanie; neuvedené to hlási ako pozorovanie; nevyžadované to nechá mimo počtu.", + "storagesNote": "Nedostupné úložisko je kritické, keď je deklarované ako nevyhnutné alebo slúži bežiacemu hosťovi, upozornenie, keď jeho úloha nie je deklarovaná, a pozorovanie, keď je deklarované ako voliteľné.", + "thresholds": "Prahy", + "thresholdsNote": "Prázdne znamená dodanú hodnotu, zobrazenú ako zástupný text.", + "backup": "Záloha", + "autostart": "Automatický štart", + "objective": "Cieľ obnovy", + "objectivePlaceholder": "hodiny", + "noGuests": "Tento uzol nemá žiadnych hostí.", + "expectation": { + "required": "Vyžadované", + "not_required": "Nevyžadované", + "unspecified": "Nedeklarované" + }, + "role": { + "essential": "Nevyhnutné", + "optional": "Voliteľné", + "unspecified": "Nedeklarované" + }, + "threshold": { + "storage_usage_percent": "Prah preverenia kapacity úložiska (%)", + "thin_pool_usage_percent": "Prah preverenia zaplnenia tenkého poolu (%)", + "thin_overprovision_ratio": "Pomer tenkého nadmerného prideľovania", + "zfs_scrub_days": "Interval scrubu ZFS (dni)", + "backup_fallback_days": "Náhradná lehota pre vek zálohy (dni)", + "backup_schedule_grace_ratio": "Tolerancia ku kalendáru (pomer)", + "certificate_expiry_days": "Upozornenie na platnosť certifikátu (dni)", + "memory_overcommit_ratio": "Pomer nadmerného prideľovania pamäte", + "disk_service_life_hours": "Životnosť disku (hodiny)", + "lynis_report_days": "Vek správy Lynis (dni)", + "package_index_days": "Vek indexov balíkov (dni)", + "journal_usage_percent": "Journal voči svojmu stropu (%)", + "filesystem_usage_percent": "Prah preverenia miesta (%)", + "filesystem_inode_percent": "Prah preverenia inodov (%)", + "disk_error_recent_days": "Okno nedávnych chýb diskov (dni)" + } + }, + "changes": { + "loading": "Načítava sa denník zmien…", + "failed": "Denník zmien sa nepodarilo prečítať", + "intro": "Čo ProxMenux na tomto hostiteľovi zmenil a čo tam bolo pred každou zmenou. Zobrazuje sa rozdiel, nie skript, ktorý ho aplikoval.", + "empty": "Na tomto hostiteľovi zatiaľ nebolo nič zaznamenané.", + "since": "Zaznamenáva sa od {date}. Čo bolo použité skôr, figuruje ako použité, bez stavu, ktorý nahradilo.", + "byFunction": "Podľa funkcie", + "count": "{count} zmien", + "function": "Funkcia", + "source": "Skript", + "reversibility": "Vrátenie zmeny", + "difference": "Rozdiel", + "diffTruncated": "Rozdiel je dlhší, než sa zobrazuje.", + "diffUnavailable": "Nahradený obsah už nie je uložený, rozdiel sa nedá zobraziť.", + "packagesAdded": "Pridané balíky", + "commandRun": "Spustený príkaz", + "executionNote": "ProxMenux to spustil na požiadanie; čo sa zmenilo, rozhodol príkaz, nie ProxMenux.", + "unknownNote": "Toto bolo použité pred vznikom denníka, takže nahradený stav sa nikdy nezachytil.", + "noneInFilter": "Žiadna zmena tohto druhu.", + "class": { + "all": "Všetky", + "configuration": "Konfigurácia", + "installation": "Inštalácie", + "execution": "Spustenia", + "registration": "Použité" + }, + "operation": { + "write_file": "Súbor nahradený", + "edit_file": "Súbor upravený", + "remove_file": "Súbor odstránený", + "install_package": "Nainštalované", + "enable_service": "Služba povolená", + "disable_service": "Služba zakázaná", + "run_command": "Spustené", + "applied": "Použité", + "removed": "Odstránené", + "unknown": "Zmena" + }, + "capture": { + "unknown": "Predchádzajúci stav neznámy" + }, + "exactness": { + "exact": "Obnoví presne to, čo tam bolo", + "partial": "Čiastočné: závislosti môžu zostať alebo odísť s tým", + "none": "Z denníka sa nedá vrátiť" + } + }, + "comparison": { + "loading": "Porovnáva sa s referenčným behom…", + "failed": "Referenčný beh sa nepodarilo nastaviť", + "since": "Od {date}", + "previousRun": "predchádzajúceho behu", + "noChange": "Bez zmeny", + "isBaseline": "Tento beh je referenciou, s ktorou sa porovnávajú ostatné.", + "noBaseline": "Zatiaľ nebol zvolený referenčný beh, takže niet s čím porovnávať.", + "setBaseline": "Použiť ako referenciu", + "unchanged": "{count} kontrol dalo rovnaký výsledok ako predtým.", + "new": "Nové", + "newNote": "hlásené teraz a predtým nie", + "resolved": "Vyriešené", + "resolvedNote": "už sa nehlásia a nikto ich neprijal", + "accepted": "Prijaté", + "acceptedNote": "už sa nepočítajú, lebo sa prijalo riziko, nie preto, že by sa hostiteľ zmenil", + "retired": "Už sa nevyhodnocujú", + "retiredNote": "boli predtým a v tomto behu nie; nič nepotvrdilo, že prestali", + "reasons": { + "insufficient_runs": "Porovnanie vyžaduje referenčné spustenie a neskoršie; zatiaľ je zaznamenané len jedno" + } + }, + "notApplicableScope": "V preskúmanom rozsahu nie je nič, na čo by sa táto kontrola vzťahovala." } } diff --git a/AppImage/messages/sv/common.json b/AppImage/messages/sv/common.json index afdfb7fc..432ae687 100644 --- a/AppImage/messages/sv/common.json +++ b/AppImage/messages/sv/common.json @@ -309,7 +309,7 @@ "shortTest": "Kort test", "longTest": "Långt test (1-4 timmar)", "extendedTest": "Utökat test", - "testHelp": "Ett kort test tar cirka 2 minuter.Det utökade testet körs i bakgrunden och kan ta flera timmar på stora diskar.Resultatet kommer att dyka upp på fliken Historik när det är klart.", + "testHelp": "Ett kort test tar cirka 2 minuter. Det utökade testet körs i bakgrunden och kan ta flera timmar på stora diskar. Resultatet kommer att dyka upp på fliken Historik när det är klart.", "startFailed": "Det gick inte att starta testet", "short": "Kort", "extended": "Förlängd", @@ -1103,7 +1103,7 @@ "backupStartFailed": "Det gick inte att starta säkerhetskopiering: {message}", "controlFailed": "Det gick inte att {action} VM {vmid}: {message}", "saveNotesFailed": "Det gick inte att spara anteckningar. Försök igen.", - "appNotFound": "Denna applikation är inte längre tillgänglig.Uppdatera sidan och försök igen.", + "appNotFound": "Denna applikation är inte längre tillgänglig. Uppdatera sidan och försök igen.", "saveCustomCommandFailed": "Kunde inte spara det anpassade uppdateringskommandot: {message}", "removeCustomCommandConfirm": "Ta bort det anpassade uppdateringskommandot för \"{name}\"?", "removeCustomCommandFailed": "Kunde inte ta bort det anpassade uppdateringskommandot: {message}", @@ -1220,7 +1220,15 @@ "humanWeekly": "Varje vecka ({day} {time})", "humanMonthly": "Varje månad (dag {day} kl. {time})", "humanHourly": "Varje timme", - "weekdays": "['söndag', 'måndag', 'tisdag', 'onsdag', 'torsdag', 'fredag', 'lördag']" + "weekdays": [ + "söndag", + "måndag", + "tisdag", + "onsdag", + "torsdag", + "fredag", + "lördag" + ] }, "cronChip": { "detected": "host cron upptäckt", @@ -1373,8 +1381,7 @@ "postApplyAllOk": "{count} paket(en) har tillämpats framgångsrikt — inget väntande.", "postApplyNothingPending": "Inget väntande — allt är uppdaterat.", "postApplyPartial": "{pending} paket som fortfarande väntar efter körningen.", - "postApplyPartialSubline": "{applied} tillämpas.Vissa uppdateringar slutfördes inte – granska terminalutgången ovan.", - "updatedWithDockerImage": "Uppdaterad med dess Docker-bild." + "postApplyPartialSubline": "{applied} tillämpas. Vissa uppdateringar slutfördes inte – granska terminalutgången ovan." }, "bulkUpdate": { "title": "Gruppuppdatering", @@ -1602,14 +1609,9 @@ "notificationsEnabled": "Uppströmsuppdateringsmeddelanden PÅ – klicka för att stänga av ljudet", "notificationsMuted": "Uppströmsuppdateringsmeddelanden AVSTÄLLD – klicka för att aktivera", "notifyUpstreamLabel": "Meddela mig när en ny uppströmsversion är tillgänglig", - "notifyUpstreamHelp": "Skickar `app_update_available` till de kanaler som är aktiverade i Inställningar → Aviseringar.Stäng av om den här appen inte kan uppdateras på din box.", + "notifyUpstreamHelp": "Skickar `app_update_available` till de kanaler som är aktiverade i Inställningar → Aviseringar. Stäng av om den här appen inte kan uppdateras på din box.", "excludeFromBadgeLabel": "Uteslut från LXC-uppdateringsräknaren", - "excludeFromBadgeHelp": "Räkna inte den här appen i det samlade uppdateringsmärket på LXC-listkortet.Användbart när du är fäst till en specifik version med avsikt (spårningskrav, frysning av kompatibilitet).Påverkar inte appflikens eget tillstånd eller det utgående meddelandet.", - "dockerDetectedWithWorkloads": "Docker upptäckt med {count} containeriserade applikationer", - "dockerWorkloadsHeading": "Kör inuti Docker", - "runsInsideDocker": "Uppdaterad med dess Docker-bild", - "upstreamDelegatedTitle": "Tillgänglig version kommer från dess Docker-bild", - "upstreamDelegatedHelp": "Den här applikationen körs i en behållare, så den tillgängliga versionen är vad dess bild än löser – ingen separat uppströmskontroll och en uppdatering rapporteras en gång.Uppdatera den från dess bild på fliken Uppdateringar." + "excludeFromBadgeHelp": "Räkna inte den här appen i det samlade uppdateringsmärket på LXC-listkortet. Användbart när du är fäst till en specifik version med avsikt (spårningskrav, frysning av kompatibilitet).Påverkar inte appflikens eget tillstånd eller det utgående meddelandet." }, "statusFilter": { "ariaLabel": "Filtrera virtuella maskiner och containrar", @@ -1889,6 +1891,7 @@ "system_reboot": "Systemet startar om", "system_restore_completed": "Värdåterställning slutförd", "system_problem": "Systemproblem upptäckt", + "kernel_warning": "Kärnvarningar och diagnostikspår", "service_fail": "Tjänsten misslyckades", "oom_kill": "Minneslös process dödar", "service_fail_batch": "Flera tjänstefel", @@ -4916,6 +4919,154 @@ "customLinkDeleteError": "Delete failed" }, "audit": { + "presentation": { + "backupAge": "Kopians ålder", + "backupLimit": "Använd gräns", + "limitDeclared": "Tidsgräns angiven av användaren", + "limitSchedule": "Schema + marginal", + "limitReference": "Referensperiod", + "verifiedChecks": "Verifierade kontroller", + "unverifiedChecks": "Ej verifierade kontroller", + "checkName": "Kontroll", + "verified": "Verifierade", + "verificationScope": "Tillämpliga kontroller som har verifierats. Täckningen beskriver inte serverns hälsa eller säkerhet.", + "noApplicable": "Inga tillämpliga kontroller i denna bedömning.", + "noJob": "Inget schemalagt jobb", + "guest": "Gäst", + "guests": "gäster", + "host": "Värd", + "resource": "Resurs", + "data": "Data", + "metadata": "Metadata", + "result": "Resultat", + "fact": "Observerat faktum", + "records": "poster", + "unscheduled": "gäster utan schemalagt jobb", + "excludedDisks": "undantagna diskar", + "disks": "Diskar", + "destination": "Mål", + "lastCopy": "Senaste lagrade säkerhetskopia", + "ageLimit": "Ålder / gräns", + "noDestination": "Inget mål konfigurerat", + "notFound": "Ingen säkerhetskopia hittades inom undersökt omfattning", + "promiscuous": "Gränssnitt i promiskuöst läge", + "noDescription": "Beskrivning saknas", + "occurrence": "Förekomst", + "occurrences": "förekomster", + "detail": "Detalj", + "technical": "Tekniskt underlag", + "annex": "Teknisk bilaga", + "overview": "Resultatöversikt", + "incomplete": "Ofullständig bedömning", + "assessment": "Bedömning", + "noGlobalScore": "Resultaten beskriver separata kriterier; inget övergripande säkerhetsbetyg beräknas.", + "coverage": "Täckning av schemalagda säkerhetskopior", + "scheduled": "Med schemalagt jobb", + "copyScope": "Ett konfigurerat jobb visar inte att en lagrad eller återställningsbar kopia finns.", + "detailsLink": "Referens till underlag", + "noSubscription": "Inget abonnemang registrerat", + "otherDevices": "Andra enheter i gruppen", + "unversioned": "Version inte registrerad", + "notInstalled": "Inte installerad", + "noPendingRecorded": "Ingen väntande uppdatering registrerad", + "originalEvidence": "Ursprungligt underlag från källan", + "evidenceObserved": "Observerat underlag", + "evidenceExcerpt": "Kompakt vy. Det fullständiga källunderlaget sparas tillsammans med bedömningen.", + "annexScope": "Fullständigt källunderlag för resultat som kräver uppmärksamhet, registrerar en observation eller inte kunde verifieras.", + "readOnlyScope": "Bedömningen ändrar inte konfigurationen. Frågor och vid behov Lynis kan skapa loggar eller rapporter.", + "capacity": "Kapacitet", + "used": "Använt", + "free": "Ledigt", + "reasons": { + "agentNotDeclared": "Ingen gästagent deklarerad i konfigurationen", + "arcConflictingSettings": "De bestående inställningarna stämmer inte överens", + "arcMinAboveMax": "ARC:s undre gräns ligger över den övre", + "arcPendingReboot": "Bestående inställning skiljer sig från den laddade parametern", + "arrayDegraded": "Kör med färre enheter än den byggdes med", + "arrayNotActive": "Inte aktiv", + "arrayRebuilding": "Saknar enheter och byggs om", + "backupRunFailed": "Körningen slutade med ett fel", + "backupRunRecovered": "Misslyckades tidigare, och en senare körning lyckades", + "bondNoMembersUp": "Nere, och ingen medlem i bonden är uppe", + "bondRedundancyLost": "Nere; bonden behåller andra länkar", + "bootEspMissingNewest": "Bär inte den nyaste kärnan som de andra bär", + "bootEspOutOfSync": "Ur fas med de övriga: den skulle starta en annan kärna", + "bootSingleEsp": "En konfigurerad startpartition", + "bootToolReported": "Rapporterat av proxmox-boot-tool", + "cephCheckRaised": "Rapporterad av Ceph", + "channelIncomplete": "Aktiverad men saknar en del av sin konfiguration", + "clusterInquorate": "Utan kvorum: ändringar av klustret avvisas", + "clusterMemberAbsent": "Konfigurerad nod som klustret inte ser", + "clusterSingleLink": "En enda corosync-länk deklarerad", + "dataExcludedFromBackup": "Data som undantas från gästens säkerhetskopia", + "deliveryFailing": "Senaste leveranser gick inte ut", + "destinationUnavailable": "Konfigurerat mål är inte tillgängligt", + "diskErrorsActive": "Fel registrerat under granskningsperioden", + "diskErrorsPast": "Rapporterade fel tidigare, inga inom det fönster som används", + "diskWarningsActive": "Enhetsvarning registrerad under granskningsperioden", + "diskWarningsPast": "Rapporterade enhetsvarningar tidigare, inga inom det fönster som används", + "essentialServiceDown": "En tjänst som Proxmox behöver för att svara är inte aktiv", + "exemptByPolicy": "Deklarerad att inte behöva detta, och står därför utanför räkningen", + "expectedButUncovered": "Deklarerad som att behöva säkerhetskopia, och inget aktiverat jobb väljer den", + "expectedToAutostart": "Deklarerad att starta med värden, men gör det inte", + "filesystemExhausted": "Inget utrymme kvar", + "filesystemNearlyFull": "Vid eller över granskningströskeln för utrymme", + "filesystemReadOnly": "Kärnan rapporterar monteringen som skrivskyddad: den tar inte längre emot skrivningar", + "haManagerNotReady": "Varken aktiv eller vilande: kan inte ta över en tjänst", + "haNoMaster": "Ingen hanterare: inget avgör var en tjänst ska köra", + "haServiceError": "I feltillstånd och inte längre hanterad", + "haServiceTransitioning": "Mellan tillstånd", + "hostArchiveMissing": "Jobbposten namnger ett arkiv som inte längre är lagrat", + "hostBackupJobFailed": "Jobbet slutade med ett fel", + "hostBackupStale": "Äldre än den åldersgräns som används", + "hostBackupUnscheduled": "Lagrad, utan schema som skapar en ytterligare kopia", + "hostNoRetrievableCopy": "Ingen kopia som den här kontrollen fortfarande kan redogöra för", + "indexesStale": "Paketindexen har inte uppdaterats nyligen", + "inodesExhausted": "Inga inoder kvar", + "inodesNearlyExhausted": "Vid eller över granskningströskeln för inoder", + "kernelAwaitingReboot": "Installerad och inte den kärna som körs", + "lynisReportStale": "Lynis-rapporten är {days} dag(ar) gammal, äldre än den referensålder som används", + "lynisWarning": "Registrerad av Lynis-granskningen", + "multipathNoPath": "Ingen väg kvar", + "multipathPathDown": "Betjänar via färre vägar", + "noAutostart": "Startar inte med värden", + "noConfigurationReference": "Ingen referens i de granskade konfigurationerna", + "noJobSelectsGuest": "Inget aktiverat säkerhetskopieringsjobb väljer den", + "noPhysicalPort": "Bär ingen fysisk port", + "noStoredBackup": "Ingen lagrad säkerhetskopia hittades", + "noStoredBackupUnscheduled": "Inget schemalagt jobb; ingen kopia hittades", + "olderThanFallback": "Kopian överskrider referensperioden.", + "olderThanObjective": "Kopian överskrider tidsgränsen som användaren angett.", + "olderThanSchedule": "Kopian överskrider det schemalagda intervallet med marginal.", + "overprovisioned": "Delar ut mer virtuell kapacitet än poolen rymmer", + "packageAwaitingRestart": "Installerat och begär omstart", + "pastServiceLife": "Över den livslängdströskel som används för planering", + "pinnedToHostCpu": "Bunden till värdens processormodell", + "poolDeviceErrors": "Enhet som räknar läs-, skriv- eller kontrollsummefel", + "poolNotOnline": "Inte online", + "rebootMarkerWithoutPackages": "Något skrev omstartsmarkören utan att namnge något paket", + "recoveryKeyLocalOnly": "Krypteringsnyckeln för säkerhetskopior finns bara på den här noden, enligt registrerat förvaringsläge", + "replicationDisabled": "Pausat", + "replicationFailing": "Senaste körningen rapporterade ett fel", + "replicationNeverRan": "Har aldrig slutfört en synkronisering", + "replicationOverdue": "Äldre än jobbets eget schema tillåter", + "retentionNotDeclared": "Ingen lagringstid deklarerad; varje kopia behålls", + "retentionOnServer": "Gallras på säkerhetskopieringsservern, av jobb noden inte kan läsa", + "runsPrivileged": "Körs privilegierat och delar värdens användarnamnrymd", + "scrubOverdue": "Senaste avslutade scrub äldre än granskningströskeln", + "storageNearlyFull": "Vid eller över tröskeln för kapacitetsgranskning", + "storageUnreachable": "Inte nåbar", + "thinDataPressure": "Skriven data nära poolens kapacitet", + "thinMetadataPressure": "Metadata nästan fulla, vilket gör poolen skrivskyddad", + "unitFailed": "systemd slutade försöka igen", + "verificationFailedOnly": "Verifieringen läste den senaste kopian och den var inte intakt; ingen annan kopia av gästen har verifierats", + "verificationFailedWithFallback": "Verifieringen läste den senaste kopian och den var inte intakt; en tidigare kopia verifierades", + "verificationNotRun": "Inget verifieringsjobb har läst tillbaka den här kopian" + }, + "lynisTest": "Test", + "lynisWarning": "Varning", + "couldNotRead": "Kunde inte läsas" + }, "title": "Audit & Report", "loading": "Loading assessment…", "run": "Run assessment", @@ -4924,9 +5075,8 @@ "lastRun": "Last assessed on {when}", "stale": "{days} days ago", "readOnlyNotice": "The assessment only reads the host. It makes no changes.", + "unverifiedChecks": "Ej gjorda: {checks}. Var och en anger i sina belägg vad den inte kunde läsa.", "noFindings": "No findings match the current filter.", - "showPassing": "Show passing checks", - "hidePassing": "Hide passing checks", "affectedCount": "{count} affected", "acceptedNotice": "{count} accepted risk(s) recorded on this host.", "states": { @@ -4934,7 +5084,8 @@ "warn": "Warning", "accepted": "Accepted risk", "pass": "Passed", - "not_applicable": "Not applicable" + "not_applicable": "Not applicable", + "unknown": "Inte verifierat" }, "areas": { "all": "All", @@ -4950,7 +5101,8 @@ "why": "Context", "evidence": "Evidence", "affected": "Affected", - "acceptedRisk": "Accepted risk" + "acceptedRisk": "Accepted risk", + "sources": "Källor och insamlingstider" }, "errors": { "runFailed": "The assessment could not be started." @@ -4959,69 +5111,434 @@ "backup": { "guest_coverage": { "title": "Backup coverage", - "rationale": "Compares the guest inventory against what each backup job selects. A job selects by VMID list, by pool, or with `all 1`, minus its `exclude` list. Jobs carrying `enabled 0` are not considered.", + "rationale": "Aktiverade säkerhetskopieringsjobb på den här noden, vilka gäster varje jobb väljer och gästdata som undantas från dem. Konfigurerad täckning bevisar inte att en användbar kopia finns. Om en icke vald gäst skulle skyddas framgår av den deklarerade policyn.", "summary": { - "noJobs": "No backup job is defined on this node", - "covered": "All {total} guests are covered by a backup job", - "uncovered": "{count} of {total} guests are not covered by any enabled backup job" + "noJobs": "Inget säkerhetskopieringsjobb är definierat på noden för de {total} gäster den håller", + "covered": "Alla {total} gäster väljs av ett aktiverat säkerhetskopieringsjobb", + "uncovered": "{count} av {total} gäster väljs inte av något aktiverat säkerhetskopieringsjobb", + "excludedData": "Granska {count} undantag för diskar eller monteringspunkter", + "uncoveredExpected": "{required} gäster som deklarerats behöva säkerhetskopia väljs inte av något aktiverat jobb", + "evaluationFailed": "The check could not be evaluated" + } + }, + "last_backup_age": { + "title": "Age of stored backups", + "rationale": "Ålder: tid som gått sedan den senaste lagrade säkerhetskopian. Använd gräns: referensåldern som kopian jämförs med.", + "summary": { + "recent": "Alla {total} gäst-/destinationskontroller uppfyller det angivna ålderskravet", + "stale": "{count} of {total} guests have no backup from the last 30 days", + "noBackups": "No stored backup matches a guest on this node", + "attention": "{count} av {total} gäst-/destinationskontroller behöver granskas", + "evaluationFailed": "The check could not be evaluated" + } + }, + "retention_defined": { + "title": "Backup retention", + "rationale": "Lagringstiden som Proxmox löser upp den: jobbets inställning, sedan lagringens, sedan nodens standardvärde. Lagringstid som en säkerhetskopieringsserver tillämpar går inte att läsa från den här noden.", + "summary": { + "allDefined": "Alla {total} jobb löser upp en inställning för lagringstid", + "missing": "{count} of {total} enabled job(s) declare no retention", + "notDeclared": "{count} av {total} jobb behåller varje kopia: ingen lagringstid är deklarerad", + "onServer": "{count} av {total} jobb skriver till en säkerhetskopieringsserver, som gallrar dem med egna jobb", + "evaluationFailed": "The check could not be evaluated" + } + }, + "verification_state": { + "title": "Verifiering av säkerhetskopior", + "rationale": "Verifieringsresultatet som Proxmox Backup Server registrerar för varje gästs senaste kopia, och om en tidigare kopia av samma gäst verifierades. Verifieringen läser tillbaka en lagrad kopia; det är ingen återställning.", + "summary": { + "allVerified": "Den senaste kopian av alla {total} gäster har verifierats hel", + "failed": "{failed} senaste kopior klarade inte verifieringen, av {total} granskade", + "notVerified": "{pending} av {total} senaste kopior har inte verifierats", + "evaluationFailed": "Verifieringsstatus kunde inte läsas" + } + }, + "job_results": { + "title": "Resultat av säkerhetskopieringskörningar", + "rationale": "Hur varje gästs senaste registrerade körning slutade, från nodens uppgiftslogg. Endast den senaste bedöms. Loggen sparas en begränsad tid.", + "summary": { + "allSucceeded": "Alla {total} registrerade körningar slutade utan fel", + "someFailed": "{count} av {total} registrerade körningar slutade med ett fel", + "evaluationFailed": "Uppgiftsloggen kunde inte läsas", + "recovered": "{count} av {total} gäster misslyckades i en tidigare körning och har lyckats sedan dess" + } + }, + "host_recovery": { + "title": "Återställning av värden", + "rationale": "Värdsäkerhetskopior så som ProxMenux registrerar dem: varje jobb som kördes, när, om det lyckades, målet och om kopian finns kvar. Ett jobb som skriver till en säkerhetskopieserver namnger ingen lokal sökväg. Krypteringsnycklar rapporteras endast med antal och registrerat förvaringsläge.", + "summary": { + "noHostBackup": "Ingen säkerhetskopia av värdens konfiguration är lagrad och ingen timer skapar någon", + "protected": "Nodens egen konfiguration är lagrad i {total} arkiv, inom den åldersgräns som används", + "attention": "{count} iakttagelse(r) för {total} poster med värdens konfiguration", + "scheduledOnly": "Säkerhetskopior av värdens konfiguration är schemalagda via {count} timer; inget arkiv är lagrat lokalt" } } }, "system": { "pending_reboot": { "title": "Restart state", - "rationale": "Checks for `/var/run/reboot-required` and, when present, the packages listed in `/var/run/reboot-required.pkgs`. Also reports the running kernel.", + "rationale": "Markören `/var/run/reboot-required` och paketen i `/var/run/reboot-required.pkgs`. Dess frånvaro bevisar inte att inget behöver startas om.", "summary": { - "none": "No restart is pending", - "pending": "The host has a pending restart" + "none": "Ingenting har begärt omstart", + "pending": "{count} poster är installerade och väntar på omstart", + "evaluationFailed": "The check could not be evaluated" } }, "enterprise_repo_without_subscription": { "title": "Enterprise repository", - "rationale": "Looks for references to `enterprise.proxmox.com` in `/etc/apt/sources.list` and `sources.list.d`, and contrasts the result with the status returned by `pvesubscription get`.", + "rationale": "Referenser till `enterprise.proxmox.com` i `/etc/apt/sources.list` och `sources.list.d`, mot statusen från `pvesubscription get`.", "summary": { "notEnabled": "The enterprise repository is not enabled", "subscribed": "The enterprise repository is backed by a subscription", - "unsubscribed": "The enterprise repository is enabled without an active subscription" + "unsubscribed": "The enterprise repository is enabled without an active subscription", + "evaluationFailed": "The check could not be evaluated" + } + }, + "memory_overcommit": { + "title": "Memory allocation", + "rationale": "Taket `memory` i varje gästkonfiguration mot MemTotal, med körande gäster räknade för sig. Containrar förbrukar upp till den gränsen; virtuella maskiner utan ballooning reserverar den.", + "summary": { + "withinRatio": "Guests are allocated {percent}% of host memory", + "aboveRatio": "Guests are allocated {percent}% of host memory", + "evaluationFailed": "The check could not be evaluated" + } + }, + "time_synchronisation": { + "title": "Time synchronisation", + "rationale": "NTP och NTPSynchronized som `timedatectl` rapporterar dem. Klustermedlemskap, certifikatvalidering och loggordning bygger på klockor som stämmer överens. En annan mekanism kan sköta klockan.", + "summary": { + "synchronised": "The clock is synchronised with a time source", + "disabled": "Time synchronisation is disabled", + "notSynchronised": "Time synchronisation is enabled but the clock is not synchronised", + "evaluationFailed": "The check could not be evaluated" + } + }, + "kernel_current": { + "title": "Running kernel", + "rationale": "Kärnan som körs mot den värden skulle starta härnäst, som `proxmox-boot-tool` rapporterar den. En nyare kärna som bara är installerad kan hållas tillbaka med avsikt; en skillnad efter omstart betyder en start som inte tog.", + "summary": { + "current": "Kärnan {version} som körs är den värden skulle starta härnäst", + "newerAvailable": "The host runs {running} while {newest} is installed", + "newerSelected": "Värden kör {running} och skulle starta {selected} vid nästa omstart", + "wouldDowngrade": "Värden kör {running} men skulle starta den äldre {selected} vid nästa omstart", + "bootTargetUnknown": "Värden kör {version}; kärnan som valts för nästa start kunde inte läsas", + "evaluationFailed": "The check could not be evaluated" + } + }, + "security_updates": { + "title": "Security updates", + "rationale": "Väntande paket vars ursprung är ett säkerhetsarkiv, från en simulerad `apt-get upgrade`. Antalet speglar vad apt rapporterar, inte hur allvarligt det varje paket rättar är.", + "summary": { + "none": "No package updates are pending", + "noSecurity": "{total} update(s) pending, none from a security repository", + "pending": "{count} of {total} pending update(s) come from a security repository", + "evaluationFailed": "The check could not be evaluated" + } + }, + "journal_size": { + "title": "Journal size", + "rationale": "Journalen på disk mot det tak som gäller för den: SystemMaxUse där det är satt, annars journalds standard på en tiondel av filsystemet den ligger på.", + "summary": { + "bounded": "Journalen upptar {size}, inom sitt gällande tak", + "large": "The journal holds {size} on disk", + "nearCap": "Journalen upptar {size} och ligger på {percent}% av sitt gällande tak", + "capUnknown": "Journalen upptar {size}; dess gällande tak kunde inte fastställas", + "evaluationFailed": "The check could not be evaluated" + } + }, + "swap_configured": { + "title": "Swap", + "rationale": "Aktiva växlingsområden enligt `swapon` och deras summa mot värdens minne. Inget krav finns på ett förhållande till RAM; minnestryck mäts på annat håll.", + "summary": { + "active": "{size} of swap is active", + "none": "No swap area is active", + "evaluationFailed": "The check could not be evaluated" + } + }, + "filesystem_capacity": { + "title": "Kapacitet i värdens filsystem", + "rationale": "Utrymme och inoder i de filsystem värden själv behöver: roten, /var, /var/log och sökvägen till den lokala lagringen. Ett filsystem med ledigt utrymme och slut på inoder fallerar precis som ett fullt.", + "summary": { + "withinLimits": "Värdens {total} filsystem ligger under sina granskningströsklar", + "pressure": "{count} avläsningar ligger vid eller över sin granskningströskel", + "evaluationFailed": "Filsystemens beläggning kunde inte läsas" + } + }, + "update_chain": { + "title": "APT-paketindexens ålder", + "rationale": "När APT senast lade ett paketindex på den här värden. Ett förråd som svarar ”inte ändrat” lämnar sitt index orört. Förrådens nåbarhet testas inte.", + "summary": { + "current": "Paketindexen uppdaterades för {days} dygn sedan", + "stale": "Paketindexen uppdaterades senast för {days} dygn sedan", + "indexAgeUnknown": "Paketindexens ålder kunde inte fastställas" + } + }, + "notification_delivery": { + "title": "Senaste aviseringsresultat", + "rationale": "Aktiverade kanaler och deras senast sparade leveransresultat. Utan historik förblir leveransen overifierad; ett tidigare fel följt av en lyckad leverans räknas inte som ett aktuellt fel. Ingen testavisering skickas.", + "summary": { + "delivering": "Den senast registrerade leveransen lyckades för alla {total} aktiverade kanaler", + "failing": "{count} av {total} aktiverade kanaler har ett konfigurationsproblem eller en misslyckad senaste leverans", + "noChannels": "Ingen aviseringskanal är aktiverad", + "evaluationFailed": "Leveranshistoriken kunde inte läsas" + } + }, + "cluster_quorum": { + "title": "Klusterkvorum", + "rationale": "Kvorum så som klustret rapporterar det, konfigurerade noder mot de som syns nu, och antalet deklarerade corosync-länkar. Länkarna läses ur konfigurationen, sonderas inte.", + "summary": { + "standalone": "Den här noden ingår inte i något kluster", + "quorate": "Klustret har kvorum med {total} konfigurerade noder över {links} corosync-länk(ar)", + "attention": "{count} iakttagelse(r) för {total} konfigurerade noder", + "evaluationFailed": "Klustrets status kunde inte läsas" + } + }, + "boot_loader": { + "title": "Starthanterare", + "rationale": "EFI-systempartitionerna som proxmox-boot-tool rapporterar och kärnorna var och en bär. Ingen partition monteras och ingen start försöks.", + "summary": { + "synchronised": "De {total} startpartitionerna bär samma kärnor", + "attention": "{count} av {total} startpartitioner behöver ses över", + "evaluationFailed": "The check could not be evaluated" + } + }, + "failed_units": { + "title": "Väsentliga tjänster och misslyckade enheter", + "rationale": "Enheter som systemd gett upp efter att ha uttömt sina omstarter, och de tjänster Proxmox behöver för att svara alls, lästa vid namn eftersom en inaktiv tjänst inte alltid räknas som misslyckad. Vad varje enhet gör tolkas inte här.", + "summary": { + "allRunning": "De {total} väsentliga tjänsterna är aktiva och ingen enhet har misslyckats", + "attention": "{count} iakttagelse(r) bland enheterna", + "evaluationFailed": "The check could not be evaluated" + } + }, + "ha_state": { + "title": "Hög tillgänglighet", + "rationale": "HA-mastern, varje nods resurshanterare och tillståndet för varje hanterad tjänst, från `ha-manager status`. Kvorum rapporteras av klusterkontrollen. Ingen tjänst startas, stoppas eller migreras.", + "summary": { + "managed": "De {total} hanterade tjänsterna är i ett stabilt läge på {nodes} nod(er)", + "attention": "{count} iakttagelse(r) för {total} hanterade tjänster", + "evaluationFailed": "The check could not be evaluated" } } }, "guests": { "privileged_containers": { "title": "Container privileges", - "rationale": "Reviews each container configuration. Proxmox marks unprivileged containers with `unprivileged: 1`; its absence indicates a privileged container, which shares the host user namespace.", + "rationale": "Inställningen `unprivileged` i varje containerkonfiguration. Att den saknas betyder att containern delar värdens användarnamnrymd, vilket vissa laster kräver.", "summary": { "allUnprivileged": "All {total} containers are unprivileged", - "privileged": "{count} of {total} containers run privileged" + "privileged": "{count} of {total} containers run privileged", + "evaluationFailed": "The check could not be evaluated" } }, "qemu_without_agent": { "title": "Guest agent on virtual machines", - "rationale": "Reviews each virtual machine configuration for `agent: 1`. The guest agent enables ordered shutdown, filesystem quiescing for snapshots and real disk usage reporting.", + "rationale": "Inställningen `agent` i varje virtuell maskins konfiguration. Inställningen säger att agenten är deklarerad, inte att den svarar.", "summary": { "allHaveAgent": "All {total} virtual machines declare the guest agent", - "missingAgent": "{count} of {total} virtual machines do not declare the guest agent" + "missingAgent": "{count} of {total} virtual machines do not declare the guest agent", + "evaluationFailed": "The check could not be evaluated" + } + }, + "autostart": { + "title": "Automatic start", + "rationale": "Inställningen `onboot` för varje gäst, utom mallar och gäster som HA hanterar. Om en gäst förväntas komma tillbaka av sig själv framgår av den deklarerade policyn.", + "summary": { + "allAutostart": "All {total} guests start with the host", + "notAutostart": "{count} of {total} guests do not start with the host", + "evaluationFailed": "The check could not be evaluated" + } + }, + "stuck_snapshots": { + "title": "Snapshot state", + "rationale": "Ögonblicksbildernas tillstånd och ålder samt aktiva uppgifter. En färsk åtgärd eller en utan verifierbart datum betraktas inte som avbruten.", + "summary": { + "noSnapshots": "No guest holds snapshots", + "allComplete": "The {total} snapshot(s) are complete", + "stuck": "{count} of {total} snapshot(s) were left mid-operation", + "evaluationFailed": "The check could not be evaluated" + } + }, + "cpu_host_type": { + "title": "Virtual CPU model", + "rationale": "Värdet `cpu` för varje virtuell maskin. `host` exponerar den fysiska processorns instruktionsuppsättning, vilket begränsar vilka noder gästen kan migrera till. Oförenlighet med ett visst mål avgörs inte här.", + "summary": { + "none": "None of the {total} virtual machines is pinned to the host processor", + "pinned": "{count} of {total} virtual machines are pinned to the host processor", + "evaluationFailed": "The check could not be evaluated" + } + }, + "replication_state": { + "title": "Replication", + "rationale": "Replikeringsjobb från API:et: antal fel, senaste fel, senaste synkronisering och den kalender varje jobb anger. Pausade jobb redovisas som sådana.", + "summary": { + "healthy": "The {total} replication job(s) report no error", + "failing": "{count} of {total} replication job(s) report an error", + "statusUnavailable": "Replication jobs are defined but their status could not be read", + "evaluationFailed": "The check could not be evaluated" } } }, "security": { "host_firewall_enabled": { - "title": "Firewall state", - "rationale": "Checks `enable: 1` in `/etc/pve/firewall/cluster.fw` and in the node's `host.fw`. Proxmox applies node rules only while the datacenter switch is on.", + "title": "Konfigurerad brandväggsaktivering", + "rationale": "Alternativet `enable` i datacentrets brandvägg och i nodens egen, och hur många regler som är skrivna. Proxmox tillämpar nodens regler bara när datacentrets brytare är på. Alternativen säger inte vad någon regel filtrerar.", "summary": { - "bothEnabled": "The firewall is enabled at datacenter and node level", - "datacenterOff": "The firewall is disabled at datacenter level, so node rules are not applied", - "nodeOff": "The firewall is enabled at datacenter level but not on this node" + "bothEnabled": "Brandväggsaktivering är konfigurerad på datacenter- och nodnivå", + "datacenterOff": "Brandväggen är avstängd på datacenternivå, så nodreglerna tillämpas inte", + "nodeOff": "Brandväggen är aktiverad på datacenternivå men inte på den här noden", + "evaluationFailed": "The check could not be evaluated" + } + }, + "lynis_warnings": { + "title": "Lynis warnings", + "rationale": "Varningar från den senaste Lynis-granskningen, var och en med sin testidentifierare, och hur gammal granskningen är. En granskning körs bara om Lynis är installerat och ingen fullständig rapport finns. Förslag ingår inte.", + "summary": { + "none": "The last Lynis audit recorded no warnings", + "found": "The last Lynis audit recorded {count} warning(s)", + "incomplete": "The Lynis report is incomplete", + "evaluationFailed": "The check could not be evaluated", + "noneStale": "Den senaste Lynis-granskningen registrerade inga varningar, och dess rapport är {days} dag(ar) gammal", + "foundStale": "Den senaste Lynis-granskningen registrerade {count} varning(ar), och dess rapport är {days} dag(ar) gammal" + } + }, + "certificate_expiry": { + "title": "Certificate validity", + "rationale": "Utgångsdatum för certifikatet som pveproxy levererar från /etc/pve/local. Ett eget certifikat går före det Proxmox genererar.", + "summary": { + "valid": "The certificate is valid for {days} more day(s)", + "expiring": "The certificate expires in {days} day(s)", + "expired": "The certificate expired {days} day(s) ago", + "evaluationFailed": "The check could not be evaluated" + } + }, + "ssh_root_login": { + "title": "SSH root access", + "rationale": "PermitRootLogin i den effektiva `sshd -T`-konfigurationen, med de autentiseringsmetoder den kombineras med. Proxmox levereras med `yes`, som accepterar lösenord.", + "summary": { + "password": "Root may sign in over SSH with a password", + "keyOnly": "Root may sign in over SSH with a key only", + "denied": "Root may not sign in over SSH", + "evaluationFailed": "The check could not be evaluated" } } }, "storage": { "orphaned_volumes": { "title": "Volume assignment", - "rationale": "Compares the volumes returned by `pvesm list` against the existing guest configurations. Only non-shared storage is examined: on shared storage a volume may belong to a guest running on another node.", + "rationale": "Gästvolymer på lokal lagring mot referenserna i aktuella, väntande och ögonblicksbildskonfigurationer. Säkerhetskopior, ISO-filer och mallar ligger utanför jämförelsen. En volym utan referens är en kandidat för granskning.", "summary": { "none": "No orphaned volumes were found", - "found": "{count} volume(s) belong to no existing guest" + "found": "{count} volymer saknar referens i de granskade konfigurationerna", + "evaluationFailed": "The check could not be evaluated" + } + }, + "zfs_arc_max": { + "title": "ZFS ARC limit", + "rationale": "Effektiva värden för c_min, c_max och ARC-storlek, den laddade modulparametern och de bestående inställningarna i /etc/modprobe.d. Ett konfigurerat nollvärde väljer modulens standard; ARC är ett tak och minnet under det är återvinningsbart.", + "summary": { + "bounded": "ARC-gränsen är {percent}% av värdens minne, och minnet under den är återvinningsbart", + "high": "The ARC may use {percent}% of host memory", + "unset": "The ARC has no explicit limit set", + "conflicting": "{count} ARC-inställningar stämmer inte överens", + "pending": "En bestående ARC-inställning skiljer sig från värdet den körande modulen bär", + "evaluationFailed": "The check could not be evaluated" + } + }, + "zfs_scrub_age": { + "title": "ZFS scrub", + "rationale": "Den senaste avslutade scrub som `zpool status` registrerar för varje pool. En resilver är inte en scrub. En nyligen skapad pool har ännu inte haft tillfälle till en.", + "summary": { + "recent": "The {total} pool(s) were scrubbed within the last 35 days", + "overdue": "{count} of {total} pool(s) have not been scrubbed in 35 days", + "neverScrubbed": "{count} pool(s) record no scrub", + "evaluationFailed": "The check could not be evaluated" + } + }, + "thin_pool_overprovisioning": { + "title": "Thin pool allocation", + "rationale": "Virtuell kapacitet som varje LVM-thin-pool delar ut mot poolens egen storlek, och hur mycket dess volymer faktiskt har skrivit, data och metadata var för sig.", + "summary": { + "withinRatio": "De {total} thin-poolerna ligger under de tillämpade granskningsgränserna", + "aboveRatio": "{count} av {total} thin-pooler delar ut mer kapacitet än de har", + "pressure": "{pressure} av {total} thin-pooler närmar sig att fylla data eller metadata", + "evaluationFailed": "The check could not be evaluated" + } + }, + "connected_storage": { + "title": "Anslutna lagringar", + "rationale": "Tillgänglighet så som PVE rapporterar den, känd kapacitet och aktuella beroenden för alla lagringar som är aktiverade på noden. Fjärrkomponenter sonderas inte och skrivåtkomst testas inte. Kapaciteten anges där PVE känner den och lämnas tom där den inte gör det.", + "summary": { + "available": "PVE rapporterar alla {total} lagringar som tillgängliga; fjärrsystemens interna delar och skrivåtkomst testades inte", + "attention": "{count} av {total} lagringar behöver granskas", + "evaluationFailed": "The check could not be evaluated" + } + }, + "pool_integrity": { + "title": "Poolernas integritet och redundans", + "rationale": "Tillståndet för varje ZFS-pool och läs-, skriv- och kontrollsummeräknarna för dess enheter. Räknarna är kumulativa sedan senaste `zpool clear`.", + "summary": { + "healthy": "Alla {total} pooler är online utan registrerade enhetsfel", + "degraded": "{count} fynd i {total} pooler", + "evaluationFailed": "Poolernas tillstånd kunde inte läsas" + } + }, + "ceph_health": { + "title": "Cephs hälsa", + "rationale": "Cephs eget hälsotillstånd och de kontroller det namnger. Dess tester implementeras inte om och ingen pool, placeringsgrupp eller OSD frågas separat.", + "summary": { + "healthy": "Ceph rapporterar HEALTH_OK", + "degraded": "Ceph rapporterar {state}, med {count} namngivna kontroller", + "evaluationFailed": "The check could not be evaluated" + } + }, + "array_integrity": { + "title": "Program-RAID och multipath", + "rationale": "mdadm-uppsättningar och multipath-kartor, lästa från /proc/mdstat och, där verktyget är installerat, från `multipath -ll`. ZFS-pooler rapporteras av sin egen kontroll.", + "summary": { + "intact": "De {total} uppsättningarna och kartorna behåller sin redundans", + "degraded": "{count} av {total} uppsättningar eller kartor saknar den", + "evaluationFailed": "The check could not be evaluated" + } + } + }, + "hardware": { + "disk_service_life": { + "title": "Disk service life", + "rationale": "Drifttimmar och återstående slitage ur monitorns SMART-avläsningar, med datum för varje avläsning. Ålder är planeringsinformation; mediafel och enhetsvarningar rapporteras av hälsoövervakningen.", + "summary": { + "withinLife": "De {total} diskavläsningarna ligger under femårströskeln", + "pastLife": "{count} av {total} diskavläsningar överskrider fem års drift", + "noReadings": "Ingen disk rapporterar användbara SMART-värden ({skipped} utan avläsningar)", + "evaluationFailed": "The check could not be evaluated" + } + }, + "disk_errors": { + "title": "Diskfel", + "rationale": "Fel som upptäckts på diskarna och registrerats av hälsoövervakaren.", + "summary": { + "recorded": "{count} av {total} diskar med post har noterade händelser", + "noEvents": "Ingen diskhändelse kvar att bedöma: ingen registrerad, eller alla avfärdade" + } + } + }, + "network": { + "bond_members": { + "title": "Bond members", + "rationale": "MII-status för varje bond-medlem från /proc/net/bonding och hur många länkar som återstår. I active-backup rapporterar en reservmedlem som uppe och bär ingen trafik.", + "summary": { + "allUp": "All members of the {total} bond(s) are up", + "membersDown": "{count} bond member(s) are not up", + "evaluationFailed": "The check could not be evaluated" + } + }, + "bridge_without_ports": { + "title": "Bridge ports", + "rationale": "Portkonfigurationen för varje bridge. En bridge utan fysisk port betjänar ett internt eller routat nät.", + "summary": { + "allConnected": "The {total} bridge(s) carry a port", + "isolated": "{count} of {total} bridge(s) carry no port", + "evaluationFailed": "The check could not be evaluated" } } } @@ -5042,6 +5559,389 @@ "expiry365": "1 year", "cancel": "Cancel", "confirm": "Accept risk" - } + }, + "incomplete": "Ofullständiga underlag", + "progress": "Kontrollerat {completed} av {total}", + "expires": "Gäller till: {when}", + "runStates": { + "partial": "Bedömningen genomfördes; vissa avläsningar kunde inte göras.", + "failed": "Bedömningen avbröts eller misslyckades. Granska underlaget innan resultaten används." + }, + "severities": { + "CRITICAL": "Kritisk", + "WARNING": "Varning", + "INFO": "Information", + "OK": "OK" + }, + "viewSwitch": { + "ariaLabel": "Switch between assessment and inventory", + "assessment": "Assessment", + "inventory": "Inventory", + "policy": "Policy", + "changes": "Ändringar" + }, + "inventory": { + "loading": "Loading inventory…", + "failed": "The inventory could not be composed.", + "collectedAt": "Composed on {when}", + "unavailable": "Not read in this inventory", + "unresolved": "path not resolved", + "noUplink": "no uplink", + "identity": "Node identity", + "node": "Node", + "pveVersion": "Proxmox VE", + "kernel": "Kernel", + "subscription": "Subscription", + "cluster": "Cluster", + "standalone": "Not in a cluster", + "hardware": "Hardware", + "system": "System", + "serial": "Serial number", + "bios": "BIOS", + "cpu": "Processor", + "topology": "Layout", + "cpuLayout": "{sockets} socket(s) × {cores} cores = {threads} threads", + "virtualisation": "Virtualisation", + "memory": "Memory", + "iommuGroups": "IOMMU groups", + "network": "Network", + "storage": "Storage", + "name": "Name", + "type": "Type", + "content": "Content", + "shared": "Shared", + "yes": "Yes", + "no": "No", + "guests": "Guests", + "ostype": "Operating system", + "onboot": "Starts with host", + "tags": "Tags", + "privilege": "Privilege", + "privileged": "Privileged", + "unprivileged": "Unprivileged", + "features": "Features", + "agent": "Guest agent", + "cpuModel": "CPU model", + "disks": "Disks", + "interfaces": "Network interfaces", + "protection": "Backup", + "passthrough": "Passthrough", + "noBackup": "No backup", + "noBackupDetail": "No enabled backup job selects this guest.", + "applications": "Applications", + "versionUnknown": "version not detected", + "passthroughTitle": "PCI passthrough", + "iommuGroup": "IOMMU group {group}", + "sharedGroup": "{count} more device(s) in the same group", + "proxmenux": "ProxMenux optimizations", + "latency": "Nätverkslatens", + "subscriptionStatus": { + "notfound": "Ingen prenumeration", + "active": "Aktiv", + "invalid": "Ogiltig", + "expired": "Utgången", + "suspended": "Pausad", + "new": "Väntar på aktivering", + "unknown": "Okänd" + } + }, + "profile": { + "label": "Report", + "full": "Full audit", + "inventory": "Inventory", + "security": "Security review", + "backup": "Backup assurance", + "capacity": "Capacity and wear", + "diagnostic": "Snabbdiagnos" + }, + "document": { + "action": "Skapa rapport", + "title": "Granskningsrapport", + "subtitle": "Struktur, konfiguration och bedömning av {node}", + "generated": "Skapad", + "assessed": "Assessed", + "runId": "Assessment reference", + "print": "Print or save as PDF", + "summaryByArea": "Summary by area", + "area": "Area", + "inventoryAnnex": "Inventory annex", + "scopeTitle": "Scope of this report", + "scopeBody": "This report describes the Proxmox VE node named above, as observed from the node itself at the time stated. It does not cover the interior of the guests beyond what they declare, network equipment outside the host, physical infrastructure, or any dependency not visible from this node. Findings marked as not determined were not measured and are not evidence of absence.", + "building": "Rapporten sätts samman…", + "node": "Nod", + "profile": "Profil", + "unknownNode": "oidentifierad nod", + "executiveSummary": "Sammanfattning av bedömningen", + "assessment": "Bedömning", + "verdictHeading": "Resultat av denna körning", + "verdict": { + "critical": "ÅTGÄRD", + "warning": "SE ÖVER", + "conformant": "I ORDNING" + }, + "verdictText": { + "critical": "{fail} kontroller rapporterar ett misslyckat tillstånd och {warn} ett tillstånd att se över, av {total} bedömda.", + "warning": "Ingen kontroll rapporterar ett misslyckat tillstånd. {warn} av {total} rapporterar ett tillstånd att se över.", + "conformant": "Profilens {total} kontroller slutförs utan misslyckat tillstånd eller tillstånd att se över.", + "none": "Den här profilen kör inga kontroller. Dokumentet beskriver noden utan att bedöma den." + }, + "runAt": "Kördes {date}", + "chartNote": "Kontroller per område och resultat.", + "nodeIdentity": "Nodens identitet", + "system": "System", + "cluster": "Kluster", + "standaloneNote": "Noden ingår inte i ett kluster: den har sin egen konfiguration och dess gäster migrerar inte till en annan nod.", + "clusterDiagramNote": "Konfigurerade noder och de corosync-länkar som binder samman dem.", + "thisNode": "denna nod", + "unreachable": "ej sedd", + "member": "medlem", + "corosyncLinks": "länkar", + "quorum": "Beslutsförhet", + "quorate": "beslutsför", + "inquorate": "ej beslutsför", + "votes": "Röster", + "nodeName": "Nod", + "architecture": "Systemarkitektur", + "architectureNote": "Hur noden är byggd: processor och minne på kortet, och vad som hänger på varje styrkort.", + "systemIdentity": "Systemidentitet", + "board": "Moderkort", + "processor": "Processor", + "topology": "Socklar × kärnor / trådar", + "memory": "Minne", + "cores": "kärnor", + "threads": "trådar", + "memoryModules": "Minnesmoduler", + "slot": "Plats", + "slotsUsed": "platser i bruk", + "slotsFilled": "{used} av {total} platser bestyckade", + "emptySlot": "tom", + "formFactor": "Format", + "speed": "Hastighet", + "manufacturer": "Tillverkare", + "product": "Modell", + "serial": "Serienummer", + "controllers": "Styrkort", + "class": "Klass", + "device": "Enhet", + "iommuGroups": "IOMMU-grupper", + "iommuGroup": "IOMMU-grupp", + "field": "Fält", + "value": "Värde", + "size": "Storlek", + "type": "Typ", + "storageDevices": "Lagringsenheter", + "disks": "Diskar", + "model": "Modell", + "bus": "Buss", + "serviceLife": "Drifttimmar", + "healthy": "frisk", + "years": "{years} år", + "events": "Händelser", + "observations": "Observationer", + "observationsNote": "Registrerade händelser. SMART rapporterar nuläget; den här loggen rapporterar vad som hänt, även händelser som disken återhämtat sig från.", + "noObservations": "Inga registrerade händelser", + "noObservationsNote": "Ingen disk har registrerat något fel sedan monitorn började observera dem.", + "event": "Händelse", + "severity": "Allvarlighet", + "occurrences": "Förekomster", + "firstSeen": "Först sedd", + "lastSeen": "Senast sedd", + "detail": "Detalj", + "network": "Nätverk", + "adapters": "Adaptrar", + "bond": "Bond", + "bridge": "Bridge", + "bridges": "Bridgar", + "physicalAdapters": "Fysiska adaptrar", + "interface": "Gränssnitt", + "driver": "Drivrutin", + "state": "Tillstånd", + "networkDiagramNote": "Vägen från kabeln till varje gäst: fysisk adapter, bond när en sådan grupperar dem, bridge och anslutna gäster.", + "storageAndProtection": "Lagring och skydd", + "storage": "Lagring", + "content": "Innehåll", + "shared": "Delad", + "location": "Plats", + "backupDestination": "Mål för säkerhetskopior", + "unprotected": "utan säkerhetskopia", + "storageDiagramNote": "Var gästernas diskar ligger och vilket mål som säkerhetskopierar dem.", + "unprotectedGuests": "{count} gäster utan säkerhetskopieringsjobb", + "allProtected": "Varje gäst omfattas av ett säkerhetskopieringsjobb", + "allProtectedNote": "Täckning innebär att ett jobb väljer gästen; resultatet av säkerhetskopiorna bedöms separat.", + "vmid": "VMID", + "name": "Namn", + "kind": "Typ", + "backup": "Säkerhetskopia", + "none": "ingen", + "managedSoftware": "Programvara som ProxMenux hanterar", + "version": "Version", + "source": "Källa", + "current": "aktuell", + "updateAvailable": "uppdatera till {version}", + "findings": "Fynd i detalj", + "incomplete": "delvis", + "scope": "Rapportens omfattning", + "scopeText": "Dokumentet redovisar profilen {profile} på noden som anges i sidhuvudet, vid tidpunkten för körningen.", + "scopeLocal": "Det omfattar endast denna nod. Gäster på andra noder och deras konfiguration ligger utanför.", + "scopeReadOnly": "Alla kontroller läser konfiguration och tillstånd som redan finns; ingen ändrar värden.", + "scopeMoment": "Det beskriver tillståndet vid körningen, inte en tidsperiod.", + "notRead": "Källor som inte kunde läsas:", + "uplink": "Upplänk", + "conformance": "{pass} av {total} uppfyllda", + "latency": "Nätverkslatens", + "latencyNote": "Latens uppmätt under angivet fönster, en linje per mål.", + "milliseconds": "ms", + "hours": "tim", + "minimum": "Minimum", + "average": "Medel", + "maximum": "Maximum", + "packetLoss": "Paketförlust", + "samples": "Mätvärden", + "target": { + "label": "Mål", + "gateway": "Gateway", + "cloudflare": "Cloudflare (1.1.1.1)", + "google": "Google (8.8.8.8)" + }, + "actionShort": "Rapport", + "policyDeclared": "Bedömningen gjordes mot en deklarerad policy: {guests} gäster, {storages} lagringar och {thresholds} trösklar angivna.", + "policyNone": "Ingen policy är deklarerad, så en frånvaro som rapporten inte kan tolka anges som observation och aldrig som varning.", + "diagnosticTitle": "Snabbdiagnos", + "diagnosticSubtitle": "Kritiska resultat och varningar på {node}", + "diagnosticActing": "{count} kritiska resultat och varningar.", + "diagnosticClear": "Inget kritiskt resultat och ingen varning. Observationer och godkända resultat finns i den fullständiga granskningen.", + "diagnosticUnread": "Avläsningar som inte kunde göras", + "diagnosticMoreRows": "{count} rad(er) till, i den fullständiga granskningen.", + "structureTitle": "Struktur och konfiguration", + "structureSubtitle": "Hur {node} är byggd och konfigurerad" + }, + "results": "Resultat", + "classifications": { + "critical": "Kritiskt", + "warning": "Varning", + "observation": "Observation", + "conformant": "Uppfyllt", + "unverified": "Ej verifierat", + "not_applicable": "Ej tillämpligt", + "accepted": "Accepterad risk", + "by_design": "Undantagen enligt policy" + }, + "policy": { + "inherit": "{value} (standard)", + "inheritUnset": "Standard", + "conflict": "Deklarationen ändrades i en annan session. Ditt utkast har inte sparats.", + "reload": "Läs in den sparade deklarationen (kasta utkastet)", + "intro": "En bedömning ser vad den här värden gör, inte vad den är till för. Det som deklareras här gör en observation till en varning, eller tar bort den ur räkningen. Inget är obligatoriskt: utan deklaration beskriver rapporten i stället för att döma.", + "loading": "Läser deklarationen…", + "failed": "Deklarationen kunde inte läsas", + "saved": "Sparat", + "declaredCount": "{count} deklarationer", + "guestsNote": "Krävs rapporterar det som saknas som en varning; ej angivet rapporterar det som en observation; krävs inte lämnar det utanför räkningen.", + "storagesNote": "En onåbar lagring är kritisk när den deklarerats som väsentlig eller betjänar en körande gäst, en varning när dess roll inte deklarerats, och en observation när den deklarerats som valfri.", + "thresholds": "Trösklar", + "thresholdsNote": "Tomt betyder det levererade värdet, som visas som platshållare.", + "backup": "Säkerhetskopia", + "autostart": "Autostart", + "objective": "Återställningsmål", + "objectivePlaceholder": "timmar", + "noGuests": "Noden håller inga gäster.", + "expectation": { + "required": "Krävs", + "not_required": "Krävs inte", + "unspecified": "Ej deklarerat" + }, + "role": { + "essential": "Nödvändig", + "optional": "Valfri", + "unspecified": "Ej deklarerat" + }, + "threshold": { + "storage_usage_percent": "Granskningströskel för lagringskapacitet (%)", + "thin_pool_usage_percent": "Granskningströskel för thin-poolens fyllnad (%)", + "thin_overprovision_ratio": "Kvot för thin-överallokering", + "zfs_scrub_days": "Intervall för ZFS-scrub (dygn)", + "backup_fallback_days": "Reservgräns för kopians ålder (dygn)", + "backup_schedule_grace_ratio": "Marginal mot schemat (kvot)", + "certificate_expiry_days": "Varsel om certifikatets utgång (dygn)", + "memory_overcommit_ratio": "Kvot för minnesöverallokering", + "disk_service_life_hours": "Diskens livslängd (timmar)", + "lynis_report_days": "Lynis-rapportens ålder (dygn)", + "package_index_days": "Paketindexens ålder (dygn)", + "journal_usage_percent": "Journalen mot sitt tak (%)", + "filesystem_usage_percent": "Granskningströskel för utrymme (%)", + "filesystem_inode_percent": "Granskningströskel för inoder (%)", + "disk_error_recent_days": "Fönster för nyliga diskfel (dagar)" + } + }, + "changes": { + "loading": "Läser ändringsjournalen…", + "failed": "Ändringsjournalen kunde inte läsas", + "intro": "Vad ProxMenux ändrat på den här värden och vad som fanns före varje ändring. Skillnaden visas, inte skriptet som tillämpade den.", + "empty": "Inget har ännu registrerats på den här värden.", + "since": "Registrerar sedan {date}. Det som tillämpades dessförinnan står som tillämpat, utan det tillstånd det ersatte.", + "byFunction": "Per funktion", + "count": "{count} ändringar", + "function": "Funktion", + "source": "Skript", + "reversibility": "Att ångra detta", + "difference": "Skillnad", + "diffTruncated": "Skillnaden är längre än det som visas.", + "diffUnavailable": "Innehållet som ersattes lagras inte längre, så skillnaden kan inte visas.", + "packagesAdded": "Tillagda paket", + "commandRun": "Kört kommando", + "executionNote": "ProxMenux körde detta på begäran; vad som ändrades avgjordes av kommandot, inte av ProxMenux.", + "unknownNote": "Detta tillämpades innan journalen fanns, så det som ersattes fångades aldrig.", + "noneInFilter": "Ingen ändring av det slaget.", + "class": { + "all": "Alla", + "configuration": "Konfiguration", + "installation": "Installationer", + "execution": "Körningar", + "registration": "Tillämpat" + }, + "operation": { + "write_file": "Fil ersatt", + "edit_file": "Fil redigerad", + "remove_file": "Fil borttagen", + "install_package": "Installerat", + "enable_service": "Tjänst aktiverad", + "disable_service": "Tjänst avaktiverad", + "run_command": "Kört", + "applied": "Tillämpat", + "removed": "Borttaget", + "unknown": "Ändring" + }, + "capture": { + "unknown": "Tidigare tillstånd okänt" + }, + "exactness": { + "exact": "Återställer exakt det som fanns", + "partial": "Delvis: beroenden kan bli kvar eller följa med", + "none": "Kan inte ångras från journalen" + } + }, + "comparison": { + "loading": "Jämför med referenskörningen…", + "failed": "Referenskörningen kunde inte sättas", + "since": "Sedan {date}", + "previousRun": "föregående körning", + "noChange": "Ingen ändring", + "isBaseline": "Den här körningen är referensen som de andra jämförs mot.", + "noBaseline": "Ingen referenskörning har valts ännu, så det finns inget att jämföra mot.", + "setBaseline": "Använd som referens", + "unchanged": "{count} kontroller gav samma resultat som förut.", + "new": "Nya", + "newNote": "rapporteras nu men inte förut", + "resolved": "Åtgärdade", + "resolvedNote": "rapporteras inte längre, och ingen accepterade dem", + "accepted": "Accepterade", + "acceptedNote": "räknas inte längre för att en risk accepterades, inte för att värden ändrats", + "retired": "Bedöms inte längre", + "retiredNote": "fanns förut och saknas i den här körningen; inget bekräftade att de upphört", + "reasons": { + "insufficient_runs": "En jämförelse kräver en referenskörning och en senare; hittills är bara en registrerad" + } + }, + "notApplicableScope": "Inget i det granskade omfånget som den här kontrollen gäller." } } diff --git a/AppImage/scripts/audit_checks.py b/AppImage/scripts/audit_checks.py index 0f8a4002..df97ffba 100644 --- a/AppImage/scripts/audit_checks.py +++ b/AppImage/scripts/audit_checks.py @@ -18,6 +18,10 @@ not acceptable. from __future__ import annotations import os +import json +import socket +import sys +import copy import re import subprocess import time @@ -44,22 +48,30 @@ AREAS = ( SEVERITIES = ("OK", "INFO", "WARNING", "CRITICAL") -# Per-check wall-clock budget. A check that cannot answer within it is -# recorded as not applicable rather than stalling the whole assessment. -CHECK_TIMEOUT = 20 +# Shared deadline for all subprocesses in a check, not a fresh timeout +# per device/storage. Exhaustion is unknown, never not applicable. +CHECK_TIMEOUT = 30 +RUN_TIMEOUT = 300 +CATALOG_VERSION = 14 + +# A check that has to produce its own evidence — rather than read +# evidence something else already produced — declares how long that +# takes. The budget is still bounded by the run's own deadline. +LYNIS_RUN_BUDGET = 240 class Check: """One registered assessment. - ``evaluate`` receives the context and returns a dict with ``state`` + ``evaluate`` receives the context and returns a dict with ``classification`` and, optionally, ``summary``, ``affected``, ``evidence`` and ``remediable_by``. Returning ``None`` marks the check as not applicable on this host. """ def __init__(self, check_id: str, area: str, severity: str, - evaluate: Callable[["AuditContext"], Optional[dict]]): + evaluate: Callable[["AuditContext"], Optional[dict]], + budget: int = CHECK_TIMEOUT): if area not in AREAS: raise ValueError(f"unknown area for {check_id}: {area}") if severity not in SEVERITIES: @@ -70,17 +82,20 @@ class Check: self.area = area self.severity = severity self.evaluate = evaluate + self.budget = budget + self.version = CATALOG_VERSION _REGISTRY: dict[str, Check] = {} -def register(check_id: str, area: str, severity: str): +def register(check_id: str, area: str, severity: str, + budget: int = CHECK_TIMEOUT): """Decorator registering a check under a stable identifier.""" def wrap(fn): if check_id in _REGISTRY: raise ValueError(f"duplicate check identifier: {check_id}") - _REGISTRY[check_id] = Check(check_id, area, severity, fn) + _REGISTRY[check_id] = Check(check_id, area, severity, fn, budget) return fn return wrap @@ -98,26 +113,91 @@ class AuditContext: def __init__(self): self._cache: dict[str, Any] = {} + self._source_info = {} + self._dependencies = {} + self._sources_used = set() + self._errors = {} + self._check_deadline = float("inf") + self._run_deadline = time.monotonic() + RUN_TIMEOUT + + def begin_check(self, budget: int = CHECK_TIMEOUT): + self._sources_used = set() + self._check_deadline = min(time.monotonic() + budget, self._run_deadline) + + def source(self, key, *, error=None): + self._sources_used.add(key) + self._source_info.setdefault(key, {"source": key, "collected_at": int(time.time())}) + if error: + self._errors[key] = str(error) + if key in self._errors: + self._source_info[key]["error"] = self._errors[key] + + def read(self, path, *, optional=False): + def load(): + try: + return Path(path).read_text(errors="replace") + except FileNotFoundError: + if optional: + return "" + raise + return self._once(str(path), load) or "" + + @property + def node(self): + return socket.gethostname().split(".")[0] + + @property + def policy(self): + """What has been declared about this host, or nothing declared. + + Read once per assessment so every check judges against the same + declaration, even if the file changes while a run is in progress. + """ + def load(): + import audit_policy + value = audit_policy.load() + if value.error: + self.source("policy", error=value.error) + return value + return self._once("policy", load) def _once(self, key: str, producer: Callable[[], Any]) -> Any: + self.source(key) if key not in self._cache: + parent_sources = self._sources_used + self._sources_used = {key} try: self._cache[key] = producer() - except Exception: + except Exception as exc: self._cache[key] = None + self.source(key, error=exc) + finally: + self._dependencies[key] = self._sources_used - {key} + parent_sources.update(self._sources_used) + self._sources_used = parent_sources + else: + for dependency in self._dependencies.get(key, ()): + self.source(dependency) return self._cache[key] - def run(self, cmd: list[str], timeout: int = 10) -> tuple[int, str]: + def run(self, cmd: list[str], timeout: int = 10, allowed_codes=(0,)) -> tuple[int, str]: """Run a read-only command, returning exit code and output.""" - key = f"cmd:{' '.join(cmd)}" + key = "cmd:" + json.dumps(cmd) + self.source(key) if key in self._cache: return self._cache[key] try: + remaining = min(timeout, self._check_deadline - time.monotonic(), + self._run_deadline - time.monotonic()) + if remaining <= 0: + raise TimeoutError("assessment time budget exhausted") proc = subprocess.run(cmd, capture_output=True, text=True, - timeout=timeout) + timeout=remaining, env={**os.environ, "LC_ALL": "C", "LANG": "C"}) result = (proc.returncode, (proc.stdout or "") + (proc.stderr or "")) except Exception as exc: result = (-1, str(exc)) + if result[0] not in allowed_codes: + self.source(key, error=f"exit {result[0]}: {result[1][:500]}") self._cache[key] = result return result @@ -128,11 +208,13 @@ class AuditContext: out: dict[int, str] = {} base = Path("/etc/pve/lxc") if not base.is_dir(): + self.source("lxc_configs", error="local PVE configuration directory unavailable") return out for path in base.glob("*.conf"): try: out[int(path.stem)] = path.read_text(errors="replace") - except (OSError, ValueError): + except (OSError, ValueError) as exc: + self.source("lxc_configs", error=f"{path}: {exc}") continue return out return self._once("lxc_configs", load) or {} @@ -143,15 +225,31 @@ class AuditContext: out: dict[int, str] = {} base = Path("/etc/pve/qemu-server") if not base.is_dir(): + self.source("qemu_configs", error="local PVE configuration directory unavailable") return out for path in base.glob("*.conf"): try: out[int(path.stem)] = path.read_text(errors="replace") - except (OSError, ValueError): + except (OSError, ValueError) as exc: + self.source("qemu_configs", error=f"{path}: {exc}") continue return out return self._once("qemu_configs", load) or {} + @property + def cluster_configs(self): + """Local pmxcfs view only, to protect volumes referenced by other nodes.""" + def load(): + result = {} + base = Path("/etc/pve/nodes") + if not base.is_dir(): + raise OSError("cluster configuration view unavailable") + for kind in ("lxc", "qemu-server"): + for path in base.glob(f"*/{kind}/*.conf"): + result[str(path)] = path.read_text(errors="replace") + return result + return self._once("cluster_configs", load) or {} + @property def apt_sources(self) -> dict[str, str]: """Contents of the apt source files that define PVE repositories.""" @@ -165,8 +263,10 @@ class AuditContext: for path in candidates: try: out[str(path)] = path.read_text(errors="replace") - except OSError: + except FileNotFoundError: continue + except OSError as exc: + self.source("apt_sources", error=f"{path}: {exc}") return out return self._once("apt_sources", load) or {} @@ -178,11 +278,86 @@ class AuditContext: for path in (Path("/etc/pve/jobs.cfg"), Path("/etc/vzdump.cron")): try: text += path.read_text(errors="replace") + "\n" - except OSError: + except FileNotFoundError: continue + except OSError as exc: + self.source("vzdump_jobs", error=f"{path}: {exc}") return text return self._once("vzdump_jobs", load) or "" + def _run_lynis(self): + """Produce a Lynis report. + + Returns the parsed report, whether this assessment produced it, + and why it could not, so a check reports what actually happened + rather than asserting a run that may never have started. + """ + from security_manager import (_find_lynis_cmd, get_lynis_audit_status, + parse_lynis_report, run_lynis_audit) + if not _find_lynis_cmd(): + return None, False, None + + deadline = min(time.monotonic() + LYNIS_RUN_BUDGET, self._run_deadline) + if not get_lynis_audit_status().get("running"): + started, message = run_lynis_audit() + if not started and "already running" not in (message or "").lower(): + reason = message or "Lynis could not be started" + self.source("lynis:run", error=reason) + return None, False, reason + # A quick audit takes about a minute; the wait is bounded by the + # budget and by the assessment's own deadline. + while get_lynis_audit_status().get("running"): + if time.monotonic() >= deadline: + reason = "Lynis was still running when the time budget ran out" + self.source("lynis:run", error=reason) + return None, True, reason + time.sleep(2) + self.source("lynis:run") + return parse_lynis_report(enrich_current=False), True, None + + @property + def lynis_report(self) -> Optional[dict]: + """The most recent Lynis audit, running one if there is none. + + An assessment that reports "not verified" because nobody has + opened the Security page yet is reporting on the Monitor, not on + the host. Where Lynis is installed and has no usable report — or + only the remains of an interrupted run — the audit is produced + here, because that reading is what was asked for. Where Lynis is + not installed there is nothing to report and the checks do not + apply. + + The run goes through Security's own entry point, which holds the + lock that keeps two audits from starting at once, so an audit the + user launched from that page is waited on rather than duplicated. + """ + def load(): + from security_manager import parse_lynis_report + parsed = parse_lynis_report(enrich_current=False) + ran, run_error = False, None + if parsed is None or not parsed.get("is_complete"): + produced, ran, run_error = self._run_lynis() + if produced is not None: + parsed = produced + if parsed is None: + return None + source = next((p for p in (Path("/var/log/lynis-report.dat"), + Path("/var/log/lynis-output.log")) if p.exists()), None) + return { + "mtime": source.stat().st_mtime if source else 0, + "source": str(source), "version": parsed.get("lynis_version"), + "warnings": parsed.get("warnings", []), + "suggestions": parsed.get("suggestions", []), + "hardening_index": parsed.get("hardening_index"), + "complete": parsed.get("is_complete", False), + # What the assessment itself did, so a check can say + # whether it is reporting a stored result or one it + # produced, and why a produced one is unusable. + "produced_here": ran, + "run_error": run_error, + } + return self._once("lynis_report", load) + @property def storages(self) -> list[dict]: """Storage definitions from ``storage.cfg``. @@ -197,7 +372,7 @@ class AuditContext: try: text = Path("/etc/pve/storage.cfg").read_text(errors="replace") except OSError: - return out + raise current: Optional[dict] = None for line in text.splitlines(): if not line.strip(): @@ -221,77 +396,267 @@ class AuditContext: def load(): try: return Path("/etc/pve/user.cfg").read_text(errors="replace") - except OSError: + except FileNotFoundError: return "" return self._once("pve_user_cfg", load) or "" + @property + def storage_snapshot(self): + """Reuse recent Monitor storage observations; one PVE metadata read otherwise. + + Never invoke a mount, activate a volume, or connect to a remote host. + A successful PVE resource query is not an end-to-end storage IO test. + """ + def load(): + server = sys.modules.get("flask_server") or sys.modules.get("__main__") + cache = copy.deepcopy(getattr(server, "_proxmox_storage_cache", {})) + when = cache.get("time", 0) + data = cache.get("data") + if (isinstance(data, dict) and isinstance(data.get("storage"), list) + and "error" not in data and 0 <= time.time() - when <= 120): + return {"rows": data["storage"], "collected_at": when, + "source": "Monitor storage cache", "units": "GiB"} + rc, out = self.run(["pvesh", "get", "/cluster/resources", "--type", "storage", + "--output-format", "json"], timeout=10) + if rc != 0: + raise RuntimeError("PVE storage resource metadata unavailable") + resources = json.loads(out) + if not isinstance(resources, list) or any(not isinstance(r, dict) for r in resources): + raise ValueError("unrecognised storage resource metadata") + rows = [{"name": r.get("storage"), "node": r.get("node"), + "status": r.get("status", "unknown"), "total": r.get("maxdisk"), + "used": r.get("disk"), "type": r.get("plugintype")} + for r in resources if r.get("node") == self.node] + return {"rows": rows, "collected_at": time.time(), + "source": "PVE cluster resource metadata", "units": "bytes"} + return self._once("storage_snapshot", load) or {} + + def _block_devices(self) -> list[str]: + """Real disks, as the kernel lists them.""" + # zd* are ZFS volumes and dm-* device-mapper targets: guest + # storage rather than hardware, with no SMART to read. + skip = ("loop", "ram", "zram", "dm-", "md", "sr", "nbd", "fd", "zd") + try: + return sorted(d.name for d in Path("/sys/block").iterdir() + if not d.name.startswith(skip)) + except OSError: + return [] + + @property + def monitor_snapshot(self): + """Copy existing Monitor data without triggering probes or importing Flask.""" + def load(): + server = sys.modules.get("flask_server") or sys.modules.get("__main__") + smart = copy.deepcopy(getattr(server, "_smart_result_cache", {})) + # That cache is filled by whoever last opened the storage view, + # so an assessment can find it empty and report nothing about + # disks the interface is already showing wear for. Ask through + # the Monitor's own accessor for what is missing: it serves a + # sleeping disk from its last known values rather than waking + # it, and reuses the same 30 s memoisation the interface hits. + reader = getattr(server, "get_smart_data", None) + if callable(reader): + for device in self._block_devices(): + if device in smart: + continue + if time.monotonic() >= self._run_deadline: + break + try: + data = reader(device) + except Exception: + continue + if isinstance(data, dict): + smart[device] = (time.time(), data) + health_module = sys.modules.get("health_monitor") + monitor = getattr(health_module, "health_monitor", None) + health = copy.deepcopy(getattr(monitor, "cached_results", {}).get("_bg_detailed")) + when = getattr(monitor, "last_check_times", {}).get("_bg_detailed") + return {"smart": smart, "health": health, "health_collected_at": when} + return self._once("monitor_snapshot", load) or {} + + def metadata(self, checks): + def local(path): + try: + return Path(path).read_text().strip() + except OSError: + return None + version = (local(Path(__file__).resolve().parents[1] / "package.json") or + local(Path(__file__).resolve().parents[2] / "package.json")) + try: + version = json.loads(version or "{}").get("version") + except ValueError: + version = None + rc, pve = self.run(["pveversion"], timeout=5) + return {"host": self.node, "kernel": os.uname().release, + "boot_id": local("/proc/sys/kernel/random/boot_id"), + "proxmenux_version": version, "pve_version": pve.strip() if rc == 0 else None, + "catalog_version": CATALOG_VERSION, "scope": "local node; no guest interior probes", + "checks": [c.check_id for c in checks], + "policy": self.policy.describe(), + "health_snapshot": self.monitor_snapshot.get("health"), + "health_collected_at": self.monitor_snapshot.get("health_collected_at")} + # --------------------------------------------------------------------------- # Evaluation # --------------------------------------------------------------------------- +def _classification_of(result: dict, check: "Check") -> str: + """The gravity of a result, from the result itself. + + A check states the gravity of what it found. Where several objects + were examined and each carries its own, the finding takes the gravest + of them, because a report that says "observation" over an object it + marked critical is wrong about the object it matters most for. + """ + per_object = [o.get("classification") for o in (result.get("affected") or []) + if isinstance(o, dict) and o.get("classification")] + declared = result.get("classification") + values = ([declared] if declared else []) + per_object + if result.get("incomplete"): + values.append(audit_store.CLASS_UNVERIFIED) + if any(v not in audit_store.CLASSIFICATIONS for v in values): + values.append(audit_store.CLASS_UNVERIFIED) + problems = [v for v in values if v in audit_store.CLASS_PROBLEMS] + if problems: + return audit_store.worst(problems) + if audit_store.CLASS_UNVERIFIED in values: + return audit_store.CLASS_UNVERIFIED + if values: + return audit_store.worst(values) + if declared in audit_store.CLASSIFICATIONS: + return declared + # A check that has not been migrated to the scale is read on it from + # what it used to return, so the catalogue keeps working while the + # rules are revised one by one. + return audit_store.classification_of( + result.get("state", audit_store.STATE_UNKNOWN), check.severity) + + def run_assessment(profile: str = "full", - only_areas: Optional[set[str]] = None) -> str: + only_areas: Optional[set[str]] = None, *, run_id=None, progress=None) -> str: """Evaluate every registered check and persist the result. - A check that raises is recorded as not applicable with the error kept + A check that raises is recorded as unverified with the error kept as evidence. One faulty check must never abort an assessment: a partial report that says which check failed is more useful than no report at all. """ + import audit_profiles + if not audit_profiles.is_known(profile) or ( + only_areas is not None and (not only_areas or not only_areas <= set(AREAS))): + raise ValueError("unsupported audit profile or areas") + # The profile narrows the catalogue to its question; an explicit area + # filter narrows it further within that. + checks = audit_profiles.selected_checks(profile, registered_checks()) + if only_areas is not None: + checks = [c for c in checks if c.area in only_areas] ctx = AuditContext() exceptions = audit_store.active_exceptions() - run_id = audit_store.start_run(profile) + metadata = ctx.metadata(checks) + if run_id is None: + run_id = audit_store.start_run(profile, metadata, len(checks)) + else: + audit_store.update_run_metadata(run_id, metadata, len(checks)) findings: list[dict[str, Any]] = [] error: Optional[str] = None try: - for check in registered_checks(): - if only_areas and check.area not in only_areas: - continue + for check in checks: + ctx.begin_check(check.budget) + if progress: + progress(run_id, len(findings), len(checks), check.check_id) started = time.monotonic() try: + if started >= ctx._run_deadline: + raise TimeoutError("assessment time budget exhausted") result = check.evaluate(ctx) + if result is not None and (not isinstance(result, dict) + or not isinstance(result.get("affected", []), list) + or any(not isinstance(obj, dict) for obj in result.get("affected", []))): + raise ValueError("invalid check result") except Exception as exc: result = { - "state": audit_store.STATE_NOT_APPLICABLE, + "classification": audit_store.CLASS_UNVERIFIED, "summary_key": "evaluationFailed", "evidence": f"{type(exc).__name__}: {exc}", } elapsed = time.monotonic() - started if result is None: - result = {"state": audit_store.STATE_NOT_APPLICABLE} + # No prose here: this sentence reached a report that + # exists in eight languages. The interface says it in the + # reader's own, and a check with something specific to + # say returns its own summary instead of None. + result = {"classification": audit_store.CLASS_NOT_APPLICABLE} - state = result.get("state", audit_store.STATE_NOT_APPLICABLE) + errors = [f"{k}: {ctx._errors[k]}" for k in ctx._sources_used if k in ctx._errors] + if elapsed > check.budget: + errors.append("check time budget exceeded") + if errors: + result["incomplete"] = True + result["evidence"] = (result.get("evidence") or "") + "\n" + "\n".join(errors) + # A source that could not be read cannot turn into a clean + # result, but it must not soften one that already found a + # problem either: what was found stands, what was missed is + # named. + if _classification_of(result, check) not in audit_store.CLASS_PROBLEMS: + result.update(classification=audit_store.CLASS_UNVERIFIED, + summary_key="evaluationFailed") + + classification = _classification_of(result, check) # An accepted risk keeps its evidence and its declared # severity; only the state changes, so the report can still # show what was accepted and why it mattered. - if state in (audit_store.STATE_FAIL, audit_store.STATE_WARN) \ - and check.check_id in exceptions: - state = audit_store.STATE_ACCEPTED - evidence = result.get("evidence") - if elapsed > CHECK_TIMEOUT: - evidence = (evidence or "") + \ - f"\n[check exceeded its time budget: {elapsed:.1f}s]" - - findings.append({ + # Names already collected by a check are display metadata, not a + # reason to probe guests again or alter the finding's scope. + for obj in result.get("affected") or []: + vmid = obj.get("vmid") + if vmid is None or obj.get("name"): + continue + for cache_key, field in (("lxc_configs", "hostname"), ("qemu_configs", "name")): + config = (getattr(ctx, "_cache", {}).get(cache_key) or {}).get(vmid, "") + match = re.search(r"^" + field + r":\s*(.+)$", config, re.MULTILINE) + if match: + obj["name"] = match.group(1).strip() + break + finding = { "check_id": check.check_id, "area": check.area, + # Retained as the gravity the check can reach at worst, + # which is what the catalogue advertises; the finding's own + # gravity is its classification. "severity": check.severity, - "state": state, + "classification": classification, "summary_key": result.get("summary_key"), "summary_params": result.get("summary_params") or {}, "affected": result.get("affected") or [], "evidence": evidence, "remediable_by": result.get("remediable_by"), - }) + "raw_classification": classification, + "check_version": check.version, "host": ctx.node, + "collected_at": int(time.time()), "incomplete": result.get("incomplete", False), + "observations": result.get("observations", []), + "sources": [ctx._source_info[k] for k in sorted(ctx._sources_used)], + } + finding["scope"] = audit_store.finding_scope(finding) + decision = exceptions.get(check.check_id) + if (classification in audit_store.CLASS_PROBLEMS and decision + and decision.get("scope") == finding["scope"] and not finding["incomplete"] + and (decision.get("expires_at") is None or decision["expires_at"] > time.time())): + finding.update(decision=audit_store.DECISION_ACCEPTED, exception=decision) + findings.append(finding) except Exception as exc: error = f"{type(exc).__name__}: {exc}" audit_store.record_findings(run_id, findings) - audit_store.finish_run(run_id, checks_total=len(findings), error=error) + audit_store.finish_run( + run_id, checks_total=len(findings), error=error, + partial=any(f["classification"] == audit_store.CLASS_UNVERIFIED + or f.get("incomplete") for f in findings)) + if progress: + progress(run_id, len(findings), len(checks), None) return run_id @@ -307,30 +672,37 @@ def compare_runs(base_run: str, other_run: str) -> dict[str, list[dict]]: ``unchanged`` is kept so a report can state that the rest of the surface held steady rather than leaving it unaccounted for. """ - failing = {audit_store.STATE_FAIL, audit_store.STATE_WARN} + problems = set(audit_store.CLASS_PROBLEMS) base = {f["check_id"]: f for f in audit_store.get_findings(base_run)} other = {f["check_id"]: f for f in audit_store.get_findings(other_run)} - new, resolved, accepted, unchanged = [], [], [], [] + new, resolved, accepted, unchanged, unverified = [], [], [], [], [] for check_id, current in other.items(): previous = base.get(check_id) - was = previous["state"] in failing if previous else False - now = current["state"] in failing - if now and not was: + was = previous["classification"] in problems if previous else False + now = current["classification"] in problems + if current["classification"] in (audit_store.CLASS_UNVERIFIED, + audit_store.CLASS_NOT_APPLICABLE) \ + or current.get("incomplete"): + unverified.append(current) + elif now and current.get("decision") == audit_store.DECISION_ACCEPTED: + accepted.append(current) + elif now and (not was or previous["classification"] != current["classification"]): new.append(current) elif was and not now: - if current["state"] == audit_store.STATE_ACCEPTED: + if current.get("decision") == audit_store.DECISION_ACCEPTED: accepted.append(current) - else: + elif current["classification"] in (audit_store.CLASS_CONFORMANT, + audit_store.CLASS_OBSERVATION): resolved.append(current) - elif previous and previous["state"] == current["state"]: + elif previous and previous["classification"] == current["classification"]: unchanged.append(current) # A check present in the base run but absent from the later one was # retired between the two. It is reported as no longer assessed rather # than as resolved, since nothing verified that it stopped failing. retired = [ previous for check_id, previous in base.items() - if check_id not in other and previous["state"] in failing + if check_id not in other and previous["classification"] in problems ] return { @@ -339,4 +711,5 @@ def compare_runs(base_run: str, other_run: str) -> dict[str, list[dict]]: "accepted": accepted, "unchanged": unchanged, "retired": retired, + "unverified": unverified, } diff --git a/AppImage/scripts/audit_checks_pve.py b/AppImage/scripts/audit_checks_pve.py index 70a4e498..de4a8495 100644 --- a/AppImage/scripts/audit_checks_pve.py +++ b/AppImage/scripts/audit_checks_pve.py @@ -10,19 +10,81 @@ the source of notifications. """ from __future__ import annotations +import calendar +from datetime import datetime +import math +import json +import shlex +import socket +import sys import re +import time from pathlib import Path import audit_store from audit_checks import ( - AREA_BACKUP, AREA_GUESTS, AREA_SECURITY, AREA_STORAGE, AREA_SYSTEM, - register, + AREA_BACKUP, AREA_GUESTS, AREA_HARDWARE, AREA_NETWORK, AREA_SECURITY, + AREA_STORAGE, AREA_SYSTEM, LYNIS_RUN_BUDGET, register, ) +import audit_policy -FAIL = audit_store.STATE_FAIL -WARN = audit_store.STATE_WARN -PASS = audit_store.STATE_PASS -NA = audit_store.STATE_NOT_APPLICABLE +CLASS_CRITICAL = audit_store.CLASS_CRITICAL +CLASS_WARNING = audit_store.CLASS_WARNING +CLASS_OBSERVATION = audit_store.CLASS_OBSERVATION +CLASS_CONFORMANT = audit_store.CLASS_CONFORMANT +CLASS_UNVERIFIED = audit_store.CLASS_UNVERIFIED +CLASS_NOT_APPLICABLE = audit_store.CLASS_NOT_APPLICABLE + + +def _unverified(evidence, **extra): + return {"classification": CLASS_UNVERIFIED, "summary_key": "evaluationFailed", + "incomplete": True, "evidence": str(evidence), **extra} + + +def _current_config(text): + """Snapshots and pending sections must not override effective settings.""" + return re.split(r"^\[", text, maxsplit=1, flags=re.M)[0] + + +def _guest_configs(configs): + return {v: _current_config(t) for v, t in configs.items() + if not re.search(r"^template:\s*1\s*$", _current_config(t), re.M)} + + +def _local_enabled(item, ctx): + nodes = re.split(r"[,;\s]+", item.get("nodes", item.get("node", "")).strip()) + node = getattr(ctx, "node", socket.gethostname().split(".")[0]) + return (item.get("enabled", "1").strip() != "0" and + item.get("disable", "0").strip() != "1" and + (nodes == [""] or node in nodes)) + + +def _job_guests(job, guests, pools): + selected = set(guests) if job.get("all", "0").strip() == "1" else { + int(x) for x in re.findall(r"\d+", job.get("vmid", ""))} + for pool in re.split(r"[,\s]+", job.get("pool", "").strip()): + selected |= pools.get(pool, set()) + return (selected - {int(x) for x in re.findall(r"\d+", job.get("exclude", ""))}) & set(guests) + + +def _backup_exclusions(ctx): + excluded = [] + for kind, configs in (("lxc", ctx.lxc_configs), ("qemu", ctx.qemu_configs)): + for vmid, text in _guest_configs(configs).items(): + for line in text.splitlines(): + m = re.match(r"^(rootfs|mp\d+|(?:scsi|sata|ide|virtio)\d+):\s*(.*)", line) + if not m: + continue + disk, value = m.groups() + if "media=cdrom" in value: + continue + source = value.split(",", 1)[0] + if (re.search(r"(?:^|,)backup=0(?:,|$)", value) or + (kind == "lxc" and disk.startswith("mp") and + (source.startswith("/") or not re.search(r"(?:^|,)backup=1(?:,|$)", value)))): + excluded.append({"vmid": vmid, "type": kind, "volume": disk, + "source": source, "reason": "data excluded from guest backup"}) + return excluded # --------------------------------------------------------------------------- @@ -38,14 +100,38 @@ def _parse_vzdump_jobs(text: str) -> list[dict]: """ jobs: list[dict] = [] current: dict | None = None - for line in text.splitlines(): + for line_number, line in enumerate(text.splitlines()): if not line.strip(): continue + if line.lstrip().startswith("#"): + continue + if not line[:1].isspace() and re.search(r"(?:^|\s)(?:/\S*/)?vzdump\s", line): + parts = shlex.split(line) + pos = next(i for i, p in enumerate(parts) if p.rsplit("/", 1)[-1] == "vzdump") + job = {"id": f"legacy-{line_number}", "schedule": "cron: " + " ".join(parts[:5])} + args = parts[pos + 1:] + index = 0 + while index < len(args): + value = args[index] + if value.isdigit(): + job["vmid"] = (job.get("vmid", "") + " " + value).strip() + elif value.startswith("--"): + key, sep, val = value[2:].partition("=") + if not sep and index + 1 < len(args) and not args[index + 1].startswith("--"): + index += 1 + val = args[index] + job[key] = val or "1" + index += 1 + jobs.append(job) + current = None + continue header = re.match(r"^vzdump:\s*(\S+)", line) if header: current = {"id": header.group(1)} jobs.append(current) continue + if re.match(r"^\S+:\s", line): + current = None if current is None or not line[:1].isspace(): continue parts = line.strip().split(None, 1) @@ -72,71 +158,126 @@ def _pool_members(text: str) -> dict[str, set[int]]: @register("backup.guest_coverage", AREA_BACKUP, "CRITICAL") def _guest_coverage(ctx): - """Guests that no enabled backup job includes. + """Which guests an enabled backup job selects, and which none does. A job selects guests by enumerating them (``vmid``), by taking every guest (``all 1``), or by pool, and may subtract an ``exclude`` list. A job carrying ``enabled 0`` selects nothing: it is defined but never runs, which is precisely the situation this check exists to surface, since a disabled job looks like coverage in the interface. + + What it does not know is whether an unselected guest was meant to be + protected. A machine built for an afternoon and a production database + look identical from here, so an absence is reported as an observation + until somebody declares the expectation. Where the declaration says a + guest must be protected and no job selects it, that is a warning: an + expected protection is missing. Where it says the guest is exempt, it + leaves the count entirely rather than appearing as something to + justify. """ guests = {} - for vmid in ctx.lxc_configs: + for vmid in _guest_configs(ctx.lxc_configs): guests[vmid] = "lxc" - for vmid in ctx.qemu_configs: + for vmid in _guest_configs(ctx.qemu_configs): guests[vmid] = "qemu" if not guests: return None + policy = ctx.policy jobs = _parse_vzdump_jobs(ctx.vzdump_jobs) - if not jobs: - return { - "state": FAIL, - "summary_key": "noJobs", - "affected": [{"vmid": v, "type": t} for v, t in sorted(guests.items())], - "evidence": "no job definitions found in /etc/pve/jobs.cfg " - "or /etc/vzdump.cron", - } - pools = _pool_members(ctx.pve_user_cfg) covered: set[int] = set() considered: list[str] = [] skipped: list[str] = [] for job in jobs: - if job.get("enabled", "1").strip() == "0": - skipped.append(f"{job['id']} (disabled)") + if not _local_enabled(job, ctx): + skipped.append(f"{job['id']} (disabled or assigned to another node)") continue - excluded = {int(x) for x in re.findall(r"\d+", job.get("exclude", ""))} - selected: set[int] = set() - if job.get("all", "0").strip() == "1": - selected = set(guests) - else: - selected |= {int(x) for x in re.findall(r"\d+", job.get("vmid", ""))} - for pool in re.split(r"[,\s]+", job.get("pool", "").strip()): - if pool: - selected |= pools.get(pool, set()) - covered |= selected - excluded - considered.append(f"{job['id']} -> {sorted(selected - excluded) or 'nothing'}") + selected = _job_guests(job, guests, pools) + covered |= selected + considered.append(f"{job['id']} -> {sorted(selected) or 'nothing'}; " + f"storage={job.get('storage', 'node default')}; " + f"schedule={job.get('schedule', 'unknown')}") evidence = "enabled jobs:\n " + ("\n ".join(considered) or "(none)") if skipped: evidence += "\nignored jobs:\n " + "\n ".join(skipped) + evidence += ("\nTemplates excluded. Configured coverage does not prove a " + "stored or restorable backup.") - uncovered = sorted(set(guests) - covered) - if not uncovered: + uncovered, exempt = [], [] + for vmid in sorted(set(guests) - covered): + expectation = policy.backup_required(vmid) + if expectation == audit_policy.NOT_REQUIRED: + exempt.append({"vmid": vmid, "type": guests[vmid], + "note": policy.guest_note(vmid), + "classification": CLASS_NOT_APPLICABLE, + "decision": audit_store.DECISION_BY_DESIGN, + "reason_key": "exemptByPolicy"}) + continue + uncovered.append({ + "vmid": vmid, "type": guests[vmid], + "classification": (CLASS_WARNING + if expectation == audit_policy.REQUIRED + else CLASS_OBSERVATION), + "reason_key": ("expectedButUncovered" + if expectation == audit_policy.REQUIRED + else "noJobSelectsGuest"), + }) + + if exempt: + evidence += ("\nDeclared as not requiring a backup: " + + ", ".join(str(g["vmid"]) for g in exempt)) + + # Data a job deliberately leaves out is reported separately from a + # guest nothing protects: one is a decision recorded in the guest's + # own configuration, the other is an absence of any decision. + exclusions = _backup_exclusions(ctx) + if exclusions: + evidence += "\nExcluded data:\n" + json.dumps(exclusions, indent=2) + for row in exclusions: + row.setdefault("classification", CLASS_OBSERVATION) + row.setdefault("reason_key", "dataExcludedFromBackup") + + if not jobs: + # No job at all is different from a guest that no job selects: + # nothing on this node is scheduled to be protected. + required = [g for g in uncovered + if g["classification"] == CLASS_WARNING] return { - "state": PASS, - "summary_key": "covered", + "classification": CLASS_WARNING if required else CLASS_OBSERVATION, + "summary_key": "noJobs", "summary_params": {"total": len(guests)}, + "affected": uncovered, + "evidence": evidence + "\nNo job definitions found in " + "/etc/pve/jobs.cfg or /etc/vzdump.cron.", + } + + affected = uncovered + exclusions + exempt + if not uncovered and not exclusions: + return { + "classification": CLASS_CONFORMANT, + "summary_key": "covered", + "summary_params": {"total": len(guests), "exempt": len(exempt)}, "evidence": evidence, } + if not uncovered: + return {"classification": CLASS_OBSERVATION, "summary_key": "excludedData", + "affected": exclusions + exempt, + "summary_params": {"count": len(exclusions)}, + "evidence": evidence} + + required = [g for g in uncovered if g["classification"] == CLASS_WARNING] return { - "state": FAIL, - "summary_key": "uncovered", - "summary_params": {"count": len(uncovered), "total": len(guests)}, - "affected": [{"vmid": v, "type": guests[v]} for v in uncovered], - "evidence": evidence + f"\n\nuncovered: {uncovered}", + # The finding takes the gravity of its gravest guest; with nothing + # declared, that is an observation. + "summary_key": "uncoveredExpected" if required else "uncovered", + "summary_params": {"count": len(uncovered), "total": len(guests), + "required": len(required), "exempt": len(exempt)}, + "affected": affected, + "evidence": evidence + "\nuncovered: " + + ", ".join(str(g["vmid"]) for g in uncovered), } @@ -146,7 +287,17 @@ def _guest_coverage(ctx): @register("system.pending_reboot", AREA_SYSTEM, "WARNING") def _pending_reboot(ctx): - """Kernel or packages installed but not yet in effect.""" + """What is installed but not yet running. + + Two things say the same thing in different ways: the marker Debian + writes when a package needs a restart, and a kernel that is installed + and selected but not the one running. They are read together because + they are one question — is there work waiting for a reboot — and + reporting them separately counts the same maintenance twice. + + The absence of the marker does not prove nothing needs restarting, + only that nothing asked for it, so a clean result says exactly that. + """ marker = Path("/var/run/reboot-required") packages = "" pkg_file = Path("/var/run/reboot-required.pkgs") @@ -158,40 +309,84 @@ def _pending_reboot(ctx): rc, running = ctx.run(["uname", "-r"]) running = running.strip() + # A newer kernel installed and not running is the running-kernel + # check's subject: it reads the boot selection as well and can say + # whether the host would even start it. Counting it here too put the + # same fact in two findings and in two counters. + newer = None - if not marker.exists(): - return { - "state": PASS, - "summary_key": "none", - "evidence": f"running kernel: {running}", - } - return { - "state": WARN, - "summary_key": "pending", - "affected": [{"package": p} for p in packages.splitlines() if p], - "evidence": f"running kernel: {running}\n" - f"packages requesting a restart:\n{packages or '(not reported)'}", - } + pending = [] + for name in packages.splitlines(): + if name.strip(): + pending.append({"package": name.strip(), + "classification": CLASS_OBSERVATION, + "reason_key": "packageAwaitingRestart"}) + if newer: + pending.append({"kernel": newer, "running": running, + "classification": CLASS_OBSERVATION, + "reason_key": "kernelAwaitingReboot"}) + + installed = _newer_kernel_installed(ctx, running) + evidence = f"running kernel: {running}\n" + evidence += (f"reboot marker: {'present' if marker.exists() else 'absent'}\n") + evidence += f"packages requesting a restart:\n{packages or '(none reported)'}" + if installed: + evidence += (f"\nnewest installed kernel: {installed}, reported by the " + "running-kernel check, which reads the boot selection too") + evidence += ("\nA kernel may be held deliberately, and the absence of the " + "marker does not prove that nothing needs restarting.") + + if not marker.exists() and not newer: + return {"classification": CLASS_CONFORMANT, "summary_key": "none", + "evidence": evidence} + if marker.exists() and not pending: + # The marker is the evidence. Something wrote it and did not say + # what; reporting that as unverified described the reading rather + # than the host, which had plainly asked for a restart. + pending.append({"name": "reboot-required", + "classification": CLASS_OBSERVATION, + "reason_key": "rebootMarkerWithoutPackages"}) + return {"summary_key": "pending", "summary_params": {"count": len(pending)}, + "affected": pending, "evidence": evidence} + + +def _newer_kernel_installed(ctx, running: str): + """The newest installed kernel, when it is newer than the running one.""" + rc, out = ctx.run(["dpkg-query", "-W", "-f=${db:Status-Status} ${Package}\n"]) + installed = set() + for line in (out or "").splitlines(): + m = re.search(r"^installed (?:proxmox|pve)-kernel-(\d[\w.\-]*?)(?:-signed)?$", + line.strip()) + if m: + installed.add(m.group(1)) + if not installed or not running: + return None + newest = max(installed, key=_version_key) + return newest if _version_key(newest) > _version_key(running) else None @register("system.enterprise_repo_without_subscription", AREA_SYSTEM, "WARNING") def _enterprise_repo(ctx): - """Enterprise repository enabled on a host without a subscription. - - The combination leaves ``apt update`` failing on every run, which - tends to be misread as a broken host rather than a licensing state. - """ + """Describe repository/subscription configuration, not host conformance.""" enabled = [] for path, text in ctx.apt_sources.items(): + if path.endswith(".sources"): + for stanza in re.split(r"\n\s*\n", text): + if re.search(r"^Enabled:\s*(?:no|false|0)\s*$", stanza, re.M | re.I): + continue + for line in stanza.splitlines(): + if re.match(r"^URIs:", line, re.I) and "enterprise.proxmox.com" in line: + enabled.append((path, line.strip())) + continue for line in text.splitlines(): stripped = line.strip() if stripped.startswith("#") or not stripped: continue - if "enterprise.proxmox.com" in stripped: + if re.match(r"^deb(?:-src)?\s", stripped) and "enterprise.proxmox.com" in stripped: enabled.append((path, stripped)) if not enabled: return { - "state": PASS, + "classification": CLASS_OBSERVATION, "summary_key": "notEnabled", } @@ -205,14 +400,20 @@ def _enterprise_repo(ctx): evidence = "\n".join(f"{p}: {l}" for p, l in enabled) evidence += f"\n\npvesubscription status: {status or '(unavailable)'}" + if rc != 0 or status not in ("active", "new", "notfound", "invalid", "expired", "suspended"): + return { + "classification": CLASS_UNVERIFIED, + "summary_key": "evaluationFailed", + "evidence": evidence, + } if status in ("active", "new"): return { - "state": PASS, + "classification": CLASS_OBSERVATION, "summary_key": "subscribed", "evidence": evidence, } return { - "state": WARN, + "classification": CLASS_WARNING, "summary_key": "unsubscribed", "affected": [{"file": p, "line": l} for p, l in enabled], "evidence": evidence, @@ -232,7 +433,7 @@ def _privileged_containers(ctx): containers unprivileged by default; a container is privileged when ``unprivileged: 1`` is absent from its configuration. """ - configs = ctx.lxc_configs + configs = _guest_configs(ctx.lxc_configs) if not configs: return None @@ -247,7 +448,7 @@ def _privileged_containers(ctx): if not privileged: return { - "state": PASS, + "classification": CLASS_CONFORMANT, "summary_key": "allUnprivileged", "summary_params": {"total": len(configs)}, } @@ -255,8 +456,12 @@ def _privileged_containers(ctx): f"{c['vmid']}{' (' + c['name'] + ')' if c['name'] else ''}" for c in privileged ) + # Some workloads need the privilege. Reporting it is useful; calling + # it a fault would be telling the reader to undo a deliberate choice. + for row in privileged: + row["classification"] = CLASS_OBSERVATION + row["reason_key"] = "runsPrivileged" return { - "state": WARN, "summary_key": "privileged", "summary_params": {"count": len(privileged), "total": len(configs)}, "affected": privileged, @@ -271,13 +476,14 @@ def _qemu_without_agent(ctx): Without it the host cannot request a clean shutdown, quiesce the filesystem for a snapshot, or report real disk usage. """ - configs = ctx.qemu_configs + configs = _guest_configs(ctx.qemu_configs) if not configs: return None missing = [] for vmid, text in sorted(configs.items()): - if not re.search(r"^agent:\s*(1|enabled=1)", text, re.M): + agent = re.search(r"^agent:\s*(.*)$", text, re.M) + if not agent or not re.search(r"(?:^|,)(?:enabled=)?1(?:,|$)", agent.group(1)): name = "" m = re.search(r"^name:\s*(\S+)", text, re.M) if m: @@ -286,7 +492,7 @@ def _qemu_without_agent(ctx): if not missing: return { - "state": PASS, + "classification": CLASS_CONFORMANT, "summary_key": "allHaveAgent", "summary_params": {"total": len(configs)}, } @@ -294,8 +500,12 @@ def _qemu_without_agent(ctx): f"{v['vmid']}{' (' + v['name'] + ')' if v['name'] else ''}" for v in missing ) + # The configuration says the agent is declared, never that it + # answers. Either way its absence describes how the guest is set up. + for row in missing: + row["classification"] = CLASS_OBSERVATION + row["reason_key"] = "agentNotDeclared" return { - "state": WARN, "summary_key": "missingAgent", "summary_params": {"count": len(missing), "total": len(configs)}, "affected": missing, @@ -309,50 +519,60 @@ def _qemu_without_agent(ctx): @register("security.host_firewall_enabled", AREA_SECURITY, "WARNING") def _host_firewall(ctx): - """Proxmox firewall enabled at datacenter and node level. - - Both levels matter: the node rules are not applied while the - datacenter switch is off, so a node that looks configured can still - be filtering nothing. - """ - def enabled_in(path: Path) -> tuple[bool, str]: - try: - text = path.read_text(errors="replace") - except OSError: - return False, f"{path}: not present" + """Effective enable options; this does not prove every traffic path is filtered.""" + def option(path, default): + text = ctx.read(path, optional=True) + section = "" + value = default for line in text.splitlines(): - if re.match(r"^\s*enable:\s*1\s*$", line): - return True, f"{path}: enable: 1" - return False, f"{path}: enable not set to 1" + if line.strip().startswith("["): + section = line.strip().upper() + m = re.match(r"^\s*enable:\s*([01])\s*$", line) + if section == "[OPTIONS]" and m: + value = m.group(1) == "1" + return value + def has_rules(path): + # A `[RULES]` heading with something under it. Proxmox ships the + # node switch on and the datacenter switch off, so the switch + # alone says nothing about whether anyone wrote a rule. + text = ctx.read(path, optional=True) + section, rules = "", 0 + for line in text.splitlines(): + stripped = line.strip() + if stripped.startswith("["): + section = stripped.upper() + elif section == "[RULES]" and re.match( + r"^\|?\s*(IN|OUT|GROUP)\b", stripped, re.I): + # A rule declares a direction. Anything else under the + # heading is a stray key, not something being filtered. + rules += 1 + return rules - dc_on, dc_note = enabled_in(Path("/etc/pve/firewall/cluster.fw")) - try: - node = Path("/etc/hostname").read_text().strip() - except OSError: - node = "" - node_path = Path(f"/etc/pve/nodes/{node}/host.fw") if node else None - node_on, node_note = (False, "node firewall file not resolved") - if node_path: - node_on, node_note = enabled_in(node_path) - - evidence = f"{dc_note}\n{node_note}" - if dc_on and node_on: - return { - "state": PASS, - "summary_key": "bothEnabled", - "evidence": evidence, - } - if not dc_on: - return { - "state": WARN, - "summary_key": "datacenterOff", - "evidence": evidence, - } - return { - "state": WARN, - "summary_key": "nodeOff", - "evidence": evidence, - } + dc_on = option("/etc/pve/firewall/cluster.fw", False) + node_on = option(f"/etc/pve/nodes/{ctx.node}/host.fw", True) + written = (has_rules("/etc/pve/firewall/cluster.fw") + + has_rules(f"/etc/pve/nodes/{ctx.node}/host.fw")) + evidence = (f"datacenter enable: {dc_on} (default false)\n" + f"host enable: {node_on} (default true)\n" + f"rules written: {written}\n" + "Configuration assessment only; runtime filtering and guest rules are not validated.") + # These options say the firewall is switched on, not that any rule + # filters anything, and a firewall elsewhere in the path is a valid + # design. What is enabled is reported; what it achieves is not + # something this can demonstrate. + # A node with its own rules while the datacenter switch is off is + # not a host without a firewall: it is a host whose rules the + # administrator believes are applied and are not. That gap is a + # warning; a firewall simply not turned on is a stated fact unless + # the site declared it should be on. + declared = ctx.policy.host_expectation("firewall") + overridden = bool(written) and node_on and not dc_on + gravity = (CLASS_CONFORMANT if dc_on and node_on + else CLASS_WARNING if overridden or declared == audit_policy.REQUIRED + else CLASS_OBSERVATION) + return {"classification": gravity, + "summary_key": "bothEnabled" if dc_on and node_on else "datacenterOff" if not dc_on else "nodeOff", + "evidence": evidence} # --------------------------------------------------------------------------- @@ -361,70 +581,3181 @@ def _host_firewall(ctx): @register("storage.orphaned_volumes", AREA_STORAGE, "WARNING") def _orphaned_volumes(ctx): - """Disk images that no guest configuration references. - - A volume survives when a guest is removed without its disks, or when - a restore leaves the previous copy behind. Nothing reports it and it - keeps occupying the pool. - - Only storage that is not shared is examined. On shared storage a - volume may belong to a guest running on another node, which this node - cannot see, so flagging it would be wrong rather than merely noisy. - """ - known = set(ctx.lxc_configs) | set(ctx.qemu_configs) - if not known: - return None - - candidates = [ - s for s in ctx.storages - if str(s.get("shared", "0")).strip() != "1" - and any(c in (s.get("content") or "") for c in ("images", "rootdir")) - ] + """Unreferenced candidates only: never a deletion recommendation.""" + # Include snapshots, pending configuration and unusedN entries. A disk + # disconnected from the current boot configuration is still owned. + configs = list(ctx.lxc_configs.values()) + list(ctx.qemu_configs.values()) + if hasattr(ctx, "cluster_configs"): + configs += list(ctx.cluster_configs.values()) + references = set() + for text in configs: + for line in text.splitlines(): + if re.match(r"^(?:rootfs|mp\d+|unused\d+|(?:scsi|sata|ide|virtio)\d+|efidisk\d+|tpmstate\d+|vmstate):", line): + value = line.split(":", 1)[1].strip() + references.add(value.split(",", 1)[0]) + shared_types = {"rbd", "cephfs", "nfs", "cifs", "glusterfs", "iscsi", "iscsidirect"} + candidates = [s for s in ctx.storages if _local_enabled(s, ctx) + and s.get("shared", "0") != "1" and s["type"] not in shared_types + and set((s.get("content") or "").split(",")) & {"images", "rootdir"}] if not candidates: return None - - orphans: list[dict] = [] - inspected: list[str] = [] + orphans, inspected, failed = [], [], [] for storage in candidates: sid = storage["id"] rc, out = ctx.run(["pvesm", "list", sid], timeout=15) if rc != 0: - inspected.append(f"{sid}: not readable") + failed.append(sid) continue - count = 0 - for line in (out or "").splitlines()[1:]: + if not re.search(r"^Volid\s", out.strip()): + failed.append(sid + " (unrecognised inventory)") + continue + count, ignored = 0, 0 + for line in out.splitlines()[1:]: fields = line.split() - if len(fields) < 5: + # pvesm reports Volid, Format, Type, Size and, for guest + # volumes, VMID. A mixed storage also lists backups, ISOs and + # templates, which are content this check is not about: they + # are skipped, not treated as rows it failed to read. + if len(fields) < 4: + failed.append(sid + " (unrecognised row)") continue - volid, vmid_raw = fields[0], fields[-1] - if not vmid_raw.isdigit(): + content = fields[2] + if content not in ("images", "rootdir"): + ignored += 1 continue + if len(fields) < 5 or not fields[-1].isdigit(): + failed.append(sid + " (guest volume without a VMID)") + continue + volume = fields[0] count += 1 - vmid = int(vmid_raw) - if vmid not in known: - orphans.append({"volume": volid, "vmid": vmid}) - inspected.append(f"{sid}: {count} volume(s)") + # Template bases may be referenced indirectly by linked clones. + if "/base-" in volume or ":base-" in volume: + continue + if volume not in references: + orphans.append({"volume": volume, "vmid": int(fields[-1]), + "storage": sid, + "classification": CLASS_OBSERVATION, + "reason_key": "noConfigurationReference"}) + inspected.append(f"{sid}: {count} guest volume(s)" + + (f", {ignored} other content skipped" if ignored else "")) + evidence = "\n".join(inspected) + "\nShared/remote storage and template bases excluded. No deletion is proposed." + if failed: + evidence += "\nNot verified: " + ", ".join(failed) + # An unreferenced volume is a candidate for review, never a + # recommendation to delete: the reference may live somewhere this + # check cannot see, and the data may still matter. + if orphans: + return {"summary_key": "found", "summary_params": {"count": len(orphans)}, + "affected": orphans, "incomplete": bool(failed), "evidence": evidence} + return {"classification": CLASS_UNVERIFIED if failed else CLASS_CONFORMANT, + "summary_key": "evaluationFailed" if failed else "none", + "summary_params": {"count": 0}, "incomplete": bool(failed), + "evidence": evidence} - shared_skipped = [ - s["id"] for s in ctx.storages - if str(s.get("shared", "0")).strip() == "1" - ] - evidence = "inspected:\n " + "\n ".join(inspected) - if shared_skipped: - evidence += ("\nskipped as shared (ownership not resolvable from this " - "node):\n " + ", ".join(shared_skipped)) - if not orphans: +# --------------------------------------------------------------------------- +# Lynis +# --------------------------------------------------------------------------- + +# A Lynis report describes the system as it was when the audit ran. Past +# this many days it is treated as no longer representative. + + +def _lynis_entry(raw: str) -> dict: + """Split a Lynis record into its test identifier and message. + + Records are pipe-separated and begin with the test id, which is kept + so a finding can be traced back to the Lynis test that produced it. + """ + if isinstance(raw, dict): + return {"test": raw.get("test_id", ""), "message": raw.get("description", ""), + "details": raw.get("details", "")} + parts = raw.split("|") + return { + "test": parts[0].strip() if parts else "", + "message": parts[1].strip() if len(parts) > 1 else "", + } + + +@register("security.lynis_warnings", AREA_SECURITY, "WARNING", + budget=LYNIS_RUN_BUDGET) +def _lynis_warnings(ctx): + """Warnings recorded by the most recent Lynis audit. + + Suggestions are not reported here. Lynis emits them by the dozen and + they describe optional hardening rather than a defect, so folding them + in would bury the warnings among them. + """ + report = ctx.lynis_report + if report is None: + return None + if not report["complete"]: + # Say which of the two happened: an audit this assessment ran and + # that did not finish, or a stored report left behind by one. + detail = (report.get("run_error") + or ("this assessment ran an audit and it wrote no hardening " + "index, which a finished audit always writes" + if report.get("produced_here") + else "the stored report has no hardening index, which a " + "finished audit always writes")) return { - "state": PASS, + "classification": CLASS_UNVERIFIED, + "summary_key": "incomplete", + "incomplete": True, + "evidence": f"Lynis report unusable: {detail}.", + } + + warnings = [_lynis_entry(w) for w in report["warnings"]] + index = report["hardening_index"] + + # How old the report is describes the report, not the host — which + # is why it is a row here rather than a check of its own. It belongs + # beside the warnings it qualifies: reading "no warnings" without + # knowing the audit ran in June is reading the wrong thing. + stamp, age_days = report.get("mtime"), None + if (report.get("complete") and not isinstance(stamp, bool) + and isinstance(stamp, (int, float)) and math.isfinite(stamp) + and 0 < stamp <= time.time() + 300): + age_days = max(0, int((time.time() - stamp) / 86400)) + stale = (age_days is not None + and age_days >= ctx.policy.threshold("lynis_report_days")) + + evidence = (f"Lynis version: {report.get('version', 'unknown')}\n" + f"report written: " + + (time.strftime("%Y-%m-%d %H:%M", time.localtime(stamp)) + if age_days is not None else "not determined") + + (f" ({age_days} day(s) ago)\n" if age_days is not None else "\n") + + f"source collected at: {report.get('mtime')}\n" + f"lynis hardening index: {index}\n" + f"warnings: {len(warnings)}\n" + f"suggestions: {len(report['suggestions'])}") + + aged = [{"name": "lynis", "days": age_days, + "classification": CLASS_OBSERVATION, + "reason_key": "lynisReportStale"}] if stale else [] + if not warnings: + if aged: + return {"summary_key": "noneStale", + "summary_params": {"days": str(age_days)}, + "affected": aged, "evidence": evidence} + return { + "classification": CLASS_CONFORMANT, "summary_key": "none", "evidence": evidence, } + # Lynis is a source, not a verdict. Its warnings are worth reading + # and vary in how much they apply to a Proxmox host, so they are + # reported as observations rather than folded into this host's + # problem count; SSH, the firewall and certificates have checks of + # their own here and are not double-counted through Lynis. + for row in warnings: + row["classification"] = CLASS_OBSERVATION + row["reason_key"] = "lynisWarning" return { - "state": WARN, - "summary_key": "found", - "summary_params": {"count": len(orphans)}, - "affected": orphans, - "evidence": evidence + "\n\norphans:\n " + "\n ".join( - f"{o['volume']} (no config for {o['vmid']})" for o in orphans), + "summary_key": "foundStale" if stale else "found", + "summary_params": {"count": len(warnings), "days": str(age_days)}, + "affected": warnings + aged, + "evidence": evidence + "\n\n" + "\n".join( + f"{w['test']}: {w['message']}" for w in warnings), } + + +# --------------------------------------------------------------------------- +# Memory, ZFS, certificates and time +# --------------------------------------------------------------------------- + +# Allocating more memory than the host owns is a deliberate technique when +# guests do not peak together. This is the ratio past which the margin is +# reported rather than assumed. + +# Certificate lifetimes are reported before they lapse, not once they have. + + +def _host_memory_bytes(ctx) -> int: + for line in (ctx.run(["cat", "/proc/meminfo"])[1] or "").splitlines(): + if line.startswith("MemTotal:"): + parts = line.split() + if len(parts) >= 2 and parts[1].isdigit(): + return int(parts[1]) * 1024 + return 0 + + +@register("system.memory_overcommit", AREA_SYSTEM, "WARNING") +def _memory_overcommit(ctx): + """Memory assigned to guests compared with the host's physical memory. + + Each guest contributes the ``memory`` value from its configuration, + which is its ceiling. Containers consume what they need up to that + limit while virtual machines without ballooning reserve it, so the + two are reported separately. + """ + total = _host_memory_bytes(ctx) + if not total: + return None + + def assigned(configs): + out = 0 + for text in configs.values(): + m = re.search(r"^memory:\s*(\d+)", text, re.M) + if m: + out += int(m.group(1)) * 1024 * 1024 + return out + + lxc = assigned(_guest_configs(ctx.lxc_configs)) + qemu = assigned(_guest_configs(ctx.qemu_configs)) + if not (lxc or qemu): + return None + + gib = 1024 ** 3 + ratio = (lxc + qemu) / total + evidence = (f"host memory: {total / gib:.1f} GiB\n" + f"assigned to virtual machines: {qemu / gib:.1f} GiB\n" + f"assigned to containers: {lxc / gib:.1f} GiB\n" + f"ratio: {ratio * 100:.0f}%\n" + "Configured maximums include stopped guests; this is not measured RAM use.\n") + meminfo = ctx.run(["cat", "/proc/meminfo"])[1] + evidence += "\n".join(l for l in meminfo.splitlines() if l.startswith(("MemAvailable:", "SwapTotal:", "SwapFree:"))) + rc, resource_text = ctx.run(["pvesh", "get", "/cluster/resources", "--type", "vm", "--output-format", "json"]) + if rc == 0: + resources = json.loads(resource_text) + active = {int(r["vmid"]) for r in resources if r.get("node") == ctx.node and r.get("status") == "running"} + active_bytes = assigned({v: t for v, t in _guest_configs(ctx.lxc_configs).items() if v in active}) + active_bytes += assigned({v: t for v, t in _guest_configs(ctx.qemu_configs).items() if v in active}) + evidence += f"\nRunning guests configured maximum: {active_bytes / gib:.1f} GiB" + params = {"percent": round(ratio * 100)} + + # Assigning more than the host owns is a technique, not a fault: + # guests rarely peak together and containers take what they use. The + # ratio is reported as planning information; sustained pressure is a + # different measurement, and the health monitor makes it. + if ratio <= ctx.policy.threshold("memory_overcommit_ratio"): + return {"classification": CLASS_CONFORMANT, "summary_key": "withinRatio", + "summary_params": params, "evidence": evidence} + return {"classification": CLASS_OBSERVATION, "summary_key": "aboveRatio", + "summary_params": params, "evidence": evidence} + + +@register("storage.zfs_arc_max", AREA_STORAGE, "WARNING") +def _zfs_arc_max(ctx): + """Effective ARC bounds, not assumptions about a version's defaults.""" + if not Path("/sys/module/zfs").exists(): + return None + text = ctx.read("/proc/spl/kstat/zfs/arcstats") + stats = {} + for line in text.splitlines(): + fields = line.split() + if len(fields) == 3 and fields[0] in {"c_min", "c_max", "size"} and fields[-1].isdigit(): + stats[fields[0]] = int(fields[-1]) + total = _host_memory_bytes(ctx) + if not total or not all(k in stats for k in ("c_min", "c_max", "size")): + return {"classification": CLASS_UNVERIFIED, "summary_key": "evaluationFailed", "evidence": text} + settings = {} + for path in sorted(Path("/etc/modprobe.d").glob("*.conf")): + lines = [l for l in ctx.read(path).splitlines() + if re.match(r"^\s*options\s+zfs\s", l) and re.search(r"zfs_arc_(?:min|max)=", l)] + if lines: + settings[str(path)] = lines + parameter = ctx.read("/sys/module/zfs/parameters/zfs_arc_max").strip() + declared = set(re.findall( + r"zfs_arc_max=(\d+)", + "\n".join(l for rows in settings.values() for l in rows))) + share = stats["c_max"] / total + loaded = int(parameter) if parameter.isdigit() else None + + findings = [] + # Two files disagreeing about the same parameter is a configuration + # that cannot all be true. + if len(declared) > 1: + findings.append({"setting": "zfs_arc_max", "values": sorted(declared), + "classification": CLASS_WARNING, + "reason_key": "arcConflictingSettings"}) + if stats["c_min"] > stats["c_max"]: + findings.append({"setting": "arc bounds", "classification": CLASS_WARNING, + "reason_key": "arcMinAboveMax"}) + # A persistent value the running module does not carry is a change + # waiting for the next boot, which is worth stating and is not a + # fault: the reader may have just made it. + if declared and loaded is not None: + only = next(iter(declared)) if len(declared) == 1 else None + if only is not None and int(only) != loaded: + findings.append({"setting": "zfs_arc_max", + "configured": int(only), "loaded": loaded, + "classification": CLASS_OBSERVATION, + "reason_key": "arcPendingReboot"}) + + evidence = json.dumps({"arc_bytes": stats, "host_bytes": total, + "module_parameter": parameter, + "persistent_settings": settings, + "c_max_share_percent": round(share * 100, 1)}, indent=2) + evidence += ("\nA module parameter of 0 selects the default; c_max is the " + "effective limit. ARC is a ceiling, not a reservation: the " + "memory is reclaimable, so a high limit is not evidence of " + "memory pressure.") + if not findings: + return {"classification": CLASS_CONFORMANT, "summary_key": "bounded", + "summary_params": {"percent": round(share * 100)}, + "evidence": evidence} + return {"summary_key": "conflicting" if any( + f["classification"] == CLASS_WARNING for f in findings) else "pending", + "summary_params": {"percent": round(share * 100), + "count": len(findings)}, + "affected": findings, "evidence": evidence} + + +@register("security.certificate_expiry", AREA_SECURITY, "WARNING") +def _certificate_expiry(ctx): + """Remaining validity of the certificate served by pveproxy. + + The custom certificate takes precedence when present; otherwise the + one Proxmox generates is examined. + """ + for name in ("pveproxy-ssl.pem", "pve-ssl.pem"): + path = Path("/etc/pve/local") / name + if path.exists(): + break + else: + return None + + rc, out = ctx.run(["openssl", "x509", "-enddate", "-noout", "-in", str(path)]) + if rc != 0 or "notAfter=" not in (out or ""): + return None + raw = out.split("notAfter=", 1)[1].strip().splitlines()[0] + + rc2, epoch_out = ctx.run(["date", "-d", raw, "+%s"]) + if rc2 != 0 or not epoch_out.strip().lstrip("-").isdigit(): + return None + remaining = int(epoch_out.strip()) - time.time() + days = math.floor(remaining / 86400) + + evidence = f"certificate: {path}\nexpires: {raw}\nremaining: {days} day(s)" + params = {"days": days} + if remaining <= 0: + return {"classification": CLASS_WARNING, "summary_key": "expired", + "summary_params": {"days": abs(days)}, "evidence": evidence} + if days < ctx.policy.threshold("certificate_expiry_days"): + return {"classification": CLASS_OBSERVATION, "summary_key": "expiring", + "summary_params": params, "evidence": evidence} + return {"classification": CLASS_CONFORMANT, "summary_key": "valid", + "summary_params": params, "evidence": evidence} + + +@register("system.time_synchronisation", AREA_SYSTEM, "WARNING") +def _time_sync(ctx): + """Whether the host clock is disciplined by a time source. + + Proxmox relies on agreeing clocks for cluster membership, certificate + validation and the ordering of log entries. + """ + rc, out = ctx.run(["timedatectl", "show", + "-p", "NTP", "-p", "NTPSynchronized"]) + if rc != 0: + return _unverified(out) + values = {} + for line in (out or "").splitlines(): + if "=" in line: + k, v = line.split("=", 1) + values[k.strip()] = v.strip() + + if any(values.get(k) not in ("yes", "no") for k in ("NTP", "NTPSynchronized")): + return _unverified(out or "NTP state missing") + + enabled = values.get("NTP") == "yes" + synced = values.get("NTPSynchronized") == "yes" + clustered = Path("/etc/corosync/corosync.conf").exists() + evidence = (f"NTP: {values.get('NTP', 'unknown')}\n" + f"NTPSynchronized: {values.get('NTPSynchronized', 'unknown')}\n" + f"node belongs to a cluster: {'yes' if clustered else 'no'}") + + if enabled and synced: + return {"classification": CLASS_CONFORMANT, "summary_key": "synchronised", + "evidence": evidence} + if not enabled: + # Another mechanism may be disciplining the clock, so this + # states what timedatectl reports rather than concluding the + # clock is wrong. + return {"classification": CLASS_OBSERVATION, "summary_key": "disabled", + "evidence": evidence} + # Synchronisation enabled and not achieved is a drift that will keep + # growing, and cluster membership and backup timestamps depend on it. + return {"classification": CLASS_WARNING, "summary_key": "notSynchronised", + "evidence": evidence} + + +@register("guests.autostart", AREA_GUESTS, "INFO") +def _autostart(ctx): + """Guests that do not start with the host. + + A guest without ``onboot: 1`` stays down after a host restart until + someone starts it. + """ + guests = {} + for vmid, text in _guest_configs(ctx.lxc_configs).items(): + guests[vmid] = ("lxc", text) + for vmid, text in _guest_configs(ctx.qemu_configs).items(): + guests[vmid] = ("qemu", text) + if not guests: + return None + + ha = ctx.read("/etc/pve/ha/resources.cfg", optional=True) + ha_guests = {int(v) for v in re.findall(r"^(?:vm|ct):\s*(\d+)", ha, re.M)} + missing = [] + for vmid, (kind, text) in sorted(guests.items()): + if vmid in ha_guests: + continue + if not re.search(r"^onboot:\s*1\s*$", text, re.M): + name = "" + m = re.search(r"^(?:hostname|name):\s*(\S+)", text, re.M) + if m: + name = m.group(1) + missing.append({"vmid": vmid, "name": name, "type": kind}) + + # A machine that is meant to come back by itself and does not is a + # missing protection; one nobody said that about is a configuration. + policy = ctx.policy + for row in missing: + expected = policy.autostart_required(row["vmid"]) + row["classification"] = (CLASS_WARNING if expected == audit_policy.REQUIRED + else CLASS_OBSERVATION) + row["reason_key"] = ("expectedToAutostart" if expected == audit_policy.REQUIRED + else "noAutostart") + missing = [row for row in missing + if policy.autostart_required(row["vmid"]) != audit_policy.NOT_REQUIRED] + if not missing: + return {"classification": CLASS_CONFORMANT, "summary_key": "allAutostart", + "summary_params": {"total": len(guests)}} + return { + "summary_key": "notAutostart", + "summary_params": {"count": len(missing), "total": len(guests)}, + "affected": missing, + "evidence": "without onboot: " + ", ".join( + f"{g['vmid']}{' (' + g['name'] + ')' if g['name'] else ''}" + for g in missing), + } + + +# --------------------------------------------------------------------------- +# Kernel and disk service life +# --------------------------------------------------------------------------- + +# Typical service life used to separate disks that are within their +# expected working period from those that have outlived it. + + +def _version_key(value: str) -> list: + """Sort key for a kernel version, comparing numeric parts as numbers.""" + return [int(p) if p.isdigit() else p + for p in re.split(r"[.\-]", value) if p] + + +@register("system.kernel_current", AREA_SYSTEM, "WARNING") +def _kernel_current(ctx): + """Which kernel is running, and which one the host would boot. + + Whether a newer kernel is waiting is reported by the pending-restart + check, which reads that together with the packages asking for one. + What this adds is the comparison the other cannot make: the kernel + running now against the kernel the host has selected for its next + boot. Those differing after a reboot is a boot that did not take, and + that is worth knowing; a newer kernel merely installed is not, since + it may be held on purpose. + """ + rc, running = ctx.run(["uname", "-r"]) + running = running.strip() + if rc != 0 or not running: + return None + + # What the boot loader would start next. Where it cannot be read the + # check says so rather than falling back to the package list, which + # answers a different question. + rc2, out = ctx.run(["proxmox-boot-tool", "kernel", "list"], + timeout=15, allowed_codes=(0, 1, 127)) + if rc2 != 0: + # A host that does not use proxmox-boot-tool selects its kernel + # elsewhere; there is nothing here to compare. + return None + + # Retained kernel lists are not boot selection. Only a declared pin + # identifies the intended kernel; a next-boot pin takes precedence. + manual, automatic, pinned, next_boot, section = [], [], [], [], "" + for line in (out or "").splitlines(): + lowered = line.strip().lower() + if lowered.startswith("manually selected"): + section = "manual" + continue + if lowered.startswith("automatically selected"): + section = "automatic" + continue + if lowered.startswith("pinned kernel:"): + section = "pinned" + continue + if lowered.startswith("kernel pinned on next-boot:"): + section = "next_boot" + continue + m = re.match(r"^\s*(\d[\w.\-]+)\s*$", line) + if m: + {"manual": manual, "automatic": automatic, "pinned": pinned, + "next_boot": next_boot}.get(section, []).append(m.group(1)) + candidates = next_boot or pinned + origin = "pinned for next boot" if next_boot else "pinned" if pinned else "" + selected = candidates[0] if len(candidates) == 1 else None + if selected is None: + # No pin is not an unknown: proxmox-boot-tool boots the newest of + # the kernels it keeps. Reading that is the whole point of the + # check, and calling it undetermined left every unpinned host — + # which is most of them — with a finding nobody could act on. + retained = manual + automatic + if retained: + selected = max(retained, key=_version_key) + origin = "newest kernel retained, no pin declared" + + evidence = (f"running: {running}\nnext boot: {selected or 'not determined'}" + f"{f' ({origin})' if origin else ''}\n" + f"manually selected: {', '.join(manual) or 'none'}\n" + f"automatically selected: {', '.join(automatic) or 'none'}\n" + "Next boot is what the boot tool reports it would start; " + "the boot loader's installation on each disk is not verified.") + if selected is None: + return {"classification": CLASS_UNVERIFIED, "summary_key": "bootTargetUnknown", + "summary_params": {"version": running}, "evidence": evidence} + if _version_key(selected) == _version_key(running): + return {"classification": CLASS_CONFORMANT, "summary_key": "current", + "summary_params": {"version": running}, "evidence": evidence} + if _version_key(selected) < _version_key(running): + # Running something newer than the host would boot means a fall + # back to an older kernel is queued for the next restart. + return {"classification": CLASS_WARNING, "summary_key": "wouldDowngrade", + "summary_params": {"running": running, "selected": selected}, + "evidence": evidence} + return {"classification": CLASS_OBSERVATION, "summary_key": "newerSelected", + "summary_params": {"running": running, "selected": selected}, + "evidence": evidence} + + +@register("hardware.disk_service_life", AREA_HARDWARE, "INFO") +def _disk_service_life(ctx): + """Planning information from the Monitor's SMART readings.""" + cached = ctx.monitor_snapshot.get("smart", {}) + service_life_hours = ctx.policy.threshold("disk_service_life_hours") + aged, readings, skipped = [], [], [] + for dev, entry in sorted(cached.items()): + collected_at, data = entry if isinstance(entry, (list, tuple)) else (None, entry) + hours = data.get("power_on_hours") + if data.get("smart_status") == "unknown" or not isinstance(hours, (int, float)) \ + or hours <= 0: + skipped.append(dev) + continue + row = {"device": dev, "hours": hours, "collected_at": collected_at, + "percentage_used": data.get("percentage_used"), + "ssd_life_left": data.get("ssd_life_left"), + "health": data.get("health")} + readings.append(row) + if hours >= service_life_hours: + aged.append({"device": dev, "hours": hours}) + + evidence = json.dumps(readings, indent=2) + \ + "\nAge is planning information, not a failure or replacement criterion." + if skipped: + evidence += "\nDisks reporting no usable SMART data: " + ", ".join(skipped) + + # A disk that exposes no SMART counters — a USB enclosure, a device + # behind a RAID controller — is outside what this check can read, not + # a gap in the evaluation of the disks that did answer. + if not readings: + return {"classification": CLASS_UNVERIFIED, "summary_key": "noReadings", + "summary_params": {"skipped": len(skipped)}, + "incomplete": True, "evidence": evidence} + + # Age is planning information. A disk does not become defective at a + # birthday; media errors and device warnings are what demonstrate a + # defect, and the health monitor reports those. + for row in aged: + row["classification"] = CLASS_OBSERVATION + row["reason_key"] = "pastServiceLife" + return {"classification": CLASS_OBSERVATION if aged else CLASS_CONFORMANT, + "summary_key": "pastLife" if aged else "withinLife", + "summary_params": {"count": len(aged), "total": len(readings), + "skipped": len(skipped)}, + "affected": aged, "observations": readings, + "evidence": evidence} + + +# --------------------------------------------------------------------------- +# Backup results +# --------------------------------------------------------------------------- + +def _backup_storages(ctx) -> list[dict]: + return [s for s in ctx.storages if "backup" in (s.get("content") or "").split(",") + and _local_enabled(s, ctx)] + + +def _volid_timestamp(volid: str): + """Epoch of a backup volume, read from its identifier. + + Proxmox Backup Server snapshots end in an ISO instant; vzdump archives + carry the date in the file name. + """ + m = re.search(r"(\d{4}-\d{2}-\d{2})T(\d{2}):(\d{2}):(\d{2})Z", volid) + if m: + try: + return calendar.timegm(time.strptime( + f"{m.group(1)}T{m.group(2)}:{m.group(3)}:{m.group(4)}Z", + "%Y-%m-%dT%H:%M:%SZ")) + except ValueError: + return None + m = re.search(r"(\d{4})_(\d{2})_(\d{2})-(\d{2})_(\d{2})_(\d{2})", volid) + if m: + try: + return time.mktime(time.strptime("-".join(m.groups()), + "%Y-%m-%d-%H-%M-%S")) + except ValueError: + return None + return None + + +@register("storage.connected_storage", AREA_STORAGE, "CRITICAL") +def _destination_reachable(ctx): + """All configured storage, using PVE's observations, not remote IO probes.""" + configured = ctx.storages + storages = [s for s in configured if _local_enabled(s, ctx)] + if not storages: + return None + snapshot = ctx.storage_snapshot + resources = {r.get("name"): r for r in snapshot.get("rows", []) + if r.get("node") == ctx.node} + jobs = [j for j in _parse_vzdump_jobs(ctx.vzdump_jobs) if _local_enabled(j, ctx)] + policy = ctx.policy + usage_limit = policy.threshold("storage_usage_percent") + dependencies = {s["id"]: [] for s in storages} + running: set[int] = set() + rc, resource_text = ctx.run( + ["pvesh", "get", "/cluster/resources", "--type", "vm", "--output-format", "json"], + timeout=20) + if rc == 0: + try: + running = {int(r["vmid"]) for r in json.loads(resource_text) + if r.get("node") == ctx.node and r.get("status") == "running"} + except (ValueError, KeyError, TypeError): + running = set() + for kind, configs in (("lxc", ctx.lxc_configs), ("qemu", ctx.qemu_configs)): + for vmid, text in configs.items(): + for line in _current_config(text).splitlines(): + m = re.match(r"^(rootfs|mp\d+|(?:scsi|sata|ide|virtio|efidisk|tpmstate)\d+|unused\d+):\s*([^,]+)", line) + if m and ":" in m[2]: + sid = m[2].split(":", 1)[0] + if sid in dependencies: + dependencies[sid].append({"vmid": vmid, "type": kind, + "disk": m[1], "volume": m[2]}) + observations, affected, unknown = [], [], [] + for storage in storages: + sid = storage["id"] + resource = resources.get(sid, {}) + status = resource.get("status", "unknown") + if resource.get("status_detail") == "not_found": + status = "unknown" # Missing PVE observation is not a confirmed outage. + row = {"storage": sid, "type": storage["type"], "status": status, + "content": storage.get("content", ""), "dependencies": dependencies[sid], + "jobs": [j["id"] for j in jobs if j.get("storage") == sid], + "source": snapshot.get("source", "unavailable"), + "collected_at": snapshot.get("collected_at"), + "remote_internals": "not checked: disks/RAID, PBS verification/pruning, remote permissions", + "io_test": "not performed; availability is reported by PVE"} + # Explicit allowlist: never persist credentials or the raw storage configuration. + row["configuration"] = {k: storage[k] for k in + ("path", "pool", "vgname", "thinpool", "datastore", "namespace", "shared", "nodes") + if k in storage} + reasons = [] + if status in ("error", "unavailable", "inactive", "offline"): + reasons.append("PVE reports storage unavailable") + elif status not in ("active", "available", "namespace_restricted"): + unknown.append(sid) + try: + total, used = float(resource["total"]), float(resource["used"]) + if not (0 < total and 0 <= used <= total): + raise ValueError("capacity unknown") + percent = used * 100 / total + row.update(total=total, used=used, units=snapshot.get("units"), + used_percent=round(percent, 2), capacity_known=True) + if percent >= usage_limit: + reasons.append(f"capacity usage at or above the " + f"{usage_limit:g}% review threshold") + except (KeyError, ValueError, TypeError): + row["capacity_known"] = False + # Restricted PBS namespaces and block backends can omit capacity. + # Unknown capacity is not proof of a full or failed storage. + if reasons: + # A storage backing a guest that is running right now cannot + # be lost without the guest noticing; one holding nothing in + # use can. + serves_running = any(dep["vmid"] in running + for dep in dependencies[sid]) + # Being unreachable is what can interrupt something; being + # full is a margin running out. Capacity is never critical on + # its own, however essential the storage. + offline = status in ("error", "unavailable", "inactive", "offline") + role = policy.storage_role(sid) + if not offline: + gravity = CLASS_WARNING + elif role == audit_policy.ROLE_OPTIONAL: + gravity = CLASS_OBSERVATION + elif role == audit_policy.ROLE_ESSENTIAL or serves_running: + gravity = CLASS_CRITICAL + else: + gravity = CLASS_WARNING + affected.append({"storage": sid, "type": storage["type"], + "classification": gravity, "status": status, + "role": role, + "reason_key": ("storageUnreachable" if offline + else "storageNearlyFull"), + "reason": "; ".join(reasons)}) + observations.append(row) + evidence_payload = {"storages": observations} + if unknown: + evidence_payload["status_not_verified"] = unknown + excluded = [s["id"] for s in configured if not _local_enabled(s, ctx)] + if excluded: + evidence_payload["excluded_disabled_or_other_node"] = excluded + evidence_payload["scope"] = ( + "PVE-side observations only; shared volumes are not classified as orphans here") + if not affected: + return {"classification": CLASS_UNVERIFIED if unknown else CLASS_CONFORMANT, + "summary_key": "evaluationFailed" if unknown else "available", + "summary_params": {"count": 0, "total": len(storages)}, + "observations": observations, "incomplete": bool(unknown), + "evidence": json.dumps(evidence_payload, indent=2)} + return {"summary_key": "attention", + "summary_params": {"count": len(affected), "total": len(storages)}, + "affected": affected, "observations": observations, "incomplete": bool(unknown), + "evidence": json.dumps(evidence_payload, indent=2)} + + +@register("backup.last_backup_age", AREA_BACKUP, "WARNING") +def _last_backup_age(ctx): + """Stored backups for every non-template guest, including missing copies.""" + guests = set(_guest_configs(ctx.lxc_configs)) | set(_guest_configs(ctx.qemu_configs)) + if not guests: + return None + policy = ctx.policy + guests = {v for v in guests if policy.backup_required(v) != audit_policy.NOT_REQUIRED} + if not guests: + return None + storages = _backup_storages(ctx) + newest, failed = {}, [] + unreadable = set() + unattributed = [] + for storage in storages: + rc, out = ctx.run(["pvesm", "list", storage["id"]], timeout=25) + if rc != 0: + failed.append(storage["id"]) + unreadable.add(storage["id"]) + continue + if not re.search(r"^Volid\s", out.strip()): + failed.append(storage["id"] + " (unrecognised inventory)") + unreadable.add(storage["id"]) + continue + for line in out.splitlines()[1:]: + fields = line.split() + # Mixed storage may also list ISOs/templates without a VMID. + if len(fields) >= 4 and fields[2] != "backup": + continue + if len(fields) == 4 and fields[2] == "backup": + match = re.search(r"(?:^|/)vzdump-(?:qemu|lxc)-(\d+)-", fields[0]) + if match: + fields.append(match[1]) + else: + # e.g. host configuration archives: never count as a guest copy. + unattributed.append({"storage": storage["id"], "volume": fields[0]}) + continue + if len(fields) != 5 or not fields[-1].isdigit(): + failed.append(storage["id"] + " (unrecognised row)") + unreadable.add(storage["id"]) + continue + vmid = int(fields[-1]) + if vmid not in guests: + continue + when = _volid_timestamp(fields[0]) + key = (vmid, storage["id"]) + if when is None: + failed.append(storage["id"] + " (unrecognised backup date)") + unreadable.add(storage["id"]) + elif key not in newest or when > newest[key][0]: + newest[key] = (when, storage["id"], fields[0]) + pools = _pool_members(ctx.pve_user_cfg) + jobs = [j for j in _parse_vzdump_jobs(ctx.vzdump_jobs) if _local_enabled(j, ctx)] + stale, missing, observations = [], [], [] + now = time.time() + expectations = [] + for vmid in sorted(guests): + selected = [j for j in jobs if vmid in _job_guests(j, guests, pools)] + # Evaluate each explicitly scheduled destination independently. A current + # copy in PBS A must not mask a missing or old copy in PBS B. + targets = sorted({j.get("storage", "") for j in selected}) or [""] + expectations.extend((vmid, target, [j for j in selected if j.get("storage", "") == target]) + for target in targets) + available_ids = {s["id"] for s in storages} + for vmid, target, selected in expectations: + grace = policy.threshold("backup_schedule_grace_ratio") + limits = [_schedule_age_limit(j.get("schedule", ""), grace) for j in selected] + known_limits = [n for n in limits if n is not None] + # What bounds the age of a guest's newest copy, in order of how + # much it is worth: a recovery objective somebody declared, then + # the schedule its jobs actually run on, and only then a stated + # fallback that stands in for a policy nobody has expressed. + declared = policy.recovery_objective_hours(vmid) + if declared: + limit, basis = declared * 3600, "declared recovery objective" + elif known_limits: + limit, basis = min(known_limits), "schedule and grace" + else: + limit = policy.threshold("backup_fallback_days") * 86400 + basis = "fallback; no recovery objective declared and schedule not read" + row = {"vmid": vmid, "expected_storage": target or "any visible destination (no explicit target)", + "jobs": [j["id"] for j in selected], + "schedules": [j.get("schedule", "unknown") for j in selected], + "max_age_hours": round(limit / 3600, 1), + "age_policy": basis, + "verification": "not queried; existence is not a restore test"} + candidates = [value for (guest, sid), value in newest.items() + if guest == vmid and (not target or sid == target)] + latest = max(candidates, key=lambda value: value[0]) if candidates else None + uncertain = target in unreadable if target else bool(unreadable) + if target and target not in available_ids: + row["backup"] = "scheduled destination disabled, missing or outside this node" + missing.append({"vmid": vmid, "storage": target, + "classification": CLASS_WARNING, + "reason_key": "destinationUnavailable"}) + observations.append(row) + continue + if selected and not target: + failed.append(f"{vmid}: job destination not explicitly resolved") + if latest is None: + row["backup"] = "unknown: destination unreadable" if uncertain else "none found" + if not uncertain: + # An expected copy that is not there is a missing + # protection; where nothing was expected of the guest it + # is the absence of a schedule, already reported by + # coverage, and here it is only an observation. + expected = policy.backup_required(vmid) + missing.append({ + "vmid": vmid, "storage": target or "any", + "classification": (CLASS_WARNING + if selected or expected == audit_policy.REQUIRED + else CLASS_OBSERVATION), + "reason_key": ("noStoredBackup" if selected + else "noStoredBackupUnscheduled"), + }) + else: + when, storage, volume = latest + row.update(last_backup=int(when), storage=storage, volume=volume, + age_hours=round((now - when) / 3600, 1)) + if when > now + 300: + failed.append(f"{vmid}: backup timestamp is in the future") + elif now - when > limit and not uncertain: + stale.append({"vmid": vmid, "days": int((now - when) / 86400), + "storage": storage, "classification": ( + CLASS_WARNING if declared or known_limits or policy.backup_required(vmid) == audit_policy.REQUIRED + else CLASS_OBSERVATION), + "reason_key": ("olderThanObjective" if declared + else "olderThanSchedule" if known_limits + else "olderThanFallback")}) + observations.append(row) + evidence = json.dumps(observations, indent=2) + if unattributed: + evidence += "\nArchives without guest attribution (not counted): " + json.dumps(unattributed) + if failed: + evidence += "\nNot verified: " + ", ".join(sorted(set(failed))) + affected = missing + stale + if not affected: + return {"classification": CLASS_UNVERIFIED if failed else CLASS_CONFORMANT, + "summary_key": "evaluationFailed" if failed else "recent", + "summary_params": {"count": 0, "total": len(expectations)}, + "observations": observations, "incomplete": bool(failed), + "evidence": evidence} + warnings = sum(1 for a in affected if a["classification"] == CLASS_WARNING) + return {"summary_key": "attention", + "summary_params": {"count": len(affected), "total": len(expectations), + "warnings": warnings}, + "affected": affected, "observations": observations, + "incomplete": bool(failed), "evidence": evidence} + + +_WEEKDAYS = {"mon": 0, "tue": 1, "wed": 2, "thu": 3, "fri": 4, "sat": 5, "sun": 6} + +_SHORTHAND = {"hourly": 3600, "daily": 86400, "weekly": 7 * 86400, + "monthly": 31 * 86400, "yearly": 366 * 86400, + "annually": 366 * 86400, "quarterly": 92 * 86400, + "semiannually": 184 * 86400, "minutely": 60} + + +def _weekday_set(spec: str): + """Days named by a Proxmox schedule, as indexes. + + Accepts the forms Proxmox writes: ``mon``, ``mon,wed``, ``mon..fri`` + and combinations of the two. Returns None when the text is not a day + specification at all, so the caller can tell "no days named" from + "days that could not be read". + """ + days: set[int] = set() + for part in spec.split(","): + part = part.strip() + if not part: + continue + if ".." in part: + first, _, last = part.partition("..") + if first not in _WEEKDAYS or last not in _WEEKDAYS: + return None + start, end = _WEEKDAYS[first], _WEEKDAYS[last] + # A range may wrap around the end of the week. + index = start + while True: + days.add(index) + if index == end: + break + index = (index + 1) % 7 + elif part in _WEEKDAYS: + days.add(_WEEKDAYS[part]) + else: + return None + return days or None + + +def _longest_gap(days: set[int], times: list[str]) -> float: + """Seconds between consecutive runs, at their widest. + + A job that runs on Monday and Friday has a three-day gap and a + four-day gap; what bounds the age of the newest backup is the wider + of the two. + """ + offsets = set() + for clock in times: + parts = [int(p) for p in clock.split(":")] + seconds = parts[0] * 3600 + parts[1] * 60 + (parts[2] if len(parts) > 2 else 0) + offsets.update(day * 86400 + seconds for day in days) + ordered = sorted(offsets) + week = 7 * 86400 + return float(max((ordered[(i + 1) % len(ordered)] - value) % week or week + for i, value in enumerate(ordered))) + + +def _schedule_interval(schedule: str): + """How often a Proxmox calendar event fires, in seconds. + + Reads the calendar forms Proxmox actually writes — ``sun 07:00``, + ``mon..fri 05:30``, ``*-*-* 03:00``, ``mon,wed 01:00``, ``daily`` — + rather than recognising a handful of keywords and treating everything + else as unknown. Anything genuinely unreadable still returns None, so + an interval is never invented. + """ + text = (schedule or "").strip().lower() + if not text: + return None + if text in _SHORTHAND: + return float(_SHORTHAND[text]) + + # A repeat specification (``*-*-* 03:00/6``) is left unread rather + # than approximated. + body = text + if body.startswith("*-*-*"): + body = body[5:].strip() + elif re.match(r"^\*-\*-\*\s", body): + body = body.split(None, 1)[1] + + def clock_values(token: str): + # A time token may itself be a list: "01:00,13:00". + values = [v for v in token.split(",") if v] + if not values or not all( + re.fullmatch(r"\d{1,2}:\d{2}(:\d{2})?", v) for v in values): + return None + for value in values: + hour, minute = value.split(":")[:2] + if int(hour) > 23 or int(minute) > 59 or (len(value.split(":")) == 3 and int(value.split(":")[2]) > 59): + return None + return values + + parts = body.split() + times: list[str] = [] + day_spec: list[str] = [] + for token in parts: + values = clock_values(token) + if values is None: + day_spec.append(token) + else: + times.extend(values) + if not times: + return None + + if not day_spec: + # Times only: every day at those times. + return _longest_gap(set(range(7)), times) + if len(day_spec) > 1: + return None + days = _weekday_set(day_spec[0]) + if days is None: + return None + return _longest_gap(days, times) + + +def _schedule_age_limit(schedule, grace_ratio: float = 0.5): + """How old the newest backup may be before the schedule was missed. + + The limit is one full interval plus a margin, so a job that has just + run and one that ran a little late are both within it. A schedule + that could not be read yields None, and the caller falls back to a + stated policy rather than to an invented one. + """ + interval = _schedule_interval(schedule) + if interval is None: + return None + return interval + max(3600.0, interval * grace_ratio) + + +# --------------------------------------------------------------------------- +# Snapshots, pools, SSH and bonds +# --------------------------------------------------------------------------- + +# ZFS ships a monthly scrub schedule; this allows one full period plus +# margin before the last scrub is reported as overdue. + + +def _snapshot_sections(text: str) -> list[tuple[str, str]]: + """Return each snapshot section of a guest configuration. + + A configuration keeps its live settings first and then one ``[name]`` + section per snapshot. + """ + out = [] + name = None + body: list[str] = [] + for line in text.splitlines(): + header = re.match(r"^\[([^\]]+)\]", line) + if header: + if name is not None: + out.append((name, "\n".join(body))) + name, body = header.group(1), [] + continue + if name is not None: + body.append(line) + if name is not None: + out.append((name, "\n".join(body))) + return out + + +@register("guests.stuck_snapshots", AREA_GUESTS, "WARNING") +def _stuck_snapshots(ctx): + """Snapshots left mid-operation. + + ``snapstate`` is written while a snapshot is being created or removed + and cleared when the operation finishes. A section that still carries + it was interrupted: the snapshot occupies space and further snapshot + operations on that guest are refused until it is resolved. + """ + configs = {} + configs.update({v: ("lxc", t) for v, t in ctx.lxc_configs.items()}) + configs.update({v: ("qemu", t) for v, t in ctx.qemu_configs.items()}) + if not configs: + return None + + stuck, total = [], 0 + for vmid, (kind, text) in sorted(configs.items()): + for name, body in _snapshot_sections(text): + total += 1 + m = re.search(r"^snapstate:\s*(\S+)", body, re.M) + if m: + stuck.append({"vmid": vmid, "snapshot": name, + "state": m.group(1), "type": kind}) + + if total == 0: + return {"classification": CLASS_CONFORMANT, "summary_key": "noSnapshots"} + evidence = f"snapshots found: {total}" + if stuck: + evidence += "\n\nleft mid-operation:\n " + "\n ".join( + f"{s['vmid']} [{s['snapshot']}] snapstate={s['state']}" for s in stuck) + if not stuck: + return {"classification": CLASS_CONFORMANT, "summary_key": "allComplete", + "summary_params": {"total": total}, "evidence": evidence} + rc, active_text = ctx.run(["pvesh", "get", f"/nodes/{ctx.node}/tasks", + "--source", "active", "--output-format", "json"]) + if rc != 0: + return {"classification": CLASS_UNVERIFIED, "summary_key": "evaluationFailed", + "incomplete": True, "evidence": evidence} + active = json.loads(active_text) + # Only a task that could be holding the snapshot suspends the + # finding. Any running task for the same guest used to do it, so a + # long console session or a migration hid a snapshot that really was + # stuck — the one case this check exists to catch. + RELATED = ("vzdump", "qmsnapshot", "vzsnapshot", "qmdelsnapshot", + "vzdelsnapshot", "qmrollback", "vzrollback", "qmmove", "backup") + active_ids = {str(task.get("id", "")) for task in active + if any(str(task.get("type", "")).lower().startswith(k) + or k in str(task.get("type", "")).lower() for k in RELATED)} + suspicious, uncertain = [], [] + for snapshot in stuck: + text = configs[snapshot["vmid"]][1] + body = next(b for n, b in _snapshot_sections(text) if n == snapshot["snapshot"]) + stamp = re.search(r"^snaptime:\s*(\d+)", body, re.M) + if str(snapshot["vmid"]) in active_ids or not stamp or time.time() - int(stamp.group(1)) < 3600: + uncertain.append(snapshot) + else: + suspicious.append(snapshot) + return { + "classification": CLASS_WARNING if suspicious else CLASS_UNVERIFIED, + "summary_key": "stuck" if suspicious else "evaluationFailed", + "summary_params": {"count": len(suspicious), "total": total}, + "affected": suspicious, + "incomplete": bool(uncertain), + "evidence": evidence + "\nRecent, undated, or held by a running snapshot" + " or backup task, so not classified as interrupted: " + + json.dumps(uncertain), + } + + +@register("storage.zfs_scrub_age", AREA_STORAGE, "WARNING") +def _zfs_scrub_age(ctx): + """A resilver is not evidence of a completed scrub.""" + if not Path("/sys/module/zfs").exists(): + return None + rc, out = ctx.run(["zpool", "list", "-H", "-o", "name"], timeout=15) + if rc != 0: + return {"classification": CLASS_UNVERIFIED, "summary_key": "evaluationFailed", + "incomplete": True, "evidence": out} + pools = out.split() + if not pools: + return None + overdue, observations, unknown = [], [], [] + for pool in pools: + rc, status = ctx.run(["zpool", "status", pool], timeout=20) + scan = re.search(r"^\s*scan:\s*(.+)$", status, re.M) + row = {"pool": pool, "scan": scan.group(1) if scan else "unavailable"} + observations.append(row) + if rc != 0 or not scan: + unknown.append(pool) + continue + if "none requested" in row["scan"]: + overdue.append({"pool": pool, "reason": "no scrub recorded"}) + continue + m = re.search(r"^scrub repaired .* on\s+(.+)$", row["scan"]) + if not m: + # In-progress scrub or last scan was a resilver: last completed + # scrub date is not available here, not proved absent. + unknown.append(pool) + continue + try: + when = time.mktime(time.strptime(" ".join(m.group(1).split()), "%a %b %d %H:%M:%S %Y")) + except ValueError: + unknown.append(pool) + continue + days = int((time.time() - when) / 86400) + row["days"] = days + if days < 0: + unknown.append(pool) + elif days >= ctx.policy.threshold("zfs_scrub_days"): + overdue.append({"pool": pool, "days": days}) + for row in overdue: + row["classification"] = CLASS_WARNING + row["reason_key"] = "scrubOverdue" + return {"classification": None if overdue + else CLASS_UNVERIFIED if unknown else CLASS_CONFORMANT, + "summary_key": "overdue" if overdue else "evaluationFailed" if unknown else "recent", + "summary_params": {"count": len(overdue), "total": len(pools)}, + "affected": overdue, "observations": observations, "incomplete": bool(unknown), + "evidence": json.dumps(observations, indent=2)} + + +@register("security.ssh_root_login", AREA_SECURITY, "WARNING") +def _ssh_root_login(ctx): + """How the SSH daemon admits the root account. + + Proxmox ships ``PermitRootLogin yes``, which accepts a password. + ``prohibit-password`` keeps root access while requiring a key. + """ + rc, out = ctx.run(["sshd", "-T"], timeout=15) + if rc != 0: + return None + value = "" + for line in (out or "").splitlines(): + if line.lower().startswith("permitrootlogin"): + parts = line.split() + value = parts[1].lower() if len(parts) > 1 else "" + break + if not value: + return _unverified(out) + + options = dict(line.split(None, 1) for line in out.splitlines() if len(line.split(None, 1)) == 2) + password = options.get("passwordauthentication", "unknown") + interactive = options.get("kbdinteractiveauthentication", "unknown") + methods = options.get("authenticationmethods", "any") + evidence = (f"PermitRootLogin: {value}\nPasswordAuthentication: {password}\n" + f"KbdInteractiveAuthentication: {interactive}\nAuthenticationMethods: {methods}\n" + "Default sshd context only; Match rules and each client/source are not evaluated.") + if value == "yes" and (password == "yes" or interactive == "yes"): + # Proxmox ships `yes`, so every stock install would otherwise + # carry a warning. Reporting the shipped state as a fault makes + # this a hardening policy of our own; declaring the access + # unwanted is what turns it into one. + declared = ctx.policy.host_expectation("ssh_root_login") + return {"classification": CLASS_WARNING if declared == audit_policy.NOT_REQUIRED + else CLASS_OBSERVATION, + "summary_key": "password", + "evidence": evidence} + if value in ("prohibit-password", "without-password", "forced-commands-only") or ( + value == "yes" and password == "no" and interactive == "no"): + return {"classification": CLASS_CONFORMANT, "summary_key": "keyOnly", + "evidence": evidence} + if value == "no": + return {"classification": CLASS_CONFORMANT, "summary_key": "denied", + "evidence": evidence} + return _unverified(evidence) + + +@register("network.bond_members", AREA_NETWORK, "WARNING") +def _bond_members(ctx): + """Each bond's members, and what their state costs. + + A bond keeps working while members fail, so the loss is not otherwise + visible from the host — but losing one link of four and losing the + only live link are not the same event. In active-backup a standby + member reports as up and carries nothing, which is the mode working + as designed, so the reading here is how many links remain, not how + many are passing traffic. + """ + base = Path("/proc/net/bonding") + if not base.is_dir(): + return None + bonds = sorted(p.name for p in base.iterdir() if p.is_file()) + if not bonds: + return None + + down, lines, unreadable = [], [], [] + for bond in bonds: + text = ctx.read(base / bond) + if not text: + unreadable.append(bond) + continue + mode = "" + m = re.search(r"^Bonding Mode:\s*(.+)$", text, re.M) + if m: + mode = m.group(1).strip() + members = re.findall( + r"^Slave Interface:\s*(\S+)(.*?)(?=^Slave Interface:|\Z)", + text, re.M | re.S) + up, failed, unknown = [], [], [] + for name, body in members: + status = re.search(r"^MII Status:\s*(\S+)", body, re.M) + state = status.group(1) if status else "unknown" + if state == "up": + up.append(name) + elif state == "down": + failed.append({"bond": bond, "interface": name, + "status": state, "mode": mode}) + else: + unknown.append(name) + if unknown or not members: + unreadable.append(bond) + lines.append(f"{bond} ({mode}): {len(up)}/{len(members)} member(s) up") + + for entry in failed: + # No link left is a connectivity loss; some link left is a + # redundancy loss, which is serious but not an interruption. + if not up and not unknown: + entry["classification"] = CLASS_CRITICAL + entry["reason_key"] = "bondNoMembersUp" + else: + entry["classification"] = CLASS_WARNING + entry["reason_key"] = "bondRedundancyLost" + down.extend(failed) + + if not lines: + return _unverified("Bond state could not be read") + evidence = "\n".join(lines) + if down: + evidence += "\n\nnot up:\n " + "\n ".join( + f"{d['bond']}/{d['interface']}: {d['status']}" for d in down) + + if not down: + if unreadable: + return _unverified(evidence) + return {"classification": CLASS_CONFORMANT, "summary_key": "allUp", + "summary_params": {"total": len(bonds)}, "evidence": evidence} + return { + "summary_key": "membersDown", + "summary_params": {"count": len(down), "total": len(bonds)}, + "affected": down, + "incomplete": bool(unreadable), + "evidence": evidence, + } + + +# --------------------------------------------------------------------------- +# Remaining catalogue +# --------------------------------------------------------------------------- + +# A thin pool serves writes from its own capacity, so allocating beyond it +# only holds while guests leave space unwritten. + +# Journald keeps growing until it reaches its configured cap; past this it +# is reported so the cap can be confirmed as deliberate. + + +@register("system.security_updates", AREA_SYSTEM, "WARNING") +def _security_updates(ctx): + """Pending package updates that come from a security repository.""" + rc, out = ctx.run(["apt-get", "-s", "upgrade"], timeout=40) + if rc != 0: + return None + lines = [l for l in (out or "").splitlines() if l.startswith("Inst ")] + if not lines: + return {"classification": CLASS_CONFORMANT, "summary_key": "none"} + security = [l for l in lines if re.search(r"security", l, re.I)] + evidence = f"pending updates: {len(lines)}\nfrom a security repository: {len(security)}" + if security: + evidence += "\n\n" + "\n".join( + l.split()[1] for l in security[:25] if len(l.split()) > 1) + if not security: + return {"classification": CLASS_CONFORMANT, "summary_key": "noSecurity", + "summary_params": {"total": len(lines)}, "evidence": evidence} + return { + "classification": CLASS_WARNING, + "summary_key": "pending", + "summary_params": {"count": len(security), "total": len(lines)}, + "affected": [{"package": l.split()[1]} for l in security if len(l.split()) > 1], + "evidence": evidence, + } + + +@register("guests.cpu_host_type", AREA_GUESTS, "INFO") +def _cpu_host_type(ctx): + """Virtual machines pinned to the host processor model. + + ``cpu: host`` exposes the physical processor's feature set. A guest + started this way can only migrate to a node offering the same + features. + + The constraint only has an effect where there is somewhere to migrate + to, so on a node that belongs to no cluster this is not assessed. + Reporting it there would flag the setting that gives the best + performance on a standalone host. + """ + configs = {vmid: _current_config(text) for vmid, text in ctx.qemu_configs.items()} + if not configs: + return None + if not Path("/etc/corosync/corosync.conf").exists(): + return None + pinned = [] + for vmid, text in sorted(configs.items()): + m = re.search(r"^cpu:\s*([^\s,]+)", text, re.M) + if m and m.group(1).strip() == "host": + name = "" + n = re.search(r"^name:\s*(\S+)", text, re.M) + if n: + name = n.group(1) + pinned.append({"vmid": vmid, "name": name}) + if not pinned: + return {"classification": CLASS_CONFORMANT, "summary_key": "none", + "summary_params": {"total": len(configs)}} + # Pinning to the host processor is correct on plenty of clusters. It + # constrains migration, which is worth stating; incompatibility with + # a particular destination is not something this can demonstrate. + for row in pinned: + row["classification"] = CLASS_OBSERVATION + row["reason_key"] = "pinnedToHostCpu" + return { + "summary_key": "pinned", + "summary_params": {"count": len(pinned), "total": len(configs)}, + "affected": pinned, + "evidence": "cpu: host — " + ", ".join( + f"{p['vmid']}{' (' + p['name'] + ')' if p['name'] else ''}" + for p in pinned), + } + + +@register("backup.retention_defined", AREA_BACKUP, "WARNING") +def _retention_defined(ctx): + """Where each job's retention comes from, without guessing at the rest. + + Retention is resolved in the order Proxmox applies it: the job's own + setting, then the storage's, then the node default in + ``/etc/vzdump.conf``. Keeping every copy can be deliberate, so a job + with no explicit policy is reported as configuration rather than as a + defect. A PBS destination prunes on the server, under jobs this node + cannot see, so its retention is reported as not read here — which is + a limit of the vantage point, not a finding about the job. + """ + jobs = [j for j in _parse_vzdump_jobs(ctx.vzdump_jobs) if _local_enabled(j, ctx)] + if not jobs: + return None + defaults = {} + for line in ctx.read("/etc/vzdump.conf", optional=True).splitlines(): + m = re.match(r"^\s*([\w-]+):\s*(.+)", line) + if m: + defaults[m.group(1)] = m.group(2) + storages = {s["id"]: s for s in ctx.storages} + undeclared, remote, rows = [], [], [] + for job in jobs: + sid = job.get("storage") or defaults.get("storage") + storage = storages.get(sid, {}) + retention, source = None, None + for name, settings in (("job", job), ("storage", storage), ("node", defaults)): + retention = settings.get("prune-backups") or settings.get("maxfiles") + if retention: + source = name + break + row = {"job": job["id"], "storage": sid, "policy": retention, "source": source} + if not retention: + if storage.get("type") == "pbs" or not storage: + row["status"] = "pruned on the backup server; not read from this node" + remote.append({"job": job["id"], "storage": sid, + "classification": CLASS_OBSERVATION, + "reason_key": "retentionOnServer"}) + else: + row["status"] = "no explicit retention; every copy is kept" + undeclared.append({"job": job["id"], "storage": sid, + "classification": CLASS_OBSERVATION, + "reason_key": "retentionNotDeclared"}) + rows.append(row) + + affected = undeclared + remote + if not affected: + return {"classification": CLASS_CONFORMANT, "summary_key": "allDefined", + "summary_params": {"total": len(jobs)}, "observations": rows, + "evidence": json.dumps(rows, indent=2)} + return {"summary_key": "notDeclared" if undeclared else "onServer", + "summary_params": {"count": len(affected), "total": len(jobs)}, + "affected": affected, "observations": rows, + "evidence": json.dumps(rows, indent=2)} + + +@register("storage.thin_pool_overprovisioning", AREA_STORAGE, "WARNING") +def _thin_overprovisioning(ctx): + """Virtual capacity handed out by each thin pool against its own size. + + A thin pool serves writes from its real capacity. Allocating beyond it + holds only while guests leave space unwritten; once they do not, writes + to the pool fail. + """ + # An explicit separator is required: a thin pool leaves pool_lv empty, + # and splitting on whitespace would collapse the gap and shift every + # field after it. + rc, out = ctx.run( + ["lvs", "--noheadings", "--units", "b", "--nosuffix", + "--separator", "|", + "-o", "vg_name,lv_name,lv_size,pool_lv,lv_attr,data_percent,metadata_percent"], + timeout=20) + if rc != 0: + return _unverified(out or "Thin-pool inventory could not be read.") + if not (out or "").strip(): + return None + + overprovision_limit = ctx.policy.threshold("thin_overprovision_ratio") + fill_limit = ctx.policy.threshold("thin_pool_usage_percent") + pools: dict[tuple, int] = {} + used_percent: dict[tuple, str] = {} + metadata_percent: dict[tuple, str] = {} + allocated: dict[tuple, int] = {} + unreadable = [] + for line in out.splitlines(): + f = [c.strip() for c in line.split("|")] + if len(f) < 5: + unreadable.append("Malformed logical-volume inventory row.") + continue + vg, lv, size_raw, pool, attr = f[0], f[1], f[2], f[3], f[4] + try: + size_number = float(size_raw) + if not math.isfinite(size_number) or size_number <= 0: + raise ValueError("invalid size") + size = int(size_number) + except (ValueError, OverflowError): + unreadable.append(f"{vg}/{lv}: capacity could not be read.") + continue + if attr.startswith("t"): + pools[(vg, lv)] = size + used_percent[(vg, lv)] = f[5] if len(f) > 5 and f[5] else "?" + metadata_percent[(vg, lv)] = f[6] if len(f) > 6 and f[6] else "?" + elif pool: + key = (vg, pool) + allocated[key] = allocated.get(key, 0) + size + if not pools: + return _unverified("\n".join(unreadable)) if unreadable else None + + over, rows, pressure = [], [], [] + for key, size in sorted(pools.items()): + used = allocated.get(key, 0) + ratio = used / size if size else 0 + # The allocation ratio alone does not describe the risk: what + # matters is how much of the pool the volumes have actually + # written. Both figures are reported so the reader can judge. + rows.append({"pool": f"{key[0]}/{key[1]}", + "allocated_bytes": used, "pool_bytes": size, + "allocation_percent": round(ratio * 100, 2), + "data_percent": used_percent.get(key, "?"), + "metadata_percent": metadata_percent.get(key, "?")}) + for metric, readings in (("data", used_percent), ("metadata", metadata_percent)): + try: + percent = float(readings.get(key, "?")) + if not math.isfinite(percent) or not 0 <= percent <= 100: + raise ValueError("invalid percentage") + except ValueError: + unreadable.append(f"{key[0]}/{key[1]}: {metric} usage could not be read.") + continue + if percent >= fill_limit: + # Written space is the figure that runs out. Metadata + # exhaustion takes a pool read-only, which is graver than + # data running low. + pressure.append({"pool": f"{key[0]}/{key[1]}", "metric": metric, + "percent": percent, + "classification": CLASS_WARNING, + "reason_key": ("thinMetadataPressure" + if metric == "metadata" + else "thinDataPressure")}) + if ratio > overprovision_limit: + # Handing out more virtual capacity than the pool has is the + # point of thin provisioning. On its own it describes the + # design, not a risk. + over.append({"pool": f"{key[0]}/{key[1]}", + "percent": round(ratio * 100), + "classification": CLASS_OBSERVATION, + "reason_key": "overprovisioned"}) + evidence = json.dumps(rows, indent=2) + if unreadable: + evidence += "\nNot verified: " + ", ".join(unreadable) + evidence += (f"\nReview thresholds: {overprovision_limit:g}x allocated capacity; " + f"{fill_limit:g}% written data or metadata.") + affected = pressure + over + if unreadable and not pressure: + return _unverified(evidence, affected=affected) + if not affected: + return {"classification": CLASS_CONFORMANT, "summary_key": "withinRatio", + "summary_params": {"total": len(pools)}, "evidence": evidence} + return { + "summary_key": "pressure" if pressure else "aboveRatio", + "summary_params": {"count": len(affected), "total": len(pools), + "pressure": len(pressure)}, + "affected": affected, + "evidence": evidence, + "incomplete": bool(unreadable), + } + + +def _size_to_mb(value: str): + """A systemd size specification in MiB, or None when unreadable.""" + m = re.fullmatch(r"\s*([\d.]+)\s*([KMGT])?\s*", value or "", re.I) + if not m: + return None + scale = {"K": 1 / 1024, "M": 1, "G": 1024, "T": 1024 * 1024} + return float(m.group(1)) * scale.get((m.group(2) or "M").upper(), 1) + + +@register("system.journal_size", AREA_SYSTEM, "INFO") +def _journal_size(ctx): + """Space held by the systemd journal against its configured cap.""" + rc, out = ctx.run(["journalctl", "--disk-usage"], timeout=20) + if rc != 0: + return None + m = re.search(r"take up ([\d.]+)([KMG])", out or "") + if not m: + return None + value, unit = float(m.group(1)), m.group(2) + mb = value * {"K": 1 / 1024, "M": 1, "G": 1024}[unit] + + # The cap may be set in the main file or overridden by a drop-in; the + # last assignment encountered is the effective one. + candidates = [Path("/etc/systemd/journald.conf")] + drop_in = Path("/etc/systemd/journald.conf.d") + if drop_in.is_dir(): + candidates.extend(sorted(drop_in.glob("*.conf"))) + + cap = "" + for path in candidates: + try: + for line in path.read_text(errors="replace").splitlines(): + if re.match(r"^\s*SystemMaxUse=", line): + cap = line.split("=", 1)[1].strip() + except OSError: + continue + # What matters is the journal against the cap that applies to it, not + # against a number chosen here. journald keeps to SystemMaxUse where + # it is set, and otherwise to a tenth of the filesystem it lives on, + # so a 2 GiB journal on a large volume is within its bounds and a + # small one on a full volume may not be. + cap_mb = _size_to_mb(cap) if cap else None + if cap_mb is None: + rc_fs, fs_out = ctx.run(["df", "-B1", "--output=size", "/var/log"], timeout=10) + sizes = [int(v) for v in (fs_out or "").split() if v.isdigit()] + cap_mb = (sizes[0] / (1024 * 1024)) * 0.10 if sizes else None + cap_source = "journald default: 10% of the filesystem" + else: + cap_source = f"SystemMaxUse={cap}" + + share = (mb / cap_mb * 100) if cap_mb else None + evidence = (f"journal on disk: {value:.1f}{unit}\n" + f"effective cap: {cap_source}" + + (f"\ncap: {cap_mb:.0f} MiB, in use: {share:.0f}%" + if cap_mb else "\ncap: not determined")) + params = {"size": f"{value:.1f}{unit}", + "percent": str(round(share)) if share is not None else "?"} + + if share is None: + return {"classification": CLASS_UNVERIFIED, "summary_key": "capUnknown", + "summary_params": params, "evidence": evidence} + if share >= ctx.policy.threshold("journal_usage_percent"): + return {"classification": CLASS_OBSERVATION, "summary_key": "nearCap", + "summary_params": params, "evidence": evidence} + return {"classification": CLASS_CONFORMANT, "summary_key": "bounded", + "summary_params": params, "evidence": evidence} + + +@register("guests.replication_state", AREA_GUESTS, "WARNING") +def _replication_state(ctx): + """Replication jobs and what their last run actually reported. + + Read from the API rather than from the status table: matching the + words "error" or "fail" in formatted output cannot tell a failing job + from one whose target is named ``failover``, and it cannot see a job + that has never run at all. The API reports the failure count, the + last error and the last successful synchronisation, which is what the + question needs. + """ + try: + text = Path("/etc/pve/replication.cfg").read_text(errors="replace") + except OSError: + return None + if not re.search(r"^local:\s*\S+", text, re.M): + return None + + rc, out = ctx.run(["pvesh", "get", f"/nodes/{ctx.node}/replication", + "--output-format", "json"], timeout=25) + if rc != 0: + return {"classification": CLASS_UNVERIFIED, "summary_key": "statusUnavailable", + "incomplete": True, "evidence": (out or "")[:2000]} + try: + jobs = json.loads(out) + if not isinstance(jobs, list) or any(not isinstance(job, dict) for job in jobs): + raise ValueError("invalid replication inventory") + except ValueError: + return {"classification": CLASS_UNVERIFIED, "summary_key": "statusUnavailable", + "incomplete": True, "evidence": (out or "")[:2000]} + if not jobs: + return None + + now = time.time() + affected, rows, unreadable = [], [], [] + for job in jobs: + try: + fail_count = int(job.get("fail_count", 0)) + last_sync = float(job.get("last_sync") or 0) + disabled = job.get("disable", 0) + if (fail_count < 0 or not math.isfinite(last_sync) or last_sync < 0 + or last_sync > now + 300 or disabled not in (0, 1, "0", "1", False, True)): + raise ValueError("invalid replication status") + except (ValueError, TypeError, OverflowError): + unreadable.append(str(job.get("id", "unknown job"))) + continue + row = {"job": job.get("id"), "guest": job.get("guest"), + "target": job.get("target"), "schedule": job.get("schedule"), + "fail_count": fail_count, + "last_sync": last_sync, + "next_sync": job.get("next_sync"), + "disabled": disabled in (1, "1", True)} + rows.append(row) + if row["disabled"]: + # A paused job is a decision, and it is worth seeing: it looks + # like protection in the interface and provides none. + affected.append({"job": row["job"], "guest": row["guest"], + "classification": CLASS_OBSERVATION, + "reason_key": "replicationDisabled"}) + continue + if job.get("error") or fail_count > 0: + row["error"] = str(job.get("error") or "")[:400] + affected.append({"job": row["job"], "guest": row["guest"], + "fail_count": row["fail_count"], + "classification": CLASS_WARNING, + "reason_key": "replicationFailing"}) + continue + if not row["last_sync"]: + affected.append({"job": row["job"], "guest": row["guest"], + "classification": CLASS_WARNING, + "reason_key": "replicationNeverRan"}) + continue + # Overdue against the schedule the job itself declares, with the + # same grace the backup age check applies. + limit = _schedule_age_limit( + row["schedule"] or "", ctx.policy.threshold("backup_schedule_grace_ratio")) + if limit and now - row["last_sync"] > limit: + affected.append({"job": row["job"], "guest": row["guest"], + "hours": round((now - row["last_sync"]) / 3600, 1), + "classification": CLASS_WARNING, + "reason_key": "replicationOverdue"}) + + evidence = json.dumps(rows, indent=2) + if unreadable: + evidence += "\nUnreadable job status: " + ", ".join(unreadable) + if not any(obj["classification"] == CLASS_WARNING for obj in affected): + return _unverified(evidence, affected=affected, observations=rows) + if not affected: + return {"classification": CLASS_CONFORMANT, "summary_key": "healthy", + "summary_params": {"total": len(jobs)}, "observations": rows, + "evidence": evidence} + return {"summary_key": "failing", + "summary_params": {"count": len(affected), "total": len(jobs)}, + "affected": affected, "observations": rows, "evidence": evidence, + "incomplete": bool(unreadable)} + + +@register("network.bridge_without_ports", AREA_NETWORK, "INFO") +def _bridge_without_ports(ctx): + """Bridges that carry no physical interface. + + Such a bridge connects guests to each other but not to the network + beyond the host. That is a valid internal network and also what a + bridge looks like when its port was removed or renamed. + """ + try: + text = Path("/etc/network/interfaces").read_text(errors="replace") + except OSError: + return None + + bridges, current = {}, None + for line in text.splitlines(): + m = re.match(r"^iface\s+(\S+)", line) + if m: + current = m.group(1) if m.group(1).startswith("vmbr") else None + if current: + bridges[current] = "" + continue + if current and re.match(r"^\s+bridge[-_]ports\s+", line): + bridges[current] = line.split(None, 1)[1].strip() + if not bridges: + return None + + isolated = [{"bridge": b} for b, ports in sorted(bridges.items()) + if not ports or ports == "none"] + evidence = "\n".join(f"{b}: {p or 'none'}" for b, p in sorted(bridges.items())) + if not isolated: + return {"classification": CLASS_CONFORMANT, "summary_key": "allConnected", + "summary_params": {"total": len(bridges)}, "evidence": evidence} + # A bridge without a physical port is how an internal network is + # built; without a stated expectation of where it should reach, there + # is nothing here to contradict. + for row in isolated: + row["classification"] = CLASS_OBSERVATION + row["reason_key"] = "noPhysicalPort" + return { + "summary_key": "isolated", + "summary_params": {"count": len(isolated), "total": len(bridges)}, + "affected": isolated, + "evidence": evidence, + } + + +@register("system.swap_configured", AREA_SYSTEM, "INFO") +def _swap_configured(ctx): + """Swap available to the host and its size against physical memory.""" + rc, out = ctx.run(["swapon", "--show=NAME,SIZE,TYPE", "--bytes", + "--noheadings"], timeout=15) + total_mem = _host_memory_bytes(ctx) + entries = [] + swap_total = 0 + for line in (out or "").splitlines(): + f = line.split() + if len(f) >= 2 and f[1].isdigit(): + entries.append({"device": f[0], "bytes": int(f[1])}) + swap_total += int(f[1]) + + gib = 1024 ** 3 + if not entries: + # Running without swap is a legitimate design, and no ratio to + # RAM is required by anything. Memory pressure is a separate + # question, answered by the memory analysis. + return {"classification": CLASS_OBSERVATION, "summary_key": "none", + "evidence": f"no swap active\nhost memory: {total_mem / gib:.1f} GiB" + if total_mem else "no swap active"} + evidence = "\n".join(f"{e['device']}: {e['bytes'] / gib:.1f} GiB" for e in entries) + if total_mem: + evidence += f"\nhost memory: {total_mem / gib:.1f} GiB" + return { + "classification": CLASS_CONFORMANT, + "summary_key": "active", + "summary_params": {"size": f"{swap_total / gib:.1f} GiB"}, + "evidence": evidence, + } + + +# --------------------------------------------------------------------------- +# Recoverability, capacity and the chain that keeps a host maintainable +# +# What the catalogue above establishes is that a copy was scheduled and +# that one exists. Neither says it can be restored. These read the +# evidence Proxmox already keeps about whether the protection works, what +# the host is running out of, and whether the mechanisms that would warn +# somebody are themselves working. +# --------------------------------------------------------------------------- + +@register("backup.verification_state", AREA_BACKUP, "WARNING") +def _backup_verification(ctx): + """The verification result each stored backup carries. + + Proxmox Backup Server records, per snapshot, whether a verification + job has read it back and found it intact. That is the nearest thing + to evidence that a copy is restorable that can be had without + restoring it, and it is already stored — so an audit that reports + only that a backup exists is leaving the better fact unread. + + A snapshot nothing has verified is not a damaged snapshot. It is one + whose integrity has not been established, which is what the report + says. + """ + destinations = [s for s in ctx.storages + if s.get("type") == "pbs" and _local_enabled(s, ctx)] + if not destinations: + return None + + # A shared backup server holds the copies of every node that writes + # to it, and keeps the copies of guests that no longer exist. Grading + # all of them made this node answerable for another node's snapshots + # and for guests nobody could restore anywhere. + local = set(ctx.lxc_configs) | set(ctx.qemu_configs) + if not local: + return None + + failed, unverified, verified, unreadable = [], [], 0, [] + for storage in destinations: + sid = storage["id"] + rc, out = ctx.run(["pvesh", "get", f"/nodes/{ctx.node}/storage/{sid}/content", + "--output-format", "json"], timeout=30) + if rc != 0: + unreadable.append(sid) + continue + try: + entries = json.loads(out) + if not isinstance(entries, list) or any(not isinstance(e, dict) for e in entries): + raise ValueError("invalid backup inventory") + except ValueError: + unreadable.append(sid) + continue + # Only the newest snapshot of each guest is judged: an older one + # that was never verified is history, not present protection. + newest: dict = {} + for entry in entries: + vmid = entry.get("vmid") + if vmid is None or entry.get("content") != "backup": + continue + try: + if int(vmid) not in local: + continue + except (TypeError, ValueError): + continue + when = entry.get("ctime") + if (str(vmid).isdigit() and type(when) in (int, float) + and math.isfinite(when) and 0 < when <= time.time() + 300): + vmid = int(vmid) + else: + unreadable.append(sid + " (snapshot identity/date not verified)") + continue + current = newest.get(vmid) + if current is None or when > current["ctime"]: + newest[vmid] = entry + + # Whether an earlier copy of the same guest did verify. A newest + # copy that failed while a verified one is still held is a + # different situation from one where nothing verified at all, + # and the same result told both stories. + fallback = set() + for entry in entries: + vmid = entry.get("vmid") + if vmid is None or entry.get("content") != "backup": + continue + try: + vmid = int(vmid) + except (TypeError, ValueError): + continue + if vmid not in newest or entry is newest.get(vmid): + continue + verification = entry.get("verification") + if isinstance(verification, dict) and str( + verification.get("state", "")).lower() == "ok": + fallback.add(vmid) + + for vmid, entry in sorted(newest.items()): + verification = entry.get("verification") + if verification is None: + state = "none" + elif isinstance(verification, dict) and isinstance(verification.get("state"), str): + state = verification["state"].lower() + else: + unreadable.append(sid + f" (verification not read for {vmid})") + continue + row = {"vmid": vmid, "storage": sid, + "volume": entry.get("volid"), "verification": state} + if state == "ok": + verified += 1 + elif state == "failed": + # Never critical: the audit performs no restore, so it + # cannot demonstrate that recovery is impossible. What it + # can say is whether anything else verified. + failed.append({**row, "classification": CLASS_WARNING, + "reason_key": "verificationFailedWithFallback" + if vmid in fallback else "verificationFailedOnly"}) + elif state in ("none", "pending"): + unverified.append({**row, "classification": CLASS_OBSERVATION, + "reason_key": "verificationNotRun"}) + else: + unreadable.append(sid + f" (unknown verification state for {vmid}: {state})") + + total = verified + len(failed) + len(unverified) + if not total and not unreadable: + return None + evidence = (f"destinations: {', '.join(s['id'] for s in destinations)}\n" + f"guests on this node: {len(local)}\n" + f"verified: {verified}\nfailed: {len(failed)}\n" + f"not verified: {len(unverified)}\n" + "Verification reads a stored copy back; it is not a restore test.") + if unreadable: + evidence += "\nNot read: " + ", ".join(unreadable) + + affected = failed + unverified + if not affected: + return {"classification": CLASS_UNVERIFIED if unreadable else CLASS_CONFORMANT, + "summary_key": "evaluationFailed" if unreadable else "allVerified", + "summary_params": {"total": str(verified)}, + "incomplete": bool(unreadable), "evidence": evidence} + return {"summary_key": "failed" if failed else "notVerified", + "summary_params": {"failed": str(len(failed)), + "pending": str(len(unverified)), "total": str(total)}, + "affected": affected, "incomplete": bool(unreadable), "evidence": evidence} + + +@register("backup.job_results", AREA_BACKUP, "WARNING") +def _backup_job_results(ctx): + """How the recorded backup runs ended. + + A schedule that fires and fails leaves the configuration looking + correct, and the age check only notices once the newest copy has + aged past its limit. The task log says what happened at the time. + """ + rc, out = ctx.run(["pvesh", "get", f"/nodes/{ctx.node}/tasks", + "--typefilter", "vzdump", "--limit", "200", "--output-format", "json"], timeout=30) + if rc != 0: + return _unverified(out) + try: + tasks = json.loads(out) + if not isinstance(tasks, list) or any(not isinstance(t, dict) for t in tasks): + raise ValueError("invalid task inventory") + except ValueError: + return _unverified(out) + + runs = [t for t in tasks if str(t.get("type", "")).startswith("vzdump")] + if not runs: + return None + + def guest_of(task): + # `/nodes//tasks` does not populate `id` consistently, + # although the same guest identifier is part of the UPID. + vmid = task.get("id") + upid = str(task.get("upid") or "") + if vmid in (None, "") and upid.startswith("UPID:"): + parts = upid.split(":") + if len(parts) > 6 and parts[6].isdigit(): + vmid = int(parts[6]) + return vmid + + # The last run is what describes the present. A failure four months + # ago followed by success every night since says the backup works, + # and counting both in one number said the opposite for as long as + # the task log kept the old one. + latest, unknown, recovered = {}, [], [] + for task in sorted(runs, key=lambda t: t.get("starttime") or 0): + status = str(task.get("status") or "") + if not status or status.lower() in ("running", "unknown"): + unknown.append(task.get("upid", "unknown task")) + continue + key = guest_of(task) + previous = latest.get(key) + if previous is not None and previous["status"] != "OK" and status == "OK": + recovered.append(previous) + latest[key] = {"status": status[:200], "when": task.get("starttime"), + "upid": str(task.get("upid") or ""), "vmid": key} + + failures = [{"job": r["upid"], "upid": r["upid"], "vmid": r["vmid"], + "status": r["status"], "when": r["when"], + "classification": CLASS_WARNING, "reason_key": "backupRunFailed"} + for r in latest.values() if r["status"] != "OK"] + # A failure the next run cleared is history, not a finding. + healed = [{"job": r["upid"], "upid": r["upid"], "vmid": r["vmid"], + "status": r["status"], "when": r["when"], + "classification": CLASS_OBSERVATION, "reason_key": "backupRunRecovered"} + for r in recovered] + + evidence = (f"recorded backup runs: {len(runs)}\n" + f"guests with a recorded run: {len(latest)}\n" + f"latest run failed for: {len(failures)}\n" + f"earlier failures a later run cleared: {len(recovered)}\n" + "Only the most recent run of each guest is graded: an earlier " + "failure followed by a success is not a current failure. Read " + "from the node's task log, which is retained for a limited " + "period; runs older than that are not visible here.") + if unknown: + evidence += "\nResults not verified: " + ", ".join(unknown) + if not failures: + if unknown and not latest: + return _unverified(evidence) + if healed: + return {"summary_key": "recovered", + "summary_params": {"count": str(len(healed)), + "total": str(len(latest))}, + "affected": healed, "incomplete": bool(unknown), + "evidence": evidence} + return {"classification": CLASS_CONFORMANT, "summary_key": "allSucceeded", + "summary_params": {"total": str(len(latest))}, "evidence": evidence} + return {"summary_key": "someFailed", + "summary_params": {"count": str(len(failures)), "total": str(len(latest))}, + "affected": failures + healed, "incomplete": bool(unknown), + "evidence": evidence} + + +@register("system.filesystem_capacity", AREA_SYSTEM, "CRITICAL") +def _filesystem_capacity(ctx): + """Space and inodes on the filesystems the host itself needs. + + PVE reports the capacity of its own storage; it says nothing about + the root filesystem, /var or /var/log, which is where a host stops + being able to write logs, take a snapshot or run an upgrade. Inodes + are read alongside: a filesystem with free space and no inodes left + fails exactly the same way, and nothing else here would see it. + """ + mounts = ["/", "/var", "/var/log", "/var/lib/vz"] + limit = ctx.policy.threshold("filesystem_usage_percent") + inode_limit = ctx.policy.threshold("filesystem_inode_percent") + + # A filesystem remounted read-only has already stopped accepting + # writes; nothing about its percentage says so. Read from the kernel + # rather than inferred from how full it looks. + readonly = set() + rc0, mountinfo = ctx.run(["findmnt", "-rno", "TARGET,OPTIONS"], + timeout=15, allowed_codes=(0, 1)) + for line in (mountinfo or "").splitlines(): + parts = line.split(None, 1) + if len(parts) == 2 and re.search(r"(^|,)ro(,|$)", parts[1]): + readonly.add(parts[0]) + + rc, out = ctx.run(["df", "--output=target,pcent,ipcent,size,avail"] + mounts, + timeout=15, allowed_codes=(0, 1)) + if rc not in (0, 1): + return _unverified(out) + rows, affected, seen = [], [], set() + incomplete = rc != 0 + for line in (out or "").splitlines()[1:]: + fields = line.rsplit(None, 4) + if len(fields) != 5: + incomplete = True + continue + target, used, inodes = fields[0], fields[1].rstrip("%"), fields[2].rstrip("%") + if (not target.startswith("/") or not used.isdigit() + or not (inodes.isdigit() or inodes == "-")): + incomplete = True + continue + if inodes == "-": + incomplete = True + # Several of the paths often live on one filesystem; reporting it + # once is the truth, four times is noise. + if target in seen: + continue + seen.add(target) + available = fields[4].strip() + exhausted = available.isdigit() and int(available) == 0 + row = {"mount": target, "used_percent": used, "inode_percent": inodes, + "available_kb": int(available) if available.isdigit() else None, + "read_only": target in readonly} + rows.append(row) + + # Crossing a review threshold is a risk; having nothing left is + # the failure itself. Ninety-one per cent and a hundred per cent + # are not the same event, and a fixed high percentage would not + # prove one either — zero bytes, no inodes or a read-only mount + # do. + if target in readonly: + affected.append({"mount": target, + "classification": CLASS_CRITICAL, + "reason_key": "filesystemReadOnly"}) + elif exhausted: + affected.append({"mount": target, "percent": int(used or 100), + "classification": CLASS_CRITICAL, + "reason_key": "filesystemExhausted"}) + elif used.isdigit() and int(used) >= limit: + affected.append({"mount": target, "percent": int(used), + "classification": CLASS_WARNING, + "reason_key": "filesystemNearlyFull"}) + if inodes.isdigit() and int(inodes) >= 100: + affected.append({"mount": target, "percent": int(inodes), + "classification": CLASS_CRITICAL, + "reason_key": "inodesExhausted"}) + elif inodes.isdigit() and int(inodes) >= inode_limit: + affected.append({"mount": target, "percent": int(inodes), + "classification": CLASS_WARNING, + "reason_key": "inodesNearlyExhausted"}) + if not rows: + return _unverified(out or "No filesystem readings") + + evidence = json.dumps(rows, indent=2) + ( + f"\nReview thresholds: {limit:g}% space, {inode_limit:g}% inodes. " + "A critical result is not a higher percentage: it is no space " + "left, no inodes left, or a mount the kernel reports read-only.") + if not affected: + if incomplete: + return _unverified(evidence) + return {"classification": CLASS_CONFORMANT, "summary_key": "withinLimits", + "summary_params": {"total": str(len(rows))}, "evidence": evidence} + return {"summary_key": "pressure", + "summary_params": {"count": str(len(affected))}, + "affected": affected, "observations": rows, "incomplete": incomplete, "evidence": evidence} + + +@register("storage.pool_integrity", AREA_STORAGE, "CRITICAL") +def _pool_integrity(ctx): + """Redundancy and error counters of each ZFS pool. + + Age of a disk is planning information; a pool that is degraded, or + that is counting read, write or checksum errors, is the thing that + actually threatens the data on it. Both the pool state and the + counters come from the same `zpool status` this catalogue already + reads for scrub age. + """ + if not Path("/sys/module/zfs").exists(): + return None + rc, out = ctx.run(["zpool", "list", "-H", "-o", "name,health"], timeout=20) + if rc != 0: + return _unverified(out) + pools = [line.split("\t") for line in (out or "").splitlines() if line.strip()] + if not pools: + return None + + affected, rows, unreadable = [], [], [] + for entry in pools: + name = entry[0] + health = entry[1] if len(entry) > 1 else "UNKNOWN" + rc2, status = ctx.run(["zpool", "status", name], timeout=25) + if rc2 != 0 or not re.search(r"^\s*state:\s*\S+", status, re.M): + unreadable.append(name) + status = "" + errors = [] + for line in (status or "").splitlines(): + # Device lines carry three counters; anything non-zero is a + # device that has been having trouble. + m = re.match(r"^\s+(\S+)\s+(\S+)\s+(\d+)\s+(\d+)\s+(\d+)\s*$", line) + if m and any(int(m.group(i)) for i in (3, 4, 5)): + errors.append({"device": m.group(1), "state": m.group(2), + "read": int(m.group(3)), "write": int(m.group(4)), + "checksum": int(m.group(5))}) + rows.append({"pool": name, "health": health, "devices_with_errors": errors}) + + if health not in ("ONLINE", "DEGRADED", "FAULTED", "UNAVAIL", "REMOVED", "OFFLINE", "SUSPENDED"): + unreadable.append(name + " (unknown health)") + elif health != "ONLINE": + affected.append({"pool": name, "state": health, + "classification": CLASS_CRITICAL if health in + ("FAULTED", "UNAVAIL", "REMOVED") else CLASS_WARNING, + "reason_key": "poolNotOnline"}) + for device in errors: + affected.append({"pool": name, "device": device["device"], + "read": device["read"], "write": device["write"], + "checksum": device["checksum"], + "classification": CLASS_WARNING, + "reason_key": "poolDeviceErrors"}) + + evidence = json.dumps(rows, indent=2) + ( + "\nCounters are cumulative since the last `zpool clear`; a non-zero " + "count is a device that had trouble, not necessarily one having it now.") + if unreadable: + evidence += "\nNot verified: " + ", ".join(unreadable) + if not affected: + if unreadable: + return _unverified(evidence) + return {"classification": CLASS_CONFORMANT, "summary_key": "healthy", + "summary_params": {"total": str(len(pools))}, "evidence": evidence} + return {"summary_key": "degraded", + "summary_params": {"count": str(len(affected)), "total": str(len(pools))}, + "affected": affected, "observations": rows, "incomplete": bool(unreadable), "evidence": evidence} + + +@register("system.update_chain", AREA_SYSTEM, "WARNING") +def _update_chain(ctx): + """How recently the host learned what updates exist. + + An empty list of pending updates means one of two things: the host is + current, or nothing has told it otherwise in weeks. They look + identical from the package list alone, so what is read here is when + apt last rebuilt its picture — every update check in this catalogue + inherits the age of that picture. + + Whether each repository can still be reached is not tested: finding + out means refreshing the indexes, and an assessment that only reads + does not do that. + """ + # pkgcache.bin is rebuilt from files already on disk, so its date + # says nothing about contacting a repository. These three do, in + # descending order of how directly they say it. The success stamp is + # written by apt's own periodic job and is absent on a plain Proxmox + # install, which is why it cannot be the only source: relying on it + # alone left this check unverifiable on every host that never + # installed unattended-upgrades. + # An index file's own mtime is the date the repository published it — + # identical on every host that fetched the same file — so it says + # nothing about this host. Its ctime is when apt put it here, which + # only happens when a fetch succeeded. The partial directory is + # touched by the attempt itself, so it proves apt tried and not that + # anything arrived. + lists = Path("/var/lib/apt/lists") + newest, origin, meaning = 0.0, "", "" + for index in (list(lists.glob("*_InRelease")) + list(lists.glob("*_Release")) + + list(lists.glob("*_Packages")) if lists.is_dir() else []): + try: + stamp = index.stat().st_ctime + except OSError: + continue + if stamp > newest: + newest, origin = stamp, str(index) + meaning = "when apt last installed an index file here" + for path, description in ( + (Path("/var/lib/apt/periodic/update-success-stamp"), + "a refresh apt recorded as successful"), + (Path("/var/lib/apt/lists"), + "the last time a file was added or replaced in the index directory")): + try: + stamp = path.stat().st_mtime + except OSError: + continue + if stamp > newest: + newest, origin, meaning = stamp, str(path), description + + attempted = None + try: + attempted = (lists / "partial").stat().st_mtime + except OSError: + pass + if not newest: + return {"classification": CLASS_UNVERIFIED, "summary_key": "indexAgeUnknown", + "incomplete": True, + "evidence": "No index file in /var/lib/apt/lists carries a " + "placement date, and neither the directory nor " + "apt's success stamp could be read."} + + age_days = (time.time() - newest) / 86400 + if age_days < 0: + return _unverified("APT refresh timestamp is in the future", summary_key="indexAgeUnknown") + limit = ctx.policy.threshold("package_index_days") + evidence = (f"package indexes last refreshed: " + f"{time.strftime('%Y-%m-%d %H:%M', time.localtime(newest))}\n" + f"age: {age_days:.1f} day(s)\nread from: {origin}\n" + f"what that date is: {meaning}\n" + + (f"last fetch apt started: " + f"{time.strftime('%Y-%m-%d %H:%M', time.localtime(attempted))}" + " (an attempt, not a result)\n" if attempted else "") + + "A repository that answers \"not modified\" leaves its index " + "untouched, so this is when an index last changed here rather " + "than when apt last succeeded. Repository reachability is not " + "tested: establishing it would mean refreshing the indexes, " + "which this assessment does not do.") + if age_days < limit: + return {"classification": CLASS_CONFORMANT, "summary_key": "current", + "summary_params": {"days": str(int(age_days))}, "evidence": evidence} + return {"summary_key": "stale", "summary_params": {"days": str(int(age_days))}, + "affected": [{"indexes": "apt", "days": int(age_days), + "classification": CLASS_WARNING, + "reason_key": "indexesStale"}], + "evidence": evidence} + + +@register("system.notification_delivery", AREA_SYSTEM, "WARNING") +def _notification_delivery(ctx): + """Observed delivery outcomes for currently enabled channels; never sends.""" + server = sys.modules.get("flask_server") or sys.modules.get("__main__") + manager = getattr(server, "notification_manager", None) + lister = getattr(manager, "list_channels", None) + if not callable(lister): + return _unverified("Notification configuration is unavailable") + try: + payload = lister() + channels = payload.get("channels") if isinstance(payload, dict) else None + if not isinstance(channels, dict) or payload.get("error"): + raise ValueError("Notification configuration is unreadable") + except Exception as exc: + return _unverified(exc) + enabled = {name: info for name, info in channels.items() + if isinstance(info, dict) and info.get("enabled")} + if not enabled: + return {"classification": CLASS_OBSERVATION, "summary_key": "noChannels", + "evidence": "No notification channel is enabled.\nConfigured channel types: " + + (", ".join(sorted(channels)) or "(none)")} + + affected, observations, unreadable = [], [], [] + reader = getattr(manager, "get_history", None) + for name, info in enabled.items(): + row = {"channel": name, "configured": bool(info.get("configured"))} + observations.append(row) + if not info.get("configured"): + affected.append({"channel": name, "classification": CLASS_WARNING, + "reason_key": "channelIncomplete"}) + continue + try: + if not callable(reader): + raise ValueError("Notification history is unavailable") + payload = reader(limit=100, channel=name) + history = payload.get("history") if isinstance(payload, dict) else None + if payload.get("error") or not isinstance(history, list): + raise ValueError("Notification history is unreadable") + history = [r for r in history if isinstance(r, dict) and r.get("channel") == name] + if not history: + raise ValueError("No retained delivery for this enabled channel") + latest = history[0] # get_history guarantees descending sent_at. + if type(latest.get("success")) not in (bool, int) or latest["success"] not in (0, 1): + raise ValueError("Unrecognised delivery result") + row.update(last_success=bool(latest["success"]), when=latest.get("sent_at"), + records_examined=len(history)) + if not latest["success"]: + affected.append({"channel": name, "classification": CLASS_WARNING, + "reason_key": "deliveryFailing", "when": latest.get("sent_at"), + "last_error": str(latest.get("error_message") or "")[:200]}) + except Exception as exc: + row["not_verified"] = str(exc) + unreadable.append(name) + evidence = json.dumps(observations, indent=2) + ( + "\nLatest retained outcome per enabled channel; not a delivery test " + "or a guarantee of future delivery. Disabled channels are outside scope.") + if affected: + return {"summary_key": "failing", + "summary_params": {"count": str(len(affected)), "total": str(len(enabled))}, + "affected": affected, "observations": observations, + "incomplete": bool(unreadable), "evidence": evidence} + if unreadable: + return _unverified(evidence, observations=observations) + return {"classification": CLASS_CONFORMANT, "summary_key": "delivering", + "summary_params": {"total": str(len(enabled))}, + "observations": observations, "evidence": evidence} + + +HB_STATE_DIR = "/usr/local/share/proxmenux" +DUMP_DIR = "/var/lib/vz/dump" + + +def _escrow_mode() -> str: + """How the site has chosen to protect the backup encryption key. + + Only the recorded mode is read. The keyfile itself is never opened + and its content never leaves this function's absence. + """ + try: + value = (Path(HB_STATE_DIR) / "pbs-key.mode").read_text().strip() + except OSError: + return "full" # absent means an install from before the setting + return value if value in ("none", "local", "full") else "full" + + +def _host_backup_jobs() -> list[dict]: + """What ProxMenux recorded about the host backups it ran. + + Its own job log is the authority here, not a directory listing. The + runner writes wherever the job's backend says — the local dump + directory, a mounted share, a backup server — and names the archive + after the job. Looking for files called ``hostcfg-*`` in one + directory therefore missed every manual job and every job whose + destination was somewhere else, and then reported their absence as + the absence of any backup at all. + """ + log_dir = Path("/var/log/proxmenux/backup-jobs") + if not log_dir.is_dir(): + return [] + jobs = [] + for status in sorted(log_dir.glob("*-last.status")): + try: + fields = dict( + line.split("=", 1) for line in status.read_text(errors="replace").splitlines() + if "=" in line) + except OSError: + continue + archive, backend, profile = "", "", "" + log_path = fields.get("LOG_FILE", "").strip() + if log_path: + try: + for line in Path(log_path).read_text(errors="replace").splitlines(): + if line.startswith("LOCAL_ARCHIVE="): + archive = line.split("=", 1)[1].strip() + elif line.startswith("Backend:"): + backend = line.split(":", 1)[1].strip() + elif line.startswith("Profile:"): + profile = line.split(":", 1)[1].strip() + except OSError: + pass + jobs.append({ + "job": fields.get("JOB_ID", status.stem).strip(), + "run_at": fields.get("RUN_AT", "").strip(), + "result": fields.get("RESULT", "").strip(), + "backend": backend, "profile": profile, + "archive": archive, + # A job whose destination is a backup server names no local + # path: absent is not the same as unreadable from here. + "stored": Path(archive).is_file() if archive else None, + }) + return jobs + + +@register("backup.host_recovery", AREA_BACKUP, "CRITICAL", budget=40) +def _host_recovery(ctx): + """Whether this node could be rebuilt, not just its guests. + + Guest backups restore workloads onto a working node. They do not + restore the node: the storage definitions that say where those + guests live, the network that reaches them, the cluster membership, + the certificates. + """ + jobs = _host_backup_jobs() + + # Sidecars describe archives the local dump directory holds, and add + # the size the job log does not record. Archives already named by a + # job record are not repeated. + dump_dir = Path(DUMP_DIR) + known = {j["archive"] for j in jobs if j["archive"]} + if dump_dir.is_dir(): + for sidecar in sorted(dump_dir.glob("*.proxmenux.json"), key=lambda f: f.name): + try: + meta = json.loads(sidecar.read_text(errors="replace")) + except (OSError, ValueError): + continue + name = str(meta.get("archive") or "") + path = str(dump_dir / name) if name else "" + if not path or path in known: + continue + jobs.append({ + "job": meta.get("job_id") or meta.get("kind") or sidecar.name, + "run_at": meta.get("created_at", ""), "result": "ok", + "backend": "local", "profile": meta.get("profile") or "", + "archive": path, "size_bytes": meta.get("archive_size"), + "stored": (dump_dir / name).is_file(), + }) + + rc, timers = ctx.run(["systemctl", "list-timers", "--all", "--no-pager"], + timeout=20, allowed_codes=(0, 1)) + scheduled = sorted({part for line in (timers or "").splitlines() + for part in line.split() + if part.startswith("proxmenux-backup-") + and part.endswith(".timer")}) + + # Encryption is reported through the mode the site recorded. Whether + # a key exists bears on recoverability; what it contains does not. + keys = sorted(p.name for p in Path("/etc/pve/priv/storage").glob("*.enc")) \ + if Path("/etc/pve/priv/storage").is_dir() else [] + if (Path(HB_STATE_DIR) / "pbs-key.conf").is_file(): + keys.append("pbs-key.conf") + mode = _escrow_mode() if keys else "" + + now = time.time() + for job in jobs: + job["days"] = None + age = _event_age_days(job.get("run_at"), now) + if age is not None: + job["days"] = round(age, 1) + + limit_days = ctx.policy.threshold("backup_fallback_days") + # A copy this check can still account for: stored where it said, or + # sent to a destination it cannot read but has no evidence against. + retrievable = [j for j in jobs + if j["result"] == "ok" and j["stored"] is not False + and j["days"] is not None] + newest = min(retrievable, key=lambda j: j["days"]) if retrievable else None + + evidence = json.dumps({"jobs": jobs, "scheduled_timers": scheduled, + "encryption_keys_present": len(keys), + "key_escrow_mode": mode or None, + "age_limit_days": limit_days}, + indent=2, ensure_ascii=False, default=str) + evidence += ("\nRead from the job records ProxMenux writes for every host " + "backup it runs, and from the sidecars in " + DUMP_DIR + ". A " + "job whose destination is a backup server names no local " + "path, so whether its copy is still held there is not " + "established from this node. Restoring the node's own " + "configuration is a separate operation from restoring a " + "guest, and neither the presence of an archive nor this " + "check is a restore test. Encryption keys are reported by " + "count and recorded escrow mode only; no key is read.") + + if not jobs: + if scheduled: + return {"classification": CLASS_OBSERVATION, + "summary_key": "scheduledOnly", + "summary_params": {"count": str(len(scheduled))}, + "evidence": evidence} + return {"classification": CLASS_WARNING, "summary_key": "noHostBackup", + "evidence": evidence} + + affected = [] + for job in jobs: + name = job["archive"].rsplit("/", 1)[-1] or job["job"] + if job["result"] and job["result"] != "ok": + affected.append({"name": name, "job": job["job"], + "classification": CLASS_WARNING, + "reason_key": "hostBackupJobFailed"}) + elif job["stored"] is False: + # Recorded as produced and no longer at the path it named: + # bookkeeping left behind by a copy removed elsewhere. + affected.append({"name": name, "job": job["job"], + "classification": CLASS_OBSERVATION, + "reason_key": "hostArchiveMissing"}) + + if newest is None: + affected.append({"name": "hostcfg", "classification": CLASS_WARNING, + "reason_key": "hostNoRetrievableCopy"}) + elif newest["days"] > limit_days: + affected.append({"name": newest["archive"].rsplit("/", 1)[-1] or newest["job"], + "hours": newest["days"] * 24, + "classification": CLASS_WARNING, + "reason_key": "hostBackupStale"}) + if not scheduled: + affected.append({"name": "hostcfg", "classification": CLASS_OBSERVATION, + "reason_key": "hostBackupUnscheduled"}) + if keys and mode == "none": + affected.append({"name": "pbs-key", "classification": CLASS_OBSERVATION, + "reason_key": "recoveryKeyLocalOnly"}) + + if not affected: + return {"classification": CLASS_CONFORMANT, "summary_key": "protected", + "summary_params": {"total": str(len(retrievable))}, + "observations": jobs, "evidence": evidence} + return {"summary_key": "attention", + "summary_params": {"count": str(len(affected)), + "total": str(len(jobs))}, + "affected": affected, "observations": jobs, "evidence": evidence} + + +@register("system.cluster_quorum", AREA_SYSTEM, "CRITICAL", budget=40) +def _cluster_quorum(ctx): + """Quorum, membership and the redundancy of what carries them. + + A node that loses quorum keeps its guests running and stops being + able to change anything: no start, no migration, no write to the + cluster filesystem. Corosync on a single link means one switch, one + cable or one NIC decides whether the cluster stays whole, which is + worth stating while everything still works rather than after. + """ + conf = Path("/etc/pve/corosync.conf") + if not conf.exists(): + conf = Path("/etc/corosync/corosync.conf") + if not conf.exists(): + return {"classification": CLASS_NOT_APPLICABLE, + "summary_key": "standalone", + "evidence": "No corosync configuration is present: this node " + "is not a member of a cluster."} + + try: + text = conf.read_text(errors="replace") + except OSError as exc: + return _unverified(exc) + + # Nodes the cluster is configured to have, and the links it is + # configured to carry them over. + configured = re.findall(r"\bname:\s*(\S+)", text) + rings = len({m for m in re.findall(r"ring(\d+)_addr", text)}) or 1 + + rc, status = ctx.run(["pvecm", "status"], timeout=20, allowed_codes=(0, 2)) + quorate = expected = total = None + for line in (status or "").splitlines(): + key, _, value = line.partition(":") + key, value = key.strip().lower(), value.strip() + if key == "quorate": + quorate = value.lower() == "yes" + elif key == "expected votes": + expected = value + elif key == "total votes": + total = value + + # Members corosync currently sees. A configured node that is absent + # is not a spare: it is a vote the cluster is not counting. + seen = set() + rc2, members = ctx.run(["pvecm", "nodes"], timeout=20, allowed_codes=(0, 2)) + for line in (members or "").splitlines(): + parts = line.split() + if len(parts) >= 3 and parts[0].isdigit(): + seen.add(parts[-2] if parts[-1] == "(local)" else parts[-1]) + + evidence = (f"quorate: {quorate}\nexpected votes: {expected}\n" + f"total votes: {total}\n" + f"nodes configured: {len(configured)} ({', '.join(configured)})\n" + f"nodes seen: {len(seen)} ({', '.join(sorted(seen))})\n" + f"corosync links: {rings}\n" + "Losing quorum leaves running guests running and blocks every " + "change to the cluster. Link count is read from the " + "configuration; the links are not probed.") + + if quorate is None: + return _unverified(evidence) + + affected = [] + if not quorate: + affected.append({"name": ctx.node, "classification": CLASS_CRITICAL, + "reason_key": "clusterInquorate"}) + for name in configured: + if seen and name not in seen: + affected.append({"name": name, "classification": CLASS_WARNING, + "reason_key": "clusterMemberAbsent"}) + if rings < 2 and len(configured) > 1: + # One link is a working cluster with a single point of failure: + # a fact about how it was built, not a fault in how it runs. + affected.append({"name": "corosync", "classification": CLASS_OBSERVATION, + "reason_key": "clusterSingleLink"}) + + if not affected: + return {"classification": CLASS_CONFORMANT, "summary_key": "quorate", + "summary_params": {"total": str(len(configured)), + "links": str(rings)}, + "evidence": evidence} + return {"summary_key": "attention", + "summary_params": {"count": str(len(affected)), + "total": str(len(configured))}, + "affected": affected, "evidence": evidence} + + +def _event_age_days(value, now: float): + """Age of a recorded event, whichever way the store wrote its date. + + The observation log keeps ISO strings, while other Monitor tables + keep epoch seconds. Reading only one of the two silently produced an + unknown age, and an unknown age turns an error happening this + morning into one that stopped months ago. + """ + if value in (None, ""): + return None + try: + return (now - float(value)) / 86400 + except (TypeError, ValueError): + pass + try: + text = str(value).replace("Z", "+00:00") + stamp = datetime.fromisoformat(text) + if stamp.tzinfo is not None: + stamp = stamp.astimezone().replace(tzinfo=None) + return (now - time.mktime(stamp.timetuple())) / 86400 + except (TypeError, ValueError): + return None + + +def _recorded_disk_events() -> list[dict]: + """Disk events the Monitor recorded, flattened per device. + + Read from the same store the inventory prints, so the assessment and + the inventory can never disagree about what happened to a disk. + """ + server = sys.modules.get("flask_server") or sys.modules.get("__main__") + getter = getattr(getattr(server, "health_persistence", None), + "get_disk_observations", None) + if getter is None: + return [] + try: + return list(getter() or []) + except Exception: + return [] + + +@register("hardware.disk_errors", AREA_HARDWARE, "WARNING", budget=20) +def _disk_errors(ctx): + """Historical disk events recorded independently of current SMART health. + + SMART answers "does the device consider itself healthy", and it + keeps answering yes while the kernel logs read failures. ProxMenux + keeps this log because those failures happened and nothing else was + writing them down. + + These recorded events are warnings because their occurrence was + verified. They do not override the device's current + SMART self-assessment or Proxmox's current health result, and they do + not assert that the disk is presently failing. + """ + records = _recorded_disk_events() + if not records: + # An empty store and a store whose entries were all dismissed + # look the same from here, and neither supports the claim that + # no disk reported an error. Saying which of the two it is is + # not possible; saying there is nothing to grade is. + return {"classification": CLASS_NOT_APPLICABLE, + "summary_key": "noEvents", + "evidence": "The health monitor holds no undismissed disk " + "event. A dismissed observation is dropped by " + "the monitor and is not read back here."} + + now = time.time() + recent_days = ctx.policy.threshold("disk_error_recent_days") + affected, observations = [], [] + for record in records: + device = (record.get("device_name") or "").replace("/dev/", "") + if not device: + continue + last = record.get("last_occurrence") + age_days = _event_age_days(last, now) + severity = str(record.get("severity") or "").upper() + count = record.get("occurrence_count") or 0 + active = age_days is not None and age_days <= recent_days + + + # Carries what the inventory's own observation table shows, so + # the finding and the inventory read as one account of the disk + # rather than as a summary and a table that repeat each other. + row = {"name": device, "type": record.get("error_type", ""), + "severity": severity.lower(), "count": count, + "first_seen": record.get("first_occurrence"), "last_seen": last, + "message": (record.get("raw_message") or "")[:400], + "days": None if age_days is None else round(age_days, 1)} + observations.append(row) + + severe = severity == "CRITICAL" + affected.append({**row, "classification": CLASS_WARNING, + "reason_key": ("diskErrorsActive" if severe and active else + "diskErrorsPast" if severe else + "diskWarningsActive" if active else + "diskWarningsPast")}) + + devices = sorted({r["name"] for r in observations}) + evidence = json.dumps({"devices": devices, "events": observations, + "recent_within_days": recent_days}, + indent=2, ensure_ascii=False, default=str) + evidence += ("\nRecorded because Linux reported them; neither SMART nor " + "Proxmox surfaces these, and both may report the device as " + "healthy. Stated as separate warnings without overriding " + "that current health result. Events the reader dismissed are not " + "listed: the " + "monitor drops them and this reads what the monitor keeps. " + "Recorded by the health monitor as events occurred. SMART " + "reports the device's present opinion of itself and is read " + "by a separate check; a device can report healthy while its " + "reads fail. Counts are cumulative since the record was " + "opened, not a rate.") + + # Every retained record becomes a row, so there is no path to a + # conformant result here: a host with nothing recorded returned not + # applicable above. Claiming "no disk reported an error" would be an + # assertion this check never gets to make. + with_events = sorted({a["name"] for a in affected}) + return {"classification": CLASS_WARNING, "summary_key": "recorded", + "summary_params": {"count": str(len(with_events)), + "total": str(len(devices))}, + "affected": affected, "observations": observations, + "evidence": evidence} + + +@register("system.boot_loader", AREA_SYSTEM, "WARNING", budget=25) +def _boot_loader(ctx): + """Whether the loader that would start the kernel is on every disk. + + The kernel check reads which version the host would boot and says, + in as many words, that it does not verify the boot loader's + installation. This is that half. Proxmox keeps one EFI partition per + boot disk and synchronises the kernels into all of them, so the + machine survives losing any one of them. When one falls behind, the + redundancy is nominal: the surviving disk boots an older kernel, or + does not boot. + + A host that does not use proxmox-boot-tool keeps its loader + elsewhere and there is nothing here to compare. + """ + if not Path("/etc/kernel/proxmox-boot-uuids").exists(): + return None + + rc, out = ctx.run(["proxmox-boot-tool", "status"], timeout=25, + allowed_codes=(0, 1)) + booted = "" + partitions, problems = [], [] + for line in (out or "").splitlines(): + line = line.strip() + if line.startswith("System currently booted with"): + booted = line.rsplit(" ", 1)[-1] + continue + # ` is configured with: (versions: a, b, c)` + m = re.match(r"^([0-9A-Fa-f-]+)\s+is configured with:\s*(\S+)" + r"(?:\s*\(versions:\s*(.*?)\))?\s*$", line) + if m: + partitions.append({ + "partition": m.group(1), "mode": m.group(2), + "versions": [v.strip() for v in (m.group(3) or "").split(",") if v.strip()], + }) + elif line.startswith("E:") or " is " in line and "configured" not in line: + problems.append(line) + + if not partitions: + return _unverified( + "proxmox-boot-tool reported no configured partition.\n" + (out or "").strip()) + + rc2, running = ctx.run(["uname", "-r"]) + running = (running or "").strip() + sets = {tuple(sorted(p["versions"])) for p in partitions} + newest = max((v for p in partitions for v in p["versions"]), + key=_version_key, default="") + + evidence = json.dumps({"booted_with": booted or None, "partitions": partitions, + "running_kernel": running, "messages": problems}, + indent=2, ensure_ascii=False) + evidence += ("\nEach partition is an EFI system partition Proxmox keeps in " + "step so the host survives losing any one boot disk. The " + "kernel each would start is read from the tool's own report; " + "no partition is mounted and no boot is attempted.") + + affected = [] + for message in problems: + affected.append({"name": "proxmox-boot-tool", "detail": message, + "classification": CLASS_WARNING, + "reason_key": "bootToolReported"}) + if len(sets) > 1: + # Redundancy that only exists on paper: the surviving disk would + # start something other than what this one would. + for entry in partitions: + if tuple(sorted(entry["versions"])) != tuple(sorted( + max(sets, key=len))): + affected.append({"name": entry["partition"], + "classification": CLASS_WARNING, + "reason_key": "bootEspOutOfSync"}) + for entry in partitions: + if newest and newest not in entry["versions"]: + affected.append({"name": entry["partition"], + "classification": CLASS_WARNING, + "reason_key": "bootEspMissingNewest"}) + if len(partitions) == 1: + affected.append({"name": partitions[0]["partition"], + "classification": CLASS_OBSERVATION, + "reason_key": "bootSingleEsp"}) + + # Duplicates arise when a partition is both out of step and missing + # the newest kernel, which is one fact told twice. + seen, unique = set(), [] + for row in affected: + key = (row["name"], row["reason_key"]) + if key not in seen: + seen.add(key) + unique.append(row) + + if not unique: + return {"classification": CLASS_CONFORMANT, "summary_key": "synchronised", + "summary_params": {"total": str(len(partitions))}, + "observations": partitions, "evidence": evidence} + return {"summary_key": "attention", + "summary_params": {"count": str(len({r["name"] for r in unique})), + "total": str(len(partitions))}, + "affected": unique, "observations": partitions, "evidence": evidence} + + +# The services Proxmox needs to answer at all. A node whose pvedaemon is +# down still runs its guests and stops being manageable, which no other +# check here would notice. +PVE_ESSENTIAL = ("pve-cluster", "pvedaemon", "pveproxy", "pvestatd") + + +@register("system.failed_units", AREA_SYSTEM, "CRITICAL", budget=25) +def _failed_units(ctx): + """Units systemd has given up on, and the ones Proxmox needs. + + A failed unit is not an opinion: systemd tried, exhausted its + restarts and stopped. Most of what fails on a host is peripheral, + so the list is reported as it stands — except for the services that + answer the API and hold the cluster filesystem, where a node that + keeps its guests running while refusing every management operation + looks healthy from every other angle. + """ + rc, out = ctx.run(["systemctl", "list-units", "--state=failed", + "--no-legend", "--no-pager", "--plain"], + timeout=20, allowed_codes=(0, 1)) + if rc not in (0, 1): + return _unverified(out) + + failed = [] + for line in (out or "").splitlines(): + parts = line.split(None, 4) + if len(parts) >= 4 and parts[0].endswith((".service", ".socket", ".mount", + ".timer", ".target", ".path")): + failed.append({"unit": parts[0], "load": parts[1], + "active": parts[2], "sub": parts[3], + "description": parts[4] if len(parts) > 4 else ""}) + + # Asked separately: an essential service can be inactive without + # systemd counting it as failed, and that is the same outcome. + # `systemctl is-active` prints one word per unit and exits non-zero + # when any is not active. Anything else — a usage error, a message + # about a unit it could not find — is prose, and zipping prose onto + # the service names turned every word of it into a critical finding. + KNOWN = {"active", "inactive", "failed", "activating", "deactivating", + "reloading", "unknown", "maintenance"} + rc2, states = ctx.run(["systemctl", "is-active", *PVE_ESSENTIAL], + timeout=20, allowed_codes=(0, 1, 3)) + tokens = (states or "").split() + essential, essential_error = {}, "" + if len(tokens) == len(PVE_ESSENTIAL) and all(t in KNOWN for t in tokens): + essential = dict(zip(PVE_ESSENTIAL, tokens)) + else: + essential_error = (states or "").strip()[:300] or "no state was returned" + + evidence = json.dumps({"failed_units": failed, + "essential_services": essential or None, + "essential_services_error": essential_error or None}, + indent=2, ensure_ascii=False) + evidence += ("\nA failed unit is one systemd stopped retrying, not one that " + "reported an error and recovered. The essential services are " + "read by name because an inactive one is not always a failed " + "one, and the outcome is the same. What each unit does is not " + "interpreted here.") + + affected = [] + for unit, state in essential.items(): + if state and state != "active": + affected.append({"name": unit, "state": state, + "classification": CLASS_CRITICAL, + "reason_key": "essentialServiceDown"}) + for entry in failed: + if entry["unit"].split(".")[0] in PVE_ESSENTIAL: + continue + affected.append({"name": entry["unit"], "detail": entry["description"], + "classification": CLASS_WARNING, + "reason_key": "unitFailed"}) + + if essential_error and not affected: + return _unverified(evidence) + if not affected: + return {"classification": CLASS_CONFORMANT, "summary_key": "allRunning", + "summary_params": {"total": str(len(essential))}, + "evidence": evidence} + return {"summary_key": "attention", + "summary_params": {"count": str(len(affected))}, + "affected": affected, "observations": failed, + "incomplete": bool(essential_error), "evidence": evidence} + + +@register("storage.ceph_health", AREA_STORAGE, "CRITICAL", budget=30) +def _ceph_health(ctx): + """Ceph's own verdict on itself, and the checks behind it. + + Ceph already grades its own state and does it far better than + anything read from outside could: it knows which placement groups + are short of replicas and which OSDs stopped answering. What is + added here is putting that verdict where the rest of the host's + state is read, with the named checks that produced it, so a cluster + in HEALTH_WARN does not go unseen because nobody opened its + dashboard. Its tests are not reimplemented. + + A node with no Ceph configuration has nothing to report: the client + binary ships with Proxmox whether or not a cluster was ever created. + """ + if not Path("/etc/pve/ceph.conf").exists(): + return None + + rc, out = ctx.run(["ceph", "-s", "--format", "json"], timeout=30, + allowed_codes=(0, 1)) + if rc != 0: + return _unverified(f"ceph status could not be read: {(out or '').strip()[:400]}") + try: + status = json.loads(out) + health = status.get("health") or {} + state = str(health.get("status") or "") + except (ValueError, AttributeError): + return _unverified(out) + if not state: + return _unverified(out) + + named = health.get("checks") or {} + rows = [] + for key, body in (named.items() if isinstance(named, dict) else []): + summary = "" + if isinstance(body, dict): + summary = str((body.get("summary") or {}).get("message") or "") + rows.append({"name": key, "severity": str((body or {}).get("severity", "")), + "message": summary[:300]}) + + evidence = json.dumps({"status": state, "checks": rows, + "monitors": (status.get("quorum_names") or []), + "osds": (status.get("osdmap") or {})}, + indent=2, ensure_ascii=False, default=str) + evidence += ("\nCeph's own health verdict and the checks it named. Its " + "tests are not reimplemented here and no pool, placement " + "group or OSD is queried separately.") + + if state == "HEALTH_OK": + return {"classification": CLASS_CONFORMANT, "summary_key": "healthy", + "evidence": evidence} + gravity = CLASS_CRITICAL if state == "HEALTH_ERR" else CLASS_WARNING + affected = [{"name": r["name"], "detail": r["message"], + "classification": (CLASS_CRITICAL + if str(r["severity"]).upper().endswith("ERR") + else CLASS_WARNING), + "reason_key": "cephCheckRaised"} for r in rows] + if not affected: + affected = [{"name": state, "classification": gravity, + "reason_key": "cephCheckRaised"}] + return {"summary_key": "degraded", + "summary_params": {"state": state, "count": str(len(affected))}, + "affected": affected, "evidence": evidence} + + +@register("storage.array_integrity", AREA_STORAGE, "CRITICAL", budget=25) +def _array_integrity(ctx): + """Redundancy below the filesystems: software RAID and multipath. + + ZFS pools have their own check. What neither it nor PVE reports is + an mdadm array running on fewer devices than it was built with, or a + multipath map down to its last path — both of which keep serving + while the redundancy they exist for is gone, and neither of which + appears anywhere else in this report. + """ + arrays, paths = [], [] + + try: + mdstat = Path("/proc/mdstat").read_text(errors="replace") + except OSError: + mdstat = "" + current = None + for line in mdstat.splitlines(): + header = re.match(r"^(md\d+)\s*:\s*(\S+)\s+(\S+)", line) + if header: + current = {"array": header.group(1), "state": header.group(2), + "level": header.group(3), "devices": "", "healthy": None, + "rebuilding": False} + arrays.append(current) + continue + if current is None: + continue + # ` 2929890304 blocks super 1.2 [3/2] [UU_]` + blocks = re.search(r"\[(\d+)/(\d+)\]\s+\[([U_]+)\]", line) + if blocks: + current["devices"] = f"{blocks.group(2)}/{blocks.group(1)}" + current["healthy"] = "_" not in blocks.group(3) + current["present"] = int(blocks.group(2)) + current["expected"] = int(blocks.group(1)) + if re.search(r"(resync|recovery|reshape)\s*=", line): + current["rebuilding"] = True + + # Asked of the filesystem rather than of a shell. `command -v` exits + # 127 when the tool is absent, which the runner recorded as a failed + # source — turning the ordinary case, a host with neither RAID nor + # multipath, into an unverified finding with an error attached. + if any((Path(d) / "multipath").exists() + for d in ("/sbin", "/usr/sbin", "/bin", "/usr/bin")): + rc, out = ctx.run(["multipath", "-ll"], timeout=25, allowed_codes=(0, 1)) + mapname = None + for line in (out or "").splitlines(): + if line and not line[0].isspace() and not line.startswith(("size=", "|", "`")): + mapname = line.split()[0] + paths.append({"map": mapname, "active": 0, "failed": 0}) + elif paths and re.search(r"\b(active|failed|faulty|offline|shaky)\b", line): + if re.search(r"\b(failed|faulty|offline)\b", line): + paths[-1]["failed"] += 1 + elif re.search(r"\bactive\b", line) and ":" in line: + paths[-1]["active"] += 1 + + if not arrays and not paths: + return None + + evidence = json.dumps({"md_arrays": arrays, "multipath_maps": paths}, + indent=2, ensure_ascii=False) + evidence += ("\nRead from /proc/mdstat and, where the tool is installed, " + "`multipath -ll`. An array rebuilding is doing what it should; " + "an array short of devices is serving without the redundancy it " + "was built with. ZFS pools are reported by their own check.") + + affected = [] + for array in arrays: + if array["state"] != "active": + affected.append({"name": array["array"], "classification": CLASS_CRITICAL, + "reason_key": "arrayNotActive"}) + elif array.get("healthy") is False: + affected.append({"name": array["array"], "detail": array["devices"], + "classification": CLASS_WARNING, + "reason_key": "arrayRebuilding" if array["rebuilding"] + else "arrayDegraded"}) + for entry in paths: + if entry["failed"] and entry["active"] == 0: + affected.append({"name": entry["map"], "classification": CLASS_CRITICAL, + "reason_key": "multipathNoPath"}) + elif entry["failed"]: + affected.append({"name": entry["map"], + "detail": f"{entry['active']}/{entry['active'] + entry['failed']}", + "classification": CLASS_WARNING, + "reason_key": "multipathPathDown"}) + + if not affected: + return {"classification": CLASS_CONFORMANT, "summary_key": "intact", + "summary_params": {"total": str(len(arrays) + len(paths))}, + "observations": arrays + paths, "evidence": evidence} + return {"summary_key": "degraded", + "summary_params": {"count": str(len(affected)), + "total": str(len(arrays) + len(paths))}, + "affected": affected, "observations": arrays + paths, + "evidence": evidence} + + +@register("system.ha_state", AREA_SYSTEM, "CRITICAL", budget=25) +def _ha_state(ctx): + """Whether HA could actually move the workloads it promises to move. + + Quorum belongs to the cluster check and is not graded again here. + What this adds is the half quorum does not answer: a resource + manager that is idle or gone cannot start anything anywhere, a + service left in an error state has stopped being managed, and both + look like a healthy cluster from every other angle. A node with no + HA resources declared has nothing to move. + """ + if not Path("/etc/pve/ha/resources.cfg").exists(): + return None + rc, out = ctx.run(["ha-manager", "status"], timeout=25, allowed_codes=(0, 1, 2)) + if rc not in (0, 1, 2) or not (out or "").strip(): + return _unverified(out or "ha-manager reported nothing") + + quorum, master, managers, services = "", "", [], [] + for line in (out or "").splitlines(): + line = line.strip() + if line.startswith("quorum "): + quorum = line.split(None, 1)[1] + elif line.startswith("master "): + master = line.split(None, 1)[1] + elif line.startswith("lrm "): + body = line.split(None, 1)[1] + name = body.split()[0] + state = re.search(r"\((\w+)", body) + managers.append({"node": name, "state": state.group(1) if state else ""}) + elif line.startswith("service "): + body = line.split(None, 1)[1] + name = body.split()[0] + state = re.search(r"\(([^,)]+),\s*([^)]+)\)", body) + services.append({"service": name, + "node": state.group(1).strip() if state else "", + "state": state.group(2).strip() if state else body}) + + if not services and not managers: + return None + + evidence = json.dumps({"quorum": quorum, "master": master, + "resource_managers": managers, "services": services}, + indent=2, ensure_ascii=False) + evidence += ("\nRead from `ha-manager status`. Quorum is reported by the " + "cluster check and is not graded twice; it appears here as " + "context. No service is started, stopped or migrated, and " + "whether a migration would succeed is not established.") + + affected = [] + if not master: + # Without a manager nothing decides where a service should run. + affected.append({"name": "master", "classification": CLASS_CRITICAL, + "reason_key": "haNoMaster"}) + for entry in services: + state = entry["state"].lower() + if "error" in state or "fence" in state: + affected.append({"name": entry["service"], "detail": entry["state"], + "classification": CLASS_CRITICAL, + "reason_key": "haServiceError"}) + elif state.startswith("request") or "queued" in state: + affected.append({"name": entry["service"], "detail": entry["state"], + "classification": CLASS_OBSERVATION, + "reason_key": "haServiceTransitioning"}) + for manager in managers: + # `idle` is the resting state of a node holding no service; + # anything that is neither active nor idle cannot take one. + if manager["state"] not in ("active", "idle", ""): + affected.append({"name": manager["node"], "detail": manager["state"], + "classification": CLASS_WARNING, + "reason_key": "haManagerNotReady"}) + + if not affected: + return {"classification": CLASS_CONFORMANT, "summary_key": "managed", + "summary_params": {"total": str(len(services)), + "nodes": str(len(managers))}, + "observations": services, "evidence": evidence} + return {"summary_key": "attention", + "summary_params": {"count": str(len(affected)), + "total": str(len(services))}, + "affected": affected, "observations": services, "evidence": evidence} diff --git a/AppImage/scripts/audit_inventory.py b/AppImage/scripts/audit_inventory.py new file mode 100644 index 00000000..eb0ca324 --- /dev/null +++ b/AppImage/scripts/audit_inventory.py @@ -0,0 +1,844 @@ +"""Structural inventory for Audit & Report. + +Composes what the node is, what it holds and how those pieces connect, +from the collectors the Monitor already runs. Nothing here probes the +host: every section reads material that exists for another purpose. + +The value of an inventory is not the lists but the relations between +them. Enumerating interfaces and enumerating guests does not say which +path a guest's traffic takes to the wire, nor which device a virtual +disk actually lives on. Those chains are resolved here: + + guest -> disk -> storage -> backing device + guest -> interface -> bridge -> bond -> physical NIC + guest -> backup job -> destination + guest -> passthrough device -> IOMMU group -> controller + node -> uplink -> measured latency to gateway and to the internet + +Sections degrade independently. A source that cannot be read leaves its +section marked unavailable with the reason, rather than dropping the +whole inventory or presenting a gap as an empty result. +""" +from __future__ import annotations + +import copy +import re +import sys +import time +from typing import Any, Optional + +SCHEMA_VERSION = 2 + +# Disk entries in a guest configuration: rootfs and mpN for containers, +# the bus-prefixed keys for virtual machines. +_DISK_KEYS = re.compile( + r"^(rootfs|mp\d+|scsi\d+|virtio\d+|sata\d+|ide\d+|efidisk\d+|tpmstate\d+):", + re.M) + + +def _kv(text: str, key: str) -> str: + m = re.search(rf"^{key}:\s*(.+)$", text, re.M) + return m.group(1).strip() if m else "" + + +def _parse_options(value: str) -> dict[str, str]: + """Split a Proxmox option string into its comma-separated pairs.""" + out: dict[str, str] = {} + for part in value.split(","): + if "=" in part: + k, v = part.split("=", 1) + out[k.strip()] = v.strip() + return out + + +def _guest_disks(text: str) -> list[dict[str, Any]]: + """Disks declared by a guest, resolved to their storage. + + A volume reads as ``storage:volume,option=value``. Anything without + that shape is a passthrough or a raw device path and is reported as + such rather than being attributed to a storage that does not own it. + """ + disks = [] + for line in text.splitlines(): + m = _DISK_KEYS.match(line) + if not m: + continue + key = m.group(1) + value = line.split(":", 1)[1].strip() + head = value.split(",", 1)[0] + options = _parse_options(value) + entry: dict[str, Any] = {"slot": key, "size": options.get("size", "")} + if ":" in head and not head.startswith("/"): + storage, volume = head.split(":", 1) + entry.update(storage=storage, volume=volume) + else: + entry.update(storage=None, volume=head, passthrough=True) + disks.append(entry) + return disks + + +def _guest_interfaces(text: str) -> list[dict[str, Any]]: + """Network devices declared by a guest, with the bridge each uses.""" + out = [] + for line in text.splitlines(): + m = re.match(r"^(net\d+):\s*(.+)$", line) + if not m: + continue + options = _parse_options(m.group(2)) + out.append({ + "slot": m.group(1), + "name": options.get("name", ""), + "bridge": options.get("bridge", ""), + "mac": options.get("hwaddr") or options.get("macaddr", ""), + "vlan": options.get("tag", ""), + "model": next((p for p in m.group(2).split(",") if "=" not in p), ""), + }) + return out + + +def _network_topology() -> Optional[dict[str, Any]]: + """Physical path from each bridge to the wire. + + Built from the Monitor's own per-interface resolvers rather than from + the aggregate network payload: ``get_bridge_info`` already reports a + bridge's uplink and, when that uplink is a bond, its member + interfaces. Absent those resolvers the chain is left unresolved + rather than guessed. + """ + server = sys.modules.get("flask_server") or sys.modules.get("__main__") + bridge_info = getattr(server, "get_bridge_info", None) + bond_info = getattr(server, "get_bond_info", None) + if not callable(bridge_info): + return None + + try: + from pathlib import Path + # fwbr* bridges are created by Proxmox per guest interface to + # attach its firewall. They are plumbing rather than part of the + # host's configured topology, so the inventory omits them. + names = sorted(p.name for p in Path("/sys/class/net").iterdir() + if (p / "bridge").is_dir() + and not p.name.startswith("fwbr")) + except OSError: + return None + + bridges: dict[str, Any] = {} + bonds: dict[str, Any] = {} + for name in names: + try: + info = copy.deepcopy(bridge_info(name)) + except Exception: + continue + if not isinstance(info, dict): + continue + uplink = info.get("physical_interface") + vlan = info.get("vlan_interface") + chain: list[dict[str, str]] = [] + if uplink: + slaves = info.get("bond_slaves") or [] + if slaves: + mode = "" + if callable(bond_info): + try: + detail = bond_info(uplink) or {} + mode = detail.get("mode_detail") or detail.get("mode", "") + bonds[uplink] = detail + except Exception: + mode = "" + chain.append({"kind": "bond", "id": uplink, "mode": mode}) + chain.extend({"kind": "nic", "id": s} for s in slaves) + else: + chain.append({"kind": "nic", "id": uplink}) + bridges[name] = { + "parent": uplink, + "vlan_interface": vlan, + # Guest taps are excluded upstream, so members here are the + # bridge's own ports rather than every attached guest. + "members": info.get("members") or [], + "uplink": chain, + } + return {"bridges": bridges, "bonds": bonds} + + +def _latency(ctx) -> Optional[dict[str, Any]]: + """Network latency over the last day, from the Monitor's own history. + + The Monitor samples the gateway and two public resolvers + continuously. A report that describes a node's network without + saying how it behaves is describing the wiring, not the network, so + the measurements already on disk are carried here. Nothing is probed: + the samples exist whether or not anyone asks for them. + """ + server = sys.modules.get("flask_server") or sys.modules.get("__main__") + history = getattr(server, "get_latency_history", None) + if not callable(history): + return None + + targets = [] + for name in ("gateway", "cloudflare", "google"): + try: + result = history(name, "day") or {} + except Exception: + continue + stats = result.get("stats") or {} + samples = result.get("data") or [] + if not samples: + continue + losses = [s.get("packet_loss") for s in samples + if isinstance(s.get("packet_loss"), (int, float))] + targets.append({ + "target": name, + "samples": len(samples), + "min_ms": stats.get("min"), + "avg_ms": stats.get("avg"), + "max_ms": stats.get("max"), + "current_ms": stats.get("current"), + "packet_loss": round(sum(losses) / len(losses), 2) if losses else None, + # Kept for the chart: one point per sample, oldest first. + # The peak travels with the average because a chart of + # averages alone contradicts the maximum in the table. + "series": [{"t": s.get("timestamp"), "v": s.get("value"), + "max": s.get("max")} + for s in samples if s.get("value") is not None], + }) + if not targets: + return None + return {"window": "day", "targets": targets} + + +def _backup_map(ctx) -> dict[int, list[dict[str, str]]]: + """Which enabled backup job selects each guest, and where it writes.""" + import audit_checks_pve as pve + + guests = set(ctx.lxc_configs) | set(ctx.qemu_configs) + pools = pve._pool_members(ctx.pve_user_cfg) + out: dict[int, list[dict[str, str]]] = {} + for job in pve._parse_vzdump_jobs(ctx.vzdump_jobs): + if job.get("enabled", "1").strip() == "0": + continue + excluded = {int(x) for x in re.findall(r"\d+", job.get("exclude", ""))} + selected: set[int] = set() + if job.get("all", "0").strip() == "1": + selected = set(guests) + else: + selected |= {int(x) for x in re.findall(r"\d+", job.get("vmid", ""))} + for pool in re.split(r"[,\s]+", job.get("pool", "").strip()): + if pool: + selected |= pools.get(pool, set()) + entry = {"job": job["id"], "storage": job.get("storage", ""), + "schedule": job.get("schedule", ""), + "retention": job.get("prune-backups") or job.get("maxfiles", "")} + for vmid in selected - excluded: + out.setdefault(vmid, []).append(entry) + return out + + +def _identity(ctx) -> dict[str, Any]: + rc, version = ctx.run(["pveversion"], timeout=10) + rc2, kernel = ctx.run(["uname", "-r"], timeout=10) + rc3, sub = ctx.run(["pvesubscription", "get"], timeout=10) + status = "" + for line in (sub or "").splitlines(): + if line.lower().startswith("status:"): + status = line.split(":", 1)[1].strip() + break + cluster = "" + try: + from pathlib import Path + corosync = Path("/etc/corosync/corosync.conf") + if corosync.exists(): + m = re.search(r"cluster_name:\s*(\S+)", + corosync.read_text(errors="replace")) + cluster = m.group(1) if m else "unnamed" + except OSError: + cluster = "" + return { + "node": ctx.node, + "pve_version": (version or "").strip().splitlines()[0] if version else "", + "kernel": (kernel or "").strip(), + "subscription": status or "unknown", + "cluster": cluster or None, + } + + +def _storages(ctx) -> list[dict[str, Any]]: + out = [] + for storage in ctx.storages: + out.append({ + "id": storage.get("id"), + "type": storage.get("type"), + "content": storage.get("content", ""), + "shared": str(storage.get("shared", "0")).strip() == "1", + "path": storage.get("path") or storage.get("export") or "", + "server": storage.get("server", ""), + }) + return sorted(out, key=lambda s: s["id"] or "") + + +def _guests(ctx, topology, backups) -> list[dict[str, Any]]: + """Every local guest with its disks, interfaces and protection resolved.""" + entries = [] + for kind, configs in (("lxc", ctx.lxc_configs), ("qemu", ctx.qemu_configs)): + for vmid, text in configs.items(): + interfaces = _guest_interfaces(text) + for nic in interfaces: + if topology is None: + # Distinguish a bridge with no uplink from one whose + # path could not be read: the first is a fact about + # the host, the second is a gap in this inventory. + nic["uplink"] = None + else: + bridge = topology["bridges"].get(nic["bridge"]) + nic["uplink"] = bridge["uplink"] if bridge else [] + entries.append({ + "vmid": vmid, + "type": kind, + "name": _kv(text, "hostname") or _kv(text, "name"), + "cores": _kv(text, "cores"), + "memory": _kv(text, "memory"), + "ostype": _kv(text, "ostype"), + "onboot": _kv(text, "onboot") == "1", + "tags": _kv(text, "tags"), + "protected": _kv(text, "protection") == "1", + "unprivileged": _kv(text, "unprivileged") == "1" if kind == "lxc" else None, + "features": _kv(text, "features") if kind == "lxc" else None, + "agent": bool(_kv(text, "agent")) if kind == "qemu" else None, + "cpu": _kv(text, "cpu") if kind == "qemu" else None, + "disks": _guest_disks(text), + "interfaces": interfaces, + "backups": backups.get(vmid, []), + }) + return sorted(entries, key=lambda g: g["vmid"]) + + +def collect(ctx, sections: Optional[tuple] = None) -> dict[str, Any]: + """Assemble the inventory, keeping each section independent. + + A section that raises is recorded with its error so the rest of the + document still describes what could be read. An inventory that fails + as a whole because one source was unavailable is less useful than one + that says which part is missing. + """ + out: dict[str, Any] = {} + errors: dict[str, str] = {} + + wanted = None if sections is None else set(sections) + + def section(name, producer): + # A section the profile did not ask for is absent rather than + # empty, so a reader never takes an omission for a finding. + if wanted is not None and name not in wanted: + return + try: + out[name] = producer() + except Exception as exc: + out[name] = None + errors[name] = f"{type(exc).__name__}: {exc}" + + topology = None + try: + topology = _network_topology() + if topology is None: + errors["network"] = ("the Monitor's network view is not reachable " + "from this process, so bridge uplinks are " + "unresolved") + except Exception as exc: + errors["network"] = f"{type(exc).__name__}: {exc}" + + backups: dict[int, list] = {} + try: + backups = _backup_map(ctx) + except Exception as exc: + errors["backup_map"] = f"{type(exc).__name__}: {exc}" + + section("identity", lambda: _identity(ctx)) + section("hardware", lambda: _hardware(ctx)) + section("cluster", lambda: _cluster(ctx)) + section("storages", lambda: _storages(ctx)) + section("guests", lambda: _guests(ctx, topology, backups)) + section("passthrough", lambda: _passthrough(ctx)) + section("applications", lambda: _applications(ctx)) + section("custom_links", _custom_links) + section("proxmenux", lambda: _proxmenux(ctx)) + section("latency", lambda: _latency(ctx)) + if wanted is None or "network" in wanted: + out["network"] = topology + + return { + "schema_version": SCHEMA_VERSION, + "collected_at": int(time.time()), + "node": ctx.node, + "sections": out, + # Named so a reader can tell an empty section from an unread one. + "unavailable": errors, + } + + +# --------------------------------------------------------------------------- +# Passthrough, applications and hardware +# --------------------------------------------------------------------------- + +def _iommu_groups() -> dict[str, str]: + """Map each PCI address to the IOMMU group that contains it. + + A device can only be handed to a guest together with everything else + in its group, so the group is what determines whether a passthrough + is possible at all. + """ + from pathlib import Path + out: dict[str, str] = {} + base = Path("/sys/kernel/iommu_groups") + if not base.is_dir(): + return out + for group in base.iterdir(): + devices = group / "devices" + if not devices.is_dir(): + continue + for device in devices.iterdir(): + out[device.name] = group.name + return out + + +def _passthrough(ctx) -> list[dict[str, Any]]: + """PCI devices assigned to a guest, with their IOMMU group. + + ``hostpci`` may name a function (``0000:03:00.0``) or a whole device + (``0000:03:00``). Both are reported as written and resolved against + the groups, so a reader sees what was configured rather than a + normalised form that no longer matches the configuration. + """ + groups = _iommu_groups() + out = [] + for vmid, text in sorted(ctx.qemu_configs.items()): + name = _kv(text, "name") + for line in text.splitlines(): + m = re.match(r"^(hostpci\d+):\s*(.+)$", line) + if not m: + continue + value = m.group(2) + address = value.split(",", 1)[0].strip() + # A device written without its function covers every function + # of that device, so the group is looked up through them. + candidates = ([address] if address.count(".") else + [f"{address}.{fn}" for fn in range(8)]) + found = {groups[c] for c in candidates if c in groups} + out.append({ + "vmid": vmid, + "guest": name, + "slot": m.group(1), + "address": address, + "options": _parse_options(value), + "iommu_groups": sorted(found) or None, + "shared_group_devices": sorted( + d for d, gid in groups.items() + if gid in found and d not in candidates) or [], + }) + return out + + +def _applications(ctx) -> list[dict[str, Any]]: + """Applications registered inside each container and their web links. + + Read from the sidecars the App tab maintains, which is where a + container's real purpose is recorded; the configuration alone only + says how much memory it has. + """ + import json as _json + from pathlib import Path + base = Path("/etc/proxmenux/apps") + out = [] + if not base.is_dir(): + return out + for path in sorted(base.glob("*.json")): + try: + data = _json.loads(path.read_text(errors="replace")) + except (OSError, ValueError): + continue + vmid = data.get("vmid") + for app in data.get("apps", []) or []: + # Detection results live under `state`, separate from the + # registration itself, and carry the moment they were taken. + # A version that could not be detected is stored as null, so + # the value is coerced rather than defaulted: a key present + # with no value would otherwise pass a default straight through. + state = app.get("state") or {} + out.append({ + "vmid": vmid, + "name": app.get("name") or "", + "slug": app.get("helper_slug") or app.get("slug") or "", + "installed_via": app.get("installed_via") or "", + "version": state.get("installed_version") or "", + "available": state.get("latest_version") or "", + "update_available": bool(state.get("update_available")), + "checked_at": state.get("checked_at") or "", + "ports": [ + {"port": p.get("port"), "path": p.get("web_path", ""), + "scheme": p.get("scheme", ""), + "category": p.get("category", ""), + "url": p.get("custom_url", "")} + for p in (app.get("ports") or []) + ], + }) + return out + + +def _custom_links() -> list[dict[str, Any]]: + """User-defined web links, including those pointing inside guests.""" + import json as _json + from pathlib import Path + try: + data = _json.loads( + Path("/etc/proxmenux/custom_links.json").read_text(errors="replace")) + except (OSError, ValueError): + return [] + entries = data if isinstance(data, list) else data.get("links", []) + return [{"name": e.get("name", ""), "url": e.get("url", ""), + "category": e.get("category", ""), "vmid": e.get("vmid")} + for e in entries if isinstance(e, dict)] + + +def _memory_modules(ctx) -> dict[str, Any]: + """Populated and empty slots, so remaining capacity is visible. + + dmidecode reports every slot the board has; a slot without a module + carries the literal "No Module Installed" as its size. + """ + rc, out = ctx.run(["dmidecode", "-t", "memory"], timeout=15) + devices: list[dict[str, str]] = [] + current: Optional[dict[str, str]] = None + for line in (out or "").splitlines(): + stripped = line.strip() + if stripped == "Memory Device": + current = {} + devices.append(current) + continue + if current is None or ":" not in stripped: + continue + key, value = stripped.split(":", 1) + current[key.strip()] = value.strip() + + modules, empty = [], 0 + for dev in devices: + size = dev.get("Size", "") + if not size or size.lower().startswith("no module"): + empty += 1 + continue + modules.append({ + "locator": dev.get("Locator", ""), + "size": size, + "type": dev.get("Type", ""), + "form_factor": dev.get("Form Factor", ""), + "speed": dev.get("Configured Memory Speed") or dev.get("Speed", ""), + "manufacturer": dev.get("Manufacturer", ""), + "part_number": dev.get("Part Number", ""), + }) + return {"slots": len(devices) or None, "populated": len(modules), + "empty": empty, "modules": modules} + + +def _lsblk_pairs(ctx) -> list[dict[str, str]]: + """lsblk key="value" output; model strings contain spaces.""" + rc, out = ctx.run( + ["lsblk", "-dn", "-P", "-b", "-o", + "NAME,MODEL,SERIAL,SIZE,ROTA,TRAN,TYPE"], timeout=15) + rows = [] + for line in (out or "").splitlines(): + fields = dict(re.findall(r'(\w+)="([^"]*)"', line)) + # zd* are ZFS volumes: guest disks the kernel exposes as block + # devices. They are not hardware and report no SMART. + if fields.get("TYPE") == "disk" and not fields.get("NAME", "").startswith("zd"): + rows.append(fields) + return rows + + +def _disk_observations() -> dict[str, list[dict[str, Any]]]: + """Recorded disk events, keyed by device. + + The Monitor keeps these because a transient error that clears is + still part of a disk's history: SMART reports the present state, + the observation log reports what happened. A report that only shows + the present state hides the pattern that precedes a failure. + """ + server = sys.modules.get("flask_server") or sys.modules.get("__main__") + store = getattr(server, "health_persistence", None) + getter = getattr(store, "get_disk_observations", None) + if getter is None: + return {} + try: + records = getter() or [] + except Exception: + return {} + + grouped: dict[str, list[dict[str, Any]]] = {} + for record in records: + device = (record.get("device_name") or "").replace("/dev/", "") + if not device: + continue + grouped.setdefault(device, []).append({ + "type": record.get("error_type", ""), + "severity": record.get("severity", ""), + "count": record.get("occurrence_count", 0), + "first_seen": record.get("first_occurrence"), + "last_seen": record.get("last_occurrence"), + "message": (record.get("raw_message") or "")[:400], + }) + for entries in grouped.values(): + entries.sort(key=lambda e: e.get("last_seen") or 0, reverse=True) + return grouped + + +def _physical_disks(ctx) -> list[dict[str, Any]]: + observations = _disk_observations() + # The SMART cache is keyed by device, each entry a (collected_at, data) + # pair as the Monitor stores it. + smart = {} + cached = (getattr(ctx, "monitor_snapshot", None) or {}).get("smart") or {} + for device, value in cached.items(): + data = value[1] if isinstance(value, (list, tuple)) and len(value) == 2 else value + if isinstance(data, dict): + smart[str(device).replace("/dev/", "")] = data + + disks = [] + for row in _lsblk_pairs(ctx): + size = row.get("SIZE", "") + name = row.get("NAME", "") + health = smart.get(name) or {} + disks.append({ + "name": name, + "model": (row.get("MODEL") or "").strip(), + "serial": (row.get("SERIAL") or "").strip(), + "size_bytes": int(size) if size.isdigit() else None, + "rotational": row.get("ROTA") == "1", + "bus": (row.get("TRAN") or "").strip(), + "health": health.get("smart_status"), + "temperature": health.get("temperature"), + "power_on_hours": health.get("power_on_hours"), + "observations": observations.get(name, []), + }) + return sorted(disks, key=lambda d: d["name"]) + + +def _network_adapters() -> list[dict[str, Any]]: + """Physical adapters only: an interface backed by a real device.""" + from pathlib import Path as _Path + + def read(path): + try: + return _Path(path).read_text(errors="replace").strip() + except OSError: + return "" + + adapters = [] + try: + entries = sorted(_Path("/sys/class/net").iterdir()) + except OSError: + return adapters + for iface in entries: + device = iface / "device" + if not device.exists(): + continue + speed = read(iface / "speed") + driver = "" + try: + driver = (device / "driver").resolve().name + except OSError: + pass + pci = "" + try: + pci = device.resolve().name + except OSError: + pass + adapters.append({ + "name": iface.name, + "mac": read(iface / "address"), + "state": read(iface / "operstate"), + # An interface that is down reports -1, which is not a speed. + "speed_mbps": int(speed) if speed.lstrip("-").isdigit() + and int(speed) > 0 else None, + "driver": driver, + "pci": pci, + }) + return adapters + + +# Device classes worth naming in a report: what moves the storage and +# what a guest could be given directly. +_CONTROLLER_CLASSES = ( + "RAID bus controller", "Serial Attached SCSI controller", + "SATA controller", "SCSI storage controller", + "Non-Volatile memory controller", "Fibre Channel", + "VGA compatible controller", "3D controller", "Display controller", + "Ethernet controller", "Network controller", +) + + +def _controllers(ctx) -> list[dict[str, Any]]: + rc, out = ctx.run(["lspci", "-D"], timeout=15) + devices = [] + for line in (out or "").splitlines(): + if " " not in line: + continue + slot, rest = line.split(" ", 1) + if ":" not in rest: + continue + klass, name = rest.split(":", 1) + klass = klass.strip() + if klass in _CONTROLLER_CLASSES: + devices.append({"slot": slot, "class": klass, "name": name.strip()}) + return devices + + +def _cluster(ctx) -> Optional[dict[str, Any]]: + """The cluster this node belongs to, or None when it stands alone. + + Membership is read from corosync's own configuration; quorum state + comes from pvecm, which reports what the node currently sees. + """ + from pathlib import Path as _Path + + conf = _Path("/etc/pve/corosync.conf") + if not conf.exists(): + conf = _Path("/etc/corosync/corosync.conf") + if not conf.exists(): + return None + try: + text = conf.read_text(errors="replace") + except OSError: + return None + + name = "" + m = re.search(r"cluster_name:\s*(\S+)", text) + if m: + name = m.group(1) + + nodes = [] + for block in re.findall(r"node\s*{([^}]*)}", text): + entry = { + "name": _kv(block, r"\s*name") or _kv(block, r"\s*ring0_addr"), + "nodeid": _kv(block, r"\s*nodeid"), + "ring0_addr": _kv(block, r"\s*ring0_addr"), + "ring1_addr": _kv(block, r"\s*ring1_addr") or None, + } + entry["local"] = entry["name"] == ctx.node + nodes.append(entry) + + quorate, expected, total = None, None, None + rc, status = ctx.run(["pvecm", "status"], timeout=15, allowed_codes=(0, 2)) + for line in (status or "").splitlines(): + low = line.lower() + if low.startswith("quorate:"): + quorate = line.split(":", 1)[1].strip().lower() == "yes" + elif low.startswith("expected votes:"): + expected = line.split(":", 1)[1].strip() + elif low.startswith("total votes:"): + total = line.split(":", 1)[1].strip() + + # pvecm lists the members it currently sees; a configured node absent + # from that list is configured but not reachable right now. + online = set() + rc2, members = ctx.run(["pvecm", "nodes"], timeout=15, allowed_codes=(0, 2)) + for line in (members or "").splitlines(): + parts = line.split() + if len(parts) >= 3 and parts[0].isdigit(): + # The local node is marked with a trailing "(local)" token. + online.add(parts[-2] if parts[-1] == "(local)" else parts[-1]) + if online: + for node in nodes: + node["online"] = node["name"] in online + + return {"name": name or "unnamed", "nodes": sorted(nodes, key=lambda n: n["name"]), + "quorate": quorate, "expected_votes": expected, "total_votes": total, + "links": 2 if any(n.get("ring1_addr") for n in nodes) else 1} + + +def _hardware(ctx) -> dict[str, Any]: + """System identity and processor, from data the host already exposes.""" + def dmi(field): + rc, out = ctx.run(["dmidecode", "-s", field], timeout=10) + value = (out or "").strip().splitlines() + value = value[-1].strip() if value else "" + # dmidecode returns these placeholders when a board ships without + # the field populated; they are not identities. + return "" if value.lower() in ("default string", "to be filled by o.e.m.", + "not specified", "unknown") else value + + cpu_model, sockets, cores, threads = "", 0, 0, 0 + physical: set[str] = set() + rc, cpuinfo = ctx.run(["cat", "/proc/cpuinfo"], timeout=10) + for line in (cpuinfo or "").splitlines(): + if line.startswith("model name") and not cpu_model: + cpu_model = line.split(":", 1)[1].strip() + elif line.startswith("physical id"): + physical.add(line.split(":", 1)[1].strip()) + elif line.startswith("processor"): + threads += 1 + elif line.startswith("cpu cores") and not cores: + cores = int(line.split(":", 1)[1].strip() or 0) + sockets = len(physical) or 1 + + virt = "" + if cpuinfo: + if " vmx" in cpuinfo: + virt = "vmx" + elif " svm" in cpuinfo: + virt = "svm" + + return { + "system": {"manufacturer": dmi("system-manufacturer"), + "product": dmi("system-product-name"), + "serial": dmi("system-serial-number")}, + "board": {"manufacturer": dmi("baseboard-manufacturer"), + "product": dmi("baseboard-product-name")}, + "bios": {"vendor": dmi("bios-vendor"), "version": dmi("bios-version"), + "date": dmi("bios-release-date")}, + "cpu": {"model": cpu_model, "sockets": sockets, + "cores_per_socket": cores, "threads": threads, + "virtualisation": virt or None}, + "memory_bytes": _host_memory(ctx), + "memory": _memory_modules(ctx), + "disks": _physical_disks(ctx), + "adapters": _network_adapters(), + "controllers": _controllers(ctx), + "iommu_groups": len(set(_iommu_groups().values())) or None, + } + + +def _host_memory(ctx) -> int: + rc, out = ctx.run(["cat", "/proc/meminfo"], timeout=10) + for line in (out or "").splitlines(): + if line.startswith("MemTotal:"): + parts = line.split() + if len(parts) >= 2 and parts[1].isdigit(): + return int(parts[1]) * 1024 + return 0 + + +def _proxmenux(ctx) -> dict[str, Any]: + """What ProxMenux itself has applied to this host.""" + import json as _json + from pathlib import Path + + def load(path): + try: + return _json.loads(Path(path).read_text(errors="replace")) + except (OSError, ValueError): + return None + + from post_install_versions import load_installed_tools + installed = load_installed_tools() + updates = load("/usr/local/share/proxmenux/updates_available.json") or {} + tools = [] + for key in sorted(installed): + value = installed[key] + if not value.get("installed", False): + continue + version = value.get("version") + tools.append({"key": key, "version": str(version) if version is not None else ""}) + return { + "optimizations": tools, + "pending_updates": [ + {"key": u.get("key"), "current": u.get("current_version"), + "available": u.get("available_version")} + for u in (updates.get("updates") or []) + ], + } diff --git a/AppImage/scripts/audit_policy.py b/AppImage/scripts/audit_policy.py new file mode 100644 index 00000000..c69714c4 --- /dev/null +++ b/AppImage/scripts/audit_policy.py @@ -0,0 +1,361 @@ +"""Declared policy for Audit & Report. + +An assessment can see what a host does; it cannot see what the host is +*for*. Whether a guest needs a backup, whether a service has to come back +by itself after a reboot, whether a storage is essential or convenient — +none of that is discoverable, and guessing at it is what turns an +ordinary configuration into an alarm. + +So the audit reports an absence it cannot interpret as an observation, +and only calls it a warning once somebody has declared what was expected. +Nothing here is required: a host with no policy at all still produces a +complete report, just one that describes rather than judges. + +The declaration lives in ``/usr/local/share/proxmenux/audit_policy.json`` +and is written by hand or by the interface. It is read, never inferred: +if the file is missing, malformed or partial, every unstated question +stays unstated. + +A guest marked as exempt is not a risk somebody accepted. It is a guest +outside the scope of the expectation, so it leaves the count entirely +rather than appearing as something to justify. +""" +from __future__ import annotations + +import json +import fcntl +import hashlib +import math +import os +import tempfile +import threading +import time +from pathlib import Path +from typing import Any, Optional + +POLICY_PATH = Path("/usr/local/share/proxmenux/audit_policy.json") + +SCHEMA_VERSION = 1 + +# What a declaration can say about an expectation. +REQUIRED = "required" +NOT_REQUIRED = "not_required" +UNSPECIFIED = "unspecified" + +_EXPECTATIONS = (REQUIRED, NOT_REQUIRED, UNSPECIFIED) + +# What a site can declare about the host itself, as opposed to about a +# guest. Each is read as "is this expected here": `firewall: required` +# expects the switch on, `ssh_root_login: not_required` expects that +# access not to be available. +HOST_EXPECTATIONS = ("firewall", "ssh_root_login") + +# What a storage is for, which decides how gravely its loss reads. +ROLE_ESSENTIAL = "essential" +ROLE_OPTIONAL = "optional" +ROLE_UNSPECIFIED = "unspecified" + +_ROLES = (ROLE_ESSENTIAL, ROLE_OPTIONAL, ROLE_UNSPECIFIED) + +# Thresholds a site may want to move. The defaults are the values the +# checks used before policy existed, so a host without a declaration +# behaves exactly as it did. +DEFAULT_THRESHOLDS: dict[str, float] = { + "storage_usage_percent": 90, + "thin_pool_usage_percent": 90, + "thin_overprovision_ratio": 2.0, + "zfs_scrub_days": 35, + "backup_fallback_days": 30, + "backup_schedule_grace_ratio": 0.5, + "certificate_expiry_days": 30, + "memory_overcommit_ratio": 1.5, + "disk_service_life_hours": 43800, + "lynis_report_days": 30, + "package_index_days": 7, + "journal_usage_percent": 80, + "filesystem_usage_percent": 90, + "filesystem_inode_percent": 90, + "disk_error_recent_days": 7, +} + +_lock = threading.Lock() + + +class PolicyConflict(ValueError): + """The declaration changed after the editor read it.""" + + +def _valid_number(value, name: str = "") -> bool: + try: + return (type(value) in (int, float) and math.isfinite(value) + and value > 0 and (not name.endswith("_percent") or value <= 100)) + except OverflowError: + return False + + +class Policy: + """One reading of the declaration, answering only what it was told.""" + + def __init__(self, raw: Optional[dict] = None, source: str = "", + error: Optional[str] = None, revision: str = "missing"): + raw = raw if isinstance(raw, dict) else {} + self.source = source + self.error = error + self.revision = revision + self.declared = bool(raw) + self._guests = raw.get("guests") if isinstance(raw.get("guests"), dict) else {} + self._storages = raw.get("storages") if isinstance(raw.get("storages"), dict) else {} + self._defaults = raw.get("defaults") if isinstance(raw.get("defaults"), dict) else {} + self._host = raw.get("host") if isinstance(raw.get("host"), dict) else {} + thresholds = raw.get("thresholds") if isinstance(raw.get("thresholds"), dict) else {} + self._thresholds = {} + for name, value in thresholds.items(): + # A malformed threshold falls back to the default rather than + # silently disabling the check it belongs to. + if name in DEFAULT_THRESHOLDS and _valid_number(value, name): + self._thresholds[name] = float(value) + + # -- guests ------------------------------------------------------ + + def _guest(self, vmid) -> dict: + entry = self._guests.get(str(vmid)) + return entry if isinstance(entry, dict) else {} + + def expectation(self, vmid, name: str) -> str: + """Whether something is expected of a guest, as declared. + + Falls back to the site default for that expectation, and to + ``unspecified`` when neither says anything. + """ + value = self._guest(vmid).get(name) + if value not in _EXPECTATIONS: + value = self._defaults.get(name) + return value if value in _EXPECTATIONS else UNSPECIFIED + + def backup_required(self, vmid) -> str: + return self.expectation(vmid, "backup") + + def autostart_required(self, vmid) -> str: + return self.expectation(vmid, "autostart") + + def guest_note(self, vmid) -> str: + note = self._guest(vmid).get("note") + return note if isinstance(note, str) else "" + + def recovery_objective_hours(self, vmid) -> Optional[float]: + """How old a guest's newest backup may be before it is a warning. + + Declared per guest because it is a property of the workload, not + of the schedule that happens to protect it. + """ + value = self._guest(vmid).get("recovery_objective_hours") + if value is None: + value = self._defaults.get("recovery_objective_hours") + return float(value) if _valid_number(value) else None + + # -- the host itself --------------------------------------------- + + def host_expectation(self, name: str) -> str: + """What the site declares about the host's own configuration. + + Kept apart from ``defaults``, which are per-guest fallbacks. The + vocabulary is the same one the guest expectations use, read the + same way: ``ssh_root_login: not_required`` says that access is + not meant to be available here, and ``firewall: required`` says + the switch is meant to be on. Undeclared means the check states + the fact and does not judge it. + """ + value = self._host.get(name) + return value if value in _EXPECTATIONS else UNSPECIFIED + + def exempt_guests(self, name: str) -> set: + """Guests explicitly declared as not needing something.""" + return {vmid for vmid, entry in self._guests.items() + if isinstance(entry, dict) and entry.get(name) == NOT_REQUIRED} + + # -- storages ---------------------------------------------------- + + def storage_role(self, storage_id: str) -> str: + entry = self._storages.get(storage_id) + role = entry.get("role") if isinstance(entry, dict) else None + if role not in _ROLES: + role = self._defaults.get("storage_role") + return role if role in _ROLES else ROLE_UNSPECIFIED + + # -- thresholds -------------------------------------------------- + + def threshold(self, name: str) -> float: + if name in self._thresholds: + return self._thresholds[name] + return float(DEFAULT_THRESHOLDS[name]) + + def is_default(self, name: str) -> bool: + """Whether a threshold is the shipped value or a declared one.""" + return name not in self._thresholds + + # -- reporting --------------------------------------------------- + + def describe(self) -> dict[str, Any]: + """What the report says about the policy it applied.""" + return { + "declared": self.declared, + "source": self.source or str(POLICY_PATH), + "guests_declared": len(self._guests), + "storages_declared": len(self._storages), + "thresholds_declared": sorted(self._thresholds), + "host_declared": sorted(k for k in self._host if k in HOST_EXPECTATIONS), + "error": self.error, + "revision": self.revision, + } + + +def load(path: Path = POLICY_PATH) -> Policy: + """Read one complete snapshot of the small declaration file. + + An unreadable or malformed file is reported as an error and treated as + no declaration at all. Falling back to an assumed policy would be + worse than having none: it would judge the host against expectations + nobody set. + """ + try: + content = path.read_bytes() + except FileNotFoundError: + return Policy(source=str(path)) + except OSError as exc: + return Policy(source=str(path), error=f"{type(exc).__name__}: {exc}") + revision = hashlib.sha256(content).hexdigest() + try: + raw = json.loads(content) + _clean(raw) + return Policy(raw, source=str(path), revision=revision) + except (ValueError, UnicodeError, OverflowError) as exc: + return Policy(source=str(path), error=f"{type(exc).__name__}: {exc}", + revision=revision) + + +def _clean(raw: dict) -> dict: + if not isinstance(raw, dict): + raise ValueError("the declaration must be an object") + + cleaned: dict[str, Any] = {"version": SCHEMA_VERSION, + "updated_at": int(time.time())} + + guests = raw.get("guests", {}) + if not isinstance(guests, dict): + raise ValueError("guests must be an object keyed by VMID") + kept_guests: dict[str, dict] = {} + for vmid, entry in guests.items(): + if not str(vmid).isdigit() or not isinstance(entry, dict): + raise ValueError(f"invalid guest declaration: {vmid}") + kept: dict[str, Any] = {} + for name in ("backup", "autostart"): + value = entry.get(name) + if value in _EXPECTATIONS: + kept[name] = value + elif value is not None: + raise ValueError(f"invalid expectation for guest {vmid}: {name}={value}") + rpo = entry.get("recovery_objective_hours") + if rpo is not None: + if not _valid_number(rpo): + raise ValueError(f"invalid recovery objective for guest {vmid}: {rpo}") + kept["recovery_objective_hours"] = float(rpo) + note = entry.get("note") + if isinstance(note, str) and note.strip(): + kept["note"] = note.strip()[:500] + if kept: + kept_guests[str(vmid)] = kept + cleaned["guests"] = kept_guests + + storages = raw.get("storages", {}) + if not isinstance(storages, dict): + raise ValueError("storages must be an object keyed by storage id") + kept_storages: dict[str, dict] = {} + for storage_id, entry in storages.items(): + if not isinstance(entry, dict): + raise ValueError(f"invalid storage declaration: {storage_id}") + role = entry.get("role") + if role in _ROLES: + kept_storages[str(storage_id)] = {"role": role} + elif role is not None: + raise ValueError(f"invalid role for storage {storage_id}: {role}") + cleaned["storages"] = kept_storages + + thresholds = raw.get("thresholds", {}) + if not isinstance(thresholds, dict): + raise ValueError("thresholds must be an object") + kept_thresholds: dict[str, float] = {} + for name, value in thresholds.items(): + if name not in DEFAULT_THRESHOLDS: + raise ValueError(f"unknown threshold: {name}") + if not _valid_number(value, name): + raise ValueError(f"invalid value for {name}: {value}") + kept_thresholds[name] = float(value) + cleaned["thresholds"] = kept_thresholds + + defaults = raw.get("defaults", {}) + if not isinstance(defaults, dict): + raise ValueError("defaults must be an object") + kept_defaults: dict[str, Any] = {} + for name in ("backup", "autostart"): + if defaults.get(name) in _EXPECTATIONS: + kept_defaults[name] = defaults[name] + elif defaults.get(name) is not None: + raise ValueError(f"invalid default expectation: {name}") + if defaults.get("storage_role") in _ROLES: + kept_defaults["storage_role"] = defaults["storage_role"] + elif defaults.get("storage_role") is not None: + raise ValueError("invalid default storage role") + if defaults.get("recovery_objective_hours") is not None: + if not _valid_number(defaults["recovery_objective_hours"]): + raise ValueError("invalid default recovery objective") + kept_defaults["recovery_objective_hours"] = float( + defaults["recovery_objective_hours"]) + cleaned["defaults"] = kept_defaults + + host = raw.get("host", {}) + if not isinstance(host, dict): + raise ValueError("host must be an object") + kept_host: dict[str, Any] = {} + for name in HOST_EXPECTATIONS: + if host.get(name) in _EXPECTATIONS: + kept_host[name] = host[name] + elif host.get(name) is not None: + raise ValueError(f"invalid host expectation: {name}") + cleaned["host"] = kept_host + return cleaned + + +def save(raw: dict, path: Path = POLICY_PATH, + expected_revision: Optional[str] = None) -> Policy: + """Validate and atomically replace a declaration, rejecting stale editors. + + The process lock and flock cover revision comparison and replacement. + Each writer owns a private 0600 temporary file in the target directory. + """ + cleaned = _clean(raw) + content = json.dumps(cleaned, indent=2, ensure_ascii=False, allow_nan=False) + "\n" + + path.parent.mkdir(parents=True, exist_ok=True) + with _lock: + lock_fd = os.open(str(path) + ".lock", os.O_CREAT | os.O_RDWR, 0o600) + with os.fdopen(lock_fd, "a") as lock_file: + fcntl.flock(lock_file, fcntl.LOCK_EX) + current = load(path) + if expected_revision is not None and current.revision != expected_revision: + raise PolicyConflict("The declaration changed in another session; reload before saving.") + if current.error: + raise ValueError(current.error) + temporary = None + try: + with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", + dir=path.parent, prefix=".audit-policy-", + delete=False) as handle: + temporary = Path(handle.name) + handle.write(content) + handle.flush() + os.fsync(handle.fileno()) + temporary.replace(path) + finally: + if temporary is not None: + temporary.unlink(missing_ok=True) + return load(path) diff --git a/AppImage/scripts/audit_profiles.py b/AppImage/scripts/audit_profiles.py new file mode 100644 index 00000000..94420c99 --- /dev/null +++ b/AppImage/scripts/audit_profiles.py @@ -0,0 +1,125 @@ +"""Report profiles for Audit & Report. + +A profile answers one question, so it selects the checks and the +inventory sections that bear on it. The alternative — always producing +everything — leaves the reader to find the relevant part, and is how a +report grows section by section until nobody reads it. + +Profiles are declared as data rather than as code so the backend and the +interface work from the same definition, and so adding a check does not +require revisiting every profile: a profile names areas, and only names +individual checks when it needs one that lives elsewhere. +""" +from __future__ import annotations + +from typing import Any, Optional + +# Every inventory section the composer can produce. A profile lists the +# subset its question needs. +ALL_SECTIONS = ( + "identity", "cluster", "hardware", "network", "latency", "storages", "guests", + "passthrough", "applications", "custom_links", "proxmenux", +) + +PROFILES: dict[str, dict[str, Any]] = { + # The whole picture. What an assessment produces when no narrower + # question has been asked. + "full": { + "areas": None, # None means every area + "include": (), + "sections": ALL_SECTIONS, + }, + + # Everything is assessed and almost nothing is printed. The reader + # of this one is deciding what to do in the next few minutes, so it + # carries the findings that ask for a decision and the readings that + # could not be taken, and leaves out the inventory, the diagrams and + # the annex. Scope stays full deliberately: a short report that + # skipped checks would be quick and untrustworthy. + "diagnostic": { + "areas": None, + "include": (), + "sections": ("identity",), + "brief": True, + }, + + # Describes the node without judging it. Runs no checks, so it is + # available on a host that has never been assessed. + "inventory": { + "areas": (), # empty means no checks + "include": (), + "sections": ALL_SECTIONS, + }, + + # Exposure and access. Container privilege and the enterprise + # repository sit in other areas but bear on the same question. + "security": { + "areas": ("security",), + "include": ( + "guests.privileged_containers", + "system.security_updates", + "system.enterprise_repo_without_subscription", + "system.update_chain", + ), + "sections": ("identity", "cluster", "network", "latency", "guests"), + }, + + # Whether guests are protected, and whether the protection is real. + # Storage is included because a destination that cannot be reached + # accepts no backup. + "backup": { + "areas": ("backup",), + "include": ("storage.connected_storage", "system.notification_delivery"), + "sections": ("identity", "cluster", "guests", "storages"), + }, + + # Room to grow and the age of what it grows on. + "capacity": { + "areas": ("storage", "hardware"), + "include": ("system.memory_overcommit", "system.journal_size", + "system.swap_configured", "system.filesystem_capacity"), + "sections": ("identity", "cluster", "hardware", "storages", "guests"), + }, +} + +DEFAULT_PROFILE = "full" + + +def is_known(profile: str) -> bool: + return profile in PROFILES + + +def selected_checks(profile: str, checks) -> list: + """Checks a profile runs, from the registered catalogue. + + ``areas`` of ``None`` selects everything and an empty tuple selects + nothing, which is what lets the inventory profile produce a document + without assessing the host. + """ + spec = PROFILES.get(profile) or PROFILES[DEFAULT_PROFILE] + areas = spec["areas"] + include = set(spec["include"]) + if areas is None: + return list(checks) + areas = set(areas) + return [c for c in checks if c.area in areas or c.check_id in include] + + +def sections(profile: str) -> tuple: + spec = PROFILES.get(profile) or PROFILES[DEFAULT_PROFILE] + return tuple(spec["sections"]) + + +def describe() -> list[dict[str, Any]]: + """Profile catalogue for the interface, without any host data.""" + return [ + { + "id": name, + "areas": None if spec["areas"] is None else list(spec["areas"]), + "include": list(spec["include"]), + "sections": list(spec["sections"]), + "runs_checks": spec["areas"] != (), + "brief": bool(spec.get("brief")), + } + for name, spec in PROFILES.items() + ] diff --git a/AppImage/scripts/audit_store.py b/AppImage/scripts/audit_store.py index 4e565ed6..253ec991 100644 --- a/AppImage/scripts/audit_store.py +++ b/AppImage/scripts/audit_store.py @@ -19,6 +19,9 @@ and is stored verbatim. from __future__ import annotations import json +import hashlib +import re +import os import sqlite3 import threading import time @@ -28,22 +31,147 @@ from typing import Any, Optional DB_PATH = Path("/usr/local/share/proxmenux/audit.db") -# Result of a check within one run. Severity is what the check declares -# for a failure; state is what actually happened this time. +# What a check concluded, on one scale. +# +# Severity used to be declared per check and state per run, which meant a +# storage at 90% capacity was labelled "critical" because the check that +# found it is the one that can also find an unreachable storage. Gravity +# belongs to the situation, so the check now returns it with the result, +# and it may differ between the objects one check reports on. +# +# The scale is deliberately short, and each step says what it takes to +# earn it: +# +# critical an interruption or an urgent threat to availability, +# integrity or recoverability, backed by evidence +# warning a verified degradation, an expected protection that is +# absent, or a declared policy that is not met +# observation a configuration, a limit or planning information; it +# does not demonstrate a problem and is not counted as one +# conformant the criterion was verified and is met +# unverified not enough information to conclude; not a fault +# not_applicable nothing on this host to evaluate +CLASS_CRITICAL = "critical" +CLASS_WARNING = "warning" +CLASS_OBSERVATION = "observation" +CLASS_CONFORMANT = "conformant" +CLASS_UNVERIFIED = "unverified" +CLASS_NOT_APPLICABLE = "not_applicable" + +CLASSIFICATIONS = (CLASS_CRITICAL, CLASS_WARNING, CLASS_OBSERVATION, + CLASS_CONFORMANT, CLASS_UNVERIFIED, CLASS_NOT_APPLICABLE) + +# Worst first: a finding takes the gravity of its gravest object. +CLASS_ORDER = {name: i for i, name in enumerate(CLASSIFICATIONS)} + +# Only these two are problems. An observation is information, and +# unverified is an absence of information; counting either as a problem is +# what made ordinary configurations look like faults. +CLASS_PROBLEMS = (CLASS_CRITICAL, CLASS_WARNING) + +# What the reader decided about a finding, kept apart from what the +# assessment concluded. A technical result does not change because someone +# accepted it; only the decision layered over it does. +DECISION_NONE = "" +DECISION_ACCEPTED = "accepted" # a signed exception over a real finding +DECISION_BY_DESIGN = "by_design" # declared policy: this object is exempt + +# Retained so findings recorded before the scale existed still read, and +# so the interface can be migrated without breaking the stored history. STATE_FAIL = "fail" STATE_WARN = "warn" STATE_PASS = "pass" STATE_NOT_APPLICABLE = "not_applicable" STATE_ACCEPTED = "accepted" +STATE_UNKNOWN = "unknown" + +# A finding written before the scale is read on the scale, using the +# severity its check declared at the time. +_LEGACY_STATE_MAP = { + STATE_PASS: CLASS_CONFORMANT, + STATE_UNKNOWN: CLASS_UNVERIFIED, + STATE_NOT_APPLICABLE: CLASS_NOT_APPLICABLE, + STATE_ACCEPTED: CLASS_WARNING, +} + + +def classification_of(state: str, severity: str) -> str: + """Read a stored state and severity on the current scale.""" + mapped = _LEGACY_STATE_MAP.get(state) + if mapped: + return mapped + if state == STATE_FAIL: + return CLASS_CRITICAL if severity == "CRITICAL" else CLASS_WARNING + if state == STATE_WARN: + return CLASS_OBSERVATION if severity == "INFO" else CLASS_WARNING + return CLASS_UNVERIFIED + + +def state_of(classification: str) -> str: + """The state a classification would have had, for stored compatibility.""" + return { + CLASS_CRITICAL: STATE_FAIL, + CLASS_WARNING: STATE_WARN, + CLASS_OBSERVATION: STATE_WARN, + CLASS_CONFORMANT: STATE_PASS, + CLASS_UNVERIFIED: STATE_UNKNOWN, + CLASS_NOT_APPLICABLE: STATE_NOT_APPLICABLE, + }.get(classification, STATE_UNKNOWN) + + +def worst(classifications) -> str: + """The gravest of several, or not applicable when there are none.""" + ranked = [c for c in classifications if c in CLASS_ORDER] + if not ranked: + return CLASS_NOT_APPLICABLE + return min(ranked, key=lambda c: CLASS_ORDER[c]) RUN_RUNNING = "running" RUN_COMPLETE = "complete" RUN_FAILED = "failed" +RUN_PARTIAL = "partial" _schema_lock = threading.Lock() _schema_ready = False +def safe_evidence(value): + """Redact secrets before persistence; bound individual evidence fields.""" + if isinstance(value, dict): + return {k: ("[redacted]" if re.search(r"password|secret|token|authorization|private.key", k, re.I) + else safe_evidence(v)) for k, v in value.items()} + if isinstance(value, (list, tuple)): + return [safe_evidence(v) for v in value] + if not isinstance(value, str): + return value + value = re.sub(r"(?s)-----BEGIN [^-]*PRIVATE KEY-----.*?-----END [^-]*PRIVATE KEY-----", + "[private key redacted]", value) + value = re.sub(r"(https?://)[^/\s@]+@", r"\1[redacted]@", value) + value = re.sub(r"(?i)((?:password|passwd|secret|token|api[_-]?key)\s*[=:]\s*)[^\s&,;]+", + r"\1[redacted]", value) + value = re.sub(r"(?im)(authorization\s*:\s*).*", r"\1[redacted]", value) + return value if len(value) <= 32768 else value[:32768] + "\n[evidence truncated]" + + +def finding_scope(finding): + """Bind decisions to object identity, rule version, host and gravity. + + A decision is about a situation, not about a check. If the same + objects come back at a different gravity, the situation is not the one + that was accepted, so the acceptance does not carry over. + """ + objects = [] + for obj in finding.get("affected") or []: + identity = {k: obj[k] for k in ("vmid", "type", "volume", "device", "pool", + "job", "test", "bridge", "file", "snapshot", "storage", "package") if k in obj} + objects.append(identity or obj) + payload = {"objects": sorted(objects, key=lambda v: json.dumps(v, sort_keys=True)), + "check": finding["check_id"], "version": finding.get("check_version", 1), + "classification": finding.get("classification", ""), + "host": finding.get("host", "")} + return hashlib.sha256(json.dumps(payload, sort_keys=True).encode()).hexdigest() + + def _connect() -> sqlite3.Connection: conn = sqlite3.connect(str(DB_PATH), timeout=10) conn.execute("PRAGMA journal_mode=WAL") @@ -59,6 +187,9 @@ def init_db() -> None: if _schema_ready: return DB_PATH.parent.mkdir(parents=True, exist_ok=True) + fd = os.open(DB_PATH, os.O_CREAT | os.O_WRONLY, 0o600) + os.close(fd) + os.chmod(DB_PATH, 0o600) conn = _connect() try: conn.executescript(""" @@ -113,7 +244,34 @@ def init_db() -> None: CREATE INDEX IF NOT EXISTS idx_audit_runs_started ON audit_runs(started_at); """) + # Additive migration: retain existing runs and decisions. + for table, columns in { + "audit_runs": {"metadata": "TEXT", "checks_expected": "INTEGER NOT NULL DEFAULT 0"}, + "audit_findings": {"raw_state": "TEXT", "exception_snapshot": "TEXT", + "scope": "TEXT", "details": "TEXT", "classification": "TEXT", + "raw_classification": "TEXT", "decision": "TEXT"}, + "audit_exceptions": {"scope": "TEXT"}, + }.items(): + present = {row[1] for row in conn.execute(f"PRAGMA table_info({table})")} + for name, kind in columns.items(): + if name not in present: + conn.execute(f"ALTER TABLE {table} ADD COLUMN {name} {kind}") + conn.execute("""CREATE TABLE IF NOT EXISTS audit_exception_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, check_id TEXT NOT NULL, + action TEXT NOT NULL, happened_at INTEGER NOT NULL, decision TEXT NOT NULL)""") + conn.row_factory = sqlite3.Row + for legacy in conn.execute("SELECT * FROM audit_exceptions WHERE scope IS NULL"): + exists = conn.execute("SELECT 1 FROM audit_exception_events WHERE check_id = ? LIMIT 1", + (legacy["check_id"],)).fetchone() + if not exists: + conn.execute("INSERT INTO audit_exception_events (check_id, action, happened_at, decision) " + "VALUES (?, 'legacy-unscoped', ?, ?)", + (legacy["check_id"], legacy["accepted_at"], json.dumps(safe_evidence(dict(legacy))))) + # Old accepted findings have no recoverable technical state. + conn.execute("UPDATE audit_findings SET raw_state = CASE WHEN state = 'accepted' " + "THEN 'unknown' ELSE state END WHERE raw_state IS NULL") conn.commit() + os.chmod(DB_PATH, 0o600) _schema_ready = True finally: conn.close() @@ -123,16 +281,17 @@ def init_db() -> None: # Runs # --------------------------------------------------------------------------- -def start_run(profile: str) -> str: +def start_run(profile: str, metadata=None, checks_expected=0) -> str: """Open a run and return its identifier.""" init_db() run_id = uuid.uuid4().hex[:16] conn = _connect() try: conn.execute( - "INSERT INTO audit_runs (run_id, profile, started_at, status) " - "VALUES (?, ?, ?, ?)", - (run_id, profile, int(time.time()), RUN_RUNNING), + "INSERT INTO audit_runs (run_id, profile, started_at, status, metadata, " + "checks_expected, schema_version) VALUES (?, ?, ?, ?, ?, ?, 2)", + (run_id, profile, int(time.time()), RUN_RUNNING, + json.dumps(safe_evidence(metadata or {})), checks_expected), ) conn.commit() finally: @@ -140,8 +299,19 @@ def start_run(profile: str) -> str: return run_id +def update_run_metadata(run_id, metadata, checks_expected): + init_db() + conn = _connect() + try: + conn.execute("UPDATE audit_runs SET metadata = ?, checks_expected = ? WHERE run_id = ?", + (json.dumps(safe_evidence(metadata)), checks_expected, run_id)) + conn.commit() + finally: + conn.close() + + def finish_run(run_id: str, *, checks_total: int, - error: Optional[str] = None) -> None: + error: Optional[str] = None, partial: bool = False) -> None: """Close a run, marking it failed when an error is supplied.""" init_db() conn = _connect() @@ -149,8 +319,8 @@ def finish_run(run_id: str, *, checks_total: int, conn.execute( "UPDATE audit_runs SET finished_at = ?, status = ?, error = ?, " "checks_total = ? WHERE run_id = ?", - (int(time.time()), RUN_FAILED if error else RUN_COMPLETE, - error, checks_total, run_id), + (int(time.time()), RUN_FAILED if error else RUN_PARTIAL if partial else RUN_COMPLETE, + safe_evidence(error), checks_total, run_id), ) conn.commit() finally: @@ -165,7 +335,7 @@ def get_run(run_id: str) -> Optional[dict[str, Any]]: row = conn.execute( "SELECT * FROM audit_runs WHERE run_id = ?", (run_id,) ).fetchone() - return dict(row) if row else None + return _run_row(row) if row else None finally: conn.close() @@ -179,22 +349,36 @@ def list_runs(limit: int = 20) -> list[dict[str, Any]]: "SELECT * FROM audit_runs ORDER BY started_at DESC LIMIT ?", (limit,), ).fetchall() - return [dict(r) for r in rows] + return [_run_row(r) for r in rows] finally: conn.close() -def latest_run(status: str = RUN_COMPLETE) -> Optional[dict[str, Any]]: +def _run_row(row) -> dict[str, Any]: + """A run as its consumers need it, with metadata as an object. + + The column holds JSON text; handing that to an interface means every + caller parses it, and the one that forgets silently reads nothing + rather than failing. + """ + run = dict(row) + try: + run["metadata"] = json.loads(run.get("metadata") or "{}") + except (TypeError, ValueError): + run["metadata"] = {} + return run + + +def latest_run(status: Optional[str] = None) -> Optional[dict[str, Any]]: init_db() conn = _connect() try: conn.row_factory = sqlite3.Row - row = conn.execute( - "SELECT * FROM audit_runs WHERE status = ? " - "ORDER BY started_at DESC LIMIT 1", - (status,), - ).fetchone() - return dict(row) if row else None + condition = "status = ?" if status else "status != 'running'" + row = conn.execute(f"SELECT * FROM audit_runs WHERE {condition} " + "ORDER BY started_at DESC, rowid DESC LIMIT 1", + (status,) if status else ()).fetchone() + return _run_row(row) if row else None finally: conn.close() @@ -212,18 +396,32 @@ def record_findings(run_id: str, findings: list[dict[str, Any]]) -> int: init_db() if not findings: return 0 + findings = safe_evidence(findings) rows = [ ( run_id, f["check_id"], f["area"], f["severity"], - f["state"], + # state is derived from the classification and kept so a + # database written by this version still reads on the old + # columns; the scale is what the interface reads. + state_of(f["classification"]), f.get("summary_key"), json.dumps(f.get("summary_params") or {}, ensure_ascii=False), json.dumps(f.get("affected") or [], ensure_ascii=False), f.get("evidence"), f.get("remediable_by"), + # raw_state stays a state, on the old vocabulary; the scale + # travels in its own column. + state_of(f.get("raw_classification", f["classification"])), + json.dumps(f.get("exception")), + f.get("scope"), + json.dumps({k: f[k] for k in ("check_version", "collected_at", "sources", + "incomplete", "observations", "host") if k in f}), + f["classification"], + f.get("raw_classification", f["classification"]), + f.get("decision", DECISION_NONE), ) for f in findings ] @@ -233,7 +431,9 @@ def record_findings(run_id: str, findings: list[dict[str, Any]]) -> int: conn.executemany( "INSERT INTO audit_findings (run_id, check_id, area, severity, " "state, summary_key, summary_params, affected, evidence, " - "remediable_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "remediable_by, raw_state, exception_snapshot, scope, details, " + "classification, raw_classification, decision) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", rows, ) conn.commit() @@ -263,6 +463,18 @@ def get_findings(run_id: str) -> list[dict[str, Any]]: item.get("summary_params") or "{}") except (TypeError, ValueError): item["summary_params"] = {} + item.update(json.loads(item.pop("details", None) or "{}")) + item["exception"] = json.loads(item.pop("exception_snapshot", None) or "null") + # A finding recorded before the scale existed is read on it, + # from the state and severity it was stored with. + if not item.get("classification"): + item["classification"] = classification_of( + item.get("state", ""), item.get("severity", "")) + item["raw_classification"] = ( + item.get("raw_classification") + or classification_of(item.get("raw_state") or item.get("state", ""), + item.get("severity", ""))) + item.setdefault("decision", DECISION_NONE) out.append(item) return out finally: @@ -291,7 +503,7 @@ def check_history(check_id: str, limit: int = 30) -> list[dict[str, Any]]: # --------------------------------------------------------------------------- def accept_risk(check_id: str, reason: str, accepted_by: str, - expires_at: Optional[int] = None) -> None: + expires_at: Optional[int] = None, *, scope: str) -> None: """Record a deliberate decision to leave a finding unresolved. A reason is mandatory: an acceptance without one is indistinguishable @@ -300,25 +512,44 @@ def accept_risk(check_id: str, reason: str, accepted_by: str, """ if not (reason or "").strip(): raise ValueError("an accepted risk requires a reason") + if not scope: + raise ValueError("an accepted risk requires an assessed scope") + if expires_at is not None and expires_at <= time.time(): + raise ValueError("expiry must be in the future") init_db() conn = _connect() try: + conn.execute("BEGIN IMMEDIATE") + decision = dict(check_id=check_id, reason=reason.strip(), accepted_by=accepted_by, + accepted_at=int(time.time()), expires_at=expires_at, scope=scope) conn.execute( "INSERT OR REPLACE INTO audit_exceptions " - "(check_id, reason, accepted_by, accepted_at, expires_at) " - "VALUES (?, ?, ?, ?, ?)", + "(check_id, reason, accepted_by, accepted_at, expires_at, scope) " + "VALUES (?, ?, ?, ?, ?, ?)", (check_id, reason.strip(), accepted_by, int(time.time()), - expires_at), + expires_at, scope), ) + conn.execute("INSERT INTO audit_exception_events (check_id, action, happened_at, decision) " + "VALUES (?, 'accepted', ?, ?)", + (check_id, int(time.time()), json.dumps(safe_evidence(decision)))) conn.commit() finally: conn.close() -def revoke_risk(check_id: str) -> bool: +def revoke_risk(check_id: str, actor: str = "local-admin") -> bool: init_db() conn = _connect() try: + conn.row_factory = sqlite3.Row + conn.execute("BEGIN IMMEDIATE") + previous = conn.execute("SELECT * FROM audit_exceptions WHERE check_id = ?", (check_id,)).fetchone() + if previous: + decision = dict(previous) + decision["revoked_by"] = actor + conn.execute("INSERT INTO audit_exception_events (check_id, action, happened_at, decision) " + "VALUES (?, 'revoked', ?, ?)", + (check_id, int(time.time()), json.dumps(safe_evidence(decision)))) cur = conn.execute( "DELETE FROM audit_exceptions WHERE check_id = ?", (check_id,) ) @@ -349,6 +580,54 @@ def active_exceptions() -> dict[str, dict[str, Any]]: conn.close() +def effective_findings(run_id): + """Current decisions over immutable technical results; history stays intact. + + The classification is what the assessment concluded and does not + change because somebody accepted it. What changes is the decision + recorded beside it, which is why the two are separate fields: a + report can still show that a critical finding was accepted, and by + whom, instead of showing a finding that looks resolved. + """ + exceptions = active_exceptions() + findings = get_findings(run_id) + for f in findings: + f["classification"] = f["raw_classification"] + f["state"] = f["raw_state"] + f["exception"] = None + f["decision"] = DECISION_NONE + decision = exceptions.get(f["check_id"]) + if (decision and decision.get("scope") and decision["scope"] == f.get("scope") + and f["classification"] in CLASS_PROBLEMS and not f.get("incomplete")): + f["decision"] = DECISION_ACCEPTED + f["state"] = STATE_ACCEPTED + f["exception"] = decision + return findings + + +def exception_history(): + init_db() + conn = _connect() + try: + conn.row_factory = sqlite3.Row + return [dict(row) for row in conn.execute( + "SELECT * FROM audit_exception_events ORDER BY id DESC LIMIT 200")] + finally: + conn.close() + + +def recover_interrupted_runs(): + """Called at service startup, never during an active assessment.""" + init_db() + conn = _connect() + try: + conn.execute("UPDATE audit_runs SET status = ?, error = ?, finished_at = ? WHERE status = ?", + (RUN_FAILED, "Assessment interrupted by Monitor restart", int(time.time()), RUN_RUNNING)) + conn.commit() + finally: + conn.close() + + def all_exceptions() -> list[dict[str, Any]]: init_db() now = int(time.time()) @@ -414,7 +693,7 @@ def prune_runs(keep: int = 30) -> int: try: conn.execute("BEGIN IMMEDIATE") cur = conn.execute( - "DELETE FROM audit_runs WHERE is_baseline = 0 AND run_id NOT IN (" + "DELETE FROM audit_runs WHERE is_baseline = 0 AND status != 'running' AND run_id NOT IN (" " SELECT run_id FROM audit_runs " " WHERE is_baseline = 0 ORDER BY started_at DESC LIMIT ?" ")", diff --git a/AppImage/scripts/auth_manager.py b/AppImage/scripts/auth_manager.py index 43d6def3..46255d89 100644 --- a/AppImage/scripts/auth_manager.py +++ b/AppImage/scripts/auth_manager.py @@ -307,6 +307,8 @@ def verify_password(password, password_hash): can log in once and trigger a rehash via `_maybe_rehash_password` — see lazy migration in `authenticate()`. """ + if not isinstance(password, str) or not password: + return False if not isinstance(password_hash, str) or not password_hash: return False if password_hash.startswith(_PWD_PBKDF2_PREFIX): diff --git a/AppImage/scripts/build_appimage.sh b/AppImage/scripts/build_appimage.sh index 031e6991..85b031f4 100755 --- a/AppImage/scripts/build_appimage.sh +++ b/AppImage/scripts/build_appimage.sh @@ -168,7 +168,13 @@ cp "$SCRIPT_DIR/flask_oci_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo " cp "$SCRIPT_DIR/flask_audit_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_audit_routes.py not found" cp "$SCRIPT_DIR/audit_store.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ audit_store.py not found" cp "$SCRIPT_DIR/audit_checks.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ audit_checks.py not found" +cp "$SCRIPT_DIR/audit_profiles.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ audit_profiles.py not found" +cp "$SCRIPT_DIR/audit_policy.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ audit_policy.py not found" +cp "$SCRIPT_DIR/changes_journal.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ changes_journal.py not found" +cp "$SCRIPT_DIR/audit_inventory.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ audit_inventory.py not found" cp "$SCRIPT_DIR/audit_checks_pve.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ audit_checks_pve.py not found" +# Preserve the existing build version as assessment provenance; no version bump. +cp "$APPIMAGE_ROOT/package.json" "$APP_DIR/package.json" cp "$SCRIPT_DIR/oci/description_templates.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ description_templates.py not found" # Copy AI providers module for notification enhancement diff --git a/AppImage/scripts/changes_journal.py b/AppImage/scripts/changes_journal.py new file mode 100644 index 00000000..80983508 --- /dev/null +++ b/AppImage/scripts/changes_journal.py @@ -0,0 +1,367 @@ +"""ProxMenux change journal — reading side. + +The scripts that change this host write one small JSON file per change +into a spool directory, and copy whatever they replaced into a content +store keyed by digest. Nothing there needs a database, a daemon or a +network: recording has to work during a first installation, before +anything else exists, and it must never be the reason an operation fails. + +This module is the other half. It consolidates the spool into a table +that can be queried, and answers the question the whole thing exists +for: *what did ProxMenux change on this machine, and what was there +before.* + +Two distinctions are load-bearing and are kept throughout: + + * **What was changed** against **what was run.** A post-install + function that rewrites a file authored that change. An upgrade + launched from a menu did not: apt decided what changed, and claiming + it would be taking credit and blame for someone else's work. Both are + recorded; they are not the same kind of entry. + + * **How well the previous state is known.** A change recorded as it + happened carries the original. A function re-applied on a host that + was already modified carries what was there at the time, which is not + the original. Anything applied before the journal existed carries + nothing at all. A reader who is deciding whether to revert needs to + know which of the three they are looking at. +""" +from __future__ import annotations + +import difflib +import json +import os +import sqlite3 +import threading +import time +from pathlib import Path +from typing import Any, Optional + +ROOT = Path("/usr/local/share/proxmenux/changes") +SPOOL = ROOT / "spool" +OBJECTS = ROOT / "objects" +DB_PATH = Path("/usr/local/share/proxmenux/changes.db") + +# What kind of act an entry records. +CLASS_CONFIGURATION = "configuration" # ProxMenux changed this +CLASS_INSTALLATION = "installation" # ProxMenux put this here +CLASS_EXECUTION = "execution" # ProxMenux ran this; it did not decide the outcome +CLASS_REGISTRATION = "registration" # applied, with no record of what changed + +CLASSES = (CLASS_CONFIGURATION, CLASS_INSTALLATION, + CLASS_EXECUTION, CLASS_REGISTRATION) + +# How much of the previous state is actually known. +CAPTURE_PRESENT = "present" # what was there when the change was made +CAPTURE_CREATED = "created" # nothing was there; the change created it +CAPTURE_UNKNOWN = "unknown" # applied before the journal, or unknowable +CAPTURE_NONE = "none" # nothing to capture (an execution) + +# A file large enough that keeping it whole in the journal would cost +# more than the answer is worth; the digest and size are still recorded. +MAX_OBJECT_BYTES = 2 * 1024 * 1024 + +# Diffs are for reading, not for archiving: past this many lines the +# reader is better served by the counts than by the hunks. +MAX_DIFF_LINES = 400 + +_lock = threading.Lock() +_ready = False + + +def _connect() -> sqlite3.Connection: + conn = sqlite3.connect(str(DB_PATH), timeout=10) + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA foreign_keys=ON") + return conn + + +def init_db() -> None: + global _ready + with _lock: + if _ready: + return + DB_PATH.parent.mkdir(parents=True, exist_ok=True) + first = not DB_PATH.exists() + conn = _connect() + try: + conn.executescript(""" + CREATE TABLE IF NOT EXISTS changes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + recorded_at INTEGER NOT NULL, + ingested_at INTEGER NOT NULL, + class TEXT NOT NULL, + operation TEXT NOT NULL, + source TEXT, + function TEXT, + function_version TEXT, + target TEXT, + before_ref TEXT, + after_ref TEXT, + capture TEXT, + revert TEXT, + exactness TEXT, + result TEXT, + detail TEXT, + -- The spool file this came from, so an entry is + -- ingested once however often the reader runs. + origin TEXT UNIQUE + ); + CREATE INDEX IF NOT EXISTS idx_changes_time + ON changes(recorded_at DESC); + CREATE INDEX IF NOT EXISTS idx_changes_function + ON changes(function); + """) + conn.commit() + finally: + conn.close() + if first: + try: + DB_PATH.chmod(0o600) + except OSError: + pass + _ready = True + + +def object_path(digest: str) -> Optional[Path]: + """Where a captured content lives, if it is still there.""" + if not digest or len(digest) < 4 or not digest.isalnum(): + return None + path = OBJECTS / digest[:2] / digest + return path if path.is_file() else None + + +def read_object(digest: str) -> Optional[str]: + """Captured content as text, or None when it is gone or too large.""" + path = object_path(digest) + if path is None: + return None + try: + if path.stat().st_size > MAX_OBJECT_BYTES: + return None + return path.read_text(errors="replace") + except OSError: + return None + + +def ingest(limit: int = 5000) -> int: + """Move what the scripts wrote into the table. + + A malformed entry is dropped rather than allowed to stop the rest: + the spool is written by shell running under conditions this process + cannot see, and one bad file must not cost the reader every other + change on the host. + """ + init_db() + if not SPOOL.is_dir(): + return 0 + try: + pending = sorted(p for p in SPOOL.iterdir() + if p.suffix == ".json" and p.is_file())[:limit] + except OSError: + return 0 + if not pending: + return 0 + + rows, consumed = [], [] + for path in pending: + try: + entry = json.loads(path.read_text(errors="replace")) + except (OSError, ValueError): + # Keep it out of the way but do not delete it: a file that + # could not be read is evidence of its own. + _quarantine(path) + continue + if not isinstance(entry, dict): + _quarantine(path) + continue + rows.append(( + int(entry.get("recorded_at") or time.time()), + int(time.time()), + str(entry.get("class") or CLASS_CONFIGURATION), + str(entry.get("operation") or "unknown"), + str(entry.get("source") or ""), + str(entry.get("function") or ""), + str(entry.get("function_version") or ""), + str(entry.get("target") or ""), + str(entry.get("before") or ""), + str(entry.get("after") or ""), + str(entry.get("capture") or CAPTURE_UNKNOWN), + str(entry.get("revert") or "none"), + str(entry.get("exactness") or "none"), + str(entry.get("result") or "ok"), + json.dumps({k: v for k, v in entry.items() + if k not in ("recorded_at", "class", "operation", "source", + "function", "function_version", "target", + "before", "after", "capture", "revert", + "exactness", "result")}, ensure_ascii=False), + path.name, + )) + consumed.append(path) + + if not rows: + return 0 + conn = _connect() + try: + conn.execute("BEGIN IMMEDIATE") + conn.executemany( + "INSERT OR IGNORE INTO changes (recorded_at, ingested_at, class, " + "operation, source, function, function_version, target, before_ref, " + "after_ref, capture, revert, exactness, result, detail, origin) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", rows) + conn.commit() + finally: + conn.close() + for path in consumed: + try: + path.unlink() + except OSError: + pass + return len(rows) + + +def _quarantine(path: Path) -> None: + bad = ROOT / "unreadable" + try: + bad.mkdir(parents=True, exist_ok=True) + path.rename(bad / path.name) + except OSError: + pass + + +def diff_of(entry: dict[str, Any]) -> Optional[dict[str, Any]]: + """What changed in a file, as the difference and nothing else. + + A function may run to four hundred lines and alter two values; the + reader is owed the two values, not the function. Where the content is + gone or too large to hold, the absence is reported rather than + guessed at. + """ + if entry.get("class") != CLASS_CONFIGURATION: + return None + before_ref, after_ref = entry.get("before_ref"), entry.get("after_ref") + before = read_object(before_ref) if before_ref else "" + after = read_object(after_ref) if after_ref else "" + if before is None or after is None: + return {"available": False, + "reason": "content no longer stored or too large to show"} + + before_lines = before.splitlines() + after_lines = after.splitlines() + hunks = list(difflib.unified_diff(before_lines, after_lines, + lineterm="", n=2))[2:] + added = sum(1 for l in hunks if l.startswith("+")) + removed = sum(1 for l in hunks if l.startswith("-")) + return { + "available": True, + "added": added, + "removed": removed, + "before_lines": len(before_lines), + "after_lines": len(after_lines), + "truncated": len(hunks) > MAX_DIFF_LINES, + "hunks": hunks[:MAX_DIFF_LINES], + } + + +def changes(limit: int = 200, offset: int = 0, + function: str = "", klass: str = "") -> list[dict[str, Any]]: + """Recorded changes, newest first.""" + init_db() + ingest() + query = "SELECT * FROM changes WHERE 1=1" + params: list[Any] = [] + if function: + query += " AND function = ?" + params.append(function) + if klass: + query += " AND class = ?" + params.append(klass) + query += " ORDER BY recorded_at DESC, id DESC LIMIT ? OFFSET ?" + params.extend([limit, offset]) + + conn = _connect() + try: + conn.row_factory = sqlite3.Row + rows = [dict(r) for r in conn.execute(query, params)] + finally: + conn.close() + for row in rows: + try: + row["detail"] = json.loads(row.get("detail") or "{}") + except ValueError: + row["detail"] = {} + # Whether the previous state can still be shown at all, which is + # what decides if a revert is even discussable. + row["recoverable"] = bool(row.get("before_ref") + and object_path(row["before_ref"])) + return rows + + +def summary() -> dict[str, Any]: + """What the host has been through, in the shape the page opens with.""" + init_db() + ingest() + conn = _connect() + try: + conn.row_factory = sqlite3.Row + by_class = {row["class"]: row["n"] for row in conn.execute( + "SELECT class, COUNT(*) AS n FROM changes GROUP BY class")} + functions = [dict(row) for row in conn.execute( + "SELECT function, source, MAX(function_version) AS version, " + "COUNT(*) AS changes, MAX(recorded_at) AS last_change, " + "MIN(recorded_at) AS first_change " + "FROM changes WHERE function <> '' " + "GROUP BY function ORDER BY last_change DESC")] + total = sum(by_class.values()) + finally: + conn.close() + return { + "total": total, + "by_class": by_class, + "functions": functions, + # Where the journal itself stands, so a host with nothing recorded + # can say why rather than looking like a host nothing touched. + "journal_started": _journal_started(), + } + + +def _journal_started() -> Optional[int]: + """When this host first recorded anything, if it ever has.""" + conn = _connect() + try: + row = conn.execute("SELECT MIN(recorded_at) AS first FROM changes").fetchone() + return row[0] if row and row[0] else None + finally: + conn.close() + + +def prune(keep_days: int = 365) -> int: + """Drops entries and their content past the retention window. + + Content is only removed once no entry references it, since the same + original may be shared by several changes. + """ + init_db() + cutoff = int(time.time()) - keep_days * 86400 + conn = _connect() + try: + conn.execute("BEGIN IMMEDIATE") + removed = conn.execute("DELETE FROM changes WHERE recorded_at < ?", + (cutoff,)).rowcount + referenced = {row[0] for row in conn.execute( + "SELECT before_ref FROM changes WHERE before_ref <> '' " + "UNION SELECT after_ref FROM changes WHERE after_ref <> ''")} + conn.commit() + finally: + conn.close() + if OBJECTS.is_dir(): + for shard in OBJECTS.iterdir(): + if not shard.is_dir(): + continue + for obj in shard.iterdir(): + if obj.name not in referenced: + try: + obj.unlink() + except OSError: + pass + return removed diff --git a/AppImage/scripts/flask_audit_routes.py b/AppImage/scripts/flask_audit_routes.py index 95bac4a8..05bc7487 100644 --- a/AppImage/scripts/flask_audit_routes.py +++ b/AppImage/scripts/flask_audit_routes.py @@ -14,7 +14,8 @@ import threading import time from flask import Blueprint, jsonify, request -from jwt_middleware import require_auth +from jwt_middleware import require_auth, require_admin_scope +from auth_manager import verify_token, load_auth_config audit_bp = Blueprint('audit', __name__) @@ -22,14 +23,47 @@ try: import audit_store import audit_checks import audit_checks_pve # noqa: F401 — importing registers the checks + import audit_inventory + import audit_profiles + import audit_policy + import changes_journal except ImportError: audit_store = None audit_checks = None + audit_inventory = None + audit_profiles = None + audit_policy = None + changes_journal = None # One assessment at a time. The flag is also what the interface polls to # know a run is still in progress. _run_lock = threading.Lock() _running: dict = {'active': False, 'run_id': None, 'started_at': 0} +_startup_error = None + + +def _actor(): + config = load_auth_config() + if not config.get('enabled') or config.get('declined'): + return 'local-admin (authentication disabled)' + parts = request.headers.get('Authorization', '').split() + return verify_token(parts[1]) if len(parts) == 2 else 'unknown' + + +def _progress(run_id, completed, total, check_id): + _running.update(run_id=run_id, completed=completed, total=total, check_id=check_id) + + +@audit_bp.record_once +def _on_register(state): + global _startup_error + if audit_store: + try: + audit_store.recover_interrupted_runs() + except Exception as exc: + # An audit DB problem must never prevent the Monitor starting. + _startup_error = str(exc) + print(f"[audit] persistence unavailable: {exc}") def _unavailable(): @@ -66,17 +100,22 @@ def list_checks(): @require_auth def status(): """Latest run, whether an assessment is in progress, and the baseline.""" - if not audit_store: + if not audit_store or _startup_error: return _unavailable() try: latest = audit_store.latest_run() summary = {} if latest: - for f in audit_store.get_findings(latest['run_id']): - summary[f['state']] = summary.get(f['state'], 0) + 1 + for f in audit_store.effective_findings(latest['run_id']): + # An accepted finding is counted as a decision, not as the + # problem it still technically is, so the counters and the + # list a reader sees agree with each other. + key = (f.get('decision') or f['classification']) + summary[key] = summary.get(key, 0) + 1 return jsonify({ "success": True, "running": _running['active'], + "progress": {k: _running.get(k) for k in ('run_id', 'completed', 'total', 'check_id')}, "latest": latest, "summary": summary, "baseline": audit_store.get_baseline(), @@ -87,7 +126,7 @@ def status(): @audit_bp.route('/api/audit/run', methods=['POST']) -@require_auth +@require_admin_scope def run(): """Start an assessment in the background. @@ -95,13 +134,17 @@ def run(): interface polls ``/api/audit/status``. A full assessment is short but runs against a production host, so it must not hold an HTTP worker. """ - if not audit_checks: + if not audit_checks or _startup_error: return _unavailable() data = request.get_json(silent=True) or {} profile = str(data.get('profile') or 'full') areas = data.get('areas') - only = set(areas) if isinstance(areas, list) and areas else None + if (not audit_profiles.is_known(profile) or (areas is not None and + (not isinstance(areas, list) or not areas or + any(not isinstance(a, str) or a not in audit_checks.AREAS for a in areas)))): + return jsonify(success=False, message="Unsupported audit profile or areas"), 400 + only = set(areas) if areas is not None else None with _run_lock: if _running['active']: @@ -110,21 +153,27 @@ def run(): "message": "An assessment is already running", "run_id": _running['run_id'], }), 409 - _running.update({'active': True, 'run_id': None, - 'started_at': time.time()}) + run_id = audit_store.start_run(profile) + _running.update({'active': True, 'run_id': run_id, + 'started_at': time.time(), 'completed': 0, 'total': 0, 'check_id': None}) def worker(): try: - run_id = audit_checks.run_assessment(profile, only_areas=only) - _running['run_id'] = run_id + audit_checks.run_assessment(profile, only_areas=only, run_id=run_id, progress=_progress) audit_store.prune_runs() except Exception as e: + audit_store.finish_run(run_id, checks_total=_running.get('completed', 0), error=str(e)) print(f"[audit] assessment failed: {e}") finally: _running['active'] = False - threading.Thread(target=worker, daemon=True, name='audit-run').start() - return jsonify({"success": True, "started": True}) + try: + threading.Thread(target=worker, daemon=True, name='audit-run').start() + except Exception as e: + _running['active'] = False + audit_store.finish_run(run_id, checks_total=0, error=str(e)) + return jsonify(success=False, message="Unable to start assessment"), 500 + return jsonify({"success": True, "started": True, "run_id": run_id}) @audit_bp.route('/api/audit/runs', methods=['GET']) @@ -154,10 +203,10 @@ def run_detail(run_id): run = audit_store.get_run(run_id) if not run: return jsonify({"success": False, "message": "Run not found"}), 404 - exceptions = audit_store.active_exceptions() - findings = audit_store.get_findings(run_id) - for f in findings: - f['exception'] = exceptions.get(f['check_id']) + # History is immutable by default. The live view explicitly asks + # for current decisions, so acceptance/revocation needs no scan. + findings = (audit_store.effective_findings(run_id) if request.args.get('effective') == '1' + else audit_store.get_findings(run_id)) return jsonify({"success": True, "run": run, "findings": findings}) except Exception as e: return jsonify({"success": False, "message": str(e)}), 500 @@ -181,6 +230,7 @@ def compare(): if not base or not other: return jsonify({ "success": False, + "reason": "insufficient_runs", "message": "Two runs are required to compare", }), 400 return jsonify({ @@ -194,7 +244,7 @@ def compare(): @audit_bp.route('/api/audit/baseline', methods=['POST']) -@require_auth +@require_admin_scope def set_baseline(): if not audit_store: return _unavailable() @@ -218,13 +268,14 @@ def list_exceptions(): return jsonify({ "success": True, "exceptions": audit_store.all_exceptions(), + "history": audit_store.exception_history(), }) except Exception as e: return jsonify({"success": False, "message": str(e)}), 500 @audit_bp.route('/api/audit/exceptions', methods=['POST']) -@require_auth +@require_admin_scope def accept_exception(): """Record a finding as a deliberate decision. @@ -244,11 +295,20 @@ def accept_exception(): if not reason: return jsonify({"success": False, "message": "A reason is required"}), 400 + latest = audit_store.latest_run() + if not latest or data.get('run_id') != latest['run_id']: + return jsonify(success=False, message="Reload the latest assessment before accepting a risk"), 409 + finding = next((f for f in audit_store.get_findings(latest['run_id']) if f['check_id'] == check_id), None) + if (not finding or finding.get('raw_classification') not in audit_store.CLASS_PROBLEMS or + finding.get('incomplete') or not finding.get('scope')): + return jsonify(success=False, message="This finding cannot be accepted"), 400 expires_at = None days = data.get('expires_in_days') - if days: + if days is not None: try: + if isinstance(days, bool) or int(days) != float(days) or not 1 <= int(days) <= 3650: + raise ValueError("invalid expiry") expires_at = int(time.time()) + int(days) * 86400 except (TypeError, ValueError): return jsonify({"success": False, @@ -256,8 +316,9 @@ def accept_exception(): audit_store.accept_risk( check_id, reason, - accepted_by=str(data.get('accepted_by') or 'admin'), + accepted_by=_actor(), expires_at=expires_at, + scope=finding['scope'], ) return jsonify({"success": True}) except ValueError as e: @@ -267,15 +328,140 @@ def accept_exception(): @audit_bp.route('/api/audit/exceptions/', methods=['DELETE']) -@require_auth +@require_admin_scope def revoke_exception(check_id): if not audit_store: return _unavailable() try: - removed = audit_store.revoke_risk(check_id) + removed = audit_store.revoke_risk(check_id, _actor()) if not removed: return jsonify({"success": False, "message": "Exception not found"}), 404 return jsonify({"success": True}) except Exception as e: return jsonify({"success": False, "message": str(e)}), 500 + + +@audit_bp.route('/api/audit/inventory', methods=['GET']) +@require_auth +def inventory(): + """Structural inventory of the node. + + Composed from collectors the Monitor already runs; the assessment and + the inventory answer different questions and neither depends on the + other, so this endpoint does not require a run to exist. + """ + if not audit_inventory: + return _unavailable() + try: + profile = request.args.get('profile') or audit_profiles.DEFAULT_PROFILE + if not audit_profiles.is_known(profile): + return jsonify(success=False, message="Unsupported report profile"), 400 + ctx = audit_checks.AuditContext() + ctx.begin_check() + inventory = audit_inventory.collect(ctx, sections=audit_profiles.sections(profile)) + return jsonify({"success": True, "profile": profile, "inventory": inventory}) + except Exception as e: + return jsonify({"success": False, "message": str(e)}), 500 + + +@audit_bp.route('/api/audit/profiles', methods=['GET']) +@require_auth +def profiles(): + """Report profiles this build offers, without touching the host.""" + if not audit_profiles: + return _unavailable() + try: + return jsonify({"success": True, "default": audit_profiles.DEFAULT_PROFILE, + "profiles": audit_profiles.describe()}) + except Exception as e: + return jsonify({"success": False, "message": str(e)}), 500 + + +@audit_bp.route('/api/audit/policy', methods=['GET']) +@require_auth +def policy(): + """The declaration, and what a declaration can say. + + The vocabulary travels with the declaration so the interface offers + exactly the expectations and thresholds this build understands, + rather than a list written twice and drifting apart. + """ + if not audit_policy: + return _unavailable() + try: + current = audit_policy.load() + if current.error: + return jsonify(success=False, message=current.error), 422 + return jsonify({ + "success": True, + "policy": { + "guests": current._guests, + "storages": current._storages, + "defaults": current._defaults, + "thresholds": current._thresholds, + }, + "summary": current.describe(), + "vocabulary": { + "expectations": list(audit_policy._EXPECTATIONS), + "roles": list(audit_policy._ROLES), + "thresholds": audit_policy.DEFAULT_THRESHOLDS, + }, + }) + except Exception as e: + return jsonify({"success": False, "message": str(e)}), 500 + + +@audit_bp.route('/api/audit/policy', methods=['PUT']) +@require_admin_scope +def save_policy(): + """Replace the declaration. + + Validation is the store's, not this endpoint's: a declaration that + cannot be understood is refused with the reason rather than written + and reinterpreted later. + """ + if not audit_policy: + return _unavailable() + payload = request.get_json(silent=True) + if not isinstance(payload, dict): + return jsonify(success=False, message="A policy object is required"), 400 + revision = payload.get("expected_revision") + if not isinstance(revision, str) or not revision: + return jsonify(success=False, message="A policy revision is required"), 428 + try: + saved = audit_policy.save(payload, expected_revision=revision) + except audit_policy.PolicyConflict as e: + return jsonify(success=False, message=str(e)), 409 + except ValueError as e: + return jsonify({"success": False, "message": str(e)}), 400 + except Exception as e: + return jsonify({"success": False, "message": str(e)}), 500 + return jsonify({"success": True, "summary": saved.describe()}) + + +@audit_bp.route('/api/audit/changes', methods=['GET']) +@require_auth +def changes(): + """What ProxMenux changed on this host, and what was there before. + + The diff of each configuration change travels with it: a function may + run to hundreds of lines and alter two values, and it is the two + values the reader is owed. + """ + if not changes_journal: + return _unavailable() + try: + limit = min(int(request.args.get('limit', 200)), 1000) + entries = changes_journal.changes( + limit=limit, + offset=int(request.args.get('offset', 0)), + function=request.args.get('function', ''), + klass=request.args.get('class', ''), + ) + for entry in entries: + entry["diff"] = changes_journal.diff_of(entry) + return jsonify({"success": True, "changes": entries, + "summary": changes_journal.summary()}) + except Exception as e: + return jsonify({"success": False, "message": str(e)}), 500 diff --git a/AppImage/scripts/flask_auth_routes.py b/AppImage/scripts/flask_auth_routes.py index 5b7d9b10..b462da56 100644 --- a/AppImage/scripts/flask_auth_routes.py +++ b/AppImage/scripts/flask_auth_routes.py @@ -495,10 +495,26 @@ def auth_change_password(): """ try: data = request.json or {} + # `old_password` is the canonical API field. Accept the original + # frontend name as a compatibility alias so an already-open browser + # tab can still complete the request after a Monitor update. old_password = data.get('old_password') + if old_password is None: + old_password = data.get('current_password') new_password = data.get('new_password') totp_code = data.get('totp_code') + if not isinstance(old_password, str) or not isinstance(new_password, str): + return jsonify({ + "success": False, + "message": "Current password and new password are required", + }), 400 + if totp_code is not None and not isinstance(totp_code, str): + return jsonify({ + "success": False, + "message": "Invalid 2FA code", + }), 400 + success, message = auth_manager.change_password(old_password, new_password, totp_code) if success: diff --git a/AppImage/scripts/flask_server.py b/AppImage/scripts/flask_server.py index be0ede88..82b6d055 100644 --- a/AppImage/scripts/flask_server.py +++ b/AppImage/scripts/flask_server.py @@ -2254,11 +2254,17 @@ def _vm_disk_refresher_loop(): cycle_started = time.time() try: resources = get_cached_pvesh_cluster_resources_vm() or [] + local_node = get_proxmox_node_name() live_vmids = set() targets = [] for r in resources: if r.get('type') not in ('qemu', 'vm'): continue + # Cluster resources contains guests from every member. `qm + # guest cmd` and the resulting health ownership are local-node + # operations, so never probe a VM currently owned elsewhere. + if r.get('node') != local_node: + continue if r.get('status') != 'running': continue vmid = r.get('vmid') @@ -6669,7 +6675,12 @@ def get_proxmox_vms(): # producing a false "1 package pending" # every time a registered app had a newer # upstream version. - app_list = lxc_app_map.get(str(resource.get('vmid'))) + # Docker inventory can be ready before this CT has an + # app sidecar (especially during startup). Keep the + # core VM/LXC inventory independent from that optional + # decoration: an absent app entry is an empty list, + # never a reason to discard every guest in /api/vms. + app_list = lxc_app_map.get(str(resource.get('vmid'))) or [] if app_list: vm_data['app_watches'] = app_list # Apps dashboard reads this to build diff --git a/AppImage/scripts/health_monitor.py b/AppImage/scripts/health_monitor.py index 547e950e..124ef115 100644 --- a/AppImage/scripts/health_monitor.py +++ b/AppImage/scripts/health_monitor.py @@ -6149,6 +6149,7 @@ class HealthMonitor: try: import flask_server # deferred — avoids circular import at module load resources = flask_server.get_cached_pvesh_cluster_resources_vm() or [] + local_node = flask_server.get_proxmox_node_name() except Exception as e: print(f"[HealthMonitor] LXC disk check failed: {e}") return None @@ -6170,6 +6171,12 @@ class HealthMonitor: for r in resources: if r.get('type') != 'lxc': continue + # `/cluster/resources` is cluster-wide. Capacity belongs to the + # node currently running the CT, so every Monitor must ignore + # guests owned by another node or the same condition is recorded + # and notified independently by every cluster member. + if r.get('node') != local_node: + continue if r.get('status') != 'running': # Stopped CTs — `disk` reads as 0 from pvesh because the # rootfs isn't mounted. Skip rather than report a @@ -6194,6 +6201,7 @@ class HealthMonitor: 'maxdisk_bytes': maxdisk, 'vmid': vmid, 'name': name, + 'node': local_node, } error_key = f'lxc_disk_{vmid}' @@ -6287,13 +6295,16 @@ class HealthMonitor: try: import flask_server # deferred — avoids circular import resources = flask_server.get_cached_pvesh_cluster_resources_vm() or [] + local_node = flask_server.get_proxmox_node_name() except Exception as e: print(f"[HealthMonitor] VM disk check failed: {e}") return None # Cheap short-circuit: no running QEMU VMs on this node. if not any( - r.get('type') in ('qemu', 'vm') and r.get('status') == 'running' + r.get('type') in ('qemu', 'vm') + and r.get('node') == local_node + and r.get('status') == 'running' for r in resources ): return None @@ -6308,6 +6319,8 @@ class HealthMonitor: for r in resources: if r.get('type') not in ('qemu', 'vm'): continue + if r.get('node') != local_node: + continue if r.get('status') != 'running': continue @@ -6338,6 +6351,7 @@ class HealthMonitor: 'maxdisk_bytes': total, 'vmid': vmid_str, 'name': name, + 'node': local_node, } error_key = f'vm_disk_{vmid_str}' diff --git a/AppImage/scripts/lxc_apps.py b/AppImage/scripts/lxc_apps.py index 2a9250db..18ce86c5 100644 --- a/AppImage/scripts/lxc_apps.py +++ b/AppImage/scripts/lxc_apps.py @@ -17,8 +17,8 @@ # update_app(vmid, app_id, config) -> (bool, …) # delete_app(vmid, app_id) -> bool # delete_all(vmid) -> bool -# check_app(vmid, app_id, force=False) -> dict|None -# check_all(vmid, force=False) -> dict|None +# check_app(vmid, app_id, force=False, notify=True) -> dict|None +# check_all(vmid, force=False, notify=True) -> dict|None # get_active_apps() -> {str(vmid): [summary, …]} # get_suggestions(vmid) -> {name, port_suggestions[], web_path_hint} # ========================================================== @@ -3872,43 +3872,69 @@ def clear_schedule_reboot_required(vmid) -> bool: return _write_sidecar(vmid, sidecar) -def _fire_update_notification(vmid, app: dict) -> None: +def _app_update_notification_payload(vmid, app: dict) -> Optional[dict]: + """Return the notification payload for one pending app update. + + The same eligibility rules are used by direct/manual checks and by the + scheduled batch so per-app opt-outs and Docker-owned updates cannot drift + between the two paths. + """ # Per-app opt-out: user flipped the bell icon off for this specific # app (because they know it can't be updated on their box or they # just don't care). Field defaults to True — an app registered # before this feature landed keeps receiving notifications. if app.get("notifications_enabled", True) is False: - return + return None if app.get("helper_slug") == "docker": - return + return None # Delegated apps are announced by their Docker image's own event; a # second one for the same release would land in a different event type # and therefore escape deduplication. if app.get("update_via") == "docker": - return + return None + state = app.get("state") or {} + latest = state.get("latest_version") + if not state.get("update_available") or not latest: + return None + return { + "vmid": int(vmid), + "ct_name": app.get("name") or f"CT-{vmid}", + "app_name": app.get("name") or "app", + "installed": state.get("installed_version") or "unknown", + "latest": latest, + "app_id": str(app.get("id") or ""), + } + + +def _emit_app_update_event(data: dict, entity: str, entity_id: str) -> bool: try: from notification_manager import notification_manager - import socket - state = app.get("state") or {} notification_manager.emit_event( event_type='app_update_available', severity='INFO', - data={ - 'hostname': socket.gethostname(), - 'vmid': int(vmid), - 'ct_name': app.get('name') or f'CT-{vmid}', - 'app_name': app.get('name') or 'app', - 'installed': state.get('installed_version') or 'unknown', - 'latest': state.get('latest_version') or 'unknown', - }, + data={"hostname": socket.gethostname(), **data}, source='app_watch', - entity='ct', - # vmid + app_id + latest so multi-app CTs don't dedup and - # subsequent upstream releases still fire. - entity_id=f"{vmid}:{app.get('id')}:{state.get('latest_version') or ''}", + entity=entity, + entity_id=entity_id, ) + return True except Exception as e: - print(f"[ProxMenux] lxc_apps: notif emit failed for CT {vmid}: {e}") + print(f"[ProxMenux] lxc_apps: app update notification failed: {e}") + return False + + +def _fire_update_notification(vmid, app: dict) -> bool: + payload = _app_update_notification_payload(vmid, app) + if payload is None: + return False + app_id = payload.pop("app_id") + return _emit_app_update_event( + payload, + entity="ct", + # vmid + app_id + latest so multi-app CTs don't dedup and + # subsequent upstream releases still fire. + entity_id=f"{vmid}:{app_id}:{payload['latest']}", + ) def _docker_stack_notification_payload( @@ -4166,7 +4192,9 @@ def _detect_with_alt_healing(vmid, app: dict) -> tuple: return installed, err, False -def check_app(vmid, app_id: str, force: bool = False) -> Optional[dict]: +def check_app( + vmid, app_id: str, force: bool = False, notify: bool = True, +) -> Optional[dict]: with _cache_lock: sidecar = _read_sidecar(vmid) if not sidecar: @@ -4230,18 +4258,20 @@ def check_app(vmid, app_id: str, force: bool = False) -> Optional[dict]: # (vmid + app_id + latest_version) with its cooldown, and only # a genuinely new upstream release changes the entity_id and # triggers a fresh delivery. - if update_available and latest: + if notify and update_available and latest: _fire_update_notification(vmid, app) return sidecar def emit_all_pending_updates() -> int: - """Walk every sidecar and emit `app_update_available` for each - app currently marked with a pending upstream release. Safe to - call repeatedly — `notification_manager` dedups by entity_id - (vmid + app_id + latest_version), so a given release only sends - once until a newer version appears. + """Emit pending registered-app updates as one scheduled summary. + + A single pending app retains the original per-app notification. Multiple + apps are grouped into one event, ordered by CT and app, while preserving + every installed/latest version pair. Safe to call repeatedly: the batch + entity id is derived from the exact pending set and notification_manager + applies its normal cooldown. Needed because `check_app(force=False)` short-circuits on a fresh `checked_at` and never reaches the emit path. The 24 h @@ -4249,14 +4279,14 @@ def emit_all_pending_updates() -> int: this helper the notification only ever fired on the exact tick where a new upstream version was FIRST observed — and even that was silenced when the user's setting was OFF at the time. - Returns the number of emits attempted (delivery still depends on - channel enablement + cooldown + rate limit).""" + Returns the number of eligible pending apps represented by the event + (delivery still depends on channel enablement + cooldown + rate limit).""" try: entries = sorted(os.listdir(_APPS_DIR)) except (FileNotFoundError, OSError): print("[ProxMenux] emit_all_pending_updates: _APPS_DIR missing", flush=True) return 0 - n = 0 + pending_payloads: list[dict] = [] print(f"[ProxMenux] emit_all_pending_updates: scanning {len(entries)} sidecar file(s)", flush=True) for name in entries: if not name.endswith(".json"): @@ -4271,36 +4301,81 @@ def emit_all_pending_updates() -> int: print(f"[ProxMenux] emit_all_pending_updates: CT {vmid} sidecar empty", flush=True) continue apps = sidecar.get("apps") or [] - pending = [a for a in apps - if (a.get("state") or {}).get("update_available") - and (a.get("state") or {}).get("latest_version")] + pending = [ + app for app in apps + if (app.get("state") or {}).get("update_available") + and (app.get("state") or {}).get("latest_version") + ] print(f"[ProxMenux] emit_all_pending_updates: CT {vmid} apps={len(apps)} pending={len(pending)}", flush=True) for app in pending: - try: - _fire_update_notification(vmid, app) - n += 1 - print(f"[ProxMenux] emit_all_pending_updates: CT {vmid} emit '{app.get('name')}'", flush=True) - except Exception as inner: - print(f"[ProxMenux] emit_all_pending_updates: CT {vmid} emit '{app.get('name')}' FAILED: {inner}", flush=True) + payload = _app_update_notification_payload(vmid, app) + if payload is not None: + pending_payloads.append(payload) except Exception as e: print(f"[ProxMenux] emit_all_pending_updates: CT {vmid} outer failure: {e}", flush=True) - print(f"[ProxMenux] emit_all_pending_updates: {n} emit(s) attempted total", flush=True) - return n + pending_payloads.sort( + key=lambda item: ( + item["vmid"], + item["app_name"].casefold(), + item["app_id"], + ) + ) + count = len(pending_payloads) + if count == 0: + print("[ProxMenux] emit_all_pending_updates: no eligible pending apps", flush=True) + return 0 + + if count == 1: + payload = dict(pending_payloads[0]) + app_id = payload.pop("app_id") + _emit_app_update_event( + payload, + entity="ct", + entity_id=f"{payload['vmid']}:{app_id}:{payload['latest']}", + ) + print("[ProxMenux] emit_all_pending_updates: 1 app in 1 notification", flush=True) + return 1 + + signature = "|".join( + f"{item['vmid']}:{item['app_id']}:{item['latest']}" + for item in pending_payloads + ) + updates = [ + {key: value for key, value in item.items() if key != "app_id"} + for item in pending_payloads + ] + container_count = len({item["vmid"] for item in pending_payloads}) + _emit_app_update_event( + { + "count": count, + "container_count": container_count, + "updates": updates, + }, + entity="node", + entity_id=f"batch:{hashlib.sha256(signature.encode()).hexdigest()[:20]}", + ) + print( + f"[ProxMenux] emit_all_pending_updates: {count} apps in 1 notification", + flush=True, + ) + return count -def check_all(vmid, force: bool = False) -> Optional[dict]: +def check_all( + vmid, force: bool = False, notify: bool = True, +) -> Optional[dict]: sidecar = _read_sidecar(vmid) if not sidecar: return None for app in (sidecar.get("apps") or []): try: - check_app(vmid, app.get("id"), force=force) + check_app(vmid, app.get("id"), force=force, notify=notify) except Exception as e: print(f"[ProxMenux] lxc_apps.check_all: CT {vmid} app {app.get('id')} failed: {e}") return _read_sidecar(vmid) -def refresh_all_apps(force: bool = False) -> int: +def refresh_all_apps(force: bool = False, notify: bool = True) -> int: """Called from the polling collector's daily cycle so header badges stay fresh without needing to open every modal.""" try: @@ -4316,7 +4391,7 @@ def refresh_all_apps(force: bool = False) -> int: except ValueError: continue try: - check_all(vmid, force=force) + check_all(vmid, force=force, notify=notify) n += 1 except Exception as e: print(f"[ProxMenux] lxc_apps refresh_all: CT {vmid} failed: {e}") @@ -4985,12 +5060,14 @@ def _docker_service_catalog_meta(service: str, container: str, image: str) -> di def _probe_docker_web_links(vmid) -> list[dict]: - """Return running Docker workloads that publish TCP ports on the LXC. + """Return Docker workloads that publish TCP ports on the LXC. The result is suggestion-only. No sidecar entry is written and no port is assumed to be HTTP until the user explicitly adds it in the editor. IPv4 and IPv6 bindings of the same host port are deduplicated; loopback-only bindings are omitted because they cannot form a usable remote LXC link. + Stopped containers are included from their persistent HostConfig bindings, + so their links remain registrable before the workload is started again. """ key = str(vmid) now = time.time() @@ -4999,7 +5076,7 @@ def _probe_docker_web_links(vmid) -> list[dict]: if cached and (now - cached[0]) < _PORT_PROBE_TTL_SEC: return [dict(item) for item in cached[1]] - rc, out, _ = _pct_exec(vmid, ["docker", "ps", "-q"], timeout=10) + rc, out, _ = _pct_exec(vmid, ["docker", "ps", "-aq"], timeout=10) if rc != 0: result: list[dict] = [] else: @@ -5023,7 +5100,18 @@ def _probe_docker_web_links(vmid) -> list[dict]: labels = config.get("Labels") or {} service = str(labels.get("com.docker.compose.service") or container).strip() meta = _docker_service_catalog_meta(service, container, image) - ports = (obj.get("NetworkSettings") or {}).get("Ports") or {} + # NetworkSettings.Ports is populated while a container is + # running, but Docker empties it after the container stops. + # HostConfig.PortBindings retains the declared mapping and + # is therefore the fallback needed to keep those web-link + # suggestions available. Prefer live bindings whenever + # Docker provides them. + ports = dict((obj.get("HostConfig") or {}).get("PortBindings") or {}) + for endpoint, bindings in ( + (obj.get("NetworkSettings") or {}).get("Ports") or {} + ).items(): + if bindings: + ports[endpoint] = bindings seen_host_ports: set[int] = set() for container_endpoint, bindings in ports.items(): if not str(container_endpoint).endswith("/tcp") or not isinstance(bindings, list): diff --git a/AppImage/scripts/notification_events.py b/AppImage/scripts/notification_events.py index 1394bc72..82e8429e 100644 --- a/AppImage/scripts/notification_events.py +++ b/AppImage/scripts/notification_events.py @@ -513,6 +513,16 @@ class JournalWatcher: self._oom_lines = [] self._oom_started_at = 0.0 + # Keep the small amount of journal history that precedes a kernel + # diagnostic. `Call Trace:` is only a structural marker inside that + # diagnostic, never the cause itself. The old detector promoted the + # marker to an event and therefore sent an unactionable "Kernel call + # trace" every 24 h, sometimes followed by a second burst message for + # another line from the same incident. + from collections import deque as _deque + self._kernel_context = _deque(maxlen=40) + self._KERNEL_CONTEXT_WINDOW_SECS = 15 + # 24h anti-cascade for disk I/O + filesystem errors. The dict # key includes a tier suffix (`sdh:warning`, `sdh:critical`) # so a disk in WARNING cooldown can still escalate to CRITICAL @@ -526,7 +536,6 @@ class JournalWatcher: # paper showed ~36% of failed drives gave no SMART warning. # Rate-based escalation catches the dying drives that SMART # would never flag until they were already bricked. - from collections import deque as _deque self._disk_error_window: Dict[str, "_deque[float]"] = {} self._DISK_ERROR_WINDOW_SECS = 86400 # 24h # Tiers calibrated for homelab/SMB Proxmox usage: @@ -767,7 +776,7 @@ class JournalWatcher: self._check_auth_failure(msg, syslog_id, entry) self._check_fail2ban(msg, syslog_id) - self._check_kernel_critical(msg, syslog_id, priority) + self._check_kernel_critical(msg, syslog_id, priority, entry) self._check_service_failure(msg, unit) self._check_disk_io(msg, syslog_id, priority) self._check_cluster_events(msg, syslog_id) @@ -849,13 +858,69 @@ class JournalWatcher: 'hostname': self._hostname, }, entity='user', entity_id=ip) - def _check_kernel_critical(self, msg: str, syslog_id: str, priority: int): + def _remember_kernel_context(self, msg: str, now: float) -> str: + """Record and return the recent journal excerpt for a kernel event.""" + self._kernel_context.append((now, msg)) + cutoff = now - self._KERNEL_CONTEXT_WINDOW_SECS + while self._kernel_context and self._kernel_context[0][0] < cutoff: + self._kernel_context.popleft() + return '\n'.join(line for _, line in self._kernel_context)[-4000:] + + @staticmethod + def _kernel_diagnostic(msg: str) -> Optional[Tuple[str, str, str]]: + """Return (kind, process, component) for an attributable kernel event. + + A bare ``Call Trace:`` intentionally has no match. It is analogous to + a heading in a diagnostic block and cannot establish that a new fault + occurred. The patterns below identify the line that explains why the + kernel printed the trace. + """ + patterns = ( + (r'\bWARNING:\s+CPU:', 'Kernel warning'), + (r'\bINFO:\s+task\s+.+?\s+blocked for more than\s+\d+', 'Blocked kernel task'), + (r'\btask\s+.+?\s+blocked for more than\s+\d+', 'Blocked kernel task'), + (r'\brcu(?:_preempt|_sched|):.*detected stalls?', 'RCU stall'), + (r'\bsoft lockup\b', 'CPU soft lockup'), + (r'\bhard LOCKUP\b', 'CPU hard lockup'), + (r'\bgeneral protection fault\b', 'General protection fault'), + (r'\bunable to handle kernel (?:NULL pointer dereference|paging request)', 'Kernel memory access fault'), + (r'\bOops:', 'Kernel oops'), + (r'\bUBSAN:', 'Undefined behaviour detected'), + (r'\bKASAN:', 'Kernel memory safety violation'), + ) + kind = '' + for pattern, label in patterns: + if re.search(pattern, msg, re.IGNORECASE): + kind = label + break + if not kind: + return None + + process = '' + process_match = re.search(r'\bPID:\s*(\d+)\s+Comm:\s*([^\s]+)', msg) + if process_match: + process = f'{process_match.group(2)} (PID {process_match.group(1)})' + else: + blocked_match = re.search(r'\btask\s+([^:\s]+)(?::\d+)?\s+blocked for more than', msg, re.IGNORECASE) + if blocked_match: + process = blocked_match.group(1) + + component = '' + component_match = re.search(r'\bat\s+([^\s+]+)(?:\+0x[0-9a-f]+/0x[0-9a-f]+)?', msg, re.IGNORECASE) + if component_match: + component = component_match.group(1) + + return kind, process, component + + def _check_kernel_critical(self, msg: str, syslog_id: str, priority: int, + entry: Optional[Dict] = None): """Detect kernel panics, OOM, segfaults, hardware errors.""" # Only process messages from kernel or systemd (not app-level logs) if syslog_id and syslog_id not in ('kernel', 'systemd', 'systemd-coredump', ''): return now = time.time() + journal_context = self._remember_kernel_context(msg, now) if self._oom_lines and now - self._oom_started_at > 15: self._oom_lines = [] self._oom_started_at = 0.0 @@ -918,6 +983,43 @@ class JournalWatcher: for noise in _KERNEL_NOISE: if re.search(noise, msg, re.IGNORECASE): return + + # A JSON journal entry lets us prove that the diagnostic came from the + # kernel transport. Plain-mode input remains supported for older + # journalctl fallbacks, but a systemd/application entry containing the + # words "WARNING: CPU" cannot masquerade as a kernel event. + transport = str((entry or {}).get('_TRANSPORT', '') or '') + is_kernel_source = entry is None or syslog_id == 'kernel' or transport == 'kernel' + diagnostic = self._kernel_diagnostic(msg) if is_kernel_source and not self._oom_lines else None + if diagnostic: + kind, process, component = diagnostic + observed_us = str((entry or {}).get('__REALTIME_TIMESTAMP', '') or '') + try: + observed_ts = int(observed_us) / 1_000_000 if observed_us else now + except (TypeError, ValueError): + observed_ts = now + observed_at = time.strftime('%Y-%m-%dT%H:%M:%S%z', time.localtime(observed_ts)) + details = [f'Type: {kind}'] + if process: + details.append(f'Process: {process}') + if component: + details.append(f'Component: {component}') + details.extend((f'Message: {msg[:500]}', f'Recorded: {observed_at}')) + identity = f'{kind}\x1f{component}\x1f{process}\x1f{msg[:300]}' + entity_id = f'kernel_{hashlib.sha256(identity.encode(errors="replace")).hexdigest()[:16]}' + self._emit( + 'kernel_warning', + 'WARNING', + { + 'hostname': self._hostname, + 'reason': f'{kind}\n{msg[:500]}', + 'kernel_details': '\n'.join(details), + '_journal_context': journal_context, + }, + entity='node', + entity_id=entity_id, + ) + return # NOTE: Disk I/O errors (ATA, SCSI, blk_update_request) are NOT handled # here. They are detected exclusively by HealthMonitor._check_disks_optimized @@ -932,7 +1034,6 @@ class JournalWatcher: r'Out of memory': ('system_problem', 'CRITICAL', 'Out of memory killer activated'), r'segfault': ('system_problem', 'WARNING', 'Segmentation fault detected'), r'BUG:': ('system_problem', 'CRITICAL', 'Kernel BUG detected'), - r'Call Trace:': ('system_problem', 'WARNING', 'Kernel call trace'), r'EXT4-fs error': ('system_problem', 'CRITICAL', 'Filesystem error'), r'BTRFS error': ('system_problem', 'CRITICAL', 'Filesystem error'), r'XFS.*error': ('system_problem', 'CRITICAL', 'Filesystem error'), @@ -2634,6 +2735,53 @@ class PollingCollector: def _hostname(self) -> str: return _hostname() + @staticmethod + def _guest_storage_error_is_now_foreign(error_key: str, old_meta: dict) -> bool: + """Return True when a disappearing guest-capacity error moved nodes. + + Older versions recorded `lxc_disk_` and `vm_disk_` on + every cluster member because the health check consumed the unfiltered + cluster resource list. A normal `resolved_keys` transition would make + those foreign records produce one final, false recovery after the + ownership filter is installed. The same distinction matters during a + real migration: leaving the old node is not recovery. + + Prefer the current cluster owner over the historical details, because + a legitimate local alert can subsequently migrate. The stored node is + only a fallback for a guest no longer present in the resource list. + """ + match = re.fullmatch(r'(?:lxc|vm)_disk_(\d+)', str(error_key or '')) + if not match: + return False + + try: + import flask_server # deferred: flask_server imports this module + local_node = str(flask_server.get_proxmox_node_name() or '') + resources = flask_server.get_cached_pvesh_cluster_resources_vm() or [] + vmid = match.group(1) + for resource in resources: + if str(resource.get('vmid', '')) != vmid: + continue + if resource.get('type') not in ('lxc', 'qemu', 'vm'): + continue + owner = str(resource.get('node') or '') + if owner and local_node: + return owner != local_node + except Exception: + local_node = '' + + details = old_meta.get('details') if isinstance(old_meta, dict) else None + if isinstance(details, str): + try: + details = json.loads(details) + except (json.JSONDecodeError, TypeError): + details = None + if isinstance(details, dict): + owner = str(details.get('node') or '') + if owner and local_node: + return owner != local_node + return False + def start(self): if self._running: return @@ -2988,6 +3136,15 @@ class PollingCollector: reason = old_meta.get('reason', '') first_seen = old_meta.get('first_seen', '') + # A guest moving to another cluster node — or a legacy foreign + # record created by the old cluster-wide capacity scan — has not + # recovered. Drop only this node's tracking state and let the + # current owner report the condition if it is still present. + if self._guest_storage_error_is_now_foreign(key, old_meta): + self._last_notified.pop(key, None) + self._notified_severity.pop(key, None) + continue + # Skip recovery for INFO/OK - they never triggered an alert if old_meta.get('severity', '') in ('INFO', 'OK'): self._last_notified.pop(key, None) @@ -3642,7 +3799,11 @@ class PollingCollector: # blocks the others. try: import lxc_apps - lxc_apps.refresh_all_apps(force=False) + # The automatic sweep builds one detailed summary after every app + # has been refreshed. Suppress the per-app emit here so the user + # does not receive the individual messages before that summary. + # Explicit UI checks keep the default notify=True behaviour. + lxc_apps.refresh_all_apps(force=False, notify=False) # Docker images have an independent lifecycle from both the OS # packages and the Docker engine. Refresh their read-only # registry digest inventory on the same daily cadence; this never @@ -3652,16 +3813,9 @@ class PollingCollector: # yesterday's cycle cannot postpone the next automatic scan by an # additional day. Normal UI reads remain cache-only for 24 hours. lxc_apps.refresh_docker_inventories(force=True) - # After the refresh, emit `app_update_available` for every - # sidecar entry currently flagged with a pending upstream - # release. `check_app(force=False)` short-circuits on a - # fresh `checked_at` and never reaches the emit path, so - # without this call the notification only ever fired on - # the exact tick where a new version was FIRST observed — - # missed forever if the user had the toggle off at that - # moment. `notification_manager` dedups by entity_id - # (vmid + app_id + latest_version) so repeated calls only - # deliver one notification per release. + # Emit one detailed registered-app summary for this sweep. A + # single pending app retains the existing individual wording; + # several apps are grouped by CT with every version pair intact. lxc_apps.emit_all_pending_docker_stacks() lxc_apps.emit_all_pending_updates() except Exception as e: diff --git a/AppImage/scripts/notification_manager.py b/AppImage/scripts/notification_manager.py index 9edca6f0..0a14ea05 100644 --- a/AppImage/scripts/notification_manager.py +++ b/AppImage/scripts/notification_manager.py @@ -497,6 +497,7 @@ AGGREGATION_RULES = { 'service_fail': {'window': 90, 'min_count': 2, 'burst_type': 'burst_service_fail'}, 'service_fail_batch': {'window': 90, 'min_count': 2, 'burst_type': 'burst_service_fail'}, 'system_problem': {'window': 90, 'min_count': 2, 'burst_type': 'burst_system'}, + 'kernel_warning': {'window': 90, 'min_count': 2, 'burst_type': 'burst_system'}, 'oom_kill': {'window': 60, 'min_count': 2, 'burst_type': 'burst_generic'}, 'firewall_issue': {'window': 60, 'min_count': 2, 'burst_type': 'burst_generic'}, } @@ -522,12 +523,10 @@ _DEFAULT_AGGREGATION = {'window': 60, 'min_count': 2, 'burst_type': 'burst_gener # recovery is per-event; collapsing them adds zero information. _AGGREGATION_EXEMPT_EVENTS = frozenset({ 'error_resolved', - # Per-app upstream update. Each event carries a distinct app name, - # version and CT id — collapsing "5 app updates burst" into a - # summary hides exactly the information the user wants (which - # apps, which versions). Startup emit fires all pending updates - # at once, so without this exemption only the first 1-2 land and - # the rest get buffered into a useless summary. + # Registered-app updates are grouped deliberately by their producer during + # automatic/startup sweeps, preserving each app, CT and version pair. + # Manual checks still emit one complete per-app event. Sending either form + # through the generic burst formatter would discard those details. 'app_update_available', 'docker_stack_update_available', 'lxc_update_applied', @@ -1274,8 +1273,18 @@ class NotificationManager: channels = dict(self._channels) template = TEMPLATES.get(event_type, {}) - event_group = template.get('group', 'other') - default_event_enabled = 'true' if template.get('default_enabled', True) else 'false' + # Hidden burst templates represent their originating event; they must + # inherit both its category and its per-event toggle. Otherwise turning + # off an individual alert suppresses the first message but the hidden + # "+N more" summary still arrives later. + filter_event_type = event_type + if template.get('hidden', False): + source_event_type = str(data.get('event_type', '') or '') + if source_event_type in TEMPLATES: + filter_event_type = source_event_type + filter_template = TEMPLATES.get(filter_event_type, template) + event_group = filter_template.get('group', template.get('group', 'other')) + default_event_enabled = 'true' if filter_template.get('default_enabled', True) else 'false' # Build AI config once (shared across channels, detail_level varies) ai_config = self._build_ai_config() @@ -1292,7 +1301,7 @@ class NotificationManager: # ── Per-channel event check ── # Default: from template default_enabled, unless explicitly set. - ch_event_key = f'{ch_name}.event.{event_type}' + ch_event_key = f'{ch_name}.event.{filter_event_type}' if self._config.get(ch_event_key, default_event_enabled) == 'false': continue # Channel has this specific event disabled diff --git a/AppImage/scripts/notification_templates.py b/AppImage/scripts/notification_templates.py index 25a28c9f..2befce56 100644 --- a/AppImage/scripts/notification_templates.py +++ b/AppImage/scripts/notification_templates.py @@ -418,6 +418,73 @@ def _format_system_startup(data: Dict[str, Any]) -> Tuple[str, str]: return title, body +def _format_app_update_available(data: Dict[str, Any]) -> Tuple[str, str]: + """Render one app update or a scheduled multi-app summary.""" + hostname = str(data.get("hostname") or _get_hostname()) + updates = data.get("updates") + if not isinstance(updates, list) or len(updates) < 2: + app_name = str(data.get("app_name") or "app") + vmid = data.get("vmid", "") + ct_name = str(data.get("ct_name") or f"CT-{vmid}") + installed = str(data.get("installed") or "unknown") + latest = str(data.get("latest") or "unknown") + return ( + f"{hostname}: {app_name} update available on CT {vmid}", + f"{app_name} on CT {vmid} ({ct_name}) has a new version:\n" + f" {installed} → {latest}", + ) + + clean_updates = [] + for item in updates: + if not isinstance(item, dict): + continue + try: + vmid = int(item.get("vmid")) + except (TypeError, ValueError): + continue + clean_updates.append({ + "vmid": vmid, + "app_name": str(item.get("app_name") or "app"), + "installed": str(item.get("installed") or "unknown"), + "latest": str(item.get("latest") or "unknown"), + }) + clean_updates.sort( + key=lambda item: (item["vmid"], item["app_name"].casefold()) + ) + if not clean_updates: + return ( + f"{hostname}: Application updates available", + "Application updates are available.", + ) + + count = len(clean_updates) + container_count = len({item["vmid"] for item in clean_updates}) + title = f"{hostname}: {count} application updates available" + lead = ( + f"{count} applications in {container_count} LXC " + f"container{'s' if container_count != 1 else ''} have a newer version:" + ) + sections = [] + omitted = 0 + for vmid in sorted({item["vmid"] for item in clean_updates}): + rows = [item for item in clean_updates if item["vmid"] == vmid] + section = [f"CT {vmid}"] + section.extend( + f"• {item['app_name']}: {item['installed']} → {item['latest']}" + for item in rows + ) + candidate = "\n\n".join([lead, *sections, "\n".join(section)]) + # Leave room for channel-specific wrappers and AI formatting while + # keeping the raw Telegram message comfortably below 4096 chars. + if len(candidate) > 3200: + omitted += len(rows) + continue + sections.append("\n".join(section)) + if omitted: + sections.append(f"… {omitted} additional application(s)") + return title, "\n\n".join([lead, *sections]) + + # ─── Severity Icons ────────────────────────────────────────────── SEVERITY_ICONS = { @@ -536,6 +603,7 @@ TEMPLATES = { # this one off meant users who registered apps in the App tab # never received the notification they explicitly asked for. 'default_enabled': True, + 'formatter': '_format_app_update_available', }, 'docker_stack_update_available': { 'title': '{hostname}: Docker updates available on CT {vmid}', @@ -968,6 +1036,13 @@ TEMPLATES = { 'group': 'services', 'default_enabled': True, }, + 'kernel_warning': { + 'title': '{hostname}: Kernel diagnostic event detected', + 'body': 'The kernel recorded a diagnostic event.\n{kernel_details}', + 'label': 'Kernel warnings and diagnostic traces', + 'group': 'services', + 'default_enabled': True, + }, 'service_fail': { 'title': '{hostname}: Service failed — {service_name}', 'body': 'System service "{service_name}" has failed.\nReason: {reason}', @@ -1811,6 +1886,7 @@ EVENT_EMOJI = { 'system_reboot': '\U0001F504', 'system_restore_completed': '✅', # check mark 'system_problem': '\u26A0\uFE0F', + 'kernel_warning': '\u26A0\uFE0F', 'service_fail': '\u274C', 'oom_kill': '\U0001F4A3', # bomb # Health diff --git a/AppImage/scripts/security_manager.py b/AppImage/scripts/security_manager.py index 8fe8a74f..44d96eda 100644 --- a/AppImage/scripts/security_manager.py +++ b/AppImage/scripts/security_manager.py @@ -1760,11 +1760,27 @@ def get_lynis_audit_status(): } -def parse_lynis_report(): +def _parse_lynis_warning(value): + """Lynis 3.x: ID|message|details|solution; retain legacy L/M/H records.""" + parts = [part.strip() for part in value.split("|")] + if len(parts) < 2: + return None + legacy = parts[1] in ("L", "M", "H") + return { + "test_id": parts[0], + "severity": parts[1] if legacy else "", + "description": (parts[2] if len(parts) > 2 else "") if legacy else parts[1], + "details": "" if legacy or len(parts) < 3 or parts[2] == "-" else parts[2], + "solution": parts[3] if len(parts) > 3 and parts[3] != "-" else "", + } + + +def parse_lynis_report(enrich_current=True): """ Parse /var/log/lynis-report.dat into structured report data. Also enriches with data from lynis.log when report.dat is sparse. - Returns a dict with all audit findings. + Returns a dict with all audit findings. Set enrich_current=False when + consuming historical evidence: do not run live fallback probes. """ report_file = "/var/log/lynis-report.dat" output_file = "/var/log/lynis-output.log" @@ -1890,14 +1906,9 @@ def parse_lynis_report(): # Parse warnings for w in warnings_raw: - parts = w.split("|") - if len(parts) >= 2: - report["warnings"].append({ - "test_id": parts[0].strip() if len(parts) > 0 else "", - "severity": parts[1].strip() if len(parts) > 1 else "", - "description": parts[2].strip() if len(parts) > 2 else parts[1].strip(), - "solution": parts[3].strip() if len(parts) > 3 else "", - }) + warning = _parse_lynis_warning(w) + if warning: + report["warnings"].append(warning) # Parse suggestions for s in suggestions_raw: @@ -2100,7 +2111,7 @@ def parse_lynis_report(): break # Also check pve-firewall directly (Proxmox uses its own firewall service) - if not report["firewall_active"]: + if enrich_current and not report["firewall_active"]: try: rc, out, _ = _run_cmd(["systemctl", "is-active", "pve-firewall"]) if rc == 0 and out.strip() == "active": @@ -2246,7 +2257,7 @@ def parse_lynis_report(): pass # Fallback: get kernel from uname if still empty - if not report["kernel_version"]: + if enrich_current and not report["kernel_version"]: try: rc, out, _ = _run_cmd(["uname", "-r"]) if rc == 0 and out.strip(): @@ -2255,7 +2266,7 @@ def parse_lynis_report(): pass # Fallback: get hostname from system - if not report["hostname"]: + if enrich_current and not report["hostname"]: try: import socket report["hostname"] = socket.gethostname() @@ -2263,7 +2274,7 @@ def parse_lynis_report(): pass # Fallback: get installed packages count - if report["installed_packages"] == 0: + if enrich_current and report["installed_packages"] == 0: try: rc, out, _ = _run_cmd(["dpkg", "-l"]) if rc == 0 and out: diff --git a/AppImage/scripts/tests/test_auth_manager_setup.py b/AppImage/scripts/tests/test_auth_manager_setup.py index 8e45a22d..658f23b2 100644 --- a/AppImage/scripts/tests/test_auth_manager_setup.py +++ b/AppImage/scripts/tests/test_auth_manager_setup.py @@ -109,5 +109,105 @@ class SetupAuthTests(unittest.TestCase): self.assertEqual(config[key], value) +class ChangePasswordTests(unittest.TestCase): + def setUp(self): + self.temp_dir = tempfile.TemporaryDirectory() + self.addCleanup(self.temp_dir.cleanup) + config_dir = Path(self.temp_dir.name) + config_patch = mock.patch.multiple( + auth_manager, + CONFIG_DIR=config_dir, + AUTH_CONFIG_FILE=config_dir / "auth.json", + ) + config_patch.start() + self.addCleanup(config_patch.stop) + + self.current_password = "CurrentPass1!" + self.new_password = "Replacement2!" + auth_manager.AUTH_CONFIG_FILE.write_text(json.dumps({ + "enabled": True, + "configured": True, + "declined": False, + "username": "admin", + "password_hash": auth_manager.hash_password(self.current_password), + "totp_enabled": False, + "totp_secret": None, + "backup_codes": [], + })) + + def read_config(self): + return json.loads(auth_manager.AUTH_CONFIG_FILE.read_text()) + + def test_missing_current_password_is_rejected_without_exception(self): + self.assertFalse(auth_manager.verify_password(None, self.read_config()["password_hash"])) + + success, message = auth_manager.change_password(None, self.new_password) + + self.assertFalse(success) + self.assertEqual(message, "Current password is incorrect") + + def test_password_change_without_2fa(self): + success, message = auth_manager.change_password( + self.current_password, self.new_password + ) + + self.assertTrue(success, message) + self.assertTrue(auth_manager.verify_password( + self.new_password, self.read_config()["password_hash"] + )) + + def test_password_change_requires_2fa_when_enabled(self): + config = self.read_config() + config["totp_enabled"] = True + auth_manager.AUTH_CONFIG_FILE.write_text(json.dumps(config)) + + success, message = auth_manager.change_password( + self.current_password, self.new_password + ) + + self.assertFalse(success) + self.assertEqual(message, "2FA code required to change password") + self.assertTrue(auth_manager.verify_password( + self.current_password, self.read_config()["password_hash"] + )) + + def test_password_change_accepts_valid_2fa_code(self): + config = self.read_config() + config["totp_enabled"] = True + auth_manager.AUTH_CONFIG_FILE.write_text(json.dumps(config)) + + with mock.patch.object( + auth_manager, "verify_totp", return_value=(True, "accepted") + ) as verify_totp: + success, message = auth_manager.change_password( + self.current_password, self.new_password, "123456" + ) + + self.assertTrue(success, message) + verify_totp.assert_called_once_with("admin", "123456", use_backup=False) + self.assertTrue(auth_manager.verify_password( + self.new_password, self.read_config()["password_hash"] + )) + + def test_password_change_rejects_invalid_2fa_and_preserves_password(self): + config = self.read_config() + config["totp_enabled"] = True + auth_manager.AUTH_CONFIG_FILE.write_text(json.dumps(config)) + + with mock.patch.object( + auth_manager, "verify_totp", return_value=(False, "rejected") + ) as verify_totp: + success, message = auth_manager.change_password( + self.current_password, self.new_password, "000000" + ) + + self.assertFalse(success) + self.assertEqual(message, "Invalid 2FA code") + self.assertEqual(verify_totp.call_count, 2) + self.assertTrue(auth_manager.verify_password( + self.current_password, self.read_config()["password_hash"] + )) + + if __name__ == "__main__": unittest.main() diff --git a/AppImage/scripts/tests/test_cluster_guest_storage_ownership.py b/AppImage/scripts/tests/test_cluster_guest_storage_ownership.py new file mode 100644 index 00000000..8d49b380 --- /dev/null +++ b/AppImage/scripts/tests/test_cluster_guest_storage_ownership.py @@ -0,0 +1,129 @@ +import sys +import unittest +from pathlib import Path +from queue import Queue +from types import ModuleType, SimpleNamespace +from unittest.mock import patch + + +SCRIPTS_DIR = Path(__file__).resolve().parents[1] +if str(SCRIPTS_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPTS_DIR)) + +# These modules normally bind the live `/usr/local/share/proxmenux` database +# while importing. Ownership tests need no host state, so provide the same +# narrow dependency boundary used by the production functions below. +_health_persistence_module = ModuleType("health_persistence") +_health_persistence_module.health_persistence = SimpleNamespace( + cleanup_old_errors=lambda: None, +) +_health_persistence_module.disk_base_name = lambda name: str(name).replace("/dev/", "") +sys.modules.setdefault("health_persistence", _health_persistence_module) + +sys.modules.setdefault("psutil", ModuleType("psutil")) + +flask_server = SimpleNamespace( + get_proxmox_node_name=lambda: "fixture", + get_cached_pvesh_cluster_resources_vm=lambda: [], + get_cached_vm_disk=lambda _vmid: None, +) +sys.modules.setdefault("flask_server", flask_server) + +import health_monitor # noqa: E402 +import notification_events # noqa: E402 + + +class _Persistence: + def __init__(self): + self.recorded = [] + self.cleared = [] + + def record_error(self, **kwargs): + self.recorded.append(kwargs) + + def get_active_errors(self, *args, **kwargs): + return [] + + def clear_error(self, key): + self.cleared.append(key) + + +class ClusterGuestStorageOwnershipTests(unittest.TestCase): + def setUp(self): + self.monitor = health_monitor.HealthMonitor.__new__(health_monitor.HealthMonitor) + self.persistence = _Persistence() + self.resources = [ + { + "type": "lxc", "node": "hades", "status": "running", + "vmid": 128, "name": "plex", "disk": 94, "maxdisk": 100, + }, + { + "type": "lxc", "node": "poseidon", "status": "running", + "vmid": 129, "name": "remote", "disk": 99, "maxdisk": 100, + }, + ] + + def test_lxc_capacity_records_only_guests_owned_by_local_node(self): + with ( + patch.object(health_monitor, "MOUNT_MONITOR_AVAILABLE", False), + patch.object(health_monitor, "health_persistence", self.persistence), + patch.object(flask_server, "get_proxmox_node_name", return_value="hades"), + patch.object(flask_server, "get_cached_pvesh_cluster_resources_vm", return_value=self.resources), + ): + result = self.monitor._check_lxc_disk_usage() + + self.assertEqual(result["status"], "WARNING") + self.assertEqual([row["error_key"] for row in self.persistence.recorded], ["lxc_disk_128"]) + self.assertEqual(self.persistence.recorded[0]["details"]["node"], "hades") + self.assertNotIn("CT 129", result["checks"]) + + def test_vm_capacity_does_not_probe_remote_guest_agent(self): + resources = [ + {"type": "qemu", "node": "hades", "status": "running", "vmid": 201, "name": "local"}, + {"type": "qemu", "node": "poseidon", "status": "running", "vmid": 202, "name": "remote"}, + ] + + def disk_for(vmid): + if vmid == 201: + return (94, 100) + raise AssertionError("remote VM was probed") + + with ( + patch.object(health_monitor, "health_persistence", self.persistence), + patch.object(flask_server, "get_proxmox_node_name", return_value="hades"), + patch.object(flask_server, "get_cached_pvesh_cluster_resources_vm", return_value=resources), + patch.object(flask_server, "get_cached_vm_disk", side_effect=disk_for), + ): + result = self.monitor._check_vm_disk_usage() + + self.assertEqual(result["status"], "WARNING") + self.assertEqual([row["error_key"] for row in self.persistence.recorded], ["vm_disk_201"]) + self.assertEqual(self.persistence.recorded[0]["details"]["node"], "hades") + + def test_foreign_legacy_record_is_not_a_recovery(self): + collector = notification_events.PollingCollector(Queue()) + resources = [{"type": "lxc", "node": "poseidon", "vmid": 128}] + with ( + patch.object(flask_server, "get_proxmox_node_name", return_value="hades"), + patch.object(flask_server, "get_cached_pvesh_cluster_resources_vm", return_value=resources), + ): + foreign = collector._guest_storage_error_is_now_foreign( + "lxc_disk_128", {"details": {"vmid": "128"}} + ) + self.assertTrue(foreign) + + def test_local_recovery_remains_a_recovery(self): + collector = notification_events.PollingCollector(Queue()) + resources = [{"type": "lxc", "node": "hades", "vmid": 128}] + with ( + patch.object(flask_server, "get_proxmox_node_name", return_value="hades"), + patch.object(flask_server, "get_cached_pvesh_cluster_resources_vm", return_value=resources), + ): + foreign = collector._guest_storage_error_is_now_foreign( + "lxc_disk_128", {"details": {"vmid": "128", "node": "hades"}} + ) + self.assertFalse(foreign) + + +if __name__ == "__main__": + unittest.main() diff --git a/AppImage/scripts/tests/test_kernel_trace_notifications.py b/AppImage/scripts/tests/test_kernel_trace_notifications.py new file mode 100644 index 00000000..3ff3431a --- /dev/null +++ b/AppImage/scripts/tests/test_kernel_trace_notifications.py @@ -0,0 +1,86 @@ +import json +import sys +import unittest +from pathlib import Path +from queue import Empty, Queue + + +SCRIPTS_DIR = Path(__file__).resolve().parents[1] +APPIMAGE_DIR = SCRIPTS_DIR.parent +if str(SCRIPTS_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPTS_DIR)) + +import notification_events # noqa: E402 +import notification_templates # noqa: E402 + + +class KernelTraceNotificationTests(unittest.TestCase): + def setUp(self): + self.queue = Queue() + self.watcher = notification_events.JournalWatcher(self.queue) + + def _check(self, message, *, syslog_id="kernel", transport="kernel"): + self.watcher._check_kernel_critical( + message, + syslog_id, + 4, + { + "_TRANSPORT": transport, + "__REALTIME_TIMESTAMP": "1788883200000000", + }, + ) + + def test_bare_call_trace_is_not_an_event(self): + self._check("Call Trace:") + with self.assertRaises(Empty): + self.queue.get_nowait() + + def test_kernel_warning_carries_attributable_fields(self): + self._check( + "WARNING: CPU: 2 PID: 418 Comm: z_wr_iss at arc_evict_state+0x12/0x80" + ) + event = self.queue.get_nowait() + self.assertEqual(event.event_type, "kernel_warning") + self.assertEqual(event.severity, "WARNING") + self.assertIn("Type: Kernel warning", event.data["kernel_details"]) + self.assertIn("Process: z_wr_iss (PID 418)", event.data["kernel_details"]) + self.assertIn("Component: arc_evict_state", event.data["kernel_details"]) + self.assertIn("Recorded: 2026-", event.data["kernel_details"]) + self.assertIn("WARNING: CPU", event.data["_journal_context"]) + + self._check("Call Trace:") + with self.assertRaises(Empty): + self.queue.get_nowait() + + def test_application_text_cannot_impersonate_kernel_warning(self): + self._check( + "WARNING: CPU: 0 PID: 99 Comm: example at fake_function+0x1/0x2", + syslog_id="systemd", + transport="stdout", + ) + with self.assertRaises(Empty): + self.queue.get_nowait() + + def test_blocked_task_is_identified(self): + self._check("INFO: task txg_sync:812 blocked for more than 120 seconds.") + event = self.queue.get_nowait() + self.assertEqual(event.event_type, "kernel_warning") + self.assertIn("Type: Blocked kernel task", event.data["kernel_details"]) + self.assertIn("Process: txg_sync", event.data["kernel_details"]) + + def test_event_is_visible_and_translated_in_every_monitor_locale(self): + services = notification_templates.get_event_types_by_group()["services"] + self.assertIn("kernel_warning", {item["type"] for item in services}) + for locale in ("en", "es", "de", "fr", "it", "pt", "sk", "sv"): + messages = json.loads( + (APPIMAGE_DIR / "messages" / locale / "common.json").read_text( + encoding="utf-8" + ) + ) + self.assertTrue( + messages["settings"]["notifications"]["eventTypes"]["kernel_warning"] + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/AppImage/scripts/tests/test_lxc_app_notification_batch.py b/AppImage/scripts/tests/test_lxc_app_notification_batch.py new file mode 100644 index 00000000..48479eb3 --- /dev/null +++ b/AppImage/scripts/tests/test_lxc_app_notification_batch.py @@ -0,0 +1,140 @@ +import json +import sys +import tempfile +import types +import unittest +from pathlib import Path +from unittest import mock + + +SCRIPTS_DIR = Path(__file__).resolve().parents[1] +if str(SCRIPTS_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPTS_DIR)) + +import lxc_apps +import notification_templates + + +def _app(app_id, name, installed, latest, **extra): + return { + "id": app_id, + "name": name, + "state": { + "installed_version": installed, + "latest_version": latest, + "update_available": True, + }, + **extra, + } + + +class _FakeNotificationManager: + def __init__(self): + self.calls = [] + + def emit_event(self, **kwargs): + self.calls.append(kwargs) + return {"success": True} + + +class AppUpdateNotificationBatchTests(unittest.TestCase): + def _write_sidecar(self, directory, vmid, apps): + Path(directory, f"{vmid}.json").write_text( + json.dumps({"vmid": vmid, "apps": apps}), + encoding="utf-8", + ) + + def _emit(self, sidecars): + fake = _FakeNotificationManager() + module = types.SimpleNamespace(notification_manager=fake) + with tempfile.TemporaryDirectory() as directory: + for vmid, apps in sidecars.items(): + self._write_sidecar(directory, vmid, apps) + with ( + mock.patch.object(lxc_apps, "_APPS_DIR", directory), + mock.patch.dict(sys.modules, {"notification_manager": module}), + ): + count = lxc_apps.emit_all_pending_updates() + return count, fake.calls + + def test_multiple_updates_are_sent_as_one_sorted_batch(self): + count, calls = self._emit({ + 115: [ + _app("redis", "Redis", "7.0.15-1", "8.10.1"), + _app("docmost", "Docmost", "0.23.2", "0.95.0"), + ], + 100: [_app("adguard", "AdGuard Home", "0.107.78", "0.107.79")], + }) + + self.assertEqual(count, 3) + self.assertEqual(len(calls), 1) + event = calls[0] + self.assertEqual(event["event_type"], "app_update_available") + self.assertEqual(event["entity"], "node") + self.assertTrue(event["entity_id"].startswith("batch:")) + self.assertEqual(event["data"]["count"], 3) + self.assertEqual(event["data"]["container_count"], 2) + self.assertEqual( + [(item["vmid"], item["app_name"]) for item in event["data"]["updates"]], + [(100, "AdGuard Home"), (115, "Docmost"), (115, "Redis")], + ) + + def test_single_update_keeps_the_individual_event_shape(self): + count, calls = self._emit({ + 101: [_app("npm", "Nginx Proxy Manager", "2.9.19", "2.15.1")], + }) + + self.assertEqual(count, 1) + self.assertEqual(len(calls), 1) + event = calls[0] + self.assertEqual(event["entity"], "ct") + self.assertNotIn("updates", event["data"]) + self.assertEqual(event["data"]["vmid"], 101) + self.assertEqual(event["data"]["latest"], "2.15.1") + + def test_batch_respects_opt_outs_and_docker_delegation(self): + count, calls = self._emit({ + 110: [ + _app("silent", "Silent", "1.0", "2.0", notifications_enabled=False), + _app("docker", "Docker", "1.0", "2.0", helper_slug="docker"), + _app("portainer", "Portainer", "2.0", "2.1", update_via="docker"), + ], + }) + + self.assertEqual(count, 0) + self.assertEqual(calls, []) + + def test_check_all_can_refresh_without_emitting_individual_events(self): + sidecar = {"vmid": 120, "apps": [{"id": "one"}, {"id": "two"}]} + with ( + mock.patch.object(lxc_apps, "_read_sidecar", return_value=sidecar), + mock.patch.object(lxc_apps, "check_app") as check, + ): + lxc_apps.check_all(120, force=False, notify=False) + + self.assertEqual(check.call_count, 2) + check.assert_any_call(120, "one", force=False, notify=False) + check.assert_any_call(120, "two", force=False, notify=False) + + def test_batch_formatter_groups_versions_by_container(self): + rendered = notification_templates.render_template( + "app_update_available", + { + "hostname": "pve01", + "updates": [ + {"vmid": 115, "app_name": "Redis", "installed": "7.0", "latest": "8.1"}, + {"vmid": 100, "app_name": "AdGuard Home", "installed": "1.0", "latest": "1.1"}, + {"vmid": 115, "app_name": "Docmost", "installed": "0.2", "latest": "0.9"}, + ], + }, + ) + + self.assertEqual(rendered["title"], "pve01: 3 application updates available") + self.assertIn("3 applications in 2 LXC containers", rendered["body"]) + self.assertLess(rendered["body"].index("CT 100"), rendered["body"].index("CT 115")) + self.assertIn("• Docmost: 0.2 → 0.9", rendered["body"]) + self.assertIn("• Redis: 7.0 → 8.1", rendered["body"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/AppImage/scripts/tests/test_notification_burst_toggle_inheritance.py b/AppImage/scripts/tests/test_notification_burst_toggle_inheritance.py new file mode 100644 index 00000000..4dd3ac56 --- /dev/null +++ b/AppImage/scripts/tests/test_notification_burst_toggle_inheritance.py @@ -0,0 +1,66 @@ +import sys +import unittest +from pathlib import Path + + +SCRIPTS_DIR = Path(__file__).resolve().parents[1] +if str(SCRIPTS_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPTS_DIR)) + +import notification_manager # noqa: E402 + + +class RecordingChannel: + def __init__(self): + self.calls = 0 + + def send(self, title, body, severity, data): + self.calls += 1 + return {"success": True, "error": ""} + + +class NotificationBurstToggleInheritanceTests(unittest.TestCase): + def setUp(self): + self.channel = RecordingChannel() + self.manager = notification_manager.NotificationManager() + self.manager._channels = {"email": self.channel} + self.manager._config = { + "email.enabled": "true", + "email.events.services": "true", + "email.rich_format": "false", + "email.event.kernel_warning": "false", + "ai_enabled": "false", + } + + def test_hidden_summary_inherits_source_event_toggle(self): + delivered = self.manager._dispatch_to_channels( + "host: +1 more system problem", + "One additional issue", + "WARNING", + "burst_system", + {"event_type": "kernel_warning", "hostname": "host"}, + "aggregator", + ) + self.assertFalse(delivered) + self.assertEqual(self.channel.calls, 0) + + def test_generic_summary_inherits_source_event_category(self): + self.manager._config.update({ + "email.event.oom_kill": "true", + "email.events.services": "false", + "email.events.other": "true", + }) + delivered = self.manager._dispatch_to_channels( + "host: related events", + "One additional issue", + "WARNING", + "burst_generic", + {"event_type": "oom_kill", "hostname": "host"}, + "aggregator", + ) + self.assertFalse(delivered) + self.assertEqual(self.channel.calls, 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/lang/de.json b/lang/de.json index 6b1e52ed..ca546757 100644 --- a/lang/de.json +++ b/lang/de.json @@ -6,7 +6,7 @@ "(Only the host directory is modified. Nothing inside the container is changed.": "(Es wird nur das Hostverzeichnis geändert. Im Container wird nichts geändert.", "(common default on Debian/LXC: PermitRootLogin prohibit-password).": "(allgemeiner Standard unter Debian/LXC: PermitRootLogin prohibit-password).", "(disabled)": "(deaktiviert)", - "(e.g.": "(z.B.", + "(e.g.": "(z. B.", "(for unprivileged LXCs)": "(für unprivilegierte LXCs)", "(if only privileged LXCs need write access)": "(wenn nur privilegierte LXCs Schreibzugriff benötigen)", "(make.log not found — DKMS may have failed before invoking make)": "(make.log nicht gefunden – DKMS ist möglicherweise vor dem Aufruf von make fehlgeschlagen)", @@ -347,7 +347,7 @@ "Backup created:": "Backup erstellt:", "Backup declares unused NICs that are not on this host:": "Backup deklariert nicht verwendete Netzwerkkarten, die sich nicht auf diesem Host befinden:", "Backup destination is inside the backup": "Das Backup-Ziel liegt innerhalb des Backups", - "Backup failed. See log:": "Sicherung fehlgeschlagen.Siehe Protokoll:", + "Backup failed. See log:": "Sicherung fehlgeschlagen. Siehe Protokoll:", "Backup file appears corrupted, will reinstall packages": "Die Sicherungsdatei scheint beschädigt zu sein, die Pakete werden neu installiert", "Backup host configuration": "Backup-Hostkonfiguration", "Backup includes /etc/zfs/zpool.cache. Restore it (same host detected)?": "Die Sicherung umfasst /etc/zfs/zpool.cache. Wiederherstellen (gleicher Host erkannt)?", @@ -367,7 +367,7 @@ "Backup to local archive (.tar.zst)": "Sicherung im lokalen Archiv (.tar.zst)", "Backup:": "Sicherung:", "Backups already on PBS were encrypted with the current key — downloading them will fail unless you first Download the current keyfile to keep a copy.": "Backups, die sich bereits auf PBS befinden, wurden mit dem aktuellen Schlüssel verschlüsselt – der Download schlägt fehl, es sei denn, Sie laden zuerst die aktuelle Schlüsseldatei herunter, um eine Kopie zu behalten.", - "Backups already stored on PBS were encrypted with the current keyfile. After this action:": "Bereits auf PBS gespeicherte Backups wurden mit der aktuellen Schlüsseldatei verschlüsselt.Nach dieser Aktion:", + "Backups already stored on PBS were encrypted with the current keyfile. After this action:": "Bereits auf PBS gespeicherte Backups wurden mit der aktuellen Schlüsseldatei verschlüsselt. Nach dieser Aktion:", "Bandwidth limit configured": "Bandbreitenbegrenzung konfiguriert", "Bandwidth test (iperf3)": "Bandbreitentest (iperf3)", "Bandwidth test completed successfully": "Bandbreitentest erfolgreich abgeschlossen", @@ -456,7 +456,7 @@ "Cannot proceed with invalid export path.": "Mit ungültigem Exportpfad kann nicht fortgefahren werden.", "Cannot proceed with invalid share name.": "Mit ungültigem Freigabenamen kann nicht fortgefahren werden.", "Cannot reach Proxmox repositories": "Proxmox-Repositorys können nicht erreicht werden", - "Cannot reach download.proxmox.com. Check network, proxy or DNS.": "Download.proxmox.com kann nicht erreicht werden.Überprüfen Sie Netzwerk, Proxy oder DNS.", + "Cannot reach download.proxmox.com. Check network, proxy or DNS.": "Download.proxmox.com kann nicht erreicht werden. Überprüfen Sie Netzwerk, Proxy oder DNS.", "Cannot reach portal:": "Portal kann nicht erreicht werden:", "Cannot reach server": "Server ist nicht erreichbar", "Cannot validate credentials - no shares available for testing.": "Anmeldeinformationen können nicht validiert werden – keine Freigaben zum Testen verfügbar.", @@ -599,7 +599,7 @@ "Cleaning up unused time synchronization services...": "Bereinigen ungenutzter Zeitsynchronisierungsdienste...", "Cleans duplicate or conflicting sources": "Bereinigt doppelte oder widersprüchliche Quellen", "Cleanup Complete": "Bereinigung abgeschlossen", - "Cleanup completed. A reboot is recommended to fully apply pending kernel package configurations.": "Bereinigung abgeschlossen.Ein Neustart wird empfohlen, um ausstehende Kernelpaketkonfigurationen vollständig zu übernehmen.", + "Cleanup completed. A reboot is recommended to fully apply pending kernel package configurations.": "Bereinigung abgeschlossen. Ein Neustart wird empfohlen, um ausstehende Kernelpaketkonfigurationen vollständig zu übernehmen.", "Cleanup finished": "Aufräumen abgeschlossen", "Cleanup legacy gasket-dkms": "Veraltetes gasket-dkms bereinigen", "Cleanup partial VM?": "Teilweise VM bereinigen?", @@ -851,8 +851,8 @@ "Copy that file offsite yourself, or download it from the Monitor.": "Kopieren Sie diese Datei selbst oder laden Sie sie vom Monitor herunter.", "Copy the correct keyfile to this host and rerun Restore — or pick an unencrypted backup.": "Kopieren Sie die richtige Schlüsseldatei auf diesen Host und führen Sie die Wiederherstellung erneut aus – oder wählen Sie ein unverschlüsseltes Backup aus.", "Copy the keyfile to a path for offsite backup": "Kopieren Sie die Schlüsseldatei in einen Pfad für die Offsite-Sicherung", - "Copy your PBS keyfile onto this host first (via scp, USB, sftp, etc.) and enter its absolute path below. The file will be copied to": "Kopieren Sie zuerst Ihre PBS-Schlüsseldatei auf diesen Host (über scp, USB, sftp usw.) und geben Sie unten den absoluten Pfad ein.Die Datei wird kopiert", - "Copy your existing PBS keyfile onto this host first (via scp, USB, sftp, etc.) and enter its absolute path below. The file will be copied to": "Kopieren Sie zuerst Ihre vorhandene PBS-Schlüsseldatei auf diesen Host (über scp, USB, sftp usw.) und geben Sie unten den absoluten Pfad ein.Die Datei wird kopiert", + "Copy your PBS keyfile onto this host first (via scp, USB, sftp, etc.) and enter its absolute path below. The file will be copied to": "Kopieren Sie zuerst Ihre PBS-Schlüsseldatei auf diesen Host (über scp, USB, sftp usw.) und geben Sie unten den absoluten Pfad ein. Die Datei wird kopiert", + "Copy your existing PBS keyfile onto this host first (via scp, USB, sftp, etc.) and enter its absolute path below. The file will be copied to": "Kopieren Sie zuerst Ihre vorhandene PBS-Schlüsseldatei auf diesen Host (über scp, USB, sftp usw.) und geben Sie unten den absoluten Pfad ein. Die Datei wird kopiert", "Copying installer to container": "Installationsprogramm in Container kopieren", "Copying sources to": "Kopieren von Quellen nach", "Coral APT repository ready.": "Coral APT-Repository bereit.", @@ -886,9 +886,9 @@ "Could not change VM virtual display to vga: std": "Die virtuelle VM-Anzeige konnte nicht in vga: std geändert werden", "Could not clone any gasket-driver repository. Check your internet connection and": "Es konnte kein gasket-driver-Repository geklont werden. Überprüfen Sie Ihre Internetverbindung und", "Could not configure IOMMU kernel parameters automatically. Configure manually and reboot.": "IOMMU-Kernelparameter konnten nicht automatisch konfiguriert werden. Manuell konfigurieren und neu starten.", - "Could not copy the PVE keyfile into place. Check permissions on:": "Die PVE-Schlüsseldatei konnte nicht kopiert werden.Überprüfen Sie die Berechtigungen für:", + "Could not copy the PVE keyfile into place. Check permissions on:": "Die PVE-Schlüsseldatei konnte nicht kopiert werden. Überprüfen Sie die Berechtigungen für:", "Could not copy the keyfile into place.": "Die Schlüsseldatei konnte nicht kopiert werden.", - "Could not copy the keyfile into place. Check permissions on:": "Die Schlüsseldatei konnte nicht kopiert werden.Überprüfen Sie die Berechtigungen für:", + "Could not copy the keyfile into place. Check permissions on:": "Die Schlüsseldatei konnte nicht kopiert werden. Überprüfen Sie die Berechtigungen für:", "Could not create converter directory:": "Konverterverzeichnis konnte nicht erstellt werden:", "Could not create destination directory:": "Zielverzeichnis konnte nicht erstellt werden:", "Could not create or access directory:": "Verzeichnis konnte nicht erstellt oder darauf zugegriffen werden:", @@ -898,7 +898,7 @@ "Could not detect the CIFS mount for this directory. Try accessing it manually.": "Der CIFS-Mount für dieses Verzeichnis konnte nicht erkannt werden. Versuchen Sie, manuell darauf zuzugreifen.", "Could not determine a valid ISO storage directory.": "Es konnte kein gültiges ISO-Speicherverzeichnis ermittelt werden.", "Could not determine disk path for:": "Der Festplattenpfad konnte nicht ermittelt werden für:", - "Could not determine filesystem signature types. Aborting.": "Die Signaturtypen des Dateisystems konnten nicht ermittelt werden.Abbruch.", + "Could not determine filesystem signature types. Aborting.": "Die Signaturtypen des Dateisystems konnten nicht ermittelt werden. Abbruch.", "Could not determine the IOMMU group for the selected GPU.": "Die IOMMU-Gruppe für die ausgewählte GPU konnte nicht ermittelt werden.", "Could not download recovery blob from PBS.": "Wiederherstellungsblob konnte nicht von PBS heruntergeladen werden.", "Could not download the installer.": "Das Installationsprogramm konnte nicht heruntergeladen werden.", @@ -920,8 +920,8 @@ "Could not mount": "Konnte nicht gemountet werden", "Could not mount ISO on device": "ISO konnte nicht auf dem Gerät gemountet werden", "Could not parse OVF file, or no disk image references found.": "Die OVF-Datei konnte nicht analysiert werden oder es wurden keine Disk-Image-Referenzen gefunden.", - "Could not prepare on-boot restore service. Nothing new was scheduled.": "Der On-Boot-Wiederherstellungsdienst konnte nicht vorbereitet werden.Es war nichts Neues geplant.", - "Could not publish pending restore. Previous pending restore was kept.": "Ausstehende Wiederherstellung konnte nicht veröffentlicht werden.Die vorherige ausstehende Wiederherstellung wurde beibehalten.", + "Could not prepare on-boot restore service. Nothing new was scheduled.": "Der On-Boot-Wiederherstellungsdienst konnte nicht vorbereitet werden. Es war nichts Neues geplant.", + "Could not publish pending restore. Previous pending restore was kept.": "Ausstehende Wiederherstellung konnte nicht veröffentlicht werden. Die vorherige ausstehende Wiederherstellung wurde beibehalten.", "Could not push the key. Check the password and that": "Die Taste konnte nicht gedrückt werden. Überprüfen Sie das Passwort und so weiter", "Could not read SMART data from": "Die SMART-Daten konnten nicht gelesen werden", "Could not read VM configuration.": "Die VM-Konfiguration konnte nicht gelesen werden.", @@ -935,7 +935,7 @@ "Could not set VM virtual display to vga: std": "Die virtuelle VM-Anzeige konnte nicht auf vga: std gesetzt werden", "Could not set boot order for": "Die Startreihenfolge konnte nicht festgelegt werden", "Could not stage pending restore path:": "Ausstehender Wiederherstellungspfad konnte nicht bereitgestellt werden:", - "Could not stage pending restore. Nothing new was scheduled.": "Die Wiederherstellung konnte nicht bereitgestellt werden.Es war nichts Neues geplant.", + "Could not stage pending restore. Nothing new was scheduled.": "Die Wiederherstellung konnte nicht bereitgestellt werden. Es war nichts Neues geplant.", "Could not stop LXC": "LXC konnte nicht gestoppt werden", "Could not unload nouveau module (may be in use). The blacklist will take effect after reboot. Installation will continue but a reboot will be required.": "Das Nouveau-Modul konnte nicht entladen werden (möglicherweise wird es verwendet). Die Blacklist wird nach dem Neustart wirksam. Die Installation wird fortgesetzt, es ist jedoch ein Neustart erforderlich.", "Could not unmount": "Die Bereitstellung konnte nicht aufgehoben werden", @@ -1628,7 +1628,7 @@ "Failed to create directory on host:": "Verzeichnis auf Host konnte nicht erstellt werden:", "Failed to create directory:": "Verzeichnis konnte nicht erstellt werden:", "Failed to create disk": "Fehler beim Erstellen des Datenträgers", - "Failed to create encryption key. Backup cancelled — fix the underlying issue and retry.": "Fehler beim Erstellen des Verschlüsselungsschlüssels.Sicherung abgebrochen – beheben Sie das zugrunde liegende Problem und versuchen Sie es erneut.", + "Failed to create encryption key. Backup cancelled — fix the underlying issue and retry.": "Fehler beim Erstellen des Verschlüsselungsschlüssels. Sicherung abgebrochen – beheben Sie das zugrunde liegende Problem und versuchen Sie es erneut.", "Failed to create group:": "Gruppe konnte nicht erstellt werden:", "Failed to create mount point.": "Mountpunkt konnte nicht erstellt werden.", "Failed to create mount point:": "Mountpunkt konnte nicht erstellt werden:", @@ -2354,7 +2354,7 @@ "Kernel panic configuration removed": "Kernel-Panic-Konfiguration entfernt", "Kernel panic configuration updated and applied": "Kernel-Panic-Konfiguration aktualisiert und angewendet", "Kernel, modules and boot config": "Kernel, Module und Bootkonfiguration", - "Kernel/boot-tied files (boot config, /etc/systemd/system, initramfs config, apt sources, ZFS state, ...) are NOT copied verbatim to keep the target's boot safe. The operator's own tuning inside them (IOMMU cmdline, VFIO IDs, custom quirks, GRUB timeout, ...) is merged into the target's fresh copies automatically via kernel-agnostic merge.": "Kernel-/Boot-gebundene Dateien (Boot-Konfiguration, /etc/systemd/system, initramfs-Konfiguration, Apt-Quellen, ZFS-Status usw.) werden NICHT wörtlich kopiert, um den Start des Ziels zu schützen.Die darin enthaltenen Einstellungen des Betreibers (IOMMU-Cmdline, VFIO-IDs, benutzerdefinierte Macken, GRUB-Timeout usw.) werden über eine Kernel-unabhängige Zusammenführung automatisch in die neuen Kopien des Ziels eingefügt.", + "Kernel/boot-tied files (boot config, /etc/systemd/system, initramfs config, apt sources, ZFS state, ...) are NOT copied verbatim to keep the target's boot safe. The operator's own tuning inside them (IOMMU cmdline, VFIO IDs, custom quirks, GRUB timeout, ...) is merged into the target's fresh copies automatically via kernel-agnostic merge.": "Kernel-/Boot-gebundene Dateien (Boot-Konfiguration, /etc/systemd/system, initramfs-Konfiguration, Apt-Quellen, ZFS-Status usw.) werden NICHT wörtlich kopiert, um den Start des Ziels zu schützen. Die darin enthaltenen Einstellungen des Betreibers (IOMMU-Cmdline, VFIO-IDs, benutzerdefinierte Macken, GRUB-Timeout usw.) werden über eine Kernel-unabhängige Zusammenführung automatisch in die neuen Kopien des Ziels eingefügt.", "Keyfile copied": "Schlüsseldatei kopiert", "Keyfile copied to:": "Schlüsseldatei kopiert nach:", "Keyfile passphrase": "Schlüsseldatei-Passphrase", @@ -2860,7 +2860,7 @@ "No Shares Found": "Keine Aktien gefunden", "No Storage Found": "Kein Speicher gefunden", "No USB drives detected. Enter the mountpoint path manually:": "Keine USB-Laufwerke erkannt. Geben Sie den Mountpoint-Pfad manuell ein:", - "No USB drives mounted by ProxMenux yet. Mount one first to use it as a target.": "Noch keine USB-Laufwerke von ProxMenux gemountet.Montieren Sie zuerst eines, um es als Ziel zu verwenden.", + "No USB drives mounted by ProxMenux yet. Mount one first to use it as a target.": "Noch keine USB-Laufwerke von ProxMenux gemountet. Montieren Sie zuerst eines, um es als Ziel zu verwenden.", "No UUP folder found.": "Kein UUP-Ordner gefunden.", "No VM was selected.": "Es wurde keine VM ausgewählt.", "No VMID defined. Cannot apply guest agent config.": "Keine VMID definiert. Gast-Agent-Konfiguration kann nicht angewendet werden.", @@ -2872,7 +2872,7 @@ "No VirtIO ISO found. Please download one.": "Keine VirtIO-ISO gefunden. Bitte laden Sie eines herunter.", "No VirtIO ISO selected. Please choose again.": "Kein VirtIO ISO ausgewählt. Bitte wählen Sie erneut.", "No Virtual Machines found on this system.": "Auf diesem System wurden keine virtuellen Maschinen gefunden.", - "No ZFS pools detected. Skipping ZFS ARC optimization.": "Keine ZFS-Pools erkannt.Überspringen der ZFS ARC-Optimierung.", + "No ZFS pools detected. Skipping ZFS ARC optimization.": "Keine ZFS-Pools erkannt. Überspringen der ZFS ARC-Optimierung.", "No ZFS pools detected. Skipping ZFS autotrim.": "Keine ZFS-Pools erkannt. ZFS-Autotrim wird übersprungen.", "No accessible": "Nicht zugänglich", "No accessible NFS servers found.": "Es wurden keine zugänglichen NFS-Server gefunden.", @@ -2922,7 +2922,7 @@ "No duplicate repositories found": "Keine doppelten Repositorys gefunden", "No eligible Controller/NVMe devices remain after SR-IOV filtering. Skipping.": "Nach der SR-IOV-Filterung bleiben keine berechtigten Controller/NVMe-Geräte übrig. Überspringen.", "No eligible controllers remain after SR-IOV filtering.": "Nach der SR-IOV-Filterung bleiben keine berechtigten Controller übrig.", - "No encryption key is stored on this host. Choose how to set one up:": "Auf diesem Host ist kein Verschlüsselungsschlüssel gespeichert.Wählen Sie aus, wie Sie eines einrichten möchten:", + "No encryption key is stored on this host. Choose how to set one up:": "Auf diesem Host ist kein Verschlüsselungsschlüssel gespeichert. Wählen Sie aus, wie Sie eines einrichten möchten:", "No exportable VM disks were found (CD-ROM/cloud-init are excluded).": "Es wurden keine exportierbaren VM-Festplatten gefunden (CD-ROM/Cloud-Init sind ausgeschlossen).", "No exportable disks": "Keine exportierbaren Datenträger", "No exports configured.": "Keine Exporte konfiguriert.", @@ -2975,7 +2975,7 @@ "No ports configured": "Keine Ports konfiguriert", "No privileged containers available in Proxmox.": "In Proxmox sind keine privilegierten Container verfügbar.", "No pve-enterprise.list present (skipped)": "Keine pve-enterprise.list vorhanden (übersprungen)", - "No reboot was started. Review the log before retrying:": "Es wurde kein Neustart gestartet.Überprüfen Sie das Protokoll, bevor Sie es erneut versuchen:", + "No reboot was started. Review the log before retrying:": "Es wurde kein Neustart gestartet. Überprüfen Sie das Protokoll, bevor Sie es erneut versuchen:", "No recent": "Nicht aktuell", "No recent Samba servers found.": "Keine aktuellen Samba-Server gefunden.", "No routing information found.": "Keine Routing-Informationen gefunden.", @@ -3799,7 +3799,7 @@ "Same Version Detected": "Gleiche Version erkannt", "Same host:": "Gleicher Gastgeber:", "Same major series:": "Gleiche Hauptserie:", - "Same major.minor:": "Gleiches Dur.Moll:", + "Same major.minor:": "Gleiches Dur. Moll:", "Sanitizing NVIDIA host services for VFIO mode...": "Bereinigen der NVIDIA-Hostdienste für den VFIO-Modus ...", "Save the passphrase somewhere safe NOW, before continuing.": "Speichern Sie die Passphrase JETZT an einem sicheren Ort, bevor Sie fortfahren.", "Save this Borg target so you don't need to enter the details again?": "Dieses Borg-Ziel speichern, damit Sie die Details nicht erneut eingeben müssen?", @@ -4131,7 +4131,7 @@ "Smart restore plan — hardware compatibility check": "Smart Restore Plan – Hardware-Kompatibilitätsprüfung", "Snippets — hook scripts / config": "Snippets – Hook-Skripte/Konfiguration", "SoC-integrated GPU: tight coupling with other SoC components": "SoC-integrierte GPU: enge Kopplung mit anderen SoC-Komponenten", - "Some DKMS removals reported errors; final verification will determine the result.": "Bei einigen DKMS-Entfernungen wurden Fehler gemeldet.Über das Ergebnis entscheidet die abschließende Prüfung.", + "Some DKMS removals reported errors; final verification will determine the result.": "Bei einigen DKMS-Entfernungen wurden Fehler gemeldet. Über das Ergebnis entscheidet die abschließende Prüfung.", "Some changes require a reboot to take effect. Do you want to restart now?": "Einige Änderungen erfordern einen Neustart, damit sie wirksam werden. Möchten Sie jetzt neu starten?", "Some essential Proxmox packages may not have been installed": "Einige wichtige Proxmox-Pakete wurden möglicherweise nicht installiert", "Some log2ram files may still exist. Manual cleanup may be required.": "Möglicherweise sind noch einige Log2RAM-Dateien vorhanden. Möglicherweise ist eine manuelle Bereinigung erforderlich.", @@ -4324,7 +4324,7 @@ "Testing network connectivity...": "Netzwerkkonnektivität testen...", "Thank you for using ProxMenux. Goodbye!": "Vielen Dank, dass Sie ProxMenux verwenden. Auf Wiedersehen!", "That VM is currently stopped, so the GPU can be reassigned now.": "Diese VM ist derzeit gestoppt, sodass die GPU jetzt neu zugewiesen werden kann.", - "That doesn't look like an SSH private key. Pick the private key file (no .pub extension, parseable by ssh-keygen).": "Das sieht nicht nach einem privaten SSH-Schlüssel aus.Wählen Sie die private Schlüsseldatei aus (keine .pub-Erweiterung, per ssh-keygen analysierbar).", + "That doesn't look like an SSH private key. Pick the private key file (no .pub extension, parseable by ssh-keygen).": "Das sieht nicht nach einem privaten SSH-Schlüssel aus. Wählen Sie die private Schlüsseldatei aus (keine .pub-Erweiterung, per ssh-keygen analysierbar).", "The GPU has been moved out of VM": "Die GPU wurde aus der VM verschoben", "The GPU is being detached from VM": "Die GPU wird von der VM getrennt", "The NVIDIA installer needs at least": "Das NVIDIA-Installationsprogramm benötigt mindestens", @@ -4339,8 +4339,8 @@ "The active kernel driver is not vfio-pci, but the entry will rebind the GPU to vfio-pci on the next reboot.": "Der aktive Kernel-Treiber ist nicht vfio-pci, aber der Eintrag bindet die GPU beim nächsten Neustart erneut an vfio-pci.", "The archive could not be extracted.": "Das Archiv konnte nicht extrahiert werden.", "The archive destination directory is INSIDE one of the paths you are about to back up. Writing the archive there would copy the backup into itself — producing a corrupted archive, or growing without limit until the disk fills up.": "Das Zielverzeichnis des Archivs befindet sich INNERHALB eines der Pfade, die Sie sichern möchten. Wenn Sie das Archiv dorthin schreiben, wird das Backup in sich selbst kopiert – was zu einem beschädigten Archiv führt oder unbegrenzt wächst, bis die Festplatte voll ist.", - "The backup metadata was compared against this host. The following items will be SKIPPED to keep the boot safe:": "Die Backup-Metadaten wurden mit diesem Host verglichen.Die folgenden Elemente werden ÜBERSPRINGT, um die Sicherheit des Stiefels zu gewährleisten:", - "The backup was taken on a different PVE or kernel major.minor. These paths will be SKIPPED to keep the boot safe:": "Das Backup wurde auf einem anderen PVE oder Kernel-Major.Minor erstellt.Diese Pfade werden ÜBERSPRINGT, um die Boot-Sicherheit zu gewährleisten:", + "The backup metadata was compared against this host. The following items will be SKIPPED to keep the boot safe:": "Die Backup-Metadaten wurden mit diesem Host verglichen. Die folgenden Elemente werden ÜBERSPRINGT, um die Sicherheit des Stiefels zu gewährleisten:", + "The backup was taken on a different PVE or kernel major.minor. These paths will be SKIPPED to keep the boot safe:": "Das Backup wurde auf einem anderen PVE oder Kernel-Major. Minor erstellt. Diese Pfade werden ÜBERSPRINGT, um die Boot-Sicherheit zu gewährleisten:", "The compatibility check raised failures that may break the system after restore.": "Bei der Kompatibilitätsprüfung sind Fehler aufgetreten, die das System nach der Wiederherstellung beschädigen können.", "The container is currently stopped. Do you want to start it now to install the package?": "Der Container ist derzeit gestoppt. Möchten Sie es jetzt starten, um das Paket zu installieren?", "The container should now start as privileged": "Der Container sollte nun als privilegiert starten", @@ -4353,12 +4353,12 @@ "The filesystem": "Das Dateisystem", "The following DKMS-managed drivers will now be rebuilt against it so they keep working after reboot:": "Die folgenden von DKMS verwalteten Treiber werden nun entsprechend neu erstellt, sodass sie nach dem Neustart weiterhin funktionieren:", "The following LXC containers have NVIDIA passthrough configured:": "Für die folgenden LXC-Container ist NVIDIA-Passthrough konfiguriert:", - "The following backup paths are kernel-tied and are excluded from the picker to keep the target's boot safe. The operator's own tuning inside these paths (IOMMU cmdline, VFIO IDs, custom quirks) is merged back automatically via kernel-agnostic merge:": "Die folgenden Sicherungspfade sind an den Kernel gebunden und werden von der Auswahl ausgeschlossen, um den Start des Ziels zu gewährleisten.Die eigene Abstimmung des Betreibers innerhalb dieser Pfade (IOMMU-Cmdline, VFIO-IDs, benutzerdefinierte Macken) wird automatisch über die Kernel-agnostische Zusammenführung wieder zusammengeführt:", + "The following backup paths are kernel-tied and are excluded from the picker to keep the target's boot safe. The operator's own tuning inside these paths (IOMMU cmdline, VFIO IDs, custom quirks) is merged back automatically via kernel-agnostic merge:": "Die folgenden Sicherungspfade sind an den Kernel gebunden und werden von der Auswahl ausgeschlossen, um den Start des Ziels zu gewährleisten. Die eigene Abstimmung des Betreibers innerhalb dieser Pfade (IOMMU-Cmdline, VFIO-IDs, benutzerdefinierte Macken) wird automatisch über die Kernel-agnostische Zusammenführung wieder zusammengeführt:", "The following changes will be applied": "Die folgenden Änderungen werden angewendet", "The following devices were excluded because they are part of an SR-IOV configuration:": "Die folgenden Geräte wurden ausgeschlossen, da sie Teil einer SR-IOV-Konfiguration sind:", "The following devices were excluded from Controller/NVMe passthrough because they are part of an SR-IOV configuration:": "Die folgenden Geräte wurden vom Controller/NVMe-Passthrough ausgeschlossen, da sie Teil einer SR-IOV-Konfiguration sind:", "The following drivers could not be rebuilt for the new kernel — run their installer manually after reboot:": "Die folgenden Treiber konnten für den neuen Kernel nicht neu erstellt werden – führen Sie ihr Installationsprogramm nach dem Neustart manuell aus:", - "The following entries exist on the host but were NOT in the backup. To make the host EXACTLY match the backup state, they must be removed:": "Die folgenden Einträge sind auf dem Host vorhanden, waren aber NICHT in der Sicherung.Damit der Host GENAU mit dem Backup-Status übereinstimmt, müssen sie entfernt werden:", + "The following entries exist on the host but were NOT in the backup. To make the host EXACTLY match the backup state, they must be removed:": "Die folgenden Einträge sind auf dem Host vorhanden, waren aber NICHT in der Sicherung. Damit der Host GENAU mit dem Backup-Status übereinstimmt, müssen sie entfernt werden:", "The following selected GPU(s) are currently in GPU -> VM mode (vfio-pci):": "Die folgenden ausgewählten GPU(s) befinden sich derzeit im GPU -> VM-Modus (vfio-pci):", "The following selected GPU(s) still have a VFIO passthrough entry in": "Die folgenden ausgewählten GPUs verfügen noch über einen VFIO-Passthrough-Eintrag", "The following selected device(s) are Physical Functions with active Virtual Functions:": "Bei den folgenden ausgewählten Geräten handelt es sich um physische Funktionen mit aktiven virtuellen Funktionen:", @@ -4368,7 +4368,7 @@ "The host directory may not be accessible from an unprivileged container.": "Auf das Hostverzeichnis kann von einem unprivilegierten Container aus möglicherweise nicht zugegriffen werden.", "The installation requires a server restart to apply changes. Do you want to restart now?": "Die Installation erfordert einen Serverneustart, um die Änderungen zu übernehmen. Möchten Sie jetzt neu starten?", "The installation/changes require a server restart to apply correctly. Do you want to reboot now?": "Die Installation/Änderungen erfordern einen Serverneustart, um korrekt angewendet zu werden. Möchten Sie jetzt neu starten?", - "The local envelope is dropped and future backups do not upload anything. Uploaded envelopes already on PBS stay intact and remain recoverable with their original passphrase.": "Der lokale Umschlag wird gelöscht und zukünftige Sicherungen laden nichts hoch.Bereits auf PBS hochgeladene Umschläge bleiben intakt und können mit ihrer ursprünglichen Passphrase wiederhergestellt werden.", + "The local envelope is dropped and future backups do not upload anything. Uploaded envelopes already on PBS stay intact and remain recoverable with their original passphrase.": "Der lokale Umschlag wird gelöscht und zukünftige Sicherungen laden nichts hoch. Bereits auf PBS hochgeladene Umschläge bleiben intakt und können mit ihrer ursprünglichen Passphrase wiederhergestellt werden.", "The long test runs directly on the disk hardware.": "Der Langzeittest läuft direkt auf der Festplatten-Hardware.", "The new SSH key was installed and is now authorized on the server.\nKey file:": "Der neue SSH-Schlüssel wurde installiert und ist nun auf dem Server autorisiert.\nSchlüsseldatei:", "The new SSH key was pushed to the LXC via 'pct exec' on": "Der neue SSH-Schlüssel wurde über „pct exec“ an den LXC übertragen", @@ -4376,17 +4376,17 @@ "The next visit to the dashboard will show the initial setup wizard.": "Beim nächsten Besuch des Dashboards wird der Ersteinrichtungsassistent angezeigt.", "The original MOTD backup is unavailable; no changes were made": "Das ursprüngliche MOTD-Backup ist nicht verfügbar;Es wurden keine Änderungen vorgenommen", "The original MOTD configuration has been restored": "Die ursprüngliche MOTD-Konfiguration wurde wiederhergestellt", - "The original MOTD state is unavailable; no changes were made": "Der ursprüngliche MOTD-Status ist nicht verfügbar.Es wurden keine Änderungen vorgenommen", + "The original MOTD state is unavailable; no changes were made": "Der ursprüngliche MOTD-Status ist nicht verfügbar. Es wurden keine Änderungen vorgenommen", "The original rpcbind service state has been restored": "Der ursprüngliche Rpcbind-Dienststatus wurde wiederhergestellt", "The original rpcbind state could not be restored completely": "Der ursprüngliche Rpcbind-Status konnte nicht vollständig wiederhergestellt werden", - "The original rpcbind state is unavailable; no service state was changed": "Der ursprüngliche Rpcbind-Status ist nicht verfügbar.Es wurde kein Dienststatus geändert", + "The original rpcbind state is unavailable; no service state was changed": "Der ursprüngliche Rpcbind-Status ist nicht verfügbar. Es wurde kein Dienststatus geändert", "The package is currently in a broken state and is blocking apt updates on this system.": "Das Paket befindet sich derzeit in einem fehlerhaften Zustand und blockiert Apt-Updates auf diesem System.", "The passwords do not match. Please try again.": "Die Passwörter stimmen nicht überein. Bitte versuchen Sie es erneut.", "The preselected VMID does not exist on this host:": "Die vorausgewählte VMID ist auf diesem Host nicht vorhanden:", "The proposed ARC maximum is below Proxmox VE's pool-size guideline:": "Das vorgeschlagene ARC-Maximum liegt unter der Poolgrößenrichtlinie von Proxmox VE:", "The same GPU cannot be used by two VMs at the same time.": "Die gleiche GPU kann nicht von zwei VMs gleichzeitig verwendet werden.", "The saved MOTD state is invalid; no changes were made": "Der gespeicherte MOTD-Status ist ungültig;Es wurden keine Änderungen vorgenommen", - "The saved utility package list is invalid; no packages were removed": "Die gespeicherte Liste der Dienstprogrammpakete ist ungültig.Es wurden keine Pakete entfernt", + "The saved utility package list is invalid; no packages were removed": "Die gespeicherte Liste der Dienstprogrammpakete ist ungültig. Es wurden keine Pakete entfernt", "The script clones the osx-proxmox.com repository and once the setup is complete, the server will automatically reboot.": "Das Skript klont das osx-proxmox.com-Repository und sobald die Einrichtung abgeschlossen ist, wird der Server automatisch neu gestartet.", "The script will continue to restore VM passthrough mode on the host and reuse existing hostpci entries.": "Das Skript stellt weiterhin den VM-Passthrough-Modus auf dem Host wieder her und verwendet vorhandene Hostpci-Einträge wieder.", "The script will preconfigure the selected GPU now and finalize hardware binding after reboot.": "Das Skript konfiguriert jetzt die ausgewählte GPU vor und schließt die Hardwarebindung nach dem Neustart ab.", @@ -4470,14 +4470,14 @@ "This is unexpected since credentials were validated.": "Dies ist unerwartet, da die Anmeldeinformationen validiert wurden.", "This marks the container as unprivileged": "Dadurch wird der Container als nicht privilegiert markiert", "This may be normal for a fresh installation": "Dies kann bei einer Neuinstallation normal sein", - "This may take a few minutes. Press OK to proceed.": "Dies kann einige Minuten dauern.Drücken Sie OK, um fortzufahren.", + "This may take a few minutes. Press OK to proceed.": "Dies kann einige Minuten dauern. Drücken Sie OK, um fortzufahren.", "This may take a few seconds...": "Dies kann einige Sekunden dauern...", "This may take several minutes...": "Dies kann einige Minuten dauern...", "This means Proxmox handles mount lifecycle natively (no manual /etc/fstab needed for NFS/CIFS host storages).": "Das bedeutet, dass Proxmox den Mount-Lebenszyklus nativ verwaltet (für NFS/CIFS-Hostspeicher ist kein manuelles /etc/fstab erforderlich).", "This means the credentials are incorrect.": "Dies bedeutet, dass die Anmeldeinformationen falsch sind.", "This might indicate network connectivity issues.": "Dies könnte auf Probleme mit der Netzwerkverbindung hinweisen.", "This operation may take several minutes and requires internet connectivity.": "Dieser Vorgang kann mehrere Minuten dauern und erfordert eine Internetverbindung.", - "This package was installed by older versions of the ProxMenux Coral installer that placed the M.2 kernel driver on every system, including USB-only setups. It is not needed for Coral USB devices, which use libedgetpu1-std / libedgetpu1-max only.": "Dieses Paket wurde von älteren Versionen des ProxMenux Coral-Installationsprogramms installiert, das den M.2-Kernel-Treiber auf jedem System platzierte, einschließlich reiner USB-Setups.Es ist nicht für Coral USB-Geräte erforderlich, die nur libedgetpu1-std / libedgetpu1-max verwenden.", + "This package was installed by older versions of the ProxMenux Coral installer that placed the M.2 kernel driver on every system, including USB-only setups. It is not needed for Coral USB devices, which use libedgetpu1-std / libedgetpu1-max only.": "Dieses Paket wurde von älteren Versionen des ProxMenux Coral-Installationsprogramms installiert, das den M.2-Kernel-Treiber auf jedem System platzierte, einschließlich reiner USB-Setups. Es ist nicht für Coral USB-Geräte erforderlich, die nur libedgetpu1-std / libedgetpu1-max verwenden.", "This passphrase is the ONLY way to access encrypted Borg backups.": "Diese Passphrase ist die EINZIGE Möglichkeit, auf verschlüsselte Borg-Backups zuzugreifen.", "This path is already used as a mount point in this container.": "Dieser Pfad wird in diesem Container bereits als Mountpunkt verwendet.", "This path is not a registered mount point. Use it anyway?": "Dieser Pfad ist kein registrierter Mountpunkt. Trotzdem nutzen?", @@ -4492,8 +4492,8 @@ "This script must be run on a Proxmox host.": "Dieses Skript muss auf einem Proxmox-Host ausgeführt werden.", "This script will apply the following optimizations and advanced adjustments to your Proxmox VE server": "Dieses Skript wendet die folgenden Optimierungen und erweiterten Anpassungen auf Ihren Proxmox VE-Server an", "This script will update your Proxmox VE system with advanced options:": "Dieses Skript aktualisiert Ihr Proxmox VE-System mit erweiterten Optionen:", - "This session is running in the Monitor terminal. Running it from here would cut the connection mid-install and leave the switch in a broken state.": "Diese Sitzung wird im Monitor-Terminal ausgeführt.Wenn Sie es von hier aus ausführen, wird die Verbindung während der Installation unterbrochen und der Switch bleibt in einem defekten Zustand.", - "This session is running in the Monitor terminal. Updating from here would restart the Monitor service and cut the connection mid-install, leaving the update in a broken state.": "Diese Sitzung wird im Monitor-Terminal ausgeführt.Eine Aktualisierung von hier aus würde den Monitor-Dienst neu starten und die Verbindung während der Installation unterbrechen, sodass das Update in einem fehlerhaften Zustand verbleibt.", + "This session is running in the Monitor terminal. Running it from here would cut the connection mid-install and leave the switch in a broken state.": "Diese Sitzung wird im Monitor-Terminal ausgeführt. Wenn Sie es von hier aus ausführen, wird die Verbindung während der Installation unterbrochen und der Switch bleibt in einem defekten Zustand.", + "This session is running in the Monitor terminal. Updating from here would restart the Monitor service and cut the connection mid-install, leaving the update in a broken state.": "Diese Sitzung wird im Monitor-Terminal ausgeführt. Eine Aktualisierung von hier aus würde den Monitor-Dienst neu starten und die Verbindung während der Installation unterbrechen, sodass das Update in einem fehlerhaften Zustand verbleibt.", "This shows the storage type and disk identifier": "Hier werden der Speichertyp und die Festplattenkennung angezeigt", "This state has a high probability of VM startup/reset failures.": "In diesem Zustand besteht eine hohe Wahrscheinlichkeit für VM-Start-/Reset-Fehler.", "This state indicates a high risk of passthrough failure due to": "Dieser Zustand weist auf ein hohes Risiko eines Passthrough-Fehlers hin", @@ -4691,9 +4691,9 @@ "Upload an encrypted copy of the key to PBS so you can recover it on a reinstalled host with just a passphrase?": "Eine verschlüsselte Kopie des Schlüssels auf PBS hochladen, damit Sie ihn auf einem neu installierten Host mit nur einer Passphrase wiederherstellen können?", "Upload key to PBS?": "Schlüssel auf PBS hochladen?", "Upload to PBS disabled.": "Hochladen auf PBS deaktiviert.", - "Upload to PBS enabled. The envelope is uploaded on every encrypted backup.": "Hochladen auf PBS aktiviert.Der Umschlag wird bei jedem verschlüsselten Backup hochgeladen.", - "Upload to PBS is currently: no. Pick an action:": "Hochladen auf PBS ist derzeit: Nein.Wählen Sie eine Aktion:", - "Upload to PBS is currently: yes. Pick an action:": "Auf PBS hochladen ist derzeit: ja.Wählen Sie eine Aktion:", + "Upload to PBS enabled. The envelope is uploaded on every encrypted backup.": "Hochladen auf PBS aktiviert. Der Umschlag wird bei jedem verschlüsselten Backup hochgeladen.", + "Upload to PBS is currently: no. Pick an action:": "Hochladen auf PBS ist derzeit: Nein. Wählen Sie eine Aktion:", + "Upload to PBS is currently: yes. Pick an action:": "Auf PBS hochladen ist derzeit: ja. Wählen Sie eine Aktion:", "Upload to PBS: enable, disable or rotate the recovery passphrase": "Auf PBS hochladen: Wiederherstellungspassphrase aktivieren, deaktivieren oder drehen", "Uptime and who is logged in": "Betriebszeit und wer angemeldet ist", "Use \"Check test progress\" to see results.": "Verwenden Sie „Testfortschritt prüfen“, um die Ergebnisse anzuzeigen.", @@ -4701,7 +4701,7 @@ "Use 'pct restore' / 'qmrestore' to recover their disks from your VM backups.": "Verwenden Sie „pct restart“ / „qmrestore“, um ihre Festplatten aus Ihren VM-Backups wiederherzustellen.", "Use Custom backup and uncheck the conflicting path from the list": "Verwenden Sie die benutzerdefinierte Sicherung und deaktivieren Sie den in Konflikt stehenden Pfad aus der Liste", "Use Default Settings?": "Standardeinstellungen verwenden?", - "Use Download first if you want to save a copy of the current key. Continue?": "Verwenden Sie zuerst „Herunterladen“, wenn Sie eine Kopie des aktuellen Schlüssels speichern möchten.Weitermachen?", + "Use Download first if you want to save a copy of the current key. Continue?": "Verwenden Sie zuerst „Herunterladen“, wenn Sie eine Kopie des aktuellen Schlüssels speichern möchten. Weitermachen?", "Use SPACE to select, ENTER to confirm": "Benutzen Sie die Leertaste zur Auswahl und ENTER zur Bestätigung", "Use SPACE to select/deselect, ENTER to confirm": "Benutzen Sie die LEERTASTE zum Auswählen/Abwählen, ENTER zum Bestätigen", "Use SSH or terminal access (SSH recommended)": "Verwenden Sie SSH oder Terminalzugriff (SSH empfohlen)", @@ -4814,7 +4814,7 @@ "Verify installations": "Überprüfen Sie die Installationen", "Verify mount:": "Mount überprüfen:", "Verify the conversion:": "Überprüfen Sie die Konvertierung:", - "Verify the credentials. Switching to manual paste mode so you can finish the setup without re-typing the password.": "Überprüfen Sie die Anmeldeinformationen.Wechseln Sie in den manuellen Einfügemodus, damit Sie die Einrichtung abschließen können, ohne das Passwort erneut eingeben zu müssen.", + "Verify the credentials. Switching to manual paste mode so you can finish the setup without re-typing the password.": "Überprüfen Sie die Anmeldeinformationen. Wechseln Sie in den manuellen Einfügemodus, damit Sie die Einrichtung abschließen können, ohne das Passwort erneut eingeben zu müssen.", "Verifying Ceph installation...": "Ceph-Installation wird überprüft...", "Verifying Ceph packages availability...": "Verfügbarkeit von Ceph-Paketen überprüfen...", "Verifying all utilities status": "Überprüfen des Status aller Dienstprogramme", @@ -4824,7 +4824,7 @@ "Version info not available": "Versionsinformationen nicht verfügbar", "Version:": "Version:", "Version: Auto-negotiation (NFSv3/NFSv4)": "Version: Auto-Negotiation (NFSv3/NFSv4)", - "Versions shown belong to maintained NVIDIA branches that list your GPU PCI ID. DKMS compilation is the final validation against the running kernel. The recommended version keeps the current branch, or uses the NVIDIA Production Branch on a fresh install.": "Die angezeigten Versionen gehören zu gepflegten NVIDIA-Zweigen, in denen Ihre GPU-PCI-ID aufgeführt ist.Die DKMS-Kompilierung ist die endgültige Validierung gegenüber dem laufenden Kernel.Die empfohlene Version behält den aktuellen Zweig bei oder verwendet den NVIDIA Production Branch bei einer Neuinstallation.", + "Versions shown belong to maintained NVIDIA branches that list your GPU PCI ID. DKMS compilation is the final validation against the running kernel. The recommended version keeps the current branch, or uses the NVIDIA Production Branch on a fresh install.": "Die angezeigten Versionen gehören zu gepflegten NVIDIA-Zweigen, in denen Ihre GPU-PCI-ID aufgeführt ist. Die DKMS-Kompilierung ist die endgültige Validierung gegenüber dem laufenden Kernel. Die empfohlene Version behält den aktuellen Zweig bei oder verwendet den NVIDIA Production Branch bei einer Neuinstallation.", "View CIFS Mounts (pvesm + fstab)": "CIFS-Mounts anzeigen (pvesm + fstab)", "View Current Exports": "Aktuelle Exporte anzeigen", "View Current Mounts": "Aktuelle Reittiere anzeigen", @@ -4925,7 +4925,7 @@ "Wrong passphrase": "Falsche Passphrase", "Yes": "Ja", "Yes, upload": "Ja, hochladen", - "Yes: set a recovery passphrase now; the encrypted key envelope is uploaded with every backup.": "Ja: Legen Sie jetzt eine Wiederherstellungspassphrase fest.Der verschlüsselte Schlüsselumschlag wird bei jedem Backup hochgeladen.", + "Yes: set a recovery passphrase now; the encrypted key envelope is uploaded with every backup.": "Ja: Legen Sie jetzt eine Wiederherstellungspassphrase fest. Der verschlüsselte Schlüsselumschlag wird bei jedem Backup hochgeladen.", "You are connected via SSH and selected network-related restore paths.": "Die Verbindung erfolgt über SSH und ausgewählte netzwerkbezogene Wiederherstellungspfade.", "You can add it manually through:": "Sie können es manuell hinzufügen über:", "You can add servers manually.": "Sie können Server manuell hinzufügen.", @@ -5018,7 +5018,7 @@ "blocking issue(s).": "Blockierungsproblem(e).", "btrfs — Proxmox dir storage (snapshots, compression)": "btrfs – Proxmox-Verzeichnisspeicher (Snapshots, Komprimierung)", "btrfs — snapshots and compression": "btrfs – Snapshots und Komprimierung", - "but it does not match the one used to create the backup. Replace it with the correct keyfile from the source host and retry.": "aber es stimmt nicht mit dem überein, das zum Erstellen der Sicherung verwendet wurde.Ersetzen Sie es durch die richtige Schlüsseldatei vom Quellhost und versuchen Sie es erneut.", + "but it does not match the one used to create the backup. Replace it with the correct keyfile from the source host and retry.": "aber es stimmt nicht mit dem überein, das zum Erstellen der Sicherung verwendet wurde. Ersetzen Sie es durch die richtige Schlüsseldatei vom Quellhost und versuchen Sie es erneut.", "bytes": "Bytes", "can write to": "kann schreiben", "chmod 1777 + setfacl o::rwx (applied on the NFS share from this host)": "chmod 1777 + setfacl o::rwx (auf die NFS-Freigabe von diesem Host angewendet)", @@ -5267,7 +5267,7 @@ "smbclient command is not working properly.": "Der smbclient-Befehl funktioniert nicht ordnungsgemäß.", "smbclient command not found after installation.": "Der Befehl smbclient wurde nach der Installation nicht gefunden.", "sources.list update skipped (no change)": "Aktualisierung der Quellenliste übersprungen (keine Änderung)", - "sources.list updated to Trixie": "Quellen.Liste auf Trixie aktualisiert", + "sources.list updated to Trixie": "Quellen. Liste auf Trixie aktualisiert", "ssh-keygen failed. Cannot create a new SSH key.": "ssh-keygen ist fehlgeschlagen. Es kann kein neuer SSH-Schlüssel erstellt werden.", "stale entry/entries for interfaces no longer present": "veralteter Eintrag/Einträge für Schnittstellen, die nicht mehr vorhanden sind", "standard performance": "Standardleistung", diff --git a/lang/es.json b/lang/es.json index 680797c5..47e09027 100644 --- a/lang/es.json +++ b/lang/es.json @@ -347,7 +347,7 @@ "Backup created:": "Copia de seguridad creada:", "Backup declares unused NICs that are not on this host:": "La copia de seguridad declara las NIC no utilizadas que no están en este host:", "Backup destination is inside the backup": "el destino de la copia de seguridad está dentro de la copia de seguridad", - "Backup failed. See log:": "Error en la copia de seguridad.Ver registro:", + "Backup failed. See log:": "Error en la copia de seguridad. Ver registro:", "Backup file appears corrupted, will reinstall packages": "El archivo de copia de seguridad parece dañado, reinstalará los paquetes", "Backup host configuration": "Copia de seguridad de la configuración del host", "Backup includes /etc/zfs/zpool.cache. Restore it (same host detected)?": "La copia de seguridad incluye /etc/zfs/zpool.cache.¿Restaurarlo (se detectó el mismo host)?", @@ -367,7 +367,7 @@ "Backup to local archive (.tar.zst)": "Copia de seguridad en archivo local (.tar.zst)", "Backup:": "Copia de seguridad:", "Backups already on PBS were encrypted with the current key — downloading them will fail unless you first Download the current keyfile to keep a copy.": "Las copias de seguridad que ya están en PBS se cifraron con la clave actual; la descarga fallará a menos que primero descargue el archivo de clave actual para conservar una copia.", - "Backups already stored on PBS were encrypted with the current keyfile. After this action:": "las copias de seguridad ya almacenadas en PBS se cifraron con el archivo de claves actual.Después de esta acción:", + "Backups already stored on PBS were encrypted with the current keyfile. After this action:": "las copias de seguridad ya almacenadas en PBS se cifraron con el archivo de claves actual. Después de esta acción:", "Bandwidth limit configured": "Límite de ancho de banda configurado", "Bandwidth test (iperf3)": "Prueba de ancho de banda (iperf3)", "Bandwidth test completed successfully": "La prueba de ancho de banda se completó con éxito", @@ -456,7 +456,7 @@ "Cannot proceed with invalid export path.": "No se puede continuar con una ruta de exportación no válida.", "Cannot proceed with invalid share name.": "No se puede continuar con un nombre compartido no válido.", "Cannot reach Proxmox repositories": "No se puede acceder a los repositorios de Proxmox", - "Cannot reach download.proxmox.com. Check network, proxy or DNS.": "No se puede acceder a download.proxmox.com.Verifique la red, proxy o DNS.", + "Cannot reach download.proxmox.com. Check network, proxy or DNS.": "No se puede acceder a download.proxmox.com. Verifique la red, proxy o DNS.", "Cannot reach portal:": "No se puede acceder al portal:", "Cannot reach server": "No puede alcanzar el servidor", "Cannot validate credentials - no shares available for testing.": "No se pueden validar las credenciales: no hay recursos compartidos disponibles para realizar pruebas.", @@ -851,8 +851,8 @@ "Copy that file offsite yourself, or download it from the Monitor.": "copie ese archivo fuera del sitio usted mismo o descárguelo del Monitor.", "Copy the correct keyfile to this host and rerun Restore — or pick an unencrypted backup.": "copie el archivo de claves correcto en este host y vuelva a ejecutar Restaurar, o elija una copia de seguridad sin cifrar.", "Copy the keyfile to a path for offsite backup": "copie el archivo de claves a una ruta para realizar una copia de seguridad externa", - "Copy your PBS keyfile onto this host first (via scp, USB, sftp, etc.) and enter its absolute path below. The file will be copied to": "primero copie su archivo de clave PBS en este host (a través de scp, USB, sftp, etc.) e ingrese su ruta absoluta a continuación.El archivo se copiará a", - "Copy your existing PBS keyfile onto this host first (via scp, USB, sftp, etc.) and enter its absolute path below. The file will be copied to": "primero copie su archivo de claves PBS existente en este host (a través de scp, USB, sftp, etc.) e ingrese su ruta absoluta a continuación.El archivo se copiará a", + "Copy your PBS keyfile onto this host first (via scp, USB, sftp, etc.) and enter its absolute path below. The file will be copied to": "primero copie su archivo de clave PBS en este host (a través de scp, USB, sftp, etc.) e ingrese su ruta absoluta a continuación. El archivo se copiará a", + "Copy your existing PBS keyfile onto this host first (via scp, USB, sftp, etc.) and enter its absolute path below. The file will be copied to": "primero copie su archivo de claves PBS existente en este host (a través de scp, USB, sftp, etc.) e ingrese su ruta absoluta a continuación. El archivo se copiará a", "Copying installer to container": "Copiando el instalador al contenedor", "Copying sources to": "Copiar fuentes a", "Coral APT repository ready.": "Repositorio Coral APT listo.", @@ -886,9 +886,9 @@ "Could not change VM virtual display to vga: std": "No se pudo cambiar la pantalla virtual de VM a vga: estándar", "Could not clone any gasket-driver repository. Check your internet connection and": "No se pudo clonar ningún repositorio de gasket-driver. Compruebe la conexión a Internet y", "Could not configure IOMMU kernel parameters automatically. Configure manually and reboot.": "No se pudieron configurar los parámetros del kernel IOMMU automáticamente. Configure manualmente y reinicie.", - "Could not copy the PVE keyfile into place. Check permissions on:": "No se pudo copiar el archivo de claves PVE en su lugar.Verifique los permisos en:", + "Could not copy the PVE keyfile into place. Check permissions on:": "No se pudo copiar el archivo de claves PVE en su lugar. Verifique los permisos en:", "Could not copy the keyfile into place.": "no se pudo copiar el archivo de claves en su lugar.", - "Could not copy the keyfile into place. Check permissions on:": "no se pudo copiar el archivo de claves en su lugar.Verifique los permisos en:", + "Could not copy the keyfile into place. Check permissions on:": "no se pudo copiar el archivo de claves en su lugar. Verifique los permisos en:", "Could not create converter directory:": "No se pudo crear el directorio del convertidor:", "Could not create destination directory:": "No se pudo crear el directorio de destino:", "Could not create or access directory:": "No se pudo crear o acceder al directorio:", @@ -898,7 +898,7 @@ "Could not detect the CIFS mount for this directory. Try accessing it manually.": "No se pudo detectar el montaje CIFS para este directorio. Intente acceder manualmente.", "Could not determine a valid ISO storage directory.": "No se pudo determinar un directorio de almacenamiento ISO válido.", "Could not determine disk path for:": "No se pudo determinar la ruta del disco para:", - "Could not determine filesystem signature types. Aborting.": "No se pudieron determinar los tipos de firma del sistema de archivos.Abortando.", + "Could not determine filesystem signature types. Aborting.": "No se pudieron determinar los tipos de firma del sistema de archivos. Se cancela.", "Could not determine the IOMMU group for the selected GPU.": "No se pudo determinar el grupo IOMMU para la GPU seleccionada.", "Could not download recovery blob from PBS.": "No se pudo descargar el blob de recuperación de PBS.", "Could not download the installer.": "No se pudo descargar el instalador.", @@ -920,9 +920,9 @@ "Could not mount": "No se pudo montar", "Could not mount ISO on device": "No se pudo montar ISO en el dispositivo", "Could not parse OVF file, or no disk image references found.": "No se pudo analizar el archivo OVF o no se encontraron referencias de imágenes de disco.", - "Could not prepare on-boot restore service. Nothing new was scheduled.": "No se pudo preparar el servicio de restauración al arrancar.No se programó nada nuevo.", - "Could not publish pending restore. Previous pending restore was kept.": "No se pudo publicar pendiente de restauración.Se mantuvo la restauración pendiente anterior.", - "Could not push the key. Check the password and that": "No se pudo presionar la tecla.Verifique la contraseña y eso", + "Could not prepare on-boot restore service. Nothing new was scheduled.": "No se pudo preparar el servicio de restauración en el arranque. No se ha programado nada nuevo.", + "Could not publish pending restore. Previous pending restore was kept.": "No se pudo publicar la restauración pendiente. Se mantiene la anterior.", + "Could not push the key. Check the password and that": "No se pudo presionar la tecla. Verifique la contraseña y eso", "Could not read SMART data from": "No se pudieron leer los datos SMART de", "Could not read VM configuration.": "No se pudo leer la configuración de la VM.", "Could not remount automatically. Try manually or check credentials.": "No se pudo volver a montar automáticamente. Pruebe manualmente o verifique las credenciales.", @@ -934,8 +934,8 @@ "Could not run NVIDIA patch script. Please verify repository and driver version.": "No se pudo ejecutar el script de parche de NVIDIA. Verifique el repositorio y la versión del controlador.", "Could not set VM virtual display to vga: std": "No se pudo configurar la pantalla virtual de VM en vga: estándar", "Could not set boot order for": "No se pudo establecer el orden de inicio para", - "Could not stage pending restore path:": "No se pudo preparar la ruta de restauración pendiente:", - "Could not stage pending restore. Nothing new was scheduled.": "No se pudo realizar la restauración pendiente.No se programó nada nuevo.", + "Could not stage pending restore path:": "No se pudo preparar la ruta de la restauración pendiente:", + "Could not stage pending restore. Nothing new was scheduled.": "No se pudo preparar la restauración pendiente. No se ha programado nada nuevo.", "Could not stop LXC": "No se pudo detener LXC", "Could not unload nouveau module (may be in use). The blacklist will take effect after reboot. Installation will continue but a reboot will be required.": "No se pudo descargar el módulo nouveau (puede estar en uso). La lista negra entrará en vigor después del reinicio. La instalación continuará pero será necesario reiniciar.", "Could not unmount": "No se pudo desmontar", @@ -1091,7 +1091,7 @@ "Deactivate ProxMenux Monitor": "Desactivar ProxMenux Monitor", "Debian repositories missing; creating default source file": "Faltan repositorios de Debian; creando un archivo fuente predeterminado", "Decompress backup manually": "Descomprimir la copia de seguridad manualmente", - "Decryption failed. The passphrase may be wrong, or the blob is corrupt. Try again?": "Falló el descifrado.La frase de contraseña puede ser incorrecta o el blob está dañado.¿Intentar otra vez?", + "Decryption failed. The passphrase may be wrong, or the blob is corrupt. Try again?": "Falló el descifrado. La frase de contraseña puede ser incorrecta o el blob está dañado.¿Intentar otra vez?", "Default ACLs applied for group inheritance.": "ACL predeterminadas aplicadas para la herencia de grupo.", "Default Credentials": "Credenciales predeterminadas", "Default Gateway": "Puerta de enlace predeterminada", @@ -1171,7 +1171,7 @@ "Device already present in target VM — existing hostpci entry reused": "Dispositivo ya presente en la máquina virtual de destino: se reutiliza la entrada hostpci existente", "Device assignments will be written now and become active after reboot.": "Las asignaciones de dispositivos se escribirán ahora y se activarán después del reinicio.", "Device hostname": "Nombre de host del dispositivo", - "Device path mismatch. Format cancelled.": "la ruta del dispositivo no coincide.Formato cancelado.", + "Device path mismatch. Format cancelled.": "la ruta del dispositivo no coincide. Formato cancelado.", "Device:": "Dispositivo:", "Devices to add to VM": "Dispositivos para agregar a VM", "Diff: current system vs backup (--- system +++ backup)": "Diferencia: sistema actual vs copia de seguridad (--- sistema +++ copia de seguridad)", @@ -1628,7 +1628,7 @@ "Failed to create directory on host:": "No se pudo crear el directorio en el host:", "Failed to create directory:": "No se pudo crear el directorio:", "Failed to create disk": "No se pudo crear el disco", - "Failed to create encryption key. Backup cancelled — fix the underlying issue and retry.": "no se pudo crear la clave de cifrado.Copia de seguridad cancelada: solucione el problema subyacente y vuelva a intentarlo.", + "Failed to create encryption key. Backup cancelled — fix the underlying issue and retry.": "no se pudo crear la clave de cifrado. Copia de seguridad cancelada: solucione el problema subyacente y vuelva a intentarlo.", "Failed to create group:": "No se pudo crear el grupo:", "Failed to create mount point.": "No se pudo crear el punto de montaje.", "Failed to create mount point:": "No se pudo crear el punto de montaje:", @@ -1910,7 +1910,7 @@ "Git installed": "git instalado", "Global settings and SSH jail configured": "Configuración global y cárcel SSH configurada", "Go to \"Manage custom paths\" and remove your custom entry that includes the destination": "vaya a \"Administrar rutas personalizadas\" y elimine la entrada personalizada que incluye el destino.", - "Google only ships an official libedgetpu APT repository for Debian/Ubuntu. Hardware passthrough is already written to": "Google solo envía un repositorio APT oficial de libedgetpu para Debian/Ubuntu.La transferencia de hardware ya está escrita en", + "Google only ships an official libedgetpu APT repository for Debian/Ubuntu. Hardware passthrough is already written to": "Google solo envía un repositorio APT oficial de libedgetpu para Debian/Ubuntu. La transferencia de hardware ya está escrita en", "Graceful shutdown timed out.": "Se agotó el tiempo de cierre elegante.", "Group": "Grupo", "Group 'sharedfiles' already exists inside the CT": "El grupo 'archivos compartidos' ya existe dentro del CT", @@ -2354,7 +2354,7 @@ "Kernel panic configuration removed": "Se eliminó la configuración de pánico del kernel", "Kernel panic configuration updated and applied": "Configuración de pánico del kernel actualizada y aplicada", "Kernel, modules and boot config": "Kernel, módulos y configuración de arranque", - "Kernel/boot-tied files (boot config, /etc/systemd/system, initramfs config, apt sources, ZFS state, ...) are NOT copied verbatim to keep the target's boot safe. The operator's own tuning inside them (IOMMU cmdline, VFIO IDs, custom quirks, GRUB timeout, ...) is merged into the target's fresh copies automatically via kernel-agnostic merge.": "Los archivos vinculados al kernel/arranque (configuración de arranque, /etc/systemd/system, configuración initramfs, fuentes apt, estado de ZFS, ...) NO se copian palabra por palabra para mantener seguro el arranque del destino.El propio ajuste del operador dentro de ellos (línea cmd de IOMMU, ID de VFIO, peculiaridades personalizadas, tiempo de espera de GRUB, ...) se fusiona automáticamente con las copias nuevas del objetivo mediante una fusión independiente del kernel.", + "Kernel/boot-tied files (boot config, /etc/systemd/system, initramfs config, apt sources, ZFS state, ...) are NOT copied verbatim to keep the target's boot safe. The operator's own tuning inside them (IOMMU cmdline, VFIO IDs, custom quirks, GRUB timeout, ...) is merged into the target's fresh copies automatically via kernel-agnostic merge.": "Los archivos vinculados al kernel/arranque (configuración de arranque, /etc/systemd/system, configuración initramfs, fuentes apt, estado de ZFS, ...) NO se copian palabra por palabra para mantener seguro el arranque del destino. El propio ajuste del operador dentro de ellos (línea cmd de IOMMU, ID de VFIO, peculiaridades personalizadas, tiempo de espera de GRUB, ...) se fusiona automáticamente con las copias nuevas del objetivo mediante una fusión independiente del kernel.", "Keyfile copied": "archivo clave copiado", "Keyfile copied to:": "archivo clave copiado a:", "Keyfile passphrase": "frase de contraseña del archivo clave", @@ -2860,7 +2860,7 @@ "No Shares Found": "No se encontraron acciones", "No Storage Found": "No se encontró almacenamiento", "No USB drives detected. Enter the mountpoint path manually:": "No se detectaron unidades USB.Ingrese la ruta del punto de montaje manualmente:", - "No USB drives mounted by ProxMenux yet. Mount one first to use it as a target.": "Aún no hay unidades USB montadas por ProxMenux.Monta uno primero para usarlo como objetivo.", + "No USB drives mounted by ProxMenux yet. Mount one first to use it as a target.": "Aún no hay unidades USB montadas por ProxMenux. Monta uno primero para usarlo como objetivo.", "No UUP folder found.": "No se encontró ninguna carpeta UUP.", "No VM was selected.": "No se seleccionó ninguna máquina virtual.", "No VMID defined. Cannot apply guest agent config.": "No hay VMID definido. No se puede aplicar la configuración del agente invitado.", @@ -2922,7 +2922,7 @@ "No duplicate repositories found": "No se encontraron repositorios duplicados", "No eligible Controller/NVMe devices remain after SR-IOV filtering. Skipping.": "No quedan dispositivos de controlador/NVMe elegibles después del filtrado SR-IOV. Salto a la comba.", "No eligible controllers remain after SR-IOV filtering.": "No quedan controladores elegibles después del filtrado SR-IOV.", - "No encryption key is stored on this host. Choose how to set one up:": "No se almacena ninguna clave de cifrado en este host.Elija cómo configurar uno:", + "No encryption key is stored on this host. Choose how to set one up:": "No se almacena ninguna clave de cifrado en este host. Elija cómo configurar uno:", "No exportable VM disks were found (CD-ROM/cloud-init are excluded).": "No se encontraron discos de VM exportables (se excluyen CD-ROM/cloud-init).", "No exportable disks": "No hay discos exportables", "No exports configured.": "No hay exportaciones configuradas.", @@ -3203,10 +3203,10 @@ "Paths:": "Rutas:", "Pending restore ID:": "ID de restauración pendiente:", "Pending restore dir:": "Directorio de restauración pendiente:", - "Pending restore prepared. A reboot is required to complete it.": "Restauración pendiente preparada.Es necesario reiniciar para completarlo.", + "Pending restore prepared. A reboot is required to complete it.": "Restauración pendiente preparada. Es necesario reiniciar para completarlo.", "Pending restore prepared. It will run automatically at next boot.": "Pendiente de restauración preparada. Se ejecutará automáticamente en el próximo arranque.", "Pending restore script not found or not executable:": "Script de restauración pendiente no encontrado o no ejecutable:", - "Pending restore source is missing:": "Falta la fuente de restauración pendiente:", + "Pending restore source is missing:": "Falta el origen de la restauración pendiente:", "Pending upgrades detected on a clustered node.\n\nTo proceed safely, update this node to the latest Proxmox VE 8.x before switching to Trixie/PVE 9.\n\nSelect Yes for AUTOMATIC upgrade (recommended), or No for MANUAL instructions.": "Actualizaciones pendientes detectadas en un nodo agrupado.\n\nPara proceder de forma segura, actualice este nodo a la última versión de Proxmox VE 8.x antes de cambiar a Trixie/PVE 9.\n\nSeleccione Sí para actualización AUTOMÁTICA (recomendado) o No para instrucciones MANUALES.", "Pending upgrades detected on a clustered node. Perform AUTOMATIC upgrade now? (y = automatic, n = manual):": "Actualizaciones pendientes detectadas en un nodo agrupado. ¿Realizar actualización AUTOMÁTICA ahora? (y = automático, n = manual):", "Per official known issues; ensures proper boot after upgrade": "Según problemas oficiales conocidos; garantiza un arranque adecuado después de la actualización", @@ -3353,7 +3353,7 @@ "ProxMenux logo applied": "Logotipo de ProxMenux aplicado", "ProxMenux only acts as a launcher — once the script starts, control leaves ProxMenux.": "ProxMenux solo actúa como Lanzador del script.", "ProxMenux saved it locally at:": "ProxMenux lo guardó localmente en:", - "ProxMenux-managed .link file(s). User-authored .link files were left in place.": "archivo(s) .link administrado por ProxMenux.Los archivos .link creados por el usuario se dejaron en su lugar.", + "ProxMenux-managed .link file(s). User-authored .link files were left in place.": "archivo(s) .link administrado por ProxMenux. Los archivos .link creados por el usuario se dejaron en su lugar.", "Proxmology logo applied": "Logotipo de Proxmología aplicado.", "Proxmox 9 system update allready": "Actualización del sistema Proxmox 9 ya", "Proxmox APT repositories configured": "Repositorios Proxmox APT configurados", @@ -4338,9 +4338,9 @@ "The active kernel driver is not vfio-pci, but the entry will rebind the GPU to vfio-pci on the next reboot, breaking the LXC passthrough about to be configured.": "El controlador del kernel activo no es vfio-pci, pero la entrada volverá a vincular la GPU a vfio-pci en el próximo reinicio, interrumpiendo el paso a través de LXC que está a punto de configurarse.", "The active kernel driver is not vfio-pci, but the entry will rebind the GPU to vfio-pci on the next reboot.": "el controlador del kernel activo no es vfio-pci, pero la entrada volverá a vincular la GPU a vfio-pci en el próximo reinicio.", "The archive could not be extracted.": "No se pudo extraer el archivo.", - "The archive destination directory is INSIDE one of the paths you are about to back up. Writing the archive there would copy the backup into itself — producing a corrupted archive, or growing without limit until the disk fills up.": "El directorio de destino del archivo está DENTRO de una de las rutas de las que está a punto de realizar una copia de seguridad.Escribir el archivo allí copiaría la copia de seguridad en sí mismo, lo que produciría un archivo corrupto o crecería sin límite hasta que el disco se llenara.", - "The backup metadata was compared against this host. The following items will be SKIPPED to keep the boot safe:": "los metadatos de la copia de seguridad se compararon con este host.Se SALTARÁN los siguientes elementos para mantener el arranque seguro:", - "The backup was taken on a different PVE or kernel major.minor. These paths will be SKIPPED to keep the boot safe:": "La copia de seguridad se realizó en un PVE o kernel mayor.menor diferente.Estas rutas se SALTARÁN para mantener el arranque seguro:", + "The archive destination directory is INSIDE one of the paths you are about to back up. Writing the archive there would copy the backup into itself — producing a corrupted archive, or growing without limit until the disk fills up.": "El directorio de destino del archivo está DENTRO de una de las rutas de las que está a punto de realizar una copia de seguridad. Escribir el archivo allí copiaría la copia de seguridad en sí mismo, lo que produciría un archivo corrupto o crecería sin límite hasta que el disco se llenara.", + "The backup metadata was compared against this host. The following items will be SKIPPED to keep the boot safe:": "los metadatos de la copia de seguridad se compararon con este host. Se SALTARÁN los siguientes elementos para mantener el arranque seguro:", + "The backup was taken on a different PVE or kernel major.minor. These paths will be SKIPPED to keep the boot safe:": "La copia de seguridad se realizó en un PVE o kernel mayor.menor diferente. Estas rutas se SALTARÁN para mantener el arranque seguro:", "The compatibility check raised failures that may break the system after restore.": "La verificación de compatibilidad generó fallas que pueden dañar el sistema después de la restauración.", "The container is currently stopped. Do you want to start it now to install the package?": "El contenedor se encuentra actualmente detenido. ¿Quieres iniciarlo ahora para instalar el paquete?", "The container should now start as privileged": "El contenedor ahora debería comenzar como privilegiado.", @@ -4353,12 +4353,12 @@ "The filesystem": "El sistema de archivos", "The following DKMS-managed drivers will now be rebuilt against it so they keep working after reboot:": "Los siguientes controladores administrados por DKMS ahora se reconstruirán para que sigan funcionando después del reinicio:", "The following LXC containers have NVIDIA passthrough configured:": "Los siguientes contenedores LXC tienen configurado el paso a través de NVIDIA:", - "The following backup paths are kernel-tied and are excluded from the picker to keep the target's boot safe. The operator's own tuning inside these paths (IOMMU cmdline, VFIO IDs, custom quirks) is merged back automatically via kernel-agnostic merge:": "las siguientes rutas de respaldo están vinculadas al kernel y se excluyen del selector para mantener seguro el arranque del destino.El propio ajuste del operador dentro de estas rutas (línea cmd de IOMMU, ID de VFIO, peculiaridades personalizadas) se fusiona automáticamente mediante una fusión independiente del kernel:", + "The following backup paths are kernel-tied and are excluded from the picker to keep the target's boot safe. The operator's own tuning inside these paths (IOMMU cmdline, VFIO IDs, custom quirks) is merged back automatically via kernel-agnostic merge:": "las siguientes rutas de respaldo están vinculadas al kernel y se excluyen del selector para mantener seguro el arranque del destino. El propio ajuste del operador dentro de estas rutas (línea cmd de IOMMU, ID de VFIO, peculiaridades personalizadas) se fusiona automáticamente mediante una fusión independiente del kernel:", "The following changes will be applied": "Se aplicarán los siguientes cambios.", "The following devices were excluded because they are part of an SR-IOV configuration:": "Se excluyeron los siguientes dispositivos porque forman parte de una configuración SR-IOV:", "The following devices were excluded from Controller/NVMe passthrough because they are part of an SR-IOV configuration:": "Los siguientes dispositivos se excluyeron del paso directo de Controlador/NVMe porque forman parte de una configuración SR-IOV:", "The following drivers could not be rebuilt for the new kernel — run their installer manually after reboot:": "Los siguientes controladores no se pudieron reconstruir para el nuevo kernel; ejecute su instalador manualmente después de reiniciar:", - "The following entries exist on the host but were NOT in the backup. To make the host EXACTLY match the backup state, they must be removed:": "Las siguientes entradas existen en el host pero NO estaban en la copia de seguridad.Para que el host coincida EXACTAMENTE con el estado de la copia de seguridad, se deben eliminar:", + "The following entries exist on the host but were NOT in the backup. To make the host EXACTLY match the backup state, they must be removed:": "Las siguientes entradas existen en el host pero NO estaban en la copia de seguridad. Para que el host coincida EXACTAMENTE con el estado de la copia de seguridad, se deben eliminar:", "The following selected GPU(s) are currently in GPU -> VM mode (vfio-pci):": "Las siguientes GPU seleccionadas se encuentran actualmente en modo GPU -> VM (vfio-pci):", "The following selected GPU(s) still have a VFIO passthrough entry in": "Las siguientes GPU seleccionadas todavía tienen una entrada de paso VFIO en", "The following selected device(s) are Physical Functions with active Virtual Functions:": "Los siguientes dispositivos seleccionados son funciones físicas con funciones virtuales activas:", @@ -4368,7 +4368,7 @@ "The host directory may not be accessible from an unprivileged container.": "Es posible que no se pueda acceder al directorio del host desde un contenedor sin privilegios.", "The installation requires a server restart to apply changes. Do you want to restart now?": "La instalación requiere reiniciar el servidor para aplicar los cambios. ¿Quieres reiniciar ahora?", "The installation/changes require a server restart to apply correctly. Do you want to reboot now?": "La instalación/los cambios requieren un reinicio del servidor para que se apliquen correctamente. ¿Quieres reiniciar ahora?", - "The local envelope is dropped and future backups do not upload anything. Uploaded envelopes already on PBS stay intact and remain recoverable with their original passphrase.": "El sobre local se elimina y las copias de seguridad futuras no cargan nada.Los sobres cargados que ya están en PBS permanecen intactos y recuperables con su frase de contraseña original.", + "The local envelope is dropped and future backups do not upload anything. Uploaded envelopes already on PBS stay intact and remain recoverable with their original passphrase.": "El sobre local se elimina y las copias de seguridad futuras no cargan nada. Los sobres cargados que ya están en PBS permanecen intactos y recuperables con su frase de contraseña original.", "The long test runs directly on the disk hardware.": "La prueba larga se ejecuta directamente en el hardware del disco.", "The new SSH key was installed and is now authorized on the server.\nKey file:": "La nueva clave SSH se instaló y ahora está autorizada en el servidor.\nArchivo clave:", "The new SSH key was pushed to the LXC via 'pct exec' on": "La nueva clave SSH se envió al LXC a través de 'pct exec' en", @@ -4470,7 +4470,7 @@ "This is unexpected since credentials were validated.": "Esto es inesperado ya que se validaron las credenciales.", "This marks the container as unprivileged": "Esto marca el contenedor como sin privilegios.", "This may be normal for a fresh installation": "Esto puede ser normal para una instalación nueva.", - "This may take a few minutes. Press OK to proceed.": "Esto puede tardar unos minutos.Presione Aceptar para continuar.", + "This may take a few minutes. Press OK to proceed.": "Esto puede tardar unos minutos. Presione Aceptar para continuar.", "This may take a few seconds...": "Esto puede tardar unos segundos...", "This may take several minutes...": "Esto puede tardar varios minutos...", "This means Proxmox handles mount lifecycle natively (no manual /etc/fstab needed for NFS/CIFS host storages).": "Esto significa que Proxmox maneja el ciclo de vida del montaje de forma nativa (no se necesita /etc/fstab manual para almacenamientos de host NFS/CIFS).", @@ -4492,8 +4492,8 @@ "This script must be run on a Proxmox host.": "Este script debe ejecutarse en un host Proxmox.", "This script will apply the following optimizations and advanced adjustments to your Proxmox VE server": "Este script aplicará las siguientes optimizaciones y ajustes avanzados a su servidor Proxmox VE", "This script will update your Proxmox VE system with advanced options:": "Este script actualizará su sistema Proxmox VE con opciones avanzadas:", - "This session is running in the Monitor terminal. Running it from here would cut the connection mid-install and leave the switch in a broken state.": "esta sesión se ejecuta en la terminal Monitor.Ejecutarlo desde aquí cortaría la conexión durante la instalación y dejaría el conmutador en un estado roto.", - "This session is running in the Monitor terminal. Updating from here would restart the Monitor service and cut the connection mid-install, leaving the update in a broken state.": "esta sesión se ejecuta en la terminal Monitor.La actualización desde aquí reiniciaría el servicio Monitor y cortaría la conexión durante la instalación, dejando la actualización en un estado roto.", + "This session is running in the Monitor terminal. Running it from here would cut the connection mid-install and leave the switch in a broken state.": "esta sesión se ejecuta en la terminal Monitor. Ejecutarlo desde aquí cortaría la conexión durante la instalación y dejaría el conmutador en un estado roto.", + "This session is running in the Monitor terminal. Updating from here would restart the Monitor service and cut the connection mid-install, leaving the update in a broken state.": "esta sesión se ejecuta en la terminal Monitor. La actualización desde aquí reiniciaría el servicio Monitor y cortaría la conexión durante la instalación, dejando la actualización en un estado roto.", "This shows the storage type and disk identifier": "Esto muestra el tipo de almacenamiento y el identificador del disco.", "This state has a high probability of VM startup/reset failures.": "Este estado tiene una alta probabilidad de que se produzcan errores de inicio/reinicio de la máquina virtual.", "This state indicates a high risk of passthrough failure due to": "Este estado indica un alto riesgo de fallo de paso debido a", @@ -4519,7 +4519,7 @@ "This will restart the network service and may cause a brief disconnection. Continue?": "Esto reiniciará el servicio de red y puede provocar una breve desconexión. ¿Continuar?", "This will take time. Answer prompts carefully - see notes below.": "Esto llevará tiempo. Responda las indicaciones con atención; consulte las notas a continuación.", "This will upgrade this node to Proxmox VE 9 on Debian Trixie.": "Esto actualizará este nodo a Proxmox VE 9 en Debian Trixie.", - "Tick the paths to include in this backup. Press \"Add custom path\" to add a folder or file of your own to the list.": "marque las rutas que desea incluir en esta copia de seguridad.Presione \"Agregar ruta personalizada\" para agregar una carpeta o archivo propio a la lista.", + "Tick the paths to include in this backup. Press \"Add custom path\" to add a folder or file of your own to the list.": "marque las rutas que desea incluir en esta copia de seguridad. Presione \"Agregar ruta personalizada\" para agregar una carpeta o archivo propio a la lista.", "Tick the paths to remove (they will not be deleted from disk — only from this list):": "marque las rutas a eliminar (no se eliminarán del disco, solo de esta lista):", "Time settings configured - Timezone:": "Configuración de hora configurada - Zona horaria:", "Time synchronization reset to UTC": "Restablecimiento de la sincronización horaria a UTC", @@ -4691,9 +4691,9 @@ "Upload an encrypted copy of the key to PBS so you can recover it on a reinstalled host with just a passphrase?": "¿Cargar una copia cifrada de la clave a PBS para poder recuperarla en un host reinstalado con solo una frase de contraseña?", "Upload key to PBS?": "¿Subir clave a PBS?", "Upload to PBS disabled.": "Subir a PBS deshabilitado.", - "Upload to PBS enabled. The envelope is uploaded on every encrypted backup.": "Subir a PBS habilitado.El sobre se carga en cada copia de seguridad cifrada.", - "Upload to PBS is currently: no. Pick an action:": "Subir a PBS es actualmente: no.Elige una acción:", - "Upload to PBS is currently: yes. Pick an action:": "Subir a PBS actualmente es: sí.Elige una acción:", + "Upload to PBS enabled. The envelope is uploaded on every encrypted backup.": "Subir a PBS habilitado. El sobre se carga en cada copia de seguridad cifrada.", + "Upload to PBS is currently: no. Pick an action:": "Subir a PBS es actualmente: no. Elige una acción:", + "Upload to PBS is currently: yes. Pick an action:": "Subir a PBS actualmente es: sí. Elige una acción:", "Upload to PBS: enable, disable or rotate the recovery passphrase": "cargar en PBS: habilitar, deshabilitar o rotar la frase de contraseña de recuperación", "Uptime and who is logged in": "Tiempo de actividad y quién ha iniciado sesión", "Use \"Check test progress\" to see results.": "Utilice \"Verificar el progreso de la prueba\" para ver los resultados.", @@ -4814,7 +4814,7 @@ "Verify installations": "Verificar instalaciones", "Verify mount:": "Verificar montaje:", "Verify the conversion:": "Verifique la conversión:", - "Verify the credentials. Switching to manual paste mode so you can finish the setup without re-typing the password.": "Verifique las credenciales.Cambiar al modo de pegado manual para que pueda finalizar la configuración sin volver a escribir la contraseña.", + "Verify the credentials. Switching to manual paste mode so you can finish the setup without re-typing the password.": "Verifique las credenciales. Cambiar al modo de pegado manual para que pueda finalizar la configuración sin volver a escribir la contraseña.", "Verifying Ceph installation...": "Verificando la instalación de Ceph...", "Verifying Ceph packages availability...": "Verificando la disponibilidad de los paquetes de Ceph...", "Verifying all utilities status": "Comprobando el estado de todas las utilidades", @@ -4824,7 +4824,7 @@ "Version info not available": "Información de versión no disponible", "Version:": "Versión:", "Version: Auto-negotiation (NFSv3/NFSv4)": "Versión: negociación automática (NFSv3/NFSv4)", - "Versions shown belong to maintained NVIDIA branches that list your GPU PCI ID. DKMS compilation is the final validation against the running kernel. The recommended version keeps the current branch, or uses the NVIDIA Production Branch on a fresh install.": "Las versiones mostradas pertenecen a ramas mantenidas de NVIDIA que enumeran su ID PCI de GPU.La compilación DKMS es la validación final contra el kernel en ejecución.La versión recomendada mantiene la rama actual o utiliza la rama de producción de NVIDIA en una instalación nueva.", + "Versions shown belong to maintained NVIDIA branches that list your GPU PCI ID. DKMS compilation is the final validation against the running kernel. The recommended version keeps the current branch, or uses the NVIDIA Production Branch on a fresh install.": "Las versiones mostradas pertenecen a ramas mantenidas de NVIDIA que enumeran su ID PCI de GPU.La compilación DKMS es la validación final contra el kernel en ejecución. La versión recomendada mantiene la rama actual o utiliza la rama de producción de NVIDIA en una instalación nueva.", "View CIFS Mounts (pvesm + fstab)": "Ver montajes CIFS (pvesm + fstab)", "View Current Exports": "Ver exportaciones actuales", "View Current Mounts": "Ver montajes actuales", @@ -4916,7 +4916,7 @@ "Without a usable reset path, passthrough reliability is poor and VM": "Sin una ruta de reinicio utilizable, la confiabilidad del paso a través es pobre y la VM", "Working directory:": "Directorio de trabajo:", "Works with LVM, ZFS, and BTRFS storage types": "Funciona con tipos de almacenamiento LVM, ZFS y BTRFS", - "Would you like to continue in passthrough-only mode? The libedgetpu APT install will be skipped, the Coral device will still be visible inside the container (e.g. /dev/apex_0), and you can install the runtime yourself or use an app container that bundles it (e.g. the Frigate Docker image).": "¿Le gustaría continuar en modo de solo paso?Se omitirá la instalación de libedgetpu APT, el dispositivo Coral seguirá siendo visible dentro del contenedor (por ejemplo, /dev/apex_0) y podrá instalar el tiempo de ejecución usted mismo o usar un contenedor de aplicaciones que lo incluya (por ejemplo, la imagen de Frigate Docker).", + "Would you like to continue in passthrough-only mode? The libedgetpu APT install will be skipped, the Coral device will still be visible inside the container (e.g. /dev/apex_0), and you can install the runtime yourself or use an app container that bundles it (e.g. the Frigate Docker image).": "¿Le gustaría continuar en modo de solo paso? Se omitirá la instalación de libedgetpu APT, el dispositivo Coral seguirá siendo visible dentro del contenedor (por ejemplo, /dev/apex_0) y podrá instalar el tiempo de ejecución usted mismo o usar un contenedor de aplicaciones que lo incluya (por ejemplo, la imagen de Frigate Docker).", "Would you like to see the current": "¿Quieres ver la actualidad?", "Write access confirmed for user:": "Acceso de escritura confirmado para el usuario:", "Write access confirmed.": "Acceso de escritura confirmado.", @@ -5018,7 +5018,7 @@ "blocking issue(s).": "problema(s) de bloqueo.", "btrfs — Proxmox dir storage (snapshots, compression)": "btrfs: almacenamiento de directorios de Proxmox (instantáneas, compresión)", "btrfs — snapshots and compression": "btrfs: instantáneas y compresión", - "but it does not match the one used to create the backup. Replace it with the correct keyfile from the source host and retry.": "pero no coincide con el utilizado para crear la copia de seguridad.Reemplácelo con el archivo de claves correcto del host de origen y vuelva a intentarlo.", + "but it does not match the one used to create the backup. Replace it with the correct keyfile from the source host and retry.": "pero no coincide con el utilizado para crear la copia de seguridad. Reemplácelo con el archivo de claves correcto del host de origen y vuelva a intentarlo.", "bytes": "bytes", "can write to": "puede escribir a", "chmod 1777 + setfacl o::rwx (applied on the NFS share from this host)": "chmod 1777 + setfacl o::rwx (aplicado en el recurso compartido NFS de este host)", @@ -5038,7 +5038,7 @@ "disks present": "discos presentes", "dkms autoinstall did not activate:": "la instalación automática de dkms no se activó:", "dkms.conf generated.": "dkms.conf generado.", - "does not exist on this host. Path not added.": "no existe en este host.Ruta no agregada.", + "does not exist on this host. Path not added.": "no existe en este host. Ruta no agregada.", "does not exist. Exiting.": "no existe. Saliendo.", "dpkg still reports unfinished package work; review": "dpkg todavía informa de tareas de paquetes sin finalizar; revise", "driver:": "conductor:", @@ -5198,7 +5198,7 @@ "older firmware may increase passthrough instability": "el firmware más antiguo puede aumentar la inestabilidad del paso", "on SSD/NVMe pools that support discard": "en grupos de SSD/NVMe que admiten descarte", "openssl encryption failed.": "falló el cifrado de openssl.", - "openssl is not installed — cannot create recovery copy. Install openssl and retry.": "openssl no está instalado; no se puede crear una copia de recuperación.Instale openssl y vuelva a intentarlo.", + "openssl is not installed — cannot create recovery copy. Install openssl and retry.": "openssl no está instalado; no se puede crear una copia de recuperación. Instale openssl y vuelva a intentarlo.", "or format it manually using external tools.": "o formatéelo manualmente utilizando herramientas externas.", "or use the ProxMenux LXC Mount Manager.": "o utilice el Administrador de montaje ProxMenux LXC.", "orphan iface lines, no impact on restore": "líneas de iface huérfanas, sin impacto en la restauración", @@ -5268,7 +5268,7 @@ "smbclient command not found after installation.": "El comando smbclient no se encuentra después de la instalación.", "sources.list update skipped (no change)": "Actualización de fuentes.list omitida (sin cambios)", "sources.list updated to Trixie": "fuentes.lista actualizada a Trixie", - "ssh-keygen failed. Cannot create a new SSH key.": "ssh-keygen falló.No se puede crear una nueva clave SSH.", + "ssh-keygen failed. Cannot create a new SSH key.": "ssh-keygen falló. No se puede crear una nueva clave SSH.", "stale entry/entries for interfaces no longer present": "entrada obsoleta/entradas para interfaces que ya no están presentes", "standard performance": "rendimiento estándar", "start/restart failures and reset instability.": "fallos de inicio/reinicio y reinicio de inestabilidad.", diff --git a/lang/fr.json b/lang/fr.json index e3642c66..eaa80002 100644 --- a/lang/fr.json +++ b/lang/fr.json @@ -347,7 +347,7 @@ "Backup created:": "Sauvegarde créée :", "Backup declares unused NICs that are not on this host:": "La sauvegarde déclare les cartes réseau inutilisées qui ne se trouvent pas sur cet hôte :", "Backup destination is inside the backup": "La destination de la sauvegarde se trouve à l'intérieur de la sauvegarde", - "Backup failed. See log:": "La sauvegarde a échoué.Voir le journal :", + "Backup failed. See log:": "La sauvegarde a échoué. Voir le journal :", "Backup file appears corrupted, will reinstall packages": "Le fichier de sauvegarde semble corrompu, réinstallera les packages", "Backup host configuration": "Configuration de l'hôte de sauvegarde", "Backup includes /etc/zfs/zpool.cache. Restore it (same host detected)?": "La sauvegarde inclut /etc/zfs/zpool.cache. Le restaurer (même hôte détecté) ?", @@ -367,7 +367,7 @@ "Backup to local archive (.tar.zst)": "Sauvegarde vers une archive locale (.tar.zst)", "Backup:": "Sauvegarde :", "Backups already on PBS were encrypted with the current key — downloading them will fail unless you first Download the current keyfile to keep a copy.": "Les sauvegardes déjà sur PBS ont été chiffrées avec la clé actuelle. Leur téléchargement échouera à moins que vous ne téléchargiez d'abord le fichier de clé actuel pour en conserver une copie.", - "Backups already stored on PBS were encrypted with the current keyfile. After this action:": "les sauvegardes déjà stockées sur PBS ont été chiffrées avec le fichier de clés actuel.Après cette action :", + "Backups already stored on PBS were encrypted with the current keyfile. After this action:": "les sauvegardes déjà stockées sur PBS ont été chiffrées avec le fichier de clés actuel. Après cette action :", "Bandwidth limit configured": "Limite de bande passante configurée", "Bandwidth test (iperf3)": "Test de bande passante (iperf3)", "Bandwidth test completed successfully": "Test de bande passante terminé avec succès", @@ -456,7 +456,7 @@ "Cannot proceed with invalid export path.": "Impossible de poursuivre avec un chemin d'exportation non valide.", "Cannot proceed with invalid share name.": "Impossible de continuer avec un nom de partage invalide.", "Cannot reach Proxmox repositories": "Impossible d'accéder aux référentiels Proxmox", - "Cannot reach download.proxmox.com. Check network, proxy or DNS.": "Impossible d'accéder à download.proxmox.com.Vérifiez le réseau, le proxy ou le DNS.", + "Cannot reach download.proxmox.com. Check network, proxy or DNS.": "Impossible d'accéder à download.proxmox.com. Vérifiez le réseau, le proxy ou le DNS.", "Cannot reach portal:": "Impossible d'accéder au portail :", "Cannot reach server": "Ne peut pas atteindre le serveur", "Cannot validate credentials - no shares available for testing.": "Impossible de valider les informations d'identification - aucun partage disponible pour les tests.", @@ -599,7 +599,7 @@ "Cleaning up unused time synchronization services...": "Nettoyage des services de synchronisation de l'heure inutilisés...", "Cleans duplicate or conflicting sources": "Nettoie les sources en double ou en conflit", "Cleanup Complete": "Nettoyage terminé", - "Cleanup completed. A reboot is recommended to fully apply pending kernel package configurations.": "Nettoyage terminé.Un redémarrage est recommandé pour appliquer entièrement les configurations de packages de noyau en attente.", + "Cleanup completed. A reboot is recommended to fully apply pending kernel package configurations.": "Nettoyage terminé. Un redémarrage est recommandé pour appliquer entièrement les configurations de packages de noyau en attente.", "Cleanup finished": "Nettoyage terminé", "Cleanup legacy gasket-dkms": "Nettoyer l'ancien paquet gasket-dkms", "Cleanup partial VM?": "Nettoyer une VM partielle ?", @@ -851,8 +851,8 @@ "Copy that file offsite yourself, or download it from the Monitor.": "copiez vous-même ce fichier hors site ou téléchargez-le depuis le moniteur.", "Copy the correct keyfile to this host and rerun Restore — or pick an unencrypted backup.": "copiez le fichier de clés correct sur cet hôte et réexécutez la restauration – ou choisissez une sauvegarde non cryptée.", "Copy the keyfile to a path for offsite backup": "copiez le fichier de clés dans un chemin pour une sauvegarde hors site", - "Copy your PBS keyfile onto this host first (via scp, USB, sftp, etc.) and enter its absolute path below. The file will be copied to": "copiez d'abord votre fichier de clé PBS sur cet hôte (via scp, USB, sftp, etc.) et entrez son chemin absolu ci-dessous.Le fichier sera copié dans", - "Copy your existing PBS keyfile onto this host first (via scp, USB, sftp, etc.) and enter its absolute path below. The file will be copied to": "copiez d'abord votre fichier de clé PBS existant sur cet hôte (via scp, USB, sftp, etc.) et entrez son chemin absolu ci-dessous.Le fichier sera copié dans", + "Copy your PBS keyfile onto this host first (via scp, USB, sftp, etc.) and enter its absolute path below. The file will be copied to": "copiez d'abord votre fichier de clé PBS sur cet hôte (via scp, USB, sftp, etc.) et entrez son chemin absolu ci-dessous. Le fichier sera copié dans", + "Copy your existing PBS keyfile onto this host first (via scp, USB, sftp, etc.) and enter its absolute path below. The file will be copied to": "copiez d'abord votre fichier de clé PBS existant sur cet hôte (via scp, USB, sftp, etc.) et entrez son chemin absolu ci-dessous. Le fichier sera copié dans", "Copying installer to container": "Copie du programme d'installation dans le conteneur", "Copying sources to": "Copie des sources vers", "Coral APT repository ready.": "Le référentiel Coral APT est prêt.", @@ -888,7 +888,7 @@ "Could not configure IOMMU kernel parameters automatically. Configure manually and reboot.": "Impossible de configurer automatiquement les paramètres du noyau IOMMU. Configurez manuellement et redémarrez.", "Could not copy the PVE keyfile into place. Check permissions on:": "Impossible de copier le fichier de clés PVE.Vérifiez les autorisations sur :", "Could not copy the keyfile into place.": "Impossible de copier le fichier de clés.", - "Could not copy the keyfile into place. Check permissions on:": "Impossible de copier le fichier de clés.Vérifiez les autorisations sur :", + "Could not copy the keyfile into place. Check permissions on:": "Impossible de copier le fichier de clés. Vérifiez les autorisations sur :", "Could not create converter directory:": "Impossible de créer le répertoire du convertisseur :", "Could not create destination directory:": "Impossible de créer le répertoire de destination :", "Could not create or access directory:": "Impossible de créer ou d'accéder au répertoire :", @@ -898,7 +898,7 @@ "Could not detect the CIFS mount for this directory. Try accessing it manually.": "Impossible de détecter le montage CIFS pour ce répertoire. Essayez d'y accéder manuellement.", "Could not determine a valid ISO storage directory.": "Impossible de déterminer un répertoire de stockage ISO valide.", "Could not determine disk path for:": "Impossible de déterminer le chemin du disque pour :", - "Could not determine filesystem signature types. Aborting.": "impossible de déterminer les types de signatures du système de fichiers.Avorter.", + "Could not determine filesystem signature types. Aborting.": "impossible de déterminer les types de signatures du système de fichiers. Avorter.", "Could not determine the IOMMU group for the selected GPU.": "Impossible de déterminer le groupe IOMMU pour le GPU sélectionné.", "Could not download recovery blob from PBS.": "Impossible de télécharger le blob de récupération depuis PBS.", "Could not download the installer.": "Impossible de télécharger le programme d'installation.", @@ -920,8 +920,8 @@ "Could not mount": "Impossible de monter", "Could not mount ISO on device": "Impossible de monter l'ISO sur l'appareil", "Could not parse OVF file, or no disk image references found.": "Impossible d'analyser le fichier OVF ou aucune référence d'image disque n'a été trouvée.", - "Could not prepare on-boot restore service. Nothing new was scheduled.": "Impossible de préparer le service de restauration au démarrage.Rien de nouveau n'était prévu.", - "Could not publish pending restore. Previous pending restore was kept.": "Impossible de publier la restauration en attente.La restauration précédente en attente a été conservée.", + "Could not prepare on-boot restore service. Nothing new was scheduled.": "Impossible de préparer le service de restauration au démarrage. Rien de nouveau n'était prévu.", + "Could not publish pending restore. Previous pending restore was kept.": "Impossible de publier la restauration en attente. La restauration précédente en attente a été conservée.", "Could not push the key. Check the password and that": "Impossible d'appuyer sur la clé. Vérifiez le mot de passe et cela", "Could not read SMART data from": "Impossible de lire les données SMART de", "Could not read VM configuration.": "Impossible de lire la configuration de la VM.", @@ -935,7 +935,7 @@ "Could not set VM virtual display to vga: std": "Impossible de définir l'affichage virtuel de la VM sur VGA : std", "Could not set boot order for": "Impossible de définir l'ordre de démarrage pour", "Could not stage pending restore path:": "Impossible de préparer le chemin de restauration en attente :", - "Could not stage pending restore. Nothing new was scheduled.": "Impossible d’effectuer la restauration en attente.Rien de nouveau n'était prévu.", + "Could not stage pending restore. Nothing new was scheduled.": "Impossible d’effectuer la restauration en attente. Rien de nouveau n'était prévu.", "Could not stop LXC": "Impossible d'arrêter LXC", "Could not unload nouveau module (may be in use). The blacklist will take effect after reboot. Installation will continue but a reboot will be required.": "Impossible de décharger le nouveau module (peut-être en cours d'utilisation). La liste noire prendra effet après le redémarrage. L'installation continuera mais un redémarrage sera nécessaire.", "Could not unmount": "Impossible de démonter", @@ -1628,7 +1628,7 @@ "Failed to create directory on host:": "Échec de la création du répertoire sur l'hôte :", "Failed to create directory:": "Échec de la création du répertoire :", "Failed to create disk": "Échec de la création du disque", - "Failed to create encryption key. Backup cancelled — fix the underlying issue and retry.": "Échec de la création de la clé de cryptage.Sauvegarde annulée : corrigez le problème sous-jacent et réessayez.", + "Failed to create encryption key. Backup cancelled — fix the underlying issue and retry.": "Échec de la création de la clé de cryptage. Sauvegarde annulée : corrigez le problème sous-jacent et réessayez.", "Failed to create group:": "Échec de la création du groupe :", "Failed to create mount point.": "Échec de la création du point de montage.", "Failed to create mount point:": "Échec de la création du point de montage :", @@ -2354,7 +2354,7 @@ "Kernel panic configuration removed": "Configuration de panique du noyau supprimée", "Kernel panic configuration updated and applied": "Configuration de panique du noyau mise à jour et appliquée", "Kernel, modules and boot config": "Noyau, modules et configuration de démarrage", - "Kernel/boot-tied files (boot config, /etc/systemd/system, initramfs config, apt sources, ZFS state, ...) are NOT copied verbatim to keep the target's boot safe. The operator's own tuning inside them (IOMMU cmdline, VFIO IDs, custom quirks, GRUB timeout, ...) is merged into the target's fresh copies automatically via kernel-agnostic merge.": "les fichiers liés au noyau/au démarrage (configuration de démarrage, /etc/systemd/system, configuration initramfs, sources apt, état ZFS, ...) ne sont PAS copiés textuellement pour assurer la sécurité du démarrage de la cible.Les propres réglages de l'opérateur à l'intérieur (ligne de commande IOMMU, ID VFIO, bizarreries personnalisées, délai d'attente GRUB, ...) sont automatiquement fusionnés dans les nouvelles copies de la cible via une fusion indépendante du noyau.", + "Kernel/boot-tied files (boot config, /etc/systemd/system, initramfs config, apt sources, ZFS state, ...) are NOT copied verbatim to keep the target's boot safe. The operator's own tuning inside them (IOMMU cmdline, VFIO IDs, custom quirks, GRUB timeout, ...) is merged into the target's fresh copies automatically via kernel-agnostic merge.": "les fichiers liés au noyau/au démarrage (configuration de démarrage, /etc/systemd/system, configuration initramfs, sources apt, état ZFS, ...) ne sont PAS copiés textuellement pour assurer la sécurité du démarrage de la cible. Les propres réglages de l'opérateur à l'intérieur (ligne de commande IOMMU, ID VFIO, bizarreries personnalisées, délai d'attente GRUB, ...) sont automatiquement fusionnés dans les nouvelles copies de la cible via une fusion indépendante du noyau.", "Keyfile copied": "fichier clé copié", "Keyfile copied to:": "fichier clé copié dans :", "Keyfile passphrase": "Phrase secrète du fichier clé", @@ -2860,7 +2860,7 @@ "No Shares Found": "Aucun partage trouvé", "No Storage Found": "Aucun stockage trouvé", "No USB drives detected. Enter the mountpoint path manually:": "Aucune clé USB détectée. Saisissez manuellement le chemin du point de montage :", - "No USB drives mounted by ProxMenux yet. Mount one first to use it as a target.": "Aucune clé USB montée par ProxMenux pour le moment.Montez-en un d’abord pour l’utiliser comme cible.", + "No USB drives mounted by ProxMenux yet. Mount one first to use it as a target.": "Aucune clé USB montée par ProxMenux pour le moment. Montez-en un d’abord pour l’utiliser comme cible.", "No UUP folder found.": "Aucun dossier UUP trouvé.", "No VM was selected.": "Aucune VM n'a été sélectionnée.", "No VMID defined. Cannot apply guest agent config.": "Aucun VMID défini. Impossible d'appliquer la configuration de l'agent invité.", @@ -2872,7 +2872,7 @@ "No VirtIO ISO found. Please download one.": "Aucun ISO VirtIO trouvé. Veuillez en télécharger un.", "No VirtIO ISO selected. Please choose again.": "Aucun ISO VirtIO sélectionné. Veuillez choisir à nouveau.", "No Virtual Machines found on this system.": "Aucune machine virtuelle trouvée sur ce système.", - "No ZFS pools detected. Skipping ZFS ARC optimization.": "Aucun pool ZFS détecté.Ignorer l'optimisation ZFS ARC.", + "No ZFS pools detected. Skipping ZFS ARC optimization.": "Aucun pool ZFS détecté. Ignorer l'optimisation ZFS ARC.", "No ZFS pools detected. Skipping ZFS autotrim.": "Aucun pool ZFS détecté. Ignorer le découpage automatique ZFS.", "No accessible": "Non accessible", "No accessible NFS servers found.": "Aucun serveur NFS accessible trouvé.", @@ -2922,7 +2922,7 @@ "No duplicate repositories found": "Aucun référentiel en double trouvé", "No eligible Controller/NVMe devices remain after SR-IOV filtering. Skipping.": "Il ne reste aucun périphérique contrôleur/NVMe éligible après le filtrage SR-IOV. Saut.", "No eligible controllers remain after SR-IOV filtering.": "Il ne reste aucun contrôleur éligible après le filtrage SR-IOV.", - "No encryption key is stored on this host. Choose how to set one up:": "Aucune clé de cryptage n’est stockée sur cet hôte.Choisissez comment en configurer un :", + "No encryption key is stored on this host. Choose how to set one up:": "Aucune clé de cryptage n’est stockée sur cet hôte. Choisissez comment en configurer un :", "No exportable VM disks were found (CD-ROM/cloud-init are excluded).": "Aucun disque de VM exportable n'a été trouvé (les CD-ROM/cloud-init sont exclus).", "No exportable disks": "Aucun disque exportable", "No exports configured.": "Aucune exportation configurée.", @@ -2975,7 +2975,7 @@ "No ports configured": "Aucun port configuré", "No privileged containers available in Proxmox.": "Aucun conteneur privilégié disponible dans Proxmox.", "No pve-enterprise.list present (skipped)": "Aucun pve-enterprise.list présent (ignoré)", - "No reboot was started. Review the log before retrying:": "Aucun redémarrage n'a été lancé.Consultez le journal avant de réessayer :", + "No reboot was started. Review the log before retrying:": "Aucun redémarrage n'a été lancé. Consultez le journal avant de réessayer :", "No recent": "Pas de récent", "No recent Samba servers found.": "Aucun serveur Samba récent trouvé.", "No routing information found.": "Aucune information de routage trouvée.", @@ -3353,7 +3353,7 @@ "ProxMenux logo applied": "Logo ProxMenux appliqué", "ProxMenux only acts as a launcher — once the script starts, control leaves ProxMenux.": "ProxMenux agit uniquement comme un lanceur : une fois le script démarré, le contrôle quitte ProxMenux.", "ProxMenux saved it locally at:": "ProxMenux l'a enregistré localement à :", - "ProxMenux-managed .link file(s). User-authored .link files were left in place.": "fichier(s) .link gérés par ProxMenux.Les fichiers .link créés par l'utilisateur ont été laissés en place.", + "ProxMenux-managed .link file(s). User-authored .link files were left in place.": "fichier(s) .link gérés par ProxMenux. Les fichiers .link créés par l'utilisateur ont été laissés en place.", "Proxmology logo applied": "Logo Proxmologie appliqué", "Proxmox 9 system update allready": "La mise à jour du système Proxmox 9 est déjà terminée", "Proxmox APT repositories configured": "Dépôts Proxmox APT configurés", @@ -4339,8 +4339,8 @@ "The active kernel driver is not vfio-pci, but the entry will rebind the GPU to vfio-pci on the next reboot.": "Le pilote du noyau actif n'est pas vfio-pci, mais l'entrée reliera le GPU à vfio-pci au prochain redémarrage.", "The archive could not be extracted.": "L'archive n'a pas pu être extraite.", "The archive destination directory is INSIDE one of the paths you are about to back up. Writing the archive there would copy the backup into itself — producing a corrupted archive, or growing without limit until the disk fills up.": "Le répertoire de destination de l'archive se trouve À L'INTÉRIEUR de l'un des chemins que vous êtes sur le point de sauvegarder. Écrire l'archive là-bas copierait la sauvegarde sur elle-même, produisant une archive corrompue ou s'agrandissant sans limite jusqu'à ce que le disque se remplisse.", - "The backup metadata was compared against this host. The following items will be SKIPPED to keep the boot safe:": "les métadonnées de sauvegarde ont été comparées à cet hôte.Les éléments suivants seront SAUTÉS pour assurer la sécurité du démarrage :", - "The backup was taken on a different PVE or kernel major.minor. These paths will be SKIPPED to keep the boot safe:": "La sauvegarde a été effectuée sur un autre PVE ou noyau major.minor.Ces chemins seront SAUTÉS pour assurer la sécurité du démarrage :", + "The backup metadata was compared against this host. The following items will be SKIPPED to keep the boot safe:": "les métadonnées de sauvegarde ont été comparées à cet hôte. Les éléments suivants seront SAUTÉS pour assurer la sécurité du démarrage :", + "The backup was taken on a different PVE or kernel major.minor. These paths will be SKIPPED to keep the boot safe:": "La sauvegarde a été effectuée sur un autre PVE ou noyau major.minor. Ces chemins seront SAUTÉS pour assurer la sécurité du démarrage :", "The compatibility check raised failures that may break the system after restore.": "La vérification de compatibilité a généré des échecs susceptibles de casser le système après la restauration.", "The container is currently stopped. Do you want to start it now to install the package?": "Le conteneur est actuellement arrêté. Voulez-vous le démarrer maintenant pour installer le package ?", "The container should now start as privileged": "Le conteneur devrait maintenant démarrer en tant que privilégié", @@ -4353,12 +4353,12 @@ "The filesystem": "Le système de fichiers", "The following DKMS-managed drivers will now be rebuilt against it so they keep working after reboot:": "Les pilotes gérés par DKMS suivants seront désormais reconstruits afin qu'ils continuent de fonctionner après le redémarrage :", "The following LXC containers have NVIDIA passthrough configured:": "Les conteneurs LXC suivants ont configuré le relais NVIDIA :", - "The following backup paths are kernel-tied and are excluded from the picker to keep the target's boot safe. The operator's own tuning inside these paths (IOMMU cmdline, VFIO IDs, custom quirks) is merged back automatically via kernel-agnostic merge:": "Les chemins de sauvegarde suivants sont liés au noyau et sont exclus du sélecteur pour assurer la sécurité du démarrage de la cible.Les propres réglages de l'opérateur à l'intérieur de ces chemins (ligne de commande IOMMU, ID VFIO, bizarreries personnalisées) sont automatiquement fusionnés via une fusion indépendante du noyau :", + "The following backup paths are kernel-tied and are excluded from the picker to keep the target's boot safe. The operator's own tuning inside these paths (IOMMU cmdline, VFIO IDs, custom quirks) is merged back automatically via kernel-agnostic merge:": "Les chemins de sauvegarde suivants sont liés au noyau et sont exclus du sélecteur pour assurer la sécurité du démarrage de la cible. Les propres réglages de l'opérateur à l'intérieur de ces chemins (ligne de commande IOMMU, ID VFIO, bizarreries personnalisées) sont automatiquement fusionnés via une fusion indépendante du noyau :", "The following changes will be applied": "Les modifications suivantes seront appliquées", "The following devices were excluded because they are part of an SR-IOV configuration:": "Les appareils suivants ont été exclus car ils font partie d'une configuration SR-IOV :", "The following devices were excluded from Controller/NVMe passthrough because they are part of an SR-IOV configuration:": "Les périphériques suivants ont été exclus du relais Controller/NVMe car ils font partie d'une configuration SR-IOV :", "The following drivers could not be rebuilt for the new kernel — run their installer manually after reboot:": "Les pilotes suivants n'ont pas pu être reconstruits pour le nouveau noyau — exécutez leur programme d'installation manuellement après le redémarrage :", - "The following entries exist on the host but were NOT in the backup. To make the host EXACTLY match the backup state, they must be removed:": "Les entrées suivantes existent sur l'hôte mais n'étaient PAS dans la sauvegarde.Pour que l'hôte corresponde EXACTEMENT à l'état de la sauvegarde, ils doivent être supprimés :", + "The following entries exist on the host but were NOT in the backup. To make the host EXACTLY match the backup state, they must be removed:": "Les entrées suivantes existent sur l'hôte mais n'étaient PAS dans la sauvegarde. Pour que l'hôte corresponde EXACTEMENT à l'état de la sauvegarde, ils doivent être supprimés :", "The following selected GPU(s) are currently in GPU -> VM mode (vfio-pci):": "Les GPU sélectionnés suivants sont actuellement en mode GPU -> VM (vfio-pci) :", "The following selected GPU(s) still have a VFIO passthrough entry in": "Les GPU sélectionnés suivants ont toujours une entrée de relais VFIO dans", "The following selected device(s) are Physical Functions with active Virtual Functions:": "Les appareils sélectionnés suivants sont des fonctions physiques avec des fonctions virtuelles actives :", @@ -4368,7 +4368,7 @@ "The host directory may not be accessible from an unprivileged container.": "Le répertoire hôte peut ne pas être accessible à partir d'un conteneur non privilégié.", "The installation requires a server restart to apply changes. Do you want to restart now?": "L'installation nécessite un redémarrage du serveur pour appliquer les modifications. Voulez-vous redémarrer maintenant ?", "The installation/changes require a server restart to apply correctly. Do you want to reboot now?": "L'installation/les modifications nécessitent un redémarrage du serveur pour s'appliquer correctement. Voulez-vous redémarrer maintenant ?", - "The local envelope is dropped and future backups do not upload anything. Uploaded envelopes already on PBS stay intact and remain recoverable with their original passphrase.": "L'enveloppe locale est supprimée et les futures sauvegardes ne téléchargent rien.Les enveloppes téléchargées déjà sur PBS restent intactes et restent récupérables avec leur phrase secrète d'origine.", + "The local envelope is dropped and future backups do not upload anything. Uploaded envelopes already on PBS stay intact and remain recoverable with their original passphrase.": "L'enveloppe locale est supprimée et les futures sauvegardes ne téléchargent rien. Les enveloppes téléchargées déjà sur PBS restent intactes et restent récupérables avec leur phrase secrète d'origine.", "The long test runs directly on the disk hardware.": "Le test long s'exécute directement sur le matériel du disque.", "The new SSH key was installed and is now authorized on the server.\nKey file:": "La nouvelle clé SSH a été installée et est désormais autorisée sur le serveur.\nFichier clé :", "The new SSH key was pushed to the LXC via 'pct exec' on": "La nouvelle clé SSH a été transmise au LXC via 'pct exec' sur", @@ -4470,14 +4470,14 @@ "This is unexpected since credentials were validated.": "C'est inattendu puisque les informations d'identification ont été validées.", "This marks the container as unprivileged": "Cela marque le conteneur comme non privilégié", "This may be normal for a fresh installation": "Cela peut être normal pour une nouvelle installation", - "This may take a few minutes. Press OK to proceed.": "Cela peut prendre quelques minutes.Appuyez sur OK pour continuer.", + "This may take a few minutes. Press OK to proceed.": "Cela peut prendre quelques minutes. Appuyez sur OK pour continuer.", "This may take a few seconds...": "Cela peut prendre quelques secondes...", "This may take several minutes...": "Cela peut prendre plusieurs minutes...", "This means Proxmox handles mount lifecycle natively (no manual /etc/fstab needed for NFS/CIFS host storages).": "Cela signifie que Proxmox gère le cycle de vie du montage de manière native (aucun /etc/fstab manuel n'est nécessaire pour les stockages hôtes NFS/CIFS).", "This means the credentials are incorrect.": "Cela signifie que les informations d'identification sont incorrectes.", "This might indicate network connectivity issues.": "Cela peut indiquer des problèmes de connectivité réseau.", "This operation may take several minutes and requires internet connectivity.": "Cette opération peut prendre plusieurs minutes et nécessite une connexion Internet.", - "This package was installed by older versions of the ProxMenux Coral installer that placed the M.2 kernel driver on every system, including USB-only setups. It is not needed for Coral USB devices, which use libedgetpu1-std / libedgetpu1-max only.": "Ce package a été installé par les anciennes versions du programme d'installation de ProxMenux Coral qui plaçaient le pilote du noyau M.2 sur chaque système, y compris les configurations USB uniquement.Il n'est pas nécessaire pour les périphériques USB Coral, qui utilisent uniquement libedgetpu1-std / libedgetpu1-max.", + "This package was installed by older versions of the ProxMenux Coral installer that placed the M.2 kernel driver on every system, including USB-only setups. It is not needed for Coral USB devices, which use libedgetpu1-std / libedgetpu1-max only.": "Ce package a été installé par les anciennes versions du programme d'installation de ProxMenux Coral qui plaçaient le pilote du noyau M.2 sur chaque système, y compris les configurations USB uniquement. Il n'est pas nécessaire pour les périphériques USB Coral, qui utilisent uniquement libedgetpu1-std / libedgetpu1-max.", "This passphrase is the ONLY way to access encrypted Borg backups.": "Cette phrase secrète est le SEUL moyen d'accéder aux sauvegardes Borg cryptées.", "This path is already used as a mount point in this container.": "Ce chemin est déjà utilisé comme point de montage dans ce conteneur.", "This path is not a registered mount point. Use it anyway?": "Ce chemin n'est pas un point de montage enregistré. L'utiliser quand même ?", @@ -4492,8 +4492,8 @@ "This script must be run on a Proxmox host.": "Ce script doit être exécuté sur un hôte Proxmox.", "This script will apply the following optimizations and advanced adjustments to your Proxmox VE server": "Ce script appliquera les optimisations et ajustements avancés suivants à votre serveur Proxmox VE", "This script will update your Proxmox VE system with advanced options:": "Ce script mettra à jour votre système Proxmox VE avec des options avancées :", - "This session is running in the Monitor terminal. Running it from here would cut the connection mid-install and leave the switch in a broken state.": "Cette session est en cours d'exécution dans le terminal Monitor.L’exécuter à partir d’ici couperait la connexion en cours d’installation et laisserait le commutateur dans un état cassé.", - "This session is running in the Monitor terminal. Updating from here would restart the Monitor service and cut the connection mid-install, leaving the update in a broken state.": "Cette session est en cours d'exécution dans le terminal Monitor.La mise à jour à partir d'ici redémarrerait le service Monitor et couperait la connexion en cours d'installation, laissant la mise à jour dans un état interrompu.", + "This session is running in the Monitor terminal. Running it from here would cut the connection mid-install and leave the switch in a broken state.": "Cette session est en cours d'exécution dans le terminal Monitor. L’exécuter à partir d’ici couperait la connexion en cours d’installation et laisserait le commutateur dans un état cassé.", + "This session is running in the Monitor terminal. Updating from here would restart the Monitor service and cut the connection mid-install, leaving the update in a broken state.": "Cette session est en cours d'exécution dans le terminal Monitor. La mise à jour à partir d'ici redémarrerait le service Monitor et couperait la connexion en cours d'installation, laissant la mise à jour dans un état interrompu.", "This shows the storage type and disk identifier": "Ceci montre le type de stockage et l'identifiant du disque", "This state has a high probability of VM startup/reset failures.": "Cet état présente une forte probabilité d’échecs de démarrage/réinitialisation de la VM.", "This state indicates a high risk of passthrough failure due to": "Cet état indique un risque élevé d'échec du relais en raison de", @@ -4691,9 +4691,9 @@ "Upload an encrypted copy of the key to PBS so you can recover it on a reinstalled host with just a passphrase?": "Télécharger une copie cryptée de la clé sur PBS afin de pouvoir la récupérer sur un hôte réinstallé avec juste une phrase secrète ?", "Upload key to PBS?": "Télécharger la clé sur PBS ?", "Upload to PBS disabled.": "Téléchargement vers PBS désactivé.", - "Upload to PBS enabled. The envelope is uploaded on every encrypted backup.": "Téléchargement vers PBS activé.L'enveloppe est téléchargée sur chaque sauvegarde cryptée.", - "Upload to PBS is currently: no. Pick an action:": "Le téléchargement sur PBS est actuellement : non.Choisissez une action :", - "Upload to PBS is currently: yes. Pick an action:": "Le téléchargement sur PBS est actuellement : oui.Choisissez une action :", + "Upload to PBS enabled. The envelope is uploaded on every encrypted backup.": "Téléchargement vers PBS activé. L'enveloppe est téléchargée sur chaque sauvegarde cryptée.", + "Upload to PBS is currently: no. Pick an action:": "Le téléchargement sur PBS est actuellement : non. Choisissez une action :", + "Upload to PBS is currently: yes. Pick an action:": "Le téléchargement sur PBS est actuellement : oui. Choisissez une action :", "Upload to PBS: enable, disable or rotate the recovery passphrase": "Télécharger sur PBS : activer, désactiver ou alterner la phrase secrète de récupération", "Uptime and who is logged in": "Disponibilité et qui est connecté", "Use \"Check test progress\" to see results.": "Utilisez « Vérifier la progression du test » pour voir les résultats.", @@ -4701,7 +4701,7 @@ "Use 'pct restore' / 'qmrestore' to recover their disks from your VM backups.": "Utilisez « PCT Restore » / « qmrestore » pour récupérer leurs disques à partir des sauvegardes de votre VM.", "Use Custom backup and uncheck the conflicting path from the list": "Utilisez la sauvegarde personnalisée et décochez le chemin en conflit dans la liste", "Use Default Settings?": "Utiliser les paramètres par défaut ?", - "Use Download first if you want to save a copy of the current key. Continue?": "utilisez d'abord Télécharger si vous souhaitez enregistrer une copie de la clé actuelle.Continuer?", + "Use Download first if you want to save a copy of the current key. Continue?": "utilisez d'abord Télécharger si vous souhaitez enregistrer une copie de la clé actuelle. Continuer?", "Use SPACE to select, ENTER to confirm": "Utilisez ESPACE pour sélectionner, ENTRÉE pour confirmer", "Use SPACE to select/deselect, ENTER to confirm": "Utilisez ESPACE pour sélectionner/désélectionner, ENTER pour confirmer", "Use SSH or terminal access (SSH recommended)": "Utilisez SSH ou l'accès au terminal (SSH recommandé)", @@ -4814,7 +4814,7 @@ "Verify installations": "Vérifier les installations", "Verify mount:": "Vérifiez le montage :", "Verify the conversion:": "Vérifiez la conversion :", - "Verify the credentials. Switching to manual paste mode so you can finish the setup without re-typing the password.": "Vérifiez les informations d’identification.Passage en mode collage manuel pour pouvoir terminer la configuration sans retaper le mot de passe.", + "Verify the credentials. Switching to manual paste mode so you can finish the setup without re-typing the password.": "Vérifiez les informations d’identification. Passage en mode collage manuel pour pouvoir terminer la configuration sans retaper le mot de passe.", "Verifying Ceph installation...": "Vérification de l'installation de Ceph...", "Verifying Ceph packages availability...": "Vérification de la disponibilité des packages Ceph...", "Verifying all utilities status": "Vérification de l'état de tous les utilitaires", @@ -4824,7 +4824,7 @@ "Version info not available": "Informations sur la version non disponibles", "Version:": "Version:", "Version: Auto-negotiation (NFSv3/NFSv4)": "Version : Auto-négociation (NFSv3/NFSv4)", - "Versions shown belong to maintained NVIDIA branches that list your GPU PCI ID. DKMS compilation is the final validation against the running kernel. The recommended version keeps the current branch, or uses the NVIDIA Production Branch on a fresh install.": "Les versions affichées appartiennent aux branches NVIDIA maintenues qui répertorient votre ID PCI GPU.La compilation DKMS est la validation finale par rapport au noyau en cours d'exécution.La version recommandée conserve la branche actuelle ou utilise la branche de production NVIDIA sur une nouvelle installation.", + "Versions shown belong to maintained NVIDIA branches that list your GPU PCI ID. DKMS compilation is the final validation against the running kernel. The recommended version keeps the current branch, or uses the NVIDIA Production Branch on a fresh install.": "Les versions affichées appartiennent aux branches NVIDIA maintenues qui répertorient votre ID PCI GPU.La compilation DKMS est la validation finale par rapport au noyau en cours d'exécution. La version recommandée conserve la branche actuelle ou utilise la branche de production NVIDIA sur une nouvelle installation.", "View CIFS Mounts (pvesm + fstab)": "Afficher les montages CIFS (pvesm + fstab)", "View Current Exports": "Afficher les exportations actuelles", "View Current Mounts": "Afficher les montures actuelles", @@ -5018,7 +5018,7 @@ "blocking issue(s).": "problème(s) bloquant(s).", "btrfs — Proxmox dir storage (snapshots, compression)": "btrfs — Stockage du répertoire Proxmox (instantanés, compression)", "btrfs — snapshots and compression": "btrfs — instantanés et compression", - "but it does not match the one used to create the backup. Replace it with the correct keyfile from the source host and retry.": "mais il ne correspond pas à celui utilisé pour créer la sauvegarde.Remplacez-le par le fichier de clés correct de l'hôte source et réessayez.", + "but it does not match the one used to create the backup. Replace it with the correct keyfile from the source host and retry.": "mais il ne correspond pas à celui utilisé pour créer la sauvegarde. Remplacez-le par le fichier de clés correct de l'hôte source et réessayez.", "bytes": "octets", "can write to": "peut écrire à", "chmod 1777 + setfacl o::rwx (applied on the NFS share from this host)": "chmod 1777 + setfacl o::rwx (appliqué sur le partage NFS de cet hôte)", @@ -5309,7 +5309,7 @@ "will rebind the GPU to vfio-pci on the next reboot, breaking the driver that is about to be installed.": "reliera le GPU à vfio-pci au prochain redémarrage, cassant ainsi le pilote qui est sur le point d'être installé.", "wipefs failed on": "les wipefs ont échoué", "with": "avec", - "with the password you provided.": "Message technique pour Proxmox et l'informatique.Traduisez : avec le mot de passe que vous avez fourni.", + "with the password you provided.": "Message technique pour Proxmox et l'informatique. Traduisez : avec le mot de passe que vous avez fourni.", "xfs — Proxmox dir storage (large files and VMs)": "xfs — Stockage du répertoire Proxmox (fichiers volumineux et machines virtuelles)", "xfs — better for large files": "xfs – meilleur pour les gros fichiers", "years old": "ans", diff --git a/lang/it.json b/lang/it.json index 6b7ebdff..58a0b2cb 100644 --- a/lang/it.json +++ b/lang/it.json @@ -347,7 +347,7 @@ "Backup created:": "Backup creato:", "Backup declares unused NICs that are not on this host:": "Il backup dichiara le NIC inutilizzate che non si trovano su questo host:", "Backup destination is inside the backup": "La destinazione del backup è all'interno del backup", - "Backup failed. See log:": "backup non riuscito.Vedi registro:", + "Backup failed. See log:": "backup non riuscito. Vedi registro:", "Backup file appears corrupted, will reinstall packages": "Il file di backup sembra danneggiato, i pacchetti verranno reinstallati", "Backup host configuration": "Backup della configurazione dell'host", "Backup includes /etc/zfs/zpool.cache. Restore it (same host detected)?": "Il backup include /etc/zfs/zpool.cache. Ripristinarlo (stesso host rilevato)?", @@ -367,7 +367,7 @@ "Backup to local archive (.tar.zst)": "Backup nell'archivio locale (.tar.zst)", "Backup:": "Backup:", "Backups already on PBS were encrypted with the current key — downloading them will fail unless you first Download the current keyfile to keep a copy.": "i backup già presenti su PBS sono stati crittografati con la chiave corrente: il loro download fallirà a meno che non si scarichi prima il file di chiavi corrente per conservarne una copia.", - "Backups already stored on PBS were encrypted with the current keyfile. After this action:": "i backup già archiviati su PBS sono stati crittografati con il file di chiavi corrente.Dopo questa azione:", + "Backups already stored on PBS were encrypted with the current keyfile. After this action:": "i backup già archiviati su PBS sono stati crittografati con il file di chiavi corrente. Dopo questa azione:", "Bandwidth limit configured": "Limite di larghezza di banda configurato", "Bandwidth test (iperf3)": "test della larghezza di banda (iperf3)", "Bandwidth test completed successfully": "Test della larghezza di banda completato con successo", @@ -456,7 +456,7 @@ "Cannot proceed with invalid export path.": "Impossibile procedere con un percorso di esportazione non valido.", "Cannot proceed with invalid share name.": "Impossibile procedere con un nome di condivisione non valido.", "Cannot reach Proxmox repositories": "Impossibile raggiungere i repository Proxmox", - "Cannot reach download.proxmox.com. Check network, proxy or DNS.": "impossibile raggiungere download.proxmox.com.Controlla rete, proxy o DNS.", + "Cannot reach download.proxmox.com. Check network, proxy or DNS.": "impossibile raggiungere download.proxmox.com. Controlla rete, proxy o DNS.", "Cannot reach portal:": "Impossibile raggiungere il portale:", "Cannot reach server": "Impossibile raggiungere il server", "Cannot validate credentials - no shares available for testing.": "Impossibile convalidare le credenziali: nessuna condivisione disponibile per il test.", @@ -599,7 +599,7 @@ "Cleaning up unused time synchronization services...": "Eliminazione dei servizi di sincronizzazione dell'ora inutilizzati...", "Cleans duplicate or conflicting sources": "Pulisce le fonti duplicate o in conflitto", "Cleanup Complete": "Pulizia completata", - "Cleanup completed. A reboot is recommended to fully apply pending kernel package configurations.": "pulizia completata.Si consiglia un riavvio per applicare completamente le configurazioni del pacchetto kernel in sospeso.", + "Cleanup completed. A reboot is recommended to fully apply pending kernel package configurations.": "pulizia completata. Si consiglia un riavvio per applicare completamente le configurazioni del pacchetto kernel in sospeso.", "Cleanup finished": "La pulizia è terminata", "Cleanup legacy gasket-dkms": "Pulisci il pacchetto gasket-dkms legacy", "Cleanup partial VM?": "Pulire la VM parziale?", @@ -851,8 +851,8 @@ "Copy that file offsite yourself, or download it from the Monitor.": "copia tu stesso il file fuori sede o scaricalo dal Monitor.", "Copy the correct keyfile to this host and rerun Restore — or pick an unencrypted backup.": "copia il file di chiavi corretto su questo host ed esegui nuovamente il ripristino oppure scegli un backup non crittografato.", "Copy the keyfile to a path for offsite backup": "copia il file di chiavi in ​​un percorso per il backup fuori sede", - "Copy your PBS keyfile onto this host first (via scp, USB, sftp, etc.) and enter its absolute path below. The file will be copied to": "copia prima il file di chiavi PBS su questo host (tramite scp, USB, sftp, ecc.) e inserisci il suo percorso assoluto di seguito.Il file verrà copiato", - "Copy your existing PBS keyfile onto this host first (via scp, USB, sftp, etc.) and enter its absolute path below. The file will be copied to": "copia prima il file di chiavi PBS esistente su questo host (tramite scp, USB, sftp, ecc.) e inserisci il suo percorso assoluto di seguito.Il file verrà copiato", + "Copy your PBS keyfile onto this host first (via scp, USB, sftp, etc.) and enter its absolute path below. The file will be copied to": "copia prima il file di chiavi PBS su questo host (tramite scp, USB, sftp, ecc.) e inserisci il suo percorso assoluto di seguito. Il file verrà copiato", + "Copy your existing PBS keyfile onto this host first (via scp, USB, sftp, etc.) and enter its absolute path below. The file will be copied to": "copia prima il file di chiavi PBS esistente su questo host (tramite scp, USB, sftp, ecc.) e inserisci il suo percorso assoluto di seguito. Il file verrà copiato", "Copying installer to container": "Copia del programma di installazione nel contenitore", "Copying sources to": "Copia delle fonti in", "Coral APT repository ready.": "Repository APT Coral pronto.", @@ -886,9 +886,9 @@ "Could not change VM virtual display to vga: std": "Impossibile modificare la visualizzazione virtuale della VM in vga: std", "Could not clone any gasket-driver repository. Check your internet connection and": "Impossibile clonare un repository gasket-driver. Controlla la connessione Internet e", "Could not configure IOMMU kernel parameters automatically. Configure manually and reboot.": "Impossibile configurare automaticamente i parametri del kernel IOMMU. Configura manualmente e riavvia.", - "Could not copy the PVE keyfile into place. Check permissions on:": "impossibile copiare il file di chiavi PVE nella sua posizione.Controlla i permessi su:", + "Could not copy the PVE keyfile into place. Check permissions on:": "impossibile copiare il file di chiavi PVE nella sua posizione. Controlla i permessi su:", "Could not copy the keyfile into place.": "impossibile copiare il file di chiavi in ​​posizione.", - "Could not copy the keyfile into place. Check permissions on:": "impossibile copiare il file di chiavi in ​​posizione.Controlla i permessi su:", + "Could not copy the keyfile into place. Check permissions on:": "impossibile copiare il file di chiavi in ​​posizione. Controlla i permessi su:", "Could not create converter directory:": "Impossibile creare la directory del convertitore:", "Could not create destination directory:": "Impossibile creare la directory di destinazione:", "Could not create or access directory:": "Impossibile creare o accedere alla directory:", @@ -898,7 +898,7 @@ "Could not detect the CIFS mount for this directory. Try accessing it manually.": "Impossibile rilevare il montaggio CIFS per questa directory. Prova ad accedervi manualmente.", "Could not determine a valid ISO storage directory.": "Impossibile determinare una directory di archiviazione ISO valida.", "Could not determine disk path for:": "Impossibile determinare il percorso del disco per:", - "Could not determine filesystem signature types. Aborting.": "Messaggio tecnico per Proxmox e IT.Traduzione: impossibile determinare i tipi di firma del file system.Interruzione.", + "Could not determine filesystem signature types. Aborting.": "Messaggio tecnico per Proxmox e IT.Traduzione: impossibile determinare i tipi di firma del file system. Interruzione.", "Could not determine the IOMMU group for the selected GPU.": "Impossibile determinare il gruppo IOMMU per la GPU selezionata.", "Could not download recovery blob from PBS.": "Impossibile scaricare il BLOB di ripristino da PBS.", "Could not download the installer.": "Impossibile scaricare il programma di installazione.", @@ -920,8 +920,8 @@ "Could not mount": "Impossibile montare", "Could not mount ISO on device": "Impossibile montare l'ISO sul dispositivo", "Could not parse OVF file, or no disk image references found.": "Impossibile analizzare il file OVF o nessun riferimento all'immagine del disco trovato.", - "Could not prepare on-boot restore service. Nothing new was scheduled.": "impossibile preparare il servizio di ripristino all'avvio.Non era previsto nulla di nuovo.", - "Could not publish pending restore. Previous pending restore was kept.": "impossibile pubblicare il ripristino in sospeso.Il precedente ripristino in sospeso è stato mantenuto.", + "Could not prepare on-boot restore service. Nothing new was scheduled.": "impossibile preparare il servizio di ripristino all'avvio. Non era previsto nulla di nuovo.", + "Could not publish pending restore. Previous pending restore was kept.": "impossibile pubblicare il ripristino in sospeso. Il precedente ripristino in sospeso è stato mantenuto.", "Could not push the key. Check the password and that": "Impossibile premere la chiave. Controlla la password e quello", "Could not read SMART data from": "Impossibile leggere i dati SMART da", "Could not read VM configuration.": "Impossibile leggere la configurazione della VM.", @@ -935,7 +935,7 @@ "Could not set VM virtual display to vga: std": "Impossibile impostare il display virtuale della VM su vga: std", "Could not set boot order for": "Impossibile impostare l'ordine di avvio per", "Could not stage pending restore path:": "Impossibile organizzare il percorso di ripristino in sospeso:", - "Could not stage pending restore. Nothing new was scheduled.": "impossibile eseguire il ripristino in sospeso.Non era previsto nulla di nuovo.", + "Could not stage pending restore. Nothing new was scheduled.": "impossibile eseguire il ripristino in sospeso. Non era previsto nulla di nuovo.", "Could not stop LXC": "Impossibile fermare LXC", "Could not unload nouveau module (may be in use). The blacklist will take effect after reboot. Installation will continue but a reboot will be required.": "Impossibile scaricare il modulo nouveau (potrebbe essere in uso). La lista nera avrà effetto dopo il riavvio. L'installazione continuerà ma sarà necessario un riavvio.", "Could not unmount": "Impossibile smontare", @@ -1628,7 +1628,7 @@ "Failed to create directory on host:": "Impossibile creare la directory sull'host:", "Failed to create directory:": "Impossibile creare la directory:", "Failed to create disk": "Impossibile creare il disco", - "Failed to create encryption key. Backup cancelled — fix the underlying issue and retry.": "impossibile creare la chiave di crittografia.Backup annullato: risolvi il problema sottostante e riprova.", + "Failed to create encryption key. Backup cancelled — fix the underlying issue and retry.": "impossibile creare la chiave di crittografia. Backup annullato: risolvi il problema sottostante e riprova.", "Failed to create group:": "Impossibile creare il gruppo:", "Failed to create mount point.": "Impossibile creare il punto di montaggio.", "Failed to create mount point:": "Impossibile creare il punto di montaggio:", @@ -2354,7 +2354,7 @@ "Kernel panic configuration removed": "Configurazione Kernel Panic rimossa", "Kernel panic configuration updated and applied": "Configurazione Kernel Panic aggiornata e applicata", "Kernel, modules and boot config": "Kernel, moduli e configurazione di avvio", - "Kernel/boot-tied files (boot config, /etc/systemd/system, initramfs config, apt sources, ZFS state, ...) are NOT copied verbatim to keep the target's boot safe. The operator's own tuning inside them (IOMMU cmdline, VFIO IDs, custom quirks, GRUB timeout, ...) is merged into the target's fresh copies automatically via kernel-agnostic merge.": "i file legati al kernel/avvio (configurazione di avvio, /etc/systemd/system, configurazione di initramfs, origini apt, stato ZFS, ...) NON vengono copiati parola per parola per mantenere sicuro l'avvio della destinazione.L'ottimizzazione dell'operatore al loro interno (linea cmd IOMMU, ID VFIO, stranezze personalizzate, timeout GRUB, ...) viene unita automaticamente alle nuove copie della destinazione tramite unione indipendente dal kernel.", + "Kernel/boot-tied files (boot config, /etc/systemd/system, initramfs config, apt sources, ZFS state, ...) are NOT copied verbatim to keep the target's boot safe. The operator's own tuning inside them (IOMMU cmdline, VFIO IDs, custom quirks, GRUB timeout, ...) is merged into the target's fresh copies automatically via kernel-agnostic merge.": "i file legati al kernel/avvio (configurazione di avvio, /etc/systemd/system, configurazione di initramfs, origini apt, stato ZFS, ...) NON vengono copiati parola per parola per mantenere sicuro l'avvio della destinazione. L'ottimizzazione dell'operatore al loro interno (linea cmd IOMMU, ID VFIO, stranezze personalizzate, timeout GRUB, ...) viene unita automaticamente alle nuove copie della destinazione tramite unione indipendente dal kernel.", "Keyfile copied": "file chiave copiato", "Keyfile copied to:": "file di chiavi copiato in:", "Keyfile passphrase": "passphrase del file chiave", @@ -2860,7 +2860,7 @@ "No Shares Found": "Nessuna azione trovata", "No Storage Found": "Nessun spazio di archiviazione trovato", "No USB drives detected. Enter the mountpoint path manually:": "Nessuna unità USB rilevata. Immettere manualmente il percorso del punto di montaggio:", - "No USB drives mounted by ProxMenux yet. Mount one first to use it as a target.": "ancora nessuna unità USB montata da ProxMenux.Montane uno prima per usarlo come bersaglio.", + "No USB drives mounted by ProxMenux yet. Mount one first to use it as a target.": "ancora nessuna unità USB montata da ProxMenux. Montane uno prima per usarlo come bersaglio.", "No UUP folder found.": "Nessuna cartella UUP trovata.", "No VM was selected.": "Non è stata selezionata alcuna VM.", "No VMID defined. Cannot apply guest agent config.": "Nessun VMID definito. Impossibile applicare la configurazione dell'agente guest.", @@ -2872,7 +2872,7 @@ "No VirtIO ISO found. Please download one.": "Nessuna ISO VirtIO trovata. Per favore scaricane uno.", "No VirtIO ISO selected. Please choose again.": "Nessun ISO VirtIO selezionato. Per favore scegli di nuovo.", "No Virtual Machines found on this system.": "Nessuna macchina virtuale trovata su questo sistema.", - "No ZFS pools detected. Skipping ZFS ARC optimization.": "nessun pool ZFS rilevato.Saltare l'ottimizzazione ZFS ARC.", + "No ZFS pools detected. Skipping ZFS ARC optimization.": "nessun pool ZFS rilevato. Saltare l'ottimizzazione ZFS ARC.", "No ZFS pools detected. Skipping ZFS autotrim.": "Nessun pool ZFS rilevato. Saltare l'autotrim ZFS.", "No accessible": "Non accessibile", "No accessible NFS servers found.": "Nessun server NFS accessibile trovato.", @@ -2922,7 +2922,7 @@ "No duplicate repositories found": "Nessun repository duplicato trovato", "No eligible Controller/NVMe devices remain after SR-IOV filtering. Skipping.": "Dopo il filtraggio SR-IOV non rimane alcun dispositivo Controller/NVMe idoneo. Saltare.", "No eligible controllers remain after SR-IOV filtering.": "Dopo il filtraggio SR-IOV non rimane alcun controller idoneo.", - "No encryption key is stored on this host. Choose how to set one up:": "su questo host non è archiviata alcuna chiave di crittografia.Scegli come configurarne uno:", + "No encryption key is stored on this host. Choose how to set one up:": "su questo host non è archiviata alcuna chiave di crittografia. Scegli come configurarne uno:", "No exportable VM disks were found (CD-ROM/cloud-init are excluded).": "Non è stato trovato alcun disco VM esportabile (CD-ROM/cloud-init esclusi).", "No exportable disks": "Nessun disco esportabile", "No exports configured.": "Nessuna esportazione configurata.", @@ -2975,7 +2975,7 @@ "No ports configured": "Nessuna porta configurata", "No privileged containers available in Proxmox.": "Nessun contenitore privilegiato disponibile in Proxmox.", "No pve-enterprise.list present (skipped)": "Nessun pve-enterprise.list presente (saltato)", - "No reboot was started. Review the log before retrying:": "non è stato avviato alcun riavvio.Esaminare il registro prima di riprovare:", + "No reboot was started. Review the log before retrying:": "non è stato avviato alcun riavvio. Esaminare il registro prima di riprovare:", "No recent": "Non recente", "No recent Samba servers found.": "Nessun server Samba recente trovato.", "No routing information found.": "Nessuna informazione sul percorso trovata.", @@ -3353,7 +3353,7 @@ "ProxMenux logo applied": "Logo ProxMenux applicato", "ProxMenux only acts as a launcher — once the script starts, control leaves ProxMenux.": "ProxMenux funge solo da launcher: una volta avviato lo script, il controllo lascia ProxMenux.", "ProxMenux saved it locally at:": "ProxMenux lo ha salvato localmente in:", - "ProxMenux-managed .link file(s). User-authored .link files were left in place.": "file .link gestiti da ProxMenux.I file .link creati dall'utente sono stati lasciati al loro posto.", + "ProxMenux-managed .link file(s). User-authored .link files were left in place.": "file .link gestiti da ProxMenux. I file .link creati dall'utente sono stati lasciati al loro posto.", "Proxmology logo applied": "Logo Proxmology applicato", "Proxmox 9 system update allready": "Già l'aggiornamento del sistema Proxmox 9", "Proxmox APT repositories configured": "repository APT Proxmox configurati", @@ -4339,8 +4339,8 @@ "The active kernel driver is not vfio-pci, but the entry will rebind the GPU to vfio-pci on the next reboot.": "il driver del kernel attivo non è vfio-pci, ma la voce ricollegherà la GPU a vfio-pci al prossimo riavvio.", "The archive could not be extracted.": "Impossibile estrarre l'archivio.", "The archive destination directory is INSIDE one of the paths you are about to back up. Writing the archive there would copy the backup into itself — producing a corrupted archive, or growing without limit until the disk fills up.": "La directory di destinazione dell'archivio è ALL'INTERNO di uno dei percorsi di cui stai per eseguire il backup. Scrivere l'archivio lì copierebbe il backup su se stesso, producendo un archivio danneggiato o crescendo senza limiti finché il disco non si riempie.", - "The backup metadata was compared against this host. The following items will be SKIPPED to keep the boot safe:": "i metadati di backup sono stati confrontati con questo host.I seguenti elementi verranno SALTATI per mantenere lo stivale sicuro:", - "The backup was taken on a different PVE or kernel major.minor. These paths will be SKIPPED to keep the boot safe:": "il backup è stato eseguito su un PVE o kernel major.minor diverso.Questi percorsi verranno SALTATI per mantenere l'avvio sicuro:", + "The backup metadata was compared against this host. The following items will be SKIPPED to keep the boot safe:": "i metadati di backup sono stati confrontati con questo host. I seguenti elementi verranno SALTATI per mantenere lo stivale sicuro:", + "The backup was taken on a different PVE or kernel major.minor. These paths will be SKIPPED to keep the boot safe:": "il backup è stato eseguito su un PVE o kernel major.minor diverso. Questi percorsi verranno SALTATI per mantenere l'avvio sicuro:", "The compatibility check raised failures that may break the system after restore.": "Il controllo di compatibilità ha rilevato errori che potrebbero danneggiare il sistema dopo il ripristino.", "The container is currently stopped. Do you want to start it now to install the package?": "Il contenitore è attualmente fermo. Vuoi avviarlo adesso per installare il pacchetto?", "The container should now start as privileged": "Il contenitore ora dovrebbe iniziare come privilegiato", @@ -4353,12 +4353,12 @@ "The filesystem": "Il file system", "The following DKMS-managed drivers will now be rebuilt against it so they keep working after reboot:": "i seguenti driver gestiti da DKMS verranno ora ricostruiti in modo che continuino a funzionare dopo il riavvio:", "The following LXC containers have NVIDIA passthrough configured:": "I seguenti contenitori LXC hanno il passthrough NVIDIA configurato:", - "The following backup paths are kernel-tied and are excluded from the picker to keep the target's boot safe. The operator's own tuning inside these paths (IOMMU cmdline, VFIO IDs, custom quirks) is merged back automatically via kernel-agnostic merge:": "i seguenti percorsi di backup sono legati al kernel e sono esclusi dal selettore per mantenere sicuro l'avvio della destinazione.L'ottimizzazione dell'operatore all'interno di questi percorsi (linea cmd IOMMU, ID VFIO, stranezze personalizzate) viene riunita automaticamente tramite unione indipendente dal kernel:", + "The following backup paths are kernel-tied and are excluded from the picker to keep the target's boot safe. The operator's own tuning inside these paths (IOMMU cmdline, VFIO IDs, custom quirks) is merged back automatically via kernel-agnostic merge:": "i seguenti percorsi di backup sono legati al kernel e sono esclusi dal selettore per mantenere sicuro l'avvio della destinazione. L'ottimizzazione dell'operatore all'interno di questi percorsi (linea cmd IOMMU, ID VFIO, stranezze personalizzate) viene riunita automaticamente tramite unione indipendente dal kernel:", "The following changes will be applied": "Verranno applicate le seguenti modifiche", "The following devices were excluded because they are part of an SR-IOV configuration:": "I seguenti dispositivi sono stati esclusi perché fanno parte di una configurazione SR-IOV:", "The following devices were excluded from Controller/NVMe passthrough because they are part of an SR-IOV configuration:": "I seguenti dispositivi sono stati esclusi dal passthrough Controller/NVMe perché fanno parte di una configurazione SR-IOV:", "The following drivers could not be rebuilt for the new kernel — run their installer manually after reboot:": "non è stato possibile ricostruire i seguenti driver per il nuovo kernel: esegui manualmente il programma di installazione dopo il riavvio:", - "The following entries exist on the host but were NOT in the backup. To make the host EXACTLY match the backup state, they must be removed:": "le seguenti voci esistono sull'host ma NON erano nel backup.Per fare in modo che l'host corrisponda ESATTAMENTE allo stato del backup, è necessario rimuoverli:", + "The following entries exist on the host but were NOT in the backup. To make the host EXACTLY match the backup state, they must be removed:": "le seguenti voci esistono sull'host ma NON erano nel backup. Per fare in modo che l'host corrisponda ESATTAMENTE allo stato del backup, è necessario rimuoverli:", "The following selected GPU(s) are currently in GPU -> VM mode (vfio-pci):": "Le seguenti GPU selezionate sono attualmente in modalità GPU -> VM (vfio-pci):", "The following selected GPU(s) still have a VFIO passthrough entry in": "le seguenti GPU selezionate hanno ancora una voce passthrough VFIO", "The following selected device(s) are Physical Functions with active Virtual Functions:": "I seguenti dispositivi selezionati sono funzioni fisiche con funzioni virtuali attive:", @@ -4368,7 +4368,7 @@ "The host directory may not be accessible from an unprivileged container.": "La directory host potrebbe non essere accessibile da un contenitore non privilegiato.", "The installation requires a server restart to apply changes. Do you want to restart now?": "L'installazione richiede il riavvio del server per applicare le modifiche. Vuoi riavviare adesso?", "The installation/changes require a server restart to apply correctly. Do you want to reboot now?": "L'installazione/modifiche richiedono il riavvio del server per essere applicate correttamente. Vuoi riavviare adesso?", - "The local envelope is dropped and future backups do not upload anything. Uploaded envelopes already on PBS stay intact and remain recoverable with their original passphrase.": "la busta locale viene eliminata e i backup futuri non caricano nulla.Le buste caricate già su PBS rimangono intatte e recuperabili con la loro passphrase originale.", + "The local envelope is dropped and future backups do not upload anything. Uploaded envelopes already on PBS stay intact and remain recoverable with their original passphrase.": "la busta locale viene eliminata e i backup futuri non caricano nulla. Le buste caricate già su PBS rimangono intatte e recuperabili con la loro passphrase originale.", "The long test runs directly on the disk hardware.": "Il test lungo viene eseguito direttamente sull'hardware del disco.", "The new SSH key was installed and is now authorized on the server.\nKey file:": "La nuova chiave SSH è stata installata ed è ora autorizzata sul server.\nFascicolo chiave:", "The new SSH key was pushed to the LXC via 'pct exec' on": "la nuova chiave SSH è stata inviata all'LXC tramite 'pct exec'", @@ -4470,7 +4470,7 @@ "This is unexpected since credentials were validated.": "Ciò è inaspettato poiché le credenziali sono state convalidate.", "This marks the container as unprivileged": "Ciò contrassegna il contenitore come non privilegiato", "This may be normal for a fresh installation": "Questo potrebbe essere normale per una nuova installazione", - "This may take a few minutes. Press OK to proceed.": "l'operazione potrebbe richiedere alcuni minuti.Premere OK per procedere.", + "This may take a few minutes. Press OK to proceed.": "l'operazione potrebbe richiedere alcuni minuti. Premere OK per procedere.", "This may take a few seconds...": "L'operazione potrebbe richiedere alcuni secondi...", "This may take several minutes...": "L'operazione potrebbe richiedere diversi minuti...", "This means Proxmox handles mount lifecycle natively (no manual /etc/fstab needed for NFS/CIFS host storages).": "Ciò significa che Proxmox gestisce il ciclo di vita del montaggio in modo nativo (non è necessario il manuale /etc/fstab per gli archivi host NFS/CIFS).", @@ -4492,8 +4492,8 @@ "This script must be run on a Proxmox host.": "Questo script deve essere eseguito su un host Proxmox.", "This script will apply the following optimizations and advanced adjustments to your Proxmox VE server": "Questo script applicherà le seguenti ottimizzazioni e regolazioni avanzate al tuo server Proxmox VE", "This script will update your Proxmox VE system with advanced options:": "Questo script aggiornerà il tuo sistema Proxmox VE con opzioni avanzate:", - "This session is running in the Monitor terminal. Running it from here would cut the connection mid-install and leave the switch in a broken state.": "questa sessione è in esecuzione nel terminale Monitor.Eseguirlo da qui interromperebbe la connessione a metà installazione e lascerebbe l'interruttore in uno stato interrotto.", - "This session is running in the Monitor terminal. Updating from here would restart the Monitor service and cut the connection mid-install, leaving the update in a broken state.": "questa sessione è in esecuzione nel terminale Monitor.L'aggiornamento da qui riavvierebbe il servizio Monitor e interromperebbe la connessione durante l'installazione, lasciando l'aggiornamento in uno stato interrotto.", + "This session is running in the Monitor terminal. Running it from here would cut the connection mid-install and leave the switch in a broken state.": "questa sessione è in esecuzione nel terminale Monitor. Eseguirlo da qui interromperebbe la connessione a metà installazione e lascerebbe l'interruttore in uno stato interrotto.", + "This session is running in the Monitor terminal. Updating from here would restart the Monitor service and cut the connection mid-install, leaving the update in a broken state.": "questa sessione è in esecuzione nel terminale Monitor. L'aggiornamento da qui riavvierebbe il servizio Monitor e interromperebbe la connessione durante l'installazione, lasciando l'aggiornamento in uno stato interrotto.", "This shows the storage type and disk identifier": "Mostra il tipo di archiviazione e l'identificatore del disco", "This state has a high probability of VM startup/reset failures.": "Questo stato ha un'alta probabilità di errori di avvio/reimpostazione della VM.", "This state indicates a high risk of passthrough failure due to": "Questo stato indica un rischio elevato di errore passthrough dovuto a", @@ -4691,9 +4691,9 @@ "Upload an encrypted copy of the key to PBS so you can recover it on a reinstalled host with just a passphrase?": "caricare una copia crittografata della chiave su PBS in modo da poterla ripristinare su un host reinstallato solo con una passphrase?", "Upload key to PBS?": "Carica la chiave su PBS?", "Upload to PBS disabled.": "caricamento su PBS disabilitato.", - "Upload to PBS enabled. The envelope is uploaded on every encrypted backup.": "caricamento su PBS abilitato.La busta viene caricata su ogni backup crittografato.", - "Upload to PBS is currently: no. Pick an action:": "Il caricamento su PBS è attualmente: no.Scegli un'azione:", - "Upload to PBS is currently: yes. Pick an action:": "Il caricamento su PBS è attualmente: sì.Scegli un'azione:", + "Upload to PBS enabled. The envelope is uploaded on every encrypted backup.": "caricamento su PBS abilitato. La busta viene caricata su ogni backup crittografato.", + "Upload to PBS is currently: no. Pick an action:": "Il caricamento su PBS è attualmente: no. Scegli un'azione:", + "Upload to PBS is currently: yes. Pick an action:": "Il caricamento su PBS è attualmente: sì. Scegli un'azione:", "Upload to PBS: enable, disable or rotate the recovery passphrase": "Carica su PBS: abilita, disabilita o ruota la passphrase di ripristino", "Uptime and who is logged in": "Uptime e chi ha effettuato l'accesso", "Use \"Check test progress\" to see results.": "Utilizza \"Controlla l'avanzamento del test\" per visualizzare i risultati.", @@ -4701,7 +4701,7 @@ "Use 'pct restore' / 'qmrestore' to recover their disks from your VM backups.": "Utilizza 'pct Restore' / 'qmrestore' per ripristinare i loro dischi dai backup della tua VM.", "Use Custom backup and uncheck the conflicting path from the list": "Utilizza il backup personalizzato e deseleziona il percorso in conflitto dall'elenco", "Use Default Settings?": "Utilizzare le impostazioni predefinite?", - "Use Download first if you want to save a copy of the current key. Continue?": "utilizzare prima Scarica se si desidera salvare una copia della chiave corrente.Continuare?", + "Use Download first if you want to save a copy of the current key. Continue?": "utilizzare prima Scarica se si desidera salvare una copia della chiave corrente. Continuare?", "Use SPACE to select, ENTER to confirm": "Usa SPAZIO per selezionare, INVIO per confermare", "Use SPACE to select/deselect, ENTER to confirm": "Utilizzare SPAZIO per selezionare/deselezionare, INVIO per confermare", "Use SSH or terminal access (SSH recommended)": "Utilizza SSH o l'accesso al terminale (consigliato SSH)", @@ -4814,7 +4814,7 @@ "Verify installations": "Verificare le installazioni", "Verify mount:": "Verifica montaggio:", "Verify the conversion:": "Verifica la conversione:", - "Verify the credentials. Switching to manual paste mode so you can finish the setup without re-typing the password.": "verifica le credenziali.Passaggio alla modalità Incolla manuale in modo da poter completare la configurazione senza digitare nuovamente la password.", + "Verify the credentials. Switching to manual paste mode so you can finish the setup without re-typing the password.": "verifica le credenziali. Passaggio alla modalità Incolla manuale in modo da poter completare la configurazione senza digitare nuovamente la password.", "Verifying Ceph installation...": "Verifica dell'installazione di Ceph in corso...", "Verifying Ceph packages availability...": "Verifica della disponibilità dei pacchetti Ceph in corso...", "Verifying all utilities status": "Verifica dello stato di tutte le utenze", @@ -4824,7 +4824,7 @@ "Version info not available": "Informazioni sulla versione non disponibili", "Version:": "Versione:", "Version: Auto-negotiation (NFSv3/NFSv4)": "Versione: negoziazione automatica (NFSv3/NFSv4)", - "Versions shown belong to maintained NVIDIA branches that list your GPU PCI ID. DKMS compilation is the final validation against the running kernel. The recommended version keeps the current branch, or uses the NVIDIA Production Branch on a fresh install.": "le versioni mostrate appartengono ai rami NVIDIA gestiti che elencano il tuo ID PCI GPU.La compilazione DKMS è la convalida finale rispetto al kernel in esecuzione.La versione consigliata mantiene il ramo corrente o utilizza NVIDIA Production Branch in una nuova installazione.", + "Versions shown belong to maintained NVIDIA branches that list your GPU PCI ID. DKMS compilation is the final validation against the running kernel. The recommended version keeps the current branch, or uses the NVIDIA Production Branch on a fresh install.": "le versioni mostrate appartengono ai rami NVIDIA gestiti che elencano il tuo ID PCI GPU.La compilazione DKMS è la convalida finale rispetto al kernel in esecuzione. La versione consigliata mantiene il ramo corrente o utilizza NVIDIA Production Branch in una nuova installazione.", "View CIFS Mounts (pvesm + fstab)": "Visualizza montaggi CIFS (pvesm + fstab)", "View Current Exports": "Visualizza le esportazioni correnti", "View Current Mounts": "Visualizza i supporti attuali", @@ -5018,7 +5018,7 @@ "blocking issue(s).": "problemi di blocco.", "btrfs — Proxmox dir storage (snapshots, compression)": "btrfs — Archiviazione delle directory Proxmox (istantanee, compressione)", "btrfs — snapshots and compression": "btrfs: istantanee e compressione", - "but it does not match the one used to create the backup. Replace it with the correct keyfile from the source host and retry.": "ma non corrisponde a quello utilizzato per creare il backup.Sostituirlo con il file di chiavi corretto dall'host di origine e riprovare.", + "but it does not match the one used to create the backup. Replace it with the correct keyfile from the source host and retry.": "ma non corrisponde a quello utilizzato per creare il backup. Sostituirlo con il file di chiavi corretto dall'host di origine e riprovare.", "bytes": "byte", "can write to": "può scrivere a", "chmod 1777 + setfacl o::rwx (applied on the NFS share from this host)": "chmod 1777 + setfacl o::rwx (applicato alla condivisione NFS da questo host)", diff --git a/lang/pt.json b/lang/pt.json index dc2ae827..6774a909 100644 --- a/lang/pt.json +++ b/lang/pt.json @@ -347,7 +347,7 @@ "Backup created:": "Backup criado:", "Backup declares unused NICs that are not on this host:": "O backup declara NICs não utilizados que não estão neste host:", "Backup destination is inside the backup": "O destino do backup está dentro do backup", - "Backup failed. See log:": "Falha no backup.Veja registro:", + "Backup failed. See log:": "Falha no backup. Veja registro:", "Backup file appears corrupted, will reinstall packages": "O arquivo de backup parece corrompido, irá reinstalar os pacotes", "Backup host configuration": "Configuração do host de backup", "Backup includes /etc/zfs/zpool.cache. Restore it (same host detected)?": "O backup inclui /etc/zfs/zpool.cache. Restaurá-lo (mesmo host detectado)?", @@ -367,7 +367,7 @@ "Backup to local archive (.tar.zst)": "Backup para arquivo local (.tar.zst)", "Backup:": "Backup:", "Backups already on PBS were encrypted with the current key — downloading them will fail unless you first Download the current keyfile to keep a copy.": "Os backups já no PBS foram criptografados com a chave atual – o download deles falhará, a menos que você primeiro baixe o arquivo de chave atual para manter uma cópia.", - "Backups already stored on PBS were encrypted with the current keyfile. After this action:": "Os backups já armazenados no PBS foram criptografados com o arquivo-chave atual.Após esta ação:", + "Backups already stored on PBS were encrypted with the current keyfile. After this action:": "Os backups já armazenados no PBS foram criptografados com o arquivo-chave atual. Após esta ação:", "Bandwidth limit configured": "Limite de largura de banda configurado", "Bandwidth test (iperf3)": "teste de largura de banda (iperf3)", "Bandwidth test completed successfully": "Teste de largura de banda concluído com sucesso", @@ -456,7 +456,7 @@ "Cannot proceed with invalid export path.": "Não é possível prosseguir com caminho de exportação inválido.", "Cannot proceed with invalid share name.": "Não é possível continuar com um nome de compartilhamento inválido.", "Cannot reach Proxmox repositories": "Não é possível acessar os repositórios Proxmox", - "Cannot reach download.proxmox.com. Check network, proxy or DNS.": "Não é possível acessar download.proxmox.com.Verifique a rede, proxy ou DNS.", + "Cannot reach download.proxmox.com. Check network, proxy or DNS.": "Não é possível acessar download.proxmox.com. Verifique a rede, proxy ou DNS.", "Cannot reach portal:": "Não é possível acessar o portal:", "Cannot reach server": "Sem contato com o servidor", "Cannot validate credentials - no shares available for testing.": "Não é possível validar credenciais – não há compartilhamentos disponíveis para teste.", @@ -599,7 +599,7 @@ "Cleaning up unused time synchronization services...": "Limpando serviços de sincronização de horário não utilizados...", "Cleans duplicate or conflicting sources": "Limpa fontes duplicadas ou conflitantes", "Cleanup Complete": "Limpeza concluída", - "Cleanup completed. A reboot is recommended to fully apply pending kernel package configurations.": "Limpeza concluída.Recomenda-se uma reinicialização para aplicar totalmente as configurações pendentes do pacote do kernel.", + "Cleanup completed. A reboot is recommended to fully apply pending kernel package configurations.": "Limpeza concluída. Recomenda-se uma reinicialização para aplicar totalmente as configurações pendentes do pacote do kernel.", "Cleanup finished": "Limpeza concluída", "Cleanup legacy gasket-dkms": "Limpar o pacote gasket-dkms legado", "Cleanup partial VM?": "Limpar VM parcial?", @@ -851,8 +851,8 @@ "Copy that file offsite yourself, or download it from the Monitor.": "Copie você mesmo esse arquivo fora do local ou baixe-o do Monitor.", "Copy the correct keyfile to this host and rerun Restore — or pick an unencrypted backup.": "Copie o arquivo-chave correto para este host e execute novamente a Restauração – ou escolha um backup não criptografado.", "Copy the keyfile to a path for offsite backup": "Copie o arquivo-chave para um caminho para backup externo", - "Copy your PBS keyfile onto this host first (via scp, USB, sftp, etc.) and enter its absolute path below. The file will be copied to": "Copie seu arquivo-chave PBS para este host primeiro (via scp, USB, sftp, etc.) e insira seu caminho absoluto abaixo.O arquivo será copiado para", - "Copy your existing PBS keyfile onto this host first (via scp, USB, sftp, etc.) and enter its absolute path below. The file will be copied to": "primeiro copie seu arquivo de chave PBS existente para este host (via scp, USB, sftp, etc.) e insira seu caminho absoluto abaixo.O arquivo será copiado para", + "Copy your PBS keyfile onto this host first (via scp, USB, sftp, etc.) and enter its absolute path below. The file will be copied to": "Copie seu arquivo-chave PBS para este host primeiro (via scp, USB, sftp, etc.) e insira seu caminho absoluto abaixo. O arquivo será copiado para", + "Copy your existing PBS keyfile onto this host first (via scp, USB, sftp, etc.) and enter its absolute path below. The file will be copied to": "primeiro copie seu arquivo de chave PBS existente para este host (via scp, USB, sftp, etc.) e insira seu caminho absoluto abaixo. O arquivo será copiado para", "Copying installer to container": "Copiando o instalador para o contêiner", "Copying sources to": "Copiando fontes para", "Coral APT repository ready.": "Repositório Coral APT pronto.", @@ -886,9 +886,9 @@ "Could not change VM virtual display to vga: std": "Não foi possível alterar a exibição virtual da VM para vga: std", "Could not clone any gasket-driver repository. Check your internet connection and": "Não foi possível clonar um repositório gasket-driver. Verifique a ligação à Internet e", "Could not configure IOMMU kernel parameters automatically. Configure manually and reboot.": "Não foi possível configurar os parâmetros do kernel IOMMU automaticamente. Configure manualmente e reinicie.", - "Could not copy the PVE keyfile into place. Check permissions on:": "Não foi possível copiar o arquivo-chave PVE no lugar.Verifique as permissões em:", + "Could not copy the PVE keyfile into place. Check permissions on:": "Não foi possível copiar o arquivo-chave PVE no lugar. Verifique as permissões em:", "Could not copy the keyfile into place.": "Não foi possível copiar o arquivo-chave no lugar.", - "Could not copy the keyfile into place. Check permissions on:": "Não foi possível copiar o arquivo-chave no lugar.Verifique as permissões em:", + "Could not copy the keyfile into place. Check permissions on:": "Não foi possível copiar o arquivo-chave no lugar. Verifique as permissões em:", "Could not create converter directory:": "Não foi possível criar o diretório do conversor:", "Could not create destination directory:": "Não foi possível criar o diretório de destino:", "Could not create or access directory:": "Não foi possível criar ou acessar o diretório:", @@ -898,7 +898,7 @@ "Could not detect the CIFS mount for this directory. Try accessing it manually.": "Não foi possível detectar a montagem CIFS para este diretório. Tente acessá-lo manualmente.", "Could not determine a valid ISO storage directory.": "Não foi possível determinar um diretório de armazenamento ISO válido.", "Could not determine disk path for:": "Não foi possível determinar o caminho do disco para:", - "Could not determine filesystem signature types. Aborting.": "Não foi possível determinar os tipos de assinatura do sistema de arquivos.Abortando.", + "Could not determine filesystem signature types. Aborting.": "Não foi possível determinar os tipos de assinatura do sistema de arquivos. Abortando.", "Could not determine the IOMMU group for the selected GPU.": "Não foi possível determinar o grupo IOMMU para a GPU selecionada.", "Could not download recovery blob from PBS.": "Não foi possível baixar o blob de recuperação do PBS.", "Could not download the installer.": "Não foi possível baixar o instalador.", @@ -920,8 +920,8 @@ "Could not mount": "Não foi possível montar", "Could not mount ISO on device": "Não foi possível montar o ISO no dispositivo", "Could not parse OVF file, or no disk image references found.": "Não foi possível analisar o arquivo OVF ou nenhuma referência de imagem de disco foi encontrada.", - "Could not prepare on-boot restore service. Nothing new was scheduled.": "Não foi possível preparar o serviço de restauração na inicialização.Nada de novo foi programado.", - "Could not publish pending restore. Previous pending restore was kept.": "não foi possível publicar a restauração pendente.A restauração pendente anterior foi mantida.", + "Could not prepare on-boot restore service. Nothing new was scheduled.": "Não foi possível preparar o serviço de restauração na inicialização. Nada de novo foi programado.", + "Could not publish pending restore. Previous pending restore was kept.": "não foi possível publicar a restauração pendente. A restauração pendente anterior foi mantida.", "Could not push the key. Check the password and that": "Não foi possível pressionar a chave. Verifique a senha e isso", "Could not read SMART data from": "Não foi possível ler os dados SMART de", "Could not read VM configuration.": "Não foi possível ler a configuração da VM.", @@ -935,7 +935,7 @@ "Could not set VM virtual display to vga: std": "Não foi possível definir a exibição virtual da VM como vga: std", "Could not set boot order for": "Não foi possível definir a ordem de inicialização para", "Could not stage pending restore path:": "Não foi possível preparar o caminho de restauração pendente:", - "Could not stage pending restore. Nothing new was scheduled.": "não foi possível preparar a restauração pendente.Nada de novo foi programado.", + "Could not stage pending restore. Nothing new was scheduled.": "não foi possível preparar a restauração pendente. Nada de novo foi programado.", "Could not stop LXC": "Não foi possível parar o LXC", "Could not unload nouveau module (may be in use). The blacklist will take effect after reboot. Installation will continue but a reboot will be required.": "Não foi possível descarregar o módulo nouveau (pode estar em uso). A lista negra entrará em vigor após a reinicialização. A instalação continuará, mas será necessária uma reinicialização.", "Could not unmount": "Não foi possível desmontar", @@ -1628,7 +1628,7 @@ "Failed to create directory on host:": "Falha ao criar diretório no host:", "Failed to create directory:": "Falha ao criar diretório:", "Failed to create disk": "Falha ao criar disco", - "Failed to create encryption key. Backup cancelled — fix the underlying issue and retry.": "Falha ao criar chave de criptografia.Backup cancelado — corrija o problema subjacente e tente novamente.", + "Failed to create encryption key. Backup cancelled — fix the underlying issue and retry.": "Falha ao criar chave de criptografia. Backup cancelado — corrija o problema subjacente e tente novamente.", "Failed to create group:": "Falha ao criar grupo:", "Failed to create mount point.": "Falha ao criar ponto de montagem.", "Failed to create mount point:": "Falha ao criar ponto de montagem:", @@ -2354,7 +2354,7 @@ "Kernel panic configuration removed": "Configuração de pânico do kernel removida", "Kernel panic configuration updated and applied": "Configuração de pânico do kernel atualizada e aplicada", "Kernel, modules and boot config": "Kernel, módulos e configuração de inicialização", - "Kernel/boot-tied files (boot config, /etc/systemd/system, initramfs config, apt sources, ZFS state, ...) are NOT copied verbatim to keep the target's boot safe. The operator's own tuning inside them (IOMMU cmdline, VFIO IDs, custom quirks, GRUB timeout, ...) is merged into the target's fresh copies automatically via kernel-agnostic merge.": "arquivos vinculados ao kernel/inicialização (configuração de inicialização, /etc/systemd/system, configuração initramfs, fontes apt, estado ZFS, ...) NÃO são copiados literalmente para manter a inicialização do destino segura.O próprio ajuste do operador dentro deles (cmdline IOMMU, IDs VFIO, peculiaridades personalizadas, tempo limite do GRUB, ...) é mesclado nas novas cópias do destino automaticamente por meio de mesclagem independente do kernel.", + "Kernel/boot-tied files (boot config, /etc/systemd/system, initramfs config, apt sources, ZFS state, ...) are NOT copied verbatim to keep the target's boot safe. The operator's own tuning inside them (IOMMU cmdline, VFIO IDs, custom quirks, GRUB timeout, ...) is merged into the target's fresh copies automatically via kernel-agnostic merge.": "arquivos vinculados ao kernel/inicialização (configuração de inicialização, /etc/systemd/system, configuração initramfs, fontes apt, estado ZFS, ...) NÃO são copiados literalmente para manter a inicialização do destino segura. O próprio ajuste do operador dentro deles (cmdline IOMMU, IDs VFIO, peculiaridades personalizadas, tempo limite do GRUB, ...) é mesclado nas novas cópias do destino automaticamente por meio de mesclagem independente do kernel.", "Keyfile copied": "arquivo-chave copiado", "Keyfile copied to:": "arquivo-chave copiado para:", "Keyfile passphrase": "senha do arquivo-chave", @@ -2860,7 +2860,7 @@ "No Shares Found": "Nenhum compartilhamento encontrado", "No Storage Found": "Nenhum armazenamento encontrado", "No USB drives detected. Enter the mountpoint path manually:": "Nenhuma unidade USB detectada. Insira o caminho do ponto de montagem manualmente:", - "No USB drives mounted by ProxMenux yet. Mount one first to use it as a target.": "Ainda não há unidades USB montadas pelo ProxMenux.Monte um primeiro para usá-lo como alvo.", + "No USB drives mounted by ProxMenux yet. Mount one first to use it as a target.": "Ainda não há unidades USB montadas pelo ProxMenux. Monte um primeiro para usá-lo como alvo.", "No UUP folder found.": "Nenhuma pasta UUP encontrada.", "No VM was selected.": "Nenhuma VM foi selecionada.", "No VMID defined. Cannot apply guest agent config.": "Nenhum VMID definido. Não é possível aplicar a configuração do agente convidado.", @@ -2872,7 +2872,7 @@ "No VirtIO ISO found. Please download one.": "Nenhum ISO do VirtIO encontrado. Por favor baixe um.", "No VirtIO ISO selected. Please choose again.": "Nenhum VirtIO ISO selecionado. Por favor, escolha novamente.", "No Virtual Machines found on this system.": "Nenhuma máquina virtual encontrada neste sistema.", - "No ZFS pools detected. Skipping ZFS ARC optimization.": "Nenhum pool ZFS detectado.Ignorando a otimização do ZFS ARC.", + "No ZFS pools detected. Skipping ZFS ARC optimization.": "Nenhum pool ZFS detectado. Ignorando a otimização do ZFS ARC.", "No ZFS pools detected. Skipping ZFS autotrim.": "Nenhum pool ZFS detectado. Ignorando o ajuste automático do ZFS.", "No accessible": "Não acessível", "No accessible NFS servers found.": "Nenhum servidor NFS acessível encontrado.", @@ -2922,7 +2922,7 @@ "No duplicate repositories found": "Nenhum repositório duplicado encontrado", "No eligible Controller/NVMe devices remain after SR-IOV filtering. Skipping.": "Nenhum dispositivo Controlador/NVMe qualificado permanece após a filtragem SR-IOV. Pulando.", "No eligible controllers remain after SR-IOV filtering.": "Nenhum controlador elegível permanece após a filtragem SR-IOV.", - "No encryption key is stored on this host. Choose how to set one up:": "Nenhuma chave de criptografia é armazenada neste host.Escolha como configurar um:", + "No encryption key is stored on this host. Choose how to set one up:": "Nenhuma chave de criptografia é armazenada neste host. Escolha como configurar um:", "No exportable VM disks were found (CD-ROM/cloud-init are excluded).": "Nenhum disco VM exportável foi encontrado (CD-ROM/cloud-init foram excluídos).", "No exportable disks": "Nenhum disco exportável", "No exports configured.": "Nenhuma exportação configurada.", @@ -2975,7 +2975,7 @@ "No ports configured": "Nenhuma porta configurada", "No privileged containers available in Proxmox.": "Nenhum contêiner privilegiado disponível no Proxmox.", "No pve-enterprise.list present (skipped)": "Nenhum pve-enterprise.list presente (ignorado)", - "No reboot was started. Review the log before retrying:": "Nenhuma reinicialização foi iniciada.Revise o log antes de tentar novamente:", + "No reboot was started. Review the log before retrying:": "Nenhuma reinicialização foi iniciada. Revise o log antes de tentar novamente:", "No recent": "Nenhum recente", "No recent Samba servers found.": "Nenhum servidor Samba recente encontrado.", "No routing information found.": "Nenhuma informação de roteamento encontrada.", @@ -3353,7 +3353,7 @@ "ProxMenux logo applied": "Logotipo ProxMenux aplicado", "ProxMenux only acts as a launcher — once the script starts, control leaves ProxMenux.": "ProxMenux atua apenas como um iniciador – assim que o script é iniciado, o controle sai do ProxMenux.", "ProxMenux saved it locally at:": "ProxMenux salvou localmente em:", - "ProxMenux-managed .link file(s). User-authored .link files were left in place.": "arquivo(s) .link gerenciado(s) pelo ProxMenux.Os arquivos .link de autoria do usuário foram deixados no lugar.", + "ProxMenux-managed .link file(s). User-authored .link files were left in place.": "arquivo(s) .link gerenciado(s) pelo ProxMenux. Os arquivos .link de autoria do usuário foram deixados no lugar.", "Proxmology logo applied": "Logotipo da Proxmologia aplicado", "Proxmox 9 system update allready": "Atualização do sistema Proxmox 9 já", "Proxmox APT repositories configured": "repositórios Proxmox APT configurados", @@ -4339,8 +4339,8 @@ "The active kernel driver is not vfio-pci, but the entry will rebind the GPU to vfio-pci on the next reboot.": "O driver do kernel ativo não é vfio-pci, mas a entrada irá religar a GPU ao vfio-pci na próxima reinicialização.", "The archive could not be extracted.": "O arquivo não pôde ser extraído.", "The archive destination directory is INSIDE one of the paths you are about to back up. Writing the archive there would copy the backup into itself — producing a corrupted archive, or growing without limit until the disk fills up.": "O diretório de destino do arquivo está DENTRO de um dos caminhos dos quais você está prestes a fazer backup. Escrever o arquivo ali copiaria o backup para si mesmo – produzindo um arquivo corrompido ou crescendo sem limites até que o disco ficasse cheio.", - "The backup metadata was compared against this host. The following items will be SKIPPED to keep the boot safe:": "os metadados de backup foram comparados com este host.Os seguintes itens serão IGNORADOS para manter a inicialização segura:", - "The backup was taken on a different PVE or kernel major.minor. These paths will be SKIPPED to keep the boot safe:": "O backup foi feito em um PVE ou kernel major.minor diferente.Esses caminhos serão SKIPPED para manter a inicialização segura:", + "The backup metadata was compared against this host. The following items will be SKIPPED to keep the boot safe:": "os metadados de backup foram comparados com este host. Os seguintes itens serão IGNORADOS para manter a inicialização segura:", + "The backup was taken on a different PVE or kernel major.minor. These paths will be SKIPPED to keep the boot safe:": "O backup foi feito em um PVE ou kernel major.minor diferente. Esses caminhos serão SKIPPED para manter a inicialização segura:", "The compatibility check raised failures that may break the system after restore.": "A verificação de compatibilidade levantou falhas que podem danificar o sistema após a restauração.", "The container is currently stopped. Do you want to start it now to install the package?": "O contêiner está atualmente parado. Deseja iniciá-lo agora para instalar o pacote?", "The container should now start as privileged": "O contêiner agora deve começar como privilegiado", @@ -4353,12 +4353,12 @@ "The filesystem": "O sistema de arquivos", "The following DKMS-managed drivers will now be rebuilt against it so they keep working after reboot:": "Os seguintes drivers gerenciados pelo DKMS agora serão reconstruídos para que continuem funcionando após a reinicialização:", "The following LXC containers have NVIDIA passthrough configured:": "Os seguintes contêineres LXC têm passagem NVIDIA configurada:", - "The following backup paths are kernel-tied and are excluded from the picker to keep the target's boot safe. The operator's own tuning inside these paths (IOMMU cmdline, VFIO IDs, custom quirks) is merged back automatically via kernel-agnostic merge:": "Os seguintes caminhos de backup estão vinculados ao kernel e são excluídos do seletor para manter a inicialização do destino segura.O próprio ajuste do operador dentro desses caminhos (cmdline IOMMU, IDs VFIO, peculiaridades personalizadas) é mesclado automaticamente por meio de mesclagem independente de kernel:", + "The following backup paths are kernel-tied and are excluded from the picker to keep the target's boot safe. The operator's own tuning inside these paths (IOMMU cmdline, VFIO IDs, custom quirks) is merged back automatically via kernel-agnostic merge:": "Os seguintes caminhos de backup estão vinculados ao kernel e são excluídos do seletor para manter a inicialização do destino segura. O próprio ajuste do operador dentro desses caminhos (cmdline IOMMU, IDs VFIO, peculiaridades personalizadas) é mesclado automaticamente por meio de mesclagem independente de kernel:", "The following changes will be applied": "As seguintes alterações serão aplicadas", "The following devices were excluded because they are part of an SR-IOV configuration:": "Os seguintes dispositivos foram excluídos porque fazem parte de uma configuração SR-IOV:", "The following devices were excluded from Controller/NVMe passthrough because they are part of an SR-IOV configuration:": "Os seguintes dispositivos foram excluídos da passagem do Controlador/NVMe porque fazem parte de uma configuração SR-IOV:", "The following drivers could not be rebuilt for the new kernel — run their installer manually after reboot:": "Os seguintes drivers não puderam ser reconstruídos para o novo kernel – execute seu instalador manualmente após a reinicialização:", - "The following entries exist on the host but were NOT in the backup. To make the host EXACTLY match the backup state, they must be removed:": "As seguintes entradas existem no host, mas NÃO estavam no backup.Para fazer com que o host corresponda EXATAMENTE ao estado de backup, eles devem ser removidos:", + "The following entries exist on the host but were NOT in the backup. To make the host EXACTLY match the backup state, they must be removed:": "As seguintes entradas existem no host, mas NÃO estavam no backup. Para fazer com que o host corresponda EXATAMENTE ao estado de backup, eles devem ser removidos:", "The following selected GPU(s) are currently in GPU -> VM mode (vfio-pci):": "As seguintes GPUs selecionadas estão atualmente no modo GPU -> VM (vfio-pci):", "The following selected GPU(s) still have a VFIO passthrough entry in": "As seguintes GPUs selecionadas ainda têm uma entrada de passagem VFIO em", "The following selected device(s) are Physical Functions with active Virtual Functions:": "Os seguintes dispositivos selecionados são funções físicas com funções virtuais ativas:", @@ -4368,7 +4368,7 @@ "The host directory may not be accessible from an unprivileged container.": "O diretório host pode não estar acessível a partir de um contêiner sem privilégios.", "The installation requires a server restart to apply changes. Do you want to restart now?": "A instalação requer a reinicialização do servidor para aplicar as alterações. Quer reiniciar agora?", "The installation/changes require a server restart to apply correctly. Do you want to reboot now?": "A instalação/alterações requerem a reinicialização do servidor para serem aplicadas corretamente. Você quer reiniciar agora?", - "The local envelope is dropped and future backups do not upload anything. Uploaded envelopes already on PBS stay intact and remain recoverable with their original passphrase.": "O envelope local é eliminado e os backups futuros não carregam nada.Os envelopes carregados já no PBS permanecem intactos e podem ser recuperados com sua senha original.", + "The local envelope is dropped and future backups do not upload anything. Uploaded envelopes already on PBS stay intact and remain recoverable with their original passphrase.": "O envelope local é eliminado e os backups futuros não carregam nada. Os envelopes carregados já no PBS permanecem intactos e podem ser recuperados com sua senha original.", "The long test runs directly on the disk hardware.": "O teste longo é executado diretamente no hardware do disco.", "The new SSH key was installed and is now authorized on the server.\nKey file:": "A nova chave SSH foi instalada e agora está autorizada no servidor.\nArquivo chave:", "The new SSH key was pushed to the LXC via 'pct exec' on": "A nova chave SSH foi enviada para o LXC via 'pct exec' em", @@ -4470,7 +4470,7 @@ "This is unexpected since credentials were validated.": "Isto é inesperado, uma vez que as credenciais foram validadas.", "This marks the container as unprivileged": "Isso marca o contêiner como sem privilégios", "This may be normal for a fresh installation": "Isso pode ser normal para uma nova instalação", - "This may take a few minutes. Press OK to proceed.": "Isso pode levar alguns minutos.Pressione OK para continuar.", + "This may take a few minutes. Press OK to proceed.": "Isso pode levar alguns minutos. Pressione OK para continuar.", "This may take a few seconds...": "Isso pode levar alguns segundos...", "This may take several minutes...": "Isso pode levar vários minutos...", "This means Proxmox handles mount lifecycle natively (no manual /etc/fstab needed for NFS/CIFS host storages).": "Isso significa que o Proxmox lida com o ciclo de vida da montagem nativamente (não é necessário /etc/fstab manual para armazenamentos de host NFS/CIFS).", @@ -4492,8 +4492,8 @@ "This script must be run on a Proxmox host.": "Este script deve ser executado em um host Proxmox.", "This script will apply the following optimizations and advanced adjustments to your Proxmox VE server": "Este script aplicará as seguintes otimizações e ajustes avançados ao seu servidor Proxmox VE", "This script will update your Proxmox VE system with advanced options:": "Este script atualizará seu sistema Proxmox VE com opções avançadas:", - "This session is running in the Monitor terminal. Running it from here would cut the connection mid-install and leave the switch in a broken state.": "Esta sessão está sendo executada no terminal Monitor.Executá-lo a partir daqui cortaria a conexão no meio da instalação e deixaria o switch quebrado.", - "This session is running in the Monitor terminal. Updating from here would restart the Monitor service and cut the connection mid-install, leaving the update in a broken state.": "Esta sessão está sendo executada no terminal Monitor.A atualização a partir daqui reiniciaria o serviço Monitor e cortaria a conexão no meio da instalação, deixando a atualização em um estado interrompido.", + "This session is running in the Monitor terminal. Running it from here would cut the connection mid-install and leave the switch in a broken state.": "Esta sessão está sendo executada no terminal Monitor. Executá-lo a partir daqui cortaria a conexão no meio da instalação e deixaria o switch quebrado.", + "This session is running in the Monitor terminal. Updating from here would restart the Monitor service and cut the connection mid-install, leaving the update in a broken state.": "Esta sessão está sendo executada no terminal Monitor. A atualização a partir daqui reiniciaria o serviço Monitor e cortaria a conexão no meio da instalação, deixando a atualização em um estado interrompido.", "This shows the storage type and disk identifier": "Isso mostra o tipo de armazenamento e o identificador do disco", "This state has a high probability of VM startup/reset failures.": "Este estado tem uma alta probabilidade de falhas de inicialização/redefinição da VM.", "This state indicates a high risk of passthrough failure due to": "Este estado indica um alto risco de falha de passagem devido a", @@ -4691,9 +4691,9 @@ "Upload an encrypted copy of the key to PBS so you can recover it on a reinstalled host with just a passphrase?": "Carregar uma cópia criptografada da chave para o PBS para que você possa recuperá-la em um host reinstalado com apenas uma senha?", "Upload key to PBS?": "Carregar chave para PBS?", "Upload to PBS disabled.": "Upload para PBS desativado.", - "Upload to PBS enabled. The envelope is uploaded on every encrypted backup.": "Upload para PBS habilitado.O envelope é carregado em cada backup criptografado.", - "Upload to PBS is currently: no. Pick an action:": "O upload para PBS é atualmente: não.Escolha uma ação:", - "Upload to PBS is currently: yes. Pick an action:": "O upload para PBS é atualmente: sim.Escolha uma ação:", + "Upload to PBS enabled. The envelope is uploaded on every encrypted backup.": "Upload para PBS habilitado. O envelope é carregado em cada backup criptografado.", + "Upload to PBS is currently: no. Pick an action:": "O upload para PBS é atualmente: não. Escolha uma ação:", + "Upload to PBS is currently: yes. Pick an action:": "O upload para PBS é atualmente: sim. Escolha uma ação:", "Upload to PBS: enable, disable or rotate the recovery passphrase": "Carregar para PBS: ativar, desativar ou alternar a senha de recuperação", "Uptime and who is logged in": "Tempo de atividade e quem está logado", "Use \"Check test progress\" to see results.": "Use \"Verificar o progresso do teste\" para ver os resultados.", @@ -4701,7 +4701,7 @@ "Use 'pct restore' / 'qmrestore' to recover their disks from your VM backups.": "Use 'pct restore' / 'qmrestore' para recuperar seus discos de seus backups de VM.", "Use Custom backup and uncheck the conflicting path from the list": "Use backup personalizado e desmarque o caminho conflitante na lista", "Use Default Settings?": "Usar configurações padrão?", - "Use Download first if you want to save a copy of the current key. Continue?": "Use Baixar primeiro se quiser salvar uma cópia da chave atual.Continuar?", + "Use Download first if you want to save a copy of the current key. Continue?": "Use Baixar primeiro se quiser salvar uma cópia da chave atual. Continuar?", "Use SPACE to select, ENTER to confirm": "Use ESPAÇO para selecionar, ENTER para confirmar", "Use SPACE to select/deselect, ENTER to confirm": "Use ESPAÇO para selecionar/desmarcar, ENTER para confirmar", "Use SSH or terminal access (SSH recommended)": "Use SSH ou acesso de terminal (SSH recomendado)", @@ -4814,7 +4814,7 @@ "Verify installations": "Verifique as instalações", "Verify mount:": "Verifique a montagem:", "Verify the conversion:": "Verifique a conversão:", - "Verify the credentials. Switching to manual paste mode so you can finish the setup without re-typing the password.": "verifique as credenciais.Mudando para o modo de colagem manual para que você possa concluir a configuração sem digitar a senha novamente.", + "Verify the credentials. Switching to manual paste mode so you can finish the setup without re-typing the password.": "verifique as credenciais. Mudando para o modo de colagem manual para que você possa concluir a configuração sem digitar a senha novamente.", "Verifying Ceph installation...": "Verificando a instalação do Ceph...", "Verifying Ceph packages availability...": "Verificando a disponibilidade dos pacotes do Ceph...", "Verifying all utilities status": "Verificando o status de todos os utilitários", @@ -4824,7 +4824,7 @@ "Version info not available": "Informações da versão não disponíveis", "Version:": "Versão:", "Version: Auto-negotiation (NFSv3/NFSv4)": "Versão: Negociação automática (NFSv3/NFSv4)", - "Versions shown belong to maintained NVIDIA branches that list your GPU PCI ID. DKMS compilation is the final validation against the running kernel. The recommended version keeps the current branch, or uses the NVIDIA Production Branch on a fresh install.": "As versões mostradas pertencem a filiais NVIDIA mantidas que listam seu ID PCI de GPU.A compilação DKMS é a validação final em relação ao kernel em execução.A versão recomendada mantém a ramificação atual ou usa a ramificação de produção NVIDIA em uma nova instalação.", + "Versions shown belong to maintained NVIDIA branches that list your GPU PCI ID. DKMS compilation is the final validation against the running kernel. The recommended version keeps the current branch, or uses the NVIDIA Production Branch on a fresh install.": "As versões mostradas pertencem a filiais NVIDIA mantidas que listam seu ID PCI de GPU.A compilação DKMS é a validação final em relação ao kernel em execução. A versão recomendada mantém a ramificação atual ou usa a ramificação de produção NVIDIA em uma nova instalação.", "View CIFS Mounts (pvesm + fstab)": "Ver montagens CIFS (pvesm + fstab)", "View Current Exports": "Ver exportações atuais", "View Current Mounts": "Ver montagens atuais", @@ -5018,7 +5018,7 @@ "blocking issue(s).": "problema(s) de bloqueio.", "btrfs — Proxmox dir storage (snapshots, compression)": "btrfs — Armazenamento de diretório Proxmox (instantâneos, compactação)", "btrfs — snapshots and compression": "btrfs — instantâneos e compactação", - "but it does not match the one used to create the backup. Replace it with the correct keyfile from the source host and retry.": "Mensagem técnica para Proxmox e TI.Traduza: mas não corresponde ao usado para criar o backup.Substitua-o pelo arquivo-chave correto do host de origem e tente novamente.", + "but it does not match the one used to create the backup. Replace it with the correct keyfile from the source host and retry.": "Mensagem técnica para Proxmox e TI.Traduza: mas não corresponde ao usado para criar o backup. Substitua-o pelo arquivo-chave correto do host de origem e tente novamente.", "bytes": "bytes", "can write to": "pode escrever para", "chmod 1777 + setfacl o::rwx (applied on the NFS share from this host)": "chmod 1777 + setfacl o::rwx (aplicado no compartilhamento NFS deste host)", diff --git a/lang/sk.json b/lang/sk.json index 5935e887..9acee117 100644 --- a/lang/sk.json +++ b/lang/sk.json @@ -456,7 +456,7 @@ "Cannot proceed with invalid export path.": "Nedá sa pokračovať s neplatnou cestou exportu.", "Cannot proceed with invalid share name.": "Nedá sa pokračovať s neplatným názvom zdieľania.", "Cannot reach Proxmox repositories": "Repozitáre Proxmoxu nie sú dostupné", - "Cannot reach download.proxmox.com. Check network, proxy or DNS.": "Nedá sa dosiahnuť download.proxmox.com.Skontrolujte sieť, proxy alebo DNS.", + "Cannot reach download.proxmox.com. Check network, proxy or DNS.": "Nedá sa dosiahnuť download.proxmox.com. Skontrolujte sieť, proxy alebo DNS.", "Cannot reach portal:": "Portál nie je dostupný:", "Cannot reach server": "Server nie je dostupný", "Cannot validate credentials - no shares available for testing.": "Prihlasovacie údaje sa nedajú overiť - nie sú dostupné žiadne zdieľania na test.", @@ -599,7 +599,7 @@ "Cleaning up unused time synchronization services...": "Čistím nepoužívané služby synchronizácie času...", "Cleans duplicate or conflicting sources": "Vyčistí duplicitné alebo konfliktné zdroje", "Cleanup Complete": "Čistenie je dokončené", - "Cleanup completed. A reboot is recommended to fully apply pending kernel package configurations.": "Čistenie dokončené.Na úplné uplatnenie čakajúcich konfigurácií balíkov jadra sa odporúča reštart.", + "Cleanup completed. A reboot is recommended to fully apply pending kernel package configurations.": "Čistenie dokončené. Na úplné uplatnenie čakajúcich konfigurácií balíkov jadra sa odporúča reštart.", "Cleanup finished": "Čistenie je dokončené", "Cleanup legacy gasket-dkms": "Vyčistiť starší balík gasket-dkms", "Cleanup partial VM?": "Vyčistiť čiastočne vytvorenú VM?", @@ -898,7 +898,7 @@ "Could not detect the CIFS mount for this directory. Try accessing it manually.": "Pre tento priečinok sa nepodarilo zistiť CIFS mount. Skúste ho otvoriť ručne.", "Could not determine a valid ISO storage directory.": "Nepodarilo sa určiť platný priečinok pre ISO úložisko.", "Could not determine disk path for:": "Nepodarilo sa zistiť cestu k disku pre:", - "Could not determine filesystem signature types. Aborting.": "Nepodarilo sa určiť typy podpisov súborových systémov.Prerušuje sa.", + "Could not determine filesystem signature types. Aborting.": "Nepodarilo sa určiť typy podpisov súborových systémov. Prerušuje sa.", "Could not determine the IOMMU group for the selected GPU.": "Nepodarilo sa zistiť IOMMU skupinu pre vybranú GPU.", "Could not download recovery blob from PBS.": "Nepodarilo sa stiahnuť obnovovací balíček z PBS.", "Could not download the installer.": "Inštalátor sa nepodarilo stiahnuť.", @@ -920,8 +920,8 @@ "Could not mount": "Nepodarilo sa pripojiť", "Could not mount ISO on device": "ISO sa nepodarilo pripojiť k zariadeniu", "Could not parse OVF file, or no disk image references found.": "OVF súbor sa nepodarilo spracovať alebo neobsahuje odkazy na diskové obrazy.", - "Could not prepare on-boot restore service. Nothing new was scheduled.": "Nepodarilo sa pripraviť službu obnovenia pri spustení.Nič nové nebolo naplánované.", - "Could not publish pending restore. Previous pending restore was kept.": "Nepodarilo sa zverejniť čakajúce obnovenie.Predchádzajúce čakajúce obnovenie bolo zachované.", + "Could not prepare on-boot restore service. Nothing new was scheduled.": "Nepodarilo sa pripraviť službu obnovenia pri spustení. Nič nové nebolo naplánované.", + "Could not publish pending restore. Previous pending restore was kept.": "Nepodarilo sa zverejniť čakajúce obnovenie. Predchádzajúce čakajúce obnovenie bolo zachované.", "Could not push the key. Check the password and that": "Kľúč sa nepodarilo odoslať. Skontrolujte heslo a to, že", "Could not read SMART data from": "SMART dáta sa nepodarilo prečítať z", "Could not read VM configuration.": "Nastavenie VM sa nepodarilo prečítať.", @@ -935,7 +935,7 @@ "Could not set VM virtual display to vga: std": "Virtuálne zobrazenie VM sa nepodarilo nastaviť na vga: std", "Could not set boot order for": "Poradie bootovania sa nepodarilo nastaviť pre", "Could not stage pending restore path:": "Nepodarilo sa pripraviť cestu obnovenia:", - "Could not stage pending restore. Nothing new was scheduled.": "Nepodarilo sa pripraviť čakajúce obnovenie.Nič nové nebolo naplánované.", + "Could not stage pending restore. Nothing new was scheduled.": "Nepodarilo sa pripraviť čakajúce obnovenie. Nič nové nebolo naplánované.", "Could not stop LXC": "LXC sa nepodarilo zastaviť", "Could not unload nouveau module (may be in use). The blacklist will take effect after reboot. Installation will continue but a reboot will be required.": "Modul nouveau sa nepodarilo uvoľniť (možno sa práve používa). Blacklist sa prejaví po reštarte. Inštalácia bude pokračovať, ale reštart bude potrebný.", "Could not unmount": "Nepodarilo sa odpojiť", @@ -2975,7 +2975,7 @@ "No ports configured": "Nie sú nastavené žiadne porty", "No privileged containers available in Proxmox.": "V Proxmoxe nie sú dostupné žiadne privilegované kontajnery.", "No pve-enterprise.list present (skipped)": "pve-enterprise.list neexistuje (preskočené)", - "No reboot was started. Review the log before retrying:": "Nebol spustený žiadny reštart.Pred opätovným pokusom skontrolujte denník:", + "No reboot was started. Review the log before retrying:": "Nebol spustený žiadny reštart. Pred opätovným pokusom skontrolujte denník:", "No recent": "Žiadne nedávne", "No recent Samba servers found.": "Nenašli sa žiadne nedávne Samba servery.", "No routing information found.": "Nenašli sa žiadne informácie o smerovaní.", @@ -4824,7 +4824,7 @@ "Version info not available": "Informácie o verzii nie sú dostupné", "Version:": "Verzia:", "Version: Auto-negotiation (NFSv3/NFSv4)": "Verzia: automatické dohodnutie (NFSv3/NFSv4)", - "Versions shown belong to maintained NVIDIA branches that list your GPU PCI ID. DKMS compilation is the final validation against the running kernel. The recommended version keeps the current branch, or uses the NVIDIA Production Branch on a fresh install.": "Zobrazené verzie patria do udržiavaných pobočiek NVIDIA, ktoré uvádzajú vaše ID PCI GPU.Kompilácia DKMS je konečná validácia voči bežiacemu jadru.Odporúčaná verzia ponecháva aktuálnu vetvu alebo používa NVIDIA Production Branch pri novej inštalácii.", + "Versions shown belong to maintained NVIDIA branches that list your GPU PCI ID. DKMS compilation is the final validation against the running kernel. The recommended version keeps the current branch, or uses the NVIDIA Production Branch on a fresh install.": "Zobrazené verzie patria do udržiavaných pobočiek NVIDIA, ktoré uvádzajú vaše ID PCI GPU.Kompilácia DKMS je konečná validácia voči bežiacemu jadru. Odporúčaná verzia ponecháva aktuálnu vetvu alebo používa NVIDIA Production Branch pri novej inštalácii.", "View CIFS Mounts (pvesm + fstab)": "Zobraziť CIFS mounty (pvesm + fstab)", "View Current Exports": "Zobraziť aktuálne exporty", "View Current Mounts": "Zobraziť aktuálne pripojenia", diff --git a/lang/sv.json b/lang/sv.json index 1274ea89..c237b8c8 100644 --- a/lang/sv.json +++ b/lang/sv.json @@ -456,7 +456,7 @@ "Cannot proceed with invalid export path.": "Kan inte fortsätta med ogiltig exportsökväg.", "Cannot proceed with invalid share name.": "Kan inte fortsätta med ogiltigt delningsnamn.", "Cannot reach Proxmox repositories": "Kan inte nå Proxmox-förråd", - "Cannot reach download.proxmox.com. Check network, proxy or DNS.": "Kan inte nå download.proxmox.com.Kontrollera nätverk, proxy eller DNS.", + "Cannot reach download.proxmox.com. Check network, proxy or DNS.": "Kan inte nå download.proxmox.com. Kontrollera nätverk, proxy eller DNS.", "Cannot reach portal:": "Kan inte nå portalen:", "Cannot reach server": "Kan inte nå servern", "Cannot validate credentials - no shares available for testing.": "Kan inte validera autentiseringsuppgifter - inga delningar tillgängliga för testning.", @@ -599,7 +599,7 @@ "Cleaning up unused time synchronization services...": "Rensar oanvända tidssynkroniseringstjänster...", "Cleans duplicate or conflicting sources": "Rensar dubbletter eller motstridiga källor", "Cleanup Complete": "Rensning klar", - "Cleanup completed. A reboot is recommended to fully apply pending kernel package configurations.": "Rengöring klar.En omstart rekommenderas för att helt tillämpa väntande kärnpaketkonfigurationer.", + "Cleanup completed. A reboot is recommended to fully apply pending kernel package configurations.": "Rengöring klar. En omstart rekommenderas för att helt tillämpa väntande kärnpaketkonfigurationer.", "Cleanup finished": "Rensning avslutad", "Cleanup legacy gasket-dkms": "Cleanup legacy gasket-dkms", "Cleanup partial VM?": "Rensa delvis VM?", @@ -898,7 +898,7 @@ "Could not detect the CIFS mount for this directory. Try accessing it manually.": "Kunde inte detektera CIFS-monteringen för den här katalogen. Försök att komma åt det manuellt.", "Could not determine a valid ISO storage directory.": "Det gick inte att fastställa en giltig ISO-lagringskatalog.", "Could not determine disk path for:": "Kunde inte bestämma disksökväg för:", - "Could not determine filesystem signature types. Aborting.": "Kunde inte fastställa filsystemsignaturtyper.Avbryter.", + "Could not determine filesystem signature types. Aborting.": "Kunde inte fastställa filsystemsignaturtyper. Avbryter.", "Could not determine the IOMMU group for the selected GPU.": "Det gick inte att fastställa IOMMU-gruppen för den valda GPU:n.", "Could not download recovery blob from PBS.": "Det gick inte att ladda ned återställningsblobb från PBS.", "Could not download the installer.": "Det gick inte att ladda ner installationsprogrammet.", @@ -920,8 +920,8 @@ "Could not mount": "Kunde inte montera", "Could not mount ISO on device": "Det gick inte att montera ISO på enheten", "Could not parse OVF file, or no disk image references found.": "Det gick inte att analysera OVF-filen eller så hittades inga referenser till diskbilden.", - "Could not prepare on-boot restore service. Nothing new was scheduled.": "Kunde inte förbereda återställningstjänst vid uppstart.Inget nytt var inplanerat.", - "Could not publish pending restore. Previous pending restore was kept.": "Kunde inte publicera väntande återställning.Tidigare pågående återställning behölls.", + "Could not prepare on-boot restore service. Nothing new was scheduled.": "Kunde inte förbereda återställningstjänst vid uppstart. Inget nytt var inplanerat.", + "Could not publish pending restore. Previous pending restore was kept.": "Kunde inte publicera väntande återställning. Tidigare pågående återställning behölls.", "Could not push the key. Check the password and that": "Kunde inte trycka på nyckeln. Kolla lösenordet och så", "Could not read SMART data from": "Det gick inte att läsa SMART-data från", "Could not read VM configuration.": "Det gick inte att läsa VM-konfigurationen.", @@ -935,7 +935,7 @@ "Could not set VM virtual display to vga: std": "Det gick inte att ställa in virtuell skärm på vga: std", "Could not set boot order for": "Det gick inte att ställa in startordning för", "Could not stage pending restore path:": "Kunde inte scenen väntande återställningssökväg:", - "Could not stage pending restore. Nothing new was scheduled.": "Kunde inte scenen väntande återställning.Inget nytt var inplanerat.", + "Could not stage pending restore. Nothing new was scheduled.": "Kunde inte scenen väntande återställning. Inget nytt var inplanerat.", "Could not stop LXC": "Kunde inte stoppa LXC", "Could not unload nouveau module (may be in use). The blacklist will take effect after reboot. Installation will continue but a reboot will be required.": "Kunde inte ladda ner nouveau-modulen (kan vara i bruk). Svartlistan träder i kraft efter omstart. Installationen kommer att fortsätta men en omstart kommer att krävas.", "Could not unmount": "Det gick inte att avmontera", @@ -2975,7 +2975,7 @@ "No ports configured": "Inga portar konfigurerade", "No privileged containers available in Proxmox.": "Inga privilegierade behållare tillgängliga i Proxmox.", "No pve-enterprise.list present (skipped)": "Ingen pve-enterprise.list närvarande (hoppade över)", - "No reboot was started. Review the log before retrying:": "Ingen omstart startades.Granska loggen innan du försöker igen:", + "No reboot was started. Review the log before retrying:": "Ingen omstart startades. Granska loggen innan du försöker igen:", "No recent": "Inga nya", "No recent Samba servers found.": "Inga nya Samba-servrar hittades.", "No routing information found.": "Ingen ruttinformation hittades.", @@ -4824,7 +4824,7 @@ "Version info not available": "Versionsinformation är inte tillgänglig", "Version:": "Version:", "Version: Auto-negotiation (NFSv3/NFSv4)": "Version: Auto-negotiation (NFSv3/NFSv4)", - "Versions shown belong to maintained NVIDIA branches that list your GPU PCI ID. DKMS compilation is the final validation against the running kernel. The recommended version keeps the current branch, or uses the NVIDIA Production Branch on a fresh install.": "De visade versionerna tillhör underhållna NVIDIA-grenar som listar ditt GPU PCI-ID.DKMS kompilering är den slutliga valideringen mot den körande kärnan.Den rekommenderade versionen behåller den aktuella grenen eller använder NVIDIA Production Branch på en nyinstallation.", + "Versions shown belong to maintained NVIDIA branches that list your GPU PCI ID. DKMS compilation is the final validation against the running kernel. The recommended version keeps the current branch, or uses the NVIDIA Production Branch on a fresh install.": "De visade versionerna tillhör underhållna NVIDIA-grenar som listar ditt GPU PCI-ID.DKMS kompilering är den slutliga valideringen mot den körande kärnan. Den rekommenderade versionen behåller den aktuella grenen eller använder NVIDIA Production Branch på en nyinstallation.", "View CIFS Mounts (pvesm + fstab)": "Visa CIFS-fästen (pvesm + fstab)", "View Current Exports": "Visa aktuell export", "View Current Mounts": "Visa aktuella monteringar", diff --git a/scripts/emergency_repair.sh b/scripts/emergency_repair.sh index 798b6e17..1cbc2fb2 100644 --- a/scripts/emergency_repair.sh +++ b/scripts/emergency_repair.sh @@ -22,6 +22,9 @@ BACKUP_DIR="/var/backups/proxmenux" if [[ -f "$UTILS_FILE" ]]; then source "$UTILS_FILE" fi +if [[ -f "$BASE_DIR/scripts/global/pmx_journal.sh" ]]; then + source "$BASE_DIR/scripts/global/pmx_journal.sh" +fi load_language initialize_cache @@ -328,6 +331,8 @@ analyze_bridge_configuration() { } guided_bridge_repair() { + local FUNC_VERSION="1.0" + pmx_journal_context "guided_bridge_repair" "$FUNC_VERSION" local step=1 local total_steps=5 @@ -420,7 +425,7 @@ guided_bridge_repair() { # Apply the change if [ "$new_ports" != "$current_ports" ]; then - sed -i "/iface $bridge/,/bridge-ports/ s/bridge-ports.*/bridge-ports $new_ports/" /etc/network/interfaces + pmx_edit_file /etc/network/interfaces "/iface $bridge/,/bridge-ports/ s/bridge-ports.*/bridge-ports $new_ports/" fi fi done @@ -567,6 +572,8 @@ analyze_network_configuration() { } guided_configuration_cleanup() { + local FUNC_VERSION="1.0" + pmx_journal_context "guided_configuration_cleanup" "$FUNC_VERSION" local step=1 local total_steps=5 @@ -645,7 +652,7 @@ guided_configuration_cleanup() { --infobox "$(translate "Removing invalid configurations...")\n\n$(translate "This may take a few seconds...")" 8 50 for iface in $interfaces_to_remove; do - sed -i "/^iface $iface/,/^$/d" /etc/network/interfaces + pmx_edit_file /etc/network/interfaces "/^iface $iface/,/^$/d" done ((step++)) diff --git a/scripts/global/common-functions.sh b/scripts/global/common-functions.sh index f024886b..6f334b13 100644 --- a/scripts/global/common-functions.sh +++ b/scripts/global/common-functions.sh @@ -12,6 +12,9 @@ TOOLS_JSON="/usr/local/share/proxmenux/installed_tools.json" if [[ -f "$UTILS_FILE" ]]; then source "$UTILS_FILE" fi +if [[ -f "$LOCAL_SCRIPTS/global/pmx_journal.sh" ]]; then + source "$LOCAL_SCRIPTS/global/pmx_journal.sh" +fi load_language initialize_cache @@ -84,6 +87,8 @@ lvm_repair_check() { cleanup_duplicate_repos_pve9() { + local FUNC_VERSION="1.0" + pmx_journal_context "cleanup_duplicate_repos_pve9" "$FUNC_VERSION" msg_info "$(translate "Cleaning up duplicate repositories...")" local sources_file="/etc/apt/sources.list" @@ -152,7 +157,8 @@ cleanup_duplicate_repos_pve9() { if [[ "$file_changed" -eq 1 ]]; then _backup_once "$sources_file" - mv "$temp_file" "$sources_file" + pmx_write_file "$sources_file" < "$temp_file" + rm -f "$temp_file" chmod 644 "$sources_file" else rm -f "$temp_file" @@ -201,7 +207,7 @@ cleanup_duplicate_repos_pve9() { esc_uri=$(printf '%s' "$uri" | sed 's/[][\.^$*/]/\\&/g') esc_suite=$(printf '%s' "$suite" | sed 's/[][\.^$*/]/\\&/g') esc_comp=$(printf '%s' "$first_comp" | sed 's/[][\.^$*/]/\\&/g') - sed -i -E "/^deb[[:space:]]+${esc_uri}[[:space:]]+${esc_suite}[[:space:]]+.*(^| )${esc_comp}( |$)/s/^/# /" "$target_file" + pmx_edit_file "$target_file" -E "/^deb[[:space:]]+${esc_uri}[[:space:]]+${esc_suite}[[:space:]]+.*(^| )${esc_comp}( |$)/s/^/# /" cleaned_count=$((cleaned_count + 1)) fi } @@ -240,7 +246,7 @@ cleanup_duplicate_repos_pve9() { for old_file in /etc/apt/sources.list.d/pve-public-repo.list /etc/apt/sources.list.d/pve-install-repo.list; do if [ -f "$old_file" ]; then _backup_once "$old_file" - rm -f "$old_file" + pmx_remove_file "$old_file" cleaned_count=$((cleaned_count + 1)) fi done @@ -248,6 +254,7 @@ cleanup_duplicate_repos_pve9() { if [ $cleaned_count -gt 0 ]; then msg_ok "$(translate "Cleaned up $cleaned_count duplicate/old repositories")" + pmx_record_execution "Update package lists after repository cleanup" "apt-get update" apt-get update > /dev/null 2>&1 || true else msg_ok "$(translate "No duplicate repositories found")" @@ -257,6 +264,8 @@ cleanup_duplicate_repos_pve9() { cleanup_duplicate_repos_pve9_() { + local FUNC_VERSION="1.0" + pmx_journal_context "cleanup_duplicate_repos_pve9_" "$FUNC_VERSION" msg_info "$(translate "Cleaning up duplicate repositories...")" local sources_file="/etc/apt/sources.list" @@ -285,7 +294,8 @@ cleanup_duplicate_repos_pve9_() { fi done < "$sources_file" - mv "$temp_file" "$sources_file" + pmx_write_file "$sources_file" < "$temp_file" + rm -f "$temp_file" chmod 644 "$sources_file" for src in proxmox debian ceph; do @@ -308,7 +318,7 @@ cleanup_duplicate_repos_pve9_() { if [[ -n "$url_match" ]]; then if grep -q "^deb.*$url_match" "$sources_file"; then - sed -i "/^deb.*$url_match/s/^/# /" "$sources_file" + pmx_edit_file "$sources_file" "/^deb.*$url_match/s/^/# /" cleaned_count=$((cleaned_count + 1)) fi fi @@ -316,7 +326,7 @@ cleanup_duplicate_repos_pve9_() { for list_file in /etc/apt/sources.list.d/*.list; do [[ -f "$list_file" ]] || continue if grep -q "^deb.*$url_match" "$list_file"; then - sed -i "/^deb.*$url_match/s/^/# /" "$list_file" + pmx_edit_file "$list_file" "/^deb.*$url_match/s/^/# /" cleaned_count=$((cleaned_count + 1)) fi done @@ -325,6 +335,7 @@ cleanup_duplicate_repos_pve9_() { if [ $cleaned_count -gt 0 ]; then msg_ok "$(translate "Cleaned up $cleaned_count duplicate/old repositories")" + pmx_record_execution "Update package lists after repository cleanup" "apt-get update" apt-get update > /dev/null 2>&1 || true else msg_ok "$(translate "No duplicate repositories found")" diff --git a/scripts/global/pci_passthrough_helpers.sh b/scripts/global/pci_passthrough_helpers.sh index e1a05a76..3ef69779 100644 --- a/scripts/global/pci_passthrough_helpers.sh +++ b/scripts/global/pci_passthrough_helpers.sh @@ -5,6 +5,10 @@ if [[ -n "${__PROXMENUX_PCI_PASSTHROUGH_HELPERS__:-}" ]]; then fi __PROXMENUX_PCI_PASSTHROUGH_HELPERS__=1 +if [[ -f /usr/local/share/proxmenux/scripts/global/pmx_journal.sh ]]; then + source /usr/local/share/proxmenux/scripts/global/pmx_journal.sh +fi + function _pci_is_iommu_active() { grep -qE 'intel_iommu=on|amd_iommu=on' /proc/cmdline 2>/dev/null || return 1 [[ -d /sys/kernel/iommu_groups ]] || return 1 @@ -497,6 +501,8 @@ _proxmenux_vfio_bind_add_bdfs() { } _proxmenux_vfio_bind_remove_bdfs() { + local FUNC_VERSION="1.0" + pmx_journal_context "_proxmenux_vfio_bind_remove_bdfs" "$FUNC_VERSION" # Args: any number of BDFs to remove from the binder list [[ -f "$PROXMENUX_VFIO_BIND_STATE" ]] || return 0 _proxmenux_vfio_bind_cleanup_legacy @@ -511,13 +517,14 @@ _proxmenux_vfio_bind_remove_bdfs() { else normalized="0000:${bdf}" fi - sed -i "\|^${normalized}\$|d" "$tmp" + sed "\|^${normalized}\$|d" "$tmp" > "${tmp}.next" && mv "${tmp}.next" "$tmp" done if ! cmp -s "$tmp" "$PROXMENUX_VFIO_BIND_STATE"; then - mv "$tmp" "$PROXMENUX_VFIO_BIND_STATE" + pmx_write_file "$PROXMENUX_VFIO_BIND_STATE" < "$tmp" + rm -f "$tmp" _proxmenux_vfio_bind_write_udev_rule # If empty, remove state file too (keeps host clean) - [[ ! -s "$PROXMENUX_VFIO_BIND_STATE" ]] && rm -f "$PROXMENUX_VFIO_BIND_STATE" + [[ ! -s "$PROXMENUX_VFIO_BIND_STATE" ]] && pmx_remove_file "$PROXMENUX_VFIO_BIND_STATE" _proxmenux_nvidia_vfio_policy_sync || true _proxmenux_mark_host_config_changed else @@ -598,9 +605,11 @@ EOF } _proxmenux_nvidia_vfio_softdeps_sync() { + local FUNC_VERSION="1.0" + pmx_journal_context "_proxmenux_nvidia_vfio_softdeps_sync" "$FUNC_VERSION" local changed=1 mkdir -p "$(dirname "$PROXMENUX_VFIO_CONF")" - touch "$PROXMENUX_VFIO_CONF" + [[ -f "$PROXMENUX_VFIO_CONF" ]] || pmx_write_file "$PROXMENUX_VFIO_CONF" < /dev/null local -a softdeps=( "softdep nvidia pre: vfio-pci" @@ -612,14 +621,14 @@ _proxmenux_nvidia_vfio_softdeps_sync() { if _proxmenux_vfio_bind_state_has_vendor "10de"; then for line in "${softdeps[@]}"; do if ! grep -qFx "$line" "$PROXMENUX_VFIO_CONF" 2>/dev/null; then - echo "$line" >> "$PROXMENUX_VFIO_CONF" + echo "$line" | pmx_append_file "$PROXMENUX_VFIO_CONF" changed=0 fi done else for line in "${softdeps[@]}"; do if grep -qFx "$line" "$PROXMENUX_VFIO_CONF" 2>/dev/null; then - sed -i "\|^${line}$|d" "$PROXMENUX_VFIO_CONF" + pmx_edit_file "$PROXMENUX_VFIO_CONF" "\|^${line}$|d" changed=0 fi done @@ -779,6 +788,8 @@ _proxmenux_vfio_bind_migrate_legacy_nvidia_ids() { # passed through. # ────────────────────────────────────────────────────────────────────── _proxmenux_nvidia_migrate_legacy_blacklist() { + local FUNC_VERSION="1.0" + pmx_journal_context "_proxmenux_nvidia_migrate_legacy_blacklist" "$FUNC_VERSION" local changed=false local blacklist_file="${PROXMENUX_ETC_ROOT}/modprobe.d/blacklist.conf" local nvidia_blacklist="${PROXMENUX_ETC_ROOT}/modprobe.d/nvidia-blacklist.conf" @@ -788,29 +799,37 @@ _proxmenux_nvidia_migrate_legacy_blacklist() { local modules_load_active="${PROXMENUX_ETC_ROOT}/modules-load.d/nvidia-vfio.conf" if [[ -f "$blacklist_file" ]] && grep -qE '^blacklist (nvidia|nvidia_drm|nvidia_modeset|nvidia_uvm|nvidiafb)$' "$blacklist_file"; then - sed -i \ + pmx_edit_file "$blacklist_file" \ -e '/^blacklist nvidia$/d' \ -e '/^blacklist nvidia_drm$/d' \ -e '/^blacklist nvidia_modeset$/d' \ -e '/^blacklist nvidia_uvm$/d' \ - -e '/^blacklist nvidiafb$/d' \ - "$blacklist_file" + -e '/^blacklist nvidiafb$/d' changed=true fi if [[ -f "$nvidia_blacklist" ]]; then - rm -f "$nvidia_blacklist" + pmx_remove_file "$nvidia_blacklist" changed=true fi if [[ -f "$udev_disabled" ]]; then - mv "$udev_disabled" "$udev_rules" >/dev/null 2>&1 || true + if pmx_write_file "$udev_rules" < "$udev_disabled"; then + chmod --reference="$udev_disabled" "$udev_rules" 2>/dev/null || true + chown --reference="$udev_disabled" "$udev_rules" 2>/dev/null || true + pmx_remove_file "$udev_disabled" || true + fi + pmx_record_execution "Reload udev rules" "udevadm control --reload-rules" udevadm control --reload-rules >/dev/null 2>&1 || true changed=true fi if [[ -f "$modules_load_disabled" ]]; then - mv "$modules_load_disabled" "$modules_load_active" >/dev/null 2>&1 || true + if pmx_write_file "$modules_load_active" < "$modules_load_disabled"; then + chmod --reference="$modules_load_disabled" "$modules_load_active" 2>/dev/null || true + chown --reference="$modules_load_disabled" "$modules_load_active" 2>/dev/null || true + pmx_remove_file "$modules_load_disabled" || true + fi changed=true fi diff --git a/scripts/global/pmx_journal.sh b/scripts/global/pmx_journal.sh new file mode 100644 index 00000000..caf08b62 --- /dev/null +++ b/scripts/global/pmx_journal.sh @@ -0,0 +1,416 @@ +#!/usr/bin/env bash +# ProxMenux change journal — recording side. +# +# What a sysadmin holds against a tool like this one is not that it +# changes things: it is that afterwards nobody can say what it changed. +# Reading the script does not answer it either — a function of four +# hundred lines may alter two values, and the reader has no way to know +# which two. +# +# So the rule here is that a change is recorded because it could not be +# made any other way. These helpers are the writing path: they capture +# what was there, make the change, and record both. A function that uses +# them is auditable without its author having remembered anything, and a +# function that writes directly is a bug we can find by grepping. +# +# Nothing here needs sqlite, python or network access. Each entry is one +# small JSON file written whole into a spool directory, which the Monitor +# reads and consolidates. One file per entry means no two concurrent +# scripts can interleave a line, and an interrupted write leaves a file +# the reader skips rather than a corrupted log. +# +# Usage: +# source /usr/local/share/proxmenux/scripts/pmx_journal.sh +# pmx_journal_context "optimize_logrotate" "1.1" +# pmx_write_file /etc/logrotate.conf </dev/null || return 1 + chmod 700 "$PMX_JOURNAL_ROOT" 2>/dev/null || true + return 0 +} + +# JSON string escaping in pure bash: no jq dependency on the recording +# side, because the recording side runs before anything is installed. +_pmx_json_escape() { + local text="$1" + text="${text//\\/\\\\}" + text="${text//\"/\\\"}" + text="${text//$'\n'/\\n}" + text="${text//$'\r'/\\r}" + text="${text//$'\t'/\\t}" + printf '%s' "$text" +} + +# The largest file whose contents are worth keeping. Configuration is +# measured in kilobytes; a binary is measured in megabytes and shows no +# useful difference, so past this the journal records that the file was +# there and what it hashed to, and stops short of copying it. A host that +# fills its disk with captured binaries is a worse outcome than a change +# whose contents cannot be shown. +PMX_JOURNAL_MAX_OBJECT="${PMX_JOURNAL_MAX_OBJECT:-1048576}" + +# Set by _pmx_store_object. Reported through globals rather than printed +# because a command substitution runs in a subshell: anything the helper +# set there would be lost on the way back, and the caller would record +# every capture as unrecoverable. +PMX_LAST_DIGEST="" +PMX_LAST_OBJECT_STORED=false + +# Stores a file's contents and returns its digest, so an entry references +# the bytes rather than embedding them. Content is kept once however many +# times it is captured. +_pmx_store_object() { + local path="$1" + PMX_LAST_DIGEST="" + PMX_LAST_OBJECT_STORED=false + [ -f "$path" ] || return 1 + local digest + digest="$(sha256sum "$path" 2>/dev/null | cut -d' ' -f1)" || return 1 + [ -n "$digest" ] || return 1 + PMX_LAST_DIGEST="$digest" + + local size + size="$(stat -c %s "$path" 2>/dev/null || echo 0)" + if [ "$size" -gt "$PMX_JOURNAL_MAX_OBJECT" ] 2>/dev/null; then + # The digest still identifies what was there; the bytes are not + # kept, and the entry will say the change cannot be undone from + # the journal alone. + return 0 + fi + + local target="$PMX_JOURNAL_OBJECTS/${digest:0:2}/$digest" + if [ ! -f "$target" ]; then + mkdir -p "$(dirname "$target")" 2>/dev/null || return 1 + cp "$path" "$target.tmp.$$" 2>/dev/null || return 1 + chmod 600 "$target.tmp.$$" 2>/dev/null || true + mv "$target.tmp.$$" "$target" 2>/dev/null || return 1 + fi + PMX_LAST_OBJECT_STORED=true +} + +# Writes one entry. Callers pass key=value pairs; values are escaped +# here so no caller has to think about JSON. +_pmx_journal_record() { + _pmx_journal_ready || return 0 + local entry="" key value first=1 + for pair in "$@"; do + key="${pair%%=*}" + value="${pair#*=}" + [ "$first" = 1 ] && first=0 || entry+="," + # A key ending in _raw carries a number or a literal such as + # true/false/null and is written unquoted. + if [ "${key%_raw}" != "$key" ]; then + entry+="\"${key%_raw}\":${value}" + else + entry+="\"$key\":\"$(_pmx_json_escape "$value")\"" + fi + done + local file + file="$PMX_JOURNAL_SPOOL/$(date +%s)-$$-${RANDOM}.json" + printf '{%s}\n' "$entry" > "$file.tmp" 2>/dev/null || return 0 + chmod 600 "$file.tmp" 2>/dev/null || true + mv "$file.tmp" "$file" 2>/dev/null || true + return 0 +} + +_pmx_journal_common() { + printf '%s\n' \ + "recorded_at_raw=$(date +%s)" \ + "function=${PMX_JOURNAL_FUNCTION:-unknown}" \ + "function_version=${PMX_JOURNAL_VERSION:-}" \ + "source=${PMX_JOURNAL_SOURCE:-unknown}" +} + +# --------------------------------------------------------------------- +# Configuration: files this host had, and what they became +# --------------------------------------------------------------------- + +# Replaces a file with what arrives on stdin, capturing what was there. +# +# pmx_write_file /etc/logrotate.conf < "$path"; return $?; } + cat > "$temp" + + local kept="true" + if [ -f "$path" ]; then + existed="true" + _pmx_store_object "$path" + before="$PMX_LAST_DIGEST"; kept="$PMX_LAST_OBJECT_STORED" + fi + # The change itself. Permissions of an existing file are preserved by + # writing through it rather than replacing the inode. + if ! cat "$temp" > "$path" 2>/dev/null; then + rm -f "$temp" + return 1 + fi + _pmx_store_object "$path"; after="$PMX_LAST_DIGEST" + rm -f "$temp" + # Writing the same bytes back is not a change. Recording it would + # fill the journal with entries a reader has to open to discover + # nothing happened — which is exactly what re-running an idempotent + # post-install does. + [ "$before" = "$after" ] && return 0 + + local -a fields + mapfile -t fields < <(_pmx_journal_common) + _pmx_journal_record "${fields[@]}" \ + "class=configuration" "operation=write_file" "target=$path" \ + "before=${before:-}" "after=${after:-}" \ + "existed_raw=$existed" \ + "capture=$([ "$existed" = true ] && echo present || echo created)" \ + "revert=$([ "$existed" = true ] && echo restore || echo remove)" \ + "exactness=$([ "$existed" != true ] || [ "$kept" = true ] && echo exact || echo none)" +} + +# Applies a sed expression in place, capturing the file first. +# +# pmx_edit_file /etc/default/grub 's/^X=.*/X=1/' +pmx_edit_file() { + local path="$1"; shift + [ -f "$path" ] || return 1 + local before after kept + _pmx_store_object "$path" + before="$PMX_LAST_DIGEST"; kept="$PMX_LAST_OBJECT_STORED" + sed -i "$@" "$path" || return 1 + _pmx_store_object "$path"; after="$PMX_LAST_DIGEST" + # An expression that matched nothing is not a change, and recording + # it would fill the journal with entries a reader has to dismiss. + [ "$before" = "$after" ] && return 0 + + local -a fields + mapfile -t fields < <(_pmx_journal_common) + _pmx_journal_record "${fields[@]}" \ + "class=configuration" "operation=edit_file" "target=$path" \ + "before=${before:-}" "after=${after:-}" \ + "expression=$*" "capture=present" "revert=restore" \ + "exactness=$([ "$kept" = true ] && echo exact || echo none)" +} + +# Removes a file, keeping its contents so the removal can be undone. +pmx_remove_file() { + local path="$1" + [ -e "$path" ] || return 0 + local before kept + _pmx_store_object "$path" + before="$PMX_LAST_DIGEST"; kept="$PMX_LAST_OBJECT_STORED" + rm -f "$path" || return 1 + + local -a fields + mapfile -t fields < <(_pmx_journal_common) + _pmx_journal_record "${fields[@]}" \ + "class=configuration" "operation=remove_file" "target=$path" \ + "before=${before:-}" "after=" "capture=present" \ + "revert=restore" \ + "exactness=$([ "$kept" = true ] && echo exact || echo none)" +} + +# Adds to a file, keeping what was there. +# +# Appending looks like it needs no capture — the previous content is +# still in the file — but the journal shows a change as the difference +# between two states, and a reader asking what a function did to a file +# should not have to reconstruct the first state by subtracting. +# +# printf 'ulimit -n 1048576\n' | pmx_append_file /root/.profile +pmx_append_file() { + local path="$1" + local temp before after existed="false" + temp="$(mktemp)" || { cat >> "$path"; return $?; } + cat > "$temp" + + local kept="true" + if [ -f "$path" ]; then + existed="true" + _pmx_store_object "$path" + before="$PMX_LAST_DIGEST"; kept="$PMX_LAST_OBJECT_STORED" + fi + if ! cat "$temp" >> "$path" 2>/dev/null; then + rm -f "$temp" + return 1 + fi + _pmx_store_object "$path"; after="$PMX_LAST_DIGEST" + rm -f "$temp" + [ "$before" = "$after" ] && return 0 + + local -a fields + mapfile -t fields < <(_pmx_journal_common) + _pmx_journal_record "${fields[@]}" \ + "class=configuration" "operation=append_file" "target=$path" \ + "before=${before:-}" "after=${after:-}" \ + "capture=$([ "$existed" = true ] && echo present || echo created)" \ + "revert=$([ "$existed" = true ] && echo restore || echo remove)" \ + "exactness=$([ "$existed" != true ] || [ "$kept" = true ] && echo exact || echo none)" +} + +# Applies a setting through the command that owns it, capturing the +# state that command reports before and after. +# +# Some settings have no file to write: the timezone, whether the clock is +# disciplined, a bootloader entry. The tool that owns them is the only +# thing that can read them back, so it is asked twice — before and after +# — and the journal records the two answers. +# +# pmx_apply_setting "timezone" "timedatectl show -p Timezone --value" \ +# timedatectl set-timezone "$timezone" +pmx_apply_setting() { + local name="$1" reader="$2"; shift 2 + local before after + before="$(eval "$reader" 2>/dev/null | head -c 400)" + "$@" >/dev/null 2>&1 + local status=$? + after="$(eval "$reader" 2>/dev/null | head -c 400)" + # A setting already at the wanted value is not a change. + [ "$before" = "$after" ] && return $status + + local -a fields + mapfile -t fields < <(_pmx_journal_common) + _pmx_journal_record "${fields[@]}" \ + "class=configuration" "operation=apply_setting" "target=$name" \ + "before_state=$before" "after_state=$after" "command=$*" \ + "capture=present" "revert=reapply" \ + "result=$([ $status -eq 0 ] && echo ok || echo failed)" \ + "exactness=exact" + return $status +} + +# --------------------------------------------------------------------- +# Installation: what was not on this host and now is +# --------------------------------------------------------------------- + +# Installs packages, recording which ones actually arrived. +# +# What is recorded is the difference the operation made, not what was +# asked for: a package already present is not a change, and the +# dependencies apt pulled in are, even though nobody named them. +pmx_install_pkg() { + local -a requested=("$@") + [ ${#requested[@]} -gt 0 ] || return 0 + + local before_list after_list added + before_list="$(dpkg-query -W -f='${binary:Package}\n' 2>/dev/null | sort -u)" + DEBIAN_FRONTEND=noninteractive apt-get install -y "${requested[@]}" >/dev/null 2>&1 + local status=$? + after_list="$(dpkg-query -W -f='${binary:Package}\n' 2>/dev/null | sort -u)" + added="$(comm -13 <(printf '%s\n' "$before_list") <(printf '%s\n' "$after_list") | tr '\n' ' ')" + + local -a fields + mapfile -t fields < <(_pmx_journal_common) + _pmx_journal_record "${fields[@]}" \ + "class=installation" "operation=install_package" \ + "target=${requested[*]}" "installed=${added% }" \ + "result=$([ $status -eq 0 ] && echo ok || echo failed)" \ + "capture=present" "revert=purge" \ + "exactness=$([ -n "${added// /}" ] && echo partial || echo none)" + return $status +} + +# --------------------------------------------------------------------- +# Services: what was running, and what runs now +# --------------------------------------------------------------------- + +_pmx_service_state() { + local unit="$1" + printf '%s/%s' \ + "$(systemctl is-enabled "$unit" 2>/dev/null || echo unknown)" \ + "$(systemctl is-active "$unit" 2>/dev/null || echo unknown)" +} + +pmx_enable_service() { + local unit="$1" + local before after + before="$(_pmx_service_state "$unit")" + systemctl enable --now "$unit" >/dev/null 2>&1 + local status=$? + after="$(_pmx_service_state "$unit")" + [ "$before" = "$after" ] && return $status + + local -a fields + mapfile -t fields < <(_pmx_journal_common) + _pmx_journal_record "${fields[@]}" \ + "class=configuration" "operation=enable_service" "target=$unit" \ + "before_state=$before" "after_state=$after" \ + "capture=present" "revert=disable" "exactness=exact" + return $status +} + +pmx_disable_service() { + local unit="$1" + local before after + before="$(_pmx_service_state "$unit")" + systemctl disable --now "$unit" >/dev/null 2>&1 + local status=$? + after="$(_pmx_service_state "$unit")" + [ "$before" = "$after" ] && return $status + + local -a fields + mapfile -t fields < <(_pmx_journal_common) + _pmx_journal_record "${fields[@]}" \ + "class=configuration" "operation=disable_service" "target=$unit" \ + "before_state=$before" "after_state=$after" \ + "capture=present" "revert=enable" "exactness=exact" + return $status +} + +# --------------------------------------------------------------------- +# Execution: what ProxMenux ran on the user's behalf +# --------------------------------------------------------------------- + +# For work ProxMenux launches but does not decide: a system upgrade, a +# rebuild. Recording it as a change of ours would claim authorship of +# whatever apt decided; recording nothing would leave a host that changed +# under the reader's feet with no trace of why. +pmx_record_execution() { + local description="$1"; shift + local command="$*" + local -a fields + mapfile -t fields < <(_pmx_journal_common) + _pmx_journal_record "${fields[@]}" \ + "class=execution" "operation=run_command" \ + "target=$description" "command=$command" \ + "capture=none" "revert=none" "exactness=none" +} + +# Records that a function was applied without being able to say what it +# changed — the state before it ran is not knowable. Used by the +# registration path so a host carries an honest account of what was +# applied before the journal existed. +pmx_record_applied() { + local tool="$1" version="$2" state="${3:-applied}" + local -a fields + mapfile -t fields < <(_pmx_journal_common) + _pmx_journal_record "${fields[@]}" \ + "class=registration" "operation=$state" "target=$tool" \ + "function_version=$version" "capture=unknown" \ + "revert=none" "exactness=none" +} diff --git a/scripts/global/remove-banner-pve-v3.sh b/scripts/global/remove-banner-pve-v3.sh index 7e4213e1..4ddf7af2 100644 --- a/scripts/global/remove-banner-pve-v3.sh +++ b/scripts/global/remove-banner-pve-v3.sh @@ -17,6 +17,9 @@ TOOLS_JSON="/usr/local/share/proxmenux/installed_tools.json" if [[ -f "$UTILS_FILE" ]]; then source "$UTILS_FILE" fi +if [[ -f /usr/local/share/proxmenux/scripts/global/pmx_journal.sh ]]; then + source /usr/local/share/proxmenux/scripts/global/pmx_journal.sh +fi load_language initialize_cache @@ -77,7 +80,9 @@ create_backup() { # Create the patch script that will be called by APT hook create_patch_script() { - cat > "$PATCH_BIN" <<'EOFPATCH' + local FUNC_VERSION="1.0" + pmx_journal_context "create_patch_script" "$FUNC_VERSION" + pmx_write_file "$PATCH_BIN" <<'EOFPATCH' #!/usr/bin/env bash # ========================================================== # Proxmox Subscription Banner Patch (v3 - Minimal) diff --git a/scripts/global/remove-banner-pve8.sh b/scripts/global/remove-banner-pve8.sh index 8b55a7fb..7a0d2f05 100644 --- a/scripts/global/remove-banner-pve8.sh +++ b/scripts/global/remove-banner-pve8.sh @@ -10,6 +10,9 @@ TOOLS_JSON="/usr/local/share/proxmenux/installed_tools.json" if [[ -f "$UTILS_FILE" ]]; then source "$UTILS_FILE" fi +if [[ -f "$LOCAL_SCRIPTS/global/pmx_journal.sh" ]]; then + source "$LOCAL_SCRIPTS/global/pmx_journal.sh" +fi load_language initialize_cache @@ -27,6 +30,8 @@ register_tool() { } remove_subscription_banner_pve8() { + local FUNC_VERSION="1.0" + pmx_journal_context "remove_subscription_banner_pve8" "$FUNC_VERSION" local JS_FILE="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js" local GZ_FILE="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js.gz" local APT_HOOK="/etc/apt/apt.conf.d/no-nag-script" @@ -50,17 +55,20 @@ remove_subscription_banner_pve8() { cp "$JS_FILE" "$BACKUP_FILE" - sed -i "s/No valid subscription/Subscription active/g" "$JS_FILE" - sed -i "s/Ext.Msg.WARNING/Ext.Msg.INFO/g" "$JS_FILE" - sed -i "s/res.data.status.toLowerCase() !== 'active'/false/g" "$JS_FILE" - sed -i "s/subscriptionActive: ''/subscriptionActive: true/g" "$JS_FILE" + pmx_edit_file "$JS_FILE" \ + -e "s/No valid subscription/Subscription active/g" \ + -e "s/Ext.Msg.WARNING/Ext.Msg.INFO/g" \ + -e "s/res.data.status.toLowerCase() !== 'active'/false/g" \ + -e "s/subscriptionActive: ''/subscriptionActive: true/g" - [[ -f "$GZ_FILE" ]] && rm -f "$GZ_FILE" + [[ -f "$GZ_FILE" ]] && pmx_remove_file "$GZ_FILE" + pmx_record_execution "Clear cached Proxmox JavaScript files" "find /var/cache/pve-manager/ -name *.js* -delete" find /var/cache/pve-manager/ -name "*.js*" -delete 2>/dev/null || true + pmx_record_execution "Clear generated Proxmox JavaScript files" "find /var/lib/pve-manager/ -name *.js* -delete" find /var/lib/pve-manager/ -name "*.js*" -delete 2>/dev/null || true - [[ -f "$APT_HOOK" ]] && rm -f "$APT_HOOK" + [[ -f "$APT_HOOK" ]] && pmx_remove_file "$APT_HOOK" msg_ok "Subscription banner removed successfully." diff --git a/scripts/global/update-pve8.sh b/scripts/global/update-pve8.sh index b825be57..d108c80f 100644 --- a/scripts/global/update-pve8.sh +++ b/scripts/global/update-pve8.sh @@ -12,6 +12,9 @@ TOOLS_JSON="/usr/local/share/proxmenux/installed_tools.json" if [[ -f "$UTILS_FILE" ]]; then source "$UTILS_FILE" fi +if [[ -f "$LOCAL_SCRIPTS/global/pmx_journal.sh" ]]; then + source "$LOCAL_SCRIPTS/global/pmx_journal.sh" +fi load_language initialize_cache @@ -34,6 +37,8 @@ download_common_functions() { } update_pve8() { + local FUNC_VERSION="1.0" + pmx_journal_context "update_pve8" "$FUNC_VERSION" local start_time=$(date +%s) local log_file="/var/log/proxmox-update-$(date +%Y%m%d-%H%M%S).log" local changes_made=false @@ -67,20 +72,20 @@ update_pve8() { if [ -f /etc/apt/sources.list.d/pve-enterprise.list ] && grep -q "^deb" /etc/apt/sources.list.d/pve-enterprise.list; then - sed -i "s/^deb/#deb/g" /etc/apt/sources.list.d/pve-enterprise.list + pmx_edit_file /etc/apt/sources.list.d/pve-enterprise.list "s/^deb/#deb/g" msg_ok "$(translate "Enterprise Proxmox repository disabled")" changes_made=true fi if [ -f /etc/apt/sources.list.d/ceph.list ] && grep -q "^deb" /etc/apt/sources.list.d/ceph.list; then - sed -i "s/^deb/#deb/g" /etc/apt/sources.list.d/ceph.list + pmx_edit_file /etc/apt/sources.list.d/ceph.list "s/^deb/#deb/g" msg_ok "$(translate "Enterprise Proxmox Ceph repository disabled")" changes_made=true fi if [ ! -f /etc/apt/sources.list.d/pve-public-repo.list ] || ! grep -q "pve-no-subscription" /etc/apt/sources.list.d/pve-public-repo.list; then - echo "deb http://download.proxmox.com/debian/pve $OS_CODENAME pve-no-subscription" > /etc/apt/sources.list.d/pve-public-repo.list + echo "deb http://download.proxmox.com/debian/pve $OS_CODENAME pve-no-subscription" | pmx_write_file /etc/apt/sources.list.d/pve-public-repo.list msg_ok "$(translate "Free public Proxmox repository enabled")" changes_made=true fi @@ -90,14 +95,15 @@ update_pve8() { cp "$sources_file" "${sources_file}.backup.$(date +%Y%m%d_%H%M%S)" if grep -q -E "(debian-security -security|debian main$|debian -updates)" "$sources_file"; then - sed -i '/^deb.*debian-security -security/d' "$sources_file" - sed -i '/^deb.*debian main$/d' "$sources_file" - sed -i '/^deb.*debian -updates/d' "$sources_file" + pmx_edit_file "$sources_file" \ + -e '/^deb.*debian-security -security/d' \ + -e '/^deb.*debian main$/d' \ + -e '/^deb.*debian -updates/d' changes_made=true msg_ok "$(translate "Malformed repository entries cleaned")" fi - cat > "$sources_file" << EOF + pmx_write_file "$sources_file" << EOF # Debian $OS_CODENAME repositories deb http://deb.debian.org/debian $OS_CODENAME main contrib non-free non-free-firmware deb http://deb.debian.org/debian $OS_CODENAME-updates main contrib non-free non-free-firmware @@ -108,12 +114,13 @@ EOF local firmware_conf="/etc/apt/apt.conf.d/no-firmware-warnings.conf" if [ ! -f "$firmware_conf" ]; then - echo 'APT::Get::Update::SourceListWarnings::NonFreeFirmware "false";' > "$firmware_conf" + echo 'APT::Get::Update::SourceListWarnings::NonFreeFirmware "false";' | pmx_write_file "$firmware_conf" fi cleanup_duplicate_repos msg_info "$(translate "Updating package lists...")" + pmx_record_execution "Update package lists" "apt-get update" if apt-get update > "$log_file" 2>&1; then msg_ok "$(translate "Package lists updated successfully")" else @@ -159,12 +166,16 @@ EOF if [[ $MENU_RESULT -eq 1 ]]; then msg_info2 "$(translate "Update cancelled by user")" + pmx_record_execution "Remove unused packages" "apt-get -y autoremove" apt-get -y autoremove > /dev/null 2>&1 || true + pmx_record_execution "Clean downloaded package cache" "apt-get -y autoclean" apt-get -y autoclean > /dev/null 2>&1 || true return 0 elif [[ $MENU_RESULT -eq 2 ]]; then msg_ok "$(translate "System is already up to date. No update needed.")" + pmx_record_execution "Remove unused packages" "apt-get -y autoremove" apt-get -y autoremove > /dev/null 2>&1 || true + pmx_record_execution "Clean downloaded package cache" "apt-get -y autoclean" apt-get -y autoclean > /dev/null 2>&1 || true return 0 fi @@ -173,6 +184,7 @@ EOF local conflicting_packages=$(dpkg -l 2>/dev/null | grep -E "^ii.*(ntp|openntpd|systemd-timesyncd)" | awk '{print $2}') if [ -n "$conflicting_packages" ]; then msg_info "$(translate "Removing conflicting utilities...")" + pmx_record_execution "Purge conflicting time services" "apt-get -y purge $conflicting_packages" DEBIAN_FRONTEND=noninteractive apt-get -y purge $conflicting_packages >> "$log_file" 2>&1 msg_ok "$(translate "Conflicting utilities removed")" fi @@ -185,7 +197,7 @@ EOF export DPKG_OPTIONS="--force-confdef --force-confold" msg_info "$(translate "Performing packages upgrade...")" - apt-get install pv -y > /dev/null 2>&1 + pmx_install_pkg pv total_packages=$(apt-get -s dist-upgrade | grep "^Inst" | wc -l) msg_ok "$(translate "Packages upgrade successfull")" @@ -196,6 +208,7 @@ EOF tput civis tput sc + pmx_record_execution "Upgrade Proxmox VE 8 packages" "apt-get -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold dist-upgrade" ( /usr/bin/env \ DEBIAN_FRONTEND=noninteractive \ @@ -250,7 +263,7 @@ EOF if [ ${#missing_packages[@]} -gt 0 ]; then msg_info "$(translate "Installing essential Proxmox packages...")" - DEBIAN_FRONTEND=noninteractive apt-get -y install "${missing_packages[@]}" >> "$log_file" 2>&1 + pmx_install_pkg "${missing_packages[@]}" msg_ok "$(translate "Essential Proxmox packages installed")" fi @@ -258,7 +271,9 @@ EOF cleanup_duplicate_repos msg_info "$(translate "Performing system cleanup...")" + pmx_record_execution "Remove unused packages" "apt-get -y autoremove" apt-get -y autoremove > /dev/null 2>&1 || true + pmx_record_execution "Clean downloaded package cache" "apt-get -y autoclean" apt-get -y autoclean > /dev/null 2>&1 || true msg_ok "$(translate "Cleanup finished")" diff --git a/scripts/global/update-pve9_2.sh b/scripts/global/update-pve9_2.sh index 58d9e85f..cf04de12 100644 --- a/scripts/global/update-pve9_2.sh +++ b/scripts/global/update-pve9_2.sh @@ -13,6 +13,9 @@ APT_ENV="env DEBIAN_FRONTEND=noninteractive LC_ALL=C LANG=C" if [[ -f "$UTILS_FILE" ]]; then source "$UTILS_FILE" fi +if [[ -f "$LOCAL_SCRIPTS/global/pmx_journal.sh" ]]; then + source "$LOCAL_SCRIPTS/global/pmx_journal.sh" +fi load_language initialize_cache @@ -35,6 +38,8 @@ download_common_functions() { } update_pve9() { + local FUNC_VERSION="1.0" + pmx_journal_context "update_pve9" "$FUNC_VERSION" local pve_version pve_version=$(pveversion | awk -F'/' '{print $2}' | cut -d'-' -f1) local start_time @@ -79,17 +84,17 @@ update_pve9() { disable_sources_repo() { local file="$1" if [[ -f "$file" ]]; then - sed -i ':a;/^\n*$/{$d;N;ba}' "$file" + pmx_edit_file "$file" ':a;/^\n*$/{$d;N;ba}' if grep -q "^Enabled:" "$file"; then - sed -i 's/^Enabled:.*$/Enabled: false/' "$file" + pmx_edit_file "$file" 's/^Enabled:.*$/Enabled: false/' else - echo "Enabled: false" >> "$file" + echo "Enabled: false" | pmx_append_file "$file" fi if ! grep -q "^Types: " "$file"; then msg_warn "$(translate "Malformed .sources file detected, removing: $(basename "$file")")" - rm -f "$file" + pmx_remove_file "$file" fi return 0 fi @@ -110,18 +115,18 @@ update_pve9() { /etc/apt/sources.list.d/pve-install-repo.list \ /etc/apt/sources.list.d/debian.list; do if [[ -f "$legacy_file" ]]; then - rm -f "$legacy_file" + pmx_remove_file "$legacy_file" msg_ok "$(translate "Removed legacy repository: $(basename "$legacy_file")")" | tee -a "$screen_capture" fi done if [[ -f /etc/apt/sources.list.d/debian.sources ]]; then - rm -f /etc/apt/sources.list.d/debian.sources + pmx_remove_file /etc/apt/sources.list.d/debian.sources msg_ok "$(translate "Old debian.sources file removed to prevent duplication")" | tee -a "$screen_capture" fi msg_info "$(translate "Creating Proxmox VE 9.x no-subscription repository...")" - cat > /etc/apt/sources.list.d/proxmox.sources << EOF + pmx_write_file /etc/apt/sources.list.d/proxmox.sources << EOF Enabled: true Types: deb URIs: http://download.proxmox.com/debian/pve @@ -134,7 +139,7 @@ EOF changes_made=true msg_info "$(translate "Creating Debian ${TARGET_CODENAME} sources file...")" - cat > /etc/apt/sources.list.d/debian.sources << EOF + pmx_write_file /etc/apt/sources.list.d/debian.sources << EOF Types: deb URIs: http://deb.debian.org/debian/ Suites: ${TARGET_CODENAME} ${TARGET_CODENAME}-updates @@ -154,11 +159,12 @@ EOF local firmware_conf="/etc/apt/apt.conf.d/no-firmware-warnings.conf" if [ ! -f "$firmware_conf" ]; then msg_info "$(translate "Disabling non-free firmware warnings...")" - echo 'APT::Get::Update::SourceListWarnings::NonFreeFirmware "false";' > "$firmware_conf" + echo 'APT::Get::Update::SourceListWarnings::NonFreeFirmware "false";' | pmx_write_file "$firmware_conf" msg_ok "$(translate "Non-free firmware warnings disabled")" fi # UPDATE: no progress bar here (dpkg is not involved); capture output to parse errors + pmx_record_execution "Update package lists" "apt-get update" update_output=$(apt-get update 2>&1) update_exit_code=$? @@ -176,21 +182,25 @@ EOF if command -v gpg >/dev/null 2>&1; then # Modern approach: receive -> export -> dearmor into /etc/apt/keyrings/.gpg + pmx_record_execution "Import missing repository signing key" "gpg --batch --keyserver keyserver.ubuntu.com --recv-keys $key" if gpg --batch --keyserver keyserver.ubuntu.com --recv-keys "$key" \ && gpg --batch --export "$key" | gpg --dearmor -o "/etc/apt/keyrings/${key}.gpg"; then msg_ok "$(translate "Imported missing GPG key: $key")" else msg_warn "$(translate "Keyrings method failed; trying apt-key fallback")" + pmx_record_execution "Import missing repository signing key with apt-key" "apt-key adv --keyserver keyserver.ubuntu.com --recv-keys $key" apt-key adv --keyserver keyserver.ubuntu.com --recv-keys "$key" >/dev/null 2>&1 || true fi else # Fallback for minimal systems without gpg installed msg_warn "$(translate "gpg not found; trying apt-key fallback")" + pmx_record_execution "Import missing repository signing key with apt-key" "apt-key adv --keyserver keyserver.ubuntu.com --recv-keys $key" apt-key adv --keyserver keyserver.ubuntu.com --recv-keys "$key" >/dev/null 2>&1 || true fi fi # Retry update after importing the key + pmx_record_execution "Retry package list update" "apt-get update" if apt-get update > "$log_file" 2>&1; then msg_ok "$(translate "Package lists updated after GPG fix")" | tee -a "$screen_capture" else @@ -270,19 +280,24 @@ EOF if [[ $MENU_RESULT -eq 1 ]]; then msg_info2 "$(translate "Update cancelled by user")" + pmx_record_execution "Remove unused packages" "apt-get -y autoremove" apt-get -y autoremove > /dev/null 2>&1 || true + pmx_record_execution "Clean downloaded package cache" "apt-get -y autoclean" apt-get -y autoclean > /dev/null 2>&1 || true rm -f "$screen_capture" return 0 elif [[ $MENU_RESULT -eq 2 ]]; then msg_ok "$(translate "System is already up to date. No update needed.")" + pmx_record_execution "Remove unused packages" "apt-get -y autoremove" apt-get -y autoremove > /dev/null 2>&1 || true + pmx_record_execution "Clean downloaded package cache" "apt-get -y autoclean" apt-get -y autoclean > /dev/null 2>&1 || true rm -f "$screen_capture" return 0 fi msg_info "$(translate "Cleaning up unused time synchronization services...")" + pmx_record_execution "Purge unused time synchronization services" "apt-get -y -o Dpkg::Options::=--force-confdef purge ntp openntpd systemd-timesyncd" if /usr/bin/env DEBIAN_FRONTEND=noninteractive apt-get -y -o Dpkg::Options::='--force-confdef' purge ntp openntpd systemd-timesyncd > /dev/null 2>&1; then msg_ok "$(translate "Old time services removed successfully")" else @@ -292,6 +307,7 @@ EOF echo -e + pmx_record_execution "Upgrade Proxmox VE 9 packages" "apt -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold full-upgrade" DEBIAN_FRONTEND=noninteractive apt -y \ -o Dpkg::Options::='--force-confdef' \ -o Dpkg::Options::='--force-confold' \ @@ -314,7 +330,7 @@ EOF msg_info "$(translate "Installing essential Proxmox packages...")" local additional_packages="zfsutils-linux proxmox-backup-restore-image chrony" - if /usr/bin/env DEBIAN_FRONTEND=noninteractive apt-get -y -o Dpkg::Options::='--force-confdef' install $additional_packages >> "$log_file" 2>&1; then + if pmx_install_pkg $additional_packages; then msg_ok "$(translate "Essential Proxmox packages installed")" else msg_warn "$(translate "Some essential Proxmox packages may not have been installed")" @@ -323,7 +339,9 @@ EOF lvm_repair_check cleanup_duplicate_repos + pmx_record_execution "Remove unused packages" "apt-get -y autoremove" apt-get -y autoremove > /dev/null 2>&1 || true + pmx_record_execution "Clean downloaded package cache" "apt-get -y autoclean" apt-get -y autoclean > /dev/null 2>&1 || true msg_ok "$(translate "Cleanup finished")" diff --git a/scripts/global/utils-install-functions.sh b/scripts/global/utils-install-functions.sh index 80bfb8b8..5bffd85b 100644 --- a/scripts/global/utils-install-functions.sh +++ b/scripts/global/utils-install-functions.sh @@ -41,7 +41,16 @@ PROXMENUX_UTILS=( # Ensure APT repositories are configured for the current PVE version. # Creates missing no-subscription repo entries for PVE8 (bookworm) or PVE9 (trixie). +# Shared journal helpers, so any script sourcing this file records what +# it installs without arranging for it. +if [[ -f "${LOCAL_SCRIPTS:-/usr/local/share/proxmenux/scripts}/global/pmx_journal.sh" ]]; then + source "${LOCAL_SCRIPTS:-/usr/local/share/proxmenux/scripts}/global/pmx_journal.sh" +fi + + ensure_repositories() { + local FUNC_VERSION="1.0" + pmx_journal_context "ensure_repositories" "$FUNC_VERSION" local pve_version need_update=false pve_version=$(pveversion 2>/dev/null | grep -oP 'pve-manager/\K[0-9]+' | head -1) @@ -57,7 +66,7 @@ ensure_repositories() { # 0640, which the PVE 9 webgui's repository manager treats as # unparseable and silently hides the source — issue #230. if [[ ! -f /etc/apt/sources.list.d/proxmox.sources ]]; then - cat > /etc/apt/sources.list.d/proxmox.sources <<'EOF' + pmx_write_file /etc/apt/sources.list.d/proxmox.sources <<'EOF' Enabled: true Types: deb URIs: http://download.proxmox.com/debian/pve @@ -70,7 +79,7 @@ EOF fi if [[ ! -f /etc/apt/sources.list.d/debian.sources ]]; then - cat > /etc/apt/sources.list.d/debian.sources <<'EOF' + pmx_write_file /etc/apt/sources.list.d/debian.sources <<'EOF' Types: deb URIs: http://deb.debian.org/debian/ Suites: trixie trixie-updates @@ -96,19 +105,20 @@ EOF echo "deb http://deb.debian.org/debian bookworm main contrib non-free non-free-firmware" echo "deb http://deb.debian.org/debian bookworm-updates main contrib non-free non-free-firmware" echo "deb http://security.debian.org/debian-security bookworm-security main contrib non-free non-free-firmware" - } >> "$sources_file" + } | pmx_append_file "$sources_file" need_update=true fi if [[ ! -f /etc/apt/sources.list.d/pve-no-subscription.list ]]; then echo "deb http://download.proxmox.com/debian/pve bookworm pve-no-subscription" \ - > /etc/apt/sources.list.d/pve-no-subscription.list + | pmx_write_file /etc/apt/sources.list.d/pve-no-subscription.list need_update=true fi fi if [[ "$need_update" == true ]] || [[ ! -d /var/lib/apt/lists || -z "$(ls -A /var/lib/apt/lists 2>/dev/null)" ]]; then msg_info "$(translate "Updating APT package lists...")" + pmx_record_execution "Update APT package lists" "apt-get update" apt-get update >/dev/null 2>&1 || apt-get update # Spinner pair: msg_info must be closed before returning. # Without this the next `msg_info` caller spawns a second @@ -132,7 +142,16 @@ install_single_package() { msg_info "$(translate "Installing") $package${description:+ ($description)}..." local install_success=false - if DEBIAN_FRONTEND=noninteractive apt-get install -y "$package" >/dev/null 2>&1; then + # Every script that installs anything comes through here, so this is + # where an installation becomes visible in the audit. What gets + # recorded is the difference the operation made — the packages that + # were not on the host and now are, dependencies included — rather + # than the name that was asked for. + if declare -F pmx_install_pkg >/dev/null 2>&1; then + PMX_JOURNAL_FUNCTION="${PMX_JOURNAL_FUNCTION:-install_single_package}" \ + PMX_JOURNAL_SOURCE="${PMX_JOURNAL_SOURCE:-${SCRIPT_SOURCE:-utils-install-functions.sh}}" \ + pmx_install_pkg "$package" && install_success=true + elif DEBIAN_FRONTEND=noninteractive apt-get install -y "$package" >/dev/null 2>&1; then install_success=true fi cleanup 2>/dev/null || true diff --git a/scripts/global/vm_storage_helpers.sh b/scripts/global/vm_storage_helpers.sh index 18066229..7b668b58 100644 --- a/scripts/global/vm_storage_helpers.sh +++ b/scripts/global/vm_storage_helpers.sh @@ -5,6 +5,10 @@ if [[ -n "${__PROXMENUX_VM_STORAGE_HELPERS__}" ]]; then fi __PROXMENUX_VM_STORAGE_HELPERS__=1 +if [[ -f "/usr/local/share/proxmenux/scripts/global/pmx_journal.sh" ]]; then + source "/usr/local/share/proxmenux/scripts/global/pmx_journal.sh" +fi + function _array_contains() { local needle="$1" shift @@ -371,6 +375,8 @@ function _vm_storage_register_vfio_iommu_tool() { } function _vm_storage_enable_iommu_cmdline() { + local FUNC_VERSION="1.0" + pmx_journal_context "_vm_storage_enable_iommu_cmdline" "$FUNC_VERSION" local cpu_vendor iommu_param cpu_vendor=$(grep -m1 "vendor_id" /proc/cpuinfo 2>/dev/null | awk '{print $3}') @@ -388,13 +394,15 @@ function _vm_storage_enable_iommu_cmdline() { if [[ -f "$cmdline_file" ]] && grep -qE 'root=ZFS=|root=ZFS/' "$cmdline_file" 2>/dev/null; then if ! grep -q "$iommu_param" "$cmdline_file"; then cp "$cmdline_file" "${cmdline_file}.bak.$(date +%Y%m%d_%H%M%S)" - sed -i "s|\\s*$| ${iommu_param} iommu=pt|" "$cmdline_file" + pmx_edit_file "$cmdline_file" "s|\\s*$| ${iommu_param} iommu=pt|" + pmx_record_execution "refresh Proxmox boot entries" "proxmox-boot-tool refresh" proxmox-boot-tool refresh >/dev/null 2>&1 || true fi elif [[ -f "$grub_file" ]]; then if ! grep -q "$iommu_param" "$grub_file"; then cp "$grub_file" "${grub_file}.bak.$(date +%Y%m%d_%H%M%S)" - sed -i "/GRUB_CMDLINE_LINUX_DEFAULT=/ s|\"$| ${iommu_param} iommu=pt\"|" "$grub_file" + pmx_edit_file "$grub_file" "/GRUB_CMDLINE_LINUX_DEFAULT=/ s|\"$| ${iommu_param} iommu=pt\"|" + pmx_record_execution "regenerate GRUB configuration" "update-grub" update-grub >/dev/null 2>&1 || true fi else diff --git a/scripts/lxc/lxc-privileged-to-unprivileged.sh b/scripts/lxc/lxc-privileged-to-unprivileged.sh index b9e94f15..f195b868 100644 --- a/scripts/lxc/lxc-privileged-to-unprivileged.sh +++ b/scripts/lxc/lxc-privileged-to-unprivileged.sh @@ -23,6 +23,10 @@ if [[ -f "$UTILS_FILE" ]]; then source "$UTILS_FILE" fi +if [[ -f "$LOCAL_SCRIPTS/global/pmx_journal.sh" ]]; then + source "$LOCAL_SCRIPTS/global/pmx_journal.sh" +fi + load_language initialize_cache @@ -53,6 +57,9 @@ select_privileged_container() { } validate_container_id() { + local FUNC_VERSION="1.1" + pmx_journal_context "validate_container_id" "$FUNC_VERSION" + if [ -z "$CONTAINER_ID" ]; then msg_error "$(translate 'Container ID not defined. Make sure to select a container first.')" exit 1 @@ -66,6 +73,8 @@ validate_container_id() { if pct status "$CONTAINER_ID" | grep -q "running"; then msg_info "$(translate 'Stopping the container before conversion...')" + pmx_record_execution "stop CT ${CONTAINER_ID} for privileged-to-unprivileged conversion" \ + "pct stop ${CONTAINER_ID}" pct stop "$CONTAINER_ID" msg_ok "$(translate 'Container stopped.')" fi @@ -89,7 +98,12 @@ show_backup_warning() { } convert_direct_method() { + local FUNC_VERSION="1.1" + pmx_journal_context "convert_direct_method" "$FUNC_VERSION" + msg_info2 "$(translate 'Starting direct conversion of container') $CONTAINER_ID..." + pmx_record_execution "convert CT ${CONTAINER_ID} filesystem ownership to unprivileged IDs" \ + "mount rootfs, remap ownership by 100000, and update CT configuration" TEMP_DIR="/tmp/lxc_convert_$CONTAINER_ID" mkdir -p "$TEMP_DIR" @@ -225,9 +239,9 @@ convert_direct_method() { CONFIG_FILE="/etc/pve/lxc/$CONTAINER_ID.conf" if ! grep -q "^unprivileged:" "$CONFIG_FILE"; then - echo "unprivileged: 1" >> "$CONFIG_FILE" + echo "unprivileged: 1" | pmx_append_file "$CONFIG_FILE" else - sed -i 's/^unprivileged:.*/unprivileged: 1/' "$CONFIG_FILE" + pmx_edit_file "$CONFIG_FILE" 's/^unprivileged:.*/unprivileged: 1/' fi msg_ok "$(translate 'Direct conversion completed for container') $CONTAINER_ID" @@ -238,9 +252,12 @@ convert_direct_method() { } cleanup_and_finalize() { + local FUNC_VERSION="1.1" + pmx_journal_context "cleanup_and_finalize" "$FUNC_VERSION" if whiptail --yesno "$(translate 'Do you want to start the converted unprivileged container') $CONTAINER_ID $(translate 'now?')" 10 60; then msg_info2 "$(translate 'Starting unprivileged container...')" + pmx_record_execution "start converted unprivileged CT ${CONTAINER_ID}" "pct start ${CONTAINER_ID}" pct start "$CONTAINER_ID" msg_ok "$(translate 'Unprivileged container') $CONTAINER_ID $(translate 'started successfully.')" fi diff --git a/scripts/lxc/lxc-unprivileged-to-privileged.sh b/scripts/lxc/lxc-unprivileged-to-privileged.sh index 4992e799..cd9e754d 100644 --- a/scripts/lxc/lxc-unprivileged-to-privileged.sh +++ b/scripts/lxc/lxc-unprivileged-to-privileged.sh @@ -25,6 +25,10 @@ if [[ -f "$UTILS_FILE" ]]; then source "$UTILS_FILE" fi +if [[ -f "$LOCAL_SCRIPTS/global/pmx_journal.sh" ]]; then + source "$LOCAL_SCRIPTS/global/pmx_journal.sh" +fi + load_language initialize_cache @@ -69,12 +73,19 @@ show_backup_warning() { } convert_to_privileged() { + local FUNC_VERSION="2.0" + pmx_journal_context "convert_to_privileged" "$FUNC_VERSION" + CONF_FILE="/etc/pve/lxc/$CONTAINER_ID.conf" + pmx_record_execution "convert CT ${CONTAINER_ID} to privileged mode" \ + "stop CT if running and update ${CONF_FILE}" CONTAINER_STATUS=$(pct status "$CONTAINER_ID" | awk '{print $2}') if [ "$CONTAINER_STATUS" == "running" ]; then msg_info "$(translate 'Stopping container') $CONTAINER_ID..." + pmx_record_execution "stop CT ${CONTAINER_ID} for unprivileged-to-privileged conversion" \ + "pct shutdown ${CONTAINER_ID}" pct shutdown "$CONTAINER_ID" # Wait for container to stop @@ -101,8 +112,8 @@ convert_to_privileged() { msg_ok "$(translate 'Configuration backup created:') $CONF_FILE.bak" msg_info "$(translate 'Converting container to privileged...')" - sed -i '/^unprivileged: 1/d' "$CONF_FILE" - echo "unprivileged: 0" >> "$CONF_FILE" + pmx_edit_file "$CONF_FILE" '/^unprivileged: 1/d' + echo "unprivileged: 0" | pmx_append_file "$CONF_FILE" msg_ok "$(translate 'Container successfully converted to privileged.')" @@ -112,9 +123,12 @@ convert_to_privileged() { } finalize_conversion() { + local FUNC_VERSION="2.0" + pmx_journal_context "finalize_conversion" "$FUNC_VERSION" if whiptail --yesno "$(translate 'Do you want to start the privileged container') $CONTAINER_ID $(translate 'now?')" 10 60; then msg_info "$(translate 'Starting privileged container...')" + pmx_record_execution "start converted privileged CT ${CONTAINER_ID}" "pct start ${CONTAINER_ID}" pct start "$CONTAINER_ID" msg_ok "$(translate 'Privileged container') $CONTAINER_ID $(translate 'started successfully.')" fi diff --git a/scripts/menus/config_menu.sh b/scripts/menus/config_menu.sh index c3adf700..dc06268f 100644 --- a/scripts/menus/config_menu.sh +++ b/scripts/menus/config_menu.sh @@ -61,6 +61,9 @@ MONITOR_PORT=8008 if [[ -f "$UTILS_FILE" ]]; then source "$UTILS_FILE" fi +if [[ -f "$LOCAL_SCRIPTS/global/pmx_journal.sh" ]]; then + source "$LOCAL_SCRIPTS/global/pmx_journal.sh" +fi load_language initialize_cache @@ -749,6 +752,8 @@ show_version_info() { # ========================================================== uninstall_proxmenu() { + local FUNC_VERSION="1.0" + pmx_journal_context "uninstall_proxmenu" "$FUNC_VERSION" if ! dialog --clear --backtitle "$BACKTITLE" \ --title "Uninstall ProxMenux" \ --yesno "\n$(translate "Are you sure you want to uninstall ProxMenux?")" 8 60; then @@ -773,11 +778,13 @@ uninstall_proxmenu() { # a pre-static-translations install. Cheap idempotent check. if [ -d "/opt/googletrans-env" ]; then echo "30" ; echo "Removing legacy googletrans virtualenv..." + pmx_record_execution "Remove legacy googletrans virtual environment" "rm -rf /opt/googletrans-env" rm -rf "/opt/googletrans-env" fi echo "50" ; echo "Removing ProxMenu files..." - rm -f "$INSTALL_DIR/$MENU_SCRIPT" + pmx_remove_file "$INSTALL_DIR/$MENU_SCRIPT" + pmx_record_execution "Remove ProxMenux application directory" "rm -rf $BASE_DIR" rm -rf "$BASE_DIR" # Remove selected dependencies @@ -785,22 +792,30 @@ uninstall_proxmenu() { echo "70" ; echo "Removing selected dependencies..." read -r -a DEPS_ARRAY <<< "$(echo "$deps_to_remove" | tr -d '"')" for dep in "${DEPS_ARRAY[@]}"; do + pmx_record_execution "Mark ProxMenux dependency as automatic" "apt-mark auto $dep" apt-mark auto "$dep" >/dev/null 2>&1 + pmx_record_execution "Remove selected ProxMenux dependency" "apt-get -y --purge autoremove $dep" apt-get -y --purge autoremove "$dep" >/dev/null 2>&1 done + pmx_record_execution "Remove unused ProxMenux dependencies" "apt-get autoremove -y --purge" apt-get autoremove -y --purge >/dev/null 2>&1 fi echo "80" ; echo "Removing ProxMenux Monitor..." + pmx_record_execution "Uninstall ProxMenux Monitor" "uninstall_proxmenux_monitor" uninstall_proxmenux_monitor echo "90" ; echo "Restoring system files..." # Restore .bashrc and motd - [ -f /root/.bashrc.bak ] && mv /root/.bashrc.bak /root/.bashrc + if [ -f /root/.bashrc.bak ]; then + pmx_write_file /root/.bashrc < /root/.bashrc.bak + pmx_remove_file /root/.bashrc.bak + fi if [ -f /etc/motd.bak ]; then - mv /etc/motd.bak /etc/motd + pmx_write_file /etc/motd < /etc/motd.bak + pmx_remove_file /etc/motd.bak else - sed -i '/This system is optimised by: ProxMenux/d' /etc/motd + pmx_edit_file /etc/motd '/This system is optimised by: ProxMenux/d' fi echo "100" ; echo "Uninstallation complete!" diff --git a/scripts/menus/network_menu.sh b/scripts/menus/network_menu.sh index 19ef447a..50e84ee9 100644 --- a/scripts/menus/network_menu.sh +++ b/scripts/menus/network_menu.sh @@ -41,6 +41,9 @@ TOOLS_JSON="/usr/local/share/proxmenux/installed_tools.json" if [[ -f "$UTILS_FILE" ]]; then source "$UTILS_FILE" fi +if [[ -f "$LOCAL_SCRIPTS/global/pmx_journal.sh" ]]; then + source "$LOCAL_SCRIPTS/global/pmx_journal.sh" +fi load_language initialize_cache @@ -389,6 +392,8 @@ analyze_bridge_configuration() { guided_bridge_repair() { + local FUNC_VERSION="1.0" + pmx_journal_context "guided_bridge_repair" "$FUNC_VERSION" local step=1 local total_steps=5 @@ -482,7 +487,7 @@ guided_bridge_repair() { # Apply the change if [ "$new_ports" != "$current_ports" ]; then - sed -i "/iface $bridge/,/bridge-ports/ s/bridge-ports.*/bridge-ports $new_ports/" /etc/network/interfaces + pmx_edit_file /etc/network/interfaces "/iface $bridge/,/bridge-ports/ s/bridge-ports.*/bridge-ports $new_ports/" fi fi done @@ -520,6 +525,7 @@ guided_bridge_repair() { clear msg_info "$(translate "Restarting network service...")" + pmx_record_execution "Restart networking service" "systemctl restart networking" if systemctl restart networking; then msg_ok "$(translate "Network service restarted successfully")" else @@ -635,6 +641,8 @@ analyze_network_configuration() { } guided_configuration_cleanup() { + local FUNC_VERSION="1.0" + pmx_journal_context "guided_configuration_cleanup" "$FUNC_VERSION" local step=1 local total_steps=5 @@ -714,7 +722,7 @@ guided_configuration_cleanup() { --infobox "$(translate "Removing invalid configurations...")\n\n$(translate "This may take a few seconds...")" 8 50 for iface in $interfaces_to_remove; do - sed -i "/^iface $iface/,/^$/d" /etc/network/interfaces + pmx_edit_file /etc/network/interfaces "/^iface $iface/,/^$/d" done ((step++)) diff --git a/scripts/post_install/auto_post_install.sh b/scripts/post_install/auto_post_install.sh index e73df523..97f3f56a 100644 --- a/scripts/post_install/auto_post_install.sh +++ b/scripts/post_install/auto_post_install.sh @@ -48,6 +48,11 @@ fi if [[ -f "$LOCAL_SCRIPTS/global/utils-install-functions.sh" ]]; then source "$LOCAL_SCRIPTS/global/utils-install-functions.sh" fi +# Recording is part of writing: sourced before any function runs so a +# change made without it is a mistake we can find, not one we can make. +if [[ -f "$LOCAL_SCRIPTS/global/pmx_journal.sh" ]]; then + source "$LOCAL_SCRIPTS/global/pmx_journal.sh" +fi load_language initialize_cache @@ -92,6 +97,16 @@ register_tool() { local state="$2" local version="${3:-1.0}" local source="${4:-${SCRIPT_SOURCE:-unknown}}" + # Same as in the customizable script: the one call every function + # already makes, so an applied tool reaches the journal even where + # the function itself still writes directly. + if declare -F pmx_record_applied >/dev/null 2>&1; then + PMX_JOURNAL_FUNCTION="${FUNCNAME[1]:-$tool}" \ + PMX_JOURNAL_VERSION="$version" \ + PMX_JOURNAL_SOURCE="$source" \ + pmx_record_applied "$tool" "$version" \ + "$([[ "$state" == "true" ]] && echo applied || echo removed)" + fi ensure_tools_json if [[ "$state" == "true" ]]; then jq --arg t "$tool" --arg ver "$version" --arg src "$source" \ @@ -290,9 +305,10 @@ configure_time_sync() { skip_apt_languages() { local FUNC_VERSION="1.0" + pmx_journal_context "skip_apt_languages" "$FUNC_VERSION" # description: Stop APT from downloading translation files to speed up updates. msg_info "$(translate "Configuring APT to skip downloading additional languages...")" - cat > /etc/apt/apt.conf.d/99-disable-translations <<'EOF' + pmx_write_file /etc/apt/apt.conf.d/99-disable-translations <<'EOF' Acquire::Languages "none"; EOF msg_ok "$(translate "APT configured to skip additional languages")" @@ -302,6 +318,7 @@ EOF # ========================================================== optimize_journald() { local FUNC_VERSION="1.0" + pmx_journal_context "optimize_journald" "$FUNC_VERSION" # description: Cap journald size, raise rate limit and force info-level logging so the log viewer and Fail2Ban work. if [ -f /etc/log2ram.conf ] || [ -d /var/log.hdd ]; then return 0 @@ -314,7 +331,7 @@ optimize_journald() { cp -a "$jf" "${jf}.bak" 2>/dev/null || true fi - cat < /etc/systemd/journald.conf + pmx_write_file /etc/systemd/journald.conf < /dev/null 2>&1 + pmx_record_execution "Vacuum system journal" "journalctl --vacuum-size=64M --vacuum-time=1d" journalctl --vacuum-size=64M --vacuum-time=1d > /dev/null 2>&1 + pmx_record_execution "Rotate system journal" "journalctl --rotate" journalctl --rotate > /dev/null 2>&1 msg_ok "$(translate "Journald optimized - Max size: 64M")" @@ -348,6 +368,7 @@ EOF # ========================================================== optimize_logrotate() { local FUNC_VERSION="1.1" + pmx_journal_context "optimize_logrotate" "$FUNC_VERSION" # description: Replace logrotate.conf with a Log2RAM-friendly profile (daily rotation, copytruncate). msg_info "$(translate "Optimizing logrotate configuration...")" local logrotate_conf="/etc/logrotate.conf" @@ -355,7 +376,7 @@ optimize_logrotate() { cp -n "$logrotate_conf" "$backup_conf" 2>/dev/null || true - cat < "$logrotate_conf" + pmx_write_file "$logrotate_conf" < /dev/null 2>&1 msg_ok "$(translate "Logrotate optimization completed")" @@ -378,12 +400,13 @@ EOF # ========================================================== increase_system_limits() { local FUNC_VERSION="1.1" + pmx_journal_context "increase_system_limits" "$FUNC_VERSION" # description: Raise inotify watches, file descriptors, process keys and PID limits to enterprise levels. msg_info "$(translate "Increasing various system limits...")" NECESSARY_REBOOT=1 - cat > /etc/sysctl.d/99-maxwatches.conf << EOF + pmx_write_file /etc/sysctl.d/99-maxwatches.conf << EOF # ProxMenux configuration fs.inotify.max_user_watches = 1048576 fs.inotify.max_user_instances = 1048576 @@ -391,7 +414,7 @@ fs.inotify.max_queued_events = 1048576 EOF - cat > /etc/security/limits.d/99-limits.conf << EOF + pmx_write_file /etc/security/limits.d/99-limits.conf << EOF # ProxMenux configuration * soft nproc 1048576 * hard nproc 1048576 @@ -404,7 +427,7 @@ root hard nofile unlimited EOF - cat > /etc/sysctl.d/99-maxkeys.conf << EOF + pmx_write_file /etc/sysctl.d/99-maxkeys.conf << EOF # ProxMenux configuration kernel.keys.root_maxkeys=1000000 kernel.keys.maxkeys=1000000 @@ -413,32 +436,32 @@ EOF for file in /etc/systemd/system.conf /etc/systemd/user.conf; do if ! grep -q "^DefaultLimitNOFILE=" "$file"; then - echo "DefaultLimitNOFILE=1048576" >> "$file" + echo "DefaultLimitNOFILE=1048576" | pmx_append_file "$file" fi done for file in /etc/pam.d/common-session /etc/pam.d/runuser-l; do if ! grep -q "^session required pam_limits.so" "$file"; then - echo 'session required pam_limits.so' >> "$file" + echo 'session required pam_limits.so' | pmx_append_file "$file" fi done if ! grep -q "ulimit -n 1048576" /root/.profile; then - sed -i '/ulimit -n 256000/d' /root/.profile 2>/dev/null - echo "ulimit -n 1048576" >> /root/.profile + pmx_edit_file /root/.profile '/ulimit -n 256000/d' 2>/dev/null || true + echo "ulimit -n 1048576" | pmx_append_file /root/.profile fi - cat > /etc/sysctl.d/99-swap.conf << EOF + pmx_write_file /etc/sysctl.d/99-swap.conf << EOF # ProxMenux configuration vm.swappiness = 10 vm.vfs_cache_pressure = 100 EOF - cat > /etc/sysctl.d/99-fs.conf << EOF + pmx_write_file /etc/sysctl.d/99-fs.conf << EOF # ProxMenux configuration fs.nr_open = 2097152 fs.file-max = 2097152 @@ -452,21 +475,25 @@ EOF # ========================================================== optimize_memory_settings() { local FUNC_VERSION="1.2" + pmx_journal_context "optimize_memory_settings" "$FUNC_VERSION" # description: Tune swappiness, dirty page ratios and compaction proactiveness for VM hosts without overriding the kernel's memory-overcommit policy. msg_info "$(translate "Optimizing memory settings...")" NECESSARY_REBOOT=1 - cat < /etc/sysctl.d/99-memory.conf + local memory_settings + memory_settings="$(cat <> /etc/sysctl.d/99-memory.conf + memory_settings+=$'\n''vm.compaction_proactiveness = 20' fi + printf '%s\n' "$memory_settings" | pmx_write_file /etc/sysctl.d/99-memory.conf msg_ok "$(translate "Memory optimization completed.")" register_tool "memory_settings" true "$FUNC_VERSION" @@ -475,11 +502,12 @@ EOF # ========================================================== configure_kernel_panic() { local FUNC_VERSION="1.0" + pmx_journal_context "configure_kernel_panic" "$FUNC_VERSION" # description: Auto-reboot on kernel panic / oops / hardlockup; write crash dumps to /var/crash. msg_info "$(translate "Configuring kernel panic behavior")" NECESSARY_REBOOT=1 - cat < /etc/sysctl.d/99-kernelpanic.conf + pmx_write_file /etc/sysctl.d/99-kernelpanic.conf < /etc/sysctl.d/99-network.conf + pmx_write_file /etc/sysctl.d/99-network.conf <<'EOF' # ========================================================== # ProxMenux - Network tuning (PVE 9 compatible) # ========================================================== @@ -555,9 +584,10 @@ net.ipv4.tcp_wmem = 8192 65536 16777216 net.unix.max_dgram_qlen = 4096 EOF + pmx_record_execution "Apply network sysctl configuration" "sysctl --system" sysctl --system > /dev/null 2>&1 - cat > /usr/local/sbin/proxmenux-fwbr-tune <<'EOF' + pmx_write_file /usr/local/sbin/proxmenux-fwbr-tune <<'EOF' #!/usr/bin/env bash # Set rp_filter=0 and log_martians=0 on Proxmox fw bridge interfaces. # No arg → sweep every interface currently under /proc/sys/net/ipv4/conf/. @@ -588,7 +618,7 @@ EOF chmod 0755 /usr/local/sbin/proxmenux-fwbr-tune chown root:root /usr/local/sbin/proxmenux-fwbr-tune - cat > /etc/systemd/system/proxmenux-fwbr-tune.service <<'EOF' + pmx_write_file /etc/systemd/system/proxmenux-fwbr-tune.service <<'EOF' [Unit] Description=ProxMenux - Tune rp_filter/log_martians on virtual fw bridges After=network-online.target @@ -603,7 +633,7 @@ RemainAfterExit=yes WantedBy=multi-user.target EOF - cat > /etc/udev/rules.d/99-proxmenux-fwbr-tune.rules <<'EOF' + pmx_write_file /etc/udev/rules.d/99-proxmenux-fwbr-tune.rules <<'EOF' ACTION=="add", SUBSYSTEM=="net", KERNEL=="fwbr*", RUN+="/usr/local/sbin/proxmenux-fwbr-tune %k" ACTION=="add", SUBSYSTEM=="net", KERNEL=="fwln*", RUN+="/usr/local/sbin/proxmenux-fwbr-tune %k" ACTION=="add", SUBSYSTEM=="net", KERNEL=="fwpr*", RUN+="/usr/local/sbin/proxmenux-fwbr-tune %k" @@ -612,15 +642,18 @@ EOF chmod 0644 /etc/udev/rules.d/99-proxmenux-fwbr-tune.rules chown root:root /etc/udev/rules.d/99-proxmenux-fwbr-tune.rules + pmx_record_execution "Reload systemd configuration" "systemctl daemon-reload" systemctl daemon-reload >/dev/null 2>&1 || true + pmx_record_execution "Reload udev rules" "udevadm control --reload-rules" udevadm control --reload-rules >/dev/null 2>&1 || true - systemctl enable --now proxmenux-fwbr-tune.service >/dev/null 2>&1 || true + pmx_enable_service proxmenux-fwbr-tune.service || true + pmx_record_execution "Tune existing Proxmox firewall bridge interfaces" "/usr/local/sbin/proxmenux-fwbr-tune" /usr/local/sbin/proxmenux-fwbr-tune >/dev/null 2>&1 || true local interfaces_file="/etc/network/interfaces" if ! grep -q 'source /etc/network/interfaces.d/*' "$interfaces_file"; then - echo "source /etc/network/interfaces.d/*" >> "$interfaces_file" + echo "source /etc/network/interfaces.d/*" | pmx_append_file "$interfaces_file" fi msg_ok "$(translate "Network optimization completed")" @@ -759,6 +792,7 @@ PY customize_bashrc() { local FUNC_VERSION="1.2" + pmx_journal_context "customize_bashrc" "$FUNC_VERSION" # description: Install and safely migrate the managed ProxMenux Bash prompt and aliases while preserving or selecting the short/full working-directory style. msg_info "$(translate "Customizing bashrc for root user...")" local bashrc="/root/.bashrc" @@ -768,7 +802,7 @@ customize_bashrc() { local prompt_path_escape='\W' local detected_path_style="short" - [[ -f "$bashrc" ]] || touch "$bashrc" + [[ -f "$bashrc" ]] || pmx_write_file "$bashrc" < /dev/null if ! detected_path_style="$(_migrate_proxmenux_bashrc "$bashrc" inspect)"; then msg_error "$(translate "Failed to inspect the existing ProxMenux Bash configuration.")" return 1 @@ -791,13 +825,19 @@ customize_bashrc() { esac [ -f "${bashrc}.bak" ] || cp "$bashrc" "${bashrc}.bak" > /dev/null 2>&1 - if ! _migrate_proxmenux_bashrc "$bashrc" migrate >/dev/null; then + local migrated_bashrc + migrated_bashrc="$(mktemp)" + cp -p "$bashrc" "$migrated_bashrc" + if ! _migrate_proxmenux_bashrc "$migrated_bashrc" migrate >/dev/null; then + rm -f "$migrated_bashrc" msg_error "$(translate "Failed to migrate the existing ProxMenux Bash configuration.")" return 1 fi + pmx_write_file "$bashrc" < "$migrated_bashrc" + rm -f "$migrated_bashrc" - cat >> "$bashrc" << EOF + pmx_append_file "$bashrc" << EOF ${marker_begin} # ProxMenux core customizations export HISTTIMEFORMAT="%d/%m/%y %T " @@ -815,7 +855,7 @@ EOF if ! grep -q "source /root/.bashrc" "$bash_profile" 2>/dev/null; then - echo "source /root/.bashrc" >> "$bash_profile" 2>/dev/null + echo "source /root/.bashrc" | pmx_append_file "$bash_profile" 2>/dev/null fi msg_ok "$(translate "Bashrc customization completed")" @@ -839,6 +879,7 @@ _update_existing_log2ram_auto() { local func_version="$1" local log2ram_bin="" local candidate resolved tmp_file + pmx_journal_context "_update_existing_log2ram_auto" "$func_version" msg_ok "$(translate "Log2RAM already registered — updating to latest configuration")" @@ -862,10 +903,7 @@ _update_existing_log2ram_auto() { if grep -q 'rsync -aAXv ' "$log2ram_bin" 2>/dev/null; then [[ -e "${log2ram_bin}.proxmenux.bak" ]] || cp -a "$log2ram_bin" "${log2ram_bin}.proxmenux.bak" - tmp_file="$(mktemp "${log2ram_bin}.proxmenux.XXXXXX")" || return 1 - cp -a "$log2ram_bin" "$tmp_file" - sed -i 's/rsync -aAXv /rsync -aXv --no-acls /g' "$tmp_file" - mv -f "$tmp_file" "$log2ram_bin" + sed 's/rsync -aAXv /rsync -aXv --no-acls /g' "$log2ram_bin" | pmx_write_file "$log2ram_bin" fi if dpkg-query -W -f='${Status}' proxmox-backup-server 2>/dev/null \ @@ -884,7 +922,8 @@ _update_existing_log2ram_auto() { EOF chmod 0644 "$tmp_file" chown root:root "$tmp_file" - mv -f "$tmp_file" /etc/logrotate.d/proxmox-backup-api + pmx_write_file /etc/logrotate.d/proxmox-backup-api < "$tmp_file" + rm -f "$tmp_file" tmp_file="$(mktemp /etc/cron.hourly/.proxmox-backup-logrotate.XXXXXX)" || return 1 cat > "$tmp_file" <<'EOF' @@ -893,7 +932,10 @@ EOF EOF chmod 0755 "$tmp_file" chown root:root "$tmp_file" - mv -f "$tmp_file" /etc/cron.hourly/proxmox-backup-logrotate + pmx_write_file /etc/cron.hourly/proxmox-backup-logrotate < "$tmp_file" + chmod 0755 /etc/cron.hourly/proxmox-backup-logrotate + chown root:root /etc/cron.hourly/proxmox-backup-logrotate + rm -f "$tmp_file" msg_ok "$(translate "PBS API log rotation configured (hourly, size-based)")" fi @@ -945,7 +987,10 @@ EOF chmod 0755 "$tmp_file" chown root:root "$tmp_file" bash -n "$tmp_file" || return 1 - mv -f "$tmp_file" /usr/local/bin/log2ram-check.sh + pmx_write_file /usr/local/bin/log2ram-check.sh < "$tmp_file" + chmod 0755 /usr/local/bin/log2ram-check.sh + chown root:root /usr/local/bin/log2ram-check.sh + rm -f "$tmp_file" tmp_file="$(mktemp /etc/cron.d/.log2ram-auto-sync.XXXXXX)" || return 1 cat > "$tmp_file" <<'EOF' @@ -958,7 +1003,10 @@ MAILTO="" EOF chmod 0644 "$tmp_file" chown root:root "$tmp_file" - mv -f "$tmp_file" /etc/cron.d/log2ram-auto-sync + pmx_write_file /etc/cron.d/log2ram-auto-sync < "$tmp_file" + chmod 0644 /etc/cron.d/log2ram-auto-sync + chown root:root /etc/cron.d/log2ram-auto-sync + rm -f "$tmp_file" register_tool "log2ram" true "$func_version" msg_success "$(translate "Log2RAM installation and configuration completed successfully.")" @@ -968,6 +1016,7 @@ EOF install_log2ram_auto() { local FUNC_VERSION="1.5" local existing_log2ram_bin="" + pmx_journal_context "install_log2ram_auto" "$FUNC_VERSION" # description: Install Log2RAM with size auto-tuned to host RAM (128M/256M/512M); SSD/M.2 detection skips on rotational disks. @@ -1024,31 +1073,40 @@ install_log2ram_auto() { msg_info "$(translate "Cleaning previous Log2RAM installation...")" - systemctl stop log2ram log2ram-daily.timer >/dev/null 2>&1 || true - systemctl disable log2ram log2ram-daily.timer >/dev/null 2>&1 || true + pmx_disable_service log2ram || true + pmx_disable_service log2ram-daily.timer || true - rm -f /etc/cron.d/log2ram /etc/cron.d/log2ram-auto-sync \ - /etc/cron.hourly/log2ram /etc/cron.daily/log2ram \ - /etc/cron.weekly/log2ram /etc/cron.monthly/log2ram 2>/dev/null || true - rm -f /usr/local/bin/log2ram-check.sh /usr/local/bin/log2ram /usr/sbin/log2ram 2>/dev/null || true - rm -f /etc/systemd/system/log2ram.service \ - /etc/systemd/system/log2ram-daily.timer \ - /etc/systemd/system/log2ram-daily.service \ - /etc/systemd/system/sysinit.target.wants/log2ram.service 2>/dev/null || true + local obsolete_path + for obsolete_path in \ + /etc/cron.d/log2ram /etc/cron.d/log2ram-auto-sync \ + /etc/cron.hourly/log2ram /etc/cron.daily/log2ram \ + /etc/cron.weekly/log2ram /etc/cron.monthly/log2ram \ + /usr/local/bin/log2ram-check.sh /usr/local/bin/log2ram /usr/sbin/log2ram \ + /etc/systemd/system/log2ram.service \ + /etc/systemd/system/log2ram-daily.timer \ + /etc/systemd/system/log2ram-daily.service \ + /etc/systemd/system/sysinit.target.wants/log2ram.service \ + /etc/log2ram.conf /etc/log2ram.conf.* /etc/logrotate.d/log2ram + do + pmx_remove_file "$obsolete_path" 2>/dev/null || true + done rm -rf /etc/systemd/system/log2ram.service.d 2>/dev/null || true - rm -f /etc/log2ram.conf* 2>/dev/null || true - rm -rf /etc/logrotate.d/log2ram /var/log.hdd /tmp/log2ram 2>/dev/null || true + rm -rf /var/log.hdd /tmp/log2ram 2>/dev/null || true + pmx_record_execution "Re-execute the systemd manager" "systemctl daemon-reexec" systemctl daemon-reexec >/dev/null 2>&1 || true + pmx_record_execution "Reload systemd configuration" "systemctl daemon-reload" systemctl daemon-reload >/dev/null 2>&1 || true + pmx_record_execution "Restart cron" "systemctl restart cron" systemctl restart cron >/dev/null 2>&1 || true msg_ok "$(translate "Previous installation cleaned")" msg_info "$(translate "Installing Log2RAM from source...")" if ! command -v git >/dev/null 2>&1; then + pmx_record_execution "Update package lists for Log2RAM" "apt-get update -qq" apt-get update -qq >/dev/null 2>&1 - apt-get install -y git >/dev/null 2>&1 + pmx_install_pkg git fi rm -rf /tmp/log2ram 2>/dev/null || true @@ -1059,6 +1117,7 @@ install_log2ram_auto() { cd /tmp/log2ram || { msg_error "$(translate "Failed to access log2ram directory")"; return 1; } + pmx_record_execution "Run the Log2RAM installer" "bash install.sh" if ! bash install.sh >>/tmp/log2ram_install.log 2>&1; then msg_error "$(translate "Failed to run log2ram installer. Check /tmp/log2ram_install.log")" return 1 @@ -1077,7 +1136,7 @@ install_log2ram_auto() { [[ -n "$_l2r_bin" && -f "$_l2r_bin" ]] || continue if grep -q 'rsync -aAXv ' "$_l2r_bin" 2>/dev/null; then cp -a "$_l2r_bin" "${_l2r_bin}.proxmenux.bak" - sed -i 's/rsync -aAXv /rsync -aXv --no-acls /g' "$_l2r_bin" + pmx_edit_file "$_l2r_bin" 's/rsync -aAXv /rsync -aXv --no-acls /g' fi break done @@ -1088,7 +1147,7 @@ install_log2ram_auto() { if dpkg-query -W -f='${Status}' proxmox-backup-server 2>/dev/null \ | grep -q 'install ok installed'; then mkdir -p /var/log/proxmox-backup/api 2>/dev/null || true - cat > /etc/logrotate.d/proxmox-backup-api <<'EOF' + pmx_write_file /etc/logrotate.d/proxmox-backup-api <<'EOF' /var/log/proxmox-backup/api/access.log /var/log/proxmox-backup/api/auth.log { size 20M rotate 3 @@ -1101,7 +1160,7 @@ install_log2ram_auto() { EOF chmod 0644 /etc/logrotate.d/proxmox-backup-api chown root:root /etc/logrotate.d/proxmox-backup-api - cat > /etc/cron.hourly/proxmox-backup-logrotate <<'EOF' + pmx_write_file /etc/cron.hourly/proxmox-backup-logrotate <<'EOF' #!/bin/sh /usr/sbin/logrotate /etc/logrotate.d/proxmox-backup-api >/dev/null 2>&1 EOF @@ -1110,6 +1169,7 @@ EOF msg_ok "$(translate "PBS API log rotation configured (hourly, size-based)")" fi + pmx_record_execution "Reload systemd configuration" "systemctl daemon-reload" systemctl daemon-reload >/dev/null 2>&1 || true if [[ -f /etc/log2ram.conf ]] && command -v log2ram >/dev/null 2>&1; then @@ -1131,11 +1191,11 @@ EOF fi msg_ok "$(translate "Detected RAM:") $RAM_SIZE_GB GB — $(translate "Log2RAM size set to:") $LOG2RAM_SIZE" - sed -i "s/^SIZE=.*/SIZE=$LOG2RAM_SIZE/" /etc/log2ram.conf + pmx_edit_file /etc/log2ram.conf "s/^SIZE=.*/SIZE=$LOG2RAM_SIZE/" LOG2RAM_BIN="$(command -v log2ram || echo /usr/sbin/log2ram)" - cat > /etc/cron.d/log2ram < /usr/local/bin/log2ram-check.sh <<'EOF' + pmx_write_file /usr/local/bin/log2ram-check.sh <<'EOF' #!/usr/bin/env bash # Watch /var/log usage on Log2RAM's tmpfs and act at two thresholds: # > 80% → vacuum journald down to ~30% of SIZE, then log2ram write @@ -1196,7 +1256,7 @@ fi EOF chmod +x /usr/local/bin/log2ram-check.sh - cat > /etc/cron.d/log2ram-auto-sync <<'EOF' + pmx_write_file /etc/cron.d/log2ram-auto-sync <<'EOF' # Log2RAM auto-sync based on /var/log usage - Created by ProxMenux SHELL=/bin/bash PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin @@ -1207,6 +1267,7 @@ EOF chmod 0644 /etc/cron.d/log2ram-auto-sync chown root:root /etc/cron.d/log2ram-auto-sync + pmx_record_execution "Restart cron" "systemctl restart cron" systemctl restart cron >/dev/null 2>&1 || true msg_ok "$(translate "Auto-sync enabled when /var/log exceeds 80% of") $LOG2RAM_SIZE" @@ -1232,8 +1293,8 @@ EOF [ "$KEEP_MB" -lt 8 ] && KEEP_MB=8 - sed -i '/^\[Journal\]/,$d' /etc/systemd/journald.conf 2>/dev/null || true - tee -a /etc/systemd/journald.conf >/dev/null </dev/null || true + pmx_append_file /etc/systemd/journald.conf </dev/null 2>&1 || true - if ! systemctl enable log2ram >/dev/null 2>&1; then + if ! pmx_apply_setting "service-enabled:log2ram" "systemctl is-enabled log2ram" \ + systemctl enable log2ram; then msg_error "$(translate "Log2RAM installation verification failed. Check /tmp/log2ram_install.log")" return 1 fi diff --git a/scripts/post_install/customizable_post_install.sh b/scripts/post_install/customizable_post_install.sh index b3ee583e..fbeea07b 100644 --- a/scripts/post_install/customizable_post_install.sh +++ b/scripts/post_install/customizable_post_install.sh @@ -58,6 +58,11 @@ fi if [[ -f "$LOCAL_SCRIPTS/global/utils-install-functions.sh" ]]; then source "$LOCAL_SCRIPTS/global/utils-install-functions.sh" fi +# Recording is part of writing: sourced before any function runs so a +# change made without it is a mistake we can find, not one we can make. +if [[ -f "$LOCAL_SCRIPTS/global/pmx_journal.sh" ]]; then + source "$LOCAL_SCRIPTS/global/pmx_journal.sh" +fi # ========================================================== @@ -89,6 +94,18 @@ register_tool() { local state="$2" local version="${3:-1.0}" local source="${4:-${SCRIPT_SOURCE:-unknown}}" + # Recorded here rather than in each function: this is the one call the + # whole of post-install already makes, so every applied tool reaches + # the journal even where the function itself still writes directly. + # Such an entry says what was applied and admits it cannot say what + # changed, which is the honest account for anything not yet migrated. + if declare -F pmx_record_applied >/dev/null 2>&1; then + PMX_JOURNAL_FUNCTION="${FUNCNAME[1]:-$tool}" \ + PMX_JOURNAL_VERSION="$version" \ + PMX_JOURNAL_SOURCE="$source" \ + pmx_record_applied "$tool" "$version" \ + "$([[ "$state" == "true" ]] && echo applied || echo removed)" + fi ensure_tools_json if [[ "$state" == "true" ]]; then jq --arg t "$tool" --arg ver "$version" --arg src "$source" \ @@ -157,17 +174,20 @@ $(translate "Do you want to continue anyway?")" 13 70 enable_kexec() { local FUNC_VERSION="1.1" + pmx_journal_context "enable_kexec" "$FUNC_VERSION" # description: Install kexec-tools and add a Ctrl+Alt+K hotkey + systemd unit for fast reboots that skip BIOS/POST. msg_info2 "$(translate "Configuring kexec for quick reboots...")" NECESSARY_REBOOT=1 # Set default answers for debconf - echo "kexec-tools kexec-tools/load_kexec boolean false" | debconf-set-selections > /dev/null 2>&1 + pmx_apply_setting "kexec-tools/load_kexec" \ + "debconf-show kexec-tools 2>/dev/null | grep -E '^[* ]*kexec-tools/load_kexec:'" \ + bash -c 'echo "kexec-tools kexec-tools/load_kexec boolean false" | debconf-set-selections' msg_info "$(translate "Installing kexec-tools...")" # Install kexec-tools without showing output if ! dpkg -s kexec-tools >/dev/null 2>&1; then - /usr/bin/env DEBIAN_FRONTEND=noninteractive apt-get -y -o Dpkg::Options::='--force-confdef' install kexec-tools > /dev/null 2>&1 + pmx_install_pkg kexec-tools msg_ok "$(translate "kexec-tools installed successfully")" else msg_ok "$(translate "kexec-tools is already installed")" @@ -176,7 +196,7 @@ enable_kexec() { # Create systemd service file local service_file="/etc/systemd/system/kexec-pve.service" if [ ! -f "$service_file" ]; then - cat <<'EOF' > "$service_file" + pmx_write_file "$service_file" <<'EOF' [Unit] Description=Loading new kernel into memory Documentation=man:kexec(8) @@ -200,18 +220,20 @@ EOF # Enable the service if ! systemctl is-enabled kexec-pve.service > /dev/null 2>&1; then - systemctl enable kexec-pve.service > /dev/null 2>&1 + pmx_apply_setting "kexec-pve.service enablement" \ + "systemctl is-enabled kexec-pve.service 2>/dev/null" \ + systemctl enable kexec-pve.service msg_ok "$(translate "kexec-pve service enabled")" else msg_ok "$(translate "kexec-pve service is already enabled")" fi if [ ! -f /root/.bash_profile ]; then - touch /root/.bash_profile + pmx_write_file /root/.bash_profile < /dev/null fi if ! grep -q "alias reboot-quick='systemctl kexec'" /root/.bash_profile; then - echo "alias reboot-quick='systemctl kexec'" >> /root/.bash_profile + echo "alias reboot-quick='systemctl kexec'" | pmx_append_file /root/.bash_profile msg_ok "$(translate "reboot-quick alias added")" else msg_ok "$(translate "reboot-quick alias is already configured")" @@ -301,9 +323,12 @@ MaxLevelConsole=notice MaxLevelWall=crit EOF - # Compare the current configuration with the new one + # This function already declines to write when nothing differs; the + # journal replaces the move so the audit sees what was replaced. if ! cmp -s "$journald_conf" "/tmp/journald.conf.new"; then - mv "/tmp/journald.conf.new" "$journald_conf" + pmx_journal_context "optimize_journald" "$FUNC_VERSION" + pmx_write_file "$journald_conf" < /tmp/journald.conf.new + rm -f "/tmp/journald.conf.new" config_changed=true else rm "/tmp/journald.conf.new" @@ -349,8 +374,10 @@ configure_kernel_panic() { msg_info "$(translate "Updating kernel panic configuration...")" - # Create or update the configuration file - cat < "$config_file" + # Written through the journal, so the audit can show what this + # replaced — or that the file did not exist before. + pmx_journal_context "configure_kernel_panic" "$FUNC_VERSION" + pmx_write_file "$config_file" < "$temp_file" fi echo -e "# ProxMenux configuration\n$content" >> "$temp_file" - mv "$temp_file" "$file" + pmx_write_file "$file" < "$temp_file" + rm -f "$temp_file" } # Increase max user watches @@ -426,7 +455,7 @@ kernel.keys.maxkeys=1000000" msg_info "$(translate "Setting systemd ulimits...")" for file in /etc/systemd/system.conf /etc/systemd/user.conf; do if ! grep -q "^DefaultLimitNOFILE=" "$file"; then - echo "DefaultLimitNOFILE=1048576" >> "$file" + echo "DefaultLimitNOFILE=1048576" | pmx_append_file "$file" fi done msg_ok "$(translate "Systemd ulimits set")" @@ -435,7 +464,7 @@ kernel.keys.maxkeys=1000000" msg_info "$(translate "Configuring PAM limits...")" for file in /etc/pam.d/common-session /etc/pam.d/runuser-l; do if ! grep -q "^session required pam_limits.so" "$file"; then - echo 'session required pam_limits.so' >> "$file" + echo 'session required pam_limits.so' | pmx_append_file "$file" fi done msg_ok "$(translate "PAM limits configured")" @@ -443,8 +472,8 @@ kernel.keys.maxkeys=1000000" # Set ulimit for the shell user msg_info "$(translate "Setting ulimit for the shell user...")" if ! grep -q "ulimit -n 1048576" /root/.profile; then - sed -i '/ulimit -n 256000/d' /root/.profile 2>/dev/null - echo "ulimit -n 1048576" >> /root/.profile + pmx_edit_file /root/.profile '/ulimit -n 256000/d' 2>/dev/null || true + echo "ulimit -n 1048576" | pmx_append_file /root/.profile fi msg_ok "$(translate "Shell user ulimit set")" @@ -477,6 +506,7 @@ fs.aio-max-nr = 1048576" skip_apt_languages() { local FUNC_VERSION="1.0" + pmx_journal_context "skip_apt_languages" "$FUNC_VERSION" # description: Stop APT from downloading translation files to speed up updates. msg_info2 "$(translate "Configuring APT to skip downloading additional languages")" @@ -499,9 +529,10 @@ skip_apt_languages() { if ! locale -a | grep -qi "^$normalized_locale$"; then # Only add to locale.gen if missing if ! grep -qE "^${default_locale}[[:space:]]+UTF-8" /etc/locale.gen; then - echo "$default_locale UTF-8" >> /etc/locale.gen + echo "$default_locale UTF-8" | pmx_append_file /etc/locale.gen fi msg_info "$(translate "Generating missing locale:") $default_locale" + pmx_record_execution "Generate locale" "locale-gen $default_locale" locale-gen "$default_locale" msg_ok "$(translate "Locale generated")" fi @@ -514,7 +545,7 @@ skip_apt_languages() { if [ -f "$config_file" ] && grep -Fxq "$config_content" "$config_file"; then msg_ok "$(translate "APT language configuration already set")" else - echo "$config_content" > "$config_file" + printf '%s\n' "$config_content" | pmx_write_file "$config_file" msg_ok "$(translate "APT language configuration updated")" fi @@ -537,6 +568,7 @@ skip_apt_languages() { configure_time_sync() { local FUNC_VERSION="1.0" + pmx_journal_context "configure_time_sync" "$FUNC_VERSION" # description: Detect timezone from public IP and enable systemd time sync (NTP). msg_info2 "$(translate "Configuring system time settings...")" @@ -564,13 +596,18 @@ configure_time_sync() { msg_ok "$(translate "Found timezone $timezone for IP $this_ip")" + pmx_apply_setting "timezone" "timedatectl show -p Timezone --value" \ + timedatectl set-timezone "$timezone" if timedatectl set-timezone "$timezone"; then msg_ok "$(translate "Timezone set to $timezone")" + pmx_apply_setting "ntp" "timedatectl show -p NTP --value" \ + timedatectl set-ntp true if timedatectl set-ntp true; then msg_ok "$(translate "Time settings configured - Timezone:") $timezone" register_tool "time_sync" true "$FUNC_VERSION" + pmx_record_execution "Restart Postfix" "systemctl restart postfix" systemctl restart postfix 2>/dev/null || true else msg_warn "$(translate "Failed to enable automatic time synchronization")" @@ -643,6 +680,7 @@ configure_time_sync() { apply_amd_fixes() { local FUNC_VERSION="1.0" + pmx_journal_context "apply_amd_fixes" "$FUNC_VERSION" # description: Detect AMD EPYC/Ryzen CPUs and apply microcode + IOMMU + KVM-specific kernel boot params. msg_info2 "$(translate "Detecting AMD CPU and applying fixes if necessary...")" NECESSARY_REBOOT=1 @@ -674,13 +712,14 @@ apply_amd_fixes() { if ! grep -qw "$added_param" "$cmdline_file"; then cp "$cmdline_file" "${cmdline_file}.bak" - sed -i "s|\s*$| $added_param|" "$cmdline_file" + pmx_edit_file "$cmdline_file" "s|\s*$| $added_param|" msg_ok "$(translate "Added '$added_param' to /etc/kernel/cmdline")" else msg_ok "$(translate "'$added_param' already present in /etc/kernel/cmdline")" fi if command -v proxmox-boot-tool >/dev/null 2>&1; then + pmx_record_execution "Refresh Proxmox boot configuration" "proxmox-boot-tool refresh" proxmox-boot-tool refresh >/dev/null 2>&1 && \ msg_ok "$(translate "proxmox-boot-tool refreshed")" || \ msg_warn "$(translate "Failed to refresh proxmox-boot-tool")" @@ -689,18 +728,19 @@ apply_amd_fixes() { # GRUB (no ZFS) if [[ -f "$grub_file" ]]; then - grep -q '^GRUB_CMDLINE_LINUX_DEFAULT="' "$grub_file" || echo 'GRUB_CMDLINE_LINUX_DEFAULT=""' >> "$grub_file" + grep -q '^GRUB_CMDLINE_LINUX_DEFAULT="' "$grub_file" || echo 'GRUB_CMDLINE_LINUX_DEFAULT=""' | pmx_append_file "$grub_file" if ! grep -q 'GRUB_CMDLINE_LINUX_DEFAULT=' "$grub_file"; then msg_warn "$(translate "GRUB_CMDLINE_LINUX_DEFAULT not found in GRUB config")" else if ! grep -q "GRUB_CMDLINE_LINUX_DEFAULT=.*\b$added_param\b" "$grub_file"; then cp "$grub_file" "${grub_file}.bak" - sed -i "s/^\(GRUB_CMDLINE_LINUX_DEFAULT=\"[^\"]*\)\"/\1 $added_param\"/" "$grub_file" + pmx_edit_file "$grub_file" "s/^\(GRUB_CMDLINE_LINUX_DEFAULT=\"[^\"]*\)\"/\1 $added_param\"/" msg_ok "$(translate "Added '$added_param' to GRUB_CMDLINE_LINUX_DEFAULT")" else msg_ok "$(translate "'$added_param' already present in GRUB_CMDLINE_LINUX_DEFAULT")" fi + pmx_record_execution "Regenerate GRUB configuration" "update-grub" update-grub >/dev/null 2>&1 && \ msg_ok "$(translate "GRUB configuration updated")" || \ msg_warn "$(translate "Failed to update GRUB")" @@ -712,22 +752,22 @@ apply_amd_fixes() { local kvm_conf="/etc/modprobe.d/kvm.conf" - touch "$kvm_conf" + [[ -f "$kvm_conf" ]] || pmx_write_file "$kvm_conf" < /dev/null if ! grep -q "^options kvm " "$kvm_conf"; then - echo "options kvm ignore_msrs=Y report_ignored_msrs=N" >> "$kvm_conf" + echo "options kvm ignore_msrs=Y report_ignored_msrs=N" | pmx_append_file "$kvm_conf" msg_ok "$(translate "KVM MSR options added to /etc/modprobe.d/kvm.conf")" else if ! grep -q "ignore_msrs=" "$kvm_conf"; then - sed -i 's/^options kvm /options kvm ignore_msrs=Y /' "$kvm_conf" + pmx_edit_file "$kvm_conf" 's/^options kvm /options kvm ignore_msrs=Y /' else - sed -i 's/ignore_msrs=[YNyn]/ignore_msrs=Y/' "$kvm_conf" + pmx_edit_file "$kvm_conf" 's/ignore_msrs=[YNyn]/ignore_msrs=Y/' fi if ! grep -q "report_ignored_msrs=" "$kvm_conf"; then - sed -i 's/^options kvm .*/& report_ignored_msrs=N/' "$kvm_conf" + pmx_edit_file "$kvm_conf" 's/^options kvm .*/& report_ignored_msrs=N/' else - sed -i 's/report_ignored_msrs=[YNyn]/report_ignored_msrs=N/' "$kvm_conf" + pmx_edit_file "$kvm_conf" 's/report_ignored_msrs=[YNyn]/report_ignored_msrs=N/' fi msg_ok "$(translate "KVM MSR options ensured in /etc/modprobe.d/kvm.conf")" fi @@ -780,11 +820,12 @@ force_apt_ipv4() { apply_network_optimizations() { local FUNC_VERSION="1.2" + pmx_journal_context "apply_network_optimizations" "$FUNC_VERSION" # description: Tune TCP buffers, somaxconn, IPv4 hardening and disable rp_filter on fw bridges (PVE 9 compatible). msg_info "$(translate "Optimizing network settings...")" NECESSARY_REBOOT=1 - cat <<'EOF' > /etc/sysctl.d/99-network.conf + pmx_write_file /etc/sysctl.d/99-network.conf <<'EOF' # ========================================================== # ProxMenux - Network tuning (PVE 9 compatible) # ========================================================== @@ -838,9 +879,10 @@ net.unix.max_dgram_qlen = 4096 EOF + pmx_record_execution "Apply network sysctl configuration" "sysctl --system" sysctl --system > /dev/null 2>&1 - cat > /usr/local/sbin/proxmenux-fwbr-tune <<'EOF' + pmx_write_file /usr/local/sbin/proxmenux-fwbr-tune <<'EOF' #!/usr/bin/env bash # Set rp_filter=0 and log_martians=0 on Proxmox fw bridge interfaces. # No arg → sweep every interface currently under /proc/sys/net/ipv4/conf/. @@ -871,7 +913,7 @@ EOF chmod 0755 /usr/local/sbin/proxmenux-fwbr-tune chown root:root /usr/local/sbin/proxmenux-fwbr-tune - cat > /etc/systemd/system/proxmenux-fwbr-tune.service <<'EOF' + pmx_write_file /etc/systemd/system/proxmenux-fwbr-tune.service <<'EOF' [Unit] Description=ProxMenux - Tune rp_filter/log_martians on virtual fw bridges After=network-online.target @@ -886,9 +928,9 @@ RemainAfterExit=yes WantedBy=multi-user.target EOF - rm -f /etc/udev/rules.d/99-proxmenux-fwbr-tune.rules + pmx_remove_file /etc/udev/rules.d/99-proxmenux-fwbr-tune.rules - cat > /etc/udev/rules.d/99-zz-proxmenux-fwbr-tune.rules <<'EOF' + pmx_write_file /etc/udev/rules.d/99-zz-proxmenux-fwbr-tune.rules <<'EOF' ACTION=="add", SUBSYSTEM=="net", KERNEL=="fwbr*", RUN+="/usr/local/sbin/proxmenux-fwbr-tune %k" ACTION=="add", SUBSYSTEM=="net", KERNEL=="fwln*", RUN+="/usr/local/sbin/proxmenux-fwbr-tune %k" ACTION=="add", SUBSYSTEM=="net", KERNEL=="fwpr*", RUN+="/usr/local/sbin/proxmenux-fwbr-tune %k" @@ -897,15 +939,18 @@ EOF chmod 0644 /etc/udev/rules.d/99-zz-proxmenux-fwbr-tune.rules chown root:root /etc/udev/rules.d/99-zz-proxmenux-fwbr-tune.rules + pmx_record_execution "Reload systemd configuration" "systemctl daemon-reload" systemctl daemon-reload >/dev/null 2>&1 || true + pmx_record_execution "Reload udev rules" "udevadm control --reload-rules" udevadm control --reload-rules >/dev/null 2>&1 || true - systemctl enable --now proxmenux-fwbr-tune.service >/dev/null 2>&1 || true + pmx_enable_service proxmenux-fwbr-tune.service || true + pmx_record_execution "Tune existing Proxmox firewall bridge interfaces" "/usr/local/sbin/proxmenux-fwbr-tune" /usr/local/sbin/proxmenux-fwbr-tune >/dev/null 2>&1 || true local interfaces_file="/etc/network/interfaces" if ! grep -q 'source /etc/network/interfaces.d/*' "$interfaces_file"; then - echo "source /etc/network/interfaces.d/*" >> "$interfaces_file" + echo "source /etc/network/interfaces.d/*" | pmx_append_file "$interfaces_file" fi msg_ok "$(translate "Network optimization completed")" @@ -971,6 +1016,7 @@ install_openvswitch() { enable_tcp_fast_open() { local FUNC_VERSION="1.0" + pmx_journal_context "enable_tcp_fast_open" "$FUNC_VERSION" # description: Enable TCP Fast Open (clients + server) and BBR congestion control for better latency under load. msg_info2 "$(translate "Configuring TCP optimizations...")" @@ -981,7 +1027,7 @@ enable_tcp_fast_open() { # Enable Google TCP BBR congestion control msg_info "$(translate "Enabling Google TCP BBR congestion control...")" if [ ! -f "$bbr_conf" ] || ! grep -q "net.ipv4.tcp_congestion_control = bbr" "$bbr_conf"; then - cat < "$bbr_conf" + pmx_write_file "$bbr_conf" < "$tfo_conf" + pmx_write_file "$tfo_conf" < /dev/null 2>&1 if [ "$reboot_needed" -eq 1 ]; then @@ -1028,6 +1075,7 @@ EOF install_ceph() { local FUNC_VERSION="1.1" + pmx_journal_context "install_ceph" "$FUNC_VERSION" # description: Install Ceph (client + server packages) for distributed RBD/CephFS storage; PVE 8/9 aware repo selection. msg_info2 "$(translate "Installing Ceph support...")" @@ -1070,12 +1118,12 @@ install_ceph() { # ========================================== - [ -f /etc/apt/sources.list.d/ceph-squid.list ] && rm -f /etc/apt/sources.list.d/ceph-squid.list - [ -f /etc/apt/sources.list.d/ceph.list ] && rm -f /etc/apt/sources.list.d/ceph.list + [ -f /etc/apt/sources.list.d/ceph-squid.list ] && pmx_remove_file /etc/apt/sources.list.d/ceph-squid.list + [ -f /etc/apt/sources.list.d/ceph.list ] && pmx_remove_file /etc/apt/sources.list.d/ceph.list # Create new deb822 format Ceph repository for PVE 9 msg_info "$(translate "Creating Ceph repository for PVE 9 (deb822 format)...")" - cat > /etc/apt/sources.list.d/ceph.sources << EOF + pmx_write_file /etc/apt/sources.list.d/ceph.sources << EOF Types: deb URIs: https://download.proxmox.com/debian/ceph-${ceph_version} Suites: ${target_codename} @@ -1092,13 +1140,14 @@ EOF # Use legacy format for PVE 8 msg_info "$(translate "Creating Ceph repository for PVE 8 (legacy format)...")" - echo "deb [signed-by=/usr/share/keyrings/proxmox-archive-keyring.gpg] https://download.proxmox.com/debian/ceph-${ceph_version} ${target_codename} no-subscription" > /etc/apt/sources.list.d/ceph-${ceph_version}.list + echo "deb [signed-by=/usr/share/keyrings/proxmox-archive-keyring.gpg] https://download.proxmox.com/debian/ceph-${ceph_version} ${target_codename} no-subscription" | pmx_write_file /etc/apt/sources.list.d/ceph-${ceph_version}.list msg_ok "$(translate "Ceph repository configured for PVE 8")" fi msg_info "$(translate "Updating package lists...")" + pmx_record_execution "Update package lists for Ceph" "apt-get update" update_output=$(apt-get update 2>&1) update_exit_code=$? @@ -1131,6 +1180,7 @@ EOF tput civis tput sc + pmx_record_execution "Install Ceph packages" "pveceph install" (pveceph install 2>&1 | \ while IFS= read -r line; do if [[ $line == *"Installing"* ]] || [[ $line == *"Unpacking"* ]] || [[ $line == *"Setting up"* ]] || [[ $line == *"Processing"* ]]; then @@ -1518,13 +1568,14 @@ update_snapshot_schedule() { local schedule_type="$2" local keep_value="$3" local frequency="$4" + pmx_journal_context "update_snapshot_schedule" "$FUNC_VERSION" if [ -f "$config_file" ]; then if ! grep -q ".*--keep=$keep_value" "$config_file"; then if [ -n "$frequency" ]; then - sed -i "s|^\*/[0-9]*.*--keep=[0-9]*|$frequency * * * * root /usr/sbin/zfs-auto-snapshot --quiet --syslog --label=$schedule_type --keep=$keep_value|" "$config_file" + pmx_edit_file "$config_file" "s|^\*/[0-9]*.*--keep=[0-9]*|$frequency * * * * root /usr/sbin/zfs-auto-snapshot --quiet --syslog --label=$schedule_type --keep=$keep_value|" else - sed -i "s|--keep=[0-9]*|--keep=$keep_value|g" "$config_file" + pmx_edit_file "$config_file" "s|--keep=[0-9]*|--keep=$keep_value|g" fi msg_ok "$(translate "Updated $schedule_type snapshot schedule")" else @@ -1577,7 +1628,9 @@ disable_rpc() { msg_info "$(translate "Disabling and stopping rpcbind service and socket...")" - systemctl disable --now rpcbind.socket rpcbind.service > /dev/null 2>&1 || true + pmx_journal_context "disable_rpc" "$FUNC_VERSION" + pmx_disable_service rpcbind.socket || true + pmx_disable_service rpcbind.service || true for unit in rpcbind.socket rpcbind.service; do active_state="$(systemctl is-active "$unit" 2>/dev/null || true)" @@ -1604,13 +1657,14 @@ disable_rpc() { configure_pigz() { local FUNC_VERSION="1.0" + pmx_journal_context "configure_pigz" "$FUNC_VERSION" # description: Replace gzip with pigz (parallel implementation) for faster vzdump backup compression. msg_info2 "$(translate "Configuring pigz as a faster replacement for gzip...")" # Enable pigz in vzdump configuration msg_info "$(translate "Enabling pigz in vzdump configuration...")" if ! grep -q "^pigz: 1" /etc/vzdump.conf; then - sed -i "s/#pigz:.*/pigz: 1/" /etc/vzdump.conf + pmx_edit_file /etc/vzdump.conf "s/#pigz:.*/pigz: 1/" msg_ok "$(translate "pigz enabled in vzdump configuration")" else msg_ok "$(translate "pigz enabled in vzdump configuration")" @@ -1619,7 +1673,7 @@ configure_pigz() { # Install pigz if ! dpkg -s pigz >/dev/null 2>&1; then msg_info "$(translate "Installing pigz...")" - if /usr/bin/env DEBIAN_FRONTEND=noninteractive apt-get -y -o Dpkg::Options::='--force-confdef' install pigz > /dev/null 2>&1; then + if pmx_install_pkg pigz; then msg_ok "$(translate "pigz installed successfully")" else msg_error "$(translate "Failed to install pigz")" @@ -1638,7 +1692,7 @@ GZIP="-1" exec /usr/bin/pigz "\$@" EOF then - cat < /bin/pigzwrapper + pmx_write_file /bin/pigzwrapper <> "$modules_file" + echo "$module" | pmx_append_file "$modules_file" fi done msg_ok "$(translate "VFIO modules configured.")" @@ -1905,27 +1960,30 @@ enable_vfio_iommu() { # Blacklist conflicting drivers (sin cambios) local blacklist_file="/etc/modprobe.d/blacklist.conf" msg_info "$(translate "Checking conflicting drivers blacklist...")" - touch "$blacklist_file" + [[ -f "$blacklist_file" ]] || pmx_write_file "$blacklist_file" < /dev/null local blacklist_drivers=("nouveau" "lbm-nouveau" "radeon" "nvidia" "nvidiafb") for driver in "${blacklist_drivers[@]}"; do if ! grep -q "^blacklist $driver" "$blacklist_file"; then - echo "blacklist $driver" >> "$blacklist_file" + echo "blacklist $driver" | pmx_append_file "$blacklist_file" fi done if ! grep -q "options nouveau modeset=0" "$blacklist_file"; then - echo "options nouveau modeset=0" >> "$blacklist_file" + echo "options nouveau modeset=0" | pmx_append_file "$blacklist_file" fi msg_ok "$(translate "Conflicting drivers blacklisted successfully.")" # Update initramfs and bootloader msg_info "$(translate "Updating initramfs, GRUB, and EFI boot, patience...")" + pmx_record_execution "Regenerate initramfs" "update-initramfs -u -k all" update-initramfs -u -k all > /dev/null 2>&1 if [[ "$uses_zfs" == true ]]; then + pmx_record_execution "Refresh Proxmox boot configuration" "proxmox-boot-tool refresh" proxmox-boot-tool refresh > /dev/null 2>&1 else + pmx_record_execution "Regenerate GRUB configuration" "update-grub" update-grub > /dev/null 2>&1 fi @@ -2064,6 +2122,7 @@ PY customize_bashrc() { local FUNC_VERSION="1.2" + pmx_journal_context "customize_bashrc" "$FUNC_VERSION" # description: Install and safely migrate the managed ProxMenux Bash prompt and aliases while preserving or selecting the short/full working-directory style. msg_info2 "$(translate "Customizing bashrc for root user...")" @@ -2079,7 +2138,7 @@ customize_bashrc() { local choice="" local detected_path_style="short" - [[ -f "$bashrc" ]] || touch "$bashrc" + [[ -f "$bashrc" ]] || pmx_write_file "$bashrc" < /dev/null if ! detected_path_style="$(_migrate_proxmenux_bashrc "$bashrc" inspect)"; then msg_error "$(translate "Failed to inspect the existing ProxMenux Bash configuration.")" return 1 @@ -2123,13 +2182,19 @@ customize_bashrc() { esac [ -f "${bashrc}.bak" ] || cp "$bashrc" "${bashrc}.bak" > /dev/null 2>&1 - if ! _migrate_proxmenux_bashrc "$bashrc" migrate >/dev/null; then + local migrated_bashrc + migrated_bashrc="$(mktemp)" + cp -p "$bashrc" "$migrated_bashrc" + if ! _migrate_proxmenux_bashrc "$migrated_bashrc" migrate >/dev/null; then + rm -f "$migrated_bashrc" msg_error "$(translate "Failed to migrate the existing ProxMenux Bash configuration.")" return 1 fi + pmx_write_file "$bashrc" < "$migrated_bashrc" + rm -f "$migrated_bashrc" - cat >> "$bashrc" << EOF + pmx_append_file "$bashrc" << EOF ${marker_begin} # ProxMenux core customizations export HISTTIMEFORMAT="%d/%m/%y %T " @@ -2147,7 +2212,7 @@ EOF if ! grep -q "source /root/.bashrc" "$bash_profile" 2>/dev/null; then - echo "source /root/.bashrc" >> "$bash_profile" 2>/dev/null + echo "source /root/.bashrc" | pmx_append_file "$bash_profile" 2>/dev/null fi msg_ok "$(translate "Bashrc customization completed")" @@ -2168,6 +2233,7 @@ EOF setup_motd() { local FUNC_VERSION="1.0" + pmx_journal_context "setup_motd" "$FUNC_VERSION" # description: Add the ProxMenux MOTD banner while preserving the original file contents or absence for rollback. msg_info2 "$(translate "Configuring MOTD (Message of the Day) banner...")" @@ -2201,20 +2267,20 @@ setup_motd() { msg_ok "$(translate "Custom MOTD message is already configured")" else # Add the custom message at the beginning of the file - touch "$motd_file" + [[ -f "$motd_file" ]] || pmx_write_file "$motd_file" < /dev/null local motd_tmp motd_tmp="$(mktemp)" { printf '%s\n\n' "$custom_message" cat "$motd_file" } > "$motd_tmp" - cat "$motd_tmp" > "$motd_file" + pmx_write_file "$motd_file" < "$motd_tmp" rm -f "$motd_tmp" changes_made=true msg_ok "$(translate "Custom message added to MOTD")" fi - sed -i '/^$/N;/^\n$/D' "$motd_file" + pmx_edit_file "$motd_file" '/^$/N;/^\n$/D' if $changes_made; then msg_success "$(translate "MOTD configuration updated successfully")" @@ -2242,10 +2308,14 @@ optimize_logrotate() { local logrotate_conf="/etc/logrotate.conf" local backup_conf="${logrotate_conf}.bak" + # The .bak stays until reverting from the journal exists: + # uninstall_logrotate restores from it, and migrating the write must + # not quietly disable the rollback that is already shipping. cp -n "$logrotate_conf" "$backup_conf" 2>/dev/null || true msg_info "$(translate "Applying optimized logrotate configuration...")" - cat < "$logrotate_conf" + pmx_journal_context "optimize_logrotate" "$FUNC_VERSION" + pmx_write_file "$logrotate_conf" < "$sysctl_conf" + # Composed in full before writing: the journal records the file as + # it ends up, not a write followed by an append. + local memory_settings + memory_settings="$(cat <> "$sysctl_conf" + memory_settings+=$'\n''vm.compaction_proactiveness = 20' msg_ok "$(translate "Enabled memory compaction proactiveness")" fi + pmx_journal_context "optimize_memory_settings" "$FUNC_VERSION" + printf '%s\n' "$memory_settings" | pmx_write_file "$sysctl_conf" + msg_ok "$(translate "Memory settings optimized successfully")" msg_success "$(translate "Memory optimization completed.")" register_tool "memory_settings" true "$FUNC_VERSION" @@ -2363,6 +2440,7 @@ EOF optimize_vzdump() { local FUNC_VERSION="1.0" + pmx_journal_context "optimize_vzdump" "$FUNC_VERSION" # description: Lift vzdump bandwidth/IO limits so backups run at the storage's real throughput. msg_info2 "$(translate "Optimizing vzdump backup speed...")" @@ -2378,16 +2456,16 @@ optimize_vzdump() { # Configure bandwidth limit msg_info "$(translate "Configuring bandwidth limit for vzdump...")" if ! grep -q "^bwlimit: 0" "$vzdump_conf"; then - sed -i '/^#*bwlimit:/d' "$vzdump_conf" - echo "bwlimit: 0" >> "$vzdump_conf" + pmx_edit_file "$vzdump_conf" '/^#*bwlimit:/d' + echo "bwlimit: 0" | pmx_append_file "$vzdump_conf" fi msg_ok "$(translate "Bandwidth limit configured")" # Configure I/O priority msg_info "$(translate "Configuring I/O priority for vzdump...")" if ! grep -q "^ionice: 5" "$vzdump_conf"; then - sed -i '/^#*ionice:/d' "$vzdump_conf" - echo "ionice: 5" >> "$vzdump_conf" + pmx_edit_file "$vzdump_conf" '/^#*ionice:/d' + echo "ionice: 5" | pmx_append_file "$vzdump_conf" fi msg_ok "$(translate "I/O priority configured")" @@ -2470,6 +2548,7 @@ enable_ha() { configure_fastfetch() { local FUNC_VERSION="1.1" + pmx_journal_context "configure_fastfetch" "$FUNC_VERSION" # description: Install Fastfetch system summary tool with the ProxMenux logo + status block as the SSH login banner. msg_info2 "$(translate "Installing and configuring Fastfetch...")" @@ -2480,14 +2559,37 @@ configure_fastfetch() { local logos_dir="/usr/local/share/fastfetch/logos" local fastfetch_config="$fastfetch_config_dir/config.jsonc" + apply_fastfetch_config() { + local config_tmp status + config_tmp="$(mktemp)" + if jq "$@" "$fastfetch_config" > "$config_tmp"; then + pmx_write_file "$fastfetch_config" < "$config_tmp" + status=$? + else + status=$? + fi + rm -f "$config_tmp" + return "$status" + } + + download_fastfetch_logo() { + local path="$1" url="$2" + local -a statuses + wget -qO - "$url" | pmx_write_file "$path" + statuses=("${PIPESTATUS[@]}") + [[ "${statuses[0]}" -eq 0 && "${statuses[1]}" -eq 0 ]] + } + # Ensure directories exist mkdir -p "$fastfetch_config_dir" mkdir -p "$logos_dir" if command -v fastfetch &> /dev/null; then + pmx_record_execution "Remove existing Fastfetch package" "apt-get remove --purge -y fastfetch" apt-get remove --purge -y fastfetch > /dev/null 2>&1 - rm -f /usr/bin/fastfetch /usr/local/bin/fastfetch + pmx_remove_file /usr/bin/fastfetch + pmx_remove_file /usr/local/bin/fastfetch fi @@ -2512,7 +2614,9 @@ configure_fastfetch() { wget -qO /tmp/fastfetch.deb "$fastfetch_deb_url" + pmx_record_execution "Install Fastfetch package" "dpkg -i /tmp/fastfetch.deb" if dpkg -i /tmp/fastfetch.deb > /dev/null 2>&1; then + pmx_record_execution "Resolve Fastfetch package dependencies" "apt-get install -f -y" apt-get install -f -y > /dev/null 2>&1 msg_ok "$(translate "Fastfetch installed successfully")" else @@ -2531,9 +2635,10 @@ configure_fastfetch() { if [ ! -f "$fastfetch_config" ]; then - echo '{"$schema": "https://github.com/fastfetch-cli/fastfetch/raw/dev/doc/json_schema.json", "modules": []}' > "$fastfetch_config" + echo '{"$schema": "https://github.com/fastfetch-cli/fastfetch/raw/dev/doc/json_schema.json", "modules": []}' | pmx_write_file "$fastfetch_config" fi + pmx_record_execution "Generate Fastfetch configuration" "fastfetch --gen-config-force" fastfetch --gen-config-force > /dev/null 2>&1 while true; do @@ -2555,8 +2660,8 @@ configure_fastfetch() { 1) msg_info "$(translate "Downloading ProxMenux logo...")" local proxmenux_logo_path="$logos_dir/ProxMenux.txt" - if wget -qO "$proxmenux_logo_path" "https://raw.githubusercontent.com/MacRimi/ProxMenux/main/images/logos_txt/logo.txt"; then - jq --arg path "$proxmenux_logo_path" '. + {logo: $path}' "$fastfetch_config" > "${fastfetch_config}.tmp" && mv "${fastfetch_config}.tmp" "$fastfetch_config" + if download_fastfetch_logo "$proxmenux_logo_path" "https://raw.githubusercontent.com/MacRimi/ProxMenux/main/images/logos_txt/logo.txt"; then + apply_fastfetch_config --arg path "$proxmenux_logo_path" '. + {logo: $path}' msg_ok "$(translate "ProxMenux logo applied")" else msg_error "$(translate "Failed to download ProxMenux logo")" @@ -2565,15 +2670,15 @@ configure_fastfetch() { ;; 2) msg_info "$(translate "Using default Proxmox logo...")" - jq 'del(.logo)' "$fastfetch_config" > "${fastfetch_config}.tmp" && mv "${fastfetch_config}.tmp" "$fastfetch_config" + apply_fastfetch_config 'del(.logo)' msg_ok "$(translate "Default Proxmox logo applied")" break ;; 3) msg_info "$(translate "Downloading JC Channel logo...")" local jc_channel_logo_path="$logos_dir/jc_channel.txt" - if wget -qO "$jc_channel_logo_path" "https://raw.githubusercontent.com/MacRimi/ProxMenux/main/images/logos_txt/jc_channel.txt"; then - jq --arg path "$jc_channel_logo_path" '. + {logo: $path}' "$fastfetch_config" > "${fastfetch_config}.tmp" && mv "${fastfetch_config}.tmp" "$fastfetch_config" + if download_fastfetch_logo "$jc_channel_logo_path" "https://raw.githubusercontent.com/MacRimi/ProxMenux/main/images/logos_txt/jc_channel.txt"; then + apply_fastfetch_config --arg path "$jc_channel_logo_path" '. + {logo: $path}' msg_ok "$(translate "JC Channel logo applied")" else msg_error "$(translate "Failed to download JC Channel logo")" @@ -2583,8 +2688,8 @@ configure_fastfetch() { 4) msg_info "$(translate "Downloading Helper-Scripts logo...")" local helper_scripts_logo_path="$logos_dir/Helper_Scripts.txt" - if wget -qO "$helper_scripts_logo_path" "https://raw.githubusercontent.com/MacRimi/ProxMenux/main/images/logos_txt/Helper_Scripts.txt"; then - jq --arg path "$helper_scripts_logo_path" '. + {logo: $path}' "$fastfetch_config" > "${fastfetch_config}.tmp" && mv "${fastfetch_config}.tmp" "$fastfetch_config" + if download_fastfetch_logo "$helper_scripts_logo_path" "https://raw.githubusercontent.com/MacRimi/ProxMenux/main/images/logos_txt/Helper_Scripts.txt"; then + apply_fastfetch_config --arg path "$helper_scripts_logo_path" '. + {logo: $path}' msg_ok "$(translate "Helper-Scripts logo applied")" else msg_error "$(translate "Failed to download Helper-Scripts logo")" @@ -2594,8 +2699,8 @@ configure_fastfetch() { 5) msg_info "$(translate "Downloading Home-Labs-Club logo...")" local home_lab_club_logo_path="$logos_dir/home_labsclub.txt" - if wget -qO "$home_lab_club_logo_path" "https://raw.githubusercontent.com/MacRimi/ProxMenux/main/images/logos_txt/home_labsclub.txt"; then - jq --arg path "$home_lab_club_logo_path" '. + {logo: $path}' "$fastfetch_config" > "${fastfetch_config}.tmp" && mv "${fastfetch_config}.tmp" "$fastfetch_config" + if download_fastfetch_logo "$home_lab_club_logo_path" "https://raw.githubusercontent.com/MacRimi/ProxMenux/main/images/logos_txt/home_labsclub.txt"; then + apply_fastfetch_config --arg path "$home_lab_club_logo_path" '. + {logo: $path}' msg_ok "$(translate "Home-Lab-Club logo applied")" else msg_error "$(translate "Failed to download Home-Lab-Club logo")" @@ -2605,8 +2710,8 @@ configure_fastfetch() { 6) msg_info "$(translate "Downloading Proxmology logo...")" local proxmology_logo_path="$logos_dir/proxmology.txt" - if wget -qO "$proxmology_logo_path" "https://raw.githubusercontent.com/MacRimi/ProxMenux/main/images/logos_txt/proxmology.txt"; then - jq --arg path "$proxmology_logo_path" '. + {logo: $path}' "$fastfetch_config" > "${fastfetch_config}.tmp" && mv "${fastfetch_config}.tmp" "$fastfetch_config" + if download_fastfetch_logo "$proxmology_logo_path" "https://raw.githubusercontent.com/MacRimi/ProxMenux/main/images/logos_txt/proxmology.txt"; then + apply_fastfetch_config --arg path "$proxmology_logo_path" '. + {logo: $path}' msg_ok "$(translate "Proxmology logo applied")" else msg_error "$(translate "Failed to download Proxmology logo")" @@ -2638,7 +2743,7 @@ configure_fastfetch() { fi local selected_logo="${logo_files[$((selected_logo_index-1))]}" - jq --arg path "$selected_logo" '. + {logo: $path}' "$fastfetch_config" > "${fastfetch_config}.tmp" && mv "${fastfetch_config}.tmp" "$fastfetch_config" + apply_fastfetch_config --arg path "$selected_logo" '. + {logo: $path}' msg_ok "$(translate "Custom logo applied: $(basename "$selected_logo")")" break ;; @@ -2651,25 +2756,27 @@ configure_fastfetch() { # Modify Fastfetch modules to display custom title msg_info "$(translate "Modifying Fastfetch configuration...")" - jq '.modules |= map(select(. != "title"))' "$fastfetch_config" > "${fastfetch_config}.tmp" && mv "${fastfetch_config}.tmp" "$fastfetch_config" + apply_fastfetch_config '.modules |= map(select(. != "title"))' - jq 'del(.modules[] | select(type == "object" and .type == "custom"))' "$fastfetch_config" > "${fastfetch_config}.tmp" && mv "${fastfetch_config}.tmp" "$fastfetch_config" + apply_fastfetch_config 'del(.modules[] | select(type == "object" and .type == "custom"))' - jq '.modules |= [{"type": "custom", "format": "\u001b[1;38;5;166mSystem optimised by ProxMenux\u001b[0m"}] + .' "$fastfetch_config" > "${fastfetch_config}.tmp" && mv "${fastfetch_config}.tmp" "$fastfetch_config" + apply_fastfetch_config '.modules |= [{"type": "custom", "format": "\u001b[1;38;5;166mSystem optimised by ProxMenux\u001b[0m"}] + .' msg_ok "$(translate "Fastfetch now displays: System optimised by: ProxMenux")" + pmx_record_execution "Generate Fastfetch configuration" "fastfetch --gen-config" fastfetch --gen-config > /dev/null 2>&1 msg_ok "$(translate "Fastfetch configuration updated")" - sed -i '/fastfetch/d' ~/.profile /etc/profile 2>/dev/null - rm -f /etc/update-motd.d/99-fastfetch + pmx_edit_file "$HOME/.profile" '/fastfetch/d' 2>/dev/null || true + pmx_edit_file /etc/profile '/fastfetch/d' 2>/dev/null || true + pmx_remove_file /etc/update-motd.d/99-fastfetch - sed -i '/# BEGIN FASTFETCH/,/# END FASTFETCH/d' "$HOME/.bashrc" 2>/dev/null + pmx_edit_file "$HOME/.bashrc" '/# BEGIN FASTFETCH/,/# END FASTFETCH/d' 2>/dev/null || true if ! grep -q '# BEGIN FASTFETCH' "$HOME/.bashrc"; then - cat << 'EOF' >> "$HOME/.bashrc" + pmx_append_file "$HOME/.bashrc" << 'EOF' # BEGIN FASTFETCH # Run Fastfetch only in interactive sessions @@ -2716,6 +2823,7 @@ register_tool "fastfetch" true "$FUNC_VERSION" configure_figurine() { local FUNC_VERSION="1.1" + pmx_journal_context "configure_figurine" "$FUNC_VERSION" # description: Install Figurine (ASCII-art hostname banner) and wire it into the SSH login flow. msg_info2 "$(translate "Installing and configuring Figurine...")" # `FIGURINE_VERSION` env var allows pinning to a specific release; @@ -2733,7 +2841,7 @@ configure_figurine() { cleanup_dir() { rm -rf "$temp_dir" 2>/dev/null || true; } trap cleanup_dir EXIT - [[ -f "$bashrc" ]] || touch "$bashrc" + [[ -f "$bashrc" ]] || pmx_write_file "$bashrc" < /dev/null if command -v figurine &>/dev/null; then msg_info "$(translate "Updating Figurine binary...")" @@ -2758,10 +2866,12 @@ configure_figurine() { fi msg_info "$(translate "Installing binary to ${install_dir}...")" - install -m 0755 -o root -g root "${temp_dir}/deploy/figurine" "$bin_path" + pmx_write_file "$bin_path" < "${temp_dir}/deploy/figurine" + chmod 0755 "$bin_path" + chown root:root "$bin_path" - cat > "$profile_script" << 'EOF' + pmx_write_file "$profile_script" << 'EOF' /usr/local/bin/figurine -f "3d.flf" $(hostname) EOF chmod +x "$profile_script" @@ -2769,10 +2879,10 @@ EOF ensure_aliases() { local bashrc="/root/.bashrc" - [[ -f "$bashrc" ]] || touch "$bashrc" + [[ -f "$bashrc" ]] || pmx_write_file "$bashrc" < /dev/null if ! grep -q "shopt -s expand_aliases" "$bashrc" 2>/dev/null; then - echo "shopt -s expand_aliases" >> "$bashrc" + echo "shopt -s expand_aliases" | pmx_append_file "$bashrc" fi local -a ALIASES=( @@ -2795,9 +2905,9 @@ EOF local safe_cmd=${cmd//\'/\'\\\'\'} - sed -i -E "/^[[:space:]]*alias[[:space:]]+${name}=.*/d" "$bashrc" + pmx_edit_file "$bashrc" -E "/^[[:space:]]*alias[[:space:]]+${name}=.*/d" - printf "alias %s='%s'\n" "$name" "$safe_cmd" >> "$bashrc" + printf "alias %s='%s'\n" "$name" "$safe_cmd" | pmx_append_file "$bashrc" done . "$bashrc" @@ -2854,6 +2964,7 @@ _update_existing_log2ram_custom() { local func_version="$1" local log2ram_bin="" local candidate resolved tmp_file + pmx_journal_context "_update_existing_log2ram_custom" "$func_version" msg_ok "$(translate "Log2RAM already registered — updating to latest configuration")" @@ -2877,10 +2988,7 @@ _update_existing_log2ram_custom() { if grep -q 'rsync -aAXv ' "$log2ram_bin" 2>/dev/null; then [[ -e "${log2ram_bin}.proxmenux.bak" ]] || cp -a "$log2ram_bin" "${log2ram_bin}.proxmenux.bak" - tmp_file="$(mktemp "${log2ram_bin}.proxmenux.XXXXXX")" || return 1 - cp -a "$log2ram_bin" "$tmp_file" - sed -i 's/rsync -aAXv /rsync -aXv --no-acls /g' "$tmp_file" - mv -f "$tmp_file" "$log2ram_bin" + sed 's/rsync -aAXv /rsync -aXv --no-acls /g' "$log2ram_bin" | pmx_write_file "$log2ram_bin" fi if dpkg-query -W -f='${Status}' proxmox-backup-server 2>/dev/null \ @@ -2899,7 +3007,8 @@ _update_existing_log2ram_custom() { EOF chmod 0644 "$tmp_file" chown root:root "$tmp_file" - mv -f "$tmp_file" /etc/logrotate.d/proxmox-backup-api + pmx_write_file /etc/logrotate.d/proxmox-backup-api < "$tmp_file" + rm -f "$tmp_file" tmp_file="$(mktemp /etc/cron.hourly/.proxmox-backup-logrotate.XXXXXX)" || return 1 cat > "$tmp_file" <<'EOF' @@ -2908,7 +3017,10 @@ EOF EOF chmod 0755 "$tmp_file" chown root:root "$tmp_file" - mv -f "$tmp_file" /etc/cron.hourly/proxmox-backup-logrotate + pmx_write_file /etc/cron.hourly/proxmox-backup-logrotate < "$tmp_file" + chmod 0755 /etc/cron.hourly/proxmox-backup-logrotate + chown root:root /etc/cron.hourly/proxmox-backup-logrotate + rm -f "$tmp_file" msg_ok "$(translate "PBS API log rotation configured (hourly, size-based)")" fi @@ -2960,7 +3072,10 @@ EOF chmod 0755 "$tmp_file" chown root:root "$tmp_file" bash -n "$tmp_file" || return 1 - mv -f "$tmp_file" /usr/local/bin/log2ram-check.sh + pmx_write_file /usr/local/bin/log2ram-check.sh < "$tmp_file" + chmod 0755 /usr/local/bin/log2ram-check.sh + chown root:root /usr/local/bin/log2ram-check.sh + rm -f "$tmp_file" fi register_tool "log2ram" true "$func_version" @@ -2971,6 +3086,7 @@ EOF configure_log2ram() { local FUNC_VERSION="1.5" local existing_log2ram_bin="" + pmx_journal_context "configure_log2ram" "$FUNC_VERSION" # description: Install Log2RAM with user-chosen RAM size; prompts for size and SSD/M.2 awareness before applying. existing_log2ram_bin="$(command -v log2ram 2>/dev/null || true)" @@ -3031,22 +3147,29 @@ configure_log2ram() { msg_info "$(translate "Cleaning previous Log2RAM installation...")" - systemctl stop log2ram log2ram-daily.timer >/dev/null 2>&1 || true - systemctl disable log2ram log2ram-daily.timer >/dev/null 2>&1 || true + pmx_disable_service log2ram || true + pmx_disable_service log2ram-daily.timer || true - rm -f /etc/cron.d/log2ram /etc/cron.d/log2ram-auto-sync \ - /etc/cron.hourly/log2ram /etc/cron.daily/log2ram \ - /etc/cron.weekly/log2ram /etc/cron.monthly/log2ram 2>/dev/null || true - rm -f /usr/local/bin/log2ram-check.sh /usr/local/bin/log2ram /usr/sbin/log2ram 2>/dev/null || true - rm -f /etc/systemd/system/log2ram.service \ - /etc/systemd/system/log2ram-daily.timer \ - /etc/systemd/system/log2ram-daily.service \ - /etc/systemd/system/sysinit.target.wants/log2ram.service 2>/dev/null || true + local obsolete_path + for obsolete_path in \ + /etc/cron.d/log2ram /etc/cron.d/log2ram-auto-sync \ + /etc/cron.hourly/log2ram /etc/cron.daily/log2ram \ + /etc/cron.weekly/log2ram /etc/cron.monthly/log2ram \ + /usr/local/bin/log2ram-check.sh /usr/local/bin/log2ram /usr/sbin/log2ram \ + /etc/systemd/system/log2ram.service \ + /etc/systemd/system/log2ram-daily.timer \ + /etc/systemd/system/log2ram-daily.service \ + /etc/systemd/system/sysinit.target.wants/log2ram.service \ + /etc/log2ram.conf /etc/log2ram.conf.* /etc/logrotate.d/log2ram + do + pmx_remove_file "$obsolete_path" 2>/dev/null || true + done rm -rf /etc/systemd/system/log2ram.service.d 2>/dev/null || true - rm -f /etc/log2ram.conf* 2>/dev/null || true - rm -rf /etc/logrotate.d/log2ram /var/log.hdd /tmp/log2ram 2>/dev/null || true + rm -rf /var/log.hdd /tmp/log2ram 2>/dev/null || true + pmx_record_execution "Reload systemd configuration" "systemctl daemon-reload" systemctl daemon-reload >/dev/null 2>&1 || true + pmx_record_execution "Restart cron" "systemctl restart cron" systemctl restart cron >/dev/null 2>&1 || true msg_ok "$(translate "Previous installation cleaned")" @@ -3054,8 +3177,9 @@ configure_log2ram() { msg_info "$(translate "Installing Log2RAM from GitHub...")" if ! command -v git >/dev/null 2>&1; then msg_info "$(translate "Installing required package: git")" + pmx_record_execution "Update package lists for Log2RAM" "apt-get update -qq" apt-get update -qq >/dev/null 2>&1 - apt-get install -y git >/dev/null 2>&1 + pmx_install_pkg git fi rm -rf /tmp/log2ram 2>/dev/null || true @@ -3066,6 +3190,7 @@ configure_log2ram() { fi cd /tmp/log2ram || { msg_error "$(translate "Failed to access log2ram directory")"; return 1; } + pmx_record_execution "Run the Log2RAM installer" "bash install.sh" if ! bash install.sh >>/tmp/log2ram_install.log 2>&1; then msg_error "$(translate "Failed to run log2ram installer. Check /tmp/log2ram_install.log")" return 1 @@ -3084,7 +3209,7 @@ configure_log2ram() { [[ -n "$_l2r_bin" && -f "$_l2r_bin" ]] || continue if grep -q 'rsync -aAXv ' "$_l2r_bin" 2>/dev/null; then cp -a "$_l2r_bin" "${_l2r_bin}.proxmenux.bak" - sed -i 's/rsync -aAXv /rsync -aXv --no-acls /g' "$_l2r_bin" + pmx_edit_file "$_l2r_bin" 's/rsync -aAXv /rsync -aXv --no-acls /g' fi break done @@ -3095,7 +3220,7 @@ configure_log2ram() { if dpkg-query -W -f='${Status}' proxmox-backup-server 2>/dev/null \ | grep -q 'install ok installed'; then mkdir -p /var/log/proxmox-backup/api 2>/dev/null || true - cat > /etc/logrotate.d/proxmox-backup-api <<'EOF' + pmx_write_file /etc/logrotate.d/proxmox-backup-api <<'EOF' /var/log/proxmox-backup/api/access.log /var/log/proxmox-backup/api/auth.log { size 20M rotate 3 @@ -3108,7 +3233,7 @@ configure_log2ram() { EOF chmod 0644 /etc/logrotate.d/proxmox-backup-api chown root:root /etc/logrotate.d/proxmox-backup-api - cat > /etc/cron.hourly/proxmox-backup-logrotate <<'EOF' + pmx_write_file /etc/cron.hourly/proxmox-backup-logrotate <<'EOF' #!/bin/sh /usr/sbin/logrotate /etc/logrotate.d/proxmox-backup-api >/dev/null 2>&1 EOF @@ -3117,6 +3242,7 @@ EOF msg_ok "$(translate "PBS API log rotation configured (hourly, size-based)")" fi + pmx_record_execution "Reload systemd configuration" "systemctl daemon-reload" systemctl daemon-reload >/dev/null 2>&1 || true if [[ -f /etc/log2ram.conf ]] && command -v log2ram >/dev/null 2>&1; then @@ -3127,10 +3253,10 @@ EOF fi - sed -i "s/^SIZE=.*/SIZE=$LOG2RAM_SIZE/" /etc/log2ram.conf + pmx_edit_file /etc/log2ram.conf "s/^SIZE=.*/SIZE=$LOG2RAM_SIZE/" LOG2RAM_BIN="$(command -v log2ram || echo /usr/sbin/log2ram)" - cat > /etc/cron.d/log2ram < /usr/local/bin/log2ram-check.sh <<'EOF' + pmx_write_file /usr/local/bin/log2ram-check.sh <<'EOF' #!/usr/bin/env bash # Watch /var/log usage on Log2RAM's tmpfs and act at two thresholds: # > 80% → vacuum journald down to ~30% of SIZE, then log2ram write @@ -3188,7 +3314,7 @@ fi EOF chmod +x /usr/local/bin/log2ram-check.sh - cat > /etc/cron.d/log2ram-auto-sync <<'EOF' + pmx_write_file /etc/cron.d/log2ram-auto-sync <<'EOF' # Log2RAM auto-sync based on /var/log usage - Created by ProxMenux SHELL=/bin/bash PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin @@ -3199,7 +3325,8 @@ EOF chown root:root /etc/cron.d/log2ram-auto-sync msg_ok "$(translate "Auto-sync enabled when /var/log exceeds 80% of") $LOG2RAM_SIZE" else - rm -f /usr/local/bin/log2ram-check.sh /etc/cron.d/log2ram-auto-sync 2>/dev/null || true + pmx_remove_file /usr/local/bin/log2ram-check.sh 2>/dev/null || true + pmx_remove_file /etc/cron.d/log2ram-auto-sync 2>/dev/null || true msg_info2 "$(translate "Auto-sync was not enabled")" fi @@ -3221,8 +3348,8 @@ EOF [ "$KEEP_MB" -lt 8 ] && KEEP_MB=8 # Reescribir bloque [Journal] de forma segura - sed -i '/^\[Journal\]/,$d' /etc/systemd/journald.conf 2>/dev/null || true - tee -a /etc/systemd/journald.conf >/dev/null </dev/null || true + pmx_append_file /etc/systemd/journald.conf </dev/null 2>&1 || true - if ! systemctl enable log2ram >/dev/null 2>&1; then + if ! pmx_apply_setting "service-enabled:log2ram" "systemctl is-enabled log2ram" \ + systemctl enable log2ram; then msg_error "$(translate "Log2RAM installation verification failed. Check /tmp/log2ram_install.log")" return 1 fi diff --git a/scripts/post_install/uninstall-tools.sh b/scripts/post_install/uninstall-tools.sh index d007251f..3605c7a7 100644 --- a/scripts/post_install/uninstall-tools.sh +++ b/scripts/post_install/uninstall-tools.sh @@ -34,6 +34,9 @@ TOOLS_JSON="$BASE_DIR/installed_tools.json" if [[ -f "$UTILS_FILE" ]]; then source "$UTILS_FILE" fi +if [[ -f "$LOCAL_SCRIPTS/global/pmx_journal.sh" ]]; then + source "$LOCAL_SCRIPTS/global/pmx_journal.sh" +fi load_language initialize_cache @@ -53,18 +56,28 @@ register_tool() { ################################################################ uninstall_fastfetch() { + local FUNC_VERSION="1.0" + pmx_journal_context "uninstall_fastfetch" "$FUNC_VERSION" if ! command -v fastfetch &>/dev/null && [[ ! -f /usr/local/bin/fastfetch ]]; then msg_warn "$(translate "Fastfetch is not installed.")" return 0 fi msg_info2 "$(translate "Uninstalling Fastfetch...")" - rm -f /usr/local/bin/fastfetch /usr/bin/fastfetch + pmx_remove_file /usr/local/bin/fastfetch + pmx_remove_file /usr/bin/fastfetch + pmx_record_execution "Remove Fastfetch configuration directory" "rm -rf $HOME/.config/fastfetch" rm -rf "$HOME/.config/fastfetch" + pmx_record_execution "Remove shared Fastfetch files" "rm -rf /usr/local/share/fastfetch" rm -rf /usr/local/share/fastfetch - sed -i '/fastfetch/d' "$HOME/.bashrc" "$HOME/.profile" /etc/profile 2>/dev/null - sed -i '/# BEGIN FASTFETCH/,/# END FASTFETCH/d' "$HOME/.bashrc" - rm -f /etc/profile.d/fastfetch.sh /etc/update-motd.d/99-fastfetch + local profile_file + for profile_file in "$HOME/.bashrc" "$HOME/.profile" /etc/profile; do + pmx_edit_file "$profile_file" '/fastfetch/d' 2>/dev/null || true + done + pmx_edit_file "$HOME/.bashrc" '/# BEGIN FASTFETCH/,/# END FASTFETCH/d' + pmx_remove_file /etc/profile.d/fastfetch.sh + pmx_remove_file /etc/update-motd.d/99-fastfetch + pmx_record_execution "Remove Fastfetch package" "dpkg -r fastfetch" dpkg -r fastfetch &>/dev/null msg_ok "$(translate "Fastfetch removed from system")" @@ -74,18 +87,23 @@ uninstall_fastfetch() { ################################################################ uninstall_figurine() { + local FUNC_VERSION="1.0" + pmx_journal_context "uninstall_figurine" "$FUNC_VERSION" if ! command -v figurine &>/dev/null; then msg_warn "$(translate "Figurine is not installed.")" return 0 fi msg_info2 "$(translate "Uninstalling Figurine...")" - rm -f /usr/local/bin/figurine - rm -f /etc/profile.d/figurine.sh + pmx_remove_file /usr/local/bin/figurine + pmx_remove_file /etc/profile.d/figurine.sh - sed -i '/lxcclean/d;/lxcupdate/d;/kernelclean/d;/cpugov/d;/updatecerts/d;/seqwrite/d;/seqread/d;/ranwrite/d;/ranread/d' "$HOME/.bashrc" "$HOME/.profile" 2>/dev/null - sed -i '/# ProxMenux Figurine aliases and tools/,+20d' "$HOME/.bashrc" "$HOME/.profile" 2>/dev/null - sed -i '/# BEGIN PROXMENUX ALIASES/,/# END PROXMENUX ALIASES/d' "$HOME/.bashrc" "$HOME/.profile" 2>/dev/null + local profile_file + for profile_file in "$HOME/.bashrc" "$HOME/.profile"; do + pmx_edit_file "$profile_file" '/lxcclean/d;/lxcupdate/d;/kernelclean/d;/cpugov/d;/updatecerts/d;/seqwrite/d;/seqread/d;/ranwrite/d;/ranread/d' 2>/dev/null || true + pmx_edit_file "$profile_file" '/# ProxMenux Figurine aliases and tools/,+20d' 2>/dev/null || true + pmx_edit_file "$profile_file" '/# BEGIN PROXMENUX ALIASES/,/# END PROXMENUX ALIASES/d' 2>/dev/null || true + done msg_ok "$(translate "Figurine removed from system")" register_tool "figurine" false @@ -95,15 +113,18 @@ uninstall_figurine() { ################################################################ uninstall_kexec() { + local FUNC_VERSION="1.0" + pmx_journal_context "uninstall_kexec" "$FUNC_VERSION" if ! dpkg -s kexec-tools >/dev/null 2>&1 && [ ! -f /etc/systemd/system/kexec-pve.service ]; then msg_warn "$(translate "kexec-tools is not installed or already removed.")" return 0 fi msg_info2 "$(translate "Uninstalling kexec-tools and removing custom service...")" - systemctl disable --now kexec-pve.service &>/dev/null - rm -f /etc/systemd/system/kexec-pve.service - sed -i "/alias reboot-quick='systemctl kexec'/d" /root/.bash_profile + pmx_disable_service kexec-pve.service + pmx_remove_file /etc/systemd/system/kexec-pve.service + pmx_edit_file /root/.bash_profile "/alias reboot-quick='systemctl kexec'/d" + pmx_record_execution "Purge kexec-tools package" "apt-get purge -y kexec-tools" apt-get purge -y kexec-tools >/dev/null 2>&1 msg_ok "$(translate "kexec-tools and related settings removed")" @@ -269,6 +290,8 @@ uninstall_rpc() { ################################################################ uninstall_motd() { + local FUNC_VERSION="1.0" + pmx_journal_context "uninstall_motd" "$FUNC_VERSION" local state_file="$BASE_DIR/motd.state" local original_file="$BASE_DIR/motd.original" local motd_file="${PROXMENUX_MOTD_FILE:-/etc/motd}" @@ -287,15 +310,15 @@ uninstall_motd() { msg_error "$(translate "The original MOTD backup is unavailable; no changes were made")" return 1 fi - cp -a "$original_file" "$motd_file" + pmx_write_file "$motd_file" < "$original_file" ;; absent) - rm -f "$motd_file" + pmx_remove_file "$motd_file" ;; legacy-marker) if [[ -f "$motd_file" ]]; then - sed -i "\|^${custom_message}$|d" "$motd_file" - sed -i '/./,$!d' "$motd_file" + pmx_edit_file "$motd_file" "\|^${custom_message}$|d" + pmx_edit_file "$motd_file" '/./,$!d' fi ;; *) @@ -304,7 +327,8 @@ uninstall_motd() { ;; esac - rm -f "$state_file" "$original_file" + pmx_remove_file "$state_file" + pmx_remove_file "$original_file" register_tool "motd" false msg_ok "$(translate "The original MOTD configuration has been restored")" } @@ -380,10 +404,12 @@ uninstall_apt_languages() { ################################################################ uninstall_journald() { + local FUNC_VERSION="1.0" + pmx_journal_context "uninstall_journald" "$FUNC_VERSION" msg_info "$(translate "Restoring default journald configuration...")" # Restore default journald configuration - cat > /etc/systemd/journald.conf << 'EOF' + pmx_write_file /etc/systemd/journald.conf << 'EOF' # This file is part of systemd. # # systemd is free software; you can redistribute it and/or modify it @@ -425,6 +451,7 @@ uninstall_journald() { #MaxLevelWall=emerg EOF + pmx_record_execution "Restart systemd-journald" "systemctl restart systemd-journald.service" systemctl restart systemd-journald.service >/dev/null 2>&1 msg_ok "$(translate "Default journald configuration restored")" @@ -452,37 +479,40 @@ uninstall_logrotate() { ################################################################ uninstall_system_limits() { + local FUNC_VERSION="1.0" + pmx_journal_context "uninstall_system_limits" "$FUNC_VERSION" msg_info "$(translate "Removing system limits optimizations...")" # Remove ProxMenux sysctl configurations - rm -f /etc/sysctl.d/99-maxwatches.conf - rm -f /etc/sysctl.d/99-maxkeys.conf - rm -f /etc/sysctl.d/99-swap.conf - rm -f /etc/sysctl.d/99-fs.conf + pmx_remove_file /etc/sysctl.d/99-maxwatches.conf + pmx_remove_file /etc/sysctl.d/99-maxkeys.conf + pmx_remove_file /etc/sysctl.d/99-swap.conf + pmx_remove_file /etc/sysctl.d/99-fs.conf # Remove ProxMenux limits configuration - rm -f /etc/security/limits.d/99-limits.conf + pmx_remove_file /etc/security/limits.d/99-limits.conf # Remove systemd limits (restore defaults) for file in /etc/systemd/system.conf /etc/systemd/user.conf; do if [ -f "$file" ]; then - sed -i '/^DefaultLimitNOFILE=256000/d' "$file" + pmx_edit_file "$file" '/^DefaultLimitNOFILE=256000/d' fi done # Remove PAM limits for file in /etc/pam.d/common-session /etc/pam.d/runuser-l; do if [ -f "$file" ]; then - sed -i '/^session required pam_limits.so/d' "$file" + pmx_edit_file "$file" '/^session required pam_limits.so/d' fi done # Remove ulimit from profile if [ -f /root/.profile ]; then - sed -i '/ulimit -n 256000/d' /root/.profile + pmx_edit_file /root/.profile '/ulimit -n 256000/d' fi # Reload sysctl + pmx_record_execution "Apply sysctl configuration" "sysctl --system" sysctl --system >/dev/null 2>&1 msg_ok "$(translate "System limits optimizations removed")" @@ -553,26 +583,31 @@ uninstall_apt_ipv4() { ################################################################ uninstall_network_optimization() { + local FUNC_VERSION="1.0" + pmx_journal_context "uninstall_network_optimization" "$FUNC_VERSION" msg_info "$(translate "Removing network optimizations...")" - rm -f /etc/sysctl.d/99-network.conf + pmx_remove_file /etc/sysctl.d/99-network.conf local interfaces_file="/etc/network/interfaces" if [ -f "$interfaces_file" ]; then - sed -i '/^source \/etc\/network\/interfaces\.d\/\*/d' "$interfaces_file" + pmx_edit_file "$interfaces_file" '/^source \/etc\/network\/interfaces\.d\/\*/d' fi - rm -f /etc/sysctl.d/97-proxmenux-fwbr.conf \ - /etc/sysctl.d/98-proxmenux-rpf.conf + pmx_remove_file /etc/sysctl.d/97-proxmenux-fwbr.conf + pmx_remove_file /etc/sysctl.d/98-proxmenux-rpf.conf - systemctl disable --now proxmenux-fwbr-tune.service >/dev/null 2>&1 || true - rm -f /etc/systemd/system/proxmenux-fwbr-tune.service - rm -f /usr/local/sbin/proxmenux-fwbr-tune - rm -f /etc/udev/rules.d/99-proxmenux-fwbr-tune.rules \ - /etc/udev/rules.d/99-zz-proxmenux-fwbr-tune.rules + pmx_disable_service proxmenux-fwbr-tune.service || true + pmx_remove_file /etc/systemd/system/proxmenux-fwbr-tune.service + pmx_remove_file /usr/local/sbin/proxmenux-fwbr-tune + pmx_remove_file /etc/udev/rules.d/99-proxmenux-fwbr-tune.rules + pmx_remove_file /etc/udev/rules.d/99-zz-proxmenux-fwbr-tune.rules + pmx_record_execution "Reload udev rules" "udevadm control --reload-rules" udevadm control --reload-rules >/dev/null 2>&1 || true + pmx_record_execution "Reload systemd configuration" "systemctl daemon-reload" systemctl daemon-reload >/dev/null 2>&1 || true + pmx_record_execution "Apply sysctl configuration" "sysctl --system" sysctl --system >/dev/null 2>&1 || true @@ -585,24 +620,27 @@ uninstall_network_optimization() { ################################################################ uninstall_bashrc_custom() { + local FUNC_VERSION="1.0" + pmx_journal_context "uninstall_bashrc_custom" "$FUNC_VERSION" msg_info "$(translate "Restoring original bashrc...")" # Restore original bashrc from backup if [ -f /root/.bashrc.bak ]; then - mv /root/.bashrc.bak /root/.bashrc + pmx_write_file /root/.bashrc < /root/.bashrc.bak + pmx_remove_file /root/.bashrc.bak msg_ok "$(translate "Original bashrc restored")" else # Remove ProxMenux customizations manually if [ -f /root/.bashrc ]; then # Remove the customization block using the markers written by customize_bashrc - sed -i '/# BEGIN PMX_CORE_BASHRC/,/# END PMX_CORE_BASHRC/d' /root/.bashrc + pmx_edit_file /root/.bashrc '/# BEGIN PMX_CORE_BASHRC/,/# END PMX_CORE_BASHRC/d' fi msg_ok "$(translate "ProxMenux customizations removed from bashrc")" fi # Remove bash_profile source line if we added it if [ -f /root/.bash_profile ]; then - sed -i '/source \/root\/\.bashrc/d' /root/.bash_profile + pmx_edit_file /root/.bash_profile '/source \/root\/\.bashrc/d' fi register_tool "bashrc_custom" false @@ -718,21 +756,23 @@ uninstall_persistent_network() { uninstall_vfio_iommu() { + local FUNC_VERSION="1.0" + pmx_journal_context "uninstall_vfio_iommu" "$FUNC_VERSION" msg_info2 "$(translate "Reverting IOMMU/VFIO configuration...")" NECESSARY_REBOOT=1 # Remove VFIO modules from /etc/modules local modules_file="/etc/modules" if [ -f "$modules_file" ]; then - sed -i '/^vfio$/d;/^vfio_iommu_type1$/d;/^vfio_pci$/d;/^vfio_virqfd$/d' "$modules_file" + pmx_edit_file "$modules_file" '/^vfio$/d;/^vfio_iommu_type1$/d;/^vfio_pci$/d;/^vfio_virqfd$/d' msg_ok "$(translate "VFIO modules removed from /etc/modules")" fi # Remove driver blacklists added by ProxMenux local blacklist_file="/etc/modprobe.d/blacklist.conf" if [ -f "$blacklist_file" ]; then - sed -i '/^blacklist nouveau$/d;/^blacklist lbm-nouveau$/d;/^blacklist radeon$/d;/^blacklist nvidia$/d;/^blacklist nvidiafb$/d;/^options nouveau modeset=0$/d' "$blacklist_file" - [ ! -s "$blacklist_file" ] && rm -f "$blacklist_file" + pmx_edit_file "$blacklist_file" '/^blacklist nouveau$/d;/^blacklist lbm-nouveau$/d;/^blacklist radeon$/d;/^blacklist nvidia$/d;/^blacklist nvidiafb$/d;/^options nouveau modeset=0$/d' + [ ! -s "$blacklist_file" ] && pmx_remove_file "$blacklist_file" msg_ok "$(translate "Driver blacklist entries removed")" fi @@ -742,9 +782,12 @@ uninstall_vfio_iommu() { # systemd-boot / ZFS if grep -qE 'intel_iommu=on|amd_iommu=on|iommu=pt|pcie_acs_override=' "$cmdline_file"; then cp "$cmdline_file" "${cmdline_file}.bak.$(date +%Y%m%d_%H%M%S)" - sed -i -E 's/\b(intel_iommu=on|amd_iommu=on|iommu=pt|pcie_acs_override=[^ ]*)\b//g' "$cmdline_file" - sed -i -E 's/[[:space:]]+/ /g; s/^ //; s/ $//' "$cmdline_file" - command -v proxmox-boot-tool >/dev/null 2>&1 && proxmox-boot-tool refresh >/dev/null 2>&1 || true + pmx_edit_file "$cmdline_file" -E 's/\b(intel_iommu=on|amd_iommu=on|iommu=pt|pcie_acs_override=[^ ]*)\b//g' + pmx_edit_file "$cmdline_file" -E 's/[[:space:]]+/ /g; s/^ //; s/ $//' + if command -v proxmox-boot-tool >/dev/null 2>&1; then + pmx_record_execution "Refresh Proxmox boot configuration" "proxmox-boot-tool refresh" + proxmox-boot-tool refresh >/dev/null 2>&1 || true + fi msg_ok "$(translate "IOMMU parameters removed from /etc/kernel/cmdline")" fi else @@ -752,9 +795,10 @@ uninstall_vfio_iommu() { local grub_file="/etc/default/grub" if [[ -f "$grub_file" ]] && grep -qE 'intel_iommu=on|amd_iommu=on|iommu=pt|pcie_acs_override=' "$grub_file"; then cp "$grub_file" "${grub_file}.bak.$(date +%Y%m%d_%H%M%S)" - sed -i -E 's/\b(intel_iommu=on|amd_iommu=on|iommu=pt|pcie_acs_override=[^ "]*)\b//g' "$grub_file" + pmx_edit_file "$grub_file" -E 's/\b(intel_iommu=on|amd_iommu=on|iommu=pt|pcie_acs_override=[^ "]*)\b//g' awk -F\" 'BEGIN{OFS="\""} /GRUB_CMDLINE_LINUX_DEFAULT=/{gsub(/[[:space:]]+/," ",$2);sub(/^ /,"",$2);sub(/ $/,"",$2)}1' \ - "$grub_file" > "${grub_file}.tmp" && mv "${grub_file}.tmp" "$grub_file" + "$grub_file" | pmx_write_file "$grub_file" + pmx_record_execution "Regenerate GRUB configuration" "update-grub" update-grub >/dev/null 2>&1 || true msg_ok "$(translate "IOMMU parameters removed from GRUB")" fi @@ -762,7 +806,9 @@ uninstall_vfio_iommu() { msg_info "$(translate 'Updating initramfs (this may take a minute)...')" + pmx_record_execution "Regenerate initramfs" "update-initramfs -u -k all" update-initramfs -u -k all >/dev/null 2>&1 || true + pmx_record_execution "Refresh Proxmox boot configuration" "proxmox-boot-tool refresh" proxmox-boot-tool refresh >/dev/null 2>&1 || true msg_ok "$(translate "IOMMU/VFIO configuration reverted")" @@ -772,6 +818,8 @@ uninstall_vfio_iommu() { ################################################################ uninstall_amd_fixes() { + local FUNC_VERSION="1.0" + pmx_journal_context "uninstall_amd_fixes" "$FUNC_VERSION" msg_info2 "$(translate "Reverting AMD (Ryzen/EPYC) fixes...")" NECESSARY_REBOOT=1 @@ -785,9 +833,10 @@ uninstall_amd_fixes() { return 1 } - sed -i 's/\bidle=nomwait\b//g; s/[[:space:]]\+/ /g; s/^ //; s/ $//' "$cmdline_file" + pmx_edit_file "$cmdline_file" 's/\bidle=nomwait\b//g; s/[[:space:]]\+/ /g; s/^ //; s/ $//' if command -v proxmox-boot-tool >/dev/null 2>&1; then + pmx_record_execution "Refresh Proxmox boot configuration" "proxmox-boot-tool refresh" proxmox-boot-tool refresh >/dev/null 2>&1 || { msg_error "$(translate "Failed to refresh boot configuration")" return 1 @@ -805,14 +854,15 @@ uninstall_amd_fixes() { return 1 } - sed -i -E 's/(GRUB_CMDLINE_LINUX_DEFAULT=")/\1/; s/\bidle=nomwait\b//g' "$grub_file" + pmx_edit_file "$grub_file" -E 's/(GRUB_CMDLINE_LINUX_DEFAULT=")/\1/; s/\bidle=nomwait\b//g' awk -F\" ' $1=="GRUB_CMDLINE_LINUX_DEFAULT=" { gsub(/[[:space:]]+/," ",$2); sub(/^ /,"",$2); sub(/ $/,"",$2) }1 - ' OFS="\"" "$grub_file" > "${grub_file}.tmp" && mv "${grub_file}.tmp" "$grub_file" + ' OFS="\"" "$grub_file" | pmx_write_file "$grub_file" + pmx_record_execution "Regenerate GRUB configuration" "update-grub" update-grub >/dev/null 2>&1 || { msg_error "$(translate "Failed to update GRUB configuration")" return 1 @@ -830,17 +880,19 @@ uninstall_amd_fixes() { msg_error "$(translate "Failed to backup $kvm_conf")" return 1 } - sed -i -E '/ignore_msrs|report_ignored_msrs/d' "$kvm_conf" + pmx_edit_file "$kvm_conf" -E '/ignore_msrs|report_ignored_msrs/d' if [[ ! -s "$kvm_conf" ]]; then - rm -f "$kvm_conf" + pmx_remove_file "$kvm_conf" msg_ok "$(translate "Removed empty KVM configuration file")" else msg_ok "$(translate "Removed KVM MSR options from configuration")" fi + pmx_record_execution "Regenerate initramfs" "update-initramfs -u -k all" update-initramfs -u -k all >/dev/null 2>&1 || true - proxmox-boot-tool refresh >/dev/null 2>&1 || true + pmx_record_execution "Refresh Proxmox boot configuration" "proxmox-boot-tool refresh" + proxmox-boot-tool refresh >/dev/null 2>&1 || true else msg_ok "$(translate "KVM MSR options not present, nothing to revert")" fi @@ -957,8 +1009,12 @@ uninstall_ceph() { } uninstall_ha() { + local FUNC_VERSION="1.0" + pmx_journal_context "uninstall_ha" "$FUNC_VERSION" msg_info2 "$(translate 'Disabling High Availability services...')" - systemctl disable --now pve-ha-lrm pve-ha-crm corosync >/dev/null 2>&1 || true + pmx_disable_service pve-ha-lrm || true + pmx_disable_service pve-ha-crm || true + pmx_disable_service corosync || true msg_ok "$(translate 'HA services disabled (configs preserved)')" register_tool "ha" false } @@ -1009,13 +1065,17 @@ uninstall_ovh_rtm() { } uninstall_pigz() { + local FUNC_VERSION="1.0" + pmx_journal_context "uninstall_pigz" "$FUNC_VERSION" msg_info2 "$(translate 'Reverting pigz wrapper...')" if [[ -f /bin/gzip.original ]]; then - mv -f /bin/gzip.original /bin/gzip + pmx_write_file /bin/gzip < /bin/gzip.original + pmx_remove_file /bin/gzip.original msg_ok "$(translate 'Restored original /bin/gzip')" fi - rm -f /bin/pigzwrapper - sed -i 's/^pigz: 1/#pigz: 1/' /etc/vzdump.conf 2>/dev/null || true + pmx_remove_file /bin/pigzwrapper + pmx_edit_file /etc/vzdump.conf 's/^pigz: 1/#pigz: 1/' 2>/dev/null || true + pmx_record_execution "Purge pigz package" "apt-get purge -y pigz" apt-get purge -y pigz >/dev/null 2>&1 || true msg_ok "$(translate 'pigz removed')" register_tool "pigz" false @@ -1124,12 +1184,15 @@ uninstall_zfs_autotrim() { } uninstall_vzdump_speed() { + local FUNC_VERSION="1.0" + pmx_journal_context "uninstall_vzdump_speed" "$FUNC_VERSION" msg_info2 "$(translate 'Reverting vzdump speed tuning...')" if [[ -f /etc/vzdump.conf.bak ]]; then - mv -f /etc/vzdump.conf.bak /etc/vzdump.conf + pmx_write_file /etc/vzdump.conf < /etc/vzdump.conf.bak + pmx_remove_file /etc/vzdump.conf.bak msg_ok "$(translate 'Restored original /etc/vzdump.conf from .bak')" else - sed -i '/^bwlimit: 0$/d;/^ionice: 5$/d' /etc/vzdump.conf 2>/dev/null + pmx_edit_file /etc/vzdump.conf '/^bwlimit: 0$/d;/^ionice: 5$/d' 2>/dev/null msg_ok "$(translate 'Removed bwlimit/ionice tuning (no .bak found)')" fi register_tool "vzdump_speed" false diff --git a/scripts/security/fail2ban_installer.sh b/scripts/security/fail2ban_installer.sh index ef7e761e..4d822010 100644 --- a/scripts/security/fail2ban_installer.sh +++ b/scripts/security/fail2ban_installer.sh @@ -48,6 +48,10 @@ if [[ -f "$UTILS_FILE" ]]; then source "$UTILS_FILE" fi +if [[ -f "$LOCAL_SCRIPTS/global/pmx_journal.sh" ]]; then + source "$LOCAL_SCRIPTS/global/pmx_journal.sh" +fi + if [[ ! -f "$COMPONENTS_STATUS_FILE" ]]; then echo "{}" > "$COMPONENTS_STATUS_FILE" fi @@ -79,6 +83,9 @@ detect_fail2ban() { # Installation # ========================================================== install_fail2ban() { + local FUNC_VERSION="1.0" + pmx_journal_context "install_fail2ban" "$FUNC_VERSION" + show_proxmenux_logo msg_title "$(translate "$SCRIPT_TITLE")" msg_info2 "$(translate "Installing and configuring Fail2Ban to protect Proxmox web interface and SSH...")" @@ -90,7 +97,7 @@ install_fail2ban() { if ! grep -RqsE "debian.*(bookworm|trixie)" /etc/apt/sources.list /etc/apt/sources.list.d 2>/dev/null; then msg_warn "$(translate "Debian repositories missing; creating default source file")" local src="/etc/apt/sources.list.d/debian.sources" - cat > "$src" </dev/null 2>&1 || \ - ! DEBIAN_FRONTEND=noninteractive apt-get install -y fail2ban >/dev/null 2>&1; then + ! pmx_install_pkg fail2ban; then msg_error "$(translate "Failed to install Fail2Ban")" return 1 fi @@ -132,7 +139,7 @@ EOF # Create a drop-in so we don't break other Proxmox settings mkdir -p /etc/systemd/journald.conf.d - cat > /etc/systemd/journald.conf.d/proxmenux-loglevel.conf <<'JEOF' + pmx_write_file /etc/systemd/journald.conf.d/proxmenux-loglevel.conf <<'JEOF' # ProxMenux: Allow auth/info messages so Fail2Ban can detect SSH failures # Proxmox default MaxLevelStore=warning drops PAM/SSH auth events [Journal] @@ -148,6 +155,7 @@ JEOF esac if $journald_changed; then + pmx_record_execution "restart systemd-journald" "systemctl restart systemd-journald" systemctl restart systemd-journald sleep 1 msg_ok "$(translate "journald restarted - auth messages will now be stored")" @@ -163,7 +171,7 @@ JEOF # -- Proxmox UI auth logger (pvedaemon) -- msg_info "$(translate "Creating Proxmox auth logger service...")" - cat > /etc/systemd/system/proxmox-auth-logger.service <<'EOF' + pmx_write_file /etc/systemd/system/proxmox-auth-logger.service <<'EOF' [Unit] Description=Proxmox Auth Logger for Fail2Ban Documentation=https://github.com/MacRimi/ProxMenux @@ -185,12 +193,12 @@ EOF chown root:adm /var/log/proxmox-auth.log 2>/dev/null || true systemctl daemon-reload - systemctl enable --now proxmox-auth-logger.service >/dev/null 2>&1 + pmx_enable_service proxmox-auth-logger.service msg_ok "$(translate "Proxmox auth logger service created and started")" # -- SSH auth logger -- msg_info "$(translate "Creating SSH auth logger service...")" - cat > /etc/systemd/system/ssh-auth-logger.service <<'EOF' + pmx_write_file /etc/systemd/system/ssh-auth-logger.service <<'EOF' [Unit] Description=SSH Auth Logger for Fail2Ban Documentation=https://github.com/MacRimi/ProxMenux @@ -212,13 +220,13 @@ EOF chown root:adm /var/log/ssh-auth.log 2>/dev/null || true systemctl daemon-reload - systemctl enable --now ssh-auth-logger.service >/dev/null 2>&1 + pmx_enable_service ssh-auth-logger.service msg_ok "$(translate "SSH auth logger service created and started")" # Configure Proxmox filter mkdir -p /etc/fail2ban/filter.d /etc/fail2ban/jail.d msg_info "$(translate "Configuring Proxmox filter...")" - cat > /etc/fail2ban/filter.d/proxmox.conf <<'EOF' + pmx_write_file /etc/fail2ban/filter.d/proxmox.conf <<'EOF' [Definition] # The proxmox-auth-logger service writes journal lines to /var/log/proxmox-auth.log # in short-iso format: 2026-02-10T19:36:08+01:00 host pvedaemon[PID]: message @@ -231,7 +239,7 @@ EOF # Configure Proxmox jail (file-based backend) msg_info "$(translate "Configuring Proxmox jail...")" - cat > /etc/fail2ban/jail.d/proxmox.conf <<'EOF' + pmx_write_file /etc/fail2ban/jail.d/proxmox.conf <<'EOF' [proxmox] enabled = true port = 8006 @@ -248,7 +256,7 @@ EOF # This reads from a file written directly by the Flask app (not syslog/journal), # so it uses a datepattern that matches Python's logging format. msg_info "$(translate "Configuring ProxMenux Monitor filter...")" - cat > /etc/fail2ban/filter.d/proxmenux.conf <<'EOF' + pmx_write_file /etc/fail2ban/filter.d/proxmenux.conf <<'EOF' [Definition] failregex = ^.*proxmenux-auth: authentication failure; rhost= user=.*$ ignoreregex = @@ -259,7 +267,7 @@ EOF # Configure ProxMenux Monitor jail (port 8008 + http/https for reverse proxy) # Uses backend=auto with logpath because the Flask app writes directly to this file. msg_info "$(translate "Configuring ProxMenux Monitor jail...")" - cat > /etc/fail2ban/jail.d/proxmenux.conf <<'EOF' + pmx_write_file /etc/fail2ban/jail.d/proxmenux.conf <<'EOF' [proxmenux] enabled = true port = 8008,http,https @@ -289,7 +297,7 @@ EOF # Configure global settings and SSH jail msg_info "$(translate "Configuring global Fail2Ban settings and SSH jail...")" - cat > /etc/fail2ban/jail.local < "${BASE_DIR}/sshd_maxauthtries_backup" + printf '%s\n' "$original_max_auth" | pmx_write_file "${BASE_DIR}/sshd_maxauthtries_backup" msg_info "$(translate "Hardening SSH: setting MaxAuthTries to 3...")" if grep -qi '^MaxAuthTries' "$sshd_config"; then - sed -i 's/^MaxAuthTries.*/MaxAuthTries 3/' "$sshd_config" + pmx_edit_file "$sshd_config" 's/^MaxAuthTries.*/MaxAuthTries 3/' elif grep -qi '^#MaxAuthTries' "$sshd_config"; then - sed -i 's/^#MaxAuthTries.*/MaxAuthTries 3/' "$sshd_config" + pmx_edit_file "$sshd_config" 's/^#MaxAuthTries.*/MaxAuthTries 3/' else - echo "MaxAuthTries 3" >> "$sshd_config" + echo "MaxAuthTries 3" | pmx_append_file "$sshd_config" fi # Reload SSH to apply the change (reload, not restart, to keep existing sessions) + pmx_record_execution "reload SSH service" "systemctl reload sshd or ssh" systemctl reload sshd 2>/dev/null || systemctl reload ssh 2>/dev/null || true msg_ok "$(translate "SSH MaxAuthTries set to 3 (original: ${original_max_auth})")" fi @@ -344,7 +353,9 @@ EOF # Enable and restart the service (restart ensures new jails are loaded # even if fail2ban was already running from a previous install) systemctl daemon-reload - systemctl enable fail2ban >/dev/null 2>&1 + pmx_apply_setting "fail2ban enabled state" "systemctl is-enabled fail2ban 2>/dev/null || true" \ + systemctl enable fail2ban + pmx_record_execution "restart fail2ban" "systemctl restart fail2ban" systemctl restart fail2ban >/dev/null 2>&1 sleep 3 @@ -372,29 +383,32 @@ EOF # Uninstall # ========================================================== uninstall_fail2ban() { + local FUNC_VERSION="1.0" + pmx_journal_context "uninstall_fail2ban" "$FUNC_VERSION" + show_proxmenux_logo msg_title "$(translate "$SCRIPT_TITLE")" msg_info2 "$(translate "Removing Fail2Ban...")" - systemctl stop fail2ban 2>/dev/null || true - systemctl disable fail2ban 2>/dev/null || true + pmx_disable_service fail2ban 2>/dev/null || true # Stop and remove the auth logger services - systemctl stop proxmox-auth-logger.service 2>/dev/null || true - systemctl disable proxmox-auth-logger.service 2>/dev/null || true - rm -f /etc/systemd/system/proxmox-auth-logger.service - systemctl stop ssh-auth-logger.service 2>/dev/null || true - systemctl disable ssh-auth-logger.service 2>/dev/null || true - rm -f /etc/systemd/system/ssh-auth-logger.service + pmx_disable_service proxmox-auth-logger.service 2>/dev/null || true + pmx_remove_file /etc/systemd/system/proxmox-auth-logger.service + pmx_disable_service ssh-auth-logger.service 2>/dev/null || true + pmx_remove_file /etc/systemd/system/ssh-auth-logger.service systemctl daemon-reload 2>/dev/null || true + pmx_record_execution "remove Fail2Ban auth logger files" \ + "rm -f /var/log/proxmox-auth.log /var/log/ssh-auth.log" rm -f /var/log/proxmox-auth.log /var/log/ssh-auth.log + pmx_record_execution "purge fail2ban package" "apt-get purge -y fail2ban" DEBIAN_FRONTEND=noninteractive apt-get purge -y fail2ban >/dev/null 2>&1 - rm -f /etc/fail2ban/jail.d/proxmox.conf - rm -f /etc/fail2ban/jail.d/proxmenux.conf - rm -f /etc/fail2ban/filter.d/proxmox.conf - rm -f /etc/fail2ban/filter.d/proxmenux.conf - rm -f /etc/fail2ban/jail.local + pmx_remove_file /etc/fail2ban/jail.d/proxmox.conf + pmx_remove_file /etc/fail2ban/jail.d/proxmenux.conf + pmx_remove_file /etc/fail2ban/filter.d/proxmox.conf + pmx_remove_file /etc/fail2ban/filter.d/proxmenux.conf + pmx_remove_file /etc/fail2ban/jail.local # ── Restore SSH MaxAuthTries to original value ── local sshd_config="/etc/ssh/sshd_config" @@ -405,17 +419,19 @@ uninstall_fail2ban() { if [[ -n "$original_val" ]]; then msg_info "$(translate "Restoring SSH MaxAuthTries to ${original_val}...")" if grep -qi '^MaxAuthTries' "$sshd_config"; then - sed -i "s/^MaxAuthTries.*/MaxAuthTries ${original_val}/" "$sshd_config" + pmx_edit_file "$sshd_config" "s/^MaxAuthTries.*/MaxAuthTries ${original_val}/" fi + pmx_record_execution "reload SSH service" "systemctl reload sshd or ssh" systemctl reload sshd 2>/dev/null || systemctl reload ssh 2>/dev/null || true msg_ok "$(translate "SSH MaxAuthTries restored to ${original_val}")" fi - rm -f "$backup_file" + pmx_remove_file "$backup_file" fi # Remove journald drop-in and restore original log level if [[ -f /etc/systemd/journald.conf.d/proxmenux-loglevel.conf ]]; then - rm -f /etc/systemd/journald.conf.d/proxmenux-loglevel.conf + pmx_remove_file /etc/systemd/journald.conf.d/proxmenux-loglevel.conf + pmx_record_execution "restart systemd-journald" "systemctl restart systemd-journald" systemctl restart systemd-journald 2>/dev/null || true msg_ok "$(translate "journald log level restored")" fi diff --git a/scripts/security/lynis_installer.sh b/scripts/security/lynis_installer.sh index d9ffc3f3..49e95586 100644 --- a/scripts/security/lynis_installer.sh +++ b/scripts/security/lynis_installer.sh @@ -46,6 +46,10 @@ if [[ -f "$UTILS_FILE" ]]; then source "$UTILS_FILE" fi +if [[ -f "$LOCAL_SCRIPTS/global/pmx_journal.sh" ]]; then + source "$LOCAL_SCRIPTS/global/pmx_journal.sh" +fi + if [[ ! -f "$COMPONENTS_STATUS_FILE" ]]; then echo "{}" > "$COMPONENTS_STATUS_FILE" fi @@ -80,6 +84,9 @@ detect_lynis() { # Installation # ========================================================== install_lynis() { + local FUNC_VERSION="1.0" + pmx_journal_context "install_lynis" "$FUNC_VERSION" + show_proxmenux_logo msg_title "$(translate "$SCRIPT_TITLE")" msg_info2 "$(translate "Installing latest Lynis security scan tool...")" @@ -91,7 +98,7 @@ install_lynis() { if ! command -v git >/dev/null 2>&1; then msg_info "$(translate "Installing Git as a prerequisite...")" apt-get update -qq >/dev/null 2>&1 - if apt-get install -y git >/dev/null 2>&1 && command -v git >/dev/null 2>&1; then + if pmx_install_pkg git && command -v git >/dev/null 2>&1; then msg_ok "$(translate "Git installed")" else msg_error "$(translate "Could not install Git — Lynis cannot be cloned. Run 'apt-get install git' manually.")" @@ -102,15 +109,17 @@ install_lynis() { # Remove old installation if present if [[ -d /opt/lynis ]]; then msg_info "$(translate "Removing previous Lynis installation...")" + pmx_record_execution "remove previous Lynis installation from /opt/lynis" "rm -rf /opt/lynis" rm -rf /opt/lynis >/dev/null 2>&1 msg_ok "$(translate "Previous installation removed")" fi # Clone from GitHub msg_info "$(translate "Cloning Lynis from GitHub...")" + pmx_record_execution "install Lynis in /opt/lynis" "git clone https://github.com/CISOfy/lynis.git /opt/lynis" if git clone --quiet https://github.com/CISOfy/lynis.git /opt/lynis >/dev/null 2>&1; then # Create wrapper script - cat << 'EOF' > /usr/local/bin/lynis + pmx_write_file /usr/local/bin/lynis << 'EOF' #!/bin/bash cd /opt/lynis && ./lynis "$@" EOF @@ -144,6 +153,9 @@ EOF # Update # ========================================================== update_lynis() { + local FUNC_VERSION="1.0" + pmx_journal_context "update_lynis" "$FUNC_VERSION" + show_proxmenux_logo msg_title "$(translate "$SCRIPT_TITLE")" msg_info2 "$(translate "Updating Lynis to the latest version...")" @@ -151,6 +163,7 @@ update_lynis() { if [[ -d /opt/lynis/.git ]]; then cd /opt/lynis msg_info "$(translate "Pulling latest changes from GitHub...")" + pmx_record_execution "update Lynis installation in /opt/lynis" "git pull --quiet" if git pull --quiet >/dev/null 2>&1; then local version version=$(/usr/local/bin/lynis show version 2>/dev/null) @@ -174,6 +187,9 @@ update_lynis() { # Run Audit # ========================================================== run_audit() { + local FUNC_VERSION="1.0" + pmx_journal_context "run_audit" "$FUNC_VERSION" + show_proxmenux_logo msg_title "$(translate "$SCRIPT_TITLE")" msg_info2 "$(translate "Running Lynis security audit...")" @@ -185,6 +201,7 @@ run_audit() { fi # Run the audit + pmx_record_execution "run Lynis system audit" "$LYNIS_CMD audit system --no-colors" "$LYNIS_CMD" audit system --no-colors 2>&1 echo "" @@ -197,12 +214,16 @@ run_audit() { # Uninstall # ========================================================== uninstall_lynis() { + local FUNC_VERSION="1.0" + pmx_journal_context "uninstall_lynis" "$FUNC_VERSION" + show_proxmenux_logo msg_title "$(translate "$SCRIPT_TITLE")" msg_info2 "$(translate "Removing Lynis...")" + pmx_record_execution "remove Lynis installation from /opt/lynis" "rm -rf /opt/lynis" rm -rf /opt/lynis 2>/dev/null - rm -f /usr/local/bin/lynis 2>/dev/null + pmx_remove_file /usr/local/bin/lynis 2>/dev/null update_component_status "lynis" "removed" "" "security" '{}' diff --git a/scripts/share/disk_host.sh b/scripts/share/disk_host.sh index 1cf00036..355c12aa 100644 --- a/scripts/share/disk_host.sh +++ b/scripts/share/disk_host.sh @@ -54,6 +54,10 @@ elif [[ -f "$LOCAL_SCRIPTS_DEFAULT/global/disk_ops_helpers.sh" ]]; then source "$LOCAL_SCRIPTS_DEFAULT/global/disk_ops_helpers.sh" fi +if [[ -f "$LOCAL_SCRIPTS/global/pmx_journal.sh" ]]; then + source "$LOCAL_SCRIPTS/global/pmx_journal.sh" +fi + load_language initialize_cache @@ -471,6 +475,8 @@ format_and_mount_disk() { local disk="$1" local mount_path="$2" local filesystem="$3" + local FUNC_VERSION="1.0" + pmx_journal_context "format_and_mount_disk" "$FUNC_VERSION" # Final confirmation before any destructive operation local disk_size @@ -480,6 +486,8 @@ format_and_mount_disk() { 14 80; then return 1 fi + pmx_record_execution "format disk ${disk} as ${filesystem} for ${mount_path}" \ + "wipe disk, create partition and format as ${filesystem}" show_proxmenux_logo if [[ "$MODE_PVESM" -eq 1 && "$MODE_FSTAB" -eq 1 ]]; then msg_title "$(translate "Add Local Disk (Proxmox storage + host mount)")" @@ -544,6 +552,8 @@ mount_disk_permanently() { local partition="$1" local mount_path="$2" local filesystem="$3" + local FUNC_VERSION="1.0" + pmx_journal_context "mount_disk_permanently" "$FUNC_VERSION" if [[ "$filesystem" == "zfs" ]]; then if ! zpool list "$STORAGE_ID" >/dev/null 2>&1; then @@ -562,6 +572,8 @@ mount_disk_permanently() { msg_ok "$(translate "Mount point created")" msg_info "$(translate "Mounting disk...")" + pmx_record_execution "mount ${partition} at ${mount_path}" \ + "mount -t ${filesystem} ${partition} ${mount_path}" if ! mount -t "$filesystem" "$partition" "$mount_path" 2>/dev/null; then msg_error "$(translate "Failed to mount disk")" return 1 @@ -574,13 +586,13 @@ mount_disk_permanently() { if [[ -n "$disk_uuid" ]]; then # Remove any existing fstab entry for this UUID or mount point - sed -i "\|UUID=$disk_uuid|d" /etc/fstab - sed -i "\|[[:space:]]${mount_path}[[:space:]]|d" /etc/fstab - echo "UUID=$disk_uuid $mount_path $filesystem defaults,nofail 0 2" >> /etc/fstab + pmx_edit_file /etc/fstab "\|UUID=$disk_uuid|d" + pmx_edit_file /etc/fstab "\|[[:space:]]${mount_path}[[:space:]]|d" + echo "UUID=$disk_uuid $mount_path $filesystem defaults,nofail 0 2" | pmx_append_file /etc/fstab msg_ok "$(translate "Added to /etc/fstab using UUID")" else - sed -i "\|[[:space:]]${mount_path}[[:space:]]|d" /etc/fstab - echo "$partition $mount_path $filesystem defaults,nofail 0 2" >> /etc/fstab + pmx_edit_file /etc/fstab "\|[[:space:]]${mount_path}[[:space:]]|d" + echo "$partition $mount_path $filesystem defaults,nofail 0 2" | pmx_append_file /etc/fstab msg_ok "$(translate "Added to /etc/fstab using device path")" fi @@ -604,10 +616,14 @@ mount_disk_permanently() { # but the change is harmless: existing owners keep their access. _apply_lxc_bind_mount_perms() { local mount_path="$1" + local FUNC_VERSION="1.0" + pmx_journal_context "_apply_lxc_bind_mount_perms" "$FUNC_VERSION" [[ "${MODE_FSTAB:-0}" -eq 1 ]] || return 0 [[ -d "$mount_path" ]] || return 0 msg_info "$(translate "Applying host permissions for unprivileged LXC bind-mounts...")" + pmx_record_execution "apply LXC bind-mount permissions to ${mount_path}" \ + "chmod o+rwx and setfacl on ${mount_path}" chmod o+rwx "$mount_path" 2>/dev/null || true if command -v setfacl >/dev/null 2>&1; then setfacl -m o::rwx "$mount_path" 2>/dev/null || true @@ -619,6 +635,8 @@ _apply_lxc_bind_mount_perms() { mount_existing_disk() { local disk="$1" local mount_path="$2" + local FUNC_VERSION="1.0" + pmx_journal_context "mount_existing_disk" "$FUNC_VERSION" local existing_fs existing_fs=$(blkid -s TYPE -o value "$disk" 2>/dev/null || true) @@ -635,6 +653,7 @@ mount_existing_disk() { msg_ok "$(translate "Mount point created")" msg_info "$(translate "Mounting existing") $existing_fs $(translate "filesystem...")" + pmx_record_execution "mount existing disk ${disk} at ${mount_path}" "mount ${disk} ${mount_path}" if ! mount "$disk" "$mount_path" 2>/dev/null; then msg_error "$(translate "Failed to mount disk")" return 1 @@ -645,9 +664,9 @@ mount_existing_disk() { local disk_uuid disk_uuid=$(blkid -s UUID -o value "$disk" 2>/dev/null) if [[ -n "$disk_uuid" ]]; then - sed -i "\|UUID=$disk_uuid|d" /etc/fstab - sed -i "\|[[:space:]]${mount_path}[[:space:]]|d" /etc/fstab - echo "UUID=$disk_uuid $mount_path $existing_fs defaults,nofail 0 2" >> /etc/fstab + pmx_edit_file /etc/fstab "\|UUID=$disk_uuid|d" + pmx_edit_file /etc/fstab "\|[[:space:]]${mount_path}[[:space:]]|d" + echo "UUID=$disk_uuid $mount_path $existing_fs defaults,nofail 0 2" | pmx_append_file /etc/fstab msg_ok "$(translate "Added to /etc/fstab")" fi @@ -664,6 +683,8 @@ add_proxmox_dir_storage() { local content="$3" local storage_kind="dir" local pool_name="$storage_id" + local FUNC_VERSION="1.0" + pmx_journal_context "add_proxmox_dir_storage" "$FUNC_VERSION" if [[ "${FILESYSTEM:-}" == "zfs" ]]; then storage_kind="zfspool" @@ -681,6 +702,7 @@ add_proxmox_dir_storage() { 8 60; then return 0 fi + pmx_record_execution "remove existing Proxmox storage ${storage_id}" "pvesm remove ${storage_id}" pvesm remove "$storage_id" 2>/dev/null || true fi @@ -688,12 +710,16 @@ add_proxmox_dir_storage() { local pvesm_output local add_ok=false if [[ "$storage_kind" == "zfspool" ]]; then + pmx_record_execution "add ZFS pool ${pool_name} as Proxmox storage ${storage_id}" \ + "pvesm add zfspool ${storage_id} --pool ${pool_name} --content ${content}" if pvesm_output=$(pvesm add zfspool "$storage_id" \ --pool "$pool_name" \ --content "$content" 2>&1); then add_ok=true fi else + pmx_record_execution "add directory ${path} as Proxmox storage ${storage_id}" \ + "pvesm add dir ${storage_id} --path ${path} --content ${content}" if pvesm_output=$(pvesm add dir "$storage_id" \ --path "$path" \ --content "$content" 2>&1); then @@ -742,6 +768,9 @@ add_proxmox_dir_storage() { # ========================================================== add_disk_to_proxmox() { + local FUNC_VERSION="1.0" + pmx_journal_context "add_disk_to_proxmox" "$FUNC_VERSION" + # Check required tools for tool in parted mkfs.ext4 mkfs.xfs blkid lsblk sgdisk; do if ! command -v "$tool" >/dev/null 2>&1; then @@ -749,7 +778,7 @@ add_disk_to_proxmox() { msg_title "$(translate "Add Local Disk as Proxmox Storage")" msg_info "$(translate "Installing required tools...")" apt-get update &>/dev/null - apt-get install -y parted e2fsprogs util-linux xfsprogs gdisk btrfs-progs &>/dev/null + pmx_install_pkg parted e2fsprogs util-linux xfsprogs gdisk btrfs-progs stop_spinner break fi @@ -990,6 +1019,8 @@ view_disk_storages() { _remove_pvesm_storage() { local storage_id="$1" + local FUNC_VERSION="1.0" + pmx_journal_context "_remove_pvesm_storage" "$FUNC_VERSION" local path pool content stype path=$(get_storage_config "$storage_id" | awk '$1 == "path" {print $2}') pool=$(get_storage_config "$storage_id" | awk '$1 == "pool" {print $2}') @@ -1017,6 +1048,7 @@ _remove_pvesm_storage() { # Step 1: Remove from Proxmox msg_info "$(translate "Removing storage from Proxmox...")" + pmx_record_execution "remove Proxmox storage ${storage_id}" "pvesm remove ${storage_id}" if ! pvesm remove "$storage_id" 2>/dev/null; then msg_error "$(translate "Failed to remove storage from Proxmox.")" echo "" @@ -1029,6 +1061,7 @@ _remove_pvesm_storage() { # Step 2: Unmount if mounted (dir-backed storages only) if [[ -n "$path" ]] && mountpoint -q "$path" 2>/dev/null; then msg_info "$(translate "Unmounting disk...")" + pmx_record_execution "unmount disk from ${path}" "umount ${path}" if umount "$path" 2>/dev/null; then msg_ok "$(translate "Disk unmounted from") $path" else @@ -1045,7 +1078,9 @@ _remove_pvesm_storage() { msg_info "$(translate "Removing from /etc/fstab...")" local tmp tmp=$(mktemp) - awk -v mp="$path" '$2 != mp' /etc/fstab > "$tmp" && mv "$tmp" /etc/fstab + if awk -v mp="$path" '$2 != mp' /etc/fstab > "$tmp"; then + pmx_write_file /etc/fstab < "$tmp" && rm -f "$tmp" + fi systemctl daemon-reload 2>/dev/null || true msg_ok "$(translate "Removed from /etc/fstab")" fi @@ -1053,6 +1088,7 @@ _remove_pvesm_storage() { # Step 3b: Export ZFS pool if applicable if [[ -n "$pool" ]] && zpool list "$pool" >/dev/null 2>&1; then msg_info "$(translate "Exporting ZFS pool...") $pool" + pmx_record_execution "export ZFS pool ${pool}" "zpool export ${pool}" if zpool export "$pool" 2>/dev/null; then msg_ok "$(translate "ZFS pool exported:") $pool" else @@ -1069,6 +1105,7 @@ _remove_pvesm_storage() { read -r echo "" msg_warn "$(translate "Rebooting the system...")" + pmx_record_execution "reboot host after removing storage ${storage_id}" "reboot" reboot else echo "" @@ -1082,6 +1119,8 @@ _remove_pvesm_storage() { _remove_fstab_entry() { local mount_point="$1" + local FUNC_VERSION="1.0" + pmx_journal_context "_remove_fstab_entry" "$FUNC_VERSION" local fs fstype while IFS= read -r line; do @@ -1122,6 +1161,7 @@ _remove_fstab_entry() { if $mounted; then msg_info "$(translate "Unmounting") $mount_point..." + pmx_record_execution "unmount disk from ${mount_point}" "umount ${mount_point}" if umount "$mount_point" 2>/dev/null; then msg_ok "$(translate "Unmounted successfully")" else @@ -1133,7 +1173,8 @@ _remove_fstab_entry() { local tmp tmp=$(mktemp) awk -v mp="$mount_point" '$2 != mp' /etc/fstab > "$tmp" - mv "$tmp" /etc/fstab + pmx_write_file /etc/fstab < "$tmp" + rm -f "$tmp" systemctl daemon-reload 2>/dev/null || true msg_ok "$(translate "Removed from /etc/fstab")" diff --git a/scripts/share/iscsi_host.sh b/scripts/share/iscsi_host.sh index 2400b50f..3e6b4099 100644 --- a/scripts/share/iscsi_host.sh +++ b/scripts/share/iscsi_host.sh @@ -30,6 +30,10 @@ if [[ -f "$UTILS_FILE" ]]; then source "$UTILS_FILE" fi +if [[ -f "$LOCAL_SCRIPTS/global/pmx_journal.sh" ]]; then + source "$LOCAL_SCRIPTS/global/pmx_journal.sh" +fi + load_language initialize_cache @@ -56,16 +60,20 @@ get_storage_config() { # ========================================================== ensure_iscsi_tools() { + local FUNC_VERSION="1.0" + pmx_journal_context "ensure_iscsi_tools" "$FUNC_VERSION" + if ! command -v iscsiadm >/dev/null 2>&1; then msg_info "$(translate "Installing iSCSI initiator tools...")" apt-get update &>/dev/null - apt-get install -y open-iscsi &>/dev/null - systemctl enable --now iscsid 2>/dev/null || true + pmx_install_pkg open-iscsi + pmx_enable_service iscsid 2>/dev/null || true msg_ok "$(translate "iSCSI tools installed")" fi if ! systemctl is-active --quiet iscsid 2>/dev/null; then - systemctl start iscsid 2>/dev/null || true + pmx_apply_setting "iscsid active state" "systemctl is-active iscsid 2>/dev/null || true" \ + systemctl start iscsid || true fi } @@ -217,6 +225,9 @@ configure_iscsi_storage() { # ========================================================== add_proxmox_iscsi_storage() { + local FUNC_VERSION="1.0" + pmx_journal_context "add_proxmox_iscsi_storage" "$FUNC_VERSION" + local storage_id="$1" local portal="$2" local target="$3" @@ -233,6 +244,8 @@ add_proxmox_iscsi_storage() { 8 60 --title "$(translate "Storage Exists")"; then return 0 fi + pmx_record_execution "remove existing Proxmox iSCSI storage ${storage_id}" \ + "pvesm remove ${storage_id}" pvesm remove "$storage_id" 2>/dev/null || true fi @@ -240,6 +253,8 @@ add_proxmox_iscsi_storage() { msg_info "$(translate "Adding iSCSI storage to Proxmox...")" local pvesm_output pvesm_result + pmx_record_execution "add iSCSI target ${target} as Proxmox storage ${storage_id}" \ + "pvesm add iscsi ${storage_id} --portal ${portal} --target ${target} --content ${content}" pvesm_output=$(pvesm add iscsi "$storage_id" \ --portal "$portal" \ --target "$target" \ @@ -359,6 +374,9 @@ view_iscsi_storages() { } remove_iscsi_storage() { + local FUNC_VERSION="1.0" + pmx_journal_context "remove_iscsi_storage" "$FUNC_VERSION" + if ! command -v pvesm >/dev/null 2>&1; then dialog --backtitle "ProxMenux" --title "$(translate "Error")" \ --msgbox "\n$(translate "pvesm not found.")" 8 60 @@ -400,6 +418,7 @@ remove_iscsi_storage() { show_proxmenux_logo msg_title "$(translate "Remove iSCSI Storage")" + pmx_record_execution "remove Proxmox iSCSI storage ${SELECTED}" "pvesm remove ${SELECTED}" if pvesm remove "$SELECTED" 2>/dev/null; then msg_ok "$(translate "Storage") $SELECTED $(translate "removed successfully from Proxmox.")" else diff --git a/scripts/share/local-shared-manager.sh b/scripts/share/local-shared-manager.sh index 31f6a378..b0e47533 100644 --- a/scripts/share/local-shared-manager.sh +++ b/scripts/share/local-shared-manager.sh @@ -42,6 +42,10 @@ if [[ -f "$UTILS_FILE" ]]; then source "$UTILS_FILE" fi +if [[ -f "$LOCAL_SCRIPTS/global/pmx_journal.sh" ]]; then + source "$LOCAL_SCRIPTS/global/pmx_journal.sh" +fi + SHARE_COMMON_FILE="$LOCAL_SCRIPTS/global/share-common.func" if ! source "$SHARE_COMMON_FILE" 2>/dev/null; then msg_error "$(translate "Could not load shared functions. Script cannot continue.")" @@ -64,9 +68,14 @@ fi lsm_apply_multi_unpriv_permissions() { local dir="$1" + local FUNC_VERSION="1.0" + pmx_journal_context "lsm_apply_multi_unpriv_permissions" "$FUNC_VERSION" [[ -z "$dir" || ! -d "$dir" ]] && return 1 + pmx_record_execution "apply shared LXC permission profile to ${dir}" \ + "chown root:root; chmod 1777; chmod -R a+rwX; apply default ACLs when available" + # root:root ownership — no new group needed. chown root:root "$dir" 2>/dev/null || true @@ -224,6 +233,9 @@ lsm_select_host_mount_point_dialog() { } create_shared_directory() { + local FUNC_VERSION="1.0" + pmx_journal_context "create_shared_directory" "$FUNC_VERSION" + lsm_select_host_mount_point_dialog "$(translate "Select Shared Directory Location")" "shared" [[ -z "$LSM_SELECTED_MOUNT_POINT" ]] && return SHARED_DIR="$LSM_SELECTED_MOUNT_POINT" @@ -231,6 +243,7 @@ create_shared_directory() { show_proxmenux_logo msg_title "$(translate "Create Shared Directory")" + pmx_record_execution "create shared directory ${SHARED_DIR}" "mkdir -p ${SHARED_DIR}" if ! mkdir -p "$SHARED_DIR" 2>/dev/null; then msg_error "$(translate "Failed to create directory:") $SHARED_DIR" echo "" diff --git a/scripts/share/lxc-mount-manager_minimal.sh b/scripts/share/lxc-mount-manager_minimal.sh index 2052b2f3..b09894bf 100644 --- a/scripts/share/lxc-mount-manager_minimal.sh +++ b/scripts/share/lxc-mount-manager_minimal.sh @@ -30,6 +30,10 @@ BASE_DIR="/usr/local/share/proxmenux" source "$BASE_DIR/utils.sh" +if [[ -f "/usr/local/share/proxmenux/scripts/global/pmx_journal.sh" ]]; then + source "/usr/local/share/proxmenux/scripts/global/pmx_journal.sh" +fi + load_language initialize_cache @@ -289,6 +293,8 @@ select_lxc_container() { select_container_mount_point() { local ctid="$1" local host_dir="$2" + local FUNC_VERSION="1.0" + pmx_journal_context "select_container_mount_point" "$FUNC_VERSION" local base_name base_name=$(basename "$host_dir") @@ -333,6 +339,8 @@ select_container_mount_point() { local ct_status ct_status=$(pct status "$ctid" 2>/dev/null | awk '{print $2}') if [[ "$ct_status" == "running" ]]; then + pmx_record_execution "create mount directory ${mount_point} in CT ${ctid}" \ + "pct exec ${ctid} -- mkdir -p ${mount_point}" pct exec "$ctid" -- mkdir -p "$mount_point" 2>/dev/null fi @@ -367,6 +375,8 @@ add_bind_mount() { local ctid="$1" local host_path="$2" local ct_path="$3" + local FUNC_VERSION="1.0" + pmx_journal_context "add_bind_mount" "$FUNC_VERSION" if [[ ! "$ctid" =~ ^[0-9]+$ || -z "$host_path" || -z "$ct_path" ]]; then msg_error "$(translate "Invalid parameters for bind mount")" @@ -383,6 +393,8 @@ add_bind_mount() { mpidx=$(get_next_mp_index "$ctid") local result + pmx_record_execution "add bind mount ${host_path} to CT ${ctid} at ${ct_path}" \ + "pct set ${ctid} -mp${mpidx} ${host_path},mp=${ct_path},shared=1,backup=0" result=$(pct set "$ctid" -mp${mpidx} "$host_path,mp=$ct_path,shared=1,backup=0" 2>&1) if [[ $? -eq 0 ]]; then @@ -451,6 +463,9 @@ view_mount_points() { } remove_mount_point() { + local FUNC_VERSION="1.0" + pmx_journal_context "remove_mount_point" "$FUNC_VERSION" + show_proxmenux_logo msg_title "$(translate "Remove LXC Mount Point")" @@ -532,6 +547,8 @@ $(translate "Proceed with removal")?" msg_title "$(translate "Remove LXC Mount Point")" msg_info "$(translate "Removing mount point") $selected_mp $(translate "from container") $container_id..." + pmx_record_execution "remove mount point ${selected_mp} from CT ${container_id}" \ + "pct set ${container_id} --delete ${selected_mp}" if pct set "$container_id" --delete "$selected_mp" 2>/dev/null; then msg_ok "$(translate "Mount point removed successfully")" @@ -541,6 +558,8 @@ $(translate "Proceed with removal")?" echo "" if whiptail --yesno "$(translate "Container is running. Restart to apply changes?")" 8 60; then msg_info "$(translate "Restarting container...")" + pmx_record_execution "restart CT ${container_id} after removing ${selected_mp}" \ + "pct reboot ${container_id}" if pct reboot "$container_id"; then sleep 3 msg_ok "$(translate "Container restarted successfully")" @@ -573,6 +592,8 @@ $(translate "Proceed with removal")?" lmm_fix_cifs_access() { local host_dir="$1" local is_unprivileged="$2" + local FUNC_VERSION="1.0" + pmx_journal_context "lmm_fix_cifs_access" "$FUNC_VERSION" # CIFS mounted by Proxmox GUI uses uid=0/gid=0 by default (root only). # The fix: remount with uid/gid that the LXC can access. @@ -620,13 +641,16 @@ $(translate "Apply fix now? (The share will be briefly remounted)")" \ 18 84 3>&1 1>&2 2>&3; then msg_info "$(translate "Remounting CIFS share with open permissions...")" + pmx_record_execution "remount CIFS share ${mount_src} at ${host_dir}" \ + "umount ${host_dir}; mount -t cifs ${mount_src} ${host_dir} -o ${new_opts}" if umount "$host_dir" 2>/dev/null && \ mount -t cifs "$mount_src" "$host_dir" -o "$new_opts" 2>/dev/null; then msg_ok "$(translate "CIFS share remounted — LXC containers can now read and write")" # Update fstab if the mount is there if grep -qF "$host_dir" /etc/fstab 2>/dev/null; then - sed -i "s|^\(${mount_src}[[:space:]].*${host_dir}.*cifs[[:space:]]\).*|\1${new_opts} 0 0|" /etc/fstab 2>/dev/null || true + pmx_edit_file /etc/fstab \ + "s|^\(${mount_src}[[:space:]].*${host_dir}.*cifs[[:space:]]\).*|\1${new_opts} 0 0|" 2>/dev/null || true msg_ok "$(translate "/etc/fstab updated — permissions will persist after reboot")" fi else @@ -639,6 +663,8 @@ lmm_fix_nfs_access() { local host_dir="$1" local is_unprivileged="$2" local uid_shift="${3:-100000}" + local FUNC_VERSION="1.0" + pmx_journal_context "lmm_fix_nfs_access" "$FUNC_VERSION" # NFS: the host cannot override server-side permissions. # BUT: if the server exports with root_squash (default), we can check @@ -678,6 +704,8 @@ $(translate "If it still fails, the NFS server export options must be changed on $(translate "Apply fix now?")" \ 18 84 3>&1 1>&2 2>&3; then + pmx_record_execution "apply LXC access permissions to NFS directory ${host_dir}" \ + "chmod 1777 and setfacl on ${host_dir}" if chmod 1777 "$host_dir" 2>/dev/null; then msg_ok "$(translate "NFS directory permissions set — containers should now be able to write")" else @@ -716,6 +744,8 @@ $(translate "You can still mount this share for READ-ONLY access.")" \ lmm_offer_host_permissions() { local host_dir="$1" local is_unprivileged="$2" + local FUNC_VERSION="1.0" + pmx_journal_context "lmm_offer_host_permissions" "$FUNC_VERSION" # Privileged containers: UID 0 inside = UID 0 on host — always accessible [[ "$is_unprivileged" != "1" ]] && return 0 @@ -749,6 +779,8 @@ $(translate "Apply read+write access for 'others' on the host directory?")\n\n\ $(translate "(Only the host directory is modified. Nothing inside the container is changed.")" \ 16 80 3>&1 1>&2 2>&3; then + pmx_record_execution "grant mapped LXC users access to host directory ${host_dir}" \ + "chmod o+rwx and setfacl on ${host_dir}" chmod o+rwx "$host_dir" 2>/dev/null || true if command -v setfacl >/dev/null 2>&1; then setfacl -m o::rwx "$host_dir" 2>/dev/null || true @@ -798,6 +830,8 @@ _lmm_verify_writable() { # ========================================================== mount_host_directory_minimal() { + local FUNC_VERSION="1.0" + # Step 1: Select container local container_id container_id=$(select_lxc_container) @@ -900,10 +934,13 @@ $(translate "Proceed")?" # bind-mount is supposed to spare them. local ct_status ct_status=$(pct status "$container_id" 2>/dev/null | awk '{print $2}') + pmx_journal_context "mount_host_directory_minimal" "$FUNC_VERSION" echo "" if [[ "$ct_status" == "running" ]]; then if whiptail --yesno "$(translate "Restart container to activate mount?")" 8 60; then msg_info "$(translate "Restarting container...")" + pmx_record_execution "restart CT ${container_id} to activate bind mount" \ + "pct reboot ${container_id}" if pct reboot "$container_id"; then sleep 5 msg_ok "$(translate "Container restarted successfully")" @@ -918,6 +955,8 @@ $(translate "Proceed")?" # declines, fall back to the informational line. if whiptail --yesno "$(translate "Container is stopped. Start it now to verify the mount works?")" 8 70; then msg_info "$(translate "Starting container...")" + pmx_record_execution "start CT ${container_id} to activate and verify bind mount" \ + "pct start ${container_id}" if pct start "$container_id"; then sleep 5 msg_ok "$(translate "Container started successfully")" diff --git a/scripts/share/nfs_client.sh b/scripts/share/nfs_client.sh index 1d8548bf..31572d79 100644 --- a/scripts/share/nfs_client.sh +++ b/scripts/share/nfs_client.sh @@ -29,6 +29,10 @@ if [[ -f "$UTILS_FILE" ]]; then source "$UTILS_FILE" fi +if [[ -f "$LOCAL_SCRIPTS/global/pmx_journal.sh" ]]; then + source "$LOCAL_SCRIPTS/global/pmx_journal.sh" +fi + # Load shared functions SHARE_COMMON_FILE="$LOCAL_SCRIPTS/global/share-common.func" if ! source "$SHARE_COMMON_FILE" 2>/dev/null; then @@ -44,6 +48,8 @@ select_privileged_lxc install_nfs_client() { + local FUNC_VERSION="1.0" + pmx_journal_context "install_nfs_client" "$FUNC_VERSION" if pct exec "$CTID" -- dpkg -s nfs-common &>/dev/null; then return 0 @@ -65,6 +71,8 @@ install_nfs_client() { fi msg_info "$(translate "Installing NFS client packages...")" + pmx_record_execution "install NFS client packages in CT ${CTID}" \ + "pct exec ${CTID} -- apt-get update and apt-get install -y nfs-common" if ! pct exec "$CTID" -- apt-get update >/dev/null 2>&1; then msg_error "$(translate "Failed to update package list.")" msg_success "$(translate "Press Enter to return to menu...")" @@ -99,6 +107,9 @@ install_nfs_client() { discover_nfs_servers() { + local FUNC_VERSION="1.0" + pmx_journal_context "discover_nfs_servers" "$FUNC_VERSION" + show_proxmenux_logo msg_title "$(translate "Mount NFS Client in LXC")" msg_info "$(translate "Scanning network for NFS servers...")" @@ -110,7 +121,7 @@ discover_nfs_servers() { if ! which nmap >/dev/null 2>&1; then - apt-get install -y nmap &>/dev/null + pmx_install_pkg nmap fi @@ -367,6 +378,7 @@ validate_export_exists() { mount_nfs_share() { + local FUNC_VERSION="1.0" # Step 0: Install NFS client first install_nfs_client || return @@ -395,7 +407,9 @@ mount_nfs_share() { # Step 4: Configure mount options configure_mount_options || return - + pmx_journal_context "mount_nfs_share" "$FUNC_VERSION" + pmx_record_execution "mount NFS export ${NFS_SERVER}:${NFS_EXPORT} in CT ${CTID} at ${MOUNT_POINT}" \ + "pct exec ${CTID} -- mount NFS; persistent=${PERMANENT_MOUNT}" if ! pct exec "$CTID" -- test -d "$MOUNT_POINT"; then @@ -432,9 +446,9 @@ mount_nfs_share() { # Add to fstab if permanent if [[ "$PERMANENT_MOUNT" == "true" ]]; then - pct exec "$CTID" -- sed -i "\|$MOUNT_POINT|d" /etc/fstab + pct exec "$CTID" -- sed --in-place "\|$MOUNT_POINT|d" /etc/fstab FSTAB_ENTRY="$NFS_PATH $MOUNT_POINT nfs ${MOUNT_OPTIONS},_netdev,x-systemd.automount,noauto 0 0" - pct exec "$CTID" -- bash -c "echo '$FSTAB_ENTRY' >> /etc/fstab" + pct exec "$CTID" -- bash -c "printf '%s\\n' '$FSTAB_ENTRY' | tee -a /etc/fstab >/dev/null" msg_ok "$(translate "Added to /etc/fstab for permanent mounting.")" fi @@ -543,6 +557,9 @@ view_nfs_mounts() { unmount_nfs_share() { + local FUNC_VERSION="1.0" + pmx_journal_context "unmount_nfs_share" "$FUNC_VERSION" + # Get current NFS mounts MOUNTS=$(pct exec "$CTID" -- mount | grep -E "type nfs|:.*on.*nfs" | awk '{print $3}' | sort -u || true) FSTAB_MOUNTS=$(pct exec "$CTID" -- grep -E "nfs" /etc/fstab 2>/dev/null | grep -v "^#" | awk '{print $2}' | sort -u || true) @@ -568,7 +585,9 @@ unmount_nfs_share() { msg_title "$(translate "Unmount NFS Share")" # Remove from fstab - pct exec "$CTID" -- sed -i "\|[[:space:]]$SELECTED_MOUNT[[:space:]]|d" /etc/fstab + pmx_record_execution "remove NFS mount ${SELECTED_MOUNT} from CT ${CTID}" \ + "remove CT fstab entry and unmount ${SELECTED_MOUNT}" + pct exec "$CTID" -- sed --in-place "\|[[:space:]]$SELECTED_MOUNT[[:space:]]|d" /etc/fstab msg_ok "$(translate "Removed from /etc/fstab.")" # Actually unmount it now (the previous version only edited fstab, @@ -598,6 +617,9 @@ unmount_nfs_share() { test_nfs_connectivity() { + local FUNC_VERSION="1.0" + pmx_journal_context "test_nfs_connectivity" "$FUNC_VERSION" + show_proxmenux_logo msg_title "$(translate "Test NFS Connectivity")" @@ -621,6 +643,8 @@ test_nfs_connectivity() { else echo "$(translate "RPC Bind Service: STOPPED")" msg_warn "$(translate "Starting rpcbind service...")" + pmx_record_execution "start rpcbind in CT ${CTID}" \ + "pct exec ${CTID} -- systemctl start rpcbind" pct exec "$CTID" -- systemctl start rpcbind 2>/dev/null || true fi diff --git a/scripts/share/nfs_host.sh b/scripts/share/nfs_host.sh index 1127646c..4f7e9141 100644 --- a/scripts/share/nfs_host.sh +++ b/scripts/share/nfs_host.sh @@ -38,6 +38,10 @@ if [[ -f "$UTILS_FILE" ]]; then source "$UTILS_FILE" fi +if [[ -f "$LOCAL_SCRIPTS/global/pmx_journal.sh" ]]; then + source "$LOCAL_SCRIPTS/global/pmx_journal.sh" +fi + load_language initialize_cache @@ -64,6 +68,9 @@ get_storage_config() { # ========================================================== discover_nfs_servers() { + local FUNC_VERSION="1.0" + pmx_journal_context "discover_nfs_servers" "$FUNC_VERSION" + show_proxmenux_logo msg_title "$(translate "Add NFS Share as Proxmox Storage")" msg_info "$(translate "Scanning network for NFS servers...")" @@ -72,7 +79,7 @@ discover_nfs_servers() { NETWORK=$(echo "$HOST_IP" | cut -d. -f1-3).0/24 if ! which nmap >/dev/null 2>&1; then - apt-get install -y nmap &>/dev/null + pmx_install_pkg nmap fi SERVERS=$(nmap -p 2049 --open "$NETWORK" 2>/dev/null | grep -B 4 "2049/tcp open" | grep "Nmap scan report" | awk '{print $5}' | sort -u || true) @@ -253,6 +260,8 @@ add_proxmox_nfs_storage() { local server="$2" local export="$3" local content="${4:-import}" + local FUNC_VERSION="1.0" + pmx_journal_context "add_proxmox_nfs_storage" "$FUNC_VERSION" msg_info "$(translate "Starting Proxmox storage integration...")" @@ -267,11 +276,15 @@ add_proxmox_nfs_storage() { 8 60 --title "$(translate "Storage Exists")"; then return 0 fi + pmx_record_execution "remove existing Proxmox NFS storage ${storage_id}" \ + "pvesm remove ${storage_id}" pvesm remove "$storage_id" 2>/dev/null || true fi msg_ok "$(translate "Storage ID is available")" msg_info "$(translate "NFS storage adding in progress...")" + pmx_record_execution "add NFS export ${server}:${export} as Proxmox storage ${storage_id}" \ + "pvesm add nfs ${storage_id} --server ${server} --export ${export} --content ${content}" if pvesm_output=$(pvesm add nfs "$storage_id" \ --server "$server" \ --export "$export" \ @@ -384,6 +397,8 @@ mount_nfs_via_fstab() { local mount_path="$3" local mount_opts="$4" local replace="$5" + local FUNC_VERSION="1.0" + pmx_journal_context "mount_nfs_via_fstab" "$FUNC_VERSION" msg_info "$(translate "Preparing host mount...")" @@ -396,6 +411,8 @@ mount_nfs_via_fstab() { msg_ok "$(translate "Mount point ready:") $mount_path" msg_info "$(translate "Mounting NFS share...")" + pmx_record_execution "mount NFS export ${server}:${export_path} at ${mount_path}" \ + "mount -t nfs -o ${mount_opts} ${server}:${export_path} ${mount_path}" if ! mount -t nfs -o "$mount_opts" "${server}:${export_path}" "$mount_path" >/dev/null 2>&1; then msg_error "$(translate "Failed to mount NFS share on host.")" return 1 @@ -418,11 +435,12 @@ mount_nfs_via_fstab() { # Persist in /etc/fstab. if [[ "$replace" == "1" ]]; then - sed -i "\|[[:space:]]${mount_path}[[:space:]]|d" /etc/fstab + pmx_edit_file /etc/fstab "\|[[:space:]]${mount_path}[[:space:]]|d" fi - echo "${server}:${export_path} $mount_path nfs $mount_opts 0 0" >> /etc/fstab + echo "${server}:${export_path} $mount_path nfs $mount_opts 0 0" | pmx_append_file /etc/fstab msg_ok "$(translate "Added to /etc/fstab.")" + pmx_record_execution "reload systemd units after NFS fstab update" "systemctl daemon-reload" systemctl daemon-reload 2>/dev/null || true echo -e "" @@ -480,10 +498,13 @@ select_mount_methods() { # ========================================================== mount_nfs_share() { + local FUNC_VERSION="1.0" + pmx_journal_context "mount_nfs_share" "$FUNC_VERSION" + if ! which showmount >/dev/null 2>&1; then msg_info "$(translate "Installing NFS client tools...")" apt-get update &>/dev/null - apt-get install -y nfs-common &>/dev/null + pmx_install_pkg nfs-common msg_ok "$(translate "NFS client tools installed")" fi @@ -654,6 +675,9 @@ view_nfs_storages() { } remove_nfs_storage() { + local FUNC_VERSION="1.0" + pmx_journal_context "remove_nfs_storage" "$FUNC_VERSION" + # Collect every removable NFS entry: pvesm storages and fstab-only mounts. local OPTIONS=() local has_pvesm=0 @@ -718,6 +742,7 @@ remove_nfs_storage() { show_proxmenux_logo msg_title "$(translate "Remove NFS Storage")" + pmx_record_execution "remove Proxmox NFS storage ${target}" "pvesm remove ${target}" if pvesm remove "$target" 2>/dev/null; then msg_ok "$(translate "Storage") $target $(translate "removed successfully from Proxmox.")" else @@ -742,6 +767,7 @@ remove_nfs_storage() { # Try umount only if currently mounted; never force. if mount | grep -q " on ${mount_path} type "; then + pmx_record_execution "unmount NFS path ${mount_path}" "umount ${mount_path}" if umount "$mount_path" 2>/dev/null; then msg_ok "$(translate "Unmounted:") $mount_path" else @@ -756,12 +782,14 @@ remove_nfs_storage() { if awk -v mp="$mount_path" ' $2 == mp && ($3 == "nfs" || $3 == "nfs4") { next } { print } - ' /etc/fstab > /etc/fstab.tmp && mv /etc/fstab.tmp /etc/fstab; then + ' /etc/fstab > /etc/fstab.tmp && pmx_write_file /etc/fstab < /etc/fstab.tmp; then + rm -f /etc/fstab.tmp msg_ok "$(translate "Removed entry from /etc/fstab") ($(translate "backup at /etc/fstab.proxmenux.bak"))" else msg_error "$(translate "Failed to edit /etc/fstab — remove the line manually.")" fi + pmx_record_execution "reload systemd units after NFS fstab removal" "systemctl daemon-reload" systemctl daemon-reload 2>/dev/null || true # Try to remove the directory if empty; keep it otherwise. @@ -778,6 +806,9 @@ remove_nfs_storage() { } test_nfs_connectivity() { + local FUNC_VERSION="1.0" + pmx_journal_context "test_nfs_connectivity" "$FUNC_VERSION" + show_proxmenux_logo msg_title "$(translate "Test NFS Connectivity")" @@ -791,7 +822,8 @@ test_nfs_connectivity() { msg_ok "$(translate "RPC Bind Service: RUNNING")" else msg_warn "$(translate "RPC Bind Service: STOPPED - starting...")" - systemctl start rpcbind 2>/dev/null || true + pmx_apply_setting "rpcbind active state" "systemctl is-active rpcbind 2>/dev/null || true" \ + systemctl start rpcbind || true fi else msg_warn "$(translate "NFS Client Tools: NOT AVAILABLE")" diff --git a/scripts/share/nfs_lxc_server.sh b/scripts/share/nfs_lxc_server.sh index 870dd4de..6d80d7d6 100644 --- a/scripts/share/nfs_lxc_server.sh +++ b/scripts/share/nfs_lxc_server.sh @@ -31,6 +31,10 @@ if [[ -f "$UTILS_FILE" ]]; then source "$UTILS_FILE" fi +if [[ -f "$LOCAL_SCRIPTS/global/pmx_journal.sh" ]]; then + source "$LOCAL_SCRIPTS/global/pmx_journal.sh" +fi + # Load shared functions SHARE_COMMON_FILE="$LOCAL_SCRIPTS/global/share-common.func" if ! source "$SHARE_COMMON_FILE" 2>/dev/null; then @@ -49,6 +53,10 @@ select_privileged_lxc setup_universal_sharedfiles_group() { local ctid="$1" + local FUNC_VERSION="1.0" + pmx_journal_context "setup_universal_sharedfiles_group" "$FUNC_VERSION" + pmx_record_execution "configure sharedfiles group and UID mappings in CT ${ctid}" \ + "pct exec ${ctid} -- manage sharedfiles group, memberships and remapped users" msg_info "$(translate "Setting sharedfiles group with UID remapping...")" @@ -135,6 +143,9 @@ setup_universal_sharedfiles_group() { select_mount_point() { + local FUNC_VERSION="1.0" + pmx_journal_context "select_mount_point" "$FUNC_VERSION" + while true; do METHOD=$(whiptail --backtitle "ProxMenux" --title "$(translate "Select Folder")" \ --menu "$(translate "How do you want to select the folder to export?")" 15 60 5 \ @@ -181,6 +192,8 @@ select_mount_point() { --msgbox "$(translate "No mount point was specified.")" 8 50 continue fi + pmx_record_execution "create NFS export directory ${MOUNT_POINT} in CT ${CTID}" \ + "pct exec ${CTID} -- mkdir -p ${MOUNT_POINT}" pct exec "$CTID" -- mkdir -p "$MOUNT_POINT" 2>/dev/null return 0 ;; @@ -252,6 +265,7 @@ select_export_options() { create_nfs_export() { + local FUNC_VERSION="1.0" show_proxmenux_logo msg_title "$(translate "Create LXC server NFS")" @@ -262,6 +276,10 @@ create_nfs_export() { get_network_config || return select_export_options || return + pmx_journal_context "create_nfs_export" "$FUNC_VERSION" + pmx_record_execution "configure NFS export ${MOUNT_POINT} in CT ${CTID}" \ + "install and enable NFS services, update /etc/exports and reload exports" + msg_ok "$(translate "Directory successfully.")" @@ -269,7 +287,7 @@ create_nfs_export() { if ! pct exec "$CTID" -- dpkg -s nfs-kernel-server &>/dev/null; then msg_info "$(translate "Installing NFS server packages inside the CT...")" pct exec "$CTID" -- bash -c "apt-get update && apt-get install -y nfs-kernel-server nfs-common rpcbind" - pct exec "$CTID" -- systemctl enable --now rpcbind nfs-kernel-server + pct exec "$CTID" -- systemctl --now enable rpcbind nfs-kernel-server msg_ok "$(translate "NFS server installed successfully.")" else msg_ok "$(translate "NFS server is already installed.")" @@ -296,8 +314,8 @@ create_nfs_export() { if pct exec "$CTID" -- grep -q "^$MOUNT_POINT " /etc/exports; then if dialog --yesno "$(translate "Do you want to update the existing export?")" \ 10 60 --title "$(translate "Update Export")"; then - pct exec "$CTID" -- sed -i "\|^$MOUNT_POINT |d" /etc/exports - pct exec "$CTID" -- bash -c "echo '$EXPORT_LINE' >> /etc/exports" + pct exec "$CTID" -- sed --in-place "\|^$MOUNT_POINT |d" /etc/exports + pct exec "$CTID" -- bash -c "printf '%s\\n' '$EXPORT_LINE' | tee -a /etc/exports >/dev/null" show_proxmenux_logo msg_title "$(translate "Create LXC server NFS")" msg_ok "$(translate "Directory successfully.")" @@ -307,7 +325,7 @@ create_nfs_export() { fi else - pct exec "$CTID" -- bash -c "echo '$EXPORT_LINE' >> /etc/exports" + pct exec "$CTID" -- bash -c "printf '%s\\n' '$EXPORT_LINE' | tee -a /etc/exports >/dev/null" msg_ok "$(translate "Export added successfully.")" fi @@ -405,6 +423,9 @@ view_exports() { } delete_export() { + local FUNC_VERSION="1.0" + pmx_journal_context "delete_export" "$FUNC_VERSION" + if ! pct exec "$CTID" -- test -f /etc/exports; then dialog --title "$(translate "Error")" --msgbox "\n$(translate "No exports file found.")" 8 50 return @@ -435,7 +456,9 @@ delete_export() { if whiptail --yesno "$(translate "Are you sure you want to delete this export?")\n\n$EXPORT_LINE" 10 70 --title "$(translate "Confirm Deletion")"; then show_proxmenux_logo msg_title "$(translate "Delete Export")" - pct exec "$CTID" -- sed -i "${SELECTED_NUM}d" /etc/exports + pmx_record_execution "remove NFS export line ${SELECTED_NUM} from CT ${CTID}" \ + "edit /etc/exports and restart nfs-kernel-server" + pct exec "$CTID" -- sed --in-place "${SELECTED_NUM}d" /etc/exports pct exec "$CTID" -- exportfs -ra pct exec "$CTID" -- systemctl restart nfs-kernel-server msg_ok "$(translate "Export deleted and NFS service restarted.")" @@ -506,6 +529,9 @@ check_nfs_status() { } uninstall_nfs() { + local FUNC_VERSION="1.0" + pmx_journal_context "uninstall_nfs" "$FUNC_VERSION" + if ! pct exec "$CTID" -- dpkg -s nfs-kernel-server &>/dev/null; then dialog --title "$(translate "NFS Not Installed")" --msgbox "\n$(translate "NFS server is not installed in this CT.")" 8 60 return @@ -519,6 +545,8 @@ uninstall_nfs() { show_proxmenux_logo msg_title "$(translate "Uninstall NFS Server")" + pmx_record_execution "uninstall NFS server from CT ${CTID}" \ + "stop and disable NFS services, clear exports, remove users, groups and packages" msg_info "$(translate "Stopping NFS services...")" pct exec "$CTID" -- systemctl stop nfs-kernel-server 2>/dev/null || true diff --git a/scripts/share/samba_client.sh b/scripts/share/samba_client.sh index 0420c078..8ed4d0bb 100644 --- a/scripts/share/samba_client.sh +++ b/scripts/share/samba_client.sh @@ -33,6 +33,10 @@ if [[ -f "$UTILS_FILE" ]]; then source "$UTILS_FILE" fi +if [[ -f "$LOCAL_SCRIPTS/global/pmx_journal.sh" ]]; then + source "$LOCAL_SCRIPTS/global/pmx_journal.sh" +fi + SHARE_COMMON_FILE="$LOCAL_SCRIPTS/global/share-common.func" if ! source "$SHARE_COMMON_FILE" 2>/dev/null; then @@ -49,6 +53,10 @@ select_privileged_lxc install_samba_client() { + local FUNC_VERSION="1.0" + pmx_journal_context "install_samba_client" "$FUNC_VERSION" + pmx_record_execution "install and prepare Samba client in CT ${CTID}" \ + "pct exec ${CTID} -- install cifs-utils and smbclient; create ${CREDENTIALS_DIR}" if pct exec "$CTID" -- dpkg -s cifs-utils &>/dev/null && pct exec "$CTID" -- dpkg -s smbclient &>/dev/null; then pct exec "$CTID" -- mkdir -p "$CREDENTIALS_DIR" @@ -94,6 +102,9 @@ install_samba_client() { discover_samba_servers() { + local FUNC_VERSION="1.0" + pmx_journal_context "discover_samba_servers" "$FUNC_VERSION" + show_proxmenux_logo msg_title "$(translate "Samba LXC Manager")" msg_info "$(translate "Scanning network for Samba servers...")" @@ -105,7 +116,7 @@ discover_samba_servers() { for pkg in nmap samba-common-bin; do if ! which ${pkg%%-*} >/dev/null 2>&1; then - apt-get install -y "$pkg" &>/dev/null + pmx_install_pkg "$pkg" fi done @@ -678,13 +689,18 @@ configure_mount_options() { } create_credentials_file() { + local FUNC_VERSION="1.0" + pmx_journal_context "create_credentials_file" "$FUNC_VERSION" + if [[ "$USE_GUEST" == "true" ]]; then return 0 fi CRED_FILE="$CREDENTIALS_DIR/${SAMBA_SERVER}_${SAMBA_SHARE}.cred" - + + pmx_record_execution "create Samba credentials file ${CRED_FILE} in CT ${CTID}" \ + "pct exec ${CTID} -- write credentials file and chmod 600" pct exec "$CTID" -- bash -c "cat > '$CRED_FILE' << EOF username=$USERNAME @@ -729,6 +745,7 @@ EOF" } mount_samba_share() { + local FUNC_VERSION="1.0" # Step 0: install_samba_client || return @@ -754,6 +771,10 @@ mount_samba_share() { # Step 5: configure_mount_options || return + + pmx_journal_context "mount_samba_share" "$FUNC_VERSION" + pmx_record_execution "mount Samba share //${SAMBA_SERVER}/${SAMBA_SHARE} in CT ${CTID} at ${MOUNT_POINT}" \ + "pct exec ${CTID} -- mount CIFS share; persistent=${PERMANENT_MOUNT}" show_proxmenux_logo msg_title "$(translate "Installing Samba Client in LXC")" @@ -803,11 +824,11 @@ mount_samba_share() { if [[ "$PERMANENT_MOUNT" == "true" ]]; then - pct exec "$CTID" -- sed -i "\|$MOUNT_POINT|d" /etc/fstab + pct exec "$CTID" -- sed --in-place "\|$MOUNT_POINT|d" /etc/fstab FSTAB_ENTRY="$UNC_PATH $MOUNT_POINT cifs ${FULL_OPTIONS},_netdev,x-systemd.automount,noauto 0 0" - pct exec "$CTID" -- bash -c "echo '$FSTAB_ENTRY' >> /etc/fstab" + pct exec "$CTID" -- bash -c "printf '%s\\n' '$FSTAB_ENTRY' | tee -a /etc/fstab >/dev/null" msg_ok "$(translate "Added to /etc/fstab for permanent mounting.")" fi @@ -927,6 +948,8 @@ view_samba_mounts() { unmount_samba_share() { + local FUNC_VERSION="1.0" + pmx_journal_context "unmount_samba_share" "$FUNC_VERSION" MOUNTS=$(pct exec "$CTID" -- mount -t cifs 2>/dev/null | awk '{print $3}' | sort -u || true) @@ -955,7 +978,9 @@ unmount_samba_share() { msg_title "$(translate "Unmount Samba Share")" CRED_FILE=$(pct exec "$CTID" -- grep -E "\s+$SELECTED_MOUNT\s+" /etc/fstab 2>/dev/null | grep -o "credentials=[^, ]*" | cut -d= -f2 || true) - pct exec "$CTID" -- sed -i "\|[[:space:]]$SELECTED_MOUNT[[:space:]]|d" /etc/fstab + pmx_record_execution "remove Samba mount ${SELECTED_MOUNT} from CT ${CTID}" \ + "remove CT fstab entry and credentials file when present" + pct exec "$CTID" -- sed --in-place "\|[[:space:]]$SELECTED_MOUNT[[:space:]]|d" /etc/fstab msg_ok "$(translate "Removed from /etc/fstab.")" if [[ -n "$CRED_FILE" && "$CRED_FILE" != "guest" ]]; then diff --git a/scripts/share/samba_host.sh b/scripts/share/samba_host.sh index 56b58d88..876ea94f 100644 --- a/scripts/share/samba_host.sh +++ b/scripts/share/samba_host.sh @@ -44,6 +44,10 @@ if [[ -f "$UTILS_FILE" ]]; then source "$UTILS_FILE" fi +if [[ -f "$LOCAL_SCRIPTS/global/pmx_journal.sh" ]]; then + source "$LOCAL_SCRIPTS/global/pmx_journal.sh" +fi + load_language initialize_cache @@ -70,6 +74,9 @@ get_storage_config() { # ========================================================== discover_samba_servers() { + local FUNC_VERSION="1.0" + pmx_journal_context "discover_samba_servers" "$FUNC_VERSION" + show_proxmenux_logo msg_title "$(translate "Add Samba Share as Proxmox Storage")" msg_info "$(translate "Scanning network for Samba servers...")" @@ -79,7 +86,7 @@ discover_samba_servers() { for pkg in nmap samba-common-bin; do if ! which "${pkg%%-*}" >/dev/null 2>&1; then - apt-get install -y "$pkg" &>/dev/null + pmx_install_pkg "$pkg" &>/dev/null fi done @@ -274,6 +281,8 @@ add_proxmox_cifs_storage() { local server="$2" local share="$3" local content="${4:-import}" + local FUNC_VERSION="1.0" + pmx_journal_context "add_proxmox_cifs_storage" "$FUNC_VERSION" if ! command -v pvesm >/dev/null 2>&1; then msg_error "$(translate "pvesm command not found. This should not happen on Proxmox.")" @@ -288,6 +297,8 @@ add_proxmox_cifs_storage() { 8 60 --title "$(translate "Storage Exists")"; then return 0 fi + pmx_record_execution "remove Proxmox CIFS storage ${storage_id}" \ + "pvesm remove ${storage_id}" pvesm remove "$storage_id" 2>/dev/null || true fi @@ -295,6 +306,8 @@ add_proxmox_cifs_storage() { msg_info "$(translate "Adding CIFS storage to Proxmox...")" local pvesm_result pvesm_output + pmx_record_execution "add Proxmox CIFS storage ${storage_id}" \ + "pvesm add cifs ${storage_id} --server ${server} --share ${share} --content ${content}" if [[ "$USE_GUEST" == "true" ]]; then pvesm_output=$(pvesm add cifs "$storage_id" \ --server "$server" \ @@ -414,15 +427,20 @@ select_cifs_mount_options() { # Write a root-only credentials file for the fstab mount. # Sets HOST_CRED_FILE on success, or empty string for guest mode. write_host_credentials_file() { + local FUNC_VERSION="1.0" + pmx_journal_context "write_host_credentials_file" "$FUNC_VERSION" + if [[ "$USE_GUEST" == "true" ]]; then HOST_CRED_FILE="" return 0 fi local creds_dir="/etc/samba/credentials" + pmx_record_execution "create Samba credentials directory ${creds_dir}" \ + "mkdir -p ${creds_dir}; chmod 0700 ${creds_dir}" mkdir -p "$creds_dir" chmod 0700 "$creds_dir" HOST_CRED_FILE="${creds_dir}/$(echo "${SAMBA_SERVER}_${SAMBA_SHARE}" | tr -c 'A-Za-z0-9._-' '_').cred" - cat > "$HOST_CRED_FILE" </dev/null; then msg_error "$(translate "Failed to create mount point:") $mount_path" return 1 @@ -459,6 +481,8 @@ mount_cifs_via_fstab() { fi msg_info "$(translate "Mounting CIFS share...")" + pmx_record_execution "mount CIFS share //${server}/${share} at ${mount_path}" \ + "mount -t cifs //${server}/${share} ${mount_path}" if ! mount -t cifs -o "$mount_opts" "//${server}/${share}" "$mount_path" >/dev/null 2>&1; then msg_error "$(translate "Failed to mount CIFS share on host.")" return 1 @@ -474,11 +498,12 @@ mount_cifs_via_fstab() { # Persist in /etc/fstab. if [[ "$replace" == "1" ]]; then - sed -i "\|[[:space:]]${mount_path}[[:space:]]|d" /etc/fstab + pmx_edit_file /etc/fstab "\|[[:space:]]${mount_path}[[:space:]]|d" fi - echo "//${server}/${share} $mount_path cifs $mount_opts 0 0" >> /etc/fstab + echo "//${server}/${share} $mount_path cifs $mount_opts 0 0" | pmx_append_file /etc/fstab msg_ok "$(translate "Added to /etc/fstab.")" + pmx_record_execution "reload systemd after CIFS fstab update" "systemctl daemon-reload" systemctl daemon-reload 2>/dev/null || true echo -e "" @@ -535,10 +560,13 @@ select_cifs_mount_methods() { # ========================================================== mount_cifs_share() { + local FUNC_VERSION="1.0" + pmx_journal_context "mount_cifs_share" "$FUNC_VERSION" + if ! which smbclient >/dev/null 2>&1; then msg_info "$(translate "Installing Samba client tools...")" apt-get update &>/dev/null - apt-get install -y cifs-utils smbclient &>/dev/null + pmx_install_pkg cifs-utils smbclient &>/dev/null msg_ok "$(translate "Samba client tools installed")" fi @@ -721,6 +749,9 @@ view_cifs_storages() { } remove_cifs_storage() { + local FUNC_VERSION="1.0" + pmx_journal_context "remove_cifs_storage" "$FUNC_VERSION" + local OPTIONS=() local has_pvesm=0 local has_fstab=0 @@ -784,6 +815,8 @@ remove_cifs_storage() { show_proxmenux_logo msg_title "$(translate "Remove CIFS Storage")" + pmx_record_execution "remove Proxmox CIFS storage ${target}" \ + "pvesm remove ${target}" if pvesm remove "$target" 2>/dev/null; then msg_ok "$(translate "Storage") $target $(translate "removed successfully from Proxmox.")" else @@ -817,6 +850,8 @@ remove_cifs_storage() { msg_title "$(translate "Remove CIFS fstab Mount")" if mount | grep -q " on ${mount_path} type "; then + pmx_record_execution "unmount CIFS path ${mount_path}" \ + "umount ${mount_path}" if umount "$mount_path" 2>/dev/null; then msg_ok "$(translate "Unmounted:") $mount_path" else @@ -831,17 +866,18 @@ remove_cifs_storage() { if awk -v mp="$mount_path" ' $2 == mp && $3 == "cifs" { next } { print } - ' /etc/fstab > /etc/fstab.tmp && mv /etc/fstab.tmp /etc/fstab; then + ' /etc/fstab > /etc/fstab.tmp && pmx_write_file /etc/fstab < /etc/fstab.tmp; then msg_ok "$(translate "Removed entry from /etc/fstab") ($(translate "backup at /etc/fstab.proxmenux.bak"))" else msg_error "$(translate "Failed to edit /etc/fstab — remove the line manually.")" fi + pmx_record_execution "reload systemd after CIFS fstab removal" "systemctl daemon-reload" systemctl daemon-reload 2>/dev/null || true # Remove credentials file if it's under the standard ProxMenux dir if [[ -n "$cred_file" && -f "$cred_file" && "$cred_file" == /etc/samba/credentials/* ]]; then - rm -f "$cred_file" + pmx_remove_file "$cred_file" msg_ok "$(translate "Removed credentials file:") $cred_file" fi @@ -858,6 +894,9 @@ remove_cifs_storage() { } test_samba_connectivity() { + local FUNC_VERSION="1.0" + pmx_journal_context "test_samba_connectivity" "$FUNC_VERSION" + show_proxmenux_logo msg_title "$(translate "Test Samba Connectivity")" @@ -869,7 +908,7 @@ test_samba_connectivity() { else msg_warn "$(translate "CIFS Client Tools: NOT AVAILABLE - installing...")" apt-get update &>/dev/null - apt-get install -y cifs-utils smbclient &>/dev/null + pmx_install_pkg cifs-utils smbclient &>/dev/null msg_ok "$(translate "CIFS client tools installed.")" fi diff --git a/scripts/share/samba_lxc_server.sh b/scripts/share/samba_lxc_server.sh index 5e39484e..726b90b7 100644 --- a/scripts/share/samba_lxc_server.sh +++ b/scripts/share/samba_lxc_server.sh @@ -32,6 +32,10 @@ if [[ -f "$UTILS_FILE" ]]; then source "$UTILS_FILE" fi +if [[ -f "$LOCAL_SCRIPTS/global/pmx_journal.sh" ]]; then + source "$LOCAL_SCRIPTS/global/pmx_journal.sh" +fi + SHARE_COMMON_FILE="$LOCAL_SCRIPTS/global/share-common.func" if ! source "$SHARE_COMMON_FILE" 2>/dev/null; then @@ -48,6 +52,9 @@ select_privileged_lxc select_mount_point() { + local FUNC_VERSION="1.0" + pmx_journal_context "select_mount_point" "$FUNC_VERSION" + while true; do METHOD=$(whiptail --backtitle "ProxMenux" --title "$(translate "Select Folder")" \ --menu "$(translate "How do you want to select the folder to share?")" 15 60 5 \ @@ -104,12 +111,16 @@ select_mount_point() { create_share() { + local FUNC_VERSION="1.0" show_proxmenux_logo msg_title "$(translate "Create Samba server service")" sleep 2 select_mount_point || return + pmx_journal_context "create_share" "$FUNC_VERSION" + pmx_record_execution "configure Samba share ${MOUNT_POINT} in CT ${CTID}" \ + "pct exec ${CTID} -- install and configure Samba share ${MOUNT_POINT}" if ! pct exec "$CTID" -- test -d "$MOUNT_POINT"; then @@ -311,7 +322,7 @@ EOF msg_warn "$(translate "The share already exists in smb.conf:") [$SHARE_NAME]" if whiptail --yesno "$(translate "Do you want to update the existing share?")" 10 60 --title "$(translate "Update Share")"; then - pct exec "$CTID" -- sed -i "/^\[$SHARE_NAME\]/,/^$/d" /etc/samba/smb.conf + pct exec "$CTID" -- sed --in-place "/^\[$SHARE_NAME\]/,/^$/d" /etc/samba/smb.conf pct exec "$CTID" -- bash -c "echo '$CONFIG' >> /etc/samba/smb.conf" msg_ok "$(translate "Share updated successfully.")" else @@ -406,6 +417,9 @@ view_shares() { delete_share() { + local FUNC_VERSION="1.0" + pmx_journal_context "delete_share" "$FUNC_VERSION" + if ! pct exec "$CTID" -- test -f /etc/samba/smb.conf; then dialog --backtitle "ProxMenux" --title "$(translate "Error")" --msgbox "\n$(translate "No smb.conf file found.")" 8 50 return @@ -438,7 +452,9 @@ delete_share() { msg_title "$(translate "Delete Share")" - pct exec "$CTID" -- sed -i "/^\[$SELECTED_SHARE\]/,/^$/d" /etc/samba/smb.conf + pmx_record_execution "remove Samba share ${SELECTED_SHARE} from CT ${CTID}" \ + "pct exec ${CTID} -- remove share ${SELECTED_SHARE} from /etc/samba/smb.conf and restart smbd" + pct exec "$CTID" -- sed --in-place "/^\[$SELECTED_SHARE\]/,/^$/d" /etc/samba/smb.conf pct exec "$CTID" -- systemctl restart smbd.service msg_ok "$(translate "Share deleted and Samba service restarted.")" fi @@ -495,6 +511,7 @@ check_samba_status() { uninstall_samba() { + local FUNC_VERSION="1.0" if ! pct exec "$CTID" -- dpkg -s samba &>/dev/null; then dialog --backtitle "ProxMenux" --title "$(translate "Samba Not Installed")" --msgbox "\n$(translate "Samba server is not installed in this CT.")" 8 60 @@ -510,6 +527,9 @@ uninstall_samba() { show_proxmenux_logo msg_title "$(translate "Uninstall Samba Server")" + pmx_journal_context "uninstall_samba" "$FUNC_VERSION" + pmx_record_execution "uninstall Samba server from CT ${CTID}" \ + "pct exec ${CTID} -- stop services, preserve smb.conf backup, remove Samba users and packages" msg_info "$(translate "Stopping Samba services...")" diff --git a/scripts/storage/add_controller_nvme_vm.sh b/scripts/storage/add_controller_nvme_vm.sh index d249bd17..e8eb7afd 100644 --- a/scripts/storage/add_controller_nvme_vm.sh +++ b/scripts/storage/add_controller_nvme_vm.sh @@ -39,6 +39,9 @@ if [[ -f "$LOCAL_SCRIPTS_LOCAL/global/pci_passthrough_helpers.sh" ]]; then elif [[ -f "$LOCAL_SCRIPTS_DEFAULT/global/pci_passthrough_helpers.sh" ]]; then source "$LOCAL_SCRIPTS_DEFAULT/global/pci_passthrough_helpers.sh" fi +if [[ -f "$LOCAL_SCRIPTS/global/pmx_journal.sh" ]]; then + source "$LOCAL_SCRIPTS/global/pmx_journal.sh" +fi load_language initialize_cache @@ -74,6 +77,9 @@ register_vfio_iommu_tool() { } enable_iommu_cmdline() { + local FUNC_VERSION="1.0" + pmx_journal_context "enable_iommu_cmdline" "$FUNC_VERSION" + local silent="${1:-}" local cpu_vendor iommu_param cpu_vendor=$(grep -m1 "vendor_id" /proc/cpuinfo 2>/dev/null | awk '{print $3}') @@ -95,7 +101,8 @@ enable_iommu_cmdline() { if [[ -f "$cmdline_file" ]] && grep -qE 'root=ZFS=|root=ZFS/' "$cmdline_file" 2>/dev/null; then if ! grep -q "$iommu_param" "$cmdline_file" || ! grep -q "iommu=pt" "$cmdline_file"; then cp "$cmdline_file" "${cmdline_file}.bak.$(date +%Y%m%d_%H%M%S)" - sed -i "s|\\s*$| ${iommu_param} iommu=pt|" "$cmdline_file" + pmx_edit_file "$cmdline_file" "s|\\s*$| ${iommu_param} iommu=pt|" + pmx_record_execution "refresh Proxmox boot entries" "proxmox-boot-tool refresh" proxmox-boot-tool refresh >/dev/null 2>&1 || true [[ "$silent" != "silent" ]] && msg_ok "$(translate "IOMMU parameters added to /etc/kernel/cmdline")" else @@ -104,7 +111,8 @@ enable_iommu_cmdline() { elif [[ -f "$grub_file" ]]; then if ! grep -q "$iommu_param" "$grub_file" || ! grep -q "iommu=pt" "$grub_file"; then cp "$grub_file" "${grub_file}.bak.$(date +%Y%m%d_%H%M%S)" - sed -i "/GRUB_CMDLINE_LINUX_DEFAULT=/ s|\"$| ${iommu_param} iommu=pt\"|" "$grub_file" + pmx_edit_file "$grub_file" "/GRUB_CMDLINE_LINUX_DEFAULT=/ s|\"$| ${iommu_param} iommu=pt\"|" + pmx_record_execution "regenerate GRUB configuration" "update-grub" update-grub >/dev/null 2>&1 || true [[ "$silent" != "silent" ]] && msg_ok "$(translate "IOMMU parameters added to GRUB")" else @@ -521,6 +529,9 @@ prompt_controller_conflict_policy() { # ── DIALOG PHASE: resolve all conflicts before terminal ─────────────────────── resolve_disk_conflicts() { + local FUNC_VERSION="1.0" + pmx_journal_context "resolve_disk_conflicts" "$FUNC_VERSION" + local -a new_pci_list=() local pci vmid action slot_base scope_key has_running @@ -559,13 +570,18 @@ resolve_disk_conflicts() { case "$action" in keep_disable_onboot) for vmid in "${source_vms[@]}"; do - _vm_onboot_is_enabled "$vmid" && qm set "$vmid" -onboot 0 >/dev/null 2>&1 + if _vm_onboot_is_enabled "$vmid"; then + pmx_record_execution "disable autostart for source VM ${vmid}" "qm set ${vmid} -onboot 0" + qm set "$vmid" -onboot 0 >/dev/null 2>&1 + fi done new_pci_list+=("$pci") ;; move_remove_source) slot_base=$(_pci_slot_base "$pci") for vmid in "${source_vms[@]}"; do + pmx_record_execution "remove PCI slot ${slot_base} from source VM ${vmid}" \ + "_remove_pci_slot_from_vm_config ${vmid} ${slot_base}" _remove_pci_slot_from_vm_config "$vmid" "$slot_base" done new_pci_list+=("$pci") @@ -616,10 +632,15 @@ resolve_disk_conflicts() { for gid in "${guest_ids[@]}"; do gtype="${gid%%:*}"; gid_num="${gid##*:}" if [[ "$gtype" == "VM" ]]; then - _vm_onboot_is_enabled "$gid_num" && qm set "$gid_num" -onboot 0 >/dev/null 2>&1 + if _vm_onboot_is_enabled "$gid_num"; then + pmx_record_execution "disable autostart for VM ${gid_num}" "qm set ${gid_num} -onboot 0" + qm set "$gid_num" -onboot 0 >/dev/null 2>&1 + fi else - grep -qE '^onboot:\s*1' "/etc/pve/lxc/$gid_num.conf" 2>/dev/null && \ + if grep -qE '^onboot:\s*1' "/etc/pve/lxc/$gid_num.conf" 2>/dev/null; then + pmx_record_execution "disable autostart for CT ${gid_num}" "pct set ${gid_num} -onboot 0" pct set "$gid_num" -onboot 0 >/dev/null 2>&1 + fi fi done ;; @@ -629,11 +650,15 @@ resolve_disk_conflicts() { if [[ "$gtype" == "VM" ]]; then while IFS= read -r slot; do [[ -z "$slot" ]] && continue + pmx_record_execution "remove disk slot ${slot} from VM ${gid_num}" \ + "qm set ${gid_num} -delete ${slot}" qm set "$gid_num" -delete "$slot" >/dev/null 2>&1 done < <(_find_disk_slots_in_vm "$gid_num" "$disk") else while IFS= read -r slot; do [[ -z "$slot" ]] && continue + pmx_record_execution "remove disk slot ${slot} from CT ${gid_num}" \ + "pct set ${gid_num} -delete ${slot}" pct set "$gid_num" -delete "$slot" >/dev/null 2>&1 done < <(_find_disk_slots_in_ct "$gid_num" "$disk") fi @@ -647,6 +672,9 @@ resolve_disk_conflicts() { } apply_assignment() { + local FUNC_VERSION="1.0" + pmx_journal_context "apply_assignment" "$FUNC_VERSION" + : >"$LOG_FILE" set_title @@ -681,6 +709,8 @@ apply_assignment() { local display_name display_name=$(_pci_storage_display_name "$pci") msg_info "$(translate "Adding") ${display_name} (${pci}) → hostpci${hostpci_idx}..." + pmx_record_execution "assign PCI device ${pci} to VM ${SELECTED_VMID} as hostpci${hostpci_idx}" \ + "qm set ${SELECTED_VMID} --hostpci${hostpci_idx} ${pci},pcie=1" if qm set "$SELECTED_VMID" "--hostpci${hostpci_idx}" "${pci},pcie=1" >>"$LOG_FILE" 2>&1; then msg_ok "$(translate "Controller/NVMe assigned") (hostpci${hostpci_idx} → ${pci})" assigned_count=$((assigned_count + 1)) @@ -709,6 +739,7 @@ apply_assignment() { msg_success "$(translate "Press Enter to continue...")" read -r msg_warn "$(translate "Rebooting the system...")" + pmx_record_execution "reboot host after enabling IOMMU" "reboot" reboot else msg_info2 "$(translate "To use the VM without issues, the host must be restarted before starting it.")" diff --git a/scripts/storage/disk-passthrough_ct.sh b/scripts/storage/disk-passthrough_ct.sh index 1f4bafd5..1efcb9f5 100644 --- a/scripts/storage/disk-passthrough_ct.sh +++ b/scripts/storage/disk-passthrough_ct.sh @@ -48,6 +48,12 @@ elif [[ -f "$LOCAL_SCRIPTS_DEFAULT/global/vm_storage_helpers.sh" ]]; then source "$LOCAL_SCRIPTS_DEFAULT/global/vm_storage_helpers.sh" fi +if [[ -f "$LOCAL_SCRIPTS/global/pmx_journal.sh" ]]; then + source "$LOCAL_SCRIPTS/global/pmx_journal.sh" +fi + +FUNC_VERSION="1.3" + BACKTITLE="ProxMenux" UI_MENU_H=20 UI_MENU_W=84 @@ -120,12 +126,20 @@ get_preferred_disk_path() { install_fs_tools_in_ct() { local ctid="$1" local pkg="$2" + local FUNC_VERSION="1.3" + pmx_journal_context "install_fs_tools_in_ct" "$FUNC_VERSION" if pct exec "$ctid" -- sh -c "[ -f /etc/alpine-release ]"; then + pmx_record_execution "install ${pkg} in CT ${ctid}" \ + "pct exec ${ctid} -- apk update and apk add ${pkg}" pct exec "$ctid" -- sh -c "apk update >/dev/null 2>&1 && apk add --no-progress $pkg >/dev/null 2>&1" elif pct exec "$ctid" -- sh -c "grep -qi 'arch' /etc/os-release 2>/dev/null"; then + pmx_record_execution "install ${pkg} in CT ${ctid}" \ + "pct exec ${ctid} -- pacman -Sy --noconfirm ${pkg}" pct exec "$ctid" -- sh -c "pacman -Sy --noconfirm $pkg >/dev/null 2>&1" elif pct exec "$ctid" -- sh -c "grep -qiE 'debian|ubuntu' /etc/os-release 2>/dev/null"; then + pmx_record_execution "install ${pkg} in CT ${ctid}" \ + "pct exec ${ctid} -- apt-get update and apt-get install ${pkg}" pct exec "$ctid" -- sh -c "apt-get update -qq >/dev/null 2>&1 && apt-get install -y -qq $pkg >/dev/null 2>&1" else return 1 @@ -247,12 +261,15 @@ msg_ok "$(translate "CT $CTID selected successfully.")" if [ "$CONVERT_PRIVILEGED" = true ]; then + pmx_journal_context "disk_passthrough_ct" "$FUNC_VERSION" + show_proxmenux_logo msg_title "$(translate "Import Disk to LXC")" CURRENT_CT_STATUS=$(pct status "$CTID" | awk '{print $2}') if [ "$CURRENT_CT_STATUS" == "running" ]; then msg_info "$(translate "Stopping container") $CTID..." + pmx_record_execution "stop CT ${CTID} for privileged conversion" "pct shutdown ${CTID}" pct shutdown "$CTID" &>/dev/null for i in {1..10}; do sleep 1 @@ -266,12 +283,13 @@ if [ "$CONVERT_PRIVILEGED" = true ]; then fi cp "$CONF_FILE" "$CONF_FILE.bak" - sed -i '/^unprivileged: 1/d' "$CONF_FILE" - echo "unprivileged: 0" >> "$CONF_FILE" + pmx_edit_file "$CONF_FILE" '/^unprivileged: 1/d' + echo "unprivileged: 0" | pmx_append_file "$CONF_FILE" msg_ok "$(translate "Container successfully converted to privileged.")" if [ "$CT_RUNNING" = true ]; then msg_info "$(translate "Starting container") $CTID..." + pmx_record_execution "start CT ${CTID} after privileged conversion" "pct start ${CTID}" pct start "$CTID" &>/dev/null sleep 2 if [ "$(pct status "$CTID" | awk '{print $2}')" != "running" ]; then @@ -567,6 +585,8 @@ msg_title "$(translate "Import Disk to LXC")" msg_ok "$(translate "CT $CTID selected successfully.")" msg_ok "$(translate "Disks to process:") ${#DISK_LIST[@]}" for i in "${!DISK_LIST[@]}"; do + pmx_journal_context "disk_passthrough_ct" "$FUNC_VERSION" + IFS=$'\t' read -r _desc_model _desc_size <<< "${DISK_DESCRIPTIONS[$i]}" echo -e "${TAB}${BL}${DISK_LIST[$i]} $_desc_model $_desc_size${CL}" done @@ -590,6 +610,8 @@ for i in "${!DISK_LIST[@]}"; do if [ "$NEEDS_PARTITION" = true ]; then msg_info "$(translate "Creating partition table and partition...")" + pmx_record_execution "create GPT partition on ${DISK} for CT ${CTID}" \ + "parted -s ${DISK} mklabel gpt mkpart primary 0% 100%" if ! parted -s "$DISK" mklabel gpt mkpart primary 0% 100% >/dev/null 2>&1; then msg_error "$(translate "Failed to create partition table on disk") $DISK_INFO." continue @@ -616,6 +638,8 @@ for i in "${!DISK_LIST[@]}"; do if [ "$SKIP_FORMAT" != true ]; then msg_info "$(translate "Formatting partition") $PARTITION $(translate "with") $FORMAT_TYPE..." + pmx_record_execution "format ${PARTITION} as ${FORMAT_TYPE} for CT ${CTID}" \ + "mkfs ${FORMAT_TYPE} ${PARTITION}" if ! case "$FORMAT_TYPE" in "ext4") mkfs.ext4 -F "$PARTITION" >/dev/null 2>&1 ;; "xfs") mkfs.xfs -f "$PARTITION" >/dev/null 2>&1 ;; @@ -658,6 +682,7 @@ for i in "${!DISK_LIST[@]}"; do --yesno "$(translate "The filesystem") $FORMAT_TYPE $(translate "requires the package") $FS_PKG $(translate "installed inside CT") $CTID.\n\n$(translate "The container is currently stopped. Do you want to start it now to install the package?")\n\n$(translate "If you choose No, install") $FS_PKG $(translate "manually inside the container before starting it.")" \ $UI_YESNO_H $UI_YESNO_W; then msg_info "$(translate "Starting CT") $CTID..." + pmx_record_execution "start CT ${CTID} to install filesystem tools" "pct start ${CTID}" pct start "$CTID" &>/dev/null sleep 2 if [ "$(pct status "$CTID" | awk '{print $2}')" != "running" ]; then @@ -685,9 +710,14 @@ for i in "${!DISK_LIST[@]}"; do PERSISTENT_PARTITION=$(get_preferred_disk_path "$PARTITION") msg_info "$(translate "Applying passthrough to CT") $CTID..." + pmx_journal_context "disk_passthrough_ct" "$FUNC_VERSION" if [ "$FORMAT_TYPE" == "xfs" ]; then + pmx_record_execution "assign ${PERSISTENT_PARTITION} to CT ${CTID} at ${MOUNT_POINT}" \ + "pct set ${CTID} -mp${INDEX} ${PERSISTENT_PARTITION},mp=${MOUNT_POINT},backup=0,ro=0" RESULT=$(pct set "$CTID" -mp${INDEX} "$PERSISTENT_PARTITION,mp=$MOUNT_POINT,backup=0,ro=0" 2>&1) else + pmx_record_execution "assign ${PERSISTENT_PARTITION} to CT ${CTID} at ${MOUNT_POINT}" \ + "pct set ${CTID} -mp${INDEX} ${PERSISTENT_PARTITION},mp=${MOUNT_POINT},backup=0,ro=0,acl=1" RESULT=$(pct set "$CTID" -mp${INDEX} "$PERSISTENT_PARTITION,mp=$MOUNT_POINT,backup=0,ro=0,acl=1" 2>&1) fi SET_STATUS=$? diff --git a/scripts/storage/format-disk.sh b/scripts/storage/format-disk.sh index aa0ebacd..9fea3dc1 100644 --- a/scripts/storage/format-disk.sh +++ b/scripts/storage/format-disk.sh @@ -64,6 +64,10 @@ elif [[ -f "$LOCAL_SCRIPTS_DEFAULT/global/utils-install-functions.sh" ]]; then source "$LOCAL_SCRIPTS_DEFAULT/global/utils-install-functions.sh" fi +if [[ -f "$LOCAL_SCRIPTS/global/pmx_journal.sh" ]]; then + source "$LOCAL_SCRIPTS/global/pmx_journal.sh" +fi + BACKTITLE="ProxMenux" UI_MENU_H=20 UI_MENU_W=84 @@ -607,13 +611,16 @@ prompt_zfs_pool_name() { # ────────────────────────────────────────────────────────────────────────────── ensure_fs_tool() { + local FUNC_VERSION="2.0" + pmx_journal_context "ensure_fs_tool" "$FUNC_VERSION" + case "$FORMAT_TYPE" in exfat) command -v mkfs.exfat >/dev/null 2>&1 && return 0 if declare -F ensure_repositories >/dev/null 2>&1; then ensure_repositories || true fi - if DEBIAN_FRONTEND=noninteractive apt-get install -y exfatprogs >/dev/null 2>&1; then + if pmx_install_pkg exfatprogs; then command -v mkfs.exfat >/dev/null 2>&1 && { msg_ok "$(translate "exFAT tools installed successfully.")" return 0 @@ -657,6 +664,9 @@ wait_for_enter_to_main() { # ────────────────────────────────────────────────────────────────────────────── main() { + local FUNC_VERSION="2.0" + pmx_journal_context "main" "$FUNC_VERSION" + select_target_disk || exit 0 select_operation_mode || exit 0 confirm_format_action || exit 0 @@ -701,6 +711,10 @@ main() { export DOH_SHOW_PROGRESS=0 export DOH_ENABLE_STACK_RELEASE=0 + pmx_record_execution \ + "disk operation ${OPERATION_MODE} on ${SELECTED_DISK}" \ + "format-disk operation=${OPERATION_MODE} disk=${SELECTED_DISK} filesystem=${FORMAT_TYPE:-none} zfs_pool=${ZFS_POOL_NAME:-none}" + if [[ "$OPERATION_MODE" == "wipe_all" ]]; then msg_info "$(translate "Wiping partitions and metadata...")" doh_wipe_disk "$SELECTED_DISK" diff --git a/scripts/utilities/export_vm_ova_ovf.sh b/scripts/utilities/export_vm_ova_ovf.sh index d706fba2..e400a0f2 100755 --- a/scripts/utilities/export_vm_ova_ovf.sh +++ b/scripts/utilities/export_vm_ova_ovf.sh @@ -41,6 +41,10 @@ if [[ -f "$UTILS_FILE" ]]; then source "$UTILS_FILE" fi +if [[ -f "$LOCAL_SCRIPTS/global/pmx_journal.sh" ]]; then + source "$LOCAL_SCRIPTS/global/pmx_journal.sh" +fi + load_language initialize_cache @@ -134,6 +138,9 @@ select_vm() { } ensure_vm_stopped() { + local FUNC_VERSION="1.0" + pmx_journal_context "ensure_vm_stopped" "$FUNC_VERSION" + local status status=$(qm status "$VMID" 2>/dev/null | awk '{print $2}') @@ -146,6 +153,7 @@ ensure_vm_stopped() { return 1 fi + pmx_record_execution "shut down VM ${VMID} for export" "qm shutdown ${VMID} --timeout 120" qm shutdown "$VMID" --timeout 120 >/dev/null 2>&1 || true local i @@ -157,6 +165,7 @@ ensure_vm_stopped() { if dialog --backtitle "ProxMenux" --title "$(translate "Shutdown timeout")" --yesno \ "$(translate "Graceful shutdown timed out.")\n\n$(translate "Force stop VM now?")" 10 60; then + pmx_record_execution "force stop VM ${VMID} for export" "qm stop ${VMID}" qm stop "$VMID" >/dev/null 2>&1 || true sleep 2 status=$(qm status "$VMID" 2>/dev/null | awk '{print $2}') @@ -516,12 +525,17 @@ print_export_result() { } run_export() { + local FUNC_VERSION="1.0" + pmx_journal_context "run_export" "$FUNC_VERSION" + show_proxmenux_logo msg_title "$(translate "Export VM to OVA or OVF")" msg_ok "$(translate "VM selected:") $VMID ($VM_NAME)" msg_ok "$(translate "Export mode:") ${EXPORT_MODE^^}" msg_ok "$(translate "Destination:") $DEST_DIR" + pmx_record_execution "export VM ${VMID} as ${EXPORT_MODE^^} to ${DEST_DIR}" \ + "convert ${DISK_COUNT} VM disk(s), generate OVF metadata and package ${EXPORT_MODE^^}" local ts vm_safe base_name ts=$(date +%Y%m%d_%H%M%S) diff --git a/scripts/utilities/import_vm_ova_ovf.sh b/scripts/utilities/import_vm_ova_ovf.sh index 9beed5a5..c2e7cce8 100755 --- a/scripts/utilities/import_vm_ova_ovf.sh +++ b/scripts/utilities/import_vm_ova_ovf.sh @@ -49,6 +49,9 @@ INSTALL_HELPERS="$LOCAL_SCRIPTS/global/utils-install-functions.sh" [[ -f "$UTILS_FILE" ]] && source "$UTILS_FILE" [[ -f "$INSTALL_HELPERS" ]] && source "$INSTALL_HELPERS" +if [[ -f "$LOCAL_SCRIPTS/global/pmx_journal.sh" ]]; then + source "$LOCAL_SCRIPTS/global/pmx_journal.sh" +fi load_language initialize_cache @@ -87,6 +90,9 @@ BRIDGE="vmbr0" # with "syntax error at or near ,". Returns 0 on success, 1 if install # fails (caller is expected to abort with a clear error). ensure_gawk() { + local FUNC_VERSION="1.0" + pmx_journal_context "ensure_gawk" "$FUNC_VERSION" + if command -v gawk >/dev/null 2>&1; then return 0 fi @@ -111,7 +117,7 @@ ensure_gawk() { # Fallback when utils-install-functions.sh was not sourced. # Here we own the spinner: msg_info opens it, msg_ok / msg_error closes it. msg_info "$(translate "Installing gawk (required for OVF parsing)...")" - if apt-get update -qq >/dev/null 2>&1 && apt-get install -y gawk >/dev/null 2>&1; then + if apt-get update -qq >/dev/null 2>&1 && pmx_install_pkg gawk; then msg_ok "$(translate "gawk installed")" return 0 fi @@ -478,6 +484,9 @@ confirm_import() { # ------------------------------------------------------- run_import() { + local FUNC_VERSION="1.0" + pmx_journal_context "run_import" "$FUNC_VERSION" + show_proxmenux_logo msg_title "$(translate "Import VM from OVA or OVF")" @@ -488,6 +497,8 @@ run_import() { # 1. Create VM shell msg_info "$(translate "Creating VM...")" + pmx_record_execution "import ${SOURCE_FILE} as VM ${NEW_VMID} on storage ${STORAGE}" \ + "qm create ${NEW_VMID}; qm importdisk for ${#OVF_DISK_FILES[@]} disk(s); attach disks and configure boot" if ! qm create "$NEW_VMID" \ --name "$NEW_VM_NAME" \ --memory "$OVF_MEMORY_MB" \ @@ -624,6 +635,7 @@ print_import_result() { # ------------------------------------------------------- main() { + local FUNC_VERSION="1.0" if ! command -v pveversion >/dev/null 2>&1; then dialog --backtitle "$BACKTITLE" --title "$(translate "Error")" \ --msgbox "$(translate "This script must be run on a Proxmox host.")" 8 60 @@ -694,6 +706,9 @@ main() { --yesno "$(translate "Remove the partial VM ($NEW_VMID) and its imported disks?")" 8 60; then clear msg_info "$(translate "Removing partial VM") $NEW_VMID..." + pmx_journal_context "main" "$FUNC_VERSION" + pmx_record_execution "remove partial imported VM ${NEW_VMID}" \ + "qm destroy ${NEW_VMID} --destroy-unreferenced-disks 1" if qm destroy "$NEW_VMID" --destroy-unreferenced-disks 1 &>/dev/null; then msg_ok "$(translate "Partial VM removed")" else diff --git a/scripts/utilities/upgrade_pve8_to_pve9.sh b/scripts/utilities/upgrade_pve8_to_pve9.sh index e97431c3..5bd26676 100644 --- a/scripts/utilities/upgrade_pve8_to_pve9.sh +++ b/scripts/utilities/upgrade_pve8_to_pve9.sh @@ -62,6 +62,9 @@ fi if [[ -f "$LOCAL_SCRIPTS/global/utils-install-functions.sh" ]]; then source "$LOCAL_SCRIPTS/global/utils-install-functions.sh" fi +if [[ -f "$LOCAL_SCRIPTS/global/pmx_journal.sh" ]]; then + source "$LOCAL_SCRIPTS/global/pmx_journal.sh" +fi # ========================================================== @@ -376,31 +379,37 @@ EOF } disable_enterprise_repo_if_present() { + local FUNC_VERSION="1.0" + pmx_journal_context "disable_enterprise_repo_if_present" "$FUNC_VERSION" local s="/etc/apt/sources.list.d/pve-enterprise.sources" local l="/etc/apt/sources.list.d/pve-enterprise.list" if [[ -f "$s" ]]; then if grep -qi '^Enabled:' "$s"; then - sed -i 's/^Enabled:.*/Enabled: false/i' "$s" + pmx_edit_file "$s" 's/^Enabled:.*/Enabled: false/i' else - echo "Enabled: false" >> "$s" + echo "Enabled: false" | pmx_append_file "$s" fi fi if [[ -f "$l" ]]; then - sed -i 's/^[[:space:]]*deb/# deb/' "$l" + pmx_edit_file "$l" 's/^[[:space:]]*deb/# deb/' fi } comment_legacy_pve8_lists() { + local FUNC_VERSION="1.0" + pmx_journal_context "comment_legacy_pve8_lists" "$FUNC_VERSION" for f in /etc/apt/sources.list.d/pve-public-repo.list /etc/apt/sources.list.d/pve-install-repo.list; do [[ -f "$f" ]] || continue - sed -i 's/^[[:space:]]*deb/# deb/' "$f" || true + pmx_edit_file "$f" 's/^[[:space:]]*deb/# deb/' || true done } comment_legacy_ceph_list() { + local FUNC_VERSION="1.0" + pmx_journal_context "comment_legacy_ceph_list" "$FUNC_VERSION" local f="/etc/apt/sources.list.d/ceph.list" [[ -f "$f" ]] || return 0 - sed -i 's/^[[:space:]]*deb/# deb/' "$f" || true + pmx_edit_file "$f" 's/^[[:space:]]*deb/# deb/' || true } apt_update_with_repo_fallback() { @@ -811,11 +820,11 @@ else fi +FUNC_VERSION="1.0" +pmx_journal_context "upgrade_pve8_to_pve9" "$FUNC_VERSION" if [[ "$DISABLE_AUDIT" == "1" ]]; then - append_step \ - "" \ - "Audit socket disabled or not required" \ - "systemctl disable --now systemd-journald-audit.socket >/dev/null 2>&1 || true" + pmx_disable_service systemd-journald-audit.socket >> "$LOG" 2>&1 || true + echo -e "${BFR}${TAB}${CM}${GN}$(translate "Audit socket disabled or not required")${CL}" fi @@ -856,10 +865,12 @@ fi # Step 4 # --------------------------- +FUNC_VERSION="1.0" +pmx_journal_context "upgrade_pve8_to_pve9" "$FUNC_VERSION" OS_FILE="/etc/apt/sources.list" if [[ -f "$OS_FILE" ]]; then msg_info "$(translate "Updating Debian Bookworm → Trixie in sources.list...")" - if sed -i 's/bookworm/trixie/g' "$OS_FILE"; then + if pmx_edit_file "$OS_FILE" 's/bookworm/trixie/g'; then msg_ok "$(translate "sources.list updated to Trixie")" else msg_ok "$(translate "sources.list update skipped (no change)")" @@ -871,7 +882,7 @@ fi PVE_ENT_LIST="/etc/apt/sources.list.d/pve-enterprise.list" msg_info "$(translate "Updating pve-enterprise.list (if present) to Trixie...")" if [[ -f "$PVE_ENT_LIST" ]]; then - if sed -i 's/bookworm/trixie/g' "$PVE_ENT_LIST"; then + if pmx_edit_file "$PVE_ENT_LIST" 's/bookworm/trixie/g'; then msg_ok "$(translate "pve-enterprise.list updated to Trixie")" else msg_ok "$(translate "pve-enterprise.list update skipped (no change)")" @@ -884,9 +895,9 @@ fi msg_info "$(translate "Commenting any residual Bookworm lines in *.list...")" for f in /etc/apt/sources.list.d/*.list; do [[ -f "$f" ]] || continue - sed -i '/bookworm/s/^/# /' "$f" || true + pmx_edit_file "$f" '/bookworm/s/^/# /' || true done -sed -i '/bookworm/s/^/# /' "$OS_FILE" 2>/dev/null || true +pmx_edit_file "$OS_FILE" '/bookworm/s/^/# /' 2>/dev/null || true msg_ok "$(translate "Residual Bookworm entries commented where applicable")" diff --git a/tests/journal/verify_journal_migration.py b/tests/journal/verify_journal_migration.py new file mode 100644 index 00000000..8d28d045 --- /dev/null +++ b/tests/journal/verify_journal_migration.py @@ -0,0 +1,296 @@ +#!/usr/bin/env python3 +"""Checks a journal migration without reading the whole script. + +Migrating a function to the change journal must not change what the +function does — only how it writes. That is a narrow claim, and a narrow +claim can be verified mechanically, which is the point of this: reviewing +a four-thousand-line shell script by eye is how a byte-level difference +in a configuration file gets shipped. + +Run it against the pre-migration version of the same file: + + verify_journal_migration.py --before original.sh --after migrated.sh + +The pre-migration version is whatever the repository had before the work +started, for example: + + git show HEAD:scripts/post_install/customizable_post_install.sh +""" +from __future__ import annotations + +import argparse +import re +import subprocess +import sys +from pathlib import Path + +# Writes that reach the host. A heredoc into /tmp is a composition step, +# not a change, so paths under /tmp are excluded from the search. +DIRECT_WRITE = re.compile( + r"""(?x) + (?:cat|printf|echo|tee)\s*(?:<<-?\s*['"]?\w+['"]?\s*)?>{1,2}\s*["']?(?:/etc|/usr|/var|/boot|/root|\$\{?(?:config_file|sysctl_conf|conf|target)) + | sed\s+-i(?!\s+[^|;&]*\s/tmp/) + | systemctl\s+(?:enable|disable)\s+--now + """) + +# A heredoc body: what the function actually writes. The delimiter is +# usually followed by a redirection on the same line — `< "$file"` +# — so everything up to the newline is skipped before the body starts. +HEREDOC = re.compile(r"<<-?\s*['\"]?(\w+)['\"]?[^\n]*\n(.*?)^\1\s*$", re.M | re.S) + +# A backup the uninstaller may restore from. The path is often a +# variable — `cp -n "$conf" "$backup_conf"` — so the copy itself is what +# is matched, not the .bak suffix. +BACKUP = re.compile(r"cp\s+(?:-n\s+)?[^\n]*(?:\.bak|backup_conf|_backup|\bbackup\b)") + + +# Both declaration forms bash accepts, because a file written in the +# `function name() {` style used to yield no functions at all: the +# walker saw none, the sanity check counted none, the two agreed, and +# the file passed without a single one of its bodies being read. +FUNC_START = re.compile( + r"^(?:function\s+([A-Za-z_][A-Za-z0-9_-]*)\s*(?:\(\))?" + r"|([A-Za-z_][A-Za-z0-9_-]*)\s*\(\))\s*\{\s*$", re.M) +HEREDOC_START = re.compile(r"<<-?\s*['\"]?(\w+)['\"]?") + + +def functions(source: str) -> list[tuple[str, str]]: + """Every top-level shell function and its body, in declaration order. + + A list rather than a mapping because a script may declare the same + name twice — the later definition is the one bash keeps, but both are + in the file. Keyed by name, the first body vanished and its lines + were then counted as top-level code that nothing had recorded. + + Walked line by line rather than matched with a regular expression, + because these scripts embed whole files in heredocs and several of + those contain a closing brace in the first column — a systemd unit, + an awk program, a shell script being installed. A regex that ends the + function at the first such line cuts it in half, and everything after + the cut looks like top-level code that nothing is checking. + """ + found: list[tuple[str, str]] = [] + lines = source.splitlines() + i, total = 0, len(lines) + while i < total: + match = FUNC_START.match(lines[i]) + if not match: + i += 1 + continue + name = match.group(1) or match.group(2) + body, depth, delimiter = [], 1, None + i += 1 + while i < total and depth > 0: + line = lines[i] + if delimiter is not None: + # Inside a heredoc nothing counts as shell syntax. The + # closing line is usually the delimiter alone, but these + # scripts also nest heredocs inside quoted strings passed + # to `pct exec`, where the terminator carries the closing + # quote: `EOF"`. Treating only the exact form as a close + # swallows the rest of the file and silently merges every + # function after it. + stripped = line.strip() + if stripped == delimiter or ( + stripped.startswith(delimiter) + and stripped[len(delimiter):].strip(" \"';)") == ""): + delimiter = None + else: + opened = HEREDOC_START.search(line) + if opened: + delimiter = opened.group(1) + elif line == "}": + depth -= 1 + if depth == 0: + break + body.append(line) + i += 1 + found.append((name, "\n".join(body))) + i += 1 + return found + + +def heredocs(body: str) -> list[str]: + """Contents written by a function, in order, ignoring the delimiters.""" + return [text for _, text in HEREDOC.findall(body)] + + +def _without_heredocs(source: str) -> str: + """The script with heredoc bodies removed, line count preserved. + + What a script writes into a file is content, not code: a function + declared inside a heredoc belongs to the file being installed. + """ + out, delimiter = [], None + for line in source.splitlines(): + if delimiter is not None: + stripped = line.strip() + if stripped == delimiter or ( + stripped.startswith(delimiter) + and stripped[len(delimiter):].strip(" \"';)") == ""): + delimiter = None + out.append("") + continue + opened = HEREDOC_START.search(line) + out.append(line) + if opened: + delimiter = opened.group(1) + return "\n".join(out) + + +def _top_level(source: str) -> str: + """The script with every function body removed. + + Built by subtracting the bodies the walker found, so a heredoc + containing a closing brace cannot make half a function look like + top-level code. + """ + remaining = source + for _, body in functions(source): + if body: + remaining = remaining.replace(body, "", 1) + return remaining + + +def _heredocs_of(source: str) -> list[str]: + return [text for _, text in HEREDOC.findall(source)] + + +def check(before_path: Path, after_path: Path) -> int: + before = functions(before_path.read_text()) + after = functions(after_path.read_text()) + problems: list[str] = [] + migrated: list[str] = [] + + # The file has to be valid shell before anything else is worth saying. + syntax = subprocess.run(["bash", "-n", str(after_path)], + capture_output=True, text=True) + if syntax.returncode != 0: + print(f"FAIL bash -n: {syntax.stderr.strip()}") + return 1 + + before_by_name: dict[str, list[str]] = {} + for name, body in before: + before_by_name.setdefault(name, []).append(body) + after_by_name: dict[str, list[str]] = {} + for name, body in after: + after_by_name.setdefault(name, []).append(body) + + gone = sorted(set(before_by_name) - set(after_by_name)) + if gone: + problems.append(f"functions removed: {', '.join(gone)}") + + # A name declared more than once is a property of the script, not a + # fault in the migration. Stated so the reader knows which body the + # results below belong to, and not counted against the file. + repeated = sorted(n for n, bodies in after_by_name.items() if len(bodies) > 1) + for name in repeated: + print(f"note: {name} is declared {len(after_by_name[name])} times; " + f"each declaration is checked against its own original") + + # Sanity: the walker must find every function the file declares. If + # it finds fewer, it merged some, and everything it reported about + # them is unreliable — a green result on a file it did not read. + # + # Counted with the heredocs removed, because these scripts install + # other scripts by writing them out, and a function declared inside + # one of those belongs to the installed file, not to this one. + declared = len(FUNC_START.findall(_without_heredocs(after_path.read_text()))) + if declared != len(after): + problems.append( + f"parser found {len(after)} functions but the file declares " + f"{declared}; the result cannot be trusted") + + # Everything above only looks inside functions. A script that acts at + # the top level — and several do — was invisible to this check, which + # is exactly where an unrecorded write would hide. + outside_before = _top_level(before_path.read_text()) + outside_after = _top_level(after_path.read_text()) + if "pmx_journal" in outside_after or any( + "pmx_journal_context" in body for _, body in after): + # Scanned with the heredoc bodies blanked: a script that installs + # another script writes that script's own `sed -i` lines as + # content, and the contract forbids touching what is written. + direct = [m.group(0).strip() + for m in DIRECT_WRITE.finditer(_without_heredocs(outside_after))] + if direct: + problems.append( + f"top level: {len(direct)} write(s) still reach the host directly — " + f"{direct[0][:70]}") + if _heredocs_of(outside_before) != _heredocs_of(outside_after): + problems.append("top level: the content written outside any function changed") + + occurrence: dict[str, int] = {} + for name, body in after: + index = occurrence.get(name, 0) + occurrence[name] = index + 1 + if "pmx_journal_context" not in body: + continue + migrated.append(name if index == 0 else f"{name} (declaration {index + 1})") + originals = before_by_name.get(name, []) + if index >= len(originals): + problems.append(f"{name}: declaration {index + 1} was not present " + f"before the migration") + continue + original = originals[index] + + # 1. One context, naming the function it sits in. + contexts = re.findall(r'pmx_journal_context\s+"([^"]+)"', body) + if len(contexts) != 1: + problems.append(f"{name}: {len(contexts)} calls to pmx_journal_context, expected 1") + elif contexts[0] != name: + problems.append(f"{name}: context declares '{contexts[0]}'") + + # 2. Nothing still writes to the host directly. The heredoc + # bodies are blanked first: a `sed -i` inside a script this + # function installs is that script's line, not this one's, and + # rewriting it is exactly what the contract forbids. + direct = [m.group(0).strip() + for m in DIRECT_WRITE.finditer(_without_heredocs(body))] + if direct: + problems.append(f"{name}: still writes directly — {direct[0][:70]}") + + # 3. What it writes has to be what it wrote before. This is the + # check that matters: a migration that alters a configuration + # file by one byte is a behaviour change wearing a refactor. + if heredocs(original) != heredocs(body): + before_docs, after_docs = heredocs(original), heredocs(body) + if len(before_docs) != len(after_docs): + problems.append( + f"{name}: wrote {len(before_docs)} block(s) before, {len(after_docs)} now") + else: + for i, (was, now) in enumerate(zip(before_docs, after_docs)): + if was != now: + problems.append(f"{name}: content of block {i + 1} changed") + + # 4. A backup the uninstaller depends on must survive. + if BACKUP.search(original) and not BACKUP.search(body): + problems.append(f"{name}: the .bak copy was removed; " + f"uninstall-tools.sh restores from it") + + # 5. Registration is untouched. + if original.count("register_tool") != body.count("register_tool"): + problems.append(f"{name}: register_tool calls changed") + + print(f"functions migrated: {len(migrated)}") + for name in migrated: + print(f" {name}") + if problems: + print(f"\n{len(problems)} problem(s):") + for problem in problems: + print(f" {problem}") + return 1 + print("\nno problems found") + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--before", required=True, type=Path) + parser.add_argument("--after", required=True, type=Path) + args = parser.parse_args() + return check(args.before, args.after) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/lxc_updates/test_docker_delegated_ui.cjs b/tests/lxc_updates/test_docker_delegated_ui.cjs index c0f69695..1d259e87 100644 --- a/tests/lxc_updates/test_docker_delegated_ui.cjs +++ b/tests/lxc_updates/test_docker_delegated_ui.cjs @@ -33,6 +33,16 @@ assert.equal(cache.getLxcAppsCached(101).sidecar.apps[0].docker_available_versio assert.equal(requests, 0) const source = fs.readFileSync(path.join(root, 'components/virtual-machines.tsx'), 'utf8') +assert.match( + source, + /const independentlyUpdatedApps = registeredApps\.filter\(\s*\(a\) => a\.update_via !== "docker",\s*\)/, + 'Docker-delegated apps must not render a second Updates section', +) +assert.match( + source, + /image\.update_available === false \? "text-green-500" : "text-foreground\/80"/, + 'a current Docker image must show its installed version in green', +) const tree = ts.createSourceFile('vm.tsx', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX) const pieces = [] function walk(node) { diff --git a/tests/test_audit_api.py b/tests/test_audit_api.py new file mode 100644 index 00000000..eed3d0fa --- /dev/null +++ b/tests/test_audit_api.py @@ -0,0 +1,113 @@ +"""Flask API contracts with stubbed authentication, temporary DB, no probes.""" +import importlib +import json +import sys +import tempfile +import types +import unittest +from pathlib import Path +from unittest.mock import patch + +try: + from flask import Flask +except ImportError: + Flask = None + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "AppImage/scripts")) +import audit_store as store + + +@unittest.skipIf(Flask is None, "Flask runtime required") +class AuditApiTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.dbpatch = patch.object(store, "DB_PATH", Path(self.temp.name) / "audit.db") + self.dbpatch.start() + self.addCleanup(self.dbpatch.stop) + store._schema_ready = False + self.addCleanup(lambda: setattr(store, "_schema_ready", False)) + auth = types.ModuleType("auth_manager") + auth.load_auth_config = lambda: {"enabled": True} + auth.verify_token = lambda token: "verified-operator" + middleware = types.ModuleType("jwt_middleware") + middleware.require_auth = lambda f: f + middleware.require_admin_scope = lambda f: f + with patch.dict(sys.modules, auth_manager=auth, jwt_middleware=middleware): + sys.modules.pop("flask_audit_routes", None) + self.routes = importlib.import_module("flask_audit_routes") + self.addCleanup(lambda: sys.modules.pop("flask_audit_routes", None)) + self.app = Flask(__name__) + self.app.register_blueprint(self.routes.audit_bp) + self.client = self.app.test_client() + self.headers = {"Authorization": "Bearer fixture"} + self.finding = {"check_id": "guests.privileged_containers", "area": "guests", + "severity": "WARNING", "state": "warn", "raw_state": "warn", + "classification": "warning", "raw_classification": "warning", + "affected": [{"vmid": 101}], "scope": "fixture-scope"} + self.run = store.start_run("full") + store.record_findings(self.run, [self.finding]) + store.finish_run(self.run, checks_total=1) + + def accept(self, **extra): + return self.client.post("/api/audit/exceptions", headers=self.headers, json={ + "check_id": self.finding["check_id"], "reason": "intentional lab", "run_id": self.run, + "accepted_by": "forged-author", **extra}) + + def test_actor_is_from_authentication_and_live_view_updates(self): + self.assertEqual(self.accept().status_code, 200) + row = self.client.get(f"/api/audit/runs/{self.run}?effective=1").json["findings"][0] + self.assertEqual(row["state"], "accepted") + self.assertEqual(row["exception"]["accepted_by"], "verified-operator") + self.assertEqual(row["classification"], "warning") + self.assertEqual(row["raw_classification"], "warning") + historical = self.client.get(f"/api/audit/runs/{self.run}").json["findings"][0] + self.assertEqual(historical["state"], "warn") + status = self.client.get("/api/audit/status").json + self.assertEqual(status["summary"], {"accepted": 1}) + + def test_revoke_is_immediate(self): + self.accept() + self.client.delete(f"/api/audit/exceptions/{self.finding['check_id']}", headers=self.headers) + self.assertEqual(self.client.get("/api/audit/status").json["summary"], {"warning": 1}) + self.assertEqual(len(self.client.get("/api/audit/exceptions").json["history"]), 2) + + def test_expiry_must_be_positive_integer(self): + for days in (0, False, -1, 1.5, True, 999999, "invalid"): + with self.subTest(days=days): + self.assertEqual(self.accept(expires_in_days=days).status_code, 400) + + def test_stale_run_cannot_accept_new_results(self): + self.assertEqual(self.accept(run_id="old-run").status_code, 409) + + def test_observation_cannot_be_accepted_as_a_risk(self): + finding = {**self.finding, 'classification':'observation', 'raw_classification':'observation'} + self.run = store.start_run('full') + store.record_findings(self.run, [finding]) + store.finish_run(self.run, checks_total=1) + self.assertEqual(self.accept().status_code, 400) + self.assertFalse(store.active_exceptions()) + + def test_invalid_profile_or_area_never_starts_worker(self): + with patch.object(self.routes.threading, "Thread") as worker: + for body in ({"profile": "invented"}, {"areas": ["invented"]}, {"areas": []}, {"areas": "system"}): + self.assertEqual(self.client.post("/api/audit/run", json=body).status_code, 400) + worker.assert_not_called() + + def test_run_returns_id_before_worker_finishes(self): + with patch.object(self.routes.threading, "Thread"): + response = self.client.post("/api/audit/run", json={"profile": "full"}) + self.assertEqual(response.status_code, 200) + self.assertTrue(response.json["run_id"]) + self.assertEqual(self.client.post("/api/audit/run", json={}).status_code, 409) + + def test_audit_database_failure_does_not_prevent_monitor_startup(self): + with patch.object(store, "recover_interrupted_runs", side_effect=OSError("read-only filesystem")): + another_app = Flask("audit-startup-failure") + another_app.register_blueprint(self.routes.audit_bp) + self.assertEqual(self.client.get("/api/audit/status").status_code, 500) + self.assertEqual(self.client.post("/api/audit/run", json={}).status_code, 500) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_audit_catalog.py b/tests/test_audit_catalog.py new file mode 100644 index 00000000..9531fa29 --- /dev/null +++ b/tests/test_audit_catalog.py @@ -0,0 +1,653 @@ +"""All 43 checks against a declared fixture host, plus boundary/failure cases. + +No subprocesses, network connections or real host paths are consulted. +""" +import json +from pathlib import PurePosixPath +from types import SimpleNamespace +import sys +import time +import unittest +from unittest.mock import patch + +from test_audit_report import Context, evaluate, HEADER +import audit_checks as engine +import audit_checks_pve as checks +import audit_policy +import audit_profiles + +NOW = 1788700000 +VM_CMD = ("pvesh", "get", "/cluster/resources", "--type", "vm", "--output-format", "json") +TASK_CMD = ("pvesh", "get", "/nodes/fixture/tasks", "--typefilter", "vzdump", "--limit", "200", "--output-format", "json") +PBS_CMD = ("pvesh", "get", "/nodes/fixture/storage/pbs/content", "--output-format", "json") +DF_CMD = ("df", "--output=target,pcent,ipcent,size,avail", "/", "/var", "/var/log", "/var/lib/vz") +FINDMNT_CMD = ("findmnt", "-rno", "TARGET,OPTIONS") +LVS_CMD = ("lvs", "--noheadings", "--units", "b", "--nosuffix", "--separator", "|", "-o", + "vg_name,lv_name,lv_size,pool_lv,lv_attr,data_percent,metadata_percent") + +# Explicit identities, not just a count: swapping an old check for a new +# one must not let this contract pass accidentally. +EXPECTED = { + "backup.guest_coverage": "conformant", "backup.last_backup_age": "conformant", + "backup.retention_defined": "conformant", "backup.verification_state": "conformant", + "backup.job_results": "conformant", "storage.connected_storage": "conformant", + "storage.orphaned_volumes": "conformant", "storage.thin_pool_overprovisioning": "conformant", + "storage.zfs_arc_max": "conformant", "storage.zfs_scrub_age": "conformant", + "storage.pool_integrity": "conformant", "system.pending_reboot": "conformant", + "system.kernel_current": "conformant", "system.security_updates": "conformant", + "system.enterprise_repo_without_subscription": "observation", "system.memory_overcommit": "conformant", + "system.time_synchronisation": "conformant", "system.journal_size": "conformant", + "system.swap_configured": "conformant", "system.filesystem_capacity": "conformant", + "system.update_chain": "conformant", "system.notification_delivery": "conformant", + "guests.privileged_containers": "conformant", "guests.qemu_without_agent": "conformant", + "guests.autostart": "conformant", "guests.stuck_snapshots": "conformant", + "guests.cpu_host_type": "conformant", "guests.replication_state": "conformant", + "network.bond_members": "conformant", "network.bridge_without_ports": "conformant", + "security.host_firewall_enabled": "conformant", "security.ssh_root_login": "conformant", + "security.certificate_expiry": "conformant", "security.lynis_warnings": "conformant", + "hardware.disk_service_life": "conformant", + "backup.host_recovery": "conformant", "system.cluster_quorum": "conformant", + "hardware.disk_errors": "warning", "system.boot_loader": "conformant", + "system.failed_units": "conformant", "storage.ceph_health": "conformant", + "storage.array_integrity": "conformant", "system.ha_state": "conformant", +} + +# A three-node cluster over two corosync rings, so the check has both a +# membership to compare and a link count that is not the bare minimum. +COROSYNC_CONF = """totem { + cluster_name: fixture-cluster + interface { linknumber: 0 } +} +nodelist { + node { name: fixture ring0_addr: 10.0.0.1 ring1_addr: 10.1.0.1 nodeid: 1 } + node { name: second ring0_addr: 10.0.0.2 ring1_addr: 10.1.0.2 nodeid: 2 } + node { name: third ring0_addr: 10.0.0.3 ring1_addr: 10.1.0.3 nodeid: 3 } +} +""" + +PVECM_NODES = """Membership information +---------------------- + Nodeid Votes Name + 1 1 fixture (local) + 2 1 second + 3 1 third +""" + +TIMERS = ("NEXT LEFT LAST PASSED UNIT ACTIVATES\n" + "Sun 2026-09-07 - - - proxmenux-backup-hostcfg-daily.timer " + "proxmenux-backup-hostcfg-daily.service\n") + + +class FixturePath: + def __init__(self, owner, value): + self.owner, self.value = owner, str(value) + def __str__(self): return self.value + def __truediv__(self, name): return FixturePath(self.owner, self.value.rstrip('/') + '/' + name) + @property + def name(self): return PurePosixPath(self.value).name + def exists(self): return self.value in self.owner.ctx.files or self.is_dir() + def is_file(self): return self.value in self.owner.ctx.files + def is_dir(self): return self.value in self.owner.directories + def read_text(self, **kwargs): + if self.value not in self.owner.ctx.files: raise FileNotFoundError(self.value) + return self.owner.ctx.files[self.value] + def stat(self): + if not self.exists(): raise FileNotFoundError(self.value) + return SimpleNamespace(st_mtime=self.owner.stamps.get(self.value, NOW)) + def glob(self, pattern): + return [FixturePath(self.owner, p) for p in sorted(self.owner.ctx.files) + if str(PurePosixPath(p).parent) == self.value and PurePosixPath(p).match(pattern)] + def iterdir(self): return self.glob('*') + + +class CatalogTests(unittest.TestCase): + def setUp(self): + self.ctx = Context(lxc_configs={101: 'unprivileged: 1\nonboot: 1\nmemory: 512\n'}, + qemu_configs={200: 'agent: 1\nonboot: 1\nmemory: 1024\ncpu: kvm64\n'}, + storages=[{'id': 'backups', 'type': 'dir', 'content': 'backup', 'prune-backups': 'keep-last=3'}, + {'id': 'pbs', 'type': 'pbs', 'content': 'backup'}, + {'id': 'local', 'type': 'dir', 'content': 'images'}]) + self.ctx.storage_snapshot = {'rows': [{'name': s['id'], 'node': 'fixture', 'status': 'available', + 'total': 100, 'used': 20} for s in self.ctx.storages]} + self.ctx.lynis_report = {'complete': True, 'warnings': [], 'suggestions': [], 'hardening_index': 80, 'mtime': NOW} + self.ctx.monitor_snapshot = {'smart': {'sda': (NOW, {'power_on_hours': 12})}} + stamp = time.strftime('%Y_%m_%d-%H_%M_%S', time.localtime(NOW - 3600)) + self.ctx.responses.update({ + ('pvesm', 'list', 'backups'): (0, HEADER + ''.join( + f'backups:backup/vzdump-{kind}-{vmid}-{stamp}.tar.zst zst backup 1024 {vmid}\n' + for kind, vmid in [('lxc', 101), ('qemu', 200)])), + ('uname', '-r'): (0, '6.8.12-1-pve'), + ('dpkg-query', '-W', '-f=${db:Status-Status} ${Package}\n'): (0, 'installed proxmox-kernel-6.8.12-1-pve-signed\n'), + ('proxmox-boot-tool', 'kernel', 'list'): (0, 'Automatically selected kernels:\n6.8.12-1-pve\nPinned kernel:\n6.8.12-1-pve\n'), + ('cat', '/proc/meminfo'): (0, 'MemTotal: 16777216 kB\nMemAvailable: 10000000 kB\n'), + ('timedatectl', 'show', '-p', 'NTP', '-p', 'NTPSynchronized'): (0, 'NTP=yes\nNTPSynchronized=yes\n'), + ('apt-get', '-s', 'upgrade'): (0, 'Reading package lists...\n0 upgraded, 0 newly installed\n'), + ('openssl', 'x509', '-enddate', '-noout', '-in', '/etc/pve/local/pve-ssl.pem'): (0, 'notAfter=fixture'), + ('date', '-d', 'fixture', '+%s'): (0, str(NOW + 90 * 86400)), + ('sshd', '-T'): (0, 'permitrootlogin prohibit-password\npasswordauthentication yes\nkbdinteractiveauthentication no\n'), + ('journalctl', '--disk-usage'): (0, 'Archived and active journals take up 1.0M in the file system.'), + ('swapon', '--show=NAME,SIZE,TYPE', '--bytes', '--noheadings'): (0, '/dev/swap 1048576 partition'), + ('zpool', 'list', '-H', '-o', 'name'): (0, 'tank\n'), + ('zpool', 'list', '-H', '-o', 'name,health'): (0, 'tank\tONLINE\n'), + ('zpool', 'status', 'tank'): (0, ' state: ONLINE\n scan: scrub repaired 0B in 1h with 0 errors on ' + time.ctime(NOW) + '\n disk ONLINE 0 0 0\n'), + ('pvesh', 'get', '/nodes/fixture/replication', '--output-format', 'json'): (0, json.dumps([{'id':'101-0', 'last_sync':NOW-60, 'schedule':'daily'}])), + TASK_CMD: (0, json.dumps([{'type': 'vzdump', 'id': '101', 'status': 'OK'}])), + PBS_CMD: (0, json.dumps([self.snapshot()])), + DF_CMD: (0, 'Mounted on Use% IUse% 1K-blocks Avail\n/ 20% 10% 100 80\n/ 20% 10% 100 80\n'), + FINDMNT_CMD: (0, '/ rw,relatime\n/var/log rw,relatime\n'), + LVS_CMD: (0, 'pve|data|1000000000||twi-a-tz--|20|5\npve|vm-101-disk-0|100000000|data|Vwi-a-tz--||\n'), + ('ceph', '-s', '--format', 'json'): (0, json.dumps( + {'health': {'status': 'HEALTH_OK', 'checks': {}}, + 'quorum_names': ['a', 'b', 'c']})), + + ('ha-manager', 'status'): (0, + 'quorum OK\nmaster fixture (active, Mon Jan 1 00:00:00 2026)\n' + 'lrm fixture (active, Mon Jan 1 00:00:00 2026)\n' + 'service vm:100 (fixture, started)\n'), + ('systemctl', 'list-units', '--state=failed', '--no-legend', + '--no-pager', '--plain'): (0, ''), + ('systemctl', 'is-active', 'pve-cluster', 'pvedaemon', + 'pveproxy', 'pvestatd'): (0, 'active\nactive\nactive\nactive\n'), + ('proxmox-boot-tool', 'status'): (0, + "System currently booted with uefi\n" + "654E-D6BD is configured with: uefi (versions: 6.8.12-1-pve)\n" + "6550-5CBE is configured with: uefi (versions: 6.8.12-1-pve)\n"), + ('pvecm', 'status'): (0, 'Quorate: Yes\nExpected votes: 3\nTotal votes: 3\n'), + ('pvecm', 'nodes'): (0, PVECM_NODES), + ('systemctl', 'list-timers', '--all', '--no-pager'): (0, TIMERS), + }) + archive = 'hostcfg-daily-20260906_000017.tar.zst' + self.ctx.files.update({ + f'/var/lib/vz/dump/{archive}': 'fixture archive', + f'/var/lib/vz/dump/{archive}.proxmenux.json': json.dumps({ + 'schema_version': 1, 'kind': 'scheduled', 'job_id': 'hostcfg-daily', + 'hostname': 'fixture', 'archive': archive, 'archive_size': 4377756725, + 'created_at': time.strftime('%Y-%m-%dT%H:%M:%S', time.localtime(NOW - 3600)), + }), + }) + self.ctx.files.update({ + '/etc/kernel/proxmox-boot-uuids':'654E-D6BD\n6550-5CBE\n', + '/etc/pve/ceph.conf':'[global]\n', + '/etc/pve/ha/resources.cfg':'vm: 100\n', + '/proc/mdstat':('Personalities : [raid1]\n' + 'md0 : active raid1 sda1[0] sdb1[1]\n' + ' 976630464 blocks super 1.2 [2/2] [UU]\n'), + '/etc/pve/firewall/cluster.fw':'[OPTIONS]\nenable: 1\n', + '/etc/pve/local/pve-ssl.pem':'fixture certificate', + '/etc/corosync/corosync.conf': COROSYNC_CONF, + '/etc/systemd/journald.conf':'SystemMaxUse=1G\n', + '/etc/network/interfaces':'iface vmbr0 inet static\n bridge-ports eth0\n', + '/etc/pve/replication.cfg':'local: 101-0\n target other\n', + '/proc/spl/kstat/zfs/arcstats':'c_min 4 1048576\nc_max 4 1073741824\nsize 4 5000000\n', + '/sys/module/zfs/parameters/zfs_arc_max':'1073741824', + '/var/lib/apt/periodic/update-success-stamp':'', + '/proc/net/bonding/bond0':'Bonding Mode: active-backup\nSlave Interface: eth0\nMII Status: up\n', + }) + self.directories = {'/sys/module/zfs', '/proc/net/bonding', '/etc/modprobe.d', + '/var/lib/vz/dump'} + self.stamps = {} + self.channels = {'telegram': {'enabled': True, 'configured': True}} + self.histories = {'telegram': {'history': [{'channel':'telegram','success':1,'sent_at':NOW}]}} + self.manager = SimpleNamespace(list_channels=lambda: {'channels': self.channels}, + get_history=lambda **kw: self.histories[kw['channel']]) + # A disk that reported something long ago and has been quiet since: + # a record exists, and nothing in it is current. + self.observations = [{'device_name': '/dev/sda', 'error_type': 'smart_error', + 'severity': 'WARNING', 'occurrence_count': 2, + 'first_occurrence': NOW - 90 * 86400, + 'last_occurrence': NOW - 60 * 86400, + 'raw_message': 'fixture'}] + self.persistence = SimpleNamespace( + get_disk_observations=lambda: self.observations) + for p in (patch.object(checks, 'Path', side_effect=lambda v: FixturePath(self, v)), + patch.object(checks.time, 'time', return_value=NOW), + patch.dict(sys.modules, {'flask_server': SimpleNamespace( + notification_manager=self.manager, + health_persistence=self.persistence)})): + p.start(); self.addCleanup(p.stop) + + def snapshot(self, state='ok', **kwargs): + return {'vmid':101, 'content':'backup', 'ctime':NOW-100, 'volid':'pbs:backup/ct/101/date', + 'verification':{'state':state}, **kwargs} + + def result(self, fn): return evaluate(fn, self.ctx) + + def test_every_one_of_the_43_checks_has_a_real_fixture(self): + self.assertEqual({c.check_id for c in engine.registered_checks()}, set(EXPECTED)) + for c in engine.registered_checks(): + with self.subTest(check=c.check_id): + result = self.result(c.evaluate) + self.assertIsNotNone(result, 'This fixture must exercise the check, not skip it') + self.assertEqual(result['classification'], EXPECTED[c.check_id]) + + def test_reboot_marker_is_evidence_even_with_no_package_named(self): + """Something wrote the marker and did not say what. + + Reporting that as unverified described the reading rather than + the host, which had plainly asked for a restart. + """ + self.ctx.files['/var/run/reboot-required'] = '' + for pkgs in ('', None): + if pkgs is None: + self.ctx.files.pop('/var/run/reboot-required.pkgs', None) + else: + self.ctx.files['/var/run/reboot-required.pkgs'] = pkgs + rows = self.result(checks._pending_reboot)['affected'] + self.assertEqual([(r['reason_key'], r['classification']) for r in rows], + [('rebootMarkerWithoutPackages', 'observation')]) + # A named package still describes itself. + self.ctx.files['/var/run/reboot-required.pkgs'] = 'libc6\n' + rows = self.result(checks._pending_reboot)['affected'] + self.assertEqual(rows[0]['reason_key'], 'packageAwaitingRestart') + + def test_essential_state_must_be_one_word_per_service(self): + """`systemctl is-active` prints one word per unit; anything else + is prose, and zipping prose onto the names made every word of it + a critical finding.""" + active = ('systemctl', 'is-active', 'pve-cluster', 'pvedaemon', + 'pveproxy', 'pvestatd') + self.ctx.responses[('systemctl', 'list-units', '--state=failed', + '--no-legend', '--no-pager', '--plain')] = (0, '') + for output in ('Unit pvedaemon.service could not be found.', + 'active\nactive\n', ''): + self.ctx.responses[active] = (1, output) + result = self.result(checks._failed_units) + self.assertEqual(result['classification'], 'unverified', + f'prose became a verdict: {output!r}') + self.ctx.responses[active] = (0, 'active\nactive\nactive\nactive\n') + self.assertEqual(self.result(checks._failed_units)['classification'], + 'conformant') + + def test_verification_ignores_guests_that_are_not_on_this_node(self): + """A shared backup server holds every node's copies, and keeps + those of guests that no longer exist anywhere.""" + # 101 is local (see the fixture); 999 belongs to somebody else. + self.ctx.responses[PBS_CMD] = (0, json.dumps([ + self.snapshot(state='failed', vmid=999), + self.snapshot(state='ok', vmid=101)])) + result = self.result(checks._backup_verification) + self.assertEqual(result['classification'], 'conformant', + "another node's failed snapshot was graded here") + + def test_ceph_reports_its_own_verdict_and_the_checks_behind_it(self): + """Ceph grades its own state better than anything outside could; + what is added is putting that verdict where the host's is read.""" + cmd = ('ceph', '-s', '--format', 'json') + self.ctx.responses[cmd] = (0, json.dumps({'health': { + 'status': 'HEALTH_ERR', 'checks': { + 'PG_DAMAGED': {'severity': 'HEALTH_ERR', + 'summary': {'message': '1 pg inconsistent'}}, + 'OSD_NEARFULL': {'severity': 'HEALTH_WARN', + 'summary': {'message': '1 osd nearfull'}}}}})) + rows = self.result(checks._ceph_health)['affected'] + self.assertEqual({r['name']: r['classification'] for r in rows}, + {'PG_DAMAGED': 'critical', 'OSD_NEARFULL': 'warning'}) + # A warning cluster is a warning even with no check named. + self.ctx.responses[cmd] = (0, json.dumps( + {'health': {'status': 'HEALTH_WARN', 'checks': {}}})) + self.assertEqual( + self.result(checks._ceph_health)['affected'][0]['classification'], 'warning') + # The client binary ships with Proxmox; a node without a cluster + # configuration has nothing to report. + del self.ctx.files['/etc/pve/ceph.conf'] + self.assertIsNone(self.result(checks._ceph_health)) + + def test_array_short_of_devices_is_not_an_array_that_stopped(self): + """Both keep serving; only one has lost what it was built for.""" + def grade(mdstat): + self.ctx.files['/proc/mdstat'] = mdstat + r = self.result(checks._array_integrity) + return [(a['name'], a['reason_key'], a['classification']) + for a in r.get('affected', [])] or [(r['summary_key'],)] + self.assertEqual(grade('Personalities : [raid1]\n' + 'md0 : active raid1 sda1[0] sdb1[1]\n' + ' 976630464 blocks super 1.2 [2/1] [U_]\n'), + [('md0', 'arrayDegraded', 'warning')]) + # Rebuilding is the array doing what it should. + self.assertEqual(grade('Personalities : [raid1]\n' + 'md0 : active raid1 sda1[0] sdb1[1]\n' + ' 976630464 blocks super 1.2 [2/1] [U_]\n' + ' [==>..] recovery = 12.0% (1/9) finish=2min\n'), + [('md0', 'arrayRebuilding', 'warning')]) + self.assertEqual(grade('Personalities : [raid1]\n' + 'md0 : inactive sda1[0]\n'), + [('md0', 'arrayNotActive', 'critical')]) + # No array and no multipath tool is nothing to report on. + self.ctx.files['/proc/mdstat'] = 'Personalities :\nunused devices: \n' + self.assertIsNone(self.result(checks._array_integrity)) + + def test_ha_reads_what_quorum_does_not_answer(self): + """Quorum has its own check; this is the half it cannot answer.""" + cmd = ('ha-manager', 'status') + self.ctx.responses[cmd] = (0, + 'quorum OK\nmaster fixture (active, Mon Jan 1 00:00:00 2026)\n' + 'lrm fixture (wait_for_agent_lock, Mon Jan 1 00:00:00 2026)\n' + 'service vm:100 (fixture, error)\n') + rows = {r['name']: (r['reason_key'], r['classification']) + for r in self.result(checks._ha_state)['affected']} + self.assertEqual(rows['vm:100'], ('haServiceError', 'critical')) + self.assertEqual(rows['fixture'], ('haManagerNotReady', 'warning')) + # Nothing decides where a service runs without a master. + self.ctx.responses[cmd] = (0, 'quorum OK\nlrm fixture (idle, x)\n' + 'service vm:100 (fixture, started)\n') + self.assertIn('haNoMaster', + {r['reason_key'] for r in self.result(checks._ha_state)['affected']}) + # No declared resources is nothing to move. + del self.ctx.files['/etc/pve/ha/resources.cfg'] + self.assertIsNone(self.result(checks._ha_state)) + + def test_essential_service_down_outranks_a_peripheral_unit(self): + """A node that keeps its guests and refuses every management + operation looks healthy from every other angle.""" + failed = ('systemctl', 'list-units', '--state=failed', '--no-legend', + '--no-pager', '--plain') + active = ('systemctl', 'is-active', 'pve-cluster', 'pvedaemon', + 'pveproxy', 'pvestatd') + self.ctx.responses[failed] = ( + 1, 'smartd.service loaded failed failed Self-Monitoring daemon\n') + rows = self.result(checks._failed_units)['affected'] + self.assertEqual([(r['name'], r['classification']) for r in rows], + [('smartd.service', 'warning')]) + # An inactive essential service is not always a failed unit, and + # the outcome is the same, so it is asked for by name. + self.ctx.responses[failed] = (0, '') + self.ctx.responses[active] = (3, 'active\ninactive\nactive\nactive\n') + rows = self.result(checks._failed_units)['affected'] + self.assertEqual([(r['name'], r['classification']) for r in rows], + [('pvedaemon', 'critical')]) + + def test_filesystem_critical_needs_exhaustion_not_a_high_percentage(self): + """Ninety-one per cent is a risk; nothing left is the failure. + + A fixed high percentage would not prove an interruption either, + so the critical result comes from zero bytes, no inodes, or a + mount the kernel reports read-only. + """ + def grade(df, mounts='/ rw,relatime\n'): + self.ctx.responses[DF_CMD] = (0, 'Mounted on Use% IUse% 1K-blocks Avail\n' + df) + self.ctx.responses[FINDMNT_CMD] = (0, mounts) + r = self.result(checks._filesystem_capacity) + return [(a['reason_key'], a['classification']) for a in r.get('affected', [])] \ + or [(r.get('summary_key'), r['classification'])] + + self.assertEqual(grade('/ 95% 10% 100 5\n'), + [('filesystemNearlyFull', 'warning')]) + # Full to the last byte, whatever the rounded percentage says. + self.assertEqual(grade('/ 100% 10% 100 0\n'), + [('filesystemExhausted', 'critical')]) + self.assertEqual(grade('/ 40% 100% 100 60\n'), + [('inodesExhausted', 'critical')]) + # Already refusing writes, and no percentage says so. + self.assertEqual(grade('/ 40% 10% 100 60\n', '/ ro,relatime\n'), + [('filesystemReadOnly', 'critical')]) + # `ro` inside another option must not be mistaken for read-only. + self.assertEqual(grade('/ 40% 10% 100 60\n', '/ rw,errors=remount-ro\n'), + [('withinLimits', 'conformant')]) + + def test_boot_partitions_out_of_step_are_not_redundancy(self): + """Two partitions carrying different kernels is redundancy on paper. + + The surviving disk starts something other than what this one + would, which is exactly the case the pair exists to cover. The + kernel check reads which version boots and says it does not + verify the loader's installation; this is that half. + """ + cmd = ('proxmox-boot-tool', 'status') + self.ctx.responses[cmd] = (0, + "System currently booted with uefi\n" + "654E-D6BD is configured with: uefi (versions: 6.8.12-1-pve)\n" + "6550-5CBE is configured with: uefi (versions: 6.7.0-1-pve)\n") + reasons = {r['reason_key'] for r in self.result(checks._boot_loader)['affected']} + self.assertIn('bootEspOutOfSync', reasons) + self.assertIn('bootEspMissingNewest', reasons) + # One partition is a working boot with a single point of failure. + self.ctx.responses[cmd] = (0, + "System currently booted with uefi\n" + "654E-D6BD is configured with: uefi (versions: 6.8.12-1-pve)\n") + single = self.result(checks._boot_loader)['affected'][0] + self.assertEqual(single['reason_key'], 'bootSingleEsp') + self.assertEqual(single['classification'], 'observation') + # A host that does not use the tool keeps its loader elsewhere. + del self.ctx.files['/etc/kernel/proxmox-boot-uuids'] + self.assertIsNone(self.result(checks._boot_loader)) + + def test_disk_errors_are_warnings_separate_from_current_smart_health(self): + def grade(severity, days_ago): + self.observations[:] = [{'device_name': '/dev/sdh', 'error_type': 'io_error', + 'severity': severity, 'occurrence_count': 284252, + 'first_occurrence': NOW - 110 * 86400, + 'last_occurrence': NOW - days_ago * 86400, + 'raw_message': 'ata8.00: error: { IDNF }'}] + result = self.result(checks._disk_errors) + return result['affected'][0]['classification'] if result.get('affected') \ + else result['classification'] + + # A recorded event asks for attention, but it does not override + # the separate current SMART/Proxmox health result or assert a + # present disk failure. + for severity, days in [('CRITICAL', 0), ('CRITICAL', 60), + ('WARNING', 0), ('WARNING', 60)]: + self.assertEqual(grade(severity, days), 'warning', + f'{severity} {days}d was not reported as a warning') + # The observation log writes ISO strings while other Monitor + # tables write epoch seconds. Reading only one of them made an + # error happening now look like one that stopped long ago. + import datetime as _dt + iso = _dt.datetime.fromtimestamp(NOW - 3600).isoformat() + self.observations[:] = [{'device_name': '/dev/sdh', 'error_type': 'io_error', + 'severity': 'critical', 'occurrence_count': 284340, + 'first_occurrence': iso, 'last_occurrence': iso, + 'raw_message': 'ata8.00: error: { IDNF }'}] + row = self.result(checks._disk_errors)['affected'][0] + self.assertEqual(row['classification'], 'warning') + self.assertEqual(row['reason_key'], 'diskErrorsActive') + # An empty store and a store the reader emptied look the same, + # and neither supports "no disk reported an error". + self.observations[:] = [] + result = self.result(checks._disk_errors) + self.assertEqual(result['classification'], 'not_applicable') + self.assertEqual(result['summary_key'], 'noEvents') + + def test_thin_pool_unknown_usage_does_not_pass(self): + for row, expected in [('vg|thin|100||twi|10|?', 'unverified'), + ('vg|thin|0||twi|10|10', 'unverified'), + ('vg|thin|100||twi|nan|10', 'unverified'), + ('vg|thin|100||twi|95|?', 'warning')]: + self.ctx.responses[LVS_CMD] = (0, row) + result = self.result(checks._thin_overprovisioning) + self.assertEqual(result['classification'], expected) + self.assertTrue(result['incomplete']) + + def test_replication_status_types_and_missing_error_text(self): + cmd = ('pvesh','get','/nodes/fixture/replication','--output-format','json') + for fields, expected in [({'disable':'0', 'fail_count':2}, 'warning'), + ({'disable':'1', 'fail_count':2}, 'observation'), + ({'last_sync':NOW+500}, 'unverified'), + ({'disable':'unknown'}, 'unverified')]: + self.ctx.responses[cmd]=(0,json.dumps([{'id':'101-0','last_sync':NOW-100, **fields}])) + self.assertEqual(self.result(checks._replication_state)['classification'], expected) + for body in ('{}', 'null', '[3]'): + self.ctx.responses[cmd]=(0,body) + self.assertEqual(self.result(checks._replication_state)['classification'], 'unverified') + + def test_old_snapshot_cpu_is_not_current_cpu(self): + self.ctx.qemu_configs={200:'name: fixture\n[snapshot]\ncpu: host\n'} + self.assertEqual(self.result(checks._cpu_host_type)['classification'], 'conformant') + + def test_lynis_report_age_qualifies_the_warnings_it_came_with(self): + """The age describes the report, not the host, so it rides with + the warnings it qualifies instead of standing as a check. + + Reading "no warnings" without knowing the audit ran in June is + reading something else entirely. + """ + with patch.dict(self.ctx.lynis_report, {'mtime': NOW - 90 * 86400}): + result = self.result(checks._lynis_warnings) + self.assertEqual(result['summary_key'], 'noneStale') + self.assertEqual(result['affected'][0]['reason_key'], 'lynisReportStale') + self.assertEqual(result['affected'][0]['classification'], 'observation') + # A recent report with nothing to report is simply conformant. + self.assertEqual(self.result(checks._lynis_warnings)['classification'], + 'conformant') + # An unusable date does not become an age, and does not stop the + # warnings from being reported. + for fields in ({'mtime': NOW + 86400}, {'complete': False}): + with patch.dict(self.ctx.lynis_report, fields): + result = self.result(checks._lynis_warnings) + self.assertNotIn('Stale', str(result.get('summary_key'))) + + def test_profiles_cover_their_declared_scope(self): + all_checks = engine.registered_checks() + for name, spec in audit_profiles.PROFILES.items(): + actual = {c.check_id for c in audit_profiles.selected_checks(name, all_checks)} + expected = set(EXPECTED) if spec['areas'] is None else { + c.check_id for c in all_checks if c.area in spec['areas'] or c.check_id in spec['include']} + self.assertEqual(actual, expected, name) + + def test_backup_verification_newest_not_oldest_and_each_destination(self): + """The newest copy is what is graded, and never as critical. + + The audit performs no restore, so it cannot demonstrate that + recovery is impossible; what it can say is whether anything + else verified. + """ + for older, newest, expected in [('failed','ok','conformant'), ('ok','failed','warning')]: + self.ctx.responses[PBS_CMD]=(0,json.dumps([self.snapshot(older,ctime=NOW-500), self.snapshot(newest)])) + self.assertEqual(self.result(checks._backup_verification)['classification'], expected) + self.ctx.storages.append({'id':'second','type':'pbs'}) + self.ctx.responses[tuple(x.replace('/pbs/', '/second/') for x in PBS_CMD)] = (1,'unavailable') + result = self.result(checks._backup_verification) + self.assertEqual(result['classification'], 'warning') + self.assertTrue(result['incomplete']) + + def test_verification_empty_missing_unknown_and_malformed(self): + for body in ('{}', 'null', 'invalid', '[3]'): + self.ctx.responses[PBS_CMD]=(0,body) + self.assertEqual(self.result(checks._backup_verification)['classification'], 'unverified') + for state, expected in [('none','observation'), ('unexpected','unverified')]: + self.ctx.responses[PBS_CMD]=(0,json.dumps([self.snapshot(state)])) + self.assertEqual(self.result(checks._backup_verification)['classification'], expected) + self.ctx.responses[PBS_CMD]=(0,'[]') + self.assertIsNone(self.result(checks._backup_verification)) + + def test_backup_tasks_do_not_count_unknown_status_as_success(self): + for status, expected in [('','unverified'),('running','unverified'),('job errors','warning'),('OK','conformant')]: + self.ctx.responses[TASK_CMD]=(0,json.dumps([{'type':'vzdump','status':status}])) + self.assertEqual(self.result(checks._backup_job_results)['classification'], expected) + self.ctx.responses[TASK_CMD]=(1,'offline') + self.assertEqual(self.result(checks._backup_job_results)['classification'], 'unverified') + + def test_backup_task_recovers_guest_from_upid(self): + upid = 'UPID:fixture:001234:00ABCDEF:68BD1234:vzdump:106:root@pam:' + self.ctx.responses[TASK_CMD] = (0, json.dumps([{ + 'type': 'vzdump', 'status': 'job errors', 'upid': upid, + 'starttime': NOW - 60, + }])) + result = self.result(checks._backup_job_results) + self.assertEqual(result['affected'][0]['vmid'], 106) + self.assertEqual(result['affected'][0]['upid'], upid) + + def test_filesystem_partial_data_keeps_known_pressure(self): + for row, expected in [('/ 91% 10% 100 9','warning'),('/ 20% 95% 100 80','warning'),('/ 20% - 100 80','unverified')]: + self.ctx.responses[DF_CMD]=(0,'header\n'+row+'\n') + self.assertEqual(self.result(checks._filesystem_capacity)['classification'], expected) + self.ctx.responses[DF_CMD]=(1,'header\n/ 91% 10% 100 9\ndf: missing path\n') + result=self.result(checks._filesystem_capacity) + self.assertEqual(result['classification'],'warning'); self.assertTrue(result['incomplete']) + self.ctx.responses[DF_CMD]=(1,'df: failure') + self.assertEqual(self.result(checks._filesystem_capacity)['classification'],'unverified') + + def test_pool_status_failure_is_not_healthy(self): + self.ctx.responses[('zpool','status','tank')]=(1,'unavailable') + self.assertEqual(self.result(checks._pool_integrity)['classification'],'unverified') + self.ctx.responses[('zpool','list','-H','-o','name,health')]=(0,'tank\tFAULTED\n') + result=self.result(checks._pool_integrity) + self.assertEqual(result['classification'],'critical'); self.assertTrue(result['incomplete']) + + def test_pool_counters_are_reported_without_claiming_current_failure(self): + self.ctx.responses[('zpool','status','tank')]=(0,'state: ONLINE\n disk ONLINE 0 0 7\n') + result=self.result(checks._pool_integrity) + self.assertEqual(result['classification'],'warning') + self.assertIn('not necessarily',result['evidence']) + + def test_cache_rebuild_is_not_a_repository_refresh(self): + self.ctx.files.pop('/var/lib/apt/periodic/update-success-stamp') + self.ctx.files['/var/cache/apt/pkgcache.bin']='rebuilt just now' + self.assertEqual(self.result(checks._update_chain)['classification'],'unverified') + self.ctx.files['/var/lib/apt/periodic/update-success-stamp']='' + self.stamps['/var/lib/apt/periodic/update-success-stamp']=NOW-8*86400 + self.assertEqual(self.result(checks._update_chain)['classification'],'warning') + self.stamps['/var/lib/apt/periodic/update-success-stamp']=NOW+86400 + self.assertEqual(self.result(checks._update_chain)['classification'],'unverified') + + def test_notification_no_history_or_error_does_not_prove_delivery(self): + for payload in ({'history':[]}, {'history':[], 'error':'locked'}, {'history':[{'channel':'telegram','success':'0'}]}): + self.histories['telegram']=payload + self.assertEqual(self.result(checks._notification_delivery)['classification'],'unverified') + + def test_notification_recovery_and_disabled_channel(self): + self.histories['telegram']['history'].append({'channel':'telegram','success':0,'sent_at':NOW-10}) + self.channels['email']={'enabled':False,'configured':True} + self.assertEqual(self.result(checks._notification_delivery)['classification'],'conformant') + self.histories['telegram']['history'].insert(0,{'channel':'telegram','success':0,'error_message':'fixture error'}) + result=self.result(checks._notification_delivery) + self.assertEqual(result['classification'],'warning') + self.assertEqual(result['affected'][0]['last_error'],'fixture error') + + def test_notification_misconfiguration_and_unknown_channel_results(self): + self.channels['email']={'enabled':True,'configured':False} + self.histories['telegram']={'history':[]} + result=self.result(checks._notification_delivery) + self.assertEqual(result['classification'],'warning'); self.assertTrue(result['incomplete']) + self.channels={} + self.assertEqual(self.result(checks._notification_delivery)['classification'],'observation') + + def test_certificate_just_expired_is_expired(self): + self.ctx.responses[('date','-d','fixture','+%s')]=(0,str(NOW-1)) + result=self.result(checks._certificate_expiry) + self.assertEqual(result['classification'],'warning') + self.assertEqual(result['summary_key'],'expired') + + def test_kernel_next_boot_is_the_pin_or_the_newest_retained(self): + """Without a pin the boot tool starts the newest kernel it keeps. + + Reading that as undetermined made the check unverifiable on every + host that never pinned one, which is most of them. + """ + cmd=('proxmox-boot-tool','kernel','list') + # Retained across both lists; the newest of them is what boots. + self.ctx.responses[cmd]=(0,'Manually selected kernels:\n6.9.0-1-pve\nAutomatically selected kernels:\n6.8.12-1-pve\n') + result = self.result(checks._kernel_current) + self.assertEqual(result['summary_key'],'newerSelected') + self.assertIn('6.9.0-1-pve', result['evidence']) + # The running kernel already being the newest retained is the + # ordinary state of a host that rebooted after its last upgrade. + self.ctx.responses[cmd]=(0,'Automatically selected kernels:\n6.8.12-1-pve\n6.7.0-1-pve\n') + self.assertEqual(self.result(checks._kernel_current)['classification'],'conformant') + # An explicit pin still wins over the retention lists. + self.ctx.responses[cmd]=(0,'Pinned kernel:\n6.8.12-1-pve\nKernel pinned on next-boot:\n6.9.0-1-pve\n') + self.assertEqual(self.result(checks._kernel_current)['summary_key'],'newerSelected') + + def test_empty_ntp_and_ssh_output_do_not_prove_configuration(self): + self.ctx.responses[('timedatectl','show','-p','NTP','-p','NTPSynchronized')]=(0,'') + self.assertEqual(self.result(checks._time_sync)['classification'],'unverified') + self.ctx.responses[('sshd','-T')]=(0,'permitrootlogin yes\n') + self.assertEqual(self.result(checks._ssh_root_login)['classification'],'unverified') + + def test_bond_unknown_member_not_claimed_as_link_failure(self): + self.ctx.files['/proc/net/bonding/bond0']='Slave Interface: eth0\n' + self.assertEqual(self.result(checks._bond_members)['classification'],'unverified') + self.ctx.files['/proc/net/bonding/bond0']='Slave Interface: eth0\nMII Status: down\n' + self.assertEqual(self.result(checks._bond_members)['classification'],'critical') + + def test_policy_exemption_and_unstated_backups(self): + self.ctx.policy=audit_policy.Policy({'defaults':{'backup':'not_required'}}) + self.assertIsNone(self.result(checks._last_backup_age)) + self.ctx.policy=audit_policy.Policy() + self.ctx.vzdump_jobs='' + self.assertEqual(self.result(checks._guest_coverage)['classification'],'observation') + self.ctx.policy=audit_policy.Policy({'defaults':{'backup':'required'}}) + self.assertEqual(self.result(checks._guest_coverage)['classification'],'warning') + + +if __name__ == '__main__': unittest.main() diff --git a/tests/test_audit_diagnostic_document.cjs b/tests/test_audit_diagnostic_document.cjs new file mode 100644 index 00000000..c346aefb --- /dev/null +++ b/tests/test_audit_diagnostic_document.cjs @@ -0,0 +1,253 @@ +// 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.'); diff --git a/tests/test_audit_policy.cjs b/tests/test_audit_policy.cjs new file mode 100644 index 00000000..5d0f7091 --- /dev/null +++ b/tests/test_audit_policy.cjs @@ -0,0 +1,122 @@ +// Component-state regression tests with isolated hooks and a mocked API. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const app = path.resolve(__dirname, '../AppImage'); +const ts = require(path.join(app, 'node_modules/typescript')); +const messages = require(path.join(app, 'messages/en/common.json')); +const t = (key, params = {}) => key.split('.').reduce((v, k) => v?.[k], messages) + .replace(/\{(\w+)\}/g, (_, k) => params[k] ?? `{${k}}`); +let cursor = 0, states = [], effects = [], initialized = false, submitted, rejectSave = false; +const snapshot = { guests: {}, storages: {}, thresholds: {}, + defaults: { backup: 'required', autostart: 'not_required', storage_role: 'essential', recovery_objective_hours: 48 } }; +const hooks = { + useState(initial) { const i = cursor++; if (!(i in states)) states[i] = initial; + return [states[i], v => { states[i] = typeof v === 'function' ? v(states[i]) : v; }]; }, + useMemo: fn => fn(), useCallback: fn => fn, + useEffect(fn) { if (!initialized) effects.push(fn); }, +}; +const jsx = (type, props) => ({ type, props: props || {} }); +const api = async (url, options) => { + if (options) { + submitted = JSON.parse(options.body); + if (rejectSave) throw Object.assign(new Error('conflict'), { status: 409 }); + return { success: true, summary: { revision: 'second' } }; + } + if (url.includes('inventory')) return { inventory: { sections: { + guests: [{ vmid: 100, name: 'fixture', type: 'lxc' }], storages: [{ id: 'pbs', type: 'pbs' }], + } } }; + return { success: true, policy: snapshot, summary: { revision: 'first' }, vocabulary: { + expectations: ['required', 'not_required', 'unspecified'], roles: ['essential', 'optional', 'unspecified'], + thresholds: { storage_usage_percent: 90 }, + } }; +}; +const mod = { exports: {} }; +const source = fs.readFileSync(path.join(app, 'components/audit-policy.tsx'), 'utf8'); +const js = ts.transpileModule(source, { compilerOptions: { + module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, jsx: ts.JsxEmit.ReactJSX, +} }).outputText; +new Function('require', 'module', 'exports', js)(name => { + if (name === 'react') return hooks; + if (name === 'react/jsx-runtime') return { jsx, jsxs: jsx }; + if (name.endsWith('api-config')) return { fetchApi: api }; + if (name.endsWith('provider')) return { useT: () => t }; + return new Proxy({}, { get: (_, name) => String(name) }); +}, mod, mod.exports); +function render() { cursor = 0; const tree = mod.exports.AuditPolicy(); initialized = true; return tree; } +function nodes(tree, type) { + if (!tree || typeof tree !== 'object') return []; + if (Array.isArray(tree)) return tree.flatMap(x => nodes(x, type)); + if (typeof tree.type === 'function') return nodes(tree.type(tree.props), type); + return [...(tree.type === type ? [tree] : []), ...nodes(tree.props?.children, type)]; +} +const change = (node, value) => node.props.onChange({ target: { value } }); +// The dropdowns are the shared Select, which reports a value rather than +// an event. Unresolved imports come back as their own name, so the +// element type is the component's name. +const choose = (node, value) => node.props.onValueChange(value); +const tick = () => new Promise(resolve => setImmediate(resolve)); +(async () => { + render(); effects.forEach(fn => fn()); await tick(); + // The form is locked until the reader says they are editing it. + let tree = render(); + assert.equal(nodes(tree, 'fieldset')[0].props.disabled, true, + 'The declaration is editable before anyone asked to edit it'); + const editButton = nodes(tree, 'button').find( + b => JSON.stringify(b.props.children).includes(messages.actions.edit)); + assert(editButton, 'No edit button to unlock the declaration'); + // A disabled fieldset disables every control it holds, so the button that + // leaves that state cannot live inside it. + assert(!nodes(nodes(tree, 'fieldset')[0], 'button').includes(editButton), + 'The edit button sits inside the fieldset it unlocks, so it is never clickable'); + // The dropdown governs its own opening, so the fieldset does not reach it. + assert(nodes(tree, 'Select').every(sel => sel.props.disabled === true), + 'A locked declaration still opens its dropdowns'); + editButton.props.onClick(); tree = render(); + assert.equal(nodes(tree, 'fieldset')[0].props.disabled, false); + assert(nodes(tree, 'Select').every(sel => sel.props.disabled === false), + 'Editing does not unlock the dropdowns'); + + let select = nodes(tree, 'Select'); + assert.equal(select[0].props.value, 'inherit'); + const inherited = (value) => messages.audit.policy.inherit.replace('{value}', value); + assert.equal(nodes(select[0], 'SelectItem')[0].props.children, inherited('Required')); + assert.equal(nodes(select[2], 'SelectItem')[0].props.children, inherited('Essential')); + choose(select[0], 'unspecified'); choose(select[2], 'unspecified'); + tree = render(); + assert.equal(nodes(tree, 'Select')[0].props.value, 'unspecified'); + nodes(tree, 'form')[0].props.onSubmit({ preventDefault() {} }); await tick(); + assert.equal(submitted.expected_revision, 'first'); + assert.equal(submitted.guests['100'].backup, 'unspecified'); + assert.equal(submitted.storages.pbs.role, 'unspecified'); + tree = render(); choose(nodes(tree, 'Select')[0], 'inherit'); + tree = render(); + const inputs = nodes(tree, 'input'); + assert.equal(inputs[0].props.placeholder, '48'); + assert.equal(inputs[1].props.max, 100); + change(inputs[1], '-1'); tree = render(); + assert.equal(nodes(tree, 'input')[1].props.value, -1, 'Invalid value is not silently cleared'); + change(nodes(tree, 'input')[1], ''); tree = render(); + rejectSave = true; + nodes(tree, 'form')[0].props.onSubmit({ preventDefault() {} }); await tick(); tree = render(); + assert.equal(submitted.expected_revision, 'second'); + assert.equal(submitted.guests['100'], undefined); + assert.equal(nodes(tree, 'fieldset')[0].props.disabled, true); + assert(JSON.stringify(tree).includes(messages.audit.policy.conflict)); + assert(JSON.stringify(tree).includes(messages.audit.policy.reload)); + + // With nothing declared site-wide there is no value to name, and the + // explicit option that would say the same thing is not offered twice. + Object.assign(snapshot.defaults, { backup: undefined, autostart: undefined, + storage_role: undefined }); + cursor = 0; states = []; effects = []; initialized = false; + render(); effects.forEach(fn => fn()); await tick(); tree = render(); + select = nodes(tree, 'Select'); + const first = nodes(select[0], 'SelectItem'); + assert.equal(first[0].props.children, messages.audit.policy.inheritUnset, + 'The default option names a value nobody declared'); + assert(!first.slice(1).some(i => i.props.value === 'unspecified'), + 'The dropdown offers the same outcome twice'); + + console.log('Policy UI: inheritance, explicit unspecified, numeric constraints, revision and conflict tests passed'); +})().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/tests/test_audit_policy.py b/tests/test_audit_policy.py new file mode 100644 index 00000000..9d471b53 --- /dev/null +++ b/tests/test_audit_policy.py @@ -0,0 +1,126 @@ +"""Policy validation and atomic updates. All writes stay in temporary directories.""" +import concurrent.futures +import json +import os +from pathlib import Path +import sys +import tempfile +import unittest +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "AppImage/scripts")) +import audit_policy as policy + + +class AuditPolicyTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.path = Path(self.temp.name) / "policy.json" + + def save(self, raw, **kwargs): + return policy.save(raw, self.path, **kwargs) + + def test_missing_is_not_declared(self): + value = policy.load(self.path) + self.assertFalse(value.declared) + self.assertIsNone(value.error) + self.assertEqual(value.revision, "missing") + + def test_inheritance_and_explicit_unspecified_round_trip(self): + value = self.save({"defaults": {"backup": "required", "autostart": "required", + "storage_role": "essential", "recovery_objective_hours": 48}, + "guests": {"100": {"backup": "unspecified", "autostart": "not_required"}}, + "storages": {"local": {"role": "unspecified"}}}) + self.assertEqual(value.backup_required(100), "unspecified") + self.assertEqual(value.backup_required(101), "required") + self.assertEqual(value.autostart_required(100), "not_required") + self.assertEqual(value.storage_role("local"), "unspecified") + self.assertEqual(value.storage_role("pbs"), "essential") + self.assertEqual(value.recovery_objective_hours(100), 48) + + def test_invalid_numbers_rejected_without_changing_saved_policy(self): + self.save({"thresholds": {"storage_usage_percent": 90}}) + before = self.path.read_bytes() + for value in (True, False, 0, -1, float("nan"), float("inf"), -float("inf"), "12", 10**400): + for raw in ({"thresholds": {"storage_usage_percent": value}}, + {"guests": {"100": {"recovery_objective_hours": value}}}, + {"defaults": {"recovery_objective_hours": value}}): + with self.subTest(raw=raw), self.assertRaises(ValueError): + self.save(raw) + self.assertEqual(before, self.path.read_bytes()) + + def test_percentage_bounds_and_positive_fractional_values(self): + with self.assertRaises(ValueError): + self.save({"thresholds": {"storage_usage_percent": 101}}) + value = self.save({"thresholds": {"storage_usage_percent": 100, "thin_overprovision_ratio": 2.5}, + "guests": {"100": {"recovery_objective_hours": 0.5}}}) + self.assertEqual(value.recovery_objective_hours(100), 0.5) + self.assertEqual(value.threshold("thin_overprovision_ratio"), 2.5) + + def test_invalid_sections_and_defaults_rejected(self): + for name in ("guests", "storages", "thresholds", "defaults"): + for value in ([], False, "", None): + with self.subTest(name=name, value=value), self.assertRaises(ValueError): + self.save({name: value}) + for name in ("backup", "autostart", "storage_role"): + with self.assertRaises(ValueError): + self.save({"defaults": {name: "invalid"}}) + + def test_manual_invalid_file_is_visible_and_not_overwritten(self): + self.path.write_text('{"thresholds":{"storage_usage_percent":Infinity}}') + value = policy.load(self.path) + self.assertTrue(value.error) + with self.assertRaises(ValueError): + self.save({}, expected_revision=value.revision) + + def test_stale_editor_is_rejected(self): + first = self.save({}) + second = self.save({"defaults": {"backup": "required"}}, expected_revision=first.revision) + with self.assertRaises(policy.PolicyConflict): + self.save({}, expected_revision=first.revision) + self.assertEqual(policy.load(self.path).revision, second.revision) + + def test_deleted_file_is_also_a_conflict(self): + first = self.save({}) + self.path.unlink() + with self.assertRaises(policy.PolicyConflict): + self.save({}, expected_revision=first.revision) + + def test_concurrent_editors_only_one_can_save(self): + revision = self.save({}).revision + def write(i): + try: + self.save({"guests": {str(i): {"backup": "required"}}}, expected_revision=revision) + return "saved" + except policy.PolicyConflict: + return "conflict" + with concurrent.futures.ThreadPoolExecutor(max_workers=8) as pool: + results = list(pool.map(write, range(100, 108))) + self.assertEqual(results.count("saved"), 1) + self.assertEqual(results.count("conflict"), 7) + self.assertEqual(len(json.loads(self.path.read_text())["guests"]), 1) + self.assertEqual(list(self.path.parent.glob(".audit-policy-*")), []) + + def test_failed_replace_preserves_original_and_cleans_temp(self): + self.save({}) + before = self.path.read_bytes() + with patch.object(Path, "replace", side_effect=OSError("fixture failure")): + with self.assertRaises(OSError): + self.save({"defaults": {"backup": "required"}}) + self.assertEqual(before, self.path.read_bytes()) + self.assertEqual(list(self.path.parent.glob(".audit-policy-*")), []) + + def test_private_permissions_and_same_mtime_changes(self): + first = self.save({}) + self.assertEqual(self.path.stat().st_mode & 0o777, 0o600) + stamp = self.path.stat().st_mtime_ns + self.path.write_text('{"defaults":{"backup":"required"}}') + os.utime(self.path, ns=(stamp, stamp)) + fresh = policy.load(self.path) + self.assertNotEqual(first.revision, fresh.revision) + self.assertEqual(fresh.backup_required(100), "required") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_audit_policy_api.py b/tests/test_audit_policy_api.py new file mode 100644 index 00000000..bd0e37d4 --- /dev/null +++ b/tests/test_audit_policy_api.py @@ -0,0 +1,64 @@ +"""Policy endpoint contracts; authentication and storage are isolated fixtures.""" +import importlib +from pathlib import Path +import sys +import tempfile +import types +import unittest +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "AppImage/scripts")) +from flask import Flask +import audit_policy as policy +import audit_store as store + + +class PolicyApiTests(unittest.TestCase): + def setUp(self): + temp = tempfile.TemporaryDirectory() + self.addCleanup(temp.cleanup) + self.path = Path(temp.name) / "policy.json" + original_load, original_save = policy.load, policy.save + for patcher in ( + patch.object(policy, "load", side_effect=lambda *args: original_load(self.path)), + patch.object(policy, "save", side_effect=lambda raw, **kw: original_save(raw, self.path, **kw)), + patch.object(store, "DB_PATH", Path(temp.name) / "audit.db"), + patch.object(store, "_schema_ready", False), + ): + patcher.start(); self.addCleanup(patcher.stop) + auth = types.ModuleType("auth_manager") + auth.load_auth_config = lambda: {"enabled": True} + auth.verify_token = lambda token: "fixture" + middleware = types.ModuleType("jwt_middleware") + middleware.require_auth = lambda f: f + middleware.require_admin_scope = lambda f: f + with patch.dict(sys.modules, auth_manager=auth, jwt_middleware=middleware): + sys.modules.pop("flask_audit_routes", None) + routes = importlib.import_module("flask_audit_routes") + self.addCleanup(lambda: sys.modules.pop("flask_audit_routes", None)) + app = Flask(__name__) + app.register_blueprint(routes.audit_bp) + self.client = app.test_client() + + def test_revision_and_conflict_contract(self): + first = self.client.get("/api/audit/policy").json + self.assertEqual(first["summary"]["revision"], "missing") + self.assertEqual(self.client.put("/api/audit/policy", json={}).status_code, 428) + payload = {"expected_revision": "missing", "defaults": {"backup": "required"}} + response = self.client.put("/api/audit/policy", json=payload) + self.assertEqual(response.status_code, 200) + self.assertEqual(self.client.put("/api/audit/policy", json=payload).status_code, 409) + self.assertEqual(self.client.get("/api/audit/policy").json["policy"]["defaults"]["backup"], "required") + + def test_validation_error_is_not_silent_success(self): + bad = {"expected_revision": "missing", "thresholds": {"storage_usage_percent": True}} + self.assertEqual(self.client.put("/api/audit/policy", json=bad).status_code, 400) + self.assertFalse(self.path.exists()) + + def test_invalid_file_does_not_open_empty_editor(self): + self.path.write_text("invalid json") + self.assertEqual(self.client.get("/api/audit/policy").status_code, 422) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_audit_presentation.cjs b/tests/test_audit_presentation.cjs new file mode 100644 index 00000000..227af006 --- /dev/null +++ b/tests/test_audit_presentation.cjs @@ -0,0 +1,159 @@ +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'); +const cache = new Map(); +function load(file) { + file = path.resolve(file); + if (cache.has(file)) return cache.get(file).exports; + const mod = {exports:{}}; cache.set(file,mod); + const js = ts.transpileModule(fs.readFileSync(file,'utf8'), {compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2020,jsx:ts.JsxEmit.ReactJSX}}).outputText; + new Function('require','module','exports',js)(name => { + if (!name.startsWith('.')) return appRequire(name); + const base=path.resolve(path.dirname(file),name); + return load(fs.existsSync(base+'.ts') ? base+'.ts' : base+'.tsx'); + },mod,mod.exports); + return mod.exports; +} +function translate(locale) { + const messages=JSON.parse(fs.readFileSync(path.join(app,'messages',locale,'common.json'))); + return (key,params={}) => { + const text=key.split('.').reduce((o,k)=>o?.[k],messages); + return typeof text==='string' ? text.replace(/\{(\w+)\}/g,(m,k)=>params[k] ?? m) : key; + }; +} +global.window={location:{origin:'http://localhost'}}; +const presentation=load(path.join(app,'lib/audit-presentation.ts')); +const {buildAuditDocument}=load(path.join(app,'lib/audit-document.ts')); +const {storageDiagram}=load(path.join(app,'lib/report-diagrams.ts')); +const base=(id,classification,affected=[])=>({check_id:id,area:id.split('.')[0],severity:'INFO',classification,summary_key:null,summary_params:{},affected,evidence:null}); +const coverage=base('backup.guest_coverage','observation',[ + ...[109,111,112,114,9510].map(vmid=>({vmid,classification:'observation',reason_key:'noJobSelectsGuest'})), + ...['sata0','sata1','sata2','sata3','scsi1'].map(volume=>({vmid:106,volume,reason_key:'dataExcludedFromBackup',classification:'observation'})), + {vmid:110,volume:'scsi1',reason_key:'dataExcludedFromBackup',classification:'observation'}]); +const lynis=base('security.lynis_warnings','observation',[...['enp3s0','tap106i0','tap105i0'].map(details=>({test:'NETW-3015',message:'Found promiscuous interface',details,solution:'Do not show this advice',classification:'observation'}))]); +const age=base('backup.last_backup_age','warning',[{vmid:101,storage:'PBS-Cloud',classification:'warning',reason_key:'olderThanSchedule'},{vmid:109,storage:'any',classification:'observation',reason_key:'noStoredBackupUnscheduled'}]); +age.evidence=JSON.stringify([{vmid:101,expected_storage:'PBS-Cloud',last_backup:1787763643,age_hours:259.1,max_age_hours:252}]); +for(const locale of ['en','es','de','fr','it','pt','sk','sv']) { + const t=translate(locale); + assert(!t('audit.checks.backup.last_backup_age.rationale').includes('ProxMenux')); + assert(!t('audit.presentation.limitReference').includes('ProxMenux')); + const groups=presentation.presentFinding(coverage,t,locale); + assert.equal(groups[0].rows.length,5); assert.equal(groups[1].rows.length,2); + assert(presentation.affectedDescription(coverage,t).includes('6')); + assert(!presentation.affectedDescription(coverage,t).includes('11')); + const lxcExcluded=base('backup.guest_coverage','observation',[{vmid:120,name:'container',type:'lxc',volume:'mp0',reason_key:'dataExcludedFromBackup',classification:'observation'}]); + assert.equal(presentation.presentFinding(lxcExcluded,t,locale)[0].rows[0].cells[0],'container · LXC 120'); + const unavailable=base('backup.last_backup_age','unverified',[{vmid:120,storage:'offline',classification:'unverified',reason_key:'destinationUnavailable'}]); + const unavailableText=JSON.stringify(presentation.presentFinding(unavailable,t,locale)); + assert(!unavailableText.includes(t('audit.presentation.notFound'))); + assert(unavailableText.includes(t('audit.classifications.unverified'))); + const text=JSON.stringify(presentation.presentFinding(lynis,t,locale)); + assert(!text.includes('Do not show this advice')); + assert(text.includes('tap106i0')); + assert.equal(presentation.presentFinding(lynis,t,locale).length,1); + assert.equal(presentation.presentFinding(age,t,locale).length,2); + assert.notEqual(presentation.auditDuration(259.1,locale),presentation.auditDuration(252,locale)); + for(const [policy,labelKey] of [['schedule and grace','limitSchedule'],['declared recovery objective','limitDeclared'],['fallback; no recovery objective declared and schedule not read','limitReference']]) { + const data={...age,affected:[age.affected[0]],evidence:JSON.stringify([{vmid:101,expected_storage:'PBS-Cloud',last_backup:1787763643,age_hours:259.1,max_age_hours:252,age_policy:policy}])}; + const group=presentation.presentFinding(data,t,locale)[0]; + assert(group.columns.includes(t('audit.presentation.backupAge'))); + assert(group.columns.includes(t('audit.presentation.backupLimit'))); + assert(!group.columns.includes(t('audit.presentation.ageLimit'))); + assert(group.rows[0].cells.includes(presentation.auditDuration(259.1,locale))); + assert(group.rows[0].cells.some(cell=>cell.includes(t('audit.presentation.'+labelKey)) && cell.includes(presentation.auditDuration(252,locale)))); + } + const legacyText=JSON.stringify(presentation.presentFinding(age,t,locale)); + assert(!legacyText.includes(t('audit.presentation.limitSchedule'))); + const implicitDestination={...age,affected:[{vmid:112,storage:'local',classification:'warning',reason_key:'olderThanFallback'}],evidence:JSON.stringify([{vmid:112,expected_storage:'any visible destination (no explicit target)',storage:'local',last_backup:1787763643,age_hours:800,max_age_hours:720,age_policy:'fallback; no recovery objective declared and schedule not read'}])}; + const implicitText=JSON.stringify(presentation.presentFinding(implicitDestination,t,locale)); + assert(implicitText.includes(presentation.auditDuration(720,locale)), + `${locale}: a backup without an explicit job destination lost its limit`); + const failedRuns=base('backup.job_results','warning',[ + {vmid:106,status:'job errors',when:1787760000,upid:'UPID:first',classification:'warning',reason_key:'backupRunFailed'}, + {vmid:106,status:'job errors',when:1787763600,upid:'UPID:last',classification:'warning',reason_key:'backupRunFailed'}, + {vmid:110,status:'storage unavailable',when:1787767200,upid:'UPID:other',classification:'warning',reason_key:'backupRunFailed'}, + ]); + const failedGroups=presentation.presentFinding(failedRuns,t,locale); + assert.equal(failedGroups.length,1); + assert.equal(failedGroups[0].rows.length,2, + `${locale}: repeated backup failures were not grouped`); + assert.equal(failedGroups[0].rows.find(row=>row.cells[0].includes('106')).cells[1],'2'); + assert(failedGroups[0].rows.some(row=>row.cells.includes('UPID:last')), + `${locale}: the latest backup task reference was not retained`); + const connected={...base('storage.connected_storage','conformant'),evidence:JSON.stringify({storages:[ + {storage:'store-fixture',type:'pbs',status:'active',dependencies:[{vmid:101}],jobs:['backup-1'],capacity_known:true,used_percent:42.5}, + ],scope:'PVE-side observations only'})}; + const connectedGroups=presentation.presentFinding(connected,t,locale); + assert.equal(connectedGroups.length,1); + assert.deepEqual(connectedGroups[0].columns,[t('audit.document.storage'),t('audit.document.type'), + t('audit.document.state'),t('audit.presentation.capacity'),t('audit.presentation.fact')]); + assert(connectedGroups[0].rows[0].cells.some(cell=>cell.includes('42')), + `${locale}: connected storage capacity was not presented`); + const thin={...base('storage.thin_pool_overprovisioning','warning',[ + {pool:'pve/data',metric:'metadata',classification:'warning',reason_key:'thinMetadataPressure'}, + ]),evidence:JSON.stringify([{pool:'pve/data',allocated_bytes:214748364800, + pool_bytes:107374182400,allocation_percent:200,data_percent:81.2,metadata_percent:92.4}])}; + const thinGroups=presentation.presentFinding(thin,t,locale); + assert.equal(thinGroups.length,1); + assert.deepEqual(thinGroups[0].columns,[t('audit.presentation.resource'), + t('audit.presentation.capacity'),t('audit.presentation.data'), + t('audit.presentation.metadata'),t('audit.presentation.fact')]); + assert(thinGroups[0].rows[0].cells[1].includes('GiB'), + `${locale}: thin-pool byte values were not made readable`); + const passing={...base('system.time_synchronisation','conformant'),evidence:'NTP: yes\nNTPSynchronized: yes'}; + const input={profile:'full',run:null,findings:[coverage,age,lynis,passing,base('system.security_updates','unverified')],inventory:null,t,locale}; + const html=buildAuditDocument(input); + const header=html.split('
')[1].split('
')[0]; + assert(header.includes('4/5')); + assert(header.includes('stroke-dasharray="80 100"')); + assert(header.includes(t('audit.presentation.verified'))); + assert(!header.includes('health-ring')); + assert(!header.includes('health-lbl')); + assert(!header.includes('15 de 26')); + assert(header.includes('audit-result-heading')); + assert(header.includes('stroke="currentColor"')); + const checkedHtml=html.split('id="verified-checks"')[1].split('id="unverified-checks"')[0]; + assert.equal((checkedHtml.match(/href="#finding-/g)||[]).length,4); + assert(checkedHtml.includes(t('audit.presentation.verifiedChecks')+' · 4')); + assert(checkedHtml.includes(t('audit.checks.system.time_synchronisation.title'))); + assert(!checkedHtml.includes('href="#finding-system.security_updates"')); + assert(html.indexOf('id="verified-checks"') < html.indexOf(t('audit.presentation.overview'))); + assert(!html.includes('audit.document.area')); + for(const [findings,expected] of [ + [[base('x','critical'),base('y','observation'),base('z','not_applicable')],'2/2'], + [[{...base('x','warning'),incomplete:true,decision:'accepted'},base('y','conformant')],'1/2'], + [[{...base('x','unverified'),decision:'accepted'}],'0/1'], + [[base('x','not_applicable')],'—'], + [[],'—']]) { + const doc=buildAuditDocument({...input,findings}); + assert(doc.includes(`${expected}`)); + const verifiedSection=doc.split('id="verified-checks"')[1]?.split('')[0] || ''; + const expectedCount=expected==='—'?0:Number(expected.split('/')[0]); + assert.equal((verifiedSection.match(/href="#finding-/g)||[]).length,expectedCount); + if(expected==='—') assert(doc.includes('stroke-dasharray="0 100"')); + } + assert(!html.includes('health-icon" style="font-size:26px')); + assert(html.includes(t('audit.presentation.incomplete'))); + assert(!html.includes('{count}')); + assert(!html.includes('audit.presentation.')); + const main=html.split('id="evidence-')[0]; + assert(!main.includes('noJobSelectsGuest')); + assert(!main.includes('Do not show this advice')); + assert(html.includes(t('audit.presentation.evidenceObserved')), + `${locale}: conformant findings have no visible evidence`); + assert(!html.includes('id="evidence-system.time_synchronisation"'), + `${locale}: conformant raw evidence still bloats the appendix`); + const structuredPassing=buildAuditDocument({...input,findings:[connected]}); + assert.equal((structuredPassing.match(/store-fixture/g)||[]).length,1, + `${locale}: structured conformant evidence was printed twice`); + const dangerous={...coverage,affected:[{vmid:109,name:'',classification:'observation'}]}; + assert(!buildAuditDocument({...input,findings:[dangerous]}).includes('2')); +console.log('Audit presentation: eight languages, truthful counts, calendar age, Lynis grouping, no advice, escaping and incomplete results passed.'); +module.exports={load,translate,base,coverage,age,lynis,buildAuditDocument}; diff --git a/tests/test_audit_presentation.py b/tests/test_audit_presentation.py new file mode 100644 index 00000000..338f18f8 --- /dev/null +++ b/tests/test_audit_presentation.py @@ -0,0 +1,75 @@ +"""Pure regression tests: no imports that probe the host, no production writes.""" +import ast +import re +import unittest +from types import SimpleNamespace +from pathlib import Path + +SCRIPTS = Path(__file__).resolve().parents[1] / "AppImage/scripts" + + +def functions(file, names): + tree = ast.parse((SCRIPTS / file).read_text()) + wanted = [node for node in tree.body if isinstance(node, ast.FunctionDef) and node.name in names] + namespace = {"re": re, "_WEEKDAYS": {day: i for i, day in enumerate(["mon", "tue", "wed", "thu", "fri", "sat", "sun"])}, + "_SHORTHAND": {"daily": 86400, "weekly": 604800}} + exec(compile(ast.Module(body=wanted, type_ignores=[]), file, "exec"), namespace) + return namespace + + +class AuditPresentationTests(unittest.TestCase): + def test_enterprise_configuration_is_not_conformance(self): + tree = ast.parse((SCRIPTS / "audit_checks_pve.py").read_text()) + node = next(n for n in tree.body if isinstance(n, ast.FunctionDef) and n.name == "_enterprise_repo") + node.decorator_list = [] + ns = {"re": re, **{f"CLASS_{s.upper()}": s for s in ("observation", "conformant", "warning", "unverified")}} + exec(compile(ast.Module(body=[node], type_ignores=[]), "enterprise", "exec"), ns) + check = ns["_enterprise_repo"] + for source in ({}, {"pve.list": "# deb https://enterprise.proxmox.com/debian/pve stable pve-enterprise"}, + {"pve.sources": "URIs: https://enterprise.proxmox.com/debian/pve\nEnabled: no\n"}): + ctx = SimpleNamespace(apt_sources=source, run=lambda *_: self.fail("Disabled repository must not query subscription")) + self.assertEqual(check(ctx)["classification"], "observation") + for source in ({"pve.list": "deb https://enterprise.proxmox.com/debian/pve stable pve-enterprise"}, + {"pve.sources": "URIs: https://enterprise.proxmox.com/debian/pve\nEnabled: yes\n"}): + for rc, status, expected in [(0,"active","observation"), (0,"new","observation"), + (0,"notfound","warning"), (0,"invalid","warning"), + (0,"expired","warning"), (0,"suspended","warning"), + (1,"active","unverified"), (0,"","unverified"), + (0,"unexpected","unverified")]: + with self.subTest(source=source, rc=rc, status=status): + ctx = SimpleNamespace(apt_sources=source, run=lambda *_: (rc, f"status: {status}")) + self.assertEqual(check(ctx)["classification"], expected) + + def test_lynis_current_message_and_details_are_distinct(self): + parse = functions("security_manager.py", {"_parse_lynis_warning"})["_parse_lynis_warning"] + row = parse("NETW-3015|Found promiscuous interface|tap106i0|text:upstream text|") + self.assertEqual(row["description"], "Found promiscuous interface") + self.assertEqual(row["details"], "tap106i0") + self.assertEqual(row["severity"], "") + self.assertEqual(parse("PKGS-7392|Actual warning|-|-|")["description"], "Actual warning") + self.assertEqual(parse("PKGS-7392|Actual warning|-|-|")["details"], "") + self.assertEqual(parse("OLD-0001|H|Legacy warning|legacy solution")["description"], "Legacy warning") + self.assertIsNone(parse("broken")) + + def test_audit_does_not_surface_upstream_solutions(self): + entry = functions("audit_checks_pve.py", {"_lynis_entry"})["_lynis_entry"] + row = entry({"test_id": "NETW-3015", "description": "Found promiscuous interface", "details": "tap1", "solution": "DO SOMETHING"}) + self.assertNotIn("solution", row) + self.assertEqual(row["details"], "tap1") + + def test_longest_gap_respects_each_scheduled_instant(self): + names = {"_weekday_set", "_longest_gap", "_schedule_interval", "_schedule_age_limit"} + ns = functions("audit_checks_pve.py", names) + interval = ns["_schedule_interval"] + for schedule, hours in [("sun 07:00", 168), ("sun 01:00,13:00", 156), + ("01:00,02:00", 23), ("01:00,01:00", 24), + ("mon..fri 07:00", 72), ("mon,wed 01:00", 120)]: + with self.subTest(schedule=schedule): + self.assertEqual(interval(schedule), hours * 3600) + self.assertIsNone(interval("01:00:99")) + self.assertIsNone(interval("mon..fri */2:00")) + self.assertEqual(ns["_schedule_age_limit"]("sun 07:00"), 252 * 3600) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_audit_report.py b/tests/test_audit_report.py new file mode 100644 index 00000000..5c12e438 --- /dev/null +++ b/tests/test_audit_report.py @@ -0,0 +1,585 @@ +"""Audit regression fixtures. No host probes, daemon or production database.""" +import json +import copy +import sqlite3 +import sys +import tempfile +import time +import unittest +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "AppImage/scripts")) +import audit_checks as engine +import audit_checks_pve as checks +import audit_store as store +import audit_policy + + +def evaluate(fn, ctx): + result = fn(ctx) + if result is not None: + check = next(c for c in engine.registered_checks() if c.evaluate == fn) + result = {**result, "classification": engine._classification_of(result, check)} + return result + + +HEADER = "Volid Format Type Size VMID\n" + + +class Context: + node = "fixture" + lxc_configs = {101: "hostname: one\nunprivileged: 1\n", 102: "hostname: two\nunprivileged: 1\n"} + qemu_configs = {} + cluster_configs = {} + vzdump_jobs = "vzdump: daily\n all 1\n schedule daily\n storage backups\n" + pve_user_cfg = "" + storages = [{"id": "backups", "type": "dir", "content": "backup"}] + apt_sources = {} + monitor_snapshot = {} + storage_snapshot = {"rows": [], "source": "fixture", "collected_at": 123, "units": "bytes"} + + def __init__(self, **values): + for key, value in type(self).__dict__.items(): + if not key.startswith("_") and not callable(value): + setattr(self, key, copy.deepcopy(value)) + self.policy = audit_policy.Policy() + self.responses = {} + self.files = {} + self.__dict__.update(values) + + def run(self, argv, **kwargs): + if tuple(argv) in self.responses: + return self.responses[tuple(argv)] + if argv[:2] == ["pvesm", "list"]: + return (0, HEADER) + if argv == ["pvesh", "get", "/cluster/resources", "--type", "vm", "--output-format", "json"]: + return (0, "[]") + raise AssertionError(f"Unmocked probe: {argv}") + + def read(self, path, **kwargs): + return self.files.get(str(path), "") + + +class CheckTests(unittest.TestCase): + def backup(self, vmid=101, age=0): + stamp = time.strftime("%Y_%m_%d-%H_%M_%S", time.localtime(time.time() - age)) + return f"backups:backup/vzdump-lxc-{vmid}-{stamp}.tar.zst zst backup 1024 {vmid}\n" + + def test_missing_second_backup_is_not_pass(self): + ctx = Context(responses={("pvesm", "list", "backups"): (0, HEADER + self.backup())}) + result = evaluate(checks._last_backup_age, ctx) + self.assertEqual(result["classification"], "warning") + self.assertEqual(result["affected"][0]["vmid"], 102) + + def test_no_backups_is_not_not_applicable(self): + self.assertEqual(evaluate(checks._last_backup_age, Context())["classification"], "warning") + + def test_no_storage_is_missing_backup(self): + self.assertEqual(evaluate(checks._last_backup_age, Context(storages=[]))["classification"], "warning") + + def test_unreadable_destination_is_unknown_not_missing(self): + ctx = Context(responses={("pvesm", "list", "backups"): (1, "offline")}) + result = evaluate(checks._last_backup_age, ctx) + self.assertEqual(result["classification"], "unverified") + self.assertEqual(result.get("affected", []), []) + + def test_bad_inventory_is_unknown(self): + ctx = Context(responses={("pvesm", "list", "backups"): (0, "unexpected")}) + self.assertEqual(evaluate(checks._last_backup_age, ctx)["classification"], "unverified") + + def test_mixed_storage_host_archives_and_isos_not_guest_copies(self): + ctx = Context(lxc_configs={101: ""}, responses={ + ("pvesm", "list", "backups"): (0, HEADER + self.backup() + + "backups:iso/install.iso iso iso 100\n" + + "backups:backup/hostcfg-daily-20260905_000000.tar.zst tar.zst backup 200\n")}) + result = evaluate(checks._last_backup_age, ctx) + self.assertEqual(result["classification"], "conformant") + self.assertFalse(result["incomplete"]) + self.assertIn("not counted", result["evidence"]) + + def test_backup_without_vmid_column_can_use_vzdump_identity(self): + ctx = Context(lxc_configs={101: ""}, responses={ + ("pvesm", "list", "backups"): (0, HEADER + self.backup().rsplit(" ", 1)[0] + "\n")}) + self.assertEqual(evaluate(checks._last_backup_age, ctx)["classification"], "conformant") + + def test_daily_job_older_than_two_days_is_stale(self): + ctx = Context(lxc_configs={101: ""}, responses={ + ("pvesm", "list", "backups"): (0, HEADER + self.backup(age=2 * 86400))}) + self.assertEqual(evaluate(checks._last_backup_age, ctx)["classification"], "warning") + + def test_unsupported_schedule_is_explicit(self): + self.assertIsNone(checks._schedule_age_limit("mon..fri */2:00")) + self.assertIsNone(checks._schedule_age_limit("99:99")) + + def test_job_on_other_node_does_not_cover_local_guest(self): + ctx = Context(vzdump_jobs="vzdump: remote\n all 1\n node other\n") + self.assertEqual(evaluate(checks._guest_coverage, ctx)["classification"], "observation") + + def test_disabled_destination_is_not_probed(self): + ctx = Context(storages=[{"id": "disabled", "type": "dir", "content": "backup", "disable": "1"}]) + self.assertIsNone(evaluate(checks._destination_reachable, ctx)) + + def test_all_storage_types_and_dependencies(self): + storages = [{"id": t, "type": t, "content": "images"} for t in + ("dir", "zfspool", "lvmthin", "nfs", "cifs", "iscsi", "pbs")] + ctx = Context(storages=storages, lxc_configs={101: "rootfs: nfs:101/disk.raw\n"}, + qemu_configs={200: "scsi0: iscsi:volume\n[old]\nscsi1: cifs:old\n"}, + storage_snapshot={"rows": [{"name": s["id"], "node": "fixture", + "status": "available", "total": 100, "used": 20} for s in storages]}) + ctx.run = lambda argv, **kw: (0, "[]") if argv == ["pvesh", "get", "/cluster/resources", "--type", "vm", "--output-format", "json"] else self.fail("storage check must not probe remote storage") + result = evaluate(checks._destination_reachable, ctx) + self.assertEqual(result["classification"], "conformant") + self.assertEqual(result["summary_params"]["total"], 7) + rows = {r["storage"]: r for r in result["observations"]} + self.assertEqual(rows["nfs"]["dependencies"][0]["vmid"], 101) + self.assertEqual(rows["iscsi"]["dependencies"][0]["vmid"], 200) + self.assertEqual(rows["cifs"]["dependencies"], []) + + def test_storage_missing_metadata_is_unknown(self): + self.assertEqual(evaluate(checks._destination_reachable, Context())["classification"], "unverified") + + def test_cached_missing_resource_is_not_confirmed_outage(self): + ctx = Context(storage_snapshot={"rows": [{"name": "backups", "node": "fixture", + "status": "error", "status_detail": "not_found"}]}) + self.assertEqual(evaluate(checks._destination_reachable, ctx)["classification"], "unverified") + + def test_pbs_unknown_capacity_not_full_or_failed(self): + ctx = Context(storage_snapshot={"rows": [{"name": "backups", "node": "fixture", + "status": "namespace_restricted", "total": 0, "used": 0}]}) + result = evaluate(checks._destination_reachable, ctx) + self.assertEqual(result["classification"], "conformant") + self.assertFalse(result["observations"][0]["capacity_known"]) + + def test_unavailable_and_full_network_storage(self): + ctx = Context(storages=[{"id": "nas", "type": "nfs", "content": "images"}], + storage_snapshot={"rows": [{"name": "nas", "node": "fixture", + "status": "available", "total": 100, "used": 95}]}) + self.assertEqual(evaluate(checks._destination_reachable, ctx)["classification"], "warning") + ctx.storage_snapshot["rows"][0]["status"] = "unavailable" + self.assertEqual(evaluate(checks._destination_reachable, ctx)["classification"], "warning") + + def test_storage_credentials_not_in_evidence(self): + ctx = Context(storages=[{"id": "nas", "type": "cifs", "password": "SECRET", + "username": "PRIVATE", "content": "images"}]) + result = evaluate(checks._destination_reachable, ctx) + self.assertNotIn("SECRET", result["evidence"]) + self.assertNotIn("PRIVATE", result["evidence"]) + + def test_other_node_storage_never_assessed(self): + ctx = Context(storages=[{"id": "remote", "type": "pbs", "nodes": "other"}]) + self.assertIsNone(evaluate(checks._destination_reachable, ctx)) + + def two_destinations(self): + return Context(lxc_configs={101: ""}, storages=[ + {"id": name, "type": "pbs", "content": "backup"} for name in ("backups", "second")], + vzdump_jobs="vzdump: first\n all 1\n storage backups\n schedule daily\nvzdump: second\n all 1\n storage second\n schedule weekly\n", + responses={("pvesm", "list", "backups"): (0, HEADER + self.backup())}) + + def test_recent_pbs_cannot_mask_missing_second_destination(self): + result = evaluate(checks._last_backup_age, self.two_destinations()) + self.assertEqual(result["classification"], "warning") + self.assertEqual(result["affected"][0]["storage"], "second") + self.assertEqual(result["summary_params"]["total"], 2) + + def test_second_pbs_failure_does_not_claim_missing_copy(self): + ctx = self.two_destinations() + ctx.responses[("pvesm", "list", "second")] = (1, "offline") + result = evaluate(checks._last_backup_age, ctx) + self.assertEqual(result["classification"], "unverified") + self.assertEqual(result.get("affected", []), []) + + def test_each_destination_has_own_schedule(self): + ctx = self.two_destinations() + ctx.responses[("pvesm", "list", "second")] = (0, HEADER + self.backup(age=3 * 86400)) + self.assertEqual(evaluate(checks._last_backup_age, ctx)["classification"], "conformant") + ctx.responses[("pvesm", "list", "second")] = (0, HEADER + self.backup(age=11 * 86400)) + self.assertEqual(evaluate(checks._last_backup_age, ctx)["affected"][0]["storage"], "second") + + def test_missing_target_configuration_is_reported(self): + ctx = self.two_destinations() + ctx.storages = ctx.storages[:1] + self.assertEqual(evaluate(checks._last_backup_age, ctx)["affected"][0]["storage"], "second") + + def test_failure_elsewhere_does_not_hide_missing_expected_copy(self): + ctx = self.two_destinations() + ctx.responses[("pvesm", "list", "backups")] = (1, "offline") + result = evaluate(checks._last_backup_age, ctx) + self.assertEqual(result["classification"], "warning") + self.assertTrue(result["incomplete"]) + self.assertEqual(result["affected"][0]["storage"], "second") + + def test_templates_are_not_missing_backups(self): + ctx = Context(lxc_configs={101: "template: 1\n"}) + self.assertIsNone(evaluate(checks._guest_coverage, ctx)) + self.assertIsNone(evaluate(checks._last_backup_age, ctx)) + + def test_bind_mount_and_backup_zero_are_reported(self): + ctx = Context(lxc_configs={101: "rootfs: local:vm-101-disk-0\nmp0: /data,mp=/data,backup=1\nmp1: local:vm-101-disk-1,mp=/x,backup=0\n"}) + result = evaluate(checks._guest_coverage, ctx) + self.assertEqual(result["summary_key"], "excludedData") + self.assertEqual(len(result["affected"]), 2) + + def test_old_snapshot_cannot_override_current_privileged_state(self): + ctx = Context(lxc_configs={101: "unprivileged: 0\n[old]\nunprivileged: 1\n"}) + self.assertEqual(evaluate(checks._privileged_containers, ctx)["classification"], "observation") + + def test_agent_options_order(self): + ctx = Context(qemu_configs={101: "agent: fstrim_cloned_disks=1,enabled=1\n"}) + self.assertEqual(evaluate(checks._qemu_without_agent, ctx)["classification"], "conformant") + + def test_disabled_deb822_is_not_enterprise_enabled(self): + ctx = Context(apt_sources={"pve.sources": "Types: deb\nURIs: https://enterprise.proxmox.com/debian/pve\nEnabled: no\n"}) + self.assertEqual(evaluate(checks._enterprise_repo, ctx)["classification"], "observation") + + def test_orphan_inventory_failure_cannot_pass(self): + ctx = Context(storages=[{"id": "local", "type": "dir", "content": "images"}], + responses={("pvesm", "list", "local"): (1, "offline")}) + self.assertEqual(evaluate(checks._orphaned_volumes, ctx)["classification"], "unverified") + + def test_unreferenced_volume_with_existing_vmid_is_candidate(self): + ctx = Context(storages=[{"id": "local", "type": "dir", "content": "images"}], + responses={("pvesm", "list", "local"): (0, HEADER + "local:101/vm-101-disk-9.raw raw images 1 101\n")}) + self.assertEqual(evaluate(checks._orphaned_volumes, ctx)["classification"], "observation") + + def test_unused_snapshot_and_template_base_are_protected(self): + ctx = Context(lxc_configs={}, qemu_configs={101: "unused0: local:vm-101-disk-0\n[old]\nscsi0: local:vm-101-disk-1\n"}, + storages=[{"id": "local", "type": "lvmthin", "content": "images"}], responses={ + ("pvesm", "list", "local"): (0, HEADER + + "local:vm-101-disk-0 raw images 1 101\nlocal:vm-101-disk-1 raw images 1 101\nlocal:base-999-disk-0 raw images 1 999\n")}) + self.assertEqual(evaluate(checks._orphaned_volumes, ctx)["classification"], "conformant") + + def test_orphan_on_guestless_host(self): + ctx = Context(lxc_configs={}, storages=[{"id": "local", "type": "dir", "content": "images"}], + responses={("pvesm", "list", "local"): (0, HEADER + "local:101/vm-101-disk-0.raw raw images 1 101\n")}) + self.assertEqual(evaluate(checks._orphaned_volumes, ctx)["classification"], "observation") + + def scrub_context(self, scan): + ctx = Context(responses={("zpool", "list", "-H", "-o", "name"): (0, "tank\n"), + ("zpool", "status", "tank"): (0, " scan: " + scan)}) + return ctx + + @patch.object(Path, "exists", return_value=True) + def test_resilver_is_not_scrub(self, _): + ctx = self.scrub_context("resilvered 1G in 1h on " + time.ctime()) + self.assertEqual(evaluate(checks._zfs_scrub_age, ctx)["classification"], "unverified") + + @patch.object(Path, "exists", return_value=True) + def test_one_never_scrubbed_pool_is_not_hidden(self, _): + ctx = self.scrub_context("scrub repaired 0B in 1h with 0 errors on " + time.ctime()) + ctx.responses[("zpool", "list", "-H", "-o", "name")] = (0, "tank\nother\n") + ctx.responses[("zpool", "status", "other")] = (0, "scan: none requested") + self.assertEqual(evaluate(checks._zfs_scrub_age, ctx)["classification"], "warning") + + def test_storage_inherited_retention(self): + ctx = Context(storages=[{"id": "backups", "type": "dir", "content": "backup", "prune-backups": "keep-last=7"}]) + self.assertEqual(evaluate(checks._retention_defined, ctx)["classification"], "conformant") + + def test_pbs_remote_retention_is_not_declared_absent(self): + ctx = Context(storages=[{"id": "backups", "type": "pbs", "content": "backup"}]) + self.assertEqual(evaluate(checks._retention_defined, ctx)["classification"], "observation") + + def test_firewall_default_host_enable(self): + ctx = Context(files={"/etc/pve/firewall/cluster.fw": "[OPTIONS]\nenable: 1\n"}) + self.assertEqual(evaluate(checks._host_firewall, ctx)["classification"], "conformant") + + def test_firewall_other_section_not_an_enable_option(self): + ctx = Context(files={"/etc/pve/firewall/cluster.fw": "[RULES]\nenable: 1\n"}) + self.assertEqual(evaluate(checks._host_firewall, ctx)["classification"], "observation") + + def test_no_smart_cache_does_not_start_smartctl(self): + ctx = Context() + ctx.run = lambda *a, **kw: self.fail("must not run any disk command") + self.assertEqual(evaluate(checks._disk_service_life, ctx)["classification"], "unverified") + + def test_ha_managed_guest_does_not_need_onboot(self): + ctx = Context(lxc_configs={101: "onboot: 0\n"}, files={"/etc/pve/ha/resources.cfg": "ct: 101\n state started\n"}) + self.assertEqual(evaluate(checks._autostart, ctx)["classification"], "conformant") + + def test_legacy_jobs_parse_without_execution(self): + jobs = checks._parse_vzdump_jobs("0 2 * * * root /usr/sbin/vzdump 101 102 --storage backups --all 0\n") + self.assertEqual(jobs[0]["vmid"], "101 102") + self.assertEqual(jobs[0]["storage"], "backups") + + def test_another_job_type_ends_vzdump_section(self): + jobs = checks._parse_vzdump_jobs("vzdump: a\n all 1\nother: b\n enabled 0\n") + self.assertNotIn("enabled", jobs[0]) + + +class StoreTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.db_patch = patch.object(store, "DB_PATH", Path(self.temp.name) / "audit.db") + self.db_patch.start() + store._schema_ready = False + self.addCleanup(self.temp.cleanup) + self.addCleanup(self.db_patch.stop) + self.addCleanup(lambda: setattr(store, "_schema_ready", False)) + + def finding(self, objects=None, state="warn"): + f = {"check_id": "guests.test", "area": "guests", "severity": "WARNING", "state": state, + "raw_state": state, "classification": store.classification_of(state, "WARNING"), + "raw_classification": store.classification_of(state, "WARNING"), "affected": objects or [{"vmid": 101}], "check_version": 2, "host": "fixture"} + f["scope"] = store.finding_scope(f) + return f + + def recorded(self, f): + run = store.start_run("full") + store.record_findings(run, [f]) + store.finish_run(run, checks_total=1) + return run + + def test_accept_and_revoke_immediate_without_mutating_history(self): + f = self.finding() + run = self.recorded(f) + store.accept_risk(f["check_id"], "lab", "operator", scope=f["scope"]) + self.assertEqual(store.effective_findings(run)[0]["state"], "accepted") + self.assertEqual(store.get_findings(run)[0]["state"], "warn") + store.revoke_risk(f["check_id"], "operator") + self.assertEqual(store.effective_findings(run)[0]["state"], "warn") + self.assertEqual(len(store.exception_history()), 2) + + def test_acceptance_does_not_extend_to_new_guest(self): + f = self.finding() + self.recorded(f) + store.accept_risk(f["check_id"], "lab", "operator", scope=f["scope"]) + newer = self.recorded(self.finding([{"vmid": 101}, {"vmid": 102}])) + self.assertEqual(store.effective_findings(newer)[0]["state"], "warn") + + def test_expiry_is_effective_without_new_scan(self): + f = self.finding() + run = self.recorded(f) + expiry = int(time.time()) + 60 + store.accept_risk(f["check_id"], "lab", "operator", expiry, scope=f["scope"]) + with patch.object(store.time, "time", return_value=expiry + 1): + self.assertEqual(store.effective_findings(run)[0]["state"], "warn") + + def test_accepted_snapshot_survives_revocation(self): + f = self.finding() + store.accept_risk(f["check_id"], "lab", "operator", scope=f["scope"]) + f.update(decision=store.DECISION_ACCEPTED, exception=store.active_exceptions()[f["check_id"]]) + run = self.recorded(f) + store.revoke_risk(f["check_id"]) + self.assertEqual(store.get_findings(run)[0]["exception"]["reason"], "lab") + self.assertEqual(store.effective_findings(run)[0]["state"], "warn") + + def test_scope_ignores_age_but_not_rule_or_severity(self): + a = self.finding([{"vmid": 101, "days": 40}]) + b = self.finding([{"vmid": 101, "days": 41}]) + self.assertEqual(a["scope"], b["scope"]) + b["check_version"] = 3 + self.assertNotEqual(a["scope"], store.finding_scope(b)) + + def test_new_failure_visible_instead_of_old_complete_run(self): + self.recorded(self.finding()) + run = store.start_run("full") + store.finish_run(run, checks_total=0, error="interrupted") + self.assertEqual(store.latest_run()["run_id"], run) + + def test_no_false_resolved_when_collection_failed(self): + before = self.recorded(self.finding()) + after = self.recorded(self.finding(state="unknown")) + result = engine.compare_runs(before, after) + self.assertEqual(result["resolved"], []) + self.assertEqual(len(result["unverified"]), 1) + + def test_acceptance_is_not_resolution_and_class_changes_are_visible(self): + before = self.recorded(self.finding()) + accepted = self.finding() + accepted['decision'] = store.DECISION_ACCEPTED + after = self.recorded(accepted) + result = engine.compare_runs(before, after) + self.assertEqual(len(result['accepted']), 1) + self.assertFalse(result['resolved']) + critical = self.finding() + critical.update(classification='critical', raw_classification='critical') + changed = self.recorded(critical) + self.assertEqual(len(engine.compare_runs(before, changed)['new']), 1) + self.assertEqual(len(engine.compare_runs(changed, before)['new']), 1) + + def test_malformed_result_does_not_abort_remaining_checks(self): + for bad in (False, [], {'affected': [None]}, {'affected': 'invalid'}): + with self.subTest(result=bad), patch.object(engine.AuditContext, 'metadata', return_value={}), patch.object( + engine, 'registered_checks', return_value=[ + engine.Check('guests.bad', 'guests', 'WARNING', lambda ctx: bad), + engine.Check('guests.good', 'guests', 'WARNING', lambda ctx: {'classification':'conformant'})]): + run = engine.run_assessment() + results = {f['check_id']: f['classification'] for f in store.get_findings(run)} + self.assertEqual(results, {'guests.bad':'unverified', 'guests.good':'conformant'}) + self.assertEqual(store.get_run(run)['status'], 'partial') + + def test_interrupted_run_marked_failed(self): + run = store.start_run("full") + store.recover_interrupted_runs() + self.assertEqual(store.get_run(run)["status"], "failed") + + def test_secrets_are_redacted_before_persistence(self): + f = self.finding() + f["evidence"] = "https://alice:secret@example.com/x?token=abc\nAuthorization: Bearer xyz" + run = self.recorded(f) + evidence = store.get_findings(run)[0]["evidence"] + self.assertNotIn("alice", evidence) + self.assertNotIn("abc", evidence) + self.assertNotIn("xyz", evidence) + + def test_raising_check_is_unknown_and_run_partial(self): + def bad(ctx): + raise OSError("fixture source offline") + with patch.object(engine.AuditContext, "metadata", return_value={}), patch.object(engine, "registered_checks", return_value=[ + engine.Check("guests.test", "guests", "WARNING", bad)]): + run = engine.run_assessment() + self.assertEqual(store.get_findings(run)[0]["state"], "unknown") + self.assertEqual(store.get_run(run)["status"], "partial") + + def test_failed_command_cannot_become_pass(self): + def bad(ctx): + ctx.run(["fixture-command"]) + return {"state": "pass"} + with patch.object(engine.AuditContext, "metadata", return_value={}), patch.object(engine.subprocess, "run", return_value=SimpleNamespace( + returncode=1, stdout="", stderr="failed")), patch.object(engine, "registered_checks", return_value=[ + engine.Check("guests.test", "guests", "WARNING", bad)]): + run = engine.run_assessment() + self.assertEqual(store.get_findings(run)[0]["state"], "unknown") + + def test_v1_migration_preserves_legacy_decisions_without_reusing_scope(self): + connection = sqlite3.connect(store.DB_PATH) + connection.executescript(""" + CREATE TABLE audit_runs (run_id TEXT PRIMARY KEY, profile TEXT, started_at INTEGER, + finished_at INTEGER, status TEXT, error TEXT, is_baseline INTEGER DEFAULT 0, + checks_total INTEGER DEFAULT 0, schema_version INTEGER DEFAULT 1); + CREATE TABLE audit_findings (id INTEGER PRIMARY KEY, run_id TEXT, check_id TEXT, + area TEXT, severity TEXT, state TEXT, summary_key TEXT, summary_params TEXT, + affected TEXT, evidence TEXT, remediable_by TEXT); + CREATE TABLE audit_exceptions (check_id TEXT PRIMARY KEY, reason TEXT, + accepted_by TEXT, accepted_at INTEGER, expires_at INTEGER); + INSERT INTO audit_runs (run_id, profile, started_at, status) VALUES ('old', 'full', 1, 'complete'); + INSERT INTO audit_findings (run_id, check_id, area, severity, state) + VALUES ('old', 'guests.test', 'guests', 'WARNING', 'accepted'); + INSERT INTO audit_exceptions VALUES ('guests.test', 'original reason', 'original author', 1, NULL); + """) + connection.commit() + connection.close() + store.init_db() + self.assertEqual(store.get_findings("old")[0]["state"], "accepted") + self.assertEqual(store.effective_findings("old")[0]["state"], "unknown") + event = store.exception_history()[0] + self.assertEqual(json.loads(event["decision"])["reason"], "original reason") + self.assertEqual(event["action"], "legacy-unscoped") + + def test_baseline_and_running_run_survive_retention(self): + baseline = self.recorded(self.finding()) + store.set_baseline(baseline) + running = store.start_run("full") + store.prune_runs(keep=0) + self.assertIsNotNone(store.get_run(baseline)) + self.assertIsNotNone(store.get_run(running)) + + def test_incomplete_warning_cannot_be_accepted(self): + f = self.finding() + f["incomplete"] = True + run = self.recorded(f) + store.accept_risk(f["check_id"], "lab", "operator", scope=f["scope"]) + self.assertEqual(store.effective_findings(run)[0]["state"], "warn") + + +class EngineTests(unittest.TestCase): + def test_incomplete_and_invalid_classification_never_become_conformant(self): + check = engine.Check('guests.test', 'guests', 'WARNING', lambda ctx: None) + for result, expected in [ + ({'classification':'conformant', 'incomplete':True}, 'unverified'), + ({'classification':'observation', 'affected':[{'classification':'unverified'}]}, 'unverified'), + ({'classification':'invented'}, 'unverified'), + ({'incomplete':True, 'affected':[{'classification':'warning'}]}, 'warning'), + ({'classification':'unverified', 'affected':[{'classification':'critical'}]}, 'critical')]: + self.assertEqual(engine._classification_of(result, check), expected) + + def test_cached_derived_source_keeps_transitive_failures(self): + ctx = engine.AuditContext() + with patch.object(engine.subprocess, 'run', return_value=SimpleNamespace(returncode=1, stdout='', stderr='offline')) as probe: + def collect(): + ctx.run(['fixture']) + return {} + ctx.begin_check() + ctx._once('derived', lambda: ctx._once('inner', collect)) + ctx.begin_check() + ctx._once('derived', lambda: self.fail('source must be reused')) + self.assertIn('inner', ctx._sources_used) + self.assertTrue(ctx._sources_used & ctx._errors.keys()) + self.assertEqual(probe.call_count, 1) + + def test_storage_snapshot_reuses_cache_without_probe(self): + server = SimpleNamespace(_proxmox_storage_cache={"time": time.time(), + "data": {"storage": [{"name": "nas"}]}}) + with patch.dict(sys.modules, {"flask_server": server}), patch.object(engine.subprocess, "run") as probe: + ctx = engine.AuditContext() + self.assertEqual(ctx.storage_snapshot["source"], "Monitor storage cache") + ctx.storage_snapshot["rows"][0]["name"] = "modified copy" + self.assertEqual(server._proxmox_storage_cache["data"]["storage"][0]["name"], "nas") + probe.assert_not_called() + + def test_expired_storage_snapshot_reads_metadata_once(self): + server = SimpleNamespace(_proxmox_storage_cache={"time": 1, "data": {"storage": []}}) + ctx = engine.AuditContext() + rows = [{"node": ctx.node, "storage": "nas", "status": "available"}, + {"node": "another", "storage": "hidden", "status": "available"}] + with patch.dict(sys.modules, {"flask_server": server}), patch.object(engine.subprocess, "run", + return_value=SimpleNamespace(returncode=0, stdout=json.dumps(rows), stderr="")) as probe: + self.assertEqual(len(ctx.storage_snapshot["rows"]), 1) + self.assertEqual(ctx.storage_snapshot["rows"][0]["name"], "nas") + self.assertEqual(probe.call_count, 1) + self.assertIn("/cluster/resources", probe.call_args[0][0]) + + def test_failed_storage_metadata_records_unknown_source(self): + with patch.dict(sys.modules, {"flask_server": SimpleNamespace()}), patch.object( + engine.subprocess, "run", return_value=SimpleNamespace(returncode=1, stdout="", stderr="offline")): + ctx = engine.AuditContext() + self.assertEqual(ctx.storage_snapshot, {}) + self.assertIn("storage_snapshot", ctx._errors) + + def test_shared_failed_source_remains_unknown_for_each_consumer(self): + ctx = engine.AuditContext() + with patch.object(engine.subprocess, "run", return_value=SimpleNamespace( + returncode=1, stdout="", stderr="offline")) as command: + ctx.begin_check() + ctx.run(["fixture"]) + ctx.begin_check() + ctx.run(["fixture"]) + self.assertTrue(ctx._sources_used & ctx._errors.keys()) + self.assertEqual(command.call_count, 1) + + def test_command_deadline_prevents_next_probe(self): + ctx = engine.AuditContext() + ctx._check_deadline = time.monotonic() - 1 + with patch.object(engine.subprocess, "run") as command: + rc, _ = ctx.run(["fixture-probe"]) + command.assert_not_called() + self.assertEqual(rc, -1) + + def test_invalid_scope_rejected_before_collection(self): + with self.assertRaises(ValueError): + engine.run_assessment(only_areas={"invented"}) + + def test_catalog_has_43_distinct_checks(self): + registered = engine.registered_checks() + self.assertEqual(len(registered), 43) + self.assertEqual(len({c.check_id for c in registered}), 43) + for c in registered: + self.assertTrue(c.check_id.startswith(c.area + ".")) + + def test_locales_have_new_states_and_matching_placeholders(self): + root = Path(__file__).resolve().parents[1] / "AppImage/messages" + for locale in ("en", "es", "de", "fr", "it", "pt", "sk", "sv"): + audit = json.loads((root / locale / "common.json").read_text())["audit"] + self.assertEqual({area+'.'+name for area, names in audit['checks'].items() for name in names}, + {check.check_id for check in engine.registered_checks()}) + self.assertIn("unknown", audit["states"]) + self.assertIn("{completed}", audit["progress"]) + self.assertEqual(set(audit["areas"]), set(engine.AREAS) | {"all"}) + connected = audit["checks"]["storage"]["connected_storage"] + self.assertIn("{total}", connected["summary"]["available"]) + self.assertIn("{count}", connected["summary"]["attention"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_audit_summary.cjs b/tests/test_audit_summary.cjs new file mode 100644 index 00000000..91564d22 --- /dev/null +++ b/tests/test_audit_summary.cjs @@ -0,0 +1,81 @@ +// Render the real summary JSX with fixture state, without API calls or effects. +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 React = appRequire('react'); +const { renderToStaticMarkup } = appRequire('react-dom/server'); +const ts = appRequire('typescript'); +const {load} = require('./test_audit_presentation.cjs'); +const source = fs.readFileSync(path.join(app, 'components/audit-report.tsx'), 'utf8'); +const compiled = ts.transpileModule(source, { compilerOptions: { + module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX, target: ts.ScriptTarget.ES2020, +}}).outputText; +function render(locale, findings, status = 'partial') { + 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 [key, value] of Object.entries(values)) text = text.replaceAll(`{${key}}`, value); + return text; + }; + const summary = findings.reduce((o, f) => ({...o, [f.classification]: (o[f.classification] || 0) + 1}), {}); + // Positional: one entry per useState in audit-report.tsx, in order. + const states = ['assessment', false, {status, finished_at: Date.now()/1000}, findings, summary, + 'all', new Set(findings.map(f=>f.check_id)), null, false, null, '', '', false, + {completed: 0, total: 36},'full',[],false]; + let index = 0; + const ui = tag => ({children, ...props}) => React.createElement(tag, props, children); + const imports = { + react: {...React, useState: () => [states[index++], () => {}], useEffect: () => {}, + useMemo: cb => cb(), useCallback: cb => cb}, + './ui/card': {Card: ui('section'), CardContent: ui('div'), CardHeader: ui('header'), CardTitle: ui('h2')}, + './ui/button': {Button: ui('button')}, './ui/badge': {Badge: ui('span')}, + './ui/dialog': {Dialog: () => null}, + '../lib/api-config': {fetchApi: () => {throw Error('unexpected API call')}}, + '../lib/i18n/provider': {useT: () => t, useI18n:()=>({language:locale})}, + './audit-inventory': {AuditInventory:()=>null}, + './audit-policy': {AuditPolicy:()=>null}, + './audit-changes': {AuditChanges:()=>null}, + './audit-comparison': {AuditComparison:()=>null}, + './ui/label': {Label: ui('label')}, + './ui/select': {Select: ui('div'), SelectContent: ui('div'), SelectItem: ui('option'), + SelectTrigger: ui('div'), SelectValue: ui('span')}, + '../lib/audit-document': {}, + './audit-evidence': load(path.join(app,'components/audit-evidence.tsx')), + './audit-finding-data': load(path.join(app,'components/audit-finding-data.tsx')), + '../lib/audit-presentation': load(path.join(app,'lib/audit-presentation.ts')), + }; + const module = {exports: {}}; + new Function('require', 'module', 'exports', compiled)(name => imports[name] || appRequire(name), module, module.exports); + return {html: renderToStaticMarkup(React.createElement(module.exports.AuditReport)), t}; +} +// renderToStaticMarkup escapes text, so a translation containing an +// apostrophe never matches its raw form. Compare against what React +// actually writes. +const esc = (text) => text.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); +const finding = (check_id, state, severity, incomplete = false) => ({ + check_id, state, severity, classification: {fail:'critical',warn:'warning',unknown:'unverified'}[state] || state, + incomplete, area: check_id.split('.')[0], affected: [], evidence:null, summary_key: null, +}); +for (const locale of ['en', 'es', 'de', 'fr', 'it', 'pt', 'sk', 'sv']) { + const fixtures = [finding('backup.guest_coverage', 'fail', 'CRITICAL'), + finding('system.pending_reboot', 'warn', 'WARNING'), + finding('hardware.disk_service_life', 'unknown', 'INFO'), + finding('security.lynis_warnings', 'unknown', 'WARNING')]; + const {html, t} = render(locale, fixtures); + assert(html.includes(`aria-label="${t('audit.results')}"`)); + assert(html.includes(esc(t('audit.unverifiedChecks', {checks: [ + t('audit.checks.hardware.disk_service_life.title'), t('audit.checks.security.lynis_warnings.title'), + ].join(' · ')})))); + assert.equal((html.match(/h-6 gap-1.5 whitespace-nowrap px-2.5 py-0 text-xs/g) || []).length, 3); + assert(html.includes('flex max-w-full flex-wrap items-center gap-2')); + assert(!render(locale, [], 'complete').html.includes('role="alert"')); + assert(!render(locale, []).html.includes(`aria-label="${t('audit.severityGroup')}"`)); + const partial = render(locale, [finding('backup.guest_coverage', 'warn', 'CRITICAL', true)]); + assert(partial.html.includes(esc(t('audit.unverifiedChecks', + {checks: t('audit.checks.backup.guest_coverage.title')})))); +} +console.log('Audit summary: eight locales, uniform counters, labelled groups and partial/complete states passed.');