diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index ed6632d3..47e63a88 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -7,6 +7,7 @@ on:
paths:
- "web/**"
- "guides/**"
+ - "scripts/**"
- "CHANGELOG.md"
workflow_dispatch:
@@ -31,15 +32,15 @@ jobs:
with:
node-version: "20"
cache: 'npm'
- cache-dependency-path: 'web/package.json'
+ cache-dependency-path: 'web/package-lock.json'
- name: Setup Pages
uses: actions/configure-pages@v4
- - name: Install dependencies and generate lock file
+ - name: Install dependencies
run: |
cd web
- npm install
+ npm ci
- name: Build with Next.js
run: |
diff --git a/.gitignore b/.gitignore
index 19fae05e..94bbb09e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,6 +6,14 @@ web/out/
web/node_modules/
node_modules/
+# Local-only — accidental pagefind install at project root.
+# Pagefind is declared and installed from web/package.json; the
+# CI build (.github/workflows/deploy.yml) only runs
+# `cd web && npm install`, so a root-level package.json/lock is
+# never consumed and just adds noise. Keep them ignored.
+/package.json
+/package-lock.json
+
# Logs
web/*.log
*.log
@@ -33,6 +41,15 @@ Thumbs.db
/web/.next
/web/out
+# Build artifacts generated by web's prebuild + build scripts.
+# `prebuild` runs `sync:scripts` which rsyncs ../scripts/ into
+# public/scripts/. `build` runs pagefind --site out which writes the
+# search index into public/pagefind/. Both are regenerated fresh by
+# the GitHub Pages CI on every deploy; committing them would just
+# bloat the repo and produce constant noise in `git status`.
+/web/public/pagefind/
+/web/public/scripts/
+
# Cache
.cache
/web/.cache
diff --git a/AppImage/ProxMenux-1.2.2.AppImage b/AppImage/ProxMenux-1.2.2.AppImage
new file mode 100755
index 00000000..c96e595a
Binary files /dev/null and b/AppImage/ProxMenux-1.2.2.AppImage differ
diff --git a/AppImage/ProxMenux-Monitor.AppImage.sha256 b/AppImage/ProxMenux-Monitor.AppImage.sha256
index 48d39a76..21e23405 100644
--- a/AppImage/ProxMenux-Monitor.AppImage.sha256
+++ b/AppImage/ProxMenux-Monitor.AppImage.sha256
@@ -1 +1 @@
-db5bc199adba9c231f344428ac902a0cbf7473778e8a79a4535263599d975449 ProxMenux-1.2.0.AppImage
+097e2344675d4b21f1dd18c531c956c299a6507fbc3d0c9695418063581ba2b0
diff --git a/AppImage/app/page.tsx b/AppImage/app/page.tsx
index 826117f7..810f2766 100644
--- a/AppImage/app/page.tsx
+++ b/AppImage/app/page.tsx
@@ -29,21 +29,57 @@ export default function Home() {
const response = await fetch(getApiUrl("/api/auth/status"), {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
-
+
+ // 401 here means the token is present but invalid — typically signed
+ // under a previous jwt_secret (rotated on AppImage upgrade or fresh
+ // install). If we let this fall into the catch below, the dashboard
+ // would render and every authenticated component would fire its own
+ // 401 in parallel, flooding the backend logs and looping reloads.
+ // Drop the dead token and force the Login screen instead.
+ if (response.status === 401) {
+ try {
+ localStorage.removeItem("proxmenux-auth-token")
+ } catch {
+ // private browsing — best-effort
+ }
+ setAuthStatus({
+ loading: false,
+ authEnabled: true,
+ authConfigured: true,
+ authenticated: false,
+ })
+ return
+ }
+
// Check if response is valid JSON before parsing
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
-
+
const contentType = response.headers.get("content-type")
if (!contentType || !contentType.includes("application/json")) {
throw new Error("Response is not JSON")
}
-
+
const data = await response.json()
const authenticated = data.auth_enabled ? data.authenticated : true
+ // Clear the 401 cascade-prevention flag when we successfully end
+ // up in the authenticated state. The flag is meant to dedupe a
+ // burst of 401s during a single page load; once we've confirmed
+ // the user is in, a future 401 (token rotation, restart, etc.)
+ // should be allowed to reload again. Without this, a stale flag
+ // can prevent the post-2FA dashboard from recovering from any
+ // transient 401 and leaves the UI blocked.
+ if (authenticated) {
+ try {
+ sessionStorage.removeItem("proxmenux-auth-401-handled")
+ } catch {
+ // private browsing — best-effort
+ }
+ }
+
setAuthStatus({
loading: false,
authEnabled: data.auth_enabled,
diff --git a/AppImage/components/about.tsx b/AppImage/components/about.tsx
new file mode 100644
index 00000000..86f91ef3
--- /dev/null
+++ b/AppImage/components/about.tsx
@@ -0,0 +1,234 @@
+"use client"
+
+import Image from "next/image"
+import {
+ Github,
+ Heart,
+ BookOpen,
+ MessageSquare,
+ Bug,
+ Sparkles,
+ Scale,
+ ExternalLink,
+} from "lucide-react"
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
+import { APP_VERSION } from "./release-notes-modal"
+
+// Issue #191: a dedicated About tab. Centralises project metadata
+// (version, license, author) and every external link the project
+// already exposes — GitHub, docs, donation. Replaces the lone
+// "Support and contribute to the project" footer link with a proper
+// information surface that's easy to extend with new social channels
+// without re-cluttering the dashboard footer.
+
+interface LinkRow {
+ label: string
+ description: string
+ href: string
+ Icon: React.ComponentType<{ className?: string }>
+ accent?: keyof typeof ACCENT_CLASSES
+}
+
+// Tailwind only emits classes that appear as literal strings in the
+// source. A dynamic `bg-${accent}/10` template does not survive the
+// purge step, so each accent maps to a fully-spelled class pair below.
+const ACCENT_CLASSES = {
+ gray: "bg-gray-500/10 text-gray-400",
+ blue: "bg-blue-500/10 text-blue-500",
+ purple: "bg-purple-500/10 text-purple-400",
+ red: "bg-red-500/10 text-red-500",
+ pink: "bg-pink-500/10 text-pink-500",
+} as const
+
+const PROJECT_LINKS: LinkRow[] = [
+ {
+ label: "GitHub repository",
+ description: "Source code, releases and issue tracker.",
+ href: "https://github.com/MacRimi/ProxMenux",
+ Icon: Github,
+ accent: "gray",
+ },
+ {
+ label: "Documentation",
+ description: "Full user guide for ProxMenux and the Monitor.",
+ href: "https://proxmenux.com",
+ Icon: BookOpen,
+ accent: "blue",
+ },
+ {
+ label: "Discussions",
+ description: "Ask questions, share custom AI prompts, swap ideas.",
+ href: "https://github.com/MacRimi/ProxMenux/discussions",
+ Icon: MessageSquare,
+ accent: "purple",
+ },
+ {
+ label: "Report a bug or request a feature",
+ description: "Open an issue on GitHub — bugs, ideas, regressions.",
+ href: "https://github.com/MacRimi/ProxMenux/issues",
+ Icon: Bug,
+ accent: "red",
+ },
+]
+
+const SUPPORT_LINKS: LinkRow[] = [
+ {
+ label: "Support the project on Ko-fi",
+ description: "ProxMenux is free and open source. Donations cover hosting and dev time.",
+ href: "https://ko-fi.com/macrimi",
+ Icon: Heart,
+ accent: "pink",
+ },
+]
+
+function LinkCard({ row }: { row: LinkRow }) {
+ const accentClass = ACCENT_CLASSES[row.accent ?? "blue"]
+ // Style mirrors the PCI Devices cards in the Hardware tab: subtle
+ // translucent background by default, slightly lighter on hover, no
+ // accent-coloured borders or text colour changes — keeps the look
+ // consistent with the rest of the project.
+ return (
+
+
+ {row.description}
+ A web dashboard and management layer for Proxmox VE — health monitoring, + notifications, terminal, optimization tracker and more, packaged as a single + AppImage. +
++ Free software — see the LICENSE file for the full text. +
++ Profile · optional +
+ ++ Leave empty to render the username itself. Up to 64 characters. +
++ PNG, JPEG, WebP or GIF · up to 2 MB · pre-crop square for best results. +
+{reason}
{/* Show dismiss button for UNKNOWN status at category level when dismissable */} {status === "UNKNOWN" && categoryData?.dismissable && !hasChecks && ( - ++ {section.description} +
+ )} +ProxMenux Monitor v1.2.0
+ProxMenux Monitor v1.2.2
) diff --git a/AppImage/components/lxc-terminal-modal.tsx b/AppImage/components/lxc-terminal-modal.tsx index 7c2c387f..bbfcbb96 100644 --- a/AppImage/components/lxc-terminal-modal.tsx +++ b/AppImage/components/lxc-terminal-modal.tsx @@ -19,7 +19,10 @@ import { Terminal, Trash2, X, + Copy, + Clipboard, } from "lucide-react" +import { copyTerminalSelection, pasteFromClipboard } from "@/lib/terminal-clipboard" import { DropdownMenu, DropdownMenuContent, @@ -33,6 +36,7 @@ import { Input } from "@/components/ui/input" import { Dialog as SearchDialog, DialogContent as SearchDialogContent, DialogTitle as SearchDialogTitle } from "@/components/ui/dialog" import "xterm/css/xterm.css" import { API_PORT, fetchApi } from "@/lib/api-config" +import { getTicketedWsUrl } from "@/lib/terminal-ws" interface LxcTerminalModalProps { open: boolean @@ -161,9 +165,16 @@ export function LxcTerminalModal({ useEffect(() => { if (!isOpen) return + // `cancelled` short-circuits the async init if the modal closes + // before the dynamic xterm import resolves. Without this, we'd + // construct a Terminal instance, attach it to a now-stale ref, and + // open a WebSocket that nobody listens to. Audit Tier 6 — useEffect + // con `import("xterm")` sin cancelación. + let cancelled = false + // Small delay to ensure Dialog content is rendered const initTimeout = setTimeout(() => { - if (!terminalContainerRef.current) return + if (cancelled || !terminalContainerRef.current) return initTerminal() }, 100) @@ -172,12 +183,13 @@ export function LxcTerminalModal({ import("xterm").then((mod) => mod.Terminal), import("xterm-addon-fit").then((mod) => mod.FitAddon), ]) + if (cancelled) return const fontSize = window.innerWidth < 768 ? 12 : 16 const term = new TerminalClass({ rendererType: "dom", - fontFamily: '"Courier", "Courier New", "Liberation Mono", "DejaVu Sans Mono", monospace', + fontFamily: '"MesloLGS NF", "FiraCode Nerd Font", "JetBrainsMono Nerd Font", "Hack Nerd Font", "Symbols Nerd Font", "Courier", "Courier New", "Liberation Mono", "DejaVu Sans Mono", monospace', fontSize: fontSize, lineHeight: 1, cursorBlink: true, @@ -221,9 +233,11 @@ export function LxcTerminalModal({ termRef.current = term fitAddonRef.current = fitAddon - // Connect WebSocket to host terminal + // Connect WebSocket to host terminal. We append a single-use ticket + // (`?ticket=...`) which the backend consumes on handshake — see + // lib/terminal-ws.ts and AppImage/scripts/flask_terminal_routes.py. const wsUrl = getWebSocketUrl() - const ws = new WebSocket(wsUrl) + const ws = new WebSocket(await getTicketedWsUrl(wsUrl)) wsRef.current = ws // Reset state for new connection @@ -252,11 +266,22 @@ export function LxcTerminalModal({ rows: term.rows, })) - // Auto-execute pct enter after connection is ready + // Auto-execute pct enter after connection is ready. + // The string is sent verbatim to the bash PTY, so a non-numeric + // `vmid` would land as shell input (e.g. `pct enter ; rm -rf /`). + // The prop is typed `number` but JSON / URL query injections can + // sneak strings in; validate as a defensive redundancy. Audit + // residual #lxc-terminal-vmid-injection. setTimeout(() => { - if (ws.readyState === WebSocket.OPEN) { - ws.send(`pct enter ${vmid}\r`) + if (ws.readyState !== WebSocket.OPEN) return + // Coerce + verify: must be a positive integer that round-trips + // through Number without losing fidelity. + const id = Number(vmid) + if (!Number.isInteger(id) || id <= 0 || id >= 1_000_000) { + term.writeln('\r\n\x1b[31m[ERROR] Invalid VMID — refusing to execute pct enter\x1b[0m') + return } + ws.send(`pct enter ${id}\r`) }, 300) } @@ -302,13 +327,17 @@ export function LxcTerminalModal({ if (pctEnterMatch) { const afterPctEnter = cleanBuffer.substring(cleanBuffer.indexOf(pctEnterMatch[0]) + pctEnterMatch[0].length) - // Extract the host name from the prompt BEFORE pct enter (e.g., "root@amd") - const hostPromptMatch = cleanBuffer.match(/@([a-zA-Z0-9_-]+).*pct enter/) + // Extract the host name from the prompt BEFORE pct enter (e.g., "root@amd"). + // Charset widened to accept dotted FQDNs (`proxmox.lan`) and unicode + // letters/numbers (host names like `próxmox` or non-Latin scripts). + // The previous `[a-zA-Z0-9_-]` truncated the hostname and the + // "are we inside the LXC?" comparison then misfired. + const hostPromptMatch = cleanBuffer.match(/@([\p{L}\p{N}._-]+).*pct enter/u) const hostName = hostPromptMatch ? hostPromptMatch[1] : null - + // Look for a new prompt after pct enter that ends with # or $ // This works for both bash (user@host:~#) and ash/Alpine ([user@host /]#) - const promptMatch = afterPctEnter.match(/[@\[]([a-zA-Z0-9_-]+)[^\r\n]*[#$]\s*$/) + const promptMatch = afterPctEnter.match(/[@\[]([\p{L}\p{N}._-]+)[^\r\n]*[#$]\s*$/u) if (promptMatch) { const lxcHostname = promptMatch[1] @@ -354,6 +383,7 @@ export function LxcTerminalModal({ } return () => { + cancelled = true clearTimeout(initTimeout) if (pingIntervalRef.current) { clearInterval(pingIntervalRef.current) @@ -435,6 +465,14 @@ export function LxcTerminalModal({ const sendEnter = useCallback(() => sendKey("\r"), [sendKey]) const sendCtrlC = useCallback(() => sendKey("\x03"), [sendKey]) // Ctrl+C + // Mobile clipboard helpers — see lib/terminal-clipboard.ts for the rationale. + const handleCopy = useCallback(async () => { + await copyTerminalSelection(termRef.current) + }, []) + const handlePaste = useCallback(async () => { + await pasteFromClipboard(sendKey) + }, [sendKey]) + // Search effect - debounced search with cheat.sh useEffect(() => { const searchCheatSh = async (query: string) => { @@ -634,7 +672,7 @@ export function LxcTerminalModal({apt list --upgradable / apk list -u) and surface them on the dashboard. The
+ corresponding notification toggle in Notifications → Services appears only while detection
+ is enabled.
+ + {lastPurged} LXC entries removed from the registry. Re-enabling detection will repopulate them on the + next scan cycle. +
+{error}
++ During this window only CRITICAL events reach this channel. +
++ {sameTime + ? "Set a different start and end time to activate." + : live + ? `Active right now — only CRITICAL events pass until ${end}.` + : `Inactive right now — will start at ${start}.`} +
+ > + )} ++ All INFO events (backups OK, updates available, etc.) accumulate during the day and arrive once at this time as a single summary. CRITICAL and WARNING are never delayed. +
+{nextLabel}
+ > + )} +{v.error}
: null + })()}` token is atomic — the whole line
+ would scroll horizontally on narrow viewports.
+ `break-all` on the wrapper lets the layout break
+ mid-token if the viewport is really tight; on
+ wider screens the natural commas/spaces still
+ control wrapping. */}
+
+ A single URL that Apprise routes to the right service. Examples:
+ tgram://,
+ discord://,
+ slack://,
+ ntfy://,
+ matrix://,
+ pushover://,
+ mailto://… See the
+ {" "}
+
+ full list
+ .
+
+ + PNG, JPEG, WebP or GIF. Up to 2 MB. The image isn't resized — + render it square or pre-crop for best results in the header. +
++ The login name. To change it, disable authentication and reconfigure from + Security. +
++ Shown above the username inside the avatar menu. Leave empty to show the + username itself. Up to 64 characters. +
+ {error && displayEditMode && ( ++ Open the Security tab from the navigation. +
+ )} +