Files
sencho/backend/src/websocket/remoteForwarder.ts
T
Anso dd54a2e483 feat: graduate Host Console to Community admins (#1669)
* feat: graduate Host Console to Community admins

Make Host Console available to Community and Admiral admins (system:console), add host-console-community for mixed fleets, and keep opaque API tokens off the host shell.

* docs: document Host Console deep links

Cover root and stack-scoped Console URLs, correct the phone treatment note, and pin parse/build round-trips in senchoRoute tests.

* fix: bind Host Console socket to the resolved node

Treat unresolved activeNode as loading, target the WebSocket with an explicit nodeId, and wait for stack deep-link hydration so the shell cannot open on the wrong node or compose root. Add regression coverage for node/stack retargeting and fail-closed directory resolution.

* fix: harden Host Console node binding, audit acting_as, and console_session tokens

Reject unknown or malformed nodeIds before spawning a PTY. Record hub operators in audit_log.acting_as for remote console_session bridges. Path-scope and one-time-consume console_session JWTs so Host Console mints cannot open container exec or be replayed.

* test: expect acting_as in audit CSV export header

Align the CSV export assertion with the P0-2B acting_as column added to audit log exports.
2026-07-23 12:59:53 -04:00

103 lines
4.7 KiB
TypeScript

import type { IncomingMessage } from 'http';
import type { Duplex } from 'stream';
import { PROXY_TIER_HEADER } from '../services/license-headers';
import { LicenseService } from '../services/LicenseService';
import { wsProxyServer } from '../proxy/websocketProxy';
import { getErrorMessage } from '../utils/errors';
import { rejectUpgrade as reject } from './reject';
import { consoleSessionPathForPathname } from '../helpers/consoleSession';
/**
* Forward a WebSocket upgrade to a remote Sencho instance. Handles the
* console_session token exchange for interactive paths so the long-lived
* api_token never reaches an interactive terminal (the remote's upgrade
* handler rejects node_proxy tokens on those paths).
*
* Target resolution lives in the caller (parallel to the HTTP proxy in
* `proxy/remoteNodeProxy.ts`), so this function works uniformly for
* proxy-mode remotes (api_url + api_token) and pilot-mode remotes (loopback
* URL + empty token). When the target carries an empty token, the loopback
* bridge sits on 127.0.0.1 and demuxes onto an already-authenticated tunnel:
* we skip the console-token exchange and do not inject a Bearer.
*/
export async function handleRemoteForwarder(
req: IncomingMessage,
socket: Duplex,
head: Buffer,
opts: {
pathname: string;
target: { apiUrl: string; apiToken: string };
/** Hub browser operator; recorded as acting_as on the remote audit trail. */
actingAs?: string;
},
): Promise<void> {
const { pathname, target, actingAs } = opts;
if (!target.apiUrl) return reject(socket, 503, 'Service Unavailable');
const wsTarget = target.apiUrl.replace(/\/$/, '').replace(/^https?/, (m) => m === 'https' ? 'wss' : 'ws');
const isPilotLoopback = target.apiToken === '';
// Interactive console paths (host console / container exec) are guarded on
// the remote by an isProxyToken check that rejects the long-lived api_token.
// 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. Pilot loopback targets skip this: there is no
// long-lived api_token to exchange, and host-console is disabled on pilot
// mode at the capability registry anyway.
const sessionPath = consoleSessionPathForPathname(pathname);
let bearerTokenForProxy = target.apiToken;
if (sessionPath && !isPilotLoopback) {
try {
const consoleHeaders = LicenseService.getInstance().getProxyHeaders();
const tokenRes = await fetch(`${target.apiUrl.replace(/\/$/, '')}/api/system/console-token`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${target.apiToken}`,
'Content-Type': 'application/json',
[PROXY_TIER_HEADER]: consoleHeaders.tier,
},
body: JSON.stringify({
path: sessionPath,
...(actingAs ? { acting_as: actingAs } : {}),
}),
});
if (!tokenRes.ok) {
console.error(`[WS Proxy] Remote console-token request failed: ${tokenRes.status}`);
return reject(socket, 502, 'Bad Gateway');
}
const data = await tokenRes.json() as { token?: string };
if (typeof data.token !== 'string' || !data.token) {
console.error('[WS Proxy] Remote console-token response missing token');
return reject(socket, 502, 'Bad Gateway');
}
bearerTokenForProxy = data.token;
} catch (e) {
console.error('[WS Proxy] Failed to fetch remote console token:', getErrorMessage(e, 'unknown'));
return reject(socket, 502, 'Bad Gateway');
}
}
if (isPilotLoopback) {
// The loopback bridge does not authenticate inbound traffic; an
// Authorization header inherited from the browser would only confuse
// the agent. Strip it explicitly.
delete req.headers['authorization'];
} else {
req.headers['authorization'] = `Bearer ${bearerTokenForProxy}`;
}
delete req.headers['x-node-id'];
// Strip the browser's session cookie: signed by this instance's JWT secret
// and would fail verification on the remote. Auth is handled exclusively
// via the Bearer token (or, for pilot loopback, the tunnel itself).
delete req.headers['cookie'];
const fwdHeaders = LicenseService.getInstance().getProxyHeaders();
req.headers[PROXY_TIER_HEADER] = fwdHeaders.tier;
// Strip nodeId from the forwarded URL so the remote treats the request as
// local. The remote has no record of the gateway's nodeId; leaving it would
// trigger nodeContext's 404 branch.
const fwdUrl = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
fwdUrl.searchParams.delete('nodeId');
req.url = fwdUrl.pathname + (fwdUrl.searchParams.toString() ? `?${fwdUrl.searchParams.toString()}` : '');
wsProxyServer.ws(req, socket, head, { target: wsTarget });
}