mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
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.
This commit is contained in:
@@ -76,4 +76,8 @@ describe('WebSocket API-token scope enforcement', () => {
|
||||
it('does not scope-block a full-admin token from a generic socket', async () => {
|
||||
expect(await upgradeStatus(createToken('full-admin'), '/ws')).not.toBe(403);
|
||||
});
|
||||
|
||||
it('blocks a full-admin token from the host console (403)', async () => {
|
||||
expect(await upgradeStatus(createToken('full-admin'), '/api/system/host-console')).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -715,7 +715,7 @@ describe('GET /api/audit-log/export', () => {
|
||||
|
||||
const csvText = res.text;
|
||||
const lines = csvText.split('\n');
|
||||
expect(lines[0]).toBe('id,timestamp,username,method,path,status_code,node_id,ip_address,summary');
|
||||
expect(lines[0]).toBe('id,timestamp,username,acting_as,method,path,status_code,node_id,ip_address,summary');
|
||||
expect(lines.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -115,7 +115,8 @@ describe('POST /api/system/console-token', () => {
|
||||
const token = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
||||
const res = await request(app)
|
||||
.post('/api/system/console-token')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ path: 'host-console' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(typeof res.body.token).toBe('string');
|
||||
});
|
||||
|
||||
@@ -70,15 +70,18 @@ describe('fetchRemoteMeta Authorization header', () => {
|
||||
describe('applyPilotModeCapabilityFilter', () => {
|
||||
afterEach(() => {
|
||||
enableCapability('host-console');
|
||||
enableCapability('host-console-community');
|
||||
});
|
||||
|
||||
it('removes host-console from active capabilities', () => {
|
||||
it('removes host-console and host-console-community from active capabilities', () => {
|
||||
expect(CAPABILITIES).toContain('host-console');
|
||||
expect(CAPABILITIES).toContain('host-console-community');
|
||||
|
||||
applyPilotModeCapabilityFilter();
|
||||
const active = getActiveCapabilities();
|
||||
|
||||
expect(active).not.toContain('host-console');
|
||||
expect(active).not.toContain('host-console-community');
|
||||
expect(active).toContain('stacks');
|
||||
});
|
||||
|
||||
@@ -97,6 +100,7 @@ describe('applyPilotModeCapabilityFilter', () => {
|
||||
const active = getActiveCapabilities();
|
||||
|
||||
expect(active).not.toContain('host-console');
|
||||
expect(active.length).toBe(CAPABILITIES.length - 1);
|
||||
expect(active).not.toContain('host-console-community');
|
||||
expect(active.length).toBe(CAPABILITIES.length - 2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,8 +21,8 @@ describe('WebSocket upgrade - host console auth enforcement', () => {
|
||||
beforeAll(async () => {
|
||||
vi.restoreAllMocks();
|
||||
tmpDir = await setupTestDb();
|
||||
// Host console requires the paid tier; mock the license so the tier gate
|
||||
// passes for the admin/accepted cases. Individual tests override as needed.
|
||||
// Host console is available on every tier for admins; mock paid so other
|
||||
// suites that share LicenseService state stay stable.
|
||||
const { LicenseService } = await import('../services/LicenseService');
|
||||
getTierSpy = vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
const mod = await import('../index');
|
||||
@@ -78,10 +78,28 @@ describe('WebSocket upgrade - host console auth enforcement', () => {
|
||||
expect(await expectRejected(ws)).toBe(403);
|
||||
});
|
||||
|
||||
it('rejects an admin on the Community tier (403)', async () => {
|
||||
it('accepts a Community-tier admin', async () => {
|
||||
getTierSpy.mockReturnValueOnce('community');
|
||||
const ws = new WebSocket(wsUrl(), { headers: { Cookie: `sencho_token=${adminToken()}` } });
|
||||
expect(await expectRejected(ws)).toBe(403);
|
||||
const opened = await new Promise<boolean>((resolve) => {
|
||||
ws.on('open', () => { ws.close(); resolve(true); });
|
||||
ws.on('error', () => resolve(false));
|
||||
ws.on('unexpected-response', () => resolve(false));
|
||||
});
|
||||
expect(opened).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a console_session Bearer on Community (remote bridge mint path)', async () => {
|
||||
getTierSpy.mockReturnValueOnce('community');
|
||||
const { mintConsoleSession } = await import('../helpers/consoleSession');
|
||||
const token = mintConsoleSession({ path: 'host-console', actingAs: TEST_USERNAME });
|
||||
const ws = new WebSocket(wsUrl(), { headers: { Authorization: `Bearer ${token}` } });
|
||||
const opened = await new Promise<boolean>((resolve) => {
|
||||
ws.on('open', () => { ws.close(); resolve(true); });
|
||||
ws.on('error', () => resolve(false));
|
||||
ws.on('unexpected-response', () => resolve(false));
|
||||
});
|
||||
expect(opened).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts an admin on Admiral and records an open audit row', async () => {
|
||||
@@ -127,6 +145,116 @@ describe('WebSocket upgrade - host console auth enforcement', () => {
|
||||
expect(firstMessage).toContain('Invalid stack path');
|
||||
ws.close();
|
||||
});
|
||||
|
||||
|
||||
it('rejects an unknown nodeId without spawning a PTY (404)', async () => {
|
||||
const { HostTerminalService } = await import('../services/HostTerminalService');
|
||||
const spawnSpy = vi.spyOn(HostTerminalService, 'spawnTerminal');
|
||||
try {
|
||||
const ws = new WebSocket(wsUrl('?nodeId=99999999'), {
|
||||
headers: { Cookie: `sencho_token=${adminToken()}` },
|
||||
});
|
||||
expect(await expectRejected(ws)).toBe(404);
|
||||
expect(spawnSpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
spawnSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a malformed nodeId that parseInt would coerce (404)', async () => {
|
||||
const ws = new WebSocket(wsUrl('?nodeId=1abc'), {
|
||||
headers: { Cookie: `sencho_token=${adminToken()}` },
|
||||
});
|
||||
expect(await expectRejected(ws)).toBe(404);
|
||||
});
|
||||
|
||||
it('rejects zero and negative nodeId values (404)', async () => {
|
||||
for (const id of ['0', '-1']) {
|
||||
const ws = new WebSocket(wsUrl(`?nodeId=${id}`), {
|
||||
headers: { Cookie: `sencho_token=${adminToken()}` },
|
||||
});
|
||||
expect(await expectRejected(ws)).toBe(404);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a replayed console_session token on second Host Console upgrade', async () => {
|
||||
const { mintConsoleSession } = await import('../helpers/consoleSession');
|
||||
const token = mintConsoleSession({ path: 'host-console', actingAs: 'qaadmin' });
|
||||
const first = new WebSocket(wsUrl(), { headers: { Authorization: `Bearer ${token}` } });
|
||||
const firstOpened = await new Promise<boolean>((resolve) => {
|
||||
first.on('open', () => { first.close(); resolve(true); });
|
||||
first.on('error', () => resolve(false));
|
||||
first.on('unexpected-response', () => resolve(false));
|
||||
});
|
||||
expect(firstOpened).toBe(true);
|
||||
const second = new WebSocket(wsUrl(), { headers: { Authorization: `Bearer ${token}` } });
|
||||
expect(await expectRejected(second)).toBe(401);
|
||||
});
|
||||
|
||||
it('rejects a host-console console_session on the container-exec /ws path', async () => {
|
||||
const { mintConsoleSession } = await import('../helpers/consoleSession');
|
||||
const token = mintConsoleSession({ path: 'host-console' });
|
||||
const addr = server.address();
|
||||
if (!addr || typeof addr === 'string') throw new Error('Server not listening');
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${addr.port}/ws`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
expect(await expectRejected(ws)).toBe(403);
|
||||
});
|
||||
|
||||
it('records acting_as on console_session open audit rows', async () => {
|
||||
const { mintConsoleSession } = await import('../helpers/consoleSession');
|
||||
const { DatabaseService } = await import('../services/DatabaseService');
|
||||
const db = DatabaseService.getInstance();
|
||||
const insertSpy = vi.spyOn(db, 'insertAuditLog');
|
||||
const token = mintConsoleSession({ path: 'host-console', actingAs: 'qaadmin' });
|
||||
try {
|
||||
const ws = new WebSocket(wsUrl(), { headers: { Authorization: `Bearer ${token}` } });
|
||||
const opened = await new Promise<boolean>((resolve) => {
|
||||
ws.on('open', () => { ws.close(); resolve(true); });
|
||||
ws.on('error', () => resolve(false));
|
||||
ws.on('unexpected-response', () => resolve(false));
|
||||
});
|
||||
expect(opened).toBe(true);
|
||||
await waitFor(() => insertSpy.mock.calls.some((c) => {
|
||||
const e = c[0] as { summary?: string };
|
||||
return typeof e.summary === 'string' && e.summary.includes('Opened host console');
|
||||
}));
|
||||
const openEntry = insertSpy.mock.calls
|
||||
.map((c) => c[0] as { username: string; acting_as?: string | null; summary: string })
|
||||
.find((e) => e.summary.includes('Opened host console'));
|
||||
expect(openEntry?.username).toBe('console_session');
|
||||
expect(openEntry?.acting_as).toBe('qaadmin');
|
||||
} finally {
|
||||
insertSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('closes without spawning a PTY when directory resolution throws (no default-node fallback)', async () => {
|
||||
const { FileSystemService } = await import('../services/FileSystemService');
|
||||
const { HostTerminalService } = await import('../services/HostTerminalService');
|
||||
const spawnSpy = vi.spyOn(HostTerminalService, 'spawnTerminal');
|
||||
const getInstanceSpy = vi.spyOn(FileSystemService, 'getInstance').mockImplementation(() => {
|
||||
throw new Error('compose dir unavailable');
|
||||
});
|
||||
|
||||
try {
|
||||
const ws = new WebSocket(wsUrl(), { headers: { Cookie: `sencho_token=${adminToken()}` } });
|
||||
const firstMessage = await new Promise<string>((resolve) => {
|
||||
ws.on('message', (data) => resolve(data.toString()));
|
||||
ws.on('error', () => resolve(''));
|
||||
ws.on('unexpected-response', () => resolve(''));
|
||||
});
|
||||
expect(firstMessage).toMatch(/Failed to resolve console directory/i);
|
||||
expect(spawnSpy).not.toHaveBeenCalled();
|
||||
// getInstance must not be retried against a fallback/default node id.
|
||||
expect(getInstanceSpy).toHaveBeenCalledTimes(1);
|
||||
ws.close();
|
||||
} finally {
|
||||
spawnSpy.mockRestore();
|
||||
getInstanceSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/** Poll a predicate up to ~1s; resolve true as soon as it passes. */
|
||||
|
||||
@@ -375,9 +375,42 @@ describe('POST /api/system/console-token', () => {
|
||||
const token = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
||||
const res = await request(app)
|
||||
.post('/api/system/console-token')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ path: 'host-console' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(typeof res.body.token).toBe('string');
|
||||
const decoded = jwt.verify(res.body.token, TEST_JWT_SECRET) as { scope?: string; path?: string; jti?: string };
|
||||
expect(decoded.scope).toBe('console_session');
|
||||
expect(decoded.path).toBe('host-console');
|
||||
expect(typeof decoded.jti).toBe('string');
|
||||
});
|
||||
|
||||
it('returns 400 when path is missing', async () => {
|
||||
const token = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
||||
const res = await request(app)
|
||||
.post('/api/system/console-token')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 200 for an admin on Community (no paid gate)', async () => {
|
||||
const { LicenseService } = await import('../services/LicenseService');
|
||||
const spy = vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
|
||||
try {
|
||||
const token = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
||||
const res = await request(app)
|
||||
.post('/api/system/console-token')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ path: 'container-exec' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(typeof res.body.token).toBe('string');
|
||||
const decoded = jwt.verify(res.body.token, TEST_JWT_SECRET) as { scope?: string; path?: string };
|
||||
expect(decoded.scope).toBe('console_session');
|
||||
expect(decoded.path).toBe('container-exec');
|
||||
} finally {
|
||||
spy.mockReturnValue('paid');
|
||||
}
|
||||
});
|
||||
|
||||
it('returns 403 for non-admin user (viewer role)', async () => {
|
||||
|
||||
@@ -30,10 +30,9 @@ describe('console_session token parity (HTTP route vs mint helper)', () => {
|
||||
({ app } = await import('../index'));
|
||||
adminCookie = await loginAsTestAdmin(app);
|
||||
|
||||
// POST /api/system/console-token is paid-gated. Seed an active license so the
|
||||
// parity assertion can observe the token the route returns. The
|
||||
// license_last_validated fallback is skipped when the state key is absent, so
|
||||
// the active status alone drives the paid tier.
|
||||
// POST /api/system/console-token is admin-gated (available on every tier).
|
||||
// Seed an active license only so other suites that share LicenseService
|
||||
// state stay stable if they expect paid.
|
||||
const { DatabaseService } = await import('../services/DatabaseService');
|
||||
DatabaseService.getInstance().setSystemState('license_status', 'active');
|
||||
});
|
||||
@@ -43,12 +42,13 @@ describe('console_session token parity (HTTP route vs mint helper)', () => {
|
||||
});
|
||||
|
||||
it('POST /api/system/console-token produces a token with the same shape as mintConsoleSession()', async () => {
|
||||
const directToken = mintConsoleSession();
|
||||
const directToken = mintConsoleSession({ path: 'host-console', actingAs: 'testadmin' });
|
||||
const directDecoded = jwt.verify(directToken, TEST_JWT_SECRET) as Record<string, unknown>;
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/system/console-token')
|
||||
.set('Cookie', adminCookie);
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ path: 'host-console' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(typeof res.body.token).toBe('string');
|
||||
|
||||
@@ -57,6 +57,12 @@ describe('console_session token parity (HTTP route vs mint helper)', () => {
|
||||
// Identical scope so the remote's upgrade handler treats both the same.
|
||||
expect(routeDecoded.scope).toBe('console_session');
|
||||
expect(directDecoded.scope).toBe('console_session');
|
||||
expect(routeDecoded.path).toBe('host-console');
|
||||
expect(directDecoded.path).toBe('host-console');
|
||||
expect(typeof routeDecoded.jti).toBe('string');
|
||||
expect(typeof directDecoded.jti).toBe('string');
|
||||
expect(routeDecoded.acting_as).toBe('testadmin');
|
||||
expect(directDecoded.acting_as).toBe('testadmin');
|
||||
|
||||
// Same claim keys: if somebody adds or drops a claim on one path but not
|
||||
// the other, remote upgrade behavior will diverge.
|
||||
|
||||
@@ -58,6 +58,13 @@ describe('remoteWsForwardAllowed', () => {
|
||||
expect(remoteWsForwardAllowed(EXEC, { ...base, wsApiTokenScope: 'deploy-only', decoded: { scope: 'api_token' } })).toBe(false);
|
||||
});
|
||||
|
||||
it('denies every API-token scope for Host Console while preserving full-admin for container exec', () => {
|
||||
expect(remoteWsForwardAllowed(CONSOLE, { ...base, wsApiTokenScope: 'full-admin', decoded: { scope: 'api_token' } })).toBe(false);
|
||||
expect(remoteWsForwardAllowed(CONSOLE, { ...base, wsApiTokenScope: 'deploy-only', decoded: { scope: 'api_token' } })).toBe(false);
|
||||
expect(remoteWsForwardAllowed(CONSOLE, { ...base, wsApiTokenScope: 'read-only', decoded: { scope: 'api_token' } })).toBe(false);
|
||||
expect(remoteWsForwardAllowed(EXEC, { ...base, wsApiTokenScope: 'full-admin', decoded: { scope: 'api_token' } })).toBe(true);
|
||||
});
|
||||
|
||||
it('allows an interactive path for a pre-gated console_session token', () => {
|
||||
expect(remoteWsForwardAllowed(CONSOLE, { ...base, decoded: { scope: 'console_session' } })).toBe(true);
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* product paths. This test pins each position by observing behavior that
|
||||
* could only originate from the expected handler.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
|
||||
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest';
|
||||
import WebSocket from 'ws';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import bcrypt from 'bcrypt';
|
||||
@@ -420,6 +420,100 @@ describe('WebSocket upgrade dispatch order', () => {
|
||||
expect(outcome.kind).toBe('unexpected');
|
||||
if (outcome.kind === 'unexpected') expect(outcome.status).toBe(403);
|
||||
});
|
||||
|
||||
it('rejects a full-admin API token remote Host Console with 403 (container exec stays allowed)', async () => {
|
||||
const { DatabaseService } = await import('../services/DatabaseService');
|
||||
const adminId = DatabaseService.getInstance().getUserByUsername(TEST_USERNAME)!.id;
|
||||
const rawToken = createTestApiToken({
|
||||
db: DatabaseService,
|
||||
scope: 'full-admin',
|
||||
userId: adminId,
|
||||
name: `host-console-api-deny-${Date.now()}`,
|
||||
});
|
||||
const consoleWs = connect(`/api/system/host-console?nodeId=${remoteNodeId}`, { bearer: rawToken });
|
||||
const consoleOutcome = await waitForOutcome(consoleWs);
|
||||
expect(consoleOutcome.kind).toBe('unexpected');
|
||||
if (consoleOutcome.kind === 'unexpected') expect(consoleOutcome.status).toBe(403);
|
||||
|
||||
const execWs = connect(`/ws?nodeId=${remoteNodeId}`, { bearer: rawToken });
|
||||
const execOutcome = await waitForOutcome(execWs);
|
||||
if (execOutcome.kind === 'unexpected') expect(execOutcome.status).not.toBe(403);
|
||||
try { execWs.terminate(); } catch { /* ignore */ }
|
||||
});
|
||||
});
|
||||
|
||||
describe('remote Host Console Community capability probe', () => {
|
||||
// Community hubs must probe host-console-community before forwarding Host
|
||||
// Console; Admiral hubs skip the probe so legacy remotes still work.
|
||||
// Fail closed when the probe errors or the capability is missing.
|
||||
|
||||
const meta = (capabilities: string[]): import('../services/CapabilityRegistry').RemoteMeta => ({
|
||||
version: '0.95.0',
|
||||
capabilities,
|
||||
startedAt: 1,
|
||||
updateError: null,
|
||||
online: true,
|
||||
imagePinKind: null,
|
||||
updateBlocked: false,
|
||||
imageChannel: null,
|
||||
});
|
||||
|
||||
async function setLicense(status: string): Promise<void> {
|
||||
const { DatabaseService } = await import('../services/DatabaseService');
|
||||
DatabaseService.getInstance().setSystemState('license_status', status);
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await setLicense('active');
|
||||
});
|
||||
|
||||
it('rejects Community hub remote Host Console when the remote lacks host-console-community', async () => {
|
||||
await setLicense('community');
|
||||
const { NodeRegistry } = await import('../services/NodeRegistry');
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'fetchMetaForNode').mockResolvedValue(meta(['host-console']));
|
||||
|
||||
const ws = connect(`/api/system/host-console?nodeId=${remoteNodeId}`, { cookie: sessionCookie });
|
||||
const outcome = await waitForOutcome(ws);
|
||||
expect(outcome.kind).toBe('unexpected');
|
||||
if (outcome.kind === 'unexpected') expect(outcome.status).toBe(403);
|
||||
});
|
||||
|
||||
it('rejects Community hub remote Host Console when the capability probe fails', async () => {
|
||||
await setLicense('community');
|
||||
const { NodeRegistry } = await import('../services/NodeRegistry');
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'fetchMetaForNode').mockRejectedValue(new Error('offline'));
|
||||
|
||||
const ws = connect(`/api/system/host-console?nodeId=${remoteNodeId}`, { cookie: sessionCookie });
|
||||
const outcome = await waitForOutcome(ws);
|
||||
expect(outcome.kind).toBe('unexpected');
|
||||
if (outcome.kind === 'unexpected') expect(outcome.status).toBe(403);
|
||||
});
|
||||
|
||||
it('forwards Community hub remote Host Console when the remote advertises host-console-community', async () => {
|
||||
await setLicense('community');
|
||||
const { NodeRegistry } = await import('../services/NodeRegistry');
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'fetchMetaForNode').mockResolvedValue(
|
||||
meta(['host-console-community']),
|
||||
);
|
||||
|
||||
const ws = connect(`/api/system/host-console?nodeId=${remoteNodeId}`, { cookie: sessionCookie });
|
||||
const outcome = await waitForOutcome(ws);
|
||||
if (outcome.kind === 'unexpected') expect(outcome.status).not.toBe(403);
|
||||
try { ws.terminate(); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
it('skips the capability probe on an Admiral hub even when the remote lacks host-console-community', async () => {
|
||||
await setLicense('active');
|
||||
const { NodeRegistry } = await import('../services/NodeRegistry');
|
||||
const spy = vi.spyOn(NodeRegistry.getInstance(), 'fetchMetaForNode').mockResolvedValue(meta([]));
|
||||
|
||||
const ws = connect(`/api/system/host-console?nodeId=${remoteNodeId}`, { cookie: sessionCookie });
|
||||
const outcome = await waitForOutcome(ws);
|
||||
if (outcome.kind === 'unexpected') expect(outcome.status).not.toBe(403);
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
try { ws.terminate(); } catch { /* ignore */ }
|
||||
});
|
||||
});
|
||||
|
||||
it('dispatches /api/pilot/tunnel to the pilot handler (rejects non-pilot bearer before path-based dispatch)', async () => {
|
||||
|
||||
@@ -1,17 +1,55 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
|
||||
/**
|
||||
* Console session token lifetime. Short on purpose: these tokens are only
|
||||
* used to bridge an already-authenticated HTTP request into a WebSocket
|
||||
* upgrade, and each one is consumed by a single `wss:ws(target)` call.
|
||||
* Console session token lifetime. Short on purpose: these tokens bridge an
|
||||
* already-authenticated hub request into a remote WebSocket upgrade. Each
|
||||
* token is path-scoped and consumed on first interactive WebSocket upgrade
|
||||
* acceptance (before PTY spawn).
|
||||
*/
|
||||
const CONSOLE_SESSION_TTL_SECONDS = 60;
|
||||
const CONSOLE_SESSION_SCOPE = 'console_session';
|
||||
|
||||
/** Interactive surfaces that may mint or accept a console_session JWT. */
|
||||
export type ConsoleSessionPath = 'host-console' | 'container-exec';
|
||||
|
||||
export interface MintConsoleSessionOptions {
|
||||
path: ConsoleSessionPath;
|
||||
/** Hub operator identity for remote audit (option B). Never used as the session principal. */
|
||||
actingAs?: string;
|
||||
}
|
||||
|
||||
export interface ConsoleSessionClaims {
|
||||
scope: typeof CONSOLE_SESSION_SCOPE;
|
||||
username?: string;
|
||||
path: ConsoleSessionPath;
|
||||
acting_as?: string;
|
||||
}
|
||||
|
||||
const ACTING_AS_MAX = 64;
|
||||
const ACTING_AS_RE = /^[A-Za-z0-9._@+-]+$/;
|
||||
|
||||
/** Sanitize an optional hub operator name for the acting_as claim. */
|
||||
export function sanitizeActingAs(raw: unknown): string | undefined {
|
||||
if (typeof raw !== 'string') return undefined;
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed || trimmed.length > ACTING_AS_MAX) return undefined;
|
||||
if (!ACTING_AS_RE.test(trimmed)) return undefined;
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function isConsoleSessionPath(value: unknown): value is ConsoleSessionPath {
|
||||
return value === 'host-console' || value === 'container-exec';
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a WebSocket pathname to the console_session path claim it requires.
|
||||
* Returns null for surfaces that must not accept console_session tokens.
|
||||
*/
|
||||
export function consoleSessionPathForPathname(pathname: string): ConsoleSessionPath | null {
|
||||
if (pathname.startsWith('/api/system/host-console')) return 'host-console';
|
||||
if (pathname === '/ws') return 'container-exec';
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -19,13 +57,19 @@ export interface ConsoleSessionClaims {
|
||||
* instance without leaking the long-lived node api_token onto a
|
||||
* machine-to-machine WebSocket. Throws if the JWT secret is not configured.
|
||||
*/
|
||||
export function mintConsoleSession(username?: string): string {
|
||||
export function mintConsoleSession(opts: MintConsoleSessionOptions): string {
|
||||
if (!isConsoleSessionPath(opts.path)) {
|
||||
throw new Error('Invalid console session path');
|
||||
}
|
||||
const jwtSecret = DatabaseService.getInstance().getGlobalSettings().auth_jwt_secret;
|
||||
if (!jwtSecret) throw new Error('No JWT secret configured');
|
||||
const payload: ConsoleSessionClaims = username
|
||||
? { scope: CONSOLE_SESSION_SCOPE, username }
|
||||
: { scope: CONSOLE_SESSION_SCOPE };
|
||||
return jwt.sign(payload, jwtSecret, { expiresIn: CONSOLE_SESSION_TTL_SECONDS });
|
||||
const payload: ConsoleSessionClaims = { scope: CONSOLE_SESSION_SCOPE, path: opts.path };
|
||||
const actingAs = sanitizeActingAs(opts.actingAs);
|
||||
if (actingAs) payload.acting_as = actingAs;
|
||||
return jwt.sign(payload, jwtSecret, {
|
||||
expiresIn: CONSOLE_SESSION_TTL_SECONDS,
|
||||
jwtid: randomUUID(),
|
||||
});
|
||||
}
|
||||
|
||||
/** True when `decoded.scope` is the console_session scope. */
|
||||
@@ -33,4 +77,14 @@ export function isConsoleSessionScope(scope: unknown): boolean {
|
||||
return scope === CONSOLE_SESSION_SCOPE;
|
||||
}
|
||||
|
||||
export { CONSOLE_SESSION_SCOPE };
|
||||
/**
|
||||
* Mark a console_session jti as used. Returns false when the jti is missing,
|
||||
* already consumed, or cannot be recorded (replay / malformed token).
|
||||
*/
|
||||
export function consumeConsoleSessionJti(jti: unknown, expiresAtMs: number): boolean {
|
||||
if (typeof jti !== 'string' || !jti) return false;
|
||||
if (!Number.isFinite(expiresAtMs)) return false;
|
||||
return DatabaseService.getInstance().consumeConsoleSessionJti(jti, expiresAtMs);
|
||||
}
|
||||
|
||||
export { CONSOLE_SESSION_SCOPE, CONSOLE_SESSION_TTL_SECONDS };
|
||||
|
||||
@@ -103,7 +103,7 @@ auditLogRouter.get('/export', async (req: Request, res: Response): Promise<void>
|
||||
res.setHeader('Content-Type', 'text/csv');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="audit-log-${timestamp}.csv"`);
|
||||
|
||||
const headers = ['id', 'timestamp', 'username', 'method', 'path', 'status_code', 'node_id', 'ip_address', 'summary'];
|
||||
const headers = ['id', 'timestamp', 'username', 'acting_as', 'method', 'path', 'status_code', 'node_id', 'ip_address', 'summary'];
|
||||
const rows = result.entries.map(e =>
|
||||
headers.map(h => escapeCsvField(e[h as keyof typeof e])).join(','),
|
||||
);
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import { authMiddleware } from '../middleware/auth';
|
||||
import { requireAdmin, requirePaid } from '../middleware/tierGates';
|
||||
import { requireAdmin } from '../middleware/tierGates';
|
||||
import { rejectApiTokenScope } from '../middleware/apiTokenScope';
|
||||
import { mintConsoleSession } from '../helpers/consoleSession';
|
||||
import {
|
||||
isConsoleSessionPath,
|
||||
mintConsoleSession,
|
||||
sanitizeActingAs,
|
||||
} from '../helpers/consoleSession';
|
||||
|
||||
/**
|
||||
* Mint a short-lived `console_session` JWT. Used by the gateway when it
|
||||
@@ -11,15 +15,28 @@ import { mintConsoleSession } from '../helpers/consoleSession';
|
||||
* remote's upgrade handler on interactive paths, so the gateway authenticates
|
||||
* with the long-lived token, asks for this short-lived one, then forwards
|
||||
* the WS upgrade using it.
|
||||
*
|
||||
* Body:
|
||||
* - `path` (required): `host-console` | `container-exec`
|
||||
* - `acting_as` (optional): hub operator for remote audit. On node_proxy mints
|
||||
* the body value is used (sanitized). On browser admin mints the signed-in
|
||||
* username is stamped so a Bearer console_session cannot erase attribution.
|
||||
*/
|
||||
export const consoleRouter = Router();
|
||||
|
||||
consoleRouter.post('/console-token', authMiddleware, (req: Request, res: Response): void => {
|
||||
if (rejectApiTokenScope(req, res, 'API tokens cannot generate console tokens.')) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
try {
|
||||
res.json({ token: mintConsoleSession() });
|
||||
const path = req.body?.path;
|
||||
if (!isConsoleSessionPath(path)) {
|
||||
res.status(400).json({ error: 'Invalid or missing console path' });
|
||||
return;
|
||||
}
|
||||
const actingAs = req.machineAuthScope === 'node_proxy'
|
||||
? sanitizeActingAs(req.body?.acting_as)
|
||||
: sanitizeActingAs(req.user?.username);
|
||||
res.json({ token: mintConsoleSession({ path, actingAs }) });
|
||||
} catch (error) {
|
||||
console.error('Failed to issue console token:', error);
|
||||
res.status(500).json({ error: 'Failed to issue console token' });
|
||||
|
||||
@@ -38,6 +38,7 @@ export const CAPABILITIES = [
|
||||
'notification-suppression',
|
||||
'notification-suppression-schedule',
|
||||
'host-console',
|
||||
'host-console-community',
|
||||
'container-exec',
|
||||
'audit-log',
|
||||
'scheduled-ops',
|
||||
@@ -71,6 +72,12 @@ export const CROSS_NODE_RBAC_CAPABILITY = 'cross-node-rbac';
|
||||
|
||||
export type Capability = (typeof CAPABILITIES)[number];
|
||||
|
||||
/** Legacy Host Console advertisement (Admiral hubs still accept this on remotes). */
|
||||
export const HOST_CONSOLE_CAPABILITY = 'host-console' as const satisfies Capability;
|
||||
|
||||
/** Host Console works without a paid license on this node. */
|
||||
export const HOST_CONSOLE_COMMUNITY_CAPABILITY = 'host-console-community' as const satisfies Capability;
|
||||
|
||||
/** Remotes that evaluate weekly maintenance windows on mute/suppression replicas. */
|
||||
export const NOTIFICATION_SUPPRESSION_SCHEDULE_CAPABILITY =
|
||||
'notification-suppression-schedule' as const satisfies Capability;
|
||||
@@ -165,6 +172,7 @@ export function getActiveCapabilities(): readonly string[] {
|
||||
*/
|
||||
const PILOT_DISABLED_CAPABILITIES: readonly Capability[] = [
|
||||
'host-console',
|
||||
'host-console-community',
|
||||
];
|
||||
|
||||
/** Disable capabilities that require a central->pilot path that is not yet wired. */
|
||||
|
||||
@@ -613,6 +613,8 @@ export interface AuditLogEntry {
|
||||
node_id: number | null;
|
||||
ip_address: string;
|
||||
summary: string;
|
||||
/** Hub operator for remote console_session bridges; null/absent for direct sessions. */
|
||||
acting_as?: string | null;
|
||||
}
|
||||
|
||||
export interface SecretRow {
|
||||
@@ -1240,12 +1242,20 @@ export class DatabaseService {
|
||||
status_code INTEGER NOT NULL DEFAULT 0,
|
||||
node_id INTEGER,
|
||||
ip_address TEXT NOT NULL DEFAULT '',
|
||||
summary TEXT NOT NULL DEFAULT ''
|
||||
summary TEXT NOT NULL DEFAULT '',
|
||||
acting_as TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_timestamp ON audit_log(timestamp);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_username ON audit_log(username);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS console_session_jtis (
|
||||
jti TEXT PRIMARY KEY,
|
||||
used_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_console_session_jtis_expires ON console_session_jtis(expires_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS api_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
@@ -1788,6 +1798,21 @@ export class DatabaseService {
|
||||
try { this.db.prepare(`ALTER TABLE ${table} ADD COLUMN ${col} ${def}`).run(); } catch (e) { /* ignore */ }
|
||||
};
|
||||
|
||||
// Remote Host Console bridges record the hub operator separately from
|
||||
// the console_session principal (username stays console_session).
|
||||
maybeAddCol('audit_log', 'acting_as', 'TEXT');
|
||||
// Cached INSERT may predate the column; rebuild on next flush.
|
||||
this.auditLogInsertStmt = null;
|
||||
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS console_session_jtis (
|
||||
jti TEXT PRIMARY KEY,
|
||||
used_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_console_session_jtis_expires ON console_session_jtis(expires_at);
|
||||
`);
|
||||
|
||||
maybeAddCol('stack_update_recovery_generations', 'artifacts_retired', 'INTEGER NOT NULL DEFAULT 0');
|
||||
|
||||
// Distributed API model columns
|
||||
@@ -4462,6 +4487,21 @@ export class DatabaseService {
|
||||
this.db.prepare('DELETE FROM pilot_enrollments WHERE node_id = ?').run(nodeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time consume for a console_session JWT id. Inserts the jti on first
|
||||
* use; returns false when the jti was already recorded (replay).
|
||||
*/
|
||||
public consumeConsoleSessionJti(jti: string, expiresAtMs: number): boolean {
|
||||
const now = Date.now();
|
||||
return this.db.transaction(() => {
|
||||
this.db.prepare('DELETE FROM console_session_jtis WHERE expires_at < ?').run(now);
|
||||
const result = this.db.prepare(
|
||||
'INSERT OR IGNORE INTO console_session_jtis (jti, used_at, expires_at) VALUES (?, ?, ?)',
|
||||
).run(jti, now, expiresAtMs);
|
||||
return result.changes > 0;
|
||||
})();
|
||||
}
|
||||
|
||||
// --- Stack Update Status ---
|
||||
|
||||
/**
|
||||
@@ -5165,7 +5205,7 @@ export class DatabaseService {
|
||||
this.auditLogBuffer = [];
|
||||
if (!this.auditLogInsertStmt) {
|
||||
this.auditLogInsertStmt = this.db.prepare(
|
||||
'INSERT INTO audit_log (timestamp, username, method, path, status_code, node_id, ip_address, summary) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
'INSERT INTO audit_log (timestamp, username, method, path, status_code, node_id, ip_address, summary, acting_as) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
);
|
||||
}
|
||||
const stmt = this.auditLogInsertStmt;
|
||||
@@ -5180,6 +5220,7 @@ export class DatabaseService {
|
||||
entry.node_id,
|
||||
entry.ip_address,
|
||||
entry.summary,
|
||||
entry.acting_as ?? null,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -18,6 +18,8 @@ export interface ConsoleAuditContext {
|
||||
// path always supplies a concrete id.
|
||||
readonly nodeId: number | null;
|
||||
readonly ipAddress: string;
|
||||
/** Hub operator for remote console_session bridges; null for direct sessions. */
|
||||
readonly actingAs?: string | null;
|
||||
}
|
||||
|
||||
const CONSOLE_AUDIT_PATH = '/api/system/host-console';
|
||||
@@ -97,6 +99,7 @@ export class HostTerminalService {
|
||||
node_id: audit.nodeId,
|
||||
ip_address: audit.ipAddress,
|
||||
summary,
|
||||
acting_as: audit.actingAs ?? null,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[HostConsole] Failed to write session audit log:', err);
|
||||
|
||||
@@ -54,9 +54,10 @@ export function handleGenericWs(
|
||||
if (isProxyToken) return reject(socket, 403, 'Forbidden');
|
||||
|
||||
// Admin enforcement: container exec requires admin role.
|
||||
// console_session tokens are already admin-gated at creation time.
|
||||
// API tokens reaching this point have full-admin scope (read-only /
|
||||
// deploy-only are blocked by the upgrade handler's scope gate).
|
||||
// console_session tokens are already admin-gated at creation time and
|
||||
// path/jti-gated in upgradeHandler. API tokens reaching this point have
|
||||
// full-admin scope (read-only / deploy-only are blocked by the upgrade
|
||||
// handler's scope gate).
|
||||
if (!decoded.scope) {
|
||||
const execUser = decoded.username ? DatabaseService.getInstance().getUserByUsername(decoded.username) : undefined;
|
||||
if (!execUser) {
|
||||
|
||||
@@ -2,22 +2,16 @@ import type { IncomingMessage } from 'http';
|
||||
import type { Duplex } from 'stream';
|
||||
import WebSocket, { WebSocketServer } from 'ws';
|
||||
import { FileSystemService } from '../services/FileSystemService';
|
||||
import { NodeRegistry } from '../services/NodeRegistry';
|
||||
import { HostTerminalService } from '../services/HostTerminalService';
|
||||
import { PROXY_TIER_HEADER } from '../services/license-headers';
|
||||
import {
|
||||
isLicenseTier,
|
||||
normalizeTier,
|
||||
} from '../services/license-normalize';
|
||||
import { LicenseService } from '../services/LicenseService';
|
||||
import { ROLE_PERMISSIONS, type PermissionAction } from '../middleware/permissions';
|
||||
import { ROLE_PERMISSIONS } from '../middleware/permissions';
|
||||
import type { UserRole } from '../services/DatabaseService';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
import { rejectUpgrade as reject } from './reject';
|
||||
import { isConsoleSessionScope } from '../helpers/consoleSession';
|
||||
|
||||
interface HostConsoleContext {
|
||||
nodeId: number;
|
||||
decoded: { scope?: string; username?: string };
|
||||
decoded: { scope?: string; username?: string; acting_as?: string };
|
||||
isProxyToken: boolean;
|
||||
wsResolvedUser: { username: string; role: UserRole; token_version: number } | undefined;
|
||||
stackParam: string | null;
|
||||
@@ -26,15 +20,16 @@ interface HostConsoleContext {
|
||||
/**
|
||||
* Handle `/api/system/host-console` WebSocket upgrades.
|
||||
*
|
||||
* Enforces three gates before spawning the host PTY:
|
||||
* Enforces two gates before spawning the host PTY:
|
||||
* 1. Machine-credential rejection: node_proxy tokens cannot reach an
|
||||
* interactive host shell.
|
||||
* interactive host shell directly (remote forwarding mints a
|
||||
* console_session via POST /console-token instead).
|
||||
* 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 the paid tier. For console_session
|
||||
* tokens the tier is trusted from the gateway-supplied header;
|
||||
* otherwise the local LicenseService is consulted.
|
||||
*
|
||||
* Path scoping and one-time jti consumption are enforced in upgradeHandler
|
||||
* before this handler runs.
|
||||
*/
|
||||
export function handleHostConsoleWs(
|
||||
req: IncomingMessage,
|
||||
@@ -46,11 +41,10 @@ export function handleHostConsoleWs(
|
||||
|
||||
if (isProxyToken) return reject(socket, 403, 'Forbidden');
|
||||
|
||||
const isConsoleSession = decoded.scope === 'console_session';
|
||||
const isConsoleSession = isConsoleSessionScope(decoded.scope);
|
||||
if (!isConsoleSession) {
|
||||
const userRole = wsResolvedUser?.role;
|
||||
const consolePermission: PermissionAction = 'system:console';
|
||||
if (!userRole || !ROLE_PERMISSIONS[userRole]?.includes(consolePermission)) {
|
||||
if (!userRole || !ROLE_PERMISSIONS[userRole]?.includes('system:console')) {
|
||||
console.log('[HostConsole] Access denied: insufficient permissions', {
|
||||
username: wsResolvedUser?.username || decoded.username,
|
||||
role: userRole,
|
||||
@@ -59,18 +53,18 @@ export function handleHostConsoleWs(
|
||||
}
|
||||
}
|
||||
|
||||
const consoleTierHeader = req.headers[PROXY_TIER_HEADER] as string | undefined;
|
||||
const ls = LicenseService.getInstance();
|
||||
const consoleTier = (isConsoleSession && isLicenseTier(consoleTierHeader))
|
||||
? normalizeTier(consoleTierHeader)
|
||||
: ls.getTier();
|
||||
if (consoleTier !== 'paid') {
|
||||
return reject(socket, 403, 'Forbidden');
|
||||
}
|
||||
// Option B: console_session principal stays "console_session"; hub operator
|
||||
// is recorded separately as acting_as. Browser sessions use the real user.
|
||||
const consoleUsername = isConsoleSession
|
||||
? 'console_session'
|
||||
: (wsResolvedUser?.username || decoded.username || 'unknown');
|
||||
const actingAs = isConsoleSession && typeof decoded.acting_as === 'string' && decoded.acting_as
|
||||
? decoded.acting_as
|
||||
: null;
|
||||
|
||||
const consoleUsername = wsResolvedUser?.username || decoded.username || 'console_session';
|
||||
console.log('[HostConsole] WebSocket upgrade accepted', {
|
||||
username: consoleUsername,
|
||||
actingAs,
|
||||
nodeId,
|
||||
stack: stackParam || '(root)',
|
||||
});
|
||||
@@ -86,10 +80,6 @@ export function handleHostConsoleWs(
|
||||
hostConsoleWss.handleUpgrade(req, socket, head, (ws) => {
|
||||
hostConsoleWss.close();
|
||||
let targetDirectory: string;
|
||||
// The shell may end up rooted at a different node than requested if the
|
||||
// requested node's directory cannot be resolved; the audit row must name
|
||||
// the node the shell actually runs in, so track it alongside the directory.
|
||||
let auditNodeId: number = nodeId;
|
||||
try {
|
||||
const baseDir = FileSystemService.getInstance(nodeId).getBaseDir();
|
||||
const resolved = HostTerminalService.resolveConsoleDirectory(baseDir, stackParam);
|
||||
@@ -100,22 +90,28 @@ export function handleHostConsoleWs(
|
||||
}
|
||||
targetDirectory = resolved;
|
||||
} catch (error) {
|
||||
const fallbackNodeId = NodeRegistry.getInstance().getDefaultNodeId();
|
||||
console.error('[HostConsole] Failed to resolve console directory; falling back to the default node base dir', {
|
||||
console.error('[HostConsole] Failed to resolve console directory', {
|
||||
user: consoleUsername,
|
||||
actingAs,
|
||||
nodeId,
|
||||
fallbackNodeId,
|
||||
stack: stackParam || '(root)',
|
||||
error: getErrorMessage(error, 'unknown'),
|
||||
});
|
||||
targetDirectory = FileSystemService.getInstance(fallbackNodeId).getBaseDir();
|
||||
auditNodeId = fallbackNodeId;
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send('Error: Failed to resolve console directory.\r\n');
|
||||
ws.close();
|
||||
}
|
||||
return;
|
||||
}
|
||||
const auditCtx = { username: consoleUsername, nodeId: auditNodeId, ipAddress };
|
||||
const auditCtx = { username: consoleUsername, actingAs, nodeId, ipAddress };
|
||||
try {
|
||||
HostTerminalService.spawnTerminal(ws, targetDirectory, auditCtx);
|
||||
} catch (error) {
|
||||
console.error('[HostConsole] Unhandled spawn error:', { user: consoleUsername, error: getErrorMessage(error, 'unknown') });
|
||||
console.error('[HostConsole] Unhandled spawn error:', {
|
||||
user: consoleUsername,
|
||||
actingAs,
|
||||
error: getErrorMessage(error, 'unknown'),
|
||||
});
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send('Error: Failed to start terminal session.\r\n');
|
||||
ws.close();
|
||||
|
||||
@@ -5,6 +5,7 @@ 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
|
||||
@@ -23,9 +24,14 @@ export async function handleRemoteForwarder(
|
||||
req: IncomingMessage,
|
||||
socket: Duplex,
|
||||
head: Buffer,
|
||||
opts: { pathname: string; target: { apiUrl: string; apiToken: string } },
|
||||
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 } = opts;
|
||||
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');
|
||||
@@ -38,24 +44,33 @@ export async function handleRemoteForwarder(
|
||||
// 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 isInteractiveConsolePath = pathname === '/api/system/host-console' || pathname === '/ws';
|
||||
const sessionPath = consoleSessionPathForPathname(pathname);
|
||||
let bearerTokenForProxy = target.apiToken;
|
||||
if (isInteractiveConsolePath && !isPilotLoopback) {
|
||||
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') bearerTokenForProxy = data.token;
|
||||
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');
|
||||
|
||||
@@ -20,6 +20,14 @@ import { validateApiToken, touchApiTokenLastUsed } from '../utils/apiTokenAuth';
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
import { PROXY_TIER_HEADER } from '../services/license-headers';
|
||||
import { isLicenseTier, normalizeTier } from '../services/license-normalize';
|
||||
import { HOST_CONSOLE_COMMUNITY_CAPABILITY } from '../services/CapabilityRegistry';
|
||||
import { remoteAdvertisesCapability } from '../helpers/remoteCapabilities';
|
||||
|
||||
import {
|
||||
consoleSessionPathForPathname,
|
||||
consumeConsoleSessionJti,
|
||||
isConsoleSessionScope,
|
||||
} from '../helpers/consoleSession';
|
||||
|
||||
function parseCookies(req: IncomingMessage): Record<string, string> {
|
||||
const header = req.headers.cookie || '';
|
||||
@@ -31,6 +39,17 @@ function parseCookies(req: IncomingMessage): Record<string, string> {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse ?nodeId= as a strict positive integer. Returns null when the param is
|
||||
* absent (caller should use the default node). Returns undefined when the
|
||||
* param is present but malformed (caller should 404).
|
||||
*/
|
||||
function parseStrictNodeIdParam(raw: string | null): number | null | undefined {
|
||||
if (raw == null || raw === '') return null;
|
||||
if (!/^[1-9][0-9]*$/.test(raw)) return undefined;
|
||||
return Number(raw);
|
||||
}
|
||||
|
||||
// The two WebSocket paths open to any authenticated user (read-only/deploy-only
|
||||
// API tokens and non-admin sessions on a remote node). Defined once so the
|
||||
// scope gate and the remote-forward gate cannot drift apart.
|
||||
@@ -68,8 +87,15 @@ export function remoteWsForwardAllowed(
|
||||
if (ctx.isProxyToken) return false;
|
||||
// console_session is pre-gated as admin at issuance (routes/console.ts).
|
||||
if (ctx.decoded.scope === 'console_session') return true;
|
||||
// Reject opaque API tokens on remote Host Console forward (would mint
|
||||
// console_session and get a host shell). Local denial is enforced in
|
||||
// attachUpgrade before handleHostConsoleWs. Container exec (/ws) keeps
|
||||
// its existing full-admin API-token allow.
|
||||
if (pathname.startsWith('/api/system/host-console') && ctx.wsApiTokenScope) {
|
||||
return false;
|
||||
}
|
||||
// A read-only/deploy-only api_token is already rejected for these paths by
|
||||
// the scope gate; only full-admin survives to here.
|
||||
// the scope gate; only full-admin survives to here (container exec /ws).
|
||||
if (ctx.wsApiTokenScope) return ctx.wsApiTokenScope === 'full-admin';
|
||||
const role = ctx.wsResolvedUser?.role;
|
||||
if (!role) return false;
|
||||
@@ -136,7 +162,16 @@ export function attachUpgrade(
|
||||
try {
|
||||
// Opaque sen_sk_ API tokens: handled before jwt.verify. Prefix +
|
||||
// length + checksum reject malformed keys without touching SQLite.
|
||||
let decoded: { username?: string; scope?: string; role?: string; tv?: number };
|
||||
let decoded: {
|
||||
username?: string;
|
||||
scope?: string;
|
||||
role?: string;
|
||||
tv?: number;
|
||||
path?: string;
|
||||
acting_as?: string;
|
||||
jti?: string;
|
||||
exp?: number;
|
||||
};
|
||||
let wsApiTokenScope: string | null = null;
|
||||
if (looksLikeApiToken(token)) {
|
||||
const validation = validateApiToken(token);
|
||||
@@ -151,7 +186,7 @@ export function attachUpgrade(
|
||||
const settings = DatabaseService.getInstance().getGlobalSettings();
|
||||
const jwtSecret = settings.auth_jwt_secret;
|
||||
if (!jwtSecret) throw new Error('No JWT secret');
|
||||
decoded = jwt.verify(token, jwtSecret) as { username?: string; scope?: string; role?: string; tv?: number };
|
||||
decoded = jwt.verify(token, jwtSecret) as typeof decoded;
|
||||
}
|
||||
|
||||
// Node proxy tokens are machine-to-machine credentials and must never be
|
||||
@@ -180,6 +215,21 @@ export function attachUpgrade(
|
||||
const parsedUrl = new URL(req.url || '', `http://${req.headers.host || 'localhost'}`);
|
||||
const pathname = parsedUrl.pathname;
|
||||
|
||||
// Path-scoped, one-time console_session tokens for interactive surfaces.
|
||||
// Consume runs at upgrade acceptance (before PTY spawn); missing exp/jti fail closed.
|
||||
if (isConsoleSessionScope(decoded.scope)) {
|
||||
const requiredPath = consoleSessionPathForPathname(pathname);
|
||||
if (!requiredPath || decoded.path !== requiredPath) {
|
||||
return reject(socket, 403, 'Forbidden');
|
||||
}
|
||||
if (typeof decoded.exp !== 'number' || typeof decoded.jti !== 'string' || !decoded.jti) {
|
||||
return reject(socket, 401, 'Unauthorized');
|
||||
}
|
||||
if (!consumeConsoleSessionJti(decoded.jti, decoded.exp * 1000)) {
|
||||
return reject(socket, 401, 'Unauthorized');
|
||||
}
|
||||
}
|
||||
|
||||
// Gate WebSocket paths by API token scope
|
||||
if (wsApiTokenScope) {
|
||||
if (wsApiTokenScope === 'read-only' || wsApiTokenScope === 'deploy-only') {
|
||||
@@ -219,8 +269,11 @@ export function attachUpgrade(
|
||||
return;
|
||||
}
|
||||
|
||||
const nodeIdParam = parsedUrl.searchParams.get('nodeId');
|
||||
const nodeId = nodeIdParam ? parseInt(nodeIdParam, 10) : NodeRegistry.getInstance().getDefaultNodeId();
|
||||
const parsedNodeId = parseStrictNodeIdParam(parsedUrl.searchParams.get('nodeId'));
|
||||
if (parsedNodeId === undefined) {
|
||||
return reject(socket, 404, 'Not Found');
|
||||
}
|
||||
const nodeId = parsedNodeId ?? NodeRegistry.getInstance().getDefaultNodeId();
|
||||
const node = NodeRegistry.getInstance().getNode(nodeId);
|
||||
|
||||
// Notification push channel: local only when no remote nodeId is
|
||||
@@ -243,6 +296,23 @@ export function attachUpgrade(
|
||||
if (!remoteWsForwardAllowed(pathname, { wsResolvedUser, wsApiTokenScope, isProxyToken, decoded })) {
|
||||
return reject(socket, 403, 'Forbidden');
|
||||
}
|
||||
// Community hubs require host-console-community before forwarding Host
|
||||
// Console (marks remotes that no longer paid-gate the console path).
|
||||
// Admiral hubs skip the probe for legacy host-console peers. Fail
|
||||
// closed on probe errors; never fall through to local handlers for a
|
||||
// named remote.
|
||||
if (
|
||||
pathname.startsWith('/api/system/host-console')
|
||||
&& LicenseService.getInstance().getTier() !== 'paid'
|
||||
) {
|
||||
const communityCapable = await remoteAdvertisesCapability(
|
||||
nodeId,
|
||||
HOST_CONSOLE_COMMUNITY_CAPABILITY,
|
||||
);
|
||||
if (!communityCapable) {
|
||||
return reject(socket, 403, 'Forbidden');
|
||||
}
|
||||
}
|
||||
// Resolve the proxy target through NodeRegistry so pilot-mode nodes
|
||||
// (empty api_url + api_token, loopback bridge instead) and proxy-mode
|
||||
// nodes share one dispatch path. Mirrors proxy/remoteNodeProxy.ts.
|
||||
@@ -253,7 +323,11 @@ export function attachUpgrade(
|
||||
// serve gateway-local data for a request that named a remote node.
|
||||
return reject(socket, 503, 'Service Unavailable');
|
||||
}
|
||||
await handleRemoteForwarder(req, socket, head, { pathname, target });
|
||||
await handleRemoteForwarder(req, socket, head, {
|
||||
pathname,
|
||||
target,
|
||||
actingAs: wsResolvedUser?.username || decoded.username,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -264,6 +338,16 @@ export function attachUpgrade(
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/api/system/host-console')) {
|
||||
// Opaque API tokens never open a local host shell (parity with the
|
||||
// remote forward gate). Do not rely on missing wsResolvedUser alone.
|
||||
if (wsApiTokenScope) {
|
||||
return reject(socket, 403, 'Forbidden');
|
||||
}
|
||||
// Unknown / deleted nodeIds must not fall through to the hub compose
|
||||
// root via getComposeDir's env default.
|
||||
if (!node) {
|
||||
return reject(socket, 404, 'Not Found');
|
||||
}
|
||||
handleHostConsoleWs(req, socket, head, {
|
||||
nodeId,
|
||||
decoded,
|
||||
|
||||
Reference in New Issue
Block a user