Update notication service

This commit is contained in:
MacRimi
2026-02-19 17:26:36 +01:00
parent 7c5cdb9161
commit 4ce2699a48
3 changed files with 174 additions and 111 deletions

View File

@@ -416,25 +416,34 @@ export function NotificationSettings() {
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<p className="text-[11px] font-medium text-muted-foreground"> <p className="text-[11px] font-medium text-muted-foreground">
Run on the PBS host: Add to /etc/proxmox-backup/notifications.cfg on the PBS host:
</p> </p>
<pre className="text-[11px] bg-background p-2 rounded border border-border overflow-x-auto font-mono"> <pre className="text-[11px] bg-background p-2 rounded border border-border overflow-x-auto font-mono">
{`# Create webhook endpoint on PBS {`webhook: proxmenux-webhook
proxmox-backup-manager notification endpoint webhook create proxmenux-webhook \\ \turl http://<PVE_IP>:8008/api/notifications/webhook
--url http://<PVE_HOST_IP>:8008/api/notifications/webhook \\ \tmethod post
--header "X-Webhook-Secret=<YOUR_SECRET>" \theader Content-Type:application/json
\theader X-Webhook-Secret:{{ secrets.proxmenux_secret }}
# Create matcher to route PBS events matcher: proxmenux-pbs
proxmox-backup-manager notification matcher create proxmenux-pbs \\ \ttarget proxmenux-webhook
--target proxmenux-webhook \\ \tmatch-severity warning,error`}
--match-severity warning,error`} </pre>
</div>
<div className="space-y-1.5">
<p className="text-[11px] font-medium text-muted-foreground">
Add to /etc/proxmox-backup/notifications-priv.cfg:
</p>
<pre className="text-[11px] bg-background p-2 rounded border border-border overflow-x-auto font-mono">
{`webhook: proxmenux-webhook
\tsecret proxmenux_secret <YOUR_SECRET>`}
</pre> </pre>
</div> </div>
<div className="flex items-start gap-2 p-2 rounded-md bg-blue-500/10 border border-blue-500/20"> <div className="flex items-start gap-2 p-2 rounded-md bg-blue-500/10 border border-blue-500/20">
<Info className="h-3.5 w-3.5 text-blue-400 shrink-0 mt-0.5" /> <Info className="h-3.5 w-3.5 text-blue-400 shrink-0 mt-0.5" />
<div className="text-[10px] text-blue-400/90 leading-relaxed space-y-1"> <div className="text-[10px] text-blue-400/90 leading-relaxed space-y-1">
<p> <p>
{"Replace <PVE_HOST_IP> with the IP address of this PVE node (not 127.0.0.1, unless PBS runs on the same host)."} {"Replace <PVE_IP> with the IP of this PVE node (not 127.0.0.1, unless PBS runs on the same host)."}
</p> </p>
<p> <p>
{"Replace <YOUR_SECRET> with the webhook secret shown in your notification settings."} {"Replace <YOUR_SECRET> 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 Backups launched from PVE are covered by the PVE webhook. PBS internal jobs
(Verify, Prune, GC, Sync) require separate configuration on the PBS server. (Verify, Prune, GC, Sync) require separate configuration on the PBS server.
</p> </p>
<p className="text-[10px] font-medium text-muted-foreground">
Add to /etc/proxmox-backup/notifications.cfg:
</p>
<pre className="text-[10px] bg-background p-2 rounded border border-border overflow-x-auto font-mono"> <pre className="text-[10px] bg-background p-2 rounded border border-border overflow-x-auto font-mono">
{`# On the PBS host: {`webhook: proxmenux-webhook
proxmox-backup-manager notification endpoint webhook \\ \turl http://<PVE_IP>:8008/api/notifications/webhook
create proxmenux-webhook \\ \tmethod post
--url http://<PVE_IP>:8008/api/notifications/webhook \\ \theader Content-Type:application/json
--header "X-Webhook-Secret=<SECRET>" \theader X-Webhook-Secret:{{ secrets.proxmenux_secret }}
proxmox-backup-manager notification matcher \\ matcher: proxmenux-pbs
create proxmenux-pbs \\ \ttarget proxmenux-webhook
--target proxmenux-webhook \\ \tmatch-severity warning,error`}
--match-severity warning,error`} </pre>
<p className="text-[10px] font-medium text-muted-foreground">
Add to /etc/proxmox-backup/notifications-priv.cfg:
</p>
<pre className="text-[10px] bg-background p-1.5 rounded border border-border overflow-x-auto font-mono">
{`webhook: proxmenux-webhook
\tsecret proxmenux_secret <SECRET>`}
</pre> </pre>
<p className="text-[10px] text-muted-foreground"> <p className="text-[10px] text-muted-foreground">
{"Replace <PVE_IP> with this node's IP and <SECRET> with the webhook secret above."} {"Replace <PVE_IP> with this node's IP and <SECRET> with the webhook secret above."}

View File

@@ -161,16 +161,21 @@ def send_notification():
def setup_proxmox_webhook(): def setup_proxmox_webhook():
"""Automatically configure PVE notifications to call our webhook. """Automatically configure PVE notifications to call our webhook.
Idempotent: safe to call multiple times. Only creates/updates Writes directly to /etc/pve/notifications.cfg (cluster filesystem)
ProxMenux-owned objects (proxmenux-webhook endpoint, proxmenux-default matcher). instead of using pvesh, which has complex property-string formats
Never deletes or overrides user notification targets. 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 import secrets as secrets_mod
ENDPOINT_ID = 'proxmenux-webhook' ENDPOINT_ID = 'proxmenux-webhook'
MATCHER_ID = 'proxmenux-default' MATCHER_ID = 'proxmenux-default'
WEBHOOK_URL = 'http://127.0.0.1:8008/api/notifications/webhook' 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 = { result = {
'configured': False, 'configured': False,
@@ -181,111 +186,152 @@ def setup_proxmox_webhook():
'error': None, 'error': None,
} }
def _run_pvesh(args: list, check: bool = True) -> tuple: def _build_fallback(secret_val):
"""Run pvesh command. Returns (success, stdout, stderr).""" """Build manual instructions as fallback."""
try: return [
proc = subprocess.run( "# Add to /etc/pve/notifications.cfg:",
['pvesh'] + args, f"webhook: {ENDPOINT_ID}",
capture_output=True, text=True, timeout=15 f"\turl {WEBHOOK_URL}",
) "\tmethod post",
return proc.returncode == 0, proc.stdout.strip(), proc.stderr.strip() f"\theader Content-Type:application/json",
except FileNotFoundError: f"\theader X-Webhook-Secret:{{{{ secrets.proxmenux_secret }}}}",
return False, '', 'pvesh not found' "",
except subprocess.TimeoutExpired: "# Add to /etc/pve/priv/notifications.cfg:",
return False, '', 'pvesh timed out' f"webhook: {ENDPOINT_ID}",
except Exception as e: f"\tsecret proxmenux_secret {secret_val}",
return False, '', str(e) "",
"# Also add a matcher block to /etc/pve/notifications.cfg:",
f"matcher: {MATCHER_ID}",
f"\ttarget {ENDPOINT_ID}",
"\tmatch-severity warning,error",
]
try: try:
# Step 1: Ensure webhook secret exists # ── Step 1: Ensure webhook secret exists ──
secret = notification_manager.get_webhook_secret() secret = notification_manager.get_webhook_secret()
if not secret: if not secret:
secret = secrets_mod.token_urlsafe(32) secret = secrets_mod.token_urlsafe(32)
notification_manager._save_setting('webhook_secret', secret) notification_manager._save_setting('webhook_secret', secret)
secret_header = f'X-Webhook-Secret={secret}' # ── Step 2: Read current config ──
try:
# Step 2: Check if endpoint already exists with open(NOTIFICATIONS_CFG, 'r') as f:
exists_ok, _, _ = _run_pvesh([ cfg_text = f.read()
'get', f'/cluster/notifications/endpoints/webhook/{ENDPOINT_ID}', except FileNotFoundError:
'--output-format', 'json' cfg_text = ''
]) except PermissionError:
result['error'] = f'Permission denied reading {NOTIFICATIONS_CFG}'
if exists_ok: result['fallback_commands'] = _build_fallback(secret)
# 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}'
return jsonify(result), 200 return jsonify(result), 200
# Step 3: Create or update matcher # Read private config (secrets)
matcher_exists, _, _ = _run_pvesh([ try:
'get', f'/cluster/notifications/matchers/{MATCHER_ID}', with open(PRIV_CFG, 'r') as f:
'--output-format', 'json' 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: # ── Step 3: Build / replace our endpoint block ──
ok_m, _, err_m = _run_pvesh([ endpoint_block = (
'set', f'/cluster/notifications/matchers/{MATCHER_ID}', f"webhook: {ENDPOINT_ID}\n"
'--target', ENDPOINT_ID, f"\turl {WEBHOOK_URL}\n"
'--match-severity', 'warning,error', 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: else:
ok_m, _, err_m = _run_pvesh([ # Append with blank line separator
'create', '/cluster/notifications/matchers', if cfg_text and not cfg_text.endswith('\n\n'):
'--name', MATCHER_ID, cfg_text = cfg_text.rstrip('\n') + '\n\n'
'--target', ENDPOINT_ID, cfg_text += endpoint_block
'--match-severity', 'warning,error',
])
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'] = [ result['fallback_commands'] = [
f'pvesh create /cluster/notifications/matchers ' f"# Add to {PRIV_CFG}:",
f'--name {MATCHER_ID} --target {ENDPOINT_ID} ' f"webhook: {ENDPOINT_ID}",
f'--match-severity warning,error', f"\tsecret proxmenux_secret {secret}",
] ]
result['error'] = f'Endpoint OK, but matcher failed: {err_m}'
result['configured'] = False
return jsonify(result), 200 return jsonify(result), 200
result['configured'] = True result['configured'] = True
result['secret'] = secret # Return so UI can display it result['secret'] = secret
return jsonify(result), 200 return jsonify(result), 200
except Exception as e: except Exception as e:
result['error'] = str(e) result['error'] = str(e)
result['fallback_commands'] = [ try:
f'pvesh create /cluster/notifications/endpoints/webhook ' result['fallback_commands'] = _build_fallback(
f'--name {ENDPOINT_ID} --url {WEBHOOK_URL} --method post ' notification_manager.get_webhook_secret() or 'YOUR_SECRET'
f'--header "X-Webhook-Secret=YOUR_SECRET"', )
f'pvesh create /cluster/notifications/matchers ' except Exception:
f'--name {MATCHER_ID} --target {ENDPOINT_ID} ' result['fallback_commands'] = _build_fallback('YOUR_SECRET')
f'--match-severity warning,error',
]
return jsonify(result), 200 return jsonify(result), 200

View File

@@ -769,10 +769,9 @@ class PollingCollector:
class ProxmoxHookWatcher: class ProxmoxHookWatcher:
"""Receives native Proxmox VE notifications via local webhook endpoint. """Receives native Proxmox VE notifications via local webhook endpoint.
Proxmox can be configured to send notifications to a webhook target: Configured automatically via /etc/pve/notifications.cfg (endpoint +
pvesh create /cluster/notifications/endpoints/webhook/proxmenux \\ matcher blocks). The setup-webhook API writes these blocks on first
--url http://127.0.0.1:8008/api/notifications/webhook \\ enable. See flask_notification_routes.py for details.
--method POST
Payload varies by source (storage, replication, cluster, PBS, apt). Payload varies by source (storage, replication, cluster, PBS, apt).
This class normalizes them into NotificationEvent objects. This class normalizes them into NotificationEvent objects.