mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-29 18:16:43 +00:00
Merge pull request #367 from f3rs3n/fix/pbs-key-guidance
Clarify PBS encryption key guidance and PVE key locations
This commit is contained in:
@@ -115,6 +115,15 @@ class CommandDescriptionsTests(unittest.TestCase):
|
|||||||
report["recommendations"].setdefault(
|
report["recommendations"].setdefault(
|
||||||
key, source_report["recommendations"][key])
|
key, source_report["recommendations"][key])
|
||||||
path.write_text(json.dumps(temporary, ensure_ascii=False))
|
path.write_text(json.dumps(temporary, ensure_ascii=False))
|
||||||
|
# Model steady state after the bot fills these intentional new
|
||||||
|
# messages; keep repository locales and all other leaves intact.
|
||||||
|
if lang != "en":
|
||||||
|
messages = json.loads(path.read_text())
|
||||||
|
english = catalog("en")
|
||||||
|
for section, key in (("encryption", "pbsHelp"),
|
||||||
|
("keyfileActions", "pveKeyDescription")):
|
||||||
|
messages["backup"][section].setdefault(key, english["backup"][section][key])
|
||||||
|
path.write_text(json.dumps(messages, ensure_ascii=False))
|
||||||
before = {p: p.read_bytes() for p in root.glob("*/common.json")}
|
before = {p: p.read_bytes() for p in root.glob("*/common.json")}
|
||||||
argv = [str(SCRIPT), "--source", str(root / "en/common.json"),
|
argv = [str(SCRIPT), "--source", str(root / "en/common.json"),
|
||||||
"--messages-dir", str(root), "--languages", languages, "--sleep", "0"]
|
"--messages-dir", str(root), "--languages", languages, "--sleep", "0"]
|
||||||
|
|||||||
@@ -618,8 +618,11 @@ function PbsKeyfileActions({ pbsRepository }: { pbsRepository?: string }) {
|
|||||||
<div className="flex-1 space-y-1">
|
<div className="flex-1 space-y-1">
|
||||||
<div className="font-semibold text-emerald-300">{t("backup.keyfileActions.pveKeyDetected")}</div>
|
<div className="font-semibold text-emerald-300">{t("backup.keyfileActions.pveKeyDetected")}</div>
|
||||||
<div className="text-muted-foreground">
|
<div className="text-muted-foreground">
|
||||||
{t("backup.keyfileActions.pveKeyDescriptionBefore")} <code className="font-mono text-[10.5px]">{pveMatch.name}</code> {t("backup.keyfileActions.pveKeyDescriptionMiddle")}{" "}
|
{t("backup.keyfileActions.pveKeyDescription").split(/(\{storage\}|\{path\})/).map((part, index) =>
|
||||||
<code className="font-mono text-[10.5px] break-all">{pveMatch.path}</code>. {t("backup.keyfileActions.pveKeyDescriptionAfter")}
|
part === "{storage}" ? <code key={index} className="font-mono text-[10.5px] break-all">{pveMatch.name}</code>
|
||||||
|
: part === "{path}" ? <code key={index} className="font-mono text-[10.5px] break-all">{pveMatch.path}</code>
|
||||||
|
: part
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2131,8 +2134,11 @@ function InspectModal({
|
|||||||
<div className="flex-1 space-y-1">
|
<div className="flex-1 space-y-1">
|
||||||
<div className="font-semibold text-emerald-300">{t("backup.keyfileActions.pveKeyDetected")}</div>
|
<div className="font-semibold text-emerald-300">{t("backup.keyfileActions.pveKeyDetected")}</div>
|
||||||
<div className="text-muted-foreground">
|
<div className="text-muted-foreground">
|
||||||
{t("backup.keyfileActions.pveKeyDescriptionBefore")} <code className="font-mono text-[10.5px]">{pveMatchInspect.name}</code> {t("backup.keyfileActions.pveKeyDescriptionMiddle")}{" "}
|
{t("backup.keyfileActions.pveKeyDescription").split(/(\{storage\}|\{path\})/).map((part, index) =>
|
||||||
<code className="font-mono text-[10.5px] break-all">{pveMatchInspect.path}</code>. {t("backup.keyfileActions.pveKeyDescriptionAfter")}
|
part === "{storage}" ? <code key={index} className="font-mono text-[10.5px] break-all">{pveMatchInspect.name}</code>
|
||||||
|
: part === "{path}" ? <code key={index} className="font-mono text-[10.5px] break-all">{pveMatchInspect.path}</code>
|
||||||
|
: part
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -3879,7 +3885,14 @@ function CreateJobDialog({
|
|||||||
{t("backup.encryption.encryptBackups")}
|
{t("backup.encryption.encryptBackups")}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] text-muted-foreground mt-1">
|
<p className="text-[11px] text-muted-foreground mt-1">
|
||||||
{t("backup.encryption.pbsHelpBefore")} <code className="font-mono">--keyfile</code> {t("backup.encryption.pbsHelpMiddle")} <code className="font-mono">proxmox-backup-client backup</code>. {t("backup.encryption.pbsHelpAfter")} <code className="font-mono">/usr/local/share/proxmenux/pbs-key.conf</code> (chmod 0600) {t("backup.encryption.pbsHelpEnd")}
|
{t("backup.encryption.pbsHelp").split(/(\{keyfile\}|\{option\}|\{mode\})/).map((part, index) => {
|
||||||
|
const literals: Record<string, string> = {
|
||||||
|
"{keyfile}": "/usr/local/share/proxmenux/pbs-key.conf",
|
||||||
|
"{option}": "--keyfile",
|
||||||
|
"{mode}": "0600",
|
||||||
|
}
|
||||||
|
return literals[part] ? <code key={index} className="font-mono break-all">{literals[part]}</code> : part
|
||||||
|
})}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -4018,6 +4018,7 @@
|
|||||||
"passphraseNotSaved": "Passphrase is not saved.",
|
"passphraseNotSaved": "Passphrase is not saved.",
|
||||||
"passphraseSaved": "Passphrase saved",
|
"passphraseSaved": "Passphrase saved",
|
||||||
"pbsHelpAfter": "for encrypted PBS restores.",
|
"pbsHelpAfter": "for encrypted PBS restores.",
|
||||||
|
"pbsHelp": "Monitor uses {keyfile} with {option} for encrypted PBS backups and restores. The keyfile is stored with mode {mode}. Keep a safe copy outside this host.",
|
||||||
"pbsHelpBefore": "PBS encryption uses",
|
"pbsHelpBefore": "PBS encryption uses",
|
||||||
"pbsHelpEnd": "Keep a safe copy outside this host.",
|
"pbsHelpEnd": "Keep a safe copy outside this host.",
|
||||||
"pbsHelpMiddle": "as the keyfile",
|
"pbsHelpMiddle": "as the keyfile",
|
||||||
@@ -4212,6 +4213,7 @@
|
|||||||
"orUploadFromMachine": "or upload one from this machine.",
|
"orUploadFromMachine": "or upload one from this machine.",
|
||||||
"orUploadOwn": "or upload your own keyfile.",
|
"orUploadOwn": "or upload your own keyfile.",
|
||||||
"pveKeyDescriptionAfter": "and can be reused by Monitor.",
|
"pveKeyDescriptionAfter": "and can be reused by Monitor.",
|
||||||
|
"pveKeyDescription": "PVE storage {storage} has a keyfile at {path}. You can import it into Monitor.",
|
||||||
"pveKeyDescriptionBefore": "A PVE key exists at",
|
"pveKeyDescriptionBefore": "A PVE key exists at",
|
||||||
"pveKeyDescriptionMiddle": "It matches PVE storage encryption",
|
"pveKeyDescriptionMiddle": "It matches PVE storage encryption",
|
||||||
"pveKeyDetected": "PVE key detected",
|
"pveKeyDetected": "PVE key detected",
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
// Offline regression: execute the actual message JSX and provider lookup.
|
||||||
|
// Run with Node 22+, NODE_PATH pointing to a TypeScript installation.
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const path = require('node:path')
|
||||||
|
const vm = require('node:vm')
|
||||||
|
const test = require('node:test')
|
||||||
|
const ts = require('typescript')
|
||||||
|
const root = path.resolve(__dirname, '..')
|
||||||
|
const source = fs.readFileSync(path.join(root, 'components/host-backup.tsx'), 'utf8')
|
||||||
|
const provider = fs.readFileSync(path.join(root, 'lib/i18n/provider.tsx'), 'utf8')
|
||||||
|
const en = JSON.parse(fs.readFileSync(path.join(root, 'messages/en/common.json')))
|
||||||
|
const ast = ts.createSourceFile('host-backup.tsx', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
|
||||||
|
function compile(text) {
|
||||||
|
return ts.transpileModule(text, { compilerOptions: { jsx: ts.JsxEmit.React, module: ts.ModuleKind.CommonJS } }).outputText
|
||||||
|
}
|
||||||
|
function translator(locale) {
|
||||||
|
const pure = provider.slice(provider.indexOf('function getMessage('), provider.indexOf('export function I18nProvider'))
|
||||||
|
const callback = provider.slice(provider.indexOf('(key: string, params?: TranslationParams) => {', provider.indexOf('const t = useCallback')), provider.indexOf('\n },', provider.indexOf('const t = useCallback')) + 6)
|
||||||
|
return vm.runInNewContext(compile(`${pure}\nconst t = ${callback}; t`), { MESSAGE_CATALOG: { en, fixture: locale }, language: 'fixture' })
|
||||||
|
}
|
||||||
|
const React = { createElement: (tag, props, ...children) => ({ tag, props, children }) }
|
||||||
|
function messages(marker) {
|
||||||
|
const found = []
|
||||||
|
function visit(node) {
|
||||||
|
if (ts.isJsxElement(node) && ['p', 'div'].includes(node.openingElement.tagName.getText(ast)) && node.getText(ast).includes(marker)) {
|
||||||
|
let nested = false
|
||||||
|
function check(child) {
|
||||||
|
if (ts.isJsxElement(child) && ['p', 'div'].includes(child.openingElement.tagName.getText(ast)) && child.getText(ast).includes(marker)) nested = true
|
||||||
|
ts.forEachChild(child, check)
|
||||||
|
}
|
||||||
|
node.children.forEach(check)
|
||||||
|
if (!nested) found.push(node.getText(ast))
|
||||||
|
}
|
||||||
|
ts.forEachChild(node, visit)
|
||||||
|
}
|
||||||
|
visit(ast)
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
function render(jsx, locale = {}, match = { name: 'STORAGE<&>', path: '/etc/pve/priv/storage/DIFFERENT<&>.enc' }) {
|
||||||
|
return vm.runInNewContext(compile(`const result = (${jsx}); result`), { React, t: translator(locale), pveMatch: match, pveMatchInspect: match })
|
||||||
|
}
|
||||||
|
function text(node) {
|
||||||
|
if (Array.isArray(node)) return node.map(text).join('')
|
||||||
|
if (node == null || typeof node === 'boolean') return ''
|
||||||
|
return typeof node === 'object' ? text(node.children) : String(node)
|
||||||
|
}
|
||||||
|
function codes(node) {
|
||||||
|
if (Array.isArray(node)) return node.flatMap(codes)
|
||||||
|
if (!node || typeof node !== 'object') return []
|
||||||
|
return node.tag === 'code' ? [node] : codes(node.children)
|
||||||
|
}
|
||||||
|
const help = 'Monitor uses /usr/local/share/proxmenux/pbs-key.conf with --keyfile for encrypted PBS backups and restores. The keyfile is stored with mode 0600. Keep a safe copy outside this host.'
|
||||||
|
test('PBS help is a complete message with unconditional missing-key English fallback', () => {
|
||||||
|
const jsx = messages('backup.encryption.pbsHelp')
|
||||||
|
assert.equal(jsx.length, 1)
|
||||||
|
const output = render(jsx[0])
|
||||||
|
assert.equal(text(output), help)
|
||||||
|
assert.deepEqual(codes(output).map(text), ['/usr/local/share/proxmenux/pbs-key.conf', '--keyfile', '0600'])
|
||||||
|
})
|
||||||
|
test('both PVE dialogs distinguish storage from path with missing-key fallback', () => {
|
||||||
|
const jsx = messages('backup.keyfileActions.pveKeyDescription')
|
||||||
|
assert.equal(jsx.length, 2)
|
||||||
|
for (const message of jsx) {
|
||||||
|
const output = render(message)
|
||||||
|
assert.equal(text(output), 'PVE storage STORAGE<&> has a keyfile at /etc/pve/priv/storage/DIFFERENT<&>.enc. You can import it into Monitor.')
|
||||||
|
assert.deepEqual(codes(output).map(text), ['STORAGE<&>', '/etc/pve/priv/storage/DIFFERENT<&>.enc'])
|
||||||
|
assert.match(codes(output)[1].props.className, /break-all/)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
test('whole-message translations can reorder rich placeholders in all three consumers', () => {
|
||||||
|
const locale = { backup: {
|
||||||
|
encryption: { pbsHelp: 'Mode {mode}; option {option}; file {keyfile}.' },
|
||||||
|
keyfileActions: { pveKeyDescription: 'At {path}: key for {storage}.' },
|
||||||
|
} }
|
||||||
|
const helpOutput = render(messages('backup.encryption.pbsHelp')[0], locale)
|
||||||
|
assert.equal(text(helpOutput), 'Mode 0600; option --keyfile; file /usr/local/share/proxmenux/pbs-key.conf.')
|
||||||
|
assert.deepEqual(codes(helpOutput).map(text), ['0600', '--keyfile', '/usr/local/share/proxmenux/pbs-key.conf'])
|
||||||
|
for (const jsx of messages('backup.keyfileActions.pveKeyDescription')) {
|
||||||
|
const output = render(jsx, locale)
|
||||||
|
assert.equal(text(output), 'At /etc/pve/priv/storage/DIFFERENT<&>.enc: key for STORAGE<&>.')
|
||||||
|
assert.deepEqual(codes(output).map(text), ['/etc/pve/priv/storage/DIFFERENT<&>.enc', 'STORAGE<&>'])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
test('shipped locales fall back to complete guidance without changing their catalogs', () => {
|
||||||
|
for (const lang of fs.readdirSync(path.join(root, 'messages')).filter(name => fs.statSync(path.join(root, 'messages', name)).isDirectory())) {
|
||||||
|
const locale = JSON.parse(fs.readFileSync(path.join(root, 'messages', lang, 'common.json')))
|
||||||
|
for (const jsx of messages('backup.keyfileActions.pveKeyDescription')) assert.ok(text(render(jsx, locale)).includes('STORAGE<&>'))
|
||||||
|
assert.ok(text(render(messages('backup.encryption.pbsHelp')[0], locale)).includes('--keyfile'))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
test('both discovery consumers retain matching-only selection and absent handling', () => {
|
||||||
|
for (const [name, input] of [['pveMatch', 'pveDiscover'], ['pveMatchInspect', 'pveDiscoverInspect']]) {
|
||||||
|
let initializer
|
||||||
|
function visit(node) {
|
||||||
|
if (ts.isVariableDeclaration(node) && node.name.getText(ast) === name) initializer = node.initializer.getText(ast)
|
||||||
|
ts.forEachChild(node, visit)
|
||||||
|
}
|
||||||
|
visit(ast)
|
||||||
|
assert.ok(initializer)
|
||||||
|
for (const data of [undefined, { entries: [] }, { entries: [{ matches_repository: false }] }]) {
|
||||||
|
assert.equal(vm.runInNewContext(compile(`const result = ${initializer}; result`), { [input]: data }), null)
|
||||||
|
}
|
||||||
|
const match = { name: 'chosen', path: '/chosen.enc', matches_repository: true }
|
||||||
|
assert.equal(vm.runInNewContext(compile(`const result = ${initializer}; result`), { [input]: { entries: [{ matches_repository: false }, match] } }), match)
|
||||||
|
assert.ok(source.includes(`{${name} && (`))
|
||||||
|
assert.ok(source.includes(`setImportPath(${name}.path)`))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
module.exports = { messages, render, text, codes }
|
||||||
Reference in New Issue
Block a user