From 105576cf175e6833848c1fbb661888a03843f265 Mon Sep 17 00:00:00 2001 From: MacRimi Date: Sun, 24 May 2026 11:37:20 +0200 Subject: [PATCH] Update AppImage --- AppImage/components/lxc-terminal-modal.tsx | 18 +---- AppImage/components/script-terminal-modal.tsx | 20 +---- AppImage/components/settings.tsx | 35 ++++++-- AppImage/components/terminal-panel.tsx | 17 +--- AppImage/lib/terminal-ws.ts | 80 ++++++++++++++++++- AppImage/scripts/flask_server.py | 14 ++++ AppImage/scripts/flask_terminal_routes.py | 15 +++- 7 files changed, 140 insertions(+), 59 deletions(-) diff --git a/AppImage/components/lxc-terminal-modal.tsx b/AppImage/components/lxc-terminal-modal.tsx index bbfcbb96..3d372fc0 100644 --- a/AppImage/components/lxc-terminal-modal.tsx +++ b/AppImage/components/lxc-terminal-modal.tsx @@ -35,8 +35,8 @@ import { DialogHeader, DialogDescription } from "@/components/ui/dialog" 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" +import { fetchApi } from "@/lib/api-config" +import { getTicketedWsUrl, getWsUrl } from "@/lib/terminal-ws" interface LxcTerminalModalProps { open: boolean @@ -80,19 +80,7 @@ const proxmoxCommands = [ ] function getWebSocketUrl(): string { - if (typeof window === "undefined") { - return "ws://localhost:8008/ws/terminal" - } - - const { protocol, hostname, port } = window.location - const isStandardPort = port === "" || port === "80" || port === "443" - const wsProtocol = protocol === "https:" ? "wss:" : "ws:" - - if (isStandardPort) { - return `${wsProtocol}//${hostname}/ws/terminal` - } else { - return `${wsProtocol}//${hostname}:${API_PORT}/ws/terminal` - } + return getWsUrl("/ws/terminal") } export function LxcTerminalModal({ diff --git a/AppImage/components/script-terminal-modal.tsx b/AppImage/components/script-terminal-modal.tsx index 59c67769..f43c01d9 100644 --- a/AppImage/components/script-terminal-modal.tsx +++ b/AppImage/components/script-terminal-modal.tsx @@ -29,8 +29,7 @@ import { DropdownMenuLabel, } from "@/components/ui/dropdown-menu" import "xterm/css/xterm.css" -import { API_PORT } from "@/lib/api-config" -import { getTicketedWsUrl } from "@/lib/terminal-ws" +import { getTicketedWsUrl, getWsUrl } from "@/lib/terminal-ws" interface WebInteraction { type: "yesno" | "menu" | "msgbox" | "input" | "inputbox" @@ -530,21 +529,8 @@ const initMessage = { } }, [isOpen, isComplete, attemptReconnect]) - const getScriptWebSocketUrl = (sid: string): string => { - if (typeof window === "undefined") { - return `ws://localhost:${API_PORT}/ws/script/${sid}` - } - - const { protocol, hostname, port } = window.location - const isStandardPort = port === "" || port === "80" || port === "443" - const wsProtocol = protocol === "https:" ? "wss:" : "ws:" - - if (isStandardPort) { - return `${wsProtocol}//${hostname}/ws/script/${sid}` - } else { - return `${wsProtocol}//${hostname}:${API_PORT}/ws/script/${sid}` - } - } + const getScriptWebSocketUrl = (sid: string): string => + getWsUrl(`/ws/script/${sid}`) const handleInteractionResponse = (value: string) => { if (!wsRef.current || !currentInteraction) { diff --git a/AppImage/components/settings.tsx b/AppImage/components/settings.tsx index 697733a6..c1161e22 100644 --- a/AppImage/components/settings.tsx +++ b/AppImage/components/settings.tsx @@ -411,9 +411,27 @@ export function Settings() { // available version, and updating doesn't need to ask which flavour // to install in. The user can always re-install via the // customizable post-install flow if they want different parameters. + // Resolve which flow (auto vs custom) actually has an implementation + // for this tool. Some tools live only in the customizable flow (e.g. + // fastfetch, which needs an interactive menu and has no auto + // variant). When the recorded source is "auto" but the auto flow has + // no function for this tool, the bash wrapper aborts with + // "Function '' is not defined in the auto flow". This helper + // silently routes to the only available flow instead. + const resolveEffectiveSource = (tool: ProxMenuxTool): string => { + const recorded = tool.source || "auto" + if (recorded === "auto" && !tool.function_auto && tool.function_custom) { + return "custom" + } + if (recorded === "custom" && !tool.function_custom && tool.function_auto) { + return "auto" + } + return recorded + } + const handleSingleToolUpdate = (tool: ProxMenuxTool) => { if (!tool.has_update) return - const source = tool.source || "auto" + const source = resolveEffectiveSource(tool) runPostInstallUpdates([{ source, function: deriveFunctionName(tool, source), @@ -1534,12 +1552,15 @@ export function Settings() { onClick={() => { const entries = proxmenuxTools .filter(t => selectedUpdates.has(t.key)) - .map(t => ({ - source: t.source || 'auto', - function: deriveFunctionName(t, t.source || 'auto'), - key: t.key, - name: t.name, - })) + .map(t => { + const source = resolveEffectiveSource(t) + return { + source, + function: deriveFunctionName(t, source), + key: t.key, + name: t.name, + } + }) .filter(e => !!e.function) setUpdateModalOpen(false) setSelectedUpdates(new Set()) diff --git a/AppImage/components/terminal-panel.tsx b/AppImage/components/terminal-panel.tsx index b855b852..eb282e0e 100644 --- a/AppImage/components/terminal-panel.tsx +++ b/AppImage/components/terminal-panel.tsx @@ -3,7 +3,7 @@ import type React from "react" import { useEffect, useRef, useState } from "react" import { API_PORT, fetchApi } from "@/lib/api-config" // Unificando importaciones de api-config en una sola línea con alias @/ -import { getTicketedWsUrl } from "@/lib/terminal-ws" +import { getTicketedWsUrl, getWsUrl } from "@/lib/terminal-ws" import { Activity, Trash2, @@ -51,20 +51,7 @@ interface TerminalInstance { } function getWebSocketUrl(): string { - if (typeof window === "undefined") { - return "ws://localhost:8008/ws/terminal" - } - - const { protocol, hostname, port } = window.location - const isStandardPort = port === "" || port === "80" || port === "443" - - const wsProtocol = protocol === "https:" ? "wss:" : "ws:" - - if (isStandardPort) { - return `${wsProtocol}//${hostname}/ws/terminal` - } else { - return `${wsProtocol}//${hostname}:${API_PORT}/ws/terminal` - } + return getWsUrl("/ws/terminal") } function getApiUrl(endpoint?: string): string { diff --git a/AppImage/lib/terminal-ws.ts b/AppImage/lib/terminal-ws.ts index 5b8c48ea..a990f4d2 100644 --- a/AppImage/lib/terminal-ws.ts +++ b/AppImage/lib/terminal-ws.ts @@ -11,7 +11,85 @@ * `_consume_terminal_ticket`, `_ws_auth_check`. */ -import { fetchApi } from "@/lib/api-config" +import { fetchApi, getApiBaseUrl, API_PORT } from "@/lib/api-config" + +/** + * Build a WebSocket URL for a given path (e.g. "/ws/terminal" or + * "/ws/script/"). Centralizes the ws:// vs wss:// decision so a + * single fix benefits every terminal modal in the app. + * + * Why not just `window.location.protocol === "https:" ? "wss:" : "ws:"`? + * On iPad Safari (and some other mobile browsers) with a self-signed + * cert the user manually accepted, `location.protocol` can report + * "http:" even though the page was loaded over HTTPS — secure-context + * downgrade for untrusted certs. The frontend would then open ws:// + * against the HTTPS endpoint; the server replies with SSL handshake + * errors and the client retries in a loop. We observed this tipping + * the gevent server into a 4.4 GB RSS spiral on .55 before systemd + * OOM-killed the AppImage. + * + * Resolution: prefer the protocol from the absolute API base URL + * (which is set up at app init by getApiBaseUrl and is always honest + * about ws/wss), only falling back to window.location.protocol when + * the API base is relative (i.e. behind a reverse proxy on a standard + * port — where the proxy decides the actual scheme anyway). + */ +export function getWsUrl(path: string): string { + const normalizedPath = path.startsWith("/") ? path : `/${path}` + + if (typeof window === "undefined") { + return `ws://localhost:${API_PORT}${normalizedPath}` + } + + // Multi-signal HTTPS detection — any single signal saying https + // wins. The deliberate bias toward https comes from how the two + // failure modes differ: wss:// against a plaintext server closes + // cleanly with one "WebSocket connection error", while ws:// + // against an https server triggers the SSL-handshake loop that + // OOM-killed gevent on .55. Bias toward wss is the safer + // direction when in doubt. + // + // Signals: + // - getApiBaseUrl() absolute URL scheme (typically the most + // accurate, but it ultimately derives from + // window.location.protocol — included for completeness) + // - window.location.protocol (the obvious one — but iPad Safari + // with self-signed certs can report "http:" even when the page + // was loaded over HTTPS) + // - window.isSecureContext (true even when protocol misreports; + // the browser still treats the page as secure for crypto APIs) + // - document.URL / document.baseURI (the full URL the browser + // actually thinks it's at — last-resort cross-check) + const apiBase = getApiBaseUrl() + const docUrl = + typeof document !== "undefined" + ? (document.URL || document.baseURI || "") + : "" + + const isHttps = + apiBase.startsWith("https://") || + window.location.protocol === "https:" || + (typeof window.isSecureContext === "boolean" && window.isSecureContext) || + docUrl.startsWith("https://") + + const proto = isHttps ? "wss:" : "ws:" + + // Pick the host:port to point the WebSocket at: + // - If apiBase is absolute, strip its scheme — that's where the + // REST API lives, so the WS endpoint lives there too. + // - Otherwise (proxy / standard port), reuse the current + // window.location.host so the proxy fronts both REST and WS. + let hostPort: string + if (apiBase.startsWith("https://")) { + hostPort = apiBase.slice("https://".length) + } else if (apiBase.startsWith("http://")) { + hostPort = apiBase.slice("http://".length) + } else { + hostPort = window.location.host + } + + return `${proto}//${hostPort}${normalizedPath}` +} type TicketResponse = { success?: boolean diff --git a/AppImage/scripts/flask_server.py b/AppImage/scripts/flask_server.py index db21db12..1e022d02 100644 --- a/AppImage/scripts/flask_server.py +++ b/AppImage/scripts/flask_server.py @@ -11789,6 +11789,20 @@ if __name__ == '__main__': # `::` binds IPv6 + IPv4 (v4-mapped) on Linux when # net.ipv6.bindv6only=0 (the default). Issue #192 — IPv4-only # listening broke ProxMenux on dual-stack / v6-only hosts. + # + # NOTE: a prior version subclassed WSGIServer here to + # silence SSL handshake errors (memory leak workaround + # for clients opening ws:// against this https endpoint). + # The subclass interfered with gevent's SSL flow-control + # exceptions (SSLWantReadError) and caused + # ConcurrentObjectUseError on the wss handshake of + # /ws/script/, which manifested only on clients that + # close the WS connection mid-handshake (iPad Safari for + # the updates modal). Since the root cause of the memory + # leak was fixed client-side in lib/terminal-ws.ts + # (getWsUrl now opens wss:// instead of ws:// against + # the https endpoint), the original SSL handshake errors + # are rare and the default gevent behaviour is fine. server = pywsgi.WSGIServer( ('::', 8008), app, diff --git a/AppImage/scripts/flask_terminal_routes.py b/AppImage/scripts/flask_terminal_routes.py index 20436fd0..f7335b35 100644 --- a/AppImage/scripts/flask_terminal_routes.py +++ b/AppImage/scripts/flask_terminal_routes.py @@ -39,13 +39,20 @@ _SAFE_ID_RE = re.compile(r'^[A-Za-z0-9_-]{1,64}$') # atomically consumes the ticket — if the ticket is missing, expired, or # already used, the WS is closed immediately. # -# Tickets live in an in-memory dict guarded by a lock. TTL is intentionally -# short (5 s) — the client should issue and use the ticket immediately. -# See audit Tier 1 #2 + #17d. +# Tickets live in an in-memory dict guarded by a lock. The TTL is the +# window between POST /api/terminal/ticket and the WebSocket handshake +# that consumes it. The original 5 s was too tight for slower devices: +# on an iPad opening the post-install updates modal, xterm.js + the +# Nerd Font load took >5 s, the ticket expired before the wss handshake +# fired, and the modal hung at "Conectando" indefinitely — exactly the +# bug pattern that pushed the gevent server into the 4.4 GB OOM spiral. +# 60 s is wide enough to absorb mobile-rendering delays while still +# being one-shot (each ticket can only be consumed once), so the +# security model from audit Tier 1 #2 + #17d is unchanged. _TERMINAL_TICKETS = {} # ticket (str) -> created_at_ts (float) _TICKETS_LOCK = threading.Lock() -_TICKET_TTL = 5 # seconds +_TICKET_TTL = 60 # seconds _TICKET_MAX_INFLIGHT = 256 # sanity cap to keep memory bounded