)}
+ {/* Surface the backend's error message when a Load attempt
+ returns empty — silent dropdown was invisible to the user
+ (issue #325). Cleared when the next successful load lands. */}
+ {providerModels.length === 0 && providerModelsError && !loadingProviderModels && (
+
{providerModelsError}
+ )}
{/* Prompt Mode section */}
diff --git a/AppImage/components/release-notes-modal.tsx b/AppImage/components/release-notes-modal.tsx
index 53466ab2..f21b7cfa 100644
--- a/AppImage/components/release-notes-modal.tsx
+++ b/AppImage/components/release-notes-modal.tsx
@@ -18,6 +18,23 @@ interface ReleaseNote {
}
export const CHANGELOG: Record = {
+ "1.2.6": {
+ date: "September 2, 2026",
+ changes: {
+ added: [
+ "Borg remote target — the Add Borg destination dialog in the Monitor and the shell TUI (menu → Host Backup → New Borg target) accept a custom SSH port; the default stays at 22 and existing entries created without a port keep working. BORG_RSH, the auto key install flow and the capacity probe all honour the custom port (suggested by @songochain in discussion #236).",
+ "GitHub API — Settings → GitHub API accepts an optional personal access token for release and tag checks when GitHub's anonymous quota is exhausted. The token is encrypted at rest and never returned to the browser (suggested by @SystemIdleProcess in discussion #306).",
+ ],
+ changed: [
+ "Notification delivery is atomic — events reserve their deduplication fingerprint before AI processing and channel delivery, so concurrent collectors or parallel Monitor processes cannot send the same event twice. The reservation is shared through SQLite and released when no channel succeeds, preserving retries after temporary transport failures.",
+ "Native Proxmox replication failure notifications now resolve the replication job ID, affected VM/LXC ID and guest name; the exact error block from Proxmox is preserved as the reason, and each replication job's failures deduplicate independently (reported by Ale R.).",
+ ],
+ fixed: [
+ "AI Assistant custom OpenAI endpoint — endpoints reachable on private IPs, loopback or Docker networks (LiteLLM, LM Studio, LocalAI, vLLM, OmniRoute, self-hosted proxies…) are accepted when loading the model catalogue and validating the AI configuration. The dropdown surfaces the reason returned by the server (or the underlying network error) directly under the Load button, in every Monitor language (#325, reported by @jorgeffonte).",
+ "Secure Gateway wizard — Alpine template download and local template selection filter by the host's real architecture (via dpkg --print-architecture, falling back to uname -m); pct create is invoked with an explicit --arch so container metadata matches the host on both x86_64 and arm64 (#324, reported by @N0X4DD0).",
+ ],
+ },
+ },
"1.2.5": {
date: "September 1, 2026",
changes: {
@@ -289,28 +306,33 @@ export const CHANGELOG: Record = {
const CURRENT_VERSION_FEATURES = [
{
icon: ,
- key: "releaseNotes.currentFeatures.appsDashboard",
- text: "New top-level Apps dashboard — a single launcher for every Web Link across the node. LXC-registered apps and user-defined Custom Web Links share the same grid with category badges, search, and one-click deep-links back to the guest modal.",
+ key: "releaseNotes.currentFeatures.aiCustomEndpoint",
+ text: "AI Assistant custom OpenAI endpoint — LiteLLM, LM Studio, LocalAI, vLLM, OmniRoute and any self-hosted proxy on private IPs, loopback or Docker networks are recognised when loading the model catalogue. The dropdown surfaces the server's error (or the underlying network reason) directly under the Load button (#325, reported by @jorgeffonte).",
},
{
- icon: ,
- key: "releaseNotes.currentFeatures.lxcAppsUpdates",
- text: "App tab inside every LXC modal registers installed apps, captures weblinks and tracks upstream versions. Reworked Updates tab applies OS packages and app updates from a single button; Docker Engine and per-image tracking follow the same 24-hour cycle, with a 'Check now' action on demand.",
+ icon: ,
+ key: "releaseNotes.currentFeatures.secureGatewayArch",
+ text: "Secure Gateway wizard — the Alpine template download, local template selection and pct create all match the host's real architecture, so x86_64 hosts receive amd64 containers and arm64 hosts receive arm64 containers (#324, reported by @N0X4DD0).",
},
{
- icon: ,
- key: "releaseNotes.currentFeatures.appCatalog",
- text: "New application detection catalog with over 380 tracked workloads, generated live from community-scripts across seven detector methods (file, binary, dpkg, apk, Python, Docker exec, Docker label). Primary and fallback detectors cover both new and historical LXC layouts.",
+ icon: ,
+ key: "releaseNotes.currentFeatures.atomicNotifications",
+ text: "Notification events reserve their deduplication fingerprint atomically before AI processing and channel delivery, so concurrent collectors, completion callbacks or parallel Monitor processes cannot send the same event twice. The reservation is released when no channel succeeds, preserving retries.",
},
{
- icon: ,
- key: "releaseNotes.currentFeatures.multilingual",
- text: "The Monitor now speaks 8 languages: English, Spanish, German, French, Italian, Portuguese, Swedish and Slovak. Huge thanks to @vaso73 for building the i18n scaffolding that made this possible.",
+ icon: ,
+ key: "releaseNotes.currentFeatures.borgSshPort",
+ text: "Borg remote target — the Add Borg destination dialog and the shell TUI accept a custom SSH port. BORG_RSH, the auto key install flow and the capacity probe all honour it. Fully backwards compatible with existing entries created without an explicit port (suggested by @songochain in discussion #236).",
},
{
- icon: ,
- key: "releaseNotes.currentFeatures.nvidiaMultiGpu",
- text: "NVIDIA driver lifecycle moves to per-BDF ownership so a multi-GPU host can pass one card to a VM and keep the other operational on the host or in LXCs, plus a kernel + branch + GPU-aware version picker (#298).",
+ icon: ,
+ key: "releaseNotes.currentFeatures.githubToken",
+ text: "Settings → GitHub API accepts an optional personal access token for release and tag checks when the anonymous quota is exhausted. The token is encrypted at rest and never returned to the browser; the rate-limit error is translated in every Monitor language (suggested by @SystemIdleProcess in discussion #306).",
+ },
+ {
+ icon: ,
+ key: "releaseNotes.currentFeatures.replicationContext",
+ text: "Native Proxmox replication failure notifications resolve the replication job ID, affected VM/LXC ID and guest name; the exact error block from Proxmox is preserved as the reason, and each replication job deduplicates independently (reported by Ale R.).",
},
]
diff --git a/AppImage/components/settings.tsx b/AppImage/components/settings.tsx
index a80de8da..a110b439 100644
--- a/AppImage/components/settings.tsx
+++ b/AppImage/components/settings.tsx
@@ -2,7 +2,7 @@
import { useState, useEffect } from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
-import { Wrench, Package, Ruler, HeartPulse, Cpu, MemoryStick, HardDrive, CircleDot, Network, Server, Settings2, FileText, RefreshCw, Shield, AlertTriangle, Info, Loader2, Check, Database, CloudOff, Code, X, Copy, Sparkles, ArrowUpCircle, BellOff, Globe2 } from "lucide-react"
+import { Wrench, Package, Ruler, HeartPulse, Cpu, MemoryStick, HardDrive, CircleDot, Network, Server, Settings2, FileText, RefreshCw, Shield, AlertTriangle, Info, Loader2, Check, Database, CloudOff, Code, X, Copy, Sparkles, ArrowUpCircle, BellOff, Globe2, Github } from "lucide-react"
import { Badge } from "./ui/badge"
import { Button } from "./ui/button"
import { NotificationSettings } from "./notification-settings"
@@ -392,6 +392,17 @@ export function Settings() {
const [loadingInterfaces, setLoadingInterfaces] = useState(true)
const [savingInterface, setSavingInterface] = useState(null)
+ // Optional GitHub API authentication for app release/tag checks. The
+ // backend only returns whether a token exists; the secret itself never
+ // leaves the host after it has been saved.
+ const [githubTokenConfigured, setGithubTokenConfigured] = useState(false)
+ const [githubTokenLoading, setGithubTokenLoading] = useState(true)
+ const [githubTokenEditMode, setGithubTokenEditMode] = useState(false)
+ const [githubTokenDraft, setGithubTokenDraft] = useState("")
+ const [githubTokenSaving, setGithubTokenSaving] = useState(false)
+ const [githubTokenSaved, setGithubTokenSaved] = useState(false)
+ const [githubTokenError, setGithubTokenError] = useState("")
+
// Active Suppressions panel — lists every error currently dismissed
// (time-limited or permanent) so the user can re-enable individual
// alerts. Mirrors what /api/health/full returns under `dismissed`.
@@ -451,6 +462,63 @@ export function Settings() {
}
}
+ const loadGithubTokenStatus = async () => {
+ setGithubTokenLoading(true)
+ try {
+ const data = await fetchApi<{ configured: boolean }>("/api/apps/github-token")
+ setGithubTokenConfigured(!!data.configured)
+ setGithubTokenError("")
+ } catch (err) {
+ console.error("Failed to load GitHub API token status:", err)
+ setGithubTokenError(t("settings.githubApi.loadFailed"))
+ } finally {
+ setGithubTokenLoading(false)
+ }
+ }
+
+ const saveGithubToken = async () => {
+ const token = githubTokenDraft.trim()
+ if (!token) return
+ setGithubTokenSaving(true)
+ setGithubTokenError("")
+ try {
+ await fetchApi<{ success: boolean; configured: boolean }>("/api/apps/github-token", {
+ method: "PUT",
+ body: JSON.stringify({ token }),
+ })
+ setGithubTokenConfigured(true)
+ setGithubTokenDraft("")
+ setGithubTokenEditMode(false)
+ setGithubTokenSaved(true)
+ window.setTimeout(() => setGithubTokenSaved(false), 2500)
+ } catch (err) {
+ console.error("Failed to save GitHub API token:", err)
+ setGithubTokenError(t("settings.githubApi.saveFailed"))
+ } finally {
+ setGithubTokenSaving(false)
+ }
+ }
+
+ const removeGithubToken = async () => {
+ setGithubTokenSaving(true)
+ setGithubTokenError("")
+ try {
+ await fetchApi<{ success: boolean; configured: boolean }>("/api/apps/github-token", {
+ method: "DELETE",
+ })
+ setGithubTokenConfigured(false)
+ setGithubTokenDraft("")
+ setGithubTokenEditMode(false)
+ setGithubTokenSaved(true)
+ window.setTimeout(() => setGithubTokenSaved(false), 2500)
+ } catch (err) {
+ console.error("Failed to remove GitHub API token:", err)
+ setGithubTokenError(t("settings.githubApi.removeFailed"))
+ } finally {
+ setGithubTokenSaving(false)
+ }
+ }
+
useEffect(() => {
loadProxmenuxTools()
getUnitsSettings()
@@ -459,6 +527,7 @@ export function Settings() {
loadActiveSuppressions()
loadNetworkInterfaces()
loadSnippetsStorage()
+ loadGithubTokenStatus()
}, [])
// Refresh the Active Suppressions list whenever:
@@ -1803,6 +1872,113 @@ export function Settings() {
is re-enabled). */}
+ {/* GitHub API — optional authentication for app upstream checks. */}
+
+
+
+ )
+ }
+
// Load the schedule once whenever the user opens the Updates tab
// of a specific LXC. Keying on vmid keeps us from re-fetching on
// every render but also refetches after switching CTs.
@@ -2134,9 +2223,15 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
if (activeModalTab !== "updates") return
if (!selectedVM || selectedVM.type !== "lxc") return
if (scheduleLoaded !== selectedVM.vmid) loadSchedule(selectedVM.vmid)
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [activeModalTab, selectedVM?.vmid, selectedVM?.modal_cache_revision, scheduleLoaded])
+
+ useEffect(() => {
+ if (activeModalTab !== "updates") return
+ if (!selectedVM || selectedVM.type !== "lxc") return
if (bulkLoaded !== selectedVM.vmid) loadBulkUpdate(selectedVM.vmid)
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [activeModalTab, selectedVM?.vmid])
+ }, [activeModalTab, selectedVM?.vmid, bulkLoaded])
// Docker drift is opt-in: read it only after Docker has been registered and
// only when the user opens Updates. This request deliberately DOES NOT use
@@ -6198,29 +6293,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
<> · {t("vmLxc.scheduled.releaseDelaySummary", { days: scheduleReleaseDelayDays })}>
)}
- )}
+ {renderScheduleRunDetails()}
)}
@@ -6996,6 +7051,51 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
+
+
{/* LXC Terminal Modal */}
{terminalVmid !== null && (
None:
if guest_type == 'lxc':
_invalidate_lxc_ip(guest_id)
if action in ('start', 'reboot'):
+ if guest_type == 'lxc':
+ # TaskWatcher already owns the authoritative lifecycle event.
+ # Reuse it to retire a reboot-required warning left by the last
+ # scheduled update instead of adding another polling loop.
+ try:
+ import lxc_apps
+ lxc_apps.clear_schedule_reboot_required(guest_id)
+ _vm_cache_invalidate(guest_id, _vm_schedule_cache)
+ except Exception as exc:
+ print(
+ f'[ProxMenux] could not clear scheduled-update reboot state '
+ f'for CT {guest_id}: {exc}',
+ flush=True,
+ )
_schedule_started_guest_refresh(guest_id, guest_type)
return
if action == 'stop':
@@ -13626,6 +13640,42 @@ def api_vm_apps_schedule(vmid):
return jsonify(result)
+@app.route('/api/vms//schedule/log', methods=['GET'])
+@require_auth
+def api_vm_apps_schedule_log(vmid):
+ """Return the bounded tail of the latest scheduled-update log."""
+ try:
+ import lxc_apps
+ schedule = lxc_apps.get_schedule(vmid) or {}
+ except Exception as exc:
+ return jsonify({'error': f'lxc_apps unavailable: {exc}'}), 500
+ name = os.path.basename(str(schedule.get('last_run_log') or ''))
+ if not _LXC_UPDATE_LOG_RE.fullmatch(name) or not name.startswith(f'{vmid}-'):
+ return jsonify({'error': 'no scheduled update log is available'}), 404
+ path = os.path.join(_LXC_UPDATE_LOG_DIR, name)
+ if not os.path.isfile(path):
+ return jsonify({'error': 'scheduled update log was not found'}), 404
+ try:
+ size = os.path.getsize(path)
+ offset = max(0, size - _LXC_UPDATE_LOG_READ_LIMIT)
+ with open(path, 'rb') as stream:
+ stream.seek(offset)
+ content = stream.read(_LXC_UPDATE_LOG_READ_LIMIT).decode('utf-8', errors='replace')
+ if offset:
+ newline = content.find('\n')
+ if newline >= 0:
+ content = content[newline + 1:]
+ return jsonify({
+ 'content': content,
+ 'size': size,
+ 'truncated': bool(offset),
+ 'run_at': schedule.get('last_run_at'),
+ 'status': schedule.get('last_run_status'),
+ })
+ except OSError as exc:
+ return jsonify({'error': str(exc)}), 500
+
+
@app.route('/api/vms//bulk-update', methods=['GET', 'PUT', 'DELETE'])
@require_auth
def api_vm_bulk_update(vmid):
@@ -13747,6 +13797,54 @@ def api_apps_catalog():
return jsonify({'error': str(e)}), 500
+@app.route('/api/apps/github-token', methods=['GET'])
+@require_auth
+def api_apps_github_token_status():
+ """Return whether the optional GitHub API token is configured.
+
+ The token itself is deliberately never returned to the client.
+ """
+ try:
+ if not notification_manager._config:
+ notification_manager._load_config()
+ value = notification_manager._config.get('github_pat', '')
+ return jsonify({'configured': bool(isinstance(value, str) and value.strip())})
+ except Exception as e:
+ return jsonify({'error': str(e)}), 500
+
+
+@app.route('/api/apps/github-token', methods=['PUT'])
+@require_admin_scope
+def api_apps_github_token_save():
+ """Store the GitHub API token in the existing encrypted settings store."""
+ payload = request.get_json(silent=True) or {}
+ token = payload.get('token')
+ if not isinstance(token, str):
+ return jsonify({'error': 'token must be a string'}), 400
+ token = token.strip()
+ if not token:
+ return jsonify({'error': 'token is required'}), 400
+ if len(token) > 512:
+ return jsonify({'error': 'token exceeds the 512 character limit'}), 400
+ if any(ch.isspace() or ord(ch) < 33 or ord(ch) == 127 for ch in token):
+ return jsonify({'error': 'token contains whitespace or control characters'}), 400
+
+ result = notification_manager.save_settings({'github_pat': token})
+ if not result.get('success'):
+ return jsonify({'error': result.get('error', 'failed to save token')}), 500
+ return jsonify({'success': True, 'configured': True})
+
+
+@app.route('/api/apps/github-token', methods=['DELETE'])
+@require_admin_scope
+def api_apps_github_token_remove():
+ """Clear the optional GitHub API token without exposing its old value."""
+ result = notification_manager.save_settings({'github_pat': ''})
+ if not result.get('success'):
+ return jsonify({'error': result.get('error', 'failed to remove token')}), 500
+ return jsonify({'success': True, 'configured': False})
+
+
@app.route('/api/lxc-apps/dockerhub-tag-preview', methods=['POST'])
@require_auth
def api_lxc_apps_dockerhub_tag_preview():
@@ -14050,6 +14148,8 @@ def _lxc_update_details(
after: dict,
verification_pending: bool,
verification_errors: list[str],
+ reboot_required: bool | None,
+ reboot_packages: list[str],
) -> str:
lines = [
f"Source: {'Scheduled' if source == 'scheduled' else 'Manual'}",
@@ -14122,6 +14222,12 @@ def _lxc_update_details(
lines.append('Deferred targets: ' + ', '.join(deferred_targets))
if reason:
lines.append(f'Reason: {reason}')
+ if reboot_required is True:
+ lines.append('Restart required: yes')
+ if reboot_packages:
+ lines.append('Restart-triggering packages: ' + ', '.join(reboot_packages[:12]))
+ elif reboot_required is False:
+ lines.append('Restart required: no')
if verification_pending:
lines.append('Verification pending until the container is running')
for error in verification_errors[:4]:
@@ -14146,6 +14252,8 @@ def _finalize_lxc_update(
reason: str | None = None,
refresh_docker_inventory: bool = False,
before_snapshot: dict | None = None,
+ reboot_required: bool | None = None,
+ reboot_packages=None,
) -> dict:
safe_run_id = _normalise_lxc_update_run_id(run_id)
key = (int(vmid), safe_run_id)
@@ -14225,6 +14333,8 @@ def _finalize_lxc_update(
after=after,
verification_pending=verification_pending,
verification_errors=verification_errors,
+ reboot_required=reboot_required,
+ reboot_packages=list(reboot_packages or []),
)
try:
notification_manager.emit_event(
@@ -14255,6 +14365,8 @@ def _finalize_lxc_update(
'verification_pending': verification_pending,
'verification_errors': verification_errors,
'docker_inventory': docker_inventory,
+ 'reboot_required': reboot_required,
+ 'reboot_packages': list(reboot_packages or []),
}
with _lxc_update_finalization_lock:
_lxc_update_finalizations[key] = {
@@ -16380,11 +16492,29 @@ def _list_borg_destinations() -> list:
encrypt_mode = (parts[3] if len(parts) > 3 else '').strip() or 'repokey'
pass_file = f'{_BACKUP_STATE_DIR}/borg-pass-{name}.txt'
has_passphrase = os.path.isfile(pass_file)
+ # Parse ssh://user@host[:port]/path so the frontend can
+ # display the port without having to re-parse the URL.
+ # Non-ssh targets (local paths) leave ssh_port at 0.
+ ssh_port = 0
+ if repo.startswith('ssh://'):
+ after_scheme = repo[len('ssh://'):]
+ at_split = after_scheme.split('@', 1)
+ if len(at_split) == 2:
+ host_and_path = at_split[1]
+ host_part = host_and_path.split('/', 1)[0]
+ if ':' in host_part:
+ try:
+ ssh_port = int(host_part.rsplit(':', 1)[1])
+ except (ValueError, IndexError):
+ ssh_port = 0
+ if ssh_port == 0:
+ ssh_port = 22
targets.append({
'name': name,
'repository': repo,
'ssh_key': ssh_key,
'ssh_key_path': ssh_key,
+ 'ssh_port': ssh_port,
'encrypt_mode': encrypt_mode,
'has_passphrase': has_passphrase,
'jobs_using': _jobs_using_borg(repo),
@@ -16649,15 +16779,21 @@ def _capacity_local(path: str) -> dict:
}
-def _capacity_borg_ssh(host: str, user: str, remote_path: str, key_path: str = '') -> dict:
+def _capacity_borg_ssh(host: str, user: str, remote_path: str, key_path: str = '',
+ port: int = 22) -> dict:
"""Run `df -B1 --output=size,used,avail` over ssh against the
remote borg repo path. Times out fast — failure is just rendered
- as a missing capacity badge in the UI, not a hard error."""
+ as a missing capacity badge in the UI, not a hard error.
+
+ ``port`` defaults to 22 (standard SSH); pass a different value for
+ NAS-style hosts that expose SSH on a custom port."""
if not host or not user or not remote_path:
return {'error': 'incomplete ssh target'}
ssh_target = f'{user}@{host}'
cmd = ['ssh', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5',
'-o', 'StrictHostKeyChecking=accept-new']
+ if port and port != 22:
+ cmd += ['-p', str(int(port))]
if key_path:
cmd += ['-i', key_path]
cmd += [ssh_target, f'df -B1 --output=size,used,avail {shlex.quote(remote_path)}']
@@ -18017,11 +18153,17 @@ def api_host_backups_dest_capacity():
if kind == 'local' or kind == 'borg-local':
cap = _capacity_local((t.get('path') or '').strip())
elif kind == 'borg-ssh':
+ port_raw = t.get('port')
+ try:
+ port = int(port_raw) if port_raw not in (None, '', 0, '0') else 22
+ except (TypeError, ValueError):
+ port = 22
cap = _capacity_borg_ssh(
(t.get('host') or '').strip(),
(t.get('user') or '').strip(),
(t.get('remote_path') or '').strip(),
(t.get('key_path') or '').strip(),
+ port=port,
)
elif kind == 'pbs':
cap = _capacity_pbs(
@@ -19219,7 +19361,23 @@ def api_host_backups_dest_borg_add():
rpath = (payload.get('ssh_remote_path') or '').strip().lstrip('/')
if not user or not host or not rpath:
return jsonify({'error': 'ssh_user, ssh_host and ssh_remote_path are required for ssh mode'}), 400
- repo = f'ssh://{user}@{host}/{rpath}'
+ # Optional custom SSH port — NAS-style hosts often expose SSH on
+ # a non-standard port to reduce noise from bots. Empty / missing
+ # means default 22, which we leave out of the URL so existing
+ # targets stay byte-identical to how the shell installer writes them.
+ raw_port = payload.get('ssh_port')
+ ssh_port = 22
+ if raw_port not in (None, '', 0, '0'):
+ try:
+ ssh_port = int(raw_port)
+ except (TypeError, ValueError):
+ return jsonify({'error': 'ssh_port must be an integer between 1 and 65535'}), 400
+ if not (1 <= ssh_port <= 65535):
+ return jsonify({'error': 'ssh_port must be an integer between 1 and 65535'}), 400
+ if ssh_port == 22:
+ repo = f'ssh://{user}@{host}/{rpath}'
+ else:
+ repo = f'ssh://{user}@{host}:{ssh_port}/{rpath}'
ssh_key = (payload.get('ssh_key_path') or '').strip()
elif mode == 'local':
repo = (payload.get('repo') or '').strip()
@@ -21484,6 +21642,97 @@ _DOCKER_ENGINE_INTEGRATED_COMMAND = (
'update_docker_engine.py --vmid "$VMID"'
)
_scheduled_fired_this_minute: set = set()
+_LXC_UPDATE_LOG_DIR = "/usr/local/share/proxmenux/logs/lxc-updates"
+_LXC_UPDATE_LOG_RE = re.compile(r'^[1-9][0-9]*-scheduled-[a-f0-9]{32}\.log$')
+_LXC_UPDATE_LOG_KEEP_PER_CT = 10
+_LXC_UPDATE_LOG_READ_LIMIT = 2 * 1024 * 1024
+
+
+def _create_lxc_update_log(vmid: int, run_id: str) -> tuple[str | None, str | None]:
+ """Create a private, persistent log for one scheduled LXC run."""
+ name = f'{int(vmid)}-{run_id}.log'
+ if not _LXC_UPDATE_LOG_RE.fullmatch(name):
+ return None, None
+ try:
+ os.makedirs(_LXC_UPDATE_LOG_DIR, mode=0o700, exist_ok=True)
+ path = os.path.join(_LXC_UPDATE_LOG_DIR, name)
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
+ with os.fdopen(fd, 'w', encoding='utf-8', errors='replace') as stream:
+ stream.write(f'=== ProxMenux scheduled LXC update — CT {vmid} ===\n')
+ stream.write(f'Run ID: {run_id}\n')
+ stream.write(f'Started: {datetime.now().astimezone().isoformat()}\n\n')
+ return name, path
+ except OSError as exc:
+ print(f'[ProxMenux] scheduler: could not create update log for CT {vmid}: {exc}',
+ flush=True)
+ return None, None
+
+
+def _append_lxc_update_log(path: str | None, text: str) -> None:
+ if not path:
+ return
+ try:
+ with open(path, 'a', encoding='utf-8', errors='replace') as stream:
+ stream.write(text)
+ except OSError as exc:
+ print(f'[ProxMenux] scheduler: could not append update log: {exc}', flush=True)
+
+
+def _prune_lxc_update_logs(vmid: int) -> None:
+ try:
+ candidates = sorted(
+ glob.glob(os.path.join(_LXC_UPDATE_LOG_DIR, f'{int(vmid)}-scheduled-*.log')),
+ key=os.path.getmtime,
+ reverse=True,
+ )
+ for path in candidates[_LXC_UPDATE_LOG_KEEP_PER_CT:]:
+ if _LXC_UPDATE_LOG_RE.fullmatch(os.path.basename(path)):
+ os.unlink(path)
+ except OSError as exc:
+ print(f'[ProxMenux] scheduler: update-log retention failed for CT {vmid}: {exc}',
+ flush=True)
+
+
+def _inspect_lxc_reboot_requirement(
+ vmid: int,
+ *,
+ update_succeeded: bool,
+ restart_requested: bool,
+ originally_running: bool,
+) -> tuple[bool | None, list[str], str | None]:
+ """Read Debian's reboot marker without installing extra guest tools."""
+ if update_succeeded and (restart_requested or not originally_running):
+ return False, [], None
+ if _fast_guest_status(vmid, 'lxc') != 'running':
+ return None, [], 'container is not running; reboot marker could not be checked'
+ try:
+ marker = subprocess.run(
+ ['/usr/sbin/pct', 'exec', str(vmid), '--',
+ 'test', '-f', '/var/run/reboot-required'],
+ capture_output=True, text=True, timeout=8,
+ )
+ except (FileNotFoundError, OSError, subprocess.TimeoutExpired) as exc:
+ return None, [], str(exc)
+ if marker.returncode == 1:
+ return False, [], None
+ if marker.returncode != 0:
+ return None, [], (marker.stderr or marker.stdout or 'reboot marker check failed').strip()[:300]
+ packages: list[str] = []
+ try:
+ result = subprocess.run(
+ ['/usr/sbin/pct', 'exec', str(vmid), '--',
+ 'cat', '/var/run/reboot-required.pkgs'],
+ capture_output=True, text=True, timeout=8,
+ )
+ if result.returncode == 0:
+ packages = list(dict.fromkeys(
+ line.strip()[:160]
+ for line in result.stdout.splitlines()
+ if line.strip()
+ ))[:32]
+ except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
+ pass
+ return True, packages, None
def _normalise_schedule_targets(sched: dict) -> list[str]:
@@ -21814,16 +22063,40 @@ def _run_scheduled_update(vmid: int, sched: dict) -> dict:
"""Run one scheduled update and finalize it through the shared path."""
started_at = time.monotonic()
run_id = f'scheduled-{uuid.uuid4().hex}'
+ log_name, log_path = _create_lxc_update_log(vmid, run_id)
+ originally_running = _fast_guest_status(vmid, 'lxc') == 'running'
requested_targets = _normalise_schedule_targets(sched)
targets = list(requested_targets)
before = _lxc_update_snapshot(vmid)
deferred_targets: list[str] = []
reasons: list[str] = []
+ reboot_required: bool | None = None
+ reboot_packages: list[str] = []
+ reboot_check_error: str | None = None
def finish(status: str, actual_target: str, executed: list[str]) -> dict:
reason = '; '.join(dict.fromkeys(value for value in reasons if value)) or None
duration_seconds = max(0, int(time.monotonic() - started_at))
labels = _lxc_update_target_labels(requested_targets, [], before)
+ footer = [
+ '',
+ '=== ProxMenux result ===',
+ f'Finished: {datetime.now().astimezone().isoformat()}',
+ f'Status: {status}',
+ f'Duration: {duration_seconds}s',
+ ]
+ if reason:
+ footer.append(f'Reason: {reason}')
+ if reboot_required is True:
+ footer.append('Restart required: yes')
+ if reboot_packages:
+ footer.append('Restart-triggering packages: ' + ', '.join(reboot_packages))
+ elif reboot_required is False:
+ footer.append('Restart required: no')
+ elif reboot_check_error:
+ footer.append(f'Restart check unavailable: {reboot_check_error}')
+ _append_lxc_update_log(log_path, '\n'.join(footer) + '\n')
+ _prune_lxc_update_logs(vmid)
finalization = _finalize_lxc_update(
vmid,
run_id=run_id,
@@ -21841,6 +22114,8 @@ def _run_scheduled_update(vmid: int, sched: dict) -> dict:
value.startswith('docker-') for value in requested_targets
),
before_snapshot=before,
+ reboot_required=reboot_required,
+ reboot_packages=reboot_packages,
)
return {
'status': status,
@@ -21851,6 +22126,9 @@ def _run_scheduled_update(vmid: int, sched: dict) -> dict:
'executed_targets': list(executed),
'deferred_targets': list(deferred_targets),
'duration_seconds': duration_seconds,
+ 'log_name': log_name,
+ 'reboot_required': reboot_required,
+ 'reboot_packages': list(reboot_packages),
'finalization': finalization,
}
@@ -21969,13 +22247,32 @@ def _run_scheduled_update(vmid: int, sched: dict) -> dict:
"1" if any(value.startswith('docker-') for value in requested_targets) else "0"
)
try:
- r = subprocess.run(
- ["bash", _APPLY_UPDATES_SCRIPT],
- env=env,
- capture_output=True,
- text=True,
- timeout=60 * 60, # 1h hard cap so a stuck run doesn't
- # block the queue forever
+ if log_path:
+ with open(log_path, 'a', encoding='utf-8', errors='replace') as log_stream:
+ r = subprocess.run(
+ ["bash", _APPLY_UPDATES_SCRIPT],
+ env=env,
+ stdout=log_stream,
+ stderr=subprocess.STDOUT,
+ text=True,
+ timeout=60 * 60, # 1h hard cap so a stuck run doesn't
+ # block the queue forever
+ )
+ else:
+ r = subprocess.run(
+ ["bash", _APPLY_UPDATES_SCRIPT],
+ env=env,
+ capture_output=True,
+ text=True,
+ timeout=60 * 60,
+ )
+ reboot_required, reboot_packages, reboot_check_error = (
+ _inspect_lxc_reboot_requirement(
+ vmid,
+ update_succeeded=r.returncode == 0,
+ restart_requested=bool(sched.get("restart")),
+ originally_running=originally_running,
+ )
)
if r.returncode != 0:
reasons.append(f'update runner exited with code {r.returncode}')
@@ -21984,6 +22281,14 @@ def _run_scheduled_update(vmid: int, sched: dict) -> dict:
return finish('partial', target, targets)
return finish('success', target, targets)
except subprocess.TimeoutExpired:
+ reboot_required, reboot_packages, reboot_check_error = (
+ _inspect_lxc_reboot_requirement(
+ vmid,
+ update_succeeded=False,
+ restart_requested=False,
+ originally_running=originally_running,
+ )
+ )
reasons.append('scheduled update timed out')
return finish('failure', target, targets)
except Exception as exc:
@@ -22037,7 +22342,17 @@ def _scheduler_loop():
actual_target = outcome.get('target') or 'both'
reason = outcome.get('reason')
try:
- lxc_apps.record_schedule_run(_vmid, status, actual_target, reason)
+ lxc_apps.record_schedule_run(
+ _vmid,
+ status,
+ actual_target,
+ reason,
+ log_name=outcome.get('log_name'),
+ reboot_required=outcome.get('reboot_required'),
+ reboot_packages=outcome.get('reboot_packages'),
+ )
+ _vm_cache_invalidate(_vmid, _vm_schedule_cache)
+ _publish_guest_modal_cache_revision(_vmid)
except Exception as e:
print(f"[ProxMenux] scheduler: could not record run for {_vmid}: {e}")
print(f"[ProxMenux] scheduler: CT {_vmid} finished with status={status}"
diff --git a/AppImage/scripts/health_persistence.py b/AppImage/scripts/health_persistence.py
index 13f82948..d83031f2 100644
--- a/AppImage/scripts/health_persistence.py
+++ b/AppImage/scripts/health_persistence.py
@@ -220,6 +220,14 @@ class HealthPersistence:
)
''')
+ cursor.execute('''
+ CREATE TABLE IF NOT EXISTS notification_delivery_claims (
+ fingerprint TEXT PRIMARY KEY,
+ claim_token TEXT NOT NULL,
+ claimed_at INTEGER NOT NULL
+ )
+ ''')
+
cursor.execute('''
CREATE TABLE IF NOT EXISTS digest_pending (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -287,6 +295,7 @@ class HealthPersistence:
cursor.execute('CREATE INDEX IF NOT EXISTS idx_notif_sent_at ON notification_history(sent_at)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_notif_severity ON notification_history(severity)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_nls_ts ON notification_last_sent(last_sent_ts)')
+ cursor.execute('CREATE INDEX IF NOT EXISTS idx_notification_claimed_at ON notification_delivery_claims(claimed_at)')
# ── Disk Observations System ──
# Registry of all physical disks seen by the system
@@ -419,7 +428,7 @@ class HealthPersistence:
cursor.execute("SELECT name FROM sqlite_master WHERE type='table'")
tables = {row[0] for row in cursor.fetchall()}
required_tables = {'errors', 'events', 'system_capabilities', 'user_settings',
- 'notification_history', 'notification_last_sent',
+ 'notification_history', 'notification_last_sent', 'notification_delivery_claims',
'disk_registry', 'disk_observations',
'excluded_storages', 'excluded_interfaces'}
missing = required_tables - tables
diff --git a/AppImage/scripts/lxc_apps.py b/AppImage/scripts/lxc_apps.py
index baa88e9f..c5e5bbdb 100644
--- a/AppImage/scripts/lxc_apps.py
+++ b/AppImage/scripts/lxc_apps.py
@@ -1285,16 +1285,13 @@ def _select_working_hint_detector(vmid, hint: dict) -> tuple[dict, Optional[str]
def _github_pat() -> Optional[str]:
try:
from notification_manager import notification_manager
- pat = notification_manager._config.get("github_pat") if notification_manager._config else None
- if not pat:
- return None
- try:
- from notification_manager import decrypt_sensitive_value
- if isinstance(pat, str) and pat.startswith("encrypted:"):
- return decrypt_sensitive_value(pat)
- except Exception:
- pass
- return pat if isinstance(pat, str) else None
+ # The notification manager owns the shared encrypted settings store.
+ # During very early calls its runtime cache may not have been loaded
+ # yet, so initialise it before reading the optional GitHub token.
+ if not notification_manager._config:
+ notification_manager._load_config()
+ pat = notification_manager._config.get("github_pat")
+ return pat.strip() if isinstance(pat, str) and pat.strip() else None
except Exception:
return None
@@ -1365,7 +1362,7 @@ def _fetch_github_latest_details(config: dict) -> tuple[Optional[str], Optional[
if e.code == 403:
remaining = e.headers.get("X-RateLimit-Remaining", "1")
if remaining == "0":
- return None, "github rate limited — configure a PAT in Settings", None
+ return None, "github rate limited — configure a PAT in Settings → GitHub API", None
return None, "github rejected the request (403)", None
return None, f"github error {e.code}", None
except (urllib.error.URLError, TimeoutError, OSError) as e:
@@ -3021,7 +3018,16 @@ def scheduled_app_release_gate(
return {"allowed": True, "status": "ready", "reason": None}
-def record_schedule_run(vmid, status: str, target: str, reason: Optional[str] = None) -> bool:
+def record_schedule_run(
+ vmid,
+ status: str,
+ target: str,
+ reason: Optional[str] = None,
+ *,
+ log_name: Optional[str] = None,
+ reboot_required: Optional[bool] = None,
+ reboot_packages: Optional[list[str]] = None,
+) -> bool:
"""Called by the scheduler after a fired run completes. Updates
the schedule with last_run_at + last_run_status so the UI can show
the outcome. `status` is one of "success" | "failure" |
@@ -3037,6 +3043,38 @@ def record_schedule_run(vmid, status: str, target: str, reason: Optional[str] =
sidecar["schedule"]["last_run_reason"] = str(reason)[:300]
else:
sidecar["schedule"].pop("last_run_reason", None)
+ if log_name:
+ sidecar["schedule"]["last_run_log"] = os.path.basename(str(log_name))[:220]
+ else:
+ sidecar["schedule"].pop("last_run_log", None)
+ if reboot_required is None:
+ sidecar["schedule"].pop("last_run_reboot_required", None)
+ else:
+ sidecar["schedule"]["last_run_reboot_required"] = bool(reboot_required)
+ packages = [
+ str(package).strip()[:160]
+ for package in (reboot_packages or [])[:32]
+ if str(package).strip()
+ ]
+ if reboot_required and packages:
+ sidecar["schedule"]["last_run_reboot_packages"] = packages
+ else:
+ sidecar["schedule"].pop("last_run_reboot_packages", None)
+ sidecar["updated_at"] = _now_iso()
+ return _write_sidecar(vmid, sidecar)
+
+
+def clear_schedule_reboot_required(vmid) -> bool:
+ """Clear a persisted reboot warning after the CT starts or reboots."""
+ with _cache_lock:
+ sidecar = _read_sidecar(vmid)
+ schedule = (sidecar or {}).get("schedule")
+ if not isinstance(schedule, dict):
+ return False
+ if schedule.get("last_run_reboot_required") is not True:
+ return True
+ schedule["last_run_reboot_required"] = False
+ schedule.pop("last_run_reboot_packages", None)
sidecar["updated_at"] = _now_iso()
return _write_sidecar(vmid, sidecar)
diff --git a/AppImage/scripts/notification_events.py b/AppImage/scripts/notification_events.py
index 859634ea..fe3c8755 100644
--- a/AppImage/scripts/notification_events.py
+++ b/AppImage/scripts/notification_events.py
@@ -4147,6 +4147,17 @@ class ProxmoxHookWatcher:
'job_id': pve_job_id,
}
+ if pve_type == 'replication':
+ replication = self._extract_replication_context(
+ fields, title, message
+ )
+ data.update(replication)
+ entity_id = (
+ replication.get('job_id')
+ or replication.get('vmid')
+ or entity_id
+ )
+
# `system_problem` is the generic fallback of `_classify_pve` for
# unknown/empty pve_type. Without a populated `reason`, the template
# renders "Reason: " (empty) and `_summarize_event` falls back to
@@ -4274,6 +4285,73 @@ class ProxmoxHookWatcher:
self._queue.put(event)
return {'accepted': True, 'event_type': event_type, 'event_id': event.event_id}
+
+ def _extract_replication_context(self, fields: dict, title: str,
+ message: str) -> dict:
+ """Map a native PVE replication notice to template fields."""
+ raw_job_id = fields.get('job-id') or fields.get('job_id') or ''
+ job_id = str(raw_job_id).strip()
+ if not re.fullmatch(r'\d+(?:-\d+)?', job_id):
+ combined = f'{title or ""}\n{message or ""}'
+ match = re.search(
+ r'\breplication(?:\s+job)?(?:\s*:\s*|\s+)'
+ r'[\'\"]?(\d+(?:-\d+)?)\b',
+ combined,
+ re.IGNORECASE,
+ )
+ if not match:
+ match = re.search(r'\b(\d+-\d+)\b', combined)
+ job_id = match.group(1) if match else ''
+
+ vmid_match = re.fullmatch(r'(\d+)(?:-\d+)?', job_id)
+ vmid = vmid_match.group(1) if vmid_match else ''
+ vmname = self._resolve_replication_guest_name(vmid)
+ if not vmname and vmid:
+ vmname = 'VM/CT'
+
+ target = str(
+ fields.get('job-target') or fields.get('target') or ''
+ ).strip()
+ if not target:
+ target_match = re.search(
+ r'\bwith\s+target\s+[\'\"]([^\'\"\n]+)[\'\"]',
+ message or '',
+ re.IGNORECASE,
+ )
+ if target_match:
+ target = target_match.group(1).strip()
+
+ reason_match = re.search(
+ r'^\s*Error:\s*(.*?)\s*\Z',
+ message or '',
+ re.IGNORECASE | re.MULTILINE | re.DOTALL,
+ )
+ reason = reason_match.group(1).strip() if reason_match else ''
+ if not reason:
+ reason = (message or title or '').strip()
+
+ return {
+ 'job_id': job_id,
+ 'vmid': vmid,
+ 'vmname': vmname,
+ 'target_node': target,
+ 'reason': reason,
+ }
+
+ @staticmethod
+ def _resolve_replication_guest_name(vmid: str) -> str:
+ """Resolve the replicated guest name from the cluster config."""
+ if not vmid or not vmid.isdigit():
+ return ''
+ for base in ('/etc/pve/qemu-server', '/etc/pve/lxc'):
+ try:
+ with open(f'{base}/{vmid}.conf', encoding='utf-8') as config:
+ for line in config:
+ if line.startswith(('name:', 'hostname:')):
+ return line.split(':', 1)[1].strip()
+ except OSError:
+ continue
+ return ''
def _classify_pve(self, pve_type: str, severity: str,
title: str, message: str) -> tuple:
diff --git a/AppImage/scripts/notification_manager.py b/AppImage/scripts/notification_manager.py
index e60e70a1..064ad831 100644
--- a/AppImage/scripts/notification_manager.py
+++ b/AppImage/scripts/notification_manager.py
@@ -64,6 +64,10 @@ ENCRYPTION_KEY_FILE = Path('/usr/local/share/proxmenux/.notification_key')
# Keys that contain sensitive data and should be encrypted
SENSITIVE_KEYS = {
+ # Optional GitHub API token used by the LXC app version tracker.
+ # It lives in the shared settings store so it benefits from the same
+ # ENC2 encryption and never needs a second secrets file.
+ 'github_pat',
'ai_api_key', # Legacy - kept for migration
'ai_api_key_groq',
'ai_api_key_gemini',
@@ -388,6 +392,8 @@ DEFAULT_COOLDOWNS = {
'updates': 86400,
}
+_DELIVERY_CLAIM_TTL = 900
+
# ─── Storm Protection ────────────────────────────────────────────
@@ -803,6 +809,8 @@ class NotificationManager:
# Cooldown tracking: {fingerprint: last_sent_timestamp}
self._cooldowns: Dict[str, float] = {}
+ self._delivery_claims: Dict[str, str] = {}
+ self._delivery_claim_lock = threading.Lock()
# Storm protection
self._group_limiter = GroupRateLimiter()
@@ -1213,50 +1221,37 @@ class NotificationManager:
except Exception:
pass # Continue if check fails
- # Cooldown check (does NOT stamp yet — see audit Tier 6, cooldown order).
- # If we stamped here, a rate-limit hit or a "no channel enabled for this
- # event_type" situation would burn a 24h cooldown on a delivery that
- # never reached anyone.
- if not self._passes_cooldown(event):
+ claim_token = self._claim_delivery(event)
+ if claim_token is None:
return
+ delivered = False
+ try:
+ template = TEMPLATES.get(event.event_type, {})
+ group = template.get('group', 'other')
+ if not self._group_limiter.allow(group):
+ return
- # Group rate limit check.
- template = TEMPLATES.get(event.event_type, {})
- group = template.get('group', 'other')
- if not self._group_limiter.allow(group):
- return
-
- # Use the properly mapped severity from the event, not from template defaults.
- # event.severity was set by _map_severity which normalises to CRITICAL/WARNING/INFO.
- severity = event.severity
-
- # Inject the canonical severity into data so templates see it too.
- event.data['severity'] = severity
-
- # Render message from template (structured output)
- rendered = render_template(event.event_type, event.data)
-
- # Enrich data with structured fields for channels that support them
- enriched_data = dict(event.data)
- enriched_data['_rendered_fields'] = rendered.get('fields', [])
- enriched_data['_body_html'] = rendered.get('body_html', '')
- enriched_data['_event_type'] = event.event_type
- enriched_data['_group'] = TEMPLATES.get(event.event_type, {}).get('group', 'other')
-
- # Pass journal context if available (for AI enrichment)
- if '_journal_context' in event.data:
- enriched_data['_journal_context'] = event.data['_journal_context']
-
- # Send through all active channels (AI applied per-channel with detail_level).
- # Stamp cooldown only if at least one channel actually delivered — otherwise
- # a misconfigured per-channel toggle would silently lock the event under a
- # 24h cooldown until someone re-enables it. Audit Tier 6.
- delivered = self._dispatch_to_channels(
- rendered['title'], rendered['body'], severity,
- event.event_type, enriched_data, event.source
- )
- if delivered:
- self._record_cooldown(event.fingerprint)
+ severity = event.severity
+ event.data['severity'] = severity
+ rendered = render_template(event.event_type, event.data)
+
+ enriched_data = dict(event.data)
+ enriched_data['_rendered_fields'] = rendered.get('fields', [])
+ enriched_data['_body_html'] = rendered.get('body_html', '')
+ enriched_data['_event_type'] = event.event_type
+ enriched_data['_group'] = TEMPLATES.get(event.event_type, {}).get('group', 'other')
+
+ if '_journal_context' in event.data:
+ enriched_data['_journal_context'] = event.data['_journal_context']
+
+ delivered = self._dispatch_to_channels(
+ rendered['title'], rendered['body'], severity,
+ event.event_type, enriched_data, event.source
+ )
+ finally:
+ self._finish_delivery_claim(
+ event.fingerprint, claim_token, delivered=delivered,
+ )
def _dispatch_to_channels(self, title: str, body: str, severity: str,
event_type: str, data: Dict, source: str) -> bool:
@@ -1884,17 +1879,7 @@ class NotificationManager:
print(f"[NotificationManager] quiet cleanup failed for "
f"{ch_name}: {e}")
- def _passes_cooldown(self, event: NotificationEvent) -> bool:
- """Check if the event passes cooldown rules WITHOUT stamping.
-
- Splits the historical `_check_cooldown` into a pure predicate plus
- `_record_cooldown` (separate stamp). Lets the caller check rate-limit
- and per-channel filters first — if any of those drop the event, we
- avoid burning a 24h cooldown on a delivery that never happened.
- Audit Tier 6 (Notification stack #4 + cooldown/per-channel interaction).
- """
- now = time.time()
-
+ def _cooldown_seconds(self, event: NotificationEvent) -> int:
# Determine cooldown period
template = TEMPLATES.get(event.event_type, {})
group = template.get('group', 'system')
@@ -1958,15 +1943,112 @@ class NotificationManager:
_URGENT_EVENTS = {'system_shutdown', 'system_reboot'}
if event.event_type in _URGENT_EVENTS and cooldown_str is None:
cooldown = 5
-
- # Check against last sent time using stable fingerprint. Stamp is
- # deferred to `_record_cooldown()` — only invoked once the event has
- # passed rate-limit AND at least one channel actually delivered it.
+
+ return cooldown
+
+ def _passes_cooldown(self, event: NotificationEvent) -> bool:
+ """Check the in-memory cooldown without reserving a delivery."""
+ now = time.time()
+ cooldown = self._cooldown_seconds(event)
last_sent = self._cooldowns.get(event.fingerprint, 0)
if now - last_sent < cooldown:
return False
return True
+ def _claim_delivery(self, event: NotificationEvent) -> Optional[str]:
+ """Reserve an event fingerprint before any slow channel work begins."""
+ fingerprint = event.fingerprint
+ now = time.time()
+ token = f'{os.getpid()}:{threading.get_ident()}:{time.time_ns()}'
+
+ with self._delivery_claim_lock:
+ if fingerprint in self._delivery_claims:
+ return None
+ if not self._passes_cooldown(event):
+ return None
+ self._delivery_claims[fingerprint] = token
+
+ allowed = True
+ conn = None
+ try:
+ conn = sqlite3.connect(str(DB_PATH), timeout=10)
+ conn.execute('PRAGMA journal_mode=WAL')
+ conn.execute('PRAGMA busy_timeout=5000')
+ conn.execute('BEGIN IMMEDIATE')
+ conn.execute('''
+ CREATE TABLE IF NOT EXISTS notification_delivery_claims (
+ fingerprint TEXT PRIMARY KEY,
+ claim_token TEXT NOT NULL,
+ claimed_at INTEGER NOT NULL
+ )
+ ''')
+ conn.execute(
+ 'DELETE FROM notification_delivery_claims WHERE claimed_at < ?',
+ (int(now - _DELIVERY_CLAIM_TTL),),
+ )
+ row = conn.execute(
+ 'SELECT last_sent_ts FROM notification_last_sent WHERE fingerprint = ?',
+ (fingerprint,),
+ ).fetchone()
+ if row and now - float(row[0]) < self._cooldown_seconds(event):
+ self._cooldowns[fingerprint] = float(row[0])
+ allowed = False
+ else:
+ cursor = conn.execute('''
+ INSERT OR IGNORE INTO notification_delivery_claims
+ (fingerprint, claim_token, claimed_at) VALUES (?, ?, ?)
+ ''', (fingerprint, token, int(now)))
+ allowed = cursor.rowcount == 1
+ conn.commit()
+ except Exception as exc:
+ print(f'[NotificationManager] Delivery claim fallback: {exc}')
+ finally:
+ if conn is not None:
+ conn.close()
+
+ if not allowed:
+ with self._delivery_claim_lock:
+ if self._delivery_claims.get(fingerprint) == token:
+ self._delivery_claims.pop(fingerprint, None)
+ return None
+ return token
+
+ def _finish_delivery_claim(self, fingerprint: str, token: str,
+ *, delivered: bool) -> None:
+ """Commit a delivered cooldown or release an unsuccessful claim."""
+ now = time.time()
+ conn = None
+ try:
+ conn = sqlite3.connect(str(DB_PATH), timeout=10)
+ conn.execute('PRAGMA journal_mode=WAL')
+ conn.execute('PRAGMA busy_timeout=5000')
+ conn.execute('BEGIN IMMEDIATE')
+ if delivered:
+ conn.execute('''
+ INSERT OR REPLACE INTO notification_last_sent
+ (fingerprint, last_sent_ts, count)
+ VALUES (?, ?, COALESCE(
+ (SELECT count + 1 FROM notification_last_sent WHERE fingerprint = ?), 1
+ ))
+ ''', (fingerprint, int(now), fingerprint))
+ conn.execute('''
+ DELETE FROM notification_delivery_claims
+ WHERE fingerprint = ? AND claim_token = ?
+ ''', (fingerprint, token))
+ conn.commit()
+ except Exception as exc:
+ print(f'[NotificationManager] Delivery claim completion fallback: {exc}')
+ if delivered:
+ self._persist_cooldown(fingerprint, now)
+ finally:
+ if conn is not None:
+ conn.close()
+ with self._delivery_claim_lock:
+ if delivered:
+ self._cooldowns[fingerprint] = now
+ if self._delivery_claims.get(fingerprint) == token:
+ self._delivery_claims.pop(fingerprint, None)
+
def _record_cooldown(self, fingerprint: str):
"""Stamp the cooldown for a fingerprint that was actually delivered."""
now = time.time()
diff --git a/AppImage/scripts/oci_manager.py b/AppImage/scripts/oci_manager.py
index 3c2b0329..4d004525 100644
--- a/AppImage/scripts/oci_manager.py
+++ b/AppImage/scripts/oci_manager.py
@@ -361,32 +361,73 @@ def get_available_storages() -> List[Dict[str, Any]]:
return storages
+def _host_arch() -> str:
+ """Return the host's dpkg architecture (`amd64`, `arm64`, ...).
+
+ Falls back to mapping `uname -m` when `dpkg --print-architecture` is
+ unavailable — the Alpine LXC template filenames use the dpkg style
+ (`amd64`, `arm64`), so `uname -m`'s `x86_64` / `aarch64` gets
+ translated to that form.
+ """
+ try:
+ rc, out, _ = _run_pve_cmd(["dpkg", "--print-architecture"], timeout=5)
+ arch = out.strip()
+ if rc == 0 and arch:
+ return arch
+ except Exception:
+ pass
+ try:
+ machine = os.uname().machine.lower()
+ except Exception:
+ machine = ''
+ return {
+ 'x86_64': 'amd64', 'amd64': 'amd64',
+ 'aarch64': 'arm64', 'arm64': 'arm64',
+ 'armv7l': 'armhf', 'armhf': 'armhf',
+ 'i686': 'i386', 'i386': 'i386',
+ }.get(machine, 'amd64')
+
+
def _download_alpine_template(storage: str = DEFAULT_STORAGE) -> bool:
- """Download the latest Alpine LXC template using pveam."""
+ """Download the latest Alpine LXC template using pveam.
+
+ Filters by the host's architecture so an x86_64 host does not end up
+ with an arm64 template — the previous naive `for line in out` loop
+ kept the last match and could pick any arch that `pveam available`
+ happened to list (issue #324).
+ """
print("[*] Downloading Alpine Linux template...")
logger.info("Downloading Alpine template via pveam")
-
+
+ host_arch = _host_arch()
+ logger.info(f"Host architecture detected: {host_arch}")
+
# Update template list first
rc, out, err = _run_pve_cmd(["pveam", "update"], timeout=60)
if rc != 0:
logger.warning(f"Failed to update template list: {err}")
-
+
# Get available Alpine templates
rc, out, err = _run_pve_cmd(["pveam", "available", "--section", "system"], timeout=30)
if rc != 0:
logger.error(f"Failed to list available templates: {err}")
return False
-
- # Find latest Alpine template
+
+ # Find latest Alpine template FOR THIS HOST'S ARCH. Template names
+ # follow `alpine--default__.tar.xz`; we require the
+ # arch token to match the host before considering the candidate.
alpine_template = None
+ arch_token = f"_{host_arch}."
for line in out.strip().split('\n'):
- if 'alpine-' in line.lower():
- parts = line.split()
- if len(parts) >= 2:
- alpine_template = parts[1] # Template name is usually second column
-
+ low = line.lower()
+ if 'alpine-' not in low or arch_token not in low:
+ continue
+ parts = line.split()
+ if len(parts) >= 2:
+ alpine_template = parts[1] # Template name is usually second column
+
if not alpine_template:
- logger.error("No Alpine template found in available templates")
+ logger.error(f"No Alpine template found for architecture {host_arch}")
return False
# Download the template
@@ -410,11 +451,22 @@ def _find_alpine_template(storage: str = DEFAULT_STORAGE, auto_download: bool =
if rc == 0 and out.strip():
template_dir = os.path.dirname(out.strip())
- # Look for Alpine templates
+ # Look for Alpine templates matching the host architecture. Without
+ # this filter, a host that happened to have an arm64 Alpine template
+ # sitting in its cache from a previous experiment would be handed
+ # that template — resulting in `arch: arm64` on the container and
+ # the `Exec format error` from issue #324.
+ host_arch = _host_arch()
+ arch_token = f"_{host_arch}."
try:
templates = os.listdir(template_dir)
- alpine_templates = [t for t in templates if t.startswith("alpine-") and t.endswith((".tar.xz", ".tar.gz", ".tar"))]
-
+ alpine_templates = [
+ t for t in templates
+ if t.startswith("alpine-")
+ and t.endswith((".tar.xz", ".tar.gz", ".tar"))
+ and arch_token in t.lower()
+ ]
+
if alpine_templates:
# Sort to get latest version
alpine_templates.sort(reverse=True)
@@ -882,6 +934,7 @@ def deploy_app(app_id: str, config: Dict[str, Any], installed_by: str = "web") -
pct_cmd = [
"pct", "create", str(vmid), template,
+ "--arch", _host_arch(),
"--hostname", hostname,
"--memory", str(container_def.get("memory", 512)),
"--cores", str(container_def.get("cores", 1)),
diff --git a/AppImage/scripts/tests/test_replication_webhook.py b/AppImage/scripts/tests/test_replication_webhook.py
new file mode 100644
index 00000000..24879e0a
--- /dev/null
+++ b/AppImage/scripts/tests/test_replication_webhook.py
@@ -0,0 +1,106 @@
+import sys
+import unittest
+from pathlib import Path
+from queue import Queue
+from unittest import mock
+
+
+SCRIPTS_DIR = Path(__file__).resolve().parents[1]
+if str(SCRIPTS_DIR) not in sys.path:
+ sys.path.insert(0, str(SCRIPTS_DIR))
+
+import notification_events # noqa: E402
+import notification_templates # noqa: E402
+
+
+class ReplicationWebhookTests(unittest.TestCase):
+ def _process(self, payload, guest_name='fileserver'):
+ watcher = notification_events.ProxmoxHookWatcher(Queue())
+ with mock.patch.object(
+ watcher,
+ '_resolve_replication_guest_name',
+ return_value=guest_name,
+ ), mock.patch.object(
+ notification_events,
+ 'capture_journal_context',
+ return_value='',
+ ):
+ result = watcher.process_webhook(payload)
+ return result, watcher._queue.get_nowait()
+
+ def test_structured_job_id_populates_template_fields(self):
+ reason = 'command zfs error: cannot open pool\nremote side unavailable'
+ result, event = self._process({
+ 'title': "Replication Job: '100-0' failed",
+ 'message': (
+ "Replication job '100-0' with target 'pve02' and schedule "
+ "'*/15' failed!\n\n"
+ "Last successful sync: 2026-09-02 15:00:00\n"
+ "Next sync try: 2026-09-02 15:30:00\n"
+ "Failure count: 1\n\n"
+ f"Error:\n{reason}"
+ ),
+ 'severity': 'error',
+ 'fields': {
+ 'type': 'replication',
+ 'hostname': 'pve01',
+ 'job-id': '100-0',
+ },
+ })
+
+ self.assertTrue(result['accepted'])
+ self.assertEqual(event.event_type, 'replication_fail')
+ self.assertEqual(event.entity_id, '100-0')
+ self.assertEqual(event.data['job_id'], '100-0')
+ self.assertEqual(event.data['vmid'], '100')
+ self.assertEqual(event.data['vmname'], 'fileserver')
+ self.assertEqual(event.data['target_node'], 'pve02')
+ self.assertEqual(event.data['reason'], reason)
+
+ rendered = notification_templates.render_template(
+ event.event_type,
+ event.data,
+ )
+ self.assertIn('fileserver (100)', rendered['title'])
+ self.assertIn('ID: 100', rendered['body_text'])
+ self.assertIn(reason, rendered['body_text'])
+
+ def test_title_and_message_are_used_when_job_id_field_is_missing(self):
+ _, event = self._process({
+ 'title': "Replication Job: '212-3' failed",
+ 'message': (
+ "Replication job '212-3' with target 'pve03' failed!\n\n"
+ "Error: storage 'replica-zfs' is not available"
+ ),
+ 'severity': 'error',
+ 'fields': {'type': 'replication', 'hostname': 'pve01'},
+ }, guest_name='')
+
+ self.assertEqual(event.entity_id, '212-3')
+ self.assertEqual(event.data['vmid'], '212')
+ self.assertEqual(event.data['vmname'], 'VM/CT')
+ self.assertEqual(event.data['target_node'], 'pve03')
+ self.assertEqual(
+ event.data['reason'],
+ "storage 'replica-zfs' is not available",
+ )
+
+ def test_missing_error_block_never_renders_an_empty_reason(self):
+ message = "Replication job '300-0' failed unexpectedly"
+ _, event = self._process({
+ 'title': "Replication Job: '300-0' failed",
+ 'message': message,
+ 'severity': 'error',
+ 'fields': {'type': 'replication', 'hostname': 'pve01'},
+ })
+
+ self.assertEqual(event.data['reason'], message)
+ rendered = notification_templates.render_template(
+ event.event_type,
+ event.data,
+ )
+ self.assertIn(f'Reason: {message}', rendered['body_text'])
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/CHANGELOG.md b/CHANGELOG.md
index bced5309..5f25a490 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,4 +1,77 @@
+## 2026-09-02
+
+### New version ProxMenux v1.2.6
+
+A focused release that restores AI Assistant support for OpenAI-compatible endpoints hosted on private IPs, loopback and Docker networks, aligns the Secure Gateway wizard with the host's real architecture, and consolidates several improvements landing on develop: atomic notification delivery, custom SSH ports for Borg remote targets, an optional GitHub API token for app version tracking, and richer replication failure notifications.
+
+---
+
+## 🛠 AI Assistant custom OpenAI endpoint — LAN / Docker / localhost URLs
+
+- Custom OpenAI-compatible endpoints reachable on private IPs, loopback or Docker networks (LiteLLM, LM Studio, LocalAI, vLLM, OmniRoute, self-hosted proxies…) are now accepted by the Notifications API when loading the model catalogue and validating the AI configuration.
+- The dropdown surfaces the reason returned by the server (or the underlying network error) directly under the *Load* button, so misconfigurations are visible instead of silent.
+- Translated into every Monitor language.
+
+Reported in [#325](https://github.com/MacRimi/ProxMenux/issues/325) by [@jorgeffonte](https://github.com/jorgeffonte).
+
+---
+
+## 🛠 Secure Gateway wizard — LXC template matches host architecture
+
+- Alpine template download filters `pveam available` results by the host's architecture (via `dpkg --print-architecture`, falling back to `uname -m`), so an x86_64 Proxmox host receives the `amd64` template and an arm64 host receives the `arm64` template.
+- Local template selection applies the same architecture filter when reusing a previously downloaded Alpine template.
+- `pct create` is invoked with an explicit `--arch ` so the container metadata matches the host's real architecture.
+
+Reported in [#324](https://github.com/MacRimi/ProxMenux/issues/324) by [@N0X4DD0](https://github.com/N0X4DD0).
+
+---
+
+## 🔔 Atomic notification delivery
+
+- Notification events reserve their deduplication fingerprint atomically before AI processing and channel delivery, so concurrent collectors, completion callbacks or accidental parallel Monitor processes cannot send the same event twice.
+- The reservation is shared through SQLite, expires safely if an execution is interrupted and is released when no channel succeeds, preserving retries after temporary transport failures.
+
+---
+
+## 🗄 Borg remote target — custom SSH port
+
+- The *Add Borg destination* dialog in the Monitor and the shell TUI (`menu` → *Host Backup* → *New Borg target*) accept a custom SSH port. The default stays at `22`; any value between 1 and 65535 is embedded in the persisted `ssh://user@host:port/path` URL.
+- `BORG_RSH` honours the custom port at backup time, so scheduled jobs and manual runs reach the correct port.
+- The auto key install flow (`generate-auto`) targets the custom port too.
+- Fully backwards compatible with existing `borg-targets.txt` entries created without an explicit port.
+- Capacity probes over SSH also honour the custom port, so the *Available* badge stays accurate on non-standard ports.
+
+Reported in [discussion #236](https://github.com/MacRimi/ProxMenux/discussions/236) by [@songochain](https://github.com/songochain).
+
+---
+
+## 🎯 App version tracking — optional GitHub API token
+
+- **Settings → GitHub API** accepts an optional personal access token for release and tag checks when GitHub's anonymous quota is exhausted.
+- The token is encrypted at rest, is never returned to the browser and can be replaced or removed independently of the Notifications service.
+- The anonymous GitHub flow remains the default; a token is not required while the shared quota is available.
+- The rate-limit error points to the actual setting and is translated in every Monitor language.
+
+Reported in [discussion #306](https://github.com/MacRimi/ProxMenux/discussions/306) by [@SystemIdleProcess](https://github.com/SystemIdleProcess).
+
+---
+
+## 🔁 Replication failure notifications — complete job context
+
+- Native Proxmox replication webhooks resolve the replication job ID, affected VM/LXC ID and guest name before rendering the notification.
+- The exact error block supplied by Proxmox is preserved as the reason, including multiline failures, with the complete message retained as a safe fallback when the block is absent.
+- Replication notifications are identified by their complete job ID, keeping failures from different replication jobs independent during deduplication.
+
+Reported by Ale R.
+
+---
+
+For the full history of changes, see [Releases](https://github.com/MacRimi/ProxMenux/releases).
+
+---
+
+
## 2026-09-01
### New version ProxMenux v1.2.5
diff --git a/beta_version.txt b/beta_version.txt
index 06e45e12..3c43790f 100644
--- a/beta_version.txt
+++ b/beta_version.txt
@@ -1 +1 @@
-1.2.5.0
+1.2.6
diff --git a/lang/de.json b/lang/de.json
index a20cc357..855d37d7 100644
--- a/lang/de.json
+++ b/lang/de.json
@@ -3252,6 +3252,7 @@
"Pool name matches but GUID differs (fresh ZFS install):": "Poolname stimmt überein, aber GUID unterscheidet sich (neue ZFS-Installation):",
"Pool:": "Pool:",
"Port": "Hafen",
+ "Port must be a number between 1 and 65535.": "Port muss eine Zahl zwischen 1 und 65535 sein.",
"Port:": "Hafen:",
"Portal IP and port are correct": "Portal-IP und Port sind korrekt",
"Portal is reachable": "Portal ist erreichbar",
@@ -3734,6 +3735,7 @@
"SSH login failed": "SSH-Anmeldung fehlgeschlagen",
"SSH network risk": "SSH-Netzwerkrisiko",
"SSH password auth refused on server": "SSH-Passwortauthentifizierung auf dem Server abgelehnt",
+ "SSH port (default 22):": "SSH Port (Standard 22):",
"SSH protection (aggressive mode)": "SSH-Schutz (aggressiver Modus)",
"STEP 6: Choose commands based on your storage type": "SCHRITT 6: Wählen Sie Befehle basierend auf Ihrem Speichertyp aus",
"STEP 9: Cleanup (LVM only)": "SCHRITT 9: Bereinigung (nur LVM)",
diff --git a/lang/es.json b/lang/es.json
index da4254dc..40a810cb 100644
--- a/lang/es.json
+++ b/lang/es.json
@@ -3252,6 +3252,7 @@
"Pool name matches but GUID differs (fresh ZFS install):": "el nombre del grupo coincide pero el GUID difiere (instalación nueva de ZFS):",
"Pool:": "Pool",
"Port": "Puerto",
+ "Port must be a number between 1 and 65535.": "El puerto debe ser un número entre 1 y 65535.",
"Port:": "Puerto:",
"Portal IP and port are correct": "La IP y el puerto del portal son correctos",
"Portal is reachable": "El portal es accesible",
@@ -3734,6 +3735,7 @@
"SSH login failed": "Error al iniciar sesión en SSH",
"SSH network risk": "Riesgo de red SSH",
"SSH password auth refused on server": "Autenticación de contraseña SSH rechazada en el servidor",
+ "SSH port (default 22):": "puerto SSH (predeterminado 22):",
"SSH protection (aggressive mode)": "Protección SSH (modo agresivo)",
"STEP 6: Choose commands based on your storage type": "PASO 6: Elige comandos según el tipo de almacenamiento",
"STEP 9: Cleanup (LVM only)": "PASO 9: Limpieza (solo LVM)",
diff --git a/lang/fr.json b/lang/fr.json
index 42c98625..c5fc6ad4 100644
--- a/lang/fr.json
+++ b/lang/fr.json
@@ -3252,6 +3252,7 @@
"Pool name matches but GUID differs (fresh ZFS install):": "le nom du pool correspond mais le GUID diffère (nouvelle installation de ZFS) :",
"Pool:": "Piscine:",
"Port": "Port",
+ "Port must be a number between 1 and 65535.": "Le port doit être un nombre compris entre 1 et 65 535.",
"Port:": "Port:",
"Portal IP and port are correct": "L'adresse IP et le port du portail sont corrects",
"Portal is reachable": "Le portail est accessible",
@@ -3734,6 +3735,7 @@
"SSH login failed": "Échec de la connexion SSH",
"SSH network risk": "Risque réseau SSH",
"SSH password auth refused on server": "authentification par mot de passe SSH refusée sur le serveur",
+ "SSH port (default 22):": "port SSH (par défaut 22) :",
"SSH protection (aggressive mode)": "Protection SSH (mode agressif)",
"STEP 6: Choose commands based on your storage type": "ÉTAPE 6 : Choisissez les commandes en fonction de votre type de stockage",
"STEP 9: Cleanup (LVM only)": "ÉTAPE 9 : Nettoyage (LVM uniquement)",
diff --git a/lang/it.json b/lang/it.json
index e773917f..efce7e44 100644
--- a/lang/it.json
+++ b/lang/it.json
@@ -3252,6 +3252,7 @@
"Pool name matches but GUID differs (fresh ZFS install):": "il nome del pool corrisponde ma il GUID è diverso (nuova installazione ZFS):",
"Pool:": "Piscina:",
"Port": "Porta",
+ "Port must be a number between 1 and 65535.": "la porta deve essere un numero compreso tra 1 e 65535.",
"Port:": "Porta:",
"Portal IP and port are correct": "L'IP e la porta del portale sono corretti",
"Portal is reachable": "Il portale è raggiungibile",
@@ -3734,6 +3735,7 @@
"SSH login failed": "accesso SSH non riuscito",
"SSH network risk": "Rischio della rete SSH",
"SSH password auth refused on server": "autenticazione password SSH rifiutata sul server",
+ "SSH port (default 22):": "porta SSH (predefinita 22):",
"SSH protection (aggressive mode)": "Protezione SSH (modalità aggressiva)",
"STEP 6: Choose commands based on your storage type": "PASSO 6: Scegli i comandi in base al tipo di archiviazione",
"STEP 9: Cleanup (LVM only)": "PASSO 9: Pulizia (solo LVM)",
diff --git a/lang/pt.json b/lang/pt.json
index ea998740..0653ea97 100644
--- a/lang/pt.json
+++ b/lang/pt.json
@@ -3252,6 +3252,7 @@
"Pool name matches but GUID differs (fresh ZFS install):": "o nome do pool corresponde, mas o GUID é diferente (nova instalação do ZFS):",
"Pool:": "Piscina:",
"Port": "Porta",
+ "Port must be a number between 1 and 65535.": "A porta deve ser um número entre 1 e 65535.",
"Port:": "Porta:",
"Portal IP and port are correct": "O IP e a porta do portal estão corretos",
"Portal is reachable": "O portal está acessível",
@@ -3734,6 +3735,7 @@
"SSH login failed": "falha no login SSH",
"SSH network risk": "Risco de rede SSH",
"SSH password auth refused on server": "autenticação de senha SSH recusada no servidor",
+ "SSH port (default 22):": "porta SSH (padrão 22):",
"SSH protection (aggressive mode)": "Proteção SSH (modo agressivo)",
"STEP 6: Choose commands based on your storage type": "PASSO 6: Escolha comandos com base no seu tipo de armazenamento",
"STEP 9: Cleanup (LVM only)": "PASSO 9: Limpeza (somente LVM)",
diff --git a/lang/sk.json b/lang/sk.json
index f00d14b4..c7c170dc 100644
--- a/lang/sk.json
+++ b/lang/sk.json
@@ -3252,6 +3252,7 @@
"Pool name matches but GUID differs (fresh ZFS install):": "Názov poolu sedí, ale GUID je iné (čerstvá ZFS inštalácia):",
"Pool:": "Pool:",
"Port": "Port",
+ "Port must be a number between 1 and 65535.": "Port musí byť číslo od 1 do 65535.",
"Port:": "Port:",
"Portal IP and port are correct": "IP adresa portálu a port sú správne",
"Portal is reachable": "Portál je dostupný",
@@ -3734,6 +3735,7 @@
"SSH login failed": "SSH prihlásenie zlyhalo",
"SSH network risk": "Riziko odpojenia SSH",
"SSH password auth refused on server": "Server odmietol prihlásenie SSH heslom",
+ "SSH port (default 22):": "SSH port (predvolený 22):",
"SSH protection (aggressive mode)": "ochranou SSH (agresívny režim)",
"STEP 6: Choose commands based on your storage type": "KROK 6: vyberte príkazy podľa typu úložiska",
"STEP 9: Cleanup (LVM only)": "KROK 9: čistenie (iba LVM)",
diff --git a/lang/sv.json b/lang/sv.json
index 4262e656..9b938571 100644
--- a/lang/sv.json
+++ b/lang/sv.json
@@ -3252,6 +3252,7 @@
"Pool name matches but GUID differs (fresh ZFS install):": "Poolnamn matchar men GUID skiljer sig (ny ZFS-installation):",
"Pool:": "Slå samman:",
"Port": "Hamn",
+ "Port must be a number between 1 and 65535.": "Port måste vara ett tal mellan 1 och 65535.",
"Port:": "Hamn:",
"Portal IP and port are correct": "Portal IP och port är korrekta",
"Portal is reachable": "Portalen är tillgänglig",
@@ -3734,6 +3735,7 @@
"SSH login failed": "SSH-inloggning misslyckades",
"SSH network risk": "Risk för SSH-nätverk",
"SSH password auth refused on server": "SSH-lösenordsautentisering nekades på servern",
+ "SSH port (default 22):": "SSH port (standard 22):",
"SSH protection (aggressive mode)": "SSH-skydd (aggressivt läge)",
"STEP 6: Choose commands based on your storage type": "STEG 6: Välj kommandon baserat på din lagringstyp",
"STEP 9: Cleanup (LVM only)": "STEG 9: Rensning (endast LVM)",
diff --git a/scripts/backup_restore/lib_host_backup_common.sh b/scripts/backup_restore/lib_host_backup_common.sh
index 3d71a7b7..1b90ac2e 100755
--- a/scripts/backup_restore/lib_host_backup_common.sh
+++ b/scripts/backup_restore/lib_host_backup_common.sh
@@ -2163,6 +2163,12 @@ hb_borg_generate_and_install_key() {
local borg_user="$1" host="$2" rpath="$3" mode="$4"
local _out_var="$5"
local -n _out_ref="$_out_var"
+ # Custom SSH port arrives via env var so the callers (this file
+ # itself, in 7+ places) don't have to change their signature. When
+ # unset or 22, ssh uses its default and no `-p` flag is injected.
+ local _port="${HB_BORG_INSTALL_PORT:-22}"
+ local _p_flag=()
+ [[ "$_port" != "22" ]] && _p_flag=(-p "$_port")
local key_file="$HOME/.ssh/borg_proxmenux_$(echo "$host" | tr './:' '___')_ed25519"
local pub_file="${key_file}.pub"
@@ -2346,6 +2352,7 @@ hb_borg_generate_and_install_key() {
-o StrictHostKeyChecking=accept-new \
-o PreferredAuthentications=password -o PubkeyAuthentication=no \
-o NumberOfPasswordPrompts=1 -o ConnectTimeout=10 \
+ "${_p_flag[@]}" \
"$admin_user@$host" "true" 2>&1) || true
if echo "$_probe" | grep -qiE "permission denied[[:space:]]*\(publickey"; then
# SSH password auth refused by the server — common when the Borg
@@ -2402,6 +2409,7 @@ hb_borg_generate_and_install_key() {
local push_rc
SSHPASS="$admin_pass" sshpass -e ssh -o StrictHostKeyChecking=accept-new \
-o PreferredAuthentications=password -o PubkeyAuthentication=no \
+ "${_p_flag[@]}" \
"$admin_user@$host" "$install_cmd" <<<"$authorized_line" >/tmp/proxmenux-borg-keypush.log 2>&1
push_rc=$?
@@ -2509,6 +2517,22 @@ hb_configure_borg_manual() {
12 78 "borg" 3>&1 1>&2 2>&3) || return 1
host=$(dialog --backtitle "ProxMenux" --inputbox "$(hb_translate "SSH host or IP:")" \
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "" 3>&1 1>&2 2>&3) || return 1
+ # Custom SSH port (defaults to 22). NAS-style hosts often
+ # move SSH off 22 to keep their intrusion warnings quiet.
+ # Accepted range: 1-65535; anything else re-prompts.
+ local port
+ while :; do
+ port=$(dialog --backtitle "ProxMenux" \
+ --inputbox "$(hb_translate "SSH port (default 22):")" \
+ "$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "22" 3>&1 1>&2 2>&3) || return 1
+ port="${port//[[:space:]]/}"
+ [[ -z "$port" ]] && port=22
+ if [[ "$port" =~ ^[0-9]+$ ]] && (( port >= 1 && port <= 65535 )); then
+ break
+ fi
+ dialog --backtitle "ProxMenux" \
+ --msgbox "$(hb_translate "Port must be a number between 1 and 65535.")" 8 60
+ done
rpath=$(dialog --backtitle "ProxMenux" \
--inputbox "$(hb_translate "Remote repository path:")" \
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "/backup/borgbackup" \
@@ -2598,7 +2622,12 @@ hb_configure_borg_manual() {
fi
;;
generate-auto|generate-manual|generate-pct)
- if ! hb_borg_generate_and_install_key "$user" "$host" "$rpath" "$key_mode" ssh_key; then
+ # Thread the custom port to the key-install helper
+ # via env var so the sshpass probe and the actual
+ # push both hit the right port on the Borg server.
+ HB_BORG_INSTALL_PORT="${port:-22}" \
+ hb_borg_generate_and_install_key "$user" "$host" "$rpath" "$key_mode" ssh_key
+ if (( $? != 0 )); then
return 1
fi
;;
@@ -2606,7 +2635,15 @@ hb_configure_borg_manual() {
ssh_key=""
;;
esac
- repo="ssh://$user@$host/$rpath"
+ # Custom SSH port is embedded in the URL — Borg's ssh://
+ # scheme natively supports `ssh://user@host:port/path`. Port
+ # 22 is left out for backwards compatibility with existing
+ # borg-targets.txt entries that never carried the port.
+ if [[ "${port:-22}" == "22" ]]; then
+ repo="ssh://$user@$host/$rpath"
+ else
+ repo="ssh://$user@$host:$port/$rpath"
+ fi
;;
esac
@@ -2635,7 +2672,13 @@ hb_configure_borg_manual() {
_borg_repo_ref_new="$repo"
if [[ -n "$ssh_key" ]]; then
- export BORG_RSH="ssh -i $ssh_key -o StrictHostKeyChecking=accept-new"
+ local rsh_cmd="ssh -i $ssh_key -o StrictHostKeyChecking=accept-new"
+ [[ -n "${port:-}" && "$port" != "22" ]] && rsh_cmd="$rsh_cmd -p $port"
+ export BORG_RSH="$rsh_cmd"
+ elif [[ -n "${port:-}" && "$port" != "22" ]]; then
+ # No custom key but non-default port — still need to tell ssh
+ # which port to hit so `borg` doesn't fall back to 22.
+ export BORG_RSH="ssh -o StrictHostKeyChecking=accept-new -p $port"
else
unset BORG_RSH
fi
diff --git a/version.txt b/version.txt
index c813fe11..3c43790f 100644
--- a/version.txt
+++ b/version.txt
@@ -1 +1 @@
-1.2.5
+1.2.6
diff --git a/web/data/changelog/es.md b/web/data/changelog/es.md
index 34bf9996..470d775c 100644
--- a/web/data/changelog/es.md
+++ b/web/data/changelog/es.md
@@ -1,3 +1,76 @@
+## 2026-09-02
+
+### Nueva versión ProxMenux v1.2.6
+
+Una versión centrada en restaurar el soporte del Asistente IA para endpoints compatibles con OpenAI alojados en IPs privadas, loopback y redes Docker, alinear el asistente de Secure Gateway con la arquitectura real del host, y consolidar varias mejoras que ya venían acumulándose en develop: entrega atómica de notificaciones, puerto SSH personalizado para destinos remotos Borg, token opcional de la API de GitHub para el seguimiento de versiones de aplicaciones y notificaciones de fallo de replicación con contexto completo.
+
+---
+
+## 🛠 Endpoint OpenAI personalizado del Asistente IA — URLs de LAN / Docker / localhost
+
+- Los endpoints compatibles con OpenAI accesibles en IPs privadas, loopback o redes Docker (LiteLLM, LM Studio, LocalAI, vLLM, OmniRoute, proxies autoalojados…) se aceptan al cargar el catálogo de modelos y al validar la configuración de IA.
+- El desplegable muestra el motivo devuelto por el servidor (o el error de red subyacente) justo debajo del botón *Cargar*, así una configuración incorrecta deja de aparecer como una lista vacía y silenciosa.
+- Traducido a todos los idiomas del Monitor.
+
+Reportado en la [issue #325](https://github.com/MacRimi/ProxMenux/issues/325) por [@jorgeffonte](https://github.com/jorgeffonte).
+
+---
+
+## 🛠 Asistente Secure Gateway — la plantilla LXC coincide con la arquitectura del host
+
+- La descarga de la plantilla Alpine filtra los resultados de `pveam available` por la arquitectura del host (mediante `dpkg --print-architecture`, con fallback a `uname -m`), así un host Proxmox x86_64 recibe la plantilla `amd64` y un host arm64 recibe la plantilla `arm64`.
+- La selección de plantilla local aplica el mismo filtro de arquitectura al reutilizar una plantilla Alpine ya descargada.
+- `pct create` se invoca con `--arch ` explícito para que los metadatos del contenedor reflejen la arquitectura real del host.
+
+Reportado en la [issue #324](https://github.com/MacRimi/ProxMenux/issues/324) por [@N0X4DD0](https://github.com/N0X4DD0).
+
+---
+
+## 🔔 Entrega atómica de notificaciones
+
+- Los eventos de notificación reservan su huella de deduplicación de forma atómica antes del procesado por IA y del envío por canal, de modo que colectores concurrentes, callbacks de finalización o procesos Monitor paralelos accidentales no pueden enviar el mismo evento dos veces.
+- La reserva se comparte a través de SQLite, expira de forma segura si una ejecución se interrumpe y se libera cuando ningún canal tiene éxito, preservando los reintentos ante fallos transitorios de transporte.
+
+---
+
+## 🗄 Destino remoto Borg — puerto SSH personalizado
+
+- El diálogo *Añadir destino Borg* del Monitor y el TUI del shell (`menu` → *Host Backup* → *New Borg target*) aceptan un puerto SSH personalizado. El valor por defecto sigue siendo `22`; cualquier valor entre 1 y 65535 se incrusta en la URL `ssh://user@host:port/path` persistida.
+- `BORG_RSH` respeta el puerto personalizado en el momento del backup, así los jobs programados y las ejecuciones manuales alcanzan el puerto correcto.
+- El flujo de instalación automática de clave (`generate-auto`) también apunta al puerto personalizado.
+- Totalmente retrocompatible con las entradas existentes en `borg-targets.txt` creadas sin puerto explícito.
+- Las sondas de capacidad sobre SSH también respetan el puerto personalizado, así la insignia *Available* permanece precisa en puertos no estándar.
+
+Reportado en la [discusión #236](https://github.com/MacRimi/ProxMenux/discussions/236) por [@songochain](https://github.com/songochain).
+
+---
+
+## 🎯 Seguimiento de versiones de aplicaciones — token opcional de la API de GitHub
+
+- **Settings → GitHub API** acepta un token de acceso personal opcional para las comprobaciones de releases y tags cuando se agota la cuota anónima de GitHub.
+- El token se guarda cifrado, nunca se devuelve al navegador y puede sustituirse o eliminarse de forma independiente del servicio de Notificaciones.
+- El flujo anónimo de GitHub sigue siendo el predeterminado; no se requiere un token mientras la cuota compartida sin autenticar esté disponible.
+- El error de límite de tasa apunta al ajuste real y está traducido en todos los idiomas del Monitor.
+
+Reportado en la [discusión #306](https://github.com/MacRimi/ProxMenux/discussions/306) por [@SystemIdleProcess](https://github.com/SystemIdleProcess).
+
+---
+
+## 🔁 Notificaciones de fallo de replicación — contexto completo del job
+
+- Los webhooks nativos de replicación de Proxmox resuelven el ID del trabajo de replicación, el ID de la VM/LXC afectada y el nombre del guest antes de renderizar la notificación.
+- El bloque de error exacto proporcionado por Proxmox se conserva como motivo, incluyendo fallos multilínea, con el mensaje completo como fallback seguro cuando el bloque no está presente.
+- Las notificaciones de replicación se identifican por su ID de job completo, así los fallos de trabajos de replicación distintos permanecen independientes durante la deduplicación.
+
+Reportado por Ale R.
+
+---
+
+Para el historial completo de cambios, consulta [Releases](https://github.com/MacRimi/ProxMenux/releases).
+
+---
+
+
## 2026-09-01
### Nueva versión ProxMenux v1.2.5
diff --git a/web/messages/en/docs/monitor/dashboard/vms-lxcs-updates.json b/web/messages/en/docs/monitor/dashboard/vms-lxcs-updates.json
index 9b16bb35..8b594f24 100644
--- a/web/messages/en/docs/monitor/dashboard/vms-lxcs-updates.json
+++ b/web/messages/en/docs/monitor/dashboard/vms-lxcs-updates.json
@@ -140,7 +140,10 @@
"items": [
"Choose a preset or cron expression, then select exact targets: OS packages, individual apps, Docker Engine, standalone Docker units or Compose service groups.",
"A release hold applies only to selected applications with version tracking. Apps without tracking run their updater whenever their schedule is due.",
- "The last-run state distinguishes success, partial completion, failure, safety hold and a run with nothing pending.",
+ "The last-run state distinguishes success, partial completion, failure, safety hold and a run with nothing pending. After the first scheduled run, Updates → Scheduled updates → View log opens the complete output captured from the updater and any child scripts it invoked.",
+ "After each run, ProxMenux checks the standard Debian reboot-required marker. When a restart is needed, the Updates tab and the completion notification say so; the warning is cleared when that LXC starts or restarts.",
+ "On the Proxmox host, scheduled-run logs are stored in /usr/local/share/proxmenux/logs/lxc-updates/ using the name <VMID>-scheduled-<run-id>.log. The latest ten logs are retained per LXC and older files are removed automatically.",
+ "This retained history applies to scheduled updates. A manual update displays its output live in the Monitor execution window and does not create a scheduled-run log in that directory.",
"External host schedules detected from Proxmox VE Helper-Scripts are shown separately so overlapping automation is visible."
],
"callout": "Run every selected method manually before enabling a schedule. Scheduled commands cannot answer prompts."
@@ -150,6 +153,7 @@
"lead": "The update is not considered finished when the terminal command merely exits.",
"items": [
"The same run records its final result and refreshes OS package state, registered app versions and Docker inventory as applicable.",
+ "Scheduled runs retain their terminal output and report whether a restart is still required to finish applying package changes.",
"The LXC cache is replaced with the verified post-update state, so badges and buttons do not retain the previous result.",
"If a stopped or restored LXC starts, the existing lifecycle event refreshes that LXC again. Docker inventory waits for the daemon to become ready instead of caching an empty startup result as final.",
"Enabled notifications are emitted from the finalized run, including partial failures and grouped Docker image results."
@@ -179,6 +183,10 @@
{
"problem": "A custom command fails",
"resolution": "Run it in the LXC terminal and review its path, dependencies, non-interactive flags and exit code."
+ },
+ {
+ "problem": "A scheduled update says that a restart is required",
+ "resolution": "Open View log to review the completed run, then restart that LXC. ProxMenux clears the warning from the existing lifecycle event after the container starts again."
}
]
},
diff --git a/web/messages/es/docs/monitor/dashboard/vms-lxcs-updates.json b/web/messages/es/docs/monitor/dashboard/vms-lxcs-updates.json
index 34d3562f..9047862c 100644
--- a/web/messages/es/docs/monitor/dashboard/vms-lxcs-updates.json
+++ b/web/messages/es/docs/monitor/dashboard/vms-lxcs-updates.json
@@ -140,7 +140,10 @@
"items": [
"Selecciona una frecuencia o expresión cron y después objetivos exactos: paquetes del SO, apps individuales, Docker Engine, unidades Docker independientes o grupos de servicios Compose.",
"La espera tras una versión solo se aplica a las apps seleccionadas con seguimiento. Las apps sin seguimiento ejecutan su actualizador cuando vence la programación.",
- "El estado de la última ejecución diferencia entre éxito, finalización parcial, error, retención de seguridad y ausencia de elementos pendientes.",
+ "El estado de la última ejecución diferencia entre éxito, finalización parcial, error, retención de seguridad y ausencia de elementos pendientes. Después de la primera ejecución programada, Actualizaciones → Actualizaciones programadas → Ver log abre la salida completa capturada del actualizador y de los scripts secundarios que haya ejecutado.",
+ "Después de cada ejecución, ProxMenux comprueba el marcador estándar de Debian que indica si es necesario reiniciar. Cuando hace falta, la pestaña Actualizaciones y la notificación de finalización lo indican; el aviso se elimina cuando ese LXC se inicia o reinicia.",
+ "En el host Proxmox, los logs de las ejecuciones programadas se guardan en /usr/local/share/proxmenux/logs/lxc-updates/ con el nombre <VMID>-scheduled-<id-de-ejecución>.log. Se conservan los diez últimos logs de cada LXC y los archivos más antiguos se eliminan automáticamente.",
+ "Este historial corresponde a las actualizaciones programadas. Una actualización manual muestra su salida en tiempo real en la ventana de ejecución del Monitor y no crea un log de ejecución programada en ese directorio.",
"Las programaciones externas detectadas de Proxmox VE Helper-Scripts se muestran aparte para hacer visible cualquier automatización coincidente."
],
"callout": "Cada método seleccionado debe probarse manualmente antes de programarlo. Una tarea programada no puede responder a preguntas interactivas."
@@ -150,6 +153,7 @@
"lead": "La actualización no se considera terminada únicamente porque el comando del terminal haya finalizado.",
"items": [
"La misma ejecución guarda el resultado final y actualiza, según corresponda, los paquetes del SO, las versiones de las apps y el inventario Docker.",
+ "Las ejecuciones programadas conservan la salida del terminal e indican si todavía es necesario reiniciar para terminar de aplicar los cambios de los paquetes.",
"La caché del LXC se reemplaza con el estado verificado tras la actualización para que insignias y botones no conserven el resultado anterior.",
"Si arranca un LXC parado o restaurado, el evento de ciclo de vida existente vuelve a actualizar ese LXC. El inventario Docker espera a que el daemon esté disponible en lugar de guardar como definitivo un resultado vacío del arranque.",
"Las notificaciones activadas se emiten desde la ejecución finalizada e incluyen fallos parciales y resultados agrupados de imágenes Docker."
@@ -179,6 +183,10 @@
{
"problem": "Falla un comando personalizado",
"resolution": "Ejecútalo en el terminal del LXC y revisa la ruta, las dependencias, los parámetros no interactivos y el código de salida."
+ },
+ {
+ "problem": "Una actualización programada indica que es necesario reiniciar",
+ "resolution": "Abre Ver log para revisar la ejecución completada y reinicia ese LXC. ProxMenux elimina el aviso mediante el evento de ciclo de vida existente cuando el contenedor vuelve a arrancar."
}
]
},