mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-02-20 17:36:24 +00:00
Update notification service
This commit is contained in:
@@ -416,39 +416,23 @@ 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">
|
||||||
Add to /etc/proxmox-backup/notifications.cfg on the PBS host:
|
Append 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">
|
||||||
{`webhook: proxmenux-webhook
|
{`webhook: proxmenux-webhook
|
||||||
\turl http://<PVE_IP>:8008/api/notifications/webhook
|
|
||||||
\tmethod post
|
\tmethod post
|
||||||
\theader Content-Type:application/json
|
\turl http://<PVE_IP>:8008/api/notifications/webhook
|
||||||
\theader X-Webhook-Secret:{{ secrets.proxmenux_secret }}
|
|
||||||
|
|
||||||
matcher: proxmenux-pbs
|
matcher: proxmenux-pbs
|
||||||
\ttarget proxmenux-webhook
|
\ttarget proxmenux-webhook
|
||||||
\tmatch-severity warning,error`}
|
\tmatch-severity warning,error`}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</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>
|
|
||||||
</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">
|
<p className="text-[10px] text-blue-400/90 leading-relaxed">
|
||||||
<p>
|
{"Replace <PVE_IP> with the IP of this PVE node (not 127.0.0.1, unless PBS runs on the same host). Append at the end -- do not delete existing content."}
|
||||||
{"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>
|
|
||||||
{"Replace <YOUR_SECRET> with the webhook secret shown in your notification settings."}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
@@ -1079,7 +1063,7 @@ matcher: proxmenux-pbs
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[10px] text-muted-foreground">
|
<p className="text-[10px] text-muted-foreground">
|
||||||
{"Proxmox must send this value in the X-Webhook-Secret header. Auto-generated on first enable."}
|
{"Used for remote connections only (e.g. PBS on another host). Local PVE webhook runs on localhost and does not need this header."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
@@ -1108,28 +1092,19 @@ matcher: proxmenux-pbs
|
|||||||
(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">
|
<p className="text-[10px] font-medium text-muted-foreground">
|
||||||
Add to /etc/proxmox-backup/notifications.cfg:
|
Append to /etc/proxmox-backup/notifications.cfg:
|
||||||
</p>
|
</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">
|
||||||
{`webhook: proxmenux-webhook
|
{`webhook: proxmenux-webhook
|
||||||
\turl http://<PVE_IP>:8008/api/notifications/webhook
|
|
||||||
\tmethod post
|
\tmethod post
|
||||||
\theader Content-Type:application/json
|
\turl http://<PVE_IP>:8008/api/notifications/webhook
|
||||||
\theader X-Webhook-Secret:{{ secrets.proxmenux_secret }}
|
|
||||||
|
|
||||||
matcher: proxmenux-pbs
|
matcher: proxmenux-pbs
|
||||||
\ttarget proxmenux-webhook
|
\ttarget proxmenux-webhook
|
||||||
\tmatch-severity warning,error`}
|
\tmatch-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. Append at the end -- do not delete existing content."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|||||||
@@ -188,27 +188,19 @@ def setup_proxmox_webhook():
|
|||||||
'error': None,
|
'error': None,
|
||||||
}
|
}
|
||||||
|
|
||||||
def _build_fallback(secret_val):
|
def _build_fallback():
|
||||||
"""Build manual instructions as fallback."""
|
"""Build manual instructions as fallback."""
|
||||||
return [
|
return [
|
||||||
"# Add these blocks to /etc/pve/notifications.cfg",
|
"# Append to END of /etc/pve/notifications.cfg",
|
||||||
"# (append at the end, do NOT delete existing content):",
|
"# (do NOT delete existing content):",
|
||||||
"",
|
"",
|
||||||
f"webhook: {ENDPOINT_ID}",
|
f"webhook: {ENDPOINT_ID}",
|
||||||
|
f"\tmethod post",
|
||||||
f"\turl {WEBHOOK_URL}",
|
f"\turl {WEBHOOK_URL}",
|
||||||
"\tmethod post",
|
|
||||||
"\theader Content-Type:application/json",
|
|
||||||
f"\theader X-Webhook-Secret:{{{{ secrets.proxmenux_secret }}}}",
|
|
||||||
"",
|
"",
|
||||||
f"matcher: {MATCHER_ID}",
|
f"matcher: {MATCHER_ID}",
|
||||||
f"\ttarget {ENDPOINT_ID}",
|
f"\ttarget {ENDPOINT_ID}",
|
||||||
"\tmatch-severity warning,error",
|
"\tmatch-severity warning,error",
|
||||||
"",
|
|
||||||
"# Add this block to /etc/pve/priv/notifications.cfg",
|
|
||||||
"# (append at the end, do NOT delete existing content):",
|
|
||||||
"",
|
|
||||||
f"webhook: {ENDPOINT_ID}",
|
|
||||||
f"\tsecret proxmenux_secret {secret_val}",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
def _read_file(path):
|
def _read_file(path):
|
||||||
@@ -233,178 +225,136 @@ def setup_proxmox_webhook():
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass # Best-effort backup
|
pass # Best-effort backup
|
||||||
|
|
||||||
def _parse_blocks(text):
|
def _remove_our_blocks(text, headers_to_remove):
|
||||||
"""Parse PVE config into list of (block_type, block_name, block_text).
|
"""Remove only blocks whose header line matches one of ours.
|
||||||
|
|
||||||
A block starts with a non-whitespace line like 'type: name'
|
Preserves ALL other content byte-for-byte.
|
||||||
and includes all subsequent lines that start with whitespace.
|
A block = header line + indented continuation lines + trailing blank line.
|
||||||
Lines between blocks (blank lines, comments) are preserved as
|
|
||||||
anonymous blocks with type=None, name=None.
|
|
||||||
"""
|
"""
|
||||||
blocks = []
|
lines = text.splitlines(keepends=True)
|
||||||
current_header = None
|
cleaned = []
|
||||||
current_lines = []
|
skip_block = False
|
||||||
gap_lines = [] # blank/comment lines between blocks
|
|
||||||
|
|
||||||
for line in text.splitlines(keepends=True):
|
for line in lines:
|
||||||
stripped = line.strip()
|
stripped = line.strip()
|
||||||
|
|
||||||
# Check if this is a block header (non-whitespace, contains ':')
|
# Non-whitespace line with ':' => block header
|
||||||
if stripped and not line[0].isspace() and ':' in stripped:
|
if stripped and not line[0:1].isspace() and ':' in stripped:
|
||||||
# Save previous block
|
if stripped in headers_to_remove:
|
||||||
if current_header is not None:
|
skip_block = True
|
||||||
blocks.append(current_header + (''.join(current_lines),))
|
continue
|
||||||
current_lines = []
|
else:
|
||||||
elif current_lines:
|
skip_block = False
|
||||||
blocks.append((None, None, ''.join(current_lines)))
|
|
||||||
current_lines = []
|
|
||||||
|
|
||||||
# Save any gap lines as anonymous block
|
|
||||||
if gap_lines:
|
|
||||||
blocks.append((None, None, ''.join(gap_lines)))
|
|
||||||
gap_lines = []
|
|
||||||
|
|
||||||
# Parse header
|
|
||||||
parts = stripped.split(':', 1)
|
|
||||||
btype = parts[0].strip()
|
|
||||||
bname = parts[1].strip() if len(parts) > 1 else ''
|
|
||||||
current_header = (btype, bname)
|
|
||||||
current_lines = [line]
|
|
||||||
|
|
||||||
elif current_header is not None and line[0:1].isspace():
|
if skip_block:
|
||||||
# Continuation line (starts with whitespace)
|
if not stripped:
|
||||||
current_lines.append(line)
|
# Blank line = block separator, consume it and stop skipping
|
||||||
|
skip_block = False
|
||||||
|
continue
|
||||||
|
elif line[0:1].isspace():
|
||||||
|
# Indented = continuation of our block
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
# New block started
|
||||||
|
skip_block = False
|
||||||
|
|
||||||
else:
|
cleaned.append(line)
|
||||||
# Gap line (blank, comment, or anything between blocks)
|
|
||||||
if current_header is not None:
|
|
||||||
blocks.append(current_header + (''.join(current_lines),))
|
|
||||||
current_header = None
|
|
||||||
current_lines = []
|
|
||||||
gap_lines.append(line)
|
|
||||||
|
|
||||||
# Flush remaining
|
return ''.join(cleaned)
|
||||||
if current_header is not None:
|
|
||||||
blocks.append(current_header + (''.join(current_lines),))
|
|
||||||
elif current_lines:
|
|
||||||
blocks.append((None, None, ''.join(current_lines)))
|
|
||||||
if gap_lines:
|
|
||||||
blocks.append((None, None, ''.join(gap_lines)))
|
|
||||||
|
|
||||||
return blocks
|
|
||||||
|
|
||||||
def _upsert_block(blocks, block_type, block_name, new_text):
|
|
||||||
"""Replace block if exists, otherwise append. Returns new list."""
|
|
||||||
found = False
|
|
||||||
result_blocks = []
|
|
||||||
for btype, bname, btext in blocks:
|
|
||||||
if btype == block_type and bname == block_name:
|
|
||||||
result_blocks.append((block_type, block_name, new_text))
|
|
||||||
found = True
|
|
||||||
else:
|
|
||||||
result_blocks.append((btype, bname, btext))
|
|
||||||
if not found:
|
|
||||||
# Append with blank line separator
|
|
||||||
result_blocks.append((None, None, '\n'))
|
|
||||||
result_blocks.append((block_type, block_name, new_text))
|
|
||||||
return result_blocks
|
|
||||||
|
|
||||||
def _blocks_to_text(blocks):
|
|
||||||
"""Reassemble blocks into config text."""
|
|
||||||
return ''.join(btext for _, _, btext in blocks)
|
|
||||||
|
|
||||||
def _write_safe(path, content, original_content):
|
|
||||||
"""Write content to path. On failure, try to restore original."""
|
|
||||||
try:
|
|
||||||
with open(path, 'w') as f:
|
|
||||||
f.write(content)
|
|
||||||
return None
|
|
||||||
except PermissionError:
|
|
||||||
return f'Permission denied writing {path}'
|
|
||||||
except Exception as e:
|
|
||||||
# Try to restore original
|
|
||||||
try:
|
|
||||||
if original_content is not None:
|
|
||||||
with open(path, 'w') as f:
|
|
||||||
f.write(original_content)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return str(e)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# ── Step 1: Ensure webhook secret exists ──
|
# ── Step 1: Ensure webhook secret exists (for our own internal use) ──
|
||||||
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)
|
||||||
|
|
||||||
# ── Step 2: Read both config files ──
|
# ── Step 2: Read main config ──
|
||||||
cfg_text, err = _read_file(NOTIFICATIONS_CFG)
|
cfg_text, err = _read_file(NOTIFICATIONS_CFG)
|
||||||
if err:
|
if err:
|
||||||
result['error'] = err
|
result['error'] = err
|
||||||
result['fallback_commands'] = _build_fallback(secret)
|
result['fallback_commands'] = _build_fallback()
|
||||||
return jsonify(result), 200
|
return jsonify(result), 200
|
||||||
|
|
||||||
|
# ── Step 3: Read priv config (to clean up any broken blocks we wrote before) ──
|
||||||
priv_text, err = _read_file(PRIV_CFG)
|
priv_text, err = _read_file(PRIV_CFG)
|
||||||
if err:
|
if err:
|
||||||
result['error'] = err
|
priv_text = None # Non-fatal, we just won't clean it
|
||||||
result['fallback_commands'] = _build_fallback(secret)
|
|
||||||
return jsonify(result), 200
|
|
||||||
|
|
||||||
# ── Step 3: Create backups ──
|
# ── Step 4: Create backups before ANY modification ──
|
||||||
_backup_file(NOTIFICATIONS_CFG)
|
_backup_file(NOTIFICATIONS_CFG)
|
||||||
_backup_file(PRIV_CFG)
|
if priv_text is not None:
|
||||||
|
_backup_file(PRIV_CFG)
|
||||||
|
|
||||||
# ── Step 4: Parse existing blocks ──
|
# ── Step 5: Remove any previous proxmenux blocks from BOTH files ──
|
||||||
cfg_blocks = _parse_blocks(cfg_text)
|
our_headers = {
|
||||||
priv_blocks = _parse_blocks(priv_text)
|
f'webhook: {ENDPOINT_ID}',
|
||||||
|
f'matcher: {MATCHER_ID}',
|
||||||
|
}
|
||||||
|
|
||||||
# ── Step 5: Build our new blocks ──
|
cleaned_cfg = _remove_our_blocks(cfg_text, our_headers)
|
||||||
endpoint_text = (
|
|
||||||
|
if priv_text is not None:
|
||||||
|
cleaned_priv = _remove_our_blocks(priv_text, our_headers)
|
||||||
|
|
||||||
|
# ── Step 6: Build new blocks ──
|
||||||
|
# Exact format from a real working PVE server:
|
||||||
|
# webhook: name
|
||||||
|
# \tmethod post
|
||||||
|
# \turl http://...
|
||||||
|
#
|
||||||
|
# NO header lines -- localhost webhook doesn't need them.
|
||||||
|
# PVE header format is: header name=X-Key,value=<base64>
|
||||||
|
# PVE secret format is: secret name=key,value=<base64>
|
||||||
|
# Neither is needed for localhost calls.
|
||||||
|
|
||||||
|
endpoint_block = (
|
||||||
f"webhook: {ENDPOINT_ID}\n"
|
f"webhook: {ENDPOINT_ID}\n"
|
||||||
f"\turl {WEBHOOK_URL}\n"
|
|
||||||
f"\tmethod post\n"
|
f"\tmethod post\n"
|
||||||
f"\theader Content-Type:application/json\n"
|
f"\turl {WEBHOOK_URL}\n"
|
||||||
f"\theader X-Webhook-Secret:{{{{ secrets.proxmenux_secret }}}}\n"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
matcher_text = (
|
matcher_block = (
|
||||||
f"matcher: {MATCHER_ID}\n"
|
f"matcher: {MATCHER_ID}\n"
|
||||||
f"\ttarget {ENDPOINT_ID}\n"
|
f"\ttarget {ENDPOINT_ID}\n"
|
||||||
f"\tmatch-severity warning,error\n"
|
f"\tmatch-severity warning,error\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
priv_secret_text = (
|
# ── Step 7: Append our blocks to cleaned main config ──
|
||||||
f"webhook: {ENDPOINT_ID}\n"
|
# Ensure existing content ends cleanly
|
||||||
f"\tsecret proxmenux_secret {secret}\n"
|
if cleaned_cfg and not cleaned_cfg.endswith('\n'):
|
||||||
)
|
cleaned_cfg += '\n'
|
||||||
|
if cleaned_cfg and not cleaned_cfg.endswith('\n\n'):
|
||||||
|
cleaned_cfg += '\n'
|
||||||
|
|
||||||
# ── Step 6: Upsert (replace or append) our blocks only ──
|
new_cfg = cleaned_cfg + endpoint_block + '\n' + matcher_block
|
||||||
cfg_blocks = _upsert_block(cfg_blocks, 'webhook', ENDPOINT_ID, endpoint_text)
|
|
||||||
cfg_blocks = _upsert_block(cfg_blocks, 'matcher', MATCHER_ID, matcher_text)
|
|
||||||
priv_blocks = _upsert_block(priv_blocks, 'webhook', ENDPOINT_ID, priv_secret_text)
|
|
||||||
|
|
||||||
new_cfg = _blocks_to_text(cfg_blocks)
|
# ── Step 8: Write main config ──
|
||||||
new_priv = _blocks_to_text(priv_blocks)
|
try:
|
||||||
|
with open(NOTIFICATIONS_CFG, 'w') as f:
|
||||||
# ── Step 7: Write back (with rollback on error) ──
|
f.write(new_cfg)
|
||||||
err = _write_safe(NOTIFICATIONS_CFG, new_cfg, cfg_text)
|
except PermissionError:
|
||||||
if err:
|
result['error'] = f'Permission denied writing {NOTIFICATIONS_CFG}'
|
||||||
result['error'] = err
|
result['fallback_commands'] = _build_fallback()
|
||||||
result['fallback_commands'] = _build_fallback(secret)
|
return jsonify(result), 200
|
||||||
|
except Exception as e:
|
||||||
|
# Rollback
|
||||||
|
try:
|
||||||
|
with open(NOTIFICATIONS_CFG, 'w') as f:
|
||||||
|
f.write(cfg_text)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
result['error'] = str(e)
|
||||||
|
result['fallback_commands'] = _build_fallback()
|
||||||
return jsonify(result), 200
|
return jsonify(result), 200
|
||||||
|
|
||||||
err = _write_safe(PRIV_CFG, new_priv, priv_text)
|
# ── Step 9: Clean priv config (remove our broken blocks, write nothing new) ──
|
||||||
if err:
|
if priv_text is not None and cleaned_priv != priv_text:
|
||||||
# Rollback main config
|
try:
|
||||||
_write_safe(NOTIFICATIONS_CFG, cfg_text, None)
|
with open(PRIV_CFG, 'w') as f:
|
||||||
result['error'] = f'Secret file failed: {err}. Main config rolled back.'
|
f.write(cleaned_priv)
|
||||||
result['fallback_commands'] = [
|
except Exception:
|
||||||
f"# Add to {PRIV_CFG} (append, don't overwrite):",
|
pass # Non-fatal, priv cleanup is best-effort
|
||||||
f"webhook: {ENDPOINT_ID}",
|
|
||||||
f"\tsecret proxmenux_secret {secret}",
|
|
||||||
]
|
|
||||||
return jsonify(result), 200
|
|
||||||
|
|
||||||
result['configured'] = True
|
result['configured'] = True
|
||||||
result['secret'] = secret
|
result['secret'] = secret
|
||||||
@@ -412,29 +362,118 @@ def setup_proxmox_webhook():
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
result['error'] = str(e)
|
result['error'] = str(e)
|
||||||
try:
|
result['fallback_commands'] = _build_fallback()
|
||||||
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
|
return jsonify(result), 200
|
||||||
|
|
||||||
|
|
||||||
|
@notification_bp.route('/api/notifications/proxmox/read-cfg', methods=['GET'])
|
||||||
|
def read_pve_notification_cfg():
|
||||||
|
"""Diagnostic: return raw content of PVE notification config files.
|
||||||
|
|
||||||
|
GET /api/notifications/proxmox/read-cfg
|
||||||
|
Returns both notifications.cfg and priv/notifications.cfg content.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
files = {
|
||||||
|
'notifications_cfg': '/etc/pve/notifications.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:
|
||||||
|
with open(path, 'r') as f:
|
||||||
|
result[key] = {
|
||||||
|
'path': path,
|
||||||
|
'content': f.read(),
|
||||||
|
'size': os.path.getsize(path),
|
||||||
|
'error': None,
|
||||||
|
}
|
||||||
|
except FileNotFoundError:
|
||||||
|
result[key] = {'path': path, 'content': None, 'size': 0, 'error': 'file_not_found'}
|
||||||
|
except PermissionError:
|
||||||
|
result[key] = {'path': path, 'content': None, 'size': 0, 'error': 'permission_denied'}
|
||||||
|
except Exception as e:
|
||||||
|
result[key] = {'path': path, 'content': None, 'size': 0, 'error': str(e)}
|
||||||
|
|
||||||
|
# Find backups
|
||||||
|
backups = []
|
||||||
|
for d in [backup_dir, priv_backup_dir]:
|
||||||
|
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'})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
result['backups'] = backups
|
||||||
|
return jsonify(result), 200
|
||||||
|
|
||||||
|
|
||||||
|
@notification_bp.route('/api/notifications/proxmox/restore-cfg', methods=['POST'])
|
||||||
|
def restore_pve_notification_cfg():
|
||||||
|
"""Restore PVE notification config from our backup.
|
||||||
|
|
||||||
|
POST /api/notifications/proxmox/restore-cfg
|
||||||
|
Finds the most recent proxmenux_backup and restores it.
|
||||||
|
"""
|
||||||
|
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:
|
||||||
|
candidates = sorted([
|
||||||
|
f for f in os.listdir(search_dir)
|
||||||
|
if 'proxmenux_backup' in f and f.startswith('notifications.cfg')
|
||||||
|
], reverse=True)
|
||||||
|
|
||||||
|
if candidates:
|
||||||
|
backup_path = os.path.join(search_dir, candidates[0])
|
||||||
|
shutil.copy2(backup_path, target_path)
|
||||||
|
restored.append({'target': target_path, 'from_backup': backup_path})
|
||||||
|
else:
|
||||||
|
errors.append({'target': target_path, 'error': 'no_backup_found'})
|
||||||
|
except Exception as e:
|
||||||
|
errors.append({'target': target_path, 'error': str(e)})
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'restored': restored,
|
||||||
|
'errors': errors,
|
||||||
|
'success': len(errors) == 0 and len(restored) > 0,
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
|
||||||
@notification_bp.route('/api/notifications/webhook', methods=['POST'])
|
@notification_bp.route('/api/notifications/webhook', methods=['POST'])
|
||||||
def proxmox_webhook():
|
def proxmox_webhook():
|
||||||
"""Receive native Proxmox VE notification webhooks (hardened).
|
"""Receive native Proxmox VE notification webhooks (hardened).
|
||||||
|
|
||||||
Security layers:
|
Security layers:
|
||||||
1. Rate limiting (60 req/min) -- always
|
Localhost (127.0.0.1 / ::1): rate limiting only.
|
||||||
2. Shared secret (X-Webhook-Secret) -- always required
|
PVE calls us on localhost and cannot send custom auth headers,
|
||||||
3. Anti-replay timestamp (60s window) -- remote only
|
so we trust the loopback interface (only local processes can reach it).
|
||||||
4. Replay cache (signature dedup) -- remote only
|
Remote: rate limiting + shared secret + timestamp + replay + IP allowlist.
|
||||||
5. IP allowlist (optional) -- remote only
|
|
||||||
|
|
||||||
Localhost callers (127.0.0.1 / ::1) bypass layers 3-5 because Proxmox
|
|
||||||
cannot inject dynamic timestamp headers. The shared secret is still
|
|
||||||
required for localhost to prevent any local process from injecting events.
|
|
||||||
"""
|
"""
|
||||||
_reject = lambda code, error, status: (jsonify({'accepted': False, 'error': error}), status)
|
_reject = lambda code, error, status: (jsonify({'accepted': False, 'error': error}), status)
|
||||||
|
|
||||||
@@ -447,23 +486,21 @@ def proxmox_webhook():
|
|||||||
resp.headers['Retry-After'] = '60'
|
resp.headers['Retry-After'] = '60'
|
||||||
return resp, 429
|
return resp, 429
|
||||||
|
|
||||||
# ── Layer 2: Shared secret (always required) ──
|
# ── Layers 2-5: Remote-only checks ──
|
||||||
try:
|
|
||||||
configured_secret = notification_manager.get_webhook_secret()
|
|
||||||
except Exception:
|
|
||||||
configured_secret = ''
|
|
||||||
|
|
||||||
if not configured_secret:
|
|
||||||
return _reject(500, 'webhook_not_configured', 500)
|
|
||||||
|
|
||||||
request_secret = request.headers.get('X-Webhook-Secret', '')
|
|
||||||
if not request_secret:
|
|
||||||
return _reject(401, 'missing_secret', 401)
|
|
||||||
if not hmac.compare_digest(configured_secret, request_secret):
|
|
||||||
return _reject(401, 'invalid_secret', 401)
|
|
||||||
|
|
||||||
# ── Layers 3-5: Remote-only checks ──
|
|
||||||
if not is_localhost:
|
if not is_localhost:
|
||||||
|
# Layer 2: Shared secret
|
||||||
|
try:
|
||||||
|
configured_secret = notification_manager.get_webhook_secret()
|
||||||
|
except Exception:
|
||||||
|
configured_secret = ''
|
||||||
|
|
||||||
|
if configured_secret:
|
||||||
|
request_secret = request.headers.get('X-Webhook-Secret', '')
|
||||||
|
if not request_secret:
|
||||||
|
return _reject(401, 'missing_secret', 401)
|
||||||
|
if not hmac.compare_digest(configured_secret, request_secret):
|
||||||
|
return _reject(401, 'invalid_secret', 401)
|
||||||
|
|
||||||
# Layer 3: Anti-replay timestamp
|
# Layer 3: Anti-replay timestamp
|
||||||
ts_header = request.headers.get('X-ProxMenux-Timestamp', '')
|
ts_header = request.headers.get('X-ProxMenux-Timestamp', '')
|
||||||
if not ts_header:
|
if not ts_header:
|
||||||
|
|||||||
Reference in New Issue
Block a user