mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-06-11 11:06:24 +00:00
Update AppImage
This commit is contained in:
@@ -35,8 +35,8 @@ import { DialogHeader, DialogDescription } from "@/components/ui/dialog"
|
|||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Dialog as SearchDialog, DialogContent as SearchDialogContent, DialogTitle as SearchDialogTitle } from "@/components/ui/dialog"
|
import { Dialog as SearchDialog, DialogContent as SearchDialogContent, DialogTitle as SearchDialogTitle } from "@/components/ui/dialog"
|
||||||
import "xterm/css/xterm.css"
|
import "xterm/css/xterm.css"
|
||||||
import { API_PORT, fetchApi } from "@/lib/api-config"
|
import { fetchApi } from "@/lib/api-config"
|
||||||
import { getTicketedWsUrl } from "@/lib/terminal-ws"
|
import { getTicketedWsUrl, getWsUrl } from "@/lib/terminal-ws"
|
||||||
|
|
||||||
interface LxcTerminalModalProps {
|
interface LxcTerminalModalProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -80,19 +80,7 @@ const proxmoxCommands = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
function getWebSocketUrl(): string {
|
function getWebSocketUrl(): string {
|
||||||
if (typeof window === "undefined") {
|
return getWsUrl("/ws/terminal")
|
||||||
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({
|
export function LxcTerminalModal({
|
||||||
|
|||||||
@@ -29,8 +29,7 @@ import {
|
|||||||
DropdownMenuLabel,
|
DropdownMenuLabel,
|
||||||
} from "@/components/ui/dropdown-menu"
|
} from "@/components/ui/dropdown-menu"
|
||||||
import "xterm/css/xterm.css"
|
import "xterm/css/xterm.css"
|
||||||
import { API_PORT } from "@/lib/api-config"
|
import { getTicketedWsUrl, getWsUrl } from "@/lib/terminal-ws"
|
||||||
import { getTicketedWsUrl } from "@/lib/terminal-ws"
|
|
||||||
|
|
||||||
interface WebInteraction {
|
interface WebInteraction {
|
||||||
type: "yesno" | "menu" | "msgbox" | "input" | "inputbox"
|
type: "yesno" | "menu" | "msgbox" | "input" | "inputbox"
|
||||||
@@ -530,21 +529,8 @@ const initMessage = {
|
|||||||
}
|
}
|
||||||
}, [isOpen, isComplete, attemptReconnect])
|
}, [isOpen, isComplete, attemptReconnect])
|
||||||
|
|
||||||
const getScriptWebSocketUrl = (sid: string): string => {
|
const getScriptWebSocketUrl = (sid: string): string =>
|
||||||
if (typeof window === "undefined") {
|
getWsUrl(`/ws/script/${sid}`)
|
||||||
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) => {
|
const handleInteractionResponse = (value: string) => {
|
||||||
if (!wsRef.current || !currentInteraction) {
|
if (!wsRef.current || !currentInteraction) {
|
||||||
|
|||||||
@@ -411,9 +411,27 @@ export function Settings() {
|
|||||||
// available version, and updating doesn't need to ask which flavour
|
// available version, and updating doesn't need to ask which flavour
|
||||||
// to install in. The user can always re-install via the
|
// to install in. The user can always re-install via the
|
||||||
// customizable post-install flow if they want different parameters.
|
// 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 '<x>' 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) => {
|
const handleSingleToolUpdate = (tool: ProxMenuxTool) => {
|
||||||
if (!tool.has_update) return
|
if (!tool.has_update) return
|
||||||
const source = tool.source || "auto"
|
const source = resolveEffectiveSource(tool)
|
||||||
runPostInstallUpdates([{
|
runPostInstallUpdates([{
|
||||||
source,
|
source,
|
||||||
function: deriveFunctionName(tool, source),
|
function: deriveFunctionName(tool, source),
|
||||||
@@ -1534,12 +1552,15 @@ export function Settings() {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
const entries = proxmenuxTools
|
const entries = proxmenuxTools
|
||||||
.filter(t => selectedUpdates.has(t.key))
|
.filter(t => selectedUpdates.has(t.key))
|
||||||
.map(t => ({
|
.map(t => {
|
||||||
source: t.source || 'auto',
|
const source = resolveEffectiveSource(t)
|
||||||
function: deriveFunctionName(t, t.source || 'auto'),
|
return {
|
||||||
key: t.key,
|
source,
|
||||||
name: t.name,
|
function: deriveFunctionName(t, source),
|
||||||
}))
|
key: t.key,
|
||||||
|
name: t.name,
|
||||||
|
}
|
||||||
|
})
|
||||||
.filter(e => !!e.function)
|
.filter(e => !!e.function)
|
||||||
setUpdateModalOpen(false)
|
setUpdateModalOpen(false)
|
||||||
setSelectedUpdates(new Set())
|
setSelectedUpdates(new Set())
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import type React from "react"
|
import type React from "react"
|
||||||
import { useEffect, useRef, useState } 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 { 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 {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
Trash2,
|
Trash2,
|
||||||
@@ -51,20 +51,7 @@ interface TerminalInstance {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getWebSocketUrl(): string {
|
function getWebSocketUrl(): string {
|
||||||
if (typeof window === "undefined") {
|
return getWsUrl("/ws/terminal")
|
||||||
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 {
|
function getApiUrl(endpoint?: string): string {
|
||||||
|
|||||||
@@ -11,7 +11,85 @@
|
|||||||
* `_consume_terminal_ticket`, `_ws_auth_check`.
|
* `_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/<id>"). 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 = {
|
type TicketResponse = {
|
||||||
success?: boolean
|
success?: boolean
|
||||||
|
|||||||
@@ -11789,6 +11789,20 @@ if __name__ == '__main__':
|
|||||||
# `::` binds IPv6 + IPv4 (v4-mapped) on Linux when
|
# `::` binds IPv6 + IPv4 (v4-mapped) on Linux when
|
||||||
# net.ipv6.bindv6only=0 (the default). Issue #192 — IPv4-only
|
# net.ipv6.bindv6only=0 (the default). Issue #192 — IPv4-only
|
||||||
# listening broke ProxMenux on dual-stack / v6-only hosts.
|
# 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/<id>, 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(
|
server = pywsgi.WSGIServer(
|
||||||
('::', 8008),
|
('::', 8008),
|
||||||
app,
|
app,
|
||||||
|
|||||||
@@ -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
|
# atomically consumes the ticket — if the ticket is missing, expired, or
|
||||||
# already used, the WS is closed immediately.
|
# already used, the WS is closed immediately.
|
||||||
#
|
#
|
||||||
# Tickets live in an in-memory dict guarded by a lock. TTL is intentionally
|
# Tickets live in an in-memory dict guarded by a lock. The TTL is the
|
||||||
# short (5 s) — the client should issue and use the ticket immediately.
|
# window between POST /api/terminal/ticket and the WebSocket handshake
|
||||||
# See audit Tier 1 #2 + #17d.
|
# 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)
|
_TERMINAL_TICKETS = {} # ticket (str) -> created_at_ts (float)
|
||||||
_TICKETS_LOCK = threading.Lock()
|
_TICKETS_LOCK = threading.Lock()
|
||||||
_TICKET_TTL = 5 # seconds
|
_TICKET_TTL = 60 # seconds
|
||||||
_TICKET_MAX_INFLIGHT = 256 # sanity cap to keep memory bounded
|
_TICKET_MAX_INFLIGHT = 256 # sanity cap to keep memory bounded
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user