From fcfe8da765b99fa5ccc159f93e7fb3b4af8f77ba Mon Sep 17 00:00:00 2001 From: MacRimi Date: Wed, 26 Aug 2026 17:23:09 +0200 Subject: [PATCH] refine post-install and hardware GPU docs, Monitor UX and CLI styling - rewrite the 15 post-install pages and the 3 hardware GPU pages so they reflect the current scripts (reversibility, tracked-tool counts, kernel parameters, per-tool commands, Alpine LXC propagation flow) - migrate the legacy step-badge helper on post-install/optional and create-vm/synology to the canonical pill component, with the stepLabel key added in each locale - fix rich-text i18n calls missing helpers across network, automated, optional, security, customization and the post-install landing pages, and escape the `` placeholder in automated so intl no longer parses it as a tag - remove the mouse-follow blue overlay from the docs landing layout - reposition the App-tab Edit button and stack the Search and Register controls vertically on mobile - move the Bulk update Configure/Edit control into the section header so it behaves the same on desktop and mobile - show a spinner during the final autoremove/autoclean pass of update-pve-safe so the cleanup step reads as active instead of silent - restyle the shell spinner and msg_info in a distinctive purple and drop the unused msg_lang duplicate - add a web-docs i18n build script and its CI workflow, plus tests for the pushover notification channel --- .github/scripts/build_translation_cache.py | 7 +- .github/scripts/build_web_docs_i18n.py | 853 ++++++++++++++++++ .../scripts/tests/test_build_web_docs_i18n.py | 221 +++++ .github/workflows/build-web-docs-i18n.yml | 119 +++ AppImage/components/lxc-app-panel.tsx | 6 +- AppImage/components/notification-settings.tsx | 161 +++- AppImage/components/virtual-machines.tsx | 32 +- AppImage/messages/de/common.json | 8 + AppImage/messages/en/common.json | 8 + AppImage/messages/es/common.json | 14 +- AppImage/messages/fr/common.json | 10 +- AppImage/messages/it/common.json | 12 +- AppImage/messages/pt/common.json | 12 +- AppImage/messages/sk/common.json | 8 + AppImage/messages/sv/common.json | 10 +- AppImage/scripts/auth_manager.py | 120 ++- AppImage/scripts/flask_proxmenux_routes.py | 3 + AppImage/scripts/managed_installs.py | 14 +- AppImage/scripts/notification_channels.py | 137 ++- AppImage/scripts/notification_events.py | 18 +- AppImage/scripts/notification_manager.py | 8 +- AppImage/scripts/notification_templates.py | 2 +- .../scripts/tests/test_pushover_channel.py | 106 +++ lang/es.json | 4 +- scripts/global/pci_passthrough_helpers.sh | 332 +++++-- scripts/global/update-pve-safe.sh | 1 + scripts/gpu_tpu/add_gpu_vm.sh | 160 +--- scripts/gpu_tpu/nvidia_installer.sh | 116 ++- scripts/gpu_tpu/switch_gpu_mode.sh | 308 ++----- scripts/gpu_tpu/switch_gpu_mode_direct.sh | 302 ++----- scripts/post_install/auto_post_install.sh | 22 +- .../post_install/customizable_post_install.sh | 193 +++- scripts/post_install/uninstall-tools.sh | 188 +++- scripts/utils.sh | 16 +- web/CONTRIBUTING-TRANSLATIONS.md | 75 +- .../[locale]/docs/create-vm/synology/page.tsx | 17 +- .../docs/hardware/gpu-vm-passthrough/page.tsx | 48 +- .../docs/hardware/nvidia-host/page.tsx | 26 - .../docs/hardware/switch-gpu-mode/page.tsx | 3 +- .../dashboard/vms-lxcs/updates/page.tsx | 3 + .../docs/monitor/notifications/page.tsx | 37 +- .../docs/post-install/automated/page.tsx | 2 +- .../docs/post-install/basic-settings/page.tsx | 4 +- .../docs/post-install/customization/page.tsx | 3 +- .../docs/post-install/monitoring/page.tsx | 5 +- .../docs/post-install/network/page.tsx | 7 +- .../docs/post-install/optional/page.tsx | 123 +-- web/app/[locale]/docs/post-install/page.tsx | 2 +- .../docs/post-install/performance/page.tsx | 9 +- .../docs/post-install/security/page.tsx | 14 +- .../docs/post-install/storage/page.tsx | 9 +- .../docs/post-install/system/page.tsx | 10 +- .../docs/post-install/uninstall/page.tsx | 1 + .../docs/post-install/updates/page.tsx | 4 + .../docs/post-install/virtualization/page.tsx | 5 + web/app/[locale]/layout.tsx | 2 - web/components/mouse-move-effect.tsx | 29 - web/messages/en/docs/create-vm/synology.json | 1 + .../en/docs/hardware/gpu-vm-passthrough.json | 10 +- .../en/docs/hardware/nvidia-host.json | 78 +- .../en/docs/hardware/switch-gpu-mode.json | 4 +- web/messages/en/docs/introduction.json | 2 +- .../en/docs/monitor/architecture.json | 4 +- .../monitor/dashboard/vms-lxcs-updates.json | 1 + web/messages/en/docs/monitor/index.json | 2 +- .../en/docs/monitor/notifications.json | 44 +- .../en/docs/post-install/automated.json | 10 +- .../en/docs/post-install/basic-settings.json | 8 +- .../en/docs/post-install/customizable.json | 16 +- .../en/docs/post-install/customization.json | 8 +- web/messages/en/docs/post-install/index.json | 20 +- .../en/docs/post-install/monitoring.json | 4 +- .../en/docs/post-install/network.json | 12 +- .../en/docs/post-install/optional.json | 25 +- .../en/docs/post-install/performance.json | 4 +- .../en/docs/post-install/security.json | 6 +- .../en/docs/post-install/storage.json | 32 +- web/messages/en/docs/post-install/system.json | 2 +- .../en/docs/post-install/uninstall.json | 79 +- .../en/docs/post-install/updates.json | 8 +- .../en/docs/security/ssl-letsencrypt.json | 2 +- web/messages/es/docs/create-vm/synology.json | 1 + .../es/docs/hardware/gpu-vm-passthrough.json | 10 +- .../es/docs/hardware/nvidia-host.json | 78 +- .../es/docs/hardware/switch-gpu-mode.json | 2 +- web/messages/es/docs/introduction.json | 2 +- .../es/docs/monitor/architecture.json | 4 +- .../monitor/dashboard/vms-lxcs-updates.json | 1 + web/messages/es/docs/monitor/index.json | 2 +- .../es/docs/monitor/notifications.json | 44 +- .../es/docs/post-install/automated.json | 10 +- .../es/docs/post-install/basic-settings.json | 8 +- .../es/docs/post-install/customizable.json | 18 +- .../es/docs/post-install/customization.json | 8 +- web/messages/es/docs/post-install/index.json | 20 +- .../es/docs/post-install/monitoring.json | 4 +- .../es/docs/post-install/network.json | 12 +- .../es/docs/post-install/optional.json | 25 +- .../es/docs/post-install/performance.json | 4 +- .../es/docs/post-install/security.json | 6 +- .../es/docs/post-install/storage.json | 32 +- web/messages/es/docs/post-install/system.json | 2 +- .../es/docs/post-install/uninstall.json | 79 +- .../es/docs/post-install/updates.json | 8 +- .../es/docs/security/ssl-letsencrypt.json | 2 +- .../monitor/dashboard/vms-lxcs-updates.json | 1 + 106 files changed, 3376 insertions(+), 1358 deletions(-) create mode 100644 .github/scripts/build_web_docs_i18n.py create mode 100644 .github/scripts/tests/test_build_web_docs_i18n.py create mode 100644 .github/workflows/build-web-docs-i18n.yml create mode 100644 AppImage/scripts/tests/test_pushover_channel.py delete mode 100644 web/components/mouse-move-effect.tsx diff --git a/.github/scripts/build_translation_cache.py b/.github/scripts/build_translation_cache.py index f90fc25c..394d2784 100644 --- a/.github/scripts/build_translation_cache.py +++ b/.github/scripts/build_translation_cache.py @@ -16,6 +16,8 @@ from __future__ import annotations import argparse import ast +import asyncio +import inspect import json import os import subprocess @@ -160,7 +162,10 @@ def translate_googletrans(text: str, dest_lang: str, context: str) -> str: translator = Translator() full_text = f"{context} {text}".strip() - return translator.translate(full_text, dest=dest_lang).text + result = translator.translate(full_text, dest=dest_lang) + if inspect.isawaitable(result): + result = asyncio.run(result) + return result.text def translate_google_web(text: str, dest_lang: str, context: str, timeout: int) -> str: diff --git a/.github/scripts/build_web_docs_i18n.py b/.github/scripts/build_web_docs_i18n.py new file mode 100644 index 00000000..9c8e5c94 --- /dev/null +++ b/.github/scripts/build_web_docs_i18n.py @@ -0,0 +1,853 @@ +#!/usr/bin/env python3 +"""Build missing translations for the ProxMenux documentation catalog.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import sys +import time +from concurrent.futures import ThreadPoolExecutor, as_completed +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable + +sys.path.insert(0, str(Path(__file__).parent)) +from build_translation_cache import ( # noqa: E402 + clean_translation, + translate_appimage, + translate_google_web, + translate_googletrans, +) + + +DEFAULT_LANGUAGES = ("es", "de", "fr", "it", "pt", "sk", "sv") +DEFAULT_CONTEXT = ( + "Context: ProxMenux technical documentation for Proxmox VE users. " + "Preserve product names, commands, paths, variables and placeholders. Translate:" +) + +TECHNICAL_TERMS = ( + "Proxmox VE Helper-Scripts", + "Proxmox Backup Server", + "Proxmox Mail Gateway", + "Proxmox VE", + "ProxMenux Monitor", + "ProxMenux Scripts", + "Docker Compose", + "Docker Engine", + "Google Coral", + "Edge TPU", + "Let's Encrypt", + "Cloudflare", + "Pushover", + "Telegram", + "Discord", + "Microsoft Teams", + "GitHub", + "Gotify", + "Apprise", + "Frigate", + "Vaultwarden", + "Portainer", + "ProxMenux", + "AppImage", + "systemctl", + "journalctl", + "smartctl", + "pveproxy", + "apt-get", + "gasket-dkms", + "libedgetpu", + "QEMU", + "LXC", + "ZFS", + "Ceph", + "Docker", + "OpenAI", + "WebSocket", + "OAuth", + "DKMS", + "SSH", + "API", +) + +PROTECTED_PATTERNS = ( + # Keep rich-text tags visible to Google Translate. It preserves their + # structure while translating the enclosed prose, whereas replacing + # opening/closing tags with adjacent sentinels can make the provider drop + # one side of the pair. The contract is validated after translation. + re.compile(r"`[^`]+`"), + re.compile(r"https?://[^\s<>]+"), + re.compile(r"\{[A-Za-z_][A-Za-z0-9_.-]*\}"), + re.compile(r"\$\{?[A-Za-z_][A-Za-z0-9_]*\}?"), + re.compile(r"(?]*>.*?", + re.IGNORECASE | re.DOTALL, +) + +TERM_RE = re.compile( + "|".join( + rf"(?") +PLACEHOLDER_RE = re.compile(r"\{[A-Za-z_][A-Za-z0-9_.-]*\}") +NON_TRANSLATABLE_KEYS = { + "command", + "code", + "href", + "icon", + "id", + "path", + "route", + "slug", + "src", + "url", +} +SOURCE_STATE_VERSION = 1 + + +@dataclass(frozen=True) +class Leaf: + path: tuple[str | int, ...] + source: str + + +def read_json(path: Path) -> Any: + try: + return json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError: + return None + except json.JSONDecodeError as exc: + raise ValueError(f"Invalid JSON in {path}: {exc}") from exc + + +def write_json(path: Path, value: Any) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_suffix(path.suffix + ".tmp") + temporary.write_text( + json.dumps(value, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + temporary.replace(path) + + +def iter_leaves(node: Any, path: tuple[str | int, ...] = ()) -> list[Leaf]: + leaves: list[Leaf] = [] + if isinstance(node, dict): + for key, value in node.items(): + leaves.extend(iter_leaves(value, path + (key,))) + elif isinstance(node, list): + for index, value in enumerate(node): + leaves.extend(iter_leaves(value, path + (index,))) + elif isinstance(node, str): + leaves.append(Leaf(path, node)) + return leaves + + +def get_at_path(node: Any, path: tuple[str | int, ...]) -> Any: + current = node + try: + for part in path: + if isinstance(part, int): + if not isinstance(current, list): + return None + current = current[part] + else: + if not isinstance(current, dict): + return None + current = current[part] + except (IndexError, KeyError, TypeError): + return None + return current + + +def path_key(path: tuple[str | int, ...]) -> str: + return ".".join(str(part) for part in path) + + +def should_copy(source: str, path: tuple[str | int, ...]) -> bool: + if not source.strip() or not re.search(r"[A-Za-z]", source): + return True + last = str(path[-1]).lower() if path else "" + if last in NON_TRANSLATABLE_KEYS or any( + last.endswith(suffix) + for suffix in ("url", "href", "path", "command", "code", "icon") + ): + return True + if re.fullmatch(r"https?://\S+", source) or re.fullmatch(r"/[A-Za-z0-9_./:@%+=-]+", source): + return True + if re.fullmatch(r"[A-Z0-9_.:/+-]{2,}", source): + return True + return False + + +def leaf_token(path: tuple[str | int, ...]) -> str: + return json.dumps(path, ensure_ascii=False, separators=(",", ":")) + + +def source_fingerprints(source: Any) -> dict[str, str]: + return { + leaf_token(leaf.path): hashlib.sha256(leaf.source.encode("utf-8")).hexdigest()[:20] + for leaf in iter_leaves(source) + } + + +def needs_translation( + source: str, + target: Any, + path: tuple[str | int, ...], + refresh: bool, + forced_tokens: set[str] | None = None, +) -> bool: + if should_copy(source, path): + return False + if refresh: + return True + if forced_tokens and leaf_token(path) in forced_tokens: + return True + return not isinstance(target, str) or not target.strip() or target == source + + +def protect_rich_tags(text: str) -> tuple[str, dict[str, str]]: + """Give translatable rich-text tags opaque names during translation. + + Google can remove semantic tags such as ``strong`` or ``em`` after + translating their contents. Unknown tag names are retained, so rename + non-literal tags temporarily and restore them before contract validation. + Literal ``code``, ``kbd`` and ``pre`` elements stay untouched because the + provider preserves both their markup and their contents. + """ + + names: dict[str, str] = {} + reverse: dict[str, str] = {} + + def replace(match: re.Match[str]) -> str: + original = match.group(1) + if original.lower() in {"code", "kbd", "pre"}: + return match.group(0) + key = original.lower() + internal = names.get(key) + if internal is None: + internal = f"pmxrich{len(names):04d}" + names[key] = internal + reverse[internal] = original + slash = "/" if match.group(0).startswith("" + + return TAG_RE.sub(replace, text), reverse + + +def restore_rich_tags(text: str, mapping: dict[str, str]) -> str: + for internal, original in mapping.items(): + text = re.sub( + rf"<(/?){re.escape(internal)}>", + lambda match: f"<{match.group(1)}{original}>", + text, + flags=re.IGNORECASE, + ) + return text + + +def protect_text(text: str) -> tuple[str, dict[str, str]]: + # Google already keeps both the markup and the contents of literal rich- + # text blocks. Replacing a filename or path inside one of these blocks can + # leave the sentinel as its only child, which the provider may discard. + literal_ranges = [(match.start(), match.end()) for match in LITERAL_TAG_RE.finditer(text)] + + def overlaps_literal(start: int, end: int) -> bool: + return any(start < literal_end and end > literal_start for literal_start, literal_end in literal_ranges) + + candidates: list[tuple[int, int]] = [] + for pattern in PROTECTED_PATTERNS: + candidates.extend( + (match.start(), match.end()) + for match in pattern.finditer(text) + if not overlaps_literal(match.start(), match.end()) + ) + candidates.extend( + (match.start(), match.end()) + for match in TERM_RE.finditer(text) + if not overlaps_literal(match.start(), match.end()) + ) + candidates.sort(key=lambda item: (item[0], -(item[1] - item[0]))) + + selected: list[tuple[int, int]] = [] + cursor = -1 + for start, end in candidates: + if start >= cursor: + selected.append((start, end)) + cursor = end + + mapping: dict[str, str] = {} + chunks: list[str] = [] + cursor = 0 + for index, (start, end) in enumerate(selected): + # Google Translate can drop underscore-delimited sentinels when they + # sit directly beside inline markup (for example an opening + # token followed by translated prose). Triple brackets remain opaque + # in that position and preserve the complete rich-text contract. + token = f"[[[PMXDOC{index:04d}]]]" + chunks.append(text[cursor:start]) + chunks.append(token) + mapping[token] = text[start:end] + cursor = end + chunks.append(text[cursor:]) + return "".join(chunks), mapping + + +def restore_text(text: str, mapping: dict[str, str]) -> str: + missing = [token for token in mapping if token not in text] + if missing: + raise ValueError(f"translation provider changed protected token {missing[0]}") + for token, original in mapping.items(): + text = text.replace(token, original) + return text + + +def validate_contract(source: str, target: str) -> None: + if sorted(TAG_RE.findall(source)) != sorted(TAG_RE.findall(target)): + raise ValueError("rich-text tag contract changed") + if sorted(PLACEHOLDER_RE.findall(source)) != sorted(PLACEHOLDER_RE.findall(target)): + raise ValueError("placeholder contract changed") + + +def provider_function(args: argparse.Namespace) -> Callable[[str, str], str]: + def translate(text: str, language: str) -> str: + if args.provider == "googletrans": + raw = translate_googletrans(text, language, args.context) + elif args.provider == "google-web": + raw = translate_google_web(text, language, args.context, args.timeout) + else: + raw = translate_appimage( + text, + language, + args.context, + args.timeout, + args.appimage_path, + ) + return clean_translation(raw).strip() + + return translate + + +def translate_with_retry( + source: str, + language: str, + translate: Callable[[str, str], str], + retries: int, + delay: float, +) -> str: + rich_text, rich_mapping = protect_rich_tags(source) + protected, mapping = protect_text(rich_text) + last_error: Exception | None = None + for attempt in range(retries + 1): + try: + translated = translate(protected, language) + if not translated: + raise ValueError("translation provider returned an empty value") + translated = restore_text(translated, mapping) + translated = restore_rich_tags(translated, rich_mapping) + validate_contract(source, translated) + return translated + except Exception as exc: # network/provider errors are retried together + last_error = exc + if attempt < retries: + time.sleep(delay * (attempt + 1)) + raise RuntimeError(str(last_error)) from last_error + + +def merge_tree( + source: Any, + target: Any, + translations: dict[tuple[str | int, ...], str], + path: tuple[str | int, ...] = (), +) -> Any: + if isinstance(source, dict): + target_dict = target if isinstance(target, dict) else {} + return { + key: merge_tree(value, target_dict.get(key), translations, path + (key,)) + for key, value in source.items() + } + if isinstance(source, list): + target_list = target if isinstance(target, list) else [] + return [ + merge_tree( + value, + target_list[index] if index < len(target_list) else None, + translations, + path + (index,), + ) + for index, value in enumerate(source) + ] + if isinstance(source, str): + if path in translations: + return translations[path] + if should_copy(source, path): + return source + if isinstance(target, str) and target.strip(): + return target + return source + return source + + +def source_files(source_root: Path, section: str) -> list[Path]: + scope = (source_root / section).resolve() + root = source_root.resolve() + if scope != root and root not in scope.parents: + raise ValueError("section must stay inside the English messages directory") + if scope.is_file(): + if scope.suffix != ".json": + raise ValueError("section file must be JSON") + return [scope] + if not scope.is_dir(): + raise ValueError(f"section does not exist: {scope}") + return sorted( + path + for path in scope.rglob("*.json") + if not any(part.startswith(".") for part in path.relative_to(root).parts) + ) + + +def collect_memory(source_root: Path, messages_root: Path, language: str) -> dict[str, str]: + memory: dict[str, str] = {} + conflicts: set[str] = set() + for source_path in sorted(source_root.rglob("*.json")): + if any(part.startswith(".") for part in source_path.relative_to(source_root).parts): + continue + target_path = messages_root / language / source_path.relative_to(source_root) + source = read_json(source_path) + target = read_json(target_path) + if target is None: + continue + for leaf in iter_leaves(source): + translated = get_at_path(target, leaf.path) + if not isinstance(translated, str) or not translated.strip() or translated == leaf.source: + continue + previous = memory.get(leaf.source) + if previous is not None and previous != translated: + conflicts.add(leaf.source) + else: + memory[leaf.source] = translated + for source in conflicts: + memory.pop(source, None) + return memory + + +def pending_leaves( + source: Any, + target: Any, + refresh: bool, + forced_tokens: set[str] | None = None, +) -> list[Leaf]: + return [ + leaf + for leaf in iter_leaves(source) + if needs_translation( + leaf.source, + get_at_path(target, leaf.path), + leaf.path, + refresh, + forced_tokens, + ) + ] + + +def schema_matches(source: Any, target: Any) -> bool: + if type(source) is not type(target): + return False + if isinstance(source, dict): + return list(source) == list(target) and all( + schema_matches(source[key], target[key]) for key in source + ) + if isinstance(source, list): + return len(source) == len(target) and all( + schema_matches(left, right) for left, right in zip(source, target) + ) + if isinstance(source, str): + # Localized strings intentionally differ from the English source. + return True + # Booleans, numbers and null values are structural data and must stay in + # sync with the English catalog instead of retaining an obsolete value. + return source == target + + +def empty_source_state() -> dict[str, Any]: + return { + "version": SOURCE_STATE_VERSION, + "initialized": False, + "source": {}, + "pending": {}, + } + + +def load_source_state(path: Path) -> dict[str, Any]: + if not path.exists(): + return empty_source_state() + try: + value = read_json(path) + except ValueError: + return empty_source_state() + if not isinstance(value, dict) or value.get("version") != SOURCE_STATE_VERSION: + return empty_source_state() + if not isinstance(value.get("source"), dict) or not isinstance(value.get("pending"), dict): + return empty_source_state() + value.setdefault("initialized", True) + return value + + +def state_pending_tokens(state: dict[str, Any], language: str, relative: str) -> set[str]: + language_state = state.setdefault("pending", {}).setdefault(language, {}) + values = language_state.get(relative, []) + if not isinstance(values, list): + return set() + return {str(value) for value in values} + + +def set_state_pending_tokens( + state: dict[str, Any], language: str, relative: str, tokens: set[str] +) -> None: + language_state = state.setdefault("pending", {}).setdefault(language, {}) + if tokens: + language_state[relative] = sorted(tokens) + else: + language_state.pop(relative, None) + if not language_state: + state["pending"].pop(language, None) + + +def translate_file( + source: Any, + target: Any, + leaves: list[Leaf], + language: str, + memory: dict[str, str], + translate: Callable[[str, str], str], + args: argparse.Namespace, +) -> tuple[Any | None, list[str], int]: + resolved: dict[tuple[str | int, ...], str] = {} + failures: list[str] = [] + jobs: dict[str, list[tuple[str | int, ...]]] = {} + + for leaf in leaves: + if not args.refresh and leaf.source in memory: + resolved[leaf.path] = memory[leaf.source] + continue + jobs.setdefault(leaf.source, []).append(leaf.path) + + if jobs: + with ThreadPoolExecutor(max_workers=args.workers) as executor: + futures = { + executor.submit( + translate_with_retry, + text, + language, + translate, + args.retries, + args.retry_delay, + ): text + for text in jobs + } + for future in as_completed(futures): + text = futures[future] + try: + translated = future.result() + memory[text] = translated + for path in jobs[text]: + resolved[path] = translated + except Exception as exc: + failures.append(f"{text[:90]}: {exc}") + if args.sleep: + time.sleep(args.sleep) + + if failures: + return None, failures, len(jobs) + return merge_tree(source, target, resolved), [], len(jobs) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--source-dir", type=Path, default=Path("web/messages/en")) + parser.add_argument("--messages-dir", type=Path, default=Path("web/messages")) + parser.add_argument("--languages", default=",".join(DEFAULT_LANGUAGES)) + parser.add_argument( + "--section", + default=".", + help="Relative file or directory below the English messages directory.", + ) + parser.add_argument( + "--provider", + choices=("google-web", "googletrans", "appimage"), + default="googletrans", + ) + parser.add_argument("--appimage-path", type=Path, default=Path("ProxMenux-Monitor.AppImage")) + parser.add_argument( + "--source-state", + type=Path, + default=None, + help=( + "Source fingerprint state used to detect changed English strings. " + "Defaults to /.docs-i18n-source-state.json." + ), + ) + parser.add_argument("--context", default=DEFAULT_CONTEXT) + parser.add_argument("--timeout", type=int, default=30) + parser.add_argument("--workers", type=int, default=4) + parser.add_argument("--retries", type=int, default=3) + parser.add_argument("--retry-delay", type=float, default=2.0) + parser.add_argument("--sleep", type=float, default=0.0) + parser.add_argument( + "--max-files", + type=int, + default=0, + help="Maximum pending files per locale; zero processes all files.", + ) + parser.add_argument("--dry-run", action="store_true") + parser.add_argument("--check", action="store_true") + parser.add_argument( + "--refresh", + action="store_true", + help="Overwrite existing translations in the selected scope.", + ) + parser.add_argument("--strict", action="store_true") + return parser + + +def main() -> int: + args = build_parser().parse_args() + if args.workers < 1 or args.retries < 0 or args.max_files < 0: + print("workers must be positive; retries and max-files cannot be negative", file=sys.stderr) + return 2 + + source_root = args.source_dir.resolve() + messages_root = args.messages_dir.resolve() + source_state_path = ( + args.source_state.resolve() + if args.source_state is not None + else messages_root / ".docs-i18n-source-state.json" + ) + languages = [item.strip() for item in args.languages.split(",") if item.strip()] + try: + files = source_files(source_root, args.section) + except ValueError as exc: + print(exc, file=sys.stderr) + return 2 + + if not languages: + print("No target languages selected.", file=sys.stderr) + return 2 + if args.refresh: + print("WARNING: --refresh overwrites existing translations in the selected scope.") + + translate = provider_function(args) + state = load_source_state(source_state_path) + state_was_initialized = bool(state.get("initialized")) + previous_source_state = state.setdefault("source", {}) + + file_info: dict[ + str, tuple[Path, Any, dict[str, str], set[str], set[str]] + ] = {} + selected_relatives: set[str] = set() + for source_path in files: + relative = source_path.relative_to(source_root).as_posix() + selected_relatives.add(relative) + source = read_json(source_path) + current_fingerprints = source_fingerprints(source) + translatable_tokens = { + leaf_token(leaf.path) + for leaf in iter_leaves(source) + if not should_copy(leaf.source, leaf.path) + } + previous_fingerprints = previous_source_state.get(relative, {}) + if not isinstance(previous_fingerprints, dict): + previous_fingerprints = {} + if state_was_initialized: + changed_tokens = { + token + for token, fingerprint in current_fingerprints.items() + if token in translatable_tokens + # Existing English leaves whose text changed must be sent to + # the provider even when the target still contains the old, + # non-empty translation. New leaves are handled by the usual + # missing-target detection, preserving a translation supplied + # manually in the same commit. + and token in previous_fingerprints + and previous_fingerprints[token] != fingerprint + } + else: + # The first run establishes the baseline without replacing + # existing human/Codex translations. Missing target values are + # still discovered separately for every locale below. + changed_tokens = set() + file_info[relative] = ( + source_path, + source, + current_fingerprints, + changed_tokens, + translatable_tokens, + ) + + # Materialize every locale's pending queue before advancing the shared + # English baseline. If the runner stops halfway through, unprocessed + # locales retain the exact changed leaf tokens for the next run. + for language in languages: + for relative, ( + _, + source, + _, + changed_tokens, + translatable_tokens, + ) in file_info.items(): + target_path = messages_root / language / relative + target = read_json(target_path) + pending_tokens = ( + state_pending_tokens(state, language, relative) + & translatable_tokens + ) + pending_tokens.update(changed_tokens) + pending_tokens.update( + leaf_token(leaf.path) + for leaf in pending_leaves(source, target, args.refresh) + ) + set_state_pending_tokens(state, language, relative, pending_tokens) + + for relative, (_, _, current_fingerprints, _, _) in file_info.items(): + previous_source_state[relative] = current_fingerprints + state["initialized"] = True + + # A complete run also mirrors deletion of an English catalog. Partial + # --section runs deliberately leave unrelated paths untouched. + if args.section in (".", ""): + removed_files = set(previous_source_state) - selected_relatives + for relative in sorted(removed_files): + for language in languages: + target_path = messages_root / language / relative + if target_path.exists() and not (args.check or args.dry_run): + target_path.unlink() + print(f"[{language}] removed obsolete catalog {target_path}") + set_state_pending_tokens(state, language, relative, set()) + previous_source_state.pop(relative, None) + + if not (args.check or args.dry_run): + write_json(source_state_path, state) + + total_failures = 0 + total_written = 0 + print(f"English files: {len(files)} | locales: {', '.join(languages)}") + + for language in languages: + memory = collect_memory(source_root, messages_root, language) + # A target value paired with a newly changed English source is the old + # translation, not valid translation memory for the new sentence. + # Remove every queued source text before provider reuse; successful + # translations repopulate memory normally for later files. + queued_source_texts: set[str] = set() + for relative_key, (_, source, _, _, _) in file_info.items(): + queued_tokens = state_pending_tokens(state, language, relative_key) + queued_source_texts.update( + leaf.source + for leaf in iter_leaves(source) + if leaf_token(leaf.path) in queued_tokens + ) + for source_text in queued_source_texts: + memory.pop(source_text, None) + pending: list[ + tuple[Path, Path, Any, Any, list[Leaf], set[str], bool] + ] = [] + total_strings = 0 + missing_strings = 0 + + for source_path in files: + relative = source_path.relative_to(source_root) + relative_key = relative.as_posix() + target_path = messages_root / language / relative + source = file_info[relative_key][1] + target = read_json(target_path) + queued_tokens = state_pending_tokens(state, language, relative_key) + leaves = pending_leaves(source, target, args.refresh, queued_tokens) + total_strings += len(iter_leaves(source)) + missing_strings += len(leaves) + schema_changed = not schema_matches(source, target) + if leaves or schema_changed: + pending.append( + ( + source_path, + target_path, + source, + target, + leaves, + queued_tokens, + schema_changed, + ) + ) + + print( + f"[{language}] {missing_strings}/{total_strings} strings pending " + f"across {len(pending)} files; reusable translations: {len(memory)}" + ) + if args.check or args.dry_run: + continue + if args.max_files: + pending = pending[: args.max_files] + + for index, ( + source_path, + target_path, + source, + target, + leaves, + queued_tokens, + schema_changed, + ) in enumerate(pending, 1): + relative = source_path.relative_to(source_root) + relative_key = relative.as_posix() + suffix = " + schema sync" if schema_changed else "" + print( + f"[{language} {index}/{len(pending)}] {relative} " + f"({len(leaves)} strings{suffix})", + flush=True, + ) + built, failures, calls = translate_file( + source, + target, + leaves, + language, + memory, + translate, + args, + ) + if failures: + total_failures += len(failures) + set_state_pending_tokens(state, language, relative_key, queued_tokens) + write_json(source_state_path, state) + print( + f" skipped atomically after {len(failures)} failures " + f"({calls} calls)", + file=sys.stderr, + ) + for failure in failures[:5]: + print(f" - {failure}", file=sys.stderr) + continue + write_json(target_path, built) + set_state_pending_tokens(state, language, relative_key, set()) + write_json(source_state_path, state) + total_written += 1 + print(f" wrote {target_path} ({calls} provider calls)", flush=True) + + if args.check or args.dry_run: + return 0 + print(f"Completed: {total_written} files written; {total_failures} failed strings.") + return 1 if args.strict and total_failures else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/scripts/tests/test_build_web_docs_i18n.py b/.github/scripts/tests/test_build_web_docs_i18n.py new file mode 100644 index 00000000..e9c832c8 --- /dev/null +++ b/.github/scripts/tests/test_build_web_docs_i18n.py @@ -0,0 +1,221 @@ +import importlib.util +import json +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + + +SCRIPT = Path(__file__).parents[1] / "build_web_docs_i18n.py" +SPEC = importlib.util.spec_from_file_location("build_web_docs_i18n", SCRIPT) +MODULE = importlib.util.module_from_spec(SPEC) +assert SPEC and SPEC.loader +sys.modules[SPEC.name] = MODULE +SPEC.loader.exec_module(MODULE) + + +class WebDocsI18nTests(unittest.TestCase): + def run_generator(self, root, provider): + source_root = root / "messages" / "en" + messages_root = root / "messages" + state_path = messages_root / ".docs-i18n-source-state.json" + argv = [ + str(SCRIPT), + "--source-dir", + str(source_root), + "--messages-dir", + str(messages_root), + "--languages", + "de", + "--source-state", + str(state_path), + "--workers", + "1", + ] + with mock.patch.object(sys, "argv", argv), mock.patch.object( + MODULE, "provider_function", return_value=provider + ): + return MODULE.main() + + def test_protected_contract_round_trip(self): + source = ( + "Run systemctl restart pveproxy in Proxmox VE, " + "then open Settings at {host}." + ) + protected, mapping = MODULE.protect_text(source) + self.assertIn("systemctl restart pveproxy", protected) + self.assertNotIn("Proxmox VE", protected) + self.assertIn("", protected) + self.assertIn("", protected) + self.assertIn("", protected) + self.assertIn("", protected) + self.assertNotIn("", mapping.values()) + self.assertNotIn("", mapping.values()) + self.assertNotIn("systemctl", mapping.values()) + self.assertEqual(MODULE.restore_text(protected, mapping), source) + + def test_human_translation_is_not_pending(self): + source = {"title": "Updates", "url": "https://example.com"} + target = {"title": "Actualizaciones", "url": "https://example.com"} + self.assertEqual(MODULE.pending_leaves(source, target, refresh=False), []) + + def test_changed_source_text_is_pending_even_with_existing_translation(self): + source = {"title": "Updated installation guidance"} + target = {"title": "Vorherige Installationsanleitung"} + token = MODULE.leaf_token(("title",)) + leaves = MODULE.pending_leaves( + source, + target, + refresh=False, + forced_tokens={token}, + ) + self.assertEqual([leaf.path for leaf in leaves], [("title",)]) + + def test_source_fingerprints_change_per_leaf(self): + before = MODULE.source_fingerprints({"title": "One", "body": "Same"}) + after = MODULE.source_fingerprints({"title": "Two", "body": "Same"}) + self.assertNotEqual( + before[MODULE.leaf_token(("title",))], + after[MODULE.leaf_token(("title",))], + ) + self.assertEqual( + before[MODULE.leaf_token(("body",))], + after[MODULE.leaf_token(("body",))], + ) + + def test_schema_mismatch_detects_removed_keys(self): + self.assertFalse( + MODULE.schema_matches( + {"title": "Title"}, + {"title": "Título", "removed": "Old"}, + ) + ) + + def test_schema_mismatch_detects_changed_structural_values(self): + self.assertFalse( + MODULE.schema_matches( + {"enabled": True, "retries": 2}, + {"enabled": False, "retries": 2}, + ) + ) + + def test_schema_allows_localized_string_values(self): + self.assertTrue( + MODULE.schema_matches( + {"title": "Updates"}, + {"title": "Aktualisierungen"}, + ) + ) + + def test_translatable_rich_tags_use_opaque_names(self): + source = ( + "Keep this, that and " + "systemctl restart pveproxy." + ) + protected, mapping = MODULE.protect_rich_tags(source) + self.assertIn("this", protected) + self.assertIn("that", protected) + self.assertIn("systemctl restart pveproxy", protected) + self.assertEqual(MODULE.restore_rich_tags(protected, mapping), source) + + def test_source_schema_drives_output(self): + source = {"title": "Title", "items": ["One", "Two"]} + target = { + "title": "Título", + "items": ["Uno", "Dos", "Obsoleto"], + "removed": "Old", + } + translated = { + ("items", 0): "Uno", + ("items", 1): "Dos", + } + self.assertEqual( + MODULE.merge_tree(source, target, translated), + {"title": "Título", "items": ["Uno", "Dos"]}, + ) + + def test_translation_memory_ignores_conflicts(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + source_root = root / "messages" / "en" + target_root = root / "messages" / "es" + source_root.mkdir(parents=True) + target_root.mkdir(parents=True) + (source_root / "one.json").write_text( + json.dumps({"label": "Settings"}), encoding="utf-8" + ) + (target_root / "one.json").write_text( + json.dumps({"label": "Ajustes"}), encoding="utf-8" + ) + (source_root / "two.json").write_text( + json.dumps({"label": "Settings"}), encoding="utf-8" + ) + (target_root / "two.json").write_text( + json.dumps({"label": "Configuración"}), encoding="utf-8" + ) + memory = MODULE.collect_memory(source_root, root / "messages", "es") + self.assertNotIn("Settings", memory) + + def test_source_state_retranslates_an_existing_changed_leaf(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + source_path = root / "messages" / "en" / "page.json" + target_path = root / "messages" / "de" / "page.json" + source_path.parent.mkdir(parents=True) + target_path.parent.mkdir(parents=True) + source_path.write_text(json.dumps({"title": "Original"}), encoding="utf-8") + target_path.write_text(json.dumps({"title": "Ursprünglich"}), encoding="utf-8") + + self.assertEqual( + self.run_generator(root, lambda *_: self.fail("provider called during baseline")), + 0, + ) + self.assertEqual(read_json_file(target_path), {"title": "Ursprünglich"}) + + source_path.write_text(json.dumps({"title": "Changed"}), encoding="utf-8") + calls = [] + + def provider(text, language): + calls.append((text, language)) + return "Geändert" + + self.assertEqual(self.run_generator(root, provider), 0) + self.assertEqual(calls, [("Changed", "de")]) + self.assertEqual(read_json_file(target_path), {"title": "Geändert"}) + + def test_new_leaf_preserves_translation_supplied_with_source_change(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + source_path = root / "messages" / "en" / "page.json" + target_path = root / "messages" / "de" / "page.json" + source_path.parent.mkdir(parents=True) + target_path.parent.mkdir(parents=True) + source_path.write_text(json.dumps({"title": "Original"}), encoding="utf-8") + target_path.write_text(json.dumps({"title": "Ursprünglich"}), encoding="utf-8") + self.assertEqual(self.run_generator(root, lambda *_: "unused"), 0) + + source_path.write_text( + json.dumps({"title": "Original", "new": "New text"}), + encoding="utf-8", + ) + target_path.write_text( + json.dumps({"title": "Ursprünglich", "new": "Neuer Text"}), + encoding="utf-8", + ) + self.assertEqual( + self.run_generator(root, lambda *_: self.fail("manual translation overwritten")), + 0, + ) + self.assertEqual( + read_json_file(target_path), + {"title": "Ursprünglich", "new": "Neuer Text"}, + ) + + +def read_json_file(path): + return json.loads(path.read_text(encoding="utf-8")) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/build-web-docs-i18n.yml b/.github/workflows/build-web-docs-i18n.yml new file mode 100644 index 00000000..6583f44f --- /dev/null +++ b/.github/workflows/build-web-docs-i18n.yml @@ -0,0 +1,119 @@ +name: Build web documentation translations + +on: + push: + branches: [develop] + paths: + - 'web/messages/en/*.json' + - 'web/messages/en/**/*.json' + - '.github/scripts/build_web_docs_i18n.py' + - '.github/scripts/build_translation_cache.py' + - '.github/workflows/build-web-docs-i18n.yml' + workflow_dispatch: + inputs: + languages: + description: 'Comma-separated locales' + default: 'es,de,fr,it,pt,sk,sv' + section: + description: 'File or directory below web/messages/en' + default: '.' + max_files: + description: 'Maximum pending files per locale; 0 means all' + default: '0' + refresh: + description: 'Overwrite existing translations in the selected scope' + type: boolean + default: false + dry_run: + description: 'Report pending coverage without writing files' + type: boolean + default: false + +concurrency: + group: build-web-docs-i18n-${{ github.ref }} + cancel-in-progress: false + +jobs: + translate: + runs-on: ubuntu-latest + permissions: + contents: write + timeout-minutes: 120 + + steps: + - name: Checkout develop + uses: actions/checkout@v4 + with: + ref: develop + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install translation provider + run: | + python -m pip install --upgrade pip + pip install 'googletrans==4.0.0-rc1' 'httpx==0.13.3' 'httpcore==0.9.1' 'h11==0.9.0' + + - name: Build missing translations + shell: bash + run: | + LANGUAGES="${{ github.event.inputs.languages }}" + LANGUAGES="${LANGUAGES:-es,de,fr,it,pt,sk,sv}" + SECTION="${{ github.event.inputs.section }}" + SECTION="${SECTION:-.}" + MAX_FILES="${{ github.event.inputs.max_files }}" + MAX_FILES="${MAX_FILES:-0}" + + EXTRA_ARGS=() + if [[ "${{ github.event.inputs.refresh }}" == "true" ]]; then + EXTRA_ARGS+=(--refresh) + fi + if [[ "${{ github.event.inputs.dry_run }}" == "true" ]]; then + EXTRA_ARGS+=(--dry-run) + fi + + python .github/scripts/build_web_docs_i18n.py \ + --source-dir web/messages/en \ + --messages-dir web/messages \ + --languages "$LANGUAGES" \ + --section "$SECTION" \ + --max-files "$MAX_FILES" \ + --provider googletrans \ + --workers 4 \ + "${EXTRA_ARGS[@]}" + + - name: Validate catalogs + run: | + python .github/scripts/build_web_docs_i18n.py \ + --source-dir web/messages/en \ + --messages-dir web/messages \ + --languages "${{ github.event.inputs.languages || 'es,de,fr,it,pt,sk,sv' }}" \ + --check + + - name: Commit and push changes + if: ${{ github.event.inputs.dry_run != 'true' }} + shell: bash + run: | + if git diff --quiet -- web/messages/; then + echo "No documentation translations changed." + exit 0 + fi + + git config user.name "ProxMenuxBot" + git config user.email "bot@proxmenux.local" + git add web/messages/ + git commit -m "docs(i18n): update documentation translations" + + for attempt in 1 2 3 4 5; do + git fetch origin develop + if git rebase origin/develop && git push origin develop; then + exit 0 + fi + git rebase --abort 2>/dev/null || true + sleep $((attempt * 3)) + done + exit 1 diff --git a/AppImage/components/lxc-app-panel.tsx b/AppImage/components/lxc-app-panel.tsx index 5082b33e..f0e0f614 100644 --- a/AppImage/components/lxc-app-panel.tsx +++ b/AppImage/components/lxc-app-panel.tsx @@ -2402,12 +2402,13 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop detections, so the user gets one-click Restore before hand- typing a custom app. */} {apps.length > 0 && ( -
+
+
+ {config.channels.pushover?.enabled && ( + <> +

+ {t("settings.notifications.ui.pushoverCredentialsHint")} +

+
+
+ +
+ updateChannel("pushover", "user_key", e.target.value)} + disabled={!editMode} + /> + +
+
+
+ +
+ updateChannel("pushover", "api_token", e.target.value)} + disabled={!editMode} + /> + +
+
+
+
+
+ + updateChannel("pushover", "device", e.target.value)} + disabled={!editMode} + /> +
+
+ + updateChannel("pushover", "sound", e.target.value)} + disabled={!editMode} + /> +
+
+
+
+ +

{t("settings.notifications.ui.pushoverCriticalPriorityDescription")}

+
+ +
+ {renderChannelCategories("pushover")} + {renderQuietHours("pushover")} + {renderDailyDigest("pushover")} +
+ +
+ + )} + + {/* Apprise — issue #207. Single URL talks to ~80 notification services. The operator pastes one `tgram://`, `discord://`, `ntfy://`, `matrix://`, - `pushover://` etc. URL and the AppriseChannel + `pover://` etc. URL and the AppriseChannel backend handles the transport. Mirrors the same Enable toggle + Test button pattern as the other channels. */} @@ -1888,7 +2037,7 @@ export function NotificationSettings() { - {!bulkEditMode && bulkConfigured && ( + {!bulkEditMode && ( )}
@@ -5932,22 +5940,8 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { ) : ( -
-
- {t("vmLxc.bulkUpdate.notConfigured")} -
- +
+ {t("vmLxc.bulkUpdate.notConfigured")}
)} diff --git a/AppImage/messages/de/common.json b/AppImage/messages/de/common.json index 861b4acd..5f5c81fa 100644 --- a/AppImage/messages/de/common.json +++ b/AppImage/messages/de/common.json @@ -1839,6 +1839,14 @@ "toAddresses": "An Adressen (durch Kommas getrennt)", "subjectPrefix": "Betreff-Präfix", "emailHint": "Lassen Sie den SMTP-Host leer, um lokales Sendmail zu verwenden. Verwenden Sie für Gmail ein App-Passwort.", + "enablePushover": "Pushover aktivieren", + "pushoverUserKey": "Benutzer- oder Gruppenschlüssel", + "pushoverApiToken": "API-Token der Anwendung", + "pushoverDevice": "Gerät", + "pushoverSound": "Ton", + "pushoverCriticalPriority": "Hohe Priorität für kritische Warnungen", + "pushoverCriticalPriorityDescription": "KRITISCHE Benachrichtigungen verwenden die hohe Pushover-Priorität. Die Notfallpriorität wird nicht verwendet.", + "pushoverCredentialsHint": "Erstellen Sie eine Anwendung in Pushover und kopieren Sie deren API-Token sowie Ihren Benutzer- oder Gruppenschlüssel.", "enableApprise": "Aktivieren Sie Apprise", "urlFormats": "+URL-Formate", "appriseUrl": "Apprise-URL", diff --git a/AppImage/messages/en/common.json b/AppImage/messages/en/common.json index 19590765..cf345ec8 100644 --- a/AppImage/messages/en/common.json +++ b/AppImage/messages/en/common.json @@ -1838,6 +1838,14 @@ "toAddresses": "To addresses (comma-separated)", "subjectPrefix": "Subject prefix", "emailHint": "Leave SMTP host empty to use local sendmail. For Gmail, use an app password.", + "enablePushover": "Enable Pushover", + "pushoverUserKey": "User or group key", + "pushoverApiToken": "Application API token", + "pushoverDevice": "Device", + "pushoverSound": "Sound", + "pushoverCriticalPriority": "High priority for critical alerts", + "pushoverCriticalPriorityDescription": "CRITICAL notifications use Pushover high priority. Emergency priority is not used.", + "pushoverCredentialsHint": "Create an application in Pushover and copy its API token and your user or group key.", "enableApprise": "Enable Apprise", "urlFormats": "+URL formats", "appriseUrl": "Apprise URL", diff --git a/AppImage/messages/es/common.json b/AppImage/messages/es/common.json index 8f26dbba..b53cfb0f 100644 --- a/AppImage/messages/es/common.json +++ b/AppImage/messages/es/common.json @@ -1825,7 +1825,7 @@ "enableGotify": "Habilitar Gotify", "serverUrl": "URL del servidor", "appToken": "Ficha de aplicación", - "enableDiscord": "Habilitar discordia", + "enableDiscord": "Activar Discord", "webhookUrl": "URL de webhook", "enableEmail": "Habilitar correo electrónico", "smtpHost": "servidor SMTP", @@ -1839,9 +1839,17 @@ "toAddresses": "A direcciones (separadas por comas)", "subjectPrefix": "Prefijo de asunto", "emailHint": "Deje el host SMTP vacío para usar sendmail local. Para Gmail, utilice una contraseña de aplicación.", - "enableApprise": "Habilitar información", + "enablePushover": "Activar Pushover", + "pushoverUserKey": "Clave de usuario o grupo", + "pushoverApiToken": "Token API de la aplicación", + "pushoverDevice": "Dispositivo", + "pushoverSound": "Sonido", + "pushoverCriticalPriority": "Prioridad alta para alertas críticas", + "pushoverCriticalPriorityDescription": "Las notificaciones CRÍTICAS usan la prioridad alta de Pushover. No se utiliza la prioridad de emergencia.", + "pushoverCredentialsHint": "Cree una aplicación en Pushover y copie su token API y la clave de usuario o grupo.", + "enableApprise": "Activar Apprise", "urlFormats": "+formatos de URL", - "appriseUrl": "Informar URL", + "appriseUrl": "URL de Apprise", "showUrl": "Mostrar URL", "hideUrl": "Ocultar URL", "appriseDescription": "Una URL permite a Apprise enrutar la notificación al servicio correcto. Ejemplos:", diff --git a/AppImage/messages/fr/common.json b/AppImage/messages/fr/common.json index 197053b5..5df18786 100644 --- a/AppImage/messages/fr/common.json +++ b/AppImage/messages/fr/common.json @@ -1839,9 +1839,17 @@ "toAddresses": "Aux adresses (séparées par des virgules)", "subjectPrefix": "Préfixe du sujet", "emailHint": "Laissez l'hôte SMTP vide pour utiliser sendmail local. Pour Gmail, utilisez un mot de passe d'application.", + "enablePushover": "Activer Pushover", + "pushoverUserKey": "Clé utilisateur ou groupe", + "pushoverApiToken": "Jeton API de l’application", + "pushoverDevice": "Appareil", + "pushoverSound": "Son", + "pushoverCriticalPriority": "Priorité élevée pour les alertes critiques", + "pushoverCriticalPriorityDescription": "Les notifications CRITIQUES utilisent la priorité élevée de Pushover. La priorité d’urgence n’est pas utilisée.", + "pushoverCredentialsHint": "Créez une application dans Pushover, puis copiez son jeton API et votre clé utilisateur ou groupe.", "enableApprise": "Activer Apprise", "urlFormats": "+Formats d'URL", - "appriseUrl": "URL d'information", + "appriseUrl": "URL Apprise", "showUrl": "Afficher l'URL", "hideUrl": "Masquer l'URL", "appriseDescription": "Une URL permet à Apprise d'acheminer la notification vers le bon service. Exemples :", diff --git a/AppImage/messages/it/common.json b/AppImage/messages/it/common.json index 1d8c4875..9a8a0cfa 100644 --- a/AppImage/messages/it/common.json +++ b/AppImage/messages/it/common.json @@ -1839,9 +1839,17 @@ "toAddresses": "Agli indirizzi (separati da virgole)", "subjectPrefix": "Prefisso oggetto", "emailHint": "Lascia vuoto l'host SMTP per utilizzare sendmail locale. Per Gmail, utilizza una password per l'app.", - "enableApprise": "Abilita Appres", + "enablePushover": "Abilita Pushover", + "pushoverUserKey": "Chiave utente o gruppo", + "pushoverApiToken": "Token API dell’applicazione", + "pushoverDevice": "Dispositivo", + "pushoverSound": "Suono", + "pushoverCriticalPriority": "Priorità alta per gli avvisi critici", + "pushoverCriticalPriorityDescription": "Le notifiche CRITICHE usano la priorità alta di Pushover. La priorità di emergenza non viene utilizzata.", + "pushoverCredentialsHint": "Crea un’applicazione in Pushover e copia il relativo token API e la chiave utente o gruppo.", + "enableApprise": "Abilita Apprise", "urlFormats": "+Formati URL", - "appriseUrl": "Informare l'URL", + "appriseUrl": "URL di Apprise", "showUrl": "Mostra URL", "hideUrl": "Nascondi l'URL", "appriseDescription": "Un URL consente ad Apprise di indirizzare la notifica al servizio giusto. Esempi:", diff --git a/AppImage/messages/pt/common.json b/AppImage/messages/pt/common.json index b3dd0376..1fd17ead 100644 --- a/AppImage/messages/pt/common.json +++ b/AppImage/messages/pt/common.json @@ -1839,9 +1839,17 @@ "toAddresses": "Para endereços (separados por vírgula)", "subjectPrefix": "Prefixo do assunto", "emailHint": "Deixe o host SMTP vazio para usar o sendmail local. Para Gmail, use uma senha de aplicativo.", - "enableApprise": "Habilitar informar", + "enablePushover": "Ativar Pushover", + "pushoverUserKey": "Chave de utilizador ou grupo", + "pushoverApiToken": "Token API da aplicação", + "pushoverDevice": "Dispositivo", + "pushoverSound": "Som", + "pushoverCriticalPriority": "Prioridade alta para alertas críticos", + "pushoverCriticalPriorityDescription": "As notificações CRÍTICAS usam a prioridade alta do Pushover. A prioridade de emergência não é utilizada.", + "pushoverCredentialsHint": "Crie uma aplicação no Pushover e copie o respetivo token API e a chave de utilizador ou grupo.", + "enableApprise": "Ativar Apprise", "urlFormats": "+Formatos de URL", - "appriseUrl": "Informar URL", + "appriseUrl": "URL do Apprise", "showUrl": "Mostrar URL", "hideUrl": "Ocultar URL", "appriseDescription": "Um URL permite que o Apprise encaminhe a notificação para o serviço certo. Exemplos:", diff --git a/AppImage/messages/sk/common.json b/AppImage/messages/sk/common.json index 566bae61..746d9251 100644 --- a/AppImage/messages/sk/common.json +++ b/AppImage/messages/sk/common.json @@ -1838,6 +1838,14 @@ "toAddresses": "Adresy príjemcov (oddelené čiarkou)", "subjectPrefix": "Predpona predmetu", "emailHint": "Ak SMTP server necháte prázdny, použije sa lokálny sendmail. Pre Gmail použite heslo aplikácie.", + "enablePushover": "Zapnúť Pushover", + "pushoverUserKey": "Kľúč používateľa alebo skupiny", + "pushoverApiToken": "API token aplikácie", + "pushoverDevice": "Zariadenie", + "pushoverSound": "Zvuk", + "pushoverCriticalPriority": "Vysoká priorita pre kritické upozornenia", + "pushoverCriticalPriorityDescription": "KRITICKÉ notifikácie používajú vysokú prioritu Pushover. Núdzová priorita sa nepoužíva.", + "pushoverCredentialsHint": "Vytvorte aplikáciu v službe Pushover a skopírujte jej API token a kľúč používateľa alebo skupiny.", "enableApprise": "Zapnúť Apprise", "urlFormats": "+formáty URL", "appriseUrl": "URL Apprise", diff --git a/AppImage/messages/sv/common.json b/AppImage/messages/sv/common.json index e83d34f4..0ea42e49 100644 --- a/AppImage/messages/sv/common.json +++ b/AppImage/messages/sv/common.json @@ -1839,9 +1839,17 @@ "toAddresses": "Till adresser (kommaseparerade)", "subjectPrefix": "Ämnesprefix", "emailHint": "Lämna SMTP-värden tom för att använda lokal sendmail. Använd ett applösenord för Gmail.", + "enablePushover": "Aktivera Pushover", + "pushoverUserKey": "Användar- eller gruppnyckel", + "pushoverApiToken": "Appens API-token", + "pushoverDevice": "Enhet", + "pushoverSound": "Ljud", + "pushoverCriticalPriority": "Hög prioritet för kritiska varningar", + "pushoverCriticalPriorityDescription": "KRITISKA aviseringar använder hög prioritet i Pushover. Nödprioritet används inte.", + "pushoverCredentialsHint": "Skapa en app i Pushover och kopiera dess API-token och din användar- eller gruppnyckel.", "enableApprise": "Aktivera Apprise", "urlFormats": "+URL-format", - "appriseUrl": "Upplys URL", + "appriseUrl": "Apprise-URL", "showUrl": "Visa URL", "hideUrl": "Dölj URL", "appriseDescription": "En URL låter Apprise dirigera meddelandet till rätt tjänst. Exempel:", diff --git a/AppImage/scripts/auth_manager.py b/AppImage/scripts/auth_manager.py index f05069bb..43d6def3 100644 --- a/AppImage/scripts/auth_manager.py +++ b/AppImage/scripts/auth_manager.py @@ -1055,10 +1055,13 @@ PROXMOX_CUSTOM_CERT_PATH = "/etc/pve/local/pveproxy-ssl.pem" PROXMOX_CUSTOM_KEY_PATH = "/etc/pve/local/pveproxy-ssl.key" _SSL_RUNTIME_LOCK = threading.RLock() +_SSL_RUNTIME_REFRESH_LOCK = threading.Lock() _SSL_RUNTIME_CONTEXT = None _SSL_RUNTIME_FINGERPRINT = "" _SSL_RUNTIME_CERT_PATH = "" _SSL_RUNTIME_KEY_PATH = "" +_SSL_RUNTIME_SOURCE = "none" +_SSL_RUNTIME_LAST_REFRESH_ERROR = "" def load_ssl_config(): @@ -1100,6 +1103,15 @@ def save_ssl_config(config): return False +def _detect_proxmox_certificate_paths(): + """Return the certificate pair currently preferred by Proxmox.""" + if os.path.isfile(PROXMOX_CUSTOM_CERT_PATH) and os.path.isfile(PROXMOX_CUSTOM_KEY_PATH): + return PROXMOX_CUSTOM_CERT_PATH, PROXMOX_CUSTOM_KEY_PATH + if os.path.isfile(PROXMOX_CERT_PATH) and os.path.isfile(PROXMOX_KEY_PATH): + return PROXMOX_CERT_PATH, PROXMOX_KEY_PATH + return "", "" + + def detect_proxmox_certificates(): """ Detect available Proxmox certificates. @@ -1117,11 +1129,10 @@ def detect_proxmox_certificates(): "cert_info": None } - if os.path.isfile(PROXMOX_CUSTOM_CERT_PATH) and os.path.isfile(PROXMOX_CUSTOM_KEY_PATH): - result["proxmox_cert"] = PROXMOX_CUSTOM_CERT_PATH - result["proxmox_key"] = PROXMOX_CUSTOM_KEY_PATH - result["proxmox_available"] = True - elif os.path.isfile(PROXMOX_CERT_PATH) and os.path.isfile(PROXMOX_KEY_PATH): + cert_path, key_path = _detect_proxmox_certificate_paths() + if cert_path and key_path: + result["proxmox_cert"] = cert_path + result["proxmox_key"] = key_path result["proxmox_available"] = True if result["proxmox_available"]: @@ -1209,17 +1220,112 @@ def _build_server_ssl_context(cert_path, key_path): return context +def _record_ssl_refresh_error(error): + """Log one warning per distinct automatic-refresh failure.""" + global _SSL_RUNTIME_LAST_REFRESH_ERROR + + message = str(error) + with _SSL_RUNTIME_LOCK: + if message == _SSL_RUNTIME_LAST_REFRESH_ERROR: + return + _SSL_RUNTIME_LAST_REFRESH_ERROR = message + print( + "[ProxMenux] Proxmox TLS certificate refresh skipped; " + f"the active certificate remains unchanged: {message}", + flush=True, + ) + + +def _persist_active_proxmox_certificate_paths(cert_path, key_path): + """Keep the selected Proxmox pair in sync for the next service start.""" + config = load_ssl_config() + if not config.get("enabled") or config.get("source") != "proxmox": + return + if config.get("cert_path") == cert_path and config.get("key_path") == key_path: + return + + updated_config = dict(config) + updated_config["cert_path"] = cert_path + updated_config["key_path"] = key_path + if not save_ssl_config(updated_config): + print( + "[ProxMenux] Warning: the renewed Proxmox certificate is active, " + "but its paths could not be saved for the next service start", + flush=True, + ) + + +def _refresh_proxmox_ssl_context_for_handshake(): + """Activate a renewed Proxmox pair just before a TLS handshake. + + This deliberately has no timer and does not depend on inotify (pmxcfs can + update /etc/pve without emitting a local event). The small PEM pair is + inspected only when a client starts a new TLS connection. Any missing, + partial or mismatched pair leaves the already-active context untouched. + """ + global _SSL_RUNTIME_LAST_REFRESH_ERROR + + with _SSL_RUNTIME_LOCK: + if _SSL_RUNTIME_SOURCE != "proxmox" or _SSL_RUNTIME_CONTEXT is None: + return False + + # Several browser connections can arrive together. Only one of them may + # validate/swap a newly written pair; the others reuse its result. + with _SSL_RUNTIME_REFRESH_LOCK: + with _SSL_RUNTIME_LOCK: + if _SSL_RUNTIME_SOURCE != "proxmox" or _SSL_RUNTIME_CONTEXT is None: + return False + active_fingerprint = _SSL_RUNTIME_FINGERPRINT + active_cert_path = _SSL_RUNTIME_CERT_PATH + active_key_path = _SSL_RUNTIME_KEY_PATH + + cert_path, key_path = _detect_proxmox_certificate_paths() + if not cert_path or not key_path: + raise RuntimeError("No complete Proxmox certificate/key pair was detected") + + candidate_fingerprint = _certificate_pair_fingerprint(cert_path, key_path) + paths_changed = cert_path != active_cert_path or key_path != active_key_path + if candidate_fingerprint == active_fingerprint and not paths_changed: + return False + + # reload_server_ssl_context builds and validates the replacement first + # and checks that neither PEM changed while it was being loaded. The + # global context is swapped only after all of those checks succeed. + changed = reload_server_ssl_context(cert_path, key_path) + _persist_active_proxmox_certificate_paths(cert_path, key_path) + with _SSL_RUNTIME_LOCK: + _SSL_RUNTIME_LAST_REFRESH_ERROR = "" + + if changed: + print( + f"[ProxMenux] Renewed Proxmox TLS certificate activated from {cert_path}", + flush=True, + ) + return changed + + def create_reloadable_ssl_context(cert_path, key_path): - """Create the server context and register it for manual hot reloads.""" + """Create the stable server context used by automatic and manual reloads.""" global _SSL_RUNTIME_CONTEXT global _SSL_RUNTIME_FINGERPRINT global _SSL_RUNTIME_CERT_PATH global _SSL_RUNTIME_KEY_PATH + global _SSL_RUNTIME_SOURCE + global _SSL_RUNTIME_LAST_REFRESH_ERROR context = _build_server_ssl_context(cert_path, key_path) fingerprint = _certificate_pair_fingerprint(cert_path, key_path) + config = load_ssl_config() + source = config.get("source", "none") if config.get("enabled") else "none" def _select_active_context(ssl_socket, _server_name, _initial_context): + try: + _refresh_proxmox_ssl_context_for_handshake() + except Exception as error: + # Never fail a client handshake because Proxmox is between the + # certificate and key writes. The previously validated context + # remains authoritative until a later connection can load both. + _record_ssl_refresh_error(error) with _SSL_RUNTIME_LOCK: active_context = _SSL_RUNTIME_CONTEXT if active_context is not None and ssl_socket.context is not active_context: @@ -1231,6 +1337,8 @@ def create_reloadable_ssl_context(cert_path, key_path): _SSL_RUNTIME_FINGERPRINT = fingerprint _SSL_RUNTIME_CERT_PATH = cert_path _SSL_RUNTIME_KEY_PATH = key_path + _SSL_RUNTIME_SOURCE = source + _SSL_RUNTIME_LAST_REFRESH_ERROR = "" return context diff --git a/AppImage/scripts/flask_proxmenux_routes.py b/AppImage/scripts/flask_proxmenux_routes.py index df081f9b..04b317f6 100644 --- a/AppImage/scripts/flask_proxmenux_routes.py +++ b/AppImage/scripts/flask_proxmenux_routes.py @@ -29,6 +29,9 @@ TOOL_METADATA = { 'kernel_panic': {'name': 'Kernel Panic Configuration', 'function': 'configure_kernel_panic', 'version': '1.0'}, 'apt_ipv4': {'name': 'APT IPv4 Force', 'function': 'force_apt_ipv4', 'version': '1.0'}, 'kexec': {'name': 'kexec for quick reboots', 'function': 'enable_kexec', 'version': '1.0'}, + 'rpc': {'name': 'RPC / rpcbind Disable', 'function': 'disable_rpc', 'version': '1.0'}, + 'motd': {'name': 'Custom MOTD Banner', 'function': 'setup_motd', 'version': '1.0'}, + 'system_utils': {'name': 'System Utilities', 'function': 'install_system_utils', 'version': '1.0'}, 'network_optimization': {'name': 'Network Optimizations', 'function': 'apply_network_optimizations', 'version': '1.0'}, 'bashrc_custom': {'name': 'Bashrc Customization', 'function': 'customize_bashrc', 'version': '1.0'}, 'figurine': {'name': 'Figurine', 'function': 'configure_figurine', 'version': '1.0'}, diff --git a/AppImage/scripts/managed_installs.py b/AppImage/scripts/managed_installs.py index 57a78039..1e5f7567 100644 --- a/AppImage/scripts/managed_installs.py +++ b/AppImage/scripts/managed_installs.py @@ -1123,8 +1123,8 @@ def _check_oci_app(entry: dict) -> dict: # returns the single newest version, e.g. "580.105.08" # `https://download.nvidia.com/XFree86/Linux-x86_64/` # HTML directory listing — we scrape it for per-branch latest -# (so a user on 570.x gets 570.x's latest, not pushed to 580.x -# unless their kernel forces a branch upgrade). +# (so a user on 570.x gets 570.x's latest, without an automatic +# cross-branch upgrade). # # Cache TTL is 7 days because NVIDIA's release cadence on each branch # is roughly monthly. The cache is in-memory only; AppImage restarts @@ -1135,15 +1135,6 @@ _NVIDIA_CACHE_TTL = 7 * 86400 _nvidia_cache: dict[str, Any] = {"versions": [], "fetched_at": 0} -def _kernel_string() -> str: - try: - return subprocess.run( - ["uname", "-r"], capture_output=True, text=True, timeout=2, - ).stdout.strip() - except (OSError, subprocess.TimeoutExpired): - return "" - - def _version_tuple(v: str) -> tuple: """Convert ``580.105.08`` → ``(580, 105, 8)`` for comparison. Pads to 3 components so ``580.82`` < ``580.105.08``.""" @@ -1216,7 +1207,6 @@ def _check_nvidia_xfree86(entry: dict) -> dict: "last_check": _now_iso(), "error": None, "_upgrade_kind": "patch" if available else None, - "_kernel": _kernel_string(), } diff --git a/AppImage/scripts/notification_channels.py b/AppImage/scripts/notification_channels.py index d0a72c07..932879fd 100644 --- a/AppImage/scripts/notification_channels.py +++ b/AppImage/scripts/notification_channels.py @@ -1,6 +1,7 @@ """ ProxMenux Notification Channels -Provides transport adapters for Telegram, Gotify, and Discord. +Provides transport adapters for Telegram, Gotify, Discord, Email, Pushover, +and Apprise. Each channel implements send() and test() with: - Retry with exponential backoff (3 attempts) @@ -12,6 +13,7 @@ Author: MacRimi import json import logging +import re import time import urllib.request import urllib.error @@ -392,6 +394,119 @@ class GotifyChannel(NotificationChannel): return self._http_request(url, payload, {'Content-Type': 'application/json'}) +# ─── Pushover ──────────────────────────────────────────────────── + +class PushoverChannel(NotificationChannel): + """Pushover Messages API channel.""" + + API_URL = 'https://api.pushover.net/1/messages.json' + MAX_TITLE_LENGTH = 250 + MAX_MESSAGE_LENGTH = 1024 + _CREDENTIAL_RE = re.compile(r'^[A-Za-z0-9]{30}$') + _OPTION_RE = re.compile(r'^[A-Za-z0-9_-]{1,25}$') + + def __init__(self, user_key: str, api_token: str, device: str = '', + sound: str = '', critical_priority: str = 'true'): + super().__init__() + self.user_key = (user_key or '').strip() + self.api_token = (api_token or '').strip() + self.device = (device or '').strip() + self.sound = (sound or '').strip() + self.critical_priority = str(critical_priority).lower() == 'true' + + def validate_config(self) -> Tuple[bool, str]: + if not self.user_key: + return False, 'Pushover user or group key is required' + if not self.api_token: + return False, 'Pushover application API token is required' + if not self._CREDENTIAL_RE.fullmatch(self.user_key): + return False, 'Invalid Pushover user or group key format' + if not self._CREDENTIAL_RE.fullmatch(self.api_token): + return False, 'Invalid Pushover application API token format' + if self.device and not self._OPTION_RE.fullmatch(self.device): + return False, 'Invalid Pushover device name format' + if self.sound and not self._OPTION_RE.fullmatch(self.sound): + return False, 'Invalid Pushover sound name format' + return True, '' + + @staticmethod + def _truncate(value: str, limit: int) -> str: + value = value or '' + if len(value) <= limit: + return value + return value[:limit - 1].rstrip() + '…' + + @staticmethod + def _response_error(body: str) -> str: + try: + payload = json.loads(body or '{}') + errors = payload.get('errors') + if isinstance(errors, list): + clean = [str(item)[:160] for item in errors if item] + if clean: + return '; '.join(clean) + if isinstance(errors, str) and errors: + return errors[:200] + except (TypeError, ValueError): + pass + return 'Pushover API rejected the request' + + def _post_message(self, title: str, message: str, + priority: int) -> Tuple[int, str]: + payload = { + 'token': self.api_token, + 'user': self.user_key, + 'title': self._truncate(title, self.MAX_TITLE_LENGTH), + 'message': self._truncate(message, self.MAX_MESSAGE_LENGTH), + 'priority': str(priority), + } + if self.device: + payload['device'] = self.device + if self.sound: + payload['sound'] = self.sound + + body = urllib.parse.urlencode(payload).encode('utf-8') + status, response_body = self._http_request( + self.API_URL, + body, + {'Content-Type': 'application/x-www-form-urlencoded'}, + ) + if 200 <= status < 300: + try: + response = json.loads(response_body or '{}') + if response.get('status') == 1: + return status, '' + except (TypeError, ValueError): + pass + return 400, self._response_error(response_body) + return status, self._response_error(response_body) + + def send(self, title: str, message: str, severity: str = 'INFO', + data: Optional[Dict] = None) -> Dict[str, Any]: + valid, error = self.validate_config() + if not valid: + return {'success': False, 'error': error, 'channel': 'pushover'} + + priority = ( + 1 + if self.critical_priority and str(severity or '').upper() == 'CRITICAL' + else 0 + ) + result = self._send_with_retry( + lambda: self._post_message(title, message, priority) + ) + result['channel'] = 'pushover' + return result + + def test(self) -> Tuple[bool, str]: + result = self.send( + 'ProxMenux Test', + 'Pushover is configured correctly. This is a test message from ProxMenux Monitor.', + 'INFO', + ) + return result['success'], result.get('error', '') + + # ─── Discord ───────────────────────────────────────────────────── class DiscordChannel(NotificationChannel): @@ -1189,7 +1304,7 @@ class AppriseChannel(NotificationChannel): Apprise (https://github.com/caronc/apprise) is a Python library that normalises a wide catalogue of notification destinations behind a single URL scheme: `tgram://`, `discord://`, `slack://`, `gotify://`, - `ntfy://`, `matrix://`, `mailto://`, `pushover://`, `signal://`, etc. + `ntfy://`, `matrix://`, `mailto://`, `pover://`, `signal://`, etc. The operator pastes one URL and ProxMenux delegates the transport. Requested in issue #207 by @0berkampf. Implemented as a *separate @@ -1347,6 +1462,13 @@ CHANNEL_TYPES = { 'from_address', 'to_addresses', 'subject_prefix'], 'class': EmailChannel, }, + 'pushover': { + 'name': 'Pushover', + 'config_keys': ['user_key', 'api_token', 'device', 'sound', + 'critical_priority'], + 'required_keys': ['user_key', 'api_token'], + 'class': PushoverChannel, + }, 'apprise': { 'name': 'Apprise', 'config_keys': ['url'], @@ -1359,7 +1481,8 @@ def create_channel(channel_type: str, config: Dict[str, str]) -> Optional[Notifi """Create a channel instance from type name and config dict. Args: - channel_type: 'telegram', 'gotify', 'discord', 'email', or 'apprise' + channel_type: 'telegram', 'gotify', 'discord', 'email', 'pushover', + or 'apprise' config: Dict with channel-specific keys (see CHANNEL_TYPES) Returns: @@ -1383,6 +1506,14 @@ def create_channel(channel_type: str, config: Dict[str, str]) -> Optional[Notifi ) elif channel_type == 'email': return EmailChannel(config) + elif channel_type == 'pushover': + return PushoverChannel( + user_key=config.get('user_key', ''), + api_token=config.get('api_token', ''), + device=config.get('device', ''), + sound=config.get('sound', ''), + critical_priority=config.get('critical_priority', 'true'), + ) elif channel_type == 'apprise': return AppriseChannel(url=config.get('url', '')) except Exception as e: diff --git a/AppImage/scripts/notification_events.py b/AppImage/scripts/notification_events.py index ab91e9ea..f61041b1 100644 --- a/AppImage/scripts/notification_events.py +++ b/AppImage/scripts/notification_events.py @@ -3817,21 +3817,13 @@ class PollingCollector: return 'secure_gateway_update_available', data if item_type == 'nvidia_xfree86': - kind = update.get('_upgrade_kind') - if kind == 'branch_upgrade': - upgrade_reason = ( - "Your current driver branch is no longer compatible with " - f"kernel {update.get('_kernel') or 'this kernel'}. " - "Switch to the recommended branch — the installer will " - "rebuild against the running kernel." - ) - else: - upgrade_reason = ( - "Same-branch maintenance update with bug/security fixes." - ) + upgrade_reason = ( + "Same-branch maintenance update with bug/security fixes. " + "The installer validates the selected release by rebuilding " + "its DKMS module against the running kernel." + ) data = { **common, - 'kernel': update.get('_kernel') or '', 'upgrade_reason': upgrade_reason, } return 'nvidia_driver_update_available', data diff --git a/AppImage/scripts/notification_manager.py b/AppImage/scripts/notification_manager.py index c2d9cfeb..a851f4b0 100644 --- a/AppImage/scripts/notification_manager.py +++ b/AppImage/scripts/notification_manager.py @@ -3,7 +3,8 @@ ProxMenux Notification Manager Central orchestrator for the notification service. Connects: -- notification_channels.py (transport: Telegram, Gotify, Discord) +- notification_channels.py (transport: Telegram, Gotify, Discord, Email, + Pushover, Apprise) - notification_templates.py (message formatting + optional AI) - notification_events.py (event detection: Journal, Task, Polling watchers) - health_persistence.py (DB: config storage, notification_history) @@ -79,6 +80,8 @@ SENSITIVE_KEYS = { 'gotify.token', 'discord.webhook_url', 'email.password', + 'pushover.user_key', + 'pushover.api_token', 'apprise.url', 'webhook_secret', } @@ -2520,9 +2523,10 @@ class NotificationManager: channels_info = {} for ch_type, info in CHANNEL_TYPES.items(): enabled = self._config.get(f'{ch_type}.enabled', 'false') == 'true' + required_keys = info.get('required_keys', info['config_keys']) configured = all( bool(self._config.get(f'{ch_type}.{k}', '')) - for k in info['config_keys'] + for k in required_keys ) channels_info[ch_type] = { 'name': info['name'], diff --git a/AppImage/scripts/notification_templates.py b/AppImage/scripts/notification_templates.py index 29d17e3c..3facb161 100644 --- a/AppImage/scripts/notification_templates.py +++ b/AppImage/scripts/notification_templates.py @@ -1318,7 +1318,7 @@ TEMPLATES = { 'nvidia_driver_update_available': { 'title': '{hostname}: NVIDIA driver update available — v{latest_version}', 'body': ( - 'A newer NVIDIA driver compatible with kernel {kernel} is available.\n' + 'A newer maintenance release is available for the installed NVIDIA driver branch.\n' '🔹 Currently installed: v{current_version}\n' '🟢 Latest available: v{latest_version}\n\n' '{upgrade_reason}\n\n' diff --git a/AppImage/scripts/tests/test_pushover_channel.py b/AppImage/scripts/tests/test_pushover_channel.py new file mode 100644 index 00000000..096dfd4b --- /dev/null +++ b/AppImage/scripts/tests/test_pushover_channel.py @@ -0,0 +1,106 @@ +import sys +import unittest +import urllib.parse +from pathlib import Path + + +SCRIPTS_DIR = Path(__file__).resolve().parents[1] +if str(SCRIPTS_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPTS_DIR)) + +from notification_channels import PushoverChannel, create_channel + + +class PushoverChannelTests(unittest.TestCase): + USER_KEY = "u" * 30 + API_TOKEN = "a" * 30 + + def make_channel(self, **kwargs): + channel = PushoverChannel( + user_key=kwargs.pop("user_key", self.USER_KEY), + api_token=kwargs.pop("api_token", self.API_TOKEN), + **kwargs, + ) + channel.MAX_RETRIES = 1 + return channel + + def test_requires_valid_user_key_and_api_token(self): + channel = self.make_channel(user_key="") + self.assertEqual(channel.validate_config(), ( + False, "Pushover user or group key is required" + )) + + channel = self.make_channel(api_token="short") + self.assertEqual(channel.validate_config(), ( + False, "Invalid Pushover application API token format" + )) + + def test_optional_device_sound_and_factory(self): + channel = create_channel("pushover", { + "user_key": self.USER_KEY, + "api_token": self.API_TOKEN, + "device": "iphone_15", + "sound": "magic", + "critical_priority": "false", + }) + + self.assertIsInstance(channel, PushoverChannel) + self.assertEqual(channel.validate_config(), (True, "")) + self.assertFalse(channel.critical_priority) + + def test_critical_alert_uses_high_priority_and_api_limits(self): + channel = self.make_channel( + device="iphone_15", + sound="magic", + critical_priority="true", + ) + request = {} + + def fake_http(url, data, headers): + request["url"] = url + request["payload"] = urllib.parse.parse_qs(data.decode("utf-8")) + request["headers"] = headers + return 200, '{"status":1,"request":"test"}' + + channel._http_request = fake_http + result = channel.send("T" * 300, "M" * 1200, "critical") + + self.assertTrue(result["success"]) + self.assertEqual(request["url"], PushoverChannel.API_URL) + self.assertEqual(request["payload"]["priority"], ["1"]) + self.assertEqual(request["payload"]["device"], ["iphone_15"]) + self.assertEqual(request["payload"]["sound"], ["magic"]) + self.assertEqual(len(request["payload"]["title"][0]), 250) + self.assertEqual(len(request["payload"]["message"][0]), 1024) + self.assertTrue(request["payload"]["message"][0].endswith("…")) + + def test_noncritical_alert_uses_normal_priority(self): + channel = self.make_channel(critical_priority="true") + request = {} + + def fake_http(url, data, headers): + request["payload"] = urllib.parse.parse_qs(data.decode("utf-8")) + return 200, '{"status":1}' + + channel._http_request = fake_http + result = channel.send("Warning", "Message", "WARNING") + + self.assertTrue(result["success"]) + self.assertEqual(request["payload"]["priority"], ["0"]) + + def test_api_error_does_not_expose_credentials(self): + channel = self.make_channel() + channel._http_request = lambda *args: ( + 400, '{"status":0,"errors":["user identifier is invalid"]}' + ) + + result = channel.send("Title", "Message") + + self.assertFalse(result["success"]) + self.assertIn("user identifier is invalid", result["error"]) + self.assertNotIn(self.USER_KEY, result["error"]) + self.assertNotIn(self.API_TOKEN, result["error"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/lang/es.json b/lang/es.json index 98ac12bb..edd41bd2 100644 --- a/lang/es.json +++ b/lang/es.json @@ -441,8 +441,8 @@ "CPU set to host,hidden=1,flags=+pcid": "CPU configurada como host,hidden=1,flags=+pcid", "CPU vendor (intel/amd):": "Proveedor de CPU (Intel/AMD):", "CRITICAL: The selected disk is referenced by a RUNNING VM or CT.": "CRÍTICO: El disco seleccionado tiene referencia a una VM o CT EN EJECUCIÓN.", - "CT": "Connecticut", - "CT started successfully.": "La TC se inició con éxito.", + "CT": "LXC", + "CT started successfully.": "El LXC se inició con éxito.", "Cancel": "Cancelar", "Cancel restore": "Cancelar restauración", "Cancel this setup": "cancelar esta configuración", diff --git a/scripts/global/pci_passthrough_helpers.sh b/scripts/global/pci_passthrough_helpers.sh index f501def1..e1a05a76 100644 --- a/scripts/global/pci_passthrough_helpers.sh +++ b/scripts/global/pci_passthrough_helpers.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -if [[ -n "${__PROXMENUX_PCI_PASSTHROUGH_HELPERS__}" ]]; then +if [[ -n "${__PROXMENUX_PCI_PASSTHROUGH_HELPERS__:-}" ]]; then return 0 fi __PROXMENUX_PCI_PASSTHROUGH_HELPERS__=1 @@ -378,14 +378,23 @@ function _pci_sriov_role() { # PCI subsystem ADD event, which is exactly when we need them. # ────────────────────────────────────────────────────────────────────── -PROXMENUX_VFIO_BIND_STATE="/etc/proxmenux/vfio-bind.bdfs" -PROXMENUX_VFIO_BIND_UDEV_RULE="/etc/udev/rules.d/10-proxmenux-vfio-bind.rules" +PROXMENUX_SYSFS_ROOT="${PROXMENUX_SYSFS_ROOT:-/sys}" +PROXMENUX_ETC_ROOT="${PROXMENUX_ETC_ROOT:-/etc}" +PROXMENUX_STATE_ROOT="${PROXMENUX_STATE_ROOT:-${BASE_DIR:-/usr/local/share/proxmenux}}" +PROXMENUX_VFIO_BIND_STATE="${PROXMENUX_VFIO_BIND_STATE:-${PROXMENUX_ETC_ROOT}/proxmenux/vfio-bind.bdfs}" +PROXMENUX_VFIO_BIND_UDEV_RULE="${PROXMENUX_VFIO_BIND_UDEV_RULE:-${PROXMENUX_ETC_ROOT}/udev/rules.d/10-proxmenux-vfio-bind.rules}" +PROXMENUX_VFIO_CONF="${PROXMENUX_VFIO_CONF:-${PROXMENUX_ETC_ROOT}/modprobe.d/vfio.conf}" # Auto-managed blacklist applied only when *every* NVIDIA GPU on the host # is in passthrough. Removed when any NVIDIA GPU goes back to the host. -PROXMENUX_NVIDIA_VFIO_BLACKLIST="/etc/modprobe.d/proxmenux-nvidia-vfio-blacklist.conf" +PROXMENUX_NVIDIA_VFIO_BLACKLIST="${PROXMENUX_NVIDIA_VFIO_BLACKLIST:-${PROXMENUX_ETC_ROOT}/modprobe.d/proxmenux-nvidia-vfio-blacklist.conf}" +PROXMENUX_NVIDIA_SERVICE_STATE="${PROXMENUX_NVIDIA_SERVICE_STATE:-${PROXMENUX_STATE_ROOT}/nvidia-host-services.state}" +# A short-lived implementation stored this state under /var/lib. Keep a +# one-way migration so upgraded hosts restore the exact service state that was +# captured there, then remove the provisional file. +PROXMENUX_NVIDIA_SERVICE_LEGACY_STATE="${PROXMENUX_NVIDIA_SERVICE_LEGACY_STATE:-/var/lib/proxmenux/nvidia-host-services.state}" # Legacy artifact paths from a previous attempt — kept here so we can # remove them when migrating a host that ran the older init-top hook. -PROXMENUX_VFIO_BIND_LEGACY_HOOK="/etc/initramfs-tools/scripts/init-top/proxmenux-vfio-bind" +PROXMENUX_VFIO_BIND_LEGACY_HOOK="${PROXMENUX_VFIO_BIND_LEGACY_HOOK:-${PROXMENUX_ETC_ROOT}/initramfs-tools/scripts/init-top/proxmenux-vfio-bind}" _proxmenux_vfio_bind_write_udev_rule() { # Always nuke the obsolete init-top hook from earlier attempts (if it @@ -428,6 +437,38 @@ _proxmenux_vfio_bind_cleanup_legacy() { fi } +_proxmenux_mark_host_config_changed() { + [[ -n "${HOST_CONFIG_CHANGED+x}" ]] && HOST_CONFIG_CHANGED=true +} + +_proxmenux_vfio_bind_has_bdf() { + local bdf="$1" + [[ -n "$bdf" && -f "$PROXMENUX_VFIO_BIND_STATE" ]] || return 1 + [[ "$bdf" == 0000:* ]] || bdf="0000:${bdf}" + grep -qxF "$bdf" "$PROXMENUX_VFIO_BIND_STATE" 2>/dev/null +} + +_proxmenux_vfio_bind_has_entries() { + [[ -s "$PROXMENUX_VFIO_BIND_STATE" ]] \ + && grep -qEv '^[[:space:]]*(#|$)' "$PROXMENUX_VFIO_BIND_STATE" 2>/dev/null +} + +_proxmenux_vfio_bind_state_has_vendor() { + local target_vendor="${1,,}" + [[ -n "$target_vendor" && -f "$PROXMENUX_VFIO_BIND_STATE" ]] || return 1 + + local bdf full vendor_hex + while IFS= read -r bdf; do + [[ -z "$bdf" || "$bdf" == \#* ]] && continue + full="$bdf" + [[ "$full" == 0000:* ]] || full="0000:${full}" + vendor_hex=$(cat "${PROXMENUX_SYSFS_ROOT}/bus/pci/devices/${full}/vendor" 2>/dev/null \ + | sed 's/^0x//' | tr '[:upper:]' '[:lower:]') + [[ "$vendor_hex" == "$target_vendor" ]] && return 0 + done < "$PROXMENUX_VFIO_BIND_STATE" + return 1 +} + _proxmenux_vfio_bind_add_bdfs() { # Args: any number of BDFs ("01:00.0" or "0000:01:00.0") mkdir -p "$(dirname "$PROXMENUX_VFIO_BIND_STATE")" @@ -450,8 +491,8 @@ _proxmenux_vfio_bind_add_bdfs() { done if $changed; then _proxmenux_vfio_bind_write_udev_rule - _proxmenux_nvidia_vfio_blacklist_sync || true - [[ -n "${HOST_CONFIG_CHANGED+x}" ]] && HOST_CONFIG_CHANGED=true + _proxmenux_nvidia_vfio_policy_sync || true + _proxmenux_mark_host_config_changed fi } @@ -475,10 +516,10 @@ _proxmenux_vfio_bind_remove_bdfs() { if ! cmp -s "$tmp" "$PROXMENUX_VFIO_BIND_STATE"; then mv "$tmp" "$PROXMENUX_VFIO_BIND_STATE" _proxmenux_vfio_bind_write_udev_rule - _proxmenux_nvidia_vfio_blacklist_sync || true - [[ -n "${HOST_CONFIG_CHANGED+x}" ]] && HOST_CONFIG_CHANGED=true # If empty, remove state file too (keeps host clean) [[ ! -s "$PROXMENUX_VFIO_BIND_STATE" ]] && rm -f "$PROXMENUX_VFIO_BIND_STATE" + _proxmenux_nvidia_vfio_policy_sync || true + _proxmenux_mark_host_config_changed else rm -f "$tmp" fi @@ -490,9 +531,10 @@ _proxmenux_vfio_bind_remove_bdfs() { # or whether the host still needs the nvidia driver loaded for at # least one GPU (multi-GPU mixed case). _proxmenux_all_nvidia_in_vfio() { - local -a host_nvidia=() vfio_nvidia=() - local d cls vendor - for d in /sys/bus/pci/devices/*; do + local -a host_nvidia=() + local d cls vendor bdf + for d in "${PROXMENUX_SYSFS_ROOT}/bus/pci/devices/"*; do + [[ -d "$d" ]] || continue vendor=$(cat "$d/vendor" 2>/dev/null) [[ "$vendor" != "0x10de" ]] && continue cls=$(cat "$d/class" 2>/dev/null) @@ -502,23 +544,10 @@ _proxmenux_all_nvidia_in_vfio() { done (( ${#host_nvidia[@]} == 0 )) && return 1 - if [[ -f "$PROXMENUX_VFIO_BIND_STATE" ]]; then - local bdf full - while IFS= read -r bdf; do - [[ -z "$bdf" ]] && continue - case "$bdf" in \#*) continue ;; esac - full="$bdf" - [[ "$full" != 0000:* ]] && full="0000:${full}" - vendor=$(cat "/sys/bus/pci/devices/${full}/vendor" 2>/dev/null) - [[ "$vendor" != "0x10de" ]] && continue - cls=$(cat "/sys/bus/pci/devices/${full}/class" 2>/dev/null) - case "$cls" in - 0x0300*|0x0302*) vfio_nvidia+=("$full") ;; - esac - done < "$PROXMENUX_VFIO_BIND_STATE" - fi - - (( ${#vfio_nvidia[@]} >= ${#host_nvidia[@]} )) + for bdf in "${host_nvidia[@]}"; do + _proxmenux_vfio_bind_has_bdf "$bdf" || return 1 + done + return 0 } # Apply or remove the auto-managed nvidia blacklist + the nvidia-smi @@ -526,11 +555,12 @@ _proxmenux_all_nvidia_in_vfio() { # passthrough. Returns 0 if anything changed (caller may want to # rebuild initramfs). _proxmenux_nvidia_vfio_blacklist_sync() { - local nvidia_udev_rule="/etc/udev/rules.d/70-nvidia.rules" + local nvidia_udev_rule="${PROXMENUX_ETC_ROOT}/udev/rules.d/70-nvidia.rules" local changed=1 if _proxmenux_all_nvidia_in_vfio; then if [[ ! -f "$PROXMENUX_NVIDIA_VFIO_BLACKLIST" ]]; then + mkdir -p "$(dirname "$PROXMENUX_NVIDIA_VFIO_BLACKLIST")" cat > "$PROXMENUX_NVIDIA_VFIO_BLACKLIST" <<'EOF' # ProxMenux: every NVIDIA GPU on this host is in VFIO passthrough. # Block the nvidia module so it doesn't loop trying to claim devices @@ -567,55 +597,179 @@ EOF return $changed } -# Returns the BDF of a PCI bridge sharing the IOMMU group of $1, if any. -# The kernel refuses to bind vfio-pci to root ports, so when a GPU shares -# its IOMMU group with the upstream root port the VFIO setup silently -# does nothing — the GPU keeps its native driver and the host can also -# end up with a stuck boot if other devices behind the bridge were -# expected to come up under the original driver. Detecting this lets -# callers warn the operator and bail out before writing host config. -_proxmenux_vfio_bind_group_bridge() { - local target="$1" - [[ "$target" != 0000:* ]] && target="0000:${target}" - local group_link - group_link=$(readlink "/sys/bus/pci/devices/${target}/iommu_group" 2>/dev/null) || return 1 - local group_num - group_num=$(basename "$group_link") - local member bdf cls - for member in "/sys/kernel/iommu_groups/${group_num}/devices/"*; do - bdf=$(basename "$member") - [[ "$bdf" == "$target" ]] && continue - cls=$(cat "$member/class" 2>/dev/null) - # PCI bridge class is 0x0604xx (Normal bridge 0x060400, Subtractive 0x060401). - if [[ "$cls" == 0x0604* ]]; then - echo "$bdf" - return 0 - fi - done - return 1 +_proxmenux_nvidia_vfio_softdeps_sync() { + local changed=1 + mkdir -p "$(dirname "$PROXMENUX_VFIO_CONF")" + touch "$PROXMENUX_VFIO_CONF" + + local -a softdeps=( + "softdep nvidia pre: vfio-pci" + "softdep nvidia_drm pre: vfio-pci" + "softdep nvidia_modeset pre: vfio-pci" + "softdep nvidia_uvm pre: vfio-pci" + ) + local line + if _proxmenux_vfio_bind_state_has_vendor "10de"; then + for line in "${softdeps[@]}"; do + if ! grep -qFx "$line" "$PROXMENUX_VFIO_CONF" 2>/dev/null; then + echo "$line" >> "$PROXMENUX_VFIO_CONF" + changed=0 + fi + done + else + for line in "${softdeps[@]}"; do + if grep -qFx "$line" "$PROXMENUX_VFIO_CONF" 2>/dev/null; then + sed -i "\|^${line}$|d" "$PROXMENUX_VFIO_CONF" + changed=0 + fi + done + fi + return $changed } -_proxmenux_vfio_bind_purge_vendor() { - # Removes every BDF from the binder state whose PCI vendor matches $1 - # (hex, e.g. "10de" for NVIDIA, "1002" for AMD, "8086" for Intel). - # Used by switch_gpu_mode to drop all NVIDIA bindings when reverting - # NVIDIA passthrough — the nvidia module reclaims the GPUs after the - # next reboot. - local target_vendor="${1,,}" - [[ -z "$target_vendor" || ! -f "$PROXMENUX_VFIO_BIND_STATE" ]] && return 0 +# NVIDIA services are host-wide. They must only be stopped when every +# NVIDIA display controller is assigned to VFIO; on a mixed host they stay +# available for the GPU(s) that remain native. The first transition stores +# the previous service state and later transitions do not overwrite it. +_proxmenux_nvidia_host_services_sync() { + command -v systemctl >/dev/null 2>&1 || return 1 - local -a to_remove=() - local bdf vendor_hex - while IFS= read -r bdf; do - [[ -z "$bdf" ]] && continue - case "$bdf" in \#*) continue ;; esac - local full="$bdf" - [[ "$full" != 0000:* ]] && full="0000:${full}" - vendor_hex=$(cat "/sys/bus/pci/devices/${full}/vendor" 2>/dev/null | sed 's/^0x//' | tr '[:upper:]' '[:lower:]') - [[ "$vendor_hex" == "$target_vendor" ]] && to_remove+=("$full") - done < "$PROXMENUX_VFIO_BIND_STATE" + local changed=1 svc was_enabled was_active enabled active + local -a services=( + "nvidia-persistenced.service" + "nvidia-powerd.service" + "nvidia-fabricmanager.service" + ) - [[ ${#to_remove[@]} -gt 0 ]] && _proxmenux_vfio_bind_remove_bdfs "${to_remove[@]}" + if [[ "$PROXMENUX_NVIDIA_SERVICE_LEGACY_STATE" != "$PROXMENUX_NVIDIA_SERVICE_STATE" \ + && -f "$PROXMENUX_NVIDIA_SERVICE_LEGACY_STATE" ]]; then + mkdir -p "$(dirname "$PROXMENUX_NVIDIA_SERVICE_STATE")" + if [[ ! -f "$PROXMENUX_NVIDIA_SERVICE_STATE" ]]; then + mv "$PROXMENUX_NVIDIA_SERVICE_LEGACY_STATE" \ + "$PROXMENUX_NVIDIA_SERVICE_STATE" 2>/dev/null || true + else + rm -f "$PROXMENUX_NVIDIA_SERVICE_LEGACY_STATE" + fi + rmdir "$(dirname "$PROXMENUX_NVIDIA_SERVICE_LEGACY_STATE")" \ + >/dev/null 2>&1 || true + fi + + if _proxmenux_all_nvidia_in_vfio; then + mkdir -p "$(dirname "$PROXMENUX_NVIDIA_SERVICE_STATE")" + if [[ ! -f "$PROXMENUX_NVIDIA_SERVICE_STATE" ]]; then + local tmp + tmp=$(mktemp) + for svc in "${services[@]}"; do + was_enabled=0 + was_active=0 + systemctl is-enabled --quiet "$svc" 2>/dev/null && was_enabled=1 + systemctl is-active --quiet "$svc" 2>/dev/null && was_active=1 + if (( was_enabled == 1 || was_active == 1 )); then + echo "${svc} enabled=${was_enabled} active=${was_active}" >> "$tmp" + fi + done + if [[ -s "$tmp" ]]; then + mv "$tmp" "$PROXMENUX_NVIDIA_SERVICE_STATE" + else + rm -f "$tmp" + fi + fi + + for svc in "${services[@]}"; do + if systemctl is-active --quiet "$svc" 2>/dev/null; then + systemctl stop "$svc" >/dev/null 2>&1 || true + changed=0 + fi + if systemctl is-enabled --quiet "$svc" 2>/dev/null; then + systemctl disable "$svc" >/dev/null 2>&1 || true + changed=0 + fi + done + elif [[ -f "$PROXMENUX_NVIDIA_SERVICE_STATE" ]]; then + while IFS= read -r line; do + [[ -z "$line" ]] && continue + svc=${line%% *} + enabled=$(printf '%s\n' "$line" | sed -nE 's/.*enabled=([01]).*/\1/p') + active=$(printf '%s\n' "$line" | sed -nE 's/.*active=([01]).*/\1/p') + [[ "$enabled" == "1" ]] && systemctl enable "$svc" >/dev/null 2>&1 || true + [[ "$active" == "1" ]] && systemctl start "$svc" >/dev/null 2>&1 || true + done < "$PROXMENUX_NVIDIA_SERVICE_STATE" + rm -f "$PROXMENUX_NVIDIA_SERVICE_STATE" + changed=0 + fi + return $changed +} + +_proxmenux_nvidia_component_status_sync() { + declare -F update_component_status >/dev/null 2>&1 || return 1 + + local status_file="${BASE_DIR:-/usr/local/share/proxmenux}/components_status.json" + local version="" status="installed" patched=false metadata='{"patched":false}' + if command -v nvidia-smi >/dev/null 2>&1; then + version=$(nvidia-smi --query-gpu=driver_version --format=csv,noheader 2>/dev/null \ + | head -1 | tr -d '[:space:]') + fi + if [[ -z "$version" && -f "$status_file" ]] && command -v jq >/dev/null 2>&1; then + version=$(jq -r '.nvidia_driver.version // ""' "$status_file" 2>/dev/null) + fi + if [[ -f "$status_file" ]] && command -v jq >/dev/null 2>&1; then + patched=$(jq -r '.nvidia_driver.patched // false' "$status_file" 2>/dev/null) + [[ "$patched" == "true" ]] && metadata='{"patched":true}' + fi + _proxmenux_all_nvidia_in_vfio && status="vfio_passthrough" + update_component_status "nvidia_driver" "$status" "$version" "gpu" \ + "$metadata" >/dev/null 2>&1 || true +} + +_proxmenux_nvidia_vfio_policy_sync() { + local changed=1 + _proxmenux_nvidia_vfio_blacklist_sync && changed=0 + _proxmenux_nvidia_vfio_softdeps_sync && changed=0 + _proxmenux_nvidia_host_services_sync && changed=0 + _proxmenux_nvidia_component_status_sync || true + (( changed == 0 )) && _proxmenux_mark_host_config_changed + return $changed +} + +# Convert legacy vendor:device NVIDIA entries into exact BDF entries before +# removing the old IDs. This preserves the previous host state even when two +# GPUs share the same model/PCI ID, while allowing subsequent selective +# restore of one GPU without releasing the others. +_proxmenux_vfio_bind_migrate_legacy_nvidia_ids() { + [[ -f "$PROXMENUX_VFIO_CONF" ]] || return 1 + local ids_part + ids_part=$(grep '^options vfio-pci ids=' "$PROXMENUX_VFIO_CONF" 2>/dev/null \ + | head -1 | grep -oE 'ids=[^[:space:]]+' | sed 's/^ids=//' | tr '[:upper:]' '[:lower:]') + [[ -n "$ids_part" ]] || return 1 + + local -a ids=() matched_ids=() bdfs=() + IFS=',' read -ra ids <<< "$ids_part" + local path vendor device class token existing + for path in "${PROXMENUX_SYSFS_ROOT}/bus/pci/devices/"*; do + [[ -d "$path" ]] || continue + vendor=$(cat "$path/vendor" 2>/dev/null | sed 's/^0x//' | tr '[:upper:]' '[:lower:]') + [[ "$vendor" == "10de" ]] || continue + class=$(cat "$path/class" 2>/dev/null) + [[ "$class" == 0x0600* || "$class" == 0x0604* ]] && continue + device=$(cat "$path/device" 2>/dev/null | sed 's/^0x//' | tr '[:upper:]' '[:lower:]') + token="${vendor}:${device}" + for existing in "${ids[@]}"; do + [[ "$existing" == "$token" ]] || continue + bdfs+=("$(basename "$path")") + if [[ " ${matched_ids[*]} " != *" ${token} "* ]]; then + matched_ids+=("$token") + fi + break + done + done + (( ${#matched_ids[@]} > 0 )) || return 1 + + (( ${#bdfs[@]} > 0 )) && _proxmenux_vfio_bind_add_bdfs "${bdfs[@]}" + if _clean_vfio_conf_ids "${matched_ids[@]}"; then + _proxmenux_mark_host_config_changed + fi + _proxmenux_nvidia_vfio_policy_sync || true + return 0 } # ────────────────────────────────────────────────────────────────────── @@ -626,12 +780,12 @@ _proxmenux_vfio_bind_purge_vendor() { # ────────────────────────────────────────────────────────────────────── _proxmenux_nvidia_migrate_legacy_blacklist() { local changed=false - local blacklist_file="/etc/modprobe.d/blacklist.conf" - local nvidia_blacklist="/etc/modprobe.d/nvidia-blacklist.conf" - local udev_disabled="/etc/udev/rules.d/70-nvidia.rules.proxmenux-disabled" - local udev_rules="/etc/udev/rules.d/70-nvidia.rules" - local modules_load_disabled="/etc/modules-load.d/nvidia-vfio.conf.proxmenux-disabled-vfio" - local modules_load_active="/etc/modules-load.d/nvidia-vfio.conf" + local blacklist_file="${PROXMENUX_ETC_ROOT}/modprobe.d/blacklist.conf" + local nvidia_blacklist="${PROXMENUX_ETC_ROOT}/modprobe.d/nvidia-blacklist.conf" + local udev_disabled="${PROXMENUX_ETC_ROOT}/udev/rules.d/70-nvidia.rules.proxmenux-disabled" + local udev_rules="${PROXMENUX_ETC_ROOT}/udev/rules.d/70-nvidia.rules" + local modules_load_disabled="${PROXMENUX_ETC_ROOT}/modules-load.d/nvidia-vfio.conf.proxmenux-disabled-vfio" + local modules_load_active="${PROXMENUX_ETC_ROOT}/modules-load.d/nvidia-vfio.conf" if [[ -f "$blacklist_file" ]] && grep -qE '^blacklist (nvidia|nvidia_drm|nvidia_modeset|nvidia_uvm|nvidiafb)$' "$blacklist_file"; then sed -i \ @@ -660,8 +814,14 @@ _proxmenux_nvidia_migrate_legacy_blacklist() { changed=true fi + if _proxmenux_vfio_bind_migrate_legacy_nvidia_ids; then + changed=true + fi + + _proxmenux_nvidia_vfio_policy_sync || true + if $changed; then - [[ -n "${HOST_CONFIG_CHANGED+x}" ]] && HOST_CONFIG_CHANGED=true + _proxmenux_mark_host_config_changed if declare -F msg_ok >/dev/null 2>&1; then msg_ok "$(declare -F translate >/dev/null 2>&1 && translate 'Migrated legacy ProxMenux NVIDIA blacklist state — module will reload after reboot' || echo 'Migrated legacy ProxMenux NVIDIA blacklist state — module will reload after reboot')" else @@ -676,7 +836,7 @@ _pci_driver_of() { [[ -z "$pci" ]] && return local pci_full="$pci" [[ "$pci_full" != 0000:* ]] && pci_full="0000:${pci_full}" - local link="/sys/bus/pci/devices/${pci_full}/driver" + local link="${PROXMENUX_SYSFS_ROOT}/bus/pci/devices/${pci_full}/driver" [[ -L "$link" ]] && basename "$(readlink "$link")" } @@ -684,7 +844,7 @@ _pci_driver_of() { # /etc/modprobe.d/vfio.conf. Preserves any remaining tokens and any # trailing options on the line. Returns 0 when the file changes. _clean_vfio_conf_ids() { - local vfio_conf="/etc/modprobe.d/vfio.conf" + local vfio_conf="$PROXMENUX_VFIO_CONF" [[ ! -f "$vfio_conf" ]] && return 1 local -a targets=("$@") [[ ${#targets[@]} -eq 0 ]] && return 1 @@ -694,7 +854,7 @@ _clean_vfio_conf_ids() { awk -v targets="${targets[*]}" ' BEGIN { n = split(targets, a, " ") - for (i = 1; i <= n; i++) drop[a[i]] = 1 + for (i = 1; i <= n; i++) drop[tolower(a[i])] = 1 } /^options vfio-pci ids=/ { pre = ""; ids = ""; post = "" @@ -707,7 +867,7 @@ _clean_vfio_conf_ids() { out = "" for (i = 1; i <= m; i++) { t = tok[i] - if (!(t in drop)) { + if (!(tolower(t) in drop)) { out = (out == "" ? t : out "," t) } } diff --git a/scripts/global/update-pve-safe.sh b/scripts/global/update-pve-safe.sh index 12d985e7..28d107a3 100644 --- a/scripts/global/update-pve-safe.sh +++ b/scripts/global/update-pve-safe.sh @@ -265,6 +265,7 @@ update_pve_safe() { fi # ── 10. Final cleanup ── + msg_info "$(translate "Running cleanup")" apt-get -y autoremove >/dev/null 2>&1 || true apt-get -y autoclean >/dev/null 2>&1 || true msg_ok "$(translate "Cleanup finished")" diff --git a/scripts/gpu_tpu/add_gpu_vm.sh b/scripts/gpu_tpu/add_gpu_vm.sh index 2a27ede4..7feec179 100644 --- a/scripts/gpu_tpu/add_gpu_vm.sh +++ b/scripts/gpu_tpu/add_gpu_vm.sh @@ -5,8 +5,8 @@ # Author : MacRimi # Copyright : (c) 2024 MacRimi # License : GPL-3.0 -# Version : 1.0 -# Last Updated: 03/04/2026 +# Version : 1.1 +# Last Updated: 26/08/2026 # ========================================================== # Description: # Automates full GPU passthrough (VFIO) from Proxmox host to a VM. @@ -323,25 +323,40 @@ evaluate_host_reboot_requirement() { _file_has_exact_line "$mod" "$modules_file" || needs_change=true done - # vfio-pci ids + # VFIO ownership. NVIDIA uses exact BDFs so another GPU with the same + # vendor:device ID can remain native; AMD/Intel keep the legacy IDs list. local vfio_conf="/etc/modprobe.d/vfio.conf" local ids_line ids_part - ids_line=$(grep "^options vfio-pci ids=" "$vfio_conf" 2>/dev/null | head -1) - if [[ -z "$ids_line" ]]; then - needs_change=true - else - [[ "$ids_line" == *"disable_vga=1"* ]] || needs_change=true - ids_part=$(echo "$ids_line" | grep -oE 'ids=[^[:space:]]+' | sed 's/ids=//') - local existing_ids=() - IFS=',' read -ra existing_ids <<< "$ids_part" - local required found existing - for required in "${IOMMU_VFIO_IDS[@]}"; do - found=false - for existing in "${existing_ids[@]}"; do - [[ "$existing" == "$required" ]] && found=true && break - done - $found || needs_change=true + if [[ "$SELECTED_GPU" == "nvidia" ]]; then + local required_bdf + for required_bdf in "${IOMMU_DEVICES[@]}"; do + if ! declare -F _proxmenux_vfio_bind_has_bdf >/dev/null 2>&1 \ + || ! _proxmenux_vfio_bind_has_bdf "$required_bdf"; then + needs_change=true + fi done + _file_has_exact_line "softdep nvidia pre: vfio-pci" "$vfio_conf" || needs_change=true + _file_has_exact_line "softdep nvidia_drm pre: vfio-pci" "$vfio_conf" || needs_change=true + _file_has_exact_line "softdep nvidia_modeset pre: vfio-pci" "$vfio_conf" || needs_change=true + _file_has_exact_line "softdep nvidia_uvm pre: vfio-pci" "$vfio_conf" || needs_change=true + else + ids_line=$(grep "^options vfio-pci ids=" "$vfio_conf" 2>/dev/null | head -1) + if [[ -z "$ids_line" ]]; then + needs_change=true + else + [[ "$ids_line" == *"disable_vga=1"* ]] || needs_change=true + ids_part=$(echo "$ids_line" | grep -oE 'ids=[^[:space:]]+' | sed 's/ids=//') + local existing_ids=() + IFS=',' read -ra existing_ids <<< "$ids_part" + local required found existing + for required in "${IOMMU_VFIO_IDS[@]}"; do + found=false + for existing in "${existing_ids[@]}"; do + [[ "$existing" == "$required" ]] && found=true && break + done + $found || needs_change=true + done + fi fi # modprobe options files @@ -362,21 +377,16 @@ evaluate_host_reboot_requirement() { case "$SELECTED_GPU" in nvidia) _file_has_exact_line "blacklist nouveau" "$blacklist_file" || needs_change=true - _file_has_exact_line "blacklist nvidia" "$blacklist_file" || needs_change=true - _file_has_exact_line "blacklist nvidia_drm" "$blacklist_file" || needs_change=true - _file_has_exact_line "blacklist nvidia_modeset" "$blacklist_file" || needs_change=true - _file_has_exact_line "blacklist nvidia_uvm" "$blacklist_file" || needs_change=true - _file_has_exact_line "blacklist nvidiafb" "$blacklist_file" || needs_change=true _file_has_exact_line "blacklist lbm-nouveau" "$blacklist_file" || needs_change=true _file_has_exact_line "options nouveau modeset=0" "$blacklist_file" || needs_change=true - [[ -f /etc/modules-load.d/nvidia-vfio.conf ]] && needs_change=true - grep -qE '^(nvidia|nvidia_uvm|nvidia_drm|nvidia_modeset)$' /etc/modules 2>/dev/null && needs_change=true - local svc - for svc in nvidia-persistenced.service nvidia-persistenced nvidia-powerd.service nvidia-fabricmanager.service; do - if systemctl is-active --quiet "$svc" 2>/dev/null || systemctl is-enabled --quiet "$svc" 2>/dev/null; then - needs_change=true - fi - done + # The managed global NVIDIA blacklist is required only when + # every NVIDIA GPU is in VFIO. On a mixed host it must be absent. + if declare -F _proxmenux_all_nvidia_in_vfio >/dev/null 2>&1 \ + && _proxmenux_all_nvidia_in_vfio; then + [[ -f /etc/modprobe.d/proxmenux-nvidia-vfio-blacklist.conf ]] || needs_change=true + else + [[ -f /etc/modprobe.d/proxmenux-nvidia-vfio-blacklist.conf ]] && needs_change=true + fi ;; amd) _file_has_exact_line "blacklist radeon" "$blacklist_file" || needs_change=true @@ -1611,8 +1621,8 @@ configure_vfio_pci_ids() { # NVIDIA: per-BDF binding (multi-GPU safe). The `options vfio-pci # ids=VENDOR:DEVICE` approach captures EVERY GPU with the same # vendor:device ID — fatal when two NVIDIA GPUs share a model. - # Instead, we list the exact BDF(s) of the target GPU in the - # initramfs hook, and add `softdep nvidia pre: vfio-pci` so vfio + # Instead, we list the exact BDF(s) of the target GPU in an early + # udev driver_override rule, and add `softdep nvidia pre: vfio-pci` so vfio # has a chance to claim the BDF before nvidia loads. # ──────────────────────────────────────────────────────────────── if [[ "$SELECTED_GPU" == "nvidia" ]]; then @@ -1649,7 +1659,7 @@ configure_vfio_pci_ids() { _add_line_if_missing "softdep nvidia_modeset pre: vfio-pci" "$vfio_conf" _add_line_if_missing "softdep nvidia_uvm pre: vfio-pci" "$vfio_conf" - # Per-BDF binder hook. IOMMU_DEVICES has the BDFs for the GPU + # Per-BDF binder rule. IOMMU_DEVICES has the BDFs for the GPU # we're passing (and any same-group functions like the audio # function). Add all of them so the whole IOMMU group goes to # vfio-pci as Proxmox expects. @@ -1755,85 +1765,15 @@ blacklist_gpu_drivers() { } sanitize_nvidia_host_stack_for_vfio() { - # In the new per-BDF model we only stop systemd services that could - # actively probe / lock GPUs at boot (persistenced) — but we DO NOT: - # - blacklist the nvidia kernel module - # - remove nvidia entries from /etc/modules - # - rename /etc/modules-load.d/nvidia-vfio.conf - # - rename /etc/udev/rules.d/70-nvidia.rules - # - create /etc/modprobe.d/nvidia-blacklist.conf with install /bin/false - # All of those were global and broke multi-GPU NVIDIA scenarios where - # one GPU goes to a VM (vfio-pci) and another stays on the host - # (nvidia driver). VFIO binding is now per-BDF via driver_override in - # an initramfs hook — the nvidia module stays usable for any GPU not - # explicitly targeted. + # Host-wide NVIDIA services and module blacklisting are derived from + # the complete per-BDF state. With two NVIDIA GPUs, assigning only one + # to a VM keeps the native driver and services available for the other. msg_info "$(translate 'Sanitizing NVIDIA host services for VFIO mode...')" - local changed=false - local state_dir="/var/lib/proxmenux" - local state_file="${state_dir}/nvidia-host-services.state" - local svc - local -a services=( - "nvidia-persistenced.service" - "nvidia-powerd.service" - "nvidia-fabricmanager.service" - ) - - mkdir -p "$state_dir" >/dev/null 2>&1 || true - : > "$state_file" - - for svc in "${services[@]}"; do - local was_enabled=0 was_active=0 - if systemctl is-enabled --quiet "$svc" 2>/dev/null; then - was_enabled=1 - fi - if systemctl is-active --quiet "$svc" 2>/dev/null; then - was_active=1 - fi - if (( was_enabled == 1 || was_active == 1 )); then - echo "${svc} enabled=${was_enabled} active=${was_active}" >>"$state_file" - fi - - if systemctl is-active --quiet "$svc" 2>/dev/null; then - systemctl stop "$svc" >>"$LOG_FILE" 2>&1 || true - changed=true - fi - if systemctl is-enabled --quiet "$svc" 2>/dev/null; then - systemctl disable "$svc" >>"$LOG_FILE" 2>&1 || true - changed=true - fi - done - - [[ -s "$state_file" ]] || rm -f "$state_file" - - if $changed; then - HOST_CONFIG_CHANGED=true + _proxmenux_nvidia_vfio_policy_sync || true + if _proxmenux_all_nvidia_in_vfio; then msg_ok "$(translate 'NVIDIA host services disabled for VFIO mode')" | tee -a "$screen_capture" else - msg_ok "$(translate 'NVIDIA host services already aligned for VFIO mode')" | tee -a "$screen_capture" - fi - - # Sync components_status.json — the host driver stays on disk but is - # not in use for this GPU because it now belongs to a VM. Per-BDF - # model: on multi-GPU hosts where another NVIDIA card still uses the - # nvidia driver, keep the status as "installed" — the driver is - # genuinely in use elsewhere. Only flip to "vfio_passthrough" when no - # NVIDIA GPU is bound to the host driver anymore. - if declare -F update_component_status >/dev/null 2>&1; then - local _nvd_ver _nvd_new_status - _nvd_ver=$(jq -r '.nvidia_driver.version // ""' \ - /usr/local/share/proxmenux/components_status.json 2>/dev/null) - _nvd_new_status="vfio_passthrough" - # Any NVIDIA PCI device still using the nvidia driver on the host? - if lspci -nnk 2>/dev/null | awk ' - /NVIDIA/{gpu=1; next} - gpu && /Kernel driver in use: nvidia$/ {found=1; exit} - /^[^\t]/{gpu=0} - END{exit !found} - '; then - _nvd_new_status="installed" - fi - update_component_status "nvidia_driver" "$_nvd_new_status" \ - "${_nvd_ver:-}" "gpu" '{"patched":false}' >>"$LOG_FILE" 2>&1 || true + msg_ok "$(translate 'NVIDIA host services/autoload already aligned for native mode')" | tee -a "$screen_capture" fi } diff --git a/scripts/gpu_tpu/nvidia_installer.sh b/scripts/gpu_tpu/nvidia_installer.sh index 2aec88fb..bf4d680a 100644 --- a/scripts/gpu_tpu/nvidia_installer.sh +++ b/scripts/gpu_tpu/nvidia_installer.sh @@ -5,18 +5,18 @@ # Author : MacRimi # Copyright : (c) 2024 MacRimi # License : GPL-3.0 -# Version : 1.2 -# Last Updated: 26/03/2026 +# Version : 1.3 +# Last Updated: 26/08/2026 # ========================================================== # Description: # Installs and manages the NVIDIA proprietary driver on a -# Proxmox VE host. Detects hardware, picks a kernel-compatible -# driver version and handles the full lifecycle +# Proxmox VE host. Detects hardware, filters NVIDIA branches by +# the installed GPU PCI IDs and handles the full lifecycle # (install / update / remove). # # Features: # - GPU detection + VFIO passthrough safety check -# - Kernel-aware driver version filter (5.15 → 6.17+) +# - GPU PCI-ID-aware branch filtering from NVIDIA supportedchips # - Nouveau blacklist + module unload # - DKMS-backed install (survives kernel upgrades) # - udev rules + nvidia-persistenced service @@ -36,6 +36,10 @@ screen_capture="/tmp/proxmenux_nvidia_screen_capture_$$.txt" NVIDIA_BASE_URL="https://download.nvidia.com/XFree86/Linux-x86_64" NVIDIA_WORKDIR="/opt/nvidia" +NVIDIA_NOUVEAU_BLACKLIST="/etc/modprobe.d/proxmenux-nouveau-blacklist.conf" +NVIDIA_NOUVEAU_STATE="${BASE_DIR}/nvidia-nouveau-blacklist.state" +NVIDIA_NOUVEAU_LEGACY_BLACKLIST="/etc/modprobe.d/nouveau-blacklist.conf" +NVIDIA_GLOBAL_BLACKLIST="/etc/modprobe.d/blacklist.conf" # LXC post-install update constants (used only when NVIDIA LXC passthrough # containers are detected and the user confirms updating them after the host @@ -541,16 +545,65 @@ ensure_repos_and_headers() { msg_ok "$(translate 'Kernel headers and build tools verified.')" | tee -a "$screen_capture" } +_nouveau_legacy_file_is_proxmenux_shape() { + [[ -f "$NVIDIA_NOUVEAU_LEGACY_BLACKLIST" ]] || return 1 + local content + content=$(sed '/^[[:space:]]*$/d' "$NVIDIA_NOUVEAU_LEGACY_BLACKLIST" 2>/dev/null) + [[ "$content" == $'blacklist nouveau\noptions nouveau modeset=0' ]] +} + +_nouveau_state_set() { + local key="$1" + mkdir -p "$(dirname "$NVIDIA_NOUVEAU_STATE")" + touch "$NVIDIA_NOUVEAU_STATE" + grep -qFx "${key}=1" "$NVIDIA_NOUVEAU_STATE" 2>/dev/null \ + || echo "${key}=1" >> "$NVIDIA_NOUVEAU_STATE" +} + +restore_nouveau_after_uninstall() { + local remove_global_line=false + + if [[ -f "$NVIDIA_NOUVEAU_STATE" ]] \ + && grep -qFx 'blacklist_conf_line_added=1' "$NVIDIA_NOUVEAU_STATE" 2>/dev/null; then + remove_global_line=true + fi + + # Migration for installations made by older ProxMenux versions. That + # version overwrote this exact two-line file and added the matching line + # to blacklist.conf, but had no ownership state yet. + if _nouveau_legacy_file_is_proxmenux_shape; then + rm -f "$NVIDIA_NOUVEAU_LEGACY_BLACKLIST" + remove_global_line=true + fi + + rm -f "$NVIDIA_NOUVEAU_BLACKLIST" + if $remove_global_line && [[ -f "$NVIDIA_GLOBAL_BLACKLIST" ]]; then + sed -i '/^blacklist nouveau$/d' "$NVIDIA_GLOBAL_BLACKLIST" + fi + rm -f "$NVIDIA_NOUVEAU_STATE" +} + blacklist_nouveau() { msg_info "$(translate 'Blacklisting nouveau driver...')" - # Write blacklist config files - if ! grep -q '^blacklist nouveau' /etc/modprobe.d/blacklist.conf 2>/dev/null; then - echo "blacklist nouveau" >> /etc/modprobe.d/blacklist.conf + local legacy_owned=false + if _nouveau_legacy_file_is_proxmenux_shape; then + rm -f "$NVIDIA_NOUVEAU_LEGACY_BLACKLIST" + legacy_owned=true + _nouveau_state_set "legacy_migrated" fi - # Also write explicit options file to ensure it's fully disabled - cat > /etc/modprobe.d/nouveau-blacklist.conf <<'EOF' + if ! grep -q '^blacklist nouveau$' "$NVIDIA_GLOBAL_BLACKLIST" 2>/dev/null; then + echo "blacklist nouveau" >> "$NVIDIA_GLOBAL_BLACKLIST" + _nouveau_state_set "blacklist_conf_line_added" + elif $legacy_owned; then + # The legacy ProxMenux file proves ownership of the companion line. + _nouveau_state_set "blacklist_conf_line_added" + fi + + # ProxMenux-owned file: uninstall can now remove only what we created. + cat > "$NVIDIA_NOUVEAU_BLACKLIST" <<'EOF' +# Managed by ProxMenux NVIDIA installer. blacklist nouveau options nouveau modeset=0 EOF @@ -678,6 +731,7 @@ complete_nvidia_uninstall() { rm -f /etc/udev/rules.d/70-nvidia.rules rm -rf /usr/lib/modprobe.d/nvidia*.conf rm -rf /etc/modprobe.d/nvidia*.conf + restore_nouveau_after_uninstall if [[ -d "$NVIDIA_WORKDIR" ]]; then find "$NVIDIA_WORKDIR" -type d -name "nvidia-persistenced" -exec rm -rf {} + 2>/dev/null || true @@ -709,28 +763,14 @@ ensure_workdir() { } # ========================================================== -# Kernel + system detection +# System detection # ========================================================== -get_kernel_compatibility_info() { - local kernel_version - kernel_version=$(uname -r) - +get_system_info() { if [[ -f /etc/pve/.version ]]; then PVE_VERSION=$(cat /etc/pve/.version) else PVE_VERSION="unknown" fi - - KERNEL_MAJOR=$(echo "$kernel_version" | cut -d. -f1) - KERNEL_MINOR=$(echo "$kernel_version" | cut -d. -f2) - - MIN_DRIVER_VERSION="" - RECOMMENDED_BRANCH="" - COMPATIBILITY_NOTE="" -} - -is_version_compatible() { - return 0 } @@ -1546,7 +1586,7 @@ show_version_menu() { show_proxmenux_logo msg_title "$(translate 'NVIDIA GPU Driver Installation')" - msg_info "$(translate 'Fetching compatible driver versions for your kernel and GPU...')" + msg_info "$(translate 'Fetching NVIDIA driver versions supported by your GPU...')" latest=$(download_latest_version 2>/dev/null) versions_list=$(list_available_versions 2>/dev/null) @@ -1573,18 +1613,6 @@ show_version_menu() { latest=$(echo "$latest" | tr -d '[:space:]') local current_list="$versions_list" - - # Apply kernel compatibility filter if needed - if [[ -n "$MIN_DRIVER_VERSION" ]]; then - local filtered_list="" - while IFS= read -r ver; do - [[ -z "$ver" ]] && continue - if is_version_compatible "$ver"; then - filtered_list+="$ver"$'\n' - fi - done <<< "$current_list" - current_list="$filtered_list" - fi if [[ -n "$current_list" ]]; then current_list=$(filter_option_c_branch "$current_list" "$CURRENT_DRIVER_VERSION" "") @@ -1636,7 +1664,7 @@ show_version_menu() { # 2. Fresh install (no current driver) → Production Branch head # from NVIDIA's Unix drivers page, when present in the list. # 3. Fallback → highest numeric in the list (Production may have - # been filtered out by kernel-compat / GPU-compat / patch + # been filtered out by maintained-branch / GPU PCI-ID / patch # awareness). latest="" if [[ -n "$CURRENT_DRIVER_VERSION" && -n "$current_list" ]]; then @@ -1665,7 +1693,7 @@ show_version_menu() { fi local menu_text="$(translate 'Select the NVIDIA driver version to install:')\n\n" - menu_text+="$(translate 'Versions shown are compatible with your kernel and your GPU. The recommended version keeps you on your current driver branch, or defaults to the NVIDIA Production Branch head on a fresh install.')" + menu_text+="$(translate 'Versions shown belong to maintained NVIDIA branches that list your GPU PCI ID. DKMS compilation is the final validation against the running kernel. The recommended version keeps the current branch, or uses the NVIDIA Production Branch on a fresh install.')" if $patch_filtered; then menu_text+="\n\n$(translate 'NVENC patch detected — list narrowed to versions supported by keylase/nvidia-patch.')" elif [[ -n "$patch_filter_note" ]]; then @@ -1689,7 +1717,7 @@ show_version_menu() { choices+=("$ver" "$ver") done <<< "$current_list" else - choices+=("" "$(translate 'No compatible versions found for your kernel')") + choices+=("" "$(translate 'No supported NVIDIA versions found for this GPU')") fi stop_spinner @@ -1741,7 +1769,7 @@ main() { exit 0 fi - get_kernel_compatibility_info + get_system_info show_version_menu if [[ "$DRIVER_VERSION" == "cancel" || -z "$DRIVER_VERSION" ]]; then @@ -2008,4 +2036,4 @@ if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then exit $? fi main -fi \ No newline at end of file +fi diff --git a/scripts/gpu_tpu/switch_gpu_mode.sh b/scripts/gpu_tpu/switch_gpu_mode.sh index 1c2aeab0..f70c05de 100644 --- a/scripts/gpu_tpu/switch_gpu_mode.sh +++ b/scripts/gpu_tpu/switch_gpu_mode.sh @@ -5,8 +5,8 @@ # Author : MacRimi # Copyright : (c) 2024 MacRimi # License : GPL-3.0 -# Version : 1.0 -# Last Updated: 05/04/2026 +# Version : 1.1 +# Last Updated: 26/08/2026 # ========================================================== # Description: # Moves an already-assigned GPU between the two modes it can @@ -67,7 +67,8 @@ if [[ -f "$LOCAL_SCRIPTS_LOCAL/global/pci_passthrough_helpers.sh" ]]; then elif [[ -f "$LOCAL_SCRIPTS_DEFAULT/global/pci_passthrough_helpers.sh" ]]; then source "$LOCAL_SCRIPTS_DEFAULT/global/pci_passthrough_helpers.sh" else - msg_warn "$(translate 'pci_passthrough_helpers.sh missing — SR-IOV / orphan-audio guards will be skipped')" + echo "ProxMenux: pci_passthrough_helpers.sh is required; refusing to change GPU ownership." >&2 + exit 1 fi load_language initialize_cache @@ -81,6 +82,8 @@ declare -a SELECTED_GPU_IDX=() declare -a SELECTED_IOMMU_IDS=() declare -a SELECTED_PCI_SLOTS=() +declare -a SELECTED_NVIDIA_BDFS=() +declare -a SELECTED_LEGACY_IOMMU_IDS=() declare -a LXC_AFFECTED_CTIDS=() declare -a LXC_AFFECTED_NAMES=() @@ -167,12 +170,30 @@ _get_iommu_group_ids() { done } +_get_iommu_group_bdfs() { + local pci_full="$1" + local group_link="/sys/bus/pci/devices/${pci_full}/iommu_group" + [[ -L "$group_link" ]] || return 0 + + local group_dir dev_path dev_class + group_dir="/sys/kernel/iommu_groups/$(basename "$(readlink "$group_link")")/devices" + for dev_path in "${group_dir}/"*; do + [[ -e "$dev_path" ]] || continue + dev_class=$(cat "$dev_path/class" 2>/dev/null) + # Bridges belong to the isolation boundary, but vfio-pci does not + # support PCI bridges. Proxmox passes the endpoint devices only. + [[ "$dev_class" == 0x0604* || "$dev_class" == 0x0600* ]] && continue + basename "$dev_path" + done +} + _read_vfio_ids() { local vfio_conf="/etc/modprobe.d/vfio.conf" local ids_line ids_part ids_line=$(grep "^options vfio-pci ids=" "$vfio_conf" 2>/dev/null | head -1) [[ -z "$ids_line" ]] && return - ids_part=$(echo "$ids_line" | grep -oE 'ids=[^[:space:]]+' | sed 's/ids=//') + ids_part=$(echo "$ids_line" | grep -oE 'ids=[^[:space:]]+' | sed 's/ids=//' \ + | tr '[:upper:]' '[:lower:]') [[ -z "$ids_part" ]] && return tr ',' '\n' <<< "$ids_part" | sed '/^$/d' } @@ -213,15 +234,10 @@ _remove_gpu_blacklist() { local changed=false case "$gpu_type" in nvidia) - grep -qE '^blacklist (nouveau|nvidia|nvidiafb|nvidia_drm|nvidia_modeset|nvidia_uvm|lbm-nouveau)$|^options nouveau modeset=0$' "$blacklist_file" 2>/dev/null && changed=true - sed -i '/^blacklist nouveau$/d' "$blacklist_file" - sed -i '/^blacklist nvidia$/d' "$blacklist_file" - sed -i '/^blacklist nvidiafb$/d' "$blacklist_file" - sed -i '/^blacklist nvidia_drm$/d' "$blacklist_file" - sed -i '/^blacklist nvidia_modeset$/d' "$blacklist_file" - sed -i '/^blacklist nvidia_uvm$/d' "$blacklist_file" - sed -i '/^blacklist lbm-nouveau$/d' "$blacklist_file" - sed -i '/^options nouveau modeset=0$/d' "$blacklist_file" + # NVIDIA ownership is per BDF. Never alter the global blacklist here: + # it may belong to the host-driver installer and another NVIDIA GPU may + # still need the native driver. + return 1 ;; amd) grep -qE '^blacklist (radeon|amdgpu)$' "$blacklist_file" 2>/dev/null && changed=true @@ -243,14 +259,8 @@ _add_gpu_blacklist() { touch "$blacklist_file" case "$gpu_type" in nvidia) - _add_line_if_missing "blacklist nouveau" "$blacklist_file" - _add_line_if_missing "blacklist nvidia" "$blacklist_file" - _add_line_if_missing "blacklist nvidiafb" "$blacklist_file" - _add_line_if_missing "blacklist nvidia_drm" "$blacklist_file" - _add_line_if_missing "blacklist nvidia_modeset" "$blacklist_file" - _add_line_if_missing "blacklist nvidia_uvm" "$blacklist_file" - _add_line_if_missing "blacklist lbm-nouveau" "$blacklist_file" - _add_line_if_missing "options nouveau modeset=0" "$blacklist_file" + # NVIDIA is handled exclusively by the shared per-BDF policy. + return 0 ;; amd) _add_line_if_missing "blacklist radeon" "$blacklist_file" @@ -263,174 +273,18 @@ _add_gpu_blacklist() { } _sanitize_nvidia_host_stack_for_vfio() { - local changed=false - local state_dir="/var/lib/proxmenux" - local state_file="${state_dir}/nvidia-host-services.state" - local svc - local -a services=( - "nvidia-persistenced.service" - "nvidia-powerd.service" - "nvidia-fabricmanager.service" - ) - - mkdir -p "$state_dir" >/dev/null 2>&1 || true - : > "$state_file" - - for svc in "${services[@]}"; do - local was_enabled=0 was_active=0 - if systemctl is-enabled --quiet "$svc" 2>/dev/null; then - was_enabled=1 - fi - if systemctl is-active --quiet "$svc" 2>/dev/null; then - was_active=1 - fi - if (( was_enabled == 1 || was_active == 1 )); then - echo "${svc} enabled=${was_enabled} active=${was_active}" >>"$state_file" - fi - - if systemctl is-active --quiet "$svc" 2>/dev/null; then - systemctl stop "$svc" >>"$LOG_FILE" 2>&1 || true - changed=true - fi - if systemctl is-enabled --quiet "$svc" 2>/dev/null; then - systemctl disable "$svc" >>"$LOG_FILE" 2>&1 || true - changed=true - fi - done - - [[ -s "$state_file" ]] || rm -f "$state_file" - - if [[ -f /etc/modules-load.d/nvidia-vfio.conf ]]; then - mv /etc/modules-load.d/nvidia-vfio.conf /etc/modules-load.d/nvidia-vfio.conf.proxmenux-disabled-vfio >>"$LOG_FILE" 2>&1 || true - changed=true - fi - - if grep -qE '^(nvidia|nvidia_uvm|nvidia_drm|nvidia_modeset)$' /etc/modules 2>/dev/null; then - sed -i '/^nvidia$/d;/^nvidia_uvm$/d;/^nvidia_drm$/d;/^nvidia_modeset$/d' /etc/modules - changed=true - fi - - # Disable NVIDIA udev rules that trigger nvidia-smi (causes conflict with vfio-pci) - local udev_rules="/etc/udev/rules.d/70-nvidia.rules" - if [[ -f "$udev_rules" ]]; then - mv "$udev_rules" "${udev_rules}.proxmenux-disabled" >>"$LOG_FILE" 2>&1 || true - udevadm control --reload-rules >>"$LOG_FILE" 2>&1 || true - changed=true - fi - - # Create hard blacklist to prevent ANY nvidia module loading (even via modprobe/nvidia-smi) - local nvidia_blacklist="/etc/modprobe.d/nvidia-blacklist.conf" - if [[ ! -f "$nvidia_blacklist" ]]; then - cat > "$nvidia_blacklist" <<'EOF' -# ProxMenux: Hard blacklist to prevent ANY nvidia module loading in VFIO mode -# This prevents nvidia-smi and other tools from triggering module load attempts -install nvidia /bin/false -install nvidia_uvm /bin/false -install nvidia_drm /bin/false -install nvidia_modeset /bin/false -EOF - changed=true - fi - - if $changed; then - HOST_CONFIG_CHANGED=true - msg_ok "$(translate 'NVIDIA host services/autoload disabled for VFIO mode')" | tee -a "$screen_capture" - else - msg_ok "$(translate 'NVIDIA host services/autoload already aligned for VFIO mode')" | tee -a "$screen_capture" - fi - - # Sync components_status.json — the host driver stays on disk but is - # not in use because the GPU now belongs to a VM. Prevents the update - # notification path (and any future logic gated on nvidia_driver.status) - # from acting on a state that no longer matches reality. - if declare -F update_component_status >/dev/null 2>&1; then - local _nvd_ver - _nvd_ver=$(jq -r '.nvidia_driver.version // ""' \ - /usr/local/share/proxmenux/components_status.json 2>/dev/null) - update_component_status "nvidia_driver" "vfio_passthrough" \ - "${_nvd_ver:-}" "gpu" '{"patched":false}' >>"$LOG_FILE" 2>&1 || true - fi + _proxmenux_nvidia_vfio_policy_sync || true } _restore_nvidia_host_stack_for_lxc() { - local changed=false - local state_file="/var/lib/proxmenux/nvidia-host-services.state" - local disabled_file="/etc/modules-load.d/nvidia-vfio.conf.proxmenux-disabled-vfio" - local active_file="/etc/modules-load.d/nvidia-vfio.conf" - - # New per-BDF model: drop every NVIDIA BDF from the initramfs binder so - # the nvidia module reclaims the GPU after the next reboot. Idempotent: - # no-op if no NVIDIA BDFs are tracked. Vendor 10de = NVIDIA. - if declare -F _proxmenux_vfio_bind_purge_vendor >/dev/null 2>&1; then - _proxmenux_vfio_bind_purge_vendor "10de" && changed=true - fi - - # Remove hard blacklist that was preventing nvidia module loading - local nvidia_blacklist="/etc/modprobe.d/nvidia-blacklist.conf" - if [[ -f "$nvidia_blacklist" ]]; then - rm -f "$nvidia_blacklist" >>"$LOG_FILE" 2>&1 || true - changed=true - fi - - # Restore NVIDIA udev rules if they were disabled - local udev_disabled="/etc/udev/rules.d/70-nvidia.rules.proxmenux-disabled" - local udev_rules="/etc/udev/rules.d/70-nvidia.rules" - if [[ -f "$udev_disabled" ]]; then - mv "$udev_disabled" "$udev_rules" >>"$LOG_FILE" 2>&1 || true - udevadm control --reload-rules >>"$LOG_FILE" 2>&1 || true - changed=true - fi - - # Restore previous modules-load policy if ProxMenux disabled it in VM mode. - if [[ -f "$disabled_file" ]]; then - mv "$disabled_file" "$active_file" >>"$LOG_FILE" 2>&1 || true - changed=true - fi - - # Best effort: load NVIDIA kernel modules now that we are back in native mode. - # If not installed, these calls simply fail silently. - modprobe nvidia >/dev/null 2>&1 || true - modprobe nvidia_uvm >/dev/null 2>&1 || true - modprobe nvidia_modeset >/dev/null 2>&1 || true - modprobe nvidia_drm >/dev/null 2>&1 || true - - if [[ -f "$state_file" ]]; then - while IFS= read -r line; do - [[ -z "$line" ]] && continue - local svc enabled active - svc=$(echo "$line" | awk '{print $1}') - enabled=$(echo "$line" | awk -F'enabled=' '{print $2}' | awk '{print $1}') - active=$(echo "$line" | awk -F'active=' '{print $2}' | awk '{print $1}') - [[ "$enabled" == "1" ]] && systemctl enable "$svc" >>"$LOG_FILE" 2>&1 || true - [[ "$active" == "1" ]] && systemctl start "$svc" >>"$LOG_FILE" 2>&1 || true - done <"$state_file" - rm -f "$state_file" - changed=true - fi - - if $changed; then - HOST_CONFIG_CHANGED=true - msg_ok "$(translate 'NVIDIA host services/autoload restored for native mode')" | tee -a "$screen_capture" - else - msg_ok "$(translate 'NVIDIA host services/autoload already aligned for native mode')" | tee -a "$screen_capture" - fi - - # Sync components_status.json back to installed — the host has reclaimed - # the GPU and the nvidia stack is being reloaded. Restores the state to - # what it was before the VFIO switch so the update notification path and - # the auto-reinstall gate see the driver as active on the host again. - if declare -F update_component_status >/dev/null 2>&1; then - local _nvd_ver - _nvd_ver=$(nvidia-smi --query-gpu=driver_version --format=csv,noheader 2>/dev/null | head -1) - if [[ -z "$_nvd_ver" ]]; then - _nvd_ver=$(jq -r '.nvidia_driver.version // ""' \ - /usr/local/share/proxmenux/components_status.json 2>/dev/null) - fi - update_component_status "nvidia_driver" "installed" \ - "${_nvd_ver:-}" "gpu" '{"patched":false}' >>"$LOG_FILE" 2>&1 || true + _proxmenux_nvidia_vfio_policy_sync || true + if ! _proxmenux_all_nvidia_in_vfio; then + modprobe nvidia >/dev/null 2>&1 || true + modprobe nvidia_uvm >/dev/null 2>&1 || true + modprobe nvidia_modeset >/dev/null 2>&1 || true + modprobe nvidia_drm >/dev/null 2>&1 || true fi } - _add_amd_softdep() { local vfio_conf="/etc/modprobe.d/vfio.conf" _add_line_if_missing "softdep radeon pre: vfio-pci" "$vfio_conf" @@ -468,6 +322,10 @@ _remove_vfio_modules_if_unused() { local vfio_count vfio_count=$(_read_vfio_ids | wc -l | tr -d '[:space:]') [[ "$vfio_count" != "0" ]] && return 1 + if declare -F _proxmenux_vfio_bind_has_entries >/dev/null 2>&1 \ + && _proxmenux_vfio_bind_has_entries; then + return 1 + fi local modules_file="/etc/modules" [[ ! -f "$modules_file" ]] && return 1 local had_any=false @@ -811,25 +669,37 @@ check_sriov_and_block_if_needed() { collect_selected_iommu_ids() { SELECTED_IOMMU_IDS=() SELECTED_PCI_SLOTS=() + SELECTED_NVIDIA_BDFS=() + SELECTED_LEGACY_IOMMU_IDS=() - local idx pci viddid slot + local idx pci viddid slot selected_type bdf vid did gid for idx in "${SELECTED_GPU_IDX[@]}"; do pci="${ALL_GPU_PCIS[$idx]}" viddid="${ALL_GPU_VIDDID[$idx]}" + selected_type="${ALL_GPU_TYPES[$idx]}" slot="${pci#0000:}" slot="${slot%.*}" SELECTED_PCI_SLOTS+=("$slot") - local -a group_ids=() - mapfile -t group_ids < <(_get_iommu_group_ids "$pci") - if [[ ${#group_ids[@]} -gt 0 ]]; then - local gid - for gid in "${group_ids[@]}"; do + local -a group_bdfs=() + mapfile -t group_bdfs < <(_get_iommu_group_bdfs "$pci") + [[ ${#group_bdfs[@]} -gt 0 ]] || group_bdfs=("$pci") + + for bdf in "${group_bdfs[@]}"; do + [[ "$bdf" == 0000:* ]] || bdf="0000:${bdf}" + vid=$(cat "/sys/bus/pci/devices/${bdf}/vendor" 2>/dev/null | sed 's/^0x//') + did=$(cat "/sys/bus/pci/devices/${bdf}/device" 2>/dev/null | sed 's/^0x//') + gid="${vid}:${did}" + if [[ -n "$vid" && -n "$did" ]]; then _contains_in_array "$gid" "${SELECTED_IOMMU_IDS[@]}" || SELECTED_IOMMU_IDS+=("$gid") - done - elif [[ -n "$viddid" ]]; then - _contains_in_array "$viddid" "${SELECTED_IOMMU_IDS[@]}" || SELECTED_IOMMU_IDS+=("$viddid") - fi + if [[ "$selected_type" != "nvidia" ]]; then + _contains_in_array "$gid" "${SELECTED_LEGACY_IOMMU_IDS[@]}" || SELECTED_LEGACY_IOMMU_IDS+=("$gid") + fi + fi + if [[ "$selected_type" == "nvidia" ]]; then + _contains_in_array "$bdf" "${SELECTED_NVIDIA_BDFS[@]}" || SELECTED_NVIDIA_BDFS+=("$bdf") + fi + done done } @@ -1167,6 +1037,9 @@ apply_vm_action_for_lxc_mode() { if ! _contains_in_array "$_vd_id" "${SELECTED_IOMMU_IDS[@]}"; then SELECTED_IOMMU_IDS+=("$_vd_id") fi + if ! _contains_in_array "$_vd_id" "${SELECTED_LEGACY_IOMMU_IDS[@]}"; then + SELECTED_LEGACY_IOMMU_IDS+=("$_vd_id") + fi fi fi fi @@ -1234,6 +1107,12 @@ switch_to_vm_mode() { msg_info "$(translate 'Configuring host for GPU -> VM mode...')" + local -a selected_types=() + mapfile -t selected_types < <(_selected_types_unique) + if _contains_in_array "nvidia" "${selected_types[@]}"; then + _proxmenux_nvidia_migrate_legacy_blacklist + fi + if declare -F _pci_is_iommu_active >/dev/null 2>&1 && _pci_is_iommu_active; then _register_iommu_tool msg_ok "$(translate 'IOMMU is already active on this system')" | tee -a "$screen_capture" @@ -1269,24 +1148,30 @@ switch_to_vm_mode() { local -a current_ids=() mapfile -t current_ids < <(_read_vfio_ids) local id - for id in "${SELECTED_IOMMU_IDS[@]}"; do + for id in "${SELECTED_LEGACY_IOMMU_IDS[@]}"; do _contains_in_array "$id" "${current_ids[@]}" || current_ids+=("$id") done _write_vfio_ids "${current_ids[@]}" - if [[ ${#SELECTED_IOMMU_IDS[@]} -gt 0 ]]; then + if [[ ${#SELECTED_LEGACY_IOMMU_IDS[@]} -gt 0 ]]; then local ids_label - ids_label=$(IFS=','; echo "${SELECTED_IOMMU_IDS[*]}") + ids_label=$(IFS=','; echo "${SELECTED_LEGACY_IOMMU_IDS[*]}") msg_ok "$(translate 'vfio-pci IDs configured') (${ids_label})" | tee -a "$screen_capture" fi - local -a selected_types=() - mapfile -t selected_types < <(_selected_types_unique) - local t + if [[ ${#SELECTED_NVIDIA_BDFS[@]} -gt 0 ]]; then + _proxmenux_vfio_bind_add_bdfs "${SELECTED_NVIDIA_BDFS[@]}" + msg_ok "$(translate 'NVIDIA per-BDF VFIO binding configured') (${SELECTED_NVIDIA_BDFS[*]})" | tee -a "$screen_capture" + fi + + local t legacy_blacklist_configured=false for t in "${selected_types[@]}"; do + [[ "$t" == "nvidia" ]] && continue _add_gpu_blacklist "$t" + legacy_blacklist_configured=true done - msg_ok "$(translate 'GPU host driver blacklisted in /etc/modprobe.d/blacklist.conf')" | tee -a "$screen_capture" - _contains_in_array "nvidia" "${selected_types[@]}" && _sanitize_nvidia_host_stack_for_vfio + $legacy_blacklist_configured \ + && msg_ok "$(translate 'GPU host driver blacklisted in /etc/modprobe.d/blacklist.conf')" | tee -a "$screen_capture" + _contains_in_array "nvidia" "${selected_types[@]}" && _proxmenux_nvidia_vfio_policy_sync || true _contains_in_array "amd" "${selected_types[@]}" && _add_amd_softdep if [[ "$HOST_CONFIG_CHANGED" == "true" ]]; then @@ -1320,12 +1205,20 @@ switch_to_lxc_mode() { msg_info "$(translate 'Removing VFIO ownership for selected GPU(s)...')" + local -a selected_types=() + mapfile -t selected_types < <(_selected_types_unique) + if _contains_in_array "nvidia" "${selected_types[@]}"; then + _proxmenux_nvidia_migrate_legacy_blacklist + [[ ${#SELECTED_NVIDIA_BDFS[@]} -gt 0 ]] \ + && _proxmenux_vfio_bind_remove_bdfs "${SELECTED_NVIDIA_BDFS[@]}" + fi + local -a current_ids=() remaining_ids=() removed_ids=() mapfile -t current_ids < <(_read_vfio_ids) local id remove for id in "${current_ids[@]}"; do remove=false - _contains_in_array "$id" "${SELECTED_IOMMU_IDS[@]}" && remove=true + _contains_in_array "$id" "${SELECTED_LEGACY_IOMMU_IDS[@]}" && remove=true if $remove; then removed_ids+=("$id") else @@ -1339,17 +1232,16 @@ switch_to_lxc_mode() { msg_ok "$(translate 'VFIO device IDs removed from /etc/modprobe.d/vfio.conf') (${ids_label})" | tee -a "$screen_capture" fi - local -a selected_types=() - mapfile -t selected_types < <(_selected_types_unique) local t for t in "${selected_types[@]}"; do + if [[ "$t" == "nvidia" ]]; then + _proxmenux_nvidia_vfio_policy_sync || true + continue + fi if ! _type_has_remaining_vfio_ids "$t" "${remaining_ids[@]}"; then if _remove_gpu_blacklist "$t"; then msg_ok "$(translate 'Driver blacklist removed for') ${t}" | tee -a "$screen_capture" fi - if [[ "$t" == "nvidia" ]]; then - _restore_nvidia_host_stack_for_lxc - fi fi done diff --git a/scripts/gpu_tpu/switch_gpu_mode_direct.sh b/scripts/gpu_tpu/switch_gpu_mode_direct.sh index 746f61be..b35fe92e 100644 --- a/scripts/gpu_tpu/switch_gpu_mode_direct.sh +++ b/scripts/gpu_tpu/switch_gpu_mode_direct.sh @@ -5,8 +5,8 @@ # Author : MacRimi # Copyright : (c) 2024 MacRimi # License : GPL-3.0 -# Version : 1.0 -# Last Updated: 09/04/2026 +# Version : 1.1 +# Last Updated: 26/08/2026 # ========================================================== # This script is a hybrid version for ProxMenux Monitor. # It accepts parameters to skip GPU selection and uses @@ -36,6 +36,9 @@ if [[ -f "$LOCAL_SCRIPTS_LOCAL/global/pci_passthrough_helpers.sh" ]]; then source "$LOCAL_SCRIPTS_LOCAL/global/pci_passthrough_helpers.sh" elif [[ -f "$LOCAL_SCRIPTS_DEFAULT/global/pci_passthrough_helpers.sh" ]]; then source "$LOCAL_SCRIPTS_DEFAULT/global/pci_passthrough_helpers.sh" +else + echo "ProxMenux: pci_passthrough_helpers.sh is required; refusing to change GPU ownership." >&2 + exit 1 fi load_language initialize_cache @@ -52,6 +55,8 @@ declare -a SELECTED_GPU_IDX=() declare -a SELECTED_IOMMU_IDS=() declare -a SELECTED_PCI_SLOTS=() +declare -a SELECTED_NVIDIA_BDFS=() +declare -a SELECTED_LEGACY_IOMMU_IDS=() declare -a LXC_AFFECTED_CTIDS=() declare -a LXC_AFFECTED_NAMES=() @@ -145,12 +150,28 @@ _get_iommu_group_ids() { done } +_get_iommu_group_bdfs() { + local pci_full="$1" + local group_link="/sys/bus/pci/devices/${pci_full}/iommu_group" + [[ -L "$group_link" ]] || return 0 + + local group_dir dev_path dev_class + group_dir="/sys/kernel/iommu_groups/$(basename "$(readlink "$group_link")")/devices" + for dev_path in "${group_dir}/"*; do + [[ -e "$dev_path" ]] || continue + dev_class=$(cat "$dev_path/class" 2>/dev/null) + [[ "$dev_class" == 0x0604* || "$dev_class" == 0x0600* ]] && continue + basename "$dev_path" + done +} + _read_vfio_ids() { local vfio_conf="/etc/modprobe.d/vfio.conf" local ids_line ids_part ids_line=$(grep "^options vfio-pci ids=" "$vfio_conf" 2>/dev/null | head -1) [[ -z "$ids_line" ]] && return - ids_part=$(echo "$ids_line" | grep -oE 'ids=[^[:space:]]+' | sed 's/ids=//') + ids_part=$(echo "$ids_line" | grep -oE 'ids=[^[:space:]]+' | sed 's/ids=//' \ + | tr '[:upper:]' '[:lower:]') [[ -z "$ids_part" ]] && return tr ',' '\n' <<< "$ids_part" | sed '/^$/d' } @@ -191,15 +212,10 @@ _remove_gpu_blacklist() { local changed=false case "$gpu_type" in nvidia) - grep -qE '^blacklist (nouveau|nvidia|nvidiafb|nvidia_drm|nvidia_modeset|nvidia_uvm|lbm-nouveau)$|^options nouveau modeset=0$' "$blacklist_file" 2>/dev/null && changed=true - sed -i '/^blacklist nouveau$/d' "$blacklist_file" - sed -i '/^blacklist nvidia$/d' "$blacklist_file" - sed -i '/^blacklist nvidiafb$/d' "$blacklist_file" - sed -i '/^blacklist nvidia_drm$/d' "$blacklist_file" - sed -i '/^blacklist nvidia_modeset$/d' "$blacklist_file" - sed -i '/^blacklist nvidia_uvm$/d' "$blacklist_file" - sed -i '/^blacklist lbm-nouveau$/d' "$blacklist_file" - sed -i '/^options nouveau modeset=0$/d' "$blacklist_file" + # NVIDIA ownership is per BDF. Never alter the global blacklist here: + # it may belong to the host-driver installer and another NVIDIA GPU may + # still need the native driver. + return 1 ;; amd) grep -qE '^blacklist (radeon|amdgpu)$' "$blacklist_file" 2>/dev/null && changed=true @@ -221,14 +237,8 @@ _add_gpu_blacklist() { touch "$blacklist_file" case "$gpu_type" in nvidia) - _add_line_if_missing "blacklist nouveau" "$blacklist_file" - _add_line_if_missing "blacklist nvidia" "$blacklist_file" - _add_line_if_missing "blacklist nvidiafb" "$blacklist_file" - _add_line_if_missing "blacklist nvidia_drm" "$blacklist_file" - _add_line_if_missing "blacklist nvidia_modeset" "$blacklist_file" - _add_line_if_missing "blacklist nvidia_uvm" "$blacklist_file" - _add_line_if_missing "blacklist lbm-nouveau" "$blacklist_file" - _add_line_if_missing "options nouveau modeset=0" "$blacklist_file" + # NVIDIA is handled exclusively by the shared per-BDF policy. + return 0 ;; amd) _add_line_if_missing "blacklist radeon" "$blacklist_file" @@ -241,170 +251,18 @@ _add_gpu_blacklist() { } _sanitize_nvidia_host_stack_for_vfio() { - local changed=false - local state_dir="/var/lib/proxmenux" - local state_file="${state_dir}/nvidia-host-services.state" - local svc - local -a services=( - "nvidia-persistenced.service" - "nvidia-powerd.service" - "nvidia-fabricmanager.service" - ) - - mkdir -p "$state_dir" >/dev/null 2>&1 || true - : > "$state_file" - - for svc in "${services[@]}"; do - local was_enabled=0 was_active=0 - if systemctl is-enabled --quiet "$svc" 2>/dev/null; then - was_enabled=1 - fi - if systemctl is-active --quiet "$svc" 2>/dev/null; then - was_active=1 - fi - if (( was_enabled == 1 || was_active == 1 )); then - echo "${svc} enabled=${was_enabled} active=${was_active}" >>"$state_file" - fi - - if systemctl is-active --quiet "$svc" 2>/dev/null; then - systemctl stop "$svc" >>"$LOG_FILE" 2>&1 || true - changed=true - fi - if systemctl is-enabled --quiet "$svc" 2>/dev/null; then - systemctl disable "$svc" >>"$LOG_FILE" 2>&1 || true - changed=true - fi - done - - [[ -s "$state_file" ]] || rm -f "$state_file" - - if [[ -f /etc/modules-load.d/nvidia-vfio.conf ]]; then - mv /etc/modules-load.d/nvidia-vfio.conf /etc/modules-load.d/nvidia-vfio.conf.proxmenux-disabled-vfio >>"$LOG_FILE" 2>&1 || true - changed=true - fi - - if grep -qE '^(nvidia|nvidia_uvm|nvidia_drm|nvidia_modeset)$' /etc/modules 2>/dev/null; then - sed -i '/^nvidia$/d;/^nvidia_uvm$/d;/^nvidia_drm$/d;/^nvidia_modeset$/d' /etc/modules - changed=true - fi - - # Disable NVIDIA udev rules that trigger nvidia-smi (causes conflict with vfio-pci) - local udev_rules="/etc/udev/rules.d/70-nvidia.rules" - if [[ -f "$udev_rules" ]]; then - mv "$udev_rules" "${udev_rules}.proxmenux-disabled" >>"$LOG_FILE" 2>&1 || true - udevadm control --reload-rules >>"$LOG_FILE" 2>&1 || true - changed=true - fi - - # Create hard blacklist to prevent ANY nvidia module loading (even via modprobe/nvidia-smi) - local nvidia_blacklist="/etc/modprobe.d/nvidia-blacklist.conf" - if [[ ! -f "$nvidia_blacklist" ]]; then - cat > "$nvidia_blacklist" <<'EOF' -# ProxMenux: Hard blacklist to prevent ANY nvidia module loading in VFIO mode -# This prevents nvidia-smi and other tools from triggering module load attempts -install nvidia /bin/false -install nvidia_uvm /bin/false -install nvidia_drm /bin/false -install nvidia_modeset /bin/false -EOF - changed=true - fi - - if $changed; then - HOST_CONFIG_CHANGED=true - msg_ok "$(translate 'NVIDIA host services/autoload disabled for VFIO mode')" | tee -a "$screen_capture" - else - msg_ok "$(translate 'NVIDIA host services/autoload already aligned for VFIO mode')" | tee -a "$screen_capture" - fi - - # Sync components_status.json — the host driver stays on disk but is - # not in use because the GPU now belongs to a VM. Prevents the update - # notification path (and any future logic gated on nvidia_driver.status) - # from acting on a state that no longer matches reality. - if declare -F update_component_status >/dev/null 2>&1; then - local _nvd_ver - _nvd_ver=$(jq -r '.nvidia_driver.version // ""' \ - /usr/local/share/proxmenux/components_status.json 2>/dev/null) - update_component_status "nvidia_driver" "vfio_passthrough" \ - "${_nvd_ver:-}" "gpu" '{"patched":false}' >>"$LOG_FILE" 2>&1 || true - fi + _proxmenux_nvidia_vfio_policy_sync || true } _restore_nvidia_host_stack_for_lxc() { - local changed=false - local state_file="/var/lib/proxmenux/nvidia-host-services.state" - local disabled_file="/etc/modules-load.d/nvidia-vfio.conf.proxmenux-disabled-vfio" - local active_file="/etc/modules-load.d/nvidia-vfio.conf" - - # New per-BDF model: drop every NVIDIA BDF from the initramfs binder so - # the nvidia module reclaims the GPU after the next reboot. Idempotent. - if declare -F _proxmenux_vfio_bind_purge_vendor >/dev/null 2>&1; then - _proxmenux_vfio_bind_purge_vendor "10de" && changed=true - fi - - # Remove hard blacklist that was preventing nvidia module loading - local nvidia_blacklist="/etc/modprobe.d/nvidia-blacklist.conf" - if [[ -f "$nvidia_blacklist" ]]; then - rm -f "$nvidia_blacklist" >>"$LOG_FILE" 2>&1 || true - changed=true - fi - - # Restore NVIDIA udev rules if they were disabled - local udev_disabled="/etc/udev/rules.d/70-nvidia.rules.proxmenux-disabled" - local udev_rules="/etc/udev/rules.d/70-nvidia.rules" - if [[ -f "$udev_disabled" ]]; then - mv "$udev_disabled" "$udev_rules" >>"$LOG_FILE" 2>&1 || true - udevadm control --reload-rules >>"$LOG_FILE" 2>&1 || true - changed=true - fi - - if [[ -f "$disabled_file" ]]; then - mv "$disabled_file" "$active_file" >>"$LOG_FILE" 2>&1 || true - changed=true - fi - - modprobe nvidia >/dev/null 2>&1 || true - modprobe nvidia_uvm >/dev/null 2>&1 || true - modprobe nvidia_modeset >/dev/null 2>&1 || true - modprobe nvidia_drm >/dev/null 2>&1 || true - - if [[ -f "$state_file" ]]; then - while IFS= read -r line; do - [[ -z "$line" ]] && continue - local svc enabled active - svc=$(echo "$line" | awk '{print $1}') - enabled=$(echo "$line" | awk -F'enabled=' '{print $2}' | awk '{print $1}') - active=$(echo "$line" | awk -F'active=' '{print $2}' | awk '{print $1}') - [[ "$enabled" == "1" ]] && systemctl enable "$svc" >>"$LOG_FILE" 2>&1 || true - [[ "$active" == "1" ]] && systemctl start "$svc" >>"$LOG_FILE" 2>&1 || true - done <"$state_file" - rm -f "$state_file" - changed=true - fi - - if $changed; then - HOST_CONFIG_CHANGED=true - msg_ok "$(translate 'NVIDIA host services/autoload restored for native mode')" | tee -a "$screen_capture" - else - msg_ok "$(translate 'NVIDIA host services/autoload already aligned for native mode')" | tee -a "$screen_capture" - fi - - # Sync components_status.json back to installed — the host has reclaimed - # the GPU and the nvidia stack is being reloaded. Restores the state to - # what it was before the VFIO switch so the update notification path and - # the auto-reinstall gate see the driver as active on the host again. - if declare -F update_component_status >/dev/null 2>&1; then - local _nvd_ver - _nvd_ver=$(nvidia-smi --query-gpu=driver_version --format=csv,noheader 2>/dev/null | head -1) - if [[ -z "$_nvd_ver" ]]; then - _nvd_ver=$(jq -r '.nvidia_driver.version // ""' \ - /usr/local/share/proxmenux/components_status.json 2>/dev/null) - fi - update_component_status "nvidia_driver" "installed" \ - "${_nvd_ver:-}" "gpu" '{"patched":false}' >>"$LOG_FILE" 2>&1 || true + _proxmenux_nvidia_vfio_policy_sync || true + if ! _proxmenux_all_nvidia_in_vfio; then + modprobe nvidia >/dev/null 2>&1 || true + modprobe nvidia_uvm >/dev/null 2>&1 || true + modprobe nvidia_modeset >/dev/null 2>&1 || true + modprobe nvidia_drm >/dev/null 2>&1 || true fi } - _add_amd_softdep() { local vfio_conf="/etc/modprobe.d/vfio.conf" _add_line_if_missing "softdep radeon pre: vfio-pci" "$vfio_conf" @@ -442,6 +300,10 @@ _remove_vfio_modules_if_unused() { local vfio_count vfio_count=$(_read_vfio_ids | wc -l | tr -d '[:space:]') [[ "$vfio_count" != "0" ]] && return 1 + if declare -F _proxmenux_vfio_bind_has_entries >/dev/null 2>&1 \ + && _proxmenux_vfio_bind_has_entries; then + return 1 + fi local modules_file="/etc/modules" [[ ! -f "$modules_file" ]] && return 1 local had_any=false @@ -632,25 +494,37 @@ validate_vm_mode_blocked_ids() { collect_selected_iommu_ids() { SELECTED_IOMMU_IDS=() SELECTED_PCI_SLOTS=() + SELECTED_NVIDIA_BDFS=() + SELECTED_LEGACY_IOMMU_IDS=() - local idx pci viddid slot + local idx pci viddid slot selected_type bdf vid did gid for idx in "${SELECTED_GPU_IDX[@]}"; do pci="${ALL_GPU_PCIS[$idx]}" viddid="${ALL_GPU_VIDDID[$idx]}" + selected_type="${ALL_GPU_TYPES[$idx]}" slot="${pci#0000:}" slot="${slot%.*}" SELECTED_PCI_SLOTS+=("$slot") - local -a group_ids=() - mapfile -t group_ids < <(_get_iommu_group_ids "$pci") - if [[ ${#group_ids[@]} -gt 0 ]]; then - local gid - for gid in "${group_ids[@]}"; do + local -a group_bdfs=() + mapfile -t group_bdfs < <(_get_iommu_group_bdfs "$pci") + [[ ${#group_bdfs[@]} -gt 0 ]] || group_bdfs=("$pci") + + for bdf in "${group_bdfs[@]}"; do + [[ "$bdf" == 0000:* ]] || bdf="0000:${bdf}" + vid=$(cat "/sys/bus/pci/devices/${bdf}/vendor" 2>/dev/null | sed 's/^0x//') + did=$(cat "/sys/bus/pci/devices/${bdf}/device" 2>/dev/null | sed 's/^0x//') + gid="${vid}:${did}" + if [[ -n "$vid" && -n "$did" ]]; then _contains_in_array "$gid" "${SELECTED_IOMMU_IDS[@]}" || SELECTED_IOMMU_IDS+=("$gid") - done - elif [[ -n "$viddid" ]]; then - _contains_in_array "$viddid" "${SELECTED_IOMMU_IDS[@]}" || SELECTED_IOMMU_IDS+=("$viddid") - fi + if [[ "$selected_type" != "nvidia" ]]; then + _contains_in_array "$gid" "${SELECTED_LEGACY_IOMMU_IDS[@]}" || SELECTED_LEGACY_IOMMU_IDS+=("$gid") + fi + fi + if [[ "$selected_type" == "nvidia" ]]; then + _contains_in_array "$bdf" "${SELECTED_NVIDIA_BDFS[@]}" || SELECTED_NVIDIA_BDFS+=("$bdf") + fi + done done } @@ -948,6 +822,9 @@ apply_vm_action_for_lxc_mode() { if ! _contains_in_array "$_vd_id" "${SELECTED_IOMMU_IDS[@]}"; then SELECTED_IOMMU_IDS+=("$_vd_id") fi + if ! _contains_in_array "$_vd_id" "${SELECTED_LEGACY_IOMMU_IDS[@]}"; then + SELECTED_LEGACY_IOMMU_IDS+=("$_vd_id") + fi fi fi fi @@ -1018,6 +895,12 @@ switch_to_vm_mode() { msg_info "$(translate 'Configuring host for GPU -> VM mode...')" + local -a selected_types=() + mapfile -t selected_types < <(_selected_types_unique) + if _contains_in_array "nvidia" "${selected_types[@]}"; then + _proxmenux_nvidia_migrate_legacy_blacklist + fi + if declare -F _pci_is_iommu_active >/dev/null 2>&1 && _pci_is_iommu_active; then _register_iommu_tool msg_ok "$(translate 'IOMMU is already active on this system')" | tee -a "$screen_capture" @@ -1044,24 +927,30 @@ switch_to_vm_mode() { local -a current_ids=() mapfile -t current_ids < <(_read_vfio_ids) local id - for id in "${SELECTED_IOMMU_IDS[@]}"; do + for id in "${SELECTED_LEGACY_IOMMU_IDS[@]}"; do _contains_in_array "$id" "${current_ids[@]}" || current_ids+=("$id") done _write_vfio_ids "${current_ids[@]}" - if [[ ${#SELECTED_IOMMU_IDS[@]} -gt 0 ]]; then + if [[ ${#SELECTED_LEGACY_IOMMU_IDS[@]} -gt 0 ]]; then local ids_label - ids_label=$(IFS=','; echo "${SELECTED_IOMMU_IDS[*]}") + ids_label=$(IFS=','; echo "${SELECTED_LEGACY_IOMMU_IDS[*]}") msg_ok "$(translate 'vfio-pci IDs configured') (${ids_label})" | tee -a "$screen_capture" fi - local -a selected_types=() - mapfile -t selected_types < <(_selected_types_unique) - local t + if [[ ${#SELECTED_NVIDIA_BDFS[@]} -gt 0 ]]; then + _proxmenux_vfio_bind_add_bdfs "${SELECTED_NVIDIA_BDFS[@]}" + msg_ok "$(translate 'NVIDIA per-BDF VFIO binding configured') (${SELECTED_NVIDIA_BDFS[*]})" | tee -a "$screen_capture" + fi + + local t legacy_blacklist_configured=false for t in "${selected_types[@]}"; do + [[ "$t" == "nvidia" ]] && continue _add_gpu_blacklist "$t" + legacy_blacklist_configured=true done - msg_ok "$(translate 'GPU host driver blacklisted in /etc/modprobe.d/blacklist.conf')" | tee -a "$screen_capture" - _contains_in_array "nvidia" "${selected_types[@]}" && _sanitize_nvidia_host_stack_for_vfio + $legacy_blacklist_configured \ + && msg_ok "$(translate 'GPU host driver blacklisted in /etc/modprobe.d/blacklist.conf')" | tee -a "$screen_capture" + _contains_in_array "nvidia" "${selected_types[@]}" && _proxmenux_nvidia_vfio_policy_sync || true _contains_in_array "amd" "${selected_types[@]}" && _add_amd_softdep if [[ "$HOST_CONFIG_CHANGED" == "true" ]]; then @@ -1095,12 +984,20 @@ switch_to_lxc_mode() { msg_info "$(translate 'Removing VFIO ownership for selected GPU(s)...')" + local -a selected_types=() + mapfile -t selected_types < <(_selected_types_unique) + if _contains_in_array "nvidia" "${selected_types[@]}"; then + _proxmenux_nvidia_migrate_legacy_blacklist + [[ ${#SELECTED_NVIDIA_BDFS[@]} -gt 0 ]] \ + && _proxmenux_vfio_bind_remove_bdfs "${SELECTED_NVIDIA_BDFS[@]}" + fi + local -a current_ids=() remaining_ids=() removed_ids=() mapfile -t current_ids < <(_read_vfio_ids) local id remove for id in "${current_ids[@]}"; do remove=false - _contains_in_array "$id" "${SELECTED_IOMMU_IDS[@]}" && remove=true + _contains_in_array "$id" "${SELECTED_LEGACY_IOMMU_IDS[@]}" && remove=true if $remove; then removed_ids+=("$id") else @@ -1114,17 +1011,16 @@ switch_to_lxc_mode() { msg_ok "$(translate 'VFIO device IDs removed from /etc/modprobe.d/vfio.conf') (${ids_label})" | tee -a "$screen_capture" fi - local -a selected_types=() - mapfile -t selected_types < <(_selected_types_unique) local t for t in "${selected_types[@]}"; do + if [[ "$t" == "nvidia" ]]; then + _proxmenux_nvidia_vfio_policy_sync || true + continue + fi if ! _type_has_remaining_vfio_ids "$t" "${remaining_ids[@]}"; then if _remove_gpu_blacklist "$t"; then msg_ok "$(translate 'Driver blacklist removed for') ${t}" | tee -a "$screen_capture" fi - if [[ "$t" == "nvidia" ]]; then - _restore_nvidia_host_stack_for_lxc - fi fi done diff --git a/scripts/post_install/auto_post_install.sh b/scripts/post_install/auto_post_install.sh index 55151c0a..c89eb4fb 100644 --- a/scripts/post_install/auto_post_install.sh +++ b/scripts/post_install/auto_post_install.sh @@ -6,13 +6,14 @@ # Copyright : (c) 2024 MacRimi # License : GPL-3.0 # https://github.com/MacRimi/ProxMenux/blob/main/LICENSE -# Version : 1.0 +# Version : 1.1 # ========================================================== # Description: # Applies a curated set of 14 safe optimizations to a fresh -# Proxmox VE host without prompts. Every change is registered -# in installed_tools.json so it can be reversed later from the -# Uninstall Optimizations menu. +# Proxmox VE host without prompts. Reversible changes are registered +# in installed_tools.json so they can be restored later from the +# Uninstall Optimizations menu; package upgrades are intentionally +# excluded because they cannot be rolled back safely. # # Features: # - Zero-interaction baseline: repos, upgrade, banner, APT @@ -158,6 +159,8 @@ apt_upgrade() { remove_subscription_banner() { + local FUNC_VERSION="1.1" + # description: Patch the Proxmox web UI to suppress the subscription dialog and register a successful patch. local pve_version pve_version=$(pveversion 2>/dev/null | grep -oP 'pve-manager/\K[0-9]+' | head -1) @@ -176,15 +179,22 @@ remove_subscription_banner() { msg_warn "Banner removal cancelled by user." return 1 fi - bash "$LOCAL_SCRIPTS/global/remove-banner-pve-v3.sh" + if ! bash "$LOCAL_SCRIPTS/global/remove-banner-pve-v3.sh"; then + msg_error "$(translate "Subscription banner removal failed")" + return 1 + fi else if ! whiptail --title "Proxmox VE 8.x Subscription Banner Removal" \ --yesno "Do you want to remove the Proxmox subscription banner from the web interface for PVE $pve_version?" 10 70; then msg_warn "Banner removal cancelled by user." return 1 fi - bash "$LOCAL_SCRIPTS/global/remove-banner-pve8.sh" + if ! bash "$LOCAL_SCRIPTS/global/remove-banner-pve8.sh"; then + msg_error "$(translate "Subscription banner removal failed")" + return 1 + fi fi + register_tool "subscription_banner" true "$FUNC_VERSION" } diff --git a/scripts/post_install/customizable_post_install.sh b/scripts/post_install/customizable_post_install.sh index a7539693..fba0a7bc 100644 --- a/scripts/post_install/customizable_post_install.sh +++ b/scripts/post_install/customizable_post_install.sh @@ -7,7 +7,7 @@ # Copyright : (c) 2024 MacRimi # License : GPL-3.0 # https://github.com/MacRimi/ProxMenux/blob/main/LICENSE -# Version : 1.3 +# Version : 1.4 # ========================================================== # Description: # Interactive post-installation configurator for Proxmox VE. @@ -15,8 +15,9 @@ # System, Virtualization, Network, Storage, Security, # Customization, Monitoring, Performance, Optional) and presents # a checklist per category so the user picks exactly what to -# apply. Every change is registered in installed_tools.json for -# later reversal from Uninstall Optimizations. +# apply. Reversible changes are registered in installed_tools.json +# for later restoration from Uninstall Optimizations; package upgrades +# are intentionally excluded because they cannot be rolled back safely. # # Features: # - Checklist UI per category (10 categories, ~30 tools total). @@ -24,8 +25,8 @@ # optimizations plus opt-in items (IOMMU/VFIO, Fastfetch, # Figurine, Ceph repo, HA, AMD fixes, pigz, ZFS ARC, …). # - Idempotent: safe to run repeatedly. -# - Registration + rollback: every tool has a reverse function -# in uninstall-tools.sh. +# - Registration + rollback: every registered tool has a reverse +# function in uninstall-tools.sh. # # Credits: # Incorporates ideas and snippets originally published under BSD @@ -155,7 +156,7 @@ $(translate "Do you want to continue anyway?")" 13 70 enable_kexec() { - local FUNC_VERSION="1.0" + local FUNC_VERSION="1.1" # description: Install kexec-tools and add a Ctrl+Alt+K hotkey + systemd unit for fast reboots that skip BIOS/POST. msg_info2 "$(translate "Configuring kexec for quick reboots...")" NECESSARY_REBOOT=1 @@ -169,7 +170,7 @@ enable_kexec() { /usr/bin/env DEBIAN_FRONTEND=noninteractive apt-get -y -o Dpkg::Options::='--force-confdef' install kexec-tools > /dev/null 2>&1 msg_ok "$(translate "kexec-tools installed successfully")" else - msg_ok "$(translate "kexec-tools installed successfully")" + msg_ok "$(translate "kexec-tools is already installed")" fi # Create systemd service file @@ -194,7 +195,7 @@ WantedBy=default.target EOF msg_ok "$(translate "kexec-pve service file created")" else - msg_ok "$(translate "kexec-pve service file created")" + msg_ok "$(translate "kexec-pve service file is already configured")" fi # Enable the service @@ -202,7 +203,7 @@ EOF systemctl enable kexec-pve.service > /dev/null 2>&1 msg_ok "$(translate "kexec-pve service enabled")" else - msg_ok "$(translate "kexec-pve service enabled")" + msg_ok "$(translate "kexec-pve service is already enabled")" fi if [ ! -f /root/.bash_profile ]; then @@ -213,7 +214,7 @@ EOF echo "alias reboot-quick='systemctl kexec'" >> /root/.bash_profile msg_ok "$(translate "reboot-quick alias added")" else - msg_ok "$(translate "reboot-quick alias added")" + msg_ok "$(translate "reboot-quick alias is already configured")" fi msg_success "$(translate "kexec configured successfully. Use the command: reboot-quick")" @@ -1026,7 +1027,7 @@ EOF install_ceph() { - local FUNC_VERSION="1.0" + local FUNC_VERSION="1.1" # description: Install Ceph (client + server packages) for distributed RBD/CephFS storage; PVE 8/9 aware repo selection. msg_info2 "$(translate "Installing Ceph support...")" @@ -1055,6 +1056,11 @@ install_ceph() { return 0 fi + if [[ ! -r /usr/share/keyrings/proxmox-archive-keyring.gpg ]]; then + msg_error "$(translate "The Proxmox archive keyring is missing; Ceph installation cannot continue safely")" + return 1 + fi + # Configure Ceph repository based on version msg_info "$(translate "Configuring Ceph repository for PVE") $pve_version..." @@ -1086,10 +1092,10 @@ EOF # Use legacy format for PVE 8 msg_info "$(translate "Creating Ceph repository for PVE 8 (legacy format)...")" - echo "deb https://download.proxmox.com/debian/ceph-${ceph_version} ${target_codename} no-subscription" > /etc/apt/sources.list.d/ceph-${ceph_version}.list + echo "deb [signed-by=/usr/share/keyrings/proxmox-archive-keyring.gpg] https://download.proxmox.com/debian/ceph-${ceph_version} ${target_codename} no-subscription" > /etc/apt/sources.list.d/ceph-${ceph_version}.list msg_ok "$(translate "Ceph repository configured for PVE 8")" fi - + msg_info "$(translate "Updating package lists...")" @@ -1102,16 +1108,9 @@ EOF msg_warn "$(translate "Package update had issues, checking details...")" - if echo "$update_output" | grep -q "NO_PUBKEY\|GPG error"; then - msg_info "$(translate "Fixing GPG key issues...")" - - apt-key adv --keyserver keyserver.ubuntu.com --recv-keys $(echo "$update_output" | grep "NO_PUBKEY" | sed 's/.*NO_PUBKEY //' | head -1) 2>/dev/null - - if apt-get update > /dev/null 2>&1; then - msg_ok "$(translate "Package lists updated after GPG fix")" - else - msg_warn "$(translate "Package update still has issues, continuing anyway...")" - fi + if echo "$update_output" | grep -Eqi 'NO_PUBKEY|GPG error|EXPKEYSIG|BADSIG|not signed|signatures? (could not|couldn.t) be verified'; then + msg_error "$(translate "Ceph repository signature verification failed; installation has been stopped")" + return 1 elif echo "$update_output" | grep -q "404\|Failed to fetch"; then msg_warn "$(translate "Some repositories are not available, continuing with available ones...")" else @@ -1544,17 +1543,55 @@ update_snapshot_schedule() { disable_rpc() { + local FUNC_VERSION="1.1" + # description: Disable rpcbind service/socket while preserving their exact previous systemd state for rollback. + local state_file="$BASE_DIR/rpcbind.state" + local state_tmp="${state_file}.tmp.$$" + local unit load_state enabled_state active_state + msg_info2 "$(translate "Disabling portmapper/rpcbind for security...")" - msg_info "$(translate "Disabling and stopping rpcbind service...")" + mkdir -p "$BASE_DIR" + if [[ ! -s "$state_file" ]]; then + : > "$state_tmp" + for unit in rpcbind.socket rpcbind.service; do + load_state="$(systemctl show -p LoadState --value "$unit" 2>/dev/null || true)" + [[ -z "$load_state" || "$load_state" == "not-found" ]] && continue + enabled_state="$(systemctl is-enabled "$unit" 2>/dev/null || true)" + active_state="$(systemctl is-active "$unit" 2>/dev/null || true)" + printf '%s|%s|%s\n' "$unit" "${enabled_state:-unknown}" "${active_state:-unknown}" >> "$state_tmp" + done - # Disable and stop rpcbind - systemctl disable rpcbind > /dev/null 2>&1 - systemctl stop rpcbind > /dev/null 2>&1 + if [[ ! -s "$state_tmp" ]]; then + rm -f "$state_tmp" + msg_warn "$(translate "rpcbind units were not found; no changes were made")" + return 0 + fi + mv "$state_tmp" "$state_file" + fi - msg_ok "$(translate "rpcbind service has been disabled and stopped")" + # Register as soon as the original state is safely persisted. If a + # later systemd operation fails, Uninstall Optimizations must still + # expose the recovery path instead of leaving a hidden partial change. + register_tool "rpc" true "$FUNC_VERSION" - msg_success "$(translate "portmapper/rpcbind has been disabled and removed")" + msg_info "$(translate "Disabling and stopping rpcbind service and socket...")" + + systemctl disable --now rpcbind.socket rpcbind.service > /dev/null 2>&1 || true + + for unit in rpcbind.socket rpcbind.service; do + active_state="$(systemctl is-active "$unit" 2>/dev/null || true)" + enabled_state="$(systemctl is-enabled "$unit" 2>/dev/null || true)" + if [[ "$active_state" == "active" || "$active_state" == "activating" || + "$enabled_state" == "enabled" || "$enabled_state" == "enabled-runtime" ]]; then + msg_error "$(translate "rpcbind could not be disabled completely")" + return 1 + fi + done + + msg_ok "$(translate "rpcbind service and socket have been disabled and stopped")" + + msg_success "$(translate "portmapper/rpcbind has been disabled")" } @@ -2008,26 +2045,49 @@ EOF setup_motd() { + local FUNC_VERSION="1.1" + # description: Add the ProxMenux MOTD banner while preserving the original file contents or absence for rollback. msg_info2 "$(translate "Configuring MOTD (Message of the Day) banner...")" - local motd_file="/etc/motd" + local motd_file="${PROXMENUX_MOTD_FILE:-/etc/motd}" local custom_message=" This system is optimised by: ProxMenux" + local state_file="$BASE_DIR/motd.state" + local original_file="$BASE_DIR/motd.original" local changes_made=false msg_info "$(translate "Checking MOTD configuration...")" - # Check if the custom message already exists - if grep -q "$custom_message" "$motd_file"; then - msg_ok "$(translate "Custom message added to MOTD")" - else - # Create a backup of the original MOTD file - if [ ! -f "${motd_file}.bak" ]; then - cp "$motd_file" "${motd_file}.bak" - msg_ok "$(translate "Backup of original MOTD created")" + mkdir -p "$BASE_DIR" + if [[ ! -f "$state_file" ]]; then + if grep -Fqx "$custom_message" "$motd_file" 2>/dev/null; then + if [[ -f "${motd_file}.bak" ]]; then + cp -a "${motd_file}.bak" "$original_file" + printf 'present\n' > "$state_file" + else + printf 'legacy-marker\n' > "$state_file" + fi + elif [[ -e "$motd_file" ]]; then + cp -a "$motd_file" "$original_file" + printf 'present\n' > "$state_file" + else + printf 'absent\n' > "$state_file" fi + fi + # Check if the custom message already exists + if grep -Fqx "$custom_message" "$motd_file" 2>/dev/null; then + msg_ok "$(translate "Custom MOTD message is already configured")" + else # Add the custom message at the beginning of the file - echo -e "$custom_message\n\n$(cat $motd_file)" > "$motd_file" + touch "$motd_file" + local motd_tmp + motd_tmp="$(mktemp)" + { + printf '%s\n\n' "$custom_message" + cat "$motd_file" + } > "$motd_tmp" + cat "$motd_tmp" > "$motd_file" + rm -f "$motd_tmp" changes_made=true msg_ok "$(translate "Custom message added to MOTD")" fi @@ -2037,8 +2097,9 @@ setup_motd() { if $changes_made; then msg_success "$(translate "MOTD configuration updated successfully")" else - msg_success "$(translate "MOTD configuration updated successfully")" + msg_success "$(translate "MOTD configuration was already up to date")" fi + register_tool "motd" true "$FUNC_VERSION" } @@ -2094,7 +2155,7 @@ EOF remove_subscription_banner() { - local FUNC_VERSION="1.0" + local FUNC_VERSION="1.1" # description: Patch the Proxmox web UI to suppress the "no valid subscription" dialog (PVE 8 + 9 variants supported). local pve_version pve_version=$(pveversion 2>/dev/null | grep -oP 'pve-manager/\K[0-9]+' | head -1) @@ -2105,11 +2166,15 @@ remove_subscription_banner() { fi if [[ "$pve_version" -ge 9 ]]; then - - bash "$LOCAL_SCRIPTS/global/remove-banner-pve-v3.sh" + if ! bash "$LOCAL_SCRIPTS/global/remove-banner-pve-v3.sh"; then + msg_error "$(translate "Subscription banner removal failed")" + return 1 + fi else - - bash "$LOCAL_SCRIPTS/global/remove-banner-pve8.sh" + if ! bash "$LOCAL_SCRIPTS/global/remove-banner-pve8.sh"; then + msg_error "$(translate "Subscription banner removal failed")" + return 1 + fi fi register_tool "subscription_banner" true "$FUNC_VERSION" } @@ -3056,6 +3121,10 @@ setup_persistent_network() { install_system_utils() { + local FUNC_VERSION="1.1" + # description: Install selected system utilities and track only packages that were newly added by ProxMenux. + local state_file="$BASE_DIR/system_utils.packages" + local new_packages_tmp="" msg_info2 "$(translate "Installing system utilities...")" # Build checklist from global PROXMENUX_UTILS array @@ -3087,6 +3156,8 @@ install_system_utils() { return 1 fi + new_packages_tmp="$(mktemp)" + local success=0 failed=0 warning=0 local selected_array IFS=' ' read -ra selected_array <<< "$selected" @@ -3094,6 +3165,10 @@ install_system_utils() { for util in "${selected_array[@]}"; do util=$(echo "$util" | tr -d '"') local pkg_cmd="$util" pkg_desc="$util" + local was_installed=false + if dpkg-query -W -f='${Status}' "$util" 2>/dev/null | grep -q '^install ok installed$'; then + was_installed=true + fi for util_entry in "${PROXMENUX_UTILS[@]}"; do IFS=':' read -r epkg ecmd edesc <<< "$util_entry" if [[ "$epkg" == "$util" ]]; then @@ -3103,19 +3178,38 @@ install_system_utils() { fi done install_single_package "$util" "$pkg_cmd" "$pkg_desc" - case $? in + local install_result=$? + case $install_result in 0) success=$((success + 1)) ;; 1) failed=$((failed + 1)) ;; 2) warning=$((warning + 1)) ;; esac + + if [[ "$was_installed" == false ]] && + dpkg-query -W -f='${Status}' "$util" 2>/dev/null | grep -q '^install ok installed$'; then + printf '%s\n' "$util" >> "$new_packages_tmp" + fi done + if [[ -s "$new_packages_tmp" ]]; then + mkdir -p "$BASE_DIR" + { + [[ -f "$state_file" ]] && cat "$state_file" + cat "$new_packages_tmp" + } | sort -u > "${state_file}.tmp" + mv "${state_file}.tmp" "$state_file" + fi + rm -f "$new_packages_tmp" + hash -r 2>/dev/null echo msg_info2 "$(translate "Installation summary"):" [[ $success -gt 0 ]] && msg_ok "$(translate "Successful"): $success" [[ $warning -gt 0 ]] && msg_warn "$(translate "With warnings"): $warning" [[ $failed -gt 0 ]] && msg_error "$(translate "Failed"): $failed" + if [[ -s "$state_file" ]]; then + register_tool "system_utils" true "$FUNC_VERSION" + fi msg_success "$(translate "Utilities installation completed")" } @@ -3126,7 +3220,6 @@ custom_post_category_label() { case "$1" in "Basic Settings") translate "Basic Settings" ;; "System") translate "System" ;; - "Hardware") translate "Hardware" ;; "Virtualization") translate "Virtualization" ;; "Network") translate "Network" ;; "Storage") translate "Storage" ;; @@ -3255,9 +3348,9 @@ main_menu() { HEADER="$(translate "Choose options to configure:")\n\n${header_line}" declare -A category_order=( - ["Basic Settings"]=1 ["System"]=2 ["Hardware"]=3 ["Virtualization"]=4 - ["Network"]=5 ["Storage"]=6 ["Security"]=7 ["Customization"]=8 - ["Monitoring"]=9 ["Performance"]=10 ["Optional"]=11 + ["Basic Settings"]=1 ["System"]=2 ["Virtualization"]=3 + ["Network"]=4 ["Storage"]=5 ["Security"]=6 ["Customization"]=7 + ["Monitoring"]=8 ["Performance"]=9 ["Optional"]=10 ) local options=( diff --git a/scripts/post_install/uninstall-tools.sh b/scripts/post_install/uninstall-tools.sh index e811d28a..d007251f 100644 --- a/scripts/post_install/uninstall-tools.sh +++ b/scripts/post_install/uninstall-tools.sh @@ -6,7 +6,7 @@ # Copyright : (c) 2024 MacRimi # License : GPL-3.0 # https://github.com/MacRimi/ProxMenux/blob/main/LICENSE -# Version : 1.0 +# Version : 1.1 # ========================================================== # Description: # Reverses post-install optimizations previously applied by @@ -17,8 +17,8 @@ # # Features: # - Registry-driven: only shows tools currently applied. -# - Per-tool reverse functions (one for each entry in the -# auto / customizable scripts). +# - Per-tool reverse functions for each registered reversible entry +# in the auto / customizable scripts. # - Restores /etc configs from .bak backups when they exist. # - Reboot prompt for changes that require it (VFIO, kernel # cmdline, persistent NIC names, …). @@ -112,34 +112,6 @@ uninstall_kexec() { ################################################################ -uninstall_apt_upgrade() { - msg_info "$(translate "Restoring enterprise repositories...")" - - # Re-enable enterprise repos - if [ -f /etc/apt/sources.list.d/pve-enterprise.list ]; then - sed -i "s/^#deb/deb/g" /etc/apt/sources.list.d/pve-enterprise.list - fi - - if [ -f /etc/apt/sources.list.d/ceph.list ]; then - sed -i "s/^#deb/deb/g" /etc/apt/sources.list.d/ceph.list - fi - - # Remove public repo - rm -f /etc/apt/sources.list.d/pve-public-repo.list - - # Remove firmware warning config - rm -f /etc/apt/apt.conf.d/no-bookworm-firmware.conf - - apt-get update > /dev/null 2>&1 - - msg_ok "$(translate "Enterprise repositories restored")" - register_tool "apt_upgrade" false -} - -################################################################ - - - uninstall_subscription_banner() { msg_info "$(translate "Restoring subscription banner...")" @@ -226,9 +198,8 @@ uninstall_subscription_banner() { #systemctl restart pveproxy pvedaemon pvestatd 2>/dev/null || true - register_tool "subscription_banner" false - if [[ "$restored" == true ]]; then + register_tool "subscription_banner" false msg_ok "$(translate "Subscription banner restored successfully (desktop and mobile)")" msg_ok "$(translate "Refresh your browser to see changes (server restart may be required)")" else @@ -241,6 +212,147 @@ uninstall_subscription_banner() { +################################################################ + +uninstall_rpc() { + local state_file="$BASE_DIR/rpcbind.state" + local unit enabled_state active_state + local failed=0 + + if [[ ! -s "$state_file" ]]; then + msg_error "$(translate "The original rpcbind state is unavailable; no service state was changed")" + return 1 + fi + + msg_info2 "$(translate "Restoring the original rpcbind service state...")" + while IFS='|' read -r unit enabled_state active_state; do + [[ "$unit" != "rpcbind.service" && "$unit" != "rpcbind.socket" ]] && continue + + case "$enabled_state" in + enabled) + systemctl enable "$unit" >/dev/null 2>&1 || failed=1 + ;; + enabled-runtime) + systemctl enable --runtime "$unit" >/dev/null 2>&1 || failed=1 + ;; + masked) + systemctl mask "$unit" >/dev/null 2>&1 || failed=1 + ;; + masked-runtime) + systemctl mask --runtime "$unit" >/dev/null 2>&1 || failed=1 + ;; + *) + systemctl disable "$unit" >/dev/null 2>&1 || true + ;; + esac + + case "$active_state" in + active|activating|reloading) + systemctl start "$unit" >/dev/null 2>&1 || failed=1 + ;; + *) + systemctl stop "$unit" >/dev/null 2>&1 || true + ;; + esac + done < "$state_file" + + if [[ "$failed" -ne 0 ]]; then + msg_error "$(translate "The original rpcbind state could not be restored completely")" + return 1 + fi + + rm -f "$state_file" + register_tool "rpc" false + msg_ok "$(translate "The original rpcbind service state has been restored")" +} + +################################################################ + +uninstall_motd() { + local state_file="$BASE_DIR/motd.state" + local original_file="$BASE_DIR/motd.original" + local motd_file="${PROXMENUX_MOTD_FILE:-/etc/motd}" + local custom_message=" This system is optimised by: ProxMenux" + local original_state + + if [[ ! -f "$state_file" ]]; then + msg_error "$(translate "The original MOTD state is unavailable; no changes were made")" + return 1 + fi + + original_state="$(head -n 1 "$state_file" 2>/dev/null)" + case "$original_state" in + present) + if [[ ! -f "$original_file" ]]; then + msg_error "$(translate "The original MOTD backup is unavailable; no changes were made")" + return 1 + fi + cp -a "$original_file" "$motd_file" + ;; + absent) + rm -f "$motd_file" + ;; + legacy-marker) + if [[ -f "$motd_file" ]]; then + sed -i "\|^${custom_message}$|d" "$motd_file" + sed -i '/./,$!d' "$motd_file" + fi + ;; + *) + msg_error "$(translate "The saved MOTD state is invalid; no changes were made")" + return 1 + ;; + esac + + rm -f "$state_file" "$original_file" + register_tool "motd" false + msg_ok "$(translate "The original MOTD configuration has been restored")" +} + +################################################################ + +uninstall_system_utils() { + local state_file="$BASE_DIR/system_utils.packages" + local remaining_file="${state_file}.remaining.$$" + local package + local packages=() + + if [[ ! -s "$state_file" ]]; then + msg_error "$(translate "No ProxMenux-installed utility package list is available")" + return 1 + fi + + while IFS= read -r package; do + [[ "$package" =~ ^[a-z0-9][a-z0-9+.-]*(:[a-z0-9]+)?$ ]] || continue + packages+=("$package") + done < "$state_file" + + if [[ ${#packages[@]} -eq 0 ]]; then + msg_error "$(translate "The saved utility package list is invalid; no packages were removed")" + return 1 + fi + + msg_info2 "$(translate "Removing utilities installed by ProxMenux...")" + /usr/bin/env DEBIAN_FRONTEND=noninteractive apt-get purge -y "${packages[@]}" >/dev/null 2>&1 || true + + : > "$remaining_file" + for package in "${packages[@]}"; do + if dpkg-query -W -f='${Status}' "$package" 2>/dev/null | grep -q '^install ok installed$'; then + printf '%s\n' "$package" >> "$remaining_file" + fi + done + + if [[ -s "$remaining_file" ]]; then + mv "$remaining_file" "$state_file" + msg_error "$(translate "Some utility packages could not be removed; the remaining list has been preserved")" + return 1 + fi + + rm -f "$remaining_file" "$state_file" + register_tool "system_utils" false + msg_ok "$(translate "Utilities installed by ProxMenux have been removed")" +} + ################################################################ uninstall_time_sync() { @@ -835,7 +947,9 @@ uninstall_ceph() { apt-get purge -y 'ceph-*' 'librados*' 'librbd*' 'libcephfs*' 'python3-ceph*' >/dev/null 2>&1 || true apt-get autoremove -y >/dev/null 2>&1 || true fi - rm -f /etc/apt/sources.list.d/ceph.list /etc/apt/sources.list.d/ceph.sources 2>/dev/null + rm -f /etc/apt/sources.list.d/ceph.list \ + /etc/apt/sources.list.d/ceph-squid.list \ + /etc/apt/sources.list.d/ceph.sources 2>/dev/null rm -f /etc/apt/trusted.gpg.d/ceph.asc /etc/apt/trusted.gpg.d/ceph-release.gpg 2>/dev/null apt-get update -qq >/dev/null 2>&1 || true msg_ok "$(translate 'Ceph packages and repository removed')" @@ -1038,10 +1152,10 @@ show_uninstall_menu() { local menu_options=() for tool in "${tools_installed[@]}"; do case "$tool" in - lvm_repair) desc="LVM PV Headers Repair";; - repo_cleanup) desc="Repository Cleanup";; - #apt_upgrade) desc="APT Upgrade & Repository Config";; subscription_banner) desc="Subscription Banner Removal";; + rpc) desc="RPC / rpcbind Disable";; + motd) desc="Custom MOTD Banner";; + system_utils) desc="System Utilities installed by ProxMenux";; time_sync) desc="Time Synchronization";; apt_languages) desc="APT Language Skip";; journald) desc="Journald Optimization";; diff --git a/scripts/utils.sh b/scripts/utils.sh index 08d1d0b6..fb30c134 100644 --- a/scripts/utils.sh +++ b/scripts/utils.sh @@ -51,6 +51,7 @@ DARK_GRAY="\033[38;5;244m" ORANGE="\033[38;5;208m" YW="\033[33m" YWB="\033[1;33m" +MG="\033[35m" GN="\033[1;92m" RD="\033[01;31m" CL="\033[m" @@ -74,8 +75,8 @@ spinner() { local interval=0.1 printf "\e[?25l" - local color="${YW}" - + local color="${MG}" + while true; do printf "\r ${color}%s${CL}" "${frames[spin_i]}" spin_i=$(( (spin_i + 1) % ${#frames[@]} )) @@ -118,19 +119,10 @@ stop_spinner() { SPINNER_PID="" } -# Display trnaslate message with spinner -msg_lang() { - local msg="$1" - echo -ne "${TAB}${YW}${HOLD}${msg}" - spinner & - SPINNER_PID=$! -} - - # Display info message with spinner msg_info() { local msg="$1" - echo -ne "${TAB}${YW}${HOLD}${msg}" + echo -ne "${TAB}${MG}${HOLD}${msg}" spinner & SPINNER_PID=$! } diff --git a/web/CONTRIBUTING-TRANSLATIONS.md b/web/CONTRIBUTING-TRANSLATIONS.md index d73ccda0..ce9250cb 100644 --- a/web/CONTRIBUTING-TRANSLATIONS.md +++ b/web/CONTRIBUTING-TRANSLATIONS.md @@ -1,10 +1,11 @@ # Contributing translations The ProxMenux documentation site is built with Next.js (App Router) and -serves every page under two URLs: +serves every published page under locale-prefixed URLs: - `/en/` — English, the source of truth -- `/es/` — Spanish, in progress +- `/es/` — Spanish +- `/sk/` — Slovak, with English fallback where needed We use [`next-intl`](https://next-intl.dev) for the i18n plumbing. Anyone can translate the docs without writing TypeScript: most of the work is @@ -20,8 +21,8 @@ filling in a JSON file. This guide explains the workflow end to end. Out of the box you get: -- Routing under `app/[locale]/...` — every page already renders at both - `/en/...` and `/es/...`. +- Routing under `app/[locale]/...` — every page renders for every locale + enabled in `i18n/routing.ts`. - Locale-aware navigation via `@/i18n/navigation` (``, `useRouter`, `usePathname`). Use these instead of `next/link` for internal hrefs so the active `[locale]` prefix is preserved. @@ -50,11 +51,13 @@ web/ │ │ └── docs/ │ │ └── monitor/ │ │ └── index.json # page-specific strings for /docs/monitor -│ └── es/ +│ ├── es/ │ ├── common.json │ └── docs/ │ └── monitor/ │ └── index.json +│ └── sk/ +│ └── ... └── app/[locale]/ └── docs/ └── monitor/ @@ -81,7 +84,7 @@ web/ Browse `app/[locale]/docs/` and find a page that: -- Has no entry yet under `messages/es//` (Spanish), **and** +- Has no entry yet under `messages///`, **and** - Is not already mid-translation by someone else (check open PRs). If you're translating to a new locale, start with the smallest pages so @@ -175,6 +178,45 @@ also had to refactor the `.tsx` (case B) or only added JSON (case A). --- +## Automated baseline and incremental updates + +The `Build web documentation translations` GitHub Action can create a +machine-translated baseline and fill newly added English keys later. It uses +`.github/scripts/build_web_docs_i18n.py` and supports a locale list, a file or +directory scope, a per-locale file limit and a dry run. + +The builder is resumable and writes each completed JSON file atomically. By +default it preserves every non-empty translated value, including wording +reviewed by native speakers. It also protects rich-text tags, placeholders, +URLs, paths, commands, variables and official product names before sending a +string to the translation provider. `--refresh` deliberately overwrites the +selected scope and should only be used when those translations are going to be +reviewed again. + +Run a coverage report without contacting a translation service: + +```bash +python .github/scripts/build_web_docs_i18n.py \ + --languages de,fr,it,pt,sk,sv \ + --section docs \ + --check +``` + +Translate a small resumable batch locally: + +```bash +python .github/scripts/build_web_docs_i18n.py \ + --languages de \ + --section docs/monitor \ + --max-files 5 +``` + +Machine translation is a starting point, not the final authority. Native +contributors can edit the generated JSON normally; later incremental runs +will keep their non-empty wording unchanged. + +--- + ## Workflow: convert a page from hard-coded English to i18n (case B) This is the more involved path. Use the pilot @@ -268,7 +310,12 @@ registers a renderer for them. If you want to add a language that isn't in the project yet: -1. Add the locale code to `routing.ts`: +1. Create `messages//common.json` and the page catalogs. The automated + builder can provide the initial baseline. +2. Review the shared navigation and a representative set of documentation + pages with a native speaker. +3. Add the locale code to `routing.ts` only when the locale is ready to be + exposed publicly: ```ts export const routing = defineRouting({ locales: ["en", "es", "fr"], // add your code here @@ -276,12 +323,9 @@ If you want to add a language that isn't in the project yet: localePrefix: "always", }) ``` -2. Create the `messages//` folder. -3. Copy `messages/en/common.json` over and translate it. **This is - mandatory** — without it the navbar and footer fall back to English - on every page. -4. Start translating individual pages one PR at a time. -5. Mention in your first PR that you're seeding the locale so reviewers +4. Add its human-readable label to the language switcher. +5. Continue reviewing individual pages one PR at a time. +6. Mention in your first PR that you're seeding the locale so reviewers know to expect a follow-up batch. --- @@ -305,9 +349,8 @@ Three common causes: ### What about translations of the Monitor (the AppImage), not just the docs? This guide only covers the **public documentation site** in `web/`. -The Monitor's dashboard UI in `AppImage/` is a separate project and -not currently i18n-enabled. Translating the Monitor would require a -parallel effort. +The Monitor dashboard uses the separate catalogs under +`AppImage/messages/` and its own translation workflow. ### Where can I see what's missing? diff --git a/web/app/[locale]/docs/create-vm/synology/page.tsx b/web/app/[locale]/docs/create-vm/synology/page.tsx index 7d6bf5c5..6ad30d43 100644 --- a/web/app/[locale]/docs/create-vm/synology/page.tsx +++ b/web/app/[locale]/docs/create-vm/synology/page.tsx @@ -51,13 +51,13 @@ function ImageWithCaption({ src, alt, caption }: { src: string; alt: string; cap ) } -function StepNumber({ number }: { number: number }) { +function StepHeading({ number, label, title, id }: { number: number; label: string; title: string; id?: string }) { return ( -