diff --git a/CHANGELOG.md b/CHANGELOG.md index a6e8ec64..a9b2c273 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ 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:** Remote node host console and container exec WebSocket connections now succeed — the gateway exchanges the long-lived `node_proxy` api_token for a short-lived `console_session` JWT (60 s TTL) via a new `POST /api/system/console-token` endpoint before forwarding the WS upgrade to the remote. Previously the remote's `isProxyToken` guard correctly blocked `node_proxy` tokens from interactive terminals, which also blocked legitimate user-initiated console sessions routed through the gateway. - **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. diff --git a/backend/src/index.ts b/backend/src/index.ts index 14d7552b..81e47998 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -515,7 +515,37 @@ server.on('upgrade', async (req, socket, head) => { // Remote Node WebSocket Proxy - forward the entire WS connection to the remote Sencho instance if (node && node.type === 'remote' && node.api_url && node.api_token) { const wsTarget = node.api_url.replace(/\/$/, '').replace(/^https?/, (m) => m === 'https' ? 'wss' : 'ws'); - req.headers['authorization'] = `Bearer ${node.api_token}`; + + // Interactive console paths (host console / container exec) are guarded on the remote by + // an isProxyToken check that rejects the long-lived api_token (scope: 'node_proxy'). + // Exchange it for a short-lived console_session token before forwarding so the remote + // allows the connection while keeping the guard intact for direct api_token access. + const isInteractiveConsolePath = pathname === '/api/system/host-console' || pathname === '/ws'; + let bearerTokenForProxy = node.api_token; + if (isInteractiveConsolePath) { + try { + const tokenRes = await fetch(`${node.api_url.replace(/\/$/, '')}/api/system/console-token`, { + method: 'POST', + headers: { 'Authorization': `Bearer ${node.api_token}` }, + }); + if (tokenRes.ok) { + const data = await tokenRes.json() as { token?: string }; + if (typeof data.token === 'string') bearerTokenForProxy = data.token; + } else { + console.error(`[WS Proxy] Remote console-token request failed: ${tokenRes.status}`); + socket.write('HTTP/1.1 502 Bad Gateway\r\n\r\n'); + socket.destroy(); + return; + } + } catch (e) { + console.error('[WS Proxy] Failed to fetch remote console token:', (e as Error).message); + socket.write('HTTP/1.1 502 Bad Gateway\r\n\r\n'); + socket.destroy(); + return; + } + } + + req.headers['authorization'] = `Bearer ${bearerTokenForProxy}`; delete req.headers['x-node-id']; // Strip the browser's session cookie - it is signed by this instance's JWT secret and // would fail verification on the remote. Auth is handled exclusively via the Bearer token. @@ -1502,6 +1532,27 @@ app.post('/api/notifications/test', async (req: Request, res: Response) => { } }); +// Issue a short-lived console session token for WebSocket proxy delegation. +// When the gateway needs to proxy an interactive terminal (host console or container exec) +// to a remote node, it calls this endpoint (authenticated with the long-lived api_token) +// to receive a short-lived token. The remote's WS upgrade handler allows 'console_session' +// tokens through its isProxyToken guard, keeping the long-lived api_token off interactive paths. +app.post('/api/system/console-token', (req: Request, res: Response): void => { + try { + const settings = DatabaseService.getInstance().getGlobalSettings(); + const jwtSecret = settings.auth_jwt_secret; + if (!jwtSecret) { + res.status(500).json({ error: 'No JWT secret configured' }); + return; + } + const consoleToken = jwt.sign({ scope: 'console_session' }, jwtSecret, { expiresIn: '60s' }); + res.json({ token: consoleToken }); + } catch (error) { + console.error('Failed to issue console token:', error); + res.status(500).json({ error: 'Failed to issue console token' }); + } +}); + // --- System Maintenance Routes (The System Janitor) --- app.get('/api/system/orphans', async (req: Request, res: Response) => {