mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-06 08:58:05 +00:00
f599110386
The separate saelix/sencho-mesh sidecar container is gone. The
forwarder logic that previously lived in mesh-sidecar/src/forwarder.ts
moves into the Sencho process as backend/src/services/MeshForwarder.ts,
a thin per-port net.Server lifecycle wrapper. MeshService implements
the host interface and owns resolve plus splice; MeshForwarder owns
listener boilerplate. One container per node, no separate image to
publish, no control WebSocket.
Operator-facing change: the Sencho container now runs in
network_mode: host so the forwarder can bind alias ports on the host
network where meshed containers' extra_hosts host-gateway entries
point. Without host network mode the listeners would land in the
container's namespace and inbound traffic from peers would never
reach them. The 1852:1852 port publish becomes a no-op under host
mode and is commented out in the operator template.
Same-node forward path now dials the target container's bridge IP
via Dockerode (preferring the compose default network for
deterministic selection across daemon versions) instead of 127.0.0.1.
The legacy 127.0.0.1 path only worked when the target service
published its port to the host; the IP path works regardless.
Cross-node mesh routing in this phase is central -> pilot direction
only via PilotTunnelManager.openTcpStream. Pilot -> central and
pilot <-> pilot via central relay land in Phase B with the
tcp_open_reverse frame.
Deletions:
- mesh-sidecar/ package entirely (Dockerfile, package, sources, tests)
- backend/src/websocket/meshControl.ts
- MeshService sidecar lifecycle: spawnSidecar, stopSidecar,
isSidecarRunning, mintSidecarToken, verifySidecarToken,
attachSidecarSocket, handleSidecarResolve, sendSidecar
- POST /api/mesh/nodes/:id/sidecar/restart route
- /api/mesh/control WS dispatch in upgradeHandler
Type cleanup: 'sidecar' literal removed from MeshActivitySource and
MeshProbeResult.where (also the frontend mirror). MeshNodeStatus
sidecarRunning becomes localForwarderListening (boolean | null) so
non-local nodes get a null instead of an unconditional false; the
honest semantic is "this view only knows the local forwarder state;
remote forwarder status lands in Phase B." MeshNodeDiagnostic
sidecar object becomes forwarder { listening, listenerCount }.
Frontend MeshDiagnosticsSheet drops the restart-sidecar action and
sidecar liveness card; surfaces forwarder state plus a "runs
in-process; no separate container" caption.
Resolves audit findings C-1 (data plane non-functional), C-2 (sidecar
control WS not loopback-enforced), C-4 (sidecar lifecycle Dockerode-
on-remote, PR #999 closed), and C-5 (saelix/sencho-mesh:latest
unreachable). C-3 (PR #992) is unchanged. M-12 (PR #994) is
unchanged.
163 lines
6.9 KiB
TypeScript
163 lines
6.9 KiB
TypeScript
import type http from 'http';
|
|
import type { IncomingMessage } from 'http';
|
|
import type { WebSocketServer } from 'ws';
|
|
import jwt from 'jsonwebtoken';
|
|
import crypto from 'crypto';
|
|
import { DatabaseService, type UserRole } from '../services/DatabaseService';
|
|
import { NodeRegistry } from '../services/NodeRegistry';
|
|
import { COOKIE_NAME } from '../helpers/constants';
|
|
import { handlePilotTunnel } from './pilotTunnel';
|
|
import { handleNotificationsWs } from './notifications';
|
|
import { handleRemoteForwarder } from './remoteForwarder';
|
|
import { handleLogsWs } from './logs';
|
|
import { handleHostConsoleWs } from './hostConsole';
|
|
import { handleGenericWs, attachGenericConnectionHandlers } from './generic';
|
|
import { rejectUpgrade as reject } from './reject';
|
|
|
|
function parseCookies(req: IncomingMessage): Record<string, string> {
|
|
const header = req.headers.cookie || '';
|
|
return Object.fromEntries(
|
|
header
|
|
.split(';')
|
|
.map((c) => c.trim().split('='))
|
|
.filter(([k, v]) => k && v),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Attach the upgrade dispatcher to the HTTP server and wire the generic
|
|
* `connection` handler on the main wss.
|
|
*
|
|
* Dispatch order (first match wins):
|
|
* 1. `/api/pilot/tunnel` -> handlePilotTunnel (own auth, own wss)
|
|
* 2. shared cookie/Bearer auth + JWT verify (rejects unauthenticated)
|
|
* 3. API token scope gate (read-only / deploy-only restricted to logs + notifications)
|
|
* 4. `/ws/notifications` local -> handleNotificationsWs
|
|
* 5. remote nodeId path -> handleRemoteForwarder
|
|
* 6. `/api/stacks/:name/logs` -> handleLogsWs
|
|
* 7. `/api/system/host-console` -> handleHostConsoleWs
|
|
* 8. fallback -> handleGenericWs (`/ws` exec + stats)
|
|
*/
|
|
export function attachUpgrade(
|
|
server: http.Server,
|
|
deps: { wss: WebSocketServer; pilotTunnelWss: WebSocketServer },
|
|
): void {
|
|
const { wss, pilotTunnelWss } = deps;
|
|
|
|
attachGenericConnectionHandlers(wss);
|
|
|
|
server.on('upgrade', async (req, socket, head) => {
|
|
// Pilot-agent tunnel ingress: machine credentials, no cookies.
|
|
try {
|
|
const reqUrl = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
|
|
if (reqUrl.pathname === '/api/pilot/tunnel') {
|
|
await handlePilotTunnel(req, socket, head, pilotTunnelWss);
|
|
return;
|
|
}
|
|
} catch {
|
|
// URL parse error falls through and will be rejected below.
|
|
}
|
|
|
|
const cookies = parseCookies(req);
|
|
const cookieToken = cookies[COOKIE_NAME];
|
|
const authHeader = req.headers['authorization'] as string | undefined;
|
|
const bearerToken = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null;
|
|
// Prefer Bearer over cookie: node-to-node proxy upgrades carry a Bearer
|
|
// token and must not be shadowed by a browser cookie signed with a
|
|
// different instance's JWT secret.
|
|
const token = bearerToken || cookieToken;
|
|
|
|
if (!token) return reject(socket, 401, 'Unauthorized');
|
|
|
|
try {
|
|
const settings = DatabaseService.getInstance().getGlobalSettings();
|
|
const jwtSecret = settings.auth_jwt_secret;
|
|
if (!jwtSecret) throw new Error('No JWT secret');
|
|
const decoded = jwt.verify(token, jwtSecret) as { username?: string; scope?: string; role?: string; tv?: number };
|
|
|
|
// Node proxy tokens are machine-to-machine credentials and must never be
|
|
// granted interactive terminal access (host console or container exec).
|
|
const isProxyToken = decoded.scope === 'node_proxy';
|
|
|
|
let wsApiTokenScope: string | null = null;
|
|
if (decoded.scope === 'api_token') {
|
|
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
|
|
const apiToken = DatabaseService.getInstance().getApiTokenByHash(tokenHash);
|
|
if (!apiToken || apiToken.revoked_at) return reject(socket, 401, 'Unauthorized');
|
|
if (apiToken.expires_at && apiToken.expires_at < Date.now()) return reject(socket, 401, 'Unauthorized');
|
|
DatabaseService.getInstance().updateApiTokenLastUsed(apiToken.id);
|
|
wsApiTokenScope = apiToken.scope;
|
|
}
|
|
|
|
// For user session tokens (no scope), resolve against DB for up-to-date
|
|
// role and token_version checks. Scoped tokens (api_token, node_proxy,
|
|
// console_session) skip this: they are validated by their own logic
|
|
// above or by the gateway that issued them.
|
|
let wsResolvedUser: { username: string; role: UserRole; token_version: number } | undefined;
|
|
if (!decoded.scope && decoded.username) {
|
|
const dbUser = DatabaseService.getInstance().getUserByUsername(decoded.username);
|
|
if (!dbUser) return reject(socket, 401, 'Unauthorized');
|
|
if (decoded.tv !== undefined && dbUser.token_version !== decoded.tv) {
|
|
console.log('[Auth] WS session rejected: token version mismatch for:', decoded.username);
|
|
return reject(socket, 401, 'Unauthorized');
|
|
}
|
|
wsResolvedUser = {
|
|
username: dbUser.username,
|
|
role: dbUser.role as UserRole,
|
|
token_version: dbUser.token_version,
|
|
};
|
|
}
|
|
|
|
const parsedUrl = new URL(req.url || '', `http://${req.headers.host || 'localhost'}`);
|
|
const pathname = parsedUrl.pathname;
|
|
|
|
// Gate WebSocket paths by API token scope
|
|
if (wsApiTokenScope) {
|
|
const isLogPath = /^\/api\/stacks\/[^/]+\/logs$/.test(pathname);
|
|
const isNotifPath = pathname === '/ws/notifications';
|
|
if (wsApiTokenScope === 'read-only' || wsApiTokenScope === 'deploy-only') {
|
|
if (!isLogPath && !isNotifPath) return reject(socket, 403, 'Forbidden');
|
|
}
|
|
}
|
|
|
|
const nodeIdParam = parsedUrl.searchParams.get('nodeId');
|
|
const nodeId = nodeIdParam ? parseInt(nodeIdParam, 10) : NodeRegistry.getInstance().getDefaultNodeId();
|
|
const node = NodeRegistry.getInstance().getNode(nodeId);
|
|
|
|
// Notification push channel: local only when no remote nodeId is
|
|
// specified. When a remote nodeId is provided, fall through to the
|
|
// forwarder so the browser subscribes to that remote node's push stream.
|
|
if (pathname === '/ws/notifications' && (!node || node.type !== 'remote')) {
|
|
handleNotificationsWs(req, socket, head);
|
|
return;
|
|
}
|
|
|
|
if (node && node.type === 'remote' && node.api_url && node.api_token) {
|
|
await handleRemoteForwarder(req, socket, head, { node, pathname });
|
|
return;
|
|
}
|
|
|
|
const logsMatch = pathname.match(/^\/api\/stacks\/([^/]+)\/logs$/);
|
|
if (logsMatch) {
|
|
handleLogsWs(req, socket, head, { nodeId, stackName: decodeURIComponent(logsMatch[1]) });
|
|
return;
|
|
}
|
|
|
|
if (pathname.startsWith('/api/system/host-console')) {
|
|
handleHostConsoleWs(req, socket, head, {
|
|
nodeId,
|
|
decoded,
|
|
isProxyToken,
|
|
wsResolvedUser,
|
|
stackParam: parsedUrl.searchParams.get('stack'),
|
|
});
|
|
return;
|
|
}
|
|
|
|
handleGenericWs(req, socket, head, wss, { decoded, isProxyToken });
|
|
} catch {
|
|
return reject(socket, 401, 'Unauthorized');
|
|
}
|
|
});
|
|
}
|