diff --git a/AppImage/components/notification-settings.tsx b/AppImage/components/notification-settings.tsx index acda234c..396387b8 100644 --- a/AppImage/components/notification-settings.tsx +++ b/AppImage/components/notification-settings.tsx @@ -416,25 +416,34 @@ export function NotificationSettings() {

- Run on the PBS host: + Add to /etc/proxmox-backup/notifications.cfg on the PBS host:

-{`# Create webhook endpoint on PBS
-proxmox-backup-manager notification endpoint webhook create proxmenux-webhook \\
-  --url http://:8008/api/notifications/webhook \\
-  --header "X-Webhook-Secret="
+{`webhook: proxmenux-webhook
+\turl http://:8008/api/notifications/webhook
+\tmethod post
+\theader Content-Type:application/json
+\theader X-Webhook-Secret:{{ secrets.proxmenux_secret }}
 
-# Create matcher to route PBS events
-proxmox-backup-manager notification matcher create proxmenux-pbs \\
-  --target proxmenux-webhook \\
-  --match-severity warning,error`}
+matcher: proxmenux-pbs
+\ttarget proxmenux-webhook
+\tmatch-severity warning,error`}
+                  
+
+
+

+ Add to /etc/proxmox-backup/notifications-priv.cfg: +

+
+{`webhook: proxmenux-webhook
+\tsecret proxmenux_secret `}
                   

- {"Replace with the IP address of this PVE node (not 127.0.0.1, unless PBS runs on the same host)."} + {"Replace with the IP of this PVE node (not 127.0.0.1, unless PBS runs on the same host)."}

{"Replace with the webhook secret shown in your notification settings."} @@ -1098,17 +1107,26 @@ proxmox-backup-manager notification matcher create proxmenux-pbs \\ Backups launched from PVE are covered by the PVE webhook. PBS internal jobs (Verify, Prune, GC, Sync) require separate configuration on the PBS server.

+

+ Add to /etc/proxmox-backup/notifications.cfg: +

-{`# On the PBS host:
-proxmox-backup-manager notification endpoint webhook \\
-  create proxmenux-webhook \\
-  --url http://:8008/api/notifications/webhook \\
-  --header "X-Webhook-Secret="
+{`webhook: proxmenux-webhook
+\turl http://:8008/api/notifications/webhook
+\tmethod post
+\theader Content-Type:application/json
+\theader X-Webhook-Secret:{{ secrets.proxmenux_secret }}
 
-proxmox-backup-manager notification matcher \\
-  create proxmenux-pbs \\
-  --target proxmenux-webhook \\
-  --match-severity warning,error`}
+matcher: proxmenux-pbs
+\ttarget proxmenux-webhook
+\tmatch-severity warning,error`}
+                  
+

+ Add to /etc/proxmox-backup/notifications-priv.cfg: +

+
+{`webhook: proxmenux-webhook
+\tsecret proxmenux_secret `}
                   

{"Replace with this node's IP and with the webhook secret above."} diff --git a/AppImage/scripts/flask_notification_routes.py b/AppImage/scripts/flask_notification_routes.py index 065bbf6e..fb5563a5 100644 --- a/AppImage/scripts/flask_notification_routes.py +++ b/AppImage/scripts/flask_notification_routes.py @@ -161,16 +161,21 @@ def send_notification(): def setup_proxmox_webhook(): """Automatically configure PVE notifications to call our webhook. - Idempotent: safe to call multiple times. Only creates/updates - ProxMenux-owned objects (proxmenux-webhook endpoint, proxmenux-default matcher). - Never deletes or overrides user notification targets. + Writes directly to /etc/pve/notifications.cfg (cluster filesystem) + instead of using pvesh, which has complex property-string formats + for headers and secrets that vary between PVE versions. + + Idempotent: safe to call multiple times. + Only creates/updates ProxMenux-owned objects. """ - import subprocess + import re import secrets as secrets_mod ENDPOINT_ID = 'proxmenux-webhook' MATCHER_ID = 'proxmenux-default' WEBHOOK_URL = 'http://127.0.0.1:8008/api/notifications/webhook' + NOTIFICATIONS_CFG = '/etc/pve/notifications.cfg' + PRIV_CFG = '/etc/pve/priv/notifications.cfg' result = { 'configured': False, @@ -181,111 +186,152 @@ def setup_proxmox_webhook(): 'error': None, } - def _run_pvesh(args: list, check: bool = True) -> tuple: - """Run pvesh command. Returns (success, stdout, stderr).""" - try: - proc = subprocess.run( - ['pvesh'] + args, - capture_output=True, text=True, timeout=15 - ) - return proc.returncode == 0, proc.stdout.strip(), proc.stderr.strip() - except FileNotFoundError: - return False, '', 'pvesh not found' - except subprocess.TimeoutExpired: - return False, '', 'pvesh timed out' - except Exception as e: - return False, '', str(e) + def _build_fallback(secret_val): + """Build manual instructions as fallback.""" + return [ + "# Add to /etc/pve/notifications.cfg:", + f"webhook: {ENDPOINT_ID}", + f"\turl {WEBHOOK_URL}", + "\tmethod post", + f"\theader Content-Type:application/json", + f"\theader X-Webhook-Secret:{{{{ secrets.proxmenux_secret }}}}", + "", + "# Add to /etc/pve/priv/notifications.cfg:", + f"webhook: {ENDPOINT_ID}", + f"\tsecret proxmenux_secret {secret_val}", + "", + "# Also add a matcher block to /etc/pve/notifications.cfg:", + f"matcher: {MATCHER_ID}", + f"\ttarget {ENDPOINT_ID}", + "\tmatch-severity warning,error", + ] try: - # Step 1: Ensure webhook secret exists + # ── Step 1: Ensure webhook secret exists ── secret = notification_manager.get_webhook_secret() if not secret: secret = secrets_mod.token_urlsafe(32) notification_manager._save_setting('webhook_secret', secret) - secret_header = f'X-Webhook-Secret={secret}' - - # Step 2: Check if endpoint already exists - exists_ok, _, _ = _run_pvesh([ - 'get', f'/cluster/notifications/endpoints/webhook/{ENDPOINT_ID}', - '--output-format', 'json' - ]) - - if exists_ok: - # Update existing endpoint - ok, _, err = _run_pvesh([ - 'set', f'/cluster/notifications/endpoints/webhook/{ENDPOINT_ID}', - '--url', WEBHOOK_URL, - '--method', 'post', - '--header', secret_header, - ]) - else: - # Create new endpoint - ok, _, err = _run_pvesh([ - 'create', '/cluster/notifications/endpoints/webhook', - '--name', ENDPOINT_ID, - '--url', WEBHOOK_URL, - '--method', 'post', - '--header', secret_header, - ]) - - if not ok: - # Build fallback commands for manual execution - result['fallback_commands'] = [ - f'pvesh create /cluster/notifications/endpoints/webhook ' - f'--name {ENDPOINT_ID} --url {WEBHOOK_URL} --method post ' - f'--header "{secret_header}"', - f'pvesh create /cluster/notifications/matchers ' - f'--name {MATCHER_ID} --target {ENDPOINT_ID} ' - f'--match-severity warning,error', - ] - result['error'] = f'Failed to configure endpoint: {err}' + # ── Step 2: Read current config ── + try: + with open(NOTIFICATIONS_CFG, 'r') as f: + cfg_text = f.read() + except FileNotFoundError: + cfg_text = '' + except PermissionError: + result['error'] = f'Permission denied reading {NOTIFICATIONS_CFG}' + result['fallback_commands'] = _build_fallback(secret) return jsonify(result), 200 - # Step 3: Create or update matcher - matcher_exists, _, _ = _run_pvesh([ - 'get', f'/cluster/notifications/matchers/{MATCHER_ID}', - '--output-format', 'json' - ]) + # Read private config (secrets) + try: + with open(PRIV_CFG, 'r') as f: + priv_text = f.read() + except FileNotFoundError: + priv_text = '' + except PermissionError: + result['error'] = f'Permission denied reading {PRIV_CFG}' + result['fallback_commands'] = _build_fallback(secret) + return jsonify(result), 200 - if matcher_exists: - ok_m, _, err_m = _run_pvesh([ - 'set', f'/cluster/notifications/matchers/{MATCHER_ID}', - '--target', ENDPOINT_ID, - '--match-severity', 'warning,error', - ]) + # ── Step 3: Build / replace our endpoint block ── + endpoint_block = ( + f"webhook: {ENDPOINT_ID}\n" + f"\turl {WEBHOOK_URL}\n" + f"\tmethod post\n" + f"\theader Content-Type:application/json\n" + f"\theader X-Webhook-Secret:{{{{ secrets.proxmenux_secret }}}}\n" + ) + + # Regex to find existing block: "webhook: proxmenux-webhook\n..." until next block or EOF + block_re = re.compile( + rf'^webhook:\s+{re.escape(ENDPOINT_ID)}\n(?:\t[^\n]*\n)*', + re.MULTILINE + ) + + if block_re.search(cfg_text): + cfg_text = block_re.sub(endpoint_block, cfg_text) else: - ok_m, _, err_m = _run_pvesh([ - 'create', '/cluster/notifications/matchers', - '--name', MATCHER_ID, - '--target', ENDPOINT_ID, - '--match-severity', 'warning,error', - ]) + # Append with blank line separator + if cfg_text and not cfg_text.endswith('\n\n'): + cfg_text = cfg_text.rstrip('\n') + '\n\n' + cfg_text += endpoint_block - if not ok_m: + # ── Step 4: Build / replace matcher block ── + matcher_block = ( + f"matcher: {MATCHER_ID}\n" + f"\ttarget {ENDPOINT_ID}\n" + f"\tmatch-severity warning,error\n" + ) + + matcher_re = re.compile( + rf'^matcher:\s+{re.escape(MATCHER_ID)}\n(?:\t[^\n]*\n)*', + re.MULTILINE + ) + + if matcher_re.search(cfg_text): + cfg_text = matcher_re.sub(matcher_block, cfg_text) + else: + if not cfg_text.endswith('\n\n'): + cfg_text = cfg_text.rstrip('\n') + '\n\n' + cfg_text += matcher_block + + # ── Step 5: Build / replace private config (secret) ── + priv_block = ( + f"webhook: {ENDPOINT_ID}\n" + f"\tsecret proxmenux_secret {secret}\n" + ) + + priv_re = re.compile( + rf'^webhook:\s+{re.escape(ENDPOINT_ID)}\n(?:\t[^\n]*\n)*', + re.MULTILINE + ) + + if priv_re.search(priv_text): + priv_text = priv_re.sub(priv_block, priv_text) + else: + if priv_text and not priv_text.endswith('\n\n'): + priv_text = priv_text.rstrip('\n') + '\n\n' + priv_text += priv_block + + # ── Step 6: Write back ── + try: + with open(NOTIFICATIONS_CFG, 'w') as f: + f.write(cfg_text) + except PermissionError: + result['error'] = f'Permission denied writing {NOTIFICATIONS_CFG}' + result['fallback_commands'] = _build_fallback(secret) + return jsonify(result), 200 + + try: + with open(PRIV_CFG, 'w') as f: + f.write(priv_text) + except PermissionError: + # Rollback is complex; just warn + result['error'] = ( + f'Endpoint configured but secret could not be written to {PRIV_CFG}. ' + f'Add manually: secret proxmenux_secret {secret}' + ) result['fallback_commands'] = [ - f'pvesh create /cluster/notifications/matchers ' - f'--name {MATCHER_ID} --target {ENDPOINT_ID} ' - f'--match-severity warning,error', + f"# Add to {PRIV_CFG}:", + f"webhook: {ENDPOINT_ID}", + f"\tsecret proxmenux_secret {secret}", ] - result['error'] = f'Endpoint OK, but matcher failed: {err_m}' - result['configured'] = False return jsonify(result), 200 result['configured'] = True - result['secret'] = secret # Return so UI can display it + result['secret'] = secret return jsonify(result), 200 except Exception as e: result['error'] = str(e) - result['fallback_commands'] = [ - f'pvesh create /cluster/notifications/endpoints/webhook ' - f'--name {ENDPOINT_ID} --url {WEBHOOK_URL} --method post ' - f'--header "X-Webhook-Secret=YOUR_SECRET"', - f'pvesh create /cluster/notifications/matchers ' - f'--name {MATCHER_ID} --target {ENDPOINT_ID} ' - f'--match-severity warning,error', - ] + try: + result['fallback_commands'] = _build_fallback( + notification_manager.get_webhook_secret() or 'YOUR_SECRET' + ) + except Exception: + result['fallback_commands'] = _build_fallback('YOUR_SECRET') return jsonify(result), 200 diff --git a/AppImage/scripts/notification_events.py b/AppImage/scripts/notification_events.py index 3d9a9964..9cd68595 100644 --- a/AppImage/scripts/notification_events.py +++ b/AppImage/scripts/notification_events.py @@ -769,10 +769,9 @@ class PollingCollector: class ProxmoxHookWatcher: """Receives native Proxmox VE notifications via local webhook endpoint. - Proxmox can be configured to send notifications to a webhook target: - pvesh create /cluster/notifications/endpoints/webhook/proxmenux \\ - --url http://127.0.0.1:8008/api/notifications/webhook \\ - --method POST + Configured automatically via /etc/pve/notifications.cfg (endpoint + + matcher blocks). The setup-webhook API writes these blocks on first + enable. See flask_notification_routes.py for details. Payload varies by source (storage, replication, cluster, PBS, apt). This class normalizes them into NotificationEvent objects.