mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-14 18:56:52 +00:00
Add audit and reports page, and a change journal
ProxMenux modifies the host: it rewrites configuration files, installs packages, enables services. Until now nobody could say afterwards what had changed, and showing the script does not answer that question — a four-hundred-line function may alter two values, and the reader has no way to know which two. This adds the two halves of an answer. The change journal records what ProxMenux does as it does it. Eleven bash primitives capture the previous state, apply the change and record it in the same step, writing to a spool that the Monitor reads back. One hundred and thirteen functions across twenty-five scripts are instrumented, covering post-install, shared storage, security tooling, container conversions, disk operations and the PVE 8 to 9 upgrade path. The page shows the difference — rotate 7 becoming rotate 14 — and never the script. Restore and backup scripts are deliberately left out: a restore puts the host back to a state some other script already recorded. The Audit and reports page answers the other half: what state is this host in, regardless of who put it there. Forty-three checks across seven areas read the host and classify each result as critical, warning, observation, conformant, unverified or not applicable, with the evidence they read attached to each one. A declared policy lets the reader say what this particular host is expected to do — which guests must have a backup, which storages are essential — so the report judges the host against its own intent rather than a generic template. An inventory records the hardware, network and guest topology behind those readings, a comparison shows what moved between two runs, and six report profiles produce a printable document scoped to what the reader needs. Everything is available in the eight supported languages. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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"})
|
req = Request(url, headers={"User-Agent": "ProxMenux translation cache builder"})
|
||||||
with urlopen(req, timeout=timeout) as response:
|
with urlopen(req, timeout=timeout) as response:
|
||||||
payload = json.loads(response.read().decode("utf-8"))
|
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(
|
def translate_appimage(
|
||||||
|
|||||||
@@ -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<string, unknown>
|
||||||
|
diff?: {
|
||||||
|
available: boolean; reason?: string
|
||||||
|
added?: number; removed?: number; truncated?: boolean; hunks?: string[]
|
||||||
|
} | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Summary {
|
||||||
|
total: number
|
||||||
|
by_class: Record<string, number>
|
||||||
|
functions: Array<{
|
||||||
|
function: string; source: string; version: string
|
||||||
|
changes: number; last_change: number; first_change: number
|
||||||
|
}>
|
||||||
|
journal_started: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const CLASS_STYLE: Record<string, { chip: string; Icon: typeof Settings2 }> = {
|
||||||
|
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<Change[]>([])
|
||||||
|
const [summary, setSummary] = useState<Summary | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [open, setOpen] = useState<Set<number>>(new Set())
|
||||||
|
const [filter, setFilter] = useState<string>("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 (
|
||||||
|
<div className="flex items-center justify-center py-16 text-muted-foreground">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin mr-2" />{t("audit.changes.loading")}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (error) return <p className="text-sm text-red-400 px-1">{error}</p>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card className="bg-card border-border">
|
||||||
|
<CardContent className="py-4 space-y-3">
|
||||||
|
<p className="text-sm text-muted-foreground">{t("audit.changes.intro")}</p>
|
||||||
|
{/* A host with nothing recorded should say why, rather than
|
||||||
|
looking like a host nothing has touched. */}
|
||||||
|
{summary && summary.total === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">{t("audit.changes.empty")}</p>
|
||||||
|
)}
|
||||||
|
{summary && summary.journal_started && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("audit.changes.since", { date: when(summary.journal_started) })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{(["all", "configuration", "installation", "execution", "registration"] as const)
|
||||||
|
.filter((key) => key === "all" || summary?.by_class?.[key])
|
||||||
|
.map((key) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFilter(key)}
|
||||||
|
className={`px-3 py-1 rounded-md text-sm transition-colors ${
|
||||||
|
filter === key
|
||||||
|
? "bg-blue-500 text-white"
|
||||||
|
: "text-muted-foreground hover:text-foreground hover:bg-background/60"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t(`audit.changes.class.${key}`)}
|
||||||
|
{key !== "all" && summary?.by_class?.[key] !== undefined && (
|
||||||
|
<span className="ml-1.5 tabular-nums">{summary.by_class[key]}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{summary && summary.functions.length > 0 && (
|
||||||
|
<Card className="bg-card border-border">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base font-semibold text-foreground">
|
||||||
|
<FileCode className="h-4 w-4" />{t("audit.changes.byFunction")}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0 space-y-2">
|
||||||
|
{summary.functions.map((fn) => (
|
||||||
|
<button
|
||||||
|
key={fn.function}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFilter("all")}
|
||||||
|
className="flex w-full flex-wrap items-center gap-2 rounded-md border
|
||||||
|
border-border p-2.5 text-left hover:bg-white/5
|
||||||
|
transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<span className="font-mono text-sm text-foreground">{fn.function}</span>
|
||||||
|
{fn.version && (
|
||||||
|
<Badge variant="outline" className="text-xs">v{fn.version}</Badge>
|
||||||
|
)}
|
||||||
|
<Badge variant="outline" className="text-xs tabular-nums">
|
||||||
|
{t("audit.changes.count", { count: String(fn.changes) })}
|
||||||
|
</Badge>
|
||||||
|
<span className="ml-auto text-xs text-muted-foreground">
|
||||||
|
{when(fn.last_change)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{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 (
|
||||||
|
<Card key={change.id} className="bg-card border-border">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggle(change.id)}
|
||||||
|
aria-expanded={expanded}
|
||||||
|
className="w-full text-left p-3 flex flex-wrap items-center gap-2
|
||||||
|
rounded-lg hover:bg-white/5 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
{expanded
|
||||||
|
? <ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
: <ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />}
|
||||||
|
<Badge variant="outline" className={`${style.chip} gap-1.5 shrink-0`}>
|
||||||
|
<Icon className="h-3.5 w-3.5" />
|
||||||
|
{t(`audit.changes.operation.${change.operation}`)}
|
||||||
|
</Badge>
|
||||||
|
<span className="min-w-0 font-mono text-sm text-foreground break-all">
|
||||||
|
{change.target}
|
||||||
|
</span>
|
||||||
|
{change.diff?.available && (
|
||||||
|
<Badge variant="outline" className="text-xs tabular-nums shrink-0">
|
||||||
|
+{change.diff.added} −{change.diff.removed}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{/* Whether the previous state is known is what decides
|
||||||
|
if undoing this is even discussable. */}
|
||||||
|
{change.capture === "unknown" && (
|
||||||
|
<Badge variant="outline" className="text-xs shrink-0">
|
||||||
|
{t("audit.changes.capture.unknown")}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<span className="ml-auto shrink-0 text-xs text-muted-foreground">
|
||||||
|
{when(change.recorded_at)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{expanded && (
|
||||||
|
<CardContent className="pt-0 pl-10 space-y-3">
|
||||||
|
<div className="flex flex-wrap gap-x-6 gap-y-1 text-xs text-muted-foreground">
|
||||||
|
<span>{t("audit.changes.function")}:{" "}
|
||||||
|
<span className="font-mono text-foreground">{change.function || "—"}</span>
|
||||||
|
{change.function_version && ` v${change.function_version}`}
|
||||||
|
</span>
|
||||||
|
<span>{t("audit.changes.source")}:{" "}
|
||||||
|
<span className="font-mono">{change.source || "—"}</span></span>
|
||||||
|
<span>{t("audit.changes.reversibility")}:{" "}
|
||||||
|
{t(`audit.changes.exactness.${change.exactness}`)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{installed && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-muted-foreground mb-1">
|
||||||
|
{t("audit.changes.packagesAdded")}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{installed.split(/\s+/).filter(Boolean).map((pkg) => (
|
||||||
|
<Badge key={pkg} variant="outline" className="font-mono text-xs">
|
||||||
|
{pkg}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{change.class === "execution" && Boolean(change.detail?.command) && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-muted-foreground mb-1">
|
||||||
|
{t("audit.changes.commandRun")}
|
||||||
|
</p>
|
||||||
|
<pre className="text-xs font-mono bg-background border border-border
|
||||||
|
rounded-md p-2.5 overflow-x-auto">
|
||||||
|
{String(change.detail.command)}
|
||||||
|
</pre>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
{t("audit.changes.executionNote")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{change.diff && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-muted-foreground mb-1">
|
||||||
|
{t("audit.changes.difference")}
|
||||||
|
</p>
|
||||||
|
{change.diff.available ? (
|
||||||
|
<>
|
||||||
|
<pre className="text-xs font-mono bg-background border border-border
|
||||||
|
rounded-md p-2.5 overflow-x-auto">
|
||||||
|
{(change.diff.hunks || []).map((line: string, i: number) => (
|
||||||
|
<div key={i} className={
|
||||||
|
line.startsWith("+") ? "text-green-500"
|
||||||
|
: line.startsWith("-") ? "text-red-400"
|
||||||
|
: line.startsWith("@@") ? "text-blue-400" : ""
|
||||||
|
}>{line}</div>
|
||||||
|
))}
|
||||||
|
</pre>
|
||||||
|
{change.diff.truncated && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
{t("audit.changes.diffTruncated")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("audit.changes.diffUnavailable")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{change.capture === "unknown" && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("audit.changes.unknownNote")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{visible.length === 0 && summary && summary.total > 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground px-1">{t("audit.changes.noneInFilter")}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<Comparison | null>(null)
|
||||||
|
const [baseline, setBaseline] = useState<any>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<p className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
{t("audit.comparison.loading")}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{error && <p className="text-xs text-red-400">{error}</p>}
|
||||||
|
|
||||||
|
{!comparable ? (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{baseline ? t("audit.comparison.isBaseline") : t("audit.comparison.noBaseline")}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
aria-expanded={open}
|
||||||
|
className="flex w-full flex-wrap items-center gap-2 rounded-md -mx-2 px-2 py-1
|
||||||
|
text-left hover:bg-white/5 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
{open ? <ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
: <ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />}
|
||||||
|
<TrendingUp className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{t("audit.comparison.since", {
|
||||||
|
date: baseline?.started_at
|
||||||
|
? new Date(baseline.started_at * 1000).toLocaleDateString(language)
|
||||||
|
: t("audit.comparison.previousRun"),
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
{counts.length === 0 ? (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{t("audit.comparison.noChange")}
|
||||||
|
</Badge>
|
||||||
|
) : counts.map(({ key, Icon, tone, n }) => (
|
||||||
|
<Badge key={key} variant="outline" className={`text-xs gap-1.5 ${tone}`}>
|
||||||
|
<Icon className="h-3 w-3" />
|
||||||
|
{t(`audit.comparison.${key}`)}
|
||||||
|
<span className="tabular-nums font-semibold">{n}</span>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="space-y-3 pl-6">
|
||||||
|
{GROUPS.filter((g) => (comparison[g.key] || []).length > 0).map(
|
||||||
|
({ key, Icon, tone }) => (
|
||||||
|
<div key={key}>
|
||||||
|
<p className={`flex items-center gap-1.5 text-xs font-medium mb-1 ${tone}`}>
|
||||||
|
<Icon className="h-3.5 w-3.5" />
|
||||||
|
{t(`audit.comparison.${key}`)}
|
||||||
|
<span className="font-normal text-muted-foreground">
|
||||||
|
— {t(`audit.comparison.${key}Note`)}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{(comparison[key] || []).map((f) => (
|
||||||
|
<Badge key={f.check_id} variant="outline" className="text-xs">
|
||||||
|
{t(`audit.checks.${f.check_id}.title`)}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
{(comparison.unchanged || []).length > 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("audit.comparison.unchanged", {
|
||||||
|
count: String(comparison.unchanged.length),
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Choosing a reference is what makes every later run comparable,
|
||||||
|
so the action lives beside the comparison it enables. */}
|
||||||
|
{!isBaseline && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={markBaseline}
|
||||||
|
disabled={saving}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
>
|
||||||
|
{saving
|
||||||
|
? <Loader2 className="h-3 w-3 mr-1.5 animate-spin" />
|
||||||
|
: <Flag className="h-3 w-3 mr-1.5" />}
|
||||||
|
{t("audit.comparison.setBaseline")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{blocks.map((block, i) => <Block key={i} block={block} />)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Block({ block }: { block: EvidenceBlock }) {
|
||||||
|
const title = block.title
|
||||||
|
? <p className="text-xs font-medium text-foreground mb-1">{block.title}</p>
|
||||||
|
: null
|
||||||
|
|
||||||
|
if (block.kind === "table") {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{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. */}
|
||||||
|
<div className="sm:hidden space-y-2">
|
||||||
|
{block.rows.map((row, i) => (
|
||||||
|
<div key={i} className="rounded-md border border-border p-2.5 space-y-1">
|
||||||
|
{row.map((cell, j) => cell === "—" || cell === "" ? null : (
|
||||||
|
<div key={j} className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
||||||
|
<span className="text-xs text-muted-foreground shrink-0">
|
||||||
|
{block.columns[j]}</span>
|
||||||
|
<span className="text-xs text-foreground tabular-nums break-words min-w-0">
|
||||||
|
{cell}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="hidden sm:block overflow-x-auto rounded-md border border-border">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-muted/50">
|
||||||
|
{block.columns.map((c) => (
|
||||||
|
<th key={c} className="text-left font-medium px-3 py-1.5
|
||||||
|
text-muted-foreground whitespace-nowrap">
|
||||||
|
{c}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{block.rows.map((row, i) => (
|
||||||
|
<tr key={i} className="border-t border-border">
|
||||||
|
{row.map((cell, j) => (
|
||||||
|
<td key={j} className="px-3 py-1.5 align-top tabular-nums">
|
||||||
|
{cell}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (block.kind === "pairs") {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{title}
|
||||||
|
<dl className="grid grid-cols-[minmax(0,auto)_1fr] gap-x-4 gap-y-1 text-xs
|
||||||
|
rounded-md border border-border px-3 py-2">
|
||||||
|
{block.entries.map(([label, value]) => (
|
||||||
|
<div key={label} className="contents">
|
||||||
|
<dt className="text-muted-foreground whitespace-nowrap">{label}</dt>
|
||||||
|
<dd className="text-foreground break-words tabular-nums">{value}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{title}
|
||||||
|
{block.lines.length > 0 && (
|
||||||
|
<ul className="text-xs text-muted-foreground space-y-0.5 rounded-md
|
||||||
|
border border-border px-3 py-2">
|
||||||
|
{block.lines.map((line, i) => (
|
||||||
|
<li key={i} className="break-words">{line}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 <div className="space-y-4">{presentFinding(finding, t, locale).map((group, index) =>
|
||||||
|
<section key={index}>
|
||||||
|
<p className="text-sm font-medium mb-2">{group.title}</p>
|
||||||
|
{group.note && <p className="text-sm text-muted-foreground mb-2">{group.note}</p>}
|
||||||
|
{/* 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. */}
|
||||||
|
<div className="hidden sm:block overflow-x-auto rounded-md border border-border">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-muted/50 text-muted-foreground"><tr>{group.columns.map((column, i) =>
|
||||||
|
<th key={i} className="px-3 py-2 text-left font-medium">{column}</th>)}</tr></thead>
|
||||||
|
<tbody>{group.rows.map((row, i) => <tr key={i} className="border-t border-border">
|
||||||
|
{row.cells.map((cell, j) => <td key={j} className="px-3 py-2 align-top break-words tabular-nums">{cell}</td>)}
|
||||||
|
</tr>)}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div className="sm:hidden space-y-2">{group.rows.map((row, i) =>
|
||||||
|
<div key={i} className="rounded-md border border-border p-2.5 space-y-1">
|
||||||
|
{row.cells.map((cell, j) => cell === "" || cell === "—" ? null : (
|
||||||
|
<div key={j} className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
||||||
|
<span className="text-xs text-muted-foreground shrink-0">{group.columns[j]}</span>
|
||||||
|
<span className="text-sm text-foreground tabular-nums break-words min-w-0">{cell}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>)}</div>
|
||||||
|
</section>)}</div>
|
||||||
|
}
|
||||||
@@ -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<string, string>
|
||||||
|
sections: {
|
||||||
|
identity?: Record<string, string | null>
|
||||||
|
cluster?: any
|
||||||
|
hardware?: any
|
||||||
|
storages?: any[]
|
||||||
|
guests?: Guest[]
|
||||||
|
passthrough?: any[]
|
||||||
|
applications?: any[]
|
||||||
|
custom_links?: any[]
|
||||||
|
proxmenux?: { optimizations: any[]; pending_updates: any[] }
|
||||||
|
network?: { bridges: Record<string, any> } | 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 (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-xs text-muted-foreground">{label}</p>
|
||||||
|
<p className="text-sm text-foreground break-words">{value}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 (
|
||||||
|
<>
|
||||||
|
<div className="hidden sm:block overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left text-xs text-muted-foreground">
|
||||||
|
{columns.map((c, i) => (
|
||||||
|
<th key={i} className="pb-2 pr-4 font-medium whitespace-nowrap">{c}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((row, i) => (
|
||||||
|
<tr key={i} className="border-t border-border">
|
||||||
|
{row.map((cell, j) => (
|
||||||
|
<td key={j} className="py-2 pr-4 align-top tabular-nums">{cell}</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sm:hidden space-y-2">
|
||||||
|
{rows.map((row, i) => (
|
||||||
|
<div key={i} className="rounded-md border border-border p-2.5 space-y-1">
|
||||||
|
{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 (
|
||||||
|
<div key={j} className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
||||||
|
<span className="text-xs text-muted-foreground shrink-0">{columns[j]}</span>
|
||||||
|
<span className="text-sm text-foreground tabular-nums break-words min-w-0">
|
||||||
|
{cell}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Mono({ children }: { children: React.ReactNode }) {
|
||||||
|
return <span className="font-mono text-xs text-muted-foreground">{children}</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Card className="bg-card border-border">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
aria-expanded={open}
|
||||||
|
className="flex w-full items-center gap-2 rounded-md -mx-2 -my-1 px-2 py-1
|
||||||
|
text-left hover:bg-white/5 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
{open ? <ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||||
|
: <ChevronRight className="h-4 w-4 text-muted-foreground" />}
|
||||||
|
<CardTitle className="flex min-w-0 items-center gap-2 text-base font-semibold text-foreground">
|
||||||
|
{icon}<span className="min-w-0 break-words">{title}</span>
|
||||||
|
</CardTitle>
|
||||||
|
{count !== undefined && (
|
||||||
|
<Badge variant="outline" className="ml-auto shrink-0 text-xs tabular-nums">{count}</Badge>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</CardHeader>
|
||||||
|
{open && (
|
||||||
|
<CardContent className="pt-0 space-y-3">
|
||||||
|
{note && <p className="text-xs text-muted-foreground">{note}</p>}
|
||||||
|
{children}
|
||||||
|
</CardContent>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A heading inside a section, matching the printed document's. */
|
||||||
|
function Sub({ icon, title, note }: {
|
||||||
|
icon: React.ReactNode; title: string; note?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<p className="flex flex-wrap items-center gap-1.5 text-xs font-medium text-foreground mt-4 mb-1 first:mt-0">
|
||||||
|
{icon}{title}
|
||||||
|
{note && <span className="font-normal text-muted-foreground">— {note}</span>}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 <span className="text-xs text-muted-foreground italic">{t("audit.inventory.unresolved")}</span>
|
||||||
|
}
|
||||||
|
if (hops.length === 0) {
|
||||||
|
return <span className="text-xs text-muted-foreground">{t("audit.inventory.noUplink")}</span>
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="flex flex-wrap items-center gap-1 text-xs">
|
||||||
|
{hops.map((h, i) => (
|
||||||
|
<span key={`${h.id}-${i}`} className="flex items-center gap-1">
|
||||||
|
{i > 0 && <span className="text-muted-foreground">→</span>}
|
||||||
|
<Badge variant="outline" className="font-mono text-xs">
|
||||||
|
{h.id}{h.mode ? ` · ${h.mode}` : ""}
|
||||||
|
</Badge>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuditInventory({ profile = "full" }: { profile?: string }) {
|
||||||
|
const t = useT()
|
||||||
|
const { language } = useI18n()
|
||||||
|
const [data, setData] = useState<Inventory | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [openGuest, setOpenGuest] = useState<Set<number>>(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<number, any[]>()
|
||||||
|
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 (
|
||||||
|
<div className="flex items-center justify-center py-16 text-muted-foreground">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin mr-2" />{t("audit.inventory.loading")}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (error) return <p className="text-sm text-red-400 px-1">{error}</p>
|
||||||
|
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 (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-xs text-muted-foreground px-1">
|
||||||
|
{t("audit.inventory.collectedAt", {
|
||||||
|
when: new Date(data.collected_at * 1000).toLocaleString(language),
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* 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 && (
|
||||||
|
<Card className="bg-card border-border">
|
||||||
|
<CardContent className="py-3 space-y-1">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">
|
||||||
|
{t("audit.inventory.unavailable")}
|
||||||
|
</p>
|
||||||
|
{Object.entries(data.unavailable).map(([k, v]) => (
|
||||||
|
<p key={k} className="text-xs text-muted-foreground">
|
||||||
|
<span className="font-mono">{k}</span> — {v}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{s.identity && (
|
||||||
|
<Section icon={<Server className="h-4 w-4 text-blue-500" />} title={t("audit.inventory.identity")}>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<Field label={t("audit.inventory.node")} value={s.identity?.node} />
|
||||||
|
<Field label={t("audit.inventory.pveVersion")} value={s.identity?.pve_version} />
|
||||||
|
<Field label={t("audit.inventory.kernel")} value={s.identity?.kernel} />
|
||||||
|
<Field label={t("audit.inventory.subscription")} value={subscriptionLabel(t, s.identity?.subscription)} />
|
||||||
|
<Field label={t("audit.inventory.cluster")}
|
||||||
|
value={s.identity?.cluster || t("audit.inventory.standalone")} />
|
||||||
|
<Field label={t("audit.document.system")}
|
||||||
|
value={[hw.system?.manufacturer, hw.system?.product].filter(Boolean).join(" ")} />
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{"cluster" in s && (
|
||||||
|
<Section icon={<Share2 className="h-4 w-4 text-cyan-500" />} title={t("audit.document.cluster")}
|
||||||
|
count={s.cluster ? (s.cluster.nodes || []).length : undefined}
|
||||||
|
note={s.cluster ? undefined : t("audit.document.standaloneNote")}>
|
||||||
|
{s.cluster && (
|
||||||
|
<>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
|
<Field label={t("audit.inventory.cluster")} value={s.cluster.name} />
|
||||||
|
<Field label={t("audit.document.quorum")}
|
||||||
|
value={s.cluster.quorate == null ? "—" : (
|
||||||
|
<Badge variant="outline" className={s.cluster.quorate
|
||||||
|
? "bg-green-500/10 text-green-500 border-green-500/20"
|
||||||
|
: "bg-red-500/10 text-red-500 border-red-500/20"}>
|
||||||
|
{t(s.cluster.quorate ? "audit.document.quorate"
|
||||||
|
: "audit.document.inquorate")}
|
||||||
|
</Badge>
|
||||||
|
)} />
|
||||||
|
<Field label={t("audit.document.votes")}
|
||||||
|
value={`${s.cluster.total_votes ?? "—"} / ${s.cluster.expected_votes ?? "—"}`} />
|
||||||
|
</div>
|
||||||
|
<DataTable
|
||||||
|
columns={[t("audit.document.nodeName"), "nodeid", "ring0", "ring1",
|
||||||
|
t("audit.document.state")]}
|
||||||
|
rows={(s.cluster.nodes || []).map((n: any) => [
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{n.name}
|
||||||
|
{n.local && <span className="text-muted-foreground">
|
||||||
|
{" "}({t("audit.document.thisNode")})</span>}
|
||||||
|
</span>,
|
||||||
|
<Mono>{n.nodeid || "—"}</Mono>,
|
||||||
|
<Mono>{n.ring0_addr || "—"}</Mono>,
|
||||||
|
<Mono>{n.ring1_addr || "—"}</Mono>,
|
||||||
|
n.online === false
|
||||||
|
? <Badge variant="outline" className="bg-amber-500/10 text-amber-500 border-amber-500/20">
|
||||||
|
{t("audit.document.unreachable")}</Badge>
|
||||||
|
: n.online === true
|
||||||
|
? <Badge variant="outline">{t("audit.document.member")}</Badge>
|
||||||
|
: "—",
|
||||||
|
])}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{s.hardware && (
|
||||||
|
<Section icon={<Cpu className="h-4 w-4 text-indigo-500" />} title={t("audit.document.architecture")}>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<Field label={t("audit.inventory.cpu")} value={hw.cpu?.model} />
|
||||||
|
<Field label={t("audit.inventory.topology")}
|
||||||
|
value={hw.cpu ? t("audit.inventory.cpuLayout", {
|
||||||
|
sockets: String(hw.cpu.sockets), cores: String(hw.cpu.cores_per_socket),
|
||||||
|
threads: String(hw.cpu.threads),
|
||||||
|
}) : ""} />
|
||||||
|
<Field label={t("audit.inventory.memory")}
|
||||||
|
value={hw.memory_bytes ? `${(hw.memory_bytes / GiB).toFixed(0)} GiB` : ""} />
|
||||||
|
<Field label={t("audit.inventory.virtualisation")} value={hw.cpu?.virtualisation} />
|
||||||
|
<Field label={t("audit.inventory.serial")} value={hw.system?.serial} />
|
||||||
|
<Field label={t("audit.document.board")}
|
||||||
|
value={[hw.board?.manufacturer, hw.board?.product].filter(Boolean).join(" ")} />
|
||||||
|
<Field label={t("audit.inventory.bios")}
|
||||||
|
value={[hw.bios?.vendor, hw.bios?.version, hw.bios?.date].filter(Boolean).join(" · ")} />
|
||||||
|
<Field label={t("audit.inventory.iommuGroups")} value={hw.iommu_groups} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(mem.modules || []).length > 0 && (
|
||||||
|
<>
|
||||||
|
<Sub icon={<MemoryStick className="h-3.5 w-3.5 text-muted-foreground" />}
|
||||||
|
title={t("audit.document.memoryModules")}
|
||||||
|
note={t("audit.document.slotsFilled", {
|
||||||
|
used: String(mem.populated ?? 0),
|
||||||
|
total: String(mem.slots ?? mem.populated ?? 0),
|
||||||
|
})} />
|
||||||
|
<DataTable
|
||||||
|
columns={[t("audit.document.slot"), t("audit.document.size"),
|
||||||
|
t("audit.document.type"), t("audit.document.formFactor"),
|
||||||
|
t("audit.document.speed"), t("audit.document.manufacturer")]}
|
||||||
|
rows={(mem.modules || []).map((m: any) => [
|
||||||
|
<Mono>{m.locator || "—"}</Mono>, m.size || "—", m.type || "—",
|
||||||
|
m.form_factor || "—", m.speed || "—",
|
||||||
|
[m.manufacturer, m.part_number].filter(Boolean).join(" · ") || "—",
|
||||||
|
])}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(hw.controllers || []).length > 0 && (
|
||||||
|
<>
|
||||||
|
<Sub icon={<CircuitBoard className="h-3.5 w-3.5 text-muted-foreground" />}
|
||||||
|
title={t("audit.document.controllers")} />
|
||||||
|
<DataTable
|
||||||
|
columns={["PCI", t("audit.document.class"), t("audit.document.device")]}
|
||||||
|
rows={(hw.controllers || []).map((c: any) => [
|
||||||
|
<Mono>{c.slot}</Mono>, c.class, c.name,
|
||||||
|
])}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(hw.disks || []).length > 0 && (
|
||||||
|
<Section icon={<HardDrive className="h-4 w-4 text-amber-500" />} title={t("audit.document.storageDevices")}
|
||||||
|
count={(hw.disks || []).length}>
|
||||||
|
<DataTable
|
||||||
|
columns={[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={(hw.disks || []).map((d: any) => {
|
||||||
|
const ok = ["passed", "healthy", "ok"].includes(String(d.health).toLowerCase())
|
||||||
|
return [
|
||||||
|
<span className="font-medium text-foreground">{d.name}</span>,
|
||||||
|
d.model || "—",
|
||||||
|
<Mono>{d.serial || "—"}</Mono>,
|
||||||
|
bytes(d.size_bytes),
|
||||||
|
`${(d.bus || "—").toUpperCase()} · ${d.rotational ? "HDD" : "SSD"}`,
|
||||||
|
ok
|
||||||
|
? <Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">
|
||||||
|
{t("audit.document.healthy")}</Badge>
|
||||||
|
: d.health && d.health !== "unknown"
|
||||||
|
? <Badge variant="outline" className="bg-amber-500/10 text-amber-500 border-amber-500/20">
|
||||||
|
{d.health}</Badge>
|
||||||
|
: "—",
|
||||||
|
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
|
||||||
|
? <Badge variant="outline" className="bg-amber-500/10 text-amber-500 border-amber-500/20 tabular-nums">
|
||||||
|
{d.observations.length}</Badge>
|
||||||
|
: <span className="text-muted-foreground">—</span>,
|
||||||
|
]
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(hw.disks || []).some((d: any) => (d.observations || []).length > 0) && (
|
||||||
|
<>
|
||||||
|
<Sub icon={<Activity className="h-3.5 w-3.5 text-muted-foreground" />}
|
||||||
|
title={t("audit.document.observations")}
|
||||||
|
note={t("audit.document.observationsNote")} />
|
||||||
|
{(hw.disks || []).filter((d: any) => (d.observations || []).length).map((d: any) => (
|
||||||
|
<div key={d.name} className="mt-2">
|
||||||
|
<p className="text-xs font-medium text-foreground mb-1">
|
||||||
|
{d.name} <span className="font-normal text-muted-foreground">{d.model}</span>
|
||||||
|
</p>
|
||||||
|
<DataTable
|
||||||
|
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")]}
|
||||||
|
rows={d.observations.map((o: any) => [
|
||||||
|
o.type || "—",
|
||||||
|
<Badge variant="outline" className={o.severity === "critical"
|
||||||
|
? "bg-red-500/10 text-red-500 border-red-500/20"
|
||||||
|
: "bg-amber-500/10 text-amber-500 border-amber-500/20"}>
|
||||||
|
{o.severity
|
||||||
|
? t(`audit.classifications.${
|
||||||
|
o.severity === "critical" ? "critical" : "warning"}`)
|
||||||
|
: "—"}</Badge>,
|
||||||
|
String(o.count ?? "—"),
|
||||||
|
when(o.first_seen), when(o.last_seen),
|
||||||
|
<span className="text-muted-foreground break-words">{o.message || ""}</span>,
|
||||||
|
])}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(s.network || (hw.adapters || []).length > 0) && (
|
||||||
|
<Section icon={<Network className="h-4 w-4 text-green-500" />} title={t("audit.inventory.network")}
|
||||||
|
count={Object.keys(s.network?.bridges || {}).length || undefined}>
|
||||||
|
{(hw.adapters || []).length > 0 && (
|
||||||
|
<>
|
||||||
|
<Sub icon={<Plug className="h-3.5 w-3.5 text-muted-foreground" />}
|
||||||
|
title={t("audit.document.physicalAdapters")} />
|
||||||
|
<DataTable
|
||||||
|
columns={[t("audit.document.interface"), t("audit.document.state"),
|
||||||
|
t("audit.document.speed"), "MAC", t("audit.document.driver"), "PCI"]}
|
||||||
|
rows={(hw.adapters || []).map((a: any) => [
|
||||||
|
<span className="font-medium text-foreground">{a.name}</span>,
|
||||||
|
a.state === "up"
|
||||||
|
? <Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">
|
||||||
|
{a.state}</Badge>
|
||||||
|
: <Badge variant="outline">{a.state || "—"}</Badge>,
|
||||||
|
a.speed_mbps
|
||||||
|
? (a.speed_mbps >= 1000 ? `${a.speed_mbps / 1000} Gb/s` : `${a.speed_mbps} Mb/s`)
|
||||||
|
: "—",
|
||||||
|
<Mono>{a.mac || "—"}</Mono>, a.driver || "—", <Mono>{a.pci || "—"}</Mono>,
|
||||||
|
])}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{s.network && (
|
||||||
|
<>
|
||||||
|
<Sub icon={<Network className="h-3.5 w-3.5 text-muted-foreground" />}
|
||||||
|
title={t("audit.document.bridges")} />
|
||||||
|
<div className="space-y-2">
|
||||||
|
{Object.entries(s.network.bridges || {}).map(([id, b]: [string, any]) => (
|
||||||
|
<div key={id} className="flex flex-wrap items-center gap-2 text-sm">
|
||||||
|
<Badge variant="outline" className="font-mono">{id}</Badge>
|
||||||
|
<span className="text-muted-foreground">→</span>
|
||||||
|
<Uplink hops={b.uplink} />
|
||||||
|
{b.vlan_interface && (
|
||||||
|
<Badge variant="outline" className="text-xs">VLAN {b.vlan_interface}</Badge>
|
||||||
|
)}
|
||||||
|
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
|
||||||
|
{(s.guests || []).filter((g) =>
|
||||||
|
(g.interfaces || []).some((n) => n.bridge === id)).length}
|
||||||
|
{" "}{t("audit.inventory.guests").toLowerCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{s.latency?.targets?.length ? (
|
||||||
|
<Section icon={<Activity className="h-4 w-4 text-sky-500" />} title={t("audit.document.latency")}
|
||||||
|
note={t("audit.document.latencyNote")}>
|
||||||
|
<DataTable
|
||||||
|
columns={[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={s.latency.targets.map((target: any) => [
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{t(`audit.document.target.${target.target}`)}</span>,
|
||||||
|
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),
|
||||||
|
])}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{s.storages && (
|
||||||
|
<Section icon={<HardDrive className="h-4 w-4 text-purple-500" />} title={t("audit.inventory.storage")}
|
||||||
|
count={(s.storages || []).length}>
|
||||||
|
<DataTable
|
||||||
|
columns={[t("audit.inventory.name"), t("audit.inventory.type"),
|
||||||
|
t("audit.inventory.content"), t("audit.inventory.shared"),
|
||||||
|
t("audit.document.location"), t("audit.inventory.guests")]}
|
||||||
|
rows={(s.storages || []).map((st: any) => [
|
||||||
|
<Mono>{st.id}</Mono>, st.type,
|
||||||
|
<span className="text-muted-foreground text-xs">{st.content}</span>,
|
||||||
|
st.shared
|
||||||
|
? <Badge variant="outline">{t("audit.inventory.yes")}</Badge>
|
||||||
|
: <span className="text-muted-foreground">—</span>,
|
||||||
|
<span className="text-muted-foreground text-xs break-all">
|
||||||
|
{st.server || st.path || "—"}</span>,
|
||||||
|
String((s.guests || []).filter((g) =>
|
||||||
|
(g.disks || []).some((d) => d.storage === st.id)).length),
|
||||||
|
])}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{s.guests && (
|
||||||
|
<Section icon={<Boxes className="h-4 w-4 text-emerald-500" />} title={t("audit.inventory.guests")}
|
||||||
|
count={(s.guests || []).length}>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{(s.guests || []).map((g) => {
|
||||||
|
const open = openGuest.has(g.vmid)
|
||||||
|
const guestApps = apps.get(g.vmid) || []
|
||||||
|
return (
|
||||||
|
<div key={g.vmid} className="rounded-md border border-border">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggle(g.vmid)}
|
||||||
|
aria-expanded={open}
|
||||||
|
className="flex w-full flex-wrap items-center gap-2 rounded-md p-3 text-left hover:bg-white/5 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
{open ? <ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
: <ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />}
|
||||||
|
<Badge variant="outline" className="font-mono text-xs">{g.vmid}</Badge>
|
||||||
|
<span className="font-medium text-foreground">{g.name || "—"}</span>
|
||||||
|
<Badge variant="outline" className="text-xs uppercase">{g.type}</Badge>
|
||||||
|
{/* Whether a guest needs a backup is not visible from
|
||||||
|
here, so its absence is stated, not flagged. */}
|
||||||
|
{g.backups.length === 0 && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{t("audit.inventory.noBackup")}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<span className="ml-auto shrink-0 text-xs text-muted-foreground tabular-nums">
|
||||||
|
{g.cores}c · {g.memory}MB
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="border-t border-border p-3 space-y-4">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
||||||
|
<Field label={t("audit.inventory.ostype")} value={g.ostype} />
|
||||||
|
<Field label={t("audit.inventory.onboot")}
|
||||||
|
value={g.onboot ? t("audit.inventory.yes") : t("audit.inventory.no")} />
|
||||||
|
<Field label={t("audit.inventory.tags")} value={g.tags} />
|
||||||
|
{g.type === "lxc" && (
|
||||||
|
<Field label={t("audit.inventory.privilege")}
|
||||||
|
value={g.unprivileged ? t("audit.inventory.unprivileged")
|
||||||
|
: t("audit.inventory.privileged")} />
|
||||||
|
)}
|
||||||
|
{g.type === "lxc" && <Field label={t("audit.inventory.features")} value={g.features} />}
|
||||||
|
{g.type === "qemu" && (
|
||||||
|
<Field label={t("audit.inventory.agent")}
|
||||||
|
value={g.agent ? t("audit.inventory.yes") : t("audit.inventory.no")} />
|
||||||
|
)}
|
||||||
|
{g.type === "qemu" && <Field label={t("audit.inventory.cpuModel")} value={g.cpu} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{g.disks.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<Sub icon={<HardDrive className="h-3.5 w-3.5 text-muted-foreground" />}
|
||||||
|
title={t("audit.inventory.disks")} />
|
||||||
|
<DataTable
|
||||||
|
columns={[t("audit.document.slot"), t("audit.document.storage"),
|
||||||
|
t("audit.inventory.name"), t("audit.document.size")]}
|
||||||
|
rows={g.disks.map((d) => [
|
||||||
|
<Mono>{d.slot}</Mono>,
|
||||||
|
d.storage
|
||||||
|
? <Badge variant="outline" className="font-mono text-xs">{d.storage}</Badge>
|
||||||
|
: <Badge variant="outline" className="text-xs">
|
||||||
|
{t("audit.inventory.passthrough")}</Badge>,
|
||||||
|
<span className="font-mono text-xs break-all">{d.volume}</span>,
|
||||||
|
d.size || "—",
|
||||||
|
])}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{g.interfaces.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<Sub icon={<Network className="h-3.5 w-3.5 text-muted-foreground" />}
|
||||||
|
title={t("audit.inventory.interfaces")} />
|
||||||
|
<div className="space-y-1">
|
||||||
|
{g.interfaces.map((n) => (
|
||||||
|
<div key={n.slot} className="flex flex-wrap items-center gap-1.5 text-xs">
|
||||||
|
<Badge variant="outline" className="font-mono">{n.slot}</Badge>
|
||||||
|
<span className="text-muted-foreground">→</span>
|
||||||
|
<Badge variant="outline" className="font-mono">{n.bridge}</Badge>
|
||||||
|
<span className="text-muted-foreground">→</span>
|
||||||
|
<Uplink hops={n.uplink} />
|
||||||
|
{n.vlan && <Badge variant="outline">VLAN {n.vlan}</Badge>}
|
||||||
|
{n.mac && <Mono>{n.mac}</Mono>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Sub icon={<Package className="h-3.5 w-3.5 text-muted-foreground" />}
|
||||||
|
title={t("audit.inventory.protection")} />
|
||||||
|
{g.backups.length === 0 ? (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("audit.inventory.noBackupDetail")}</p>
|
||||||
|
) : (
|
||||||
|
<DataTable
|
||||||
|
columns={[t("audit.document.backup"), t("audit.document.storage"),
|
||||||
|
t("audit.inventory.type"), t("audit.document.content")]}
|
||||||
|
rows={g.backups.map((b) => [
|
||||||
|
<Mono>{b.job}</Mono>,
|
||||||
|
<Badge variant="outline" className="font-mono text-xs">{b.storage}</Badge>,
|
||||||
|
b.schedule || "—", b.retention || "—",
|
||||||
|
])}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{guestApps.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<Sub icon={<Wrench className="h-3.5 w-3.5 text-muted-foreground" />}
|
||||||
|
title={t("audit.inventory.applications")} />
|
||||||
|
<div className="space-y-1">
|
||||||
|
{guestApps.map((a: any, i: number) => (
|
||||||
|
<div key={`${a.slug}-${i}`} className="flex flex-wrap items-center gap-1.5 text-xs">
|
||||||
|
<span className="text-foreground">{a.name}</span>
|
||||||
|
<Mono>{a.version || t("audit.inventory.versionUnknown")}</Mono>
|
||||||
|
{a.update_available && (
|
||||||
|
<Badge variant="outline" className="bg-purple-600/15 text-purple-400 border-purple-500/20">
|
||||||
|
{a.available}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{(a.ports || []).map((p: any, j: number) => (
|
||||||
|
<Badge key={j} variant="outline" className="font-mono">
|
||||||
|
{p.scheme}:{p.port}{p.path}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(s.passthrough || []).length > 0 && (
|
||||||
|
<Section icon={<Plug className="h-4 w-4 text-violet-500" />} title={t("audit.inventory.passthroughTitle")}
|
||||||
|
count={(s.passthrough || []).length}>
|
||||||
|
<DataTable
|
||||||
|
columns={[t("audit.document.vmid"), t("audit.document.name"),
|
||||||
|
t("audit.document.slot"), t("audit.document.device"),
|
||||||
|
t("audit.document.iommuGroup"), t("audit.inventory.sharedGroup")]}
|
||||||
|
rows={(s.passthrough || []).map((p: any) => [
|
||||||
|
<Mono>{p.vmid}</Mono>,
|
||||||
|
p.guest || "—",
|
||||||
|
<Mono>{p.slot || "—"}</Mono>,
|
||||||
|
<Mono>{p.address || "—"}</Mono>,
|
||||||
|
(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
|
||||||
|
? <Badge variant="outline" className="bg-amber-500/10 text-amber-500 border-amber-500/20 tabular-nums">
|
||||||
|
{p.shared_group_devices.length}</Badge>
|
||||||
|
: <span className="text-muted-foreground">—</span>,
|
||||||
|
])}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{s.proxmenux && (
|
||||||
|
<Section icon={<Wrench className="h-4 w-4 text-orange-500" />} title={t("audit.inventory.proxmenux")}
|
||||||
|
count={(s.proxmenux.optimizations || []).length}>
|
||||||
|
<DataTable
|
||||||
|
columns={[t("audit.document.name"), t("audit.document.version"),
|
||||||
|
t("audit.document.state")]}
|
||||||
|
rows={(s.proxmenux.optimizations || []).map((o: any) => {
|
||||||
|
const pending = (s.proxmenux!.pending_updates || [])
|
||||||
|
.find((u: any) => u.key === o.key)
|
||||||
|
return [
|
||||||
|
<Mono>{o.key}</Mono>,
|
||||||
|
o.version || "—",
|
||||||
|
pending
|
||||||
|
? <Badge variant="outline" className="bg-purple-600/15 text-purple-400 border-purple-500/20">
|
||||||
|
{t("audit.document.updateAvailable", { version: String(pending.available) })}
|
||||||
|
</Badge>
|
||||||
|
: <Badge variant="outline">{t("audit.document.current")}</Badge>,
|
||||||
|
]
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<string, GuestRule>
|
||||||
|
storages: Record<string, { role?: string }>
|
||||||
|
defaults: Record<string, unknown>
|
||||||
|
thresholds: Record<string, number>
|
||||||
|
}
|
||||||
|
interface Vocabulary {
|
||||||
|
expectations: string[]
|
||||||
|
roles: string[]
|
||||||
|
thresholds: Record<string, number>
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Select value={value} onValueChange={onChange} disabled={disabled}>
|
||||||
|
<SelectTrigger className="w-full min-w-0 text-foreground sm:w-[12.5rem]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="inherit">{inherited}</SelectItem>
|
||||||
|
{/* Declaring here what the default already says would be the same
|
||||||
|
entry twice, reading the same. */}
|
||||||
|
{options.filter((option) => option !== inheritedKey).map((option) => (
|
||||||
|
<SelectItem key={option} value={option}>{label(`${prefix}.${option}`)}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuditPolicy() {
|
||||||
|
const t = useT()
|
||||||
|
const [policy, setPolicy] = useState<Policy>(EMPTY)
|
||||||
|
const [vocabulary, setVocabulary] = useState<Vocabulary | null>(null)
|
||||||
|
const [guests, setGuests] = useState<Guest[]>([])
|
||||||
|
const [storages, setStorages] = useState<Storage[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [dirty, setDirty] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [saved, setSaved] = useState(false)
|
||||||
|
const [revision, setRevision] = useState<string | null>(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<string, unknown>)[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 (
|
||||||
|
<div className="flex items-center justify-center py-16 text-muted-foreground">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin mr-2" />{t("audit.policy.loading")}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectations = vocabulary?.expectations || ["required", "not_required", "unspecified"]
|
||||||
|
const roles = vocabulary?.roles || ["essential", "optional", "unspecified"]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={(event) => { event.preventDefault(); void save() }}>
|
||||||
|
{error && <p className="text-sm text-red-400 px-1">{error}</p>}
|
||||||
|
{(conflict || !revision) && <Button type="button" onClick={() => void load()}>
|
||||||
|
{t("audit.policy.reload")}
|
||||||
|
</Button>}
|
||||||
|
<Card className={editing
|
||||||
|
? "bg-accent border-border [&_input]:bg-background [&_[role=combobox]]:bg-background"
|
||||||
|
: "bg-card border-border"}>
|
||||||
|
<CardContent className="py-4 space-y-3">
|
||||||
|
<p className="text-sm text-muted-foreground">{t("audit.policy.intro")}</p>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<Badge variant="outline" className="tabular-nums">
|
||||||
|
{t("audit.policy.declaredCount", { count: String(declared) })}
|
||||||
|
</Badge>
|
||||||
|
{saved && <span className="text-sm text-green-500">{t("audit.policy.saved")}</span>}
|
||||||
|
{editing ? (
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="h-7 px-3 text-xs rounded-md border border-border bg-background
|
||||||
|
hover:bg-muted transition-colors text-muted-foreground"
|
||||||
|
onClick={() => { setEditing(false); void load() }}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{t("actions.cancel")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="h-7 px-3 text-xs rounded-md bg-blue-600 hover:bg-blue-700 text-white
|
||||||
|
transition-colors disabled:opacity-50 flex items-center gap-1.5"
|
||||||
|
disabled={!dirty || saving}
|
||||||
|
>
|
||||||
|
{saving
|
||||||
|
? <Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
: <CheckCircle2 className="h-3 w-3" />}
|
||||||
|
{t("actions.save")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ml-auto h-7 px-3 text-xs rounded-md border border-border bg-background
|
||||||
|
hover:bg-muted transition-colors flex items-center gap-1.5"
|
||||||
|
onClick={() => { setEditing(true); setSaved(false) }}
|
||||||
|
disabled={!revision || conflict}
|
||||||
|
>
|
||||||
|
<Settings2 className="h-3 w-3" />
|
||||||
|
{t("actions.edit")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<fieldset disabled={locked} className="space-y-4 min-w-0">
|
||||||
|
|
||||||
|
|
||||||
|
<Card className={editing
|
||||||
|
? "bg-accent border-border [&_input]:bg-background [&_[role=combobox]]:bg-background"
|
||||||
|
: "bg-card border-border"}>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base font-semibold text-foreground">
|
||||||
|
<Boxes className="h-4 w-4" />{t("audit.inventory.guests")}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0 space-y-2">
|
||||||
|
<p className="text-xs text-muted-foreground">{t("audit.policy.guestsNote")}</p>
|
||||||
|
{guests.map((guest) => {
|
||||||
|
const rule = policy.guests[String(guest.vmid)] || {}
|
||||||
|
return (
|
||||||
|
<div key={guest.vmid}
|
||||||
|
className="rounded-md border border-border p-3 space-y-2
|
||||||
|
sm:flex sm:flex-wrap sm:items-center sm:gap-3 sm:space-y-0">
|
||||||
|
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||||
|
<Badge variant="outline" className="font-mono text-xs shrink-0">
|
||||||
|
{guest.vmid}
|
||||||
|
</Badge>
|
||||||
|
<span className="truncate font-medium text-foreground">
|
||||||
|
{guest.name || "—"}
|
||||||
|
</span>
|
||||||
|
<Badge variant="outline" className="text-xs uppercase shrink-0">
|
||||||
|
{guest.type}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<span className="w-20 shrink-0 sm:w-auto">{t("audit.policy.backup")}</span>
|
||||||
|
<PolicySelect label={t} disabled={locked}
|
||||||
|
inherited={policy.defaults.backup
|
||||||
|
? t("audit.policy.inherit", { value: t(`audit.policy.expectation.${policy.defaults.backup}`) })
|
||||||
|
: t("audit.policy.inheritUnset")}
|
||||||
|
inheritedKey={policy.defaults.backup ? undefined : "unspecified"}
|
||||||
|
value={rule.backup || "inherit"}
|
||||||
|
options={expectations}
|
||||||
|
prefix="audit.policy.expectation"
|
||||||
|
onChange={(v) => setGuestRule(guest.vmid, "backup", v)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<span className="w-20 shrink-0 sm:w-auto">{t("audit.policy.autostart")}</span>
|
||||||
|
<PolicySelect label={t} disabled={locked}
|
||||||
|
inherited={policy.defaults.autostart
|
||||||
|
? t("audit.policy.inherit", { value: t(`audit.policy.expectation.${policy.defaults.autostart}`) })
|
||||||
|
: t("audit.policy.inheritUnset")}
|
||||||
|
inheritedKey={policy.defaults.autostart ? undefined : "unspecified"}
|
||||||
|
value={rule.autostart || "inherit"}
|
||||||
|
options={expectations}
|
||||||
|
prefix="audit.policy.expectation"
|
||||||
|
onChange={(v) => setGuestRule(guest.vmid, "autostart", v)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<span className="w-20 shrink-0 sm:w-auto">{t("audit.policy.objective")}</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0.000001}
|
||||||
|
step="any"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={rule.recovery_objective_hours ?? ""}
|
||||||
|
placeholder={policy.defaults.recovery_objective_hours
|
||||||
|
? String(policy.defaults.recovery_objective_hours) : t("audit.policy.objectivePlaceholder")}
|
||||||
|
onChange={(e) => setGuestRule(
|
||||||
|
guest.vmid, "recovery_objective_hours",
|
||||||
|
e.target.value ? Number(e.target.value) : undefined)}
|
||||||
|
className="w-full min-w-0 rounded-md border border-border bg-background
|
||||||
|
px-2 py-1.5 text-sm text-foreground focus:outline-none
|
||||||
|
focus:ring-1 focus:ring-ring sm:w-24"
|
||||||
|
/>
|
||||||
|
{rule.recovery_objective_hours == null && policy.defaults.recovery_objective_hours != null && (
|
||||||
|
<span>{t("audit.policy.inherit", { value: `${policy.defaults.recovery_objective_hours} ${t("audit.policy.objectivePlaceholder")}` })}</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{guests.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">{t("audit.policy.noGuests")}</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className={editing
|
||||||
|
? "bg-accent border-border [&_input]:bg-background [&_[role=combobox]]:bg-background"
|
||||||
|
: "bg-card border-border"}>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base font-semibold text-foreground">
|
||||||
|
<HardDrive className="h-4 w-4" />{t("audit.inventory.storage")}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0 space-y-2">
|
||||||
|
<p className="text-xs text-muted-foreground">{t("audit.policy.storagesNote")}</p>
|
||||||
|
{storages.map((storage) => (
|
||||||
|
<div key={storage.id}
|
||||||
|
className="rounded-md border border-border p-3 space-y-2
|
||||||
|
sm:flex sm:items-center sm:gap-3 sm:space-y-0">
|
||||||
|
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||||
|
<Badge variant="outline" className="font-mono text-xs shrink-0">
|
||||||
|
{storage.id}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-sm text-muted-foreground">{storage.type}</span>
|
||||||
|
</div>
|
||||||
|
<PolicySelect label={t} disabled={locked}
|
||||||
|
inherited={policy.defaults.storage_role
|
||||||
|
? t("audit.policy.inherit", { value: t(`audit.policy.role.${policy.defaults.storage_role}`) })
|
||||||
|
: t("audit.policy.inheritUnset")}
|
||||||
|
inheritedKey={policy.defaults.storage_role ? undefined : "unspecified"}
|
||||||
|
value={policy.storages[storage.id]?.role || "inherit"}
|
||||||
|
options={roles}
|
||||||
|
prefix="audit.policy.role"
|
||||||
|
onChange={(v) => setStorageRole(storage.id, v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className={editing
|
||||||
|
? "bg-accent border-border [&_input]:bg-background [&_[role=combobox]]:bg-background"
|
||||||
|
: "bg-card border-border"}>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base font-semibold text-foreground">
|
||||||
|
<SlidersHorizontal className="h-4 w-4" />{t("audit.policy.thresholds")}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0 space-y-2">
|
||||||
|
<p className="text-xs text-muted-foreground">{t("audit.policy.thresholdsNote")}</p>
|
||||||
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
|
{Object.entries(vocabulary?.thresholds || {}).map(([name, shipped]) => (
|
||||||
|
<label key={name}
|
||||||
|
className="flex flex-col items-end gap-1.5 rounded-md border
|
||||||
|
border-border p-2.5 sm:flex-row sm:items-center sm:gap-2">
|
||||||
|
<span className="w-full min-w-0 text-left text-sm text-foreground sm:flex-1">
|
||||||
|
{t(`audit.policy.threshold.${name}`)}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0.000001}
|
||||||
|
max={name.endsWith("_percent") ? 100 : undefined}
|
||||||
|
step="any"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={policy.thresholds[name] ?? ""}
|
||||||
|
placeholder={String(shipped)}
|
||||||
|
onChange={(e) => setThreshold(name, e.target.value)}
|
||||||
|
className="w-24 shrink-0 rounded-md border border-border bg-background
|
||||||
|
px-2 py-1.5 text-sm text-foreground tabular-nums
|
||||||
|
focus:outline-none focus:ring-1 focus:ring-ring"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -10,22 +10,42 @@ import {
|
|||||||
} from "./ui/dialog"
|
} from "./ui/dialog"
|
||||||
import {
|
import {
|
||||||
AlertTriangle, CheckCircle2, ChevronDown, ChevronRight, ClipboardCheck,
|
AlertTriangle, CheckCircle2, ChevronDown, ChevronRight, ClipboardCheck,
|
||||||
Loader2, MinusCircle, Play, RotateCcw, ShieldOff, XCircle,
|
FileText, HelpCircle, Info, Loader2, MinusCircle, Play, RotateCcw,
|
||||||
|
ShieldOff, XCircle,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { fetchApi } from "../lib/api-config"
|
import { fetchApi } from "../lib/api-config"
|
||||||
import { useT } from "../lib/i18n/provider"
|
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 {
|
interface Finding {
|
||||||
check_id: string
|
check_id: string
|
||||||
area: string
|
area: string
|
||||||
severity: string
|
severity: string
|
||||||
state: string
|
classification: string
|
||||||
|
decision?: string
|
||||||
summary_key: string | null
|
summary_key: string | null
|
||||||
summary_params: Record<string, string | number>
|
summary_params: Record<string, string | number>
|
||||||
affected: Array<Record<string, unknown>>
|
affected: Array<Record<string, unknown>>
|
||||||
evidence: string | null
|
evidence: string | null
|
||||||
remediable_by: 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 {
|
interface Run {
|
||||||
@@ -35,46 +55,80 @@ interface Run {
|
|||||||
finished_at: number | null
|
finished_at: number | null
|
||||||
status: string
|
status: string
|
||||||
checks_total: number
|
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.
|
// Findings are ordered by how much they demand attention, not by area.
|
||||||
// Someone triaging wants the worst thing first regardless of where it
|
// Someone triaging wants the worst thing first regardless of where it
|
||||||
// lives; grouping by area is the reading order of the printed document.
|
// lives; grouping by area is the reading order of the printed document.
|
||||||
const STATE_RANK: Record<string, number> = {
|
//
|
||||||
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<string, number> = {
|
||||||
|
critical: 0, warning: 1, observation: 2, unverified: 3,
|
||||||
|
accepted: 4, conformant: 5, not_applicable: 6,
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATE_STYLE: Record<string, { chip: string; Icon: typeof XCircle }> = {
|
// Only the first two are problems. An observation is drawn in a neutral
|
||||||
fail: { chip: "bg-red-500/10 text-red-500 border-red-500/20", Icon: XCircle },
|
// tone on purpose: colouring planning information like a fault is what
|
||||||
warn: { chip: "bg-amber-500/10 text-amber-500 border-amber-500/20", Icon: AlertTriangle },
|
// made ordinary configurations read as defects.
|
||||||
accepted: { chip: "bg-muted text-muted-foreground border-border", Icon: ShieldOff },
|
const CLASS_STYLE: Record<string, { chip: string; Icon: typeof XCircle }> = {
|
||||||
pass: { chip: "bg-green-500/10 text-green-500 border-green-500/20", Icon: CheckCircle2 },
|
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 },
|
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
|
// An assessment older than this stops describing the current system, so
|
||||||
// the age is surfaced before any count rather than as a footnote.
|
// the age is surfaced before any count rather than as a footnote.
|
||||||
const STALE_AFTER_DAYS = 30
|
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() {
|
export function AuditReport() {
|
||||||
const t = useT()
|
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 [running, setRunning] = useState(false)
|
||||||
const [latest, setLatest] = useState<Run | null>(null)
|
const [latest, setLatest] = useState<Run | null>(null)
|
||||||
const [findings, setFindings] = useState<Finding[]>([])
|
const [findings, setFindings] = useState<Finding[]>([])
|
||||||
const [summary, setSummary] = useState<Record<string, number>>({})
|
const [summary, setSummary] = useState<Record<string, number>>({})
|
||||||
const [areaFilter, setAreaFilter] = useState<string>("all")
|
const [areaFilter, setAreaFilter] = useState<string>("all")
|
||||||
const [expanded, setExpanded] = useState<Set<string>>(new Set())
|
const [expanded, setExpanded] = useState<Set<string>>(new Set())
|
||||||
const [showResolved, setShowResolved] = useState(false)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [accepting, setAccepting] = useState<Finding | null>(null)
|
const [accepting, setAccepting] = useState<Finding | null>(null)
|
||||||
const [reason, setReason] = useState("")
|
const [reason, setReason] = useState("")
|
||||||
const [expiryDays, setExpiryDays] = useState<string>("")
|
const [expiryDays, setExpiryDays] = useState<string>("")
|
||||||
const [saving, setSaving] = useState(false)
|
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<Array<{ id: string; runs_checks: boolean }>>([])
|
||||||
|
const [building, setBuilding] = useState(false)
|
||||||
|
|
||||||
const loadRun = useCallback(async (runId: string) => {
|
const loadRun = useCallback(async (runId: string) => {
|
||||||
try {
|
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 || [])
|
if (data?.success) setFindings(data.findings || [])
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : String(e))
|
setError(e instanceof Error ? e.message : String(e))
|
||||||
@@ -86,6 +140,7 @@ export function AuditReport() {
|
|||||||
const data: any = await fetchApi("/api/audit/status")
|
const data: any = await fetchApi("/api/audit/status")
|
||||||
if (!data?.success) return
|
if (!data?.success) return
|
||||||
setRunning(Boolean(data.running))
|
setRunning(Boolean(data.running))
|
||||||
|
setProgress({ completed: data.progress?.completed || 0, total: data.progress?.total || 0 })
|
||||||
setSummary(data.summary || {})
|
setSummary(data.summary || {})
|
||||||
setLatest(data.latest || null)
|
setLatest(data.latest || null)
|
||||||
if (data.latest?.run_id) await loadRun(data.latest.run_id)
|
if (data.latest?.run_id) await loadRun(data.latest.run_id)
|
||||||
@@ -97,8 +152,29 @@ export function AuditReport() {
|
|||||||
}
|
}
|
||||||
}, [loadRun])
|
}, [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])
|
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
|
// 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.
|
// interval is dropped so an idle tab does not keep waking the backend.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -112,7 +188,7 @@ export function AuditReport() {
|
|||||||
try {
|
try {
|
||||||
const data: any = await fetchApi("/api/audit/run", {
|
const data: any = await fetchApi("/api/audit/run", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ profile: "full" }),
|
body: JSON.stringify({ profile }),
|
||||||
})
|
})
|
||||||
if (data?.success) setRunning(true)
|
if (data?.success) setRunning(true)
|
||||||
else setError(data?.message || t("audit.errors.runFailed"))
|
else setError(data?.message || t("audit.errors.runFailed"))
|
||||||
@@ -130,6 +206,7 @@ export function AuditReport() {
|
|||||||
try {
|
try {
|
||||||
const body: Record<string, unknown> = {
|
const body: Record<string, unknown> = {
|
||||||
check_id: accepting.check_id,
|
check_id: accepting.check_id,
|
||||||
|
run_id: latest?.run_id,
|
||||||
reason: reason.trim(),
|
reason: reason.trim(),
|
||||||
}
|
}
|
||||||
if (expiryDays) body.expires_in_days = Number(expiryDays)
|
if (expiryDays) body.expires_in_days = Number(expiryDays)
|
||||||
@@ -163,17 +240,20 @@ export function AuditReport() {
|
|||||||
[findings],
|
[findings],
|
||||||
)
|
)
|
||||||
|
|
||||||
const visible = useMemo(() => {
|
// Every check is listed, worst first. Hiding what passed made the
|
||||||
const quiet = new Set(["pass", "not_applicable"])
|
// reader guess whether a check was clean or had not run, which is
|
||||||
return findings
|
// exactly the distinction this page exists to keep.
|
||||||
|
const visible = useMemo(() =>
|
||||||
|
findings
|
||||||
.filter((f) => areaFilter === "all" || f.area === areaFilter)
|
.filter((f) => areaFilter === "all" || f.area === areaFilter)
|
||||||
.filter((f) => showResolved || !quiet.has(f.state))
|
|
||||||
.sort((a, b) =>
|
.sort((a, b) =>
|
||||||
(STATE_RANK[a.state] ?? 9) - (STATE_RANK[b.state] ?? 9) ||
|
(CLASS_RANK[shownAs(a)] ?? 9) - (CLASS_RANK[shownAs(b)] ?? 9) ||
|
||||||
a.check_id.localeCompare(b.check_id))
|
a.check_id.localeCompare(b.check_id)),
|
||||||
}, [findings, areaFilter, showResolved])
|
[findings, areaFilter])
|
||||||
|
|
||||||
const acceptedCount = summary.accepted || 0
|
const acceptedCount = summary.accepted || 0
|
||||||
|
const unverifiedChecks = findings.filter(
|
||||||
|
(f) => f.classification === "unverified" || f.incomplete)
|
||||||
const ageDays = latest?.finished_at
|
const ageDays = latest?.finished_at
|
||||||
? Math.floor((Date.now() / 1000 - latest.finished_at) / 86400)
|
? Math.floor((Date.now() / 1000 - latest.finished_at) / 86400)
|
||||||
: null
|
: null
|
||||||
@@ -184,6 +264,7 @@ export function AuditReport() {
|
|||||||
// correctly under another. A check that failed to evaluate has no
|
// correctly under another. A check that failed to evaluate has no
|
||||||
// per-check entry, hence the shared fallback.
|
// per-check entry, hence the shared fallback.
|
||||||
const summaryOf = (f: Finding) => {
|
const summaryOf = (f: Finding) => {
|
||||||
|
if (f.check_id === "backup.last_backup_age" && f.affected.length) return resultBreakdown(f, t)
|
||||||
if (!f.summary_key) return ""
|
if (!f.summary_key) return ""
|
||||||
const params = Object.fromEntries(
|
const params = Object.fromEntries(
|
||||||
Object.entries(f.summary_params || {}).map(([k, v]) => [k, String(v)]),
|
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
|
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) => {
|
const toggle = (id: string) => {
|
||||||
setExpanded((prev) => {
|
setExpanded((prev) => {
|
||||||
const next = new Set(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 = (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={generateDocument}
|
||||||
|
disabled={building}
|
||||||
|
// Related to running an assessment, and secondary to it: the same
|
||||||
|
// hue as that button, at the translucent weight the chips use.
|
||||||
|
// Green is taken — here it means a conformant result, and this
|
||||||
|
// report may be full of critical ones.
|
||||||
|
className="shrink-0 border-blue-500/20 bg-blue-500/10 text-blue-500
|
||||||
|
hover:bg-blue-500/20 hover:text-blue-500"
|
||||||
|
>
|
||||||
|
{building
|
||||||
|
? <Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
: <FileText className="h-4 w-4 mr-2" />}
|
||||||
|
{/* The icon carries the meaning where the width is short. */}
|
||||||
|
<span className="sm:hidden">{t("audit.document.actionShort")}</span>
|
||||||
|
<span className="hidden sm:inline">{t("audit.document.action")}</span>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
|
||||||
|
const profilePicker = profiles.length > 0 ? (
|
||||||
|
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm sm:w-auto sm:flex-none">
|
||||||
|
<Label htmlFor="audit-profile" className="hidden shrink-0 text-muted-foreground sm:inline">
|
||||||
|
{t("audit.profile.label")}
|
||||||
|
</Label>
|
||||||
|
<Select value={profile} onValueChange={setProfile}>
|
||||||
|
<SelectTrigger id="audit-profile" className="min-w-0 flex-1 sm:w-56 sm:flex-none">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{profiles.map((p) => (
|
||||||
|
<SelectItem key={p.id} value={p.id}>{t(`audit.profile.${p.id}`)}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
) : null
|
||||||
|
|
||||||
|
const viewTabs = (
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
aria-label={t("audit.viewSwitch.ariaLabel")}
|
||||||
|
className="flex w-full rounded-lg border border-border bg-muted/40 p-1 gap-1
|
||||||
|
sm:inline-flex sm:w-auto"
|
||||||
|
>
|
||||||
|
{(["assessment", "inventory", "changes", "policy"] as const).map((key) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={view === key}
|
||||||
|
onClick={() => setView(key)}
|
||||||
|
className={`flex-1 inline-flex items-center justify-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors sm:flex-none ${
|
||||||
|
view === key
|
||||||
|
? "bg-blue-500 text-white shadow-sm"
|
||||||
|
: "text-muted-foreground hover:text-foreground hover:bg-background/60"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t(`audit.viewSwitch.${key}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center sm:gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ClipboardCheck className="h-6 w-6 shrink-0 text-foreground" />
|
||||||
|
<h2 className="text-xl lg:text-2xl font-bold text-foreground">{t("audit.title")}</h2>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2 sm:ml-auto sm:flex-row sm:flex-wrap
|
||||||
|
sm:items-center sm:gap-3">
|
||||||
|
{viewTabs}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<AuditChanges />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (view === "policy") {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center sm:gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ClipboardCheck className="h-6 w-6 shrink-0 text-foreground" />
|
||||||
|
<h2 className="text-xl lg:text-2xl font-bold text-foreground">{t("audit.title")}</h2>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2 sm:ml-auto sm:flex-row sm:flex-wrap
|
||||||
|
sm:items-center sm:gap-3">
|
||||||
|
{viewTabs}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<AuditPolicy />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (view === "inventory") {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 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. */}
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center sm:gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ClipboardCheck className="h-6 w-6 shrink-0 text-foreground" />
|
||||||
|
<h2 className="text-xl lg:text-2xl font-bold text-foreground">{t("audit.title")}</h2>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2 sm:ml-auto sm:flex-row sm:flex-wrap
|
||||||
|
sm:items-center sm:gap-3">
|
||||||
|
<div className="flex items-center gap-2 sm:contents">
|
||||||
|
{profilePicker}{documentButton}
|
||||||
|
</div>
|
||||||
|
{viewTabs}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<AuditInventory profile={profile} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center sm:gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ClipboardCheck className="h-6 w-6 shrink-0 text-foreground" />
|
||||||
|
<h2 className="text-xl lg:text-2xl font-bold text-foreground">{t("audit.title")}</h2>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2 sm:ml-auto sm:flex-row sm:flex-wrap
|
||||||
|
sm:items-center sm:gap-3">
|
||||||
|
<div className="flex items-center gap-2 sm:contents">
|
||||||
|
{profilePicker}{documentButton}
|
||||||
|
</div>
|
||||||
|
{viewTabs}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<Card className="bg-card border-border">
|
<Card className="bg-card border-border">
|
||||||
<CardHeader className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
<CardHeader className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<CardTitle className="flex items-center gap-2 text-xl lg:text-2xl font-bold text-foreground">
|
|
||||||
<ClipboardCheck className="h-6 w-6" />
|
|
||||||
{t("audit.title")}
|
|
||||||
</CardTitle>
|
|
||||||
{/* Stated before any count: an assessment nobody has run, or
|
{/* Stated before any count: an assessment nobody has run, or
|
||||||
one run months ago, does not describe this host today. */}
|
one run months ago, does not describe this host today. */}
|
||||||
{!latest ? (
|
{!latest ? (
|
||||||
@@ -233,6 +483,29 @@ export function AuditReport() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<p className="text-xs text-muted-foreground">{t("audit.readOnlyNotice")}</p>
|
<p className="text-xs text-muted-foreground">{t("audit.readOnlyNotice")}</p>
|
||||||
|
{running && <p role="status" className="text-sm text-muted-foreground">
|
||||||
|
{t("audit.progress", { completed: String(progress.completed), total: String(progress.total) })}
|
||||||
|
</p>}
|
||||||
|
{/* 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") && (
|
||||||
|
<div
|
||||||
|
{...(latest.status === "failed" ? { role: "alert" as const } : {})}
|
||||||
|
className={`space-y-1 text-sm ${
|
||||||
|
latest.status === "failed" ? "text-amber-500" : "text-muted-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<p>{t(`audit.runStates.${latest.status}`)}</p>
|
||||||
|
{unverifiedChecks.length > 0 && <p className="text-xs">
|
||||||
|
{t("audit.unverifiedChecks", { checks: unverifiedChecks
|
||||||
|
.map((f) => t(`audit.checks.${f.check_id}.title`)).join(" · ") })}
|
||||||
|
</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{latest?.error && <p className="text-xs text-amber-500">{latest.error}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@@ -248,21 +521,34 @@ export function AuditReport() {
|
|||||||
|
|
||||||
{latest && (
|
{latest && (
|
||||||
<CardContent className="pt-0 space-y-3">
|
<CardContent className="pt-0 space-y-3">
|
||||||
<div className="flex flex-wrap gap-2">
|
{/* One row of counters on one scale. Gravity is the
|
||||||
{(["fail", "warn", "accepted", "pass", "not_applicable"] as const)
|
classification itself, so there is nothing left to
|
||||||
.filter((s) => summary[s])
|
reconcile between two sets of numbers. */}
|
||||||
.map((s) => {
|
<div role="group" aria-label={t("audit.results")}
|
||||||
const { chip, Icon } = STATE_STYLE[s]
|
className="flex max-w-full flex-wrap items-center gap-2">
|
||||||
|
{(["critical", "warning", "observation", "unverified",
|
||||||
|
"accepted", "conformant", "not_applicable"] as const)
|
||||||
|
.filter((c) => summary[c])
|
||||||
|
.map((c) => {
|
||||||
|
const { chip, Icon } = CLASS_STYLE[c]
|
||||||
return (
|
return (
|
||||||
<Badge key={s} variant="outline" className={`${chip} gap-1.5`}>
|
<Badge key={c} variant="outline" className={`${SUMMARY_BADGE_CLASS} ${chip}`}>
|
||||||
<Icon className="h-3.5 w-3.5" />
|
<Icon className="h-3.5 w-3.5" />
|
||||||
{t(`audit.states.${s}`)}
|
{t(`audit.classifications.${c}`)}
|
||||||
<span className="tabular-nums font-semibold">{summary[s]}</span>
|
<span className="tabular-nums font-semibold">{summary[c]}</span>
|
||||||
</Badge>
|
</Badge>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* What changed since a reference run: context for the
|
||||||
|
assessment being read, not a place of its own. */}
|
||||||
|
<AuditComparison
|
||||||
|
runId={latest.run_id}
|
||||||
|
isBaseline={Boolean(latest.is_baseline)}
|
||||||
|
onBaselineSet={refresh}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -289,13 +575,6 @@ export function AuditReport() {
|
|||||||
{t(`audit.areas.${a}`)}
|
{t(`audit.areas.${a}`)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowResolved((v) => !v)}
|
|
||||||
className="ml-auto text-sm text-muted-foreground hover:text-foreground underline underline-offset-2"
|
|
||||||
>
|
|
||||||
{showResolved ? t("audit.hidePassing") : t("audit.showPassing")}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* The count of accepted risks stays visible even when the
|
{/* The count of accepted risks stays visible even when the
|
||||||
@@ -324,9 +603,10 @@ export function AuditReport() {
|
|||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{visible.map((f) => {
|
{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 open = expanded.has(f.check_id)
|
||||||
const muted = f.state === "accepted" || f.state === "not_applicable"
|
const muted = shown === "accepted" || shown === "not_applicable"
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
key={f.check_id}
|
key={f.check_id}
|
||||||
@@ -336,14 +616,14 @@ export function AuditReport() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => toggle(f.check_id)}
|
onClick={() => toggle(f.check_id)}
|
||||||
aria-expanded={open}
|
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
|
{open
|
||||||
? <ChevronDown className="h-4 w-4 mt-1 shrink-0 text-muted-foreground" />
|
? <ChevronDown className="h-4 w-4 mt-1 shrink-0 text-muted-foreground" />
|
||||||
: <ChevronRight className="h-4 w-4 mt-1 shrink-0 text-muted-foreground" />}
|
: <ChevronRight className="h-4 w-4 mt-1 shrink-0 text-muted-foreground" />}
|
||||||
<Badge variant="outline" className={`${chip} gap-1.5 shrink-0`}>
|
<Badge variant="outline" className={`${chip} gap-1.5 shrink-0`}>
|
||||||
<Icon className="h-3.5 w-3.5" />
|
<Icon className="h-3.5 w-3.5" />
|
||||||
{t(`audit.states.${f.state}`)}
|
{t(`audit.classifications.${shown}`)}
|
||||||
</Badge>
|
</Badge>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
@@ -353,15 +633,28 @@ export function AuditReport() {
|
|||||||
<Badge variant="outline" className="text-xs">
|
<Badge variant="outline" className="text-xs">
|
||||||
{t(`audit.areas.${f.area}`)}
|
{t(`audit.areas.${f.area}`)}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
{f.incomplete && <Badge variant="outline" className="text-xs text-amber-500">
|
||||||
|
{t("audit.incomplete")}
|
||||||
|
</Badge>}
|
||||||
{f.affected.length > 0 && (
|
{f.affected.length > 0 && (
|
||||||
<Badge variant="outline" className="text-xs tabular-nums">
|
<Badge variant="outline" className="text-xs tabular-nums">
|
||||||
{t("audit.affectedCount", { count: String(f.affected.length) })}
|
{affectedDescription(f, t)}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{f.summary_key && (
|
|
||||||
|
{(f.summary_key || notApplicableText(f)) && (
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
{summaryOf(f)}
|
{f.summary_key ? summaryOf(f) : notApplicableText(f)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{/* "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) && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
{unreadSources(f.sources, t)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -387,41 +680,42 @@ export function AuditReport() {
|
|||||||
<p className="text-xs text-muted-foreground mt-1">
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
{f.exception.accepted_by} ·{" "}
|
{f.exception.accepted_by} ·{" "}
|
||||||
{new Date(f.exception.accepted_at * 1000).toLocaleDateString()}
|
{new Date(f.exception.accepted_at * 1000).toLocaleDateString()}
|
||||||
|
{f.exception.expires_at && <> · {t("audit.expires", {
|
||||||
|
when: new Date(f.exception.expires_at * 1000).toLocaleString(),
|
||||||
|
})}</>}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{f.affected.length > 0 && (
|
{/* Some checks carry a useful, structured positive reading
|
||||||
<div>
|
in their evidence even when nothing is affected. The
|
||||||
<p className="text-xs font-medium text-muted-foreground mb-1">
|
presenter returns no groups for checks without such a
|
||||||
{t("audit.detail.affected")}
|
view, so rendering it unconditionally does not create
|
||||||
</p>
|
empty space. */}
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<AuditFindingData finding={f} t={t} locale={language} />
|
||||||
{f.affected.map((o, i) => (
|
|
||||||
<Badge key={i} variant="outline" className="text-xs font-mono">
|
|
||||||
{Object.values(o).filter(Boolean).join(" · ")}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{f.evidence && (
|
{f.evidence && (
|
||||||
<div>
|
<details className="text-sm">
|
||||||
<p className="text-xs font-medium text-muted-foreground mb-1">
|
<summary className="cursor-pointer text-muted-foreground mb-2">
|
||||||
{t("audit.detail.evidence")}
|
{t("audit.presentation.technical")}
|
||||||
</p>
|
</summary>
|
||||||
{/* Wide command output scrolls inside its own box so
|
<AuditEvidence evidence={f.evidence} locale={language} />
|
||||||
the page itself never scrolls sideways. */}
|
</details>
|
||||||
<pre className="text-xs font-mono bg-background border border-border rounded-md p-3 overflow-x-auto whitespace-pre">
|
|
||||||
{f.evidence}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
{f.sources && f.sources.length > 0 && <details className="text-xs text-muted-foreground">
|
||||||
|
<summary className="cursor-pointer">{t("audit.detail.sources")}</summary>
|
||||||
|
<ul className="mt-2 space-y-1">
|
||||||
|
{f.sources.map((source) => <li key={source.source} className="break-all">
|
||||||
|
{source.source} · {new Date(source.collected_at * 1000).toLocaleString()}
|
||||||
|
{source.error && <span className="text-amber-500"> · {source.error}</span>}
|
||||||
|
</li>)}
|
||||||
|
</ul>
|
||||||
|
</details>}
|
||||||
|
|
||||||
{/* Only an active finding can be accepted, and only an
|
{/* Only an active finding can be accepted, and only an
|
||||||
accepted one can be returned to the active set. */}
|
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 && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -431,7 +725,7 @@ export function AuditReport() {
|
|||||||
{t("audit.acceptRisk.action")}
|
{t("audit.acceptRisk.action")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{f.state === "accepted" && (
|
{f.decision === "accepted" && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -485,17 +779,16 @@ export function AuditReport() {
|
|||||||
<p className="text-xs text-muted-foreground mt-0.5 mb-2">
|
<p className="text-xs text-muted-foreground mt-0.5 mb-2">
|
||||||
{t("audit.acceptRisk.expiryHelp")}
|
{t("audit.acceptRisk.expiryHelp")}
|
||||||
</p>
|
</p>
|
||||||
<select
|
<Select value={expiryDays || "none"}
|
||||||
id="audit-expiry"
|
onValueChange={(v) => setExpiryDays(v === "none" ? "" : v)}>
|
||||||
value={expiryDays}
|
<SelectTrigger id="audit-expiry" className="w-full"><SelectValue /></SelectTrigger>
|
||||||
onChange={(e) => setExpiryDays(e.target.value)}
|
<SelectContent>
|
||||||
className="w-full rounded-md border border-border bg-background p-2 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-ring"
|
<SelectItem value="none">{t("audit.acceptRisk.expiryNever")}</SelectItem>
|
||||||
>
|
<SelectItem value="90">{t("audit.acceptRisk.expiry90")}</SelectItem>
|
||||||
<option value="">{t("audit.acceptRisk.expiryNever")}</option>
|
<SelectItem value="180">{t("audit.acceptRisk.expiry180")}</SelectItem>
|
||||||
<option value="90">{t("audit.acceptRisk.expiry90")}</option>
|
<SelectItem value="365">{t("audit.acceptRisk.expiry365")}</SelectItem>
|
||||||
<option value="180">{t("audit.acceptRisk.expiry180")}</option>
|
</SelectContent>
|
||||||
<option value="365">{t("audit.acceptRisk.expiry365")}</option>
|
</Select>
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1205,27 +1205,42 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
|
|||||||
web_path: link.web_path || "/",
|
web_path: link.web_path || "/",
|
||||||
logo_url: link.logo_url || "",
|
logo_url: link.logo_url || "",
|
||||||
}
|
}
|
||||||
const last = draft.ports[draft.ports.length - 1]
|
setDetectorTest(null)
|
||||||
const indexAfterAdd = (last && last.port === "" && !last.description)
|
setEditing((current) => {
|
||||||
? draft.ports.length - 1
|
if (!current) return current
|
||||||
: draft.ports.length
|
const currentPorts = current.draft.ports
|
||||||
if (indexAfterAdd === draft.ports.length) {
|
// The buttons disappear after a port is added, but guard the state
|
||||||
setField({ ports: [...draft.ports, entry] })
|
// update too so a double click can never create duplicate links.
|
||||||
} else {
|
if (currentPorts.some((port) => port.port === link.host_port)) return current
|
||||||
const ports = [...draft.ports]
|
const ports = [...currentPorts]
|
||||||
ports[indexAfterAdd] = entry
|
const last = ports[ports.length - 1]
|
||||||
setField({ ports })
|
if (last && last.port === "" && !last.description) ports[ports.length - 1] = entry
|
||||||
}
|
else ports.push(entry)
|
||||||
|
return { ...current, draft: { ...current.draft, ports } }
|
||||||
|
})
|
||||||
// Ask the backend whether this service_name has a known catalog
|
// Ask the backend whether this service_name has a known catalog
|
||||||
// category and, if so, patch the just-inserted port so the user
|
// category and, if so, patch the just-inserted port so the user
|
||||||
// finds it pre-selected instead of having to open the dropdown.
|
// finds it pre-selected instead of having to open the dropdown.
|
||||||
// Non-blocking — the port is already visible either way.
|
// This must be a functional update: the response can arrive after the
|
||||||
|
// user has added or edited more links, and must never restore the old
|
||||||
|
// draft captured by this render.
|
||||||
const q = (link.service_name || "").trim()
|
const q = (link.service_name || "").trim()
|
||||||
if (q) {
|
if (q) {
|
||||||
fetchApi<{ category: string | null }>(`/api/apps/suggest_category?name=${encodeURIComponent(q)}`)
|
fetchApi<{ category: string | null }>(`/api/apps/suggest_category?name=${encodeURIComponent(q)}`)
|
||||||
.then((r) => {
|
.then((r) => {
|
||||||
if (!r?.category) return
|
if (!r?.category) return
|
||||||
setPort(indexAfterAdd, { category: r.category })
|
setEditing((current) => {
|
||||||
|
if (!current) return current
|
||||||
|
let changed = false
|
||||||
|
const ports = current.draft.ports.map((port) => {
|
||||||
|
if (port.port !== link.host_port || port.category) return port
|
||||||
|
changed = true
|
||||||
|
return { ...port, category: r.category || undefined }
|
||||||
|
})
|
||||||
|
return changed
|
||||||
|
? { ...current, draft: { ...current.draft, ports } }
|
||||||
|
: current
|
||||||
|
})
|
||||||
})
|
})
|
||||||
.catch(() => { /* non-fatal — user can pick manually */ })
|
.catch(() => { /* non-fatal — user can pick manually */ })
|
||||||
}
|
}
|
||||||
@@ -1235,15 +1250,32 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
|
|||||||
const usedPorts = new Set(draft.ports.map((p) => p.port))
|
const usedPorts = new Set(draft.ports.map((p) => p.port))
|
||||||
const isDockerDraft = draft.helper_slug === "docker" ||
|
const isDockerDraft = draft.helper_slug === "docker" ||
|
||||||
(draft.installed_via === "binary" && draft.binary_path?.endsWith("/docker"))
|
(draft.installed_via === "binary" && draft.binary_path?.endsWith("/docker"))
|
||||||
|
// A recognised Docker workload belongs in one place at a time. Keep its
|
||||||
|
// links out of Docker while it is offered (or already registered) as an
|
||||||
|
// independent app. Dismissing that detection makes the links available
|
||||||
|
// under Docker again.
|
||||||
|
const independentDockerWorkloadSlugs = new Set(
|
||||||
|
(suggestions?.docker_workloads || [])
|
||||||
|
.filter((workload) => !dismissedSlugs.has(workload.slug))
|
||||||
|
.map((workload) => workload.slug),
|
||||||
|
)
|
||||||
const suggestableDockerLinks = isDockerDraft
|
const suggestableDockerLinks = isDockerDraft
|
||||||
? (suggestions?.docker_web_links || []).filter((link) => !usedPorts.has(link.host_port))
|
? (suggestions?.docker_web_links || []).filter(
|
||||||
|
(link) =>
|
||||||
|
!usedPorts.has(link.host_port) &&
|
||||||
|
(!link.service_slug || !independentDockerWorkloadSlugs.has(link.service_slug)),
|
||||||
|
)
|
||||||
: []
|
: []
|
||||||
// A Docker registration uses structured container → published-port
|
// Keep the generic ss/netstat probe available for Docker too. It covers
|
||||||
// suggestions below. Suppress the generic ss/netstat chips in that case
|
// host-networked services and listeners that Docker does not expose in
|
||||||
// so the same endpoint is not presented twice without its workload name.
|
// NetworkSettings.Ports. Published ports already represented by a named
|
||||||
const suggestable = isDockerDraft
|
// Docker workload stay deduplicated from the generic chips.
|
||||||
? []
|
const dockerPublishedPorts = new Set(
|
||||||
: (suggestions?.port_suggestions || []).filter((p) => !usedPorts.has(p))
|
(suggestions?.docker_web_links || []).map((link) => link.host_port),
|
||||||
|
)
|
||||||
|
const suggestable = (suggestions?.port_suggestions || []).filter(
|
||||||
|
(port) => !usedPorts.has(port) && (!isDockerDraft || !dockerPublishedPorts.has(port)),
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|||||||
@@ -778,6 +778,7 @@ export function ProxmoxDashboard() {
|
|||||||
<React.Fragment key="admin">
|
<React.Fragment key="admin">
|
||||||
{btn("logs", ScrollText, t("navigation.systemLogs"))}
|
{btn("logs", ScrollText, t("navigation.systemLogs"))}
|
||||||
{btn("security", ShieldCheck, t("navigation.security"))}
|
{btn("security", ShieldCheck, t("navigation.security"))}
|
||||||
|
{btn("audit", ClipboardCheck, t("navigation.audit"))}
|
||||||
{btn("settings", SettingsIcon, t("navigation.settings"))}
|
{btn("settings", SettingsIcon, t("navigation.settings"))}
|
||||||
{btn("about", Info, t("navigation.about"))}
|
{btn("about", Info, t("navigation.about"))}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
|
|||||||
@@ -81,6 +81,12 @@ export function Security() {
|
|||||||
if (normalized.includes("invalid 2fa code")) {
|
if (normalized.includes("invalid 2fa code")) {
|
||||||
return st("errors.invalid2faCode")
|
return st("errors.invalid2faCode")
|
||||||
}
|
}
|
||||||
|
if (normalized.includes("2fa code required")) {
|
||||||
|
return st("errors.enter2faOrBackup")
|
||||||
|
}
|
||||||
|
if (normalized.includes("current password is incorrect")) {
|
||||||
|
return st("errors.invalidPassword")
|
||||||
|
}
|
||||||
if (normalized.includes("invalid password")) {
|
if (normalized.includes("invalid password")) {
|
||||||
return st("errors.invalidPassword")
|
return st("errors.invalidPassword")
|
||||||
}
|
}
|
||||||
@@ -103,6 +109,7 @@ export function Security() {
|
|||||||
const [currentPassword, setCurrentPassword] = useState("")
|
const [currentPassword, setCurrentPassword] = useState("")
|
||||||
const [newPassword, setNewPassword] = useState("")
|
const [newPassword, setNewPassword] = useState("")
|
||||||
const [confirmNewPassword, setConfirmNewPassword] = useState("")
|
const [confirmNewPassword, setConfirmNewPassword] = useState("")
|
||||||
|
const [changePasswordTotpCode, setChangePasswordTotpCode] = useState("")
|
||||||
|
|
||||||
const [show2FASetup, setShow2FASetup] = useState(false)
|
const [show2FASetup, setShow2FASetup] = useState(false)
|
||||||
const [show2FADisable, setShow2FADisable] = useState(false)
|
const [show2FADisable, setShow2FADisable] = useState(false)
|
||||||
@@ -976,6 +983,11 @@ export function Security() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (totpEnabled && !changePasswordTotpCode.trim()) {
|
||||||
|
setError(st("errors.enter2faOrBackup"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const pwError = validatePasswordStrength(newPassword, t)
|
const pwError = validatePasswordStrength(newPassword, t)
|
||||||
if (pwError) {
|
if (pwError) {
|
||||||
setError(pwError)
|
setError(pwError)
|
||||||
@@ -992,8 +1004,9 @@ export function Security() {
|
|||||||
Authorization: `Bearer ${localStorage.getItem("proxmenux-auth-token")}`,
|
Authorization: `Bearer ${localStorage.getItem("proxmenux-auth-token")}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
current_password: currentPassword,
|
old_password: currentPassword,
|
||||||
new_password: newPassword,
|
new_password: newPassword,
|
||||||
|
...(totpEnabled ? { totp_code: changePasswordTotpCode.trim() } : {}),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1012,6 +1025,7 @@ export function Security() {
|
|||||||
setCurrentPassword("")
|
setCurrentPassword("")
|
||||||
setNewPassword("")
|
setNewPassword("")
|
||||||
setConfirmNewPassword("")
|
setConfirmNewPassword("")
|
||||||
|
setChangePasswordTotpCode("")
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : st("errors.changePasswordFailed"))
|
setError(err instanceof Error ? err.message : st("errors.changePasswordFailed"))
|
||||||
} finally {
|
} finally {
|
||||||
@@ -2012,6 +2026,22 @@ ${(report.sections && report.sections.length > 0) ? `
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{totpEnabled && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="change-password-totp">{st("twoFactor.codeOrBackup")}</Label>
|
||||||
|
<Input
|
||||||
|
id="change-password-totp"
|
||||||
|
type="text"
|
||||||
|
inputMode="text"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
placeholder={st("twoFactor.codeOrBackupPlaceholder")}
|
||||||
|
value={changePasswordTotpCode}
|
||||||
|
onChange={(e) => setChangePasswordTotpCode(e.target.value)}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
onClick={handleChangePassword}
|
onClick={handleChangePassword}
|
||||||
@@ -2021,7 +2051,13 @@ ${(report.sections && report.sections.length > 0) ? `
|
|||||||
{loading ? st("auth.changing") : st("auth.changePassword")}
|
{loading ? st("auth.changing") : st("auth.changePassword")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setShowChangePassword(false)}
|
onClick={() => {
|
||||||
|
setShowChangePassword(false)
|
||||||
|
setCurrentPassword("")
|
||||||
|
setNewPassword("")
|
||||||
|
setConfirmNewPassword("")
|
||||||
|
setChangePasswordTotpCode("")
|
||||||
|
}}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { Badge } from "./ui/badge"
|
|||||||
import { Progress } from "./ui/progress"
|
import { Progress } from "./ui/progress"
|
||||||
import { Button } from "./ui/button"
|
import { Button } from "./ui/button"
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "./ui/dialog"
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "./ui/dialog"
|
||||||
import { Server, Play, Square, Cpu, MemoryStick, HardDrive, Network, Power, RotateCcw, StopCircle, Container, ChevronDown, ChevronUp, ChevronRight, Terminal, Archive, Plus, PlusCircle, Loader2, Clock, Database, Shield, Bell, FileText, Settings2, Activity, Package, RefreshCw, EthernetPort, ArrowUpCircle, Info, CheckCircle2, EyeOff, Eye, Trash2, Check, X, AlertTriangle, AlertCircle, ExternalLink, Search, Tag as TagIcon } from 'lucide-react'
|
import { Server, Play, Square, Cpu, MemoryStick, HardDrive, Network, Power, RotateCcw, StopCircle, Container, ChevronDown, ChevronUp, ChevronRight, Terminal, Archive, Plus, PlusCircle, Loader2, Clock, Database, Shield, Bell, FileText, Settings2, Activity, Package, RefreshCw, EthernetPort, ArrowUpCircle, Info, CheckCircle2, EyeOff, Eye, Trash2, Check, X, AlertTriangle, AlertCircle, Search, Tag as TagIcon } from 'lucide-react'
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
|
||||||
import { Checkbox } from "./ui/checkbox"
|
import { Checkbox } from "./ui/checkbox"
|
||||||
import { Switch } from "./ui/switch"
|
import { Switch } from "./ui/switch"
|
||||||
@@ -281,17 +281,6 @@ function hasLxcPendingUpdates(vm: VMData): boolean {
|
|||||||
return osUpdates + appUpdates + dockerUpdates + delegatedUpdates > 0
|
return osUpdates + appUpdates + dockerUpdates + delegatedUpdates > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildRegisteredAppUrl(vm: VMData, port?: LxcAppPort): string | null {
|
|
||||||
const custom = (port?.custom_url || "").trim()
|
|
||||||
if (custom) return custom
|
|
||||||
const rawIp = (vm.ip || "").trim().split("/")[0]
|
|
||||||
if (!rawIp || rawIp === "DHCP" || !port?.port) return null
|
|
||||||
const host = rawIp.includes(":") && !rawIp.startsWith("[") ? `[${rawIp}]` : rawIp
|
|
||||||
const scheme = port.scheme || ([443, 8443, 9443].includes(port.port) ? "https" : "http")
|
|
||||||
const path = port.web_path ? `/${port.web_path.replace(/^\/+/, "")}` : ""
|
|
||||||
return `${scheme}://${host}:${port.port}${path}`
|
|
||||||
}
|
|
||||||
|
|
||||||
interface VMConfig {
|
interface VMConfig {
|
||||||
cores?: number
|
cores?: number
|
||||||
memory?: number
|
memory?: number
|
||||||
@@ -5210,26 +5199,31 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
|||||||
const helperKnownNotUpdateable = !helperExists && !!uc?.helper_slug && uc?.helper_slug_source === "update_wrapper" && !!uc?.helper_updateable_known
|
const helperKnownNotUpdateable = !helperExists && !!uc?.helper_slug && uc?.helper_slug_source === "update_wrapper" && !!uc?.helper_updateable_known
|
||||||
const helperUnlisted = !helperExists && !!uc?.helper_slug && uc?.helper_slug_source === "update_wrapper" && !uc?.helper_updateable_known
|
const helperUnlisted = !helperExists && !!uc?.helper_slug && uc?.helper_slug_source === "update_wrapper" && !uc?.helper_updateable_known
|
||||||
// Registration, version tracking and update execution
|
// Registration, version tracking and update execution
|
||||||
// are independent capabilities. Every saved app belongs
|
// are independent capabilities. Docker-delegated apps
|
||||||
// in Updates; installed_via only controls whether a
|
// already have their complete lifecycle represented by
|
||||||
// version state can be shown.
|
// the image inventory, so rendering an app section for
|
||||||
|
// them would duplicate the same update state.
|
||||||
const registeredApps = (selectedVM.app_watches || []).filter(
|
const registeredApps = (selectedVM.app_watches || []).filter(
|
||||||
(a) => !a.managed_oci_app_id,
|
(a) => !a.managed_oci_app_id,
|
||||||
)
|
)
|
||||||
|
const independentlyUpdatedApps = registeredApps.filter(
|
||||||
|
(a) => a.update_via !== "docker",
|
||||||
|
)
|
||||||
const helperSectionDetected = uc?.helper_slug !== "docker"
|
const helperSectionDetected = uc?.helper_slug !== "docker"
|
||||||
&& (helperExists || helperKnownNotUpdateable || helperUnlisted || helperInferred)
|
&& (helperExists || helperKnownNotUpdateable || helperUnlisted || helperInferred)
|
||||||
const helperMatchingApps = registeredApps.filter(
|
const helperMatchingApps = independentlyUpdatedApps.filter(
|
||||||
(a) => !!a.helper_slug && a.helper_slug === uc?.helper_slug,
|
(a) => !!a.helper_slug && a.helper_slug === uc?.helper_slug,
|
||||||
)
|
)
|
||||||
const helperOnlyApps = helperMatchingApps.filter(
|
const helperOnlyApps = helperMatchingApps.filter(
|
||||||
(a) => !a.update_command,
|
(a) => !a.update_command,
|
||||||
)
|
)
|
||||||
// Every registered app gets exactly one Updates
|
// Every independently updated app gets exactly one
|
||||||
// section. Docker and the CT-wide helper identity use
|
// Updates section. Docker and the CT-wide helper
|
||||||
// their specialised sections; all other registrations
|
// identity use their specialised sections; all other
|
||||||
// use the generic section even when installed_via is
|
// registrations use the generic section even when
|
||||||
// empty (Web Link only) or dpkg/apk is OS-managed.
|
// installed_via is empty (Web Link only) or dpkg/apk
|
||||||
const appSections = registeredApps.filter((a) => {
|
// is OS-managed.
|
||||||
|
const appSections = independentlyUpdatedApps.filter((a) => {
|
||||||
// Docker owns a dedicated section containing
|
// Docker owns a dedicated section containing
|
||||||
// Engine and image lifecycles. Its command editor
|
// Engine and image lifecycles. Its command editor
|
||||||
// is rendered there so Docker never appears twice.
|
// is rendered there so Docker never appears twice.
|
||||||
@@ -5469,18 +5463,18 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
|||||||
return (
|
return (
|
||||||
<div className="divide-y divide-border/50">
|
<div className="divide-y divide-border/50">
|
||||||
{uc!.packages.map((p) => (
|
{uc!.packages.map((p) => (
|
||||||
<div key={p.name} className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-0.5 sm:gap-2 py-2 text-sm">
|
<div key={p.name} className="py-2 text-sm min-w-0">
|
||||||
<span className="font-mono text-foreground/90 flex items-center gap-2 min-w-0">
|
<span className="font-mono text-foreground/90 flex items-start gap-2 min-w-0">
|
||||||
{p.security && (
|
{p.security && (
|
||||||
<Shield className="h-4 w-4 text-green-500 flex-shrink-0" aria-label={t("vmLxc.updates.securityUpdateAria")} />
|
<Shield className="h-4 w-4 mt-0.5 text-green-500 flex-shrink-0" aria-label={t("vmLxc.updates.securityUpdateAria")} />
|
||||||
)}
|
)}
|
||||||
<span className="truncate">{p.name}</span>
|
<span className="break-all" title={p.name}>{p.name}</span>
|
||||||
</span>
|
|
||||||
<span className="flex items-center gap-1.5 text-muted-foreground flex-shrink-0 font-mono text-xs sm:text-sm">
|
|
||||||
<span>{p.current || "—"}</span>
|
|
||||||
<span>→</span>
|
|
||||||
<span className="text-foreground">{p.latest}</span>
|
|
||||||
</span>
|
</span>
|
||||||
|
<div className={`${p.security ? "pl-6" : ""} mt-1.5 grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-start gap-2 font-mono text-xs sm:text-sm`}>
|
||||||
|
<span className="text-muted-foreground break-all">{p.current || "—"}</span>
|
||||||
|
<span className="text-muted-foreground" aria-hidden="true">→</span>
|
||||||
|
<span className="text-foreground break-all">{p.latest}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -5739,14 +5733,14 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
|||||||
<div className="mt-1 text-xs text-muted-foreground flex items-center gap-1.5">
|
<div className="mt-1 text-xs text-muted-foreground flex items-center gap-1.5">
|
||||||
<Package className="h-3.5 w-3.5 flex-shrink-0" />
|
<Package className="h-3.5 w-3.5 flex-shrink-0" />
|
||||||
{image.installed_version ? (
|
{image.installed_version ? (
|
||||||
<span>
|
<span className={image.update_available === false ? "text-green-500" : undefined}>
|
||||||
{t("vmLxc.updates.installedLabel")} {" "}
|
{t("vmLxc.updates.installedLabel")} {" "}
|
||||||
<code className="text-foreground/80">{image.installed_version}</code>
|
<code className={image.update_available === false ? "text-green-500" : "text-foreground/80"}>{image.installed_version}</code>
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span>
|
<span className={image.update_available === false ? "text-green-500" : undefined}>
|
||||||
{t("vmLxc.updates.imageInstalledTag")} {" "}
|
{t("vmLxc.updates.imageInstalledTag")} {" "}
|
||||||
<code className="text-foreground/80">{image.tag}</code>
|
<code className={image.update_available === false ? "text-green-500" : "text-foreground/80"}>{image.tag}</code>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -5848,9 +5842,6 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
|||||||
const matchApp = helperOnlyApps[0] || null
|
const matchApp = helperOnlyApps[0] || null
|
||||||
if (!matchApp || customCmdEditingApp === matchApp.id) return null
|
if (!matchApp || customCmdEditingApp === matchApp.id) return null
|
||||||
const helperSelected = matchApp.update_method === "helper"
|
const helperSelected = matchApp.update_method === "helper"
|
||||||
const appWebUrl = helperUsesWebUpdater
|
|
||||||
? buildRegisteredAppUrl(selectedVM, matchApp.ports?.[0])
|
|
||||||
: null
|
|
||||||
const helperTracksVersion = !!matchApp.installed_via
|
const helperTracksVersion = !!matchApp.installed_via
|
||||||
const hasUpd = helperTracksVersion && matchApp.update_available === true
|
const hasUpd = helperTracksVersion && matchApp.update_available === true
|
||||||
const upToD = helperTracksVersion && matchApp.update_available === false && !!matchApp.installed_version
|
const upToD = helperTracksVersion && matchApp.update_available === false && !!matchApp.installed_version
|
||||||
@@ -5959,20 +5950,10 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
|||||||
)
|
)
|
||||||
})()}
|
})()}
|
||||||
{helperUsesWebUpdater && (
|
{helperUsesWebUpdater && (
|
||||||
<div className="mt-3 space-y-3">
|
<div className="mt-3">
|
||||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||||
{t("vmLxc.updates.adguardWebUpdateOnly")}
|
{t("vmLxc.updates.adguardWebUpdateOnly")}
|
||||||
</p>
|
</p>
|
||||||
{appWebUrl && (
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<Button size="sm" variant="outline" asChild>
|
|
||||||
<a href={appWebUrl} target="_blank" rel="noopener noreferrer">
|
|
||||||
<ExternalLink className="h-4 w-4 mr-1.5" />
|
|
||||||
{t("vmLxc.updates.openAdguard")}
|
|
||||||
</a>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!helperExists && !helperUsesWebUpdater && (
|
{!helperExists && !helperUsesWebUpdater && (
|
||||||
|
|||||||
@@ -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, string>) => 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<string, unknown>
|
||||||
|
affected: Array<Record<string, unknown>>; 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<string, string> = {
|
||||||
|
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 `<span class="chip ${esc(state)}"><span aria-hidden="true">${mark}</span> ${esc(label)}</span>`
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, string> = {}
|
||||||
|
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
|
||||||
|
? `<p class="evidence-title">${esc(block.title)}</p>` : ""
|
||||||
|
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 => `<div class="evidence-record">` + table([], block.columns.map((column, i) => [esc(column), esc(row[i])])) + `</div>`).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]) =>
|
||||||
|
[`<span class="muted">${esc(k)}</span>`, 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
|
||||||
|
? `<ul class="evidence-list">${lines.map((l) =>
|
||||||
|
`<li>${esc(l)}</li>`).join("")}</ul>` : "")
|
||||||
|
})
|
||||||
|
const notice = omitted && compact
|
||||||
|
? `<p class="evidence-excerpt-note">${esc(auditLabel(compact.t, "evidenceExcerpt"))}</p>` : ""
|
||||||
|
return `<div class="evidence-block">${parts.join("")}${notice}</div>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Assessment summary
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function executiveSummary(input: DocumentInput, n: number): string {
|
||||||
|
const { findings, t, locale } = input
|
||||||
|
const counts: Record<string, number> = {}
|
||||||
|
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<string, Record<string, number>> = {}
|
||||||
|
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) =>
|
||||||
|
`<span style="display:inline-flex;align-items:center;gap:5px;margin-right:14px">
|
||||||
|
<span style="width:10px;height:10px;border-radius:2px;display:inline-block;
|
||||||
|
background:${CLASS_COLOR[s]}"></span>${esc(t(`audit.classifications.${s}`))}</span>`).join("")
|
||||||
|
|
||||||
|
const body = `
|
||||||
|
<div class="exec-box">
|
||||||
|
<div class="audit-verification-ring">
|
||||||
|
<svg viewBox="0 0 120 120" aria-hidden="true">
|
||||||
|
<circle cx="60" cy="60" r="54" fill="none" stroke="#e2e8f0" stroke-width="5" />
|
||||||
|
<circle cx="60" cy="60" r="54" fill="none" stroke="currentColor" stroke-width="5"
|
||||||
|
pathLength="100" stroke-dasharray="${coverage} 100" transform="rotate(-90 60 60)" />
|
||||||
|
</svg>
|
||||||
|
<div class="audit-verification-value"><strong>${coverageValue}</strong>
|
||||||
|
<span>${esc(auditLabel(t, "verified"))}</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="exec-text">
|
||||||
|
<h3 class="audit-result-heading">${icon("summary", 22, "#64748b")}${esc(t("audit.document.verdictHeading"))}</h3>
|
||||||
|
${!findings.length ? `<p>${esc(t("audit.document.verdictText.none"))}</p>` : !applicable.length ? `<p>${esc(auditLabel(t, "noApplicable"))}</p>` : ""}
|
||||||
|
${incomplete ? `<p class="assessment-incomplete">${esc(auditLabel(t, "incomplete"))}</p>` : ""}
|
||||||
|
<p class="muted">${esc(auditLabel(t, "verificationScope"))}</p>
|
||||||
|
<p style="font-size:11px;color:#64748b;margin-top:6px">
|
||||||
|
${esc(t("audit.document.runAt", { date: when(input.run?.started_at, locale) }))}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="audit-counters">${[
|
||||||
|
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("")}</div>
|
||||||
|
${chart ? `<div class="diagram" style="margin-top:14px">
|
||||||
|
<p class="diagram-note">${esc(t("audit.document.chartNote"))}</p>${chart}
|
||||||
|
<div style="margin-top:10px;font-size:10px;color:#475569">${legend}</div>
|
||||||
|
</div>` : ""}`
|
||||||
|
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)}`)),
|
||||||
|
`<a href="#finding-${esc(f.check_id)}">${esc(t(`audit.checks.${f.check_id}.title`))}</a>`, 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 `<div class="audit-checks-inventory" id="${id}">` +
|
||||||
|
heading(`${title} · ${checks.length}`, "summary") + table(
|
||||||
|
[auditLabel(t, "checkName"), t("audit.document.area"), auditLabel(t, "result")],
|
||||||
|
sorted.map(f => [
|
||||||
|
`<a href="#finding-${esc(f.check_id)}">${esc(t(`audit.checks.${f.check_id}.title`))}</a>`,
|
||||||
|
esc(t(`audit.areas.${f.area}`)), chip(shownAs(f), t(`audit.classifications.${shownAs(f)}`)),
|
||||||
|
])) + `</div>`
|
||||||
|
}
|
||||||
|
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
|
||||||
|
? ` <span class="muted">(${esc(t("audit.document.thisNode"))})</span>` : ""),
|
||||||
|
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 ? `<div class="diagram">
|
||||||
|
<p class="diagram-note">${esc(t("audit.document.clusterDiagramNote"))}</p>${diagram}
|
||||||
|
</div>` : ""}
|
||||||
|
${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) => [
|
||||||
|
`<span class="muted" style="font-family:ui-monospace,Menlo,monospace;font-size:10.5px">${esc(c.slot)}</span>`,
|
||||||
|
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 ? `<div class="diagram">
|
||||||
|
<p class="diagram-note">${esc(t("audit.document.architectureNote"))}</p>${diagram}
|
||||||
|
</div>` : ""}
|
||||||
|
${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 [
|
||||||
|
`<strong>${esc(d.name)}</strong>`,
|
||||||
|
esc(d.model || "—"),
|
||||||
|
`<span class="muted" style="font-size:10.5px">${esc(d.serial || "—")}</span>`,
|
||||||
|
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))
|
||||||
|
: `<span class="muted">—</span>`,
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
// 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)),
|
||||||
|
`<span class="muted" style="font-size:10.5px">${esc(o.message || "")}</span>`,
|
||||||
|
])
|
||||||
|
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
|
||||||
|
? `<p class="diagram-note">${esc(t("audit.document.observationsNote"))}</p>${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 `<span class="muted">${esc(t("audit.inventory.unresolved"))}</span>`
|
||||||
|
if (hops.length === 0) return `<span class="muted">${esc(t("audit.inventory.noUplink"))}</span>`
|
||||||
|
return hops.map((h) => esc(h.id + (h.mode ? ` · ${h.mode}` : "")))
|
||||||
|
.join('<span class="sep">→</span>')
|
||||||
|
}
|
||||||
|
|
||||||
|
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) => [
|
||||||
|
`<strong>${esc(a.name)}</strong>`,
|
||||||
|
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`)
|
||||||
|
: "—",
|
||||||
|
`<span class="muted" style="font-family:ui-monospace,Menlo,monospace;font-size:10.5px">${esc(a.mac || "—")}</span>`,
|
||||||
|
esc(a.driver || "—"),
|
||||||
|
`<span class="muted" style="font-size:10.5px">${esc(a.pci || "—")}</span>`,
|
||||||
|
])
|
||||||
|
|
||||||
|
const bridgeRows = Object.entries(net?.bridges || {}).map(([id, b]: [string, any]) => [
|
||||||
|
`<strong>${esc(id)}</strong>`,
|
||||||
|
chain(b.uplink ?? null, t),
|
||||||
|
esc(String(guests.filter((g: any) =>
|
||||||
|
(g.interfaces || []).some((i: any) => i.bridge === id)).length)),
|
||||||
|
])
|
||||||
|
|
||||||
|
const body = `
|
||||||
|
${diagram ? `<div class="diagram">
|
||||||
|
<p class="diagram-note">${esc(t("audit.document.networkDiagramNote"))}</p>${diagram}
|
||||||
|
</div>` : ""}
|
||||||
|
${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) => [
|
||||||
|
`<strong>${esc(t(`audit.document.target.${target.target}`))}</strong>`,
|
||||||
|
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 ? `<div class="diagram">
|
||||||
|
<p class="diagram-note">${esc(t("audit.document.latencyNote"))}</p>${chart}
|
||||||
|
</div>` : ""}
|
||||||
|
${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) => [
|
||||||
|
`<strong>${esc(st.id)}</strong>`,
|
||||||
|
esc(st.type),
|
||||||
|
esc(st.content || "—"),
|
||||||
|
st.shared ? chip("pass", t("audit.document.shared")) : `<span class="muted">—</span>`,
|
||||||
|
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 `<div class="capacity-item"><strong>${esc(r.storage)}</strong><span>${esc(bytes(Number(r.used)))} / ${esc(bytes(Number(r.total)))}</span><div class="audit-meter"><span style="width:${ratio}%"></span></div></div>`
|
||||||
|
}).join("")
|
||||||
|
const body = `
|
||||||
|
${guests.length ? `<div class="coverage-panel"><h3>${icon("storage")}${esc(auditLabel(t,"coverage"))}</h3>
|
||||||
|
<div class="audit-meter"><span style="width:${fraction}%"></span></div>
|
||||||
|
<div class="coverage-labels"><span>${selected} / ${guests.length} · ${esc(auditLabel(t,"scheduled"))}</span><span>${unprotected.length} · ${esc(auditLabel(t,"noJob"))}</span></div>
|
||||||
|
<p class="muted">${esc(auditLabel(t,"copyScope"))}</p></div>` : ""}
|
||||||
|
${diagram ? `<div class="diagram">
|
||||||
|
<p class="diagram-note">${esc(t("audit.document.storageDiagramNote"))}</p>${diagram}
|
||||||
|
</div>` : ""}
|
||||||
|
${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) => [
|
||||||
|
`<strong>${esc(String(g.vmid))}</strong>`,
|
||||||
|
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 || "—"),
|
||||||
|
`<span style="font-family:ui-monospace,Menlo,monospace;font-size:10.5px">${esc(d.address || "—")}</span>`,
|
||||||
|
esc((d.iommu_groups || []).join(", ") || "—"),
|
||||||
|
(d.shared_group_devices || []).length
|
||||||
|
? chip("warn", String(d.shared_group_devices.length))
|
||||||
|
: `<span class="muted">—</span>`,
|
||||||
|
])
|
||||||
|
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 = [
|
||||||
|
`<div class="finding-head" id="finding-${esc(f.check_id)}">`,
|
||||||
|
chip(shownAs(f), t(`audit.classifications.${shownAs(f)}`)),
|
||||||
|
`<span class="title">${esc(t(`audit.checks.${f.check_id}.title`))}</span>`,
|
||||||
|
f.incomplete ? chip("unknown", t("audit.document.incomplete")) : "",
|
||||||
|
`</div>`,
|
||||||
|
]
|
||||||
|
const summary = summaryOf(f, t, true)
|
||||||
|
if (summary) bits.push(`<p>${esc(summary)}</p>`)
|
||||||
|
// "Could not be evaluated" describes the assessment, not the host.
|
||||||
|
const unread = unreadSources(f.sources, t)
|
||||||
|
if (unread) bits.push(`<p class="muted">${esc(unread)}</p>`)
|
||||||
|
bits.push(`<p class="rationale">${esc(t(`audit.checks.${f.check_id}.rationale`))}</p>`)
|
||||||
|
if (f.exception) {
|
||||||
|
bits.push(`<p><strong>${esc(t("audit.detail.acceptedRisk"))}:</strong> ` +
|
||||||
|
`${esc(f.exception.reason)} — ${esc(f.exception.accepted_by)}, ` +
|
||||||
|
`${esc(when(f.exception.accepted_at, locale))}</p>`)
|
||||||
|
}
|
||||||
|
for (const group of groups) {
|
||||||
|
bits.push(heading(group.title), group.note ? `<p class="muted">${esc(group.note)}</p>` : "", 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(`<p class="technical-ref"><a href="#evidence-${esc(f.check_id)}">${esc(auditLabel(t,"detailsLink"))}: ${esc(f.check_id)}</a></p>`)
|
||||||
|
}
|
||||||
|
const rowCount = groups.reduce((total, group) => total + group.rows.length, 0)
|
||||||
|
parts.push(`<div class="finding ${esc(shownAs(f))} ${rowCount <= 4 ? "finding-short" : "finding-long"}">${bits.join("\n")}</div>`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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
|
||||||
|
? `<p>${esc(t("audit.document.diagnosticActing", { count: String(acting.length) }))}</p>`
|
||||||
|
: `<p>${esc(t("audit.document.diagnosticClear"))}</p>`
|
||||||
|
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 = [
|
||||||
|
`<div class="finding-head" id="finding-${esc(f.check_id)}">`,
|
||||||
|
chip(shownAs(f), t(`audit.classifications.${shownAs(f)}`)),
|
||||||
|
`<span class="title">${esc(t(`audit.checks.${f.check_id}.title`))}</span>`,
|
||||||
|
`<span class="muted">${esc(t(`audit.areas.${f.area}`))}</span>`,
|
||||||
|
`</div>`,
|
||||||
|
]
|
||||||
|
const summary = summaryOf(f, t)
|
||||||
|
if (summary) bits.push(`<p>${esc(summary)}</p>`)
|
||||||
|
const unread = unreadSources(f.sources, t)
|
||||||
|
if (unread) bits.push(`<p class="muted">${esc(unread)}</p>`)
|
||||||
|
bits.push(`<p class="rationale">${esc(t(`audit.checks.${f.check_id}.rationale`))}</p>`)
|
||||||
|
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(`<p class="muted">${esc(t("audit.document.diagnosticMoreRows",
|
||||||
|
{ count: String(group.rows.length - shown.length) }))}</p>`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `<div class="finding ${esc(shownAs(f))}">${bits.join("\n")}</div>`
|
||||||
|
})
|
||||||
|
// 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"), `<p class="muted">${esc(auditLabel(input.t,"annexScope"))}</p>` + rows.map(f =>
|
||||||
|
`<div class="technical-entry" id="evidence-${esc(f.check_id)}">` + heading(input.t(`audit.checks.${f.check_id}.title`), "scope", f.check_id) +
|
||||||
|
evidenceHtml(f.evidence, input.locale) + `</div>`).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 = `
|
||||||
|
<div class="scope">
|
||||||
|
<p style="margin:0">${esc(t("audit.document.scopeText",
|
||||||
|
{ profile: t(`audit.profile.${input.profile}`) }))}</p>
|
||||||
|
<ul>
|
||||||
|
<li>${esc(t("audit.document.scopeLocal"))}</li>
|
||||||
|
<li>${esc(auditLabel(t,"readOnlyScope"))}</li>
|
||||||
|
<li>${esc(t("audit.document.scopeMoment"))}</li>
|
||||||
|
<li>${esc(declared)}</li>
|
||||||
|
</ul>
|
||||||
|
${missing.length ? `
|
||||||
|
<p style="margin:12px 0 4px"><strong>${esc(t("audit.document.notRead"))}</strong></p>
|
||||||
|
<ul>${missing.map(([k, v]) =>
|
||||||
|
`<li>${esc(k)}: ${esc(String(v))}</li>`).join("")}</ul>` : ""}
|
||||||
|
</div>`
|
||||||
|
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(/<table\b/g, '<div class="audit-table-scroll"><table').replace(/<\/table>/g, '</table></div>')
|
||||||
|
|
||||||
|
// 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 }
|
||||||
@@ -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, string>) => string
|
||||||
|
export interface PresentedFinding {
|
||||||
|
check_id: string
|
||||||
|
classification: string
|
||||||
|
affected: Array<Record<string, unknown>>
|
||||||
|
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<Record<string, any>> {
|
||||||
|
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<string, any> : null
|
||||||
|
const guest = (o: Record<string, unknown>) => {
|
||||||
|
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<string, unknown>) => 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<string, unknown>) =>
|
||||||
|
clean(o.decision) || clean(o.classification) || f.classification
|
||||||
|
const state = (o: Record<string, unknown>) => t(`audit.classifications.${status(o)}`)
|
||||||
|
const reason = (o: Record<string, unknown>) => {
|
||||||
|
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<Record<string, unknown>>,
|
||||||
|
cells: (o: Record<string, unknown>) => 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<string, unknown>) => {
|
||||||
|
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<string, Record<string, unknown>>()
|
||||||
|
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<string, Record<string, unknown>[]>()
|
||||||
|
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<string, number>()
|
||||||
|
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(" / ")
|
||||||
|
}
|
||||||
@@ -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<string, string> = {
|
||||||
|
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<string, unknown>
|
||||||
|
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<string, unknown>)
|
||||||
|
.map(([k, v]) => `${humanise(k)}: ${formatValue(k, v, locale)}`)
|
||||||
|
.join(" · ")
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecordList(value: unknown): value is Array<Record<string, unknown>> {
|
||||||
|
return Array.isArray(value) && value.length > 0 &&
|
||||||
|
value.every((v) => v !== null && typeof v === "object" && !Array.isArray(v))
|
||||||
|
}
|
||||||
|
|
||||||
|
function tableFrom(records: Array<Record<string, unknown>>, 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<string, unknown>
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -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 `<g>
|
||||||
|
<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="5"
|
||||||
|
fill="${FILL}" stroke="${stroke}" stroke-width="1.5"/>
|
||||||
|
<text x="${x + w / 2}" y="${y + (n.sub ? h / 2 - 3 : h / 2 + 4)}" text-anchor="middle"
|
||||||
|
font-size="11" font-weight="600" fill="${INK}">${esc(fit(n.label, inner, 11, true))}</text>
|
||||||
|
${n.sub ? `<text x="${x + w / 2}" y="${y + h / 2 + 11}" text-anchor="middle"
|
||||||
|
font-size="9" fill="${MUTED}">${esc(fit(n.sub, inner, 9))}</text>` : ""}
|
||||||
|
</g>`
|
||||||
|
}
|
||||||
|
|
||||||
|
function arrow(x1: number, y1: number, x2: number, y2: number): string {
|
||||||
|
return `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="${LINE}"
|
||||||
|
stroke-width="1.4" marker-end="url(#pmx-arrow)"/>`
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFS = `<defs>
|
||||||
|
<marker id="pmx-arrow" viewBox="0 0 10 10" refX="9" refY="5"
|
||||||
|
markerWidth="5" markerHeight="5" orient="auto-start-reverse">
|
||||||
|
<path d="M 0 0 L 10 5 L 0 10 z" fill="${LINE}"/>
|
||||||
|
</marker>
|
||||||
|
</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 `<svg viewBox="0 0 ${width} ${height}" width="100%" role="img"
|
||||||
|
preserveAspectRatio="xMidYMin meet"
|
||||||
|
style="display:block;height:auto">${DEFS}${body}</svg>`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<string, any>,
|
||||||
|
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) =>
|
||||||
|
`<text x="${PAD + COL_W * i + COL_W / 2}" y="${PAD - 2}" text-anchor="middle"
|
||||||
|
font-size="9" font-weight="700" letter-spacing="0.06em"
|
||||||
|
fill="${MUTED}">${esc(c.toUpperCase())}</text>`).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<string, number>()
|
||||||
|
const destinations = new Map<string, number>()
|
||||||
|
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) =>
|
||||||
|
`<text x="${PAD + COL_W * i + COL_W / 2}" y="${PAD - 2}" text-anchor="middle"
|
||||||
|
font-size="9" font-weight="700" letter-spacing="0.06em"
|
||||||
|
fill="${MUTED}">${esc(c.toUpperCase())}</text>`).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<string, Record<string, number>>,
|
||||||
|
areaLabel: (a: string) => string,
|
||||||
|
stateColor: Record<string, string>,
|
||||||
|
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 = `<rect x="${x}" y="${y + 5}" width="${w}" height="14" rx="2"
|
||||||
|
fill="${stateColor[st] || LINE}"><title>${esc(st)}: ${byArea[a][st]}</title></rect>`
|
||||||
|
x += w
|
||||||
|
return seg
|
||||||
|
}).join("")
|
||||||
|
return `<text x="${LABEL_W - 8}" y="${y + 16}" text-anchor="end" font-size="10"
|
||||||
|
fill="${INK}">${esc(areaLabel(a))}</text>${segs}
|
||||||
|
<text x="${x + 6}" y="${y + 16}" font-size="10" fill="${MUTED}">${total}</text>`
|
||||||
|
})
|
||||||
|
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(`<rect x="${x}" y="${top}" width="${w}" height="${h}" rx="7"
|
||||||
|
fill="none" stroke="${EDGE}" stroke-width="1.5"/>
|
||||||
|
<rect x="${x + 12}" y="${top - 6}" width="${title.length * 6.2 + 12}" height="12"
|
||||||
|
fill="#ffffff"/>
|
||||||
|
<text x="${x + 18}" y="${top + 3}" font-size="9" font-weight="700"
|
||||||
|
letter-spacing="0.08em" fill="${MUTED}">${esc(title.toUpperCase())}</text>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
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(`<rect x="${x}" y="${top}" width="${w}" height="${h}" rx="5"
|
||||||
|
fill="${FILL}" stroke="${stroke}" stroke-width="1.5"/>
|
||||||
|
<text x="${x + w / 2}" y="${top + 15}" text-anchor="middle" font-size="10.5"
|
||||||
|
font-weight="600" fill="${INK}">${esc(fit(title, inner, 10.5, true))}</text>` +
|
||||||
|
lines.map((l, i) => `<text x="${x + w / 2}" y="${top + 29 + i * 11}"
|
||||||
|
text-anchor="middle" font-size="9" fill="${MUTED}">${esc(fit(l, inner, 9))}</text>`).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(`<text x="${W - PAD - 6}" y="${y + boardH + 12}" text-anchor="end"
|
||||||
|
font-size="9" fill="${MUTED}">${esc(labels.memory)}: ${mem.populated || 0}/${slots || "?"} ${esc(labels.slotsUsed)}</text>`)
|
||||||
|
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<string, any[]>()
|
||||||
|
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(`<rect x="${x}" y="${y}" width="${colW}" height="${groupH}" rx="6"
|
||||||
|
fill="none" stroke="${EDGE}" stroke-width="1.5"/>
|
||||||
|
<rect x="${x}" y="${y}" width="${colW}" height="24" rx="6" fill="${FILL}"/>
|
||||||
|
<text x="${x + colW / 2}" y="${y + 16}" text-anchor="middle" font-size="10"
|
||||||
|
font-weight="700" fill="${INK}">${esc(fit(g.title, colW - 12, 10, true))}</text>` +
|
||||||
|
g.items.slice(0, 8).map((item, j) =>
|
||||||
|
`<text x="${x + 10}" y="${y + 39 + j * 15}" font-size="9.5"
|
||||||
|
fill="${MUTED}">${esc(fit(item, colW - 20, 9.5))}</text>`).join("") +
|
||||||
|
(g.items.length > 8
|
||||||
|
? `<text x="${x + 10}" y="${y + 39 + 8 * 15}" font-size="9" fill="${MUTED}">+${g.items.length - 8}</text>`
|
||||||
|
: ""))
|
||||||
|
// Tie each group back to the board it hangs from.
|
||||||
|
parts.push(`<line x1="${x + colW / 2}" y1="${y - 12}" x2="${x + colW / 2}" y2="${y}"
|
||||||
|
stroke="${LINE}" stroke-width="1.2"/>`)
|
||||||
|
})
|
||||||
|
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(`<line x1="${PAD}" y1="${busY}" x2="${width - PAD}" y2="${busY}"
|
||||||
|
stroke="${ACCENT}" stroke-width="2"/>
|
||||||
|
<text x="${PAD}" y="${busY - 7}" font-size="9" font-weight="700"
|
||||||
|
letter-spacing="0.08em" fill="${MUTED}">${esc(
|
||||||
|
`${cluster.name} · ${cluster.links || 1} ${labels.links}`.toUpperCase())}</text>`)
|
||||||
|
|
||||||
|
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(`<line x1="${x + BOX_W / 2}" y1="${busY}" x2="${x + BOX_W / 2}" y2="${y}"
|
||||||
|
stroke="${LINE}" stroke-width="1.2"/>`)
|
||||||
|
const offline = n.online === false
|
||||||
|
const stroke = offline ? WARN : n.local ? ACCENT : EDGE
|
||||||
|
parts.push(`<rect x="${x}" y="${y}" width="${BOX_W}" height="${BOX_H}" rx="6"
|
||||||
|
fill="${FILL}" stroke="${stroke}" stroke-width="${n.local ? 2 : 1.5}"/>
|
||||||
|
<text x="${x + BOX_W / 2}" y="${y + 19}" text-anchor="middle" font-size="11"
|
||||||
|
font-weight="600" fill="${INK}">${esc(n.name)}</text>
|
||||||
|
<text x="${x + BOX_W / 2}" y="${y + 32}" text-anchor="middle" font-size="9"
|
||||||
|
fill="${MUTED}">${esc(n.ring0_addr || "")}</text>
|
||||||
|
<text x="${x + BOX_W / 2}" y="${y + 42}" text-anchor="middle" font-size="8.5"
|
||||||
|
fill="${offline ? WARN : MUTED}">${esc(
|
||||||
|
offline ? labels.unreachable : n.local ? labels.thisNode : `id ${n.nodeid}`)}</text>`)
|
||||||
|
})
|
||||||
|
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(`<line x1="${LEFT}" y1="${gy}" x2="${W - PAD}" y2="${gy}"
|
||||||
|
stroke="${EDGE}" stroke-width="1"/>
|
||||||
|
<text x="${LEFT - 6}" y="${gy + 3}" text-anchor="end" font-size="9"
|
||||||
|
fill="${MUTED}">${axisLabel(value)}</text>`)
|
||||||
|
}
|
||||||
|
parts.push(`<text x="${PAD - 4}" y="${PAD + 4}" font-size="9" fill="${MUTED}">${esc(labels.ms)}</text>`)
|
||||||
|
|
||||||
|
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(`<path d="${area} ${back} Z" fill="${color}" fill-opacity="0.13"
|
||||||
|
stroke="none"/>`)
|
||||||
|
}
|
||||||
|
const line = points
|
||||||
|
.map((s, j) => `${j === 0 ? "M" : "L"}${x(s.t).toFixed(1)} ${y(s.v).toFixed(1)}`)
|
||||||
|
.join(" ")
|
||||||
|
parts.push(`<path d="${line}" fill="none" stroke="${color}"
|
||||||
|
stroke-width="1.4" stroke-linejoin="round"/>`)
|
||||||
|
|
||||||
|
const legendX = LEFT + i * 150
|
||||||
|
parts.push(`<rect x="${legendX}" y="${H - 13}" width="9" height="3" rx="1.5"
|
||||||
|
fill="${color}"/>
|
||||||
|
<text x="${legendX + 14}" y="${H - 9}" font-size="9"
|
||||||
|
fill="${MUTED}">${esc(fit(t.label || t.target, 130, 9))}</text>`)
|
||||||
|
})
|
||||||
|
|
||||||
|
const span = Math.max(1, Math.round((t1 - t0) / 3600))
|
||||||
|
parts.push(`<text x="${W - PAD}" y="${H - 9}" text-anchor="end" font-size="9"
|
||||||
|
fill="${MUTED}">${esc(`${span} ${labels.hours}`)}</text>`)
|
||||||
|
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)))
|
||||||
|
}
|
||||||
@@ -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, ">")
|
||||||
|
.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 = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><line x1="10" y1="9" x2="8" y2="9"/></svg>`
|
||||||
|
|
||||||
|
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]) => `<div>${esc(k)}: ${esc(v)}</div>`).join("\n")
|
||||||
|
|
||||||
|
return `<!doctype html>
|
||||||
|
<html lang="${esc(o.lang)}">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>${esc(o.title)}${o.topBarSubtitle ? ` - ${esc(o.topBarSubtitle)}` : ""}</title>
|
||||||
|
<style>${REPORT_CSS}${o.extraCss || ""}</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<script>
|
||||||
|
function pmxPrint(){ try { window.print(); } catch(e) {} }
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="top-bar no-print">
|
||||||
|
<div class="top-bar-left">
|
||||||
|
<strong class="top-bar-title">${esc(o.title)}</strong>
|
||||||
|
<span class="top-bar-subtitle">${esc(o.topBarSubtitle || "")}</span>
|
||||||
|
</div>
|
||||||
|
<div class="btn-group">
|
||||||
|
<button onclick="pmxPrint()" title="Save as PDF" aria-label="Save as PDF">${PRINT_ICON}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rpt-header">
|
||||||
|
<div class="rpt-header-left">
|
||||||
|
<img src="${esc(o.logoUrl)}" alt="ProxMenux" onerror="this.style.display='none'">
|
||||||
|
<div>
|
||||||
|
<h1>${esc(o.title)}</h1>
|
||||||
|
<p>${esc(o.subtitle)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rpt-header-right">
|
||||||
|
${metaRows}
|
||||||
|
<div class="rid">ID: ${esc(o.reportId)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${o.body}
|
||||||
|
|
||||||
|
<div class="rpt-footer">
|
||||||
|
<span>${esc(o.footerLeft)}</span>
|
||||||
|
<span>${esc(o.footerRight)}</span>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<string, string> = {
|
||||||
|
summary: '<path d="M9 5H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2"/><rect x="9" y="3" width="6" height="4" rx="1"/><path d="m9 14 2 2 4-4"/>',
|
||||||
|
node: '<rect x="2" y="4" width="20" height="7" rx="2"/><rect x="2" y="13" width="20" height="7" rx="2"/><path d="M6 8h.01M6 17h.01"/>',
|
||||||
|
cluster: '<circle cx="12" cy="5" r="2.5"/><circle cx="5" cy="19" r="2.5"/><circle cx="19" cy="19" r="2.5"/><path d="M12 7.5v4M12 11.5H6.5a1.5 1.5 0 0 0-1.5 1.5v3.5M12 11.5h5.5a1.5 1.5 0 0 1 1.5 1.5v3.5"/>',
|
||||||
|
architecture: '<rect x="7" y="7" width="10" height="10" rx="1.5"/><path d="M10 2v3M14 2v3M10 19v3M14 19v3M2 10h3M2 14h3M19 10h3M19 14h3"/>',
|
||||||
|
disks: '<rect x="2" y="4" width="20" height="7" rx="2"/><rect x="2" y="13" width="20" height="7" rx="2"/><path d="M17 7.5h.01M17 16.5h.01"/>',
|
||||||
|
network: '<rect x="9" y="2" width="6" height="6" rx="1"/><rect x="2" y="16" width="6" height="6" rx="1"/><rect x="16" y="16" width="6" height="6" rx="1"/><path d="M12 8v4M5 16v-2h14v2"/>',
|
||||||
|
storage: '<ellipse cx="12" cy="5" rx="8" ry="3"/><path d="M4 5v14c0 1.7 3.6 3 8 3s8-1.3 8-3V5"/><path d="M4 12c0 1.7 3.6 3 8 3s8-1.3 8-3"/>',
|
||||||
|
guests: '<rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/>',
|
||||||
|
passthrough: '<path d="M9 2v6M15 2v6"/><path d="M6 8h12v3a6 6 0 0 1-6 6 6 6 0 0 1-6-6V8Z"/><path d="M12 17v5"/>',
|
||||||
|
software: '<path d="m12 2 8 4.5v9L12 20l-8-4.5v-9L12 2Z"/><path d="M12 20v-9M4 6.5l8 4.5 8-4.5"/>',
|
||||||
|
findings: '<path d="m3 6 2 2 3-3M3 13l2 2 3-3M3 20l2 2 3-3"/><path d="M12 7h9M12 14h9M12 21h9"/>',
|
||||||
|
scope: '<circle cx="12" cy="12" r="9.5"/><path d="M12 16v-5M12 8h.01"/>',
|
||||||
|
memory: '<rect x="3" y="7" width="18" height="10" rx="1.5"/><path d="M7 17v3M12 17v3M17 17v3M6 11h2M11 11h2M16 11h2"/>',
|
||||||
|
controller: '<rect x="4" y="4" width="16" height="16" rx="2"/><rect x="9" y="9" width="6" height="6" rx="1"/><path d="M9 2v2M15 2v2M9 20v2M15 20v2M2 9h2M2 15h2M20 9h2M20 15h2"/>',
|
||||||
|
adapter: '<rect x="2" y="8" width="20" height="8" rx="2"/><path d="M6 12h.01M10 12h.01M14 12h.01"/><path d="M18 8V5M18 19v-3"/>',
|
||||||
|
bridge: '<path d="M2 17V9a10 10 0 0 1 20 0v8"/><path d="M2 13h20M7 13v4M12 13v4M17 13v4"/>',
|
||||||
|
observation: '<path d="M12 9v4M12 17h.01"/><path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0Z"/>',
|
||||||
|
latency: '<path d="M3 18h4l3-11 4 16 3-9h4"/>',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 `<svg viewBox="0 0 24 24" width="${size}" height="${size}" fill="none"
|
||||||
|
stroke="${color}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
aria-hidden="true" focusable="false"
|
||||||
|
style="flex:none;vertical-align:-2px;margin-right:8px">${path}</svg>`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function section(index: number, title: string, body: string,
|
||||||
|
mark?: string): string {
|
||||||
|
return `<div class="section">
|
||||||
|
<div class="section-title">${mark ? icon(mark) : ""}${index}. ${esc(title)}</div>
|
||||||
|
${body}
|
||||||
|
</div>`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A heading inside a section, carrying its own mark. */
|
||||||
|
export function heading(title: string, mark?: string, note?: string): string {
|
||||||
|
return `<h3 class="sub-title">${mark ? icon(mark, 14) : ""}${esc(title)}${
|
||||||
|
note ? `<span class="muted" style="font-weight:400"> — ${esc(note)}</span>` : ""}</h3>`
|
||||||
|
}
|
||||||
|
|
||||||
|
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 `<div class="${cls}">
|
||||||
|
<div class="card-label">${esc(label)}</div>
|
||||||
|
<div class="card-value"${style}>${value}</div>
|
||||||
|
</div>`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function grid(columns: 2 | 3 | 4, cards: string[]): string {
|
||||||
|
return `<div class="grid-${columns}">${cards.join("")}</div>`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 `<div class="rec-item rec-${tone}">
|
||||||
|
<div class="rec-icon">${icon}</div>
|
||||||
|
<div><strong>${esc(title)}</strong><p>${body}</p></div>
|
||||||
|
</div>`
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
? `<thead><tr>${headers.map((h) => `<th>${esc(h)}</th>`).join("")}</tr></thead>`
|
||||||
|
: ""
|
||||||
|
return `<table class="attr-tbl">${head}
|
||||||
|
<tbody>${rows.map((r) => `<tr>${r.map((c) => `<td>${c}</td>`).join("")}</tr>`).join("")}</tbody>
|
||||||
|
</table>`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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(`<html><body style="background:#0f172a;color:#e2e8f0;font-family:sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0"><div style="text-align:center"><div style="border:3px solid transparent;border-top-color:#06b6d4;border-radius:50%;width:40px;height:40px;animation:spin 1s linear infinite;margin:0 auto"></div><p style="margin-top:16px">${esc(loadingText)}</p><style>@keyframes spin{to{transform:rotate(360deg)}}</style></div></body></html>`)
|
||||||
|
}
|
||||||
|
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")
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1003
-37
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,10 @@ not acceptable.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import json
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
import copy
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
@@ -44,22 +48,30 @@ AREAS = (
|
|||||||
|
|
||||||
SEVERITIES = ("OK", "INFO", "WARNING", "CRITICAL")
|
SEVERITIES = ("OK", "INFO", "WARNING", "CRITICAL")
|
||||||
|
|
||||||
# Per-check wall-clock budget. A check that cannot answer within it is
|
# Shared deadline for all subprocesses in a check, not a fresh timeout
|
||||||
# recorded as not applicable rather than stalling the whole assessment.
|
# per device/storage. Exhaustion is unknown, never not applicable.
|
||||||
CHECK_TIMEOUT = 20
|
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:
|
class Check:
|
||||||
"""One registered assessment.
|
"""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
|
and, optionally, ``summary``, ``affected``, ``evidence`` and
|
||||||
``remediable_by``. Returning ``None`` marks the check as not
|
``remediable_by``. Returning ``None`` marks the check as not
|
||||||
applicable on this host.
|
applicable on this host.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, check_id: str, area: str, severity: str,
|
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:
|
if area not in AREAS:
|
||||||
raise ValueError(f"unknown area for {check_id}: {area}")
|
raise ValueError(f"unknown area for {check_id}: {area}")
|
||||||
if severity not in SEVERITIES:
|
if severity not in SEVERITIES:
|
||||||
@@ -70,17 +82,20 @@ class Check:
|
|||||||
self.area = area
|
self.area = area
|
||||||
self.severity = severity
|
self.severity = severity
|
||||||
self.evaluate = evaluate
|
self.evaluate = evaluate
|
||||||
|
self.budget = budget
|
||||||
|
self.version = CATALOG_VERSION
|
||||||
|
|
||||||
|
|
||||||
_REGISTRY: dict[str, Check] = {}
|
_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."""
|
"""Decorator registering a check under a stable identifier."""
|
||||||
def wrap(fn):
|
def wrap(fn):
|
||||||
if check_id in _REGISTRY:
|
if check_id in _REGISTRY:
|
||||||
raise ValueError(f"duplicate check identifier: {check_id}")
|
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 fn
|
||||||
return wrap
|
return wrap
|
||||||
|
|
||||||
@@ -98,26 +113,91 @@ class AuditContext:
|
|||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._cache: dict[str, Any] = {}
|
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:
|
def _once(self, key: str, producer: Callable[[], Any]) -> Any:
|
||||||
|
self.source(key)
|
||||||
if key not in self._cache:
|
if key not in self._cache:
|
||||||
|
parent_sources = self._sources_used
|
||||||
|
self._sources_used = {key}
|
||||||
try:
|
try:
|
||||||
self._cache[key] = producer()
|
self._cache[key] = producer()
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
self._cache[key] = None
|
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]
|
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."""
|
"""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:
|
if key in self._cache:
|
||||||
return self._cache[key]
|
return self._cache[key]
|
||||||
try:
|
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,
|
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 ""))
|
result = (proc.returncode, (proc.stdout or "") + (proc.stderr or ""))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
result = (-1, str(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
|
self._cache[key] = result
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -128,11 +208,13 @@ class AuditContext:
|
|||||||
out: dict[int, str] = {}
|
out: dict[int, str] = {}
|
||||||
base = Path("/etc/pve/lxc")
|
base = Path("/etc/pve/lxc")
|
||||||
if not base.is_dir():
|
if not base.is_dir():
|
||||||
|
self.source("lxc_configs", error="local PVE configuration directory unavailable")
|
||||||
return out
|
return out
|
||||||
for path in base.glob("*.conf"):
|
for path in base.glob("*.conf"):
|
||||||
try:
|
try:
|
||||||
out[int(path.stem)] = path.read_text(errors="replace")
|
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
|
continue
|
||||||
return out
|
return out
|
||||||
return self._once("lxc_configs", load) or {}
|
return self._once("lxc_configs", load) or {}
|
||||||
@@ -143,15 +225,31 @@ class AuditContext:
|
|||||||
out: dict[int, str] = {}
|
out: dict[int, str] = {}
|
||||||
base = Path("/etc/pve/qemu-server")
|
base = Path("/etc/pve/qemu-server")
|
||||||
if not base.is_dir():
|
if not base.is_dir():
|
||||||
|
self.source("qemu_configs", error="local PVE configuration directory unavailable")
|
||||||
return out
|
return out
|
||||||
for path in base.glob("*.conf"):
|
for path in base.glob("*.conf"):
|
||||||
try:
|
try:
|
||||||
out[int(path.stem)] = path.read_text(errors="replace")
|
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
|
continue
|
||||||
return out
|
return out
|
||||||
return self._once("qemu_configs", load) or {}
|
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
|
@property
|
||||||
def apt_sources(self) -> dict[str, str]:
|
def apt_sources(self) -> dict[str, str]:
|
||||||
"""Contents of the apt source files that define PVE repositories."""
|
"""Contents of the apt source files that define PVE repositories."""
|
||||||
@@ -165,8 +263,10 @@ class AuditContext:
|
|||||||
for path in candidates:
|
for path in candidates:
|
||||||
try:
|
try:
|
||||||
out[str(path)] = path.read_text(errors="replace")
|
out[str(path)] = path.read_text(errors="replace")
|
||||||
except OSError:
|
except FileNotFoundError:
|
||||||
continue
|
continue
|
||||||
|
except OSError as exc:
|
||||||
|
self.source("apt_sources", error=f"{path}: {exc}")
|
||||||
return out
|
return out
|
||||||
return self._once("apt_sources", load) or {}
|
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")):
|
for path in (Path("/etc/pve/jobs.cfg"), Path("/etc/vzdump.cron")):
|
||||||
try:
|
try:
|
||||||
text += path.read_text(errors="replace") + "\n"
|
text += path.read_text(errors="replace") + "\n"
|
||||||
except OSError:
|
except FileNotFoundError:
|
||||||
continue
|
continue
|
||||||
|
except OSError as exc:
|
||||||
|
self.source("vzdump_jobs", error=f"{path}: {exc}")
|
||||||
return text
|
return text
|
||||||
return self._once("vzdump_jobs", load) or ""
|
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
|
@property
|
||||||
def storages(self) -> list[dict]:
|
def storages(self) -> list[dict]:
|
||||||
"""Storage definitions from ``storage.cfg``.
|
"""Storage definitions from ``storage.cfg``.
|
||||||
@@ -197,7 +372,7 @@ class AuditContext:
|
|||||||
try:
|
try:
|
||||||
text = Path("/etc/pve/storage.cfg").read_text(errors="replace")
|
text = Path("/etc/pve/storage.cfg").read_text(errors="replace")
|
||||||
except OSError:
|
except OSError:
|
||||||
return out
|
raise
|
||||||
current: Optional[dict] = None
|
current: Optional[dict] = None
|
||||||
for line in text.splitlines():
|
for line in text.splitlines():
|
||||||
if not line.strip():
|
if not line.strip():
|
||||||
@@ -221,77 +396,267 @@ class AuditContext:
|
|||||||
def load():
|
def load():
|
||||||
try:
|
try:
|
||||||
return Path("/etc/pve/user.cfg").read_text(errors="replace")
|
return Path("/etc/pve/user.cfg").read_text(errors="replace")
|
||||||
except OSError:
|
except FileNotFoundError:
|
||||||
return ""
|
return ""
|
||||||
return self._once("pve_user_cfg", load) or ""
|
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
|
# 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",
|
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.
|
"""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
|
as evidence. One faulty check must never abort an assessment: a
|
||||||
partial report that says which check failed is more useful than no
|
partial report that says which check failed is more useful than no
|
||||||
report at all.
|
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()
|
ctx = AuditContext()
|
||||||
exceptions = audit_store.active_exceptions()
|
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]] = []
|
findings: list[dict[str, Any]] = []
|
||||||
error: Optional[str] = None
|
error: Optional[str] = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
for check in registered_checks():
|
for check in checks:
|
||||||
if only_areas and check.area not in only_areas:
|
ctx.begin_check(check.budget)
|
||||||
continue
|
if progress:
|
||||||
|
progress(run_id, len(findings), len(checks), check.check_id)
|
||||||
started = time.monotonic()
|
started = time.monotonic()
|
||||||
try:
|
try:
|
||||||
|
if started >= ctx._run_deadline:
|
||||||
|
raise TimeoutError("assessment time budget exhausted")
|
||||||
result = check.evaluate(ctx)
|
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:
|
except Exception as exc:
|
||||||
result = {
|
result = {
|
||||||
"state": audit_store.STATE_NOT_APPLICABLE,
|
"classification": audit_store.CLASS_UNVERIFIED,
|
||||||
"summary_key": "evaluationFailed",
|
"summary_key": "evaluationFailed",
|
||||||
"evidence": f"{type(exc).__name__}: {exc}",
|
"evidence": f"{type(exc).__name__}: {exc}",
|
||||||
}
|
}
|
||||||
elapsed = time.monotonic() - started
|
elapsed = time.monotonic() - started
|
||||||
|
|
||||||
if result is None:
|
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
|
# An accepted risk keeps its evidence and its declared
|
||||||
# severity; only the state changes, so the report can still
|
# severity; only the state changes, so the report can still
|
||||||
# show what was accepted and why it mattered.
|
# 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")
|
evidence = result.get("evidence")
|
||||||
if elapsed > CHECK_TIMEOUT:
|
# Names already collected by a check are display metadata, not a
|
||||||
evidence = (evidence or "") + \
|
# reason to probe guests again or alter the finding's scope.
|
||||||
f"\n[check exceeded its time budget: {elapsed:.1f}s]"
|
for obj in result.get("affected") or []:
|
||||||
|
vmid = obj.get("vmid")
|
||||||
findings.append({
|
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,
|
"check_id": check.check_id,
|
||||||
"area": check.area,
|
"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,
|
"severity": check.severity,
|
||||||
"state": state,
|
"classification": classification,
|
||||||
"summary_key": result.get("summary_key"),
|
"summary_key": result.get("summary_key"),
|
||||||
"summary_params": result.get("summary_params") or {},
|
"summary_params": result.get("summary_params") or {},
|
||||||
"affected": result.get("affected") or [],
|
"affected": result.get("affected") or [],
|
||||||
"evidence": evidence,
|
"evidence": evidence,
|
||||||
"remediable_by": result.get("remediable_by"),
|
"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:
|
except Exception as exc:
|
||||||
error = f"{type(exc).__name__}: {exc}"
|
error = f"{type(exc).__name__}: {exc}"
|
||||||
|
|
||||||
audit_store.record_findings(run_id, findings)
|
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
|
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
|
``unchanged`` is kept so a report can state that the rest of the
|
||||||
surface held steady rather than leaving it unaccounted for.
|
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)}
|
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)}
|
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():
|
for check_id, current in other.items():
|
||||||
previous = base.get(check_id)
|
previous = base.get(check_id)
|
||||||
was = previous["state"] in failing if previous else False
|
was = previous["classification"] in problems if previous else False
|
||||||
now = current["state"] in failing
|
now = current["classification"] in problems
|
||||||
if now and not was:
|
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)
|
new.append(current)
|
||||||
elif was and not now:
|
elif was and not now:
|
||||||
if current["state"] == audit_store.STATE_ACCEPTED:
|
if current.get("decision") == audit_store.DECISION_ACCEPTED:
|
||||||
accepted.append(current)
|
accepted.append(current)
|
||||||
else:
|
elif current["classification"] in (audit_store.CLASS_CONFORMANT,
|
||||||
|
audit_store.CLASS_OBSERVATION):
|
||||||
resolved.append(current)
|
resolved.append(current)
|
||||||
elif previous and previous["state"] == current["state"]:
|
elif previous and previous["classification"] == current["classification"]:
|
||||||
unchanged.append(current)
|
unchanged.append(current)
|
||||||
# A check present in the base run but absent from the later one was
|
# 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
|
# retired between the two. It is reported as no longer assessed rather
|
||||||
# than as resolved, since nothing verified that it stopped failing.
|
# than as resolved, since nothing verified that it stopped failing.
|
||||||
retired = [
|
retired = [
|
||||||
previous for check_id, previous in base.items()
|
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 {
|
return {
|
||||||
@@ -339,4 +711,5 @@ def compare_runs(base_run: str, other_run: str) -> dict[str, list[dict]]:
|
|||||||
"accepted": accepted,
|
"accepted": accepted,
|
||||||
"unchanged": unchanged,
|
"unchanged": unchanged,
|
||||||
"retired": retired,
|
"retired": retired,
|
||||||
|
"unverified": unverified,
|
||||||
}
|
}
|
||||||
|
|||||||
+3492
-161
File diff suppressed because it is too large
Load Diff
@@ -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 [])
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
|
]
|
||||||
+305
-26
@@ -19,6 +19,9 @@ and is stored verbatim.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import hashlib
|
||||||
|
import re
|
||||||
|
import os
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
@@ -28,22 +31,147 @@ from typing import Any, Optional
|
|||||||
|
|
||||||
DB_PATH = Path("/usr/local/share/proxmenux/audit.db")
|
DB_PATH = Path("/usr/local/share/proxmenux/audit.db")
|
||||||
|
|
||||||
# Result of a check within one run. Severity is what the check declares
|
# What a check concluded, on one scale.
|
||||||
# for a failure; state is what actually happened this time.
|
#
|
||||||
|
# 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_FAIL = "fail"
|
||||||
STATE_WARN = "warn"
|
STATE_WARN = "warn"
|
||||||
STATE_PASS = "pass"
|
STATE_PASS = "pass"
|
||||||
STATE_NOT_APPLICABLE = "not_applicable"
|
STATE_NOT_APPLICABLE = "not_applicable"
|
||||||
STATE_ACCEPTED = "accepted"
|
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_RUNNING = "running"
|
||||||
RUN_COMPLETE = "complete"
|
RUN_COMPLETE = "complete"
|
||||||
RUN_FAILED = "failed"
|
RUN_FAILED = "failed"
|
||||||
|
RUN_PARTIAL = "partial"
|
||||||
|
|
||||||
_schema_lock = threading.Lock()
|
_schema_lock = threading.Lock()
|
||||||
_schema_ready = False
|
_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:
|
def _connect() -> sqlite3.Connection:
|
||||||
conn = sqlite3.connect(str(DB_PATH), timeout=10)
|
conn = sqlite3.connect(str(DB_PATH), timeout=10)
|
||||||
conn.execute("PRAGMA journal_mode=WAL")
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
@@ -59,6 +187,9 @@ def init_db() -> None:
|
|||||||
if _schema_ready:
|
if _schema_ready:
|
||||||
return
|
return
|
||||||
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
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()
|
conn = _connect()
|
||||||
try:
|
try:
|
||||||
conn.executescript("""
|
conn.executescript("""
|
||||||
@@ -113,7 +244,34 @@ def init_db() -> None:
|
|||||||
CREATE INDEX IF NOT EXISTS idx_audit_runs_started
|
CREATE INDEX IF NOT EXISTS idx_audit_runs_started
|
||||||
ON audit_runs(started_at);
|
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()
|
conn.commit()
|
||||||
|
os.chmod(DB_PATH, 0o600)
|
||||||
_schema_ready = True
|
_schema_ready = True
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -123,16 +281,17 @@ def init_db() -> None:
|
|||||||
# Runs
|
# 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."""
|
"""Open a run and return its identifier."""
|
||||||
init_db()
|
init_db()
|
||||||
run_id = uuid.uuid4().hex[:16]
|
run_id = uuid.uuid4().hex[:16]
|
||||||
conn = _connect()
|
conn = _connect()
|
||||||
try:
|
try:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO audit_runs (run_id, profile, started_at, status) "
|
"INSERT INTO audit_runs (run_id, profile, started_at, status, metadata, "
|
||||||
"VALUES (?, ?, ?, ?)",
|
"checks_expected, schema_version) VALUES (?, ?, ?, ?, ?, ?, 2)",
|
||||||
(run_id, profile, int(time.time()), RUN_RUNNING),
|
(run_id, profile, int(time.time()), RUN_RUNNING,
|
||||||
|
json.dumps(safe_evidence(metadata or {})), checks_expected),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
finally:
|
finally:
|
||||||
@@ -140,8 +299,19 @@ def start_run(profile: str) -> str:
|
|||||||
return run_id
|
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,
|
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."""
|
"""Close a run, marking it failed when an error is supplied."""
|
||||||
init_db()
|
init_db()
|
||||||
conn = _connect()
|
conn = _connect()
|
||||||
@@ -149,8 +319,8 @@ def finish_run(run_id: str, *, checks_total: int,
|
|||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE audit_runs SET finished_at = ?, status = ?, error = ?, "
|
"UPDATE audit_runs SET finished_at = ?, status = ?, error = ?, "
|
||||||
"checks_total = ? WHERE run_id = ?",
|
"checks_total = ? WHERE run_id = ?",
|
||||||
(int(time.time()), RUN_FAILED if error else RUN_COMPLETE,
|
(int(time.time()), RUN_FAILED if error else RUN_PARTIAL if partial else RUN_COMPLETE,
|
||||||
error, checks_total, run_id),
|
safe_evidence(error), checks_total, run_id),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
finally:
|
finally:
|
||||||
@@ -165,7 +335,7 @@ def get_run(run_id: str) -> Optional[dict[str, Any]]:
|
|||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"SELECT * FROM audit_runs WHERE run_id = ?", (run_id,)
|
"SELECT * FROM audit_runs WHERE run_id = ?", (run_id,)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
return dict(row) if row else None
|
return _run_row(row) if row else None
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
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 ?",
|
"SELECT * FROM audit_runs ORDER BY started_at DESC LIMIT ?",
|
||||||
(limit,),
|
(limit,),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return [dict(r) for r in rows]
|
return [_run_row(r) for r in rows]
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
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()
|
init_db()
|
||||||
conn = _connect()
|
conn = _connect()
|
||||||
try:
|
try:
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
row = conn.execute(
|
condition = "status = ?" if status else "status != 'running'"
|
||||||
"SELECT * FROM audit_runs WHERE status = ? "
|
row = conn.execute(f"SELECT * FROM audit_runs WHERE {condition} "
|
||||||
"ORDER BY started_at DESC LIMIT 1",
|
"ORDER BY started_at DESC, rowid DESC LIMIT 1",
|
||||||
(status,),
|
(status,) if status else ()).fetchone()
|
||||||
).fetchone()
|
return _run_row(row) if row else None
|
||||||
return dict(row) if row else None
|
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
@@ -212,18 +396,32 @@ def record_findings(run_id: str, findings: list[dict[str, Any]]) -> int:
|
|||||||
init_db()
|
init_db()
|
||||||
if not findings:
|
if not findings:
|
||||||
return 0
|
return 0
|
||||||
|
findings = safe_evidence(findings)
|
||||||
rows = [
|
rows = [
|
||||||
(
|
(
|
||||||
run_id,
|
run_id,
|
||||||
f["check_id"],
|
f["check_id"],
|
||||||
f["area"],
|
f["area"],
|
||||||
f["severity"],
|
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"),
|
f.get("summary_key"),
|
||||||
json.dumps(f.get("summary_params") or {}, ensure_ascii=False),
|
json.dumps(f.get("summary_params") or {}, ensure_ascii=False),
|
||||||
json.dumps(f.get("affected") or [], ensure_ascii=False),
|
json.dumps(f.get("affected") or [], ensure_ascii=False),
|
||||||
f.get("evidence"),
|
f.get("evidence"),
|
||||||
f.get("remediable_by"),
|
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
|
for f in findings
|
||||||
]
|
]
|
||||||
@@ -233,7 +431,9 @@ def record_findings(run_id: str, findings: list[dict[str, Any]]) -> int:
|
|||||||
conn.executemany(
|
conn.executemany(
|
||||||
"INSERT INTO audit_findings (run_id, check_id, area, severity, "
|
"INSERT INTO audit_findings (run_id, check_id, area, severity, "
|
||||||
"state, summary_key, summary_params, affected, evidence, "
|
"state, summary_key, summary_params, affected, evidence, "
|
||||||
"remediable_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
"remediable_by, raw_state, exception_snapshot, scope, details, "
|
||||||
|
"classification, raw_classification, decision) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
rows,
|
rows,
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
@@ -263,6 +463,18 @@ def get_findings(run_id: str) -> list[dict[str, Any]]:
|
|||||||
item.get("summary_params") or "{}")
|
item.get("summary_params") or "{}")
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
item["summary_params"] = {}
|
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)
|
out.append(item)
|
||||||
return out
|
return out
|
||||||
finally:
|
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,
|
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.
|
"""Record a deliberate decision to leave a finding unresolved.
|
||||||
|
|
||||||
A reason is mandatory: an acceptance without one is indistinguishable
|
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():
|
if not (reason or "").strip():
|
||||||
raise ValueError("an accepted risk requires a reason")
|
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()
|
init_db()
|
||||||
conn = _connect()
|
conn = _connect()
|
||||||
try:
|
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(
|
conn.execute(
|
||||||
"INSERT OR REPLACE INTO audit_exceptions "
|
"INSERT OR REPLACE INTO audit_exceptions "
|
||||||
"(check_id, reason, accepted_by, accepted_at, expires_at) "
|
"(check_id, reason, accepted_by, accepted_at, expires_at, scope) "
|
||||||
"VALUES (?, ?, ?, ?, ?)",
|
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
(check_id, reason.strip(), accepted_by, int(time.time()),
|
(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()
|
conn.commit()
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
def revoke_risk(check_id: str) -> bool:
|
def revoke_risk(check_id: str, actor: str = "local-admin") -> bool:
|
||||||
init_db()
|
init_db()
|
||||||
conn = _connect()
|
conn = _connect()
|
||||||
try:
|
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(
|
cur = conn.execute(
|
||||||
"DELETE FROM audit_exceptions WHERE check_id = ?", (check_id,)
|
"DELETE FROM audit_exceptions WHERE check_id = ?", (check_id,)
|
||||||
)
|
)
|
||||||
@@ -349,6 +580,54 @@ def active_exceptions() -> dict[str, dict[str, Any]]:
|
|||||||
conn.close()
|
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]]:
|
def all_exceptions() -> list[dict[str, Any]]:
|
||||||
init_db()
|
init_db()
|
||||||
now = int(time.time())
|
now = int(time.time())
|
||||||
@@ -414,7 +693,7 @@ def prune_runs(keep: int = 30) -> int:
|
|||||||
try:
|
try:
|
||||||
conn.execute("BEGIN IMMEDIATE")
|
conn.execute("BEGIN IMMEDIATE")
|
||||||
cur = conn.execute(
|
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 "
|
" SELECT run_id FROM audit_runs "
|
||||||
" WHERE is_baseline = 0 ORDER BY started_at DESC LIMIT ?"
|
" WHERE is_baseline = 0 ORDER BY started_at DESC LIMIT ?"
|
||||||
")",
|
")",
|
||||||
|
|||||||
@@ -307,6 +307,8 @@ def verify_password(password, password_hash):
|
|||||||
can log in once and trigger a rehash via `_maybe_rehash_password` —
|
can log in once and trigger a rehash via `_maybe_rehash_password` —
|
||||||
see lazy migration in `authenticate()`.
|
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:
|
if not isinstance(password_hash, str) or not password_hash:
|
||||||
return False
|
return False
|
||||||
if password_hash.startswith(_PWD_PBKDF2_PREFIX):
|
if password_hash.startswith(_PWD_PBKDF2_PREFIX):
|
||||||
|
|||||||
@@ -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/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_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_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"
|
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"
|
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
|
# Copy AI providers module for notification enhancement
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -14,7 +14,8 @@ import threading
|
|||||||
import time
|
import time
|
||||||
|
|
||||||
from flask import Blueprint, jsonify, request
|
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__)
|
audit_bp = Blueprint('audit', __name__)
|
||||||
|
|
||||||
@@ -22,14 +23,47 @@ try:
|
|||||||
import audit_store
|
import audit_store
|
||||||
import audit_checks
|
import audit_checks
|
||||||
import audit_checks_pve # noqa: F401 — importing registers the 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:
|
except ImportError:
|
||||||
audit_store = None
|
audit_store = None
|
||||||
audit_checks = 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
|
# One assessment at a time. The flag is also what the interface polls to
|
||||||
# know a run is still in progress.
|
# know a run is still in progress.
|
||||||
_run_lock = threading.Lock()
|
_run_lock = threading.Lock()
|
||||||
_running: dict = {'active': False, 'run_id': None, 'started_at': 0}
|
_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():
|
def _unavailable():
|
||||||
@@ -66,17 +100,22 @@ def list_checks():
|
|||||||
@require_auth
|
@require_auth
|
||||||
def status():
|
def status():
|
||||||
"""Latest run, whether an assessment is in progress, and the baseline."""
|
"""Latest run, whether an assessment is in progress, and the baseline."""
|
||||||
if not audit_store:
|
if not audit_store or _startup_error:
|
||||||
return _unavailable()
|
return _unavailable()
|
||||||
try:
|
try:
|
||||||
latest = audit_store.latest_run()
|
latest = audit_store.latest_run()
|
||||||
summary = {}
|
summary = {}
|
||||||
if latest:
|
if latest:
|
||||||
for f in audit_store.get_findings(latest['run_id']):
|
for f in audit_store.effective_findings(latest['run_id']):
|
||||||
summary[f['state']] = summary.get(f['state'], 0) + 1
|
# 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({
|
return jsonify({
|
||||||
"success": True,
|
"success": True,
|
||||||
"running": _running['active'],
|
"running": _running['active'],
|
||||||
|
"progress": {k: _running.get(k) for k in ('run_id', 'completed', 'total', 'check_id')},
|
||||||
"latest": latest,
|
"latest": latest,
|
||||||
"summary": summary,
|
"summary": summary,
|
||||||
"baseline": audit_store.get_baseline(),
|
"baseline": audit_store.get_baseline(),
|
||||||
@@ -87,7 +126,7 @@ def status():
|
|||||||
|
|
||||||
|
|
||||||
@audit_bp.route('/api/audit/run', methods=['POST'])
|
@audit_bp.route('/api/audit/run', methods=['POST'])
|
||||||
@require_auth
|
@require_admin_scope
|
||||||
def run():
|
def run():
|
||||||
"""Start an assessment in the background.
|
"""Start an assessment in the background.
|
||||||
|
|
||||||
@@ -95,13 +134,17 @@ def run():
|
|||||||
interface polls ``/api/audit/status``. A full assessment is short but
|
interface polls ``/api/audit/status``. A full assessment is short but
|
||||||
runs against a production host, so it must not hold an HTTP worker.
|
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()
|
return _unavailable()
|
||||||
|
|
||||||
data = request.get_json(silent=True) or {}
|
data = request.get_json(silent=True) or {}
|
||||||
profile = str(data.get('profile') or 'full')
|
profile = str(data.get('profile') or 'full')
|
||||||
areas = data.get('areas')
|
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:
|
with _run_lock:
|
||||||
if _running['active']:
|
if _running['active']:
|
||||||
@@ -110,21 +153,27 @@ def run():
|
|||||||
"message": "An assessment is already running",
|
"message": "An assessment is already running",
|
||||||
"run_id": _running['run_id'],
|
"run_id": _running['run_id'],
|
||||||
}), 409
|
}), 409
|
||||||
_running.update({'active': True, 'run_id': None,
|
run_id = audit_store.start_run(profile)
|
||||||
'started_at': time.time()})
|
_running.update({'active': True, 'run_id': run_id,
|
||||||
|
'started_at': time.time(), 'completed': 0, 'total': 0, 'check_id': None})
|
||||||
|
|
||||||
def worker():
|
def worker():
|
||||||
try:
|
try:
|
||||||
run_id = audit_checks.run_assessment(profile, only_areas=only)
|
audit_checks.run_assessment(profile, only_areas=only, run_id=run_id, progress=_progress)
|
||||||
_running['run_id'] = run_id
|
|
||||||
audit_store.prune_runs()
|
audit_store.prune_runs()
|
||||||
except Exception as e:
|
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}")
|
print(f"[audit] assessment failed: {e}")
|
||||||
finally:
|
finally:
|
||||||
_running['active'] = False
|
_running['active'] = False
|
||||||
|
|
||||||
threading.Thread(target=worker, daemon=True, name='audit-run').start()
|
try:
|
||||||
return jsonify({"success": True, "started": True})
|
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'])
|
@audit_bp.route('/api/audit/runs', methods=['GET'])
|
||||||
@@ -154,10 +203,10 @@ def run_detail(run_id):
|
|||||||
run = audit_store.get_run(run_id)
|
run = audit_store.get_run(run_id)
|
||||||
if not run:
|
if not run:
|
||||||
return jsonify({"success": False, "message": "Run not found"}), 404
|
return jsonify({"success": False, "message": "Run not found"}), 404
|
||||||
exceptions = audit_store.active_exceptions()
|
# History is immutable by default. The live view explicitly asks
|
||||||
findings = audit_store.get_findings(run_id)
|
# for current decisions, so acceptance/revocation needs no scan.
|
||||||
for f in findings:
|
findings = (audit_store.effective_findings(run_id) if request.args.get('effective') == '1'
|
||||||
f['exception'] = exceptions.get(f['check_id'])
|
else audit_store.get_findings(run_id))
|
||||||
return jsonify({"success": True, "run": run, "findings": findings})
|
return jsonify({"success": True, "run": run, "findings": findings})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"success": False, "message": str(e)}), 500
|
return jsonify({"success": False, "message": str(e)}), 500
|
||||||
@@ -181,6 +230,7 @@ def compare():
|
|||||||
if not base or not other:
|
if not base or not other:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"success": False,
|
"success": False,
|
||||||
|
"reason": "insufficient_runs",
|
||||||
"message": "Two runs are required to compare",
|
"message": "Two runs are required to compare",
|
||||||
}), 400
|
}), 400
|
||||||
return jsonify({
|
return jsonify({
|
||||||
@@ -194,7 +244,7 @@ def compare():
|
|||||||
|
|
||||||
|
|
||||||
@audit_bp.route('/api/audit/baseline', methods=['POST'])
|
@audit_bp.route('/api/audit/baseline', methods=['POST'])
|
||||||
@require_auth
|
@require_admin_scope
|
||||||
def set_baseline():
|
def set_baseline():
|
||||||
if not audit_store:
|
if not audit_store:
|
||||||
return _unavailable()
|
return _unavailable()
|
||||||
@@ -218,13 +268,14 @@ def list_exceptions():
|
|||||||
return jsonify({
|
return jsonify({
|
||||||
"success": True,
|
"success": True,
|
||||||
"exceptions": audit_store.all_exceptions(),
|
"exceptions": audit_store.all_exceptions(),
|
||||||
|
"history": audit_store.exception_history(),
|
||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"success": False, "message": str(e)}), 500
|
return jsonify({"success": False, "message": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
@audit_bp.route('/api/audit/exceptions', methods=['POST'])
|
@audit_bp.route('/api/audit/exceptions', methods=['POST'])
|
||||||
@require_auth
|
@require_admin_scope
|
||||||
def accept_exception():
|
def accept_exception():
|
||||||
"""Record a finding as a deliberate decision.
|
"""Record a finding as a deliberate decision.
|
||||||
|
|
||||||
@@ -244,11 +295,20 @@ def accept_exception():
|
|||||||
if not reason:
|
if not reason:
|
||||||
return jsonify({"success": False,
|
return jsonify({"success": False,
|
||||||
"message": "A reason is required"}), 400
|
"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
|
expires_at = None
|
||||||
days = data.get('expires_in_days')
|
days = data.get('expires_in_days')
|
||||||
if days:
|
if days is not None:
|
||||||
try:
|
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
|
expires_at = int(time.time()) + int(days) * 86400
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return jsonify({"success": False,
|
return jsonify({"success": False,
|
||||||
@@ -256,8 +316,9 @@ def accept_exception():
|
|||||||
|
|
||||||
audit_store.accept_risk(
|
audit_store.accept_risk(
|
||||||
check_id, reason,
|
check_id, reason,
|
||||||
accepted_by=str(data.get('accepted_by') or 'admin'),
|
accepted_by=_actor(),
|
||||||
expires_at=expires_at,
|
expires_at=expires_at,
|
||||||
|
scope=finding['scope'],
|
||||||
)
|
)
|
||||||
return jsonify({"success": True})
|
return jsonify({"success": True})
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
@@ -267,15 +328,140 @@ def accept_exception():
|
|||||||
|
|
||||||
|
|
||||||
@audit_bp.route('/api/audit/exceptions/<path:check_id>', methods=['DELETE'])
|
@audit_bp.route('/api/audit/exceptions/<path:check_id>', methods=['DELETE'])
|
||||||
@require_auth
|
@require_admin_scope
|
||||||
def revoke_exception(check_id):
|
def revoke_exception(check_id):
|
||||||
if not audit_store:
|
if not audit_store:
|
||||||
return _unavailable()
|
return _unavailable()
|
||||||
try:
|
try:
|
||||||
removed = audit_store.revoke_risk(check_id)
|
removed = audit_store.revoke_risk(check_id, _actor())
|
||||||
if not removed:
|
if not removed:
|
||||||
return jsonify({"success": False,
|
return jsonify({"success": False,
|
||||||
"message": "Exception not found"}), 404
|
"message": "Exception not found"}), 404
|
||||||
return jsonify({"success": True})
|
return jsonify({"success": True})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"success": False, "message": str(e)}), 500
|
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
|
||||||
|
|||||||
@@ -495,10 +495,26 @@ def auth_change_password():
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
data = request.json or {}
|
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')
|
old_password = data.get('old_password')
|
||||||
|
if old_password is None:
|
||||||
|
old_password = data.get('current_password')
|
||||||
new_password = data.get('new_password')
|
new_password = data.get('new_password')
|
||||||
totp_code = data.get('totp_code')
|
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)
|
success, message = auth_manager.change_password(old_password, new_password, totp_code)
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
|
|||||||
@@ -2254,11 +2254,17 @@ def _vm_disk_refresher_loop():
|
|||||||
cycle_started = time.time()
|
cycle_started = time.time()
|
||||||
try:
|
try:
|
||||||
resources = get_cached_pvesh_cluster_resources_vm() or []
|
resources = get_cached_pvesh_cluster_resources_vm() or []
|
||||||
|
local_node = get_proxmox_node_name()
|
||||||
live_vmids = set()
|
live_vmids = set()
|
||||||
targets = []
|
targets = []
|
||||||
for r in resources:
|
for r in resources:
|
||||||
if r.get('type') not in ('qemu', 'vm'):
|
if r.get('type') not in ('qemu', 'vm'):
|
||||||
continue
|
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':
|
if r.get('status') != 'running':
|
||||||
continue
|
continue
|
||||||
vmid = r.get('vmid')
|
vmid = r.get('vmid')
|
||||||
@@ -6669,7 +6675,12 @@ def get_proxmox_vms():
|
|||||||
# producing a false "1 package pending"
|
# producing a false "1 package pending"
|
||||||
# every time a registered app had a newer
|
# every time a registered app had a newer
|
||||||
# upstream version.
|
# 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:
|
if app_list:
|
||||||
vm_data['app_watches'] = app_list
|
vm_data['app_watches'] = app_list
|
||||||
# Apps dashboard reads this to build
|
# Apps dashboard reads this to build
|
||||||
|
|||||||
@@ -6149,6 +6149,7 @@ class HealthMonitor:
|
|||||||
try:
|
try:
|
||||||
import flask_server # deferred — avoids circular import at module load
|
import flask_server # deferred — avoids circular import at module load
|
||||||
resources = flask_server.get_cached_pvesh_cluster_resources_vm() or []
|
resources = flask_server.get_cached_pvesh_cluster_resources_vm() or []
|
||||||
|
local_node = flask_server.get_proxmox_node_name()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[HealthMonitor] LXC disk check failed: {e}")
|
print(f"[HealthMonitor] LXC disk check failed: {e}")
|
||||||
return None
|
return None
|
||||||
@@ -6170,6 +6171,12 @@ class HealthMonitor:
|
|||||||
for r in resources:
|
for r in resources:
|
||||||
if r.get('type') != 'lxc':
|
if r.get('type') != 'lxc':
|
||||||
continue
|
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':
|
if r.get('status') != 'running':
|
||||||
# Stopped CTs — `disk` reads as 0 from pvesh because the
|
# Stopped CTs — `disk` reads as 0 from pvesh because the
|
||||||
# rootfs isn't mounted. Skip rather than report a
|
# rootfs isn't mounted. Skip rather than report a
|
||||||
@@ -6194,6 +6201,7 @@ class HealthMonitor:
|
|||||||
'maxdisk_bytes': maxdisk,
|
'maxdisk_bytes': maxdisk,
|
||||||
'vmid': vmid,
|
'vmid': vmid,
|
||||||
'name': name,
|
'name': name,
|
||||||
|
'node': local_node,
|
||||||
}
|
}
|
||||||
error_key = f'lxc_disk_{vmid}'
|
error_key = f'lxc_disk_{vmid}'
|
||||||
|
|
||||||
@@ -6287,13 +6295,16 @@ class HealthMonitor:
|
|||||||
try:
|
try:
|
||||||
import flask_server # deferred — avoids circular import
|
import flask_server # deferred — avoids circular import
|
||||||
resources = flask_server.get_cached_pvesh_cluster_resources_vm() or []
|
resources = flask_server.get_cached_pvesh_cluster_resources_vm() or []
|
||||||
|
local_node = flask_server.get_proxmox_node_name()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[HealthMonitor] VM disk check failed: {e}")
|
print(f"[HealthMonitor] VM disk check failed: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Cheap short-circuit: no running QEMU VMs on this node.
|
# Cheap short-circuit: no running QEMU VMs on this node.
|
||||||
if not any(
|
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
|
for r in resources
|
||||||
):
|
):
|
||||||
return None
|
return None
|
||||||
@@ -6308,6 +6319,8 @@ class HealthMonitor:
|
|||||||
for r in resources:
|
for r in resources:
|
||||||
if r.get('type') not in ('qemu', 'vm'):
|
if r.get('type') not in ('qemu', 'vm'):
|
||||||
continue
|
continue
|
||||||
|
if r.get('node') != local_node:
|
||||||
|
continue
|
||||||
if r.get('status') != 'running':
|
if r.get('status') != 'running':
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -6338,6 +6351,7 @@ class HealthMonitor:
|
|||||||
'maxdisk_bytes': total,
|
'maxdisk_bytes': total,
|
||||||
'vmid': vmid_str,
|
'vmid': vmid_str,
|
||||||
'name': name,
|
'name': name,
|
||||||
|
'node': local_node,
|
||||||
}
|
}
|
||||||
error_key = f'vm_disk_{vmid_str}'
|
error_key = f'vm_disk_{vmid_str}'
|
||||||
|
|
||||||
|
|||||||
+137
-49
@@ -17,8 +17,8 @@
|
|||||||
# update_app(vmid, app_id, config) -> (bool, …)
|
# update_app(vmid, app_id, config) -> (bool, …)
|
||||||
# delete_app(vmid, app_id) -> bool
|
# delete_app(vmid, app_id) -> bool
|
||||||
# delete_all(vmid) -> bool
|
# delete_all(vmid) -> bool
|
||||||
# check_app(vmid, app_id, force=False) -> dict|None
|
# check_app(vmid, app_id, force=False, notify=True) -> dict|None
|
||||||
# check_all(vmid, force=False) -> dict|None
|
# check_all(vmid, force=False, notify=True) -> dict|None
|
||||||
# get_active_apps() -> {str(vmid): [summary, …]}
|
# get_active_apps() -> {str(vmid): [summary, …]}
|
||||||
# get_suggestions(vmid) -> {name, port_suggestions[], web_path_hint}
|
# 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)
|
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
|
# 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
|
# 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
|
# just don't care). Field defaults to True — an app registered
|
||||||
# before this feature landed keeps receiving notifications.
|
# before this feature landed keeps receiving notifications.
|
||||||
if app.get("notifications_enabled", True) is False:
|
if app.get("notifications_enabled", True) is False:
|
||||||
return
|
return None
|
||||||
if app.get("helper_slug") == "docker":
|
if app.get("helper_slug") == "docker":
|
||||||
return
|
return None
|
||||||
# Delegated apps are announced by their Docker image's own event; a
|
# 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
|
# second one for the same release would land in a different event type
|
||||||
# and therefore escape deduplication.
|
# and therefore escape deduplication.
|
||||||
if app.get("update_via") == "docker":
|
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:
|
try:
|
||||||
from notification_manager import notification_manager
|
from notification_manager import notification_manager
|
||||||
import socket
|
|
||||||
state = app.get("state") or {}
|
|
||||||
notification_manager.emit_event(
|
notification_manager.emit_event(
|
||||||
event_type='app_update_available',
|
event_type='app_update_available',
|
||||||
severity='INFO',
|
severity='INFO',
|
||||||
data={
|
data={"hostname": socket.gethostname(), **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',
|
|
||||||
},
|
|
||||||
source='app_watch',
|
source='app_watch',
|
||||||
entity='ct',
|
entity=entity,
|
||||||
# vmid + app_id + latest so multi-app CTs don't dedup and
|
entity_id=entity_id,
|
||||||
# subsequent upstream releases still fire.
|
|
||||||
entity_id=f"{vmid}:{app.get('id')}:{state.get('latest_version') or ''}",
|
|
||||||
)
|
)
|
||||||
|
return True
|
||||||
except Exception as e:
|
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(
|
def _docker_stack_notification_payload(
|
||||||
@@ -4166,7 +4192,9 @@ def _detect_with_alt_healing(vmid, app: dict) -> tuple:
|
|||||||
return installed, err, False
|
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:
|
with _cache_lock:
|
||||||
sidecar = _read_sidecar(vmid)
|
sidecar = _read_sidecar(vmid)
|
||||||
if not sidecar:
|
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
|
# (vmid + app_id + latest_version) with its cooldown, and only
|
||||||
# a genuinely new upstream release changes the entity_id and
|
# a genuinely new upstream release changes the entity_id and
|
||||||
# triggers a fresh delivery.
|
# triggers a fresh delivery.
|
||||||
if update_available and latest:
|
if notify and update_available and latest:
|
||||||
_fire_update_notification(vmid, app)
|
_fire_update_notification(vmid, app)
|
||||||
|
|
||||||
return sidecar
|
return sidecar
|
||||||
|
|
||||||
|
|
||||||
def emit_all_pending_updates() -> int:
|
def emit_all_pending_updates() -> int:
|
||||||
"""Walk every sidecar and emit `app_update_available` for each
|
"""Emit pending registered-app updates as one scheduled summary.
|
||||||
app currently marked with a pending upstream release. Safe to
|
|
||||||
call repeatedly — `notification_manager` dedups by entity_id
|
A single pending app retains the original per-app notification. Multiple
|
||||||
(vmid + app_id + latest_version), so a given release only sends
|
apps are grouped into one event, ordered by CT and app, while preserving
|
||||||
once until a newer version appears.
|
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
|
Needed because `check_app(force=False)` short-circuits on a fresh
|
||||||
`checked_at` and never reaches the emit path. The 24 h
|
`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
|
this helper the notification only ever fired on the exact tick
|
||||||
where a new upstream version was FIRST observed — and even that
|
where a new upstream version was FIRST observed — and even that
|
||||||
was silenced when the user's setting was OFF at the time.
|
was silenced when the user's setting was OFF at the time.
|
||||||
Returns the number of emits attempted (delivery still depends on
|
Returns the number of eligible pending apps represented by the event
|
||||||
channel enablement + cooldown + rate limit)."""
|
(delivery still depends on channel enablement + cooldown + rate limit)."""
|
||||||
try:
|
try:
|
||||||
entries = sorted(os.listdir(_APPS_DIR))
|
entries = sorted(os.listdir(_APPS_DIR))
|
||||||
except (FileNotFoundError, OSError):
|
except (FileNotFoundError, OSError):
|
||||||
print("[ProxMenux] emit_all_pending_updates: _APPS_DIR missing", flush=True)
|
print("[ProxMenux] emit_all_pending_updates: _APPS_DIR missing", flush=True)
|
||||||
return 0
|
return 0
|
||||||
n = 0
|
pending_payloads: list[dict] = []
|
||||||
print(f"[ProxMenux] emit_all_pending_updates: scanning {len(entries)} sidecar file(s)", flush=True)
|
print(f"[ProxMenux] emit_all_pending_updates: scanning {len(entries)} sidecar file(s)", flush=True)
|
||||||
for name in entries:
|
for name in entries:
|
||||||
if not name.endswith(".json"):
|
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)
|
print(f"[ProxMenux] emit_all_pending_updates: CT {vmid} sidecar empty", flush=True)
|
||||||
continue
|
continue
|
||||||
apps = sidecar.get("apps") or []
|
apps = sidecar.get("apps") or []
|
||||||
pending = [a for a in apps
|
pending = [
|
||||||
if (a.get("state") or {}).get("update_available")
|
app for app in apps
|
||||||
and (a.get("state") or {}).get("latest_version")]
|
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)
|
print(f"[ProxMenux] emit_all_pending_updates: CT {vmid} apps={len(apps)} pending={len(pending)}", flush=True)
|
||||||
for app in pending:
|
for app in pending:
|
||||||
try:
|
payload = _app_update_notification_payload(vmid, app)
|
||||||
_fire_update_notification(vmid, app)
|
if payload is not None:
|
||||||
n += 1
|
pending_payloads.append(payload)
|
||||||
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)
|
|
||||||
except Exception as e:
|
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: CT {vmid} outer failure: {e}", flush=True)
|
||||||
print(f"[ProxMenux] emit_all_pending_updates: {n} emit(s) attempted total", flush=True)
|
pending_payloads.sort(
|
||||||
return n
|
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)
|
sidecar = _read_sidecar(vmid)
|
||||||
if not sidecar:
|
if not sidecar:
|
||||||
return None
|
return None
|
||||||
for app in (sidecar.get("apps") or []):
|
for app in (sidecar.get("apps") or []):
|
||||||
try:
|
try:
|
||||||
check_app(vmid, app.get("id"), force=force)
|
check_app(vmid, app.get("id"), force=force, notify=notify)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[ProxMenux] lxc_apps.check_all: CT {vmid} app {app.get('id')} failed: {e}")
|
print(f"[ProxMenux] lxc_apps.check_all: CT {vmid} app {app.get('id')} failed: {e}")
|
||||||
return _read_sidecar(vmid)
|
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
|
"""Called from the polling collector's daily cycle so header
|
||||||
badges stay fresh without needing to open every modal."""
|
badges stay fresh without needing to open every modal."""
|
||||||
try:
|
try:
|
||||||
@@ -4316,7 +4391,7 @@ def refresh_all_apps(force: bool = False) -> int:
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
check_all(vmid, force=force)
|
check_all(vmid, force=force, notify=notify)
|
||||||
n += 1
|
n += 1
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[ProxMenux] lxc_apps refresh_all: CT {vmid} failed: {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]:
|
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
|
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
|
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
|
and IPv6 bindings of the same host port are deduplicated; loopback-only
|
||||||
bindings are omitted because they cannot form a usable remote LXC link.
|
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)
|
key = str(vmid)
|
||||||
now = time.time()
|
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:
|
if cached and (now - cached[0]) < _PORT_PROBE_TTL_SEC:
|
||||||
return [dict(item) for item in cached[1]]
|
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:
|
if rc != 0:
|
||||||
result: list[dict] = []
|
result: list[dict] = []
|
||||||
else:
|
else:
|
||||||
@@ -5023,7 +5100,18 @@ def _probe_docker_web_links(vmid) -> list[dict]:
|
|||||||
labels = config.get("Labels") or {}
|
labels = config.get("Labels") or {}
|
||||||
service = str(labels.get("com.docker.compose.service") or container).strip()
|
service = str(labels.get("com.docker.compose.service") or container).strip()
|
||||||
meta = _docker_service_catalog_meta(service, container, image)
|
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()
|
seen_host_ports: set[int] = set()
|
||||||
for container_endpoint, bindings in ports.items():
|
for container_endpoint, bindings in ports.items():
|
||||||
if not str(container_endpoint).endswith("/tcp") or not isinstance(bindings, list):
|
if not str(container_endpoint).endswith("/tcp") or not isinstance(bindings, list):
|
||||||
|
|||||||
@@ -513,6 +513,16 @@ class JournalWatcher:
|
|||||||
self._oom_lines = []
|
self._oom_lines = []
|
||||||
self._oom_started_at = 0.0
|
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
|
# 24h anti-cascade for disk I/O + filesystem errors. The dict
|
||||||
# key includes a tier suffix (`sdh:warning`, `sdh:critical`)
|
# key includes a tier suffix (`sdh:warning`, `sdh:critical`)
|
||||||
# so a disk in WARNING cooldown can still escalate to 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.
|
# paper showed ~36% of failed drives gave no SMART warning.
|
||||||
# Rate-based escalation catches the dying drives that SMART
|
# Rate-based escalation catches the dying drives that SMART
|
||||||
# would never flag until they were already bricked.
|
# 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: Dict[str, "_deque[float]"] = {}
|
||||||
self._DISK_ERROR_WINDOW_SECS = 86400 # 24h
|
self._DISK_ERROR_WINDOW_SECS = 86400 # 24h
|
||||||
# Tiers calibrated for homelab/SMB Proxmox usage:
|
# Tiers calibrated for homelab/SMB Proxmox usage:
|
||||||
@@ -767,7 +776,7 @@ class JournalWatcher:
|
|||||||
|
|
||||||
self._check_auth_failure(msg, syslog_id, entry)
|
self._check_auth_failure(msg, syslog_id, entry)
|
||||||
self._check_fail2ban(msg, syslog_id)
|
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_service_failure(msg, unit)
|
||||||
self._check_disk_io(msg, syslog_id, priority)
|
self._check_disk_io(msg, syslog_id, priority)
|
||||||
self._check_cluster_events(msg, syslog_id)
|
self._check_cluster_events(msg, syslog_id)
|
||||||
@@ -849,13 +858,69 @@ class JournalWatcher:
|
|||||||
'hostname': self._hostname,
|
'hostname': self._hostname,
|
||||||
}, entity='user', entity_id=ip)
|
}, 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."""
|
"""Detect kernel panics, OOM, segfaults, hardware errors."""
|
||||||
# Only process messages from kernel or systemd (not app-level logs)
|
# Only process messages from kernel or systemd (not app-level logs)
|
||||||
if syslog_id and syslog_id not in ('kernel', 'systemd', 'systemd-coredump', ''):
|
if syslog_id and syslog_id not in ('kernel', 'systemd', 'systemd-coredump', ''):
|
||||||
return
|
return
|
||||||
|
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
journal_context = self._remember_kernel_context(msg, now)
|
||||||
if self._oom_lines and now - self._oom_started_at > 15:
|
if self._oom_lines and now - self._oom_started_at > 15:
|
||||||
self._oom_lines = []
|
self._oom_lines = []
|
||||||
self._oom_started_at = 0.0
|
self._oom_started_at = 0.0
|
||||||
@@ -918,6 +983,43 @@ class JournalWatcher:
|
|||||||
for noise in _KERNEL_NOISE:
|
for noise in _KERNEL_NOISE:
|
||||||
if re.search(noise, msg, re.IGNORECASE):
|
if re.search(noise, msg, re.IGNORECASE):
|
||||||
return
|
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
|
# NOTE: Disk I/O errors (ATA, SCSI, blk_update_request) are NOT handled
|
||||||
# here. They are detected exclusively by HealthMonitor._check_disks_optimized
|
# 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'Out of memory': ('system_problem', 'CRITICAL', 'Out of memory killer activated'),
|
||||||
r'segfault': ('system_problem', 'WARNING', 'Segmentation fault detected'),
|
r'segfault': ('system_problem', 'WARNING', 'Segmentation fault detected'),
|
||||||
r'BUG:': ('system_problem', 'CRITICAL', 'Kernel BUG 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'EXT4-fs error': ('system_problem', 'CRITICAL', 'Filesystem error'),
|
||||||
r'BTRFS error': ('system_problem', 'CRITICAL', 'Filesystem error'),
|
r'BTRFS error': ('system_problem', 'CRITICAL', 'Filesystem error'),
|
||||||
r'XFS.*error': ('system_problem', 'CRITICAL', 'Filesystem error'),
|
r'XFS.*error': ('system_problem', 'CRITICAL', 'Filesystem error'),
|
||||||
@@ -2634,6 +2735,53 @@ class PollingCollector:
|
|||||||
def _hostname(self) -> str:
|
def _hostname(self) -> str:
|
||||||
return _hostname()
|
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_<vmid>` and `vm_disk_<vmid>` 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):
|
def start(self):
|
||||||
if self._running:
|
if self._running:
|
||||||
return
|
return
|
||||||
@@ -2988,6 +3136,15 @@ class PollingCollector:
|
|||||||
reason = old_meta.get('reason', '')
|
reason = old_meta.get('reason', '')
|
||||||
first_seen = old_meta.get('first_seen', '')
|
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
|
# Skip recovery for INFO/OK - they never triggered an alert
|
||||||
if old_meta.get('severity', '') in ('INFO', 'OK'):
|
if old_meta.get('severity', '') in ('INFO', 'OK'):
|
||||||
self._last_notified.pop(key, None)
|
self._last_notified.pop(key, None)
|
||||||
@@ -3642,7 +3799,11 @@ class PollingCollector:
|
|||||||
# blocks the others.
|
# blocks the others.
|
||||||
try:
|
try:
|
||||||
import lxc_apps
|
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
|
# Docker images have an independent lifecycle from both the OS
|
||||||
# packages and the Docker engine. Refresh their read-only
|
# packages and the Docker engine. Refresh their read-only
|
||||||
# registry digest inventory on the same daily cadence; this never
|
# 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
|
# yesterday's cycle cannot postpone the next automatic scan by an
|
||||||
# additional day. Normal UI reads remain cache-only for 24 hours.
|
# additional day. Normal UI reads remain cache-only for 24 hours.
|
||||||
lxc_apps.refresh_docker_inventories(force=True)
|
lxc_apps.refresh_docker_inventories(force=True)
|
||||||
# After the refresh, emit `app_update_available` for every
|
# Emit one detailed registered-app summary for this sweep. A
|
||||||
# sidecar entry currently flagged with a pending upstream
|
# single pending app retains the existing individual wording;
|
||||||
# release. `check_app(force=False)` short-circuits on a
|
# several apps are grouped by CT with every version pair intact.
|
||||||
# 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.
|
|
||||||
lxc_apps.emit_all_pending_docker_stacks()
|
lxc_apps.emit_all_pending_docker_stacks()
|
||||||
lxc_apps.emit_all_pending_updates()
|
lxc_apps.emit_all_pending_updates()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -497,6 +497,7 @@ AGGREGATION_RULES = {
|
|||||||
'service_fail': {'window': 90, 'min_count': 2, 'burst_type': 'burst_service_fail'},
|
'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'},
|
'service_fail_batch': {'window': 90, 'min_count': 2, 'burst_type': 'burst_service_fail'},
|
||||||
'system_problem': {'window': 90, 'min_count': 2, 'burst_type': 'burst_system'},
|
'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'},
|
'oom_kill': {'window': 60, 'min_count': 2, 'burst_type': 'burst_generic'},
|
||||||
'firewall_issue': {'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.
|
# recovery is per-event; collapsing them adds zero information.
|
||||||
_AGGREGATION_EXEMPT_EVENTS = frozenset({
|
_AGGREGATION_EXEMPT_EVENTS = frozenset({
|
||||||
'error_resolved',
|
'error_resolved',
|
||||||
# Per-app upstream update. Each event carries a distinct app name,
|
# Registered-app updates are grouped deliberately by their producer during
|
||||||
# version and CT id — collapsing "5 app updates burst" into a
|
# automatic/startup sweeps, preserving each app, CT and version pair.
|
||||||
# summary hides exactly the information the user wants (which
|
# Manual checks still emit one complete per-app event. Sending either form
|
||||||
# apps, which versions). Startup emit fires all pending updates
|
# through the generic burst formatter would discard those details.
|
||||||
# at once, so without this exemption only the first 1-2 land and
|
|
||||||
# the rest get buffered into a useless summary.
|
|
||||||
'app_update_available',
|
'app_update_available',
|
||||||
'docker_stack_update_available',
|
'docker_stack_update_available',
|
||||||
'lxc_update_applied',
|
'lxc_update_applied',
|
||||||
@@ -1274,8 +1273,18 @@ class NotificationManager:
|
|||||||
channels = dict(self._channels)
|
channels = dict(self._channels)
|
||||||
|
|
||||||
template = TEMPLATES.get(event_type, {})
|
template = TEMPLATES.get(event_type, {})
|
||||||
event_group = template.get('group', 'other')
|
# Hidden burst templates represent their originating event; they must
|
||||||
default_event_enabled = 'true' if template.get('default_enabled', True) else 'false'
|
# 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)
|
# Build AI config once (shared across channels, detail_level varies)
|
||||||
ai_config = self._build_ai_config()
|
ai_config = self._build_ai_config()
|
||||||
@@ -1292,7 +1301,7 @@ class NotificationManager:
|
|||||||
|
|
||||||
# ── Per-channel event check ──
|
# ── Per-channel event check ──
|
||||||
# Default: from template default_enabled, unless explicitly set.
|
# 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':
|
if self._config.get(ch_event_key, default_event_enabled) == 'false':
|
||||||
continue # Channel has this specific event disabled
|
continue # Channel has this specific event disabled
|
||||||
|
|
||||||
|
|||||||
@@ -418,6 +418,73 @@ def _format_system_startup(data: Dict[str, Any]) -> Tuple[str, str]:
|
|||||||
return title, body
|
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 ──────────────────────────────────────────────
|
||||||
|
|
||||||
SEVERITY_ICONS = {
|
SEVERITY_ICONS = {
|
||||||
@@ -536,6 +603,7 @@ TEMPLATES = {
|
|||||||
# this one off meant users who registered apps in the App tab
|
# this one off meant users who registered apps in the App tab
|
||||||
# never received the notification they explicitly asked for.
|
# never received the notification they explicitly asked for.
|
||||||
'default_enabled': True,
|
'default_enabled': True,
|
||||||
|
'formatter': '_format_app_update_available',
|
||||||
},
|
},
|
||||||
'docker_stack_update_available': {
|
'docker_stack_update_available': {
|
||||||
'title': '{hostname}: Docker updates available on CT {vmid}',
|
'title': '{hostname}: Docker updates available on CT {vmid}',
|
||||||
@@ -968,6 +1036,13 @@ TEMPLATES = {
|
|||||||
'group': 'services',
|
'group': 'services',
|
||||||
'default_enabled': True,
|
'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': {
|
'service_fail': {
|
||||||
'title': '{hostname}: Service failed — {service_name}',
|
'title': '{hostname}: Service failed — {service_name}',
|
||||||
'body': 'System service "{service_name}" has failed.\nReason: {reason}',
|
'body': 'System service "{service_name}" has failed.\nReason: {reason}',
|
||||||
@@ -1811,6 +1886,7 @@ EVENT_EMOJI = {
|
|||||||
'system_reboot': '\U0001F504',
|
'system_reboot': '\U0001F504',
|
||||||
'system_restore_completed': '✅', # check mark
|
'system_restore_completed': '✅', # check mark
|
||||||
'system_problem': '\u26A0\uFE0F',
|
'system_problem': '\u26A0\uFE0F',
|
||||||
|
'kernel_warning': '\u26A0\uFE0F',
|
||||||
'service_fail': '\u274C',
|
'service_fail': '\u274C',
|
||||||
'oom_kill': '\U0001F4A3', # bomb
|
'oom_kill': '\U0001F4A3', # bomb
|
||||||
# Health
|
# Health
|
||||||
|
|||||||
@@ -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.
|
Parse /var/log/lynis-report.dat into structured report data.
|
||||||
Also enriches with data from lynis.log when report.dat is sparse.
|
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"
|
report_file = "/var/log/lynis-report.dat"
|
||||||
output_file = "/var/log/lynis-output.log"
|
output_file = "/var/log/lynis-output.log"
|
||||||
@@ -1890,14 +1906,9 @@ def parse_lynis_report():
|
|||||||
|
|
||||||
# Parse warnings
|
# Parse warnings
|
||||||
for w in warnings_raw:
|
for w in warnings_raw:
|
||||||
parts = w.split("|")
|
warning = _parse_lynis_warning(w)
|
||||||
if len(parts) >= 2:
|
if warning:
|
||||||
report["warnings"].append({
|
report["warnings"].append(warning)
|
||||||
"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 "",
|
|
||||||
})
|
|
||||||
|
|
||||||
# Parse suggestions
|
# Parse suggestions
|
||||||
for s in suggestions_raw:
|
for s in suggestions_raw:
|
||||||
@@ -2100,7 +2111,7 @@ def parse_lynis_report():
|
|||||||
break
|
break
|
||||||
|
|
||||||
# Also check pve-firewall directly (Proxmox uses its own firewall service)
|
# 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:
|
try:
|
||||||
rc, out, _ = _run_cmd(["systemctl", "is-active", "pve-firewall"])
|
rc, out, _ = _run_cmd(["systemctl", "is-active", "pve-firewall"])
|
||||||
if rc == 0 and out.strip() == "active":
|
if rc == 0 and out.strip() == "active":
|
||||||
@@ -2246,7 +2257,7 @@ def parse_lynis_report():
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
# Fallback: get kernel from uname if still empty
|
# Fallback: get kernel from uname if still empty
|
||||||
if not report["kernel_version"]:
|
if enrich_current and not report["kernel_version"]:
|
||||||
try:
|
try:
|
||||||
rc, out, _ = _run_cmd(["uname", "-r"])
|
rc, out, _ = _run_cmd(["uname", "-r"])
|
||||||
if rc == 0 and out.strip():
|
if rc == 0 and out.strip():
|
||||||
@@ -2255,7 +2266,7 @@ def parse_lynis_report():
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
# Fallback: get hostname from system
|
# Fallback: get hostname from system
|
||||||
if not report["hostname"]:
|
if enrich_current and not report["hostname"]:
|
||||||
try:
|
try:
|
||||||
import socket
|
import socket
|
||||||
report["hostname"] = socket.gethostname()
|
report["hostname"] = socket.gethostname()
|
||||||
@@ -2263,7 +2274,7 @@ def parse_lynis_report():
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
# Fallback: get installed packages count
|
# Fallback: get installed packages count
|
||||||
if report["installed_packages"] == 0:
|
if enrich_current and report["installed_packages"] == 0:
|
||||||
try:
|
try:
|
||||||
rc, out, _ = _run_cmd(["dpkg", "-l"])
|
rc, out, _ = _run_cmd(["dpkg", "-l"])
|
||||||
if rc == 0 and out:
|
if rc == 0 and out:
|
||||||
|
|||||||
@@ -109,5 +109,105 @@ class SetupAuthTests(unittest.TestCase):
|
|||||||
self.assertEqual(config[key], value)
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
+43
-43
@@ -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.",
|
"(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).",
|
"(common default on Debian/LXC: PermitRootLogin prohibit-password).": "(allgemeiner Standard unter Debian/LXC: PermitRootLogin prohibit-password).",
|
||||||
"(disabled)": "(deaktiviert)",
|
"(disabled)": "(deaktiviert)",
|
||||||
"(e.g.": "(z.B.",
|
"(e.g.": "(z. B.",
|
||||||
"(for unprivileged LXCs)": "(für unprivilegierte LXCs)",
|
"(for unprivileged LXCs)": "(für unprivilegierte LXCs)",
|
||||||
"(if only privileged LXCs need write access)": "(wenn nur privilegierte LXCs Schreibzugriff benötigen)",
|
"(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)",
|
"(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 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 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 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 file appears corrupted, will reinstall packages": "Die Sicherungsdatei scheint beschädigt zu sein, die Pakete werden neu installiert",
|
||||||
"Backup host configuration": "Backup-Hostkonfiguration",
|
"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)?",
|
"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 to local archive (.tar.zst)": "Sicherung im lokalen Archiv (.tar.zst)",
|
||||||
"Backup:": "Sicherung:",
|
"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 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 limit configured": "Bandbreitenbegrenzung konfiguriert",
|
||||||
"Bandwidth test (iperf3)": "Bandbreitentest (iperf3)",
|
"Bandwidth test (iperf3)": "Bandbreitentest (iperf3)",
|
||||||
"Bandwidth test completed successfully": "Bandbreitentest erfolgreich abgeschlossen",
|
"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 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 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 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 portal:": "Portal kann nicht erreicht werden:",
|
||||||
"Cannot reach server": "Server ist nicht erreichbar",
|
"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.",
|
"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...",
|
"Cleaning up unused time synchronization services...": "Bereinigen ungenutzter Zeitsynchronisierungsdienste...",
|
||||||
"Cleans duplicate or conflicting sources": "Bereinigt doppelte oder widersprüchliche Quellen",
|
"Cleans duplicate or conflicting sources": "Bereinigt doppelte oder widersprüchliche Quellen",
|
||||||
"Cleanup Complete": "Bereinigung abgeschlossen",
|
"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 finished": "Aufräumen abgeschlossen",
|
||||||
"Cleanup legacy gasket-dkms": "Veraltetes gasket-dkms bereinigen",
|
"Cleanup legacy gasket-dkms": "Veraltetes gasket-dkms bereinigen",
|
||||||
"Cleanup partial VM?": "Teilweise VM 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 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 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 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 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 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 installer to container": "Installationsprogramm in Container kopieren",
|
||||||
"Copying sources to": "Kopieren von Quellen nach",
|
"Copying sources to": "Kopieren von Quellen nach",
|
||||||
"Coral APT repository ready.": "Coral APT-Repository bereit.",
|
"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 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 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 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.": "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 converter directory:": "Konverterverzeichnis konnte nicht erstellt werden:",
|
||||||
"Could not create destination directory:": "Zielverzeichnis 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:",
|
"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 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 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 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 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 recovery blob from PBS.": "Wiederherstellungsblob konnte nicht von PBS heruntergeladen werden.",
|
||||||
"Could not download the installer.": "Das Installationsprogramm konnte nicht 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": "Konnte nicht gemountet werden",
|
||||||
"Could not mount ISO on device": "ISO konnte nicht auf dem Gerät 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 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 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 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 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 SMART data from": "Die SMART-Daten konnten nicht gelesen werden",
|
||||||
"Could not read VM configuration.": "Die VM-Konfiguration konnte 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 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 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 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 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 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",
|
"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 on host:": "Verzeichnis auf Host konnte nicht erstellt werden:",
|
||||||
"Failed to create directory:": "Verzeichnis 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 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 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.",
|
||||||
"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 removed": "Kernel-Panic-Konfiguration entfernt",
|
||||||
"Kernel panic configuration updated and applied": "Kernel-Panic-Konfiguration aktualisiert und angewendet",
|
"Kernel panic configuration updated and applied": "Kernel-Panic-Konfiguration aktualisiert und angewendet",
|
||||||
"Kernel, modules and boot config": "Kernel, Module und Bootkonfiguration",
|
"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": "Schlüsseldatei kopiert",
|
||||||
"Keyfile copied to:": "Schlüsseldatei kopiert nach:",
|
"Keyfile copied to:": "Schlüsseldatei kopiert nach:",
|
||||||
"Keyfile passphrase": "Schlüsseldatei-Passphrase",
|
"Keyfile passphrase": "Schlüsseldatei-Passphrase",
|
||||||
@@ -2860,7 +2860,7 @@
|
|||||||
"No Shares Found": "Keine Aktien gefunden",
|
"No Shares Found": "Keine Aktien gefunden",
|
||||||
"No Storage Found": "Kein Speicher 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 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 UUP folder found.": "Kein UUP-Ordner gefunden.",
|
||||||
"No VM was selected.": "Es wurde keine VM ausgewählt.",
|
"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.",
|
"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 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 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 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 ZFS pools detected. Skipping ZFS autotrim.": "Keine ZFS-Pools erkannt. ZFS-Autotrim wird übersprungen.",
|
||||||
"No accessible": "Nicht zugänglich",
|
"No accessible": "Nicht zugänglich",
|
||||||
"No accessible NFS servers found.": "Es wurden keine zugänglichen NFS-Server gefunden.",
|
"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 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 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 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 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 exportable disks": "Keine exportierbaren Datenträger",
|
||||||
"No exports configured.": "Keine Exporte konfiguriert.",
|
"No exports configured.": "Keine Exporte konfiguriert.",
|
||||||
@@ -2975,7 +2975,7 @@
|
|||||||
"No ports configured": "Keine Ports konfiguriert",
|
"No ports configured": "Keine Ports konfiguriert",
|
||||||
"No privileged containers available in Proxmox.": "In Proxmox sind keine privilegierten Container verfügbar.",
|
"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 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": "Nicht aktuell",
|
||||||
"No recent Samba servers found.": "Keine aktuellen Samba-Server gefunden.",
|
"No recent Samba servers found.": "Keine aktuellen Samba-Server gefunden.",
|
||||||
"No routing information found.": "Keine Routing-Informationen gefunden.",
|
"No routing information found.": "Keine Routing-Informationen gefunden.",
|
||||||
@@ -3799,7 +3799,7 @@
|
|||||||
"Same Version Detected": "Gleiche Version erkannt",
|
"Same Version Detected": "Gleiche Version erkannt",
|
||||||
"Same host:": "Gleicher Gastgeber:",
|
"Same host:": "Gleicher Gastgeber:",
|
||||||
"Same major series:": "Gleiche Hauptserie:",
|
"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 ...",
|
"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 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?",
|
"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",
|
"Smart restore plan — hardware compatibility check": "Smart Restore Plan – Hardware-Kompatibilitätsprüfung",
|
||||||
"Snippets — hook scripts / config": "Snippets – Hook-Skripte/Konfiguration",
|
"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",
|
"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 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 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.",
|
"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...",
|
"Testing network connectivity...": "Netzwerkkonnektivität testen...",
|
||||||
"Thank you for using ProxMenux. Goodbye!": "Vielen Dank, dass Sie ProxMenux verwenden. Auf Wiedersehen!",
|
"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 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 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 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",
|
"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 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 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 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 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 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 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 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",
|
"The container should now start as privileged": "Der Container sollte nun als privilegiert starten",
|
||||||
@@ -4353,12 +4353,12 @@
|
|||||||
"The filesystem": "Das Dateisystem",
|
"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 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 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 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 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 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 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) 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 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:",
|
"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 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 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 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 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 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",
|
"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 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 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 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 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 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 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 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 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 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 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 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 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 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.",
|
"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 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 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 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 a few seconds...": "Dies kann einige Sekunden dauern...",
|
||||||
"This may take several minutes...": "Dies kann einige Minuten 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 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 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 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 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 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 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?",
|
"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 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 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 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. 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. 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 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 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",
|
"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 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 key to PBS?": "Schlüssel auf PBS hochladen?",
|
||||||
"Upload to PBS disabled.": "Hochladen auf PBS deaktiviert.",
|
"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 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: 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 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",
|
"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",
|
"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.",
|
"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 '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 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 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, 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 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)",
|
"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 installations": "Überprüfen Sie die Installationen",
|
||||||
"Verify mount:": "Mount überprüfen:",
|
"Verify mount:": "Mount überprüfen:",
|
||||||
"Verify the conversion:": "Überprüfen Sie die Konvertierung:",
|
"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 installation...": "Ceph-Installation wird überprüft...",
|
||||||
"Verifying Ceph packages availability...": "Verfügbarkeit von Ceph-Paketen überprüfen...",
|
"Verifying Ceph packages availability...": "Verfügbarkeit von Ceph-Paketen überprüfen...",
|
||||||
"Verifying all utilities status": "Überprüfen des Status aller Dienstprogramme",
|
"Verifying all utilities status": "Überprüfen des Status aller Dienstprogramme",
|
||||||
@@ -4824,7 +4824,7 @@
|
|||||||
"Version info not available": "Versionsinformationen nicht verfügbar",
|
"Version info not available": "Versionsinformationen nicht verfügbar",
|
||||||
"Version:": "Version:",
|
"Version:": "Version:",
|
||||||
"Version: Auto-negotiation (NFSv3/NFSv4)": "Version: Auto-Negotiation (NFSv3/NFSv4)",
|
"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 CIFS Mounts (pvesm + fstab)": "CIFS-Mounts anzeigen (pvesm + fstab)",
|
||||||
"View Current Exports": "Aktuelle Exporte anzeigen",
|
"View Current Exports": "Aktuelle Exporte anzeigen",
|
||||||
"View Current Mounts": "Aktuelle Reittiere anzeigen",
|
"View Current Mounts": "Aktuelle Reittiere anzeigen",
|
||||||
@@ -4925,7 +4925,7 @@
|
|||||||
"Wrong passphrase": "Falsche Passphrase",
|
"Wrong passphrase": "Falsche Passphrase",
|
||||||
"Yes": "Ja",
|
"Yes": "Ja",
|
||||||
"Yes, upload": "Ja, hochladen",
|
"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 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 it manually through:": "Sie können es manuell hinzufügen über:",
|
||||||
"You can add servers manually.": "Sie können Server manuell hinzufügen.",
|
"You can add servers manually.": "Sie können Server manuell hinzufügen.",
|
||||||
@@ -5018,7 +5018,7 @@
|
|||||||
"blocking issue(s).": "Blockierungsproblem(e).",
|
"blocking issue(s).": "Blockierungsproblem(e).",
|
||||||
"btrfs — Proxmox dir storage (snapshots, compression)": "btrfs – Proxmox-Verzeichnisspeicher (Snapshots, Komprimierung)",
|
"btrfs — Proxmox dir storage (snapshots, compression)": "btrfs – Proxmox-Verzeichnisspeicher (Snapshots, Komprimierung)",
|
||||||
"btrfs — snapshots and compression": "btrfs – Snapshots und 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",
|
"bytes": "Bytes",
|
||||||
"can write to": "kann schreiben",
|
"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)",
|
"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 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.",
|
"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 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.",
|
"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",
|
"stale entry/entries for interfaces no longer present": "veralteter Eintrag/Einträge für Schnittstellen, die nicht mehr vorhanden sind",
|
||||||
"standard performance": "Standardleistung",
|
"standard performance": "Standardleistung",
|
||||||
|
|||||||
+43
-43
@@ -347,7 +347,7 @@
|
|||||||
"Backup created:": "Copia de seguridad creada:",
|
"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 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 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 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 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)?",
|
"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 to local archive (.tar.zst)": "Copia de seguridad en archivo local (.tar.zst)",
|
||||||
"Backup:": "Copia de seguridad:",
|
"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 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 limit configured": "Límite de ancho de banda configurado",
|
||||||
"Bandwidth test (iperf3)": "Prueba de ancho de banda (iperf3)",
|
"Bandwidth test (iperf3)": "Prueba de ancho de banda (iperf3)",
|
||||||
"Bandwidth test completed successfully": "La prueba de ancho de banda se completó con éxito",
|
"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 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 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 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 portal:": "No se puede acceder al portal:",
|
||||||
"Cannot reach server": "No puede alcanzar el servidor",
|
"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.",
|
"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 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 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 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 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 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 installer to container": "Copiando el instalador al contenedor",
|
||||||
"Copying sources to": "Copiar fuentes a",
|
"Copying sources to": "Copiar fuentes a",
|
||||||
"Coral APT repository ready.": "Repositorio Coral APT listo.",
|
"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 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 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 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.": "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 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 destination directory:": "No se pudo crear el directorio de destino:",
|
||||||
"Could not create or access directory:": "No se pudo crear o acceder al directorio:",
|
"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 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 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 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 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 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.",
|
"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": "No se pudo montar",
|
||||||
"Could not mount ISO on device": "No se pudo montar ISO en el dispositivo",
|
"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 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 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 pendiente de restauración.Se mantuvo la restauración pendiente anterior.",
|
"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 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 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 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.",
|
"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 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 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 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 path:": "No se pudo preparar la ruta de la 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. 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 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 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",
|
"Could not unmount": "No se pudo desmontar",
|
||||||
@@ -1091,7 +1091,7 @@
|
|||||||
"Deactivate ProxMenux Monitor": "Desactivar ProxMenux Monitor",
|
"Deactivate ProxMenux Monitor": "Desactivar ProxMenux Monitor",
|
||||||
"Debian repositories missing; creating default source file": "Faltan repositorios de Debian; creando un archivo fuente predeterminado",
|
"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",
|
"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 ACLs applied for group inheritance.": "ACL predeterminadas aplicadas para la herencia de grupo.",
|
||||||
"Default Credentials": "Credenciales predeterminadas",
|
"Default Credentials": "Credenciales predeterminadas",
|
||||||
"Default Gateway": "Puerta de enlace predeterminada",
|
"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 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 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 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:",
|
"Device:": "Dispositivo:",
|
||||||
"Devices to add to VM": "Dispositivos para agregar a VM",
|
"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)",
|
"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 on host:": "No se pudo crear el directorio en el host:",
|
||||||
"Failed to create directory:": "No se pudo crear el directorio:",
|
"Failed to create directory:": "No se pudo crear el directorio:",
|
||||||
"Failed to create disk": "No se pudo crear el disco",
|
"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 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.",
|
||||||
"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",
|
"Git installed": "git instalado",
|
||||||
"Global settings and SSH jail configured": "Configuración global y cárcel SSH configurada",
|
"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.",
|
"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.",
|
"Graceful shutdown timed out.": "Se agotó el tiempo de cierre elegante.",
|
||||||
"Group": "Grupo",
|
"Group": "Grupo",
|
||||||
"Group 'sharedfiles' already exists inside the CT": "El grupo 'archivos compartidos' ya existe dentro del CT",
|
"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 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 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, 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": "archivo clave copiado",
|
||||||
"Keyfile copied to:": "archivo clave copiado a:",
|
"Keyfile copied to:": "archivo clave copiado a:",
|
||||||
"Keyfile passphrase": "frase de contraseña del archivo clave",
|
"Keyfile passphrase": "frase de contraseña del archivo clave",
|
||||||
@@ -2860,7 +2860,7 @@
|
|||||||
"No Shares Found": "No se encontraron acciones",
|
"No Shares Found": "No se encontraron acciones",
|
||||||
"No Storage Found": "No se encontró almacenamiento",
|
"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 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 UUP folder found.": "No se encontró ninguna carpeta UUP.",
|
||||||
"No VM was selected.": "No se seleccionó ninguna máquina virtual.",
|
"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.",
|
"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 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 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 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 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 exportable disks": "No hay discos exportables",
|
||||||
"No exports configured.": "No hay exportaciones configuradas.",
|
"No exports configured.": "No hay exportaciones configuradas.",
|
||||||
@@ -3203,10 +3203,10 @@
|
|||||||
"Paths:": "Rutas:",
|
"Paths:": "Rutas:",
|
||||||
"Pending restore ID:": "ID de restauración pendiente:",
|
"Pending restore ID:": "ID de restauración pendiente:",
|
||||||
"Pending restore dir:": "Directorio 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 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 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.\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):",
|
"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",
|
"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 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 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 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.",
|
"Proxmology logo applied": "Logotipo de Proxmología aplicado.",
|
||||||
"Proxmox 9 system update allready": "Actualización del sistema Proxmox 9 ya",
|
"Proxmox 9 system update allready": "Actualización del sistema Proxmox 9 ya",
|
||||||
"Proxmox APT repositories configured": "Repositorios Proxmox APT configurados",
|
"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, 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 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 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 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 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 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 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 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.",
|
"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 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 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 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 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 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 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 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) 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 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:",
|
"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 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 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 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 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 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",
|
"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 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 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 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 a few seconds...": "Esto puede tardar unos segundos...",
|
||||||
"This may take several minutes...": "Esto puede tardar varios minutos...",
|
"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).",
|
"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 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 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 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. 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. 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 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 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",
|
"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 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 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.",
|
"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):",
|
"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 settings configured - Timezone:": "Configuración de hora configurada - Zona horaria:",
|
||||||
"Time synchronization reset to UTC": "Restablecimiento de la sincronización horaria a UTC",
|
"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 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 key to PBS?": "¿Subir clave a PBS?",
|
||||||
"Upload to PBS disabled.": "Subir a PBS deshabilitado.",
|
"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 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: 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 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",
|
"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",
|
"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.",
|
"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 installations": "Verificar instalaciones",
|
||||||
"Verify mount:": "Verificar montaje:",
|
"Verify mount:": "Verificar montaje:",
|
||||||
"Verify the conversion:": "Verifique la conversión:",
|
"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 installation...": "Verificando la instalación de Ceph...",
|
||||||
"Verifying Ceph packages availability...": "Verificando la disponibilidad de los paquetes 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",
|
"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 info not available": "Información de versión no disponible",
|
||||||
"Version:": "Versión:",
|
"Version:": "Versión:",
|
||||||
"Version: Auto-negotiation (NFSv3/NFSv4)": "Versión: negociación automática (NFSv3/NFSv4)",
|
"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 CIFS Mounts (pvesm + fstab)": "Ver montajes CIFS (pvesm + fstab)",
|
||||||
"View Current Exports": "Ver exportaciones actuales",
|
"View Current Exports": "Ver exportaciones actuales",
|
||||||
"View Current Mounts": "Ver montajes 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",
|
"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:",
|
"Working directory:": "Directorio de trabajo:",
|
||||||
"Works with LVM, ZFS, and BTRFS storage types": "Funciona con tipos de almacenamiento LVM, ZFS y BTRFS",
|
"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?",
|
"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 for user:": "Acceso de escritura confirmado para el usuario:",
|
||||||
"Write access confirmed.": "Acceso de escritura confirmado.",
|
"Write access confirmed.": "Acceso de escritura confirmado.",
|
||||||
@@ -5018,7 +5018,7 @@
|
|||||||
"blocking issue(s).": "problema(s) de bloqueo.",
|
"blocking issue(s).": "problema(s) de bloqueo.",
|
||||||
"btrfs — Proxmox dir storage (snapshots, compression)": "btrfs: almacenamiento de directorios de Proxmox (instantáneas, compresión)",
|
"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",
|
"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",
|
"bytes": "bytes",
|
||||||
"can write to": "puede escribir a",
|
"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)",
|
"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",
|
"disks present": "discos presentes",
|
||||||
"dkms autoinstall did not activate:": "la instalación automática de dkms no se activó:",
|
"dkms autoinstall did not activate:": "la instalación automática de dkms no se activó:",
|
||||||
"dkms.conf generated.": "dkms.conf generado.",
|
"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.",
|
"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",
|
"dpkg still reports unfinished package work; review": "dpkg todavía informa de tareas de paquetes sin finalizar; revise",
|
||||||
"driver:": "conductor:",
|
"driver:": "conductor:",
|
||||||
@@ -5198,7 +5198,7 @@
|
|||||||
"older firmware may increase passthrough instability": "el firmware más antiguo puede aumentar la inestabilidad del paso",
|
"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",
|
"on SSD/NVMe pools that support discard": "en grupos de SSD/NVMe que admiten descarte",
|
||||||
"openssl encryption failed.": "falló el cifrado de openssl.",
|
"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 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.",
|
"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",
|
"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.",
|
"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 update skipped (no change)": "Actualización de fuentes.list omitida (sin cambios)",
|
||||||
"sources.list updated to Trixie": "fuentes.lista actualizada a Trixie",
|
"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",
|
"stale entry/entries for interfaces no longer present": "entrada obsoleta/entradas para interfaces que ya no están presentes",
|
||||||
"standard performance": "rendimiento estándar",
|
"standard performance": "rendimiento estándar",
|
||||||
"start/restart failures and reset instability.": "fallos de inicio/reinicio y reinicio de inestabilidad.",
|
"start/restart failures and reset instability.": "fallos de inicio/reinicio y reinicio de inestabilidad.",
|
||||||
|
|||||||
+35
-35
@@ -347,7 +347,7 @@
|
|||||||
"Backup created:": "Sauvegarde créée :",
|
"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 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 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 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 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é) ?",
|
"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 to local archive (.tar.zst)": "Sauvegarde vers une archive locale (.tar.zst)",
|
||||||
"Backup:": "Sauvegarde :",
|
"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 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 limit configured": "Limite de bande passante configurée",
|
||||||
"Bandwidth test (iperf3)": "Test de bande passante (iperf3)",
|
"Bandwidth test (iperf3)": "Test de bande passante (iperf3)",
|
||||||
"Bandwidth test completed successfully": "Test de bande passante terminé avec succès",
|
"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 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 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 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 portal:": "Impossible d'accéder au portail :",
|
||||||
"Cannot reach server": "Ne peut pas atteindre le serveur",
|
"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.",
|
"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...",
|
"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",
|
"Cleans duplicate or conflicting sources": "Nettoie les sources en double ou en conflit",
|
||||||
"Cleanup Complete": "Nettoyage terminé",
|
"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 finished": "Nettoyage terminé",
|
||||||
"Cleanup legacy gasket-dkms": "Nettoyer l'ancien paquet gasket-dkms",
|
"Cleanup legacy gasket-dkms": "Nettoyer l'ancien paquet gasket-dkms",
|
||||||
"Cleanup partial VM?": "Nettoyer une VM partielle ?",
|
"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 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 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 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 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 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 installer to container": "Copie du programme d'installation dans le conteneur",
|
||||||
"Copying sources to": "Copie des sources vers",
|
"Copying sources to": "Copie des sources vers",
|
||||||
"Coral APT repository ready.": "Le référentiel Coral APT est prêt.",
|
"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 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 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.": "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 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 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 :",
|
"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 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 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 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 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 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.",
|
"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": "Impossible de monter",
|
||||||
"Could not mount ISO on device": "Impossible de monter l'ISO sur l'appareil",
|
"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 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 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 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 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 SMART data from": "Impossible de lire les données SMART de",
|
||||||
"Could not read VM configuration.": "Impossible de lire la configuration de la VM.",
|
"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 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 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 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 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 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",
|
"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 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 directory:": "Échec de la création du répertoire :",
|
||||||
"Failed to create disk": "Échec de la création du disque",
|
"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 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.",
|
||||||
"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 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 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, 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": "fichier clé copié",
|
||||||
"Keyfile copied to:": "fichier clé copié dans :",
|
"Keyfile copied to:": "fichier clé copié dans :",
|
||||||
"Keyfile passphrase": "Phrase secrète du fichier clé",
|
"Keyfile passphrase": "Phrase secrète du fichier clé",
|
||||||
@@ -2860,7 +2860,7 @@
|
|||||||
"No Shares Found": "Aucun partage trouvé",
|
"No Shares Found": "Aucun partage trouvé",
|
||||||
"No Storage Found": "Aucun stockage 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 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 UUP folder found.": "Aucun dossier UUP trouvé.",
|
||||||
"No VM was selected.": "Aucune VM n'a été sélectionnée.",
|
"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é.",
|
"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 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 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 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 ZFS pools detected. Skipping ZFS autotrim.": "Aucun pool ZFS détecté. Ignorer le découpage automatique ZFS.",
|
||||||
"No accessible": "Non accessible",
|
"No accessible": "Non accessible",
|
||||||
"No accessible NFS servers found.": "Aucun serveur NFS accessible trouvé.",
|
"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 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 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 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 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 exportable disks": "Aucun disque exportable",
|
||||||
"No exports configured.": "Aucune exportation configurée.",
|
"No exports configured.": "Aucune exportation configurée.",
|
||||||
@@ -2975,7 +2975,7 @@
|
|||||||
"No ports configured": "Aucun port configuré",
|
"No ports configured": "Aucun port configuré",
|
||||||
"No privileged containers available in Proxmox.": "Aucun conteneur privilégié disponible dans Proxmox.",
|
"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 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": "Pas de récent",
|
||||||
"No recent Samba servers found.": "Aucun serveur Samba récent trouvé.",
|
"No recent Samba servers found.": "Aucun serveur Samba récent trouvé.",
|
||||||
"No routing information found.": "Aucune information de routage trouvée.",
|
"No routing information found.": "Aucune information de routage trouvée.",
|
||||||
@@ -3353,7 +3353,7 @@
|
|||||||
"ProxMenux logo applied": "Logo ProxMenux appliqué",
|
"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 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 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é",
|
"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 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",
|
"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 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 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 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 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 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 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 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é",
|
"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 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 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 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 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 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 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 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) 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 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 :",
|
"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 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 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 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 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 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",
|
"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 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 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 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 a few seconds...": "Cela peut prendre quelques secondes...",
|
||||||
"This may take several minutes...": "Cela peut prendre plusieurs minutes...",
|
"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 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 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 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 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 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 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 ?",
|
"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 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 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 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. 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. 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 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 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",
|
"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 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 key to PBS?": "Télécharger la clé sur PBS ?",
|
||||||
"Upload to PBS disabled.": "Téléchargement vers PBS désactivé.",
|
"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 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: 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 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",
|
"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é",
|
"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.",
|
"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 '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 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 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, 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 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é)",
|
"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 installations": "Vérifier les installations",
|
||||||
"Verify mount:": "Vérifiez le montage :",
|
"Verify mount:": "Vérifiez le montage :",
|
||||||
"Verify the conversion:": "Vérifiez la conversion :",
|
"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 installation...": "Vérification de l'installation de Ceph...",
|
||||||
"Verifying Ceph packages availability...": "Vérification de la disponibilité des packages 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",
|
"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 info not available": "Informations sur la version non disponibles",
|
||||||
"Version:": "Version:",
|
"Version:": "Version:",
|
||||||
"Version: Auto-negotiation (NFSv3/NFSv4)": "Version : Auto-négociation (NFSv3/NFSv4)",
|
"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 CIFS Mounts (pvesm + fstab)": "Afficher les montages CIFS (pvesm + fstab)",
|
||||||
"View Current Exports": "Afficher les exportations actuelles",
|
"View Current Exports": "Afficher les exportations actuelles",
|
||||||
"View Current Mounts": "Afficher les montures actuelles",
|
"View Current Mounts": "Afficher les montures actuelles",
|
||||||
@@ -5018,7 +5018,7 @@
|
|||||||
"blocking issue(s).": "problème(s) bloquant(s).",
|
"blocking issue(s).": "problème(s) bloquant(s).",
|
||||||
"btrfs — Proxmox dir storage (snapshots, compression)": "btrfs — Stockage du répertoire Proxmox (instantanés, compression)",
|
"btrfs — Proxmox dir storage (snapshots, compression)": "btrfs — Stockage du répertoire Proxmox (instantanés, compression)",
|
||||||
"btrfs — snapshots and compression": "btrfs — instantanés et 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",
|
"bytes": "octets",
|
||||||
"can write to": "peut écrire à",
|
"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)",
|
"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é.",
|
"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é",
|
"wipefs failed on": "les wipefs ont échoué",
|
||||||
"with": "avec",
|
"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 — 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",
|
"xfs — better for large files": "xfs – meilleur pour les gros fichiers",
|
||||||
"years old": "ans",
|
"years old": "ans",
|
||||||
|
|||||||
+34
-34
@@ -347,7 +347,7 @@
|
|||||||
"Backup created:": "Backup creato:",
|
"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 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 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 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 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)?",
|
"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 to local archive (.tar.zst)": "Backup nell'archivio locale (.tar.zst)",
|
||||||
"Backup:": "Backup:",
|
"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 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 limit configured": "Limite di larghezza di banda configurato",
|
||||||
"Bandwidth test (iperf3)": "test della larghezza di banda (iperf3)",
|
"Bandwidth test (iperf3)": "test della larghezza di banda (iperf3)",
|
||||||
"Bandwidth test completed successfully": "Test della larghezza di banda completato con successo",
|
"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 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 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 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 portal:": "Impossibile raggiungere il portale:",
|
||||||
"Cannot reach server": "Impossibile raggiungere il server",
|
"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.",
|
"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...",
|
"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",
|
"Cleans duplicate or conflicting sources": "Pulisce le fonti duplicate o in conflitto",
|
||||||
"Cleanup Complete": "Pulizia completata",
|
"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 finished": "La pulizia è terminata",
|
||||||
"Cleanup legacy gasket-dkms": "Pulisci il pacchetto gasket-dkms legacy",
|
"Cleanup legacy gasket-dkms": "Pulisci il pacchetto gasket-dkms legacy",
|
||||||
"Cleanup partial VM?": "Pulire la VM parziale?",
|
"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 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 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 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 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 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 installer to container": "Copia del programma di installazione nel contenitore",
|
||||||
"Copying sources to": "Copia delle fonti in",
|
"Copying sources to": "Copia delle fonti in",
|
||||||
"Coral APT repository ready.": "Repository APT Coral pronto.",
|
"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 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 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 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.": "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 converter directory:": "Impossibile creare la directory del convertitore:",
|
||||||
"Could not create destination directory:": "Impossibile creare la directory di destinazione:",
|
"Could not create destination directory:": "Impossibile creare la directory di destinazione:",
|
||||||
"Could not create or access directory:": "Impossibile creare o accedere alla directory:",
|
"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 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 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 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 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 recovery blob from PBS.": "Impossibile scaricare il BLOB di ripristino da PBS.",
|
||||||
"Could not download the installer.": "Impossibile scaricare il programma di installazione.",
|
"Could not download the installer.": "Impossibile scaricare il programma di installazione.",
|
||||||
@@ -920,8 +920,8 @@
|
|||||||
"Could not mount": "Impossibile montare",
|
"Could not mount": "Impossibile montare",
|
||||||
"Could not mount ISO on device": "Impossibile montare l'ISO sul dispositivo",
|
"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 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 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 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 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 SMART data from": "Impossibile leggere i dati SMART da",
|
||||||
"Could not read VM configuration.": "Impossibile leggere la configurazione della VM.",
|
"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 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 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 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 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 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",
|
"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 on host:": "Impossibile creare la directory sull'host:",
|
||||||
"Failed to create directory:": "Impossibile creare la directory:",
|
"Failed to create directory:": "Impossibile creare la directory:",
|
||||||
"Failed to create disk": "Impossibile creare il disco",
|
"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 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.",
|
||||||
"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 removed": "Configurazione Kernel Panic rimossa",
|
||||||
"Kernel panic configuration updated and applied": "Configurazione Kernel Panic aggiornata e applicata",
|
"Kernel panic configuration updated and applied": "Configurazione Kernel Panic aggiornata e applicata",
|
||||||
"Kernel, modules and boot config": "Kernel, moduli e configurazione di avvio",
|
"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": "file chiave copiato",
|
||||||
"Keyfile copied to:": "file di chiavi copiato in:",
|
"Keyfile copied to:": "file di chiavi copiato in:",
|
||||||
"Keyfile passphrase": "passphrase del file chiave",
|
"Keyfile passphrase": "passphrase del file chiave",
|
||||||
@@ -2860,7 +2860,7 @@
|
|||||||
"No Shares Found": "Nessuna azione trovata",
|
"No Shares Found": "Nessuna azione trovata",
|
||||||
"No Storage Found": "Nessun spazio di archiviazione trovato",
|
"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 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 UUP folder found.": "Nessuna cartella UUP trovata.",
|
||||||
"No VM was selected.": "Non è stata selezionata alcuna VM.",
|
"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.",
|
"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 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 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 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 ZFS pools detected. Skipping ZFS autotrim.": "Nessun pool ZFS rilevato. Saltare l'autotrim ZFS.",
|
||||||
"No accessible": "Non accessibile",
|
"No accessible": "Non accessibile",
|
||||||
"No accessible NFS servers found.": "Nessun server NFS accessibile trovato.",
|
"No accessible NFS servers found.": "Nessun server NFS accessibile trovato.",
|
||||||
@@ -2922,7 +2922,7 @@
|
|||||||
"No duplicate repositories found": "Nessun repository duplicato trovato",
|
"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 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 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 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 exportable disks": "Nessun disco esportabile",
|
||||||
"No exports configured.": "Nessuna esportazione configurata.",
|
"No exports configured.": "Nessuna esportazione configurata.",
|
||||||
@@ -2975,7 +2975,7 @@
|
|||||||
"No ports configured": "Nessuna porta configurata",
|
"No ports configured": "Nessuna porta configurata",
|
||||||
"No privileged containers available in Proxmox.": "Nessun contenitore privilegiato disponibile in Proxmox.",
|
"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 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": "Non recente",
|
||||||
"No recent Samba servers found.": "Nessun server Samba recente trovato.",
|
"No recent Samba servers found.": "Nessun server Samba recente trovato.",
|
||||||
"No routing information found.": "Nessuna informazione sul percorso trovata.",
|
"No routing information found.": "Nessuna informazione sul percorso trovata.",
|
||||||
@@ -3353,7 +3353,7 @@
|
|||||||
"ProxMenux logo applied": "Logo ProxMenux applicato",
|
"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 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 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",
|
"Proxmology logo applied": "Logo Proxmology applicato",
|
||||||
"Proxmox 9 system update allready": "Già l'aggiornamento del sistema Proxmox 9",
|
"Proxmox 9 system update allready": "Già l'aggiornamento del sistema Proxmox 9",
|
||||||
"Proxmox APT repositories configured": "repository APT Proxmox configurati",
|
"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 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 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 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 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 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 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 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",
|
"The container should now start as privileged": "Il contenitore ora dovrebbe iniziare come privilegiato",
|
||||||
@@ -4353,12 +4353,12 @@
|
|||||||
"The filesystem": "Il file system",
|
"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 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 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 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 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 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 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) 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 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:",
|
"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 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 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 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 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 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'",
|
"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 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 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 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 a few seconds...": "L'operazione potrebbe richiedere alcuni secondi...",
|
||||||
"This may take several minutes...": "L'operazione potrebbe richiedere diversi minuti...",
|
"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).",
|
"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 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 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 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. 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. 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 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 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",
|
"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 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 key to PBS?": "Carica la chiave su PBS?",
|
||||||
"Upload to PBS disabled.": "caricamento su PBS disabilitato.",
|
"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 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: 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 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",
|
"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",
|
"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.",
|
"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 '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 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 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, 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 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)",
|
"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 installations": "Verificare le installazioni",
|
||||||
"Verify mount:": "Verifica montaggio:",
|
"Verify mount:": "Verifica montaggio:",
|
||||||
"Verify the conversion:": "Verifica la conversione:",
|
"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 installation...": "Verifica dell'installazione di Ceph in corso...",
|
||||||
"Verifying Ceph packages availability...": "Verifica della disponibilità dei pacchetti 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",
|
"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 info not available": "Informazioni sulla versione non disponibili",
|
||||||
"Version:": "Versione:",
|
"Version:": "Versione:",
|
||||||
"Version: Auto-negotiation (NFSv3/NFSv4)": "Versione: negoziazione automatica (NFSv3/NFSv4)",
|
"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 CIFS Mounts (pvesm + fstab)": "Visualizza montaggi CIFS (pvesm + fstab)",
|
||||||
"View Current Exports": "Visualizza le esportazioni correnti",
|
"View Current Exports": "Visualizza le esportazioni correnti",
|
||||||
"View Current Mounts": "Visualizza i supporti attuali",
|
"View Current Mounts": "Visualizza i supporti attuali",
|
||||||
@@ -5018,7 +5018,7 @@
|
|||||||
"blocking issue(s).": "problemi di blocco.",
|
"blocking issue(s).": "problemi di blocco.",
|
||||||
"btrfs — Proxmox dir storage (snapshots, compression)": "btrfs — Archiviazione delle directory Proxmox (istantanee, compressione)",
|
"btrfs — Proxmox dir storage (snapshots, compression)": "btrfs — Archiviazione delle directory Proxmox (istantanee, compressione)",
|
||||||
"btrfs — snapshots and compression": "btrfs: istantanee e 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",
|
"bytes": "byte",
|
||||||
"can write to": "può scrivere a",
|
"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)",
|
"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)",
|
||||||
|
|||||||
+34
-34
@@ -347,7 +347,7 @@
|
|||||||
"Backup created:": "Backup criado:",
|
"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 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 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 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 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)?",
|
"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 to local archive (.tar.zst)": "Backup para arquivo local (.tar.zst)",
|
||||||
"Backup:": "Backup:",
|
"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 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 limit configured": "Limite de largura de banda configurado",
|
||||||
"Bandwidth test (iperf3)": "teste de largura de banda (iperf3)",
|
"Bandwidth test (iperf3)": "teste de largura de banda (iperf3)",
|
||||||
"Bandwidth test completed successfully": "Teste de largura de banda concluído com sucesso",
|
"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 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 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 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 portal:": "Não é possível acessar o portal:",
|
||||||
"Cannot reach server": "Sem contato com o servidor",
|
"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.",
|
"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...",
|
"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",
|
"Cleans duplicate or conflicting sources": "Limpa fontes duplicadas ou conflitantes",
|
||||||
"Cleanup Complete": "Limpeza concluída",
|
"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 finished": "Limpeza concluída",
|
||||||
"Cleanup legacy gasket-dkms": "Limpar o pacote gasket-dkms legado",
|
"Cleanup legacy gasket-dkms": "Limpar o pacote gasket-dkms legado",
|
||||||
"Cleanup partial VM?": "Limpar VM parcial?",
|
"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 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 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 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 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 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 installer to container": "Copiando o instalador para o contêiner",
|
||||||
"Copying sources to": "Copiando fontes para",
|
"Copying sources to": "Copiando fontes para",
|
||||||
"Coral APT repository ready.": "Repositório Coral APT pronto.",
|
"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 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 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 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.": "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 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 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:",
|
"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 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 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 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 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 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.",
|
"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": "Não foi possível montar",
|
||||||
"Could not mount ISO on device": "Não foi possível montar o ISO no dispositivo",
|
"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 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 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 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 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 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.",
|
"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 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 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 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 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 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",
|
"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 on host:": "Falha ao criar diretório no host:",
|
||||||
"Failed to create directory:": "Falha ao criar diretório:",
|
"Failed to create directory:": "Falha ao criar diretório:",
|
||||||
"Failed to create disk": "Falha ao criar disco",
|
"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 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.",
|
||||||
"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 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 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, 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": "arquivo-chave copiado",
|
||||||
"Keyfile copied to:": "arquivo-chave copiado para:",
|
"Keyfile copied to:": "arquivo-chave copiado para:",
|
||||||
"Keyfile passphrase": "senha do arquivo-chave",
|
"Keyfile passphrase": "senha do arquivo-chave",
|
||||||
@@ -2860,7 +2860,7 @@
|
|||||||
"No Shares Found": "Nenhum compartilhamento encontrado",
|
"No Shares Found": "Nenhum compartilhamento encontrado",
|
||||||
"No Storage Found": "Nenhum armazenamento 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 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 UUP folder found.": "Nenhuma pasta UUP encontrada.",
|
||||||
"No VM was selected.": "Nenhuma VM foi selecionada.",
|
"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.",
|
"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 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 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 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 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": "Não acessível",
|
||||||
"No accessible NFS servers found.": "Nenhum servidor NFS acessível encontrado.",
|
"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 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 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 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 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 exportable disks": "Nenhum disco exportável",
|
||||||
"No exports configured.": "Nenhuma exportação configurada.",
|
"No exports configured.": "Nenhuma exportação configurada.",
|
||||||
@@ -2975,7 +2975,7 @@
|
|||||||
"No ports configured": "Nenhuma porta configurada",
|
"No ports configured": "Nenhuma porta configurada",
|
||||||
"No privileged containers available in Proxmox.": "Nenhum contêiner privilegiado disponível no Proxmox.",
|
"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 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": "Nenhum recente",
|
||||||
"No recent Samba servers found.": "Nenhum servidor Samba recente encontrado.",
|
"No recent Samba servers found.": "Nenhum servidor Samba recente encontrado.",
|
||||||
"No routing information found.": "Nenhuma informação de roteamento encontrada.",
|
"No routing information found.": "Nenhuma informação de roteamento encontrada.",
|
||||||
@@ -3353,7 +3353,7 @@
|
|||||||
"ProxMenux logo applied": "Logotipo ProxMenux aplicado",
|
"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 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 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",
|
"Proxmology logo applied": "Logotipo da Proxmologia aplicado",
|
||||||
"Proxmox 9 system update allready": "Atualização do sistema Proxmox 9 já",
|
"Proxmox 9 system update allready": "Atualização do sistema Proxmox 9 já",
|
||||||
"Proxmox APT repositories configured": "repositórios Proxmox APT configurados",
|
"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 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 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 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 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 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 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 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",
|
"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 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 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 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 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 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 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 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) 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 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:",
|
"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 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 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 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 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 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",
|
"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 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 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 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 a few seconds...": "Isso pode levar alguns segundos...",
|
||||||
"This may take several minutes...": "Isso pode levar vários minutos...",
|
"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).",
|
"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 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 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 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. 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. 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 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 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",
|
"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 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 key to PBS?": "Carregar chave para PBS?",
|
||||||
"Upload to PBS disabled.": "Upload para PBS desativado.",
|
"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 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: 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 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",
|
"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",
|
"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.",
|
"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 '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 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 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, 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 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)",
|
"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 installations": "Verifique as instalações",
|
||||||
"Verify mount:": "Verifique a montagem:",
|
"Verify mount:": "Verifique a montagem:",
|
||||||
"Verify the conversion:": "Verifique a conversão:",
|
"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 installation...": "Verificando a instalação do Ceph...",
|
||||||
"Verifying Ceph packages availability...": "Verificando a disponibilidade dos pacotes 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",
|
"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 info not available": "Informações da versão não disponíveis",
|
||||||
"Version:": "Versão:",
|
"Version:": "Versão:",
|
||||||
"Version: Auto-negotiation (NFSv3/NFSv4)": "Versão: Negociação automática (NFSv3/NFSv4)",
|
"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 CIFS Mounts (pvesm + fstab)": "Ver montagens CIFS (pvesm + fstab)",
|
||||||
"View Current Exports": "Ver exportações atuais",
|
"View Current Exports": "Ver exportações atuais",
|
||||||
"View Current Mounts": "Ver montagens atuais",
|
"View Current Mounts": "Ver montagens atuais",
|
||||||
@@ -5018,7 +5018,7 @@
|
|||||||
"blocking issue(s).": "problema(s) de bloqueio.",
|
"blocking issue(s).": "problema(s) de bloqueio.",
|
||||||
"btrfs — Proxmox dir storage (snapshots, compression)": "btrfs — Armazenamento de diretório Proxmox (instantâneos, compactação)",
|
"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",
|
"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",
|
"bytes": "bytes",
|
||||||
"can write to": "pode escrever para",
|
"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)",
|
"chmod 1777 + setfacl o::rwx (applied on the NFS share from this host)": "chmod 1777 + setfacl o::rwx (aplicado no compartilhamento NFS deste host)",
|
||||||
|
|||||||
+8
-8
@@ -456,7 +456,7 @@
|
|||||||
"Cannot proceed with invalid export path.": "Nedá sa pokračovať s neplatnou cestou exportu.",
|
"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 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 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 portal:": "Portál nie je dostupný:",
|
||||||
"Cannot reach server": "Server 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.",
|
"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...",
|
"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",
|
"Cleans duplicate or conflicting sources": "Vyčistí duplicitné alebo konfliktné zdroje",
|
||||||
"Cleanup Complete": "Čistenie je dokončené",
|
"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 finished": "Čistenie je dokončené",
|
||||||
"Cleanup legacy gasket-dkms": "Vyčistiť starší balík gasket-dkms",
|
"Cleanup legacy gasket-dkms": "Vyčistiť starší balík gasket-dkms",
|
||||||
"Cleanup partial VM?": "Vyčistiť čiastočne vytvorenú VM?",
|
"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 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 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 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 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 recovery blob from PBS.": "Nepodarilo sa stiahnuť obnovovací balíček z PBS.",
|
||||||
"Could not download the installer.": "Inštalátor sa nepodarilo stiahnuť.",
|
"Could not download the installer.": "Inštalátor sa nepodarilo stiahnuť.",
|
||||||
@@ -920,8 +920,8 @@
|
|||||||
"Could not mount": "Nepodarilo sa pripojiť",
|
"Could not mount": "Nepodarilo sa pripojiť",
|
||||||
"Could not mount ISO on device": "ISO sa nepodarilo pripojiť k zariadeniu",
|
"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 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 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 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 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 SMART data from": "SMART dáta sa nepodarilo prečítať z",
|
||||||
"Could not read VM configuration.": "Nastavenie VM sa nepodarilo prečítať.",
|
"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 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 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 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 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 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ť",
|
"Could not unmount": "Nepodarilo sa odpojiť",
|
||||||
@@ -2975,7 +2975,7 @@
|
|||||||
"No ports configured": "Nie sú nastavené žiadne porty",
|
"No ports configured": "Nie sú nastavené žiadne porty",
|
||||||
"No privileged containers available in Proxmox.": "V Proxmoxe nie sú dostupné žiadne privilegované kontajnery.",
|
"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 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": "Žiadne nedávne",
|
||||||
"No recent Samba servers found.": "Nenašli sa žiadne nedávne Samba servery.",
|
"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í.",
|
"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 info not available": "Informácie o verzii nie sú dostupné",
|
||||||
"Version:": "Verzia:",
|
"Version:": "Verzia:",
|
||||||
"Version: Auto-negotiation (NFSv3/NFSv4)": "Verzia: automatické dohodnutie (NFSv3/NFSv4)",
|
"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 CIFS Mounts (pvesm + fstab)": "Zobraziť CIFS mounty (pvesm + fstab)",
|
||||||
"View Current Exports": "Zobraziť aktuálne exporty",
|
"View Current Exports": "Zobraziť aktuálne exporty",
|
||||||
"View Current Mounts": "Zobraziť aktuálne pripojenia",
|
"View Current Mounts": "Zobraziť aktuálne pripojenia",
|
||||||
|
|||||||
+8
-8
@@ -456,7 +456,7 @@
|
|||||||
"Cannot proceed with invalid export path.": "Kan inte fortsätta med ogiltig exportsökväg.",
|
"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 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 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 portal:": "Kan inte nå portalen:",
|
||||||
"Cannot reach server": "Kan inte nå servern",
|
"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.",
|
"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...",
|
"Cleaning up unused time synchronization services...": "Rensar oanvända tidssynkroniseringstjänster...",
|
||||||
"Cleans duplicate or conflicting sources": "Rensar dubbletter eller motstridiga källor",
|
"Cleans duplicate or conflicting sources": "Rensar dubbletter eller motstridiga källor",
|
||||||
"Cleanup Complete": "Rensning klar",
|
"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 finished": "Rensning avslutad",
|
||||||
"Cleanup legacy gasket-dkms": "Cleanup legacy gasket-dkms",
|
"Cleanup legacy gasket-dkms": "Cleanup legacy gasket-dkms",
|
||||||
"Cleanup partial VM?": "Rensa delvis VM?",
|
"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 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 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 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 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 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.",
|
"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": "Kunde inte montera",
|
||||||
"Could not mount ISO on device": "Det gick inte att montera ISO på enheten",
|
"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 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 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 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 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 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.",
|
"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 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 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 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 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 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",
|
"Could not unmount": "Det gick inte att avmontera",
|
||||||
@@ -2975,7 +2975,7 @@
|
|||||||
"No ports configured": "Inga portar konfigurerade",
|
"No ports configured": "Inga portar konfigurerade",
|
||||||
"No privileged containers available in Proxmox.": "Inga privilegierade behållare tillgängliga i Proxmox.",
|
"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 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": "Inga nya",
|
||||||
"No recent Samba servers found.": "Inga nya Samba-servrar hittades.",
|
"No recent Samba servers found.": "Inga nya Samba-servrar hittades.",
|
||||||
"No routing information found.": "Ingen ruttinformation hittades.",
|
"No routing information found.": "Ingen ruttinformation hittades.",
|
||||||
@@ -4824,7 +4824,7 @@
|
|||||||
"Version info not available": "Versionsinformation är inte tillgänglig",
|
"Version info not available": "Versionsinformation är inte tillgänglig",
|
||||||
"Version:": "Version:",
|
"Version:": "Version:",
|
||||||
"Version: Auto-negotiation (NFSv3/NFSv4)": "Version: Auto-negotiation (NFSv3/NFSv4)",
|
"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 CIFS Mounts (pvesm + fstab)": "Visa CIFS-fästen (pvesm + fstab)",
|
||||||
"View Current Exports": "Visa aktuell export",
|
"View Current Exports": "Visa aktuell export",
|
||||||
"View Current Mounts": "Visa aktuella monteringar",
|
"View Current Mounts": "Visa aktuella monteringar",
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ BACKUP_DIR="/var/backups/proxmenux"
|
|||||||
if [[ -f "$UTILS_FILE" ]]; then
|
if [[ -f "$UTILS_FILE" ]]; then
|
||||||
source "$UTILS_FILE"
|
source "$UTILS_FILE"
|
||||||
fi
|
fi
|
||||||
|
if [[ -f "$BASE_DIR/scripts/global/pmx_journal.sh" ]]; then
|
||||||
|
source "$BASE_DIR/scripts/global/pmx_journal.sh"
|
||||||
|
fi
|
||||||
|
|
||||||
load_language
|
load_language
|
||||||
initialize_cache
|
initialize_cache
|
||||||
@@ -328,6 +331,8 @@ analyze_bridge_configuration() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
guided_bridge_repair() {
|
guided_bridge_repair() {
|
||||||
|
local FUNC_VERSION="1.0"
|
||||||
|
pmx_journal_context "guided_bridge_repair" "$FUNC_VERSION"
|
||||||
local step=1
|
local step=1
|
||||||
local total_steps=5
|
local total_steps=5
|
||||||
|
|
||||||
@@ -420,7 +425,7 @@ guided_bridge_repair() {
|
|||||||
|
|
||||||
# Apply the change
|
# Apply the change
|
||||||
if [ "$new_ports" != "$current_ports" ]; then
|
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
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
@@ -567,6 +572,8 @@ analyze_network_configuration() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
guided_configuration_cleanup() {
|
guided_configuration_cleanup() {
|
||||||
|
local FUNC_VERSION="1.0"
|
||||||
|
pmx_journal_context "guided_configuration_cleanup" "$FUNC_VERSION"
|
||||||
local step=1
|
local step=1
|
||||||
local total_steps=5
|
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
|
--infobox "$(translate "Removing invalid configurations...")\n\n$(translate "This may take a few seconds...")" 8 50
|
||||||
|
|
||||||
for iface in $interfaces_to_remove; do
|
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
|
done
|
||||||
((step++))
|
((step++))
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ TOOLS_JSON="/usr/local/share/proxmenux/installed_tools.json"
|
|||||||
if [[ -f "$UTILS_FILE" ]]; then
|
if [[ -f "$UTILS_FILE" ]]; then
|
||||||
source "$UTILS_FILE"
|
source "$UTILS_FILE"
|
||||||
fi
|
fi
|
||||||
|
if [[ -f "$LOCAL_SCRIPTS/global/pmx_journal.sh" ]]; then
|
||||||
|
source "$LOCAL_SCRIPTS/global/pmx_journal.sh"
|
||||||
|
fi
|
||||||
|
|
||||||
load_language
|
load_language
|
||||||
initialize_cache
|
initialize_cache
|
||||||
@@ -84,6 +87,8 @@ lvm_repair_check() {
|
|||||||
|
|
||||||
|
|
||||||
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...")"
|
msg_info "$(translate "Cleaning up duplicate repositories...")"
|
||||||
|
|
||||||
local sources_file="/etc/apt/sources.list"
|
local sources_file="/etc/apt/sources.list"
|
||||||
@@ -152,7 +157,8 @@ cleanup_duplicate_repos_pve9() {
|
|||||||
|
|
||||||
if [[ "$file_changed" -eq 1 ]]; then
|
if [[ "$file_changed" -eq 1 ]]; then
|
||||||
_backup_once "$sources_file"
|
_backup_once "$sources_file"
|
||||||
mv "$temp_file" "$sources_file"
|
pmx_write_file "$sources_file" < "$temp_file"
|
||||||
|
rm -f "$temp_file"
|
||||||
chmod 644 "$sources_file"
|
chmod 644 "$sources_file"
|
||||||
else
|
else
|
||||||
rm -f "$temp_file"
|
rm -f "$temp_file"
|
||||||
@@ -201,7 +207,7 @@ cleanup_duplicate_repos_pve9() {
|
|||||||
esc_uri=$(printf '%s' "$uri" | sed 's/[][\.^$*/]/\\&/g')
|
esc_uri=$(printf '%s' "$uri" | sed 's/[][\.^$*/]/\\&/g')
|
||||||
esc_suite=$(printf '%s' "$suite" | sed 's/[][\.^$*/]/\\&/g')
|
esc_suite=$(printf '%s' "$suite" | sed 's/[][\.^$*/]/\\&/g')
|
||||||
esc_comp=$(printf '%s' "$first_comp" | 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))
|
cleaned_count=$((cleaned_count + 1))
|
||||||
fi
|
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
|
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
|
if [ -f "$old_file" ]; then
|
||||||
_backup_once "$old_file"
|
_backup_once "$old_file"
|
||||||
rm -f "$old_file"
|
pmx_remove_file "$old_file"
|
||||||
cleaned_count=$((cleaned_count + 1))
|
cleaned_count=$((cleaned_count + 1))
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
@@ -248,6 +254,7 @@ cleanup_duplicate_repos_pve9() {
|
|||||||
|
|
||||||
if [ $cleaned_count -gt 0 ]; then
|
if [ $cleaned_count -gt 0 ]; then
|
||||||
msg_ok "$(translate "Cleaned up $cleaned_count duplicate/old repositories")"
|
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
|
apt-get update > /dev/null 2>&1 || true
|
||||||
else
|
else
|
||||||
msg_ok "$(translate "No duplicate repositories found")"
|
msg_ok "$(translate "No duplicate repositories found")"
|
||||||
@@ -257,6 +264,8 @@ cleanup_duplicate_repos_pve9() {
|
|||||||
|
|
||||||
|
|
||||||
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...")"
|
msg_info "$(translate "Cleaning up duplicate repositories...")"
|
||||||
|
|
||||||
local sources_file="/etc/apt/sources.list"
|
local sources_file="/etc/apt/sources.list"
|
||||||
@@ -285,7 +294,8 @@ cleanup_duplicate_repos_pve9_() {
|
|||||||
fi
|
fi
|
||||||
done < "$sources_file"
|
done < "$sources_file"
|
||||||
|
|
||||||
mv "$temp_file" "$sources_file"
|
pmx_write_file "$sources_file" < "$temp_file"
|
||||||
|
rm -f "$temp_file"
|
||||||
chmod 644 "$sources_file"
|
chmod 644 "$sources_file"
|
||||||
|
|
||||||
for src in proxmox debian ceph; do
|
for src in proxmox debian ceph; do
|
||||||
@@ -308,7 +318,7 @@ cleanup_duplicate_repos_pve9_() {
|
|||||||
|
|
||||||
if [[ -n "$url_match" ]]; then
|
if [[ -n "$url_match" ]]; then
|
||||||
if grep -q "^deb.*$url_match" "$sources_file"; 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))
|
cleaned_count=$((cleaned_count + 1))
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
@@ -316,7 +326,7 @@ cleanup_duplicate_repos_pve9_() {
|
|||||||
for list_file in /etc/apt/sources.list.d/*.list; do
|
for list_file in /etc/apt/sources.list.d/*.list; do
|
||||||
[[ -f "$list_file" ]] || continue
|
[[ -f "$list_file" ]] || continue
|
||||||
if grep -q "^deb.*$url_match" "$list_file"; then
|
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))
|
cleaned_count=$((cleaned_count + 1))
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
@@ -325,6 +335,7 @@ cleanup_duplicate_repos_pve9_() {
|
|||||||
|
|
||||||
if [ $cleaned_count -gt 0 ]; then
|
if [ $cleaned_count -gt 0 ]; then
|
||||||
msg_ok "$(translate "Cleaned up $cleaned_count duplicate/old repositories")"
|
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
|
apt-get update > /dev/null 2>&1 || true
|
||||||
else
|
else
|
||||||
msg_ok "$(translate "No duplicate repositories found")"
|
msg_ok "$(translate "No duplicate repositories found")"
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ if [[ -n "${__PROXMENUX_PCI_PASSTHROUGH_HELPERS__:-}" ]]; then
|
|||||||
fi
|
fi
|
||||||
__PROXMENUX_PCI_PASSTHROUGH_HELPERS__=1
|
__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() {
|
function _pci_is_iommu_active() {
|
||||||
grep -qE 'intel_iommu=on|amd_iommu=on' /proc/cmdline 2>/dev/null || return 1
|
grep -qE 'intel_iommu=on|amd_iommu=on' /proc/cmdline 2>/dev/null || return 1
|
||||||
[[ -d /sys/kernel/iommu_groups ]] || return 1
|
[[ -d /sys/kernel/iommu_groups ]] || return 1
|
||||||
@@ -497,6 +501,8 @@ _proxmenux_vfio_bind_add_bdfs() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_proxmenux_vfio_bind_remove_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
|
# Args: any number of BDFs to remove from the binder list
|
||||||
[[ -f "$PROXMENUX_VFIO_BIND_STATE" ]] || return 0
|
[[ -f "$PROXMENUX_VFIO_BIND_STATE" ]] || return 0
|
||||||
_proxmenux_vfio_bind_cleanup_legacy
|
_proxmenux_vfio_bind_cleanup_legacy
|
||||||
@@ -511,13 +517,14 @@ _proxmenux_vfio_bind_remove_bdfs() {
|
|||||||
else
|
else
|
||||||
normalized="0000:${bdf}"
|
normalized="0000:${bdf}"
|
||||||
fi
|
fi
|
||||||
sed -i "\|^${normalized}\$|d" "$tmp"
|
sed "\|^${normalized}\$|d" "$tmp" > "${tmp}.next" && mv "${tmp}.next" "$tmp"
|
||||||
done
|
done
|
||||||
if ! cmp -s "$tmp" "$PROXMENUX_VFIO_BIND_STATE"; then
|
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
|
_proxmenux_vfio_bind_write_udev_rule
|
||||||
# If empty, remove state file too (keeps host clean)
|
# 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_nvidia_vfio_policy_sync || true
|
||||||
_proxmenux_mark_host_config_changed
|
_proxmenux_mark_host_config_changed
|
||||||
else
|
else
|
||||||
@@ -598,9 +605,11 @@ EOF
|
|||||||
}
|
}
|
||||||
|
|
||||||
_proxmenux_nvidia_vfio_softdeps_sync() {
|
_proxmenux_nvidia_vfio_softdeps_sync() {
|
||||||
|
local FUNC_VERSION="1.0"
|
||||||
|
pmx_journal_context "_proxmenux_nvidia_vfio_softdeps_sync" "$FUNC_VERSION"
|
||||||
local changed=1
|
local changed=1
|
||||||
mkdir -p "$(dirname "$PROXMENUX_VFIO_CONF")"
|
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=(
|
local -a softdeps=(
|
||||||
"softdep nvidia pre: vfio-pci"
|
"softdep nvidia pre: vfio-pci"
|
||||||
@@ -612,14 +621,14 @@ _proxmenux_nvidia_vfio_softdeps_sync() {
|
|||||||
if _proxmenux_vfio_bind_state_has_vendor "10de"; then
|
if _proxmenux_vfio_bind_state_has_vendor "10de"; then
|
||||||
for line in "${softdeps[@]}"; do
|
for line in "${softdeps[@]}"; do
|
||||||
if ! grep -qFx "$line" "$PROXMENUX_VFIO_CONF" 2>/dev/null; then
|
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
|
changed=0
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
else
|
else
|
||||||
for line in "${softdeps[@]}"; do
|
for line in "${softdeps[@]}"; do
|
||||||
if grep -qFx "$line" "$PROXMENUX_VFIO_CONF" 2>/dev/null; then
|
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
|
changed=0
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
@@ -779,6 +788,8 @@ _proxmenux_vfio_bind_migrate_legacy_nvidia_ids() {
|
|||||||
# passed through.
|
# passed through.
|
||||||
# ──────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────
|
||||||
_proxmenux_nvidia_migrate_legacy_blacklist() {
|
_proxmenux_nvidia_migrate_legacy_blacklist() {
|
||||||
|
local FUNC_VERSION="1.0"
|
||||||
|
pmx_journal_context "_proxmenux_nvidia_migrate_legacy_blacklist" "$FUNC_VERSION"
|
||||||
local changed=false
|
local changed=false
|
||||||
local blacklist_file="${PROXMENUX_ETC_ROOT}/modprobe.d/blacklist.conf"
|
local blacklist_file="${PROXMENUX_ETC_ROOT}/modprobe.d/blacklist.conf"
|
||||||
local nvidia_blacklist="${PROXMENUX_ETC_ROOT}/modprobe.d/nvidia-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"
|
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
|
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$/d' \
|
||||||
-e '/^blacklist nvidia_drm$/d' \
|
-e '/^blacklist nvidia_drm$/d' \
|
||||||
-e '/^blacklist nvidia_modeset$/d' \
|
-e '/^blacklist nvidia_modeset$/d' \
|
||||||
-e '/^blacklist nvidia_uvm$/d' \
|
-e '/^blacklist nvidia_uvm$/d' \
|
||||||
-e '/^blacklist nvidiafb$/d' \
|
-e '/^blacklist nvidiafb$/d'
|
||||||
"$blacklist_file"
|
|
||||||
changed=true
|
changed=true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -f "$nvidia_blacklist" ]]; then
|
if [[ -f "$nvidia_blacklist" ]]; then
|
||||||
rm -f "$nvidia_blacklist"
|
pmx_remove_file "$nvidia_blacklist"
|
||||||
changed=true
|
changed=true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -f "$udev_disabled" ]]; then
|
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
|
udevadm control --reload-rules >/dev/null 2>&1 || true
|
||||||
changed=true
|
changed=true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -f "$modules_load_disabled" ]]; then
|
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
|
changed=true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -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 <<EOF
|
||||||
|
# ...
|
||||||
|
# EOF
|
||||||
|
# pmx_enable_service log2ram
|
||||||
|
#
|
||||||
|
# Everything degrades quietly: if the journal cannot be written, the
|
||||||
|
# change still happens. Recording must never be the reason an operation
|
||||||
|
# fails on somebody's host.
|
||||||
|
|
||||||
|
PMX_JOURNAL_ROOT="${PMX_JOURNAL_ROOT:-/usr/local/share/proxmenux/changes}"
|
||||||
|
PMX_JOURNAL_SPOOL="$PMX_JOURNAL_ROOT/spool"
|
||||||
|
PMX_JOURNAL_OBJECTS="$PMX_JOURNAL_ROOT/objects"
|
||||||
|
|
||||||
|
# Set by pmx_journal_context; every entry carries them.
|
||||||
|
PMX_JOURNAL_FUNCTION="${PMX_JOURNAL_FUNCTION:-}"
|
||||||
|
PMX_JOURNAL_VERSION="${PMX_JOURNAL_VERSION:-}"
|
||||||
|
PMX_JOURNAL_SOURCE="${PMX_JOURNAL_SOURCE:-${SCRIPT_SOURCE:-}}"
|
||||||
|
|
||||||
|
# Which function is making the changes that follow. Called once at the
|
||||||
|
# top of a function, so the entries it produces are attributable to it
|
||||||
|
# rather than to whichever script happened to source this file.
|
||||||
|
pmx_journal_context() {
|
||||||
|
PMX_JOURNAL_FUNCTION="${1:-unknown}"
|
||||||
|
PMX_JOURNAL_VERSION="${2:-}"
|
||||||
|
PMX_JOURNAL_SOURCE="${3:-${SCRIPT_SOURCE:-$(basename "${BASH_SOURCE[-1]:-unknown}")}}"
|
||||||
|
}
|
||||||
|
|
||||||
|
_pmx_journal_ready() {
|
||||||
|
mkdir -p "$PMX_JOURNAL_SPOOL" "$PMX_JOURNAL_OBJECTS" 2>/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 <<EOF
|
||||||
|
# ...
|
||||||
|
# EOF
|
||||||
|
pmx_write_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
|
||||||
|
# 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"
|
||||||
|
}
|
||||||
@@ -17,6 +17,9 @@ TOOLS_JSON="/usr/local/share/proxmenux/installed_tools.json"
|
|||||||
if [[ -f "$UTILS_FILE" ]]; then
|
if [[ -f "$UTILS_FILE" ]]; then
|
||||||
source "$UTILS_FILE"
|
source "$UTILS_FILE"
|
||||||
fi
|
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
|
load_language
|
||||||
initialize_cache
|
initialize_cache
|
||||||
@@ -77,7 +80,9 @@ create_backup() {
|
|||||||
|
|
||||||
# Create the patch script that will be called by APT hook
|
# Create the patch script that will be called by APT hook
|
||||||
create_patch_script() {
|
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
|
#!/usr/bin/env bash
|
||||||
# ==========================================================
|
# ==========================================================
|
||||||
# Proxmox Subscription Banner Patch (v3 - Minimal)
|
# Proxmox Subscription Banner Patch (v3 - Minimal)
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ TOOLS_JSON="/usr/local/share/proxmenux/installed_tools.json"
|
|||||||
if [[ -f "$UTILS_FILE" ]]; then
|
if [[ -f "$UTILS_FILE" ]]; then
|
||||||
source "$UTILS_FILE"
|
source "$UTILS_FILE"
|
||||||
fi
|
fi
|
||||||
|
if [[ -f "$LOCAL_SCRIPTS/global/pmx_journal.sh" ]]; then
|
||||||
|
source "$LOCAL_SCRIPTS/global/pmx_journal.sh"
|
||||||
|
fi
|
||||||
|
|
||||||
load_language
|
load_language
|
||||||
initialize_cache
|
initialize_cache
|
||||||
@@ -27,6 +30,8 @@ register_tool() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
remove_subscription_banner_pve8() {
|
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 JS_FILE="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js"
|
||||||
local GZ_FILE="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js.gz"
|
local GZ_FILE="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js.gz"
|
||||||
local APT_HOOK="/etc/apt/apt.conf.d/no-nag-script"
|
local APT_HOOK="/etc/apt/apt.conf.d/no-nag-script"
|
||||||
@@ -50,17 +55,20 @@ remove_subscription_banner_pve8() {
|
|||||||
cp "$JS_FILE" "$BACKUP_FILE"
|
cp "$JS_FILE" "$BACKUP_FILE"
|
||||||
|
|
||||||
|
|
||||||
sed -i "s/No valid subscription/Subscription active/g" "$JS_FILE"
|
pmx_edit_file "$JS_FILE" \
|
||||||
sed -i "s/Ext.Msg.WARNING/Ext.Msg.INFO/g" "$JS_FILE"
|
-e "s/No valid subscription/Subscription active/g" \
|
||||||
sed -i "s/res.data.status.toLowerCase() !== 'active'/false/g" "$JS_FILE"
|
-e "s/Ext.Msg.WARNING/Ext.Msg.INFO/g" \
|
||||||
sed -i "s/subscriptionActive: ''/subscriptionActive: true/g" "$JS_FILE"
|
-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
|
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
|
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."
|
msg_ok "Subscription banner removed successfully."
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ TOOLS_JSON="/usr/local/share/proxmenux/installed_tools.json"
|
|||||||
if [[ -f "$UTILS_FILE" ]]; then
|
if [[ -f "$UTILS_FILE" ]]; then
|
||||||
source "$UTILS_FILE"
|
source "$UTILS_FILE"
|
||||||
fi
|
fi
|
||||||
|
if [[ -f "$LOCAL_SCRIPTS/global/pmx_journal.sh" ]]; then
|
||||||
|
source "$LOCAL_SCRIPTS/global/pmx_journal.sh"
|
||||||
|
fi
|
||||||
|
|
||||||
load_language
|
load_language
|
||||||
initialize_cache
|
initialize_cache
|
||||||
@@ -34,6 +37,8 @@ download_common_functions() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
update_pve8() {
|
update_pve8() {
|
||||||
|
local FUNC_VERSION="1.0"
|
||||||
|
pmx_journal_context "update_pve8" "$FUNC_VERSION"
|
||||||
local start_time=$(date +%s)
|
local start_time=$(date +%s)
|
||||||
local log_file="/var/log/proxmox-update-$(date +%Y%m%d-%H%M%S).log"
|
local log_file="/var/log/proxmox-update-$(date +%Y%m%d-%H%M%S).log"
|
||||||
local changes_made=false
|
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
|
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")"
|
msg_ok "$(translate "Enterprise Proxmox repository disabled")"
|
||||||
changes_made=true
|
changes_made=true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -f /etc/apt/sources.list.d/ceph.list ] && grep -q "^deb" /etc/apt/sources.list.d/ceph.list; then
|
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")"
|
msg_ok "$(translate "Enterprise Proxmox Ceph repository disabled")"
|
||||||
changes_made=true
|
changes_made=true
|
||||||
fi
|
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
|
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")"
|
msg_ok "$(translate "Free public Proxmox repository enabled")"
|
||||||
changes_made=true
|
changes_made=true
|
||||||
fi
|
fi
|
||||||
@@ -90,14 +95,15 @@ update_pve8() {
|
|||||||
cp "$sources_file" "${sources_file}.backup.$(date +%Y%m%d_%H%M%S)"
|
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
|
if grep -q -E "(debian-security -security|debian main$|debian -updates)" "$sources_file"; then
|
||||||
sed -i '/^deb.*debian-security -security/d' "$sources_file"
|
pmx_edit_file "$sources_file" \
|
||||||
sed -i '/^deb.*debian main$/d' "$sources_file"
|
-e '/^deb.*debian-security -security/d' \
|
||||||
sed -i '/^deb.*debian -updates/d' "$sources_file"
|
-e '/^deb.*debian main$/d' \
|
||||||
|
-e '/^deb.*debian -updates/d'
|
||||||
changes_made=true
|
changes_made=true
|
||||||
msg_ok "$(translate "Malformed repository entries cleaned")"
|
msg_ok "$(translate "Malformed repository entries cleaned")"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
cat > "$sources_file" << EOF
|
pmx_write_file "$sources_file" << EOF
|
||||||
# Debian $OS_CODENAME repositories
|
# 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 main contrib non-free non-free-firmware
|
||||||
deb http://deb.debian.org/debian $OS_CODENAME-updates 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"
|
local firmware_conf="/etc/apt/apt.conf.d/no-firmware-warnings.conf"
|
||||||
if [ ! -f "$firmware_conf" ]; then
|
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
|
fi
|
||||||
|
|
||||||
cleanup_duplicate_repos
|
cleanup_duplicate_repos
|
||||||
|
|
||||||
msg_info "$(translate "Updating package lists...")"
|
msg_info "$(translate "Updating package lists...")"
|
||||||
|
pmx_record_execution "Update package lists" "apt-get update"
|
||||||
if apt-get update > "$log_file" 2>&1; then
|
if apt-get update > "$log_file" 2>&1; then
|
||||||
msg_ok "$(translate "Package lists updated successfully")"
|
msg_ok "$(translate "Package lists updated successfully")"
|
||||||
else
|
else
|
||||||
@@ -159,12 +166,16 @@ EOF
|
|||||||
|
|
||||||
if [[ $MENU_RESULT -eq 1 ]]; then
|
if [[ $MENU_RESULT -eq 1 ]]; then
|
||||||
msg_info2 "$(translate "Update cancelled by user")"
|
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
|
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
|
apt-get -y autoclean > /dev/null 2>&1 || true
|
||||||
return 0
|
return 0
|
||||||
elif [[ $MENU_RESULT -eq 2 ]]; then
|
elif [[ $MENU_RESULT -eq 2 ]]; then
|
||||||
msg_ok "$(translate "System is already up to date. No update needed.")"
|
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
|
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
|
apt-get -y autoclean > /dev/null 2>&1 || true
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
@@ -173,6 +184,7 @@ EOF
|
|||||||
local conflicting_packages=$(dpkg -l 2>/dev/null | grep -E "^ii.*(ntp|openntpd|systemd-timesyncd)" | awk '{print $2}')
|
local conflicting_packages=$(dpkg -l 2>/dev/null | grep -E "^ii.*(ntp|openntpd|systemd-timesyncd)" | awk '{print $2}')
|
||||||
if [ -n "$conflicting_packages" ]; then
|
if [ -n "$conflicting_packages" ]; then
|
||||||
msg_info "$(translate "Removing conflicting utilities...")"
|
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
|
DEBIAN_FRONTEND=noninteractive apt-get -y purge $conflicting_packages >> "$log_file" 2>&1
|
||||||
msg_ok "$(translate "Conflicting utilities removed")"
|
msg_ok "$(translate "Conflicting utilities removed")"
|
||||||
fi
|
fi
|
||||||
@@ -185,7 +197,7 @@ EOF
|
|||||||
export DPKG_OPTIONS="--force-confdef --force-confold"
|
export DPKG_OPTIONS="--force-confdef --force-confold"
|
||||||
|
|
||||||
msg_info "$(translate "Performing packages upgrade...")"
|
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)
|
total_packages=$(apt-get -s dist-upgrade | grep "^Inst" | wc -l)
|
||||||
msg_ok "$(translate "Packages upgrade successfull")"
|
msg_ok "$(translate "Packages upgrade successfull")"
|
||||||
|
|
||||||
@@ -196,6 +208,7 @@ EOF
|
|||||||
tput civis
|
tput civis
|
||||||
tput sc
|
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 \
|
/usr/bin/env \
|
||||||
DEBIAN_FRONTEND=noninteractive \
|
DEBIAN_FRONTEND=noninteractive \
|
||||||
@@ -250,7 +263,7 @@ EOF
|
|||||||
|
|
||||||
if [ ${#missing_packages[@]} -gt 0 ]; then
|
if [ ${#missing_packages[@]} -gt 0 ]; then
|
||||||
msg_info "$(translate "Installing essential Proxmox packages...")"
|
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")"
|
msg_ok "$(translate "Essential Proxmox packages installed")"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -258,7 +271,9 @@ EOF
|
|||||||
cleanup_duplicate_repos
|
cleanup_duplicate_repos
|
||||||
|
|
||||||
msg_info "$(translate "Performing system cleanup...")"
|
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
|
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
|
apt-get -y autoclean > /dev/null 2>&1 || true
|
||||||
msg_ok "$(translate "Cleanup finished")"
|
msg_ok "$(translate "Cleanup finished")"
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ APT_ENV="env DEBIAN_FRONTEND=noninteractive LC_ALL=C LANG=C"
|
|||||||
if [[ -f "$UTILS_FILE" ]]; then
|
if [[ -f "$UTILS_FILE" ]]; then
|
||||||
source "$UTILS_FILE"
|
source "$UTILS_FILE"
|
||||||
fi
|
fi
|
||||||
|
if [[ -f "$LOCAL_SCRIPTS/global/pmx_journal.sh" ]]; then
|
||||||
|
source "$LOCAL_SCRIPTS/global/pmx_journal.sh"
|
||||||
|
fi
|
||||||
|
|
||||||
load_language
|
load_language
|
||||||
initialize_cache
|
initialize_cache
|
||||||
@@ -35,6 +38,8 @@ download_common_functions() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
update_pve9() {
|
update_pve9() {
|
||||||
|
local FUNC_VERSION="1.0"
|
||||||
|
pmx_journal_context "update_pve9" "$FUNC_VERSION"
|
||||||
local pve_version
|
local pve_version
|
||||||
pve_version=$(pveversion | awk -F'/' '{print $2}' | cut -d'-' -f1)
|
pve_version=$(pveversion | awk -F'/' '{print $2}' | cut -d'-' -f1)
|
||||||
local start_time
|
local start_time
|
||||||
@@ -79,17 +84,17 @@ update_pve9() {
|
|||||||
disable_sources_repo() {
|
disable_sources_repo() {
|
||||||
local file="$1"
|
local file="$1"
|
||||||
if [[ -f "$file" ]]; then
|
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
|
if grep -q "^Enabled:" "$file"; then
|
||||||
sed -i 's/^Enabled:.*$/Enabled: false/' "$file"
|
pmx_edit_file "$file" 's/^Enabled:.*$/Enabled: false/'
|
||||||
else
|
else
|
||||||
echo "Enabled: false" >> "$file"
|
echo "Enabled: false" | pmx_append_file "$file"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if ! grep -q "^Types: " "$file"; then
|
if ! grep -q "^Types: " "$file"; then
|
||||||
msg_warn "$(translate "Malformed .sources file detected, removing: $(basename "$file")")"
|
msg_warn "$(translate "Malformed .sources file detected, removing: $(basename "$file")")"
|
||||||
rm -f "$file"
|
pmx_remove_file "$file"
|
||||||
fi
|
fi
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
@@ -110,18 +115,18 @@ update_pve9() {
|
|||||||
/etc/apt/sources.list.d/pve-install-repo.list \
|
/etc/apt/sources.list.d/pve-install-repo.list \
|
||||||
/etc/apt/sources.list.d/debian.list; do
|
/etc/apt/sources.list.d/debian.list; do
|
||||||
if [[ -f "$legacy_file" ]]; then
|
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"
|
msg_ok "$(translate "Removed legacy repository: $(basename "$legacy_file")")" | tee -a "$screen_capture"
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
if [[ -f /etc/apt/sources.list.d/debian.sources ]]; then
|
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"
|
msg_ok "$(translate "Old debian.sources file removed to prevent duplication")" | tee -a "$screen_capture"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
msg_info "$(translate "Creating Proxmox VE 9.x no-subscription repository...")"
|
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
|
Enabled: true
|
||||||
Types: deb
|
Types: deb
|
||||||
URIs: http://download.proxmox.com/debian/pve
|
URIs: http://download.proxmox.com/debian/pve
|
||||||
@@ -134,7 +139,7 @@ EOF
|
|||||||
changes_made=true
|
changes_made=true
|
||||||
|
|
||||||
msg_info "$(translate "Creating Debian ${TARGET_CODENAME} sources file...")"
|
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
|
Types: deb
|
||||||
URIs: http://deb.debian.org/debian/
|
URIs: http://deb.debian.org/debian/
|
||||||
Suites: ${TARGET_CODENAME} ${TARGET_CODENAME}-updates
|
Suites: ${TARGET_CODENAME} ${TARGET_CODENAME}-updates
|
||||||
@@ -154,11 +159,12 @@ EOF
|
|||||||
local firmware_conf="/etc/apt/apt.conf.d/no-firmware-warnings.conf"
|
local firmware_conf="/etc/apt/apt.conf.d/no-firmware-warnings.conf"
|
||||||
if [ ! -f "$firmware_conf" ]; then
|
if [ ! -f "$firmware_conf" ]; then
|
||||||
msg_info "$(translate "Disabling non-free firmware warnings...")"
|
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")"
|
msg_ok "$(translate "Non-free firmware warnings disabled")"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# UPDATE: no progress bar here (dpkg is not involved); capture output to parse errors
|
# 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_output=$(apt-get update 2>&1)
|
||||||
update_exit_code=$?
|
update_exit_code=$?
|
||||||
|
|
||||||
@@ -176,21 +182,25 @@ EOF
|
|||||||
|
|
||||||
if command -v gpg >/dev/null 2>&1; then
|
if command -v gpg >/dev/null 2>&1; then
|
||||||
# Modern approach: receive -> export -> dearmor into /etc/apt/keyrings/<KEY>.gpg
|
# Modern approach: receive -> export -> dearmor into /etc/apt/keyrings/<KEY>.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" \
|
if gpg --batch --keyserver keyserver.ubuntu.com --recv-keys "$key" \
|
||||||
&& gpg --batch --export "$key" | gpg --dearmor -o "/etc/apt/keyrings/${key}.gpg"; then
|
&& gpg --batch --export "$key" | gpg --dearmor -o "/etc/apt/keyrings/${key}.gpg"; then
|
||||||
msg_ok "$(translate "Imported missing GPG key: $key")"
|
msg_ok "$(translate "Imported missing GPG key: $key")"
|
||||||
else
|
else
|
||||||
msg_warn "$(translate "Keyrings method failed; trying apt-key fallback")"
|
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
|
apt-key adv --keyserver keyserver.ubuntu.com --recv-keys "$key" >/dev/null 2>&1 || true
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
# Fallback for minimal systems without gpg installed
|
# Fallback for minimal systems without gpg installed
|
||||||
msg_warn "$(translate "gpg not found; trying apt-key fallback")"
|
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
|
apt-key adv --keyserver keyserver.ubuntu.com --recv-keys "$key" >/dev/null 2>&1 || true
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Retry update after importing the key
|
# 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
|
if apt-get update > "$log_file" 2>&1; then
|
||||||
msg_ok "$(translate "Package lists updated after GPG fix")" | tee -a "$screen_capture"
|
msg_ok "$(translate "Package lists updated after GPG fix")" | tee -a "$screen_capture"
|
||||||
else
|
else
|
||||||
@@ -270,19 +280,24 @@ EOF
|
|||||||
|
|
||||||
if [[ $MENU_RESULT -eq 1 ]]; then
|
if [[ $MENU_RESULT -eq 1 ]]; then
|
||||||
msg_info2 "$(translate "Update cancelled by user")"
|
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
|
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
|
apt-get -y autoclean > /dev/null 2>&1 || true
|
||||||
rm -f "$screen_capture"
|
rm -f "$screen_capture"
|
||||||
return 0
|
return 0
|
||||||
elif [[ $MENU_RESULT -eq 2 ]]; then
|
elif [[ $MENU_RESULT -eq 2 ]]; then
|
||||||
msg_ok "$(translate "System is already up to date. No update needed.")"
|
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
|
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
|
apt-get -y autoclean > /dev/null 2>&1 || true
|
||||||
rm -f "$screen_capture"
|
rm -f "$screen_capture"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
msg_info "$(translate "Cleaning up unused time synchronization services...")"
|
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
|
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")"
|
msg_ok "$(translate "Old time services removed successfully")"
|
||||||
else
|
else
|
||||||
@@ -292,6 +307,7 @@ EOF
|
|||||||
echo -e
|
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 \
|
DEBIAN_FRONTEND=noninteractive apt -y \
|
||||||
-o Dpkg::Options::='--force-confdef' \
|
-o Dpkg::Options::='--force-confdef' \
|
||||||
-o Dpkg::Options::='--force-confold' \
|
-o Dpkg::Options::='--force-confold' \
|
||||||
@@ -314,7 +330,7 @@ EOF
|
|||||||
msg_info "$(translate "Installing essential Proxmox packages...")"
|
msg_info "$(translate "Installing essential Proxmox packages...")"
|
||||||
local additional_packages="zfsutils-linux proxmox-backup-restore-image chrony"
|
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")"
|
msg_ok "$(translate "Essential Proxmox packages installed")"
|
||||||
else
|
else
|
||||||
msg_warn "$(translate "Some essential Proxmox packages may not have been installed")"
|
msg_warn "$(translate "Some essential Proxmox packages may not have been installed")"
|
||||||
@@ -323,7 +339,9 @@ EOF
|
|||||||
lvm_repair_check
|
lvm_repair_check
|
||||||
cleanup_duplicate_repos
|
cleanup_duplicate_repos
|
||||||
|
|
||||||
|
pmx_record_execution "Remove unused packages" "apt-get -y autoremove"
|
||||||
apt-get -y autoremove > /dev/null 2>&1 || true
|
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
|
apt-get -y autoclean > /dev/null 2>&1 || true
|
||||||
msg_ok "$(translate "Cleanup finished")"
|
msg_ok "$(translate "Cleanup finished")"
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,16 @@ PROXMENUX_UTILS=(
|
|||||||
|
|
||||||
# Ensure APT repositories are configured for the current PVE version.
|
# Ensure APT repositories are configured for the current PVE version.
|
||||||
# Creates missing no-subscription repo entries for PVE8 (bookworm) or PVE9 (trixie).
|
# 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() {
|
ensure_repositories() {
|
||||||
|
local FUNC_VERSION="1.0"
|
||||||
|
pmx_journal_context "ensure_repositories" "$FUNC_VERSION"
|
||||||
local pve_version need_update=false
|
local pve_version need_update=false
|
||||||
pve_version=$(pveversion 2>/dev/null | grep -oP 'pve-manager/\K[0-9]+' | head -1)
|
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
|
# 0640, which the PVE 9 webgui's repository manager treats as
|
||||||
# unparseable and silently hides the source — issue #230.
|
# unparseable and silently hides the source — issue #230.
|
||||||
if [[ ! -f /etc/apt/sources.list.d/proxmox.sources ]]; then
|
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
|
Enabled: true
|
||||||
Types: deb
|
Types: deb
|
||||||
URIs: http://download.proxmox.com/debian/pve
|
URIs: http://download.proxmox.com/debian/pve
|
||||||
@@ -70,7 +79,7 @@ EOF
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ ! -f /etc/apt/sources.list.d/debian.sources ]]; then
|
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
|
Types: deb
|
||||||
URIs: http://deb.debian.org/debian/
|
URIs: http://deb.debian.org/debian/
|
||||||
Suites: trixie trixie-updates
|
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 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://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"
|
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
|
need_update=true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ ! -f /etc/apt/sources.list.d/pve-no-subscription.list ]]; then
|
if [[ ! -f /etc/apt/sources.list.d/pve-no-subscription.list ]]; then
|
||||||
echo "deb http://download.proxmox.com/debian/pve bookworm pve-no-subscription" \
|
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
|
need_update=true
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "$need_update" == true ]] || [[ ! -d /var/lib/apt/lists || -z "$(ls -A /var/lib/apt/lists 2>/dev/null)" ]]; then
|
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...")"
|
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
|
apt-get update >/dev/null 2>&1 || apt-get update
|
||||||
# Spinner pair: msg_info must be closed before returning.
|
# Spinner pair: msg_info must be closed before returning.
|
||||||
# Without this the next `msg_info` caller spawns a second
|
# Without this the next `msg_info` caller spawns a second
|
||||||
@@ -132,7 +142,16 @@ install_single_package() {
|
|||||||
msg_info "$(translate "Installing") $package${description:+ ($description)}..."
|
msg_info "$(translate "Installing") $package${description:+ ($description)}..."
|
||||||
local install_success=false
|
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
|
install_success=true
|
||||||
fi
|
fi
|
||||||
cleanup 2>/dev/null || true
|
cleanup 2>/dev/null || true
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ if [[ -n "${__PROXMENUX_VM_STORAGE_HELPERS__}" ]]; then
|
|||||||
fi
|
fi
|
||||||
__PROXMENUX_VM_STORAGE_HELPERS__=1
|
__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() {
|
function _array_contains() {
|
||||||
local needle="$1"
|
local needle="$1"
|
||||||
shift
|
shift
|
||||||
@@ -371,6 +375,8 @@ function _vm_storage_register_vfio_iommu_tool() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function _vm_storage_enable_iommu_cmdline() {
|
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
|
local cpu_vendor iommu_param
|
||||||
cpu_vendor=$(grep -m1 "vendor_id" /proc/cpuinfo 2>/dev/null | awk '{print $3}')
|
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 [[ -f "$cmdline_file" ]] && grep -qE 'root=ZFS=|root=ZFS/' "$cmdline_file" 2>/dev/null; then
|
||||||
if ! grep -q "$iommu_param" "$cmdline_file"; then
|
if ! grep -q "$iommu_param" "$cmdline_file"; then
|
||||||
cp "$cmdline_file" "${cmdline_file}.bak.$(date +%Y%m%d_%H%M%S)"
|
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
|
proxmox-boot-tool refresh >/dev/null 2>&1 || true
|
||||||
fi
|
fi
|
||||||
elif [[ -f "$grub_file" ]]; then
|
elif [[ -f "$grub_file" ]]; then
|
||||||
if ! grep -q "$iommu_param" "$grub_file"; then
|
if ! grep -q "$iommu_param" "$grub_file"; then
|
||||||
cp "$grub_file" "${grub_file}.bak.$(date +%Y%m%d_%H%M%S)"
|
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
|
update-grub >/dev/null 2>&1 || true
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ if [[ -f "$UTILS_FILE" ]]; then
|
|||||||
source "$UTILS_FILE"
|
source "$UTILS_FILE"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [[ -f "$LOCAL_SCRIPTS/global/pmx_journal.sh" ]]; then
|
||||||
|
source "$LOCAL_SCRIPTS/global/pmx_journal.sh"
|
||||||
|
fi
|
||||||
|
|
||||||
load_language
|
load_language
|
||||||
initialize_cache
|
initialize_cache
|
||||||
|
|
||||||
@@ -53,6 +57,9 @@ select_privileged_container() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
validate_container_id() {
|
validate_container_id() {
|
||||||
|
local FUNC_VERSION="1.1"
|
||||||
|
pmx_journal_context "validate_container_id" "$FUNC_VERSION"
|
||||||
|
|
||||||
if [ -z "$CONTAINER_ID" ]; then
|
if [ -z "$CONTAINER_ID" ]; then
|
||||||
msg_error "$(translate 'Container ID not defined. Make sure to select a container first.')"
|
msg_error "$(translate 'Container ID not defined. Make sure to select a container first.')"
|
||||||
exit 1
|
exit 1
|
||||||
@@ -66,6 +73,8 @@ validate_container_id() {
|
|||||||
|
|
||||||
if pct status "$CONTAINER_ID" | grep -q "running"; then
|
if pct status "$CONTAINER_ID" | grep -q "running"; then
|
||||||
msg_info "$(translate 'Stopping the container before conversion...')"
|
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"
|
pct stop "$CONTAINER_ID"
|
||||||
msg_ok "$(translate 'Container stopped.')"
|
msg_ok "$(translate 'Container stopped.')"
|
||||||
fi
|
fi
|
||||||
@@ -89,7 +98,12 @@ show_backup_warning() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
convert_direct_method() {
|
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..."
|
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"
|
TEMP_DIR="/tmp/lxc_convert_$CONTAINER_ID"
|
||||||
mkdir -p "$TEMP_DIR"
|
mkdir -p "$TEMP_DIR"
|
||||||
@@ -225,9 +239,9 @@ convert_direct_method() {
|
|||||||
|
|
||||||
CONFIG_FILE="/etc/pve/lxc/$CONTAINER_ID.conf"
|
CONFIG_FILE="/etc/pve/lxc/$CONTAINER_ID.conf"
|
||||||
if ! grep -q "^unprivileged:" "$CONFIG_FILE"; then
|
if ! grep -q "^unprivileged:" "$CONFIG_FILE"; then
|
||||||
echo "unprivileged: 1" >> "$CONFIG_FILE"
|
echo "unprivileged: 1" | pmx_append_file "$CONFIG_FILE"
|
||||||
else
|
else
|
||||||
sed -i 's/^unprivileged:.*/unprivileged: 1/' "$CONFIG_FILE"
|
pmx_edit_file "$CONFIG_FILE" 's/^unprivileged:.*/unprivileged: 1/'
|
||||||
fi
|
fi
|
||||||
|
|
||||||
msg_ok "$(translate 'Direct conversion completed for container') $CONTAINER_ID"
|
msg_ok "$(translate 'Direct conversion completed for container') $CONTAINER_ID"
|
||||||
@@ -238,9 +252,12 @@ convert_direct_method() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cleanup_and_finalize() {
|
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
|
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...')"
|
msg_info2 "$(translate 'Starting unprivileged container...')"
|
||||||
|
pmx_record_execution "start converted unprivileged CT ${CONTAINER_ID}" "pct start ${CONTAINER_ID}"
|
||||||
pct start "$CONTAINER_ID"
|
pct start "$CONTAINER_ID"
|
||||||
msg_ok "$(translate 'Unprivileged container') $CONTAINER_ID $(translate 'started successfully.')"
|
msg_ok "$(translate 'Unprivileged container') $CONTAINER_ID $(translate 'started successfully.')"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ if [[ -f "$UTILS_FILE" ]]; then
|
|||||||
source "$UTILS_FILE"
|
source "$UTILS_FILE"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [[ -f "$LOCAL_SCRIPTS/global/pmx_journal.sh" ]]; then
|
||||||
|
source "$LOCAL_SCRIPTS/global/pmx_journal.sh"
|
||||||
|
fi
|
||||||
|
|
||||||
load_language
|
load_language
|
||||||
initialize_cache
|
initialize_cache
|
||||||
|
|
||||||
@@ -69,12 +73,19 @@ show_backup_warning() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
convert_to_privileged() {
|
convert_to_privileged() {
|
||||||
|
local FUNC_VERSION="2.0"
|
||||||
|
pmx_journal_context "convert_to_privileged" "$FUNC_VERSION"
|
||||||
|
|
||||||
CONF_FILE="/etc/pve/lxc/$CONTAINER_ID.conf"
|
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}')
|
CONTAINER_STATUS=$(pct status "$CONTAINER_ID" | awk '{print $2}')
|
||||||
|
|
||||||
if [ "$CONTAINER_STATUS" == "running" ]; then
|
if [ "$CONTAINER_STATUS" == "running" ]; then
|
||||||
msg_info "$(translate 'Stopping container') $CONTAINER_ID..."
|
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"
|
pct shutdown "$CONTAINER_ID"
|
||||||
|
|
||||||
# Wait for container to stop
|
# Wait for container to stop
|
||||||
@@ -101,8 +112,8 @@ convert_to_privileged() {
|
|||||||
msg_ok "$(translate 'Configuration backup created:') $CONF_FILE.bak"
|
msg_ok "$(translate 'Configuration backup created:') $CONF_FILE.bak"
|
||||||
|
|
||||||
msg_info "$(translate 'Converting container to privileged...')"
|
msg_info "$(translate 'Converting container to privileged...')"
|
||||||
sed -i '/^unprivileged: 1/d' "$CONF_FILE"
|
pmx_edit_file "$CONF_FILE" '/^unprivileged: 1/d'
|
||||||
echo "unprivileged: 0" >> "$CONF_FILE"
|
echo "unprivileged: 0" | pmx_append_file "$CONF_FILE"
|
||||||
|
|
||||||
msg_ok "$(translate 'Container successfully converted to privileged.')"
|
msg_ok "$(translate 'Container successfully converted to privileged.')"
|
||||||
|
|
||||||
@@ -112,9 +123,12 @@ convert_to_privileged() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
finalize_conversion() {
|
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
|
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...')"
|
msg_info "$(translate 'Starting privileged container...')"
|
||||||
|
pmx_record_execution "start converted privileged CT ${CONTAINER_ID}" "pct start ${CONTAINER_ID}"
|
||||||
pct start "$CONTAINER_ID"
|
pct start "$CONTAINER_ID"
|
||||||
msg_ok "$(translate 'Privileged container') $CONTAINER_ID $(translate 'started successfully.')"
|
msg_ok "$(translate 'Privileged container') $CONTAINER_ID $(translate 'started successfully.')"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -61,6 +61,9 @@ MONITOR_PORT=8008
|
|||||||
if [[ -f "$UTILS_FILE" ]]; then
|
if [[ -f "$UTILS_FILE" ]]; then
|
||||||
source "$UTILS_FILE"
|
source "$UTILS_FILE"
|
||||||
fi
|
fi
|
||||||
|
if [[ -f "$LOCAL_SCRIPTS/global/pmx_journal.sh" ]]; then
|
||||||
|
source "$LOCAL_SCRIPTS/global/pmx_journal.sh"
|
||||||
|
fi
|
||||||
|
|
||||||
load_language
|
load_language
|
||||||
initialize_cache
|
initialize_cache
|
||||||
@@ -749,6 +752,8 @@ show_version_info() {
|
|||||||
|
|
||||||
# ==========================================================
|
# ==========================================================
|
||||||
uninstall_proxmenu() {
|
uninstall_proxmenu() {
|
||||||
|
local FUNC_VERSION="1.0"
|
||||||
|
pmx_journal_context "uninstall_proxmenu" "$FUNC_VERSION"
|
||||||
if ! dialog --clear --backtitle "$BACKTITLE" \
|
if ! dialog --clear --backtitle "$BACKTITLE" \
|
||||||
--title "Uninstall ProxMenux" \
|
--title "Uninstall ProxMenux" \
|
||||||
--yesno "\n$(translate "Are you sure you want to uninstall ProxMenux?")" 8 60; then
|
--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.
|
# a pre-static-translations install. Cheap idempotent check.
|
||||||
if [ -d "/opt/googletrans-env" ]; then
|
if [ -d "/opt/googletrans-env" ]; then
|
||||||
echo "30" ; echo "Removing legacy googletrans virtualenv..."
|
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"
|
rm -rf "/opt/googletrans-env"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "50" ; echo "Removing ProxMenu files..."
|
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"
|
rm -rf "$BASE_DIR"
|
||||||
|
|
||||||
# Remove selected dependencies
|
# Remove selected dependencies
|
||||||
@@ -785,22 +792,30 @@ uninstall_proxmenu() {
|
|||||||
echo "70" ; echo "Removing selected dependencies..."
|
echo "70" ; echo "Removing selected dependencies..."
|
||||||
read -r -a DEPS_ARRAY <<< "$(echo "$deps_to_remove" | tr -d '"')"
|
read -r -a DEPS_ARRAY <<< "$(echo "$deps_to_remove" | tr -d '"')"
|
||||||
for dep in "${DEPS_ARRAY[@]}"; do
|
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
|
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
|
apt-get -y --purge autoremove "$dep" >/dev/null 2>&1
|
||||||
done
|
done
|
||||||
|
pmx_record_execution "Remove unused ProxMenux dependencies" "apt-get autoremove -y --purge"
|
||||||
apt-get autoremove -y --purge >/dev/null 2>&1
|
apt-get autoremove -y --purge >/dev/null 2>&1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "80" ; echo "Removing ProxMenux Monitor..."
|
echo "80" ; echo "Removing ProxMenux Monitor..."
|
||||||
|
pmx_record_execution "Uninstall ProxMenux Monitor" "uninstall_proxmenux_monitor"
|
||||||
uninstall_proxmenux_monitor
|
uninstall_proxmenux_monitor
|
||||||
|
|
||||||
echo "90" ; echo "Restoring system files..."
|
echo "90" ; echo "Restoring system files..."
|
||||||
# Restore .bashrc and motd
|
# 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
|
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
|
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
|
fi
|
||||||
|
|
||||||
echo "100" ; echo "Uninstallation complete!"
|
echo "100" ; echo "Uninstallation complete!"
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ TOOLS_JSON="/usr/local/share/proxmenux/installed_tools.json"
|
|||||||
if [[ -f "$UTILS_FILE" ]]; then
|
if [[ -f "$UTILS_FILE" ]]; then
|
||||||
source "$UTILS_FILE"
|
source "$UTILS_FILE"
|
||||||
fi
|
fi
|
||||||
|
if [[ -f "$LOCAL_SCRIPTS/global/pmx_journal.sh" ]]; then
|
||||||
|
source "$LOCAL_SCRIPTS/global/pmx_journal.sh"
|
||||||
|
fi
|
||||||
|
|
||||||
load_language
|
load_language
|
||||||
initialize_cache
|
initialize_cache
|
||||||
@@ -389,6 +392,8 @@ analyze_bridge_configuration() {
|
|||||||
|
|
||||||
|
|
||||||
guided_bridge_repair() {
|
guided_bridge_repair() {
|
||||||
|
local FUNC_VERSION="1.0"
|
||||||
|
pmx_journal_context "guided_bridge_repair" "$FUNC_VERSION"
|
||||||
local step=1
|
local step=1
|
||||||
local total_steps=5
|
local total_steps=5
|
||||||
|
|
||||||
@@ -482,7 +487,7 @@ guided_bridge_repair() {
|
|||||||
|
|
||||||
# Apply the change
|
# Apply the change
|
||||||
if [ "$new_ports" != "$current_ports" ]; then
|
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
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
@@ -520,6 +525,7 @@ guided_bridge_repair() {
|
|||||||
clear
|
clear
|
||||||
msg_info "$(translate "Restarting network service...")"
|
msg_info "$(translate "Restarting network service...")"
|
||||||
|
|
||||||
|
pmx_record_execution "Restart networking service" "systemctl restart networking"
|
||||||
if systemctl restart networking; then
|
if systemctl restart networking; then
|
||||||
msg_ok "$(translate "Network service restarted successfully")"
|
msg_ok "$(translate "Network service restarted successfully")"
|
||||||
else
|
else
|
||||||
@@ -635,6 +641,8 @@ analyze_network_configuration() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
guided_configuration_cleanup() {
|
guided_configuration_cleanup() {
|
||||||
|
local FUNC_VERSION="1.0"
|
||||||
|
pmx_journal_context "guided_configuration_cleanup" "$FUNC_VERSION"
|
||||||
local step=1
|
local step=1
|
||||||
local total_steps=5
|
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
|
--infobox "$(translate "Removing invalid configurations...")\n\n$(translate "This may take a few seconds...")" 8 50
|
||||||
|
|
||||||
for iface in $interfaces_to_remove; do
|
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
|
done
|
||||||
((step++))
|
((step++))
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,11 @@ fi
|
|||||||
if [[ -f "$LOCAL_SCRIPTS/global/utils-install-functions.sh" ]]; then
|
if [[ -f "$LOCAL_SCRIPTS/global/utils-install-functions.sh" ]]; then
|
||||||
source "$LOCAL_SCRIPTS/global/utils-install-functions.sh"
|
source "$LOCAL_SCRIPTS/global/utils-install-functions.sh"
|
||||||
fi
|
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
|
load_language
|
||||||
initialize_cache
|
initialize_cache
|
||||||
@@ -92,6 +97,16 @@ register_tool() {
|
|||||||
local state="$2"
|
local state="$2"
|
||||||
local version="${3:-1.0}"
|
local version="${3:-1.0}"
|
||||||
local source="${4:-${SCRIPT_SOURCE:-unknown}}"
|
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
|
ensure_tools_json
|
||||||
if [[ "$state" == "true" ]]; then
|
if [[ "$state" == "true" ]]; then
|
||||||
jq --arg t "$tool" --arg ver "$version" --arg src "$source" \
|
jq --arg t "$tool" --arg ver "$version" --arg src "$source" \
|
||||||
@@ -290,9 +305,10 @@ configure_time_sync() {
|
|||||||
|
|
||||||
skip_apt_languages() {
|
skip_apt_languages() {
|
||||||
local FUNC_VERSION="1.0"
|
local FUNC_VERSION="1.0"
|
||||||
|
pmx_journal_context "skip_apt_languages" "$FUNC_VERSION"
|
||||||
# description: Stop APT from downloading translation files to speed up updates.
|
# description: Stop APT from downloading translation files to speed up updates.
|
||||||
msg_info "$(translate "Configuring APT to skip downloading additional languages...")"
|
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";
|
Acquire::Languages "none";
|
||||||
EOF
|
EOF
|
||||||
msg_ok "$(translate "APT configured to skip additional languages")"
|
msg_ok "$(translate "APT configured to skip additional languages")"
|
||||||
@@ -302,6 +318,7 @@ EOF
|
|||||||
# ==========================================================
|
# ==========================================================
|
||||||
optimize_journald() {
|
optimize_journald() {
|
||||||
local FUNC_VERSION="1.0"
|
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.
|
# 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
|
if [ -f /etc/log2ram.conf ] || [ -d /var/log.hdd ]; then
|
||||||
return 0
|
return 0
|
||||||
@@ -314,7 +331,7 @@ optimize_journald() {
|
|||||||
cp -a "$jf" "${jf}.bak" 2>/dev/null || true
|
cp -a "$jf" "${jf}.bak" 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
cat <<EOF > /etc/systemd/journald.conf
|
pmx_write_file /etc/systemd/journald.conf <<EOF
|
||||||
[Journal]
|
[Journal]
|
||||||
Storage=persistent
|
Storage=persistent
|
||||||
SplitMode=none
|
SplitMode=none
|
||||||
@@ -337,8 +354,11 @@ MaxLevelConsole=notice
|
|||||||
MaxLevelWall=crit
|
MaxLevelWall=crit
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
|
pmx_record_execution "Restart systemd-journald" "systemctl restart systemd-journald.service"
|
||||||
systemctl restart systemd-journald.service > /dev/null 2>&1
|
systemctl restart systemd-journald.service > /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
|
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
|
journalctl --rotate > /dev/null 2>&1
|
||||||
|
|
||||||
msg_ok "$(translate "Journald optimized - Max size: 64M")"
|
msg_ok "$(translate "Journald optimized - Max size: 64M")"
|
||||||
@@ -348,6 +368,7 @@ EOF
|
|||||||
# ==========================================================
|
# ==========================================================
|
||||||
optimize_logrotate() {
|
optimize_logrotate() {
|
||||||
local FUNC_VERSION="1.1"
|
local FUNC_VERSION="1.1"
|
||||||
|
pmx_journal_context "optimize_logrotate" "$FUNC_VERSION"
|
||||||
# description: Replace logrotate.conf with a Log2RAM-friendly profile (daily rotation, copytruncate).
|
# description: Replace logrotate.conf with a Log2RAM-friendly profile (daily rotation, copytruncate).
|
||||||
msg_info "$(translate "Optimizing logrotate configuration...")"
|
msg_info "$(translate "Optimizing logrotate configuration...")"
|
||||||
local logrotate_conf="/etc/logrotate.conf"
|
local logrotate_conf="/etc/logrotate.conf"
|
||||||
@@ -355,7 +376,7 @@ optimize_logrotate() {
|
|||||||
|
|
||||||
cp -n "$logrotate_conf" "$backup_conf" 2>/dev/null || true
|
cp -n "$logrotate_conf" "$backup_conf" 2>/dev/null || true
|
||||||
|
|
||||||
cat <<EOF > "$logrotate_conf"
|
pmx_write_file "$logrotate_conf" <<EOF
|
||||||
# ProxMenux optimized configuration (Log2RAM-friendly)
|
# ProxMenux optimized configuration (Log2RAM-friendly)
|
||||||
daily
|
daily
|
||||||
su root adm
|
su root adm
|
||||||
@@ -369,6 +390,7 @@ create 0640 root adm
|
|||||||
copytruncate
|
copytruncate
|
||||||
include /etc/logrotate.d
|
include /etc/logrotate.d
|
||||||
EOF
|
EOF
|
||||||
|
pmx_record_execution "Restart logrotate" "systemctl restart logrotate"
|
||||||
systemctl restart logrotate > /dev/null 2>&1
|
systemctl restart logrotate > /dev/null 2>&1
|
||||||
|
|
||||||
msg_ok "$(translate "Logrotate optimization completed")"
|
msg_ok "$(translate "Logrotate optimization completed")"
|
||||||
@@ -378,12 +400,13 @@ EOF
|
|||||||
# ==========================================================
|
# ==========================================================
|
||||||
increase_system_limits() {
|
increase_system_limits() {
|
||||||
local FUNC_VERSION="1.1"
|
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.
|
# description: Raise inotify watches, file descriptors, process keys and PID limits to enterprise levels.
|
||||||
msg_info "$(translate "Increasing various system limits...")"
|
msg_info "$(translate "Increasing various system limits...")"
|
||||||
NECESSARY_REBOOT=1
|
NECESSARY_REBOOT=1
|
||||||
|
|
||||||
|
|
||||||
cat > /etc/sysctl.d/99-maxwatches.conf << EOF
|
pmx_write_file /etc/sysctl.d/99-maxwatches.conf << EOF
|
||||||
# ProxMenux configuration
|
# ProxMenux configuration
|
||||||
fs.inotify.max_user_watches = 1048576
|
fs.inotify.max_user_watches = 1048576
|
||||||
fs.inotify.max_user_instances = 1048576
|
fs.inotify.max_user_instances = 1048576
|
||||||
@@ -391,7 +414,7 @@ fs.inotify.max_queued_events = 1048576
|
|||||||
EOF
|
EOF
|
||||||
|
|
||||||
|
|
||||||
cat > /etc/security/limits.d/99-limits.conf << EOF
|
pmx_write_file /etc/security/limits.d/99-limits.conf << EOF
|
||||||
# ProxMenux configuration
|
# ProxMenux configuration
|
||||||
* soft nproc 1048576
|
* soft nproc 1048576
|
||||||
* hard nproc 1048576
|
* hard nproc 1048576
|
||||||
@@ -404,7 +427,7 @@ root hard nofile unlimited
|
|||||||
EOF
|
EOF
|
||||||
|
|
||||||
|
|
||||||
cat > /etc/sysctl.d/99-maxkeys.conf << EOF
|
pmx_write_file /etc/sysctl.d/99-maxkeys.conf << EOF
|
||||||
# ProxMenux configuration
|
# ProxMenux configuration
|
||||||
kernel.keys.root_maxkeys=1000000
|
kernel.keys.root_maxkeys=1000000
|
||||||
kernel.keys.maxkeys=1000000
|
kernel.keys.maxkeys=1000000
|
||||||
@@ -413,32 +436,32 @@ EOF
|
|||||||
|
|
||||||
for file in /etc/systemd/system.conf /etc/systemd/user.conf; do
|
for file in /etc/systemd/system.conf /etc/systemd/user.conf; do
|
||||||
if ! grep -q "^DefaultLimitNOFILE=" "$file"; then
|
if ! grep -q "^DefaultLimitNOFILE=" "$file"; then
|
||||||
echo "DefaultLimitNOFILE=1048576" >> "$file"
|
echo "DefaultLimitNOFILE=1048576" | pmx_append_file "$file"
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
|
|
||||||
for file in /etc/pam.d/common-session /etc/pam.d/runuser-l; do
|
for file in /etc/pam.d/common-session /etc/pam.d/runuser-l; do
|
||||||
if ! grep -q "^session required pam_limits.so" "$file"; then
|
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
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
|
|
||||||
if ! grep -q "ulimit -n 1048576" /root/.profile; then
|
if ! grep -q "ulimit -n 1048576" /root/.profile; then
|
||||||
sed -i '/ulimit -n 256000/d' /root/.profile 2>/dev/null
|
pmx_edit_file /root/.profile '/ulimit -n 256000/d' 2>/dev/null || true
|
||||||
echo "ulimit -n 1048576" >> /root/.profile
|
echo "ulimit -n 1048576" | pmx_append_file /root/.profile
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
||||||
cat > /etc/sysctl.d/99-swap.conf << EOF
|
pmx_write_file /etc/sysctl.d/99-swap.conf << EOF
|
||||||
# ProxMenux configuration
|
# ProxMenux configuration
|
||||||
vm.swappiness = 10
|
vm.swappiness = 10
|
||||||
vm.vfs_cache_pressure = 100
|
vm.vfs_cache_pressure = 100
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
|
|
||||||
cat > /etc/sysctl.d/99-fs.conf << EOF
|
pmx_write_file /etc/sysctl.d/99-fs.conf << EOF
|
||||||
# ProxMenux configuration
|
# ProxMenux configuration
|
||||||
fs.nr_open = 2097152
|
fs.nr_open = 2097152
|
||||||
fs.file-max = 2097152
|
fs.file-max = 2097152
|
||||||
@@ -452,21 +475,25 @@ EOF
|
|||||||
# ==========================================================
|
# ==========================================================
|
||||||
optimize_memory_settings() {
|
optimize_memory_settings() {
|
||||||
local FUNC_VERSION="1.2"
|
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.
|
# 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...")"
|
msg_info "$(translate "Optimizing memory settings...")"
|
||||||
NECESSARY_REBOOT=1
|
NECESSARY_REBOOT=1
|
||||||
|
|
||||||
cat <<EOF > /etc/sysctl.d/99-memory.conf
|
local memory_settings
|
||||||
|
memory_settings="$(cat <<EOF
|
||||||
# Balanced Memory Optimization
|
# Balanced Memory Optimization
|
||||||
vm.swappiness = 10
|
vm.swappiness = 10
|
||||||
vm.dirty_ratio = 15
|
vm.dirty_ratio = 15
|
||||||
vm.dirty_background_ratio = 5
|
vm.dirty_background_ratio = 5
|
||||||
vm.max_map_count = 262144
|
vm.max_map_count = 262144
|
||||||
EOF
|
EOF
|
||||||
|
)"
|
||||||
|
|
||||||
if [ -f /proc/sys/vm/compaction_proactiveness ]; then
|
if [ -f /proc/sys/vm/compaction_proactiveness ]; then
|
||||||
echo "vm.compaction_proactiveness = 20" >> /etc/sysctl.d/99-memory.conf
|
memory_settings+=$'\n''vm.compaction_proactiveness = 20'
|
||||||
fi
|
fi
|
||||||
|
printf '%s\n' "$memory_settings" | pmx_write_file /etc/sysctl.d/99-memory.conf
|
||||||
|
|
||||||
msg_ok "$(translate "Memory optimization completed.")"
|
msg_ok "$(translate "Memory optimization completed.")"
|
||||||
register_tool "memory_settings" true "$FUNC_VERSION"
|
register_tool "memory_settings" true "$FUNC_VERSION"
|
||||||
@@ -475,11 +502,12 @@ EOF
|
|||||||
# ==========================================================
|
# ==========================================================
|
||||||
configure_kernel_panic() {
|
configure_kernel_panic() {
|
||||||
local FUNC_VERSION="1.0"
|
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.
|
# description: Auto-reboot on kernel panic / oops / hardlockup; write crash dumps to /var/crash.
|
||||||
msg_info "$(translate "Configuring kernel panic behavior")"
|
msg_info "$(translate "Configuring kernel panic behavior")"
|
||||||
NECESSARY_REBOOT=1
|
NECESSARY_REBOOT=1
|
||||||
|
|
||||||
cat <<EOF > /etc/sysctl.d/99-kernelpanic.conf
|
pmx_write_file /etc/sysctl.d/99-kernelpanic.conf <<EOF
|
||||||
# Enable restart on kernel panic, kernel oops and hardlockup
|
# Enable restart on kernel panic, kernel oops and hardlockup
|
||||||
kernel.core_pattern = /var/crash/core.%t.%p
|
kernel.core_pattern = /var/crash/core.%t.%p
|
||||||
kernel.panic = 10
|
kernel.panic = 10
|
||||||
@@ -507,11 +535,12 @@ force_apt_ipv4() {
|
|||||||
|
|
||||||
apply_network_optimizations() {
|
apply_network_optimizations() {
|
||||||
local FUNC_VERSION="1.1"
|
local FUNC_VERSION="1.1"
|
||||||
|
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).
|
# description: Tune TCP buffers, somaxconn, IPv4 hardening and disable rp_filter on fw bridges (PVE 9 compatible).
|
||||||
msg_info "$(translate "Optimizing network settings...")"
|
msg_info "$(translate "Optimizing network settings...")"
|
||||||
NECESSARY_REBOOT=1
|
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)
|
# ProxMenux - Network tuning (PVE 9 compatible)
|
||||||
# ==========================================================
|
# ==========================================================
|
||||||
@@ -555,9 +584,10 @@ net.ipv4.tcp_wmem = 8192 65536 16777216
|
|||||||
net.unix.max_dgram_qlen = 4096
|
net.unix.max_dgram_qlen = 4096
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
|
pmx_record_execution "Apply network sysctl configuration" "sysctl --system"
|
||||||
sysctl --system > /dev/null 2>&1
|
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
|
#!/usr/bin/env bash
|
||||||
# Set rp_filter=0 and log_martians=0 on Proxmox fw bridge interfaces.
|
# 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/.
|
# 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
|
chmod 0755 /usr/local/sbin/proxmenux-fwbr-tune
|
||||||
chown root:root /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]
|
[Unit]
|
||||||
Description=ProxMenux - Tune rp_filter/log_martians on virtual fw bridges
|
Description=ProxMenux - Tune rp_filter/log_martians on virtual fw bridges
|
||||||
After=network-online.target
|
After=network-online.target
|
||||||
@@ -603,7 +633,7 @@ RemainAfterExit=yes
|
|||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
EOF
|
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=="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=="fwln*", RUN+="/usr/local/sbin/proxmenux-fwbr-tune %k"
|
||||||
ACTION=="add", SUBSYSTEM=="net", KERNEL=="fwpr*", 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
|
chmod 0644 /etc/udev/rules.d/99-proxmenux-fwbr-tune.rules
|
||||||
chown root:root /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
|
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
|
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
|
/usr/local/sbin/proxmenux-fwbr-tune >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
|
||||||
local interfaces_file="/etc/network/interfaces"
|
local interfaces_file="/etc/network/interfaces"
|
||||||
if ! grep -q 'source /etc/network/interfaces.d/*' "$interfaces_file"; then
|
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
|
fi
|
||||||
|
|
||||||
msg_ok "$(translate "Network optimization completed")"
|
msg_ok "$(translate "Network optimization completed")"
|
||||||
@@ -759,6 +792,7 @@ PY
|
|||||||
|
|
||||||
customize_bashrc() {
|
customize_bashrc() {
|
||||||
local FUNC_VERSION="1.2"
|
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.
|
# 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...")"
|
msg_info "$(translate "Customizing bashrc for root user...")"
|
||||||
local bashrc="/root/.bashrc"
|
local bashrc="/root/.bashrc"
|
||||||
@@ -768,7 +802,7 @@ customize_bashrc() {
|
|||||||
local prompt_path_escape='\W'
|
local prompt_path_escape='\W'
|
||||||
local detected_path_style="short"
|
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
|
if ! detected_path_style="$(_migrate_proxmenux_bashrc "$bashrc" inspect)"; then
|
||||||
msg_error "$(translate "Failed to inspect the existing ProxMenux Bash configuration.")"
|
msg_error "$(translate "Failed to inspect the existing ProxMenux Bash configuration.")"
|
||||||
return 1
|
return 1
|
||||||
@@ -791,13 +825,19 @@ customize_bashrc() {
|
|||||||
esac
|
esac
|
||||||
|
|
||||||
[ -f "${bashrc}.bak" ] || cp "$bashrc" "${bashrc}.bak" > /dev/null 2>&1
|
[ -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.")"
|
msg_error "$(translate "Failed to migrate the existing ProxMenux Bash configuration.")"
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
pmx_write_file "$bashrc" < "$migrated_bashrc"
|
||||||
|
rm -f "$migrated_bashrc"
|
||||||
|
|
||||||
|
|
||||||
cat >> "$bashrc" << EOF
|
pmx_append_file "$bashrc" << EOF
|
||||||
${marker_begin}
|
${marker_begin}
|
||||||
# ProxMenux core customizations
|
# ProxMenux core customizations
|
||||||
export HISTTIMEFORMAT="%d/%m/%y %T "
|
export HISTTIMEFORMAT="%d/%m/%y %T "
|
||||||
@@ -815,7 +855,7 @@ EOF
|
|||||||
|
|
||||||
|
|
||||||
if ! grep -q "source /root/.bashrc" "$bash_profile" 2>/dev/null; then
|
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
|
fi
|
||||||
|
|
||||||
msg_ok "$(translate "Bashrc customization completed")"
|
msg_ok "$(translate "Bashrc customization completed")"
|
||||||
@@ -839,6 +879,7 @@ _update_existing_log2ram_auto() {
|
|||||||
local func_version="$1"
|
local func_version="$1"
|
||||||
local log2ram_bin=""
|
local log2ram_bin=""
|
||||||
local candidate resolved tmp_file
|
local candidate resolved tmp_file
|
||||||
|
pmx_journal_context "_update_existing_log2ram_auto" "$func_version"
|
||||||
|
|
||||||
msg_ok "$(translate "Log2RAM already registered — updating to latest configuration")"
|
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
|
if grep -q 'rsync -aAXv ' "$log2ram_bin" 2>/dev/null; then
|
||||||
[[ -e "${log2ram_bin}.proxmenux.bak" ]] || cp -a "$log2ram_bin" "${log2ram_bin}.proxmenux.bak"
|
[[ -e "${log2ram_bin}.proxmenux.bak" ]] || cp -a "$log2ram_bin" "${log2ram_bin}.proxmenux.bak"
|
||||||
tmp_file="$(mktemp "${log2ram_bin}.proxmenux.XXXXXX")" || return 1
|
sed 's/rsync -aAXv /rsync -aXv --no-acls /g' "$log2ram_bin" | pmx_write_file "$log2ram_bin"
|
||||||
cp -a "$log2ram_bin" "$tmp_file"
|
|
||||||
sed -i 's/rsync -aAXv /rsync -aXv --no-acls /g' "$tmp_file"
|
|
||||||
mv -f "$tmp_file" "$log2ram_bin"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if dpkg-query -W -f='${Status}' proxmox-backup-server 2>/dev/null \
|
if dpkg-query -W -f='${Status}' proxmox-backup-server 2>/dev/null \
|
||||||
@@ -884,7 +922,8 @@ _update_existing_log2ram_auto() {
|
|||||||
EOF
|
EOF
|
||||||
chmod 0644 "$tmp_file"
|
chmod 0644 "$tmp_file"
|
||||||
chown root:root "$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
|
tmp_file="$(mktemp /etc/cron.hourly/.proxmox-backup-logrotate.XXXXXX)" || return 1
|
||||||
cat > "$tmp_file" <<'EOF'
|
cat > "$tmp_file" <<'EOF'
|
||||||
@@ -893,7 +932,10 @@ EOF
|
|||||||
EOF
|
EOF
|
||||||
chmod 0755 "$tmp_file"
|
chmod 0755 "$tmp_file"
|
||||||
chown root:root "$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)")"
|
msg_ok "$(translate "PBS API log rotation configured (hourly, size-based)")"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -945,7 +987,10 @@ EOF
|
|||||||
chmod 0755 "$tmp_file"
|
chmod 0755 "$tmp_file"
|
||||||
chown root:root "$tmp_file"
|
chown root:root "$tmp_file"
|
||||||
bash -n "$tmp_file" || return 1
|
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
|
tmp_file="$(mktemp /etc/cron.d/.log2ram-auto-sync.XXXXXX)" || return 1
|
||||||
cat > "$tmp_file" <<'EOF'
|
cat > "$tmp_file" <<'EOF'
|
||||||
@@ -958,7 +1003,10 @@ MAILTO=""
|
|||||||
EOF
|
EOF
|
||||||
chmod 0644 "$tmp_file"
|
chmod 0644 "$tmp_file"
|
||||||
chown root:root "$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"
|
register_tool "log2ram" true "$func_version"
|
||||||
msg_success "$(translate "Log2RAM installation and configuration completed successfully.")"
|
msg_success "$(translate "Log2RAM installation and configuration completed successfully.")"
|
||||||
@@ -968,6 +1016,7 @@ EOF
|
|||||||
install_log2ram_auto() {
|
install_log2ram_auto() {
|
||||||
local FUNC_VERSION="1.5"
|
local FUNC_VERSION="1.5"
|
||||||
local existing_log2ram_bin=""
|
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.
|
# 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...")"
|
msg_info "$(translate "Cleaning previous Log2RAM installation...")"
|
||||||
|
|
||||||
systemctl stop log2ram log2ram-daily.timer >/dev/null 2>&1 || true
|
pmx_disable_service log2ram || true
|
||||||
systemctl disable log2ram log2ram-daily.timer >/dev/null 2>&1 || true
|
pmx_disable_service log2ram-daily.timer || true
|
||||||
|
|
||||||
rm -f /etc/cron.d/log2ram /etc/cron.d/log2ram-auto-sync \
|
local obsolete_path
|
||||||
/etc/cron.hourly/log2ram /etc/cron.daily/log2ram \
|
for obsolete_path in \
|
||||||
/etc/cron.weekly/log2ram /etc/cron.monthly/log2ram 2>/dev/null || true
|
/etc/cron.d/log2ram /etc/cron.d/log2ram-auto-sync \
|
||||||
rm -f /usr/local/bin/log2ram-check.sh /usr/local/bin/log2ram /usr/sbin/log2ram 2>/dev/null || true
|
/etc/cron.hourly/log2ram /etc/cron.daily/log2ram \
|
||||||
rm -f /etc/systemd/system/log2ram.service \
|
/etc/cron.weekly/log2ram /etc/cron.monthly/log2ram \
|
||||||
/etc/systemd/system/log2ram-daily.timer \
|
/usr/local/bin/log2ram-check.sh /usr/local/bin/log2ram /usr/sbin/log2ram \
|
||||||
/etc/systemd/system/log2ram-daily.service \
|
/etc/systemd/system/log2ram.service \
|
||||||
/etc/systemd/system/sysinit.target.wants/log2ram.service 2>/dev/null || true
|
/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 -rf /etc/systemd/system/log2ram.service.d 2>/dev/null || true
|
||||||
rm -f /etc/log2ram.conf* 2>/dev/null || true
|
rm -rf /var/log.hdd /tmp/log2ram 2>/dev/null || true
|
||||||
rm -rf /etc/logrotate.d/log2ram /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
|
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
|
systemctl daemon-reload >/dev/null 2>&1 || true
|
||||||
|
pmx_record_execution "Restart cron" "systemctl restart cron"
|
||||||
systemctl restart cron >/dev/null 2>&1 || true
|
systemctl restart cron >/dev/null 2>&1 || true
|
||||||
|
|
||||||
msg_ok "$(translate "Previous installation cleaned")"
|
msg_ok "$(translate "Previous installation cleaned")"
|
||||||
msg_info "$(translate "Installing Log2RAM from source...")"
|
msg_info "$(translate "Installing Log2RAM from source...")"
|
||||||
|
|
||||||
if ! command -v git >/dev/null 2>&1; then
|
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 update -qq >/dev/null 2>&1
|
||||||
apt-get install -y git >/dev/null 2>&1
|
pmx_install_pkg git
|
||||||
fi
|
fi
|
||||||
|
|
||||||
rm -rf /tmp/log2ram 2>/dev/null || true
|
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; }
|
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
|
if ! bash install.sh >>/tmp/log2ram_install.log 2>&1; then
|
||||||
msg_error "$(translate "Failed to run log2ram installer. Check /tmp/log2ram_install.log")"
|
msg_error "$(translate "Failed to run log2ram installer. Check /tmp/log2ram_install.log")"
|
||||||
return 1
|
return 1
|
||||||
@@ -1077,7 +1136,7 @@ install_log2ram_auto() {
|
|||||||
[[ -n "$_l2r_bin" && -f "$_l2r_bin" ]] || continue
|
[[ -n "$_l2r_bin" && -f "$_l2r_bin" ]] || continue
|
||||||
if grep -q 'rsync -aAXv ' "$_l2r_bin" 2>/dev/null; then
|
if grep -q 'rsync -aAXv ' "$_l2r_bin" 2>/dev/null; then
|
||||||
cp -a "$_l2r_bin" "${_l2r_bin}.proxmenux.bak"
|
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
|
fi
|
||||||
break
|
break
|
||||||
done
|
done
|
||||||
@@ -1088,7 +1147,7 @@ install_log2ram_auto() {
|
|||||||
if dpkg-query -W -f='${Status}' proxmox-backup-server 2>/dev/null \
|
if dpkg-query -W -f='${Status}' proxmox-backup-server 2>/dev/null \
|
||||||
| grep -q 'install ok installed'; then
|
| grep -q 'install ok installed'; then
|
||||||
mkdir -p /var/log/proxmox-backup/api 2>/dev/null || true
|
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 {
|
/var/log/proxmox-backup/api/access.log /var/log/proxmox-backup/api/auth.log {
|
||||||
size 20M
|
size 20M
|
||||||
rotate 3
|
rotate 3
|
||||||
@@ -1101,7 +1160,7 @@ install_log2ram_auto() {
|
|||||||
EOF
|
EOF
|
||||||
chmod 0644 /etc/logrotate.d/proxmox-backup-api
|
chmod 0644 /etc/logrotate.d/proxmox-backup-api
|
||||||
chown root:root /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
|
#!/bin/sh
|
||||||
/usr/sbin/logrotate /etc/logrotate.d/proxmox-backup-api >/dev/null 2>&1
|
/usr/sbin/logrotate /etc/logrotate.d/proxmox-backup-api >/dev/null 2>&1
|
||||||
EOF
|
EOF
|
||||||
@@ -1110,6 +1169,7 @@ EOF
|
|||||||
msg_ok "$(translate "PBS API log rotation configured (hourly, size-based)")"
|
msg_ok "$(translate "PBS API log rotation configured (hourly, size-based)")"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
pmx_record_execution "Reload systemd configuration" "systemctl daemon-reload"
|
||||||
systemctl daemon-reload >/dev/null 2>&1 || true
|
systemctl daemon-reload >/dev/null 2>&1 || true
|
||||||
|
|
||||||
if [[ -f /etc/log2ram.conf ]] && command -v log2ram >/dev/null 2>&1; then
|
if [[ -f /etc/log2ram.conf ]] && command -v log2ram >/dev/null 2>&1; then
|
||||||
@@ -1131,11 +1191,11 @@ EOF
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
msg_ok "$(translate "Detected RAM:") $RAM_SIZE_GB GB — $(translate "Log2RAM size set to:") $LOG2RAM_SIZE"
|
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)"
|
LOG2RAM_BIN="$(command -v log2ram || echo /usr/sbin/log2ram)"
|
||||||
|
|
||||||
cat > /etc/cron.d/log2ram <<EOF
|
pmx_write_file /etc/cron.d/log2ram <<EOF
|
||||||
# Log2RAM periodic sync - Created by ProxMenux
|
# Log2RAM periodic sync - Created by ProxMenux
|
||||||
SHELL=/bin/bash
|
SHELL=/bin/bash
|
||||||
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||||
@@ -1146,7 +1206,7 @@ EOF
|
|||||||
chown root:root /etc/cron.d/log2ram
|
chown root:root /etc/cron.d/log2ram
|
||||||
msg_ok "$(translate "Log2RAM write scheduled every") $CRON_HOURS $(translate "hour(s)")"
|
msg_ok "$(translate "Log2RAM write scheduled every") $CRON_HOURS $(translate "hour(s)")"
|
||||||
|
|
||||||
cat > /usr/local/bin/log2ram-check.sh <<'EOF'
|
pmx_write_file /usr/local/bin/log2ram-check.sh <<'EOF'
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Watch /var/log usage on Log2RAM's tmpfs and act at two thresholds:
|
# Watch /var/log usage on Log2RAM's tmpfs and act at two thresholds:
|
||||||
# > 80% → vacuum journald down to ~30% of SIZE, then log2ram write
|
# > 80% → vacuum journald down to ~30% of SIZE, then log2ram write
|
||||||
@@ -1196,7 +1256,7 @@ fi
|
|||||||
EOF
|
EOF
|
||||||
chmod +x /usr/local/bin/log2ram-check.sh
|
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
|
# Log2RAM auto-sync based on /var/log usage - Created by ProxMenux
|
||||||
SHELL=/bin/bash
|
SHELL=/bin/bash
|
||||||
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
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
|
chmod 0644 /etc/cron.d/log2ram-auto-sync
|
||||||
chown root:root /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
|
systemctl restart cron >/dev/null 2>&1 || true
|
||||||
msg_ok "$(translate "Auto-sync enabled when /var/log exceeds 80% of") $LOG2RAM_SIZE"
|
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
|
[ "$KEEP_MB" -lt 8 ] && KEEP_MB=8
|
||||||
|
|
||||||
|
|
||||||
sed -i '/^\[Journal\]/,$d' /etc/systemd/journald.conf 2>/dev/null || true
|
pmx_edit_file /etc/systemd/journald.conf '/^\[Journal\]/,$d' 2>/dev/null || true
|
||||||
tee -a /etc/systemd/journald.conf >/dev/null <<EOF
|
pmx_append_file /etc/systemd/journald.conf <<EOF
|
||||||
[Journal]
|
[Journal]
|
||||||
Storage=persistent
|
Storage=persistent
|
||||||
SplitMode=none
|
SplitMode=none
|
||||||
@@ -1267,8 +1328,10 @@ EOF
|
|||||||
#msg_ok "$(translate "Backup created:") /etc/systemd/journald.conf.bak.$(date +%Y%m%d-%H%M%S)"
|
#msg_ok "$(translate "Backup created:") /etc/systemd/journald.conf.bak.$(date +%Y%m%d-%H%M%S)"
|
||||||
msg_ok "$(translate "Journald configuration adjusted to") ${USE_MB}M (Log2RAM ${LOG2RAM_SIZE})"
|
msg_ok "$(translate "Journald configuration adjusted to") ${USE_MB}M (Log2RAM ${LOG2RAM_SIZE})"
|
||||||
|
|
||||||
|
pmx_record_execution "Reload systemd configuration" "systemctl daemon-reload"
|
||||||
systemctl daemon-reload >/dev/null 2>&1 || true
|
systemctl daemon-reload >/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")"
|
msg_error "$(translate "Log2RAM installation verification failed. Check /tmp/log2ram_install.log")"
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -34,6 +34,9 @@ TOOLS_JSON="$BASE_DIR/installed_tools.json"
|
|||||||
if [[ -f "$UTILS_FILE" ]]; then
|
if [[ -f "$UTILS_FILE" ]]; then
|
||||||
source "$UTILS_FILE"
|
source "$UTILS_FILE"
|
||||||
fi
|
fi
|
||||||
|
if [[ -f "$LOCAL_SCRIPTS/global/pmx_journal.sh" ]]; then
|
||||||
|
source "$LOCAL_SCRIPTS/global/pmx_journal.sh"
|
||||||
|
fi
|
||||||
|
|
||||||
load_language
|
load_language
|
||||||
initialize_cache
|
initialize_cache
|
||||||
@@ -53,18 +56,28 @@ register_tool() {
|
|||||||
################################################################
|
################################################################
|
||||||
|
|
||||||
uninstall_fastfetch() {
|
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
|
if ! command -v fastfetch &>/dev/null && [[ ! -f /usr/local/bin/fastfetch ]]; then
|
||||||
msg_warn "$(translate "Fastfetch is not installed.")"
|
msg_warn "$(translate "Fastfetch is not installed.")"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
msg_info2 "$(translate "Uninstalling Fastfetch...")"
|
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"
|
rm -rf "$HOME/.config/fastfetch"
|
||||||
|
pmx_record_execution "Remove shared Fastfetch files" "rm -rf /usr/local/share/fastfetch"
|
||||||
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
|
local profile_file
|
||||||
sed -i '/# BEGIN FASTFETCH/,/# END FASTFETCH/d' "$HOME/.bashrc"
|
for profile_file in "$HOME/.bashrc" "$HOME/.profile" /etc/profile; do
|
||||||
rm -f /etc/profile.d/fastfetch.sh /etc/update-motd.d/99-fastfetch
|
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
|
dpkg -r fastfetch &>/dev/null
|
||||||
|
|
||||||
msg_ok "$(translate "Fastfetch removed from system")"
|
msg_ok "$(translate "Fastfetch removed from system")"
|
||||||
@@ -74,18 +87,23 @@ uninstall_fastfetch() {
|
|||||||
################################################################
|
################################################################
|
||||||
|
|
||||||
uninstall_figurine() {
|
uninstall_figurine() {
|
||||||
|
local FUNC_VERSION="1.0"
|
||||||
|
pmx_journal_context "uninstall_figurine" "$FUNC_VERSION"
|
||||||
if ! command -v figurine &>/dev/null; then
|
if ! command -v figurine &>/dev/null; then
|
||||||
msg_warn "$(translate "Figurine is not installed.")"
|
msg_warn "$(translate "Figurine is not installed.")"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
msg_info2 "$(translate "Uninstalling Figurine...")"
|
msg_info2 "$(translate "Uninstalling Figurine...")"
|
||||||
rm -f /usr/local/bin/figurine
|
pmx_remove_file /usr/local/bin/figurine
|
||||||
rm -f /etc/profile.d/figurine.sh
|
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
|
local profile_file
|
||||||
sed -i '/# ProxMenux Figurine aliases and tools/,+20d' "$HOME/.bashrc" "$HOME/.profile" 2>/dev/null
|
for profile_file in "$HOME/.bashrc" "$HOME/.profile"; do
|
||||||
sed -i '/# BEGIN PROXMENUX ALIASES/,/# END PROXMENUX ALIASES/d' "$HOME/.bashrc" "$HOME/.profile" 2>/dev/null
|
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")"
|
msg_ok "$(translate "Figurine removed from system")"
|
||||||
register_tool "figurine" false
|
register_tool "figurine" false
|
||||||
@@ -95,15 +113,18 @@ uninstall_figurine() {
|
|||||||
################################################################
|
################################################################
|
||||||
|
|
||||||
uninstall_kexec() {
|
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
|
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.")"
|
msg_warn "$(translate "kexec-tools is not installed or already removed.")"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
msg_info2 "$(translate "Uninstalling kexec-tools and removing custom service...")"
|
msg_info2 "$(translate "Uninstalling kexec-tools and removing custom service...")"
|
||||||
systemctl disable --now kexec-pve.service &>/dev/null
|
pmx_disable_service kexec-pve.service
|
||||||
rm -f /etc/systemd/system/kexec-pve.service
|
pmx_remove_file /etc/systemd/system/kexec-pve.service
|
||||||
sed -i "/alias reboot-quick='systemctl kexec'/d" /root/.bash_profile
|
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
|
apt-get purge -y kexec-tools >/dev/null 2>&1
|
||||||
|
|
||||||
msg_ok "$(translate "kexec-tools and related settings removed")"
|
msg_ok "$(translate "kexec-tools and related settings removed")"
|
||||||
@@ -269,6 +290,8 @@ uninstall_rpc() {
|
|||||||
################################################################
|
################################################################
|
||||||
|
|
||||||
uninstall_motd() {
|
uninstall_motd() {
|
||||||
|
local FUNC_VERSION="1.0"
|
||||||
|
pmx_journal_context "uninstall_motd" "$FUNC_VERSION"
|
||||||
local state_file="$BASE_DIR/motd.state"
|
local state_file="$BASE_DIR/motd.state"
|
||||||
local original_file="$BASE_DIR/motd.original"
|
local original_file="$BASE_DIR/motd.original"
|
||||||
local motd_file="${PROXMENUX_MOTD_FILE:-/etc/motd}"
|
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")"
|
msg_error "$(translate "The original MOTD backup is unavailable; no changes were made")"
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
cp -a "$original_file" "$motd_file"
|
pmx_write_file "$motd_file" < "$original_file"
|
||||||
;;
|
;;
|
||||||
absent)
|
absent)
|
||||||
rm -f "$motd_file"
|
pmx_remove_file "$motd_file"
|
||||||
;;
|
;;
|
||||||
legacy-marker)
|
legacy-marker)
|
||||||
if [[ -f "$motd_file" ]]; then
|
if [[ -f "$motd_file" ]]; then
|
||||||
sed -i "\|^${custom_message}$|d" "$motd_file"
|
pmx_edit_file "$motd_file" "\|^${custom_message}$|d"
|
||||||
sed -i '/./,$!d' "$motd_file"
|
pmx_edit_file "$motd_file" '/./,$!d'
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
@@ -304,7 +327,8 @@ uninstall_motd() {
|
|||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
rm -f "$state_file" "$original_file"
|
pmx_remove_file "$state_file"
|
||||||
|
pmx_remove_file "$original_file"
|
||||||
register_tool "motd" false
|
register_tool "motd" false
|
||||||
msg_ok "$(translate "The original MOTD configuration has been restored")"
|
msg_ok "$(translate "The original MOTD configuration has been restored")"
|
||||||
}
|
}
|
||||||
@@ -380,10 +404,12 @@ uninstall_apt_languages() {
|
|||||||
################################################################
|
################################################################
|
||||||
|
|
||||||
uninstall_journald() {
|
uninstall_journald() {
|
||||||
|
local FUNC_VERSION="1.0"
|
||||||
|
pmx_journal_context "uninstall_journald" "$FUNC_VERSION"
|
||||||
msg_info "$(translate "Restoring default journald configuration...")"
|
msg_info "$(translate "Restoring default journald configuration...")"
|
||||||
|
|
||||||
# Restore 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.
|
# This file is part of systemd.
|
||||||
#
|
#
|
||||||
# systemd is free software; you can redistribute it and/or modify it
|
# systemd is free software; you can redistribute it and/or modify it
|
||||||
@@ -425,6 +451,7 @@ uninstall_journald() {
|
|||||||
#MaxLevelWall=emerg
|
#MaxLevelWall=emerg
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
|
pmx_record_execution "Restart systemd-journald" "systemctl restart systemd-journald.service"
|
||||||
systemctl restart systemd-journald.service >/dev/null 2>&1
|
systemctl restart systemd-journald.service >/dev/null 2>&1
|
||||||
|
|
||||||
msg_ok "$(translate "Default journald configuration restored")"
|
msg_ok "$(translate "Default journald configuration restored")"
|
||||||
@@ -452,37 +479,40 @@ uninstall_logrotate() {
|
|||||||
################################################################
|
################################################################
|
||||||
|
|
||||||
uninstall_system_limits() {
|
uninstall_system_limits() {
|
||||||
|
local FUNC_VERSION="1.0"
|
||||||
|
pmx_journal_context "uninstall_system_limits" "$FUNC_VERSION"
|
||||||
msg_info "$(translate "Removing system limits optimizations...")"
|
msg_info "$(translate "Removing system limits optimizations...")"
|
||||||
|
|
||||||
# Remove ProxMenux sysctl configurations
|
# Remove ProxMenux sysctl configurations
|
||||||
rm -f /etc/sysctl.d/99-maxwatches.conf
|
pmx_remove_file /etc/sysctl.d/99-maxwatches.conf
|
||||||
rm -f /etc/sysctl.d/99-maxkeys.conf
|
pmx_remove_file /etc/sysctl.d/99-maxkeys.conf
|
||||||
rm -f /etc/sysctl.d/99-swap.conf
|
pmx_remove_file /etc/sysctl.d/99-swap.conf
|
||||||
rm -f /etc/sysctl.d/99-fs.conf
|
pmx_remove_file /etc/sysctl.d/99-fs.conf
|
||||||
|
|
||||||
# Remove ProxMenux limits configuration
|
# 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)
|
# Remove systemd limits (restore defaults)
|
||||||
for file in /etc/systemd/system.conf /etc/systemd/user.conf; do
|
for file in /etc/systemd/system.conf /etc/systemd/user.conf; do
|
||||||
if [ -f "$file" ]; then
|
if [ -f "$file" ]; then
|
||||||
sed -i '/^DefaultLimitNOFILE=256000/d' "$file"
|
pmx_edit_file "$file" '/^DefaultLimitNOFILE=256000/d'
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
# Remove PAM limits
|
# Remove PAM limits
|
||||||
for file in /etc/pam.d/common-session /etc/pam.d/runuser-l; do
|
for file in /etc/pam.d/common-session /etc/pam.d/runuser-l; do
|
||||||
if [ -f "$file" ]; then
|
if [ -f "$file" ]; then
|
||||||
sed -i '/^session required pam_limits.so/d' "$file"
|
pmx_edit_file "$file" '/^session required pam_limits.so/d'
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
# Remove ulimit from profile
|
# Remove ulimit from profile
|
||||||
if [ -f /root/.profile ]; then
|
if [ -f /root/.profile ]; then
|
||||||
sed -i '/ulimit -n 256000/d' /root/.profile
|
pmx_edit_file /root/.profile '/ulimit -n 256000/d'
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Reload sysctl
|
# Reload sysctl
|
||||||
|
pmx_record_execution "Apply sysctl configuration" "sysctl --system"
|
||||||
sysctl --system >/dev/null 2>&1
|
sysctl --system >/dev/null 2>&1
|
||||||
|
|
||||||
msg_ok "$(translate "System limits optimizations removed")"
|
msg_ok "$(translate "System limits optimizations removed")"
|
||||||
@@ -553,26 +583,31 @@ uninstall_apt_ipv4() {
|
|||||||
################################################################
|
################################################################
|
||||||
|
|
||||||
uninstall_network_optimization() {
|
uninstall_network_optimization() {
|
||||||
|
local FUNC_VERSION="1.0"
|
||||||
|
pmx_journal_context "uninstall_network_optimization" "$FUNC_VERSION"
|
||||||
msg_info "$(translate "Removing network optimizations...")"
|
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"
|
local interfaces_file="/etc/network/interfaces"
|
||||||
if [ -f "$interfaces_file" ]; then
|
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
|
fi
|
||||||
|
|
||||||
rm -f /etc/sysctl.d/97-proxmenux-fwbr.conf \
|
pmx_remove_file /etc/sysctl.d/97-proxmenux-fwbr.conf
|
||||||
/etc/sysctl.d/98-proxmenux-rpf.conf
|
pmx_remove_file /etc/sysctl.d/98-proxmenux-rpf.conf
|
||||||
|
|
||||||
systemctl disable --now proxmenux-fwbr-tune.service >/dev/null 2>&1 || true
|
pmx_disable_service proxmenux-fwbr-tune.service || true
|
||||||
rm -f /etc/systemd/system/proxmenux-fwbr-tune.service
|
pmx_remove_file /etc/systemd/system/proxmenux-fwbr-tune.service
|
||||||
rm -f /usr/local/sbin/proxmenux-fwbr-tune
|
pmx_remove_file /usr/local/sbin/proxmenux-fwbr-tune
|
||||||
rm -f /etc/udev/rules.d/99-proxmenux-fwbr-tune.rules \
|
pmx_remove_file /etc/udev/rules.d/99-proxmenux-fwbr-tune.rules
|
||||||
/etc/udev/rules.d/99-zz-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
|
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
|
systemctl daemon-reload >/dev/null 2>&1 || true
|
||||||
|
pmx_record_execution "Apply sysctl configuration" "sysctl --system"
|
||||||
sysctl --system >/dev/null 2>&1 || true
|
sysctl --system >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
|
||||||
@@ -585,24 +620,27 @@ uninstall_network_optimization() {
|
|||||||
################################################################
|
################################################################
|
||||||
|
|
||||||
uninstall_bashrc_custom() {
|
uninstall_bashrc_custom() {
|
||||||
|
local FUNC_VERSION="1.0"
|
||||||
|
pmx_journal_context "uninstall_bashrc_custom" "$FUNC_VERSION"
|
||||||
msg_info "$(translate "Restoring original bashrc...")"
|
msg_info "$(translate "Restoring original bashrc...")"
|
||||||
|
|
||||||
# Restore original bashrc from backup
|
# Restore original bashrc from backup
|
||||||
if [ -f /root/.bashrc.bak ]; then
|
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")"
|
msg_ok "$(translate "Original bashrc restored")"
|
||||||
else
|
else
|
||||||
# Remove ProxMenux customizations manually
|
# Remove ProxMenux customizations manually
|
||||||
if [ -f /root/.bashrc ]; then
|
if [ -f /root/.bashrc ]; then
|
||||||
# Remove the customization block using the markers written by customize_bashrc
|
# 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
|
fi
|
||||||
msg_ok "$(translate "ProxMenux customizations removed from bashrc")"
|
msg_ok "$(translate "ProxMenux customizations removed from bashrc")"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Remove bash_profile source line if we added it
|
# Remove bash_profile source line if we added it
|
||||||
if [ -f /root/.bash_profile ]; then
|
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
|
fi
|
||||||
|
|
||||||
register_tool "bashrc_custom" false
|
register_tool "bashrc_custom" false
|
||||||
@@ -718,21 +756,23 @@ uninstall_persistent_network() {
|
|||||||
|
|
||||||
|
|
||||||
uninstall_vfio_iommu() {
|
uninstall_vfio_iommu() {
|
||||||
|
local FUNC_VERSION="1.0"
|
||||||
|
pmx_journal_context "uninstall_vfio_iommu" "$FUNC_VERSION"
|
||||||
msg_info2 "$(translate "Reverting IOMMU/VFIO configuration...")"
|
msg_info2 "$(translate "Reverting IOMMU/VFIO configuration...")"
|
||||||
NECESSARY_REBOOT=1
|
NECESSARY_REBOOT=1
|
||||||
|
|
||||||
# Remove VFIO modules from /etc/modules
|
# Remove VFIO modules from /etc/modules
|
||||||
local modules_file="/etc/modules"
|
local modules_file="/etc/modules"
|
||||||
if [ -f "$modules_file" ]; then
|
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")"
|
msg_ok "$(translate "VFIO modules removed from /etc/modules")"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Remove driver blacklists added by ProxMenux
|
# Remove driver blacklists added by ProxMenux
|
||||||
local blacklist_file="/etc/modprobe.d/blacklist.conf"
|
local blacklist_file="/etc/modprobe.d/blacklist.conf"
|
||||||
if [ -f "$blacklist_file" ]; then
|
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"
|
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" ] && rm -f "$blacklist_file"
|
[ ! -s "$blacklist_file" ] && pmx_remove_file "$blacklist_file"
|
||||||
msg_ok "$(translate "Driver blacklist entries removed")"
|
msg_ok "$(translate "Driver blacklist entries removed")"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -742,9 +782,12 @@ uninstall_vfio_iommu() {
|
|||||||
# systemd-boot / ZFS
|
# systemd-boot / ZFS
|
||||||
if grep -qE 'intel_iommu=on|amd_iommu=on|iommu=pt|pcie_acs_override=' "$cmdline_file"; then
|
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)"
|
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"
|
pmx_edit_file "$cmdline_file" -E 's/\b(intel_iommu=on|amd_iommu=on|iommu=pt|pcie_acs_override=[^ ]*)\b//g'
|
||||||
sed -i -E 's/[[:space:]]+/ /g; s/^ //; s/ $//' "$cmdline_file"
|
pmx_edit_file "$cmdline_file" -E 's/[[:space:]]+/ /g; s/^ //; s/ $//'
|
||||||
command -v proxmox-boot-tool >/dev/null 2>&1 && proxmox-boot-tool refresh >/dev/null 2>&1 || true
|
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")"
|
msg_ok "$(translate "IOMMU parameters removed from /etc/kernel/cmdline")"
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
@@ -752,9 +795,10 @@ uninstall_vfio_iommu() {
|
|||||||
local grub_file="/etc/default/grub"
|
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
|
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)"
|
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' \
|
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
|
update-grub >/dev/null 2>&1 || true
|
||||||
msg_ok "$(translate "IOMMU parameters removed from GRUB")"
|
msg_ok "$(translate "IOMMU parameters removed from GRUB")"
|
||||||
fi
|
fi
|
||||||
@@ -762,7 +806,9 @@ uninstall_vfio_iommu() {
|
|||||||
|
|
||||||
msg_info "$(translate 'Updating initramfs (this may take a minute)...')"
|
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
|
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
|
proxmox-boot-tool refresh >/dev/null 2>&1 || true
|
||||||
|
|
||||||
msg_ok "$(translate "IOMMU/VFIO configuration reverted")"
|
msg_ok "$(translate "IOMMU/VFIO configuration reverted")"
|
||||||
@@ -772,6 +818,8 @@ uninstall_vfio_iommu() {
|
|||||||
################################################################
|
################################################################
|
||||||
|
|
||||||
uninstall_amd_fixes() {
|
uninstall_amd_fixes() {
|
||||||
|
local FUNC_VERSION="1.0"
|
||||||
|
pmx_journal_context "uninstall_amd_fixes" "$FUNC_VERSION"
|
||||||
msg_info2 "$(translate "Reverting AMD (Ryzen/EPYC) fixes...")"
|
msg_info2 "$(translate "Reverting AMD (Ryzen/EPYC) fixes...")"
|
||||||
NECESSARY_REBOOT=1
|
NECESSARY_REBOOT=1
|
||||||
|
|
||||||
@@ -785,9 +833,10 @@ uninstall_amd_fixes() {
|
|||||||
return 1
|
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
|
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 || {
|
proxmox-boot-tool refresh >/dev/null 2>&1 || {
|
||||||
msg_error "$(translate "Failed to refresh boot configuration")"
|
msg_error "$(translate "Failed to refresh boot configuration")"
|
||||||
return 1
|
return 1
|
||||||
@@ -805,14 +854,15 @@ uninstall_amd_fixes() {
|
|||||||
return 1
|
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\" '
|
awk -F\" '
|
||||||
$1=="GRUB_CMDLINE_LINUX_DEFAULT=" {
|
$1=="GRUB_CMDLINE_LINUX_DEFAULT=" {
|
||||||
gsub(/[[:space:]]+/," ",$2); sub(/^ /,"",$2); sub(/ $/,"",$2)
|
gsub(/[[:space:]]+/," ",$2); sub(/^ /,"",$2); sub(/ $/,"",$2)
|
||||||
}1
|
}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 || {
|
update-grub >/dev/null 2>&1 || {
|
||||||
msg_error "$(translate "Failed to update GRUB configuration")"
|
msg_error "$(translate "Failed to update GRUB configuration")"
|
||||||
return 1
|
return 1
|
||||||
@@ -830,17 +880,19 @@ uninstall_amd_fixes() {
|
|||||||
msg_error "$(translate "Failed to backup $kvm_conf")"
|
msg_error "$(translate "Failed to backup $kvm_conf")"
|
||||||
return 1
|
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
|
if [[ ! -s "$kvm_conf" ]]; then
|
||||||
rm -f "$kvm_conf"
|
pmx_remove_file "$kvm_conf"
|
||||||
msg_ok "$(translate "Removed empty KVM configuration file")"
|
msg_ok "$(translate "Removed empty KVM configuration file")"
|
||||||
else
|
else
|
||||||
msg_ok "$(translate "Removed KVM MSR options from configuration")"
|
msg_ok "$(translate "Removed KVM MSR options from configuration")"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
pmx_record_execution "Regenerate initramfs" "update-initramfs -u -k all"
|
||||||
update-initramfs -u -k all >/dev/null 2>&1 || true
|
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
|
else
|
||||||
msg_ok "$(translate "KVM MSR options not present, nothing to revert")"
|
msg_ok "$(translate "KVM MSR options not present, nothing to revert")"
|
||||||
fi
|
fi
|
||||||
@@ -957,8 +1009,12 @@ uninstall_ceph() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
uninstall_ha() {
|
uninstall_ha() {
|
||||||
|
local FUNC_VERSION="1.0"
|
||||||
|
pmx_journal_context "uninstall_ha" "$FUNC_VERSION"
|
||||||
msg_info2 "$(translate 'Disabling High Availability services...')"
|
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)')"
|
msg_ok "$(translate 'HA services disabled (configs preserved)')"
|
||||||
register_tool "ha" false
|
register_tool "ha" false
|
||||||
}
|
}
|
||||||
@@ -1009,13 +1065,17 @@ uninstall_ovh_rtm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
uninstall_pigz() {
|
uninstall_pigz() {
|
||||||
|
local FUNC_VERSION="1.0"
|
||||||
|
pmx_journal_context "uninstall_pigz" "$FUNC_VERSION"
|
||||||
msg_info2 "$(translate 'Reverting pigz wrapper...')"
|
msg_info2 "$(translate 'Reverting pigz wrapper...')"
|
||||||
if [[ -f /bin/gzip.original ]]; then
|
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')"
|
msg_ok "$(translate 'Restored original /bin/gzip')"
|
||||||
fi
|
fi
|
||||||
rm -f /bin/pigzwrapper
|
pmx_remove_file /bin/pigzwrapper
|
||||||
sed -i 's/^pigz: 1/#pigz: 1/' /etc/vzdump.conf 2>/dev/null || true
|
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
|
apt-get purge -y pigz >/dev/null 2>&1 || true
|
||||||
msg_ok "$(translate 'pigz removed')"
|
msg_ok "$(translate 'pigz removed')"
|
||||||
register_tool "pigz" false
|
register_tool "pigz" false
|
||||||
@@ -1124,12 +1184,15 @@ uninstall_zfs_autotrim() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
uninstall_vzdump_speed() {
|
uninstall_vzdump_speed() {
|
||||||
|
local FUNC_VERSION="1.0"
|
||||||
|
pmx_journal_context "uninstall_vzdump_speed" "$FUNC_VERSION"
|
||||||
msg_info2 "$(translate 'Reverting vzdump speed tuning...')"
|
msg_info2 "$(translate 'Reverting vzdump speed tuning...')"
|
||||||
if [[ -f /etc/vzdump.conf.bak ]]; then
|
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')"
|
msg_ok "$(translate 'Restored original /etc/vzdump.conf from .bak')"
|
||||||
else
|
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)')"
|
msg_ok "$(translate 'Removed bwlimit/ionice tuning (no .bak found)')"
|
||||||
fi
|
fi
|
||||||
register_tool "vzdump_speed" false
|
register_tool "vzdump_speed" false
|
||||||
|
|||||||
@@ -48,6 +48,10 @@ if [[ -f "$UTILS_FILE" ]]; then
|
|||||||
source "$UTILS_FILE"
|
source "$UTILS_FILE"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [[ -f "$LOCAL_SCRIPTS/global/pmx_journal.sh" ]]; then
|
||||||
|
source "$LOCAL_SCRIPTS/global/pmx_journal.sh"
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ ! -f "$COMPONENTS_STATUS_FILE" ]]; then
|
if [[ ! -f "$COMPONENTS_STATUS_FILE" ]]; then
|
||||||
echo "{}" > "$COMPONENTS_STATUS_FILE"
|
echo "{}" > "$COMPONENTS_STATUS_FILE"
|
||||||
fi
|
fi
|
||||||
@@ -79,6 +83,9 @@ detect_fail2ban() {
|
|||||||
# Installation
|
# Installation
|
||||||
# ==========================================================
|
# ==========================================================
|
||||||
install_fail2ban() {
|
install_fail2ban() {
|
||||||
|
local FUNC_VERSION="1.0"
|
||||||
|
pmx_journal_context "install_fail2ban" "$FUNC_VERSION"
|
||||||
|
|
||||||
show_proxmenux_logo
|
show_proxmenux_logo
|
||||||
msg_title "$(translate "$SCRIPT_TITLE")"
|
msg_title "$(translate "$SCRIPT_TITLE")"
|
||||||
msg_info2 "$(translate "Installing and configuring Fail2Ban to protect Proxmox web interface and SSH...")"
|
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
|
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")"
|
msg_warn "$(translate "Debian repositories missing; creating default source file")"
|
||||||
local src="/etc/apt/sources.list.d/debian.sources"
|
local src="/etc/apt/sources.list.d/debian.sources"
|
||||||
cat > "$src" <<EOF
|
pmx_write_file "$src" <<EOF
|
||||||
Types: deb
|
Types: deb
|
||||||
URIs: http://deb.debian.org/debian
|
URIs: http://deb.debian.org/debian
|
||||||
Suites: ${deb_codename} ${deb_codename}-updates
|
Suites: ${deb_codename} ${deb_codename}-updates
|
||||||
@@ -107,7 +114,7 @@ EOF
|
|||||||
# Install Fail2Ban
|
# Install Fail2Ban
|
||||||
msg_info "$(translate "Installing Fail2Ban...")"
|
msg_info "$(translate "Installing Fail2Ban...")"
|
||||||
if ! DEBIAN_FRONTEND=noninteractive apt-get update -y >/dev/null 2>&1 || \
|
if ! DEBIAN_FRONTEND=noninteractive apt-get update -y >/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")"
|
msg_error "$(translate "Failed to install Fail2Ban")"
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
@@ -132,7 +139,7 @@ EOF
|
|||||||
|
|
||||||
# Create a drop-in so we don't break other Proxmox settings
|
# Create a drop-in so we don't break other Proxmox settings
|
||||||
mkdir -p /etc/systemd/journald.conf.d
|
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
|
# ProxMenux: Allow auth/info messages so Fail2Ban can detect SSH failures
|
||||||
# Proxmox default MaxLevelStore=warning drops PAM/SSH auth events
|
# Proxmox default MaxLevelStore=warning drops PAM/SSH auth events
|
||||||
[Journal]
|
[Journal]
|
||||||
@@ -148,6 +155,7 @@ JEOF
|
|||||||
esac
|
esac
|
||||||
|
|
||||||
if $journald_changed; then
|
if $journald_changed; then
|
||||||
|
pmx_record_execution "restart systemd-journald" "systemctl restart systemd-journald"
|
||||||
systemctl restart systemd-journald
|
systemctl restart systemd-journald
|
||||||
sleep 1
|
sleep 1
|
||||||
msg_ok "$(translate "journald restarted - auth messages will now be stored")"
|
msg_ok "$(translate "journald restarted - auth messages will now be stored")"
|
||||||
@@ -163,7 +171,7 @@ JEOF
|
|||||||
|
|
||||||
# -- Proxmox UI auth logger (pvedaemon) --
|
# -- Proxmox UI auth logger (pvedaemon) --
|
||||||
msg_info "$(translate "Creating Proxmox auth logger service...")"
|
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]
|
[Unit]
|
||||||
Description=Proxmox Auth Logger for Fail2Ban
|
Description=Proxmox Auth Logger for Fail2Ban
|
||||||
Documentation=https://github.com/MacRimi/ProxMenux
|
Documentation=https://github.com/MacRimi/ProxMenux
|
||||||
@@ -185,12 +193,12 @@ EOF
|
|||||||
chown root:adm /var/log/proxmox-auth.log 2>/dev/null || true
|
chown root:adm /var/log/proxmox-auth.log 2>/dev/null || true
|
||||||
|
|
||||||
systemctl daemon-reload
|
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")"
|
msg_ok "$(translate "Proxmox auth logger service created and started")"
|
||||||
|
|
||||||
# -- SSH auth logger --
|
# -- SSH auth logger --
|
||||||
msg_info "$(translate "Creating SSH auth logger service...")"
|
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]
|
[Unit]
|
||||||
Description=SSH Auth Logger for Fail2Ban
|
Description=SSH Auth Logger for Fail2Ban
|
||||||
Documentation=https://github.com/MacRimi/ProxMenux
|
Documentation=https://github.com/MacRimi/ProxMenux
|
||||||
@@ -212,13 +220,13 @@ EOF
|
|||||||
chown root:adm /var/log/ssh-auth.log 2>/dev/null || true
|
chown root:adm /var/log/ssh-auth.log 2>/dev/null || true
|
||||||
|
|
||||||
systemctl daemon-reload
|
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")"
|
msg_ok "$(translate "SSH auth logger service created and started")"
|
||||||
|
|
||||||
# Configure Proxmox filter
|
# Configure Proxmox filter
|
||||||
mkdir -p /etc/fail2ban/filter.d /etc/fail2ban/jail.d
|
mkdir -p /etc/fail2ban/filter.d /etc/fail2ban/jail.d
|
||||||
msg_info "$(translate "Configuring Proxmox filter...")"
|
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]
|
[Definition]
|
||||||
# The proxmox-auth-logger service writes journal lines to /var/log/proxmox-auth.log
|
# 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
|
# 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)
|
# Configure Proxmox jail (file-based backend)
|
||||||
msg_info "$(translate "Configuring Proxmox jail...")"
|
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]
|
[proxmox]
|
||||||
enabled = true
|
enabled = true
|
||||||
port = 8006
|
port = 8006
|
||||||
@@ -248,7 +256,7 @@ EOF
|
|||||||
# This reads from a file written directly by the Flask app (not syslog/journal),
|
# 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.
|
# so it uses a datepattern that matches Python's logging format.
|
||||||
msg_info "$(translate "Configuring ProxMenux Monitor filter...")"
|
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]
|
[Definition]
|
||||||
failregex = ^.*proxmenux-auth: authentication failure; rhost=<HOST> user=.*$
|
failregex = ^.*proxmenux-auth: authentication failure; rhost=<HOST> user=.*$
|
||||||
ignoreregex =
|
ignoreregex =
|
||||||
@@ -259,7 +267,7 @@ EOF
|
|||||||
# Configure ProxMenux Monitor jail (port 8008 + http/https for reverse proxy)
|
# 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.
|
# Uses backend=auto with logpath because the Flask app writes directly to this file.
|
||||||
msg_info "$(translate "Configuring ProxMenux Monitor jail...")"
|
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]
|
[proxmenux]
|
||||||
enabled = true
|
enabled = true
|
||||||
port = 8008,http,https
|
port = 8008,http,https
|
||||||
@@ -289,7 +297,7 @@ EOF
|
|||||||
|
|
||||||
# Configure global settings and SSH jail
|
# Configure global settings and SSH jail
|
||||||
msg_info "$(translate "Configuring global Fail2Ban settings and SSH jail...")"
|
msg_info "$(translate "Configuring global Fail2Ban settings and SSH jail...")"
|
||||||
cat > /etc/fail2ban/jail.local <<EOF
|
pmx_write_file /etc/fail2ban/jail.local <<EOF
|
||||||
[DEFAULT]
|
[DEFAULT]
|
||||||
ignoreip = 127.0.0.1/8 ::1
|
ignoreip = 127.0.0.1/8 ::1
|
||||||
ignoreself = true
|
ignoreself = true
|
||||||
@@ -325,18 +333,19 @@ EOF
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Store original value in our config directory
|
# Store original value in our config directory
|
||||||
echo "$original_max_auth" > "${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...")"
|
msg_info "$(translate "Hardening SSH: setting MaxAuthTries to 3...")"
|
||||||
if grep -qi '^MaxAuthTries' "$sshd_config"; then
|
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
|
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
|
else
|
||||||
echo "MaxAuthTries 3" >> "$sshd_config"
|
echo "MaxAuthTries 3" | pmx_append_file "$sshd_config"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Reload SSH to apply the change (reload, not restart, to keep existing sessions)
|
# 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
|
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})")"
|
msg_ok "$(translate "SSH MaxAuthTries set to 3 (original: ${original_max_auth})")"
|
||||||
fi
|
fi
|
||||||
@@ -344,7 +353,9 @@ EOF
|
|||||||
# Enable and restart the service (restart ensures new jails are loaded
|
# Enable and restart the service (restart ensures new jails are loaded
|
||||||
# even if fail2ban was already running from a previous install)
|
# even if fail2ban was already running from a previous install)
|
||||||
systemctl daemon-reload
|
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
|
systemctl restart fail2ban >/dev/null 2>&1
|
||||||
sleep 3
|
sleep 3
|
||||||
|
|
||||||
@@ -372,29 +383,32 @@ EOF
|
|||||||
# Uninstall
|
# Uninstall
|
||||||
# ==========================================================
|
# ==========================================================
|
||||||
uninstall_fail2ban() {
|
uninstall_fail2ban() {
|
||||||
|
local FUNC_VERSION="1.0"
|
||||||
|
pmx_journal_context "uninstall_fail2ban" "$FUNC_VERSION"
|
||||||
|
|
||||||
show_proxmenux_logo
|
show_proxmenux_logo
|
||||||
msg_title "$(translate "$SCRIPT_TITLE")"
|
msg_title "$(translate "$SCRIPT_TITLE")"
|
||||||
msg_info2 "$(translate "Removing Fail2Ban...")"
|
msg_info2 "$(translate "Removing Fail2Ban...")"
|
||||||
|
|
||||||
systemctl stop fail2ban 2>/dev/null || true
|
pmx_disable_service fail2ban 2>/dev/null || true
|
||||||
systemctl disable fail2ban 2>/dev/null || true
|
|
||||||
|
|
||||||
# Stop and remove the auth logger services
|
# Stop and remove the auth logger services
|
||||||
systemctl stop proxmox-auth-logger.service 2>/dev/null || true
|
pmx_disable_service proxmox-auth-logger.service 2>/dev/null || true
|
||||||
systemctl disable proxmox-auth-logger.service 2>/dev/null || true
|
pmx_remove_file /etc/systemd/system/proxmox-auth-logger.service
|
||||||
rm -f /etc/systemd/system/proxmox-auth-logger.service
|
pmx_disable_service ssh-auth-logger.service 2>/dev/null || true
|
||||||
systemctl stop ssh-auth-logger.service 2>/dev/null || true
|
pmx_remove_file /etc/systemd/system/ssh-auth-logger.service
|
||||||
systemctl disable ssh-auth-logger.service 2>/dev/null || true
|
|
||||||
rm -f /etc/systemd/system/ssh-auth-logger.service
|
|
||||||
systemctl daemon-reload 2>/dev/null || true
|
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
|
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
|
DEBIAN_FRONTEND=noninteractive apt-get purge -y fail2ban >/dev/null 2>&1
|
||||||
rm -f /etc/fail2ban/jail.d/proxmox.conf
|
pmx_remove_file /etc/fail2ban/jail.d/proxmox.conf
|
||||||
rm -f /etc/fail2ban/jail.d/proxmenux.conf
|
pmx_remove_file /etc/fail2ban/jail.d/proxmenux.conf
|
||||||
rm -f /etc/fail2ban/filter.d/proxmox.conf
|
pmx_remove_file /etc/fail2ban/filter.d/proxmox.conf
|
||||||
rm -f /etc/fail2ban/filter.d/proxmenux.conf
|
pmx_remove_file /etc/fail2ban/filter.d/proxmenux.conf
|
||||||
rm -f /etc/fail2ban/jail.local
|
pmx_remove_file /etc/fail2ban/jail.local
|
||||||
|
|
||||||
# ── Restore SSH MaxAuthTries to original value ──
|
# ── Restore SSH MaxAuthTries to original value ──
|
||||||
local sshd_config="/etc/ssh/sshd_config"
|
local sshd_config="/etc/ssh/sshd_config"
|
||||||
@@ -405,17 +419,19 @@ uninstall_fail2ban() {
|
|||||||
if [[ -n "$original_val" ]]; then
|
if [[ -n "$original_val" ]]; then
|
||||||
msg_info "$(translate "Restoring SSH MaxAuthTries to ${original_val}...")"
|
msg_info "$(translate "Restoring SSH MaxAuthTries to ${original_val}...")"
|
||||||
if grep -qi '^MaxAuthTries' "$sshd_config"; then
|
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
|
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
|
systemctl reload sshd 2>/dev/null || systemctl reload ssh 2>/dev/null || true
|
||||||
msg_ok "$(translate "SSH MaxAuthTries restored to ${original_val}")"
|
msg_ok "$(translate "SSH MaxAuthTries restored to ${original_val}")"
|
||||||
fi
|
fi
|
||||||
rm -f "$backup_file"
|
pmx_remove_file "$backup_file"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Remove journald drop-in and restore original log level
|
# Remove journald drop-in and restore original log level
|
||||||
if [[ -f /etc/systemd/journald.conf.d/proxmenux-loglevel.conf ]]; then
|
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
|
systemctl restart systemd-journald 2>/dev/null || true
|
||||||
msg_ok "$(translate "journald log level restored")"
|
msg_ok "$(translate "journald log level restored")"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -46,6 +46,10 @@ if [[ -f "$UTILS_FILE" ]]; then
|
|||||||
source "$UTILS_FILE"
|
source "$UTILS_FILE"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [[ -f "$LOCAL_SCRIPTS/global/pmx_journal.sh" ]]; then
|
||||||
|
source "$LOCAL_SCRIPTS/global/pmx_journal.sh"
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ ! -f "$COMPONENTS_STATUS_FILE" ]]; then
|
if [[ ! -f "$COMPONENTS_STATUS_FILE" ]]; then
|
||||||
echo "{}" > "$COMPONENTS_STATUS_FILE"
|
echo "{}" > "$COMPONENTS_STATUS_FILE"
|
||||||
fi
|
fi
|
||||||
@@ -80,6 +84,9 @@ detect_lynis() {
|
|||||||
# Installation
|
# Installation
|
||||||
# ==========================================================
|
# ==========================================================
|
||||||
install_lynis() {
|
install_lynis() {
|
||||||
|
local FUNC_VERSION="1.0"
|
||||||
|
pmx_journal_context "install_lynis" "$FUNC_VERSION"
|
||||||
|
|
||||||
show_proxmenux_logo
|
show_proxmenux_logo
|
||||||
msg_title "$(translate "$SCRIPT_TITLE")"
|
msg_title "$(translate "$SCRIPT_TITLE")"
|
||||||
msg_info2 "$(translate "Installing latest Lynis security scan tool...")"
|
msg_info2 "$(translate "Installing latest Lynis security scan tool...")"
|
||||||
@@ -91,7 +98,7 @@ install_lynis() {
|
|||||||
if ! command -v git >/dev/null 2>&1; then
|
if ! command -v git >/dev/null 2>&1; then
|
||||||
msg_info "$(translate "Installing Git as a prerequisite...")"
|
msg_info "$(translate "Installing Git as a prerequisite...")"
|
||||||
apt-get update -qq >/dev/null 2>&1
|
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")"
|
msg_ok "$(translate "Git installed")"
|
||||||
else
|
else
|
||||||
msg_error "$(translate "Could not install Git — Lynis cannot be cloned. Run 'apt-get install git' manually.")"
|
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
|
# Remove old installation if present
|
||||||
if [[ -d /opt/lynis ]]; then
|
if [[ -d /opt/lynis ]]; then
|
||||||
msg_info "$(translate "Removing previous Lynis installation...")"
|
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
|
rm -rf /opt/lynis >/dev/null 2>&1
|
||||||
msg_ok "$(translate "Previous installation removed")"
|
msg_ok "$(translate "Previous installation removed")"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Clone from GitHub
|
# Clone from GitHub
|
||||||
msg_info "$(translate "Cloning Lynis 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
|
if git clone --quiet https://github.com/CISOfy/lynis.git /opt/lynis >/dev/null 2>&1; then
|
||||||
# Create wrapper script
|
# Create wrapper script
|
||||||
cat << 'EOF' > /usr/local/bin/lynis
|
pmx_write_file /usr/local/bin/lynis << 'EOF'
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
cd /opt/lynis && ./lynis "$@"
|
cd /opt/lynis && ./lynis "$@"
|
||||||
EOF
|
EOF
|
||||||
@@ -144,6 +153,9 @@ EOF
|
|||||||
# Update
|
# Update
|
||||||
# ==========================================================
|
# ==========================================================
|
||||||
update_lynis() {
|
update_lynis() {
|
||||||
|
local FUNC_VERSION="1.0"
|
||||||
|
pmx_journal_context "update_lynis" "$FUNC_VERSION"
|
||||||
|
|
||||||
show_proxmenux_logo
|
show_proxmenux_logo
|
||||||
msg_title "$(translate "$SCRIPT_TITLE")"
|
msg_title "$(translate "$SCRIPT_TITLE")"
|
||||||
msg_info2 "$(translate "Updating Lynis to the latest version...")"
|
msg_info2 "$(translate "Updating Lynis to the latest version...")"
|
||||||
@@ -151,6 +163,7 @@ update_lynis() {
|
|||||||
if [[ -d /opt/lynis/.git ]]; then
|
if [[ -d /opt/lynis/.git ]]; then
|
||||||
cd /opt/lynis
|
cd /opt/lynis
|
||||||
msg_info "$(translate "Pulling latest changes from GitHub...")"
|
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
|
if git pull --quiet >/dev/null 2>&1; then
|
||||||
local version
|
local version
|
||||||
version=$(/usr/local/bin/lynis show version 2>/dev/null)
|
version=$(/usr/local/bin/lynis show version 2>/dev/null)
|
||||||
@@ -174,6 +187,9 @@ update_lynis() {
|
|||||||
# Run Audit
|
# Run Audit
|
||||||
# ==========================================================
|
# ==========================================================
|
||||||
run_audit() {
|
run_audit() {
|
||||||
|
local FUNC_VERSION="1.0"
|
||||||
|
pmx_journal_context "run_audit" "$FUNC_VERSION"
|
||||||
|
|
||||||
show_proxmenux_logo
|
show_proxmenux_logo
|
||||||
msg_title "$(translate "$SCRIPT_TITLE")"
|
msg_title "$(translate "$SCRIPT_TITLE")"
|
||||||
msg_info2 "$(translate "Running Lynis security audit...")"
|
msg_info2 "$(translate "Running Lynis security audit...")"
|
||||||
@@ -185,6 +201,7 @@ run_audit() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Run the audit
|
# Run the audit
|
||||||
|
pmx_record_execution "run Lynis system audit" "$LYNIS_CMD audit system --no-colors"
|
||||||
"$LYNIS_CMD" audit system --no-colors 2>&1
|
"$LYNIS_CMD" audit system --no-colors 2>&1
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
@@ -197,12 +214,16 @@ run_audit() {
|
|||||||
# Uninstall
|
# Uninstall
|
||||||
# ==========================================================
|
# ==========================================================
|
||||||
uninstall_lynis() {
|
uninstall_lynis() {
|
||||||
|
local FUNC_VERSION="1.0"
|
||||||
|
pmx_journal_context "uninstall_lynis" "$FUNC_VERSION"
|
||||||
|
|
||||||
show_proxmenux_logo
|
show_proxmenux_logo
|
||||||
msg_title "$(translate "$SCRIPT_TITLE")"
|
msg_title "$(translate "$SCRIPT_TITLE")"
|
||||||
msg_info2 "$(translate "Removing Lynis...")"
|
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 -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" '{}'
|
update_component_status "lynis" "removed" "" "security" '{}'
|
||||||
|
|
||||||
|
|||||||
+52
-11
@@ -54,6 +54,10 @@ elif [[ -f "$LOCAL_SCRIPTS_DEFAULT/global/disk_ops_helpers.sh" ]]; then
|
|||||||
source "$LOCAL_SCRIPTS_DEFAULT/global/disk_ops_helpers.sh"
|
source "$LOCAL_SCRIPTS_DEFAULT/global/disk_ops_helpers.sh"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [[ -f "$LOCAL_SCRIPTS/global/pmx_journal.sh" ]]; then
|
||||||
|
source "$LOCAL_SCRIPTS/global/pmx_journal.sh"
|
||||||
|
fi
|
||||||
|
|
||||||
load_language
|
load_language
|
||||||
initialize_cache
|
initialize_cache
|
||||||
|
|
||||||
@@ -471,6 +475,8 @@ format_and_mount_disk() {
|
|||||||
local disk="$1"
|
local disk="$1"
|
||||||
local mount_path="$2"
|
local mount_path="$2"
|
||||||
local filesystem="$3"
|
local filesystem="$3"
|
||||||
|
local FUNC_VERSION="1.0"
|
||||||
|
pmx_journal_context "format_and_mount_disk" "$FUNC_VERSION"
|
||||||
|
|
||||||
# Final confirmation before any destructive operation
|
# Final confirmation before any destructive operation
|
||||||
local disk_size
|
local disk_size
|
||||||
@@ -480,6 +486,8 @@ format_and_mount_disk() {
|
|||||||
14 80; then
|
14 80; then
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
pmx_record_execution "format disk ${disk} as ${filesystem} for ${mount_path}" \
|
||||||
|
"wipe disk, create partition and format as ${filesystem}"
|
||||||
show_proxmenux_logo
|
show_proxmenux_logo
|
||||||
if [[ "$MODE_PVESM" -eq 1 && "$MODE_FSTAB" -eq 1 ]]; then
|
if [[ "$MODE_PVESM" -eq 1 && "$MODE_FSTAB" -eq 1 ]]; then
|
||||||
msg_title "$(translate "Add Local Disk (Proxmox storage + host mount)")"
|
msg_title "$(translate "Add Local Disk (Proxmox storage + host mount)")"
|
||||||
@@ -544,6 +552,8 @@ mount_disk_permanently() {
|
|||||||
local partition="$1"
|
local partition="$1"
|
||||||
local mount_path="$2"
|
local mount_path="$2"
|
||||||
local filesystem="$3"
|
local filesystem="$3"
|
||||||
|
local FUNC_VERSION="1.0"
|
||||||
|
pmx_journal_context "mount_disk_permanently" "$FUNC_VERSION"
|
||||||
|
|
||||||
if [[ "$filesystem" == "zfs" ]]; then
|
if [[ "$filesystem" == "zfs" ]]; then
|
||||||
if ! zpool list "$STORAGE_ID" >/dev/null 2>&1; 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_ok "$(translate "Mount point created")"
|
||||||
|
|
||||||
msg_info "$(translate "Mounting disk...")"
|
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
|
if ! mount -t "$filesystem" "$partition" "$mount_path" 2>/dev/null; then
|
||||||
msg_error "$(translate "Failed to mount disk")"
|
msg_error "$(translate "Failed to mount disk")"
|
||||||
return 1
|
return 1
|
||||||
@@ -574,13 +586,13 @@ mount_disk_permanently() {
|
|||||||
|
|
||||||
if [[ -n "$disk_uuid" ]]; then
|
if [[ -n "$disk_uuid" ]]; then
|
||||||
# Remove any existing fstab entry for this UUID or mount point
|
# Remove any existing fstab entry for this UUID or mount point
|
||||||
sed -i "\|UUID=$disk_uuid|d" /etc/fstab
|
pmx_edit_file /etc/fstab "\|UUID=$disk_uuid|d"
|
||||||
sed -i "\|[[:space:]]${mount_path}[[:space:]]|d" /etc/fstab
|
pmx_edit_file /etc/fstab "\|[[:space:]]${mount_path}[[:space:]]|d"
|
||||||
echo "UUID=$disk_uuid $mount_path $filesystem defaults,nofail 0 2" >> /etc/fstab
|
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")"
|
msg_ok "$(translate "Added to /etc/fstab using UUID")"
|
||||||
else
|
else
|
||||||
sed -i "\|[[:space:]]${mount_path}[[:space:]]|d" /etc/fstab
|
pmx_edit_file /etc/fstab "\|[[:space:]]${mount_path}[[:space:]]|d"
|
||||||
echo "$partition $mount_path $filesystem defaults,nofail 0 2" >> /etc/fstab
|
echo "$partition $mount_path $filesystem defaults,nofail 0 2" | pmx_append_file /etc/fstab
|
||||||
msg_ok "$(translate "Added to /etc/fstab using device path")"
|
msg_ok "$(translate "Added to /etc/fstab using device path")"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -604,10 +616,14 @@ mount_disk_permanently() {
|
|||||||
# but the change is harmless: existing owners keep their access.
|
# but the change is harmless: existing owners keep their access.
|
||||||
_apply_lxc_bind_mount_perms() {
|
_apply_lxc_bind_mount_perms() {
|
||||||
local mount_path="$1"
|
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
|
[[ "${MODE_FSTAB:-0}" -eq 1 ]] || return 0
|
||||||
[[ -d "$mount_path" ]] || return 0
|
[[ -d "$mount_path" ]] || return 0
|
||||||
|
|
||||||
msg_info "$(translate "Applying host permissions for unprivileged LXC bind-mounts...")"
|
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
|
chmod o+rwx "$mount_path" 2>/dev/null || true
|
||||||
if command -v setfacl >/dev/null 2>&1; then
|
if command -v setfacl >/dev/null 2>&1; then
|
||||||
setfacl -m o::rwx "$mount_path" 2>/dev/null || true
|
setfacl -m o::rwx "$mount_path" 2>/dev/null || true
|
||||||
@@ -619,6 +635,8 @@ _apply_lxc_bind_mount_perms() {
|
|||||||
mount_existing_disk() {
|
mount_existing_disk() {
|
||||||
local disk="$1"
|
local disk="$1"
|
||||||
local mount_path="$2"
|
local mount_path="$2"
|
||||||
|
local FUNC_VERSION="1.0"
|
||||||
|
pmx_journal_context "mount_existing_disk" "$FUNC_VERSION"
|
||||||
|
|
||||||
local existing_fs
|
local existing_fs
|
||||||
existing_fs=$(blkid -s TYPE -o value "$disk" 2>/dev/null || true)
|
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_ok "$(translate "Mount point created")"
|
||||||
|
|
||||||
msg_info "$(translate "Mounting existing") $existing_fs $(translate "filesystem...")"
|
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
|
if ! mount "$disk" "$mount_path" 2>/dev/null; then
|
||||||
msg_error "$(translate "Failed to mount disk")"
|
msg_error "$(translate "Failed to mount disk")"
|
||||||
return 1
|
return 1
|
||||||
@@ -645,9 +664,9 @@ mount_existing_disk() {
|
|||||||
local disk_uuid
|
local disk_uuid
|
||||||
disk_uuid=$(blkid -s UUID -o value "$disk" 2>/dev/null)
|
disk_uuid=$(blkid -s UUID -o value "$disk" 2>/dev/null)
|
||||||
if [[ -n "$disk_uuid" ]]; then
|
if [[ -n "$disk_uuid" ]]; then
|
||||||
sed -i "\|UUID=$disk_uuid|d" /etc/fstab
|
pmx_edit_file /etc/fstab "\|UUID=$disk_uuid|d"
|
||||||
sed -i "\|[[:space:]]${mount_path}[[:space:]]|d" /etc/fstab
|
pmx_edit_file /etc/fstab "\|[[:space:]]${mount_path}[[:space:]]|d"
|
||||||
echo "UUID=$disk_uuid $mount_path $existing_fs defaults,nofail 0 2" >> /etc/fstab
|
echo "UUID=$disk_uuid $mount_path $existing_fs defaults,nofail 0 2" | pmx_append_file /etc/fstab
|
||||||
msg_ok "$(translate "Added to /etc/fstab")"
|
msg_ok "$(translate "Added to /etc/fstab")"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -664,6 +683,8 @@ add_proxmox_dir_storage() {
|
|||||||
local content="$3"
|
local content="$3"
|
||||||
local storage_kind="dir"
|
local storage_kind="dir"
|
||||||
local pool_name="$storage_id"
|
local pool_name="$storage_id"
|
||||||
|
local FUNC_VERSION="1.0"
|
||||||
|
pmx_journal_context "add_proxmox_dir_storage" "$FUNC_VERSION"
|
||||||
|
|
||||||
if [[ "${FILESYSTEM:-}" == "zfs" ]]; then
|
if [[ "${FILESYSTEM:-}" == "zfs" ]]; then
|
||||||
storage_kind="zfspool"
|
storage_kind="zfspool"
|
||||||
@@ -681,6 +702,7 @@ add_proxmox_dir_storage() {
|
|||||||
8 60; then
|
8 60; then
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
pmx_record_execution "remove existing Proxmox storage ${storage_id}" "pvesm remove ${storage_id}"
|
||||||
pvesm remove "$storage_id" 2>/dev/null || true
|
pvesm remove "$storage_id" 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -688,12 +710,16 @@ add_proxmox_dir_storage() {
|
|||||||
local pvesm_output
|
local pvesm_output
|
||||||
local add_ok=false
|
local add_ok=false
|
||||||
if [[ "$storage_kind" == "zfspool" ]]; then
|
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" \
|
if pvesm_output=$(pvesm add zfspool "$storage_id" \
|
||||||
--pool "$pool_name" \
|
--pool "$pool_name" \
|
||||||
--content "$content" 2>&1); then
|
--content "$content" 2>&1); then
|
||||||
add_ok=true
|
add_ok=true
|
||||||
fi
|
fi
|
||||||
else
|
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" \
|
if pvesm_output=$(pvesm add dir "$storage_id" \
|
||||||
--path "$path" \
|
--path "$path" \
|
||||||
--content "$content" 2>&1); then
|
--content "$content" 2>&1); then
|
||||||
@@ -742,6 +768,9 @@ add_proxmox_dir_storage() {
|
|||||||
# ==========================================================
|
# ==========================================================
|
||||||
|
|
||||||
add_disk_to_proxmox() {
|
add_disk_to_proxmox() {
|
||||||
|
local FUNC_VERSION="1.0"
|
||||||
|
pmx_journal_context "add_disk_to_proxmox" "$FUNC_VERSION"
|
||||||
|
|
||||||
# Check required tools
|
# Check required tools
|
||||||
for tool in parted mkfs.ext4 mkfs.xfs blkid lsblk sgdisk; do
|
for tool in parted mkfs.ext4 mkfs.xfs blkid lsblk sgdisk; do
|
||||||
if ! command -v "$tool" >/dev/null 2>&1; then
|
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_title "$(translate "Add Local Disk as Proxmox Storage")"
|
||||||
msg_info "$(translate "Installing required tools...")"
|
msg_info "$(translate "Installing required tools...")"
|
||||||
apt-get update &>/dev/null
|
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
|
stop_spinner
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
@@ -990,6 +1019,8 @@ view_disk_storages() {
|
|||||||
|
|
||||||
_remove_pvesm_storage() {
|
_remove_pvesm_storage() {
|
||||||
local storage_id="$1"
|
local storage_id="$1"
|
||||||
|
local FUNC_VERSION="1.0"
|
||||||
|
pmx_journal_context "_remove_pvesm_storage" "$FUNC_VERSION"
|
||||||
local path pool content stype
|
local path pool content stype
|
||||||
path=$(get_storage_config "$storage_id" | awk '$1 == "path" {print $2}')
|
path=$(get_storage_config "$storage_id" | awk '$1 == "path" {print $2}')
|
||||||
pool=$(get_storage_config "$storage_id" | awk '$1 == "pool" {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
|
# Step 1: Remove from Proxmox
|
||||||
msg_info "$(translate "Removing storage 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
|
if ! pvesm remove "$storage_id" 2>/dev/null; then
|
||||||
msg_error "$(translate "Failed to remove storage from Proxmox.")"
|
msg_error "$(translate "Failed to remove storage from Proxmox.")"
|
||||||
echo ""
|
echo ""
|
||||||
@@ -1029,6 +1061,7 @@ _remove_pvesm_storage() {
|
|||||||
# Step 2: Unmount if mounted (dir-backed storages only)
|
# Step 2: Unmount if mounted (dir-backed storages only)
|
||||||
if [[ -n "$path" ]] && mountpoint -q "$path" 2>/dev/null; then
|
if [[ -n "$path" ]] && mountpoint -q "$path" 2>/dev/null; then
|
||||||
msg_info "$(translate "Unmounting disk...")"
|
msg_info "$(translate "Unmounting disk...")"
|
||||||
|
pmx_record_execution "unmount disk from ${path}" "umount ${path}"
|
||||||
if umount "$path" 2>/dev/null; then
|
if umount "$path" 2>/dev/null; then
|
||||||
msg_ok "$(translate "Disk unmounted from") $path"
|
msg_ok "$(translate "Disk unmounted from") $path"
|
||||||
else
|
else
|
||||||
@@ -1045,7 +1078,9 @@ _remove_pvesm_storage() {
|
|||||||
msg_info "$(translate "Removing from /etc/fstab...")"
|
msg_info "$(translate "Removing from /etc/fstab...")"
|
||||||
local tmp
|
local tmp
|
||||||
tmp=$(mktemp)
|
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
|
systemctl daemon-reload 2>/dev/null || true
|
||||||
msg_ok "$(translate "Removed from /etc/fstab")"
|
msg_ok "$(translate "Removed from /etc/fstab")"
|
||||||
fi
|
fi
|
||||||
@@ -1053,6 +1088,7 @@ _remove_pvesm_storage() {
|
|||||||
# Step 3b: Export ZFS pool if applicable
|
# Step 3b: Export ZFS pool if applicable
|
||||||
if [[ -n "$pool" ]] && zpool list "$pool" >/dev/null 2>&1; then
|
if [[ -n "$pool" ]] && zpool list "$pool" >/dev/null 2>&1; then
|
||||||
msg_info "$(translate "Exporting ZFS pool...") $pool"
|
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
|
if zpool export "$pool" 2>/dev/null; then
|
||||||
msg_ok "$(translate "ZFS pool exported:") $pool"
|
msg_ok "$(translate "ZFS pool exported:") $pool"
|
||||||
else
|
else
|
||||||
@@ -1069,6 +1105,7 @@ _remove_pvesm_storage() {
|
|||||||
read -r
|
read -r
|
||||||
echo ""
|
echo ""
|
||||||
msg_warn "$(translate "Rebooting the system...")"
|
msg_warn "$(translate "Rebooting the system...")"
|
||||||
|
pmx_record_execution "reboot host after removing storage ${storage_id}" "reboot"
|
||||||
reboot
|
reboot
|
||||||
else
|
else
|
||||||
echo ""
|
echo ""
|
||||||
@@ -1082,6 +1119,8 @@ _remove_pvesm_storage() {
|
|||||||
|
|
||||||
_remove_fstab_entry() {
|
_remove_fstab_entry() {
|
||||||
local mount_point="$1"
|
local mount_point="$1"
|
||||||
|
local FUNC_VERSION="1.0"
|
||||||
|
pmx_journal_context "_remove_fstab_entry" "$FUNC_VERSION"
|
||||||
|
|
||||||
local fs fstype
|
local fs fstype
|
||||||
while IFS= read -r line; do
|
while IFS= read -r line; do
|
||||||
@@ -1122,6 +1161,7 @@ _remove_fstab_entry() {
|
|||||||
|
|
||||||
if $mounted; then
|
if $mounted; then
|
||||||
msg_info "$(translate "Unmounting") $mount_point..."
|
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
|
if umount "$mount_point" 2>/dev/null; then
|
||||||
msg_ok "$(translate "Unmounted successfully")"
|
msg_ok "$(translate "Unmounted successfully")"
|
||||||
else
|
else
|
||||||
@@ -1133,7 +1173,8 @@ _remove_fstab_entry() {
|
|||||||
local tmp
|
local tmp
|
||||||
tmp=$(mktemp)
|
tmp=$(mktemp)
|
||||||
awk -v mp="$mount_point" '$2 != mp' /etc/fstab > "$tmp"
|
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
|
systemctl daemon-reload 2>/dev/null || true
|
||||||
msg_ok "$(translate "Removed from /etc/fstab")"
|
msg_ok "$(translate "Removed from /etc/fstab")"
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ if [[ -f "$UTILS_FILE" ]]; then
|
|||||||
source "$UTILS_FILE"
|
source "$UTILS_FILE"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [[ -f "$LOCAL_SCRIPTS/global/pmx_journal.sh" ]]; then
|
||||||
|
source "$LOCAL_SCRIPTS/global/pmx_journal.sh"
|
||||||
|
fi
|
||||||
|
|
||||||
load_language
|
load_language
|
||||||
initialize_cache
|
initialize_cache
|
||||||
|
|
||||||
@@ -56,16 +60,20 @@ get_storage_config() {
|
|||||||
# ==========================================================
|
# ==========================================================
|
||||||
|
|
||||||
ensure_iscsi_tools() {
|
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
|
if ! command -v iscsiadm >/dev/null 2>&1; then
|
||||||
msg_info "$(translate "Installing iSCSI initiator tools...")"
|
msg_info "$(translate "Installing iSCSI initiator tools...")"
|
||||||
apt-get update &>/dev/null
|
apt-get update &>/dev/null
|
||||||
apt-get install -y open-iscsi &>/dev/null
|
pmx_install_pkg open-iscsi
|
||||||
systemctl enable --now iscsid 2>/dev/null || true
|
pmx_enable_service iscsid 2>/dev/null || true
|
||||||
msg_ok "$(translate "iSCSI tools installed")"
|
msg_ok "$(translate "iSCSI tools installed")"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if ! systemctl is-active --quiet iscsid 2>/dev/null; then
|
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
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,6 +225,9 @@ configure_iscsi_storage() {
|
|||||||
# ==========================================================
|
# ==========================================================
|
||||||
|
|
||||||
add_proxmox_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 storage_id="$1"
|
||||||
local portal="$2"
|
local portal="$2"
|
||||||
local target="$3"
|
local target="$3"
|
||||||
@@ -233,6 +244,8 @@ add_proxmox_iscsi_storage() {
|
|||||||
8 60 --title "$(translate "Storage Exists")"; then
|
8 60 --title "$(translate "Storage Exists")"; then
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
pmx_record_execution "remove existing Proxmox iSCSI storage ${storage_id}" \
|
||||||
|
"pvesm remove ${storage_id}"
|
||||||
pvesm remove "$storage_id" 2>/dev/null || true
|
pvesm remove "$storage_id" 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -240,6 +253,8 @@ add_proxmox_iscsi_storage() {
|
|||||||
msg_info "$(translate "Adding iSCSI storage to Proxmox...")"
|
msg_info "$(translate "Adding iSCSI storage to Proxmox...")"
|
||||||
|
|
||||||
local pvesm_output pvesm_result
|
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" \
|
pvesm_output=$(pvesm add iscsi "$storage_id" \
|
||||||
--portal "$portal" \
|
--portal "$portal" \
|
||||||
--target "$target" \
|
--target "$target" \
|
||||||
@@ -359,6 +374,9 @@ view_iscsi_storages() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
remove_iscsi_storage() {
|
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
|
if ! command -v pvesm >/dev/null 2>&1; then
|
||||||
dialog --backtitle "ProxMenux" --title "$(translate "Error")" \
|
dialog --backtitle "ProxMenux" --title "$(translate "Error")" \
|
||||||
--msgbox "\n$(translate "pvesm not found.")" 8 60
|
--msgbox "\n$(translate "pvesm not found.")" 8 60
|
||||||
@@ -400,6 +418,7 @@ remove_iscsi_storage() {
|
|||||||
show_proxmenux_logo
|
show_proxmenux_logo
|
||||||
msg_title "$(translate "Remove iSCSI Storage")"
|
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
|
if pvesm remove "$SELECTED" 2>/dev/null; then
|
||||||
msg_ok "$(translate "Storage") $SELECTED $(translate "removed successfully from Proxmox.")"
|
msg_ok "$(translate "Storage") $SELECTED $(translate "removed successfully from Proxmox.")"
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ if [[ -f "$UTILS_FILE" ]]; then
|
|||||||
source "$UTILS_FILE"
|
source "$UTILS_FILE"
|
||||||
fi
|
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"
|
SHARE_COMMON_FILE="$LOCAL_SCRIPTS/global/share-common.func"
|
||||||
if ! source "$SHARE_COMMON_FILE" 2>/dev/null; then
|
if ! source "$SHARE_COMMON_FILE" 2>/dev/null; then
|
||||||
msg_error "$(translate "Could not load shared functions. Script cannot continue.")"
|
msg_error "$(translate "Could not load shared functions. Script cannot continue.")"
|
||||||
@@ -64,9 +68,14 @@ fi
|
|||||||
|
|
||||||
lsm_apply_multi_unpriv_permissions() {
|
lsm_apply_multi_unpriv_permissions() {
|
||||||
local dir="$1"
|
local dir="$1"
|
||||||
|
local FUNC_VERSION="1.0"
|
||||||
|
pmx_journal_context "lsm_apply_multi_unpriv_permissions" "$FUNC_VERSION"
|
||||||
|
|
||||||
[[ -z "$dir" || ! -d "$dir" ]] && return 1
|
[[ -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.
|
# root:root ownership — no new group needed.
|
||||||
chown root:root "$dir" 2>/dev/null || true
|
chown root:root "$dir" 2>/dev/null || true
|
||||||
|
|
||||||
@@ -224,6 +233,9 @@ lsm_select_host_mount_point_dialog() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
create_shared_directory() {
|
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"
|
lsm_select_host_mount_point_dialog "$(translate "Select Shared Directory Location")" "shared"
|
||||||
[[ -z "$LSM_SELECTED_MOUNT_POINT" ]] && return
|
[[ -z "$LSM_SELECTED_MOUNT_POINT" ]] && return
|
||||||
SHARED_DIR="$LSM_SELECTED_MOUNT_POINT"
|
SHARED_DIR="$LSM_SELECTED_MOUNT_POINT"
|
||||||
@@ -231,6 +243,7 @@ create_shared_directory() {
|
|||||||
show_proxmenux_logo
|
show_proxmenux_logo
|
||||||
msg_title "$(translate "Create Shared Directory")"
|
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
|
if ! mkdir -p "$SHARED_DIR" 2>/dev/null; then
|
||||||
msg_error "$(translate "Failed to create directory:") $SHARED_DIR"
|
msg_error "$(translate "Failed to create directory:") $SHARED_DIR"
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
@@ -30,6 +30,10 @@
|
|||||||
BASE_DIR="/usr/local/share/proxmenux"
|
BASE_DIR="/usr/local/share/proxmenux"
|
||||||
source "$BASE_DIR/utils.sh"
|
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
|
load_language
|
||||||
initialize_cache
|
initialize_cache
|
||||||
|
|
||||||
@@ -289,6 +293,8 @@ select_lxc_container() {
|
|||||||
select_container_mount_point() {
|
select_container_mount_point() {
|
||||||
local ctid="$1"
|
local ctid="$1"
|
||||||
local host_dir="$2"
|
local host_dir="$2"
|
||||||
|
local FUNC_VERSION="1.0"
|
||||||
|
pmx_journal_context "select_container_mount_point" "$FUNC_VERSION"
|
||||||
local base_name
|
local base_name
|
||||||
base_name=$(basename "$host_dir")
|
base_name=$(basename "$host_dir")
|
||||||
|
|
||||||
@@ -333,6 +339,8 @@ select_container_mount_point() {
|
|||||||
local ct_status
|
local ct_status
|
||||||
ct_status=$(pct status "$ctid" 2>/dev/null | awk '{print $2}')
|
ct_status=$(pct status "$ctid" 2>/dev/null | awk '{print $2}')
|
||||||
if [[ "$ct_status" == "running" ]]; then
|
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
|
pct exec "$ctid" -- mkdir -p "$mount_point" 2>/dev/null
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -367,6 +375,8 @@ add_bind_mount() {
|
|||||||
local ctid="$1"
|
local ctid="$1"
|
||||||
local host_path="$2"
|
local host_path="$2"
|
||||||
local ct_path="$3"
|
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
|
if [[ ! "$ctid" =~ ^[0-9]+$ || -z "$host_path" || -z "$ct_path" ]]; then
|
||||||
msg_error "$(translate "Invalid parameters for bind mount")"
|
msg_error "$(translate "Invalid parameters for bind mount")"
|
||||||
@@ -383,6 +393,8 @@ add_bind_mount() {
|
|||||||
mpidx=$(get_next_mp_index "$ctid")
|
mpidx=$(get_next_mp_index "$ctid")
|
||||||
|
|
||||||
local result
|
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)
|
result=$(pct set "$ctid" -mp${mpidx} "$host_path,mp=$ct_path,shared=1,backup=0" 2>&1)
|
||||||
|
|
||||||
if [[ $? -eq 0 ]]; then
|
if [[ $? -eq 0 ]]; then
|
||||||
@@ -451,6 +463,9 @@ view_mount_points() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
remove_mount_point() {
|
remove_mount_point() {
|
||||||
|
local FUNC_VERSION="1.0"
|
||||||
|
pmx_journal_context "remove_mount_point" "$FUNC_VERSION"
|
||||||
|
|
||||||
show_proxmenux_logo
|
show_proxmenux_logo
|
||||||
msg_title "$(translate "Remove LXC Mount Point")"
|
msg_title "$(translate "Remove LXC Mount Point")"
|
||||||
|
|
||||||
@@ -532,6 +547,8 @@ $(translate "Proceed with removal")?"
|
|||||||
msg_title "$(translate "Remove LXC Mount Point")"
|
msg_title "$(translate "Remove LXC Mount Point")"
|
||||||
msg_info "$(translate "Removing mount point") $selected_mp $(translate "from container") $container_id..."
|
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
|
if pct set "$container_id" --delete "$selected_mp" 2>/dev/null; then
|
||||||
msg_ok "$(translate "Mount point removed successfully")"
|
msg_ok "$(translate "Mount point removed successfully")"
|
||||||
|
|
||||||
@@ -541,6 +558,8 @@ $(translate "Proceed with removal")?"
|
|||||||
echo ""
|
echo ""
|
||||||
if whiptail --yesno "$(translate "Container is running. Restart to apply changes?")" 8 60; then
|
if whiptail --yesno "$(translate "Container is running. Restart to apply changes?")" 8 60; then
|
||||||
msg_info "$(translate "Restarting container...")"
|
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
|
if pct reboot "$container_id"; then
|
||||||
sleep 3
|
sleep 3
|
||||||
msg_ok "$(translate "Container restarted successfully")"
|
msg_ok "$(translate "Container restarted successfully")"
|
||||||
@@ -573,6 +592,8 @@ $(translate "Proceed with removal")?"
|
|||||||
lmm_fix_cifs_access() {
|
lmm_fix_cifs_access() {
|
||||||
local host_dir="$1"
|
local host_dir="$1"
|
||||||
local is_unprivileged="$2"
|
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).
|
# 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.
|
# 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
|
18 84 3>&1 1>&2 2>&3; then
|
||||||
|
|
||||||
msg_info "$(translate "Remounting CIFS share with open permissions...")"
|
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 && \
|
if umount "$host_dir" 2>/dev/null && \
|
||||||
mount -t cifs "$mount_src" "$host_dir" -o "$new_opts" 2>/dev/null; then
|
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")"
|
msg_ok "$(translate "CIFS share remounted — LXC containers can now read and write")"
|
||||||
|
|
||||||
# Update fstab if the mount is there
|
# Update fstab if the mount is there
|
||||||
if grep -qF "$host_dir" /etc/fstab 2>/dev/null; then
|
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")"
|
msg_ok "$(translate "/etc/fstab updated — permissions will persist after reboot")"
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
@@ -639,6 +663,8 @@ lmm_fix_nfs_access() {
|
|||||||
local host_dir="$1"
|
local host_dir="$1"
|
||||||
local is_unprivileged="$2"
|
local is_unprivileged="$2"
|
||||||
local uid_shift="${3:-100000}"
|
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.
|
# NFS: the host cannot override server-side permissions.
|
||||||
# BUT: if the server exports with root_squash (default), we can check
|
# 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?")" \
|
$(translate "Apply fix now?")" \
|
||||||
18 84 3>&1 1>&2 2>&3; then
|
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
|
if chmod 1777 "$host_dir" 2>/dev/null; then
|
||||||
msg_ok "$(translate "NFS directory permissions set — containers should now be able to write")"
|
msg_ok "$(translate "NFS directory permissions set — containers should now be able to write")"
|
||||||
else
|
else
|
||||||
@@ -716,6 +744,8 @@ $(translate "You can still mount this share for READ-ONLY access.")" \
|
|||||||
lmm_offer_host_permissions() {
|
lmm_offer_host_permissions() {
|
||||||
local host_dir="$1"
|
local host_dir="$1"
|
||||||
local is_unprivileged="$2"
|
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
|
# Privileged containers: UID 0 inside = UID 0 on host — always accessible
|
||||||
[[ "$is_unprivileged" != "1" ]] && return 0
|
[[ "$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.")" \
|
$(translate "(Only the host directory is modified. Nothing inside the container is changed.")" \
|
||||||
16 80 3>&1 1>&2 2>&3; then
|
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
|
chmod o+rwx "$host_dir" 2>/dev/null || true
|
||||||
if command -v setfacl >/dev/null 2>&1; then
|
if command -v setfacl >/dev/null 2>&1; then
|
||||||
setfacl -m o::rwx "$host_dir" 2>/dev/null || true
|
setfacl -m o::rwx "$host_dir" 2>/dev/null || true
|
||||||
@@ -798,6 +830,8 @@ _lmm_verify_writable() {
|
|||||||
# ==========================================================
|
# ==========================================================
|
||||||
|
|
||||||
mount_host_directory_minimal() {
|
mount_host_directory_minimal() {
|
||||||
|
local FUNC_VERSION="1.0"
|
||||||
|
|
||||||
# Step 1: Select container
|
# Step 1: Select container
|
||||||
local container_id
|
local container_id
|
||||||
container_id=$(select_lxc_container)
|
container_id=$(select_lxc_container)
|
||||||
@@ -900,10 +934,13 @@ $(translate "Proceed")?"
|
|||||||
# bind-mount is supposed to spare them.
|
# bind-mount is supposed to spare them.
|
||||||
local ct_status
|
local ct_status
|
||||||
ct_status=$(pct status "$container_id" 2>/dev/null | awk '{print $2}')
|
ct_status=$(pct status "$container_id" 2>/dev/null | awk '{print $2}')
|
||||||
|
pmx_journal_context "mount_host_directory_minimal" "$FUNC_VERSION"
|
||||||
echo ""
|
echo ""
|
||||||
if [[ "$ct_status" == "running" ]]; then
|
if [[ "$ct_status" == "running" ]]; then
|
||||||
if whiptail --yesno "$(translate "Restart container to activate mount?")" 8 60; then
|
if whiptail --yesno "$(translate "Restart container to activate mount?")" 8 60; then
|
||||||
msg_info "$(translate "Restarting container...")"
|
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
|
if pct reboot "$container_id"; then
|
||||||
sleep 5
|
sleep 5
|
||||||
msg_ok "$(translate "Container restarted successfully")"
|
msg_ok "$(translate "Container restarted successfully")"
|
||||||
@@ -918,6 +955,8 @@ $(translate "Proceed")?"
|
|||||||
# declines, fall back to the informational line.
|
# 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
|
if whiptail --yesno "$(translate "Container is stopped. Start it now to verify the mount works?")" 8 70; then
|
||||||
msg_info "$(translate "Starting container...")"
|
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
|
if pct start "$container_id"; then
|
||||||
sleep 5
|
sleep 5
|
||||||
msg_ok "$(translate "Container started successfully")"
|
msg_ok "$(translate "Container started successfully")"
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ if [[ -f "$UTILS_FILE" ]]; then
|
|||||||
source "$UTILS_FILE"
|
source "$UTILS_FILE"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [[ -f "$LOCAL_SCRIPTS/global/pmx_journal.sh" ]]; then
|
||||||
|
source "$LOCAL_SCRIPTS/global/pmx_journal.sh"
|
||||||
|
fi
|
||||||
|
|
||||||
# Load shared functions
|
# Load shared functions
|
||||||
SHARE_COMMON_FILE="$LOCAL_SCRIPTS/global/share-common.func"
|
SHARE_COMMON_FILE="$LOCAL_SCRIPTS/global/share-common.func"
|
||||||
if ! source "$SHARE_COMMON_FILE" 2>/dev/null; then
|
if ! source "$SHARE_COMMON_FILE" 2>/dev/null; then
|
||||||
@@ -44,6 +48,8 @@ select_privileged_lxc
|
|||||||
|
|
||||||
|
|
||||||
install_nfs_client() {
|
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
|
if pct exec "$CTID" -- dpkg -s nfs-common &>/dev/null; then
|
||||||
return 0
|
return 0
|
||||||
@@ -65,6 +71,8 @@ install_nfs_client() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
msg_info "$(translate "Installing NFS client packages...")"
|
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
|
if ! pct exec "$CTID" -- apt-get update >/dev/null 2>&1; then
|
||||||
msg_error "$(translate "Failed to update package list.")"
|
msg_error "$(translate "Failed to update package list.")"
|
||||||
msg_success "$(translate "Press Enter to return to menu...")"
|
msg_success "$(translate "Press Enter to return to menu...")"
|
||||||
@@ -99,6 +107,9 @@ install_nfs_client() {
|
|||||||
|
|
||||||
|
|
||||||
discover_nfs_servers() {
|
discover_nfs_servers() {
|
||||||
|
local FUNC_VERSION="1.0"
|
||||||
|
pmx_journal_context "discover_nfs_servers" "$FUNC_VERSION"
|
||||||
|
|
||||||
show_proxmenux_logo
|
show_proxmenux_logo
|
||||||
msg_title "$(translate "Mount NFS Client in LXC")"
|
msg_title "$(translate "Mount NFS Client in LXC")"
|
||||||
msg_info "$(translate "Scanning network for NFS servers...")"
|
msg_info "$(translate "Scanning network for NFS servers...")"
|
||||||
@@ -110,7 +121,7 @@ discover_nfs_servers() {
|
|||||||
|
|
||||||
|
|
||||||
if ! which nmap >/dev/null 2>&1; then
|
if ! which nmap >/dev/null 2>&1; then
|
||||||
apt-get install -y nmap &>/dev/null
|
pmx_install_pkg nmap
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
||||||
@@ -367,6 +378,7 @@ validate_export_exists() {
|
|||||||
|
|
||||||
|
|
||||||
mount_nfs_share() {
|
mount_nfs_share() {
|
||||||
|
local FUNC_VERSION="1.0"
|
||||||
# Step 0: Install NFS client first
|
# Step 0: Install NFS client first
|
||||||
install_nfs_client || return
|
install_nfs_client || return
|
||||||
|
|
||||||
@@ -395,7 +407,9 @@ mount_nfs_share() {
|
|||||||
# Step 4: Configure mount options
|
# Step 4: Configure mount options
|
||||||
configure_mount_options || return
|
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
|
if ! pct exec "$CTID" -- test -d "$MOUNT_POINT"; then
|
||||||
@@ -432,9 +446,9 @@ mount_nfs_share() {
|
|||||||
|
|
||||||
# Add to fstab if permanent
|
# Add to fstab if permanent
|
||||||
if [[ "$PERMANENT_MOUNT" == "true" ]]; then
|
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"
|
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.")"
|
msg_ok "$(translate "Added to /etc/fstab for permanent mounting.")"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -543,6 +557,9 @@ view_nfs_mounts() {
|
|||||||
|
|
||||||
|
|
||||||
unmount_nfs_share() {
|
unmount_nfs_share() {
|
||||||
|
local FUNC_VERSION="1.0"
|
||||||
|
pmx_journal_context "unmount_nfs_share" "$FUNC_VERSION"
|
||||||
|
|
||||||
# Get current NFS mounts
|
# Get current NFS mounts
|
||||||
MOUNTS=$(pct exec "$CTID" -- mount | grep -E "type nfs|:.*on.*nfs" | awk '{print $3}' | sort -u || true)
|
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)
|
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")"
|
msg_title "$(translate "Unmount NFS Share")"
|
||||||
|
|
||||||
# Remove from fstab
|
# 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.")"
|
msg_ok "$(translate "Removed from /etc/fstab.")"
|
||||||
|
|
||||||
# Actually unmount it now (the previous version only edited fstab,
|
# Actually unmount it now (the previous version only edited fstab,
|
||||||
@@ -598,6 +617,9 @@ unmount_nfs_share() {
|
|||||||
|
|
||||||
|
|
||||||
test_nfs_connectivity() {
|
test_nfs_connectivity() {
|
||||||
|
local FUNC_VERSION="1.0"
|
||||||
|
pmx_journal_context "test_nfs_connectivity" "$FUNC_VERSION"
|
||||||
|
|
||||||
show_proxmenux_logo
|
show_proxmenux_logo
|
||||||
msg_title "$(translate "Test NFS Connectivity")"
|
msg_title "$(translate "Test NFS Connectivity")"
|
||||||
|
|
||||||
@@ -621,6 +643,8 @@ test_nfs_connectivity() {
|
|||||||
else
|
else
|
||||||
echo "$(translate "RPC Bind Service: STOPPED")"
|
echo "$(translate "RPC Bind Service: STOPPED")"
|
||||||
msg_warn "$(translate "Starting rpcbind service...")"
|
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
|
pct exec "$CTID" -- systemctl start rpcbind 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ if [[ -f "$UTILS_FILE" ]]; then
|
|||||||
source "$UTILS_FILE"
|
source "$UTILS_FILE"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [[ -f "$LOCAL_SCRIPTS/global/pmx_journal.sh" ]]; then
|
||||||
|
source "$LOCAL_SCRIPTS/global/pmx_journal.sh"
|
||||||
|
fi
|
||||||
|
|
||||||
load_language
|
load_language
|
||||||
initialize_cache
|
initialize_cache
|
||||||
|
|
||||||
@@ -64,6 +68,9 @@ get_storage_config() {
|
|||||||
# ==========================================================
|
# ==========================================================
|
||||||
|
|
||||||
discover_nfs_servers() {
|
discover_nfs_servers() {
|
||||||
|
local FUNC_VERSION="1.0"
|
||||||
|
pmx_journal_context "discover_nfs_servers" "$FUNC_VERSION"
|
||||||
|
|
||||||
show_proxmenux_logo
|
show_proxmenux_logo
|
||||||
msg_title "$(translate "Add NFS Share as Proxmox Storage")"
|
msg_title "$(translate "Add NFS Share as Proxmox Storage")"
|
||||||
msg_info "$(translate "Scanning network for NFS servers...")"
|
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
|
NETWORK=$(echo "$HOST_IP" | cut -d. -f1-3).0/24
|
||||||
|
|
||||||
if ! which nmap >/dev/null 2>&1; then
|
if ! which nmap >/dev/null 2>&1; then
|
||||||
apt-get install -y nmap &>/dev/null
|
pmx_install_pkg nmap
|
||||||
fi
|
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)
|
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 server="$2"
|
||||||
local export="$3"
|
local export="$3"
|
||||||
local content="${4:-import}"
|
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...")"
|
msg_info "$(translate "Starting Proxmox storage integration...")"
|
||||||
|
|
||||||
@@ -267,11 +276,15 @@ add_proxmox_nfs_storage() {
|
|||||||
8 60 --title "$(translate "Storage Exists")"; then
|
8 60 --title "$(translate "Storage Exists")"; then
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
pmx_record_execution "remove existing Proxmox NFS storage ${storage_id}" \
|
||||||
|
"pvesm remove ${storage_id}"
|
||||||
pvesm remove "$storage_id" 2>/dev/null || true
|
pvesm remove "$storage_id" 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
msg_ok "$(translate "Storage ID is available")"
|
msg_ok "$(translate "Storage ID is available")"
|
||||||
msg_info "$(translate "NFS storage adding in progress...")"
|
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" \
|
if pvesm_output=$(pvesm add nfs "$storage_id" \
|
||||||
--server "$server" \
|
--server "$server" \
|
||||||
--export "$export" \
|
--export "$export" \
|
||||||
@@ -384,6 +397,8 @@ mount_nfs_via_fstab() {
|
|||||||
local mount_path="$3"
|
local mount_path="$3"
|
||||||
local mount_opts="$4"
|
local mount_opts="$4"
|
||||||
local replace="$5"
|
local replace="$5"
|
||||||
|
local FUNC_VERSION="1.0"
|
||||||
|
pmx_journal_context "mount_nfs_via_fstab" "$FUNC_VERSION"
|
||||||
|
|
||||||
msg_info "$(translate "Preparing host mount...")"
|
msg_info "$(translate "Preparing host mount...")"
|
||||||
|
|
||||||
@@ -396,6 +411,8 @@ mount_nfs_via_fstab() {
|
|||||||
msg_ok "$(translate "Mount point ready:") $mount_path"
|
msg_ok "$(translate "Mount point ready:") $mount_path"
|
||||||
|
|
||||||
msg_info "$(translate "Mounting NFS share...")"
|
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
|
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.")"
|
msg_error "$(translate "Failed to mount NFS share on host.")"
|
||||||
return 1
|
return 1
|
||||||
@@ -418,11 +435,12 @@ mount_nfs_via_fstab() {
|
|||||||
|
|
||||||
# Persist in /etc/fstab.
|
# Persist in /etc/fstab.
|
||||||
if [[ "$replace" == "1" ]]; then
|
if [[ "$replace" == "1" ]]; then
|
||||||
sed -i "\|[[:space:]]${mount_path}[[:space:]]|d" /etc/fstab
|
pmx_edit_file /etc/fstab "\|[[:space:]]${mount_path}[[:space:]]|d"
|
||||||
fi
|
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.")"
|
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
|
systemctl daemon-reload 2>/dev/null || true
|
||||||
|
|
||||||
echo -e ""
|
echo -e ""
|
||||||
@@ -480,10 +498,13 @@ select_mount_methods() {
|
|||||||
# ==========================================================
|
# ==========================================================
|
||||||
|
|
||||||
mount_nfs_share() {
|
mount_nfs_share() {
|
||||||
|
local FUNC_VERSION="1.0"
|
||||||
|
pmx_journal_context "mount_nfs_share" "$FUNC_VERSION"
|
||||||
|
|
||||||
if ! which showmount >/dev/null 2>&1; then
|
if ! which showmount >/dev/null 2>&1; then
|
||||||
msg_info "$(translate "Installing NFS client tools...")"
|
msg_info "$(translate "Installing NFS client tools...")"
|
||||||
apt-get update &>/dev/null
|
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")"
|
msg_ok "$(translate "NFS client tools installed")"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -654,6 +675,9 @@ view_nfs_storages() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
remove_nfs_storage() {
|
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.
|
# Collect every removable NFS entry: pvesm storages and fstab-only mounts.
|
||||||
local OPTIONS=()
|
local OPTIONS=()
|
||||||
local has_pvesm=0
|
local has_pvesm=0
|
||||||
@@ -718,6 +742,7 @@ remove_nfs_storage() {
|
|||||||
show_proxmenux_logo
|
show_proxmenux_logo
|
||||||
msg_title "$(translate "Remove NFS Storage")"
|
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
|
if pvesm remove "$target" 2>/dev/null; then
|
||||||
msg_ok "$(translate "Storage") $target $(translate "removed successfully from Proxmox.")"
|
msg_ok "$(translate "Storage") $target $(translate "removed successfully from Proxmox.")"
|
||||||
else
|
else
|
||||||
@@ -742,6 +767,7 @@ remove_nfs_storage() {
|
|||||||
|
|
||||||
# Try umount only if currently mounted; never force.
|
# Try umount only if currently mounted; never force.
|
||||||
if mount | grep -q " on ${mount_path} type "; then
|
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
|
if umount "$mount_path" 2>/dev/null; then
|
||||||
msg_ok "$(translate "Unmounted:") $mount_path"
|
msg_ok "$(translate "Unmounted:") $mount_path"
|
||||||
else
|
else
|
||||||
@@ -756,12 +782,14 @@ remove_nfs_storage() {
|
|||||||
if awk -v mp="$mount_path" '
|
if awk -v mp="$mount_path" '
|
||||||
$2 == mp && ($3 == "nfs" || $3 == "nfs4") { next }
|
$2 == mp && ($3 == "nfs" || $3 == "nfs4") { next }
|
||||||
{ print }
|
{ 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"))"
|
msg_ok "$(translate "Removed entry from /etc/fstab") ($(translate "backup at /etc/fstab.proxmenux.bak"))"
|
||||||
else
|
else
|
||||||
msg_error "$(translate "Failed to edit /etc/fstab — remove the line manually.")"
|
msg_error "$(translate "Failed to edit /etc/fstab — remove the line manually.")"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
pmx_record_execution "reload systemd units after NFS fstab removal" "systemctl daemon-reload"
|
||||||
systemctl daemon-reload 2>/dev/null || true
|
systemctl daemon-reload 2>/dev/null || true
|
||||||
|
|
||||||
# Try to remove the directory if empty; keep it otherwise.
|
# Try to remove the directory if empty; keep it otherwise.
|
||||||
@@ -778,6 +806,9 @@ remove_nfs_storage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
test_nfs_connectivity() {
|
test_nfs_connectivity() {
|
||||||
|
local FUNC_VERSION="1.0"
|
||||||
|
pmx_journal_context "test_nfs_connectivity" "$FUNC_VERSION"
|
||||||
|
|
||||||
show_proxmenux_logo
|
show_proxmenux_logo
|
||||||
msg_title "$(translate "Test NFS Connectivity")"
|
msg_title "$(translate "Test NFS Connectivity")"
|
||||||
|
|
||||||
@@ -791,7 +822,8 @@ test_nfs_connectivity() {
|
|||||||
msg_ok "$(translate "RPC Bind Service: RUNNING")"
|
msg_ok "$(translate "RPC Bind Service: RUNNING")"
|
||||||
else
|
else
|
||||||
msg_warn "$(translate "RPC Bind Service: STOPPED - starting...")"
|
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
|
fi
|
||||||
else
|
else
|
||||||
msg_warn "$(translate "NFS Client Tools: NOT AVAILABLE")"
|
msg_warn "$(translate "NFS Client Tools: NOT AVAILABLE")"
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ if [[ -f "$UTILS_FILE" ]]; then
|
|||||||
source "$UTILS_FILE"
|
source "$UTILS_FILE"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [[ -f "$LOCAL_SCRIPTS/global/pmx_journal.sh" ]]; then
|
||||||
|
source "$LOCAL_SCRIPTS/global/pmx_journal.sh"
|
||||||
|
fi
|
||||||
|
|
||||||
# Load shared functions
|
# Load shared functions
|
||||||
SHARE_COMMON_FILE="$LOCAL_SCRIPTS/global/share-common.func"
|
SHARE_COMMON_FILE="$LOCAL_SCRIPTS/global/share-common.func"
|
||||||
if ! source "$SHARE_COMMON_FILE" 2>/dev/null; then
|
if ! source "$SHARE_COMMON_FILE" 2>/dev/null; then
|
||||||
@@ -49,6 +53,10 @@ select_privileged_lxc
|
|||||||
|
|
||||||
setup_universal_sharedfiles_group() {
|
setup_universal_sharedfiles_group() {
|
||||||
local ctid="$1"
|
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...")"
|
msg_info "$(translate "Setting sharedfiles group with UID remapping...")"
|
||||||
|
|
||||||
@@ -135,6 +143,9 @@ setup_universal_sharedfiles_group() {
|
|||||||
|
|
||||||
|
|
||||||
select_mount_point() {
|
select_mount_point() {
|
||||||
|
local FUNC_VERSION="1.0"
|
||||||
|
pmx_journal_context "select_mount_point" "$FUNC_VERSION"
|
||||||
|
|
||||||
while true; do
|
while true; do
|
||||||
METHOD=$(whiptail --backtitle "ProxMenux" --title "$(translate "Select Folder")" \
|
METHOD=$(whiptail --backtitle "ProxMenux" --title "$(translate "Select Folder")" \
|
||||||
--menu "$(translate "How do you want to select the folder to export?")" 15 60 5 \
|
--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
|
--msgbox "$(translate "No mount point was specified.")" 8 50
|
||||||
continue
|
continue
|
||||||
fi
|
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
|
pct exec "$CTID" -- mkdir -p "$MOUNT_POINT" 2>/dev/null
|
||||||
return 0
|
return 0
|
||||||
;;
|
;;
|
||||||
@@ -252,6 +265,7 @@ select_export_options() {
|
|||||||
|
|
||||||
|
|
||||||
create_nfs_export() {
|
create_nfs_export() {
|
||||||
|
local FUNC_VERSION="1.0"
|
||||||
|
|
||||||
show_proxmenux_logo
|
show_proxmenux_logo
|
||||||
msg_title "$(translate "Create LXC server NFS")"
|
msg_title "$(translate "Create LXC server NFS")"
|
||||||
@@ -262,6 +276,10 @@ create_nfs_export() {
|
|||||||
get_network_config || return
|
get_network_config || return
|
||||||
select_export_options || 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.")"
|
msg_ok "$(translate "Directory successfully.")"
|
||||||
|
|
||||||
@@ -269,7 +287,7 @@ create_nfs_export() {
|
|||||||
if ! pct exec "$CTID" -- dpkg -s nfs-kernel-server &>/dev/null; then
|
if ! pct exec "$CTID" -- dpkg -s nfs-kernel-server &>/dev/null; then
|
||||||
msg_info "$(translate "Installing NFS server packages inside the CT...")"
|
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" -- 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.")"
|
msg_ok "$(translate "NFS server installed successfully.")"
|
||||||
else
|
else
|
||||||
msg_ok "$(translate "NFS server is already installed.")"
|
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 pct exec "$CTID" -- grep -q "^$MOUNT_POINT " /etc/exports; then
|
||||||
if dialog --yesno "$(translate "Do you want to update the existing export?")" \
|
if dialog --yesno "$(translate "Do you want to update the existing export?")" \
|
||||||
10 60 --title "$(translate "Update Export")"; then
|
10 60 --title "$(translate "Update Export")"; then
|
||||||
pct exec "$CTID" -- sed -i "\|^$MOUNT_POINT |d" /etc/exports
|
pct exec "$CTID" -- sed --in-place "\|^$MOUNT_POINT |d" /etc/exports
|
||||||
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"
|
||||||
show_proxmenux_logo
|
show_proxmenux_logo
|
||||||
msg_title "$(translate "Create LXC server NFS")"
|
msg_title "$(translate "Create LXC server NFS")"
|
||||||
msg_ok "$(translate "Directory successfully.")"
|
msg_ok "$(translate "Directory successfully.")"
|
||||||
@@ -307,7 +325,7 @@ create_nfs_export() {
|
|||||||
|
|
||||||
fi
|
fi
|
||||||
else
|
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.")"
|
msg_ok "$(translate "Export added successfully.")"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -405,6 +423,9 @@ view_exports() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
delete_export() {
|
delete_export() {
|
||||||
|
local FUNC_VERSION="1.0"
|
||||||
|
pmx_journal_context "delete_export" "$FUNC_VERSION"
|
||||||
|
|
||||||
if ! pct exec "$CTID" -- test -f /etc/exports; then
|
if ! pct exec "$CTID" -- test -f /etc/exports; then
|
||||||
dialog --title "$(translate "Error")" --msgbox "\n$(translate "No exports file found.")" 8 50
|
dialog --title "$(translate "Error")" --msgbox "\n$(translate "No exports file found.")" 8 50
|
||||||
return
|
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
|
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
|
show_proxmenux_logo
|
||||||
msg_title "$(translate "Delete Export")"
|
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" -- exportfs -ra
|
||||||
pct exec "$CTID" -- systemctl restart nfs-kernel-server
|
pct exec "$CTID" -- systemctl restart nfs-kernel-server
|
||||||
msg_ok "$(translate "Export deleted and NFS service restarted.")"
|
msg_ok "$(translate "Export deleted and NFS service restarted.")"
|
||||||
@@ -506,6 +529,9 @@ check_nfs_status() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
uninstall_nfs() {
|
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
|
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
|
dialog --title "$(translate "NFS Not Installed")" --msgbox "\n$(translate "NFS server is not installed in this CT.")" 8 60
|
||||||
return
|
return
|
||||||
@@ -519,6 +545,8 @@ uninstall_nfs() {
|
|||||||
|
|
||||||
show_proxmenux_logo
|
show_proxmenux_logo
|
||||||
msg_title "$(translate "Uninstall NFS Server")"
|
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...")"
|
msg_info "$(translate "Stopping NFS services...")"
|
||||||
pct exec "$CTID" -- systemctl stop nfs-kernel-server 2>/dev/null || true
|
pct exec "$CTID" -- systemctl stop nfs-kernel-server 2>/dev/null || true
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ if [[ -f "$UTILS_FILE" ]]; then
|
|||||||
source "$UTILS_FILE"
|
source "$UTILS_FILE"
|
||||||
fi
|
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"
|
SHARE_COMMON_FILE="$LOCAL_SCRIPTS/global/share-common.func"
|
||||||
if ! source "$SHARE_COMMON_FILE" 2>/dev/null; then
|
if ! source "$SHARE_COMMON_FILE" 2>/dev/null; then
|
||||||
@@ -49,6 +53,10 @@ select_privileged_lxc
|
|||||||
|
|
||||||
|
|
||||||
install_samba_client() {
|
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
|
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"
|
pct exec "$CTID" -- mkdir -p "$CREDENTIALS_DIR"
|
||||||
@@ -94,6 +102,9 @@ install_samba_client() {
|
|||||||
|
|
||||||
|
|
||||||
discover_samba_servers() {
|
discover_samba_servers() {
|
||||||
|
local FUNC_VERSION="1.0"
|
||||||
|
pmx_journal_context "discover_samba_servers" "$FUNC_VERSION"
|
||||||
|
|
||||||
show_proxmenux_logo
|
show_proxmenux_logo
|
||||||
msg_title "$(translate "Samba LXC Manager")"
|
msg_title "$(translate "Samba LXC Manager")"
|
||||||
msg_info "$(translate "Scanning network for Samba servers...")"
|
msg_info "$(translate "Scanning network for Samba servers...")"
|
||||||
@@ -105,7 +116,7 @@ discover_samba_servers() {
|
|||||||
|
|
||||||
for pkg in nmap samba-common-bin; do
|
for pkg in nmap samba-common-bin; do
|
||||||
if ! which ${pkg%%-*} >/dev/null 2>&1; then
|
if ! which ${pkg%%-*} >/dev/null 2>&1; then
|
||||||
apt-get install -y "$pkg" &>/dev/null
|
pmx_install_pkg "$pkg"
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
@@ -678,13 +689,18 @@ configure_mount_options() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
create_credentials_file() {
|
create_credentials_file() {
|
||||||
|
local FUNC_VERSION="1.0"
|
||||||
|
pmx_journal_context "create_credentials_file" "$FUNC_VERSION"
|
||||||
|
|
||||||
if [[ "$USE_GUEST" == "true" ]]; then
|
if [[ "$USE_GUEST" == "true" ]]; then
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
||||||
CRED_FILE="$CREDENTIALS_DIR/${SAMBA_SERVER}_${SAMBA_SHARE}.cred"
|
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
|
pct exec "$CTID" -- bash -c "cat > '$CRED_FILE' << EOF
|
||||||
username=$USERNAME
|
username=$USERNAME
|
||||||
@@ -729,6 +745,7 @@ EOF"
|
|||||||
}
|
}
|
||||||
|
|
||||||
mount_samba_share() {
|
mount_samba_share() {
|
||||||
|
local FUNC_VERSION="1.0"
|
||||||
# Step 0:
|
# Step 0:
|
||||||
install_samba_client || return
|
install_samba_client || return
|
||||||
|
|
||||||
@@ -754,6 +771,10 @@ mount_samba_share() {
|
|||||||
|
|
||||||
# Step 5:
|
# Step 5:
|
||||||
configure_mount_options || return
|
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
|
show_proxmenux_logo
|
||||||
msg_title "$(translate "Installing Samba Client in LXC")"
|
msg_title "$(translate "Installing Samba Client in LXC")"
|
||||||
@@ -803,11 +824,11 @@ mount_samba_share() {
|
|||||||
if [[ "$PERMANENT_MOUNT" == "true" ]]; then
|
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"
|
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.")"
|
msg_ok "$(translate "Added to /etc/fstab for permanent mounting.")"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -927,6 +948,8 @@ view_samba_mounts() {
|
|||||||
|
|
||||||
|
|
||||||
unmount_samba_share() {
|
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)
|
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")"
|
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)
|
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.")"
|
msg_ok "$(translate "Removed from /etc/fstab.")"
|
||||||
|
|
||||||
if [[ -n "$CRED_FILE" && "$CRED_FILE" != "guest" ]]; then
|
if [[ -n "$CRED_FILE" && "$CRED_FILE" != "guest" ]]; then
|
||||||
|
|||||||
@@ -44,6 +44,10 @@ if [[ -f "$UTILS_FILE" ]]; then
|
|||||||
source "$UTILS_FILE"
|
source "$UTILS_FILE"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [[ -f "$LOCAL_SCRIPTS/global/pmx_journal.sh" ]]; then
|
||||||
|
source "$LOCAL_SCRIPTS/global/pmx_journal.sh"
|
||||||
|
fi
|
||||||
|
|
||||||
load_language
|
load_language
|
||||||
initialize_cache
|
initialize_cache
|
||||||
|
|
||||||
@@ -70,6 +74,9 @@ get_storage_config() {
|
|||||||
# ==========================================================
|
# ==========================================================
|
||||||
|
|
||||||
discover_samba_servers() {
|
discover_samba_servers() {
|
||||||
|
local FUNC_VERSION="1.0"
|
||||||
|
pmx_journal_context "discover_samba_servers" "$FUNC_VERSION"
|
||||||
|
|
||||||
show_proxmenux_logo
|
show_proxmenux_logo
|
||||||
msg_title "$(translate "Add Samba Share as Proxmox Storage")"
|
msg_title "$(translate "Add Samba Share as Proxmox Storage")"
|
||||||
msg_info "$(translate "Scanning network for Samba servers...")"
|
msg_info "$(translate "Scanning network for Samba servers...")"
|
||||||
@@ -79,7 +86,7 @@ discover_samba_servers() {
|
|||||||
|
|
||||||
for pkg in nmap samba-common-bin; do
|
for pkg in nmap samba-common-bin; do
|
||||||
if ! which "${pkg%%-*}" >/dev/null 2>&1; then
|
if ! which "${pkg%%-*}" >/dev/null 2>&1; then
|
||||||
apt-get install -y "$pkg" &>/dev/null
|
pmx_install_pkg "$pkg" &>/dev/null
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
@@ -274,6 +281,8 @@ add_proxmox_cifs_storage() {
|
|||||||
local server="$2"
|
local server="$2"
|
||||||
local share="$3"
|
local share="$3"
|
||||||
local content="${4:-import}"
|
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
|
if ! command -v pvesm >/dev/null 2>&1; then
|
||||||
msg_error "$(translate "pvesm command not found. This should not happen on Proxmox.")"
|
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
|
8 60 --title "$(translate "Storage Exists")"; then
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
pmx_record_execution "remove Proxmox CIFS storage ${storage_id}" \
|
||||||
|
"pvesm remove ${storage_id}"
|
||||||
pvesm remove "$storage_id" 2>/dev/null || true
|
pvesm remove "$storage_id" 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -295,6 +306,8 @@ add_proxmox_cifs_storage() {
|
|||||||
msg_info "$(translate "Adding CIFS storage to Proxmox...")"
|
msg_info "$(translate "Adding CIFS storage to Proxmox...")"
|
||||||
|
|
||||||
local pvesm_result pvesm_output
|
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
|
if [[ "$USE_GUEST" == "true" ]]; then
|
||||||
pvesm_output=$(pvesm add cifs "$storage_id" \
|
pvesm_output=$(pvesm add cifs "$storage_id" \
|
||||||
--server "$server" \
|
--server "$server" \
|
||||||
@@ -414,15 +427,20 @@ select_cifs_mount_options() {
|
|||||||
# Write a root-only credentials file for the fstab mount.
|
# Write a root-only credentials file for the fstab mount.
|
||||||
# Sets HOST_CRED_FILE on success, or empty string for guest mode.
|
# Sets HOST_CRED_FILE on success, or empty string for guest mode.
|
||||||
write_host_credentials_file() {
|
write_host_credentials_file() {
|
||||||
|
local FUNC_VERSION="1.0"
|
||||||
|
pmx_journal_context "write_host_credentials_file" "$FUNC_VERSION"
|
||||||
|
|
||||||
if [[ "$USE_GUEST" == "true" ]]; then
|
if [[ "$USE_GUEST" == "true" ]]; then
|
||||||
HOST_CRED_FILE=""
|
HOST_CRED_FILE=""
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
local creds_dir="/etc/samba/credentials"
|
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"
|
mkdir -p "$creds_dir"
|
||||||
chmod 0700 "$creds_dir"
|
chmod 0700 "$creds_dir"
|
||||||
HOST_CRED_FILE="${creds_dir}/$(echo "${SAMBA_SERVER}_${SAMBA_SHARE}" | tr -c 'A-Za-z0-9._-' '_').cred"
|
HOST_CRED_FILE="${creds_dir}/$(echo "${SAMBA_SERVER}_${SAMBA_SHARE}" | tr -c 'A-Za-z0-9._-' '_').cred"
|
||||||
cat > "$HOST_CRED_FILE" <<EOF
|
pmx_write_file "$HOST_CRED_FILE" <<EOF
|
||||||
username=${USERNAME}
|
username=${USERNAME}
|
||||||
password=${PASSWORD}
|
password=${PASSWORD}
|
||||||
EOF
|
EOF
|
||||||
@@ -440,10 +458,14 @@ mount_cifs_via_fstab() {
|
|||||||
local replace="$5"
|
local replace="$5"
|
||||||
local cred_file="$6"
|
local cred_file="$6"
|
||||||
local use_guest="$7"
|
local use_guest="$7"
|
||||||
|
local FUNC_VERSION="1.0"
|
||||||
|
pmx_journal_context "mount_cifs_via_fstab" "$FUNC_VERSION"
|
||||||
|
|
||||||
msg_info "$(translate "Preparing host mount...")"
|
msg_info "$(translate "Preparing host mount...")"
|
||||||
|
|
||||||
if [[ ! -d "$mount_path" ]]; then
|
if [[ ! -d "$mount_path" ]]; then
|
||||||
|
pmx_record_execution "create CIFS mount point ${mount_path}" \
|
||||||
|
"mkdir -p ${mount_path}"
|
||||||
if ! mkdir -p "$mount_path" 2>/dev/null; then
|
if ! mkdir -p "$mount_path" 2>/dev/null; then
|
||||||
msg_error "$(translate "Failed to create mount point:") $mount_path"
|
msg_error "$(translate "Failed to create mount point:") $mount_path"
|
||||||
return 1
|
return 1
|
||||||
@@ -459,6 +481,8 @@ mount_cifs_via_fstab() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
msg_info "$(translate "Mounting CIFS share...")"
|
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
|
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.")"
|
msg_error "$(translate "Failed to mount CIFS share on host.")"
|
||||||
return 1
|
return 1
|
||||||
@@ -474,11 +498,12 @@ mount_cifs_via_fstab() {
|
|||||||
|
|
||||||
# Persist in /etc/fstab.
|
# Persist in /etc/fstab.
|
||||||
if [[ "$replace" == "1" ]]; then
|
if [[ "$replace" == "1" ]]; then
|
||||||
sed -i "\|[[:space:]]${mount_path}[[:space:]]|d" /etc/fstab
|
pmx_edit_file /etc/fstab "\|[[:space:]]${mount_path}[[:space:]]|d"
|
||||||
fi
|
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.")"
|
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
|
systemctl daemon-reload 2>/dev/null || true
|
||||||
|
|
||||||
echo -e ""
|
echo -e ""
|
||||||
@@ -535,10 +560,13 @@ select_cifs_mount_methods() {
|
|||||||
# ==========================================================
|
# ==========================================================
|
||||||
|
|
||||||
mount_cifs_share() {
|
mount_cifs_share() {
|
||||||
|
local FUNC_VERSION="1.0"
|
||||||
|
pmx_journal_context "mount_cifs_share" "$FUNC_VERSION"
|
||||||
|
|
||||||
if ! which smbclient >/dev/null 2>&1; then
|
if ! which smbclient >/dev/null 2>&1; then
|
||||||
msg_info "$(translate "Installing Samba client tools...")"
|
msg_info "$(translate "Installing Samba client tools...")"
|
||||||
apt-get update &>/dev/null
|
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")"
|
msg_ok "$(translate "Samba client tools installed")"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -721,6 +749,9 @@ view_cifs_storages() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
remove_cifs_storage() {
|
remove_cifs_storage() {
|
||||||
|
local FUNC_VERSION="1.0"
|
||||||
|
pmx_journal_context "remove_cifs_storage" "$FUNC_VERSION"
|
||||||
|
|
||||||
local OPTIONS=()
|
local OPTIONS=()
|
||||||
local has_pvesm=0
|
local has_pvesm=0
|
||||||
local has_fstab=0
|
local has_fstab=0
|
||||||
@@ -784,6 +815,8 @@ remove_cifs_storage() {
|
|||||||
show_proxmenux_logo
|
show_proxmenux_logo
|
||||||
msg_title "$(translate "Remove CIFS Storage")"
|
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
|
if pvesm remove "$target" 2>/dev/null; then
|
||||||
msg_ok "$(translate "Storage") $target $(translate "removed successfully from Proxmox.")"
|
msg_ok "$(translate "Storage") $target $(translate "removed successfully from Proxmox.")"
|
||||||
else
|
else
|
||||||
@@ -817,6 +850,8 @@ remove_cifs_storage() {
|
|||||||
msg_title "$(translate "Remove CIFS fstab Mount")"
|
msg_title "$(translate "Remove CIFS fstab Mount")"
|
||||||
|
|
||||||
if mount | grep -q " on ${mount_path} type "; then
|
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
|
if umount "$mount_path" 2>/dev/null; then
|
||||||
msg_ok "$(translate "Unmounted:") $mount_path"
|
msg_ok "$(translate "Unmounted:") $mount_path"
|
||||||
else
|
else
|
||||||
@@ -831,17 +866,18 @@ remove_cifs_storage() {
|
|||||||
if awk -v mp="$mount_path" '
|
if awk -v mp="$mount_path" '
|
||||||
$2 == mp && $3 == "cifs" { next }
|
$2 == mp && $3 == "cifs" { next }
|
||||||
{ print }
|
{ 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"))"
|
msg_ok "$(translate "Removed entry from /etc/fstab") ($(translate "backup at /etc/fstab.proxmenux.bak"))"
|
||||||
else
|
else
|
||||||
msg_error "$(translate "Failed to edit /etc/fstab — remove the line manually.")"
|
msg_error "$(translate "Failed to edit /etc/fstab — remove the line manually.")"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
pmx_record_execution "reload systemd after CIFS fstab removal" "systemctl daemon-reload"
|
||||||
systemctl daemon-reload 2>/dev/null || true
|
systemctl daemon-reload 2>/dev/null || true
|
||||||
|
|
||||||
# Remove credentials file if it's under the standard ProxMenux dir
|
# Remove credentials file if it's under the standard ProxMenux dir
|
||||||
if [[ -n "$cred_file" && -f "$cred_file" && "$cred_file" == /etc/samba/credentials/* ]]; then
|
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"
|
msg_ok "$(translate "Removed credentials file:") $cred_file"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -858,6 +894,9 @@ remove_cifs_storage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
test_samba_connectivity() {
|
test_samba_connectivity() {
|
||||||
|
local FUNC_VERSION="1.0"
|
||||||
|
pmx_journal_context "test_samba_connectivity" "$FUNC_VERSION"
|
||||||
|
|
||||||
show_proxmenux_logo
|
show_proxmenux_logo
|
||||||
msg_title "$(translate "Test Samba Connectivity")"
|
msg_title "$(translate "Test Samba Connectivity")"
|
||||||
|
|
||||||
@@ -869,7 +908,7 @@ test_samba_connectivity() {
|
|||||||
else
|
else
|
||||||
msg_warn "$(translate "CIFS Client Tools: NOT AVAILABLE - installing...")"
|
msg_warn "$(translate "CIFS Client Tools: NOT AVAILABLE - installing...")"
|
||||||
apt-get update &>/dev/null
|
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.")"
|
msg_ok "$(translate "CIFS client tools installed.")"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ if [[ -f "$UTILS_FILE" ]]; then
|
|||||||
source "$UTILS_FILE"
|
source "$UTILS_FILE"
|
||||||
fi
|
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"
|
SHARE_COMMON_FILE="$LOCAL_SCRIPTS/global/share-common.func"
|
||||||
if ! source "$SHARE_COMMON_FILE" 2>/dev/null; then
|
if ! source "$SHARE_COMMON_FILE" 2>/dev/null; then
|
||||||
@@ -48,6 +52,9 @@ select_privileged_lxc
|
|||||||
|
|
||||||
|
|
||||||
select_mount_point() {
|
select_mount_point() {
|
||||||
|
local FUNC_VERSION="1.0"
|
||||||
|
pmx_journal_context "select_mount_point" "$FUNC_VERSION"
|
||||||
|
|
||||||
while true; do
|
while true; do
|
||||||
METHOD=$(whiptail --backtitle "ProxMenux" --title "$(translate "Select Folder")" \
|
METHOD=$(whiptail --backtitle "ProxMenux" --title "$(translate "Select Folder")" \
|
||||||
--menu "$(translate "How do you want to select the folder to share?")" 15 60 5 \
|
--menu "$(translate "How do you want to select the folder to share?")" 15 60 5 \
|
||||||
@@ -104,12 +111,16 @@ select_mount_point() {
|
|||||||
|
|
||||||
|
|
||||||
create_share() {
|
create_share() {
|
||||||
|
local FUNC_VERSION="1.0"
|
||||||
|
|
||||||
show_proxmenux_logo
|
show_proxmenux_logo
|
||||||
msg_title "$(translate "Create Samba server service")"
|
msg_title "$(translate "Create Samba server service")"
|
||||||
sleep 2
|
sleep 2
|
||||||
|
|
||||||
select_mount_point || return
|
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
|
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]"
|
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
|
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"
|
pct exec "$CTID" -- bash -c "echo '$CONFIG' >> /etc/samba/smb.conf"
|
||||||
msg_ok "$(translate "Share updated successfully.")"
|
msg_ok "$(translate "Share updated successfully.")"
|
||||||
else
|
else
|
||||||
@@ -406,6 +417,9 @@ view_shares() {
|
|||||||
|
|
||||||
|
|
||||||
delete_share() {
|
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
|
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
|
dialog --backtitle "ProxMenux" --title "$(translate "Error")" --msgbox "\n$(translate "No smb.conf file found.")" 8 50
|
||||||
return
|
return
|
||||||
@@ -438,7 +452,9 @@ delete_share() {
|
|||||||
msg_title "$(translate "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
|
pct exec "$CTID" -- systemctl restart smbd.service
|
||||||
msg_ok "$(translate "Share deleted and Samba service restarted.")"
|
msg_ok "$(translate "Share deleted and Samba service restarted.")"
|
||||||
fi
|
fi
|
||||||
@@ -495,6 +511,7 @@ check_samba_status() {
|
|||||||
|
|
||||||
|
|
||||||
uninstall_samba() {
|
uninstall_samba() {
|
||||||
|
local FUNC_VERSION="1.0"
|
||||||
|
|
||||||
if ! pct exec "$CTID" -- dpkg -s samba &>/dev/null; then
|
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
|
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
|
show_proxmenux_logo
|
||||||
msg_title "$(translate "Uninstall Samba Server")"
|
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...")"
|
msg_info "$(translate "Stopping Samba services...")"
|
||||||
|
|||||||
@@ -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
|
elif [[ -f "$LOCAL_SCRIPTS_DEFAULT/global/pci_passthrough_helpers.sh" ]]; then
|
||||||
source "$LOCAL_SCRIPTS_DEFAULT/global/pci_passthrough_helpers.sh"
|
source "$LOCAL_SCRIPTS_DEFAULT/global/pci_passthrough_helpers.sh"
|
||||||
fi
|
fi
|
||||||
|
if [[ -f "$LOCAL_SCRIPTS/global/pmx_journal.sh" ]]; then
|
||||||
|
source "$LOCAL_SCRIPTS/global/pmx_journal.sh"
|
||||||
|
fi
|
||||||
load_language
|
load_language
|
||||||
initialize_cache
|
initialize_cache
|
||||||
|
|
||||||
@@ -74,6 +77,9 @@ register_vfio_iommu_tool() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enable_iommu_cmdline() {
|
enable_iommu_cmdline() {
|
||||||
|
local FUNC_VERSION="1.0"
|
||||||
|
pmx_journal_context "enable_iommu_cmdline" "$FUNC_VERSION"
|
||||||
|
|
||||||
local silent="${1:-}"
|
local silent="${1:-}"
|
||||||
local cpu_vendor iommu_param
|
local cpu_vendor iommu_param
|
||||||
cpu_vendor=$(grep -m1 "vendor_id" /proc/cpuinfo 2>/dev/null | awk '{print $3}')
|
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 [[ -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
|
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)"
|
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
|
proxmox-boot-tool refresh >/dev/null 2>&1 || true
|
||||||
[[ "$silent" != "silent" ]] && msg_ok "$(translate "IOMMU parameters added to /etc/kernel/cmdline")"
|
[[ "$silent" != "silent" ]] && msg_ok "$(translate "IOMMU parameters added to /etc/kernel/cmdline")"
|
||||||
else
|
else
|
||||||
@@ -104,7 +111,8 @@ enable_iommu_cmdline() {
|
|||||||
elif [[ -f "$grub_file" ]]; then
|
elif [[ -f "$grub_file" ]]; then
|
||||||
if ! grep -q "$iommu_param" "$grub_file" || ! grep -q "iommu=pt" "$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)"
|
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
|
update-grub >/dev/null 2>&1 || true
|
||||||
[[ "$silent" != "silent" ]] && msg_ok "$(translate "IOMMU parameters added to GRUB")"
|
[[ "$silent" != "silent" ]] && msg_ok "$(translate "IOMMU parameters added to GRUB")"
|
||||||
else
|
else
|
||||||
@@ -521,6 +529,9 @@ prompt_controller_conflict_policy() {
|
|||||||
|
|
||||||
# ── DIALOG PHASE: resolve all conflicts before terminal ───────────────────────
|
# ── DIALOG PHASE: resolve all conflicts before terminal ───────────────────────
|
||||||
resolve_disk_conflicts() {
|
resolve_disk_conflicts() {
|
||||||
|
local FUNC_VERSION="1.0"
|
||||||
|
pmx_journal_context "resolve_disk_conflicts" "$FUNC_VERSION"
|
||||||
|
|
||||||
local -a new_pci_list=()
|
local -a new_pci_list=()
|
||||||
local pci vmid action slot_base scope_key has_running
|
local pci vmid action slot_base scope_key has_running
|
||||||
|
|
||||||
@@ -559,13 +570,18 @@ resolve_disk_conflicts() {
|
|||||||
case "$action" in
|
case "$action" in
|
||||||
keep_disable_onboot)
|
keep_disable_onboot)
|
||||||
for vmid in "${source_vms[@]}"; do
|
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
|
done
|
||||||
new_pci_list+=("$pci")
|
new_pci_list+=("$pci")
|
||||||
;;
|
;;
|
||||||
move_remove_source)
|
move_remove_source)
|
||||||
slot_base=$(_pci_slot_base "$pci")
|
slot_base=$(_pci_slot_base "$pci")
|
||||||
for vmid in "${source_vms[@]}"; do
|
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"
|
_remove_pci_slot_from_vm_config "$vmid" "$slot_base"
|
||||||
done
|
done
|
||||||
new_pci_list+=("$pci")
|
new_pci_list+=("$pci")
|
||||||
@@ -616,10 +632,15 @@ resolve_disk_conflicts() {
|
|||||||
for gid in "${guest_ids[@]}"; do
|
for gid in "${guest_ids[@]}"; do
|
||||||
gtype="${gid%%:*}"; gid_num="${gid##*:}"
|
gtype="${gid%%:*}"; gid_num="${gid##*:}"
|
||||||
if [[ "$gtype" == "VM" ]]; then
|
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
|
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
|
pct set "$gid_num" -onboot 0 >/dev/null 2>&1
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
;;
|
;;
|
||||||
@@ -629,11 +650,15 @@ resolve_disk_conflicts() {
|
|||||||
if [[ "$gtype" == "VM" ]]; then
|
if [[ "$gtype" == "VM" ]]; then
|
||||||
while IFS= read -r slot; do
|
while IFS= read -r slot; do
|
||||||
[[ -z "$slot" ]] && continue
|
[[ -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
|
qm set "$gid_num" -delete "$slot" >/dev/null 2>&1
|
||||||
done < <(_find_disk_slots_in_vm "$gid_num" "$disk")
|
done < <(_find_disk_slots_in_vm "$gid_num" "$disk")
|
||||||
else
|
else
|
||||||
while IFS= read -r slot; do
|
while IFS= read -r slot; do
|
||||||
[[ -z "$slot" ]] && continue
|
[[ -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
|
pct set "$gid_num" -delete "$slot" >/dev/null 2>&1
|
||||||
done < <(_find_disk_slots_in_ct "$gid_num" "$disk")
|
done < <(_find_disk_slots_in_ct "$gid_num" "$disk")
|
||||||
fi
|
fi
|
||||||
@@ -647,6 +672,9 @@ resolve_disk_conflicts() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
apply_assignment() {
|
apply_assignment() {
|
||||||
|
local FUNC_VERSION="1.0"
|
||||||
|
pmx_journal_context "apply_assignment" "$FUNC_VERSION"
|
||||||
|
|
||||||
: >"$LOG_FILE"
|
: >"$LOG_FILE"
|
||||||
set_title
|
set_title
|
||||||
|
|
||||||
@@ -681,6 +709,8 @@ apply_assignment() {
|
|||||||
local display_name
|
local display_name
|
||||||
display_name=$(_pci_storage_display_name "$pci")
|
display_name=$(_pci_storage_display_name "$pci")
|
||||||
msg_info "$(translate "Adding") ${display_name} (${pci}) → hostpci${hostpci_idx}..."
|
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
|
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})"
|
msg_ok "$(translate "Controller/NVMe assigned") (hostpci${hostpci_idx} → ${pci})"
|
||||||
assigned_count=$((assigned_count + 1))
|
assigned_count=$((assigned_count + 1))
|
||||||
@@ -709,6 +739,7 @@ apply_assignment() {
|
|||||||
msg_success "$(translate "Press Enter to continue...")"
|
msg_success "$(translate "Press Enter to continue...")"
|
||||||
read -r
|
read -r
|
||||||
msg_warn "$(translate "Rebooting the system...")"
|
msg_warn "$(translate "Rebooting the system...")"
|
||||||
|
pmx_record_execution "reboot host after enabling IOMMU" "reboot"
|
||||||
reboot
|
reboot
|
||||||
else
|
else
|
||||||
msg_info2 "$(translate "To use the VM without issues, the host must be restarted before starting it.")"
|
msg_info2 "$(translate "To use the VM without issues, the host must be restarted before starting it.")"
|
||||||
|
|||||||
@@ -48,6 +48,12 @@ elif [[ -f "$LOCAL_SCRIPTS_DEFAULT/global/vm_storage_helpers.sh" ]]; then
|
|||||||
source "$LOCAL_SCRIPTS_DEFAULT/global/vm_storage_helpers.sh"
|
source "$LOCAL_SCRIPTS_DEFAULT/global/vm_storage_helpers.sh"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [[ -f "$LOCAL_SCRIPTS/global/pmx_journal.sh" ]]; then
|
||||||
|
source "$LOCAL_SCRIPTS/global/pmx_journal.sh"
|
||||||
|
fi
|
||||||
|
|
||||||
|
FUNC_VERSION="1.3"
|
||||||
|
|
||||||
BACKTITLE="ProxMenux"
|
BACKTITLE="ProxMenux"
|
||||||
UI_MENU_H=20
|
UI_MENU_H=20
|
||||||
UI_MENU_W=84
|
UI_MENU_W=84
|
||||||
@@ -120,12 +126,20 @@ get_preferred_disk_path() {
|
|||||||
install_fs_tools_in_ct() {
|
install_fs_tools_in_ct() {
|
||||||
local ctid="$1"
|
local ctid="$1"
|
||||||
local pkg="$2"
|
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
|
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"
|
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
|
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"
|
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
|
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"
|
pct exec "$ctid" -- sh -c "apt-get update -qq >/dev/null 2>&1 && apt-get install -y -qq $pkg >/dev/null 2>&1"
|
||||||
else
|
else
|
||||||
return 1
|
return 1
|
||||||
@@ -247,12 +261,15 @@ msg_ok "$(translate "CT $CTID selected successfully.")"
|
|||||||
|
|
||||||
if [ "$CONVERT_PRIVILEGED" = true ]; then
|
if [ "$CONVERT_PRIVILEGED" = true ]; then
|
||||||
|
|
||||||
|
pmx_journal_context "disk_passthrough_ct" "$FUNC_VERSION"
|
||||||
|
|
||||||
show_proxmenux_logo
|
show_proxmenux_logo
|
||||||
msg_title "$(translate "Import Disk to LXC")"
|
msg_title "$(translate "Import Disk to LXC")"
|
||||||
|
|
||||||
CURRENT_CT_STATUS=$(pct status "$CTID" | awk '{print $2}')
|
CURRENT_CT_STATUS=$(pct status "$CTID" | awk '{print $2}')
|
||||||
if [ "$CURRENT_CT_STATUS" == "running" ]; then
|
if [ "$CURRENT_CT_STATUS" == "running" ]; then
|
||||||
msg_info "$(translate "Stopping container") $CTID..."
|
msg_info "$(translate "Stopping container") $CTID..."
|
||||||
|
pmx_record_execution "stop CT ${CTID} for privileged conversion" "pct shutdown ${CTID}"
|
||||||
pct shutdown "$CTID" &>/dev/null
|
pct shutdown "$CTID" &>/dev/null
|
||||||
for i in {1..10}; do
|
for i in {1..10}; do
|
||||||
sleep 1
|
sleep 1
|
||||||
@@ -266,12 +283,13 @@ if [ "$CONVERT_PRIVILEGED" = true ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
cp "$CONF_FILE" "$CONF_FILE.bak"
|
cp "$CONF_FILE" "$CONF_FILE.bak"
|
||||||
sed -i '/^unprivileged: 1/d' "$CONF_FILE"
|
pmx_edit_file "$CONF_FILE" '/^unprivileged: 1/d'
|
||||||
echo "unprivileged: 0" >> "$CONF_FILE"
|
echo "unprivileged: 0" | pmx_append_file "$CONF_FILE"
|
||||||
msg_ok "$(translate "Container successfully converted to privileged.")"
|
msg_ok "$(translate "Container successfully converted to privileged.")"
|
||||||
|
|
||||||
if [ "$CT_RUNNING" = true ]; then
|
if [ "$CT_RUNNING" = true ]; then
|
||||||
msg_info "$(translate "Starting container") $CTID..."
|
msg_info "$(translate "Starting container") $CTID..."
|
||||||
|
pmx_record_execution "start CT ${CTID} after privileged conversion" "pct start ${CTID}"
|
||||||
pct start "$CTID" &>/dev/null
|
pct start "$CTID" &>/dev/null
|
||||||
sleep 2
|
sleep 2
|
||||||
if [ "$(pct status "$CTID" | awk '{print $2}')" != "running" ]; then
|
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 "CT $CTID selected successfully.")"
|
||||||
msg_ok "$(translate "Disks to process:") ${#DISK_LIST[@]}"
|
msg_ok "$(translate "Disks to process:") ${#DISK_LIST[@]}"
|
||||||
for i in "${!DISK_LIST[@]}"; do
|
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]}"
|
IFS=$'\t' read -r _desc_model _desc_size <<< "${DISK_DESCRIPTIONS[$i]}"
|
||||||
echo -e "${TAB}${BL}${DISK_LIST[$i]} $_desc_model $_desc_size${CL}"
|
echo -e "${TAB}${BL}${DISK_LIST[$i]} $_desc_model $_desc_size${CL}"
|
||||||
done
|
done
|
||||||
@@ -590,6 +610,8 @@ for i in "${!DISK_LIST[@]}"; do
|
|||||||
|
|
||||||
if [ "$NEEDS_PARTITION" = true ]; then
|
if [ "$NEEDS_PARTITION" = true ]; then
|
||||||
msg_info "$(translate "Creating partition table and partition...")"
|
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
|
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."
|
msg_error "$(translate "Failed to create partition table on disk") $DISK_INFO."
|
||||||
continue
|
continue
|
||||||
@@ -616,6 +638,8 @@ for i in "${!DISK_LIST[@]}"; do
|
|||||||
|
|
||||||
if [ "$SKIP_FORMAT" != true ]; then
|
if [ "$SKIP_FORMAT" != true ]; then
|
||||||
msg_info "$(translate "Formatting partition") $PARTITION $(translate "with") $FORMAT_TYPE..."
|
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
|
if ! case "$FORMAT_TYPE" in
|
||||||
"ext4") mkfs.ext4 -F "$PARTITION" >/dev/null 2>&1 ;;
|
"ext4") mkfs.ext4 -F "$PARTITION" >/dev/null 2>&1 ;;
|
||||||
"xfs") mkfs.xfs -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.")" \
|
--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
|
$UI_YESNO_H $UI_YESNO_W; then
|
||||||
msg_info "$(translate "Starting CT") $CTID..."
|
msg_info "$(translate "Starting CT") $CTID..."
|
||||||
|
pmx_record_execution "start CT ${CTID} to install filesystem tools" "pct start ${CTID}"
|
||||||
pct start "$CTID" &>/dev/null
|
pct start "$CTID" &>/dev/null
|
||||||
sleep 2
|
sleep 2
|
||||||
if [ "$(pct status "$CTID" | awk '{print $2}')" != "running" ]; then
|
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")
|
PERSISTENT_PARTITION=$(get_preferred_disk_path "$PARTITION")
|
||||||
|
|
||||||
msg_info "$(translate "Applying passthrough to CT") $CTID..."
|
msg_info "$(translate "Applying passthrough to CT") $CTID..."
|
||||||
|
pmx_journal_context "disk_passthrough_ct" "$FUNC_VERSION"
|
||||||
if [ "$FORMAT_TYPE" == "xfs" ]; then
|
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)
|
RESULT=$(pct set "$CTID" -mp${INDEX} "$PERSISTENT_PARTITION,mp=$MOUNT_POINT,backup=0,ro=0" 2>&1)
|
||||||
else
|
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)
|
RESULT=$(pct set "$CTID" -mp${INDEX} "$PERSISTENT_PARTITION,mp=$MOUNT_POINT,backup=0,ro=0,acl=1" 2>&1)
|
||||||
fi
|
fi
|
||||||
SET_STATUS=$?
|
SET_STATUS=$?
|
||||||
|
|||||||
@@ -64,6 +64,10 @@ elif [[ -f "$LOCAL_SCRIPTS_DEFAULT/global/utils-install-functions.sh" ]]; then
|
|||||||
source "$LOCAL_SCRIPTS_DEFAULT/global/utils-install-functions.sh"
|
source "$LOCAL_SCRIPTS_DEFAULT/global/utils-install-functions.sh"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [[ -f "$LOCAL_SCRIPTS/global/pmx_journal.sh" ]]; then
|
||||||
|
source "$LOCAL_SCRIPTS/global/pmx_journal.sh"
|
||||||
|
fi
|
||||||
|
|
||||||
BACKTITLE="ProxMenux"
|
BACKTITLE="ProxMenux"
|
||||||
UI_MENU_H=20
|
UI_MENU_H=20
|
||||||
UI_MENU_W=84
|
UI_MENU_W=84
|
||||||
@@ -607,13 +611,16 @@ prompt_zfs_pool_name() {
|
|||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
ensure_fs_tool() {
|
ensure_fs_tool() {
|
||||||
|
local FUNC_VERSION="2.0"
|
||||||
|
pmx_journal_context "ensure_fs_tool" "$FUNC_VERSION"
|
||||||
|
|
||||||
case "$FORMAT_TYPE" in
|
case "$FORMAT_TYPE" in
|
||||||
exfat)
|
exfat)
|
||||||
command -v mkfs.exfat >/dev/null 2>&1 && return 0
|
command -v mkfs.exfat >/dev/null 2>&1 && return 0
|
||||||
if declare -F ensure_repositories >/dev/null 2>&1; then
|
if declare -F ensure_repositories >/dev/null 2>&1; then
|
||||||
ensure_repositories || true
|
ensure_repositories || true
|
||||||
fi
|
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 && {
|
command -v mkfs.exfat >/dev/null 2>&1 && {
|
||||||
msg_ok "$(translate "exFAT tools installed successfully.")"
|
msg_ok "$(translate "exFAT tools installed successfully.")"
|
||||||
return 0
|
return 0
|
||||||
@@ -657,6 +664,9 @@ wait_for_enter_to_main() {
|
|||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
|
local FUNC_VERSION="2.0"
|
||||||
|
pmx_journal_context "main" "$FUNC_VERSION"
|
||||||
|
|
||||||
select_target_disk || exit 0
|
select_target_disk || exit 0
|
||||||
select_operation_mode || exit 0
|
select_operation_mode || exit 0
|
||||||
confirm_format_action || exit 0
|
confirm_format_action || exit 0
|
||||||
@@ -701,6 +711,10 @@ main() {
|
|||||||
export DOH_SHOW_PROGRESS=0
|
export DOH_SHOW_PROGRESS=0
|
||||||
export DOH_ENABLE_STACK_RELEASE=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
|
if [[ "$OPERATION_MODE" == "wipe_all" ]]; then
|
||||||
msg_info "$(translate "Wiping partitions and metadata...")"
|
msg_info "$(translate "Wiping partitions and metadata...")"
|
||||||
doh_wipe_disk "$SELECTED_DISK"
|
doh_wipe_disk "$SELECTED_DISK"
|
||||||
|
|||||||
@@ -41,6 +41,10 @@ if [[ -f "$UTILS_FILE" ]]; then
|
|||||||
source "$UTILS_FILE"
|
source "$UTILS_FILE"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [[ -f "$LOCAL_SCRIPTS/global/pmx_journal.sh" ]]; then
|
||||||
|
source "$LOCAL_SCRIPTS/global/pmx_journal.sh"
|
||||||
|
fi
|
||||||
|
|
||||||
load_language
|
load_language
|
||||||
initialize_cache
|
initialize_cache
|
||||||
|
|
||||||
@@ -134,6 +138,9 @@ select_vm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ensure_vm_stopped() {
|
ensure_vm_stopped() {
|
||||||
|
local FUNC_VERSION="1.0"
|
||||||
|
pmx_journal_context "ensure_vm_stopped" "$FUNC_VERSION"
|
||||||
|
|
||||||
local status
|
local status
|
||||||
status=$(qm status "$VMID" 2>/dev/null | awk '{print $2}')
|
status=$(qm status "$VMID" 2>/dev/null | awk '{print $2}')
|
||||||
|
|
||||||
@@ -146,6 +153,7 @@ ensure_vm_stopped() {
|
|||||||
return 1
|
return 1
|
||||||
fi
|
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
|
qm shutdown "$VMID" --timeout 120 >/dev/null 2>&1 || true
|
||||||
|
|
||||||
local i
|
local i
|
||||||
@@ -157,6 +165,7 @@ ensure_vm_stopped() {
|
|||||||
|
|
||||||
if dialog --backtitle "ProxMenux" --title "$(translate "Shutdown timeout")" --yesno \
|
if dialog --backtitle "ProxMenux" --title "$(translate "Shutdown timeout")" --yesno \
|
||||||
"$(translate "Graceful shutdown timed out.")\n\n$(translate "Force stop VM now?")" 10 60; then
|
"$(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
|
qm stop "$VMID" >/dev/null 2>&1 || true
|
||||||
sleep 2
|
sleep 2
|
||||||
status=$(qm status "$VMID" 2>/dev/null | awk '{print $2}')
|
status=$(qm status "$VMID" 2>/dev/null | awk '{print $2}')
|
||||||
@@ -516,12 +525,17 @@ print_export_result() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
run_export() {
|
run_export() {
|
||||||
|
local FUNC_VERSION="1.0"
|
||||||
|
pmx_journal_context "run_export" "$FUNC_VERSION"
|
||||||
|
|
||||||
show_proxmenux_logo
|
show_proxmenux_logo
|
||||||
msg_title "$(translate "Export VM to OVA or OVF")"
|
msg_title "$(translate "Export VM to OVA or OVF")"
|
||||||
|
|
||||||
msg_ok "$(translate "VM selected:") $VMID ($VM_NAME)"
|
msg_ok "$(translate "VM selected:") $VMID ($VM_NAME)"
|
||||||
msg_ok "$(translate "Export mode:") ${EXPORT_MODE^^}"
|
msg_ok "$(translate "Export mode:") ${EXPORT_MODE^^}"
|
||||||
msg_ok "$(translate "Destination:") $DEST_DIR"
|
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
|
local ts vm_safe base_name
|
||||||
ts=$(date +%Y%m%d_%H%M%S)
|
ts=$(date +%Y%m%d_%H%M%S)
|
||||||
|
|||||||
@@ -49,6 +49,9 @@ INSTALL_HELPERS="$LOCAL_SCRIPTS/global/utils-install-functions.sh"
|
|||||||
|
|
||||||
[[ -f "$UTILS_FILE" ]] && source "$UTILS_FILE"
|
[[ -f "$UTILS_FILE" ]] && source "$UTILS_FILE"
|
||||||
[[ -f "$INSTALL_HELPERS" ]] && source "$INSTALL_HELPERS"
|
[[ -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
|
load_language
|
||||||
initialize_cache
|
initialize_cache
|
||||||
|
|
||||||
@@ -87,6 +90,9 @@ BRIDGE="vmbr0"
|
|||||||
# with "syntax error at or near ,". Returns 0 on success, 1 if install
|
# with "syntax error at or near ,". Returns 0 on success, 1 if install
|
||||||
# fails (caller is expected to abort with a clear error).
|
# fails (caller is expected to abort with a clear error).
|
||||||
ensure_gawk() {
|
ensure_gawk() {
|
||||||
|
local FUNC_VERSION="1.0"
|
||||||
|
pmx_journal_context "ensure_gawk" "$FUNC_VERSION"
|
||||||
|
|
||||||
if command -v gawk >/dev/null 2>&1; then
|
if command -v gawk >/dev/null 2>&1; then
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
@@ -111,7 +117,7 @@ ensure_gawk() {
|
|||||||
# Fallback when utils-install-functions.sh was not sourced.
|
# Fallback when utils-install-functions.sh was not sourced.
|
||||||
# Here we own the spinner: msg_info opens it, msg_ok / msg_error closes it.
|
# Here we own the spinner: msg_info opens it, msg_ok / msg_error closes it.
|
||||||
msg_info "$(translate "Installing gawk (required for OVF parsing)...")"
|
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")"
|
msg_ok "$(translate "gawk installed")"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
@@ -478,6 +484,9 @@ confirm_import() {
|
|||||||
# -------------------------------------------------------
|
# -------------------------------------------------------
|
||||||
|
|
||||||
run_import() {
|
run_import() {
|
||||||
|
local FUNC_VERSION="1.0"
|
||||||
|
pmx_journal_context "run_import" "$FUNC_VERSION"
|
||||||
|
|
||||||
show_proxmenux_logo
|
show_proxmenux_logo
|
||||||
msg_title "$(translate "Import VM from OVA or OVF")"
|
msg_title "$(translate "Import VM from OVA or OVF")"
|
||||||
|
|
||||||
@@ -488,6 +497,8 @@ run_import() {
|
|||||||
|
|
||||||
# 1. Create VM shell
|
# 1. Create VM shell
|
||||||
msg_info "$(translate "Creating VM...")"
|
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" \
|
if ! qm create "$NEW_VMID" \
|
||||||
--name "$NEW_VM_NAME" \
|
--name "$NEW_VM_NAME" \
|
||||||
--memory "$OVF_MEMORY_MB" \
|
--memory "$OVF_MEMORY_MB" \
|
||||||
@@ -624,6 +635,7 @@ print_import_result() {
|
|||||||
# -------------------------------------------------------
|
# -------------------------------------------------------
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
|
local FUNC_VERSION="1.0"
|
||||||
if ! command -v pveversion >/dev/null 2>&1; then
|
if ! command -v pveversion >/dev/null 2>&1; then
|
||||||
dialog --backtitle "$BACKTITLE" --title "$(translate "Error")" \
|
dialog --backtitle "$BACKTITLE" --title "$(translate "Error")" \
|
||||||
--msgbox "$(translate "This script must be run on a Proxmox host.")" 8 60
|
--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
|
--yesno "$(translate "Remove the partial VM ($NEW_VMID) and its imported disks?")" 8 60; then
|
||||||
clear
|
clear
|
||||||
msg_info "$(translate "Removing partial VM") $NEW_VMID..."
|
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
|
if qm destroy "$NEW_VMID" --destroy-unreferenced-disks 1 &>/dev/null; then
|
||||||
msg_ok "$(translate "Partial VM removed")"
|
msg_ok "$(translate "Partial VM removed")"
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -62,6 +62,9 @@ fi
|
|||||||
if [[ -f "$LOCAL_SCRIPTS/global/utils-install-functions.sh" ]]; then
|
if [[ -f "$LOCAL_SCRIPTS/global/utils-install-functions.sh" ]]; then
|
||||||
source "$LOCAL_SCRIPTS/global/utils-install-functions.sh"
|
source "$LOCAL_SCRIPTS/global/utils-install-functions.sh"
|
||||||
fi
|
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() {
|
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 s="/etc/apt/sources.list.d/pve-enterprise.sources"
|
||||||
local l="/etc/apt/sources.list.d/pve-enterprise.list"
|
local l="/etc/apt/sources.list.d/pve-enterprise.list"
|
||||||
if [[ -f "$s" ]]; then
|
if [[ -f "$s" ]]; then
|
||||||
if grep -qi '^Enabled:' "$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
|
else
|
||||||
echo "Enabled: false" >> "$s"
|
echo "Enabled: false" | pmx_append_file "$s"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
if [[ -f "$l" ]]; then
|
if [[ -f "$l" ]]; then
|
||||||
sed -i 's/^[[:space:]]*deb/# deb/' "$l"
|
pmx_edit_file "$l" 's/^[[:space:]]*deb/# deb/'
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
comment_legacy_pve8_lists() {
|
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
|
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
|
[[ -f "$f" ]] || continue
|
||||||
sed -i 's/^[[:space:]]*deb/# deb/' "$f" || true
|
pmx_edit_file "$f" 's/^[[:space:]]*deb/# deb/' || true
|
||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
comment_legacy_ceph_list() {
|
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"
|
local f="/etc/apt/sources.list.d/ceph.list"
|
||||||
[[ -f "$f" ]] || return 0
|
[[ -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() {
|
apt_update_with_repo_fallback() {
|
||||||
@@ -811,11 +820,11 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
FUNC_VERSION="1.0"
|
||||||
|
pmx_journal_context "upgrade_pve8_to_pve9" "$FUNC_VERSION"
|
||||||
if [[ "$DISABLE_AUDIT" == "1" ]]; then
|
if [[ "$DISABLE_AUDIT" == "1" ]]; then
|
||||||
append_step \
|
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}"
|
||||||
"Audit socket disabled or not required" \
|
|
||||||
"systemctl disable --now systemd-journald-audit.socket >/dev/null 2>&1 || true"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
||||||
@@ -856,10 +865,12 @@ fi
|
|||||||
# Step 4
|
# Step 4
|
||||||
# ---------------------------
|
# ---------------------------
|
||||||
|
|
||||||
|
FUNC_VERSION="1.0"
|
||||||
|
pmx_journal_context "upgrade_pve8_to_pve9" "$FUNC_VERSION"
|
||||||
OS_FILE="/etc/apt/sources.list"
|
OS_FILE="/etc/apt/sources.list"
|
||||||
if [[ -f "$OS_FILE" ]]; then
|
if [[ -f "$OS_FILE" ]]; then
|
||||||
msg_info "$(translate "Updating Debian Bookworm → Trixie in sources.list...")"
|
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")"
|
msg_ok "$(translate "sources.list updated to Trixie")"
|
||||||
else
|
else
|
||||||
msg_ok "$(translate "sources.list update skipped (no change)")"
|
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"
|
PVE_ENT_LIST="/etc/apt/sources.list.d/pve-enterprise.list"
|
||||||
msg_info "$(translate "Updating pve-enterprise.list (if present) to Trixie...")"
|
msg_info "$(translate "Updating pve-enterprise.list (if present) to Trixie...")"
|
||||||
if [[ -f "$PVE_ENT_LIST" ]]; then
|
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")"
|
msg_ok "$(translate "pve-enterprise.list updated to Trixie")"
|
||||||
else
|
else
|
||||||
msg_ok "$(translate "pve-enterprise.list update skipped (no change)")"
|
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...")"
|
msg_info "$(translate "Commenting any residual Bookworm lines in *.list...")"
|
||||||
for f in /etc/apt/sources.list.d/*.list; do
|
for f in /etc/apt/sources.list.d/*.list; do
|
||||||
[[ -f "$f" ]] || continue
|
[[ -f "$f" ]] || continue
|
||||||
sed -i '/bookworm/s/^/# /' "$f" || true
|
pmx_edit_file "$f" '/bookworm/s/^/# /' || true
|
||||||
done
|
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")"
|
msg_ok "$(translate "Residual Bookworm entries commented where applicable")"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 — `<<EOF > "$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())
|
||||||
@@ -33,6 +33,16 @@ assert.equal(cache.getLxcAppsCached(101).sidecar.apps[0].docker_available_versio
|
|||||||
assert.equal(requests, 0)
|
assert.equal(requests, 0)
|
||||||
|
|
||||||
const source = fs.readFileSync(path.join(root, 'components/virtual-machines.tsx'), 'utf8')
|
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 tree = ts.createSourceFile('vm.tsx', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
|
||||||
const pieces = []
|
const pieces = []
|
||||||
function walk(node) {
|
function walk(node) {
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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: <none>\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()
|
||||||
@@ -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.');
|
||||||
@@ -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; });
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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('<div class="exec-box">')[1].split('<div class="audit-counters">')[0];
|
||||||
|
assert(header.includes('<strong>4/5</strong>'));
|
||||||
|
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(`<strong>${expected}</strong>`));
|
||||||
|
const verifiedSection=doc.split('id="verified-checks"')[1]?.split('</table>')[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:'<img src=x onerror=alert(1)>',classification:'observation'}]};
|
||||||
|
assert(!buildAuditDocument({...input,findings:[dangerous]}).includes('<img src=x'));
|
||||||
|
}
|
||||||
|
const diagram=storageDiagram([{vmid:1,name:'one',disks:[{storage:'local'},{storage:'local'}],backups:[]}],{guests:'Guests',storage:'Storage',backup:'Jobs',unprotected:'No job'});
|
||||||
|
assert(!diagram.includes('>2</text>'));
|
||||||
|
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};
|
||||||
@@ -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()
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user