diff --git a/AppImage/scripts/flask_notification_routes.py b/AppImage/scripts/flask_notification_routes.py index 0e2c19cd..2354141d 100644 --- a/AppImage/scripts/flask_notification_routes.py +++ b/AppImage/scripts/flask_notification_routes.py @@ -895,6 +895,20 @@ def _pve_webhook_url() -> str: _PVE_WEBHOOK_URL = _pve_webhook_url() +_PVE_NOT_MOUNTED = 'The Proxmox cluster filesystem (/etc/pve) is not mounted' + + +def _pve_cluster_fs_mounted() -> bool: + """Whether /etc/pve is the Proxmox cluster filesystem. + + While its service is stopped or restarting, /etc/pve is an empty + directory of the root disk. A file written there is not a configuration + file: it keeps the service from mounting the filesystem again. + """ + import os + return os.path.ismount(os.path.dirname(_PVE_NOTIFICATIONS_CFG)) + + def _pve_read_file(path): """Read file, return (content, error). Content is '' if missing.""" try: @@ -909,26 +923,47 @@ def _pve_read_file(path): _PVE_BACKUPS_KEPT = 3 +# Copies of Proxmox's notification settings, taken before changing them. They +# are kept with ProxMenux, not next to the files: /etc/pve belongs to Proxmox +# and is replicated to every node of a cluster. +_PVE_BACKUP_DIR = '/usr/local/share/proxmenux/backups/pve-notifications' +_PVE_BACKUP_MARK = '.proxmenux_backup_' + + +def _pve_backup_prefix(path): + """Name the copies of a file are kept under. The private file carries + its own name, as both are called notifications.cfg.""" + import os + name = os.path.basename(path) + return ('priv-' if path == _PVE_PRIV_CFG else '') + name + _PVE_BACKUP_MARK + + +def _pve_backups(path): + """The copies kept of a file, newest first.""" + import os + prefix = _pve_backup_prefix(path) + try: + names = os.listdir(_PVE_BACKUP_DIR) + except OSError: + return [] + found = [os.path.join(_PVE_BACKUP_DIR, name) for name in names if name.startswith(prefix)] + return sorted(found, key=os.path.getmtime, reverse=True) def _pve_backup_file(path): - """Create timestamped backup if file exists. Never fails fatally. + """Keep a timestamped copy of the file if it exists. Never fails fatally. - The backups live next to the file, inside the cluster filesystem, whose - size is limited: a copy identical to the newest one is not written again - and only the newest few are kept. Restore uses the newest. + A copy identical to the newest one is not written again and only the + newest few are kept. Restore uses the newest. """ import os, shutil from datetime import datetime try: if not os.path.exists(path): return - directory, name = os.path.split(path) - prefix = f"{name}.proxmenux_backup_" - existing = sorted( - (os.path.join(directory, f) for f in os.listdir(directory) if f.startswith(prefix)), - key=os.path.getmtime, reverse=True, - ) + os.makedirs(_PVE_BACKUP_DIR, mode=0o700, exist_ok=True) + os.chmod(_PVE_BACKUP_DIR, 0o700) + existing = _pve_backups(path) with open(path, 'rb') as f: current = f.read() newest_matches = False @@ -940,16 +975,48 @@ def _pve_backup_file(path): pass if not newest_matches: ts = datetime.now().strftime('%Y%m%d_%H%M%S') - shutil.copy2(path, os.path.join(directory, prefix + ts)) - existing = sorted( - (os.path.join(directory, f) for f in os.listdir(directory) if f.startswith(prefix)), - key=os.path.getmtime, reverse=True, - ) - for stale in existing[_PVE_BACKUPS_KEPT:]: - try: - os.remove(stale) - except OSError: - pass + destination = os.path.join(_PVE_BACKUP_DIR, _pve_backup_prefix(path) + ts) + shutil.copy2(path, destination) + # The private file holds the secrets of every notification target. + os.chmod(destination, 0o600) + _pve_prune_backups(path) + except Exception: + pass + + +def _pve_prune_backups(path): + """Keep only the newest copies of a file.""" + import os + for stale in _pve_backups(path)[_PVE_BACKUPS_KEPT:]: + try: + os.remove(stale) + except OSError: + pass + + +def _pve_collect_legacy_backups(path): + """Earlier versions left the copies next to the file, inside /etc/pve. + The newest ones are moved with the rest and none is left there.""" + import os, shutil + try: + directory, name = os.path.split(path) + legacy = sorted( + (os.path.join(directory, f) for f in os.listdir(directory) if f.startswith(name + _PVE_BACKUP_MARK)), + key=os.path.getmtime, reverse=True, + ) + if not legacy: + return + os.makedirs(_PVE_BACKUP_DIR, mode=0o700, exist_ok=True) + os.chmod(_PVE_BACKUP_DIR, 0o700) + for position, source in enumerate(legacy): + if position < _PVE_BACKUPS_KEPT: + stamp = os.path.basename(source)[len(name + _PVE_BACKUP_MARK):] + destination = os.path.join(_PVE_BACKUP_DIR, _pve_backup_prefix(path) + stamp) + if not os.path.exists(destination): + shutil.copy2(source, destination) + os.chmod(destination, 0o600) + os.remove(source) + _pve_prune_backups(path) except Exception: pass @@ -1091,6 +1158,11 @@ def setup_pve_webhook_core() -> dict: } try: + if not _pve_cluster_fs_mounted(): + result['error'] = _PVE_NOT_MOUNTED + result['waiting'] = True + return result + # ── Step 1: Ensure webhook secret exists (for our own internal use) ── secret = notification_manager.get_webhook_secret() if not secret: @@ -1109,11 +1181,10 @@ def setup_pve_webhook_core() -> dict: if err: priv_text = None - # ── Step 4: Create backups before ANY modification ── - _pve_backup_file(_PVE_NOTIFICATIONS_CFG) - if priv_text is not None: - _pve_backup_file(_PVE_PRIV_CFG) - + # ── Step 4: Backups are taken below, only of a file about to change ── + _pve_collect_legacy_backups(_PVE_NOTIFICATIONS_CFG) + _pve_collect_legacy_backups(_PVE_PRIV_CFG) + # ── Step 5: Remove any previous proxmenux blocks from BOTH files ── cleaned_cfg = _pve_remove_our_blocks(cfg_text, _PVE_OUR_HEADERS) @@ -1161,10 +1232,12 @@ def setup_pve_webhook_core() -> dict: new_cfg = cleaned_cfg + endpoint_block + '\n' + matcher_block - # ── Step 8: Write main config ── + # ── Step 8: Write main config, when it is not already as wanted ── try: - with open(_PVE_NOTIFICATIONS_CFG, 'w') as f: - f.write(new_cfg) + if new_cfg != cfg_text: + _pve_backup_file(_PVE_NOTIFICATIONS_CFG) + with open(_PVE_NOTIFICATIONS_CFG, 'w') as f: + f.write(new_cfg) except PermissionError: result['error'] = f'Permission denied writing {_PVE_NOTIFICATIONS_CFG}' result['fallback_commands'] = _build_webhook_fallback() @@ -1214,8 +1287,11 @@ def setup_pve_webhook_core() -> dict: new_priv = priv_block try: - with open(_PVE_PRIV_CFG, 'w') as f: - f.write(new_priv) + if new_priv != priv_text: + if priv_text is not None: + _pve_backup_file(_PVE_PRIV_CFG) + with open(_PVE_PRIV_CFG, 'w') as f: + f.write(new_priv) except PermissionError: result['error'] = f'Permission denied writing {_PVE_PRIV_CFG}' result['fallback_commands'] = _build_webhook_fallback() @@ -1238,6 +1314,17 @@ def setup_pve_webhook_core() -> dict: return result +def setup_pve_webhook_when_mounted(timeout=900, interval=5) -> dict: + """Configure the webhook once /etc/pve is mounted. For the Monitor that + starts while the cluster service is still starting or being restarted.""" + deadline = time.monotonic() + timeout + while not _pve_cluster_fs_mounted(): + if time.monotonic() >= deadline: + return {'configured': False, 'error': _PVE_NOT_MOUNTED, 'waiting': True} + time.sleep(interval) + return setup_pve_webhook_core() + + @notification_bp.route('/api/notifications/proxmox/setup-webhook', methods=['POST']) @require_auth def setup_proxmox_webhook(): @@ -1254,6 +1341,10 @@ def cleanup_pve_webhook_core() -> dict: result = {'cleaned': False, 'error': None} try: + if not _pve_cluster_fs_mounted(): + result['error'] = _PVE_NOT_MOUNTED + return result + # Read both files cfg_text, err = _pve_read_file(_PVE_NOTIFICATIONS_CFG) if err: @@ -1336,10 +1427,6 @@ def read_pve_notification_cfg(): 'priv_cfg': '/etc/pve/priv/notifications.cfg', } - # Also look for any backups we created - backup_dir = '/etc/pve' - priv_backup_dir = '/etc/pve/priv' - result = {} for key, path in files.items(): try: @@ -1357,24 +1444,18 @@ def read_pve_notification_cfg(): except Exception as e: result[key] = {'path': path, 'content': None, 'size': 0, 'error': str(e)} - # Find backups + # The copies kept of both files backups = [] - for d in [backup_dir, priv_backup_dir]: + for fpath in _pve_backups(_PVE_NOTIFICATIONS_CFG) + _pve_backups(_PVE_PRIV_CFG): try: - for fname in sorted(os.listdir(d)): - if 'proxmenux_backup' in fname: - fpath = os.path.join(d, fname) - try: - with open(fpath, 'r') as f: - backups.append({ - 'path': fpath, - 'content': f.read(), - 'size': os.path.getsize(fpath), - }) - except Exception: - backups.append({'path': fpath, 'content': None, 'error': 'read_failed'}) + with open(fpath, 'r') as f: + backups.append({ + 'path': fpath, + 'content': f.read(), + 'size': os.path.getsize(fpath), + }) except Exception: - pass + backups.append({'path': fpath, 'content': None, 'error': 'read_failed'}) result['backups'] = backups return jsonify(result), 200 @@ -1391,35 +1472,26 @@ def restore_pve_notification_cfg(): import os import shutil - files_to_restore = { - '/etc/pve': '/etc/pve/notifications.cfg', - '/etc/pve/priv': '/etc/pve/priv/notifications.cfg', - } - restored = [] errors = [] - - for search_dir, target_path in files_to_restore.items(): - try: - # Pick the most recent backup by mtime, not lexicographic name. - # An attacker (or accidental rename) with a write primitive - # could craft `notifications.cfg.proxmenux_backup_99999999_999999` - # and have it sort first, hijacking the restore. mtime tracks - # the actual file age so renamed/touched files don't fool us. - # Audit Tier 3.1 — restore-cfg sort lexicográfico. - candidates = [ - f for f in os.listdir(search_dir) - if 'proxmenux_backup' in f and f.startswith('notifications.cfg') - ] + if not _pve_cluster_fs_mounted(): + return jsonify({'restored': [], 'errors': [{'target': '/etc/pve', 'error': _PVE_NOT_MOUNTED}], + 'success': False}), 200 + + for target_path in (_PVE_NOTIFICATIONS_CFG, _PVE_PRIV_CFG): + try: + _pve_collect_legacy_backups(target_path) + # The newest by modification time: a name can be crafted to sort + # first, a file's age cannot. + candidates = _pve_backups(target_path) if candidates: - candidates.sort( - key=lambda f: os.path.getmtime(os.path.join(search_dir, f)), - reverse=True, - ) - backup_path = os.path.join(search_dir, candidates[0]) - shutil.copy2(backup_path, target_path) - restored.append({'target': target_path, 'from_backup': backup_path}) + # Content only: the cluster filesystem sets its own permissions. + with open(candidates[0], 'rb') as source: + content = source.read() + with open(target_path, 'wb') as target: + target.write(content) + restored.append({'target': target_path, 'from_backup': candidates[0]}) else: errors.append({'target': target_path, 'error': 'no_backup_found'}) except Exception as e: diff --git a/AppImage/scripts/notification_manager.py b/AppImage/scripts/notification_manager.py index 9a19bf63..682d76e9 100644 --- a/AppImage/scripts/notification_manager.py +++ b/AppImage/scripts/notification_manager.py @@ -1141,6 +1141,11 @@ class NotificationManager: wh_result = setup_pve_webhook_core() if wh_result.get('configured'): print("[NotificationManager] PVE webhook configured OK.") + elif wh_result.get('waiting'): + # /etc/pve is not mounted yet: nothing is written until it is. + print("[NotificationManager] PVE webhook: waiting for /etc/pve to be mounted.") + threading.Thread(target=self._configure_pve_webhook_when_mounted, daemon=True, + name='pve-webhook-setup').start() elif wh_result.get('error'): print(f"[NotificationManager] PVE webhook warning: {wh_result['error']}") except ImportError: @@ -2285,6 +2290,18 @@ class NotificationManager: 'auth_fail', ) + def _configure_pve_webhook_when_mounted(self): + """Configure the PVE webhook once the cluster filesystem is back.""" + try: + from flask_notification_routes import setup_pve_webhook_when_mounted + result = setup_pve_webhook_when_mounted() + if result.get('configured'): + print("[NotificationManager] PVE webhook configured OK.") + elif result.get('error'): + print(f"[NotificationManager] PVE webhook warning: {result['error']}") + except Exception as e: + print(f"[NotificationManager] PVE webhook setup error: {e}") + def _reset_cooldowns_on_start(self): """Clear DB rows in notification_last_sent for the curated set of event types listed in `_EVENT_TYPES_RESET_ON_START`. diff --git a/AppImage/scripts/tests/test_pve_notifications_cfg_writes.py b/AppImage/scripts/tests/test_pve_notifications_cfg_writes.py new file mode 100644 index 00000000..b1ec8e35 --- /dev/null +++ b/AppImage/scripts/tests/test_pve_notifications_cfg_writes.py @@ -0,0 +1,170 @@ +"""Proxmox's notification files are only written while /etc/pve is the +mounted cluster filesystem, and only when their content has to change.""" +import ast +import os +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +SCRIPTS = Path(__file__).resolve().parents[1] +SOURCE = SCRIPTS / 'flask_notification_routes.py' +NAMES = {'_pve_cluster_fs_mounted', '_pve_read_file', '_pve_backup_file', '_pve_prune_backups', '_pve_backup_prefix', + '_pve_backups', '_pve_collect_legacy_backups', 'restore_pve_notification_cfg', '_pve_remove_our_blocks', '_pve_webhook_url', + '_build_webhook_fallback', 'setup_pve_webhook_core', 'cleanup_pve_webhook_core', 'setup_pve_webhook_when_mounted'} +ASSIGNED = {'_PVE_ENDPOINT_ID', '_PVE_MATCHER_ID', '_PVE_OUR_HEADERS', '_PVE_NOT_MOUNTED', '_PVE_BACKUPS_KEPT', + '_PVE_HEADER_RE', 'WEBHOOK_LOOPBACK_PORT', '_PVE_BACKUP_MARK'} + + +class FakeManager: + def __init__(self): + self.secret = 'c2VjcmV0LXRva2Vu' + + def get_webhook_secret(self): + return self.secret + + def _save_setting(self, _key, value): + self.secret = value + + +def load(directory): + """The functions under test, pointed at a directory standing for /etc/pve.""" + body = [] + for node in ast.parse(SOURCE.read_text()).body: + if isinstance(node, ast.FunctionDef) and node.name in NAMES: + node.decorator_list = [] + body.append(node) + elif isinstance(node, ast.Assign) and any(getattr(t, 'id', None) in ASSIGNED for t in node.targets): + body.append(node) + elif isinstance(node, ast.Import) and any(a.asname == '_re_pve_cfg' for a in node.names): + body.append(node) + import time + scope = {'notification_manager': FakeManager(), 'time': time, 'jsonify': lambda value: value, + '_PVE_NOTIFICATIONS_CFG': str(directory / 'notifications.cfg'), + '_PVE_PRIV_CFG': str(directory / 'priv' / 'notifications.cfg'), + '_PVE_BACKUP_DIR': str(directory.parent / 'proxmenux' / 'backups' / 'pve-notifications')} + exec(compile(ast.fix_missing_locations(ast.Module(body=body, type_ignores=[])), str(SOURCE), 'exec'), scope) + return scope + + +class PveNotificationWriteTests(unittest.TestCase): + def setUp(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.pve = Path(tmp.name) / 'pve' + self.pve.mkdir() + self.scope = load(self.pve) + self.kept = Path(self.scope['_PVE_BACKUP_DIR']) + self.mounted = True + patcher = mock.patch('os.path.ismount', side_effect=lambda path: self.mounted and path == str(self.pve)) + patcher.start() + self.addCleanup(patcher.stop) + + def files(self): + return sorted(str(p.relative_to(self.pve)) for p in self.pve.rglob('*') if p.is_file()) + + def test_nothing_is_written_while_the_cluster_filesystem_is_down(self): + # The bare directory of the root disk: empty, and it has to stay so. + self.mounted = False + result = self.scope['setup_pve_webhook_core']() + self.assertFalse(result['configured']) + self.assertTrue(result['waiting']) + self.assertEqual(self.scope['cleanup_pve_webhook_core']()['error'], self.scope['_PVE_NOT_MOUNTED']) + self.assertEqual(list(self.pve.iterdir()), []) + + def test_the_webhook_is_written_once_and_not_again(self): + (self.pve / 'priv').mkdir() + existing = 'sendmail: mail-to-root\n\tcomment Send mails to root@pam\n\tmailto-user root@pam\n' + (self.pve / 'notifications.cfg').write_text(existing) + (self.pve / 'priv' / 'notifications.cfg').write_text('') + self.assertTrue(self.scope['setup_pve_webhook_core']()['configured']) + written = (self.pve / 'notifications.cfg').read_text() + self.assertTrue(written.startswith(existing)) + self.assertIn('webhook: proxmenux-webhook\n', written) + self.assertIn('matcher: proxmenux-default\n', written) + first = {name: (self.pve / name).stat().st_mtime_ns for name in self.files()} + # The copies taken before the change are kept with ProxMenux, not in /etc/pve. + self.assertEqual(sorted(first), ['notifications.cfg', 'priv/notifications.cfg']) + copies = sorted(p.name.split('.proxmenux_backup_')[0] for p in self.kept.iterdir()) + self.assertEqual(copies, ['notifications.cfg', 'priv-notifications.cfg']) + self.assertEqual({oct(p.stat().st_mode & 0o777) for p in self.kept.iterdir()}, {'0o600'}) + self.assertEqual(oct(self.kept.stat().st_mode & 0o777), '0o700') + # A second start finds both files as wanted: no write, no new backup. + os.utime(self.pve / 'notifications.cfg', ns=(1, 1)) + os.utime(self.pve / 'priv' / 'notifications.cfg', ns=(1, 1)) + self.assertTrue(self.scope['setup_pve_webhook_core']()['configured']) + self.assertEqual(self.files(), sorted(first)) + self.assertEqual((self.pve / 'notifications.cfg').stat().st_mtime_ns, 1) + self.assertEqual((self.pve / 'priv' / 'notifications.cfg').stat().st_mtime_ns, 1) + + def test_copies_left_in_etc_pve_by_an_earlier_version_are_collected_without_a_write(self): + (self.pve / 'priv').mkdir() + (self.pve / 'priv' / 'notifications.cfg').write_text('') + (self.pve / 'notifications.cfg').write_text('') + self.scope['setup_pve_webhook_core']() + for item in self.kept.iterdir(): + item.unlink() + for index in range(40): + for folder in (self.pve, self.pve / 'priv'): + stale = folder / f'notifications.cfg.proxmenux_backup_20260101_{index:06d}' + stale.write_text(f'old {index}') + os.utime(stale, ns=(index * 10 ** 9, index * 10 ** 9)) + current = (self.pve / 'notifications.cfg').read_text() + self.assertTrue(self.scope['setup_pve_webhook_core']()['configured']) + self.assertEqual(self.files(), ['notifications.cfg', 'priv/notifications.cfg']) + self.assertEqual((self.pve / 'notifications.cfg').read_text(), current) + kept = sorted(p.name for p in self.kept.iterdir()) + self.assertEqual(kept, [f'notifications.cfg.proxmenux_backup_20260101_{i:06d}' for i in (37, 38, 39)] + + [f'priv-notifications.cfg.proxmenux_backup_20260101_{i:06d}' for i in (37, 38, 39)]) + self.assertEqual((self.kept / kept[2]).read_text(), 'old 39') + + def test_restore_takes_the_newest_copy_of_each_file(self): + (self.pve / 'priv').mkdir() + (self.pve / 'notifications.cfg').write_text('before\n') + (self.pve / 'priv' / 'notifications.cfg').write_text('private before\n') + self.scope['setup_pve_webhook_core']() + answer, _status = self.scope['restore_pve_notification_cfg']() + self.assertTrue(answer['success']) + self.assertEqual((self.pve / 'notifications.cfg').read_text(), 'before\n') + self.assertEqual((self.pve / 'priv' / 'notifications.cfg').read_text(), 'private before\n') + self.mounted = False + answer, _status = self.scope['restore_pve_notification_cfg']() + self.assertFalse(answer['success']) + + def test_uninstalling_the_monitor_removes_its_target_from_proxmox(self): + menu = (SCRIPTS.parents[1] / 'scripts/menus/config_menu.sh').read_text() + body = menu[menu.index('remove_monitor_notification_target() {'):menu.index('uninstall_proxmenux_monitor() {')] + self.assertIn('mountpoint -q /etc/pve || return 0', body) + # The rule first: Proxmox does not delete a target that a rule still uses. + self.assertLess(body.index('matchers/proxmenux-default'), body.index('endpoints/webhook/proxmenux-webhook')) + self.assertIn('/etc/pve/priv/notifications.cfg.proxmenux_backup_*', body) + uninstall = menu[menu.index('uninstall_proxmenux_monitor() {'):menu.index('check_monitor_status() {')] + self.assertIn('remove_monitor_notification_target', uninstall) + + def test_a_start_during_a_restart_waits_for_the_mount(self): + self.mounted = False + (self.pve / 'priv').mkdir() + + def back(_seconds): + self.mounted = True + with mock.patch('time.sleep', side_effect=back): + result = self.scope['setup_pve_webhook_when_mounted'](timeout=60, interval=1) + self.assertTrue(result['configured']) + self.assertIn('webhook: proxmenux-webhook', (self.pve / 'notifications.cfg').read_text()) + + def test_the_wait_ends_without_writing(self): + self.mounted = False + result = self.scope['setup_pve_webhook_when_mounted'](timeout=0, interval=0) + self.assertTrue(result['waiting']) + self.assertEqual(list(self.pve.iterdir()), []) + + def test_the_monitor_starts_after_the_cluster_service(self): + root = SCRIPTS.parents[1] + for name in ('install_proxmenux.sh', 'install_proxmenux_beta.sh', 'scripts/menus/config_menu.sh'): + text = (root / name).read_text() + self.assertIn('After=network.target pve-cluster.service', text, name) + self.assertNotIn('After=network.target\n\n[Service]\nType=simple\nUser=root\nWorkingDirectory=$MONITOR', text, name) + + +if __name__ == '__main__': + unittest.main() diff --git a/install_proxmenux.sh b/install_proxmenux.sh index 883b06ca..e2aee65a 100755 --- a/install_proxmenux.sh +++ b/install_proxmenux.sh @@ -623,7 +623,7 @@ _proxmenux_rewrite_monitor_unit_for_apprun() { cat > "$MONITOR_SERVICE_FILE" << EOF [Unit] Description=ProxMenux Monitor - Web Dashboard -After=network.target +After=network.target pve-cluster.service [Service] Type=simple @@ -655,7 +655,7 @@ create_monitor_service() { cat > "$MONITOR_SERVICE_FILE" << EOF [Unit] Description=ProxMenux Monitor - Web Dashboard -After=network.target +After=network.target pve-cluster.service [Service] Type=simple diff --git a/install_proxmenux_beta.sh b/install_proxmenux_beta.sh index 3171de4e..90022cd2 100644 --- a/install_proxmenux_beta.sh +++ b/install_proxmenux_beta.sh @@ -496,7 +496,7 @@ update_monitor_service() { cat > "$MONITOR_SERVICE_FILE" << EOF [Unit] Description=ProxMenux Monitor - Web Dashboard (Beta) -After=network.target +After=network.target pve-cluster.service Before=shutdown.target reboot.target halt.target Conflicts=shutdown.target reboot.target halt.target @@ -535,7 +535,7 @@ create_monitor_service() { cat > "$MONITOR_SERVICE_FILE" << EOF [Unit] Description=ProxMenux Monitor - Web Dashboard (Beta) -After=network.target +After=network.target pve-cluster.service Before=shutdown.target reboot.target halt.target Conflicts=shutdown.target reboot.target halt.target diff --git a/scripts/menus/config_menu.sh b/scripts/menus/config_menu.sh index dc06268f..1cf1cdef 100644 --- a/scripts/menus/config_menu.sh +++ b/scripts/menus/config_menu.sh @@ -71,6 +71,17 @@ BACKTITLE="$(translate "$BACKTITLE")" # ========================================================== +# Proxmox would keep sending every notification to a Monitor that is gone. +# Its own interface removes the target and the rule that feeds it; the copies +# of the settings that earlier versions left in /etc/pve are removed too. +remove_monitor_notification_target() { + mountpoint -q /etc/pve || return 0 + pvesh delete /cluster/notifications/matchers/proxmenux-default > /dev/null 2>&1 || true + pvesh delete /cluster/notifications/endpoints/webhook/proxmenux-webhook > /dev/null 2>&1 || true + rm -f /etc/pve/notifications.cfg.proxmenux_backup_* /etc/pve/priv/notifications.cfg.proxmenux_backup_* + echo " - Notification target removed from Proxmox" +} + uninstall_proxmenux_monitor() { # 1. Stop service if it is running @@ -81,6 +92,8 @@ uninstall_proxmenux_monitor() { echo " - Service is not running (ok)" fi + remove_monitor_notification_target + # 2. Disable service if enabled if systemctl is-enabled --quiet "${MONITOR_SERVICE}"; then echo " - Disabling service..." @@ -213,7 +226,7 @@ normalize_stable_monitor_service() { cat > "$MONITOR_UNIT_FILE" << EOF [Unit] Description=ProxMenux Monitor - Web Dashboard -After=network.target +After=network.target pve-cluster.service [Service] Type=simple diff --git a/web/messages/en/docs/monitor/notifications.json b/web/messages/en/docs/monitor/notifications.json index d6fd4b41..656991a1 100644 --- a/web/messages/en/docs/monitor/notifications.json +++ b/web/messages/en/docs/monitor/notifications.json @@ -342,7 +342,7 @@ "practiceBody": "The PVE setup writes the target as http://127.0.0.1:8008, so PVE-emitted notifications always go through the loopback path with rate-limit-only security. The remote-caller path with the shared secret is opt-in for custom integrations — point an external service at https://<monitor-host>:<port>/api/notifications/webhook and supply the X-Webhook-Secret header to use it.", "actionsIntro": "The Monitor manages this target through three actions on the Settings tab:", "actionsItems": [ - "Setup — runs automatically when you enable Notifications. Creates the entry in /etc/pve/notifications.cfg after backing up the current file.", + "Setup — runs automatically when you enable Notifications. Creates the entry in /etc/pve/notifications.cfg. The file is written only when its content has to change, and a copy of the previous one is kept in /usr/local/share/proxmenux/backups/pve-notifications/ (the three newest). Nothing is written while /etc/pve is not mounted: a Monitor that starts during a restart of pve-cluster registers the entry once it is back.", "Cleanup — removes the entry. The previous backup of the file is kept.", "Read config — shows the current targets and matchers as PVE sees them. This is how you confirm the Monitor's entry is the one firing when PVE has multiple notification routes configured." ], diff --git a/web/messages/en/docs/settings/uninstall-proxmenux.json b/web/messages/en/docs/settings/uninstall-proxmenux.json index 3a1dda30..ff6433b6 100644 --- a/web/messages/en/docs/settings/uninstall-proxmenux.json +++ b/web/messages/en/docs/settings/uninstall-proxmenux.json @@ -19,7 +19,7 @@ "items": [ "Confirmation dialog. Yes/no — “Are you sure you want to uninstall ProxMenux?”", "Dependency removal checklist. Pre-selected list depends on install type (see table below). All boxes default to OFF — you opt in to removing each dependency.", - "Removal runs with a progress bar. Removes the venv (Translation install only), the menu launcher, the /usr/local/share/proxmenux/ tree, the ProxMenux Monitor service if present, the dependencies you ticked, and finally restores the system file backups (see below).", + "Removal runs with a progress bar. Removes the venv (Translation install only), the menu launcher, the /usr/local/share/proxmenux/ tree, the ProxMenux Monitor service if present together with the notification target it registered in Proxmox VE (proxmenux-webhook and its matcher), the dependencies you ticked, and finally restores the system file backups (see below).", "Completion dialog. Lists removed dependencies and exits." ] }, diff --git a/web/messages/es/docs/monitor/notifications.json b/web/messages/es/docs/monitor/notifications.json index 8dec0844..a0d409ca 100644 --- a/web/messages/es/docs/monitor/notifications.json +++ b/web/messages/es/docs/monitor/notifications.json @@ -342,7 +342,7 @@ "practiceBody": "El setup de PVE escribe el destino como http://127.0.0.1:8008, así que las notificaciones emitidas por PVE siempre pasan por el camino loopback con seguridad solo rate-limit. El camino de llamador remoto con shared secret es opt-in para integraciones personalizadas — apunta un servicio externo a https://<host-monitor>:<port>/api/notifications/webhook y suministra la cabecera X-Webhook-Secret para usarlo.", "actionsIntro": "El Monitor gestiona este destino a través de tres acciones en la pestaña Settings:", "actionsItems": [ - "Setup — corre automáticamente cuando activas Notifications. Crea la entrada en /etc/pve/notifications.cfg tras hacer backup del archivo actual.", + "Setup — corre automáticamente cuando activas Notifications. Crea la entrada en /etc/pve/notifications.cfg. El archivo solo se escribe cuando su contenido tiene que cambiar, y se guarda un backup del anterior en /usr/local/share/proxmenux/backups/pve-notifications/ (los tres más recientes). No se escribe nada mientras /etc/pve no está montado: un Monitor que arranca durante un reinicio de pve-cluster registra la entrada cuando vuelve.", "Cleanup — elimina la entrada. El backup previo del archivo se conserva.", "Read config — muestra los destinos y matchers actuales como PVE los ve. Así es como confirmas que la entrada del Monitor es la que dispara cuando PVE tiene múltiples rutas de notificación configuradas." ], diff --git a/web/messages/es/docs/settings/uninstall-proxmenux.json b/web/messages/es/docs/settings/uninstall-proxmenux.json index 079c820d..a799f687 100644 --- a/web/messages/es/docs/settings/uninstall-proxmenux.json +++ b/web/messages/es/docs/settings/uninstall-proxmenux.json @@ -19,7 +19,7 @@ "items": [ "Diálogo de confirmación. Sí/no — \"¿Seguro que quieres desinstalar ProxMenux?\"", "Checklist de eliminación de dependencias. La lista preseleccionada depende del tipo de instalación (mira la tabla de abajo). Todas las casillas por defecto en OFF — te apuntas a eliminar cada dependencia.", - "La eliminación corre con una barra de progreso. Elimina el venv (solo instalación con traducción), el lanzador menu, el árbol /usr/local/share/proxmenux/, el servicio ProxMenux Monitor si está presente, las dependencias que marcaste y, por último, restaura los backups de archivos del sistema (mira abajo).", + "La eliminación corre con una barra de progreso. Elimina el venv (solo instalación con traducción), el lanzador menu, el árbol /usr/local/share/proxmenux/, el servicio ProxMenux Monitor si está presente junto con el destino de notificaciones que registró en Proxmox VE (proxmenux-webhook y su matcher), las dependencias que marcaste y, por último, restaura los backups de archivos del sistema (mira abajo).", "Diálogo de finalización. Lista las dependencias eliminadas y sale." ] },