update 1.2.2.2 beta

This commit is contained in:
MacRimi
2026-06-28 13:06:40 +02:00
parent ecfdcf1bac
commit 51b9285980
15 changed files with 670 additions and 50 deletions

View File

@@ -266,6 +266,74 @@ def save_notification_settings():
return jsonify({'error': f'Internal error ({type(e).__name__})'}), 500
@notification_bp.route('/api/notifications/reveal-secret', methods=['POST'])
@require_auth
def reveal_notification_secret():
"""Return one sensitive config value in cleartext.
Backs the "eye" toggle in the Settings UI. The settings GET masks
every entry in SENSITIVE_KEYS with `'************'` so the secret
never leaves the server just because someone loaded the page; this
endpoint lets an authenticated operator explicitly request the
real value for a single key when they need to inspect it.
Body schema (one of):
{"ai_provider": "groq" | "anthropic" | …}
{"channel": "telegram", "field": "bot_token"}
{"key": "webhook_secret"}
Returns ``{"value": "<cleartext>"}`` or 404 when nothing is stored.
400 on a malformed request, 403 if the requested key isn't on the
whitelist (i.e. the UI is asking for something we never agreed to
expose — defence against a future bug binding the eye to a
non-secret field).
"""
try:
from notification_manager import SENSITIVE_KEYS
nm = notification_manager # singleton already imported at module top
payload = request.get_json(silent=True) or {}
# Resolve the requested config key from the three accepted
# body shapes. Whitelist-checked below before we touch the DB.
cfg_key = None
provider = (payload.get('ai_provider') or '').strip().lower()
channel = (payload.get('channel') or '').strip().lower()
field = (payload.get('field') or '').strip().lower()
raw_key = (payload.get('key') or '').strip()
if provider:
cfg_key = f'ai_api_key_{provider}'
elif channel and field:
cfg_key = f'{channel}.{field}'
elif raw_key:
cfg_key = raw_key
if not cfg_key:
return jsonify({'error': 'missing_target'}), 400
# Whitelist enforcement — never reveal a value the rest of the
# system doesn't recognise as a secret. Stops a hypothetical
# future bug where the UI passes a non-secret config key and
# we hand back something we shouldn't.
if cfg_key not in SENSITIVE_KEYS:
return jsonify({'error': 'forbidden_key'}), 403
# Read the raw value through notification_manager (loads + caches
# the config). Mask placeholder is the same string the GET would
# return — treat that as "not stored" so we don't echo it back.
if not nm._config:
nm._load_config()
value = nm._config.get(cfg_key, '') or ''
if not value or value == '************':
return jsonify({'value': ''}), 200
return jsonify({'value': value}), 200
except Exception as e:
print(f"[notification_routes] {request.path} failed: {type(e).__name__}: {e}")
return jsonify({'error': f'Internal error ({type(e).__name__})'}), 500
@notification_bp.route('/api/notifications/test', methods=['POST'])
@require_auth
def test_notification():
@@ -721,23 +789,108 @@ _PVE_OUR_HEADERS = {
}
def _pve_webhook_url() -> str:
"""Return http:// or https:// based on the current SSL config.
def _ssl_cert_hostname(cert_path: str) -> str:
"""Pull the most useful hostname out of an x509 cert.
Hardcoded `http://...` previously broke webhook delivery whenever the
user enabled SSL — Flask only listened on HTTPS, so PVE got connection
refused and notifications stopped. Issue #194. PVE may still need
`update-ca-certificates` if the cert is self-signed; that's a doc
step on the user side.
Preference order: first DNS SAN → CN. Returns '' on any failure.
Used to build a webhook URL that won't fail PVE's TLS verification
(issue #239 — PVE has no `--insecure` flag and the user's ACME cert
is bound to a hostname, not to `127.0.0.1`).
"""
try:
import subprocess
out = subprocess.run(
['openssl', 'x509', '-in', cert_path, '-noout',
'-ext', 'subjectAltName', '-subject'],
capture_output=True, text=True, timeout=5,
)
if out.returncode != 0:
return ''
text = out.stdout or ''
# SAN line example: " DNS:pve.example.com, DNS:pve, IP Address:..."
import re
for line in text.splitlines():
m = re.search(r'DNS:([A-Za-z0-9.\-]+)', line)
if m:
return m.group(1)
# CN fallback. "subject= CN = pve.example.com" or "...CN=pve.example.com"
m = re.search(r'CN\s*=\s*([A-Za-z0-9.\-]+)', text)
if m:
return m.group(1)
except Exception:
pass
return ''
def _hostname_resolves_locally(hostname: str) -> bool:
"""True when `hostname` resolves to one of this host's own IPs.
Anti-misconfig: refuse to build a webhook URL that points
elsewhere (a stale DNS entry pointing at the previous host, a CN
that names a different node in the cluster, etc.). PVE delivers
webhooks from the same node, so the URL has to round-trip to
ourselves.
"""
try:
import socket
import ipaddress
target_ips = set()
for info in socket.getaddrinfo(hostname, None):
ip = info[4][0]
target_ips.add(ipaddress.ip_address(ip).compressed)
# Collect our own IPs from /proc/net/fib_trie isn't portable; use
# psutil if available, otherwise fall back to socket on the
# hostname itself.
local_ips = {'127.0.0.1', '::1'}
try:
import psutil
for _iface, addrs in psutil.net_if_addrs().items():
for a in addrs:
if a.family in (socket.AF_INET, socket.AF_INET6):
local_ips.add(ipaddress.ip_address(a.address.split('%')[0]).compressed)
except Exception:
pass
return bool(target_ips & local_ips)
except Exception:
return False
def _pve_webhook_url() -> str:
"""Return the URL we register with PVE as our webhook target.
Three branches:
1. SSL off → http://127.0.0.1:8008 (always works, no cert).
2. SSL on + cert hostname extractable and resolves locally →
https://<cert-hostname>:8008. This is what PVE's TLS layer
actually validates against. Without this, PVE rejects the
self/ACME cert with "IP address mismatch" (issue #239).
3. SSL on but hostname extraction/check failed → fall back to
https://127.0.0.1:8008 and accept that the user may still
hit the cert-mismatch error. We log so the operator can
diagnose. Better than silently emitting a wrong URL.
"""
try:
from auth_manager import load_ssl_config
cfg = load_ssl_config() or {}
if cfg.get('enabled'):
return 'https://127.0.0.1:8008/api/notifications/webhook'
if not cfg.get('enabled'):
return 'http://127.0.0.1:8008/api/notifications/webhook'
cert_path = cfg.get('cert_path') or ''
if cert_path:
host = _ssl_cert_hostname(cert_path)
if host and _hostname_resolves_locally(host):
return f'https://{host}:8008/api/notifications/webhook'
if host:
print(
f"[ProxMenux] webhook URL fallback to 127.0.0.1: "
f"cert hostname '{host}' does not resolve to a local "
f"IP — PVE will likely report a TLS verification "
f"error. Fix by ensuring the FQDN resolves on this "
f"host (e.g. /etc/hosts entry)."
)
return 'https://127.0.0.1:8008/api/notifications/webhook'
except Exception:
pass
return 'http://127.0.0.1:8008/api/notifications/webhook'
return 'http://127.0.0.1:8008/api/notifications/webhook'
# Backward-compat alias for callers that read this at import time. Most

View File

@@ -40,6 +40,12 @@ TOOL_METADATA = {
'vfio_iommu': {'name': 'VFIO/IOMMU Passthrough', 'function': 'enable_vfio_iommu', 'version': '1.0'},
'lvm_repair': {'name': 'LVM PV Headers Repair', 'function': 'repair_lvm_headers', 'version': '1.0'},
'repo_cleanup': {'name': 'Repository Cleanup', 'function': 'cleanup_repos', 'version': '1.0'},
# 1.1 = setup_proxmox_repositories now re-applies chmod 0644 to existing
# .sources/.list files. Legacy users (repos created with old 0640 perms
# but no entry in installed_tools.json) are surfaced as v1.0 via the
# legacy detector in get_installed_tools() below, so the Settings page
# shows them an "Update available" they can apply without touching apt.
'proxmox_repos': {'name': 'Proxmox APT Repositories', 'function': 'setup_proxmox_repositories', 'version': '1.1'},
# ── Legacy / Deprecated entries ──
# These optimizations were applied by previous ProxMenux versions but are
# no longer needed or have been removed from the current scripts. We still
@@ -226,8 +232,15 @@ def get_installed_tools():
'message': 'No ProxMenux optimizations installed yet'
})
with open(installed_tools_path, 'r') as f:
raw = json.load(f)
# Use the shared loader so both update detection and this
# endpoint see the same set of entries — including any
# synthetic v1.0 entries injected by `_apply_legacy_detectors`
# for tools that the host has configured but were never
# recorded in installed_tools.json (e.g. `proxmox_repos` on a
# pre-1.2.2 install). Without this, the update detector would
# surface a fix as available but the Settings endpoint
# wouldn't list the row, leaving the user nothing to click.
loaded = post_install_versions.load_installed_tools()
# Sprint 12A: index update list by tool key for has_update lookup.
try:
@@ -237,20 +250,11 @@ def get_installed_tools():
update_by_key = {u['key']: u for u in piv_snapshot.get('updates', [])}
tools = []
for tool_key, value in raw.items():
# Normalize legacy bool vs new structured entry.
if isinstance(value, bool):
if not value:
continue
installed_version = '1.0'
source = ''
elif isinstance(value, dict):
if not value.get('installed', False):
continue
installed_version = str(value.get('version', '1.0')) or '1.0'
source = str(value.get('source', '') or '')
else:
for tool_key, value in loaded.items():
if not value.get('installed', False):
continue
installed_version = str(value.get('version', '1.0')) or '1.0'
source = str(value.get('source', '') or '')
# Hard-coded display metadata (display name, deprecated flag).
meta = TOOL_METADATA.get(tool_key, {})

View File

@@ -3855,6 +3855,30 @@ class ProxmoxHookWatcher:
'title': title or event_type,
'job_id': pve_job_id,
}
# ProxMenux Host Backup: pull the extra fields that the runner
# packs into payload.fields so the host_backup_* templates can
# render backend, destination, sizes, etc. without falling back
# to '{field}' literals or empty placeholders.
if pve_type.startswith('proxmenux-host-backup-'):
backend = (fields.get('backend') or '').strip().lower()
backend_label_map = {
'pbs': 'Proxmox Backup Server',
'local': 'Local archive',
'borg': 'Borg repository',
}
data['backend'] = backend
data['backend_label'] = backend_label_map.get(backend, backend or 'unknown')
data['destination'] = fields.get('destination', '') or ''
data['profile_mode'] = fields.get('profile_mode', 'default') or 'default'
data['data_size'] = fields.get('data_size', '') or '-'
data['archive_size'] = fields.get('archive_size', '') or '-'
data['duration'] = fields.get('duration', '') or '-'
data['log_file'] = fields.get('log_file', '') or ''
# `reason` is what the body template substitutes for the
# failure case; fall back to the human message the runner
# sent so the operator never sees an empty Reason line.
data['reason'] = fields.get('reason', message) or message
# ── Extract clean reason for system-mail events ──
# smartd and other system mail contains verbose boilerplate.
@@ -3951,6 +3975,21 @@ class ProxmoxHookWatcher:
if severity in ('error', 'err'):
return 'backup_fail', 'vm', ''
return 'backup_complete', 'vm', ''
# ProxMenux Host Backups run as a standalone systemd timer +
# `run_scheduled_backup.sh` (or manually via `backup_host.sh`),
# NOT as PVE vzdump tasks. Routed to dedicated event types
# (`host_backup_*`) so the operator can toggle them separately
# from VM/CT backup events and the body can show host-backup
# specifics (backend, destination, data/archive size). Without
# this branch the Host Backup ran fine but never produced a
# notification — operator-reported.
if pve_type == 'proxmenux-host-backup-start':
return 'host_backup_start', 'node', ''
if pve_type == 'proxmenux-host-backup-complete':
return 'host_backup_complete', 'node', ''
if pve_type == 'proxmenux-host-backup-fail':
return 'host_backup_fail', 'node', ''
if pve_type == 'fencing':
return 'split_brain', 'node', ''

View File

@@ -69,10 +69,17 @@ SENSITIVE_KEYS = {
'ai_api_key_anthropic',
'ai_api_key_openai',
'ai_api_key_openrouter',
'telegram.token',
# `telegram.bot_token` — was previously listed as `telegram.token`,
# which never matched the real config_key (`bot_token` in
# CHANNEL_TYPES). The mismatch silently sent Telegram bot tokens
# in cleartext on every GET /api/notifications/settings since the
# masking layer was introduced. Fixed alongside the eye-reveal
# endpoint so the operator can still inspect the value on demand.
'telegram.bot_token',
'gotify.token',
'discord.webhook_url',
'email.password',
'apprise.url',
'webhook_secret',
}

View File

@@ -657,6 +657,37 @@ TEMPLATES = {
'group': 'backup',
'default_enabled': True,
},
# ── ProxMenux Host Backup events ──
# Distinct event types from `backup_*` (which are for vzdump VM/CT
# backups). The runner — both `run_scheduled_backup.sh` (timer) and
# the manual flow from `backup_host.sh` — POSTs to the local webhook
# with these explicit pve_type values, so the operator can toggle
# Host Backup notifications separately from VM/CT backup ones.
# Backend label (PBS / local / Borg) is highlighted in the title so
# the operator can tell at a glance where the data went.
'host_backup_start': {
'title': '{hostname}: Host backup started → {backend_label}',
'body': 'Job: {job_id}\nBackend: {backend_label}\nDestination: {destination}\nProfile: {profile_mode}',
'label': 'Host backup started',
'group': 'backup',
'default_enabled': False,
},
'host_backup_complete': {
'title': '{hostname}: Host backup complete → {backend_label}',
'body': 'Job: {job_id}\nBackend: {backend_label}\nDestination: {destination}\nData size: {data_size}\nArchive size: {archive_size}\nDuration: {duration}',
'label': 'Host backup complete',
'group': 'backup',
'default_enabled': True,
},
'host_backup_fail': {
'title': '{hostname}: Host backup FAILED → {backend_label}',
'body': 'Job: {job_id}\nBackend: {backend_label}\nDestination: {destination}\nDuration before failure: {duration}\nReason: {reason}\nLog: {log_file}',
'label': 'Host backup FAILED',
'group': 'backup',
'default_enabled': True,
},
'snapshot_complete': {
'title': '{hostname}: Snapshot created — {vmname} ({vmid})',
'body': 'Snapshot "{snapshot_name}" created for {vmname} (ID: {vmid}).',
@@ -1388,6 +1419,11 @@ def render_template(event_type: str, data: Dict[str, Any]) -> Dict[str, Any]:
'issue_list': '', 'error_key': '',
'storage_name': '', 'storage_type': '',
'important_list': 'none',
# Host Backup specifics (run_scheduled_backup.sh + backup_host.sh).
'job_id': '', 'backend': '', 'backend_label': '',
'destination': '', 'profile_mode': '',
'data_size': '', 'archive_size': '',
'log_file': '',
}
variables.update(data)
@@ -1574,6 +1610,9 @@ EVENT_EMOJI = {
'replication_complete': '\u2705',
# Backups
'backup_start': '\U0001F4BE\U0001F680', # 💾🚀 floppy + rocket
'host_backup_start': '\U0001F5C4\U0001F680', # 🗄️🚀 cabinet + rocket
'host_backup_complete': '\U0001F5C4️✅', # 🗄️✅ cabinet + check
'host_backup_fail': '\U0001F5C4️❌', # 🗄️❌ cabinet + cross
'backup_complete': '\U0001F4BE\u2705', # 💾✅ floppy + check
'backup_warning': '\U0001F4BE\u26A0\uFE0F', # 💾⚠️ floppy + warning
'backup_fail': '\U0001F4BE\u274C', # 💾❌ floppy + cross

View File

@@ -27,7 +27,8 @@ import re
import threading
import time
from pathlib import Path
from typing import Any
import os
from typing import Any, Callable
_BASE = Path("/usr/local/share/proxmenux")
_POST_INSTALL_DIR = _BASE / "scripts" / "post_install"
@@ -191,9 +192,52 @@ def load_installed_tools(path: Path = _INSTALLED_JSON) -> dict[str, dict[str, An
else:
# Unknown shape — treat as not installed rather than crash.
normalized[key] = {"installed": False, "version": "", "source": ""}
_apply_legacy_detectors(normalized)
return normalized
# Tool keys that were added later than 1.2.2 and therefore are missing
# from installed_tools.json on hosts that already had the underlying
# config in place. For each, declare how to recognise "this host has
# the thing configured already" so the registry surfaces it as a v1.0
# legacy install — the per-tool wrapper then bumps it to the current
# FUNC_VERSION on first apply, and from then on the regular update
# detector takes over without any special-casing.
_LEGACY_DETECTORS: dict[str, Callable[[], bool]] = {
"proxmox_repos": lambda: any(
os.path.exists(p) for p in (
"/etc/apt/sources.list.d/proxmox.sources",
"/etc/apt/sources.list.d/pve-no-subscription.list",
"/etc/apt/sources.list.d/pve-enterprise.list",
)
),
}
def _apply_legacy_detectors(normalized: dict[str, dict[str, Any]]) -> None:
"""Inject synthetic v1.0 entries for tools that the host already
has configured but were never recorded in installed_tools.json.
Skips any tool that already has an entry — once the user applies
the update, ``register_tool`` writes the real entry and the
detector path becomes a no-op for that key.
"""
for key, probe in _LEGACY_DETECTORS.items():
if key in normalized:
continue
try:
if probe():
normalized[key] = {
"installed": True,
"version": "1.0",
"source": "detected",
}
except Exception:
# Detectors must never break the registry load.
continue
# ---------------------------------------------------------------------------
# Detection logic
# ---------------------------------------------------------------------------