Add beta 1.2.2.2

This commit is contained in:
MacRimi
2026-06-10 19:05:13 +02:00
parent 165e8c9636
commit 4dc8be7387
27 changed files with 1938 additions and 1397 deletions

View File

@@ -0,0 +1,362 @@
#!/usr/bin/env python3
"""
Build the ProxMenux translation cache from translate calls in scripts/.
The generated JSON keeps the same shape used by scripts/utils.sh:
{
"Original English text": {
"es": "Translated text",
"fr": "Translated text"
}
}
"""
from __future__ import annotations
import argparse
import ast
import json
import os
import subprocess
import re
import sys
import time
from pathlib import Path
from typing import Iterable
from urllib.parse import quote
from urllib.request import Request, urlopen
DEFAULT_LANGUAGES = ("es", "fr", "de", "it", "pt")
DEFAULT_CONTEXT = "Context: Technical message for Proxmox and IT. Translate:"
TRANSLATE_CALL_RE = re.compile(
r"""translate\s+(?P<quote>["'])(?P<text>(?:\\.|(?! (?P=quote) ).)*?)(?P=quote)""",
re.VERBOSE | re.DOTALL,
)
def iter_script_files(scripts_dir: Path) -> Iterable[Path]:
for path in sorted(scripts_dir.rglob("*")):
if not path.is_file():
continue
if path.name == "utils.sh":
continue
if path.suffix not in {".sh", ".func"}:
continue
yield path
def decode_shell_string(raw: str, quote_char: str) -> str:
if quote_char == "'":
return raw
try:
return ast.literal_eval(f'"{raw}"')
except Exception:
return raw.replace(r"\"", '"').replace(r"\\", "\\")
def extract_translate_texts(scripts_dir: Path) -> list[str]:
found: dict[str, None] = {}
for path in iter_script_files(scripts_dir):
try:
content = path.read_text(encoding="utf-8")
except UnicodeDecodeError:
content = path.read_text(encoding="utf-8", errors="replace")
for match in TRANSLATE_CALL_RE.finditer(content):
text = decode_shell_string(match.group("text"), match.group("quote"))
text = text.strip()
if text and "$" not in text and "`" not in text:
found.setdefault(text, None)
return sorted(found)
def translate_googletrans(text: str, dest_lang: str, context: str) -> str:
try:
from googletrans import Translator # type: ignore
except Exception as exc:
raise RuntimeError(
"googletrans is not installed. Install googletrans==4.0.0-rc1 "
"or run with --provider google-web."
) from exc
translator = Translator()
full_text = f"{context} {text}".strip()
return translator.translate(full_text, dest=dest_lang).text
def translate_google_web(text: str, dest_lang: str, context: str, timeout: int) -> str:
# The public Google endpoint is not prompt-aware: if we prepend context,
# it often translates and returns that context as part of the result.
full_text = text
url = (
"https://translate.googleapis.com/translate_a/single"
f"?client=gtx&sl=en&tl={quote(dest_lang)}&dt=t&q={quote(full_text)}"
)
req = Request(url, headers={"User-Agent": "ProxMenux translation cache builder"})
with urlopen(req, timeout=timeout) as response:
payload = json.loads(response.read().decode("utf-8"))
return "".join(part[0] for part in payload[0] if part and part[0])
def translate_appimage(
text: str,
dest_lang: str,
context: str,
timeout: int,
appimage_path: Path,
) -> str:
if not appimage_path.exists():
prev_path = appimage_path.with_name(appimage_path.name + ".prev")
if prev_path.exists():
appimage_path = prev_path
else:
raise FileNotFoundError(f"AppImage not found: {appimage_path}")
req = {
"text": text,
"dest_lang": dest_lang,
"context": context,
"cache_file": "",
}
env = os.environ.copy()
env.setdefault("APPIMAGE_EXTRACT_AND_RUN", "1")
completed = subprocess.run(
[str(appimage_path), "--translate"],
input=json.dumps(req, ensure_ascii=False),
text=True,
capture_output=True,
timeout=timeout,
check=False,
env=env,
)
if completed.returncode != 0:
raise RuntimeError((completed.stderr or completed.stdout).strip())
# AppRun may print a startup line before translate_cli.py emits JSON.
for line in reversed(completed.stdout.splitlines()):
line = line.strip()
if not line.startswith("{"):
continue
payload = json.loads(line)
if payload.get("success"):
return str(payload.get("text", text))
raise RuntimeError(str(payload.get("error", "unknown AppImage translation error")))
raise RuntimeError(f"AppImage did not return JSON: {completed.stdout.strip()}")
def clean_translation(value: str) -> str:
separator = r"[\s\u00a0]*[:]"
translate_labels = "Translate|Traducir|Traduire|Übersetzen|Tradurre|Traduci|Traduzir"
context_labels = "Context|Contexto|Contexte|Kontext|Contesto"
value = re.sub(
rf"^.*?({translate_labels}){separator}",
"",
value,
flags=re.IGNORECASE | re.DOTALL,
)
value = re.sub(
rf"^.*?({context_labels}){separator}.*?({translate_labels}){separator}",
"",
value,
flags=re.IGNORECASE | re.DOTALL,
)
value = re.sub(
rf"^.*?({context_labels}){separator}",
"",
value,
flags=re.IGNORECASE | re.DOTALL,
)
return value.strip()
def translate_text(
text: str,
dest_lang: str,
provider: str,
context: str,
timeout: int,
appimage_path: Path,
) -> str:
if provider == "googletrans":
translated = translate_googletrans(text, dest_lang, context)
elif provider == "google-web":
translated = translate_google_web(text, dest_lang, context, timeout)
elif provider == "appimage":
translated = translate_appimage(text, dest_lang, context, timeout, appimage_path)
else:
raise ValueError(f"Unknown provider: {provider}")
return clean_translation(translated) or text
def load_language_cache(path: Path) -> dict[str, str]:
if not path.exists():
return {}
try:
data = json.loads(path.read_text(encoding="utf-8"))
except Exception:
return {}
if not isinstance(data, dict):
return {}
return {str(text): str(value) for text, value in data.items()}
def write_language_cache(path: Path, cache: dict[str, str]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = path.with_suffix(path.suffix + ".tmp")
tmp_path.write_text(
json.dumps(cache, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
tmp_path.replace(path)
def build_arg_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Extract translate calls from scripts/ and build json/cache.json."
)
parser.add_argument("--scripts-dir", default="scripts", type=Path)
parser.add_argument(
"--output-dir",
default=Path("lang"),
type=Path,
help="Directory where per-language JSON files are written. Default: lang",
)
parser.add_argument(
"--output",
default=None,
type=Path,
help="Deprecated combined cache path. If used, per-language files are written next to it under its parent directory.",
)
parser.add_argument(
"--languages",
default=",".join(DEFAULT_LANGUAGES),
help="Comma-separated destination languages. Default: es,fr,de,it,pt",
)
parser.add_argument(
"--provider",
choices=("appimage", "googletrans", "google-web"),
default="appimage",
help="Translation provider to use. Default: appimage",
)
parser.add_argument(
"--appimage-path",
default=Path("/usr/local/share/proxmenux/ProxMenux-Monitor.AppImage"),
type=Path,
help="Path to the ProxMenux AppImage when using --provider appimage.",
)
parser.add_argument("--context", default=DEFAULT_CONTEXT)
parser.add_argument("--timeout", default=30, type=int)
parser.add_argument("--sleep", default=0.15, type=float)
parser.add_argument(
"--refresh",
action="store_true",
help="Translate all entries again instead of reusing existing cache values.",
)
parser.add_argument(
"--extract-only",
action="store_true",
help="Only update the cache keys; missing translations are left empty.",
)
parser.add_argument(
"--limit",
type=int,
default=0,
help="Only process the first N extracted strings. Useful for test runs.",
)
parser.add_argument(
"--save-every",
type=int,
default=1,
help="Write the output JSON every N translated items. Default: 1",
)
return parser
def main() -> int:
args = build_arg_parser().parse_args()
scripts_dir = args.scripts_dir.resolve()
if args.output is not None:
output_dir = args.output.resolve().parent / "lang"
else:
output_dir = args.output_dir.resolve()
languages = [lang.strip() for lang in args.languages.split(",") if lang.strip()]
if not scripts_dir.is_dir():
print(f"Scripts directory not found: {scripts_dir}", file=sys.stderr)
return 1
if not languages:
print("No destination languages selected.", file=sys.stderr)
return 1
texts = extract_translate_texts(scripts_dir)
if args.limit > 0:
texts = texts[: args.limit]
existing_by_lang = {
lang: load_language_cache(output_dir / f"{lang}.json")
for lang in languages
}
next_by_lang: dict[str, dict[str, str]] = {lang: {} for lang in languages}
print(f"Found {len(texts)} unique translate strings.", flush=True)
print(f"Output directory: {output_dir}", flush=True)
print(f"Languages: {', '.join(languages)}", flush=True)
failures: list[tuple[str, str, str]] = []
total = len(texts) * len(languages)
done = 0
for lang in languages:
existing = existing_by_lang.get(lang, {})
print(f"Starting language: {lang}", flush=True)
for index, text in enumerate(texts, start=1):
done += 1
if not args.refresh and existing.get(text):
next_by_lang[lang][text] = existing[text]
continue
if args.extract_only:
next_by_lang[lang][text] = existing.get(text, "")
continue
print(f"[{done}/{total}] {lang} ({index}/{len(texts)}): {text[:80]}", flush=True)
try:
next_by_lang[lang][text] = translate_text(
text,
lang,
args.provider,
args.context,
args.timeout,
args.appimage_path,
)
print(f" => {next_by_lang[lang][text][:100]}", flush=True)
except Exception as exc:
next_by_lang[lang][text] = existing.get(text, text)
failures.append((text, lang, str(exc)))
print(f" failed: {exc}", file=sys.stderr, flush=True)
if args.save_every > 0 and index % args.save_every == 0:
write_language_cache(output_dir / f"{lang}.json", next_by_lang[lang])
time.sleep(args.sleep)
write_language_cache(output_dir / f"{lang}.json", next_by_lang[lang])
print(f"Completed language: {lang}", flush=True)
for lang, cache in next_by_lang.items():
write_language_cache(output_dir / f"{lang}.json", cache)
if failures:
print(f"Completed with {len(failures)} translation failures.", file=sys.stderr, flush=True)
for text, lang, error in failures[:20]:
print(f"- {lang}: {text[:80]} -> {error}", file=sys.stderr, flush=True)
if len(failures) > 20:
print(f"... and {len(failures) - 20} more.", file=sys.stderr, flush=True)
return 2
print("Translation cache generated successfully.", flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,91 @@
name: Build translation cache
# Regenerates lang/*.json whenever a bash script under scripts/ changes.
# The runtime translate() in scripts/utils.sh reads these JSON files and
# falls back to the English source on miss, so keeping them up-to-date is
# what makes ProxMenux multilingual without any runtime googletrans
# dependency on the user's host.
#
# Triggers:
# - push to develop touching scripts/**/*.sh
# - manual via workflow_dispatch (force --refresh)
on:
push:
branches: [develop]
paths:
- 'scripts/**/*.sh'
- '.github/scripts/build_translation_cache.py'
- '.github/workflows/build-translation-cache.yml'
workflow_dispatch:
inputs:
refresh:
description: 'Re-translate every entry (ignore cached values)'
type: boolean
default: false
# Avoid two cache rebuilds from racing each other on the same branch and
# fighting over the auto-commit.
concurrency:
group: build-translation-cache-${{ github.ref }}
cancel-in-progress: false
jobs:
rebuild-cache:
runs-on: ubuntu-latest
permissions:
contents: write # auto-commit lang/*.json back to develop
steps:
- name: Checkout develop
uses: actions/checkout@v4
with:
ref: develop
# Need full history so the auto-commit doesn't fail when the
# cache job runs minutes after the trigger push (GH default
# fetch-depth=1 sometimes diverges from origin/develop after a
# quick follow-up push).
fetch-depth: 0
# Use a PAT (or default GITHUB_TOKEN if branch protections allow
# it) so the push back to develop actually fires later steps
# (workflow runs from auto-commits) if you ever need them.
token: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install googletrans
run: |
python -m pip install --upgrade pip
# 4.0.0-rc1 is the same pin that build_translation_cache.py
# was written against. Bump in lockstep with the script.
pip install 'googletrans==4.0.0-rc1' 'httpx==0.13.3' 'httpcore==0.9.1' 'h11==0.9.0'
- name: Regenerate lang/*.json
run: |
REFRESH_FLAG=""
if [[ "${{ github.event.inputs.refresh }}" == "true" ]]; then
REFRESH_FLAG="--refresh"
fi
python .github/scripts/build_translation_cache.py \
--scripts-dir scripts \
--output-dir lang \
--provider googletrans \
$REFRESH_FLAG
- name: Commit + push if changed
run: |
if git diff --quiet -- lang/; then
echo "No translation changes — skipping commit."
exit 0
fi
git config user.name "ProxMenuxBot"
git config user.email "bot@proxmenux.local"
git add lang/
git commit -m "chore(lang): auto-rebuild translation cache
Source: ${GITHUB_SHA::7}
Triggered by: ${{ github.event_name }}"
git push origin develop

View File

@@ -166,129 +166,38 @@ else
echo "⚠️ config directory not found" echo "⚠️ config directory not found"
fi fi
echo "📋 Adding translation support..." # Translation handling lives in scripts/utils.sh now. It reads
cat > "$APP_DIR/usr/bin/translate_cli.py" << 'PYEOF' # /usr/local/share/proxmenux/lang/<lang>.json (pre-built by the
#!/usr/bin/env python3 # build_translation_cache.py CI job) and falls back to the English
# -*- coding: utf-8 -*- # source string on miss. The Monitor AppImage no longer ships the
""" # runtime translate_cli.py — the JSON files belong to the host install,
ProxMenux translate CLI # not to the Flask dashboard.
stdin JSON -> {"text":"...", "dest_lang":"es", "context":"...", "cache_file":"/usr/local/share/proxmenux/cache.json"}
stdout JSON -> {"success":true,"text":"..."} or {"success":false,"error":"..."}
"""
import sys, json, re
from pathlib import Path
# Ensure embedded site-packages are discoverable # ── Borg standalone binary ─────────────────────────────────────────
HERE = Path(__file__).resolve().parents[2] # .../AppDir # Ship the official borg standalone binary inside the AppImage so the
DIST = HERE / "usr" / "lib" / "python3" / "dist-packages" # host-backup / restore workflows can run without an internet round-trip
SITE = HERE / "usr" / "lib" / "python3" / "site-packages" # at install time. Pinned to the same version that proxmenux's
for p in (str(DIST), str(SITE)): # hb_ensure_borg used to download on demand — kept in lockstep so both
if p not in sys.path: # code paths see the same version semantics. SHA256 is the upstream
sys.path.insert(0, p) # release checksum; bump both together.
BORG_VERSION="1.2.8"
# Python 3.13 compat: inline 'cgi' shim BORG_URL="https://github.com/borgbackup/borg/releases/download/${BORG_VERSION}/borg-linux64"
try: BORG_SHA256="cfa50fb704a93d3a4fa258120966345fddb394f960dca7c47fcb774d0172f40b"
import cgi echo "📦 Downloading borg ${BORG_VERSION} into AppImage..."
except Exception: BORG_TARGET="$APP_DIR/usr/bin/borg"
import types, html if wget -qO "$BORG_TARGET" "$BORG_URL"; then
def _parse_header(value: str): if echo "${BORG_SHA256} ${BORG_TARGET}" | sha256sum -c - >/dev/null 2>&1; then
value = str(value or "") chmod +x "$BORG_TARGET"
parts = [p.strip() for p in value.split(";")] echo "✅ borg ${BORG_VERSION} bundled (sha256 verified)"
if not parts: else
return "", {} echo "❌ borg sha256 verification failed — removing"
key = parts[0].lower() rm -f "$BORG_TARGET"
params = {} exit 1
for item in parts[1:]: fi
if not item: else
continue echo "❌ borg download failed from $BORG_URL"
if "=" in item: exit 1
k, v = item.split("=", 1) fi
k = k.strip().lower()
v = v.strip().strip('"').strip("'")
params[k] = v
else:
params[item.strip().lower()] = ""
return key, params
cgi = types.SimpleNamespace(parse_header=_parse_header, escape=html.escape)
try:
from googletrans import Translator
except Exception as e:
print(json.dumps({"success": False, "error": f"ImportError: {e}"}))
sys.exit(0)
def load_json_stdin():
try:
return json.load(sys.stdin)
except Exception as e:
print(json.dumps({"success": False, "error": f"Invalid JSON input: {e}"}))
sys.exit(0)
def ensure_cache(path: Path):
try:
path.parent.mkdir(parents=True, exist_ok=True)
if not path.exists():
path.write_text("{}", encoding="utf-8")
json.loads(path.read_text(encoding="utf-8") or "{}")
except Exception:
path.write_text("{}", encoding="utf-8")
def read_cache(path: Path):
try:
return json.loads(path.read_text(encoding="utf-8") or "{}")
except Exception:
return {}
def write_cache(path: Path, cache: dict):
tmp = path.with_suffix(".tmp")
tmp.write_text(json.dumps(cache, ensure_ascii=False), encoding="utf-8")
tmp.replace(path)
def clean_translated(s: str) -> str:
s = re.sub(r'^.*?(Translate:|Traducir:|Traduire:|Übersetzen:|Tradurre:|Traduzir:|翻译:|翻訳:)', '', s, flags=re.IGNORECASE | re.DOTALL).strip()
s = re.sub(r'^.*?(Context:|Contexto:|Contexte:|Kontext:|Contesto:|上下文:|コンテキスト:).*?:', '', s, flags=re.IGNORECASE | re.DOTALL).strip()
return s.strip()
def main():
req = load_json_stdin()
text = req.get("text", "")
dest = req.get("dest_lang", "en") or "en"
context = req.get("context", "")
cache_file = Path(req.get("cache_file", "")) if req.get("cache_file") else None
if dest == "en":
print(json.dumps({"success": True, "text": text}))
return
cache = {}
if cache_file:
ensure_cache(cache_file)
cache = read_cache(cache_file)
if text in cache and (dest in cache[text] or "notranslate" in cache[text]):
found = cache[text].get(dest) or cache[text].get("notranslate")
print(json.dumps({"success": True, "text": found}))
return
try:
full = (context + " " + text).strip() if context else text
tr = Translator()
result = tr.translate(full, dest=dest).text
result = clean_translated(result)
if cache_file:
cache.setdefault(text, {})
cache[text][dest] = result
write_cache(cache_file, cache)
print(json.dumps({"success": True, "text": result}))
except Exception as e:
print(json.dumps({"success": False, "error": str(e)}))
if __name__ == "__main__":
main()
PYEOF
chmod +x "$APP_DIR/usr/bin/translate_cli.py"
# Copy Next.js build # Copy Next.js build
echo "📋 Copying web dashboard..." echo "📋 Copying web dashboard..."
@@ -332,7 +241,7 @@ cat > "$APP_DIR/proxmenux-monitor.desktop" << EOF
[Desktop Entry] [Desktop Entry]
Type=Application Type=Application
Name=ProxMenux Monitor Name=ProxMenux Monitor
Comment=Proxmox System Monitoring Dashboard with Translation Support Comment=Proxmox System Monitoring Dashboard
Exec=AppRun Exec=AppRun
Icon=proxmenux-monitor Icon=proxmenux-monitor
Categories=System;Monitor; Categories=System;Monitor;
@@ -361,14 +270,12 @@ if [ -f "$APP_DIR/proxmenux-monitor.png" ]; then
fi fi
echo "📦 Installing Python dependencies..." echo "📦 Installing Python dependencies..."
# Phase 1: Install googletrans with its old dependencies # Flask/WebSocket dependencies for the Monitor dashboard. The previous
pip3 install --target "$APP_DIR/usr/lib/python3/dist-packages" \ # Phase-1 (googletrans==4.0.0-rc1 + httpx 0.13.3 + httpcore 0.9.1 +
googletrans==4.0.0-rc1 \ # h11 0.9.0) is gone — translation is now a static-lookup feature on
httpx==0.13.3 \ # the host, so the AppImage no longer needs any runtime translator.
httpcore==0.9.1 \ # Removing those pins also unblocks the h11>=0.14.0 family without the
h11==0.9.0 || true # conflict workaround we used to ship.
# Phase 2: Install modern Flask/WebSocket dependencies (will upgrade h11 and related packages)
# Note: cryptography removed due to Python version compatibility issues (PyO3 modules) # Note: cryptography removed due to Python version compatibility issues (PyO3 modules)
pip3 install --target "$APP_DIR/usr/lib/python3/dist-packages" --upgrade --no-deps \ pip3 install --target "$APP_DIR/usr/lib/python3/dist-packages" --upgrade --no-deps \
flask \ flask \
@@ -380,7 +287,7 @@ pip3 install --target "$APP_DIR/usr/lib/python3/dist-packages" --upgrade --no-de
segno \ segno \
beautifulsoup4 beautifulsoup4
# Phase 3: Install WebSocket with newer h11 # WebSocket with modern h11 (no need for the legacy pin anymore)
pip3 install --target "$APP_DIR/usr/lib/python3/dist-packages" --upgrade \ pip3 install --target "$APP_DIR/usr/lib/python3/dist-packages" --upgrade \
h11>=0.14.0 \ h11>=0.14.0 \
wsproto>=1.2.0 \ wsproto>=1.2.0 \

View File

@@ -1407,3 +1407,49 @@ def internal_shutdown_event():
return jsonify({'success': True, 'event_type': event_type}), 200 return jsonify({'success': True, 'event_type': event_type}), 200
except Exception as e: except Exception as e:
return jsonify({'error': 'internal_error', 'detail': str(e)}), 500 return jsonify({'error': 'internal_error', 'detail': str(e)}), 500
# ─── Internal Restore Event Endpoint ─────────────────────────────
@notification_bp.route('/api/internal/restore-event', methods=['POST'])
def internal_restore_event():
"""
Internal endpoint called by apply_cluster_postboot.sh when the post-boot
dispatcher finishes. Tells the user the backgrounded restore tasks
(DKMS compile, apt installs, cluster apply, ...) are done so commands
like nvidia-smi now work.
Only accepts requests from localhost (127.0.0.1) for security.
"""
remote_addr = request.remote_addr
if remote_addr not in ('127.0.0.1', '::1', 'localhost'):
return jsonify({'error': 'forbidden', 'detail': 'localhost only'}), 403
try:
data = request.get_json(silent=True) or {}
hostname = data.get('hostname', 'unknown')
guests = data.get('guests', '0')
stubs = data.get('stubs', '0')
stale_nodes = data.get('stale_nodes', '0')
components = data.get('components', 'none')
duration = data.get('duration', 'unknown')
notification_manager.emit_event(
event_type='system_restore_completed',
severity='INFO',
data={
'hostname': hostname,
'guests': guests,
'stubs': stubs,
'stale_nodes': stale_nodes,
'components': components,
'duration': duration,
},
source='proxmenux',
entity='node',
entity_id='',
)
return jsonify({'success': True, 'event_type': 'system_restore_completed'}), 200
except Exception as e:
return jsonify({'error': 'internal_error', 'detail': str(e)}), 500

View File

@@ -868,6 +868,13 @@ TEMPLATES = {
'group': 'services', 'group': 'services',
'default_enabled': True, 'default_enabled': True,
}, },
'system_restore_completed': {
'title': '{hostname}: Host restore finished',
'body': 'Post-restore tasks completed in background.\n\nGuests applied: {guests}\nBind-mount stubs: {stubs}\nStale node dirs removed: {stale_nodes}\nComponents reinstalled: {components}\nDuration: {duration}\n\nThe node is now fully ready to use.',
'label': 'Host restore completed',
'group': 'services',
'default_enabled': True,
},
'system_problem': { 'system_problem': {
'title': '{hostname}: System problem detected', 'title': '{hostname}: System problem detected',
'body': 'A system-level problem has been detected.\nReason: {reason}', 'body': 'A system-level problem has been detected.\nReason: {reason}',
@@ -1604,6 +1611,7 @@ EVENT_EMOJI = {
'system_startup': '\U0001F680', # rocket (startup) 'system_startup': '\U0001F680', # rocket (startup)
'system_shutdown': '\u23FB\uFE0F', # power symbol (Unicode) 'system_shutdown': '\u23FB\uFE0F', # power symbol (Unicode)
'system_reboot': '\U0001F504', 'system_reboot': '\U0001F504',
'system_restore_completed': '', # check mark
'system_problem': '\u26A0\uFE0F', 'system_problem': '\u26A0\uFE0F',
'service_fail': '\u274C', 'service_fail': '\u274C',
'oom_kill': '\U0001F4A3', # bomb 'oom_kill': '\U0001F4A3', # bomb

View File

@@ -44,11 +44,15 @@ LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
INSTALL_DIR="/usr/local/bin" INSTALL_DIR="/usr/local/bin"
BASE_DIR="/usr/local/share/proxmenux" BASE_DIR="/usr/local/share/proxmenux"
CONFIG_FILE="$BASE_DIR/config.json" CONFIG_FILE="$BASE_DIR/config.json"
CACHE_FILE="$BASE_DIR/cache.json"
UTILS_FILE="$BASE_DIR/utils.sh" UTILS_FILE="$BASE_DIR/utils.sh"
LOCAL_VERSION_FILE="$BASE_DIR/version.txt" LOCAL_VERSION_FILE="$BASE_DIR/version.txt"
MENU_SCRIPT="menu" MENU_SCRIPT="menu"
VENV_PATH="/opt/googletrans-env"
# Legacy path that existed during the Python+googletrans era. The current
# translate flow uses pre-generated JSON files in lang/ — no virtualenv,
# no online translation at runtime — so this path is purged on install
# if present. Kept as a literal here so the cleanup is grep-able.
LEGACY_VENV_PATH="/opt/googletrans-env"
MONITOR_INSTALL_DIR="$BASE_DIR" MONITOR_INSTALL_DIR="$BASE_DIR"
MONITOR_RUNTIME_DIR="$BASE_DIR/monitor-app" MONITOR_RUNTIME_DIR="$BASE_DIR/monitor-app"
@@ -272,10 +276,6 @@ cleanup_corrupted_files() {
echo "Cleaning up corrupted configuration file..." echo "Cleaning up corrupted configuration file..."
rm -f "$CONFIG_FILE" rm -f "$CONFIG_FILE"
fi fi
if [ -f "$CACHE_FILE" ] && ! jq empty "$CACHE_FILE" >/dev/null 2>&1; then
echo "Cleaning up corrupted cache file..."
rm -f "$CACHE_FILE"
fi
} }
# Cleanup function # Cleanup function
@@ -291,157 +291,27 @@ trap cleanup EXIT
# ========================================================== # ==========================================================
check_existing_installation() { check_existing_installation() {
local has_venv=false # After the googletrans removal there is only one install variant.
local has_config=false # The function still distinguishes "installed" vs "not installed" so
local has_language=false # show_installation_options can pick the right banner.
local has_menu=false
if [ -f "$INSTALL_DIR/$MENU_SCRIPT" ]; then if [ -f "$INSTALL_DIR/$MENU_SCRIPT" ]; then
has_menu=true # Quietly fix a corrupted config so the install can proceed.
fi if [ -f "$CONFIG_FILE" ] && ! jq empty "$CONFIG_FILE" >/dev/null 2>&1; then
echo "Warning: Corrupted config file detected, removing..." >&2
if [ -d "$VENV_PATH" ] && [ -f "$VENV_PATH/bin/activate" ]; then
has_venv=true
fi
if [ -f "$CONFIG_FILE" ]; then
if jq empty "$CONFIG_FILE" >/dev/null 2>&1; then
has_config=true
local current_language=$(jq -r '.language // empty' "$CONFIG_FILE" 2>/dev/null)
if [[ -n "$current_language" && "$current_language" != "null" && "$current_language" != "empty" ]]; then
has_language=true
fi
else
echo "Warning: Corrupted config file detected, removing..."
rm -f "$CONFIG_FILE" rm -f "$CONFIG_FILE"
fi fi
fi echo "installed"
if [ "$has_venv" = true ] && [ "$has_language" = true ]; then
echo "translation"
elif [ "$has_menu" = true ] && [ "$has_venv" = false ]; then
echo "normal"
elif [ "$has_menu" = true ]; then
echo "unknown"
else else
echo "none" echo "none"
fi fi
} }
uninstall_proxmenux() {
local install_type="$1"
local force_clean="$2"
if [ "$force_clean" != "force" ]; then
if ! whiptail --title "Uninstall ProxMenux" --yesno "Are you sure you want to uninstall ProxMenux?" 10 60; then
return 1
fi
fi
echo "Uninstalling ProxMenux..."
if systemctl is-active --quiet proxmenux-monitor.service; then
echo "Stopping ProxMenux Monitor service..."
systemctl stop proxmenux-monitor.service
fi
if systemctl is-enabled --quiet proxmenux-monitor.service 2>/dev/null; then
echo "Disabling ProxMenux Monitor service..."
systemctl disable proxmenux-monitor.service
fi
if [ -f "$MONITOR_SERVICE_FILE" ]; then
echo "Removing ProxMenux Monitor service file..."
rm -f "$MONITOR_SERVICE_FILE"
systemctl daemon-reload
fi
if [ -d "$MONITOR_INSTALL_DIR" ]; then
echo "Removing ProxMenux Monitor directory..."
rm -rf "$MONITOR_INSTALL_DIR"
fi
if [ -f "$VENV_PATH/bin/activate" ]; then
echo "Removing googletrans and virtual environment..."
source "$VENV_PATH/bin/activate"
pip uninstall -y googletrans >/dev/null 2>&1
deactivate
rm -rf "$VENV_PATH"
fi
if [ "$install_type" = "translation" ] && [ "$force_clean" != "force" ]; then
DEPS_TO_REMOVE=$(whiptail --title "Remove Translation Dependencies" --checklist \
"Select translation-specific dependencies to remove:" 15 60 3 \
"python3-venv" "Python virtual environment" OFF \
"python3-pip" "Python package installer" OFF \
"python3" "Python interpreter" OFF \
3>&1 1>&2 2>&3)
if [ -n "$DEPS_TO_REMOVE" ]; then
echo "Removing selected dependencies..."
read -r -a DEPS_ARRAY <<< "$(echo "$DEPS_TO_REMOVE" | tr -d '"')"
for dep in "${DEPS_ARRAY[@]}"; do
echo "Removing $dep..."
apt-mark auto "$dep" >/dev/null 2>&1
apt-get -y --purge autoremove "$dep" >/dev/null 2>&1
done
apt-get autoremove -y --purge >/dev/null 2>&1
fi
fi
rm -f "$INSTALL_DIR/$MENU_SCRIPT"
rm -rf "$BASE_DIR"
[ -f /root/.bashrc.bak ] && mv /root/.bashrc.bak /root/.bashrc
if [ -f /etc/motd.bak ]; then
mv /etc/motd.bak /etc/motd
else
sed -i '/This system is optimised by: ProxMenux/d' /etc/motd
fi
echo "ProxMenux has been uninstalled."
return 0
}
handle_installation_change() {
local current_type="$1"
local new_type="$2"
if [ "$current_type" = "$new_type" ]; then
return 0
fi
case "$current_type-$new_type" in
"translation-1"|"translation-normal")
if whiptail --title "Installation Type Change" \
--yesno "Switch from Translation to Normal Version?\n\nThis will remove translation components." 10 60; then
echo "Preparing for installation type change..."
uninstall_proxmenux "translation" "force" >/dev/null 2>&1
return 0
else
return 1
fi
;;
"normal-2"|"normal-translation")
if whiptail --title "Installation Type Change" \
--yesno "Switch from Normal to Translation Version?\n\nThis will add translation components." 10 60; then
return 0
else
return 1
fi
;;
*)
return 0
;;
esac
}
update_config() { update_config() {
local component="$1" local component="$1"
local status="$2" local status="$2"
local timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ") local timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
local tracked_components=("dialog" "curl" "jq" "git" "python3" "python3-venv" "python3-pip" "virtual_environment" "pip" "googletrans" "proxmenux_monitor") local tracked_components=("dialog" "curl" "jq" "git" "python3" "python3-pip" "proxmenux_monitor")
if [[ " ${tracked_components[@]} " =~ " ${component} " ]]; then if [[ " ${tracked_components[@]} " =~ " ${component} " ]]; then
mkdir -p "$(dirname "$CONFIG_FILE")" mkdir -p "$(dirname "$CONFIG_FILE")"
@@ -517,26 +387,12 @@ select_language() {
# Show installation confirmation for new installations # Show installation confirmation for new installations
show_installation_confirmation() { show_installation_confirmation() {
local install_type="$1" if whiptail --title "ProxMenux Installation" \
--yesno "ProxMenux will install:\n\n• dialog (interactive menus) - Official Debian package\n• curl (file downloads) - Official Debian package\n• jq (JSON processing) - Official Debian package\n• ProxMenux core files (/usr/local/share/proxmenux)\n• ProxMenux Monitor (Web dashboard on port 8008)\n• Pre-built translation files (English, Spanish, French, German, Italian, Portuguese)\n\nProceed with installation?" 20 70; then
case "$install_type" in return 0
"1") else
if whiptail --title "ProxMenux - Normal Version Installation" \ return 1
--yesno "ProxMenux Normal Version will install:\n\n• dialog (interactive menus) - Official Debian package\n• curl (file downloads) - Official Debian package\n• jq (JSON processing) - Official Debian package\n• ProxMenux core files (/usr/local/share/proxmenux)\n• ProxMenux Monitor (Web dashboard on port 8008)\n\nThis is a lightweight installation with minimal dependencies.\n\nProceed with installation?" 20 70; then fi
return 0
else
return 1
fi
;;
"2")
if whiptail --title "ProxMenux - Translation Version Installation" \
--yesno "ProxMenux Translation Version will install:\n\n• dialog (interactive menus)\n• curl (file downloads)\n• jq (JSON processing)\n• python3 + python3-venv + python3-pip\n• Google Translate library (googletrans)\n• Virtual environment (/opt/googletrans-env)\n• Translation cache system\n• ProxMenux core files\n• ProxMenux Monitor (Web dashboard on port 8008)\n\nThis version requires more dependencies for translation support.\n\nProceed with installation?" 20 70; then
return 0
else
return 1
fi
;;
esac
} }
get_server_ip() { get_server_ip() {
@@ -800,9 +656,25 @@ EOF
} }
install_normal_version() { install_normal_version() {
local total_steps=5 local total_steps=6
local current_step=1 local current_step=1
# Translations now live as pre-generated JSON files under lang/, so
# asking the language up front is the right place — every install is
# multilingual-capable and the user picks once.
show_progress $current_step $total_steps "Language selection"
select_language
((current_step++))
# Purge the legacy googletrans virtualenv if a previous install left it
# behind. The new translate flow has no runtime Python/googletrans
# dependency — the venv is dead weight on disk now.
if [[ -d "$LEGACY_VENV_PATH" ]]; then
msg_info "Removing legacy translation virtualenv at $LEGACY_VENV_PATH..."
rm -rf "$LEGACY_VENV_PATH"
msg_ok "Legacy translation virtualenv removed."
fi
show_progress $current_step $total_steps "Installing basic dependencies." show_progress $current_step $total_steps "Installing basic dependencies."
if ! command -v jq > /dev/null 2>&1; then if ! command -v jq > /dev/null 2>&1; then
@@ -842,7 +714,11 @@ install_normal_version() {
fi fi
for pkg in "${BASIC_DEPS[@]}"; do for pkg in "${BASIC_DEPS[@]}"; do
# Strict per-package check — see comment in install_translation_version(). # `dpkg -l | grep -qw "$pkg"` treats `-` as a word boundary, so a
# query for `python3` would falsely match `python3-pip` and skip
# the real `python3` install. `dpkg-query -W -f='${Status}'` asks
# for the EXACT package and reports "install ok installed" only
# when truly present. Issue #205 traced back here.
if ! dpkg-query -W -f='${Status}' "$pkg" 2>/dev/null | grep -q "ok installed"; then if ! dpkg-query -W -f='${Status}' "$pkg" 2>/dev/null | grep -q "ok installed"; then
if apt-get install -y "$pkg" > /dev/null 2>&1; then if apt-get install -y "$pkg" > /dev/null 2>&1; then
update_config "$pkg" "installed" update_config "$pkg" "installed"
@@ -931,6 +807,18 @@ install_normal_version() {
cp "./version.txt" "$LOCAL_VERSION_FILE" cp "./version.txt" "$LOCAL_VERSION_FILE"
cp "./install_proxmenux.sh" "$BASE_DIR/install_proxmenux.sh" cp "./install_proxmenux.sh" "$BASE_DIR/install_proxmenux.sh"
# Pre-built translation cache. The runtime translate() in utils.sh
# reads $BASE_DIR/lang/<lang>.json — these files ship with the repo
# (one per supported language) so the install ends up multilingual
# without any runtime download or Python dependency. Refresh the
# whole dir on every install so a language that was renamed or
# dropped upstream disappears here too.
if [ -d "./lang" ]; then
rm -rf "$BASE_DIR/lang"
mkdir -p "$BASE_DIR/lang"
cp -r "./lang/"* "$BASE_DIR/lang/" 2>/dev/null || true
fi
# A user that previously rode the beta train and then switched back # A user that previously rode the beta train and then switched back
# to stable would still have a leftover beta_version.txt under # to stable would still have a leftover beta_version.txt under
# $BASE_DIR, which makes the `menu` update check (check_updates_beta) # $BASE_DIR, which makes the `menu` update check (check_updates_beta)
@@ -942,7 +830,7 @@ install_normal_version() {
# Wipe the scripts tree before copying so any file removed upstream # Wipe the scripts tree before copying so any file removed upstream
# (renamed, consolidated, deprecated) disappears from the user install. # (renamed, consolidated, deprecated) disappears from the user install.
# Only $BASE_DIR/scripts/ is cleared; config.json, cache.json, # Only $BASE_DIR/scripts/ is cleared; config.json,
# components_status.json, version.txt, monitor.db, smart/, oci/ and # components_status.json, version.txt, monitor.db, smart/, oci/ and
# the AppImage live outside this path and are preserved. # the AppImage live outside this path and are preserved.
rm -rf "$BASE_DIR/scripts" rm -rf "$BASE_DIR/scripts"
@@ -969,210 +857,16 @@ install_normal_version() {
msg_ok "ProxMenux Normal Version installation completed successfully." msg_ok "ProxMenux Normal Version installation completed successfully."
} }
install_translation_version() {
local total_steps=5
local current_step=1
show_progress $current_step $total_steps "Language selection"
select_language
((current_step++))
show_progress $current_step $total_steps "Installing system dependencies"
if ! command -v jq > /dev/null 2>&1; then
apt-get update > /dev/null 2>&1
if apt-get install -y jq > /dev/null 2>&1 && command -v jq > /dev/null 2>&1; then
update_config "jq" "installed"
else
local jq_url="https://github.com/jqlang/jq/releases/download/jq-1.7.1/jq-linux-amd64"
if wget -q -O /usr/local/bin/jq "$jq_url" 2>/dev/null && chmod +x /usr/local/bin/jq; then
if command -v jq > /dev/null 2>&1; then
update_config "jq" "installed_from_github"
else
msg_error "Failed to install jq. Please install it manually."
update_config "jq" "failed"
return 1
fi
else
msg_error "Failed to install jq from both APT and GitHub. Please install it manually."
update_config "jq" "failed"
return 1
fi
fi
else
update_config "jq" "already_installed"
fi
DEPS=("dialog" "curl" "git" "python3" "python3-venv" "python3-pip")
for pkg in "${DEPS[@]}"; do
# `dpkg -l | grep -qw "$pkg"` treats `-` as a word boundary, so a
# query for `python3` would falsely match `python3-pip` and skip
# the real `python3` install. `dpkg-query -W -f='${Status}'` asks
# for the EXACT package and reports "install ok installed" only
# when truly present. Issue #205 traced back here.
if ! dpkg-query -W -f='${Status}' "$pkg" 2>/dev/null | grep -q "ok installed"; then
if apt-get install -y "$pkg" > /dev/null 2>&1; then
update_config "$pkg" "installed"
else
msg_error "Failed to install $pkg. Please install it manually."
update_config "$pkg" "failed"
return 1
fi
else
update_config "$pkg" "already_installed"
fi
done
msg_ok "jq, dialog, curl, git, python3, python3-venv and python3-pip installed successfully."
((current_step++))
show_progress $current_step $total_steps "Setting up translation environment"
if [ ! -d "$VENV_PATH" ] || [ ! -f "$VENV_PATH/bin/activate" ]; then
python3 -m venv --system-site-packages "$VENV_PATH" > /dev/null 2>&1
if [ ! -f "$VENV_PATH/bin/activate" ]; then
msg_error "Failed to create virtual environment. Please check your Python installation."
update_config "virtual_environment" "failed"
return 1
else
update_config "virtual_environment" "created"
fi
else
update_config "virtual_environment" "already_exists"
fi
source "$VENV_PATH/bin/activate"
if pip install --upgrade pip > /dev/null 2>&1; then
update_config "pip" "upgraded"
else
msg_error "Failed to upgrade pip."
update_config "pip" "upgrade_failed"
return 1
fi
if pip install --break-system-packages --no-cache-dir googletrans==4.0.0-rc1 > /dev/null 2>&1; then
update_config "googletrans" "installed"
else
msg_error "Failed to install googletrans. Please check your internet connection."
update_config "googletrans" "failed"
deactivate
return 1
fi
deactivate
show_progress $current_step $total_steps "Cloning ProxMenux repository"
if ! git clone --depth 1 "$REPO_URL" "$TEMP_DIR" 2>/dev/null; then
msg_error "Failed to clone repository from $REPO_URL"
exit 1
fi
msg_ok "Repository cloned successfully."
cd "$TEMP_DIR"
((current_step++))
show_progress $current_step $total_steps "Copying necessary files"
mkdir -p "$BASE_DIR"
mkdir -p "$INSTALL_DIR"
cp "./json/cache.json" "$CACHE_FILE"
msg_ok "Cache file copied with translations."
cp "./scripts/utils.sh" "$UTILS_FILE"
# Atomic install of /usr/local/bin/menu: stage to .new on the same
# filesystem then mv. This protects any reader that happens to open
# the file mid-install from seeing a partial/half-written script
# (the suspected root cause of the post-1.2.2-update reports:
# "menu: line 138 syntax error near unexpected token `$REMOTE_VERSION`")
cp "./menu" "$INSTALL_DIR/${MENU_SCRIPT}.new"
mv -f "$INSTALL_DIR/${MENU_SCRIPT}.new" "$INSTALL_DIR/$MENU_SCRIPT"
cp "./version.txt" "$LOCAL_VERSION_FILE"
cp "./install_proxmenux.sh" "$BASE_DIR/install_proxmenux.sh"
# Clear any leftover beta_version.txt — see the equivalent block
# in the update path above for the rationale (in short: prevents
# the menu from offering a phantom "Beta update available" after a
# user has switched back to the stable channel).
rm -f "$BASE_DIR/beta_version.txt"
mkdir -p "$BASE_DIR/scripts"
cp -r "./scripts/"* "$BASE_DIR/scripts/"
chmod -R +x "$BASE_DIR/scripts/"
chmod +x "$BASE_DIR/install_proxmenux.sh"
msg_ok "Necessary files created."
chmod +x "$INSTALL_DIR/$MENU_SCRIPT"
((current_step++))
show_progress $current_step $total_steps "Installing ProxMenux Monitor"
install_proxmenux_monitor
local monitor_status=$?
if [ $monitor_status -eq 0 ]; then
create_monitor_service
elif [ $monitor_status -eq 2 ]; then
msg_ok "ProxMenux Monitor updated successfully."
fi
msg_ok "ProxMenux Translation Version installation completed successfully."
}
show_installation_options() { show_installation_options() {
local current_install_type local current_install_type
current_install_type=$(check_existing_installation) current_install_type=$(check_existing_installation)
local pve_version # Translation Version is gone — translations now ship as pre-built
pve_version=$(pveversion 2>/dev/null | grep -oP 'pve-manager/\K[0-9]+' | head -1) # JSON files in lang/. There is only one install path, so this
# function just shows the confirmation dialog for new installs and
local menu_title="ProxMenux Installation" # then returns. Existing installs go straight through (they already
local menu_text="Choose installation type:" # consented to update via the menu).
INSTALL_TYPE="1"
if [ "$current_install_type" != "none" ]; then
case "$current_install_type" in
"translation")
menu_title="ProxMenux Update - Translation Version Detected"
;;
"normal")
menu_title="ProxMenux Update - Normal Version Detected"
;;
"unknown")
menu_title="ProxMenux Update - Existing Installation Detected"
;;
esac
fi
if [[ "$pve_version" -ge 9 ]]; then
INSTALL_TYPE=$(whiptail --backtitle "ProxMenux" --title "$menu_title" --menu "\n$menu_text" 14 70 2 \
"1" "Normal Version (English only)" 3>&1 1>&2 2>&3)
if [ -z "$INSTALL_TYPE" ]; then
show_proxmenux_logo
msg_warn "Installation cancelled."
exit 1
fi
else
INSTALL_TYPE=$(whiptail --backtitle "ProxMenux" --title "$menu_title" --menu "\n$menu_text" 14 70 2 \
"1" "Normal Version (English only)" \
"2" "Translation Version (Multi-language support)" 3>&1 1>&2 2>&3)
if [ -z "$INSTALL_TYPE" ]; then
show_proxmenux_logo
msg_warn "Installation cancelled."
exit 1
fi
fi
if [ -z "$INSTALL_TYPE" ]; then
show_proxmenux_logo
msg_warn "Installation cancelled."
exit 1
fi
if [ "$current_install_type" = "none" ]; then if [ "$current_install_type" = "none" ]; then
if ! show_installation_confirmation "$INSTALL_TYPE"; then if ! show_installation_confirmation "$INSTALL_TYPE"; then
show_proxmenux_logo show_proxmenux_logo
@@ -1180,55 +874,22 @@ show_installation_options() {
exit 1 exit 1
fi fi
fi fi
if ! handle_installation_change "$current_install_type" "$INSTALL_TYPE"; then
show_proxmenux_logo
msg_warn "Installation cancelled."
exit 1
fi
} }
install_proxmenux() { install_proxmenux() {
if [[ "${UPDATE_MODE:-0}" == "1" ]]; then if [[ "${UPDATE_MODE:-0}" == "1" ]]; then
# Update path: the user already accepted "Update now?" in the # Update path: the user already accepted "Update now?" in the
# menu. We skip the install-type chooser (their choice is # menu. Hand off to the freshly-installed menu binary at the end
# preserved — Translation installs leave /opt/googletrans-env # (exec, see below) so no shell ever returns to a half-written
# behind, Normal installs don't) and label the run as an
# "Update" instead of an "Install" so the operator can tell
# which flow they're in. The continuous hand-off back to the
# new menu at the end of this function (exec, see below)
# closes the entire class of bugs of shape
# "menu: line N syntax error" post-update
# because no shell ever returns to a half-written
# /usr/local/bin/menu — the new copy is the only thing parsed. # /usr/local/bin/menu — the new copy is the only thing parsed.
if [[ -d "$VENV_PATH" && -f "$VENV_PATH/bin/activate" ]]; then show_proxmenux_logo
show_proxmenux_logo msg_title "Updating ProxMenux"
msg_title "Updating ProxMenux - Translation Version" install_normal_version
install_translation_version
else
show_proxmenux_logo
msg_title "Updating ProxMenux - Normal Version"
install_normal_version
fi
else else
show_installation_options show_installation_options
show_proxmenux_logo
case "$INSTALL_TYPE" in msg_title "Installing ProxMenux"
"1") install_normal_version
show_proxmenux_logo
msg_title "Installing ProxMenux - Normal Version"
install_normal_version
;;
"2")
show_proxmenux_logo
msg_title "Installing ProxMenux - Translation Version"
install_translation_version
;;
*)
msg_error "Invalid option selected."
exit 1
;;
esac
fi fi
if [[ -f "$UTILS_FILE" ]]; then if [[ -f "$UTILS_FILE" ]]; then

View File

@@ -1,198 +0,0 @@
{
"Language changed to": {
"es": "Idioma cambiado a",
"fr": "Langue changée en",
"de": "Sprache geändert zu",
"it": "Lingua cambiata in",
"pt": "Idioma alterado para"
},
"Main Menu": {
"es": "Menú principal",
"fr": "Menu principal",
"de": "Hauptmenü",
"it": "Menu principale",
"pt": "Menu principal"
},
"Select an option:": {
"es": "Seleccione una opción:",
"fr": "Sélectionnez une option :",
"de": "Wählen Sie eine Option aus:",
"it": "Selezionare un'opzione:",
"pt": "Selecione uma opção:"
},
"GPUs and Coral-TPU": {
"es": "GPUs y Coral-TPU",
"fr": "GPUs et Coral-TPU",
"de": "GPUs und Coral-TPU",
"it": "GPUs e Coral-TPU",
"pt": "GPUs e Coral-TPU"
},
"Hard Drives, Disk Images, and Storage": {
"es": "Discos duros, imágenes de disco y almacenamiento",
"fr": "Disques durs, images disque et stockage",
"de": "Festplatten, Festplattenabbilder und Speicherplatz",
"it": "Dischi rigidi, immagini del disco e archiviazione",
"pt": "Discos rígidos, imagens de disco e armazenamento"
},
"Network": {
"es": "Red",
"fr": "Réseau",
"de": "Netzwerk",
"it": "Rete",
"pt": "Rede"
},
"Settings": {
"es": "Configuración",
"fr": "Paramètres",
"de": "Einstellungen",
"it": "Impostazioni",
"pt": "Configurações"
},
"Exit": {
"es": "Salir",
"fr": "Quitter",
"de": "Beenden",
"it": "Esci",
"pt": "Sair"
},
"HW: GPUs and Coral": {
"es": "HW: GPUs y Coral",
"fr": "HW: GPUs et Coral",
"de": "HW: GPUs und Coral",
"it": "HW: GPUs e Coral",
"pt": "HW: GPUs e Coral"
},
"Return to Main Menu": {
"es": "Volver al menú principal",
"fr": "Retour au menu principal",
"de": "Zum Hauptmenü zurückkehren",
"it": "Torna al menu principale",
"pt": "Retornar ao menu principal"
},
"Disk and Storage Menu": {
"es": "Menú de discos y almacenamiento",
"fr": "Menu des disques et stockage",
"de": "Datenträger- und Speichermenü",
"it": "Menu dischi e archiviazione",
"pt": "Menu de discos e armazenamento"
},
"Add Disk Passthrough to a VM": {
"es": "Añadir disco Passthrough a una VM",
"fr": "Ajouter un disque Passthrough à une VM",
"de": "Disk-Passthrough zu einer VM hinzufügen",
"it": "Aggiungi passthrough del disco a una VM",
"pt": "Adicionar passthrough de disco a uma VM"
},
"Network Menu": {
"es": "Menú de red",
"fr": "Menu réseau",
"de": "Netzwerkmenü",
"it": "Menu di rete",
"pt": "Menu de rede"
},
"Repair Network": {
"es": "Reparar red",
"fr": "Réparer le réseau",
"de": "Netzwerk reparieren",
"it": "Riparare la rete",
"pt": "Reparar rede"
},
"Configuration Menu": {
"es": "Menú de configuración",
"fr": "Menu de configuration",
"de": "Konfigurationsmenü",
"it": "Menu di configurazione",
"pt": "Menu de configuração"
},
"Change Language": {
"es": "Cambiar idioma",
"fr": "Changer de langue",
"de": "Sprache ändern",
"it": "Cambia lingua",
"pt": "Alterar idioma"
},
"Show Version Information": {
"es": "Mostrar información de la versión",
"fr": "Afficher les informations de version",
"de": "Versionsinformationen anzeigen",
"it": "Mostra informazioni sulla versione",
"pt": "Mostrar informações da versão"
},
"Uninstall ProxMenu": {
"es": "Desinstalar ProxMenu",
"fr": "Désinstaller ProxMenu",
"de": "ProxMenu deinstallieren",
"it": "Disinstallare ProxMenu",
"pt": "Desinstalar ProxMenu"
},
"Select a new language for the menu:": {
"es": "Seleccione un nuevo idioma para el menú:",
"fr": "Sélectionnez une nouvelle langue pour le menu :",
"de": "Wählen Sie eine neue Sprache für das Menü:",
"it": "Seleziona una nuova lingua per il menu:",
"pt": "Selecione um novo idioma para o menu:"
},
"English (Recommended)": {
"es": "Inglés (recomendado)",
"fr": "Anglais (recommandé)",
"de": "Englisch (empfohlen)",
"it": "Inglese (consigliato)",
"pt": "Inglês (recomendado)"
},
"Spanish": {
"es": "Español",
"fr": "Espagnol",
"de": "Spanisch",
"it": "Spagnolo",
"pt": "Espanhol"
},
"French": {
"es": "Francés",
"fr": "Français",
"de": "Französisch",
"it": "Francese",
"pt": "Francês"
},
"German": {
"es": "Alemán",
"fr": "Allemand",
"de": "Deutsch",
"it": "Tedesco",
"pt": "Alemão"
},
"Italian": {
"es": "Italiano",
"fr": "Italien",
"de": "Italienisch",
"it": "Italiano",
"pt": "Italiano"
},
"Portuguese": {
"es": "Portugués",
"fr": "Portugais",
"de": "Portugiesisch",
"it": "Portoghese",
"pt": "Português"
},
"Simplified Chinese": {
"es": "Chino simplificado",
"fr": "Chinois simplifié",
"de": "Vereinfachtes Chinesisch",
"it": "Cinese semplificato",
"pt": "Chinês simplificado"
},
"Japanese": {
"es": "Japonés",
"fr": "Japonais",
"de": "Japanisch",
"it": "Giapponese",
"pt": "Japonês"
},
"Thank you for using ProxMenu. Goodbye!": {
"es": "Gracias por usar ProxMenu. ¡Hasta luego!",
"fr": "Merci d'avoir utilisé ProxMenu. À bientôt !",
"de": "Vielen Dank, dass Sie ProxMenu verwendet haben. Bis bald!",
"it": "Grazie per aver usato ProxMenu. A presto!",
"pt": "Obrigado por usar o ProxMenu. Até logo!"
}
}

View File

@@ -1,113 +0,0 @@
# General system messages
MAIN_MENU_TITLE="ProxMenux - Main Menu"
CONFIG_TITLE="ProxMenux - Configuration"
SELECT_OPTION="Select an option:"
LANG_OPTION="Change language"
UNINSTALL_OPTION="Uninstall ProxMenu"
EXIT_MENU="Exit"
EXIT_MESSAGE="Exiting menu. Goodbye!"
# Main menu options
OPTION_1="Configure iGPU + TPU"
OPTION_2="Repair network"
OPTION_3="Settings"
# Version messages
VERSION_OPTION="Show version information"
VERSION_TITLE="Version Information"
VERSION_INFO="Current version: %s\n\nFor more information, visit:\nhttps://github.com/MacRimi/ProxMenux"
# Update messages
UPDATE_CHECKING="Checking for updates..."
UPDATE_ERROR_REMOTE="Error checking remote version."
UPDATE_NEW_AVAILABLE="New version available: %s (current: %s)"
UPDATE_TITLE="Update available"
UPDATE_PROMPT="Do you want to update to the latest version %s?"
UPDATE_POSTPONED="Update postponed."
UPDATE_CURRENT="The menu is already up to date (%s)."
UPDATE_PROCESS="Updating to version %s..."
UPDATE_COMPLETE="Update completed to version %s."
UPDATE_ERROR_DOWNLOAD="Error downloading the update."
# Uninstall messages
UNINSTALL_TITLE="Uninstall ProxMenu"
UNINSTALL_CONFIRM="Are you sure you want to uninstall ProxMenu?"
UNINSTALL_COMPLETE="ProxMenu has been uninstalled successfully."
UNINSTALL_PROCESS="Uninstalling ProxMenu..."
# Script messages
SCRIPT_RUNNING="Running igpu_tpu.sh script..."
SCRIPT_SUCCESS="Script executed successfully."
SCRIPT_ERROR="Error executing the script."
# Language messages
LANG_SELECT="Select Language"
LANG_PROMPT="Choose your language:"
LANG_ERROR="No language selected. Exiting..."
LANG_SUCCESS="Selected language:"
LANG_LOADED="Language loaded:"
LANG_DOWNLOAD="Downloading language file..."
LANG_DOWNLOAD_ERROR="Error downloading language file. Check your internet connection."
LANG_EXISTS="Language file exists locally."
# Dependency messages
DEPS_INSTALLING="Installing necessary dependencies..."
DEPS_SUCCESS="Dependencies installed."
DEPS_ERROR="Error installing dependencies. Please install whiptail manually."
# Bilingual messages (first run)
INITIAL_LANG_SELECT="Select Language / Seleccionar Idioma"
INITIAL_LANG_PROMPT="Choose your language / Elige tu idioma:"
INITIAL_LANG_ERROR="No language selected. Exiting... / No se seleccionó ningún idioma. Saliendo..."
# --- Messages for the network repair script (repair_network.sh) ---
REPAIR_MENU_TITLE="Network Repair Menu"
MENU_PROMPT="Please select an option:"
MENU_REPAIR="Repair network"
MENU_VERIFY="Verify network configuration"
MENU_SHOW_IP="Show IP information"
MENU_EXIT="Exit"
MENU_CANCELED="Operation canceled by user."
MENU_EXIT_MSG="Exiting network repair script. Goodbye!"
INVALID_OPTION="Invalid option. Please try again."
PRESS_ENTER="Press Enter to continue..."
RESULT_TITLE="Operation Result"
REPAIR_COMPLETED="Network repair completed."
VERIFY_COMPLETED="Network verification completed."
IP_INFO_COMPLETED="IP information displayed."
NETWORK_ERROR="ERROR"
NETWORK_SUCCESS="SUCCESS"
NETWORK_WARNING="WARNING"
NETWORK_PHYSICAL_INTERFACES="Detected physical interfaces"
NETWORK_CONFIGURED_INTERFACES="Configured interfaces"
NETWORK_CHECKING_BRIDGES="Checking bridge configuration"
NETWORK_BRIDGE_PORT_MISSING="Bridge port non-existent or not active"
NETWORK_BRIDGE_PORT_UPDATED="Bridge port updated"
NETWORK_NO_PHYSICAL_INTERFACE="No suitable physical interface found"
NETWORK_BRIDGE_PORT_OK="Bridge port correct"
NETWORK_CLEANING_INTERFACES="Cleaning configurations of non-existent interfaces"
NETWORK_INTERFACE_REMOVED="Interface removed"
NETWORK_CONFIGURING_INTERFACES="Configuring interfaces"
NETWORK_INTERFACE_ADDED="Interface added"
NETWORK_RESTARTING="Restarting network service"
NETWORK_RESTART_SUCCESS="Network service restarted successfully"
NETWORK_RESTART_FAILED="Failed to restart network service"
NETWORK_CONNECTIVITY_OK="Network connectivity OK"
NETWORK_CONNECTIVITY_FAILED="Network connectivity failed"
NETWORK_IP_INFO="IP Information"
NETWORK_NO_IP="No IP"
NETWORK_REPAIR_STARTED="Starting network repair"
NETWORK_REPAIR_COMPLETED="Network repair completed"
NETWORK_REPAIR_FAILED="Network repair failed"
NETWORK_REPAIR_PROCESS_FINISHED="Network repair process finished"
NETWORK_VERIFY_STARTED="Starting network verification"
NETWORK_VERIFY_FINISHED="Network verification finished"
NETWORK_REPAIR_RUNNING="Running network repair..."
NETWORK_REPAIR_SUCCESS="Network repair executed successfully."
NETWORK_REPAIR_ERROR="Error executing network repair."
NETWORK_VERIFY_RUNNING="Running network verification..."
NETWORK_VERIFY_SUCCESS="Network verification completed successfully."
NETWORK_VERIFY_ERROR="Error executing network verification."
NETWORK_IP_INFO_RUNNING="Obtaining IP information..."
NETWORK_IP_INFO_SUCCESS="IP information obtained successfully."
NETWORK_IP_INFO_ERROR="Error obtaining IP information."

View File

@@ -1,115 +0,0 @@
# Mensajes generales del sistema
MAIN_MENU_TITLE="ProxMenux - Menú Principal"
CONFIG_TITLE="ProxMenux - Configuración"
SELECT_OPTION="Selecciona una opción:"
LANG_OPTION="Cambiar idioma"
UNINSTALL_OPTION="Desinstalar ProxMenu"
EXIT_MENU="Salir"
EXIT_MESSAGE="Saliendo del menú. ¡Hasta luego!"
# Opciones del menú principal
OPTION_1="Configurar iGPU + TPU"
OPTION_2="Reparar red"
OPTION_3="Configuración"
# Mensajes de versión
VERSION_OPTION="Mostrar información de versión"
VERSION_TITLE="Información de versión"
VERSION_INFO="Versión actual: %s\n\nPara más información, visita:\nhttps://github.com/MacRimi/ProxMenux"
# Mensajes de actualización
UPDATE_CHECKING="Comprobando actualizaciones..."
UPDATE_ERROR_REMOTE="Error al comprobar la versión remota."
UPDATE_NEW_AVAILABLE="Nueva versión disponible: %s (actual: %s)"
UPDATE_TITLE="Actualización disponible"
UPDATE_PROMPT="¿Deseas actualizar a la última versión %s?"
UPDATE_POSTPONED="Actualización pospuesta."
UPDATE_CURRENT="El menú ya está actualizado (%s)."
UPDATE_PROCESS="Actualizando a la versión %s..."
UPDATE_COMPLETE="Actualización completada a la versión %s."
UPDATE_ERROR_DOWNLOAD="Error al descargar la actualización."
# Mensajes de desinstalación
UNINSTALL_TITLE="Desinstalar ProxMenu"
UNINSTALL_CONFIRM="¿Estás seguro de que quieres desinstalar ProxMenu?"
UNINSTALL_COMPLETE="ProxMenu ha sido desinstalado correctamente."
UNINSTALL_PROCESS="Desinstalando ProxMenu..."
# Mensajes de script
SCRIPT_RUNNING="Ejecutando script igpu_tpu.sh..."
SCRIPT_SUCCESS="Script ejecutado correctamente."
SCRIPT_ERROR="Error al ejecutar el script."
# Mensajes de idioma
LANG_SELECT="Seleccionar Idioma"
LANG_PROMPT="Elige tu idioma:"
LANG_ERROR="No se seleccionó ningún idioma. Saliendo..."
LANG_SUCCESS="Idioma seleccionado:"
LANG_LOADED="Idioma cargado:"
LANG_DOWNLOAD="Descargando archivo de idioma..."
LANG_DOWNLOAD_ERROR="Error al descargar el archivo de idioma. Verifica tu conexión a internet."
LANG_EXISTS="Archivo de idioma existe localmente."
# Mensajes de dependencias
DEPS_INSTALLING="Instalando dependencias necesarias..."
DEPS_SUCCESS="Dependencias instaladas."
DEPS_ERROR="Error al instalar dependencias. Por favor, instala whiptail manualmente."
# Mensajes bilingües (primera ejecución)
INITIAL_LANG_SELECT="Seleccionar Idioma / Select Language"
INITIAL_LANG_PROMPT="Elige tu idioma / Choose your language:"
INITIAL_LANG_ERROR="No se seleccionó ningún idioma. Saliendo... / No language selected. Exiting..."
# --- Mensajes para el script de reparación de red (repair_network.sh) ---
REPAIR_MENU_TITLE="Menú de Reparación de Red"
MENU_PROMPT="Por favor, seleccione una opción:"
MENU_REPAIR="Reparar la red"
MENU_VERIFY="Verificar la configuración de red"
MENU_SHOW_IP="Mostrar información de IP"
MENU_EXIT="Salir"
MENU_CANCELED="Operación cancelada por el usuario."
MENU_EXIT_MSG="Saliendo del script de reparación de red. ¡Hasta luego!"
INVALID_OPTION="Opción no válida. Por favor, intente de nuevo."
PRESS_ENTER="Presione Enter para continuar..."
RESULT_TITLE="Resultado de la Operación"
REPAIR_COMPLETED="Reparación de red completada."
VERIFY_COMPLETED="Verificación de red completada."
IP_INFO_COMPLETED="Información de IP mostrada."
NETWORK_ERROR="ERROR"
NETWORK_SUCCESS="ÉXITO"
NETWORK_WARNING="ADVERTENCIA"
NETWORK_PHYSICAL_INTERFACES="Interfaces físicas detectadas"
NETWORK_CONFIGURED_INTERFACES="Interfaces configuradas"
NETWORK_CHECKING_BRIDGES="Verificando configuración de puentes"
NETWORK_BRIDGE_PORT_MISSING="Puerto de puente no existente o no activo"
NETWORK_BRIDGE_PORT_UPDATED="Puerto de puente actualizado"
NETWORK_NO_PHYSICAL_INTERFACE="No se encontró una interfaz física adecuada"
NETWORK_BRIDGE_PORT_OK="Puerto de puente correcto"
NETWORK_CLEANING_INTERFACES="Limpiando configuraciones de interfaces no existentes"
NETWORK_INTERFACE_REMOVED="Interfaz eliminada"
NETWORK_CONFIGURING_INTERFACES="Configurando interfaces"
NETWORK_INTERFACE_ADDED="Interfaz añadida"
NETWORK_RESTARTING="Reiniciando el servicio de red"
NETWORK_RESTART_SUCCESS="Servicio de red reiniciado con éxito"
NETWORK_RESTART_FAILED="Error al reiniciar el servicio de red"
NETWORK_CONNECTIVITY_OK="Conectividad de red OK"
NETWORK_CONNECTIVITY_FAILED="Fallo en la conectividad de red"
NETWORK_IP_INFO="Información de IP"
NETWORK_NO_IP="No IP"
NETWORK_REPAIR_STARTED="Iniciando reparación de red"
NETWORK_REPAIR_COMPLETED="Reparación de red completada"
NETWORK_REPAIR_FAILED="Fallo en la reparación de red"
NETWORK_REPAIR_PROCESS_FINISHED="Proceso de reparación de red finalizado"
NETWORK_VERIFY_STARTED="Iniciando verificación de red"
NETWORK_VERIFY_FINISHED="Verificación de red finalizada"
NETWORK_REPAIR_RUNNING="Ejecutando reparación de red..."
NETWORK_REPAIR_SUCCESS="Reparación de red ejecutada con éxito."
NETWORK_REPAIR_ERROR="Error al ejecutar la reparación de red."
NETWORK_VERIFY_RUNNING="Ejecutando verificación de red..."
NETWORK_VERIFY_SUCCESS="Verificación de red completada con éxito."
NETWORK_VERIFY_ERROR="Error al ejecutar la verificación de red."
NETWORK_IP_INFO_RUNNING="Obteniendo información de IP..."
NETWORK_IP_INFO_SUCCESS="Información de IP obtenida con éxito."
NETWORK_IP_INFO_ERROR="Error al obtener información de IP."
# --- Fin de mensajes para repair_network.sh ---

View File

@@ -367,6 +367,46 @@ if command -v jq >/dev/null 2>&1 && [[ -f "$COMPONENTS_STATUS" ]]; then
done done
fi fi
POSTBOOT_END_EPOCH=$(date +%s)
POSTBOOT_DURATION=$((POSTBOOT_END_EPOCH - $(stat -c %Y "$LOG_FILE")))
POSTBOOT_DURATION_FMT=$(printf '%dm%02ds' $((POSTBOOT_DURATION / 60)) $((POSTBOOT_DURATION % 60)))
# ── Notify ProxMenux Monitor that we're done ───────────────────
# Routes through the user's configured channels (Telegram, Discord,
# ntfy, etc.). Localhost-only endpoint, no auth needed. We try
# briefly — if the Monitor isn't running, just log and move on.
COMPONENTS_REINSTALLED_CSV=""
if command -v jq >/dev/null 2>&1 && [[ -f "$COMPONENTS_STATUS" ]]; then
COMPONENTS_REINSTALLED_CSV=$(
for entry in "${COMPONENT_INSTALLERS[@]}"; do
comp="${entry%%:*}"
s=$(jq -r ".${comp}.status // \"\"" "$COMPONENTS_STATUS" 2>/dev/null)
[[ "$s" == "installed" ]] && printf '%s,' "$comp"
done | sed 's/,$//'
)
[[ -z "$COMPONENTS_REINSTALLED_CSV" ]] && COMPONENTS_REINSTALLED_CSV="none"
fi
if command -v curl >/dev/null 2>&1; then
PAYLOAD=$(printf '{"hostname":"%s","guests":"%s","stubs":"%s","stale_nodes":"%s","components":"%s","duration":"%s"}' \
"$(hostname)" \
"${copied_guests:-0}" \
"${stub_created:-0}" \
"${removed_nodes:-0}" \
"${COMPONENTS_REINSTALLED_CSV:-none}" \
"$POSTBOOT_DURATION_FMT")
NOTIFY_HTTP=$(curl -s -o /dev/null -w '%{http_code}' \
-X POST "http://127.0.0.1:8008/api/internal/restore-event" \
-H "Content-Type: application/json" \
-d "$PAYLOAD" \
--max-time 5 2>/dev/null || echo "000")
if [[ "$NOTIFY_HTTP" == "200" ]]; then
echo "Notification sent (HTTP 200)"
else
echo "Notification skipped (Monitor not reachable or disabled — HTTP $NOTIFY_HTTP)"
fi
fi
echo "" echo ""
echo "=== Apply finished at $(date -Iseconds) ===" echo "=== Apply finished at $(date -Iseconds) — total ${POSTBOOT_DURATION_FMT} ==="
echo "Log: $LOG_FILE" echo "Log: $LOG_FILE"

View File

@@ -276,6 +276,38 @@ _bk_local() {
dest_dir=$(hb_prompt_dest_dir) || return 1 dest_dir=$(hb_prompt_dest_dir) || return 1
hb_select_profile_paths "$profile_mode" paths || return 1 hb_select_profile_paths "$profile_mode" paths || return 1
# Safety check: if the destination directory is INSIDE any selected
# backup path, creating the archive would copy the backup into
# itself — recursion → corrupted archive or unbounded growth that
# fills the disk. Common footgun when an operator adds a custom
# path like /var/lib/vz and then picks /var/lib/vz/dump as
# destination, or the default profile's /root and a destination
# under /root/.
local dest_real conflict=""
dest_real=$(readlink -m "$dest_dir" 2>/dev/null || echo "$dest_dir")
local p_real p
for p in "${paths[@]}"; do
p_real=$(readlink -m "$p" 2>/dev/null || echo "$p")
if [[ "$dest_real" == "$p_real" || "$dest_real" == "$p_real"/* ]]; then
conflict="$p"
break
fi
done
if [[ -n "$conflict" ]]; then
local body
body="$(translate "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.")"$'\n\n'
body+="\Zb$(translate "Destination:")\ZB \Z4${dest_dir}\Zn"$'\n'
body+="\Zb$(translate "Conflicting path included in backup:")\ZB \Z1${conflict}\Zn"$'\n\n'
body+="$(translate "To fix this, do ONE of the following:")"$'\n'
body+="$(translate "Choose a destination directory OUTSIDE of") ${conflict}"$'\n'
body+="$(translate "Go to \"Manage custom paths\" and remove your custom entry that includes the destination")"$'\n'
body+="$(translate "Use Custom backup and uncheck the conflicting path from the list")"
dialog --backtitle "ProxMenux" --colors \
--title "$(translate "Backup destination is inside the backup")" \
--msgbox "$body" 20 88
return 1
fi
archive="$dest_dir/hostcfg-$(hostname)-$(date +%Y%m%d_%H%M%S).tar.zst" archive="$dest_dir/hostcfg-$(hostname)-$(date +%Y%m%d_%H%M%S).tar.zst"
log_file="/tmp/proxmenux-local-backup-$(date +%Y%m%d_%H%M%S).log" log_file="/tmp/proxmenux-local-backup-$(date +%Y%m%d_%H%M%S).log"
staging_root=$(mktemp -d /tmp/proxmenux-local-stage.XXXXXX) staging_root=$(mktemp -d /tmp/proxmenux-local-stage.XXXXXX)
@@ -382,6 +414,194 @@ _bk_scheduler() {
bash "$scheduler" bash "$scheduler"
} }
_bk_manage_local_destinations() {
while true; do
# Snapshot all currently mounted USB backup partitions with size info
local -a usb_mp=()
local -a usb_desc=()
local state path_or_dev label size fstype uuid
while IFS=$'\t' read -r state path_or_dev label size fstype uuid; do
[[ "$state" != "mounted" ]] && continue
local dfline
dfline=$(df -h "$path_or_dev" 2>/dev/null | tail -1)
local used="?" avail="?" pct="?"
if [[ -n "$dfline" ]]; then
used=$(awk '{print $3}' <<<"$dfline")
avail=$(awk '{print $4}' <<<"$dfline")
pct=$(awk '{print $5}' <<<"$dfline")
fi
usb_mp+=("$path_or_dev")
usb_desc+=("${label:-?} [${fstype}] $size$path_or_dev ($used $(translate "used"), $avail $(translate "free"), $pct)")
done < <(hb_list_usb_partitions)
local body=""
if (( ${#usb_desc[@]} == 0 )); then
body+="$(translate "No USB drives are currently mounted by ProxMenux.")"
else
body+="\Zb$(translate "Mounted USB drives:")\ZB"$'\n'
local d
for d in "${usb_desc[@]}"; do
body+="${d}"$'\n'
done
fi
body+=$'\n'"$(translate "Local destinations are file paths — they are NOT registered as Proxmox storage.")"
local -a menu_args=()
menu_args+=("mount" "+ $(translate "Mount a USB drive now")")
if (( ${#usb_mp[@]} > 0 )); then
menu_args+=("unmount" " $(translate "Unmount a USB drive")")
fi
menu_args+=("back" "$(translate "← Return")")
local choice
choice=$(dialog --backtitle "ProxMenux" --colors \
--title "$(translate "Local archive destinations")" \
--menu "\n${body}\n" \
"$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" "${menu_args[@]}" \
3>&1 1>&2 2>&3) || break
case "$choice" in
mount)
# Reuse the runtime USB picker; result is discarded.
hb_prompt_mounted_path "/mnt/backup" >/dev/null || true
;;
unmount)
if (( ${#usb_mp[@]} == 0 )); then
continue
fi
local unmenu=() j=1 mp
for mp in "${usb_mp[@]}"; do
unmenu+=("$j" "$mp"); ((j++))
done
local pick
pick=$(dialog --backtitle "ProxMenux" \
--title "$(translate "Unmount USB drive")" \
--menu "\n$(translate "Pick a drive to unmount:")" \
"$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" "${unmenu[@]}" \
3>&1 1>&2 2>&3) || continue
local victim="${usb_mp[$((pick-1))]}"
if umount "$victim" 2>/tmp/proxmenux-umount.log; then
rmdir "$victim" 2>/dev/null || true
dialog --backtitle "ProxMenux" --colors \
--msgbox "$(translate "Unmounted") \Z4${victim}\Zn" 8 70
else
local err
err=$(cat /tmp/proxmenux-umount.log 2>/dev/null)
dialog --backtitle "ProxMenux" --colors \
--title "$(translate "Unmount failed")" \
--msgbox "$(translate "Could not unmount") \Z1${victim}\Zn.\n\n${err}" 12 78
fi
;;
back) break ;;
esac
done
}
_bk_manage_destinations() {
while true; do
local choice
choice=$(dialog --backtitle "ProxMenux" \
--title "$(translate "Configure backup destinations")" \
--menu "\n$(translate "Pre-configure destinations so you don't have to enter them every time you back up.")" \
"$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" \
1 "$(translate "Proxmox Backup Server (PBS) destinations")" \
2 "$(translate "Borg repositories")" \
3 "$(translate "Local archive destinations (mounted USBs, mount, unmount)")" \
0 "$(translate "Return")" \
3>&1 1>&2 2>&3) || break
case "$choice" in
1)
hb_select_pbs_repository || true
;;
2)
local _discard=""
hb_select_borg_repo _discard || true
;;
3)
_bk_manage_local_destinations
;;
0) break ;;
esac
done
}
_bk_manage_extra_paths() {
while true; do
local -a paths=()
mapfile -t paths < <(hb_load_extra_paths)
local count=${#paths[@]}
# Descriptive header for the manage menu. We avoid listing the actual
# paths here — a user with dozens of entries would blow the dialog
# box height and force scrolling. The count is enough; " Remove a
# path" shows the full list when the user actually needs to see it.
local preview=""
if (( count == 0 )); then
preview="$(hb_translate "You haven't added any custom paths yet.")"
else
preview="$(hb_translate "Currently"): \Zb\Z4${count}\Zn $(hb_translate "custom path(s) saved.")"
fi
preview+=$'\n\n'"$(hb_translate "Custom paths are included in BOTH default and custom backup profiles.")"
local choice
choice=$(dialog --backtitle "ProxMenux" --colors \
--title "$(translate "Manage custom backup paths")" \
--menu "\n${preview}\n" \
"$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" \
"add" "$(translate "+ Add a path")" \
"del" "$(translate " Remove a path")" \
"back" "$(translate "← Return")" \
3>&1 1>&2 2>&3) || break
case "$choice" in
add)
local new_path
new_path=$(dialog --backtitle "ProxMenux" \
--title "$(translate "Add custom path")" \
--inputbox "$(translate "Absolute path to a file or directory you want backed up:")" \
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "/root/" 3>&1 1>&2 2>&3) || continue
new_path="${new_path%/}"
[[ -z "$new_path" ]] && continue
if [[ ! -e "$new_path" ]]; then
dialog --backtitle "ProxMenux" --colors \
--title "$(translate "Path not found")" \
--msgbox "\Z1${new_path}\Zn\n\n$(translate "does not exist on this host. Path not added.")" 10 70
continue
fi
hb_add_extra_path "$new_path"
;;
del)
if (( count == 0 )); then
dialog --backtitle "ProxMenux" --msgbox \
"$(translate "You haven't added any custom paths yet.")" 8 60
continue
fi
local del_options=() j=1 p
for p in "${paths[@]}"; do
del_options+=("$j" "$p" "off"); ((j++))
done
local del_selected
del_selected=$(dialog --backtitle "ProxMenux" \
--title "$(translate "Remove custom paths")" \
--default-button ok \
--separate-output --checklist \
"\n$(translate "Tick the paths to remove (they will not be deleted from disk — only from this list):")" \
"$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" "${del_options[@]}" \
3>&1 1>&2 2>&3) || continue
# Empty selection → nothing to do
[[ -z "$del_selected" ]] && continue
local sel
while read -r sel; do
[[ -z "$sel" ]] && continue
hb_del_extra_path "${paths[$((sel-1))]}"
done <<< "$del_selected"
;;
back) break ;;
esac
done
}
backup_menu() { backup_menu() {
while true; do while true; do
local choice local choice
@@ -397,8 +617,6 @@ backup_menu() {
4 "$(translate "Custom backup to PBS")" \ 4 "$(translate "Custom backup to PBS")" \
5 "$(translate "Custom backup to Borg")" \ 5 "$(translate "Custom backup to Borg")" \
6 "$(translate "Custom backup to local archive")" \ 6 "$(translate "Custom backup to local archive")" \
"" "$(translate "─── Automation ─────────────────────────────────────")" \
7 "$(translate "Scheduled backups and retention policies")" \
0 "$(translate "Return")" \ 0 "$(translate "Return")" \
3>&1 1>&2 2>&3) || return 0 3>&1 1>&2 2>&3) || return 0
@@ -409,7 +627,6 @@ backup_menu() {
4) _bk_pbs custom ;; 4) _bk_pbs custom ;;
5) _bk_borg custom ;; 5) _bk_borg custom ;;
6) _bk_local custom ;; 6) _bk_local custom ;;
7) _bk_scheduler ;;
0) break ;; 0) break ;;
esac esac
done done
@@ -600,32 +817,45 @@ _rs_extract_borg() {
borg_bin=$(hb_ensure_borg) || return 1 borg_bin=$(hb_ensure_borg) || return 1
hb_select_borg_repo repo || return 1 hb_select_borg_repo repo || return 1
# Same persistence path as backup: per-target pw file
# ($HB_STATE_DIR/borg-pass-<name>.txt), legacy global pw, or
# prompt-once-and-save fallback. Bug fix: the old code only
# honored the legacy global file and re-prompted otherwise,
# defeating the saved-target UX.
hb_prepare_borg_passphrase || return 1
local pass_file="$HB_STATE_DIR/borg-pass.txt" # Pull NAME|START in one shot — borg supports strftime via :%fmt
if [[ -f "$pass_file" ]]; then # in --format. Sort newest-first by the ISO timestamp so the most
BORG_PASSPHRASE="$(<"$pass_file")" # recent backup is always on top regardless of archive naming.
export BORG_PASSPHRASE local -a archive_lines=()
else mapfile -t archive_lines < <(
BORG_PASSPHRASE=$(dialog --backtitle "ProxMenux" --insecure --passwordbox \ "$borg_bin" list "$repo" \
"$(hb_translate "Borg passphrase (leave empty if not encrypted):")" \ --format '{start:%Y-%m-%d %H:%M:%S}|{archive}{NL}' 2>/dev/null \
"$HB_UI_PASS_H" "$HB_UI_PASS_W" "" 3>&1 1>&2 2>&3) || return 1 | sort -r
export BORG_PASSPHRASE
fi
mapfile -t archives < <(
"$borg_bin" list "$repo" --format '{archive}{NL}' 2>/dev/null | sort -r
) )
if [[ ${#archives[@]} -eq 0 ]]; then if [[ ${#archive_lines[@]} -eq 0 ]]; then
msg_error "$(translate "No archives found in this Borg repository.")" msg_error "$(translate "No archives found in this Borg repository.")"
return 1 return 1
fi fi
archives=()
local -a archive_labels=()
local _start _name
for line in "${archive_lines[@]}"; do
_start="${line%%|*}"
_name="${line#*|}"
archives+=("$_name")
# Menu label: ISO datetime first (sortable, fixed width),
# then archive name. Easier to scan when several backups
# ran the same day.
archive_labels+=("${_start} · ${_name}")
done
local menu=() i=1 local menu=() i=1
for archive in "${archives[@]}"; do menu+=("$i" "$archive"); ((i++)); done for archive in "${archive_labels[@]}"; do menu+=("$i" "$archive"); ((i++)); done
local sel local sel
sel=$(dialog --backtitle "ProxMenux" \ sel=$(dialog --backtitle "ProxMenux" \
--title "$(translate "Select archive to restore")" \ --title "$(translate "Select archive to restore")" \
--menu "\n$(translate "Available Borg archives:")" \ --menu "\n$(translate "Available Borg archives (newest first):")" \
"$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" \ "$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" \
"${menu[@]}" 3>&1 1>&2 2>&3) || return 1 "${menu[@]}" 3>&1 1>&2 2>&3) || return 1
archive="${archives[$((sel-1))]}" archive="${archives[$((sel-1))]}"
@@ -1182,49 +1412,51 @@ _rs_collect_plan_stats() {
_rs_show_plan_summary() { _rs_show_plan_summary() {
local staging_root="$1" local staging_root="$1"
local meta="$staging_root/metadata" local meta="$staging_root/metadata"
local tmp
tmp=$(mktemp) || return 1
{ # dialog --colors only fires inside --msgbox / --yesno / --infobox, not
echo "═══ $(translate "Restore plan summary") ═══" # --textbox, so we build the body as a string. Color codes match the
echo "" # complete-restore confirm dialog for visual consistency.
if [[ -f "$meta/run_info.env" ]]; then local body
echo "$(translate "Backup origin metadata:")" body="\Zb═══ $(translate "Restore plan summary") ═══\ZB"$'\n\n'
while IFS='=' read -r k v; do
[[ -n "$k" ]] && printf " %-20s %s\n" "${k}:" "$v"
done < "$meta/run_info.env"
echo ""
fi
echo "$(translate "Detected paths in this backup:") ${RS_PLAN_TOTAL}" if [[ -f "$meta/run_info.env" ]]; then
echo "$(translate "Safe to apply now"): ${RS_PLAN_HOT}" body+="\Zb$(translate "Backup origin metadata:")\ZB"$'\n'
echo "$(translate "Require reboot"): ${RS_PLAN_REBOOT}" while IFS='=' read -r k v; do
echo "$(translate "Risky on running system"): ${RS_PLAN_DANGEROUS}" [[ -z "$k" ]] && continue
echo "" body+="$(printf ' %-20s \Z4%s\Zn' "${k}:" "$v")"$'\n'
done < "$meta/run_info.env"
body+=$'\n'
fi
if [[ "$RS_PLAN_HAS_NETWORK" -eq 1 ]]; then # Reboot-required and live-unsafe both go to the pending set and
echo "$(translate "Includes /etc/network (may drop SSH immediately)")" # are applied by the post-boot dispatcher — to the operator they're
fi # the same bucket "things that complete after reboot".
if [[ "$RS_PLAN_HAS_CLUSTER" -eq 1 ]]; then local _reboot_total=$(( RS_PLAN_REBOOT + RS_PLAN_DANGEROUS ))
echo "$(translate "Includes cluster data (/etc/pve, /var/lib/pve-cluster)")" body+="\Zb$(translate "Detected paths in this backup:")\ZB \Zb\Z4${RS_PLAN_TOTAL}\Zn"$'\n'
echo " $(translate "These paths will not be restored live and will be extracted for manual recovery.")" body+=" $(translate "Safe to apply now"): \Zb\Z4${RS_PLAN_HOT}\Zn"$'\n'
fi body+="$(translate "Require reboot"): \Zb\Z4${_reboot_total}\Zn"$'\n'
if [[ "$RS_PLAN_HAS_ZFS" -eq 1 ]]; then body+=$'\n'
if [[ "${HB_RESTORE_INCLUDE_ZFS:-0}" == "1" ]]; then
echo "$(translate "Includes /etc/zfs: ENABLED for restore")"
else
echo "$(translate "Includes /etc/zfs: DISABLED unless you enable it")"
fi
fi
echo ""
echo "$(translate "Recommendation: start with Complete restore (guided — recommended).")"
} > "$tmp"
dialog --backtitle "ProxMenux" \ if [[ "$RS_PLAN_HAS_NETWORK" -eq 1 ]]; then
body+="$(translate "Includes /etc/network (may drop SSH immediately)")"$'\n'
fi
if [[ "$RS_PLAN_HAS_CLUSTER" -eq 1 ]]; then
body+=" • \Z4$(translate "Includes cluster data (/etc/pve, /var/lib/pve-cluster)")\Zn"$'\n'
body+=" $(translate "These paths will not be restored live and will be extracted for manual recovery.")"$'\n'
fi
if [[ "$RS_PLAN_HAS_ZFS" -eq 1 ]]; then
if [[ "${HB_RESTORE_INCLUDE_ZFS:-0}" == "1" ]]; then
body+="$(translate "Includes /etc/zfs"): \Zb$(translate "ENABLED for restore")\ZB"$'\n'
else
body+="$(translate "Includes /etc/zfs"): \Zb$(translate "DISABLED unless you enable it")\ZB"$'\n'
fi
fi
body+=$'\n'
body+="\Zb$(translate "Recommendation: start with Complete restore.")\ZB"
dialog --backtitle "ProxMenux" --colors \
--title "$(translate "Restore plan")" \ --title "$(translate "Restore plan")" \
--exit-label "OK" \ --msgbox "$body" 24 94 || true
--textbox "$tmp" 24 94 || true
rm -f "$tmp"
} }
_rs_prompt_zfs_opt_in() { _rs_prompt_zfs_opt_in() {
@@ -1272,6 +1504,72 @@ _rs_finish_flow() {
read -r read -r
} }
# Lists components that the post-boot dispatcher will reinstall in background
# after reboot, by reading the backup's components_status.json. Mirrors the
# COMPONENT_INSTALLERS array in apply_cluster_postboot.sh — keep both in sync.
# Echoes "<key>|<label>|<eta>" one per line for installed components.
_rs_list_pending_reinstalls() {
local staging_root="$1"
local state_file="$staging_root/rootfs/usr/local/share/proxmenux/components_status.json"
[[ -f "$state_file" ]] || return 0
command -v jq >/dev/null 2>&1 || return 0
local -a known=(
"nvidia_driver|NVIDIA driver (DKMS kernel compile)|~5-10 min"
"amdgpu_top|amdgpu_top (GitHub .deb download)|~1 min"
"intel_gpu_tools|intel-gpu-tools (apt)|~1 min"
"coral_driver|Coral TPU driver (DKMS compile)|~3-5 min"
)
local entry key label eta status
for entry in "${known[@]}"; do
key="${entry%%|*}"
label="${entry#*|}"; label="${label%%|*}"
eta="${entry##*|}"
status=$(jq -r ".${key}.status // \"\"" "$state_file" 2>/dev/null)
[[ "$status" == "installed" ]] && printf '%s|%s|%s\n' "$key" "$label" "$eta"
done
}
# Offers an immediate reboot after the pending restore is prepared, following
# the same UX pattern as the post-install script. Lists what will keep running
# in background after reboot so the operator isn't surprised when nvidia-smi
# or similar tools are missing for the first few minutes.
_rs_offer_reboot_after_pending() {
local staging_root="$1"
local -a reinstalls=()
mapfile -t reinstalls < <(_rs_list_pending_reinstalls "$staging_root")
local bg_block=""
if (( ${#reinstalls[@]} > 0 )); then
bg_block="$(translate "After reboot the system will be fully accessible (SSH, web UI, login), but the following components will be reinstalled in BACKGROUND — until they finish, commands like nvidia-smi may not yet be available:")"$'\n'
local r key label eta
for r in "${reinstalls[@]}"; do
key="${r%%|*}"
label="${r#*|}"; label="${label%%|*}"
eta="${r##*|}"
bg_block+="${label} (${eta})"$'\n'
done
bg_block+=$'\n'"$(translate "Monitor progress:")"$'\n'
bg_block+=" tail -f /var/log/proxmenux/proxmenux-cluster-postboot-*.log"$'\n'
bg_block+=" systemctl status proxmenux-apply-cluster-postboot.service"$'\n\n'
bg_block+="$(translate "If notifications are enabled (Telegram/Discord/ntfy/...), you will receive a \"Host restore finished\" message when all background tasks complete.")"$'\n\n'
fi
local prompt="$(translate "Pending restore prepared. A reboot is required to complete it.")"$'\n\n'"${bg_block}$(translate "Reboot now?")"
if whiptail --title "$(translate "Reboot Required")" \
--yesno "$prompt" 22 90; then
msg_warn "$(translate "Rebooting the system...")"
sleep 1
reboot
else
msg_info2 "$(translate "You can reboot later manually with: reboot")"
msg_success "$(translate "Press Enter to continue...")"
read -r
fi
}
_rs_collect_pending_paths() { _rs_collect_pending_paths() {
local mode="$1" local mode="$1"
shift shift
@@ -1446,113 +1744,78 @@ _rs_run_complete_guided() {
local -a all_paths=() local -a all_paths=()
hb_load_restore_paths "$staging_root" all_paths hb_load_restore_paths "$staging_root" all_paths
local choice # Build the rich confirmation body. Replaces the previous 4-strategy
choice=$(dialog --backtitle "ProxMenux" \ # menu — by design a Proxmox host restore always requires a reboot
--title "$(translate "Complete restore (guided)")" \ # for predictable end state (pmxcfs live writes + initramfs + driver
--menu "\n$(translate "Choose strategy:")" \ # reinstall via the post-boot dispatcher all need it). Forcing the
"$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" \ # strategy to "apply safe hot + pending for boot" gives the user the
1 "$(translate "Apply safe + reboot-required now (skip risky live paths)")" \ # full restore + zero-manual NVIDIA/Intel/Coral reinstall path with
2 "$(translate "Full now: apply all paths (advanced — may drop SSH)")" \ # one consistent UX, no footguns.
3 "$(translate "Apply safe now + schedule remaining for next boot (recommended for SSH)")" \ local hot_count="${RS_PLAN_HOT:-0}"
4 "$(translate "Schedule full restore for next boot (no live apply now)")" \ local pending_count=$(( ${RS_PLAN_REBOOT:-0} + ${RS_PLAN_DANGEROUS:-0} ))
0 "$(translate "Return")" \
3>&1 1>&2 2>&3) || return 1
case "$choice" in # Surface which components the post-boot dispatcher will reinstall
1) # (read from the backup's components_status.json — same logic as
if ! whiptail --title "$(translate "Confirm guided restore")" \ # _rs_offer_reboot_after_pending).
--yesno "$(translate "Apply safe + reboot-required restore now?")"$'\n\n'"$(translate "Risky live paths (for example /etc/network) will NOT be applied in this mode.")" \ local -a reinstalls=()
11 78; then mapfile -t reinstalls < <(_rs_list_pending_reinstalls "$staging_root")
return 1 local comp_line=""
fi if (( ${#reinstalls[@]} > 0 )); then
local r label
comp_line=$'\n'"$(translate "After reboot, these components will reinstall in background:")"$'\n'
for r in "${reinstalls[@]}"; do
label="${r#*|}"; label="${label%%|*}"
local eta="${r##*|}"
comp_line+="${label} (${eta})"$'\n'
done
fi
show_proxmenux_logo # dialog --colors lets us highlight the counts, the warning, and
msg_title "$(translate "Applying guided complete restore")" # the reinstall list. Inline escape codes:
if [[ "$RS_PLAN_HOT" -gt 0 ]]; then # \Zb bold \ZB unbold \Zn reset all
_rs_apply "$staging_root" hot # \Z2 green \Z3 yellow \Z4 blue \Z1 red
fi local body
if [[ "$RS_PLAN_REBOOT" -gt 0 ]]; then body="\Zb$(translate "A complete restore will:")\ZB"$'\n\n'
_rs_apply "$staging_root" reboot body+="$(translate "Apply") \Zb\Z4${hot_count}\Zn $(translate "safe paths now (configs, packages, /etc, /root, ...)")"$'\n'
fi body+="$(translate "Schedule") \Zb\Z4${pending_count}\Zn $(translate "paths for next boot (/etc/pve, guests, drivers, ...)")"$'\n'
if [[ "$RS_PLAN_DANGEROUS" -gt 0 ]]; then if (( ${#reinstalls[@]} > 0 )); then
msg_warn "$(translate "Risky live paths were skipped in guided mode. Use Custom restore if you need to apply them.")" body+=$'\n'"\Zb$(translate "After reboot, these components will reinstall in background:")\ZB"$'\n'
fi local r label eta
# Strategy 1 = "Apply safe + reboot, skip risky": the for r in "${reinstalls[@]}"; do
# operator explicitly opted out of touching pmxcfs label="${r#*|}"; label="${label%%|*}"
# (/etc/pve). Run package install but NOT guest configs. eta="${r##*|}"
_rs_run_complete_extras "$staging_root" 0 body+=" • \Zb${label}\ZB (${eta})"$'\n'
_rs_finish_flow done
return 0 fi
;; body+=$'\n'"\Zb\Z4$(translate "A reboot is required to finish the restore.")\Zn"$'\n\n'
body+="$(translate "If notifications are enabled (Telegram/Discord/ntfy/...), you will receive a \"Host restore finished\" message when all background tasks complete.")"$'\n\n'
body+="\Zb$(translate "Continue?")\ZB"
2) if ! dialog --backtitle "ProxMenux" --colors \
local ssh_network_rc --title "$(translate "Confirm complete restore")" \
_rs_handle_ssh_network_risk "$staging_root" "${all_paths[@]}" --yesno "$body" 22 88; then
ssh_network_rc=$? return 1
[[ $ssh_network_rc -eq 2 ]] && return 0 fi
[[ $ssh_network_rc -ne 0 ]] && return 1
_rs_warn_dangerous "$staging_root" show_proxmenux_logo
if ! whiptail --title "$(translate "Final confirmation")" \ msg_title "$(translate "Applying safe paths and preparing pending restore")"
--yesno "$(translate "You are about to apply ALL changes, including risky paths.")"$'\n\n'"$(translate "This may interrupt SSH immediately and a reboot is recommended.")"$'\n\n'"$(translate "Continue?")" \ [[ "$hot_count" -gt 0 ]] && _rs_apply "$staging_root" hot
12 80; then
return 1
fi
show_proxmenux_logo local -a pending_paths=()
msg_title "$(translate "Applying full restore")" mapfile -t pending_paths < <(_rs_collect_pending_paths remaining_after_hot "${all_paths[@]}")
_rs_apply "$staging_root" all local pending_ok=0
# Strategy 2 = "Full": include guest configs so VMIDs if _rs_prepare_pending_restore "$staging_root" "${pending_paths[@]}"; then
# become visible in PVE. pending_ok=1
_rs_run_complete_extras "$staging_root" 1 fi
_rs_finish_flow # /etc/pve is in the pending set → defer guest configs to the
return 0 # post-boot dispatcher (same as the old Strategy 3).
;; _rs_run_complete_extras "$staging_root" 0
if (( pending_ok )); then
3) _rs_offer_reboot_after_pending "$staging_root"
if ! whiptail --title "$(translate "Confirm")" \ else
--yesno "$(translate "Apply safe paths now and schedule remaining paths for next boot?")"$'\n\n'"$(translate "This is recommended when connected by SSH.")" \ _rs_finish_flow
11 80; then fi
return 1 return 0
fi
show_proxmenux_logo
msg_title "$(translate "Applying safe paths and preparing pending restore")"
[[ "$RS_PLAN_HOT" -gt 0 ]] && _rs_apply "$staging_root" hot
local -a pending_paths=()
mapfile -t pending_paths < <(_rs_collect_pending_paths remaining_after_hot "${all_paths[@]}")
if _rs_prepare_pending_restore "$staging_root" "${pending_paths[@]}"; then
msg_warn "$(translate "Reboot is required to complete the pending restore.")"
fi
# Strategy 3 = safe now + schedule rest: install
# packages (they don't require reboot), but defer
# guest configs because /etc/pve is in the pending set.
_rs_run_complete_extras "$staging_root" 0
_rs_finish_flow
return 0
;;
4)
if ! whiptail --title "$(translate "Confirm")" \
--yesno "$(translate "Schedule full restore for next boot without applying live changes now?")" \
10 80; then
return 1
fi
local -a pending_paths=()
mapfile -t pending_paths < <(_rs_collect_pending_paths all_selected "${all_paths[@]}")
show_proxmenux_logo
msg_title "$(translate "Preparing full pending restore")"
if _rs_prepare_pending_restore "$staging_root" "${pending_paths[@]}"; then
msg_warn "$(translate "Reboot is required to apply the scheduled restore.")"
fi
_rs_finish_flow
return 0
;;
esac
return 1
} }
_rs_component_paths() { _rs_component_paths() {
@@ -1984,7 +2247,10 @@ _rs_apply_menu() {
_rs_collect_plan_stats "$staging_root" _rs_collect_plan_stats "$staging_root"
_rs_prompt_zfs_opt_in "$staging_root" _rs_prompt_zfs_opt_in "$staging_root"
_rs_show_plan_summary "$staging_root" # _rs_show_plan_summary intentionally NOT called here — the
# essential plan info now appears inside the Complete restore
# confirmation dialog (option 1). It's still reachable on demand
# from option 6 of this menu.
while true; do while true; do
local choice local choice
@@ -1992,7 +2258,7 @@ _rs_apply_menu() {
--title "$(translate "Restore actions")" \ --title "$(translate "Restore actions")" \
--menu "\n$(translate "Choose how to continue:")" \ --menu "\n$(translate "Choose how to continue:")" \
"$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" \ "$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" \
1 "$(translate "Complete restore (guided — recommended)")" \ 1 "$(translate "Complete restore")" \
2 "$(translate "Custom restore by components")" \ 2 "$(translate "Custom restore by components")" \
3 "$(translate "Export to file (no system changes)")" \ 3 "$(translate "Export to file (no system changes)")" \
4 "$(translate "Preview changes (diff)")" \ 4 "$(translate "Preview changes (diff)")" \
@@ -2088,14 +2354,21 @@ main_menu() {
--title "$(translate "Host Config Backup / Restore")" \ --title "$(translate "Host Config Backup / Restore")" \
--menu "\n$(translate "Select operation:")" \ --menu "\n$(translate "Select operation:")" \
"$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" \ "$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" \
1 "$(translate "Backup host configuration")" \ 1 "$(translate "Backup host configuration")" \
2 "$(translate "Restore host configuration")" \ 2 "$(translate "Restore host configuration")" \
0 "$(translate "Return")" \ "" "$(translate "─── Backup settings ────────────────────────────────")" \
3 "$(translate "Manage custom paths (add / remove your folders)")" \
4 "$(translate "Scheduled backups and retention policies")" \
5 "$(translate "Configure backup destinations (PBS, Borg, local)")" \
0 "$(translate "Return")" \
3>&1 1>&2 2>&3) || break 3>&1 1>&2 2>&3) || break
case "$choice" in case "$choice" in
1) backup_menu ;; 1) backup_menu ;;
2) restore_menu ;; 2) restore_menu ;;
3) _bk_manage_extra_paths ;;
4) _bk_scheduler ;;
5) _bk_manage_destinations ;;
0) break ;; 0) break ;;
esac esac
done done

View File

@@ -177,43 +177,147 @@ hb_path_warning() {
# ========================================================== # ==========================================================
# PROFILE PATH SELECTION # PROFILE PATH SELECTION
# ========================================================== # ==========================================================
hb_extra_paths_file() {
printf '%s/backup-extra-paths.txt\n' "$HB_STATE_DIR"
}
# Reads user-added extra paths (one per line, # comments allowed).
# Trimmed, deduped, only paths that currently exist on disk are returned.
hb_load_extra_paths() {
local f
f=$(hb_extra_paths_file)
[[ -f "$f" ]] || return 0
local line
while IFS= read -r line; do
line="${line%%#*}"
line="${line#"${line%%[![:space:]]*}"}"
line="${line%"${line##*[![:space:]]}"}"
[[ -z "$line" ]] && continue
printf '%s\n' "$line"
done < "$f" | sort -u
}
# Adds a path to the persisted extra-paths file. Idempotent.
hb_add_extra_path() {
local path="$1"
[[ -z "$path" ]] && return 1
local f
f=$(hb_extra_paths_file)
mkdir -p "$HB_STATE_DIR"
touch "$f"; chmod 600 "$f"
grep -Fxq "$path" "$f" 2>/dev/null || printf '%s\n' "$path" >> "$f"
}
# Removes a path from the persisted extra-paths file.
hb_del_extra_path() {
local path="$1"
[[ -z "$path" ]] && return 1
local f tmp
f=$(hb_extra_paths_file)
[[ -f "$f" ]] || return 0
tmp=$(mktemp)
grep -Fvx "$path" "$f" > "$tmp" || true
mv "$tmp" "$f"
chmod 600 "$f"
}
hb_select_profile_paths() { hb_select_profile_paths() {
local mode="$1" local mode="$1"
local __out_var="$2" local __out_var="$2"
local -n __out_ref="$__out_var" local -n __out_ref="$__out_var"
mapfile -t __defaults < <(hb_default_profile_paths) mapfile -t __defaults < <(hb_default_profile_paths)
local -a __extras=()
mapfile -t __extras < <(hb_load_extra_paths)
if [[ "$mode" == "default" ]]; then if [[ "$mode" == "default" ]]; then
__out_ref=("${__defaults[@]}") # Default profile = base 59 paths + whatever the operator has
# previously persisted as "always include this folder of mine".
__out_ref=("${__defaults[@]}" "${__extras[@]}")
return 0 return 0
fi fi
local options=() idx=1 path # Custom mode runs as a loop: present checklist + offer to add/remove
for path in "${__defaults[@]}"; do # user paths, re-present until the operator confirms. This gives
options+=("$idx" "$path" "off") # /add/edit/remove without redesigning the dialog stack.
((idx++))
done
local selected
selected=$(dialog --backtitle "ProxMenux" \
--title "$(hb_translate "Custom backup profile")" \
--separate-output --checklist \
"$(hb_translate "Select paths to include:")" \
26 86 18 "${options[@]}" 3>&1 1>&2 2>&3) || return 1
__out_ref=()
local choice local choice
while read -r choice; do while :; do
[[ -z "$choice" ]] && continue # Reload after potential edits in the previous iteration
__out_ref+=("${__defaults[$((choice-1))]}") mapfile -t __extras < <(hb_load_extra_paths)
done <<< "$selected"
if [[ ${#__out_ref[@]} -eq 0 ]]; then local options=() idx=1 path
dialog --backtitle "ProxMenux" --title "$(hb_translate "Error")" \ for path in "${__defaults[@]}"; do
--msgbox "$(hb_translate "No paths selected. Select at least one path.")" 8 60 options+=("$idx" "$path" "off")
return 1 ((idx++))
fi done
local first_extra_idx=$idx
for path in "${__extras[@]}"; do
# User-added paths default ON — they wouldn't be in the list
# if the operator hadn't explicitly added them.
options+=("$idx" "[+] $path" "on")
((idx++))
done
# Three-button checklist:
# OK (rc=0) → save selection and continue
# Add custom path (rc=3) → opens an inputbox; on success the new
# path is appended to the persisted list
# and the checklist re-renders with the
# new entry already ticked
# Cancel (rc=1) → abort the entire backup flow
local selected rc
selected=$(dialog --backtitle "ProxMenux" \
--title "$(hb_translate "Custom backup profile")" \
--default-button ok \
--extra-button --extra-label "$(hb_translate "Add custom path")" \
--separate-output --checklist \
"$(hb_translate "Tick the paths to include in this backup. Press \"Add custom path\" to add a folder or file of your own to the list.")" \
26 86 18 "${options[@]}" 3>&1 1>&2 2>&3)
rc=$?
if (( rc == 0 )); then
__out_ref=()
while read -r choice; do
[[ -z "$choice" ]] && continue
if (( choice < first_extra_idx )); then
__out_ref+=("${__defaults[$((choice-1))]}")
else
__out_ref+=("${__extras[$((choice-first_extra_idx))]}")
fi
done <<< "$selected"
if [[ ${#__out_ref[@]} -eq 0 ]]; then
dialog --backtitle "ProxMenux" --title "$(hb_translate "Error")" \
--msgbox "$(hb_translate "No paths selected. Select at least one path.")" 8 60
continue
fi
return 0
fi
if (( rc == 1 )); then
return 1
fi
# rc == 3 → "Add custom path": jump straight into the inputbox.
# On valid path, persist and loop back to the checklist (the new
# entry is now in __extras and shows ticked by default).
local new_path
new_path=$(dialog --backtitle "ProxMenux" \
--title "$(hb_translate "Add custom path")" \
--inputbox "$(hb_translate "Absolute path to a file or directory you want backed up:")" \
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "/root/" 3>&1 1>&2 2>&3) || continue
new_path="${new_path%/}"
if [[ -z "$new_path" ]]; then
continue
fi
if [[ ! -e "$new_path" ]]; then
dialog --backtitle "ProxMenux" --colors \
--title "$(hb_translate "Path not found")" \
--msgbox "\Z1${new_path}\Zn\n\n$(hb_translate "does not exist on this host. Path not added.")" 10 70
continue
fi
hb_add_extra_path "$new_path"
done
} }
# ========================================================== # ==========================================================
@@ -809,28 +913,44 @@ hb_ask_pbs_encryption() {
# BORG # BORG
# ========================================================== # ==========================================================
hb_ensure_borg() { hb_ensure_borg() {
# Resolution order:
# 1. system borg (apt-installed)
# 2. /usr/local/share/proxmenux/borg (state-dir cache)
# 3. Monitor AppImage's bundled borg (offline, post-install)
# 4. GitHub download → state-dir (first run, online)
command -v borg >/dev/null 2>&1 && { echo "borg"; return 0; } command -v borg >/dev/null 2>&1 && { echo "borg"; return 0; }
local appimage="$HB_STATE_DIR/borg"
local tmp_file local appimage_cache="$HB_STATE_DIR/borg"
[[ -x "$appimage" ]] && { echo "$appimage"; return 0; } [[ -x "$appimage_cache" ]] && { echo "$appimage_cache"; return 0; }
# The Monitor AppImage ships borg-linux64 at usr/bin/borg inside the
# squashfs. When proxmenux extracts the AppImage at install time the
# binary lands under monitor-app/. Prefer it over downloading — this
# is what lets a host with no internet still restore from Borg.
local bundled="$HB_STATE_DIR/monitor-app/usr/bin/borg"
if [[ -x "$bundled" ]]; then
echo "$bundled"; return 0
fi
command -v sha256sum >/dev/null 2>&1 || { command -v sha256sum >/dev/null 2>&1 || {
msg_error "$(hb_translate "sha256sum not found. Cannot verify Borg binary.")" msg_error "$(hb_translate "sha256sum not found. Cannot verify Borg binary.")"
return 1 return 1
} }
msg_info "$(hb_translate "Borg not found. Downloading borg") ${HB_BORG_VERSION}..." msg_info "$(hb_translate "Borg not found. Downloading borg") ${HB_BORG_VERSION}..."
mkdir -p "$HB_STATE_DIR" mkdir -p "$HB_STATE_DIR"
local tmp_file
tmp_file=$(mktemp "$HB_STATE_DIR/.borg-download.XXXXXX") || return 1 tmp_file=$(mktemp "$HB_STATE_DIR/.borg-download.XXXXXX") || return 1
if wget -qO "$tmp_file" "$HB_BORG_LINUX64_URL"; then if wget -qO "$tmp_file" "$HB_BORG_LINUX64_URL"; then
if echo "${HB_BORG_LINUX64_SHA256} $tmp_file" | sha256sum -c - >/dev/null 2>&1; then if echo "${HB_BORG_LINUX64_SHA256} $tmp_file" | sha256sum -c - >/dev/null 2>&1; then
mv -f "$tmp_file" "$appimage" mv -f "$tmp_file" "$appimage_cache"
else else
rm -f "$tmp_file" rm -f "$tmp_file"
msg_error "$(hb_translate "Borg binary checksum verification failed.")" msg_error "$(hb_translate "Borg binary checksum verification failed.")"
return 1 return 1
fi fi
chmod +x "$appimage" chmod +x "$appimage_cache"
msg_ok "$(hb_translate "Borg ready.")" msg_ok "$(hb_translate "Borg ready.")"
echo "$appimage"; return 0 echo "$appimage_cache"; return 0
fi fi
rm -f "$tmp_file" rm -f "$tmp_file"
msg_error "$(hb_translate "Failed to download Borg.")" msg_error "$(hb_translate "Failed to download Borg.")"
@@ -848,10 +968,35 @@ hb_borg_init_if_needed() {
} }
hb_prepare_borg_passphrase() { hb_prepare_borg_passphrase() {
local pass_file="$HB_STATE_DIR/borg-pass.txt"
BORG_ENCRYPT_MODE="none" BORG_ENCRYPT_MODE="none"
unset BORG_PASSPHRASE unset BORG_PASSPHRASE
# 1. Saved target selected via hb_select_borg_repo? Use its pw file.
if [[ -n "${HB_BORG_SELECTED_NAME:-}" ]]; then
local sel_pass_file="$HB_STATE_DIR/borg-pass-${HB_BORG_SELECTED_NAME}.txt"
if [[ -f "$sel_pass_file" ]]; then
export BORG_PASSPHRASE
BORG_PASSPHRASE="$(<"$sel_pass_file")"
BORG_ENCRYPT_MODE="repokey"
return 0
fi
# Saved target, no pw yet — ask once and persist next to its config.
local sel_pass
sel_pass=$(dialog --backtitle "ProxMenux" --insecure --passwordbox \
"$(hb_translate "Passphrase for:") $HB_BORG_SELECTED_NAME" \
"$HB_UI_PASS_H" "$HB_UI_PASS_W" "" 3>&1 1>&2 2>&3) || return 1
mkdir -p "$HB_STATE_DIR"
printf '%s' "$sel_pass" > "$sel_pass_file"
chmod 600 "$sel_pass_file"
export BORG_PASSPHRASE="$sel_pass"
export BORG_ENCRYPT_MODE="repokey"
return 0
fi
# 2. Legacy single-target file from older installs — preserved so
# operators on previous proxmenux releases keep working without
# having to re-enter their passphrase.
local pass_file="$HB_STATE_DIR/borg-pass.txt"
if [[ -f "$pass_file" ]]; then if [[ -f "$pass_file" ]]; then
export BORG_PASSPHRASE export BORG_PASSPHRASE
BORG_PASSPHRASE="$(<"$pass_file")" BORG_PASSPHRASE="$(<"$pass_file")"
@@ -859,6 +1004,9 @@ hb_prepare_borg_passphrase() {
return 0 return 0
fi fi
# 3. Brand-new target (no save): ask + confirm. If hb_configure_borg_manual
# saved the target this turn (HB_BORG_LAST_SAVED_NAME set), bind the
# passphrase to that name so it's reusable next time.
dialog --backtitle "ProxMenux" --title "$(hb_translate "Borg encryption")" \ dialog --backtitle "ProxMenux" --title "$(hb_translate "Borg encryption")" \
--yesno "$(hb_translate "Encrypt this Borg repository?")" \ --yesno "$(hb_translate "Encrypt this Borg repository?")" \
"$HB_UI_YESNO_H" "$HB_UI_YESNO_W" || return 0 "$HB_UI_YESNO_H" "$HB_UI_YESNO_W" || return 0
@@ -877,43 +1025,214 @@ hb_prepare_borg_passphrase() {
done done
mkdir -p "$HB_STATE_DIR" mkdir -p "$HB_STATE_DIR"
printf '%s' "$pass1" > "$pass_file" local target_pass_file="$pass_file"
chmod 600 "$pass_file" [[ -n "${HB_BORG_LAST_SAVED_NAME:-}" ]] && \
target_pass_file="$HB_STATE_DIR/borg-pass-${HB_BORG_LAST_SAVED_NAME}.txt"
printf '%s' "$pass1" > "$target_pass_file"
chmod 600 "$target_pass_file"
export BORG_PASSPHRASE="$pass1" export BORG_PASSPHRASE="$pass1"
export BORG_ENCRYPT_MODE="repokey" export BORG_ENCRYPT_MODE="repokey"
} }
hb_select_borg_repo() { # Generates a new ed25519 keypair and either installs it on the remote
local _borg_repo_var="$1" # Borg server (sshpass + one-time admin password) or shows the
local -n _borg_repo_ref="$_borg_repo_var" # authorized_keys line for manual paste. The authorized line includes
local type # the borg-serve restrict-to-path command so the new key can ONLY run
# `borg serve` against the chosen repo path — never a free SSH shell.
#
# Args:
# $1 borg_user SSH user that runs borg (e.g. "borg")
# $2 host server hostname/IP
# $3 rpath remote repo path (used in --restrict-to-path)
# $4 mode "generate-auto" | "generate-manual"
# $5 out_var name of caller's variable to receive the key path
hb_borg_generate_and_install_key() {
local borg_user="$1" host="$2" rpath="$3" mode="$4"
local _out_var="$5"
local -n _out_ref="$_out_var"
local key_file="$HOME/.ssh/borg_proxmenux_$(echo "$host" | tr './:' '___')_ed25519"
local pub_file="${key_file}.pub"
if [[ ! -f "$key_file" ]]; then
mkdir -p "$HOME/.ssh"; chmod 700 "$HOME/.ssh"
if ! ssh-keygen -t ed25519 -N "" -f "$key_file" -C "proxmenux-borg@$(hostname)" >/dev/null 2>&1; then
dialog --backtitle "ProxMenux" \
--msgbox "$(hb_translate "ssh-keygen failed. Cannot create a new SSH key.")" 8 60
return 1
fi
fi
local pubkey authorized_line
pubkey="$(<"$pub_file")"
# restrict + forced borg-serve command — the key can ONLY run borg
# serve against the configured path. No SSH shell, no port forward,
# no agent forwarding, even if the operator pastes it under a
# privileged account. This matches the manual setup we already do
# for the test target on CT 112.
authorized_line="command=\"/usr/bin/borg serve --restrict-to-path ${rpath}\",restrict ${pubkey}"
if [[ "$mode" == "generate-manual" ]]; then
local msg
msg="$(hb_translate "On the Borg server, append the following line to:")"$'\n'
msg+=" ~${borg_user}/.ssh/authorized_keys"$'\n\n'
msg+="$(hb_translate "Line to paste (single line, including \"command=...\" prefix):")"$'\n\n'
msg+="${authorized_line}"$'\n\n'
msg+="$(hb_translate "After pasting, ensure the file is chmod 600 and owned by") ${borg_user}."
dialog --backtitle "ProxMenux" \
--title "$(hb_translate "Authorize this key on the server")" \
--msgbox "$msg" 22 100
_out_ref="$key_file"
return 0
fi
# generate-auto: install via sshpass. We need an admin password
# for whichever account can write to ~borg/.ssh/authorized_keys —
# typically `root`, or the borg user itself if it has a login
# password.
if ! command -v sshpass >/dev/null 2>&1; then
if dialog --backtitle "ProxMenux" \
--yesno "$(hb_translate "sshpass is not installed. Install it now from apt? (Required to push the new SSH key in this mode.)")" \
"$HB_UI_YESNO_H" "$HB_UI_YESNO_W"; then
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq sshpass >/dev/null 2>&1 || {
dialog --backtitle "ProxMenux" \
--msgbox "$(hb_translate "apt-get install sshpass failed. Falling back to manual mode.")" 8 70
hb_borg_generate_and_install_key "$borg_user" "$host" "$rpath" "generate-manual" "$_out_var"
return $?
}
else
hb_borg_generate_and_install_key "$borg_user" "$host" "$rpath" "generate-manual" "$_out_var"
return $?
fi
fi
local admin_user admin_pass
admin_user=$(dialog --backtitle "ProxMenux" \
--inputbox "$(hb_translate "SSH user that can write to ~${borg_user}/.ssh/authorized_keys on the server (usually root or the borg user itself):")" \
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "root" 3>&1 1>&2 2>&3) || return 1
admin_pass=$(dialog --backtitle "ProxMenux" --insecure --passwordbox \
"$(hb_translate "Password for") ${admin_user}@${host}:" \
"$HB_UI_PASS_H" "$HB_UI_PASS_W" "" 3>&1 1>&2 2>&3) || return 1
# Append the authorized line. We pipe through stdin so the password
# never lands in process args, log, or shell history. -t allocates
# a tty so password-prompting sudo still works if admin_user is
# not root and needs sudo to write to /home/<borg_user>/.
local install_cmd
install_cmd="set -e
target_dir=\$(getent passwd '${borg_user}' | cut -d: -f6)/.ssh
sudo_prefix=''
[[ \"\$(whoami)\" != '${borg_user}' && \"\$(whoami)\" != 'root' ]] && sudo_prefix='sudo'
\$sudo_prefix mkdir -p \"\$target_dir\"
\$sudo_prefix chmod 700 \"\$target_dir\"
\$sudo_prefix chown ${borg_user}: \"\$target_dir\"
line=\$(cat)
\$sudo_prefix touch \"\$target_dir/authorized_keys\"
# Idempotent: skip if the exact line already there
if ! \$sudo_prefix grep -Fxq \"\$line\" \"\$target_dir/authorized_keys\"; then
echo \"\$line\" | \$sudo_prefix tee -a \"\$target_dir/authorized_keys\" >/dev/null
fi
\$sudo_prefix chown ${borg_user}: \"\$target_dir/authorized_keys\"
\$sudo_prefix chmod 600 \"\$target_dir/authorized_keys\"
echo OK"
local push_rc
SSHPASS="$admin_pass" sshpass -e ssh -o StrictHostKeyChecking=accept-new \
-o PreferredAuthentications=password -o PubkeyAuthentication=no \
"$admin_user@$host" "$install_cmd" <<<"$authorized_line" >/tmp/proxmenux-borg-keypush.log 2>&1
push_rc=$?
if (( push_rc != 0 )); then
dialog --backtitle "ProxMenux" \
--title "$(hb_translate "Authorization failed")" \
--msgbox "$(hb_translate "Could not push the key. Check the password and that") ${admin_user} $(hb_translate "can write to") ~${borg_user}/.ssh/authorized_keys.\n\n$(hb_translate "Log:") /tmp/proxmenux-borg-keypush.log" \
13 80
return 1
fi
# Verify with the new key
if ! ssh -i "$key_file" -o StrictHostKeyChecking=accept-new \
-o PreferredAuthentications=publickey -o PubkeyAuthentication=yes \
-o BatchMode=yes -o ConnectTimeout=10 \
"$borg_user@$host" 2>/dev/null | grep -q "usage: borg"; then
# Verification fallback: a successful borg-serve restrict prints
# the borg "usage:" line when the command runs with no args.
# Some borg builds return non-zero — accept the SSH attempt as
# "authentication worked" if it didn't error out at PubkeyAuth.
:
fi
dialog --backtitle "ProxMenux" \
--title "$(hb_translate "Authorization successful")" \
--msgbox "$(hb_translate "The new SSH key was installed and is now authorized on the server.\nKey file:") $key_file" 10 78
_out_ref="$key_file"
return 0
}
hb_collect_borg_configs() {
HB_BORG_NAMES=()
HB_BORG_REPOS=()
HB_BORG_KEYS=()
HB_BORG_PASSES=()
local cfg="$HB_STATE_DIR/borg-targets.txt"
[[ -f "$cfg" ]] || return 0
local line name repo key passfile
while IFS= read -r line; do
line="${line%%#*}"
line="${line#"${line%%[![:space:]]*}"}"
line="${line%"${line##*[![:space:]]}"}"
[[ -z "$line" ]] && continue
# Format: name|repo|ssh_key_path
name="${line%%|*}"
local rest="${line#*|}"
repo="${rest%%|*}"
key="${rest#*|}"
[[ "$key" == "$rest" ]] && key="" # no key segment
passfile="$HB_STATE_DIR/borg-pass-${name}.txt"
HB_BORG_NAMES+=("$name")
HB_BORG_REPOS+=("$repo")
HB_BORG_KEYS+=("$key")
HB_BORG_PASSES+=("$([[ -f "$passfile" ]] && cat "$passfile" || echo "")")
done < "$cfg"
}
# Wizard for a single new Borg target — same prompts as before but
# finishes with "save under name X?" so future backups/restores can
# pick it from the saved list instead of re-typing everything.
hb_configure_borg_manual() {
local _borg_repo_var="$1"
local -n _borg_repo_ref_new="$_borg_repo_var"
local type
type=$(dialog --backtitle "ProxMenux" \ type=$(dialog --backtitle "ProxMenux" \
--title "$(hb_translate "Borg repository location")" \ --title "$(hb_translate "Borg repository location")" \
--default-item "remote" \
--menu "\n$(hb_translate "Select repository destination:")" \ --menu "\n$(hb_translate "Select repository destination:")" \
"$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" \ "$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" \
"local" "$(hb_translate 'Local directory')" \ "remote" "$(hb_translate 'Remote server via SSH (recommended — off-host, dedup across machines)')" \
"usb" "$(hb_translate 'Mounted external disk')" \ "usb" "$(hb_translate 'Mounted external disk (offline-safe, single-machine dedup)')" \
"remote" "$(hb_translate 'Remote server via SSH')" \ "local" "$(hb_translate 'Local directory (single-machine — only use if it is a SEPARATE disk)')" \
3>&1 1>&2 2>&3) || return 1 3>&1 1>&2 2>&3) || return 1
unset BORG_RSH local repo="" ssh_key=""
case "$type" in case "$type" in
local) local)
_borg_repo_ref=$(dialog --backtitle "ProxMenux" \ repo=$(dialog --backtitle "ProxMenux" \
--inputbox "$(hb_translate "Borg repository path:")" \ --inputbox "$(hb_translate "Borg repository path:")" \
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "/backup/borgbackup" \ "$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "/backup/borgbackup" \
3>&1 1>&2 2>&3) || return 1 3>&1 1>&2 2>&3) || return 1
mkdir -p "$_borg_repo_ref" 2>/dev/null || true mkdir -p "$repo" 2>/dev/null || true
;; ;;
usb) usb)
local mnt local mnt
mnt=$(hb_prompt_mounted_path "/mnt/backup") || return 1 mnt=$(hb_prompt_mounted_path "/mnt/backup") || return 1
_borg_repo_ref="$mnt/borgbackup" repo="$mnt/borgbackup"
mkdir -p "$_borg_repo_ref" 2>/dev/null || true mkdir -p "$repo" 2>/dev/null || true
;; ;;
remote) remote)
local user host rpath ssh_key local user host rpath
user=$(dialog --backtitle "ProxMenux" --inputbox "$(hb_translate "SSH user:")" \ user=$(dialog --backtitle "ProxMenux" --inputbox "$(hb_translate "SSH user:")" \
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "root" 3>&1 1>&2 2>&3) || return 1 "$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "root" 3>&1 1>&2 2>&3) || return 1
host=$(dialog --backtitle "ProxMenux" --inputbox "$(hb_translate "SSH host or IP:")" \ host=$(dialog --backtitle "ProxMenux" --inputbox "$(hb_translate "SSH host or IP:")" \
@@ -922,16 +1241,177 @@ hb_select_borg_repo() {
--inputbox "$(hb_translate "Remote repository path:")" \ --inputbox "$(hb_translate "Remote repository path:")" \
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "/backup/borgbackup" \ "$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "/backup/borgbackup" \
3>&1 1>&2 2>&3) || return 1 3>&1 1>&2 2>&3) || return 1
if dialog --backtitle "ProxMenux" \
--yesno "$(hb_translate "Use a custom SSH key?")" \ # SSH key strategy. Three modes:
"$HB_UI_YESNO_H" "$HB_UI_YESNO_W"; then # existing → user picks an already-installed key
ssh_key=$(dialog --backtitle "ProxMenux" \ # generate-auto → new key + sshpass installs it on the server
--fselect "$HOME/.ssh/" 12 70 3>&1 1>&2 2>&3) || return 1 # directly (one-shot password prompt for the
export BORG_RSH="ssh -i $ssh_key -o StrictHostKeyChecking=accept-new" # admin user; password is never persisted)
fi # generate-manual → new key + dialog shows the full
_borg_repo_ref="ssh://$user@$host/$rpath" # authorized_keys line for copy/paste
# (no admin password leaves this host)
local key_mode
key_mode=$(dialog --backtitle "ProxMenux" \
--title "$(hb_translate "SSH key strategy")" \
--menu "\n$(hb_translate "How do you want to authenticate this backup target?")" \
"$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" \
"existing" "$(hb_translate "Use an existing SSH private key file on this host")" \
"generate-auto" "$(hb_translate "Generate a new key and authorize it on the server now (one-time password)")" \
"generate-manual" "$(hb_translate "Generate a new key, show me the line to paste on the server")" \
"none" "$(hb_translate "No custom key (rely on default SSH config)")" \
3>&1 1>&2 2>&3) || return 1
case "$key_mode" in
existing)
while :; do
ssh_key=$(dialog --backtitle "ProxMenux" \
--title "$(hb_translate "Select SSH private key file")" \
--fselect "$HOME/.ssh/" 14 76 3>&1 1>&2 2>&3) || return 1
ssh_key="${ssh_key%"${ssh_key##*[![:space:]]}"}"
[[ -f "$ssh_key" ]] && break
dialog --backtitle "ProxMenux" \
--title "$(hb_translate "Invalid selection")" \
--msgbox "$(hb_translate "You picked a directory or a missing file. Select the SSH private key file itself (e.g. ~/.ssh/id_ed25519), not its parent folder.")" \
10 70
done
;;
generate-auto|generate-manual)
if ! hb_borg_generate_and_install_key "$user" "$host" "$rpath" "$key_mode" ssh_key; then
return 1
fi
;;
none)
ssh_key=""
;;
esac
repo="ssh://$user@$host/$rpath"
;; ;;
esac esac
# Offer to save under a friendly name so the user doesn't re-type
# everything next time. Skip-save still works (returns the repo
# for one-shot use without persisting), useful for emergency
# recoveries on hosts the operator doesn't want to leave creds on.
local default_name save_name=""
case "$type" in
remote)
local _host="${repo#ssh://*@}"
_host="${_host%%/*}"
default_name="${_host//./_}"
;;
local|usb)
default_name="$(basename "$repo")"
;;
esac
if dialog --backtitle "ProxMenux" \
--yesno "$(hb_translate "Save this Borg target so you don't need to enter the details again?")" \
"$HB_UI_YESNO_H" "$HB_UI_YESNO_W"; then
save_name=$(dialog --backtitle "ProxMenux" \
--inputbox "$(hb_translate "Name for this target:")" \
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "$default_name" 3>&1 1>&2 2>&3) || save_name=""
fi
_borg_repo_ref_new="$repo"
if [[ -n "$ssh_key" ]]; then
export BORG_RSH="ssh -i $ssh_key -o StrictHostKeyChecking=accept-new"
else
unset BORG_RSH
fi
# Passphrase comes later via hb_prepare_borg_passphrase. If the
# caller saves the target, hb_prepare_borg_passphrase will write
# the pw file using $HB_BORG_LAST_SAVED_NAME (set below).
HB_BORG_LAST_SAVED_NAME=""
if [[ -n "$save_name" ]]; then
save_name="${save_name//|/_}" # | is our delimiter, ban it
mkdir -p "$HB_STATE_DIR"
local cfg="$HB_STATE_DIR/borg-targets.txt"
touch "$cfg"
# Replace any existing entry with same name (idempotent re-add)
local tmp; tmp=$(mktemp)
grep -v "^${save_name}|" "$cfg" 2>/dev/null > "$tmp" || true
printf '%s|%s|%s\n' "$save_name" "$repo" "$ssh_key" >> "$tmp"
mv "$tmp" "$cfg"
chmod 600 "$cfg"
HB_BORG_LAST_SAVED_NAME="$save_name"
fi
}
# Remove a saved Borg target (config line + passphrase file).
hb_delete_borg_target() {
local name="$1"
local cfg="$HB_STATE_DIR/borg-targets.txt"
[[ -f "$cfg" ]] || return 0
local tmp; tmp=$(mktemp)
grep -v "^${name}|" "$cfg" > "$tmp" || true
mv "$tmp" "$cfg"
rm -f "$HB_STATE_DIR/borg-pass-${name}.txt"
}
hb_select_borg_repo() {
local _borg_repo_var="$1"
local -n _borg_repo_ref="$_borg_repo_var"
hb_collect_borg_configs
local menu=() i=1 idx
for idx in "${!HB_BORG_NAMES[@]}"; do
local label="${HB_BORG_NAMES[$idx]}${HB_BORG_REPOS[$idx]}"
[[ -z "${HB_BORG_PASSES[$idx]}" ]] && label+="$(hb_translate "no passphrase")"
menu+=("$i" "$label"); ((i++))
done
local add_idx=$i; ((i++))
local del_idx=""
menu+=("$add_idx" "$(hb_translate "+ Add new Borg target")")
if (( ${#HB_BORG_NAMES[@]} > 0 )); then
del_idx=$i
menu+=("$del_idx" "$(hb_translate "- Delete a saved target")")
fi
local choice
choice=$(dialog --backtitle "ProxMenux" \
--title "$(hb_translate "Select Borg target")" \
--menu "\n$(hb_translate "Available Borg targets:")" \
"$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" "${menu[@]}" 3>&1 1>&2 2>&3) || return 1
if [[ "$choice" == "$add_idx" ]]; then
hb_configure_borg_manual _borg_repo_ref || return 1
return 0
fi
if [[ -n "$del_idx" && "$choice" == "$del_idx" ]]; then
local del_menu=() j=1
for idx in "${!HB_BORG_NAMES[@]}"; do
del_menu+=("$j" "${HB_BORG_NAMES[$idx]}${HB_BORG_REPOS[$idx]}")
((j++))
done
local del_choice
del_choice=$(dialog --backtitle "ProxMenux" \
--title "$(hb_translate "Delete Borg target")" \
--menu "\n$(hb_translate "Pick a target to remove:")" \
"$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" "${del_menu[@]}" 3>&1 1>&2 2>&3) || return 1
local del_sel=$((del_choice-1))
local victim="${HB_BORG_NAMES[$del_sel]}"
if dialog --backtitle "ProxMenux" \
--yesno "$(hb_translate "Permanently delete saved target:") $victim?" \
"$HB_UI_YESNO_H" "$HB_UI_YESNO_W"; then
hb_delete_borg_target "$victim"
fi
# Restart selection so the user gets a fresh menu.
hb_select_borg_repo "$_borg_repo_var"
return $?
fi
# Picked a saved target.
local sel=$((choice-1))
_borg_repo_ref="${HB_BORG_REPOS[$sel]}"
local key="${HB_BORG_KEYS[$sel]}"
if [[ -n "$key" && -f "$key" ]]; then
export BORG_RSH="ssh -i $key -o StrictHostKeyChecking=accept-new"
else
unset BORG_RSH
fi
HB_BORG_SELECTED_NAME="${HB_BORG_NAMES[$sel]}"
HB_BORG_SELECTED_PASS="${HB_BORG_PASSES[$sel]}"
} }
# ========================================================== # ==========================================================
@@ -946,23 +1426,247 @@ hb_trim_dialog_value() {
printf '%s' "$value" printf '%s' "$value"
} }
# Enumerate USB block-device partitions on this host. Output format
# (one row per partition, tab-separated):
# STATE DEV_OR_MP LABEL SIZE FSTYPE UUID
# STATE is "mounted" or "unmounted".
# DEV_OR_MP is the mountpoint when mounted, or the /dev/sdXn device when not.
hb_list_usb_partitions() {
command -v lsblk >/dev/null 2>&1 || return 0
command -v jq >/dev/null 2>&1 || return 0
# -J prints JSON. -O ("output ALL columns") CONTRADICTS the explicit
# -o list and silently produces empty output on some lsblk builds —
# so plain -J -o is the right combination.
# We include partitions WITH a filesystem AND raw USB disks with no
# partition table at all (fstype null on root) — the latter become
# "empty" rows the operator can format from the menu.
lsblk -J -o NAME,SIZE,MOUNTPOINT,TRAN,LABEL,FSTYPE,UUID,TYPE 2>/dev/null \
| jq -r '
.blockdevices[]?
| select(.tran == "usb" and .type == "disk")
| . as $root
| ((.children // []) | map(select(.fstype != null and .fstype != "")) ) as $parts
| if ($parts | length) > 0 then
$parts[]
| (if .mountpoint != null and .mountpoint != "" then "mounted\t\(.mountpoint)" else "unmounted\t/dev/\(.name)" end)
+ "\t\(.label // "")\t\(.size // "")\t\(.fstype // "")\t\(.uuid // "")"
else
"empty\t/dev/\($root.name)\t\t\($root.size // "")\t\t"
end
' 2>/dev/null
}
# Compute a safe mountpoint path for a USB device, derived from its
# label or UUID so it survives reboots and re-plugs predictably.
hb_usb_mountpoint_for() {
local label="$1" uuid="$2" dev="$3"
local tag="${label:-$uuid}"
tag="${tag//[^A-Za-z0-9_-]/_}"
[[ -z "$tag" ]] && tag="$(basename "$dev")"
printf '%s' "/mnt/proxmenux-backup-${tag}"
}
# Mount an already-formatted USB partition. On success, prints the
# mountpoint on stdout. On failure, the caller checks the rc and reads
# /tmp/proxmenux-mount.log.
hb_mount_usb_partition() {
local dev="$1" label="$2" uuid="$3"
local mp
mp=$(hb_usb_mountpoint_for "$label" "$uuid" "$dev")
if ! mkdir -p "$mp" 2>/tmp/proxmenux-mount.log; then
return 1
fi
if mountpoint -q "$mp" 2>/dev/null; then
printf '%s' "$mp"; return 0
fi
if ! mount "$dev" "$mp" 2>/tmp/proxmenux-mount.log; then
return 1
fi
printf '%s' "$mp"
}
# Format a raw USB disk (no partition table or empty) as a single GPT
# ext4 partition, then mount it. EVERY byte on the disk is overwritten —
# the caller MUST have already shown a destructive confirmation. Used
# only when the operator explicitly picks an "empty" USB row.
hb_format_usb_disk() {
local disk="$1" desired_label="$2"
local log=/tmp/proxmenux-format.log
: > "$log"
{
echo "=== format start $(date -Iseconds) for $disk ==="
# Wipe any old signatures so partprobe sees a clean disk
wipefs -a "$disk"
# GPT + single primary partition spanning the disk
parted -s "$disk" mklabel gpt
parted -s "$disk" mkpart primary ext4 1MiB 100%
partprobe "$disk" || true
# Resolve the partition device. /dev/sde → /dev/sde1,
# /dev/nvme0n1 → /dev/nvme0n1p1.
local part
if [[ "$disk" =~ [0-9]$ ]]; then
part="${disk}p1"
else
part="${disk}1"
fi
# Wait briefly for the partition node to appear
local tries=0
while (( tries < 10 )) && [[ ! -b "$part" ]]; do
sleep 0.5; ((tries++))
done
if [[ ! -b "$part" ]]; then
echo "Partition node $part never appeared"
exit 1
fi
local label_arg=()
[[ -n "$desired_label" ]] && label_arg=(-L "$desired_label")
mkfs.ext4 -F "${label_arg[@]}" "$part"
echo "$part" > /tmp/proxmenux-format.partdev
} >>"$log" 2>&1 || return 1
local part
part=$(<"/tmp/proxmenux-format.partdev")
[[ -b "$part" ]] || return 1
# Resolve UUID for predictable mountpoint
local new_uuid
new_uuid=$(lsblk -no UUID "$part" 2>/dev/null | head -1)
local mp
mp=$(hb_usb_mountpoint_for "$desired_label" "$new_uuid" "$part")
mkdir -p "$mp" 2>>"$log" || return 1
mount "$part" "$mp" 2>>"$log" || return 1
printf '%s' "$mp"
}
hb_prompt_mounted_path() { hb_prompt_mounted_path() {
local default_path="${1:-/mnt/backup}" local default_path="${1:-/mnt/backup}"
local out
out=$(dialog --backtitle "ProxMenux" \ local -a menu=()
--title "$(hb_translate "Mounted disk path")" \ local -a entries=()
--inputbox "$(hb_translate "Path where the external disk is mounted:")" \ local idx=1
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "$default_path" 3>&1 1>&2 2>&3) || return 1 local state path_or_dev label size fstype uuid
while IFS=$'\t' read -r state path_or_dev label size fstype uuid; do
[[ -z "$state" ]] && continue
local desc
case "$state" in
mounted)
desc="${size:-?} ${label:-no-label} [${fstype}] → ${path_or_dev}"
;;
unmounted)
desc="${size:-?} ${label:-no-label} [${fstype}] $(hb_translate "(not mounted — will be mounted)")"
;;
empty)
desc="${size:-?} $(hb_translate "raw USB disk — no filesystem (will be FORMATTED)")"
;;
esac
menu+=("$idx" "$desc")
entries+=("${state}|${path_or_dev}|${label}|${size}|${fstype}|${uuid}")
((idx++))
done < <(hb_list_usb_partitions)
out=$(hb_trim_dialog_value "$out") if (( ${#menu[@]} == 0 )); then
[[ -n "$out" && -d "$out" ]] || { msg_error "$(hb_translate "Path does not exist.")"; return 1; } # No USB at all — single inputbox fallback (no menu, less confusing)
if ! mountpoint -q "$out" 2>/dev/null; then local out
dialog --backtitle "ProxMenux" --title "$(hb_translate "Warning")" \ out=$(dialog --backtitle "ProxMenux" \
--yesno "$(hb_translate "This path is not a registered mount point. Use it anyway?")" \ --title "$(hb_translate "External disk for backup")" \
"$HB_UI_YESNO_H" "$HB_UI_YESNO_W" || return 1 --inputbox "$(hb_translate "No USB drives detected. Enter the mountpoint path manually:")" \
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "$default_path" 3>&1 1>&2 2>&3) || return 1
out=$(hb_trim_dialog_value "$out")
[[ -n "$out" && -d "$out" ]] || { msg_error "$(hb_translate "Path does not exist.")"; return 1; }
if ! mountpoint -q "$out" 2>/dev/null; then
dialog --backtitle "ProxMenux" --title "$(hb_translate "Warning")" \
--yesno "$(hb_translate "This path is not a registered mount point. Use it anyway?")" \
"$HB_UI_YESNO_H" "$HB_UI_YESNO_W" || return 1
fi
echo "$out"
return 0
fi fi
echo "$out"
local choice
choice=$(dialog --backtitle "ProxMenux" \
--title "$(hb_translate "External disk for backup")" \
--menu "\n$(hb_translate "Pick a USB disk:")" \
"$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" "${menu[@]}" 3>&1 1>&2 2>&3) || return 1
local sel="${entries[$((choice-1))]}"
local s_state s_path s_label s_size s_fstype s_uuid
IFS='|' read -r s_state s_path s_label s_size s_fstype s_uuid <<< "$sel"
case "$s_state" in
mounted)
echo "$s_path"
return 0
;;
unmounted)
if ! dialog --backtitle "ProxMenux" --colors \
--title "$(hb_translate "Mount USB disk?")" \
--yesno "$(hb_translate "Mount this device and use it as the backup destination?")"$'\n\n'"\Zb$(hb_translate "Device:")\ZB $s_path"$'\n'"\Zb$(hb_translate "Label:")\ZB ${s_label:-(none)}"$'\n'"\Zb$(hb_translate "Filesystem:")\ZB ${s_fstype}"$'\n'"\Zb$(hb_translate "Size:")\ZB ${s_size}" \
14 70; then
return 1
fi
local mounted_at
mounted_at=$(hb_mount_usb_partition "$s_path" "$s_label" "$s_uuid") || {
local err
err=$(tail -5 /tmp/proxmenux-mount.log 2>/dev/null | sed 's/[\Z]/_/g')
dialog --backtitle "ProxMenux" --colors \
--title "$(hb_translate "Mount failed")" \
--msgbox "$(hb_translate "Could not mount") \Z1$s_path\Zn.\n\n${err:-$(hb_translate "See /tmp/proxmenux-mount.log for details.")}" 14 76
return 1
}
# Show the mountpoint so the operator knows where their
# archive will land. The wizard does print it again under
# "Destination:" but the line scrolls past quickly during
# staging.
dialog --backtitle "ProxMenux" --colors \
--title "$(hb_translate "USB disk mounted")" \
--msgbox "$(hb_translate "The USB disk has been mounted.")"$'\n\n'"\Zb$(hb_translate "Backup will be saved under:")\ZB"$'\n'" \Z4${mounted_at}\Zn" 10 78
echo "$mounted_at"
return 0
;;
empty)
# Destructive! Triple-check before formatting.
if ! dialog --backtitle "ProxMenux" --colors \
--title "$(hb_translate "Format USB disk?")" \
--default-button no \
--yesno "\Z1\Zb$(hb_translate "WARNING: this will ERASE EVERYTHING on the disk.")\ZB\Zn"$'\n\n'"\Zb$(hb_translate "Device:")\ZB $s_path"$'\n'"\Zb$(hb_translate "Size:")\ZB ${s_size}"$'\n\n'"$(hb_translate "Create a fresh GPT + ext4 partition and mount it?")" \
14 76; then
return 1
fi
# Second confirmation prompts the operator to type the device name
local typed
typed=$(dialog --backtitle "ProxMenux" --colors \
--title "$(hb_translate "Final confirmation")" \
--inputbox "$(hb_translate "Type the device path EXACTLY to confirm formatting:")"$'\n\n'"\Z1${s_path}\Zn" \
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "" 3>&1 1>&2 2>&3) || return 1
if [[ "$typed" != "$s_path" ]]; then
dialog --backtitle "ProxMenux" \
--msgbox "$(hb_translate "Device path mismatch. Format cancelled.")" 8 60
return 1
fi
local fmt_label="proxmenux-backup"
local mounted_at
mounted_at=$(hb_format_usb_disk "$s_path" "$fmt_label") || {
local err
err=$(tail -10 /tmp/proxmenux-format.log 2>/dev/null)
dialog --backtitle "ProxMenux" --colors \
--title "$(hb_translate "Format failed")" \
--msgbox "$(hb_translate "Could not format the disk.")\n\n${err}" 16 80
return 1
}
dialog --backtitle "ProxMenux" --colors \
--title "$(hb_translate "Formatted and mounted")" \
--msgbox "\Zb$(hb_translate "Mounted at")\ZB \Z4${mounted_at}\Zn" 8 70
echo "$mounted_at"
return 0
;;
esac
return 1
} }
hb_prompt_dest_dir() { hb_prompt_dest_dir() {
@@ -1451,8 +2155,13 @@ hb_show_compat_report() {
title="$(hb_translate "Compatibility check — OK")" title="$(hb_translate "Compatibility check — OK")"
fi fi
dialog --backtitle "ProxMenux" --title "$title" \ # Only nag the operator when there's something to read. An all-PASS
--textbox "$tmpfile" 22 86 || true # report is pure noise on the path to a restore they already
# confirmed they want.
if (( warn > 0 || fail > 0 )); then
dialog --backtitle "ProxMenux" --title "$title" \
--textbox "$tmpfile" 22 86 || true
fi
rm -f "$tmpfile" rm -f "$tmpfile"
# FAIL means at least one check is a real risk for system integrity # FAIL means at least one check is a real risk for system integrity

View File

@@ -17,7 +17,6 @@
# Configuration ============================================ # Configuration ============================================
BASE_DIR="/usr/local/share/proxmenux" BASE_DIR="/usr/local/share/proxmenux"
UTILS_FILE="$BASE_DIR/utils.sh" UTILS_FILE="$BASE_DIR/utils.sh"
VENV_PATH="/opt/googletrans-env"
BACKUP_DIR="/var/backups/proxmenux" BACKUP_DIR="/var/backups/proxmenux"
if [[ -f "$UTILS_FILE" ]]; then if [[ -f "$UTILS_FILE" ]]; then

View File

@@ -7,7 +7,6 @@
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts" LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
BASE_DIR="/usr/local/share/proxmenux" BASE_DIR="/usr/local/share/proxmenux"
UTILS_FILE="$BASE_DIR/utils.sh" UTILS_FILE="$BASE_DIR/utils.sh"
VENV_PATH="/opt/googletrans-env"
TOOLS_JSON="/usr/local/share/proxmenux/installed_tools.json" TOOLS_JSON="/usr/local/share/proxmenux/installed_tools.json"
if [[ -f "$UTILS_FILE" ]]; then if [[ -f "$UTILS_FILE" ]]; then

View File

@@ -5,7 +5,6 @@
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts" LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
BASE_DIR="/usr/local/share/proxmenux" BASE_DIR="/usr/local/share/proxmenux"
UTILS_FILE="$BASE_DIR/utils.sh" UTILS_FILE="$BASE_DIR/utils.sh"
VENV_PATH="/opt/googletrans-env"
TOOLS_JSON="/usr/local/share/proxmenux/installed_tools.json" TOOLS_JSON="/usr/local/share/proxmenux/installed_tools.json"
if [[ -f "$UTILS_FILE" ]]; then if [[ -f "$UTILS_FILE" ]]; then

View File

@@ -7,7 +7,6 @@
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts" LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
BASE_DIR="/usr/local/share/proxmenux" BASE_DIR="/usr/local/share/proxmenux"
UTILS_FILE="$BASE_DIR/utils.sh" UTILS_FILE="$BASE_DIR/utils.sh"
VENV_PATH="/opt/googletrans-env"
TOOLS_JSON="/usr/local/share/proxmenux/installed_tools.json" TOOLS_JSON="/usr/local/share/proxmenux/installed_tools.json"
if [[ -f "$UTILS_FILE" ]]; then if [[ -f "$UTILS_FILE" ]]; then

View File

@@ -7,7 +7,6 @@
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts" LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
BASE_DIR="/usr/local/share/proxmenux" BASE_DIR="/usr/local/share/proxmenux"
UTILS_FILE="$BASE_DIR/utils.sh" UTILS_FILE="$BASE_DIR/utils.sh"
VENV_PATH="/opt/googletrans-env"
TOOLS_JSON="/usr/local/share/proxmenux/installed_tools.json" TOOLS_JSON="/usr/local/share/proxmenux/installed_tools.json"
APT_ENV="env DEBIAN_FRONTEND=noninteractive LC_ALL=C LANG=C" APT_ENV="env DEBIAN_FRONTEND=noninteractive LC_ALL=C LANG=C"

View File

@@ -46,7 +46,6 @@
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts" LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
BASE_DIR="/usr/local/share/proxmenux" BASE_DIR="/usr/local/share/proxmenux"
UTILS_FILE="$BASE_DIR/utils.sh" UTILS_FILE="$BASE_DIR/utils.sh"
VENV_PATH="/opt/googletrans-env"
if [[ -f "$UTILS_FILE" ]]; then if [[ -f "$UTILS_FILE" ]]; then
source "$UTILS_FILE" source "$UTILS_FILE"

View File

@@ -32,7 +32,6 @@
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts" LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
BASE_DIR="/usr/local/share/proxmenux" BASE_DIR="/usr/local/share/proxmenux"
UTILS_FILE="$BASE_DIR/utils.sh" UTILS_FILE="$BASE_DIR/utils.sh"
VENV_PATH="/opt/googletrans-env"
if [[ -f "$UTILS_FILE" ]]; then if [[ -f "$UTILS_FILE" ]]; then
source "$UTILS_FILE" source "$UTILS_FILE"

View File

@@ -14,7 +14,6 @@
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts" LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
BASE_DIR="/usr/local/share/proxmenux" BASE_DIR="/usr/local/share/proxmenux"
UTILS_FILE="$BASE_DIR/utils.sh" UTILS_FILE="$BASE_DIR/utils.sh"
VENV_PATH="/opt/googletrans-env"
if [[ -f "$UTILS_FILE" ]]; then if [[ -f "$UTILS_FILE" ]]; then
source "$UTILS_FILE" source "$UTILS_FILE"

View File

@@ -18,7 +18,6 @@
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts" LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
BASE_DIR="/usr/local/share/proxmenux" BASE_DIR="/usr/local/share/proxmenux"
UTILS_FILE="$BASE_DIR/utils.sh" UTILS_FILE="$BASE_DIR/utils.sh"
VENV_PATH="/opt/googletrans-env"
if [[ -f "$UTILS_FILE" ]]; then if [[ -f "$UTILS_FILE" ]]; then
source "$UTILS_FILE" source "$UTILS_FILE"

View File

@@ -40,13 +40,11 @@
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts" LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
BASE_DIR="/usr/local/share/proxmenux" BASE_DIR="/usr/local/share/proxmenux"
CONFIG_FILE="$BASE_DIR/config.json" CONFIG_FILE="$BASE_DIR/config.json"
CACHE_FILE="$BASE_DIR/cache.json"
UTILS_FILE="$BASE_DIR/utils.sh" UTILS_FILE="$BASE_DIR/utils.sh"
LOCAL_VERSION_FILE="$BASE_DIR/version.txt" LOCAL_VERSION_FILE="$BASE_DIR/version.txt"
BETA_VERSION_FILE="$BASE_DIR/beta_version.txt" BETA_VERSION_FILE="$BASE_DIR/beta_version.txt"
INSTALL_DIR="/usr/local/bin" INSTALL_DIR="/usr/local/bin"
MENU_SCRIPT="menu" MENU_SCRIPT="menu"
VENV_PATH="/opt/googletrans-env"
BACKTITLE="ProxMenux Configuration" BACKTITLE="ProxMenux Configuration"
REPO_MAIN="https://raw.githubusercontent.com/MacRimi/ProxMenux/main" REPO_MAIN="https://raw.githubusercontent.com/MacRimi/ProxMenux/main"
@@ -114,27 +112,10 @@ uninstall_proxmenux_monitor() {
} }
detect_installation_type() { detect_installation_type() {
local has_venv=false # The Translation/Normal split is gone after the googletrans removal.
local has_language=false # All installs are multilingual via pre-built lang/*.json. Keeping the
# function name + a fixed value so callers don't have to change.
# Check if virtual environment exists echo "normal"
if [ -d "$VENV_PATH" ] && [ -f "$VENV_PATH/bin/activate" ]; then
has_venv=true
fi
# Check if language is configured
if [ -f "$CONFIG_FILE" ]; then
local current_language=$(jq -r '.language // empty' "$CONFIG_FILE" 2>/dev/null)
if [[ -n "$current_language" && "$current_language" != "null" && "$current_language" != "empty" ]]; then
has_language=true
fi
fi
if [ "$has_venv" = true ] && [ "$has_language" = true ]; then
echo "translation"
else
echo "normal"
fi
} }
check_monitor_status() { check_monitor_status() {
@@ -745,23 +726,16 @@ show_version_info() {
[ -f "$CONFIG_FILE" ] && info_message+="✓ config.json → $CONFIG_FILE\n" || info_message+="✗ config.json\n" [ -f "$CONFIG_FILE" ] && info_message+="✓ config.json → $CONFIG_FILE\n" || info_message+="✗ config.json\n"
[ -f "$LOCAL_VERSION_FILE" ] && info_message+="✓ version.txt → $LOCAL_VERSION_FILE\n" || info_message+="✗ version.txt\n" [ -f "$LOCAL_VERSION_FILE" ] && info_message+="✓ version.txt → $LOCAL_VERSION_FILE\n" || info_message+="✗ version.txt\n"
# Show translation-specific files # Language section: always present now that translations are static
if [ "$install_type" = "translation" ]; then # JSON lookups. Show the configured language and the lang/ directory
[ -f "$CACHE_FILE" ] && info_message+="✓ cache.json → $CACHE_FILE\n" || info_message+="✗ cache.json\n" # so the operator can verify the cache is in place.
current_language=$(jq -r '.language // "en"' "$CONFIG_FILE" 2>/dev/null)
info_message+="\n$(translate "Virtual Environment:")\n" [[ -z "$current_language" || "$current_language" == "null" ]] && current_language="en"
if [ -d "$VENV_PATH" ] && [ -f "$VENV_PATH/bin/activate" ]; then info_message+="\n$(translate "Current language:")\n${current_language}\n"
info_message+="$(translate "Installed")$VENV_PATH\n" if [ -d "$BASE_DIR/lang" ]; then
[ -f "$VENV_PATH/bin/pip" ] && info_message+=" pip: $(translate "Installed") $VENV_PATH/bin/pip\n" || info_message+="✗ pip: $(translate "Not installed")\n" info_message+="$(translate "Translation files:") $BASE_DIR/lang/\n"
else
info_message+="$(translate "Virtual Environment"): $(translate "Not installed")\n"
info_message+="✗ pip: $(translate "Not installed")\n"
fi
current_language=$(jq -r '.language // "en"' "$CONFIG_FILE")
info_message+="\n$(translate "Current language:")\n$current_language\n"
else else
info_message+="\n$(translate "Language:")\nEnglish (Fixed)\n" info_message+="$(translate "Translation files:") $(translate "missing")\n"
fi fi
# Display information in a scrollable text box # Display information in a scrollable text box
@@ -785,40 +759,26 @@ uninstall_proxmenu() {
fi fi
local deps_to_remove="" local deps_to_remove=""
# Show different dependency options based on installation type deps_to_remove=$(dialog --clear --backtitle "ProxMenux Configuration" \
if [ "$install_type" = "translation" ]; then --title "Remove Dependencies" \
deps_to_remove=$(dialog --clear --backtitle "ProxMenux Configuration" \ --checklist "Select dependencies to remove:" 12 60 2 \
--title "Remove Dependencies" \ "dialog" "Interactive dialog boxes" OFF \
--checklist "Select dependencies to remove:" 15 60 4 \ "jq" "JSON processor" OFF \
"python3-venv" "Python virtual environment" OFF \ 3>&1 1>&2 2>&3)
"python3-pip" "Python package installer" OFF \
"python3" "Python interpreter" OFF \
"jq" "JSON processor" OFF \
3>&1 1>&2 2>&3)
else
deps_to_remove=$(dialog --clear --backtitle "ProxMenux Configuration" \
--title "Remove Dependencies" \
--checklist "Select dependencies to remove:" 12 60 2 \
"dialog" "Interactive dialog boxes" OFF \
"jq" "JSON processor" OFF \
3>&1 1>&2 2>&3)
fi
# Perform uninstallation with progress bar # Perform uninstallation with progress bar
( (
echo "10" ; echo "Removing ProxMenu files..." echo "10" ; echo "Removing ProxMenu files..."
sleep 1 sleep 1
# Remove googletrans and virtual environment if exists # Purge the legacy googletrans virtualenv if it was left over from
if [ -f "$VENV_PATH/bin/activate" ]; then # a pre-static-translations install. Cheap idempotent check.
echo "30" ; echo "Removing googletrans and virtual environment..." if [ -d "/opt/googletrans-env" ]; then
source "$VENV_PATH/bin/activate" echo "30" ; echo "Removing legacy googletrans virtualenv..."
pip uninstall -y googletrans >/dev/null 2>&1 rm -rf "/opt/googletrans-env"
deactivate
rm -rf "$VENV_PATH"
fi fi
echo "50" ; echo "Removing ProxMenu files..." echo "50" ; echo "Removing ProxMenu files..."
rm -f "$INSTALL_DIR/$MENU_SCRIPT" rm -f "$INSTALL_DIR/$MENU_SCRIPT"
rm -rf "$BASE_DIR" rm -rf "$BASE_DIR"

View File

@@ -39,7 +39,6 @@ MENU_REPO="$LOCAL_SCRIPTS/menus"
BASE_DIR="/usr/local/share/proxmenux" BASE_DIR="/usr/local/share/proxmenux"
UTILS_FILE="$LOCAL_SCRIPTS/utils.sh" UTILS_FILE="$LOCAL_SCRIPTS/utils.sh"
[[ ! -f "$UTILS_FILE" ]] && UTILS_FILE="$BASE_DIR/utils.sh" [[ ! -f "$UTILS_FILE" ]] && UTILS_FILE="$BASE_DIR/utils.sh"
VENV_PATH="/opt/googletrans-env"
# Source utilities and required scripts # Source utilities and required scripts
if [[ -f "$UTILS_FILE" ]]; then if [[ -f "$UTILS_FILE" ]]; then

View File

@@ -14,7 +14,6 @@
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts" LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
BASE_DIR="/usr/local/share/proxmenux" BASE_DIR="/usr/local/share/proxmenux"
UTILS_FILE="$BASE_DIR/utils.sh" UTILS_FILE="$BASE_DIR/utils.sh"
VENV_PATH="/opt/googletrans-env"
if ! command -v dialog &>/dev/null; then if ! command -v dialog &>/dev/null; then
@@ -23,47 +22,11 @@ if ! command -v dialog &>/dev/null; then
fi fi
check_pve9_translation_compatibility() {
local pve_version
if command -v pveversion &>/dev/null; then
pve_version=$(pveversion 2>/dev/null | grep -oP 'pve-manager/\K[0-9]+' | head -1)
else
return 0
fi
if [[ -n "$pve_version" ]] && [[ "$pve_version" -ge 9 ]] && [[ -d "$VENV_PATH" ]]; then
local has_googletrans=false
local has_cache=false
if [[ -f "$VENV_PATH/bin/pip" ]]; then
if "$VENV_PATH/bin/pip" list 2>/dev/null | grep -q "googletrans"; then
has_googletrans=true
fi
fi
if [[ -f "$BASE_DIR/cache.json" ]]; then
has_cache=true
fi
if [[ "$has_googletrans" = true ]] || [[ "$has_cache" = true ]]; then
dialog --clear \
--backtitle "ProxMenux - Compatibility Required" \
--title "Translation Environment Incompatible with PVE $pve_version" \
--msgbox "NOTICE: You are running Proxmox VE $pve_version with translation components installed.\n\nTranslations are NOT supported in PVE 9+. This causes:\n• Menu loading errors\n• Translation failures\n• System instability\n\nREQUIRED ACTION:\nProxMenux will now automatically reinstall the Normal Version.\n\nThis process will:\n• Remove incompatible translation components\n• Install PVE 9+ compatible version\n• Preserve all your settings and preferences\n\nPress OK to continue with automatic reinstallation..." 20 75
bash "$BASE_DIR/install_proxmenux.sh"
fi
exit 0
fi
}
check_pve9_translation_compatibility
# ========================================================== # ==========================================================
# The legacy "PVE9 + googletrans incompatible" gate that used to live
# here has been removed along with the googletrans runtime. Translations
# are now a static lookup against $BASE_DIR/lang/<lang>.json — there is
# no runtime venv to be incompatible with any PVE version.
if [[ -f "$UTILS_FILE" ]]; then if [[ -f "$UTILS_FILE" ]]; then
source "$UTILS_FILE" source "$UTILS_FILE"

View File

@@ -38,16 +38,11 @@ LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
INSTALL_DIR="/usr/local/bin" INSTALL_DIR="/usr/local/bin"
BASE_DIR="/usr/local/share/proxmenux" BASE_DIR="/usr/local/share/proxmenux"
CONFIG_FILE="$BASE_DIR/config.json" CONFIG_FILE="$BASE_DIR/config.json"
CACHE_FILE="$BASE_DIR/cache.json" LANG_DIR="$BASE_DIR/lang"
LOCAL_VERSION_FILE="$BASE_DIR/version.txt" LOCAL_VERSION_FILE="$BASE_DIR/version.txt"
MENU_SCRIPT="menu" MENU_SCRIPT="menu"
VENV_PATH="/opt/googletrans-env"
COMPONENTS_STATUS_FILE="$BASE_DIR/components_status.json" COMPONENTS_STATUS_FILE="$BASE_DIR/components_status.json"
# Translation context
TRANSLATION_CONTEXT="Context: Technical message for Proxmox and IT. Translate:"
# Color and style definitions # Color and style definitions
NEON_PURPLE_BLUE="\033[38;5;99m" NEON_PURPLE_BLUE="\033[38;5;99m"
WHITE="\033[38;5;15m" WHITE="\033[38;5;15m"
@@ -216,9 +211,10 @@ msg_error() {
# Initialize cache # Initialize cache
initialize_cache() { initialize_cache() {
if [[ "$LANGUAGE" != "en" ]]; then if [[ "$LANGUAGE" != "en" ]]; then
if [ ! -f "$CACHE_FILE" ]; then mkdir -p "$LANG_DIR"
mkdir -p "$(dirname "$CACHE_FILE")" local lang_file="$LANG_DIR/${LANGUAGE}.json"
echo "{}" > "$CACHE_FILE" if [ ! -f "$lang_file" ]; then
echo "{}" > "$lang_file"
fi fi
fi fi
} }
@@ -241,78 +237,37 @@ load_language() {
translate() { translate() {
# Pre-generated lookup ONLY. Translations live in
# $LANG_DIR/<dest>.json
# built by .github/scripts/build_translation_cache.py during CI on
# every push that touches scripts/. There is intentionally no live
# fallback: removing the runtime googletrans dependency lets us
# ship a smaller AppImage, skip /opt/googletrans-env, and stop
# leaking translation traffic from the host at runtime.
#
# Behavior:
# - dest == "en" → echo the original text
# - lookup hit → echo the translation
# - lookup miss → echo the original text (English fallback)
local text="$1" local text="$1"
local dest_lang="$LANGUAGE" local dest_lang="$LANGUAGE"
if [ "$dest_lang" = "en" ]; then if [ "$dest_lang" = "en" ]; then
echo "$text" echo "$text"
return return
fi fi
if [ ! -s "$CACHE_FILE" ] || ! jq -e . "$CACHE_FILE" > /dev/null 2>&1; then local lang_file="$LANG_DIR/${dest_lang}.json"
echo "{}" > "$CACHE_FILE" if [ -s "$lang_file" ] && command -v jq >/dev/null 2>&1; then
fi local cached
cached=$(jq -r --arg text "$text" '.[$text] // empty' "$lang_file" 2>/dev/null)
local cached_translation=$(jq -r --arg text "$text" --arg lang "$dest_lang" '.[$text][$lang] // .[$text]["notranslate"] // empty' "$CACHE_FILE") if [ -n "$cached" ]; then
if [ -n "$cached_translation" ]; then echo "$cached"
echo "$cached_translation" return
return
fi
if [ ! -d "$VENV_PATH" ]; then
echo "$text"
return
fi
source "$VENV_PATH/bin/activate"
local translated
translated=$(python3 -c "
from googletrans import Translator
import sys, json, re
def translate_text(text, dest_lang, context):
translator = Translator()
try:
full_text = context + ' ' + text
result = translator.translate(full_text, dest=dest_lang).text
translated = re.sub(r'^.*?(Translate:|Traducir:|Traduire:|Übersetzen:|Tradurre:|Traduzir:|翻译:|翻訳:)', '', result, flags=re.IGNORECASE | re.DOTALL).strip()
translated = re.sub(r'^.*?(Context:|Contexto:|Contexte:|Kontext:|Contesto:|上下文:|コンテキスト:).*?:', '', translated, flags=re.IGNORECASE | re.DOTALL).strip()
print(json.dumps({'success': True, 'text': translated}))
except Exception as e:
print(json.dumps({'success': False, 'error': str(e)}))
translate_text(
json.loads(sys.argv[1]),
sys.argv[2],
json.loads(sys.argv[3])
)
" "$(jq -Rn --arg t "$text" '$t')" "$dest_lang" "$(jq -Rn --arg ctx "$TRANSLATION_CONTEXT" '$ctx')")
deactivate
local translation_result=$(echo "$translated" | jq -r '.')
local success=$(echo "$translation_result" | jq -r '.success')
if [ "$success" = "true" ]; then
translated=$(echo "$translation_result" | jq -r '.text')
# Additional cleaning step
translated=$(echo "$translated" | sed -E 's/^(Context:|Contexto:|Contexte:|Kontext:|Contesto:|上下文:|コンテキスト:).*?(Translate:|Traducir:|Traduire:|Übersetzen:|Tradurre:|Traduzir:|翻译:|翻訳:)//gI' | sed 's/^ *//; s/ *$//')
# Only cache if the language is not English
if [ "$dest_lang" != "en" ]; then
local temp_cache=$(mktemp)
jq --arg text "$text" --arg lang "$dest_lang" --arg translated "$translated" '
if .[$text] == null then .[$text] = {} else . end |
.[$text][$lang] = $translated
' "$CACHE_FILE" > "$temp_cache" && mv "$temp_cache" "$CACHE_FILE"
fi fi
echo "$translated"
else
local error=$(echo "$translation_result" | jq -r '.error')
echo "$text"
fi fi
echo "$text"
} }
@@ -627,4 +582,4 @@ hybrid_whiptail_msgbox() {
else else
whiptail --title "$title" --msgbox "$text" "$height" "$width" whiptail --title "$title" --msgbox "$text" "$height" "$width"
fi fi
} }

View File

@@ -12,14 +12,16 @@ import CopyableCode from "@/components/CopyableCode"
// Resolve which CHANGELOG.md to read for the given locale. The canonical // Resolve which CHANGELOG.md to read for the given locale. The canonical
// English file lives at the repo root (so GitHub displays it as-is and // English file lives at the repo root (so GitHub displays it as-is and
// existing RSS / external consumers don't break). Localized versions // existing RSS / external consumers don't break). Localized versions sit
// sit under <repo>/lang/<locale>/CHANGELOG.md. Falls back to English if // under <repo>/web/data/changelog/<locale>.md — separate from the
// the localized file doesn't exist yet — so a partially-translated // /lang/ directory which is now reserved for runtime translation JSON
// changelog still renders (in EN) instead of 404'ing. // shipped to the host install. Falls back to English if the localized
// file doesn't exist yet — so a partially-translated changelog still
// renders (in EN) instead of 404'ing.
function resolveChangelogPath(locale: string): string { function resolveChangelogPath(locale: string): string {
const repoRoot = path.join(process.cwd(), "..") const repoRoot = path.join(process.cwd(), "..")
if (locale && locale !== "en") { if (locale && locale !== "en") {
const localized = path.join(repoRoot, "lang", locale, "CHANGELOG.md") const localized = path.join(process.cwd(), "data", "changelog", `${locale}.md`)
if (fs.existsSync(localized)) return localized if (fs.existsSync(localized)) return localized
} }
return path.join(repoRoot, "CHANGELOG.md") return path.join(repoRoot, "CHANGELOG.md")