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,
|
||||
|
||||
@@ -171,7 +171,7 @@ Authorises every read and every write the API exposes, **except** the universal
|
||||
| `/api/stacks/:stack/logs` (stack log stream) | yes | yes | yes |
|
||||
| `/ws/notifications` (notification stream) | yes | yes | yes |
|
||||
| `/ws` (generic exec / stats) | no | no | yes |
|
||||
| `/api/system/host-console` (host shell) | no | no | yes |
|
||||
| `/api/system/host-console` (host shell) | no | no | no |
|
||||
|
||||
A Read Only or Deploy Only token attempting an out-of-scope WebSocket upgrade is rejected with `403 Forbidden` before any frames flow.
|
||||
|
||||
@@ -268,7 +268,7 @@ API tokens are issued and revoked on the hub that authenticates the call. When y
|
||||
- **One owner per token.** Tokens are not shareable across users at the data model; only the creating user can revoke a token through the UI.
|
||||
- **Owner deletion breaks the token.** If the user who created a token is deleted, subsequent calls with that token return `401` because the auth path requires the creator to still exist. Rotate before deleting accounts.
|
||||
- **No token introspection endpoint.** There is no `GET /api/me` for an API token; client code must know its own scope.
|
||||
- **WebSocket scope restrictions.** Read Only and Deploy Only tokens cannot reach the generic `/ws` exec/stats endpoint or the host console. Full Admin can.
|
||||
- **WebSocket scope restrictions.** Read Only and Deploy Only tokens cannot reach the generic `/ws` exec/stats endpoint or the host console. Full Admin can reach `/ws` (container exec / stats) but not Host Console; Host Console always requires a signed-in browser session.
|
||||
|
||||
## Common workflows
|
||||
|
||||
|
||||
@@ -23,7 +23,8 @@ Sencho encodes the active node, the view you are on, and the deep state that vie
|
||||
| App Store | `/nodes/local/templates` | App Store on the active node |
|
||||
| Logs | `/nodes/local/logs` | Cross-fleet log aggregation |
|
||||
| Update | `/nodes/local/updates` | Fleet-wide update check |
|
||||
| Console | `/nodes/local/host-console` | Host Console, a limited-availability operator surface documented on its own page when enabled on an instance |
|
||||
| Console | `/nodes/local/host-console` | Host Console (admin role) |
|
||||
| Console in a stack | `/nodes/local/host-console/radarr` | Host Console rooted in the Radarr stack directory |
|
||||
| Audit | `/nodes/local/audit` | Audit history |
|
||||
| Settings section | `/nodes/local/settings/nodes` | Settings on the Nodes section |
|
||||
| Fleet tab | `/nodes/local/fleet/snapshots` | Fleet on the Snapshots tab (desktop) |
|
||||
@@ -86,7 +87,7 @@ On desktop, opening the stack list at `/nodes/local/stacks` canonicalizes to `/n
|
||||
|
||||
Settings follows a similar split, but only on a phone: `/nodes/local/settings` opens the section list, and `/nodes/local/settings/<section>` opens a section directly. On desktop, `/nodes/local/settings` always normalizes straight to `/nodes/local/settings/appearance`; there is no bare section-list state to land on.
|
||||
|
||||
Fleet, Resources, Security, App Store, Logs, Update, Audit, and Schedules keep the exact same URL between desktop and phone, each rendering a phone-optimized screen at that path. Console also keeps the same URL on a phone, reflowing the terminal rather than switching to a dedicated phone screen.
|
||||
Fleet, Resources, Security, App Store, Logs, Update, Audit, and Schedules keep the exact same URL between desktop and phone, each rendering a phone-optimized screen at that path. Console also keeps the same URL on a phone (`/nodes/<node>/host-console`), but Host Console is a desktop-oriented terminal: open it on a wider screen for a full interactive session.
|
||||
|
||||
On a phone, `/nodes/local/stacks/<stack>/files` opens the compose editor instead. Sencho does not expose a separate file-browser URL on a phone.
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ The palette groups results into three sections.
|
||||
|
||||
| Group | What it contains | What happens when you pick one |
|
||||
|-------|------------------|--------------------------------|
|
||||
| **Pages** | The reachable page destinations for your tier and role (the same set Classic / Smart / mobile navigation use). **Home**, **Resources**, **Networking**, **Security**, and **App Store** appear for signed-in operators; **Fleet** appears when your role holds the `node:read` permission; **Logs**, **Update**, and **Schedules** appear for admins; **Console** is a limited-availability operator surface shown when enabled on an instance; **Audit** appears on Admiral for any role with the `system:audit` permission. See [RBAC & User Management](/features/rbac) for the full permission matrix. | Navigates to that page |
|
||||
| **Pages** | The reachable page destinations for your tier and role (the same set Classic / Smart / mobile navigation use). **Home**, **Resources**, **Networking**, **Security**, and **App Store** appear for signed-in operators; **Fleet** appears when your role holds the `node:read` permission; **Logs**, **Update**, **Schedules**, and **Console** appear for admins; **Audit** appears on Admiral for any role with the `system:audit` permission. See [RBAC & User Management](/features/rbac) for the full permission matrix. | Navigates to that page |
|
||||
| **Nodes** | Every node in your fleet, with a green dot for online and a grey dot for offline. The currently active node carries a small **ACTIVE** chip on the right. | Switches the active node without leaving the current page |
|
||||
| **Stacks** | Every compose stack on every online node, matched on the compose filename (extension included). | Switches to the stack's node and opens it in the editor |
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ description: An interactive terminal on your host OS, directly in the browser -
|
||||
The **Console** tab opens a full interactive terminal session on the machine running Sencho. It behaves exactly like an SSH session, but without needing an SSH server, client, or key management.
|
||||
|
||||
<Note>
|
||||
The Host Console is a limited-availability operator surface. When it is present on an instance, it requires the **admin** role. [Learn more about licensing](/features/licensing).
|
||||
Host Console requires the **admin** role. [Learn more about roles](/features/rbac).
|
||||
</Note>
|
||||
|
||||
<Frame>
|
||||
@@ -27,6 +27,14 @@ The Host Console gives you a real terminal session on the Sencho host, streamed
|
||||
|
||||
Click **Console** in the top navigation bar. The session starts immediately in the `COMPOSE_DIR` root. If a stack is selected in the sidebar, the terminal opens directly inside that stack's directory instead, and a small back button appears in the masthead so you can return to the stack editor.
|
||||
|
||||
You can also open Console from a bookmark or shared link. Every Console view has a stable URL under [Deep links and URLs](/features/deep-links):
|
||||
|
||||
- `/nodes/local/host-console` opens Host Console on the local node at the compose root
|
||||
- `/nodes/local/host-console/radarr` opens the same terminal rooted in the Radarr stack directory
|
||||
- Remote nodes use their node slug the same way (for example `/nodes/nas-box-42/host-console`)
|
||||
|
||||
Console requires the **admin** role. The URL stays in the address bar on a phone, but Host Console is meant for a desktop browser.
|
||||
|
||||
## Cockpit layout
|
||||
|
||||
The Console page is a vertical stack of two surfaces.
|
||||
@@ -74,9 +82,9 @@ Environment variables whose names suggest secrets (passwords, tokens, keys, cred
|
||||
|
||||
The Host Console is one of the most powerful features in Sencho and is treated as such:
|
||||
|
||||
- **Admin role required when present.** The Host Console is a limited-availability surface; only users with the **admin** role can open a console session.
|
||||
- **Admin role required.** Only users with the **admin** role can open a console session.
|
||||
- **Browser sessions only.** Console sessions are only available from a signed-in browser session, not from API tokens.
|
||||
- **Audited.** Every console session is recorded in the audit log. Opening and closing a session each write an entry capturing the user, node, client IP, and timestamp, so shell access is fully accountable.
|
||||
- **Audited.** Every console session is recorded in the audit log. Opening and closing a session each write an entry with the session principal, node, client IP, and timestamp. When the session is opened through a remote hub bridge, the principal is `console_session` and the hub operator is recorded in `acting_as`, so remote shell access stays accountable to the signed-in admin.
|
||||
|
||||
<Warning>
|
||||
The Host Console provides unrestricted shell access to the machine running Sencho. Do not expose Sencho on a public network without HTTPS and strong authentication.
|
||||
@@ -97,7 +105,7 @@ The Host Console is one of the most powerful features in Sencho and is treated a
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Session ends immediately after connecting">
|
||||
This typically means the shell could not be found on the host. In a Docker deployment, ensure `bash` or `sh` is available inside the container. You can check by running `docker exec <container> which bash` from the host. Also verify that your user has the **admin** role and that Console is available on this instance.
|
||||
This typically means the shell could not be found on the host. In a Docker deployment, ensure `bash` or `sh` is available inside the container. You can check by running `docker exec <container> which bash` from the host. Also verify that your user has the **admin** role.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Connection drops after a short time">
|
||||
@@ -105,6 +113,6 @@ The Host Console is one of the most powerful features in Sencho and is treated a
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Console tab is not visible">
|
||||
Console appears only when the surface is present on the instance, and only for users with the **admin** role. Verify those conditions are met, then refresh the page.
|
||||
Console appears for users with the **admin** role. If you are an admin and still do not see it, refresh the page. On a remote node, the Console tab may show a lock card when that node does not support Host Console (for example a Pilot Agent node, or a Distributed API Proxy node that does not advertise Host Console).
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
@@ -236,7 +236,7 @@ Bearer tokens grant full control over the remote Sencho instance. Treat them lik
|
||||
|
||||
- **Rotate immediately** if a token is compromised: open the remote instance's **Settings → Infrastructure → Nodes** and click **Generate Token** to mint a new one. The previous token is invalidated instantly.
|
||||
- Tokens are **encrypted at rest** in the local SQLite database.
|
||||
- Tokens cannot be used to open interactive terminals (Host Console or container exec). Interactive shell access always requires a real browser session on that specific instance.
|
||||
- Tokens cannot be used to open interactive terminals (Host Console or container exec). Interactive shell access requires a signed-in browser session. From the control plane you can open Host Console on a compatible Distributed API Proxy remote; Pilot Agent remotes do not offer Host Console.
|
||||
|
||||
### Transport encryption
|
||||
|
||||
|
||||
@@ -67,7 +67,8 @@ Every Sencho release ships with a static list of capabilities. The current list
|
||||
| `notifications` | Alert notifications |
|
||||
| `notification-routing` | Notification routing rules |
|
||||
| `notification-suppression` | Mute rules |
|
||||
| `host-console` | Host Console |
|
||||
| `host-console` | Host Console (legacy advertisement retained for mixed-version fleets) |
|
||||
| `host-console-community` | Host Console without a paid-license requirement on this node |
|
||||
| `container-exec` | Container exec terminal |
|
||||
| `audit-log` | Audit log |
|
||||
| `scheduled-ops` | Scheduled operations |
|
||||
@@ -94,7 +95,7 @@ Every Sencho release ships with a static list of capabilities. The current list
|
||||
</Note>
|
||||
|
||||
<Note>
|
||||
A node connected in [Pilot Agent](/features/multi-node#add-a-remote-node-pilot-agent) mode never advertises `host-console`, even after upgrading, because that feature's control-to-agent path is not yet wired through the enrollment tunnel. This is the one capability gap that upgrading the node cannot close; it only closes on a Distributed API Proxy connection.
|
||||
A node connected in [Pilot Agent](/features/multi-node#add-a-remote-node-pilot-agent) mode never advertises `host-console` or `host-console-community`, even after upgrading, because that feature's control-to-agent path is not yet wired through the enrollment tunnel. This is the one capability gap that upgrading the node cannot close; it only closes on a Distributed API Proxy connection.
|
||||
</Note>
|
||||
|
||||
A handful of capabilities gate a smaller piece of behavior rather than a whole panel, so a missing one falls back to an older behavior instead of a lock card:
|
||||
|
||||
@@ -210,7 +210,7 @@ When you manage nodes running different Sencho versions, the dashboard detects e
|
||||
|
||||
### Host console
|
||||
|
||||
Open an interactive terminal on the host OS directly in the browser with full xterm.js emulation and color support. No SSH client required. Limited-availability surface when present; admin role required. [Learn more →](/features/host-console)
|
||||
Open an interactive terminal on the host OS directly in the browser with full xterm.js emulation and color support. No SSH client required. Admin role required. [Learn more →](/features/host-console)
|
||||
|
||||
## Security and access
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ The **Home** view is the default landing page. It is designed for a fast operati
|
||||
- The activity panel shows **Fleet Heartbeat** when remote nodes exist, or **Stack Restarts (7d)** on a local-only install.
|
||||
- **Recent Alerts** shows the latest notification feed and includes **Clear All Notifications** when there is anything to clear.
|
||||
|
||||
The top navigation starts with **Home**, **Resources**, **Networking**, **Security**, and **App Store**. **Fleet** appears when your role can read nodes. Additional operator views (**Logs**, **Update**, **Schedules**, and **Audit**) appear based on your role and license tier. **Console** is a limited-availability operator surface documented on its own page when enabled on an instance. Fleet-wide views describe the control instance, so they are hidden while a remote node is active. Choose Classic, Smart, or Compact desktop navigation under **Settings → Appearance → Navigation**; phone navigation stays on its own layout.
|
||||
The top navigation starts with **Home**, **Resources**, **Networking**, **Security**, and **App Store**. **Fleet** appears when your role can read nodes. Additional operator views (**Logs**, **Update**, **Schedules**, and **Console**) appear for admins. **Audit** appears based on your role and license tier. Fleet-wide views describe the control instance, so they are hidden while a remote node is active. Choose Classic, Smart, or Compact desktop navigation under **Settings → Appearance → Navigation**; phone navigation stays on its own layout.
|
||||
|
||||
## Stack workspace
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@ You land on **Home**, the default operational view. The health masthead reports
|
||||
|
||||
Below the stack table, **Configuration Status** summarizes notifications, alerts, automation, security, backups, thresholds, and crash detection. The neighboring activity card shows **Fleet Heartbeat** when remote nodes exist, or **Stack Restarts (7d)** on a local-only install. **Recent Alerts** shows the latest notification feed and includes **Clear All Notifications** when there is anything to clear.
|
||||
|
||||
On the local node, baseline top navigation includes **Home**, **Resources**, **Networking**, **Security**, and **App Store**. **Fleet** appears when your role can read nodes. **Logs**, **Update**, and **Schedules** appear for admins. **Console** and **Audit** depend on license, role, and whether those surfaces are enabled; hub-only views are hidden when a remote node is active. Desktop presentation (Classic bar, Smart bar, or Compact launcher) is chosen under **Settings → Appearance → Navigation**. The right side of the top bar holds global search, notifications, and the profile menu entries **Settings**, **Billing** (when a paid license is active), **Documentation**, **Open New Issue**, and **Log Out**.
|
||||
On the local node, baseline top navigation includes **Home**, **Resources**, **Networking**, **Security**, and **App Store**. **Fleet** appears when your role can read nodes. **Logs**, **Update**, **Schedules**, and **Console** appear for admins. **Audit** depends on license and role; hub-only views are hidden when a remote node is active. Desktop presentation (Classic bar, Smart bar, or Compact launcher) is chosen under **Settings → Appearance → Navigation**. The right side of the top bar holds global search, notifications, and the profile menu entries **Settings**, **Billing** (when a paid license is active), **Documentation**, **Open New Issue**, and **Log Out**.
|
||||
|
||||
The left sidebar is the stack workspace. Below the Sencho brand, it starts with the node switcher, then **Create Stack**, a bulk-mode toggle, and **Scan stacks folder** for re-indexing compose projects added outside Sencho. Use **Search stacks...** with the **All**, **Up**, **Down**, and **Updates** chips to narrow the list. On a fresh install with an empty stack list, Sencho scans your mounted compose directory automatically and shows what it found, including compose files that still need to be adopted into their own subfolder. Once stacks carry Docker Compose labels, the list groups them under those labels, with pinned stacks always floating to the top and unlabeled stacks collected at the bottom.
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ interface AuditEntry {
|
||||
node_id: number | null;
|
||||
ip_address: string;
|
||||
summary: string;
|
||||
acting_as?: string | null;
|
||||
flags?: AnomalyFlag[];
|
||||
}
|
||||
|
||||
@@ -464,6 +465,11 @@ export function AuditLogView({ headerActions }: AuditLogViewProps = {}) {
|
||||
</TableCell>
|
||||
<TableCell className="font-medium text-sm">
|
||||
{entry.username}
|
||||
{entry.acting_as ? (
|
||||
<span className="block text-xs text-muted-foreground font-normal">
|
||||
acting as {entry.acting_as}
|
||||
</span>
|
||||
) : null}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={methodBadgeVariant(entry.method)} className="text-xs font-mono">
|
||||
@@ -492,6 +498,10 @@ export function AuditLogView({ headerActions }: AuditLogViewProps = {}) {
|
||||
<span className="text-muted-foreground text-xs block">IP Address</span>
|
||||
<span className="font-mono text-xs tabular-nums">{entry.ip_address || '-'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground text-xs block">Acting as</span>
|
||||
<span className="font-mono text-xs">{entry.acting_as || '-'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground text-xs block">Node ID</span>
|
||||
<span className="font-mono text-xs tabular-nums">{entry.node_id ?? 'Local'}</span>
|
||||
@@ -673,6 +683,9 @@ function StreamRow({ entry, now }: StreamRowProps) {
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm leading-snug">
|
||||
<span className="font-semibold">{entry.username || 'system'}</span>
|
||||
{entry.acting_as ? (
|
||||
<span className="text-muted-foreground"> (acting as {entry.acting_as})</span>
|
||||
) : null}
|
||||
<span className="text-muted-foreground"> {verb.toLowerCase()} </span>
|
||||
<span className="font-semibold">{target || entry.path}</span>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { Suspense, lazy, type ReactNode } from 'react';
|
||||
import { Unplug } from 'lucide-react';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useExperimental } from '@/hooks/useExperimental';
|
||||
import { PaidGate } from '../PaidGate';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { resolveHostConsoleCapability } from '@/lib/routing/hostConsoleCapability';
|
||||
import { LockCard } from '../ui/LockCard';
|
||||
import { CapabilityGate } from '../CapabilityGate';
|
||||
import { HubOnlyGate } from '../HubOnlyGate';
|
||||
import LazyBoundary from '../LazyBoundary';
|
||||
@@ -17,7 +20,7 @@ import type { MuteRuleDraft } from '@/lib/muteRules';
|
||||
import type { ActiveView } from './hooks/useViewNavigationState';
|
||||
import type { StackUpdateInfo } from '@/types/imageUpdates';
|
||||
import type { SecurityTab, FleetTab } from '@/lib/events';
|
||||
import { isStackEditorDeepLink } from '@/lib/router/readUrlRouteState';
|
||||
import { isStackEditorDeepLink, isHostConsoleStackDeepLink } from '@/lib/router/readUrlRouteState';
|
||||
import type { NavDestination } from '@/lib/navigation/appNavRegistry';
|
||||
|
||||
// Paid-tier views are loaded on demand. Their internal PaidGate /
|
||||
@@ -147,7 +150,8 @@ export function ViewRouter({
|
||||
quickLinkCandidates,
|
||||
}: ViewRouterProps): ReactNode {
|
||||
const { can } = useAuth();
|
||||
const { experimental, experimentalReady } = useExperimental();
|
||||
const { isPaid, licenseReady } = useLicense();
|
||||
const { activeNode, activeNodeMeta } = useNodes();
|
||||
if (activeView === 'settings') {
|
||||
return (
|
||||
<SettingsPage
|
||||
@@ -184,20 +188,48 @@ export function ViewRouter({
|
||||
);
|
||||
}
|
||||
if (activeView === 'host-console') {
|
||||
// Discovery + paid/RBAC: hide until experimental discovery is on,
|
||||
// then mirror backend gates (system:console admin-only + PaidGate +
|
||||
// capability). Nav is already gated the same way; this stops a
|
||||
// deep link from mounting a console the operator cannot use.
|
||||
if (!experimentalReady || !experimental) return null;
|
||||
// RBAC + mixed-version capability. Wait for a resolved active node and
|
||||
// remote meta; null activeNode must not be treated as local (wrong-node
|
||||
// or doomed WebSocket). Stack deep links hydrate selectedFile async:
|
||||
// wait so we never open a compose-root shell, then reconnect into the stack.
|
||||
if (!can('system:console')) return null;
|
||||
if (urlHydratingStack != null || (isHostConsoleStackDeepLink() && !selectedFile)) {
|
||||
return <ViewSkeleton />;
|
||||
}
|
||||
if (activeNode == null) return <ViewSkeleton />;
|
||||
const capState = resolveHostConsoleCapability({
|
||||
nodeResolved: true,
|
||||
isRemote: activeNode.type === 'remote',
|
||||
isPaid,
|
||||
licenseReady,
|
||||
activeNodeMeta,
|
||||
});
|
||||
if (capState === 'loading') return <ViewSkeleton />;
|
||||
if (capState === 'locked') {
|
||||
const nodeName = activeNode.name;
|
||||
const version = activeNodeMeta?.version;
|
||||
let versionHint = `${nodeName} does not advertise this capability.`;
|
||||
if (version && version !== 'unknown' && version !== '0.0.0-dev') {
|
||||
versionHint = `${nodeName} is running v${version}.`;
|
||||
}
|
||||
return (
|
||||
<LockCard
|
||||
icon={Unplug}
|
||||
title="Host Console is not available on this node"
|
||||
body={`${versionHint} Upgrade the node to use this feature.`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const nodeId = activeNode.id;
|
||||
return (
|
||||
<PaidGate>
|
||||
<CapabilityGate capability="host-console" featureName="Host Console">
|
||||
<LazyView>
|
||||
<HostConsole stackName={selectedFile} onClose={onHostConsoleClose} />
|
||||
</LazyView>
|
||||
</CapabilityGate>
|
||||
</PaidGate>
|
||||
<LazyView>
|
||||
<HostConsole
|
||||
key={`${nodeId}:${selectedFile ?? ''}`}
|
||||
nodeId={nodeId}
|
||||
stackName={selectedFile}
|
||||
onClose={onHostConsoleClose}
|
||||
/>
|
||||
</LazyView>
|
||||
);
|
||||
}
|
||||
// Stack workspace: keep a loading shell while the stack URL hydrates.
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import type { ReactNode } from 'react';
|
||||
import * as AuthContext from '@/context/AuthContext';
|
||||
import * as LicenseContext from '@/context/LicenseContext';
|
||||
import * as NodeContext from '@/context/NodeContext';
|
||||
import { ViewRouter } from '../ViewRouter';
|
||||
|
||||
vi.mock('@/context/AuthContext');
|
||||
vi.mock('@/context/LicenseContext');
|
||||
vi.mock('@/context/NodeContext');
|
||||
|
||||
vi.mock('../../HostConsole', () => ({
|
||||
default: ({ nodeId, stackName }: { nodeId: number; stackName?: string | null }) => (
|
||||
<div
|
||||
data-testid="host-console"
|
||||
data-node-id={String(nodeId)}
|
||||
data-stack={stackName ?? ''}
|
||||
>
|
||||
Host Console
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../../LazyBoundary', () => ({
|
||||
default: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
const baseProps = {
|
||||
activeView: 'host-console' as const,
|
||||
selectedFile: null as string | null,
|
||||
isLoading: false,
|
||||
settingsSection: 'appearance' as const,
|
||||
onSettingsSectionChange: vi.fn(),
|
||||
onTemplateDeploySuccess: vi.fn(),
|
||||
onHostConsoleClose: vi.fn(),
|
||||
onFleetNavigateToNode: vi.fn(),
|
||||
onOpenNodeNetworking: vi.fn(),
|
||||
filterNodeId: null,
|
||||
onClearScheduledOpsFilter: vi.fn(),
|
||||
schedulePrefill: null,
|
||||
onPrefillConsumed: vi.fn(),
|
||||
muteRulePrefill: null,
|
||||
onMutePrefillConsumed: vi.fn(),
|
||||
notifications: [] as [],
|
||||
onNavigateToStack: vi.fn(),
|
||||
onOpenSettingsSection: vi.fn(),
|
||||
onClearNotifications: vi.fn(),
|
||||
securityTab: 'overview' as const,
|
||||
onSecurityTabChange: vi.fn(),
|
||||
renderEditor: () => null,
|
||||
stackUpdates: {},
|
||||
urlHydratingStack: null as string | null,
|
||||
isFileLoading: false,
|
||||
quickLinkCandidates: [],
|
||||
};
|
||||
|
||||
describe('ViewRouter host-console', () => {
|
||||
beforeEach(() => {
|
||||
window.history.replaceState({}, '', '/nodes/local/host-console');
|
||||
vi.mocked(AuthContext.useAuth).mockReturnValue({
|
||||
can: (p: string) => p === 'system:console',
|
||||
} as unknown as ReturnType<typeof AuthContext.useAuth>);
|
||||
vi.mocked(LicenseContext.useLicense).mockReturnValue({
|
||||
isPaid: false,
|
||||
licenseReady: true,
|
||||
} as unknown as ReturnType<typeof LicenseContext.useLicense>);
|
||||
vi.mocked(NodeContext.useNodes).mockReturnValue({
|
||||
activeNode: { id: 1, name: 'Local', type: 'local' },
|
||||
activeNodeMeta: { version: '0.96.0', capabilities: ['host-console', 'host-console-community'], fetchedAt: 1 },
|
||||
} as unknown as ReturnType<typeof NodeContext.useNodes>);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.history.replaceState({}, '', '/');
|
||||
});
|
||||
|
||||
it('renders Host Console for a Community admin on the local node', async () => {
|
||||
render(<ViewRouter {...baseProps} />);
|
||||
const el = await screen.findByTestId('host-console');
|
||||
expect(el.getAttribute('data-node-id')).toBe('1');
|
||||
});
|
||||
|
||||
it('does not mount HostConsole while the active node is unresolved', () => {
|
||||
vi.mocked(NodeContext.useNodes).mockReturnValue({
|
||||
activeNode: null,
|
||||
activeNodeMeta: null,
|
||||
} as unknown as ReturnType<typeof NodeContext.useNodes>);
|
||||
render(<ViewRouter {...baseProps} />);
|
||||
expect(screen.queryByTestId('host-console')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not mount HostConsole while a stack deep link is still hydrating', () => {
|
||||
render(<ViewRouter {...baseProps} urlHydratingStack="radarr" selectedFile={null} />);
|
||||
expect(screen.queryByTestId('host-console')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not mount a root shell while the URL targets a stack-scoped Console', () => {
|
||||
window.history.replaceState({}, '', '/nodes/local/host-console/radarr');
|
||||
render(<ViewRouter {...baseProps} selectedFile={null} urlHydratingStack={null} />);
|
||||
expect(screen.queryByTestId('host-console')).toBeNull();
|
||||
});
|
||||
|
||||
it('mounts stack-scoped Console only after selectedFile matches the route', async () => {
|
||||
window.history.replaceState({}, '', '/nodes/local/host-console/radarr');
|
||||
render(<ViewRouter {...baseProps} selectedFile="radarr" />);
|
||||
const el = await screen.findByTestId('host-console');
|
||||
expect(el.getAttribute('data-stack')).toBe('radarr');
|
||||
expect(el.getAttribute('data-node-id')).toBe('1');
|
||||
});
|
||||
|
||||
it('renders nothing without system:console', () => {
|
||||
vi.mocked(AuthContext.useAuth).mockReturnValue({
|
||||
can: () => false,
|
||||
} as unknown as ReturnType<typeof AuthContext.useAuth>);
|
||||
const { container } = render(<ViewRouter {...baseProps} />);
|
||||
expect(container.querySelector('[data-testid="host-console"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows a skeleton while remote metadata is loading (does not mount HostConsole)', () => {
|
||||
vi.mocked(NodeContext.useNodes).mockReturnValue({
|
||||
activeNode: { id: 2, name: 'Legacy', type: 'remote' },
|
||||
activeNodeMeta: null,
|
||||
} as unknown as ReturnType<typeof NodeContext.useNodes>);
|
||||
render(<ViewRouter {...baseProps} />);
|
||||
expect(screen.queryByTestId('host-console')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows a lock card for Community + legacy remote without mounting HostConsole', () => {
|
||||
vi.mocked(NodeContext.useNodes).mockReturnValue({
|
||||
activeNode: { id: 2, name: 'Legacy', type: 'remote' },
|
||||
activeNodeMeta: { version: '0.95.0', capabilities: ['host-console'], fetchedAt: 1 },
|
||||
} as unknown as ReturnType<typeof NodeContext.useNodes>);
|
||||
render(<ViewRouter {...baseProps} />);
|
||||
expect(screen.queryByTestId('host-console')).toBeNull();
|
||||
expect(screen.getByText(/Host Console is not available on this node/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('mounts Host Console for Admiral + legacy remote after meta resolves', async () => {
|
||||
vi.mocked(LicenseContext.useLicense).mockReturnValue({
|
||||
isPaid: true,
|
||||
licenseReady: true,
|
||||
} as unknown as ReturnType<typeof LicenseContext.useLicense>);
|
||||
vi.mocked(NodeContext.useNodes).mockReturnValue({
|
||||
activeNode: { id: 2, name: 'Legacy', type: 'remote' },
|
||||
activeNodeMeta: { version: '0.95.0', capabilities: ['host-console'], fetchedAt: 1 },
|
||||
} as unknown as ReturnType<typeof NodeContext.useNodes>);
|
||||
render(<ViewRouter {...baseProps} />);
|
||||
const el = await screen.findByTestId('host-console');
|
||||
expect(el.getAttribute('data-node-id')).toBe('2');
|
||||
});
|
||||
|
||||
it('shows a skeleton for legacy-only remote while license is still loading', () => {
|
||||
vi.mocked(LicenseContext.useLicense).mockReturnValue({
|
||||
isPaid: false,
|
||||
licenseReady: false,
|
||||
} as unknown as ReturnType<typeof LicenseContext.useLicense>);
|
||||
vi.mocked(NodeContext.useNodes).mockReturnValue({
|
||||
activeNode: { id: 2, name: 'Legacy', type: 'remote' },
|
||||
activeNodeMeta: { version: '0.95.0', capabilities: ['host-console'], fetchedAt: 1 },
|
||||
} as unknown as ReturnType<typeof NodeContext.useNodes>);
|
||||
render(<ViewRouter {...baseProps} />);
|
||||
expect(screen.queryByTestId('host-console')).toBeNull();
|
||||
expect(screen.queryByText(/Host Console is not available on this node/i)).toBeNull();
|
||||
});
|
||||
|
||||
it('mounts Host Console for Community + host-console-community remote', async () => {
|
||||
vi.mocked(NodeContext.useNodes).mockReturnValue({
|
||||
activeNode: { id: 3, name: 'NewPeer', type: 'remote' },
|
||||
activeNodeMeta: {
|
||||
version: '0.96.0',
|
||||
capabilities: ['host-console', 'host-console-community'],
|
||||
fetchedAt: 1,
|
||||
},
|
||||
} as unknown as ReturnType<typeof NodeContext.useNodes>);
|
||||
render(<ViewRouter {...baseProps} />);
|
||||
expect(await screen.findByTestId('host-console')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -51,7 +51,7 @@ function mockDeployer() {
|
||||
function mockPaidAdmin() {
|
||||
vi.mocked(AuthContext.useAuth).mockReturnValue({
|
||||
isAdmin: true,
|
||||
can: (p: string) => p === 'system:audit' || p === 'node:read',
|
||||
can: (p: string) => p === 'system:audit' || p === 'system:console' || p === 'node:read',
|
||||
permissionsStatus: 'ready',
|
||||
} as unknown as ReturnType<typeof AuthContext.useAuth>);
|
||||
vi.mocked(LicenseContext.useLicense).mockReturnValue({
|
||||
@@ -63,7 +63,7 @@ function mockPaidAdmin() {
|
||||
function mockCommunityAdmin() {
|
||||
vi.mocked(AuthContext.useAuth).mockReturnValue({
|
||||
isAdmin: true,
|
||||
can: (p: string) => p === 'node:read',
|
||||
can: (p: string) => p === 'system:console' || p === 'node:read',
|
||||
permissionsStatus: 'ready',
|
||||
} as unknown as ReturnType<typeof AuthContext.useAuth>);
|
||||
vi.mocked(LicenseContext.useLicense).mockReturnValue({
|
||||
@@ -269,13 +269,13 @@ describe('useViewNavigationState', () => {
|
||||
expect(result.current.navItems.map(i => i.value)).toContain('global-observability');
|
||||
});
|
||||
|
||||
it('shows Update and Schedules for a community admin (now free) but hides paid Console and Audit', () => {
|
||||
it('shows Update, Schedules, and Console for a community admin; Audit stays paid', () => {
|
||||
mockCommunityAdmin();
|
||||
const { result } = renderHook(() => useViewNavigationState());
|
||||
const values = result.current.navItems.map(i => i.value);
|
||||
expect(values).toContain('auto-updates');
|
||||
expect(values).toContain('scheduled-ops');
|
||||
expect(values).not.toContain('host-console');
|
||||
expect(values).toContain('host-console');
|
||||
expect(values).not.toContain('audit-log');
|
||||
// The auto-updates nav item surfaces under the short label "Update".
|
||||
expect(result.current.navItems.find(i => i.value === 'auto-updates')?.label).toBe('Update');
|
||||
@@ -429,53 +429,29 @@ describe('useViewNavigationState', () => {
|
||||
expect(result.current.securityTab).toBe('overview');
|
||||
});
|
||||
|
||||
// ── experimental discovery ─────────────────────────────────────────────────
|
||||
// ── host-console discovery (no longer experimental) ────────────────────────
|
||||
|
||||
it('hides Console from nav for a paid admin when experimental discovery is off', () => {
|
||||
it('keeps Console in nav for a paid admin when experimental discovery is off', () => {
|
||||
mockPaidAdmin();
|
||||
useExperimentalMock.mockReturnValue({ experimental: false, experimentalReady: true });
|
||||
const { result } = renderHook(() => useViewNavigationState());
|
||||
expect(result.current.navItems.map(i => i.value)).not.toContain('host-console');
|
||||
expect(result.current.navItems.map(i => i.value)).toContain('host-console');
|
||||
});
|
||||
|
||||
it('hides Console from nav while experimental metadata is still loading', () => {
|
||||
it('keeps Console in nav while experimental metadata is still loading', () => {
|
||||
mockPaidAdmin();
|
||||
useExperimentalMock.mockReturnValue({ experimental: false, experimentalReady: false });
|
||||
const { result } = renderHook(() => useViewNavigationState());
|
||||
expect(result.current.navItems.map(i => i.value)).not.toContain('host-console');
|
||||
expect(result.current.navItems.map(i => i.value)).toContain('host-console');
|
||||
});
|
||||
|
||||
it('does not normalize a host-console deep link before experimental readiness', () => {
|
||||
it('keeps a host-console deep link selected when experimental is off', () => {
|
||||
mockPaidAdmin();
|
||||
useExperimentalMock.mockReturnValue({ experimental: false, experimentalReady: false });
|
||||
useExperimentalMock.mockReturnValue({ experimental: false, experimentalReady: true });
|
||||
const onNavigateToDashboard = vi.fn();
|
||||
const { result } = renderHook(() => useViewNavigationState({ onNavigateToDashboard }));
|
||||
act(() => result.current.setActiveView('host-console'));
|
||||
expect(result.current.activeView).toBe('host-console');
|
||||
expect(onNavigateToDashboard).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps host-console selected when delayed experimental resolves enabled', () => {
|
||||
mockPaidAdmin();
|
||||
useExperimentalMock.mockReturnValue({ experimental: false, experimentalReady: false });
|
||||
const onNavigateToDashboard = vi.fn();
|
||||
const { result, rerender } = renderHook(() => useViewNavigationState({ onNavigateToDashboard }));
|
||||
act(() => result.current.setActiveView('host-console'));
|
||||
useExperimentalMock.mockReturnValue({ experimental: true, experimentalReady: true });
|
||||
rerender();
|
||||
expect(result.current.activeView).toBe('host-console');
|
||||
expect(onNavigateToDashboard).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('normalizes host-console once when experimental resolves disabled', () => {
|
||||
mockPaidAdmin();
|
||||
useExperimentalMock.mockReturnValue({ experimental: false, experimentalReady: false });
|
||||
const onNavigateToDashboard = vi.fn();
|
||||
const { result, rerender } = renderHook(() => useViewNavigationState({ onNavigateToDashboard }));
|
||||
act(() => result.current.setActiveView('host-console'));
|
||||
useExperimentalMock.mockReturnValue({ experimental: false, experimentalReady: true });
|
||||
rerender();
|
||||
expect(result.current.activeView).toBe('dashboard');
|
||||
expect(onNavigateToDashboard).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,8 @@ import { useNodes } from '@/context/NodeContext';
|
||||
import { copyToClipboard } from '@/lib/clipboard';
|
||||
|
||||
interface HostConsoleProps {
|
||||
/** Resolved active node id; WebSocket must target this id, not localStorage. */
|
||||
nodeId: number;
|
||||
stackName?: string | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
@@ -27,7 +29,7 @@ function formatUptime(ms: number): string {
|
||||
|
||||
type ConnState = 'reconnecting' | 'connected' | 'disconnected';
|
||||
|
||||
export default function HostConsole({ stackName, onClose }: HostConsoleProps) {
|
||||
export default function HostConsole({ nodeId, stackName, onClose }: HostConsoleProps) {
|
||||
const { activeNode } = useNodes();
|
||||
const terminalRef = useRef<HTMLDivElement>(null);
|
||||
const xtermRef = useRef<Terminal | null>(null);
|
||||
@@ -93,12 +95,12 @@ export default function HostConsole({ stackName, onClose }: HostConsoleProps) {
|
||||
});
|
||||
|
||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const activeNodeId = localStorage.getItem('sencho-active-node') || '';
|
||||
const nodeParam = activeNodeId ? `nodeId=${activeNodeId}` : '';
|
||||
const stackParam = stackName ? `stack=${encodeURIComponent(stackName)}` : '';
|
||||
const queryString = [nodeParam, stackParam].filter(Boolean).join('&');
|
||||
const wsUrl = `${wsProtocol}//${window.location.host}/api/system/host-console${queryString ? `?${queryString}` : ''}`;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
const qs = stackName
|
||||
? `nodeId=${nodeId}&stack=${encodeURIComponent(stackName)}`
|
||||
: `nodeId=${nodeId}`;
|
||||
const ws = new WebSocket(
|
||||
`${wsProtocol}//${window.location.host}/api/system/host-console?${qs}`,
|
||||
);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
@@ -194,7 +196,7 @@ export default function HostConsole({ stackName, onClose }: HostConsoleProps) {
|
||||
fitAddonRef.current = null;
|
||||
serializeRef.current = null;
|
||||
};
|
||||
}, [stackName, reconnectNonce]);
|
||||
}, [nodeId, stackName, reconnectNonce]);
|
||||
|
||||
const handleCopy = useCallback(() => {
|
||||
const term = xtermRef.current;
|
||||
@@ -236,7 +238,10 @@ export default function HostConsole({ stackName, onClose }: HostConsoleProps) {
|
||||
const stateWord = connState === 'disconnected'
|
||||
? 'Disconnected'
|
||||
: connState === 'reconnecting' ? 'Reconnecting' : 'Connected';
|
||||
const nodeLabel = activeNode ? (activeNode.type === 'local' ? 'LOCAL' : activeNode.name.toUpperCase()) : 'LOCAL';
|
||||
let nodeLabel = `NODE ${nodeId}`;
|
||||
if (activeNode?.id === nodeId) {
|
||||
nodeLabel = activeNode.type === 'local' ? 'LOCAL' : activeNode.name.toUpperCase();
|
||||
}
|
||||
const kicker = `HOST CONSOLE · ${nodeLabel}`;
|
||||
|
||||
const uptime = mountedAt != null ? formatUptime(tick - mountedAt) : '—';
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, waitFor, act } from '@testing-library/react';
|
||||
import type { ReactNode } from 'react';
|
||||
import * as NodeContext from '@/context/NodeContext';
|
||||
import HostConsole from '../HostConsole';
|
||||
|
||||
vi.mock('@/context/NodeContext');
|
||||
|
||||
vi.mock('@/lib/xtermLoader', () => {
|
||||
class FakeTerminal {
|
||||
cols = 80;
|
||||
rows = 24;
|
||||
open = vi.fn();
|
||||
focus = vi.fn();
|
||||
write = vi.fn();
|
||||
clear = vi.fn();
|
||||
dispose = vi.fn();
|
||||
getSelection = vi.fn(() => '');
|
||||
loadAddon = vi.fn();
|
||||
onData = vi.fn();
|
||||
}
|
||||
class FakeFitAddon {
|
||||
fit = vi.fn();
|
||||
}
|
||||
class FakeSerializeAddon {
|
||||
serialize = vi.fn(() => '');
|
||||
}
|
||||
return {
|
||||
loadXtermModules: async () => ({
|
||||
Terminal: FakeTerminal,
|
||||
FitAddon: FakeFitAddon,
|
||||
SerializeAddon: FakeSerializeAddon,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../ui/PageMasthead', () => ({
|
||||
PageMasthead: ({ children }: { children?: ReactNode }) => <div data-testid="masthead">{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../ui/button', () => ({
|
||||
Button: ({ children, ...props }: { children?: ReactNode } & Record<string, unknown>) => (
|
||||
<button type="button" {...props}>{children}</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/clipboard', () => ({
|
||||
copyToClipboard: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
type FakeWs = {
|
||||
url: string;
|
||||
readyState: number;
|
||||
close: ReturnType<typeof vi.fn>;
|
||||
send: ReturnType<typeof vi.fn>;
|
||||
onopen: ((ev?: unknown) => void) | null;
|
||||
onmessage: ((ev: { data: string }) => void) | null;
|
||||
onerror: ((ev?: unknown) => void) | null;
|
||||
onclose: ((ev?: unknown) => void) | null;
|
||||
};
|
||||
|
||||
describe('HostConsole socket targeting', () => {
|
||||
const sockets: FakeWs[] = [];
|
||||
let OriginalWebSocket: typeof WebSocket;
|
||||
|
||||
beforeEach(() => {
|
||||
sockets.length = 0;
|
||||
OriginalWebSocket = globalThis.WebSocket;
|
||||
vi.mocked(NodeContext.useNodes).mockReturnValue({
|
||||
activeNode: { id: 1, name: 'Local', type: 'local' },
|
||||
} as unknown as ReturnType<typeof NodeContext.useNodes>);
|
||||
|
||||
globalThis.WebSocket = class {
|
||||
static OPEN = 1;
|
||||
static CLOSED = 3;
|
||||
url: string;
|
||||
readyState = 0;
|
||||
close = vi.fn(() => { this.readyState = 3; });
|
||||
send = vi.fn();
|
||||
onopen: FakeWs['onopen'] = null;
|
||||
onmessage: FakeWs['onmessage'] = null;
|
||||
onerror: FakeWs['onerror'] = null;
|
||||
onclose: FakeWs['onclose'] = null;
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
sockets.push(this as unknown as FakeWs);
|
||||
queueMicrotask(() => {
|
||||
this.readyState = 1;
|
||||
this.onopen?.(undefined);
|
||||
});
|
||||
}
|
||||
} as unknown as typeof WebSocket;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.WebSocket = OriginalWebSocket;
|
||||
localStorage.removeItem('sencho-active-node');
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('opens the WebSocket with the explicit nodeId (not localStorage)', async () => {
|
||||
localStorage.setItem('sencho-active-node', '99');
|
||||
render(<HostConsole nodeId={7} stackName={null} onClose={vi.fn()} />);
|
||||
await waitFor(() => expect(sockets.length).toBe(1));
|
||||
expect(sockets[0].url).toContain('nodeId=7');
|
||||
expect(sockets[0].url).not.toContain('nodeId=99');
|
||||
});
|
||||
|
||||
it('includes the stack parameter when provided', async () => {
|
||||
render(<HostConsole nodeId={1} stackName="radarr" onClose={vi.fn()} />);
|
||||
await waitFor(() => expect(sockets.length).toBe(1));
|
||||
expect(sockets[0].url).toContain('stack=radarr');
|
||||
});
|
||||
|
||||
it('closes the prior socket and opens a new one when nodeId changes', async () => {
|
||||
const { rerender } = render(<HostConsole nodeId={1} stackName={null} onClose={vi.fn()} />);
|
||||
await waitFor(() => expect(sockets.length).toBe(1));
|
||||
const first = sockets[0];
|
||||
|
||||
await act(async () => {
|
||||
rerender(<HostConsole nodeId={2} stackName={null} onClose={vi.fn()} />);
|
||||
});
|
||||
await waitFor(() => expect(sockets.length).toBe(2));
|
||||
expect(first.close).toHaveBeenCalled();
|
||||
expect(sockets[1].url).toContain('nodeId=2');
|
||||
});
|
||||
|
||||
it('reconnects when stackName changes so a root shell is not retained', async () => {
|
||||
const { rerender } = render(<HostConsole nodeId={1} stackName={null} onClose={vi.fn()} />);
|
||||
await waitFor(() => expect(sockets.length).toBe(1));
|
||||
const first = sockets[0];
|
||||
expect(first.url).not.toContain('stack=');
|
||||
|
||||
await act(async () => {
|
||||
rerender(<HostConsole nodeId={1} stackName="radarr" onClose={vi.fn()} />);
|
||||
});
|
||||
await waitFor(() => expect(sockets.length).toBe(2));
|
||||
expect(first.close).toHaveBeenCalled();
|
||||
expect(sockets[1].url).toContain('stack=radarr');
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,7 @@ export const CAPABILITIES = [
|
||||
'notification-suppression',
|
||||
'notification-suppression-schedule',
|
||||
'host-console',
|
||||
'host-console-community',
|
||||
'container-exec',
|
||||
'audit-log',
|
||||
'scheduled-ops',
|
||||
@@ -40,6 +41,12 @@ export const CAPABILITIES = [
|
||||
|
||||
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;
|
||||
|
||||
export const STACK_DOWN_REMOVE_VOLUMES_CAPABILITY = 'stack-down-remove-volumes' as const satisfies Capability;
|
||||
export const GUIDED_EXTERNAL_NETWORK_PREFLIGHT_CAPABILITY = 'guided-external-network-preflight' as const satisfies Capability;
|
||||
export const SERVICE_SCOPED_UPDATE_CAPABILITY = 'service-scoped-update' as const satisfies Capability;
|
||||
|
||||
@@ -77,19 +77,31 @@ describe('buildNavigationModel', () => {
|
||||
expect(values).not.toContain('audit-log');
|
||||
});
|
||||
|
||||
it('omits Console until experimental discovery is ready and enabled via reachCtx only', () => {
|
||||
it('includes Console for system:console regardless of experimental discovery', () => {
|
||||
expect(
|
||||
buildNavigationModel(makeCtx({ experimentalReady: false, experimental: false }))
|
||||
.allPageItems.map((i) => i.value),
|
||||
).not.toContain('host-console');
|
||||
expect(
|
||||
buildNavigationModel(makeCtx({ experimentalReady: true, experimental: false }))
|
||||
.allPageItems.map((i) => i.value),
|
||||
).not.toContain('host-console');
|
||||
expect(
|
||||
buildNavigationModel(makeCtx({ experimentalReady: true, experimental: true }))
|
||||
buildNavigationModel(makeCtx({
|
||||
experimentalReady: true,
|
||||
experimental: false,
|
||||
isPaid: false,
|
||||
can: (a) => a === 'system:console' || a === 'node:read',
|
||||
}))
|
||||
.allPageItems.map((i) => i.value),
|
||||
).toContain('host-console');
|
||||
expect(
|
||||
buildNavigationModel(makeCtx({
|
||||
experimentalReady: false,
|
||||
experimental: false,
|
||||
can: (a) => a === 'system:console' || a === 'node:read',
|
||||
}))
|
||||
.allPageItems.map((i) => i.value),
|
||||
).toContain('host-console');
|
||||
});
|
||||
|
||||
it('omits Console without system:console', () => {
|
||||
expect(
|
||||
buildNavigationModel(makeCtx({ can: () => false, isAdmin: false }))
|
||||
.allPageItems.map((i) => i.value),
|
||||
).not.toContain('host-console');
|
||||
});
|
||||
|
||||
it('excludes hidden views from quick-link candidates', () => {
|
||||
|
||||
@@ -28,12 +28,6 @@ function isVisuallyDiscoverable(item: AppNavItem, reachCtx: ReachabilityContext)
|
||||
// Settings is always discoverable in the launcher when the operator can open Settings.
|
||||
return true;
|
||||
}
|
||||
// Console: fail-closed visual discovery until /meta settles and the flag is on.
|
||||
// URL normalization still uses isViewHidden cold-load deferral separately.
|
||||
if (item.value === 'host-console') {
|
||||
if (!reachCtx.experimentalReady || !reachCtx.experimental) return false;
|
||||
return !isViewHidden(item.value, reachCtx);
|
||||
}
|
||||
return !isViewHidden(item.value, reachCtx);
|
||||
}
|
||||
|
||||
|
||||
@@ -19,11 +19,21 @@ const DEFAULT: UrlRouteState = {
|
||||
filterNodeId: null,
|
||||
};
|
||||
|
||||
/** True when the current URL is a stack workspace deep link (detail or editor). */
|
||||
export function isStackEditorDeepLink(): boolean {
|
||||
/** True when the URL is a stack-scoped deep link for the given view. */
|
||||
function isStackScopedDeepLink(view: ActiveView): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
const parsed = parsePath(window.location.pathname, window.location.search);
|
||||
return parsed.view === 'editor' && parsed.stackName != null;
|
||||
return parsed.view === view && parsed.stackName != null;
|
||||
}
|
||||
|
||||
/** True when the current URL is a stack workspace deep link (detail or editor). */
|
||||
export function isStackEditorDeepLink(): boolean {
|
||||
return isStackScopedDeepLink('editor');
|
||||
}
|
||||
|
||||
/** True when the URL targets Host Console rooted in a stack directory. */
|
||||
export function isHostConsoleStackDeepLink(): boolean {
|
||||
return isStackScopedDeepLink('host-console');
|
||||
}
|
||||
|
||||
/** Read shell navigation fields from the current browser URL (cold-load bootstrap). */
|
||||
|
||||
@@ -165,4 +165,21 @@ describe('senchoRoute', () => {
|
||||
expect(parsed.view).toBe('networking');
|
||||
expect(parsed.nodeSlug).toBe('local');
|
||||
});
|
||||
|
||||
it('round-trips Host Console without a stack', () => {
|
||||
const path = buildPath({ ...base, activeView: 'host-console', stackName: null });
|
||||
expect(path).toBe('/nodes/local/host-console');
|
||||
const parsed = parsePath(path, '');
|
||||
expect(parsed.view).toBe('host-console');
|
||||
expect(parsed.nodeSlug).toBe('local');
|
||||
expect(parsed.stackName).toBeNull();
|
||||
});
|
||||
|
||||
it('round-trips Host Console rooted in a stack directory', () => {
|
||||
const path = buildPath({ ...base, activeView: 'host-console', stackName: 'radarr' });
|
||||
expect(path).toBe('/nodes/local/host-console/radarr');
|
||||
const parsed = parsePath(path, '');
|
||||
expect(parsed.view).toBe('host-console');
|
||||
expect(parsed.stackName).toBe('radarr');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { resolveHostConsoleCapability } from './hostConsoleCapability';
|
||||
|
||||
describe('resolveHostConsoleCapability', () => {
|
||||
it('returns loading when the active node is unresolved', () => {
|
||||
expect(resolveHostConsoleCapability({
|
||||
nodeResolved: false,
|
||||
isRemote: false,
|
||||
isPaid: false,
|
||||
licenseReady: true,
|
||||
activeNodeMeta: null,
|
||||
})).toBe('loading');
|
||||
});
|
||||
|
||||
it('allows local nodes without waiting for meta', () => {
|
||||
expect(resolveHostConsoleCapability({
|
||||
nodeResolved: true,
|
||||
isRemote: false,
|
||||
isPaid: false,
|
||||
licenseReady: true,
|
||||
activeNodeMeta: null,
|
||||
})).toBe('allowed');
|
||||
});
|
||||
|
||||
it('returns loading when remote meta is absent', () => {
|
||||
expect(resolveHostConsoleCapability({
|
||||
nodeResolved: true,
|
||||
isRemote: true,
|
||||
isPaid: false,
|
||||
licenseReady: true,
|
||||
activeNodeMeta: null,
|
||||
})).toBe('loading');
|
||||
});
|
||||
|
||||
it('allows Community when remote advertises host-console-community', () => {
|
||||
expect(resolveHostConsoleCapability({
|
||||
nodeResolved: true,
|
||||
isRemote: true,
|
||||
isPaid: false,
|
||||
licenseReady: true,
|
||||
activeNodeMeta: { capabilities: ['host-console', 'host-console-community'] },
|
||||
})).toBe('allowed');
|
||||
});
|
||||
|
||||
it('locks Community when remote only has legacy host-console', () => {
|
||||
expect(resolveHostConsoleCapability({
|
||||
nodeResolved: true,
|
||||
isRemote: true,
|
||||
isPaid: false,
|
||||
licenseReady: true,
|
||||
activeNodeMeta: { capabilities: ['host-console'] },
|
||||
})).toBe('locked');
|
||||
});
|
||||
|
||||
it('allows Admiral when remote only has legacy host-console', () => {
|
||||
expect(resolveHostConsoleCapability({
|
||||
nodeResolved: true,
|
||||
isRemote: true,
|
||||
isPaid: true,
|
||||
licenseReady: true,
|
||||
activeNodeMeta: { capabilities: ['host-console'] },
|
||||
})).toBe('allowed');
|
||||
});
|
||||
|
||||
it('returns loading for legacy-only remote while license is not ready', () => {
|
||||
expect(resolveHostConsoleCapability({
|
||||
nodeResolved: true,
|
||||
isRemote: true,
|
||||
isPaid: false,
|
||||
licenseReady: false,
|
||||
activeNodeMeta: { capabilities: ['host-console'] },
|
||||
})).toBe('loading');
|
||||
});
|
||||
|
||||
it('allows community-capable remote without waiting on license', () => {
|
||||
expect(resolveHostConsoleCapability({
|
||||
nodeResolved: true,
|
||||
isRemote: true,
|
||||
isPaid: false,
|
||||
licenseReady: false,
|
||||
activeNodeMeta: { capabilities: ['host-console-community'] },
|
||||
})).toBe('allowed');
|
||||
});
|
||||
|
||||
it('locks Pilot / empty capability lists', () => {
|
||||
expect(resolveHostConsoleCapability({
|
||||
nodeResolved: true,
|
||||
isRemote: true,
|
||||
isPaid: true,
|
||||
licenseReady: true,
|
||||
activeNodeMeta: { capabilities: ['stacks', 'fleet'] },
|
||||
})).toBe('locked');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
HOST_CONSOLE_CAPABILITY,
|
||||
HOST_CONSOLE_COMMUNITY_CAPABILITY,
|
||||
} from '@/lib/capabilities';
|
||||
|
||||
export type HostConsoleCapabilityState = 'loading' | 'allowed' | 'locked';
|
||||
|
||||
export interface HostConsoleCapabilityInput {
|
||||
/** False until NodeContext resolves an active node (cold load). Never treat as local. */
|
||||
nodeResolved: boolean;
|
||||
/** True when the resolved active node is a remote Distributed API Proxy or Pilot node. */
|
||||
isRemote: boolean;
|
||||
/** Hub license: Admiral may accept legacy `host-console` on remotes. */
|
||||
isPaid: boolean;
|
||||
/**
|
||||
* False while LicenseContext is still loading. Legacy-remote allowance
|
||||
* must wait so a cold load does not flash LockCard as Community.
|
||||
*/
|
||||
licenseReady: boolean;
|
||||
/**
|
||||
* Cached `/api/meta` for the active node. Null means metadata has not been
|
||||
* fetched yet (or the node is unresolved). Must not be confused with
|
||||
* optimistic `hasCapability()` which returns true while meta is absent.
|
||||
*/
|
||||
activeNodeMeta: { capabilities: readonly string[] } | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether Host Console content may mount for the active node.
|
||||
*
|
||||
* Unresolved nodes stay in `loading`. Local nodes are treated as compatible
|
||||
* once RBAC passed (same build). Remote nodes wait for metadata, then require
|
||||
* `host-console-community`, or (Admiral only) legacy `host-console`.
|
||||
*/
|
||||
export function resolveHostConsoleCapability(
|
||||
input: HostConsoleCapabilityInput,
|
||||
): HostConsoleCapabilityState {
|
||||
const { nodeResolved, isRemote, isPaid, licenseReady, activeNodeMeta } = input;
|
||||
if (!nodeResolved) return 'loading';
|
||||
if (!isRemote) return 'allowed';
|
||||
if (!activeNodeMeta) return 'loading';
|
||||
|
||||
const caps = activeNodeMeta.capabilities;
|
||||
if (caps.includes(HOST_CONSOLE_COMMUNITY_CAPABILITY)) return 'allowed';
|
||||
if (!caps.includes(HOST_CONSOLE_CAPABILITY)) return 'locked';
|
||||
// Legacy host-console only: Admiral hubs may open it; wait for license first.
|
||||
if (!licenseReady) return 'loading';
|
||||
return isPaid ? 'allowed' : 'locked';
|
||||
}
|
||||
@@ -49,25 +49,25 @@ describe('reachability', () => {
|
||||
expect(isViewHidden('fleet', noFleet)).toBe(true);
|
||||
});
|
||||
|
||||
it('preserves paid views when license metadata failed', () => {
|
||||
const licenseError = ctx({ licenseStatus: 'error', experimental: true });
|
||||
it('preserves host-console when authz is not ready', () => {
|
||||
const licenseError = ctx({ licenseStatus: 'error', can: (a) => a === 'system:console' });
|
||||
expect(isViewHidden('host-console', licenseError)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not apply experimental hide to host-console until experimentalReady', () => {
|
||||
const loading = ctx({ experimental: false, experimentalReady: false, isPaid: true, isAdmin: true });
|
||||
expect(isViewHidden('host-console', loading)).toBe(false);
|
||||
it('hides host-console without system:console when ready', () => {
|
||||
const noConsole = ctx({ can: () => false, isPaid: false, experimental: false });
|
||||
expect(isViewHidden('host-console', noConsole)).toBe(true);
|
||||
expect(normalizeHiddenView('host-console', noConsole)).toBe('dashboard');
|
||||
});
|
||||
|
||||
it('hides host-console when experimental is ready and off even for paid admin', () => {
|
||||
const off = ctx({ experimental: false, experimentalReady: true, isPaid: true, isAdmin: true });
|
||||
expect(isViewHidden('host-console', off)).toBe(true);
|
||||
expect(normalizeHiddenView('host-console', off)).toBe('dashboard');
|
||||
});
|
||||
|
||||
it('keeps host-console when experimental is on for paid admin', () => {
|
||||
const on = ctx({ experimental: true, experimentalReady: true, isPaid: true, isAdmin: true });
|
||||
expect(isViewHidden('host-console', on)).toBe(false);
|
||||
it('keeps host-console for system:console regardless of tier or experimental', () => {
|
||||
const community = ctx({
|
||||
isPaid: false,
|
||||
experimental: false,
|
||||
experimentalReady: true,
|
||||
can: (a) => a === 'system:console',
|
||||
});
|
||||
expect(isViewHidden('host-console', community)).toBe(false);
|
||||
});
|
||||
|
||||
it('hides routing and secrets fleet tabs only after experimentalReady when off', () => {
|
||||
|
||||
@@ -43,11 +43,7 @@ export function isViewHidden(view: ActiveView, ctx: ReachabilityContext): boolea
|
||||
if (!ctx.isAdmin && (view === 'auto-updates' || view === 'scheduled-ops')) return true;
|
||||
if (!ctx.can('node:read') && view === 'fleet') return true;
|
||||
if (view === 'host-console') {
|
||||
// Defer experimental hide until ready so enabled deep links survive cold load.
|
||||
if (experimentalDiscoveryReady(ctx) && !ctx.experimental) return true;
|
||||
if (!ctx.isPaid) return true;
|
||||
if (!ctx.isAdmin) return true;
|
||||
return false;
|
||||
return !ctx.can('system:console');
|
||||
}
|
||||
if (!ctx.isPaid) {
|
||||
if (view === 'audit-log') return true;
|
||||
@@ -67,7 +63,7 @@ export function isViewCapabilityLocked(view: ActiveView, ctx: ReachabilityContex
|
||||
export function isFleetTabHidden(tab: FleetTab, ctx: ReachabilityContext): boolean {
|
||||
if (!authzReady(ctx)) return false;
|
||||
if (tab === 'container-labels' && !ctx.containerLabelsEnabled) return true;
|
||||
// Defer experimental hide until ready (same cold-load contract as host-console).
|
||||
// Defer experimental hide until ready so deep links survive cold load.
|
||||
if ((tab === 'routing' || tab === 'secrets') && experimentalDiscoveryReady(ctx) && !ctx.experimental) {
|
||||
return true;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user