mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-06 08:58:05 +00:00
e5b1c7b22b
Removes backend/src/entitlements/ (registry, loadProvider, CommunityEntitlementProvider, types, headers, normalize) and the two abstraction-only tests. Relocates headers/normalize/types to services/license-*.ts. Swaps 22 consumer call sites from getEntitlementProvider() to LicenseService.getInstance(). Drops the Dockerfile install step plus PRO_PACKAGE_VERSION build-arg and github_token BuildKit secret in docker-publish.yml. Removes the now stale no-restricted-imports rule in backend/eslint.config.mjs. Net: 37 files changed, ~700 lines removed, no behavior change. Local dev no longer requires GitHub Packages auth to start the backend. Rationale and revisit conditions in docs/internal/adrs/2026-05-02-collapse-entitlement-provider.md.
116 lines
4.4 KiB
TypeScript
116 lines
4.4 KiB
TypeScript
import type { IncomingMessage } from 'http';
|
|
import type { Duplex } from 'stream';
|
|
import WebSocket, { WebSocketServer } from 'ws';
|
|
import path from 'path';
|
|
import { FileSystemService } from '../services/FileSystemService';
|
|
import { NodeRegistry } from '../services/NodeRegistry';
|
|
import { HostTerminalService } from '../services/HostTerminalService';
|
|
import { PROXY_TIER_HEADER, PROXY_VARIANT_HEADER } from '../services/license-headers';
|
|
import {
|
|
isLicenseTier,
|
|
isLicenseVariant,
|
|
normalizeTier,
|
|
normalizeVariant,
|
|
} from '../services/license-normalize';
|
|
import { LicenseService } from '../services/LicenseService';
|
|
import { ROLE_PERMISSIONS, type PermissionAction } from '../middleware/permissions';
|
|
import type { UserRole } from '../services/DatabaseService';
|
|
import { getErrorMessage } from '../utils/errors';
|
|
import { rejectUpgrade as reject } from './reject';
|
|
|
|
interface HostConsoleContext {
|
|
nodeId: number;
|
|
decoded: { scope?: string; username?: string };
|
|
isProxyToken: boolean;
|
|
wsResolvedUser: { username: string; role: UserRole; token_version: number } | undefined;
|
|
stackParam: string | null;
|
|
}
|
|
|
|
/**
|
|
* Handle `/api/system/host-console` WebSocket upgrades.
|
|
*
|
|
* Enforces three gates before spawning the host PTY:
|
|
* 1. Machine-credential rejection: node_proxy tokens cannot reach an
|
|
* interactive host shell.
|
|
* 2. RBAC: user session tokens require the `system:console` permission.
|
|
* console_session tokens are pre-gated at issuance (see
|
|
* `routes/console.ts`) and skip this check.
|
|
* 3. License: host console requires paid + admiral. For console_session
|
|
* tokens the tier/variant is trusted from the gateway-supplied headers;
|
|
* otherwise the local LicenseService is consulted.
|
|
*/
|
|
export function handleHostConsoleWs(
|
|
req: IncomingMessage,
|
|
socket: Duplex,
|
|
head: Buffer,
|
|
ctx: HostConsoleContext,
|
|
): void {
|
|
const { nodeId, decoded, isProxyToken, wsResolvedUser, stackParam } = ctx;
|
|
|
|
if (isProxyToken) return reject(socket, 403, 'Forbidden');
|
|
|
|
const isConsoleSession = decoded.scope === 'console_session';
|
|
if (!isConsoleSession) {
|
|
const userRole = wsResolvedUser?.role;
|
|
const consolePermission: PermissionAction = 'system:console';
|
|
if (!userRole || !ROLE_PERMISSIONS[userRole]?.includes(consolePermission)) {
|
|
console.log('[HostConsole] Access denied: insufficient permissions', {
|
|
username: wsResolvedUser?.username || decoded.username,
|
|
role: userRole,
|
|
});
|
|
return reject(socket, 403, 'Forbidden');
|
|
}
|
|
}
|
|
|
|
const consoleTierHeader = req.headers[PROXY_TIER_HEADER] as string | undefined;
|
|
const consoleVariantHeader = req.headers[PROXY_VARIANT_HEADER] as string | undefined;
|
|
const ls = LicenseService.getInstance();
|
|
const consoleTier = (isConsoleSession && isLicenseTier(consoleTierHeader))
|
|
? normalizeTier(consoleTierHeader)
|
|
: ls.getTier();
|
|
const consoleVariant = (isConsoleSession && consoleVariantHeader !== undefined && isLicenseVariant(consoleVariantHeader))
|
|
? normalizeVariant(consoleVariantHeader)
|
|
: ls.getVariant();
|
|
if (consoleTier !== 'paid' || consoleVariant !== 'admiral') {
|
|
return reject(socket, 403, 'Forbidden');
|
|
}
|
|
|
|
const consoleUsername = wsResolvedUser?.username || decoded.username || 'console_session';
|
|
console.log('[HostConsole] WebSocket upgrade accepted', {
|
|
username: consoleUsername,
|
|
nodeId,
|
|
stack: stackParam || '(root)',
|
|
});
|
|
|
|
const hostConsoleWss = new WebSocketServer({ noServer: true });
|
|
hostConsoleWss.handleUpgrade(req, socket, head, (ws) => {
|
|
hostConsoleWss.close();
|
|
let targetDirectory = '';
|
|
try {
|
|
const baseDir = FileSystemService.getInstance(nodeId).getBaseDir();
|
|
if (stackParam) {
|
|
const resolved = path.resolve(baseDir, stackParam);
|
|
if (!resolved.startsWith(path.resolve(baseDir))) {
|
|
ws.send('Error: Invalid stack path\r\n');
|
|
ws.close();
|
|
return;
|
|
}
|
|
targetDirectory = resolved;
|
|
} else {
|
|
targetDirectory = baseDir;
|
|
}
|
|
} catch {
|
|
targetDirectory = FileSystemService.getInstance(NodeRegistry.getInstance().getDefaultNodeId()).getBaseDir();
|
|
}
|
|
try {
|
|
HostTerminalService.spawnTerminal(ws, targetDirectory, consoleUsername);
|
|
} catch (error) {
|
|
console.error('[HostConsole] Unhandled spawn error:', { user: consoleUsername, error: getErrorMessage(error, 'unknown') });
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
ws.send('Error: Failed to start terminal session.\r\n');
|
|
ws.close();
|
|
}
|
|
}
|
|
});
|
|
}
|