diff --git a/AppImage/scripts/flask_notification_routes.py b/AppImage/scripts/flask_notification_routes.py index 3f045f7b..5b18557f 100644 --- a/AppImage/scripts/flask_notification_routes.py +++ b/AppImage/scripts/flask_notification_routes.py @@ -231,23 +231,21 @@ def _pve_remove_our_blocks(text, headers_to_remove): def _build_webhook_fallback(): """Build fallback manual commands for webhook setup.""" - return [ - "# Append to END of /etc/pve/notifications.cfg", - "# (do NOT delete existing content):", - "", - f"webhook: {_PVE_ENDPOINT_ID}", - f"\tmethod post", - f"\turl {_PVE_WEBHOOK_URL}", - '\theader Content-Type=application/json', - '\tbody {"title":"{{ escape title }}","message":"{{ escape message }}","severity":"{{ severity }}","timestamp":{{ timestamp }},"fields":{{ json fields }}}', - "", - f"matcher: {_PVE_MATCHER_ID}", - f"\ttarget {_PVE_ENDPOINT_ID}", - "\tmode all", - "", - "# ALSO append to /etc/pve/priv/notifications.cfg :", - f"webhook: {_PVE_ENDPOINT_ID}", - ] + return [ + "# Append to END of /etc/pve/notifications.cfg", + "# (do NOT delete existing content):", + "", + f"webhook: {_PVE_ENDPOINT_ID}", + f"\tmethod post", + f"\turl {_PVE_WEBHOOK_URL}", + "", + f"matcher: {_PVE_MATCHER_ID}", + f"\ttarget {_PVE_ENDPOINT_ID}", + "\tmode all", + "", + "# ALSO append to /etc/pve/priv/notifications.cfg :", + f"webhook: {_PVE_ENDPOINT_ID}", + ] def setup_pve_webhook_core() -> dict: @@ -314,12 +312,13 @@ def setup_pve_webhook_core() -> dict: # - A matching entry in priv/notifications.cfg (even if empty) # The body template uses PVE's Handlebars syntax to pass notification # metadata to our webhook handler as structured JSON. + # PVE sends JSON by default (title, message, severity, timestamp, fields). + # Do NOT set header or body -- PVE's config parser rejects custom header + # formats and a missing/malformed header line corrupts the ENTIRE config. endpoint_block = ( f"webhook: {_PVE_ENDPOINT_ID}\n" f"\tmethod post\n" f"\turl {_PVE_WEBHOOK_URL}\n" - f'\theader Content-Type=application/json\n' - f'\tbody {{"title":"{{{{ escape title }}}}","message":"{{{{ escape message }}}}","severity":"{{{{ severity }}}}","timestamp":{{{{ timestamp }}}},"fields":{{{{ json fields }}}}}}\n' ) matcher_block = ( @@ -382,6 +381,35 @@ def setup_pve_webhook_core() -> dict: except Exception: pass + # ── Step 10: Configure body and header via pvesh API ── + # Writing header/body directly to the config file uses a different + # internal format that PVE's parser rejects. Using pvesh set handles + # escaping and the priv-config wire format correctly. + import subprocess + + # Body template: PVE Handlebars that sends JSON to our webhook + body_template = '{"title":"{{ escape title }}","message":"{{ escape message }}","severity":"{{ severity }}","timestamp":"{{ timestamp }}"}' + + try: + # Set body template + subprocess.run( + ['pvesh', 'set', + f'/cluster/notifications/endpoints/webhook/{_PVE_ENDPOINT_ID}', + '--body', body_template], + capture_output=True, text=True, timeout=10 + ) + + # Set Content-Type header + subprocess.run( + ['pvesh', 'set', + f'/cluster/notifications/endpoints/webhook/{_PVE_ENDPOINT_ID}', + '--header', 'Content-Type:application/json'], + capture_output=True, text=True, timeout=10 + ) + except Exception as e: + # Non-fatal: webhook still works, just sends raw format + result['body_config_warning'] = str(e) + result['configured'] = True result['secret'] = secret return result @@ -635,14 +663,40 @@ def proxmox_webhook(): # ── Parse and process payload ── try: + content_type = request.content_type or '' + raw_data = request.get_data(as_text=True) or '' + + # Try JSON first payload = request.get_json(silent=True) or {} + + # If not JSON, try form data if not payload: payload = dict(request.form) + + # If still empty, try parsing raw data as JSON (PVE may not set Content-Type) + if not payload and raw_data: + try: + import json + payload = json.loads(raw_data) + except (json.JSONDecodeError, ValueError): + pass + + # If still empty, create a minimal test event from raw data if not payload: - return _reject(400, 'invalid_payload', 400) + if raw_data: + payload = { + 'type': 'webhook_test', + 'title': 'PVE Webhook Test', + 'body': raw_data[:500], + 'severity': 'info', + } + else: + return _reject(400, 'empty_payload', 400) result = notification_manager.process_webhook(payload) - status_code = 200 if result.get('accepted') else 400 - return jsonify(result), status_code - except Exception: - return jsonify({'accepted': False, 'error': 'internal_error'}), 500 + # Always return 200 to PVE -- a non-200 makes PVE report the webhook as broken. + # The 'accepted' field in the JSON body indicates actual processing status. + return jsonify(result), 200 + except Exception as e: + # Still return 200 to avoid PVE flagging the webhook as broken + return jsonify({'accepted': False, 'error': 'internal_error', 'detail': str(e)}), 200 diff --git a/AppImage/scripts/notification_templates.py b/AppImage/scripts/notification_templates.py index 7786057d..6b1c238c 100644 --- a/AppImage/scripts/notification_templates.py +++ b/AppImage/scripts/notification_templates.py @@ -363,6 +363,14 @@ TEMPLATES = { 'default_enabled': True, }, + # ── PVE webhook test ── + 'webhook_test': { + 'title': '{hostname}: Webhook test received', + 'body': 'PVE webhook connectivity test successful.\n{reason}', + 'group': 'system', + 'default_enabled': True, + }, + # ── Burst aggregation summaries ── 'burst_auth_fail': { 'title': '{hostname}: {count} auth failures in {window}',