feat(host-console): gate Host Console behind Admiral tier (#277)

* feat(host-console): gate Host Console behind Admiral tier

Move the Host Console from the Community (free) tier to Admiral,
enforcing the gate at every layer: UI nav visibility, AdmiralGate
wrapper, POST /api/system/console-token endpoint, and the WebSocket
upgrade handler for /api/system/host-console.

* test(auth): mock Admiral license for console-token test

The console-token endpoint now requires Admiral tier. Mock
LicenseService in the test to return pro/team so the happy-path
test passes in CI where no license is activated.
This commit is contained in:
Anso
2026-03-30 15:56:58 -04:00
committed by GitHub
parent ccf6062473
commit b5d3f497cb
7 changed files with 49 additions and 6 deletions
+4
View File
@@ -41,6 +41,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Changed
* **host-console:** moved Host Console from Community tier to Admiral tier. The interactive host terminal now requires a Sencho Admiral license — enforced in the UI (nav item hidden, AdmiralGate upgrade prompt), the REST API (`POST /api/system/console-token` returns 403), and the WebSocket upgrade handler (`/api/system/host-console` rejects connections). Community and Skipper users see an upgrade prompt.
### Security
* **docker:** upgrade base image from `node:20-alpine` to `node:22-alpine` (Node 22.22.2 on Alpine 3.23.3) to remediate 31 CVEs (1 Critical, multiple High/Medium/Low) flagged by Docker Scout against the previous `node:20-alpine` base.
+12 -1
View File
@@ -1,7 +1,7 @@
/**
* Tests for authentication: login, rate limiting, and auth middleware.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import request from 'supertest';
import jwt from 'jsonwebtoken';
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_PASSWORD, TEST_JWT_SECRET } from './helpers/setupTestDb';
@@ -96,6 +96,17 @@ describe('authMiddleware', () => {
// ─── Protected endpoint: console-token ───────────────────────────────────────
describe('POST /api/system/console-token', () => {
// Console-token requires Admiral tier — mock LicenseService for the happy-path test
beforeAll(async () => {
const { LicenseService } = await import('../services/LicenseService');
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('pro');
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('team');
});
afterAll(() => {
vi.restoreAllMocks();
});
it('returns 401 without authentication (was a security bug - C1 fix)', async () => {
const res = await request(app).post('/api/system/console-token');
expect(res.status).toBe(401);
+8
View File
@@ -2316,6 +2316,13 @@ server.on('upgrade', async (req, socket, head) => {
socket.destroy();
return;
}
// Admiral license gate — host console requires Pro (team variant)
const ls = LicenseService.getInstance();
if (ls.getTier() !== 'pro' || ls.getVariant() !== 'team') {
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
socket.destroy();
return;
}
const hostConsoleWss = new WebSocketServer({ noServer: true });
hostConsoleWss.handleUpgrade(req, socket, head, (ws) => {
hostConsoleWss.close();
@@ -3362,6 +3369,7 @@ app.post('/api/system/console-token', authMiddleware, (req: Request, res: Respon
return;
}
if (!requireAdmin(req, res)) return;
if (!requireAdmiral(req, res)) return;
try {
const settings = DatabaseService.getInstance().getGlobalSettings();
const jwtSecret = settings.auth_jwt_secret;
+17
View File
@@ -5,6 +5,10 @@ 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 requires a **Sencho Admiral** license. Community and Skipper users see an upgrade prompt instead. [Learn more about licensing](/features/licensing).
</Note>
<Frame>
<img src="/images/host-console/host-console-overview.png" alt="Host Console showing a PowerShell prompt inside the Sencho container working directory" />
</Frame>
@@ -34,12 +38,25 @@ The shell depends on the host OS:
- **Linux/macOS hosts:** `bash` or `sh`
- **Windows hosts (Docker Desktop):** PowerShell (as shown in the screenshot - the console opens inside the Sencho container's Windows environment)
## Availability
The Host Console is available to **admin** users on the **Admiral** tier. Access is enforced at every layer:
- **UI:** The Console tab only appears in the navigation bar for Admiral-licensed admin users.
- **API:** The `POST /api/system/console-token` endpoint returns `403 Forbidden` without an Admiral license.
- **WebSocket:** The `/api/system/host-console` upgrade handler rejects connections without an Admiral license.
### What Community and Skipper users see
If you are on the Community or Skipper tier, navigating to the Console view shows an upgrade prompt with a link to the [pricing page](https://sencho.io/pricing). The Console tab is hidden from the navigation bar entirely.
## Security model
The host console has a stricter authentication requirement than other features:
- Requires a valid **browser session** (httpOnly cookie). Node-proxy tokens used for multi-node communication are explicitly blocked.
- Each session is issued a short-lived **console token** (60-second TTL) before the WebSocket is established.
- Access is restricted to the **Admiral** license tier, ensuring only teams with appropriate licensing can use interactive shell access.
- Because the console gives full shell access to the host, you should secure your Sencho instance with HTTPS and a strong password if it is exposed to a network.
<Warning>
+3 -3
View File
@@ -14,14 +14,14 @@ Sencho uses an open-core model. The **Community** tier is free forever with unli
| Tier | Price | Accounts |
|------|-------|----------|
| **Community** | Free | 1 admin |
| **Skipper** | $7.99/month, $69.99/year, or $249 lifetime | 1 admin + 3 viewers |
| **Admiral** | $49.99/month, $499.99/year, or $1,499 lifetime | Unlimited |
| **Skipper** | $5.99/month billed annually, $7.99/month, or $249 lifetime | 1 admin + 3 viewers |
| **Admiral** | $41.99/month billed annually, $49.99/month, or $1,499 lifetime | Unlimited |
Lifetime pricing is an early-adopter offer available for a limited time only.
## Free trial
Every new Sencho installation starts with a **14-day Skipper trial** - no license key or credit card required. Skipper features like fleet management, RBAC viewer accounts, webhooks, and atomic deployments are unlocked during the trial so you can evaluate them with your real infrastructure. Admiral features (SSO, audit log, unlimited accounts) require an Admiral license.
Every new Sencho installation starts with a **14-day Skipper trial** - no license key or credit card required. Skipper features like fleet management, RBAC viewer accounts, webhooks, and atomic deployments are unlocked during the trial so you can evaluate them with your real infrastructure. Admiral features (SSO, audit log, host console, unlimited accounts) require an Admiral license.
When the trial expires, Sencho automatically reverts to the Community tier. No data is lost.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 47 KiB

+5 -2
View File
@@ -7,6 +7,7 @@ import ErrorBoundary from './ErrorBoundary';
import HomeDashboard from './HomeDashboard';
import BashExecModal from './BashExecModal';
import HostConsole from './HostConsole';
import { AdmiralGate } from './AdmiralGate';
import ResourcesView from './ResourcesView';
import { Button } from './ui/button';
import { Input } from './ui/input';
@@ -161,13 +162,13 @@ export default function EditorLayout() {
{ value: 'dashboard', label: 'Home', icon: Home },
{ value: 'fleet', label: 'Fleet', icon: Radar },
];
if (isAdmin) items.push({ value: 'host-console', label: 'Console', icon: Terminal });
items.push(
{ value: 'resources', label: 'Resources', icon: HardDrive },
{ value: 'templates', label: 'App Store', icon: CloudDownload },
{ value: 'global-observability', label: 'Logs', icon: Activity },
);
if (isPro && license?.variant === 'team') {
if (isAdmin) items.push({ value: 'host-console', label: 'Console', icon: Terminal });
if (can('system:audit')) items.push({ value: 'audit-log', label: 'Audit', icon: ScrollText });
if (isAdmin) items.push({ value: 'scheduled-ops', label: 'Schedules', icon: Clock });
}
@@ -1479,7 +1480,9 @@ export default function EditorLayout() {
) : activeView === 'resources' ? (
<ResourcesView />
) : activeView === 'host-console' ? (
<HostConsole stackName={selectedFile} onClose={() => setActiveView(selectedFile ? 'editor' : 'dashboard')} />
<AdmiralGate featureName="Host Console">
<HostConsole stackName={selectedFile} onClose={() => setActiveView(selectedFile ? 'editor' : 'dashboard')} />
</AdmiralGate>
) : !isLoading && selectedFile && activeView === 'editor' ? (
<ErrorBoundary>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 items-stretch">