feat(license): distributed license enforcement across multi-node setups (#359)

* feat(license): distributed license enforcement across multi-node setups

The primary instance's license tier is now asserted to remote nodes on
every proxied HTTP and WebSocket request via trusted headers. Remote
nodes honor the assertion only when the request carries a valid
node_proxy JWT, preventing unauthorized elevation from browsers or API
tokens. Falls back to local license tier for direct access.

* fix(test): remove unused vi import in distributed-license tests
This commit is contained in:
Anso
2026-04-03 11:31:14 -04:00
committed by GitHub
parent 9e87e14d62
commit 6c26ae3f50
7 changed files with 324 additions and 9 deletions
+2
View File
@@ -24,6 +24,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* **fleet:** remote node update management (Pro tier) — check for outdated nodes and trigger over-the-air updates from Fleet View. Nodes self-update by pulling the latest Docker image and recreating their container. Includes version badges on node cards, per-node and bulk "Update All" actions, real-time progress tracking with 5-second polling, reconnecting overlay for local node updates, and `POST /api/system/update` endpoint for programmatic self-updates. Requires Docker Compose deployment with Docker socket access.
* **license:** distributed license enforcement across multi-node setups — the primary instance's license tier (Skipper/Admiral) is now automatically asserted to remote nodes on every proxied request. Remote nodes honor the assertion only when the request arrives with a valid node proxy token, preventing unauthorized elevation. No per-node license activation required. Includes type guards and header constants in `LicenseService`, and comprehensive test coverage for all trust chain scenarios.
## [0.32.0](https://github.com/AnsoCode/Sencho/compare/v0.31.0...v0.32.0) (2026-04-03)
@@ -0,0 +1,218 @@
/**
* Tests for Distributed License Enforcement: the trust chain where the main
* instance asserts its license tier to remote nodes via proxy headers.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import jwt from 'jsonwebtoken';
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
let tmpDir: string;
let app: import('express').Express;
beforeAll(async () => {
tmpDir = await setupTestDb();
({ app } = await import('../index'));
});
afterAll(() => {
cleanupTestDb(tmpDir);
});
/** Helper: sign a token with the test JWT secret. */
const signToken = (payload: Record<string, unknown>, expiresIn: string | number = '1m') =>
jwt.sign(payload, TEST_JWT_SECRET, { expiresIn: expiresIn as jwt.SignOptions['expiresIn'] });
// We need a Pro-gated route that doesn't depend on Docker or remote nodes.
// /api/webhooks is Pro-gated and just reads from the DB — returns an empty array
// if no webhooks exist.
const PRO_ROUTE = '/api/webhooks';
// For Admiral routes, /api/audit-log is Admiral-gated and reads from the DB.
const ADMIRAL_ROUTE = '/api/audit-log';
// ─── authMiddleware: proxyTier/proxyVariant propagation ─────────────────────
describe('authMiddleware - distributed license headers', () => {
it('sets proxyTier/proxyVariant for node_proxy tokens with valid tier headers', async () => {
const token = signToken({ scope: 'node_proxy' });
// Hit a Pro-gated route with tier assertion - should be allowed
const res = await request(app)
.get(PRO_ROUTE)
.set('Authorization', `Bearer ${token}`)
.set('x-sencho-tier', 'pro')
.set('x-sencho-variant', 'personal');
// Should NOT get 403 PRO_REQUIRED — the proxy tier assertion grants access
expect(res.status).not.toBe(403);
});
it('ignores tier headers for user session tokens', async () => {
const token = signToken({ username: TEST_USERNAME, role: 'admin' });
// Even with tier headers set, a user session should use local license (community)
const res = await request(app)
.get(PRO_ROUTE)
.set('Authorization', `Bearer ${token}`)
.set('x-sencho-tier', 'pro')
.set('x-sencho-variant', 'team');
// Local license is community in test env → should get 403
expect(res.status).toBe(403);
expect(res.body.code).toBe('PRO_REQUIRED');
});
it('ignores tier headers for malformed values on node_proxy tokens', async () => {
const token = signToken({ scope: 'node_proxy' });
const res = await request(app)
.get(PRO_ROUTE)
.set('Authorization', `Bearer ${token}`)
.set('x-sencho-tier', 'enterprise') // invalid value
.set('x-sencho-variant', 'mega'); // invalid value
// Invalid tier header → proxyTier not set → falls back to local (community) → 403
expect(res.status).toBe(403);
expect(res.body.code).toBe('PRO_REQUIRED');
});
it('falls back to local tier when no tier headers on node_proxy token', async () => {
const token = signToken({ scope: 'node_proxy' });
const res = await request(app)
.get(PRO_ROUTE)
.set('Authorization', `Bearer ${token}`);
// No tier headers → falls back to local (community) → 403
expect(res.status).toBe(403);
expect(res.body.code).toBe('PRO_REQUIRED');
});
});
// ─── requirePro guard ───────────────────────────────────────────────────────
describe('requirePro - distributed license', () => {
it('allows access when proxy asserts pro tier', async () => {
const token = signToken({ scope: 'node_proxy' });
const res = await request(app)
.get(PRO_ROUTE)
.set('Authorization', `Bearer ${token}`)
.set('x-sencho-tier', 'pro')
.set('x-sencho-variant', '');
expect(res.status).not.toBe(403);
});
it('blocks access when proxy asserts community tier', async () => {
const token = signToken({ scope: 'node_proxy' });
const res = await request(app)
.get(PRO_ROUTE)
.set('Authorization', `Bearer ${token}`)
.set('x-sencho-tier', 'community');
expect(res.status).toBe(403);
expect(res.body.code).toBe('PRO_REQUIRED');
});
it('blocks access for direct user when local tier is community', async () => {
const token = signToken({ username: TEST_USERNAME, role: 'admin' });
const res = await request(app)
.get(PRO_ROUTE)
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(403);
expect(res.body.code).toBe('PRO_REQUIRED');
});
});
// ─── requireAdmiral guard ───────────────────────────────────────────────────
describe('requireAdmiral - distributed license', () => {
it('allows access when proxy asserts pro tier with team variant', async () => {
const token = signToken({ scope: 'node_proxy' });
const res = await request(app)
.get(ADMIRAL_ROUTE)
.set('Authorization', `Bearer ${token}`)
.set('x-sencho-tier', 'pro')
.set('x-sencho-variant', 'team');
expect(res.status).not.toBe(403);
});
it('blocks when proxy asserts pro tier with personal variant', async () => {
const token = signToken({ scope: 'node_proxy' });
const res = await request(app)
.get(ADMIRAL_ROUTE)
.set('Authorization', `Bearer ${token}`)
.set('x-sencho-tier', 'pro')
.set('x-sencho-variant', 'personal');
expect(res.status).toBe(403);
expect(res.body.code).toBe('ADMIRAL_REQUIRED');
});
it('blocks when proxy asserts community tier', async () => {
const token = signToken({ scope: 'node_proxy' });
const res = await request(app)
.get(ADMIRAL_ROUTE)
.set('Authorization', `Bearer ${token}`)
.set('x-sencho-tier', 'community');
expect(res.status).toBe(403);
expect(res.body.code).toBe('PRO_REQUIRED');
});
it('blocks when proxy asserts pro tier with empty variant', async () => {
const token = signToken({ scope: 'node_proxy' });
const res = await request(app)
.get(ADMIRAL_ROUTE)
.set('Authorization', `Bearer ${token}`)
.set('x-sencho-tier', 'pro')
.set('x-sencho-variant', '');
expect(res.status).toBe(403);
expect(res.body.code).toBe('ADMIRAL_REQUIRED');
});
});
// ─── Security: header injection prevention ──────────────────────────────────
describe('Security - tier header injection', () => {
it('cannot elevate access via tier headers on a user session', async () => {
const token = signToken({ username: TEST_USERNAME, role: 'admin' });
const res = await request(app)
.get(ADMIRAL_ROUTE)
.set('Authorization', `Bearer ${token}`)
.set('x-sencho-tier', 'pro')
.set('x-sencho-variant', 'team');
// User session → tier headers ignored → local community tier → 403
expect(res.status).toBe(403);
});
it('cannot elevate access via tier headers without any auth', async () => {
const res = await request(app)
.get(PRO_ROUTE)
.set('x-sencho-tier', 'pro')
.set('x-sencho-variant', 'team');
expect(res.status).toBe(401);
});
it('cannot elevate access with expired node_proxy token', async () => {
const token = jwt.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '-1s' });
const res = await request(app)
.get(PRO_ROUTE)
.set('Authorization', `Bearer ${token}`)
.set('x-sencho-tier', 'pro');
expect(res.status).toBe(401);
});
it('cannot elevate access with token signed by wrong secret', async () => {
const token = jwt.sign({ scope: 'node_proxy' }, 'wrong-secret', { expiresIn: '1m' });
const res = await request(app)
.get(PRO_ROUTE)
.set('Authorization', `Bearer ${token}`)
.set('x-sencho-tier', 'pro');
expect(res.status).toBe(401);
});
});
+48 -9
View File
@@ -25,7 +25,7 @@ import { ImageUpdateService } from './services/ImageUpdateService';
import { templateService } from './services/TemplateService';
import { ErrorParser } from './utils/ErrorParser';
import { NodeRegistry } from './services/NodeRegistry';
import { LicenseService } from './services/LicenseService';
import { LicenseService, type LicenseTier, type LicenseVariant, isLicenseTier, isLicenseVariant, PROXY_TIER_HEADER, PROXY_VARIANT_HEADER } from './services/LicenseService';
import { WebhookService } from './services/WebhookService';
import { SSOService } from './services/SSOService';
import { SchedulerService } from './services/SchedulerService';
@@ -237,6 +237,10 @@ declare global {
nodeId: number;
apiTokenScope?: 'read-only' | 'deploy-only' | 'full-admin';
rawBody?: Buffer;
/** License tier asserted by the main instance on proxied requests. Only set for trusted node_proxy tokens. */
proxyTier?: LicenseTier;
/** License variant asserted by the main instance on proxied requests. Only set for trusted node_proxy tokens. */
proxyVariant?: LicenseVariant;
}
}
}
@@ -298,6 +302,23 @@ const authMiddleware = async (req: Request, res: Response, next: NextFunction):
// Accept both user sessions and node proxy tokens. Default role to 'admin' for backward compat with pre-RBAC tokens.
const dbUser = decoded.username ? DatabaseService.getInstance().getUserByUsername(decoded.username) : undefined;
req.user = { username: decoded.username || 'node-proxy', role: (decoded.role as UserRole) || 'admin', userId: dbUser?.id ?? 0 };
// Distributed License Enforcement: trust tier headers only from authenticated node proxy requests.
// Browser sessions and API tokens cannot set these — only a valid node_proxy JWT (signed with
// this instance's JWT secret) unlocks the trusted path.
if (decoded.scope === 'node_proxy') {
const tierHeader = req.headers[PROXY_TIER_HEADER] as string | undefined;
const variantHeader = req.headers[PROXY_VARIANT_HEADER] as string | undefined;
if (isLicenseTier(tierHeader)) {
req.proxyTier = tierHeader;
}
if (isLicenseVariant(variantHeader)) {
req.proxyVariant = variantHeader;
} else if (variantHeader === '') {
req.proxyVariant = null;
}
}
next();
} catch (err) {
console.error('[Auth] Token validation failed:', (err as Error).message);
@@ -818,8 +839,11 @@ app.use('/api', (req: Request, res: Response, next: NextFunction): void => {
// --- License Routes (local-only, never proxied) ---
// Pro feature guard: returns false and sends 403 if not Pro tier.
const requirePro = (_req: Request, res: Response): boolean => {
if (LicenseService.getInstance().getTier() !== 'pro') {
// Checks req.proxyTier first (set by authMiddleware for trusted node proxy requests),
// falling back to the local LicenseService tier for direct access.
const requirePro = (req: Request, res: Response): boolean => {
const tier = req.proxyTier !== undefined ? req.proxyTier : LicenseService.getInstance().getTier();
if (tier !== 'pro') {
res.status(403).json({ error: 'This feature requires Sencho Pro.', code: 'PRO_REQUIRED' });
return false;
}
@@ -827,13 +851,17 @@ const requirePro = (_req: Request, res: Response): boolean => {
};
// Admiral feature guard: requires Pro tier with team variant.
const requireAdmiral = (_req: Request, res: Response): boolean => {
// Checks req.proxyTier/proxyVariant first (set by authMiddleware for trusted node proxy
// requests), falling back to the local LicenseService for direct access.
const requireAdmiral = (req: Request, res: Response): boolean => {
const ls = LicenseService.getInstance();
if (ls.getTier() !== 'pro') {
const tier = req.proxyTier !== undefined ? req.proxyTier : ls.getTier();
const variant = req.proxyVariant !== undefined ? req.proxyVariant : ls.getVariant();
if (tier !== 'pro') {
res.status(403).json({ error: 'This feature requires Sencho Pro.', code: 'PRO_REQUIRED' });
return false;
}
if (ls.getVariant() !== 'team') {
if (variant !== 'team') {
res.status(403).json({ error: 'This feature requires Sencho Admiral.', code: 'ADMIRAL_REQUIRED' });
return false;
}
@@ -849,9 +877,9 @@ const requireAdmin = (req: Request, res: Response): boolean => {
};
// Tier gate for scheduled tasks: 'update' action requires Pro, everything else requires Admiral.
const requireScheduledTaskTier = (action: string, _req: Request, res: Response): boolean => {
if (action === 'update') return requirePro(_req, res);
return requireAdmiral(_req, res);
const requireScheduledTaskTier = (action: string, req: Request, res: Response): boolean => {
if (action === 'update') return requirePro(req, res);
return requireAdmiral(req, res);
};
// --- Scoped RBAC Permission Engine (Admiral) ---
@@ -2324,6 +2352,13 @@ const remoteNodeProxy = createProxyMiddleware<Request, Response>({
if (node?.api_token) {
proxyReq.setHeader('Authorization', `Bearer ${node.api_token}`);
}
// Distributed License Enforcement: assert the main instance's license tier to the
// remote node so tier-gated routes honor the main's license instead of the node's local
// (likely Community) tier. The remote's authMiddleware only trusts these headers when the
// request carries a valid node_proxy JWT.
const proxyLs = LicenseService.getInstance();
proxyReq.setHeader(PROXY_TIER_HEADER, proxyLs.getTier());
proxyReq.setHeader(PROXY_VARIANT_HEADER, proxyLs.getVariant() || '');
// Strip the ?nodeId= query param so the remote's nodeContextMiddleware
// doesn't reject the request with 404 ("Node X not found") - the remote
// has no record of the gateway's node IDs and should treat the request
@@ -2534,6 +2569,10 @@ server.on('upgrade', async (req, socket, head) => {
// Strip the browser's session cookie - it is signed by this instance's JWT secret and
// would fail verification on the remote. Auth is handled exclusively via the Bearer token.
delete req.headers['cookie'];
// Distributed License Enforcement: assert the main's license tier on proxied WS connections.
const wsLs = LicenseService.getInstance();
req.headers[PROXY_TIER_HEADER] = wsLs.getTier();
req.headers[PROXY_VARIANT_HEADER] = wsLs.getVariant() || '';
// Strip nodeId from the forwarded URL so the remote treats the request as a local one.
// The remote has no record of the gateway's nodeId, so leaving it would cause unnecessary
// fallback logic. Removing it lets the remote default cleanly to its own local node.
+15
View File
@@ -7,6 +7,21 @@ export type LicenseStatus = 'community' | 'trial' | 'active' | 'expired' | 'disa
export type LicenseVariant = 'personal' | 'team' | null;
const VALID_TIERS: readonly string[] = ['community', 'pro'] satisfies readonly LicenseTier[];
const VALID_VARIANTS: readonly string[] = ['personal', 'team'] satisfies readonly LicenseVariant[];
export function isLicenseTier(value: unknown): value is LicenseTier {
return typeof value === 'string' && (VALID_TIERS as readonly string[]).includes(value);
}
export function isLicenseVariant(value: unknown): value is Exclude<LicenseVariant, null> {
return typeof value === 'string' && (VALID_VARIANTS as readonly string[]).includes(value);
}
/** Header names used for Distributed License Enforcement between nodes. */
export const PROXY_TIER_HEADER = 'x-sencho-tier';
export const PROXY_VARIANT_HEADER = 'x-sencho-variant';
export interface LicenseInfo {
tier: LicenseTier;
status: LicenseStatus;
+6
View File
@@ -55,6 +55,12 @@ Active licenses are re-validated every **72 hours** against the Lemon Squeezy AP
To manage your subscription (update payment method, view invoices, cancel, or switch plans), visit the [Lemon Squeezy customer portal](https://app.lemonsqueezy.com/my-orders) or use the link in your purchase confirmation email.
## Multi-node license enforcement
If you manage multiple servers through Sencho's [multi-node feature](/features/multi-node), you only need a license on your **primary instance**. All remote nodes automatically inherit the primary's license tier for proxied requests — no per-node activation required.
For details on how this works, see [License enforcement across nodes](/features/multi-node#license-enforcement-across-nodes).
## Deactivating a license
To transfer your license to a different instance:
+22
View File
@@ -90,6 +90,28 @@ Click the **calendar icon** on any node row to jump directly to the Schedules vi
When a node is deleted, all scheduled tasks and update status data associated with it are automatically cleaned up.
## License enforcement across nodes
When you have a paid license (Skipper or Admiral) on your primary instance, all remote nodes automatically inherit that license tier for proxied requests. You do not need to activate a license on each remote node separately.
### How it works
Your primary Sencho instance asserts its license tier to remote nodes on every proxied request. Remote nodes trust this assertion because it arrives alongside a valid node proxy token — the same token you configured when adding the node. No additional configuration is required.
This means:
- **Paid primary → remote nodes**: Pro and Admiral features work on all remote nodes, governed by the primary instance's license.
- **Community primary → remote nodes**: Pro-gated features are blocked on remote nodes, even if a remote node has its own paid license. The primary's tier is authoritative for proxied requests.
- **Direct access to a node**: If you access a remote Sencho instance directly (not through the primary), it uses its own local license tier as usual.
### Why this matters
Without this trust chain, remote nodes would default to the Community tier and block Pro/Admiral features — even though the primary instance has a valid paid license. Distributed license enforcement eliminates this gap so your fleet behaves consistently regardless of which node you're operating on.
<Note>
Remote nodes do not need their own license keys. A single license on the primary instance covers all nodes managed through it.
</Note>
## Editing and deleting nodes
Click the **pencil icon** on any remote node row to edit its name, URL, or token. Click the **trash icon** to remove it. The local node cannot be edited or deleted.
+13
View File
@@ -134,6 +134,19 @@ Click the **wifi icon** on the node row to re-test connectivity after making cha
---
## Pro features return 403 on remote nodes
**Symptom:** A Pro or Admiral feature works on the local node but returns "This feature requires Sencho Pro" (403) when you switch to a remote node.
**Checks in order:**
1. **Is your primary instance licensed?** Go to **Settings > License** on the primary instance and verify it shows an active Skipper or Admiral license. Remote nodes inherit the primary's tier — if the primary is on Community, all remote nodes will be Community too.
2. **Is the remote node's token valid?** An expired or revoked token prevents the license tier from being transmitted. Regenerate the token on the remote instance and update the node config on the primary.
3. **Is the remote node running an up-to-date version of Sencho?** Distributed license enforcement requires both the primary and remote instances to be on v0.34.0 or later. Update the remote node if it's on an older version.
4. **Are you accessing the remote node directly?** If you navigate directly to the remote Sencho instance's URL (bypassing the primary), it uses its own local license. License inheritance only works through the primary's proxy.
---
## Forgotten admin password
Sencho has no password recovery flow. To reset the password: