diff --git a/CHANGELOG.md b/CHANGELOG.md index e3868998..699a88c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/backend/src/__tests__/distributed-license.test.ts b/backend/src/__tests__/distributed-license.test.ts new file mode 100644 index 00000000..404a46ba --- /dev/null +++ b/backend/src/__tests__/distributed-license.test.ts @@ -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, 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); + }); +}); diff --git a/backend/src/index.ts b/backend/src/index.ts index 2dd9c1e3..bdbe3b6d 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -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({ 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. diff --git a/backend/src/services/LicenseService.ts b/backend/src/services/LicenseService.ts index 5ea83aeb..75d6ae05 100644 --- a/backend/src/services/LicenseService.ts +++ b/backend/src/services/LicenseService.ts @@ -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 { + 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; diff --git a/docs/features/licensing.mdx b/docs/features/licensing.mdx index ad3f6eab..7162bd21 100644 --- a/docs/features/licensing.mdx +++ b/docs/features/licensing.mdx @@ -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: diff --git a/docs/features/multi-node.mdx b/docs/features/multi-node.mdx index e75e7fb8..b85141ff 100644 --- a/docs/features/multi-node.mdx +++ b/docs/features/multi-node.mdx @@ -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. + + + Remote nodes do not need their own license keys. A single license on the primary instance covers all nodes managed through it. + + ## 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. diff --git a/docs/operations/troubleshooting.mdx b/docs/operations/troubleshooting.mdx index 94da519c..2f0e1514 100644 --- a/docs/operations/troubleshooting.mdx +++ b/docs/operations/troubleshooting.mdx @@ -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: