From 3286fc315c2179577923b3a8a4b41621e7b0c5c4 Mon Sep 17 00:00:00 2001 From: MacRimi Date: Sun, 24 May 2026 16:42:44 +0200 Subject: [PATCH] Update AppImage 1.2.1.3 --- AppImage/components/lxc-terminal-modal.tsx | 18 ++++- AppImage/components/script-terminal-modal.tsx | 20 ++++- 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 +--- AppImage/scripts/notification_events.py | 11 ++- 7 files changed, 62 insertions(+), 113 deletions(-) diff --git a/AppImage/components/lxc-terminal-modal.tsx b/AppImage/components/lxc-terminal-modal.tsx index 3d372fc0..bbfcbb96 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 { fetchApi } from "@/lib/api-config" -import { getTicketedWsUrl, getWsUrl } from "@/lib/terminal-ws" +import { API_PORT, fetchApi } from "@/lib/api-config" +import { getTicketedWsUrl } from "@/lib/terminal-ws" interface LxcTerminalModalProps { open: boolean @@ -80,7 +80,19 @@ const proxmoxCommands = [ ] function getWebSocketUrl(): string { - return getWsUrl("/ws/terminal") + 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` + } } export function LxcTerminalModal({ diff --git a/AppImage/components/script-terminal-modal.tsx b/AppImage/components/script-terminal-modal.tsx index f43c01d9..59c67769 100644 --- a/AppImage/components/script-terminal-modal.tsx +++ b/AppImage/components/script-terminal-modal.tsx @@ -29,7 +29,8 @@ import { DropdownMenuLabel, } from "@/components/ui/dropdown-menu" import "xterm/css/xterm.css" -import { getTicketedWsUrl, getWsUrl } from "@/lib/terminal-ws" +import { API_PORT } from "@/lib/api-config" +import { getTicketedWsUrl } from "@/lib/terminal-ws" interface WebInteraction { type: "yesno" | "menu" | "msgbox" | "input" | "inputbox" @@ -529,8 +530,21 @@ const initMessage = { } }, [isOpen, isComplete, attemptReconnect]) - const getScriptWebSocketUrl = (sid: string): string => - getWsUrl(`/ws/script/${sid}`) + 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 handleInteractionResponse = (value: string) => { if (!wsRef.current || !currentInteraction) { diff --git a/AppImage/components/terminal-panel.tsx b/AppImage/components/terminal-panel.tsx index eb282e0e..b855b852 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, getWsUrl } from "@/lib/terminal-ws" +import { getTicketedWsUrl } from "@/lib/terminal-ws" import { Activity, Trash2, @@ -51,7 +51,20 @@ interface TerminalInstance { } function getWebSocketUrl(): string { - return getWsUrl("/ws/terminal") + 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` + } } function getApiUrl(endpoint?: string): string { diff --git a/AppImage/lib/terminal-ws.ts b/AppImage/lib/terminal-ws.ts index a990f4d2..5b8c48ea 100644 --- a/AppImage/lib/terminal-ws.ts +++ b/AppImage/lib/terminal-ws.ts @@ -11,85 +11,7 @@ * `_consume_terminal_ticket`, `_ws_auth_check`. */ -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}` -} +import { fetchApi } from "@/lib/api-config" type TicketResponse = { success?: boolean diff --git a/AppImage/scripts/flask_server.py b/AppImage/scripts/flask_server.py index 1e022d02..db21db12 100644 --- a/AppImage/scripts/flask_server.py +++ b/AppImage/scripts/flask_server.py @@ -11789,20 +11789,6 @@ 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 f7335b35..20436fd0 100644 --- a/AppImage/scripts/flask_terminal_routes.py +++ b/AppImage/scripts/flask_terminal_routes.py @@ -39,20 +39,13 @@ _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. 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. +# 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. _TERMINAL_TICKETS = {} # ticket (str) -> created_at_ts (float) _TICKETS_LOCK = threading.Lock() -_TICKET_TTL = 60 # seconds +_TICKET_TTL = 5 # seconds _TICKET_MAX_INFLIGHT = 256 # sanity cap to keep memory bounded diff --git a/AppImage/scripts/notification_events.py b/AppImage/scripts/notification_events.py index 5e1c4f8b..a92100a0 100644 --- a/AppImage/scripts/notification_events.py +++ b/AppImage/scripts/notification_events.py @@ -271,8 +271,17 @@ def _record_smartd_observation_impl(title: str, message: str): base_dev = _re.sub(r'\d+$', '', device) # Extract serial: "S/N:WD-WX72A30AA72R" + # The \S+ capture also matches a trailing comma/semicolon when the + # SMART message lists S/N as part of a comma-separated field + # (e.g. "Device: /dev/sdh (WDC WD20EFAX-68FB5N0) S/N:WD-WX72A30AA72R,"). + # Strip trailing punctuation so the disk_registry never gets + # duplicate rows for the same physical drive — one with a clean + # serial from smartctl and another with a comma-suffixed serial + # parsed out of this raw_message. See bug observed on .1.10 + # where /dev/sdh ended up with two registry IDs and the "obs." + # badge on the storage card disagreed with the modal count. sn_match = _re.search(r'S/N:\s*(\S+)', message) - serial = sn_match.group(1) if sn_match else '' + serial = sn_match.group(1).rstrip(',.;') if sn_match else '' # Extract model: appears before S/N on the "Device info:" line model = ''