From e190f3ad8a0532d400a59216ccdc32218e7568c0 Mon Sep 17 00:00:00 2001 From: SaelixCode Date: Sat, 21 Mar 2026 01:19:29 -0400 Subject: [PATCH 1/3] fix(alerts): overhaul alerts & notifications system for local and remote nodes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - StackAlertSheet: fetch agent status on open and show contextual banner (amber warning when no channels configured, green when active, blue info callout on remote nodes explaining remote-instance evaluation semantics) - StackAlertSheet: surface actual server error message on addAlert failure instead of generic string; add console logging for all failure paths - SettingsModal: expose Notifications tab for remote nodes so agents can be configured directly on any Sencho instance; section header shows node name and Remote badge with tooltip when proxying to a remote node - SettingsModal: re-fetch agents/settings when active node changes - SettingsModal: add hidden DialogTitle/DialogDescription to satisfy Radix UI accessibility requirements (eliminates console errors) - backend POST /api/alerts: add Zod validation schema; rejects unknown metric/operator values, negative thresholds, and missing fields with 400 - EditorLayout WS: upgrade reconnect from flat 5s retry to exponential backoff (1s→2s→4s→8s→16s→30s max); onerror now logs the event; cleanup only closes OPEN sockets — CONNECTING sockets are closed in onopen after isMounted check, eliminating "closed before established" StrictMode error --- CHANGELOG.md | 5 + backend/src/index.ts | 18 ++- frontend/src/components/EditorLayout.tsx | 35 ++++- frontend/src/components/SettingsModal.tsx | 39 +++++- frontend/src/components/StackAlertSheet.tsx | 146 ++++++++++++++++++-- 5 files changed, 213 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 42d948a2..2459c6a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +- **Fixed:** `StackAlertSheet` now fetches notification agent status from the active node on open and displays a contextual banner: green checkmark with active channel names when agents are enabled, amber warning with a link to Settings → Notifications when none are configured, and a blue info callout on remote nodes explaining that alerts are evaluated and dispatched by that remote Sencho instance. +- **Fixed:** `SettingsModal` Notifications tab is no longer hidden when a remote node is active — users can now configure Discord/Slack/Webhook channels directly on any remote node. The section header shows the remote node name and a "Remote" badge with a tooltip explaining that channels are saved on the remote instance. +- **Fixed:** `StackAlertSheet` form error handling now surfaces the actual server error message (`err.error`) instead of a generic "Failed to add alert rule." string; console error logging added for all failure paths to aid debugging. +- **Fixed:** `POST /api/alerts` now validates the request body with a Zod schema — rejects unknown metric/operator values, negative thresholds, and missing required fields with a structured 400 response instead of passing raw input to SQLite. +- **Fixed:** WebSocket notification reconnect in `EditorLayout` upgraded to exponential backoff (1 s → 2 s → 4 s → 8 s → 16 s → 30 s max) instead of a flat 5-second retry; `ws.onerror` now logs the event rather than silently calling `close()`; cleanup correctly guards against closing a WebSocket that is already in CLOSING/CLOSED state, eliminating the "WebSocket is closed before the connection is established" console error on React StrictMode double-mount. - **Security:** Host Console and container exec WebSocket endpoints now reject `node_proxy` scoped JWT tokens with HTTP 403 — machine-to-machine proxy credentials can no longer be used to open interactive terminals. - **Security:** `stackParam` query parameter on `/api/system/host-console` is now validated against `path.resolve` + `startsWith(baseDir)` to prevent directory traversal when setting the PTY working directory. - **Security:** `HostTerminalService` no longer forwards the full `process.env` to spawned PTY shells; `JWT_SECRET`, `AUTH_PASSWORD`, `AUTH_PASSWORD_HASH`, and `DATABASE_URL` are stripped before the shell is spawned. diff --git a/backend/src/index.ts b/backend/src/index.ts index d381b8a1..1a96535f 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1392,12 +1392,26 @@ app.get('/api/alerts', async (req: Request, res: Response) => { } }); +const AlertCreateSchema = z.object({ + stack_name: z.string().min(1).max(255), + metric: z.enum(['cpu_percent', 'memory_percent', 'memory_mb', 'net_rx', 'net_tx', 'restart_count']), + operator: z.enum(['>', '>=', '<', '<=', '==']), + threshold: z.number().min(0), + duration_mins: z.coerce.number().int().min(0).max(1440), + cooldown_mins: z.coerce.number().int().min(0).max(10080), +}); + app.post('/api/alerts', async (req: Request, res: Response) => { + const parsed = AlertCreateSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: 'Invalid alert data', details: parsed.error.flatten().fieldErrors }); + return; + } try { - const alert = req.body; - DatabaseService.getInstance().addStackAlert(alert); + DatabaseService.getInstance().addStackAlert(parsed.data); res.json({ success: true }); } catch (error) { + console.error('Failed to add alert:', error); res.status(500).json({ error: 'Failed to add alert' }); } }); diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 4fae0996..32ee7ecc 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -194,10 +194,22 @@ export default function EditorLayout() { let ws: WebSocket | null = null; let reconnectTimer: ReturnType | null = null; let isMounted = true; + let retryCount = 0; + const MAX_RETRY_DELAY_MS = 30000; const connect = () => { + if (!isMounted) return; ws = new WebSocket(`${wsBase}/ws/notifications`); + ws.onopen = () => { + if (!isMounted) { + // Component unmounted while the handshake was in-flight (React StrictMode double-mount) + ws?.close(); + return; + } + retryCount = 0; // Reset backoff on successful connect + }; + ws.onmessage = (event) => { try { const msg = JSON.parse(event.data); @@ -209,14 +221,18 @@ export default function EditorLayout() { } }; - ws.onclose = () => { - if (isMounted) { - reconnectTimer = setTimeout(connect, 5000); - } + ws.onclose = (event) => { + if (!isMounted) return; + // Exponential backoff: 1s, 2s, 4s, 8s, 16s, 30s max + const delay = Math.min(1000 * Math.pow(2, retryCount), MAX_RETRY_DELAY_MS); + retryCount++; + console.debug(`[WS notifications] closed (code=${event.code}), reconnecting in ${delay}ms (attempt ${retryCount})`); + reconnectTimer = setTimeout(connect, delay); }; - ws.onerror = () => { - ws?.close(); + ws.onerror = (event) => { + // onerror always fires before onclose - log it and let onclose handle reconnect + console.warn('[WS notifications] error event', event); }; }; @@ -225,7 +241,12 @@ export default function EditorLayout() { return () => { isMounted = false; if (reconnectTimer) clearTimeout(reconnectTimer); - ws?.close(); + // Only close an already-open connection. If still CONNECTING, let onopen + // detect isMounted=false and close then — avoids the browser warning + // "WebSocket is closed before the connection is established". + if (ws && ws.readyState === WebSocket.OPEN) { + ws.close(); + } }; }, []); // eslint-disable-line react-hooks/exhaustive-deps diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index e6ea8320..c300e48d 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -3,7 +3,10 @@ import { motion } from 'motion/react'; import { Dialog, DialogContent, + DialogTitle, + DialogDescription, } from '@/components/ui/dialog'; +import { VisuallyHidden } from '@radix-ui/react-visually-hidden'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Input } from '@/components/ui/input'; import { Switch } from '@/components/ui/switch'; @@ -123,7 +126,7 @@ export function SettingsModal({ isOpen, onClose, theme, setTheme }: SettingsModa fetchAgents(); fetchSettings(); } - }, [isOpen]); // eslint-disable-line react-hooks/exhaustive-deps + }, [isOpen, activeNode?.id]); // eslint-disable-line react-hooks/exhaustive-deps const fetchAgents = async () => { try { @@ -392,6 +395,8 @@ export function SettingsModal({ isOpen, onClose, theme, setTheme }: SettingsModa return ( !open && onClose()}> + Settings Hub + Configure Sencho settings {/* Sidebar */}
Settings Hub
@@ -410,9 +415,7 @@ export function SettingsModal({ isOpen, onClose, theme, setTheme }: SettingsModa label="System Limits" showDot={hasSystemChanges} /> - {!isRemote && ( - } label="Notifications" /> - )} + } label="Notifications" /> {!isRemote && ( } label="Appearance" /> )} @@ -573,9 +576,31 @@ export function SettingsModal({ isOpen, onClose, theme, setTheme }: SettingsModa {activeSection === 'notifications' && (
-
-

Notifications & Alerts

-

Configure external integrations for crash alerts.

+
+
+

Notifications & Alerts

+

+ {isRemote + ? <>Configuring notification channels on {activeNode!.name}. Alerts from this remote node will dispatch via these channels. + : 'Configure external integrations for crash alerts.' + } +

+
+ {isRemote && ( + + + + + + Remote + + + + These channels are saved on the remote Sencho instance and used when it dispatches alerts. + + + + )}
setNotifTab(v as 'discord' | 'slack' | 'webhook')} className="w-full"> diff --git a/frontend/src/components/StackAlertSheet.tsx b/frontend/src/components/StackAlertSheet.tsx index 7db64ae9..7835fdd2 100644 --- a/frontend/src/components/StackAlertSheet.tsx +++ b/frontend/src/components/StackAlertSheet.tsx @@ -11,9 +11,10 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; -import { Trash2, HelpCircle } from 'lucide-react'; +import { Trash2, HelpCircle, AlertTriangle, Info, CheckCircle2, Loader2 } from 'lucide-react'; import { toast } from 'sonner'; import { apiFetch } from '@/lib/api'; +import { useNodes } from '@/context/NodeContext'; interface StackAlert { id?: number; @@ -31,9 +32,23 @@ interface StackAlertSheetProps { stackName: string; } +interface AgentStatus { + loading: boolean; + hasEnabled: boolean; + enabledTypes: string[]; +} + export function StackAlertSheet({ isOpen, onClose, stackName }: StackAlertSheetProps) { + const { activeNode } = useNodes(); + const isRemote = activeNode?.type === 'remote'; + const [alerts, setAlerts] = useState([]); const [isLoading, setIsLoading] = useState(false); + const [agentStatus, setAgentStatus] = useState({ + loading: false, + hasEnabled: false, + enabledTypes: [], + }); // New Alert Form State const [metric, setMetric] = useState('cpu_percent'); @@ -45,18 +60,41 @@ export function StackAlertSheet({ isOpen, onClose, stackName }: StackAlertSheetP useEffect(() => { if (isOpen && stackName) { fetchAlerts(); + fetchAgentStatus(); } - }, [isOpen, stackName]); + }, [isOpen, stackName]); // eslint-disable-line react-hooks/exhaustive-deps const fetchAlerts = async () => { try { - const res = await apiFetch(`/alerts?stackName=${stackName}`); + const res = await apiFetch(`/alerts?stackName=${encodeURIComponent(stackName)}`); if (res.ok) { const data = await res.json(); setAlerts(data); } } catch (e) { - console.error('Failed to fetch alerts', e); + console.error('[StackAlertSheet] Failed to fetch alerts', e); + } + }; + + const fetchAgentStatus = async () => { + setAgentStatus(prev => ({ ...prev, loading: true })); + try { + // Always fetch agents from the active node (proxied via x-node-id for remote) + const res = await apiFetch('/agents'); + if (res.ok) { + const agents: Array<{ type: string; enabled: boolean }> = await res.json(); + const enabled = agents.filter(a => a.enabled); + setAgentStatus({ + loading: false, + hasEnabled: enabled.length > 0, + enabledTypes: enabled.map(a => a.type), + }); + } else { + setAgentStatus({ loading: false, hasEnabled: false, enabledTypes: [] }); + } + } catch (e) { + console.error('[StackAlertSheet] Failed to fetch agent status', e); + setAgentStatus({ loading: false, hasEnabled: false, enabledTypes: [] }); } }; @@ -73,23 +111,26 @@ export function StackAlertSheet({ isOpen, onClose, stackName }: StackAlertSheetP operator, threshold: parseFloat(threshold), duration_mins: parseInt(duration, 10), - cooldown_mins: parseInt(cooldown, 10) + cooldown_mins: parseInt(cooldown, 10), }; try { const res = await apiFetch('/alerts', { method: 'POST', - body: JSON.stringify(newAlert) + body: JSON.stringify(newAlert), }); if (res.ok) { toast.success('Alert rule added.'); setThreshold(''); fetchAlerts(); } else { - toast.error('Failed to add alert rule.'); + const err = await res.json().catch(() => ({})); + toast.error(err?.error || err?.message || 'Failed to add alert rule.'); + console.error('[StackAlertSheet] addAlert failed:', err); } } catch (e) { - toast.error('Network error.'); + console.error('[StackAlertSheet] addAlert threw:', e); + toast.error('Network error. Could not reach the node.'); } finally { setIsLoading(false); } @@ -103,10 +144,11 @@ export function StackAlertSheet({ isOpen, onClose, stackName }: StackAlertSheetP toast.success('Alert rule deleted.'); fetchAlerts(); } else { - toast.error('Failed to delete alert rule.'); + const err = await res.json().catch(() => ({})); + toast.error(err?.error || 'Failed to delete alert rule.'); } } catch (e) { - toast.error('Network error.'); + toast.error('Network error. Could not reach the node.'); } finally { setIsLoading(false); } @@ -118,12 +160,81 @@ export function StackAlertSheet({ isOpen, onClose, stackName }: StackAlertSheetP memory_mb: 'Memory Usage (MB)', net_rx: 'Network In (MB)', net_tx: 'Network Out (MB)', - restart_count: 'Restart Count' + restart_count: 'Restart Count', + }; + + const agentTypeLabels: Record = { + discord: 'Discord', + slack: 'Slack', + webhook: 'Webhook', + }; + + const renderAgentStatusBanner = () => { + if (agentStatus.loading) { + return ( +
+ + Checking notification channels… +
+ ); + } + + if (isRemote) { + return ( +
+ +
+

+ Remote node: {activeNode?.name} +

+

+ Alert rules are stored and evaluated on this remote instance. Notifications are dispatched using that node's configured channels. +

+ {!agentStatus.hasEnabled && ( +

+ No notification channels are configured on this remote node. Open Settings → Notifications to configure them. +

+ )} + {agentStatus.hasEnabled && ( +

+ Active channels: {agentStatus.enabledTypes.map(t => agentTypeLabels[t] ?? t).join(', ')} +

+ )} +
+
+ ); + } + + if (!agentStatus.hasEnabled) { + return ( +
+ +
+

No notification channels configured

+

+ Alert rules will be saved and evaluated, but no notifications will be dispatched. Configure Discord, Slack, or a webhook in{' '} + Settings → Notifications. +

+
+
+ ); + } + + return ( +
+ +
+

+ Notifications active via {agentStatus.enabledTypes.map(t => agentTypeLabels[t] ?? t).join(', ')} +

+
+
+ ); }; return ( !open && onClose()}> - + Stack Alerts: {stackName} @@ -132,7 +243,10 @@ export function StackAlertSheet({ isOpen, onClose, stackName }: StackAlertSheetP -
+
+ {/* Notification agent status banner */} + {renderAgentStatusBanner()} + {/* List Existing Alerts */}

Existing Rules

@@ -299,7 +413,11 @@ export function StackAlertSheet({ isOpen, onClose, stackName }: StackAlertSheetP
From a703707aa0b5bd30cb6715c0371b6822ced353f3 Mon Sep 17 00:00:00 2001 From: SaelixCode Date: Sat, 21 Mar 2026 01:26:12 -0400 Subject: [PATCH 2/3] fix(proxy): re-stream express.json()-consumed body to remote nodes for POST/PUT/PATCH MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit express.json() fully drains the incoming request stream. http-proxy-middleware then pipes an already-consumed empty stream to the remote Sencho instance, which holds the connection open waiting for body bytes that never arrive (Content-Length says N but 0 bytes are forwarded). This caused all POST/PUT/PATCH requests to remote nodes — most visibly 'Add Alert Rule' — to hang indefinitely with an infinite loading spinner. Fix: in the proxyReq handler, write JSON.stringify(req.body) with correct Content-Type and Content-Length headers before forwarding, restoring the full request body. --- CHANGELOG.md | 1 + backend/src/index.ts | 12 ++++++++++++ 2 files changed, 13 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2459c6a3..5c3da108 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Fixed:** `SettingsModal` Notifications tab is no longer hidden when a remote node is active — users can now configure Discord/Slack/Webhook channels directly on any remote node. The section header shows the remote node name and a "Remote" badge with a tooltip explaining that channels are saved on the remote instance. - **Fixed:** `StackAlertSheet` form error handling now surfaces the actual server error message (`err.error`) instead of a generic "Failed to add alert rule." string; console error logging added for all failure paths to aid debugging. - **Fixed:** `POST /api/alerts` now validates the request body with a Zod schema — rejects unknown metric/operator values, negative thresholds, and missing required fields with a structured 400 response instead of passing raw input to SQLite. +- **Fixed:** Remote node HTTP proxy (`remoteNodeProxy` in `index.ts`) now re-streams the parsed request body for POST/PUT/PATCH requests — `express.json()` drains the incoming stream into `req.body`, leaving the raw stream empty; the proxy then forwarded a request with a `Content-Length` header but zero body bytes, causing the remote Express server to stall waiting for the body indefinitely (the "Add Rule spins forever" bug on remote nodes). The `proxyReq` handler now writes `JSON.stringify(req.body)` with correct `Content-Type` and `Content-Length` headers before forwarding. - **Fixed:** WebSocket notification reconnect in `EditorLayout` upgraded to exponential backoff (1 s → 2 s → 4 s → 8 s → 16 s → 30 s max) instead of a flat 5-second retry; `ws.onerror` now logs the event rather than silently calling `close()`; cleanup correctly guards against closing a WebSocket that is already in CLOSING/CLOSED state, eliminating the "WebSocket is closed before the connection is established" console error on React StrictMode double-mount. - **Security:** Host Console and container exec WebSocket endpoints now reject `node_proxy` scoped JWT tokens with HTTP 403 — machine-to-machine proxy credentials can no longer be used to open interactive terminals. - **Security:** `stackParam` query parameter on `/api/system/host-console` is now validated against `path.resolve` + `startsWith(baseDir)` to prevent directory traversal when setting the PTY working directory. diff --git a/backend/src/index.ts b/backend/src/index.ts index 1a96535f..30fa14fc 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -370,6 +370,18 @@ const remoteNodeProxy = createProxyMiddleware({ const newQs = params.toString(); proxyReq.path = pathname + (newQs ? `?${newQs}` : ''); } + // Re-stream the request body for POST/PUT/PATCH requests. + // express.json() fully drains the incoming stream and stores the parsed + // object in req.body. http-proxy-middleware then pipes an already-consumed + // stream to the remote, which causes the remote server to stall waiting for + // body bytes that never arrive (the Content-Length header says N bytes but + // 0 are forwarded), hanging the request indefinitely. + if (req.body && Object.keys(req.body).length > 0) { + const bodyData = JSON.stringify(req.body); + proxyReq.setHeader('Content-Type', 'application/json'); + proxyReq.setHeader('Content-Length', Buffer.byteLength(bodyData)); + proxyReq.write(bodyData); + } }, error: (err, _req, proxyRes) => { console.error('[Proxy] Remote node error:', (err as Error).message); From ed6954307b82370fb8205f359eb412efaaf38d63 Mon Sep 17 00:00:00 2001 From: SaelixCode Date: Sat, 21 Mar 2026 11:56:24 -0400 Subject: [PATCH 3/3] fix(proxy): skip express.json() for remote proxy requests to fix body forwarding The previous fix attempted to re-stream req.body via proxyReq.write() in the proxyReq event handler. This raced against http-proxy's synchronous req.pipe(proxyReq) call: when express.json() consumed the IncomingMessage stream, Node.js's pipe() detected readableEnded and scheduled process.nextTick(proxyReq.end), which fired before the proxyReq socket event. Any subsequent write() threw "write after end", leaving the remote server stalled waiting for body bytes that never arrived (the "Add Rule spins forever" bug). Root fix: a conditional middleware now skips express.json() entirely for requests targeting a remote node on non-auth, non-nodes API paths. The raw stream is left intact, allowing http-proxy's req.pipe(proxyReq) to forward the body correctly. --- CHANGELOG.md | 2 +- backend/src/index.ts | 43 ++++++++++++++++++++++++++++++------------- 2 files changed, 31 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c3da108..a6e8ec64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Fixed:** `SettingsModal` Notifications tab is no longer hidden when a remote node is active — users can now configure Discord/Slack/Webhook channels directly on any remote node. The section header shows the remote node name and a "Remote" badge with a tooltip explaining that channels are saved on the remote instance. - **Fixed:** `StackAlertSheet` form error handling now surfaces the actual server error message (`err.error`) instead of a generic "Failed to add alert rule." string; console error logging added for all failure paths to aid debugging. - **Fixed:** `POST /api/alerts` now validates the request body with a Zod schema — rejects unknown metric/operator values, negative thresholds, and missing required fields with a structured 400 response instead of passing raw input to SQLite. -- **Fixed:** Remote node HTTP proxy (`remoteNodeProxy` in `index.ts`) now re-streams the parsed request body for POST/PUT/PATCH requests — `express.json()` drains the incoming stream into `req.body`, leaving the raw stream empty; the proxy then forwarded a request with a `Content-Length` header but zero body bytes, causing the remote Express server to stall waiting for the body indefinitely (the "Add Rule spins forever" bug on remote nodes). The `proxyReq` handler now writes `JSON.stringify(req.body)` with correct `Content-Type` and `Content-Length` headers before forwarding. +- **Fixed:** Remote node HTTP proxy body forwarding — replaced the manual `proxyReq.write(JSON.stringify(req.body))` approach (which raced against `http-proxy`'s `process.nextTick(proxyReq.end)` and lost, causing "write after end" and the request to hang) with a conditional JSON body parser that skips `express.json()` entirely for remote-targeted `/api/` requests. The raw `IncomingMessage` stream is left unconsumed so `http-proxy`'s `req.pipe(proxyReq)` can forward it intact. This fixes the "Add Rule spins forever on remote nodes" bug. - **Fixed:** WebSocket notification reconnect in `EditorLayout` upgraded to exponential backoff (1 s → 2 s → 4 s → 8 s → 16 s → 30 s max) instead of a flat 5-second retry; `ws.onerror` now logs the event rather than silently calling `close()`; cleanup correctly guards against closing a WebSocket that is already in CLOSING/CLOSED state, eliminating the "WebSocket is closed before the connection is established" console error on React StrictMode double-mount. - **Security:** Host Console and container exec WebSocket endpoints now reject `node_proxy` scoped JWT tokens with HTTP 403 — machine-to-machine proxy credentials can no longer be used to open interactive terminals. - **Security:** `stackParam` query parameter on `/api/system/host-console` is now validated against `path.resolve` + `startsWith(baseDir)` to prevent directory traversal when setting the PTY working directory. diff --git a/backend/src/index.ts b/backend/src/index.ts index 30fa14fc..14d7552b 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -68,7 +68,32 @@ app.use(cors({ origin: true, credentials: true, })); -app.use(express.json()); +// Conditionally parse JSON bodies. Remote proxy requests must NOT have their body +// consumed here: express.json() drains the IncomingMessage stream into req.body +// and http-proxy then pipes an already-ended stream to the remote server. +// When Node.js pipes an ended readable it calls process.nextTick(dest.end()), +// which fires *before* the proxyReq socket event, so any attempt to write the +// body inside the proxyReq handler results in "write after end" and the request +// hangs. Solution: skip JSON parsing for remote-targeted /api/ requests so the +// raw stream flows through the proxy intact. +app.use((req: Request, res: Response, next: NextFunction): void => { + const nodeIdHeader = req.headers['x-node-id']; + if (nodeIdHeader) { + const nodeId = parseInt(nodeIdHeader as string, 10); + const node = NodeRegistry.getInstance().getNode(nodeId); + if ( + node?.type === 'remote' && + req.path.startsWith('/api/') && + !req.path.startsWith('/api/auth/') && + !req.path.startsWith('/api/nodes') + ) { + // Preserve body stream for proxy piping + next(); + return; + } + } + express.json()(req, res, next); +}); app.use(cookieParser()); // Node Context Middleware @@ -370,18 +395,10 @@ const remoteNodeProxy = createProxyMiddleware({ const newQs = params.toString(); proxyReq.path = pathname + (newQs ? `?${newQs}` : ''); } - // Re-stream the request body for POST/PUT/PATCH requests. - // express.json() fully drains the incoming stream and stores the parsed - // object in req.body. http-proxy-middleware then pipes an already-consumed - // stream to the remote, which causes the remote server to stall waiting for - // body bytes that never arrive (the Content-Length header says N bytes but - // 0 are forwarded), hanging the request indefinitely. - if (req.body && Object.keys(req.body).length > 0) { - const bodyData = JSON.stringify(req.body); - proxyReq.setHeader('Content-Type', 'application/json'); - proxyReq.setHeader('Content-Length', Buffer.byteLength(bodyData)); - proxyReq.write(bodyData); - } + // Body forwarding: the conditional json parser (see top of file) skips + // parsing for remote requests, so req's raw stream is intact and + // http-proxy's req.pipe(proxyReq) forwards the body automatically. + // No manual body rewriting needed here. }, error: (err, _req, proxyRes) => { console.error('[Proxy] Remote node error:', (err as Error).message);