mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-13 12:17:34 +00:00
865d792874
* feat(pricing): collapse to two tiers (Community + Admiral) Collapse Sencho's pricing from three tiers (Community / Skipper / Admiral) to two: a generous free Community tier and a single paid Admiral tier. The Skipper tier is removed. Now free in Community: auto-heal, auto-update, scheduled operations, webhooks, notification routing, Fleet Actions and bulk operations, SSO preset providers (Google / GitHub / Okta), unlimited users with admin and viewer roles, and deploy safety (atomic deploys, auto-rollback, and one-click rollback). Admiral (paid) is focused on running and governing a fleet: blueprints, Fleet Secrets, deploy enforcement, vulnerability report export, audit log, host console, private registries, mesh networking, node cordon, managed cloud backup, LDAP / Active Directory SSO, and the advanced RBAC roles (deployer, node-admin, auditor) with per-resource scoped assignments. Internally the license variant distinction is removed so tier is binary (community / paid). License validation still verifies the Lemon Squeezy store and product before granting paid status. Docs and the contributor guide are updated to the two-tier model. * docs(pricing): correct licensing page to two-tier pricing and tidy stale tier wording The licensing docs page kept the old Admiral pricing plus a Founder Lifetime column and an Enterprise paragraph after the two-tier collapse. Update it to $12/month or $99/year, drop the lifetime and Enterprise content, and link to the pricing page for current pricing. Also fix stale "Skipper" wording in CLA.md, SUPPORT.md, one test title, and three test comments. Historical CHANGELOG entries and the retired-Skipper license-guard test are intentionally left as-is. * docs: align licensing and SSO pages with the two-tier model Correct the SSO overview so the Google, GitHub, and Okta presets read as available on every tier, matching the provider table; only LDAP and Active Directory require Sencho Admiral. Remove the lifetime-plan references from the licensing, settings, and troubleshooting pages so they reflect subscription-only Admiral pricing. * fix(rbac): omit scoped permissions from /me on the Community tier Scoped role assignments only take effect on the paid tier, but GET /api/permissions/me returned them unconditionally, so a downgraded instance with leftover assignments rendered per-resource affordances the API then rejected with 403. The endpoint now mirrors the permission middleware and includes scoped permissions only on the paid tier. Adds a regression test covering the downgrade case. * docs: use custom-pricing wording on the contact page The two-tier model has no Enterprise tier; reword the contact page's enterprise pricing/deals to custom pricing/deals so it does not imply a tier that no longer exists.
351 lines
16 KiB
TypeScript
351 lines
16 KiB
TypeScript
import { Router, type Request, type Response } from 'express';
|
|
import { DatabaseService } from '../services/DatabaseService';
|
|
import { NodeRegistry } from '../services/NodeRegistry';
|
|
import { MeshError, MeshService, type MeshGlobalAlias, type MeshRegenSummary } from '../services/MeshService';
|
|
import { requireAdmin, requirePaid } from '../middleware/tierGates';
|
|
import { sanitizeForLog } from '../utils/safeLog';
|
|
import { isValidStackName } from '../utils/validation';
|
|
|
|
export const meshRouter = Router();
|
|
|
|
function actorFor(req: Request): string {
|
|
const user = (req as Request & { user?: { username?: string } }).user;
|
|
return user?.username || 'system';
|
|
}
|
|
|
|
meshRouter.get('/status', async (_req: Request, res: Response): Promise<void> => {
|
|
if (!requirePaid(_req, res)) return;
|
|
try {
|
|
const mesh = MeshService.getInstance();
|
|
const status = await mesh.getStatus();
|
|
res.json({ nodes: status, localDataPlane: mesh.getDataPlaneStatus() });
|
|
} catch (err) {
|
|
console.warn('[mesh] /status failed:', sanitizeForLog((err as Error).message));
|
|
res.status(500).json({ error: 'Failed to load mesh status' });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Operator-triggered rerun of the boot-time override regeneration. Walks
|
|
* every `mesh_stacks` row across the fleet and re-pushes each override to
|
|
* its owning node. Useful when a remote node was offline at central boot
|
|
* and the override files there are stale; previously the only recovery
|
|
* path was opt-out + opt-in for every meshed stack on that node.
|
|
*/
|
|
meshRouter.post('/regen-overrides', async (req: Request, res: Response): Promise<void> => {
|
|
if (!requirePaid(req, res)) return;
|
|
if (!requireAdmin(req, res)) return;
|
|
const actor = actorFor(req);
|
|
let summary: MeshRegenSummary | null = null;
|
|
let outcome: 'success' | 'skipped' | 'partial' | 'error' = 'error';
|
|
try {
|
|
summary = await MeshService.getInstance().regenerateAllOverrides();
|
|
outcome = summary.skipped ? 'skipped' : (summary.failures.length === 0 ? 'success' : 'partial');
|
|
res.json(summary);
|
|
} catch (err) {
|
|
outcome = 'error';
|
|
console.warn('[mesh] /regen-overrides failed:', sanitizeForLog((err as Error).message));
|
|
res.status(500).json({ error: 'Failed to regenerate mesh overrides' });
|
|
} finally {
|
|
try {
|
|
DatabaseService.getInstance().insertAuditLog({
|
|
timestamp: Date.now(),
|
|
username: actor,
|
|
method: 'POST',
|
|
path: req.path,
|
|
status_code: res.statusCode,
|
|
node_id: null,
|
|
ip_address: req.ip ?? 'unknown',
|
|
summary: summary
|
|
? `Mesh override regen ${outcome}: ${summary.regenerated} regenerated, ${summary.failures.length} failed`
|
|
: `Mesh override regen ${outcome}`,
|
|
});
|
|
} catch (auditErr) {
|
|
console.error('[mesh] Audit log insert failed:', auditErr);
|
|
}
|
|
}
|
|
});
|
|
|
|
meshRouter.post('/nodes/:nodeId/enable', async (req: Request, res: Response): Promise<void> => {
|
|
if (!requirePaid(req, res)) return;
|
|
if (!requireAdmin(req, res)) return;
|
|
const nodeId = Number.parseInt(req.params.nodeId as string, 10);
|
|
if (!Number.isFinite(nodeId)) { res.status(400).json({ error: 'Invalid node id' }); return; }
|
|
try {
|
|
await MeshService.getInstance().enableForNode(nodeId);
|
|
res.json({ ok: true });
|
|
} catch (err) {
|
|
res.status(500).json({ error: (err as Error).message });
|
|
}
|
|
});
|
|
|
|
meshRouter.post('/nodes/:nodeId/disable', async (req: Request, res: Response): Promise<void> => {
|
|
if (!requirePaid(req, res)) return;
|
|
if (!requireAdmin(req, res)) return;
|
|
const nodeId = Number.parseInt(req.params.nodeId as string, 10);
|
|
if (!Number.isFinite(nodeId)) { res.status(400).json({ error: 'Invalid node id' }); return; }
|
|
try {
|
|
await MeshService.getInstance().disableForNode(nodeId, actorFor(req));
|
|
res.json({ ok: true });
|
|
} catch (err) {
|
|
res.status(500).json({ error: (err as Error).message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Returns the LOCAL Docker daemon's services for a stack with their listening
|
|
* ports. Always queries this Sencho instance's own Dockerode regardless of
|
|
* `x-node-id`. Central calls this endpoint against each remote node via the
|
|
* existing proxy chain (`NodeRegistry.getProxyTarget`) so it can build the
|
|
* cross-fleet alias cache without violating the local-only Dockerode rule.
|
|
*/
|
|
meshRouter.get('/local-services/:stackName', async (req: Request, res: Response): Promise<void> => {
|
|
if (!requirePaid(req, res)) return;
|
|
const stackName = req.params.stackName as string;
|
|
if (!isValidStackName(stackName)) { res.status(400).json({ error: 'Invalid stack name' }); return; }
|
|
try {
|
|
const services = await MeshService.getInstance().inspectLocalStackServices(stackName);
|
|
res.json({ services });
|
|
} catch (err) {
|
|
console.warn('[mesh] /local-services failed:', sanitizeForLog((err as Error).message));
|
|
res.status(500).json({ error: 'Failed to list local services' });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Returns the LOCAL Sencho's compose stacks. Always queries this
|
|
* instance's own filesystem regardless of `x-node-id`. Central calls this
|
|
* endpoint against each remote node via the existing proxy chain
|
|
* (`NodeRegistry.getProxyTarget`) so the mesh opt-in sheet can show the
|
|
* stacks deployed on the remote pilot rather than central's own list.
|
|
*/
|
|
meshRouter.get('/local-stacks', async (req: Request, res: Response): Promise<void> => {
|
|
if (!requirePaid(req, res)) return;
|
|
try {
|
|
const stacks = await MeshService.getInstance().listLocalStacks();
|
|
res.json({ stacks });
|
|
} catch (err) {
|
|
console.warn('[mesh] /local-stacks failed:', sanitizeForLog((err as Error).message));
|
|
res.status(500).json({ error: 'Failed to list local stacks' });
|
|
}
|
|
});
|
|
|
|
const MAX_ALIASES_PER_PUSH = 1024;
|
|
|
|
function parsePortAlias(entry: unknown): MeshGlobalAlias | null {
|
|
const e = entry as Record<string, unknown>;
|
|
const { host, nodeId, nodeName, stackName, serviceName, port } = e ?? {};
|
|
if (
|
|
typeof host !== 'string' || host.length === 0 || host.length > 253 ||
|
|
typeof nodeId !== 'number' ||
|
|
typeof nodeName !== 'string' || nodeName.length === 0 ||
|
|
typeof stackName !== 'string' || stackName.length === 0 ||
|
|
typeof serviceName !== 'string' || serviceName.length === 0 ||
|
|
typeof port !== 'number' || !Number.isInteger(port) || port < 1 || port > 65535
|
|
) return null;
|
|
return { host, nodeId, nodeName, stackName, serviceName, port };
|
|
}
|
|
|
|
/**
|
|
* Accepts a fleet-wide alias list from central and writes a mesh override
|
|
* for the named stack onto THIS Sencho's local DATA_DIR. The pilot looks
|
|
* up its own service names and uses its own static IP on `sencho_mesh`,
|
|
* so alias hostnames in user containers always resolve to the LOCAL
|
|
* Sencho IP on the deploying node. Always writes against the LOCAL
|
|
* Sencho's default node id.
|
|
*/
|
|
meshRouter.put('/local-override/:stackName', async (req: Request, res: Response): Promise<void> => {
|
|
if (!requirePaid(req, res)) return;
|
|
const stackName = req.params.stackName as string;
|
|
if (!isValidStackName(stackName)) { res.status(400).json({ error: 'Invalid stack name' }); return; }
|
|
const body = req.body as { aliases?: unknown; portAliases?: unknown };
|
|
if (!Array.isArray(body?.aliases)) { res.status(400).json({ error: 'Missing aliases array in body' }); return; }
|
|
if (body.aliases.length > MAX_ALIASES_PER_PUSH) {
|
|
res.status(413).json({ error: `Alias list exceeds ${MAX_ALIASES_PER_PUSH} entries` });
|
|
return;
|
|
}
|
|
const aliases: { host: string }[] = [];
|
|
for (const entry of body.aliases) {
|
|
const host = (entry as { host?: unknown } | null | undefined)?.host;
|
|
if (typeof host !== 'string' || host.length === 0 || host.length > 253) {
|
|
// 253 octets is the DNS hostname ceiling. Defensive against a
|
|
// malicious or buggy central sending a multi-KB host string.
|
|
res.status(400).json({ error: 'Invalid alias entry' });
|
|
return;
|
|
}
|
|
aliases.push({ host });
|
|
}
|
|
const portAliases: MeshGlobalAlias[] = [];
|
|
if (Array.isArray(body?.portAliases)) {
|
|
if (body.portAliases.length > MAX_ALIASES_PER_PUSH) {
|
|
res.status(413).json({ error: `portAliases list exceeds ${MAX_ALIASES_PER_PUSH} entries` });
|
|
return;
|
|
}
|
|
for (const entry of body.portAliases) {
|
|
const parsed = parsePortAlias(entry);
|
|
if (!parsed) { res.status(400).json({ error: 'Invalid portAliases entry' }); return; }
|
|
portAliases.push(parsed);
|
|
}
|
|
}
|
|
try {
|
|
const written = await MeshService.getInstance().applyLocalOverride(stackName, aliases, portAliases);
|
|
if (!written) { res.status(400).json({ error: 'Refused to write override (path validation failed)' }); return; }
|
|
res.json({ ok: true, path: written });
|
|
} catch (err) {
|
|
if (err instanceof MeshError && err.code === 'push_failed') {
|
|
res.status(503).json({ error: err.message, code: err.code });
|
|
return;
|
|
}
|
|
console.warn('[mesh] /local-override failed:', sanitizeForLog((err as Error).message));
|
|
res.status(500).json({ error: 'Failed to write local override' });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Delete a previously written local override. Mirror of the PUT endpoint;
|
|
* called by central when a stack is opted out so stale overrides do not
|
|
* linger on the deploying node.
|
|
*/
|
|
meshRouter.delete('/local-override/:stackName', async (req: Request, res: Response): Promise<void> => {
|
|
if (!requirePaid(req, res)) return;
|
|
const stackName = req.params.stackName as string;
|
|
if (!isValidStackName(stackName)) { res.status(400).json({ error: 'Invalid stack name' }); return; }
|
|
try {
|
|
await MeshService.getInstance().removeLocalOverride(stackName);
|
|
res.json({ ok: true });
|
|
} catch (err) {
|
|
console.warn('[mesh] DELETE /local-override failed:', sanitizeForLog((err as Error).message));
|
|
res.status(500).json({ error: 'Failed to remove local override' });
|
|
}
|
|
});
|
|
|
|
meshRouter.get('/nodes/:nodeId/stacks', async (req: Request, res: Response): Promise<void> => {
|
|
if (!requirePaid(req, res)) return;
|
|
const nodeId = Number.parseInt(req.params.nodeId as string, 10);
|
|
if (!Number.isFinite(nodeId)) { res.status(400).json({ error: 'Invalid node id' }); return; }
|
|
try {
|
|
const db = DatabaseService.getInstance();
|
|
const optedIn = new Set(db.listMeshStacks(nodeId).map((s) => s.stack_name));
|
|
const stacks = await MeshService.getInstance().listStacksOnNode(nodeId);
|
|
res.json({
|
|
stacks: stacks.map((stackName) => ({
|
|
name: stackName,
|
|
optedIn: optedIn.has(stackName),
|
|
})),
|
|
});
|
|
} catch (err) {
|
|
console.warn('[mesh] list stacks failed:', sanitizeForLog((err as Error).message));
|
|
res.status(500).json({ error: 'Failed to list stacks' });
|
|
}
|
|
});
|
|
|
|
meshRouter.post('/nodes/:nodeId/stacks/:stackName/opt-in', async (req: Request, res: Response): Promise<void> => {
|
|
if (!requirePaid(req, res)) return;
|
|
if (!requireAdmin(req, res)) return;
|
|
const nodeId = Number.parseInt(req.params.nodeId as string, 10);
|
|
const stackName = req.params.stackName as string;
|
|
if (!Number.isFinite(nodeId) || !stackName) { res.status(400).json({ error: 'Invalid params' }); return; }
|
|
try {
|
|
await MeshService.getInstance().optInStack(nodeId, stackName, actorFor(req));
|
|
res.json({ ok: true });
|
|
} catch (err) {
|
|
if (err instanceof MeshError && err.code === 'port_collision') {
|
|
res.status(409).json({ error: err.message, code: err.code });
|
|
return;
|
|
}
|
|
if (err instanceof MeshError && err.code === 'push_failed') {
|
|
res.status(503).json({ error: err.message, code: err.code });
|
|
return;
|
|
}
|
|
if (err instanceof MeshError) {
|
|
res.status(400).json({ error: err.message, code: err.code });
|
|
return;
|
|
}
|
|
console.warn('[mesh] opt-in failed:', sanitizeForLog((err as Error).message));
|
|
res.status(500).json({ error: 'Opt-in failed' });
|
|
}
|
|
});
|
|
|
|
meshRouter.post('/nodes/:nodeId/stacks/:stackName/opt-out', async (req: Request, res: Response): Promise<void> => {
|
|
if (!requirePaid(req, res)) return;
|
|
if (!requireAdmin(req, res)) return;
|
|
const nodeId = Number.parseInt(req.params.nodeId as string, 10);
|
|
const stackName = req.params.stackName as string;
|
|
if (!Number.isFinite(nodeId) || !stackName) { res.status(400).json({ error: 'Invalid params' }); return; }
|
|
try {
|
|
await MeshService.getInstance().optOutStack(nodeId, stackName, actorFor(req));
|
|
res.json({ ok: true });
|
|
} catch (err) {
|
|
console.warn('[mesh] opt-out failed:', sanitizeForLog((err as Error).message));
|
|
res.status(500).json({ error: 'Opt-out failed' });
|
|
}
|
|
});
|
|
|
|
meshRouter.get('/aliases', async (req: Request, res: Response): Promise<void> => {
|
|
if (!requirePaid(req, res)) return;
|
|
try {
|
|
const aliases = await MeshService.getInstance().listAliases();
|
|
res.json({ aliases });
|
|
} catch (err) {
|
|
console.warn('[mesh] /aliases failed:', sanitizeForLog((err as Error).message));
|
|
res.status(500).json({ error: 'Failed to list aliases' });
|
|
}
|
|
});
|
|
|
|
meshRouter.get('/aliases/:alias/diagnostic', async (req: Request, res: Response): Promise<void> => {
|
|
if (!requirePaid(req, res)) return;
|
|
try {
|
|
const diag = await MeshService.getInstance().getRouteDiagnostic(req.params.alias as string);
|
|
res.json(diag);
|
|
} catch (err) {
|
|
res.status(500).json({ error: (err as Error).message });
|
|
}
|
|
});
|
|
|
|
meshRouter.post('/aliases/:alias/test', async (req: Request, res: Response): Promise<void> => {
|
|
if (!requirePaid(req, res)) return;
|
|
try {
|
|
const sourceNodeId = NodeRegistry.getInstance().getDefaultNodeId();
|
|
const result = await MeshService.getInstance().testUpstream(req.params.alias as string, sourceNodeId);
|
|
res.json(result);
|
|
} catch (err) {
|
|
res.status(500).json({ error: (err as Error).message });
|
|
}
|
|
});
|
|
|
|
meshRouter.get('/nodes/:nodeId/diagnostic', async (req: Request, res: Response): Promise<void> => {
|
|
if (!requirePaid(req, res)) return;
|
|
const nodeId = Number.parseInt(req.params.nodeId as string, 10);
|
|
if (!Number.isFinite(nodeId)) { res.status(400).json({ error: 'Invalid node id' }); return; }
|
|
try {
|
|
const diag = await MeshService.getInstance().getNodeDiagnostic(nodeId);
|
|
res.json(diag);
|
|
} catch (err) {
|
|
res.status(500).json({ error: (err as Error).message });
|
|
}
|
|
});
|
|
|
|
meshRouter.get('/activity', (req: Request, res: Response): void => {
|
|
if (!requirePaid(req, res)) return;
|
|
const alias = typeof req.query.alias === 'string' ? req.query.alias : undefined;
|
|
const source = typeof req.query.source === 'string' ? (req.query.source as 'pilot' | 'mesh') : undefined;
|
|
const level = typeof req.query.level === 'string' ? (req.query.level as 'info' | 'warn' | 'error') : undefined;
|
|
const limit = typeof req.query.limit === 'string' ? Number.parseInt(req.query.limit, 10) : 200;
|
|
const events = MeshService.getInstance().getActivity({ alias, source, level, limit });
|
|
res.json({ events });
|
|
});
|
|
|
|
meshRouter.get('/activity/stream', (req: Request, res: Response): void => {
|
|
if (!requirePaid(req, res)) return;
|
|
res.setHeader('Content-Type', 'text/event-stream');
|
|
res.setHeader('Cache-Control', 'no-cache, no-transform');
|
|
res.setHeader('Connection', 'keep-alive');
|
|
res.flushHeaders?.();
|
|
|
|
const send = (event: object) => {
|
|
try { res.write(`data: ${JSON.stringify(event)}\n\n`); } catch { /* ignore */ }
|
|
};
|
|
const unsubscribe = MeshService.getInstance().subscribeActivity(send);
|
|
req.on('close', () => unsubscribe());
|
|
});
|