mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-08-03 14:26:24 +00:00
update 1.2.2.2 beta
This commit is contained in:
Binary file not shown.
@@ -1 +1 @@
|
|||||||
0a8d1bf1c81a9f60926d5a4af1a570c4acfcd1b41581ce02377600b2b84bf749 ProxMenux-1.2.2.2-beta.AppImage
|
8b7f4694ade901c9b1058828efd32df05d6621f5648882a172b72d44d5fb6950 ProxMenux-1.2.2.2-beta.AppImage
|
||||||
|
|||||||
@@ -335,6 +335,12 @@ export function NotificationSettings() {
|
|||||||
const [showHistory, setShowHistory] = useState(false)
|
const [showHistory, setShowHistory] = useState(false)
|
||||||
const [showAdvanced, setShowAdvanced] = useState(false)
|
const [showAdvanced, setShowAdvanced] = useState(false)
|
||||||
const [showSecrets, setShowSecrets] = useState<Record<string, boolean>>({})
|
const [showSecrets, setShowSecrets] = useState<Record<string, boolean>>({})
|
||||||
|
// Cleartext secrets cached only while the eye toggle is "on" for
|
||||||
|
// that field. Settings GET returns "************" for everything in
|
||||||
|
// SENSITIVE_KEYS; clicking eye fetches the real value via
|
||||||
|
// /api/notifications/reveal-secret and stores it here. Cleared when
|
||||||
|
// the user toggles eye off, or on every reload — never persists.
|
||||||
|
const [revealedSecrets, setRevealedSecrets] = useState<Record<string, string>>({})
|
||||||
const [editMode, setEditMode] = useState(false)
|
const [editMode, setEditMode] = useState(false)
|
||||||
const [hasChanges, setHasChanges] = useState(false)
|
const [hasChanges, setHasChanges] = useState(false)
|
||||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set())
|
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set())
|
||||||
@@ -1065,8 +1071,86 @@ export function NotificationSettings() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleSecret = (key: string) => {
|
// Maps each eye-button local key to the body shape the backend
|
||||||
setShowSecrets(prev => ({ ...prev, [key]: !prev[key] }))
|
// expects for /api/notifications/reveal-secret. Centralised here so
|
||||||
|
// the JSX call site stays a one-liner — `toggleSecret(key)`.
|
||||||
|
const SECRET_REVEAL_TARGETS: Record<string, Record<string, string>> = {
|
||||||
|
tg_token: { channel: "telegram", field: "bot_token" },
|
||||||
|
gt_token: { channel: "gotify", field: "token" },
|
||||||
|
dc_hook: { channel: "discord", field: "webhook_url" },
|
||||||
|
em_pass: { channel: "email", field: "password" },
|
||||||
|
apprise_url: { channel: "apprise", field: "url" },
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleSecret = async (key: string) => {
|
||||||
|
// Turning eye OFF — drop the cached cleartext immediately.
|
||||||
|
if (showSecrets[key]) {
|
||||||
|
setShowSecrets(prev => ({ ...prev, [key]: false }))
|
||||||
|
setRevealedSecrets(prev => {
|
||||||
|
const next = { ...prev }
|
||||||
|
delete next[key]
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Turning eye ON — resolve the backend body. ai_key is dynamic
|
||||||
|
// (depends on current provider); the rest are static.
|
||||||
|
let body: Record<string, string> | null = null
|
||||||
|
if (key === "ai_key") {
|
||||||
|
const prov = (config.ai_provider || "").trim()
|
||||||
|
if (prov) body = { ai_provider: prov }
|
||||||
|
} else {
|
||||||
|
body = SECRET_REVEAL_TARGETS[key] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!body) {
|
||||||
|
// Unknown eye-toggle — flip state and let the input render
|
||||||
|
// whatever it already has. Never silently skip the UI feedback.
|
||||||
|
setShowSecrets(prev => ({ ...prev, [key]: true }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Must go through `fetchApi` (not raw `fetch`) so the JWT
|
||||||
|
// Authorization header gets attached — the endpoint uses
|
||||||
|
// `@require_auth` and would reject a plain fetch with 401, which
|
||||||
|
// is exactly what produced the "still asterisks" behaviour seen
|
||||||
|
// on the first build.
|
||||||
|
const data = await fetchApi<{ value?: string }>("/api/notifications/reveal-secret", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
const value = typeof data?.value === "string" ? data.value : ""
|
||||||
|
setRevealedSecrets(prev => ({ ...prev, [key]: value }))
|
||||||
|
} catch {
|
||||||
|
// Network/auth failure → still flip the eye so the user sees
|
||||||
|
// the underlying input changing (and that it has the masked
|
||||||
|
// placeholder, which signals "not loaded"). Don't crash.
|
||||||
|
}
|
||||||
|
setShowSecrets(prev => ({ ...prev, [key]: true }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sentinel the backend sends in place of any populated sensitive
|
||||||
|
// value (see SENSITIVE_PLACEHOLDER in notification_manager.py).
|
||||||
|
const SECRET_PLACEHOLDER = "************"
|
||||||
|
|
||||||
|
// Render value for a secret input:
|
||||||
|
// - if the eye is on AND the stored value is the masked placeholder
|
||||||
|
// AND we have a revealed cleartext for this key → show cleartext.
|
||||||
|
// - otherwise show whatever the input already has (the placeholder
|
||||||
|
// while masked, or the value the user is typing in editMode).
|
||||||
|
// This avoids overwriting an in-progress edit when the eye toggles.
|
||||||
|
const secretValue = (key: string, current: string): string => {
|
||||||
|
if (
|
||||||
|
showSecrets[key] &&
|
||||||
|
current === SECRET_PLACEHOLDER &&
|
||||||
|
revealedSecrets[key] !== undefined
|
||||||
|
) {
|
||||||
|
return revealedSecrets[key]
|
||||||
|
}
|
||||||
|
return current
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -1402,7 +1486,7 @@ export function NotificationSettings() {
|
|||||||
type={showSecrets["tg_token"] ? "text" : "password"}
|
type={showSecrets["tg_token"] ? "text" : "password"}
|
||||||
className={`h-7 text-xs font-mono ${!editMode ? "opacity-50" : ""}`}
|
className={`h-7 text-xs font-mono ${!editMode ? "opacity-50" : ""}`}
|
||||||
placeholder="7595377878:AAGE6Fb2cy... (with or without 'bot' prefix)"
|
placeholder="7595377878:AAGE6Fb2cy... (with or without 'bot' prefix)"
|
||||||
value={config.channels.telegram?.bot_token || ""}
|
value={secretValue("tg_token", config.channels.telegram?.bot_token || "")}
|
||||||
onChange={e => updateChannel("telegram", "bot_token", e.target.value)}
|
onChange={e => updateChannel("telegram", "bot_token", e.target.value)}
|
||||||
disabled={!editMode}
|
disabled={!editMode}
|
||||||
/>
|
/>
|
||||||
@@ -1519,7 +1603,7 @@ export function NotificationSettings() {
|
|||||||
type={showSecrets["gt_token"] ? "text" : "password"}
|
type={showSecrets["gt_token"] ? "text" : "password"}
|
||||||
className={`h-7 text-xs font-mono ${!editMode ? "opacity-50" : ""}`}
|
className={`h-7 text-xs font-mono ${!editMode ? "opacity-50" : ""}`}
|
||||||
placeholder="A_valid_gotify_token"
|
placeholder="A_valid_gotify_token"
|
||||||
value={config.channels.gotify?.token || ""}
|
value={secretValue("gt_token", config.channels.gotify?.token || "")}
|
||||||
onChange={e => updateChannel("gotify", "token", e.target.value)}
|
onChange={e => updateChannel("gotify", "token", e.target.value)}
|
||||||
disabled={!editMode}
|
disabled={!editMode}
|
||||||
/>
|
/>
|
||||||
@@ -1597,7 +1681,7 @@ export function NotificationSettings() {
|
|||||||
type={showSecrets["dc_hook"] ? "text" : "password"}
|
type={showSecrets["dc_hook"] ? "text" : "password"}
|
||||||
className={`h-7 text-xs font-mono ${!editMode ? "opacity-50" : ""}`}
|
className={`h-7 text-xs font-mono ${!editMode ? "opacity-50" : ""}`}
|
||||||
placeholder="https://discord.com/api/webhooks/..."
|
placeholder="https://discord.com/api/webhooks/..."
|
||||||
value={config.channels.discord?.webhook_url || ""}
|
value={secretValue("dc_hook", config.channels.discord?.webhook_url || "")}
|
||||||
onChange={e => updateChannel("discord", "webhook_url", e.target.value)}
|
onChange={e => updateChannel("discord", "webhook_url", e.target.value)}
|
||||||
disabled={!editMode}
|
disabled={!editMode}
|
||||||
/>
|
/>
|
||||||
@@ -1729,7 +1813,7 @@ export function NotificationSettings() {
|
|||||||
type={showSecrets["em_pass"] ? "text" : "password"}
|
type={showSecrets["em_pass"] ? "text" : "password"}
|
||||||
className={`h-7 text-xs font-mono ${!editMode ? "opacity-50" : ""}`}
|
className={`h-7 text-xs font-mono ${!editMode ? "opacity-50" : ""}`}
|
||||||
placeholder="App password"
|
placeholder="App password"
|
||||||
value={config.channels.email?.password || ""}
|
value={secretValue("em_pass", config.channels.email?.password || "")}
|
||||||
onChange={e => updateChannel("email", "password", e.target.value)}
|
onChange={e => updateChannel("email", "password", e.target.value)}
|
||||||
disabled={!editMode}
|
disabled={!editMode}
|
||||||
/>
|
/>
|
||||||
@@ -1840,14 +1924,14 @@ export function NotificationSettings() {
|
|||||||
type={showSecrets["apprise_url"] ? "text" : "password"}
|
type={showSecrets["apprise_url"] ? "text" : "password"}
|
||||||
className={`h-7 text-xs font-mono min-w-0 flex-1 ${!editMode ? "opacity-50" : ""}`}
|
className={`h-7 text-xs font-mono min-w-0 flex-1 ${!editMode ? "opacity-50" : ""}`}
|
||||||
placeholder="tgram://bottoken/ChatID"
|
placeholder="tgram://bottoken/ChatID"
|
||||||
value={config.channels.apprise?.url || ""}
|
value={secretValue("apprise_url", config.channels.apprise?.url || "")}
|
||||||
onChange={e => updateChannel("apprise", "url", e.target.value)}
|
onChange={e => updateChannel("apprise", "url", e.target.value)}
|
||||||
disabled={!editMode}
|
disabled={!editMode}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="h-7 w-7 shrink-0 flex items-center justify-center rounded-md border border-border hover:bg-muted text-muted-foreground"
|
className="h-7 w-7 shrink-0 flex items-center justify-center rounded-md border border-border hover:bg-muted text-muted-foreground"
|
||||||
onClick={() => setShowSecrets(s => ({ ...s, apprise_url: !s.apprise_url }))}
|
onClick={() => toggleSecret("apprise_url")}
|
||||||
title={showSecrets["apprise_url"] ? "Hide URL" : "Show URL"}
|
title={showSecrets["apprise_url"] ? "Hide URL" : "Show URL"}
|
||||||
>
|
>
|
||||||
{showSecrets["apprise_url"] ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
|
{showSecrets["apprise_url"] ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
|
||||||
@@ -2122,13 +2206,13 @@ export function NotificationSettings() {
|
|||||||
type={showSecrets["ai_key"] ? "text" : "password"}
|
type={showSecrets["ai_key"] ? "text" : "password"}
|
||||||
className="h-9 text-sm font-mono"
|
className="h-9 text-sm font-mono"
|
||||||
placeholder="sk-..."
|
placeholder="sk-..."
|
||||||
value={config.ai_api_keys?.[config.ai_provider] || ""}
|
value={secretValue("ai_key", config.ai_api_keys?.[config.ai_provider] || "")}
|
||||||
onChange={e => updateConfig(p => ({
|
onChange={e => updateConfig(p => ({
|
||||||
...p,
|
...p,
|
||||||
ai_api_keys: {
|
ai_api_keys: {
|
||||||
...p.ai_api_keys,
|
...p.ai_api_keys,
|
||||||
[p.ai_provider]: e.target.value
|
[p.ai_provider]: e.target.value
|
||||||
}
|
}
|
||||||
}))}
|
}))}
|
||||||
disabled={!editMode}
|
disabled={!editMode}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -266,6 +266,74 @@ def save_notification_settings():
|
|||||||
return jsonify({'error': f'Internal error ({type(e).__name__})'}), 500
|
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'])
|
@notification_bp.route('/api/notifications/test', methods=['POST'])
|
||||||
@require_auth
|
@require_auth
|
||||||
def test_notification():
|
def test_notification():
|
||||||
@@ -721,23 +789,108 @@ _PVE_OUR_HEADERS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _pve_webhook_url() -> str:
|
def _ssl_cert_hostname(cert_path: str) -> str:
|
||||||
"""Return http:// or https:// based on the current SSL config.
|
"""Pull the most useful hostname out of an x509 cert.
|
||||||
|
|
||||||
Hardcoded `http://...` previously broke webhook delivery whenever the
|
Preference order: first DNS SAN → CN. Returns '' on any failure.
|
||||||
user enabled SSL — Flask only listened on HTTPS, so PVE got connection
|
Used to build a webhook URL that won't fail PVE's TLS verification
|
||||||
refused and notifications stopped. Issue #194. PVE may still need
|
(issue #239 — PVE has no `--insecure` flag and the user's ACME cert
|
||||||
`update-ca-certificates` if the cert is self-signed; that's a doc
|
is bound to a hostname, not to `127.0.0.1`).
|
||||||
step on the user side.
|
"""
|
||||||
|
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:
|
try:
|
||||||
from auth_manager import load_ssl_config
|
from auth_manager import load_ssl_config
|
||||||
cfg = load_ssl_config() or {}
|
cfg = load_ssl_config() or {}
|
||||||
if cfg.get('enabled'):
|
if not cfg.get('enabled'):
|
||||||
return 'https://127.0.0.1:8008/api/notifications/webhook'
|
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:
|
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
|
# Backward-compat alias for callers that read this at import time. Most
|
||||||
|
|||||||
@@ -40,6 +40,12 @@ TOOL_METADATA = {
|
|||||||
'vfio_iommu': {'name': 'VFIO/IOMMU Passthrough', 'function': 'enable_vfio_iommu', 'version': '1.0'},
|
'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'},
|
'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'},
|
'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 ──
|
# ── Legacy / Deprecated entries ──
|
||||||
# These optimizations were applied by previous ProxMenux versions but are
|
# These optimizations were applied by previous ProxMenux versions but are
|
||||||
# no longer needed or have been removed from the current scripts. We still
|
# 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'
|
'message': 'No ProxMenux optimizations installed yet'
|
||||||
})
|
})
|
||||||
|
|
||||||
with open(installed_tools_path, 'r') as f:
|
# Use the shared loader so both update detection and this
|
||||||
raw = json.load(f)
|
# 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.
|
# Sprint 12A: index update list by tool key for has_update lookup.
|
||||||
try:
|
try:
|
||||||
@@ -237,20 +250,11 @@ def get_installed_tools():
|
|||||||
update_by_key = {u['key']: u for u in piv_snapshot.get('updates', [])}
|
update_by_key = {u['key']: u for u in piv_snapshot.get('updates', [])}
|
||||||
|
|
||||||
tools = []
|
tools = []
|
||||||
for tool_key, value in raw.items():
|
for tool_key, value in loaded.items():
|
||||||
# Normalize legacy bool vs new structured entry.
|
if not value.get('installed', False):
|
||||||
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:
|
|
||||||
continue
|
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).
|
# Hard-coded display metadata (display name, deprecated flag).
|
||||||
meta = TOOL_METADATA.get(tool_key, {})
|
meta = TOOL_METADATA.get(tool_key, {})
|
||||||
|
|||||||
@@ -3855,6 +3855,30 @@ class ProxmoxHookWatcher:
|
|||||||
'title': title or event_type,
|
'title': title or event_type,
|
||||||
'job_id': pve_job_id,
|
'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 ──
|
# ── Extract clean reason for system-mail events ──
|
||||||
# smartd and other system mail contains verbose boilerplate.
|
# smartd and other system mail contains verbose boilerplate.
|
||||||
@@ -3951,6 +3975,21 @@ class ProxmoxHookWatcher:
|
|||||||
if severity in ('error', 'err'):
|
if severity in ('error', 'err'):
|
||||||
return 'backup_fail', 'vm', ''
|
return 'backup_fail', 'vm', ''
|
||||||
return 'backup_complete', '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':
|
if pve_type == 'fencing':
|
||||||
return 'split_brain', 'node', ''
|
return 'split_brain', 'node', ''
|
||||||
|
|||||||
@@ -69,10 +69,17 @@ SENSITIVE_KEYS = {
|
|||||||
'ai_api_key_anthropic',
|
'ai_api_key_anthropic',
|
||||||
'ai_api_key_openai',
|
'ai_api_key_openai',
|
||||||
'ai_api_key_openrouter',
|
'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',
|
'gotify.token',
|
||||||
'discord.webhook_url',
|
'discord.webhook_url',
|
||||||
'email.password',
|
'email.password',
|
||||||
|
'apprise.url',
|
||||||
'webhook_secret',
|
'webhook_secret',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -657,6 +657,37 @@ TEMPLATES = {
|
|||||||
'group': 'backup',
|
'group': 'backup',
|
||||||
'default_enabled': True,
|
'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': {
|
'snapshot_complete': {
|
||||||
'title': '{hostname}: Snapshot created — {vmname} ({vmid})',
|
'title': '{hostname}: Snapshot created — {vmname} ({vmid})',
|
||||||
'body': 'Snapshot "{snapshot_name}" created for {vmname} (ID: {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': '',
|
'issue_list': '', 'error_key': '',
|
||||||
'storage_name': '', 'storage_type': '',
|
'storage_name': '', 'storage_type': '',
|
||||||
'important_list': 'none',
|
'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)
|
variables.update(data)
|
||||||
|
|
||||||
@@ -1574,6 +1610,9 @@ EVENT_EMOJI = {
|
|||||||
'replication_complete': '\u2705',
|
'replication_complete': '\u2705',
|
||||||
# Backups
|
# Backups
|
||||||
'backup_start': '\U0001F4BE\U0001F680', # 💾🚀 floppy + rocket
|
'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_complete': '\U0001F4BE\u2705', # 💾✅ floppy + check
|
||||||
'backup_warning': '\U0001F4BE\u26A0\uFE0F', # 💾⚠️ floppy + warning
|
'backup_warning': '\U0001F4BE\u26A0\uFE0F', # 💾⚠️ floppy + warning
|
||||||
'backup_fail': '\U0001F4BE\u274C', # 💾❌ floppy + cross
|
'backup_fail': '\U0001F4BE\u274C', # 💾❌ floppy + cross
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ import re
|
|||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
import os
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
_BASE = Path("/usr/local/share/proxmenux")
|
_BASE = Path("/usr/local/share/proxmenux")
|
||||||
_POST_INSTALL_DIR = _BASE / "scripts" / "post_install"
|
_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:
|
else:
|
||||||
# Unknown shape — treat as not installed rather than crash.
|
# Unknown shape — treat as not installed rather than crash.
|
||||||
normalized[key] = {"installed": False, "version": "", "source": ""}
|
normalized[key] = {"installed": False, "version": "", "source": ""}
|
||||||
|
|
||||||
|
_apply_legacy_detectors(normalized)
|
||||||
return 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
|
# Detection logic
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -99,6 +99,17 @@ _bk_pbs() {
|
|||||||
staged_size=$(hb_file_size "$staging_root/rootfs")
|
staged_size=$(hb_file_size "$staging_root/rootfs")
|
||||||
msg_ok "$(translate "Staging ready.") $(translate "Data size:") $staged_size"
|
msg_ok "$(translate "Staging ready.") $(translate "Data size:") $staged_size"
|
||||||
|
|
||||||
|
# Notify host_backup_start. Per-channel/per-event toggles in
|
||||||
|
# Settings decide whether the user actually receives it; same
|
||||||
|
# template the scheduled runner uses.
|
||||||
|
export HB_NOTIFY_JOB_ID="manual-pbs-${backup_id}"
|
||||||
|
export HB_NOTIFY_BACKEND="pbs"
|
||||||
|
export HB_NOTIFY_DESTINATION="$HB_PBS_REPOSITORY"
|
||||||
|
export HB_NOTIFY_PROFILE_MODE="$profile_mode"
|
||||||
|
export HB_NOTIFY_LOG_FILE="$log_file"
|
||||||
|
export HB_NOTIFY_DATA_SIZE="$staged_size"
|
||||||
|
hb_notify_lifecycle "start"
|
||||||
|
|
||||||
echo -e ""
|
echo -e ""
|
||||||
msg_info "$(translate "Connecting to PBS and starting backup...")"
|
msg_info "$(translate "Connecting to PBS and starting backup...")"
|
||||||
stop_spinner
|
stop_spinner
|
||||||
@@ -164,9 +175,19 @@ _bk_pbs() {
|
|||||||
[[ -s "$log_file" ]] && echo -e "${TAB}${BGN}$(translate "Log:")${CL} ${BL}${log_file}${CL}"
|
[[ -s "$log_file" ]] && echo -e "${TAB}${BGN}$(translate "Log:")${CL} ${BL}${log_file}${CL}"
|
||||||
echo -e ""
|
echo -e ""
|
||||||
msg_ok "$(translate "Backup completed successfully.")"
|
msg_ok "$(translate "Backup completed successfully.")"
|
||||||
|
export HB_NOTIFY_ARCHIVE_SIZE="-"
|
||||||
|
export HB_NOTIFY_DURATION="$(hb_human_elapsed "$elapsed" 2>/dev/null || echo "${elapsed}s")"
|
||||||
|
hb_notify_lifecycle "complete"
|
||||||
else
|
else
|
||||||
echo -e ""
|
echo -e ""
|
||||||
msg_error "$(translate "PBS backup failed.")"
|
msg_error "$(translate "PBS backup failed.")"
|
||||||
|
local _hb_reason
|
||||||
|
_hb_reason=$(grep -iE 'error|fail|fatal|abort' "$log_file" 2>/dev/null | tail -1 | sed 's/^[[:space:]]*//')
|
||||||
|
[[ -z "$_hb_reason" ]] && _hb_reason="proxmox-backup-client returned non-zero"
|
||||||
|
export HB_NOTIFY_ARCHIVE_SIZE="-"
|
||||||
|
export HB_NOTIFY_DURATION="$(hb_human_elapsed "$((SECONDS - t_start))" 2>/dev/null || echo "")"
|
||||||
|
export HB_NOTIFY_REASON="$_hb_reason"
|
||||||
|
hb_notify_lifecycle "fail"
|
||||||
hb_show_log "$log_file" "$(translate "PBS backup error log")"
|
hb_show_log "$log_file" "$(translate "PBS backup error log")"
|
||||||
echo -e ""
|
echo -e ""
|
||||||
msg_success "$(translate "Press Enter to return to menu...")"
|
msg_success "$(translate "Press Enter to return to menu...")"
|
||||||
@@ -215,6 +236,14 @@ _bk_borg() {
|
|||||||
staged_size=$(hb_file_size "$staging_root/rootfs")
|
staged_size=$(hb_file_size "$staging_root/rootfs")
|
||||||
msg_ok "$(translate "Staging ready.") $(translate "Data size:") $staged_size"
|
msg_ok "$(translate "Staging ready.") $(translate "Data size:") $staged_size"
|
||||||
|
|
||||||
|
export HB_NOTIFY_JOB_ID="manual-borg-${archive_name}"
|
||||||
|
export HB_NOTIFY_BACKEND="borg"
|
||||||
|
export HB_NOTIFY_DESTINATION="$repo"
|
||||||
|
export HB_NOTIFY_PROFILE_MODE="$profile_mode"
|
||||||
|
export HB_NOTIFY_LOG_FILE="$log_file"
|
||||||
|
export HB_NOTIFY_DATA_SIZE="$staged_size"
|
||||||
|
hb_notify_lifecycle "start"
|
||||||
|
|
||||||
msg_info "$(translate "Initializing Borg repository if needed...")"
|
msg_info "$(translate "Initializing Borg repository if needed...")"
|
||||||
if ! hb_borg_init_if_needed "$borg_bin" "$repo" "${BORG_ENCRYPT_MODE:-none}" >/dev/null 2>&1; then
|
if ! hb_borg_init_if_needed "$borg_bin" "$repo" "${BORG_ENCRYPT_MODE:-none}" >/dev/null 2>&1; then
|
||||||
msg_error "$(translate "Failed to initialize Borg repository at:") $repo"
|
msg_error "$(translate "Failed to initialize Borg repository at:") $repo"
|
||||||
@@ -252,9 +281,19 @@ _bk_borg() {
|
|||||||
[[ -s "$log_file" ]] && echo -e "${TAB}${BGN}$(translate "Log:")${CL} ${BL}${log_file}${CL}"
|
[[ -s "$log_file" ]] && echo -e "${TAB}${BGN}$(translate "Log:")${CL} ${BL}${log_file}${CL}"
|
||||||
echo -e ""
|
echo -e ""
|
||||||
msg_ok "$(translate "Backup completed successfully.")"
|
msg_ok "$(translate "Backup completed successfully.")"
|
||||||
|
export HB_NOTIFY_ARCHIVE_SIZE="$borg_compressed"
|
||||||
|
export HB_NOTIFY_DURATION="$(hb_human_elapsed "$elapsed" 2>/dev/null || echo "${elapsed}s")"
|
||||||
|
hb_notify_lifecycle "complete"
|
||||||
else
|
else
|
||||||
echo -e ""
|
echo -e ""
|
||||||
msg_error "$(translate "Borg backup failed.")"
|
msg_error "$(translate "Borg backup failed.")"
|
||||||
|
local _hb_reason
|
||||||
|
_hb_reason=$(grep -iE 'error|fail|fatal|abort' "$log_file" 2>/dev/null | tail -1 | sed 's/^[[:space:]]*//')
|
||||||
|
[[ -z "$_hb_reason" ]] && _hb_reason="borg create returned non-zero"
|
||||||
|
export HB_NOTIFY_ARCHIVE_SIZE="-"
|
||||||
|
export HB_NOTIFY_DURATION="$(hb_human_elapsed "$((SECONDS - t_start))" 2>/dev/null || echo "")"
|
||||||
|
export HB_NOTIFY_REASON="$_hb_reason"
|
||||||
|
hb_notify_lifecycle "fail"
|
||||||
hb_show_log "$log_file" "$(translate "Borg backup error log")"
|
hb_show_log "$log_file" "$(translate "Borg backup error log")"
|
||||||
echo -e ""
|
echo -e ""
|
||||||
msg_success "$(translate "Press Enter to return to menu...")"
|
msg_success "$(translate "Press Enter to return to menu...")"
|
||||||
@@ -331,6 +370,14 @@ _bk_local() {
|
|||||||
staged_size=$(hb_file_size "$staging_root/rootfs")
|
staged_size=$(hb_file_size "$staging_root/rootfs")
|
||||||
msg_ok "$(translate "Staging ready.") $(translate "Data size:") $staged_size"
|
msg_ok "$(translate "Staging ready.") $(translate "Data size:") $staged_size"
|
||||||
|
|
||||||
|
export HB_NOTIFY_JOB_ID="manual-local-$(basename "$archive" .tar.zst)"
|
||||||
|
export HB_NOTIFY_BACKEND="local"
|
||||||
|
export HB_NOTIFY_DESTINATION="$archive"
|
||||||
|
export HB_NOTIFY_PROFILE_MODE="$profile_mode"
|
||||||
|
export HB_NOTIFY_LOG_FILE="$log_file"
|
||||||
|
export HB_NOTIFY_DATA_SIZE="$staged_size"
|
||||||
|
hb_notify_lifecycle "start"
|
||||||
|
|
||||||
echo -e ""
|
echo -e ""
|
||||||
msg_info "$(translate "Creating compressed archive...")"
|
msg_info "$(translate "Creating compressed archive...")"
|
||||||
stop_spinner
|
stop_spinner
|
||||||
@@ -384,9 +431,19 @@ _bk_local() {
|
|||||||
[[ -s "$log_file" ]] && echo -e "${TAB}${BGN}$(translate "Log:")${CL} ${BL}${log_file}${CL}"
|
[[ -s "$log_file" ]] && echo -e "${TAB}${BGN}$(translate "Log:")${CL} ${BL}${log_file}${CL}"
|
||||||
echo -e ""
|
echo -e ""
|
||||||
msg_ok "$(translate "Backup completed successfully.")"
|
msg_ok "$(translate "Backup completed successfully.")"
|
||||||
|
export HB_NOTIFY_ARCHIVE_SIZE="$archive_size"
|
||||||
|
export HB_NOTIFY_DURATION="$(hb_human_elapsed "$elapsed" 2>/dev/null || echo "${elapsed}s")"
|
||||||
|
hb_notify_lifecycle "complete"
|
||||||
else
|
else
|
||||||
echo -e ""
|
echo -e ""
|
||||||
msg_error "$(translate "Local backup failed.")"
|
msg_error "$(translate "Local backup failed.")"
|
||||||
|
local _hb_reason
|
||||||
|
_hb_reason=$(grep -iE 'error|fail|fatal|abort' "$log_file" 2>/dev/null | tail -1 | sed 's/^[[:space:]]*//')
|
||||||
|
[[ -z "$_hb_reason" ]] && _hb_reason="tar/zstd returned non-zero"
|
||||||
|
export HB_NOTIFY_ARCHIVE_SIZE="-"
|
||||||
|
export HB_NOTIFY_DURATION="$(hb_human_elapsed "$elapsed" 2>/dev/null || echo "${elapsed}s")"
|
||||||
|
export HB_NOTIFY_REASON="$_hb_reason"
|
||||||
|
hb_notify_lifecycle "fail"
|
||||||
hb_show_log "$log_file" "$(translate "Local backup error log")"
|
hb_show_log "$log_file" "$(translate "Local backup error log")"
|
||||||
echo -e ""
|
echo -e ""
|
||||||
msg_success "$(translate "Press Enter to return to menu...")"
|
msg_success "$(translate "Press Enter to return to menu...")"
|
||||||
|
|||||||
@@ -2424,6 +2424,86 @@ hb_file_size() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# POST a Host Backup lifecycle event to the local webhook so the
|
||||||
|
# Monitor notification pipeline can route it through the host_backup_*
|
||||||
|
# event types (toggle-able per channel in Settings). Used by both the
|
||||||
|
# scheduled runner (`run_scheduled_backup.sh`) and the manual flow
|
||||||
|
# (`backup_host.sh`).
|
||||||
|
#
|
||||||
|
# Arg: action (start|complete|fail)
|
||||||
|
# Required env: HB_NOTIFY_JOB_ID (or `job_id` shell var)
|
||||||
|
# Optional env (per the host_backup_* template fields):
|
||||||
|
# HB_NOTIFY_BACKEND = pbs|local|borg
|
||||||
|
# HB_NOTIFY_DESTINATION = path / URI
|
||||||
|
# HB_NOTIFY_PROFILE_MODE = default|custom
|
||||||
|
# HB_NOTIFY_DATA_SIZE = staged data (e.g. "12.4 GiB")
|
||||||
|
# HB_NOTIFY_ARCHIVE_SIZE = output archive size (local) or '-'
|
||||||
|
# HB_NOTIFY_DURATION = pretty elapsed (e.g. "2m 13s")
|
||||||
|
# HB_NOTIFY_LOG_FILE = path to job log (rendered in fail body)
|
||||||
|
# HB_NOTIFY_REASON = short failure reason (last error line)
|
||||||
|
#
|
||||||
|
# Best-effort: the curl is bounded (--connect-timeout 3 / --max-time 5)
|
||||||
|
# and silenced so a Monitor outage never aborts the backup itself.
|
||||||
|
hb_notify_lifecycle() {
|
||||||
|
command -v jq >/dev/null 2>&1 || return 0
|
||||||
|
command -v curl >/dev/null 2>&1 || return 0
|
||||||
|
|
||||||
|
local action="$1"
|
||||||
|
local jid="${HB_NOTIFY_JOB_ID:-${job_id:-}}"
|
||||||
|
local severity="info" title="Host backup ${action}: ${jid:-?}"
|
||||||
|
case "$action" in
|
||||||
|
fail) severity="error"; title="Host backup FAILED: ${jid:-?}" ;;
|
||||||
|
complete) title="Host backup complete: ${jid:-?}" ;;
|
||||||
|
start) title="Host backup started: ${jid:-?}" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
local pve_type="proxmenux-host-backup-${action}"
|
||||||
|
local host
|
||||||
|
host="$(hostname 2>/dev/null || echo unknown)"
|
||||||
|
|
||||||
|
local message="Backend: ${HB_NOTIFY_BACKEND:-}
|
||||||
|
Destination: ${HB_NOTIFY_DESTINATION:-}
|
||||||
|
Profile: ${HB_NOTIFY_PROFILE_MODE:-default}
|
||||||
|
Data size: ${HB_NOTIFY_DATA_SIZE:-}
|
||||||
|
Archive size: ${HB_NOTIFY_ARCHIVE_SIZE:-}
|
||||||
|
Duration: ${HB_NOTIFY_DURATION:-}"
|
||||||
|
[[ "$action" == "fail" ]] && message+="
|
||||||
|
Reason: ${HB_NOTIFY_REASON:-unknown}
|
||||||
|
Log: ${HB_NOTIFY_LOG_FILE:-}"
|
||||||
|
|
||||||
|
local payload
|
||||||
|
payload=$(jq -nc \
|
||||||
|
--arg t "$title" \
|
||||||
|
--arg m "$message" \
|
||||||
|
--arg s "$severity" \
|
||||||
|
--arg ts "$(date -Iseconds)" \
|
||||||
|
--arg pty "$pve_type" \
|
||||||
|
--arg host "$host" \
|
||||||
|
--arg jid "${jid:-}" \
|
||||||
|
--arg backend "${HB_NOTIFY_BACKEND:-}" \
|
||||||
|
--arg dest "${HB_NOTIFY_DESTINATION:-}" \
|
||||||
|
--arg profile "${HB_NOTIFY_PROFILE_MODE:-default}" \
|
||||||
|
--arg dsize "${HB_NOTIFY_DATA_SIZE:-}" \
|
||||||
|
--arg asize "${HB_NOTIFY_ARCHIVE_SIZE:-}" \
|
||||||
|
--arg dur "${HB_NOTIFY_DURATION:-}" \
|
||||||
|
--arg log "${HB_NOTIFY_LOG_FILE:-}" \
|
||||||
|
--arg reason "${HB_NOTIFY_REASON:-}" \
|
||||||
|
'{title:$t, message:$m, severity:$s, timestamp:$ts,
|
||||||
|
fields:{type:$pty, hostname:$host, "job-id":$jid,
|
||||||
|
backend:$backend, destination:$dest,
|
||||||
|
profile_mode:$profile, data_size:$dsize,
|
||||||
|
archive_size:$asize, duration:$dur,
|
||||||
|
log_file:$log, reason:$reason}}' \
|
||||||
|
2>/dev/null)
|
||||||
|
[[ -z "$payload" ]] && return 0
|
||||||
|
curl -sk --connect-timeout 3 --max-time 5 \
|
||||||
|
-X POST \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$payload" \
|
||||||
|
http://127.0.0.1:8008/api/notifications/webhook \
|
||||||
|
>/dev/null 2>&1 || true
|
||||||
|
}
|
||||||
|
|
||||||
hb_show_log() {
|
hb_show_log() {
|
||||||
local logfile="$1" title="${2:-$(hb_translate "Operation log")}"
|
local logfile="$1" title="${2:-$(hb_translate "Operation log")}"
|
||||||
[[ -f "$logfile" && -s "$logfile" ]] || return 0
|
[[ -f "$logfile" && -s "$logfile" ]] || return 0
|
||||||
|
|||||||
@@ -384,6 +384,21 @@ main() {
|
|||||||
echo "Profile: ${PROFILE_MODE:-default}"
|
echo "Profile: ${PROFILE_MODE:-default}"
|
||||||
} >"$log_file"
|
} >"$log_file"
|
||||||
|
|
||||||
|
# Fire the lifecycle "start" event. Per-channel/per-event toggles in
|
||||||
|
# Settings decide whether the user actually receives it
|
||||||
|
# (host_backup_start ships with default_enabled=False).
|
||||||
|
local _hb_dest=""
|
||||||
|
case "${BACKEND:-}" in
|
||||||
|
local) _hb_dest="${LOCAL_DEST_DIR:-/var/lib/vz/dump}" ;;
|
||||||
|
borg) _hb_dest="${BORG_REPO:-}" ;;
|
||||||
|
pbs) _hb_dest="${PBS_REPOSITORY:-}" ;;
|
||||||
|
esac
|
||||||
|
export HB_NOTIFY_BACKEND="${BACKEND:-}"
|
||||||
|
export HB_NOTIFY_DESTINATION="$_hb_dest"
|
||||||
|
export HB_NOTIFY_PROFILE_MODE="${PROFILE_MODE:-default}"
|
||||||
|
export HB_NOTIFY_LOG_FILE="$log_file"
|
||||||
|
hb_notify_lifecycle "start"
|
||||||
|
|
||||||
local -a paths=()
|
local -a paths=()
|
||||||
if [[ "${PROFILE_MODE:-default}" == "custom" && -f "${JOBS_DIR}/${job_id}.paths" ]]; then
|
if [[ "${PROFILE_MODE:-default}" == "custom" && -f "${JOBS_DIR}/${job_id}.paths" ]]; then
|
||||||
mapfile -t paths < "${JOBS_DIR}/${job_id}.paths"
|
mapfile -t paths < "${JOBS_DIR}/${job_id}.paths"
|
||||||
@@ -466,15 +481,27 @@ main() {
|
|||||||
echo "Cleaning up staging area ..." >>"$log_file"
|
echo "Cleaning up staging area ..." >>"$log_file"
|
||||||
rm -rf "$stage_root"
|
rm -rf "$stage_root"
|
||||||
|
|
||||||
|
# Single source of truth for archive_size (was previously computed
|
||||||
|
# only inside the TTY block, which left the notification body with
|
||||||
|
# '-' for headless scheduled runs). Local backends know the actual
|
||||||
|
# output file; PBS / Borg backends manage their own dedupe storage
|
||||||
|
# so we leave '-' meaning "see repository for usage".
|
||||||
|
local archive_size="-"
|
||||||
|
if [[ "${BACKEND:-}" == "local" && -n "$archive_path" && -f "$archive_path" ]]; then
|
||||||
|
archive_size=$(hb_file_size "$archive_path" 2>/dev/null || echo '-')
|
||||||
|
fi
|
||||||
|
local pretty_duration
|
||||||
|
pretty_duration=$(hb_human_elapsed "$elapsed" 2>/dev/null || echo "${elapsed}s")
|
||||||
|
|
||||||
if [[ $rc -eq 0 ]]; then
|
if [[ $rc -eq 0 ]]; then
|
||||||
echo "RESULT=ok" >>"$summary_file"
|
echo "RESULT=ok" >>"$summary_file"
|
||||||
echo "LOG_FILE=${log_file}" >>"$summary_file"
|
echo "LOG_FILE=${log_file}" >>"$summary_file"
|
||||||
echo "=== Job finished OK at $(date -Iseconds) ===" >>"$log_file"
|
echo "=== Job finished OK at $(date -Iseconds) ===" >>"$log_file"
|
||||||
|
export HB_NOTIFY_DATA_SIZE="$staged_size"
|
||||||
|
export HB_NOTIFY_ARCHIVE_SIZE="$archive_size"
|
||||||
|
export HB_NOTIFY_DURATION="$pretty_duration"
|
||||||
|
hb_notify_lifecycle "complete"
|
||||||
if (( TTY )); then
|
if (( TTY )); then
|
||||||
local archive_size="-"
|
|
||||||
case "${BACKEND:-}" in
|
|
||||||
local) [[ -f "$archive_path" ]] && archive_size=$(hb_file_size "$archive_path") ;;
|
|
||||||
esac
|
|
||||||
local method_label
|
local method_label
|
||||||
case "${BACKEND:-}" in
|
case "${BACKEND:-}" in
|
||||||
local) method_label="Local archive (tar)" ;;
|
local) method_label="Local archive (tar)" ;;
|
||||||
@@ -499,6 +526,17 @@ main() {
|
|||||||
echo "RESULT=failed" >>"$summary_file"
|
echo "RESULT=failed" >>"$summary_file"
|
||||||
echo "LOG_FILE=${log_file}" >>"$summary_file"
|
echo "LOG_FILE=${log_file}" >>"$summary_file"
|
||||||
echo "=== Job finished with errors at $(date -Iseconds) ===" >>"$log_file"
|
echo "=== Job finished with errors at $(date -Iseconds) ===" >>"$log_file"
|
||||||
|
# Build a one-line reason from the last error-ish line in the log
|
||||||
|
# so the notification body has something actionable rather than a
|
||||||
|
# generic "Reason: unknown". Falls back to the literal exit code.
|
||||||
|
local _hb_reason=""
|
||||||
|
_hb_reason=$(grep -iE 'error|fail|fatal|abort' "$log_file" 2>/dev/null | tail -1 | sed 's/^[[:space:]]*//')
|
||||||
|
[[ -z "$_hb_reason" ]] && _hb_reason="Runner exited with status ${rc}"
|
||||||
|
export HB_NOTIFY_DATA_SIZE="${staged_size:-}"
|
||||||
|
export HB_NOTIFY_ARCHIVE_SIZE="$archive_size"
|
||||||
|
export HB_NOTIFY_DURATION="$pretty_duration"
|
||||||
|
export HB_NOTIFY_REASON="$_hb_reason"
|
||||||
|
hb_notify_lifecycle "fail"
|
||||||
(( TTY )) && msg_error "$(translate "Backup failed. See log:") $log_file"
|
(( TTY )) && msg_error "$(translate "Backup failed. See log:") $log_file"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -40,6 +40,14 @@ if [[ -f "$UTILS_FILE" ]]; then
|
|||||||
source "$UTILS_FILE"
|
source "$UTILS_FILE"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Pull in ensure_repositories + PROXMENUX_UTILS — customizable already
|
||||||
|
# sources this helper; auto did not, so a function that calls
|
||||||
|
# `ensure_repositories` (setup_proxmox_repositories below) needs the
|
||||||
|
# import here too.
|
||||||
|
if [[ -f "$LOCAL_SCRIPTS/global/utils-install-functions.sh" ]]; then
|
||||||
|
source "$LOCAL_SCRIPTS/global/utils-install-functions.sh"
|
||||||
|
fi
|
||||||
|
|
||||||
load_language
|
load_language
|
||||||
initialize_cache
|
initialize_cache
|
||||||
|
|
||||||
@@ -191,6 +199,35 @@ remove_subscription_banner() {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
setup_proxmox_repositories() {
|
||||||
|
local FUNC_VERSION="1.1"
|
||||||
|
# Description: Configure Proxmox + Debian APT repositories (no-subscription)
|
||||||
|
# and set the correct file permissions.
|
||||||
|
|
||||||
|
msg_info2 "$(translate "Configuring Proxmox APT repositories...")"
|
||||||
|
|
||||||
|
if ! ensure_repositories; then
|
||||||
|
msg_error "$(translate "Failed to configure Proxmox repositories")"
|
||||||
|
register_tool "proxmox_repos" false "$FUNC_VERSION"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Defensive re-chmod: if the files already existed with the old 0640
|
||||||
|
# permissions there is nothing for `ensure_repositories` to recreate,
|
||||||
|
# so we still flip the bits explicitly here.
|
||||||
|
local f
|
||||||
|
for f in /etc/apt/sources.list.d/proxmox.sources \
|
||||||
|
/etc/apt/sources.list.d/debian.sources \
|
||||||
|
/etc/apt/sources.list.d/pve-no-subscription.list \
|
||||||
|
/etc/apt/sources.list.d/pve-enterprise.list; do
|
||||||
|
[[ -f "$f" ]] && chmod 0644 "$f" 2>/dev/null
|
||||||
|
done
|
||||||
|
|
||||||
|
register_tool "proxmox_repos" true "$FUNC_VERSION"
|
||||||
|
msg_success "$(translate "Proxmox APT repositories configured")"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
configure_time_sync() {
|
configure_time_sync() {
|
||||||
local FUNC_VERSION="1.0"
|
local FUNC_VERSION="1.0"
|
||||||
# description: Detect timezone from public IP and enable systemd time sync (NTP).
|
# description: Detect timezone from public IP and enable systemd time sync (NTP).
|
||||||
|
|||||||
@@ -101,6 +101,32 @@ register_tool() {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
setup_proxmox_repositories() {
|
||||||
|
local FUNC_VERSION="1.1"
|
||||||
|
# Description: Configure Proxmox + Debian APT repositories (no-subscription)
|
||||||
|
# and set the correct file permissions.
|
||||||
|
|
||||||
|
msg_info2 "$(translate "Configuring Proxmox APT repositories...")"
|
||||||
|
|
||||||
|
if ! ensure_repositories; then
|
||||||
|
msg_error "$(translate "Failed to configure Proxmox repositories")"
|
||||||
|
register_tool "proxmox_repos" false "$FUNC_VERSION"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local f
|
||||||
|
for f in /etc/apt/sources.list.d/proxmox.sources \
|
||||||
|
/etc/apt/sources.list.d/debian.sources \
|
||||||
|
/etc/apt/sources.list.d/pve-no-subscription.list \
|
||||||
|
/etc/apt/sources.list.d/pve-enterprise.list; do
|
||||||
|
[[ -f "$f" ]] && chmod 0644 "$f" 2>/dev/null
|
||||||
|
done
|
||||||
|
|
||||||
|
register_tool "proxmox_repos" true "$FUNC_VERSION"
|
||||||
|
msg_success "$(translate "Proxmox APT repositories configured")"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
check_extremeshok_warning() {
|
check_extremeshok_warning() {
|
||||||
local marker_file="/etc/extremeshok"
|
local marker_file="/etc/extremeshok"
|
||||||
|
|
||||||
@@ -2930,6 +2956,7 @@ custom_post_category_label() {
|
|||||||
|
|
||||||
custom_post_description_label() {
|
custom_post_description_label() {
|
||||||
case "$1" in
|
case "$1" in
|
||||||
|
"Configure Proxmox APT repositories") translate "Configure Proxmox APT repositories" ;;
|
||||||
"Update and upgrade system") translate "Update and upgrade system" ;;
|
"Update and upgrade system") translate "Update and upgrade system" ;;
|
||||||
"Synchronize time automatically") translate "Synchronize time automatically" ;;
|
"Synchronize time automatically") translate "Synchronize time automatically" ;;
|
||||||
"Skip downloading additional languages") translate "Skip downloading additional languages" ;;
|
"Skip downloading additional languages") translate "Skip downloading additional languages" ;;
|
||||||
@@ -3049,6 +3076,7 @@ main_menu() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
local options=(
|
local options=(
|
||||||
|
"Basic Settings|Configure Proxmox APT repositories|REPOS"
|
||||||
"Basic Settings|Update and upgrade system|APTUPGRADE"
|
"Basic Settings|Update and upgrade system|APTUPGRADE"
|
||||||
"Basic Settings|Synchronize time automatically|TIMESYNC"
|
"Basic Settings|Synchronize time automatically|TIMESYNC"
|
||||||
"Basic Settings|Skip downloading additional languages|NOAPTLANG"
|
"Basic Settings|Skip downloading additional languages|NOAPTLANG"
|
||||||
@@ -3184,6 +3212,7 @@ done
|
|||||||
IFS='|' read -r _ description function_name <<< "$option"
|
IFS='|' read -r _ description function_name <<< "$option"
|
||||||
if [[ ${selected_functions[$function_name]} -eq 1 ]]; then
|
if [[ ${selected_functions[$function_name]} -eq 1 ]]; then
|
||||||
case $function_name in
|
case $function_name in
|
||||||
|
REPOS) setup_proxmox_repositories ;;
|
||||||
APTUPGRADE) apt_upgrade ;;
|
APTUPGRADE) apt_upgrade ;;
|
||||||
TIMESYNC) configure_time_sync ;;
|
TIMESYNC) configure_time_sync ;;
|
||||||
NOAPTLANG) skip_apt_languages ;;
|
NOAPTLANG) skip_apt_languages ;;
|
||||||
|
|||||||
@@ -75,7 +75,16 @@ SOURCED_CUSTOM=0
|
|||||||
ensure_flow_loaded() {
|
ensure_flow_loaded() {
|
||||||
local source_type="$1"
|
local source_type="$1"
|
||||||
case "$source_type" in
|
case "$source_type" in
|
||||||
auto)
|
# `detected` is the synthetic source the registry assigns to a
|
||||||
|
# tool when a legacy detector finds it on the host but the
|
||||||
|
# entry was never written to installed_tools.json (e.g.
|
||||||
|
# proxmox_repos on a pre-1.2.2 install). Treat it as `auto`
|
||||||
|
# for the source-loading step — the legacy-tool's wrapper
|
||||||
|
# function lives in auto_post_install.sh, and once the update
|
||||||
|
# runs successfully `register_tool` overwrites the synthetic
|
||||||
|
# entry with source="auto" so subsequent runs don't hit this
|
||||||
|
# branch again.
|
||||||
|
auto|detected)
|
||||||
if [[ $SOURCED_AUTO -eq 0 ]]; then
|
if [[ $SOURCED_AUTO -eq 0 ]]; then
|
||||||
# shellcheck disable=SC1091
|
# shellcheck disable=SC1091
|
||||||
source "$LOCAL_SCRIPTS/post_install/auto_post_install.sh"
|
source "$LOCAL_SCRIPTS/post_install/auto_post_install.sh"
|
||||||
@@ -90,7 +99,7 @@ ensure_flow_loaded() {
|
|||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "ERROR: invalid source '$source_type' (must be 'auto' or 'custom')"
|
echo "ERROR: invalid source '$source_type' (must be 'auto', 'custom', or 'detected')"
|
||||||
return 2
|
return 2
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|||||||
Reference in New Issue
Block a user