From 7663f4cd8bb3f0cba89dd994098ce0431a89b0e4 Mon Sep 17 00:00:00 2001 From: Anso Date: Fri, 1 May 2026 01:50:53 -0400 Subject: [PATCH] feat(fleet): sencho mesh in traffic and routing tab (#858) * feat(fleet): sencho mesh in traffic and routing tab Lights up Sencho Mesh: cross-node container forwarding rendered as if the container next to you were on localhost. Builds on the dormant TCP frame plumbing from the prior PR (pilot tunnel TCP frames + sencho-mesh sidecar package) and exposes the Admiral-only orchestrator surface. Backend - New mesh_stacks table (per-node opt-ins) + nodes.mesh_enabled column via DatabaseService.migrateMeshTables. - MeshService singleton: sidecar lifecycle via Dockerode, opt-in/out with cascading override regeneration, request-based resolver from sidecar control WS, cross-node TCP forwarding via PilotTunnelManager (same-node fast path included), in-memory 1000-event activity ring buffer with durable mirror to audit_log for state-change events, per-node and per-route diagnostics, and the Test upstream probe. - MeshComposeOverride: pure YAML generator that injects extra_hosts using host-gateway. The user's docker-compose.yml is never mutated; overrides live under DATA_DIR/mesh/overrides. - ComposeService deploy/update splice the override file when the stack is opted in; non-mesh stacks behave identically to today. - Pilot agent resolveMeshTarget consults the local mesh_stacks table (defense in depth) and resolves Compose containers via Dockerode. - /api/mesh router with 13 Admiral-gated endpoints covering status, enable/disable, stack opt-in/out, alias listing, per-route diagnostic, Test upstream probe, per-node diagnostic, sidecar restart, activity log paginated and SSE. - meshControl WS slot at /api/mesh/control validates the mesh_sidecar JWT minted by MeshService; dispatched as upgrade slot 2 (canonical order preserved). Frontend - New Traffic Routing tab in FleetView, gated by isAdmiral and wrapped in AdmiralGate. Tab uses the cyan brand glyph and italic-serif state typography from the audit. - RoutingTab masthead with mesh activity drawer, per-node card grid with TogglePill, alias rows with five-state pill taxonomy (healthy / degraded / unreachable / tunnel-down / not-authorized), inline Test buttons. - Four sheets: opt-in picker with port-collision inline error, per-route detail with diagnostic + filtered activity, per-node diagnostics with active streams + resolver cache + restart action, fleet-wide activity log with filters. - meshRouteState helper centralizes pill-state mapping; pure-function tests cover all five states. Docs - User docs at /docs/features/sencho-mesh.mdx covering opt-in, troubleshooting, security model (4 guarantees + 4 explicit non-guarantees), and V1 limitations. - Internal architecture and runbook pages. - websocket-dispatch internal doc updated with the new slot. * fix(mesh): validate stack name before path use; fix test DB lifecycle Two surgical fixes against the prior PR. Path-injection (CodeQL js/path-injection): MeshService.optInStack, optOutStack, ensureStackOverride, and removeStackOverride now validate stackName via isValidStackName from utils/validation, reject malicious names at the API boundary, and additionally check isPathWithinBase on the resolved override file path for defense in depth. The dataflow from req.params.stackName to fs.writeFile no longer reaches an unsanitized path expression. Test DB lifecycle: mesh-service.test.ts used per-test setupTestDb / cleanupTestDb, which deletes the temp dir while DatabaseService still holds an open SQLite handle. On Linux CI this raises SQLITE_READONLY_DBMOVED on the next prepare() because the inode has been unlinked. Switched to file-scoped beforeAll/afterAll matching agents-routes.test.ts, with a per-test beforeEach that truncates mesh_stacks plus non-default nodes and resets the MeshService singleton in-memory state. Adds a new test case asserting the path-traversal rejection. * fix(compose): use discovered compose filename instead of hardcoded docker-compose.yml composeArgs() hardcoded `-f docker-compose.yml` for every deploy. Sencho writes its canonical compose file as `compose.yaml`, so any stack created via the UI failed to deploy with `open ...docker-compose.yml: no such file or directory`. When no mesh override applies, drop the explicit `-f` so docker compose's built-in discovery resolves the actual filename. When an override exists, look up the real base filename via FileSystemService.getComposeFilename() and pass both files explicitly. Also hoist the MeshService import to module top now that the dependency is known to be acyclic, and revert the matching unit-test assertion. --- .../__tests__/mesh-compose-override.test.ts | 64 ++ backend/src/__tests__/mesh-service.test.ts | 178 ++++ backend/src/bootstrap/shutdown.ts | 4 + backend/src/bootstrap/startup.ts | 4 + backend/src/index.ts | 2 + backend/src/pilot/agent.ts | 39 +- backend/src/routes/mesh.ts | 191 ++++ backend/src/services/ComposeService.ts | 32 +- backend/src/services/DatabaseService.ts | 57 ++ backend/src/services/FileSystemService.ts | 4 + backend/src/services/MeshComposeOverride.ts | 60 ++ backend/src/services/MeshService.ts | 844 ++++++++++++++++++ backend/src/services/PilotTunnelManager.ts | 9 + backend/src/websocket/meshControl.ts | 57 ++ backend/src/websocket/upgradeHandler.ts | 8 +- docs/docs.json | 1 + docs/features/sencho-mesh.mdx | 94 ++ frontend/src/components/FleetView.tsx | 21 +- .../components/fleet/MeshActivitySheet.tsx | 86 ++ .../components/fleet/MeshDiagnosticsSheet.tsx | 132 +++ .../src/components/fleet/MeshOptInSheet.tsx | 118 +++ .../components/fleet/MeshRouteDetailSheet.tsx | 139 +++ .../src/components/fleet/RoutingNodeCard.tsx | 141 +++ frontend/src/components/fleet/RoutingTab.tsx | 218 +++++ .../components/fleet/meshRouteState.test.ts | 62 ++ .../src/components/fleet/meshRouteState.ts | 47 + frontend/src/types/mesh.ts | 68 ++ 27 files changed, 2667 insertions(+), 13 deletions(-) create mode 100644 backend/src/__tests__/mesh-compose-override.test.ts create mode 100644 backend/src/__tests__/mesh-service.test.ts create mode 100644 backend/src/routes/mesh.ts create mode 100644 backend/src/services/MeshComposeOverride.ts create mode 100644 backend/src/services/MeshService.ts create mode 100644 backend/src/websocket/meshControl.ts create mode 100644 docs/features/sencho-mesh.mdx create mode 100644 frontend/src/components/fleet/MeshActivitySheet.tsx create mode 100644 frontend/src/components/fleet/MeshDiagnosticsSheet.tsx create mode 100644 frontend/src/components/fleet/MeshOptInSheet.tsx create mode 100644 frontend/src/components/fleet/MeshRouteDetailSheet.tsx create mode 100644 frontend/src/components/fleet/RoutingNodeCard.tsx create mode 100644 frontend/src/components/fleet/RoutingTab.tsx create mode 100644 frontend/src/components/fleet/meshRouteState.test.ts create mode 100644 frontend/src/components/fleet/meshRouteState.ts create mode 100644 frontend/src/types/mesh.ts diff --git a/backend/src/__tests__/mesh-compose-override.test.ts b/backend/src/__tests__/mesh-compose-override.test.ts new file mode 100644 index 00000000..c4715ca5 --- /dev/null +++ b/backend/src/__tests__/mesh-compose-override.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; +import * as YAML from 'yaml'; +import { buildAliasHosts, generateOverrideYaml } from '../services/MeshComposeOverride'; + +describe('generateOverrideYaml', () => { + it('emits services with extra_hosts pointing to host-gateway', () => { + const yaml = generateOverrideYaml({ + services: ['web', 'cache'], + aliases: [ + { host: 'db.api.opsix.sencho' }, + { host: 'etl.worker.opsix.sencho' }, + ], + }); + const parsed = YAML.parse(yaml) as Record; + expect(parsed.networks).toBeUndefined(); + const services = parsed.services as Record; + expect(Object.keys(services).sort()).toEqual(['cache', 'web']); + for (const svc of ['web', 'cache']) { + expect(services[svc].extra_hosts).toEqual([ + 'db.api.opsix.sencho:host-gateway', + 'etl.worker.opsix.sencho:host-gateway', + ]); + } + }); + + it('emits empty service stubs when no aliases exist yet', () => { + const yaml = generateOverrideYaml({ + services: ['web'], + aliases: [], + }); + const parsed = YAML.parse(yaml) as Record; + const services = parsed.services as Record; + expect(services.web.extra_hosts).toBeUndefined(); + }); + + it('produces stable output regardless of input ordering', () => { + const a = generateOverrideYaml({ + services: ['web', 'cache'], + aliases: [ + { host: 'b.x.y.sencho' }, + { host: 'a.x.y.sencho' }, + ], + }); + const b = generateOverrideYaml({ + services: ['cache', 'web'], + aliases: [ + { host: 'a.x.y.sencho' }, + { host: 'b.x.y.sencho' }, + ], + }); + expect(a).toBe(b); + }); +}); + +describe('buildAliasHosts', () => { + it('maps services to alias hostnames', () => { + const out = buildAliasHosts({ + nodeName: 'opsix', + stackName: 'api', + services: [{ service: 'db', ports: [5432] }, { service: 'cache', ports: [6379] }], + }); + expect(out).toEqual(['db.api.opsix.sencho', 'cache.api.opsix.sencho']); + }); +}); diff --git a/backend/src/__tests__/mesh-service.test.ts b/backend/src/__tests__/mesh-service.test.ts new file mode 100644 index 00000000..1954d950 --- /dev/null +++ b/backend/src/__tests__/mesh-service.test.ts @@ -0,0 +1,178 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb'; + +let tmpDir: string; +let MeshService: typeof import('../services/MeshService').MeshService; +let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ MeshService } = await import('../services/MeshService')); + ({ DatabaseService } = await import('../services/DatabaseService')); +}); + +afterAll(() => { + cleanupTestDb(tmpDir); +}); + +beforeEach(() => { + const db = DatabaseService.getInstance().getDb(); + db.prepare('DELETE FROM mesh_stacks').run(); + db.prepare('DELETE FROM nodes WHERE is_default = 0').run(); + const svc = MeshService.getInstance() as unknown as { + aliasCache: Map; + aliasByPort: Map; + activity: unknown[]; + activeStreams: Map; + routeErrorMap: Map; + routeLatencyMap: Map; + }; + svc.aliasCache = new Map(); + svc.aliasByPort = new Map(); + svc.activity = []; + svc.activeStreams = new Map(); + svc.routeErrorMap = new Map(); + svc.routeLatencyMap = new Map(); + vi.restoreAllMocks(); +}); + +describe('MeshService.optInStack', () => { + it('writes a mesh_stacks row and rejects duplicate ports', async () => { + const svc = MeshService.getInstance(); + + vi.spyOn(svc as unknown as { inspectStackServices: (n: number, s: string) => Promise }, 'inspectStackServices') + .mockResolvedValue([{ service: 'db', ports: [5432] }]); + vi.spyOn(svc as unknown as { regenerateOverridesForNode: (n: number) => Promise }, 'regenerateOverridesForNode') + .mockResolvedValue(undefined); + + const db = DatabaseService.getInstance(); + const localNodeId = db.getNodes()[0].id; + + await svc.optInStack(localNodeId, 'api', 'tester'); + expect(db.isMeshStackEnabled(localNodeId, 'api')).toBe(true); + + await expect(svc.optInStack(localNodeId, 'shadow', 'tester')) + .rejects.toThrow(/port 5432 is already claimed/); + }); + + it('opt-out removes the row and the override', async () => { + const svc = MeshService.getInstance(); + vi.spyOn(svc as unknown as { inspectStackServices: (n: number, s: string) => Promise }, 'inspectStackServices') + .mockResolvedValue([{ service: 'db', ports: [5432] }]); + vi.spyOn(svc as unknown as { regenerateOverridesForNode: (n: number) => Promise }, 'regenerateOverridesForNode') + .mockResolvedValue(undefined); + vi.spyOn(svc as unknown as { removeStackOverride: (n: number, s: string) => Promise }, 'removeStackOverride') + .mockResolvedValue(undefined); + + const db = DatabaseService.getInstance(); + const localNodeId = db.getNodes()[0].id; + await svc.optInStack(localNodeId, 'api', 'tester'); + await svc.optOutStack(localNodeId, 'api', 'tester'); + expect(db.isMeshStackEnabled(localNodeId, 'api')).toBe(false); + }); + + it('rejects an invalid stack name (path traversal attempt)', async () => { + const svc = MeshService.getInstance(); + const db = DatabaseService.getInstance(); + const localNodeId = db.getNodes()[0].id; + await expect(svc.optInStack(localNodeId, '../../etc/passwd', 'tester')) + .rejects.toThrow(/invalid stack name/); + expect(db.isMeshStackEnabled(localNodeId, '../../etc/passwd')).toBe(false); + }); +}); + +describe('MeshService activity log', () => { + it('keeps the most recent events under the 1000-cap', () => { + const svc = MeshService.getInstance(); + for (let i = 0; i < 1100; i++) { + svc.logActivity({ source: 'mesh', level: 'info', type: 'opt_in', message: `evt-${i}` }); + } + const all = svc.getActivity({ limit: 2000 }); + expect(all.length).toBe(1000); + expect(all[0].message).toBe('evt-100'); + expect(all[all.length - 1].message).toBe('evt-1099'); + }); + + it('filters by alias / source / level', () => { + const svc = MeshService.getInstance(); + svc.logActivity({ source: 'mesh', level: 'info', type: 'opt_in', alias: 'a.b.c.sencho', message: 'a' }); + svc.logActivity({ source: 'pilot', level: 'error', type: 'tunnel.fail', alias: 'a.b.c.sencho', message: 'b' }); + svc.logActivity({ source: 'sidecar', level: 'info', type: 'route.resolve.ok', alias: 'x.y.z.sencho', message: 'c' }); + + expect(svc.getActivity({ alias: 'a.b.c.sencho' }).length).toBe(2); + expect(svc.getActivity({ source: 'pilot' }).length).toBe(1); + expect(svc.getActivity({ level: 'error' }).length).toBe(1); + }); + + it('subscribeActivity fires for new events and unsubscribes cleanly', () => { + const svc = MeshService.getInstance(); + const seen: string[] = []; + const unsubscribe = svc.subscribeActivity((e) => seen.push(e.message)); + svc.logActivity({ source: 'mesh', level: 'info', type: 'mesh.enable', message: 'one' }); + unsubscribe(); + svc.logActivity({ source: 'mesh', level: 'info', type: 'mesh.disable', message: 'two' }); + expect(seen).toEqual(['one']); + }); +}); + +describe('MeshService.testUpstream tunnel-down path', () => { + it('returns ok:false where=pilot_tunnel when no tunnel is registered', async () => { + const svc = MeshService.getInstance(); + const db = DatabaseService.getInstance(); + const localNodeId = db.getNodes()[0].id; + const remoteNodeId = db.addNode({ + name: 'opsix', type: 'remote', is_default: false, + compose_dir: '/tmp', api_url: 'https://opsix.example', + api_token: 'tok', mode: 'pilot_agent', + }); + + (svc as unknown as { aliasCache: Map }).aliasCache = new Map([ + ['db.api.opsix.sencho', { + host: 'db.api.opsix.sencho', + nodeId: remoteNodeId, + nodeName: 'opsix', + stackName: 'api', + serviceName: 'db', + port: 5432, + }], + ]); + db.insertMeshStack(remoteNodeId, 'api', 'tester'); + + const result = await svc.testUpstream('db.api.opsix.sencho', localNodeId); + expect(result.ok).toBe(false); + expect(result.where).toBe('pilot_tunnel'); + expect(result.code).toBe('tunnel_down'); + }); + + it('returns ok:false where=agent_resolve when target stack is not opted in', async () => { + const svc = MeshService.getInstance(); + const db = DatabaseService.getInstance(); + const localNodeId = db.getNodes()[0].id; + + (svc as unknown as { aliasCache: Map }).aliasCache = new Map([ + ['db.api.opsix.sencho', { + host: 'db.api.opsix.sencho', + nodeId: localNodeId, + nodeName: 'opsix', + stackName: 'api', + serviceName: 'db', + port: 5432, + }], + ]); + + const result = await svc.testUpstream('db.api.opsix.sencho', localNodeId); + expect(result.ok).toBe(false); + expect(result.where).toBe('agent_resolve'); + expect(result.code).toBe('denied'); + }); + + it('returns ok:false where=sidecar when alias is unknown', async () => { + const svc = MeshService.getInstance(); + const localNodeId = DatabaseService.getInstance().getNodes()[0].id; + + const result = await svc.testUpstream('nonexistent.sencho', localNodeId); + expect(result.ok).toBe(false); + expect(result.where).toBe('sidecar'); + expect(result.code).toBe('no_route'); + }); +}); diff --git a/backend/src/bootstrap/shutdown.ts b/backend/src/bootstrap/shutdown.ts index 06d70278..df6b31b6 100644 --- a/backend/src/bootstrap/shutdown.ts +++ b/backend/src/bootstrap/shutdown.ts @@ -7,6 +7,7 @@ import { DockerEventManager } from '../services/DockerEventManager'; import { ImageUpdateService } from '../services/ImageUpdateService'; import { SchedulerService } from '../services/SchedulerService'; import { MfaService } from '../services/MfaService'; +import { MeshService } from '../services/MeshService'; /** * Wire graceful shutdown handlers. Docker sends SIGTERM when the container @@ -39,6 +40,9 @@ export function installShutdownHandlers(server: Server): void { try { MfaService.getInstance().stop(); } catch (e) { console.warn('[Shutdown] MfaService cleanup failed:', (e as Error).message); } + MeshService.getInstance().stop().catch((e) => { + console.warn('[Shutdown] MeshService cleanup failed:', (e as Error).message); + }); try { DatabaseService.getInstance().flushAuditLogBuffer(); } catch (e) { console.warn('[Shutdown] Audit log flush failed:', (e as Error).message); } diff --git a/backend/src/bootstrap/startup.ts b/backend/src/bootstrap/startup.ts index 19812cda..0d1c3999 100644 --- a/backend/src/bootstrap/startup.ts +++ b/backend/src/bootstrap/startup.ts @@ -10,6 +10,7 @@ import TrivyService from '../services/TrivyService'; import { ImageUpdateService } from '../services/ImageUpdateService'; import { SchedulerService } from '../services/SchedulerService'; import { MfaService } from '../services/MfaService'; +import { MeshService } from '../services/MeshService'; import { sweepStaleTempDirs as sweepStaleGitTempDirs } from '../services/GitSourceService'; import { PORT } from '../helpers/constants'; @@ -38,6 +39,9 @@ export async function startServer(server: Server): Promise { ImageUpdateService.getInstance().start(); SchedulerService.getInstance().start(); MfaService.getInstance().start(); + MeshService.getInstance().start().catch((err) => { + console.warn('[Startup] MeshService start failed:', (err as Error).message); + }); // Async initializers are independent of each other; run in parallel // so total boot time is the slowest one rather than the sum. diff --git a/backend/src/index.ts b/backend/src/index.ts index c92a8883..a61f8587 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -27,6 +27,7 @@ import { apiTokensRouter } from './routes/apiTokens'; import { auditLogRouter } from './routes/auditLog'; import { settingsRouter } from './routes/settings'; import { scheduledTasksRouter } from './routes/scheduledTasks'; +import { meshRouter } from './routes/mesh'; import { agentsRouter } from './routes/agents'; import { metricsRouter } from './routes/metrics'; import { imageUpdatesRouter, autoUpdateRouter } from './routes/imageUpdates'; @@ -101,6 +102,7 @@ app.use('/api/git-sources', gitSourcesRouter); app.use('/api/stacks', stackGitSourceRouter); app.use('/api/settings', settingsRouter); app.use('/api/scheduled-tasks', scheduledTasksRouter); +app.use('/api/mesh', meshRouter); app.use('/api/agents', agentsRouter); app.use('/api', metricsRouter); app.use('/api/image-updates', imageUpdatesRouter); diff --git a/backend/src/pilot/agent.ts b/backend/src/pilot/agent.ts index 2586ac08..9c3e916e 100644 --- a/backend/src/pilot/agent.ts +++ b/backend/src/pilot/agent.ts @@ -416,15 +416,42 @@ class PilotAgent { } /** - * Always returns ok:false in PR 1. Tests inject a real resolver via - * setMeshResolver; PR 2 will install the Dockerode-backed implementation. + * Resolves a mesh target by consulting the local mesh_stacks opt-in table + * and Compose container labels. Refuses if the target stack is not opted + * in on this node (defense-in-depth: the primary is trusted, but we also + * gate at the agent so a leaked tunnel token cannot reach unauthorized + * services). */ private async resolveMeshTarget( - _stack: string, - _service: string, - _port: number, + stack: string, + service: string, + port: number, ): Promise { - return { ok: false, err: 'mesh_not_enabled' }; + try { + const { DatabaseService } = await import('../services/DatabaseService'); + const { NodeRegistry } = await import('../services/NodeRegistry'); + const dockerodeMod = await import('dockerode'); + const Docker = (dockerodeMod as { default: new (opts?: unknown) => { listContainers: (opts?: unknown) => Promise } }).default; + const db = DatabaseService.getInstance(); + const localNodeId = NodeRegistry.getInstance().getDefaultNodeId(); + if (!db.isMeshStackEnabled(localNodeId, stack)) { + return { ok: false, err: 'denied' }; + } + const docker = new Docker(); + const containers = (await docker.listContainers({ + filters: { label: [`com.docker.compose.project=${stack}`, `com.docker.compose.service=${service}`] }, + })) as Array<{ NetworkSettings?: { Networks?: Record } }>; + for (const c of containers) { + const networks = c.NetworkSettings?.Networks ?? {}; + for (const net of Object.values(networks)) { + if (net.IPAddress) return { ok: true, host: net.IPAddress, port }; + } + } + return { ok: false, err: 'no_target' }; + } catch (err) { + console.warn('[Pilot] resolveMeshTarget failed:', sanitizeForLog((err as Error).message)); + return { ok: false, err: 'agent_error' }; + } } } diff --git a/backend/src/routes/mesh.ts b/backend/src/routes/mesh.ts new file mode 100644 index 00000000..38ec0acc --- /dev/null +++ b/backend/src/routes/mesh.ts @@ -0,0 +1,191 @@ +import { Router, type Request, type Response } from 'express'; +import { DatabaseService } from '../services/DatabaseService'; +import { NodeRegistry } from '../services/NodeRegistry'; +import { MeshError, MeshService } from '../services/MeshService'; +import { requireAdmin, requireAdmiral } from '../middleware/tierGates'; +import { sanitizeForLog } from '../utils/safeLog'; + +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 => { + if (!requireAdmiral(_req, res)) return; + try { + const status = await MeshService.getInstance().getStatus(); + res.json({ nodes: status }); + } catch (err) { + console.warn('[mesh] /status failed:', sanitizeForLog((err as Error).message)); + res.status(500).json({ error: 'Failed to load mesh status' }); + } +}); + +meshRouter.post('/nodes/:nodeId/enable', async (req: Request, res: Response): Promise => { + if (!requireAdmiral(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 => { + if (!requireAdmiral(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); + res.json({ ok: true }); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } +}); + +meshRouter.get('/nodes/:nodeId/stacks', async (req: Request, res: Response): Promise => { + if (!requireAdmiral(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 fsSvc = (await import('../services/FileSystemService')).FileSystemService.getInstance(nodeId); + const stacks = await fsSvc.getStacks(); + res.json({ + stacks: stacks.map((stackName: string) => ({ + 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 => { + if (!requireAdmiral(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) { + 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 => { + if (!requireAdmiral(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 => { + if (!requireAdmiral(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 => { + if (!requireAdmiral(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 => { + if (!requireAdmiral(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 => { + if (!requireAdmiral(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.post('/nodes/:nodeId/sidecar/restart', async (req: Request, res: Response): Promise => { + if (!requireAdmiral(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().stopSidecar(nodeId); + await MeshService.getInstance().spawnSidecar(nodeId); + res.json({ ok: true }); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } +}); + +meshRouter.get('/activity', (req: Request, res: Response): void => { + if (!requireAdmiral(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 'sidecar' | '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 (!requireAdmiral(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()); +}); diff --git a/backend/src/services/ComposeService.ts b/backend/src/services/ComposeService.ts index f1ad807e..492e4d50 100644 --- a/backend/src/services/ComposeService.ts +++ b/backend/src/services/ComposeService.ts @@ -6,6 +6,7 @@ import WebSocket from 'ws'; import DockerController from './DockerController'; import { DatabaseService } from './DatabaseService'; import { FileSystemService } from './FileSystemService'; +import { MeshService } from './MeshService'; import { LogFormatter } from './LogFormatter'; import { NodeRegistry } from './NodeRegistry'; import { RegistryService } from './RegistryService'; @@ -33,6 +34,29 @@ export class ComposeService { return new ComposeService(nodeId); } + /** + * Build the `docker compose` argument prefix for a stack, splicing in the + * Sencho Mesh override file if the stack is opted into the mesh. When no + * override applies, returns args without `-f` so docker compose's built-in + * file discovery resolves the stack's actual compose filename. The user's + * source compose file is never mutated. + */ + private async composeArgs(stackName: string, action: string[]): Promise { + const args: string[] = ['compose']; + let overridePath: string | null = null; + try { + overridePath = await MeshService.getInstance().ensureStackOverride(this.nodeId, stackName); + } catch (err) { + console.warn('[ComposeService] mesh override skipped:', sanitizeForLog((err as Error).message)); + } + if (overridePath) { + const baseFilename = await FileSystemService.getInstance(this.nodeId).getComposeFilename(stackName); + args.push('-f', baseFilename, '-f', overridePath); + } + args.push(...action); + return args; + } + private execute( command: string, args: string[], @@ -159,7 +183,7 @@ export class ComposeService { } await this.withRegistryAuth(async (env) => { - await this.execute('docker', ['compose', 'up', '-d', '--remove-orphans'], stackDir, ws, true, env); + await this.execute('docker', await this.composeArgs(stackName, ['up', '-d', '--remove-orphans']), stackDir, ws, true, env); }, sendOutput); // Post-Deploy Health Probe @@ -193,7 +217,7 @@ export class ComposeService { const fsSvc = FileSystemService.getInstance(this.nodeId); await fsSvc.restoreStackFiles(stackName); await this.withRegistryAuth(async (env) => { - await this.execute('docker', ['compose', 'up', '-d', '--remove-orphans'], stackDir, ws, true, env); + await this.execute('docker', await this.composeArgs(stackName, ['up', '-d', '--remove-orphans']), stackDir, ws, true, env); }, sendOutput); sendOutput('=== Rolled back successfully ===\n'); } catch (rollbackError) { @@ -353,7 +377,7 @@ export class ComposeService { await this.execute('docker', ['compose', 'pull'], stackDir, ws, true, env); sendOutput('=== Recreating containers ===\n'); - await this.execute('docker', ['compose', 'up', '-d', '--remove-orphans'], stackDir, ws, true, env); + await this.execute('docker', await this.composeArgs(stackName, ['up', '-d', '--remove-orphans']), stackDir, ws, true, env); }, sendOutput); // Post-Update Health Probe @@ -389,7 +413,7 @@ export class ComposeService { const fsSvc = FileSystemService.getInstance(this.nodeId); await fsSvc.restoreStackFiles(stackName); await this.withRegistryAuth(async (env) => { - await this.execute('docker', ['compose', 'up', '-d', '--remove-orphans'], stackDir, ws, true, env); + await this.execute('docker', await this.composeArgs(stackName, ['up', '-d', '--remove-orphans']), stackDir, ws, true, env); }, sendOutput); sendOutput('=== Rolled back successfully ===\n'); } catch (rollbackError) { diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index 4b0c4178..a05ae2e7 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -519,6 +519,7 @@ export class DatabaseService { this.migratePolicyEvaluationColumn(); this.migrateNotificationCategory(); this.migrateNotificationActor(); + this.migrateMeshTables(); // Reset the cache once at end of constructor in case any migration // populated it via getGlobalSettings() and a subsequent migration @@ -1251,6 +1252,62 @@ export class DatabaseService { } } + private migrateMeshTables(): void { + try { + this.db.prepare(` + CREATE TABLE IF NOT EXISTS mesh_stacks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + node_id INTEGER NOT NULL, + stack_name TEXT NOT NULL, + created_at INTEGER NOT NULL, + created_by TEXT, + UNIQUE(node_id, stack_name), + FOREIGN KEY (node_id) REFERENCES nodes(id) ON DELETE CASCADE + ) + `).run(); + this.db.prepare('CREATE INDEX IF NOT EXISTS idx_mesh_stacks_node ON mesh_stacks(node_id)').run(); + } catch (e) { + console.warn('[DatabaseService] Could not create mesh_stacks:', (e as Error).message); + } + this.tryAddColumn('nodes', 'mesh_enabled', 'INTEGER NOT NULL DEFAULT 0'); + } + + // --- Sencho Mesh --- + + public listMeshStacks(nodeId?: number): Array<{ id: number; node_id: number; stack_name: string; created_at: number; created_by: string | null }> { + const sql = nodeId !== undefined + ? 'SELECT id, node_id, stack_name, created_at, created_by FROM mesh_stacks WHERE node_id = ?' + : 'SELECT id, node_id, stack_name, created_at, created_by FROM mesh_stacks'; + const rows = nodeId !== undefined + ? this.db.prepare(sql).all(nodeId) + : this.db.prepare(sql).all(); + return rows as Array<{ id: number; node_id: number; stack_name: string; created_at: number; created_by: string | null }>; + } + + public isMeshStackEnabled(nodeId: number, stackName: string): boolean { + const row = this.db.prepare('SELECT 1 FROM mesh_stacks WHERE node_id = ? AND stack_name = ?').get(nodeId, stackName); + return !!row; + } + + public insertMeshStack(nodeId: number, stackName: string, createdBy: string | null): void { + this.db.prepare( + 'INSERT INTO mesh_stacks (node_id, stack_name, created_at, created_by) VALUES (?, ?, ?, ?)' + ).run(nodeId, stackName, Date.now(), createdBy); + } + + public deleteMeshStack(nodeId: number, stackName: string): void { + this.db.prepare('DELETE FROM mesh_stacks WHERE node_id = ? AND stack_name = ?').run(nodeId, stackName); + } + + public setNodeMeshEnabled(nodeId: number, enabled: boolean): void { + this.db.prepare('UPDATE nodes SET mesh_enabled = ? WHERE id = ?').run(enabled ? 1 : 0, nodeId); + } + + public getNodeMeshEnabled(nodeId: number): boolean { + const row = this.db.prepare('SELECT mesh_enabled FROM nodes WHERE id = ?').get(nodeId) as { mesh_enabled?: number } | undefined; + return !!row?.mesh_enabled; + } + // --- Agents --- public getAgents(nodeId: number): Agent[] { diff --git a/backend/src/services/FileSystemService.ts b/backend/src/services/FileSystemService.ts index a76c7320..cd2cb968 100644 --- a/backend/src/services/FileSystemService.ts +++ b/backend/src/services/FileSystemService.ts @@ -107,6 +107,10 @@ export class FileSystemService { throw new Error(`No compose file found for stack: ${stackName}`); } + async getComposeFilename(stackName: string): Promise { + return path.basename(await this.getComposeFilePath(stackName)); + } + async getStacks(): Promise { try { const items = await fsPromises.readdir(this.baseDir, { withFileTypes: true }); diff --git a/backend/src/services/MeshComposeOverride.ts b/backend/src/services/MeshComposeOverride.ts new file mode 100644 index 00000000..a8b0d8d4 --- /dev/null +++ b/backend/src/services/MeshComposeOverride.ts @@ -0,0 +1,60 @@ +import * as YAML from 'yaml'; + +/** + * Sencho Mesh Compose override generator. + * + * Produces a YAML override applied with `docker compose -f compose.yml -f + * mesh.override.yml up` that injects cross-node alias entries into each + * opted-in service's /etc/hosts. The aliases resolve to the host gateway + * (`host-gateway`), which is where the local Sencho Mesh sidecar listens + * (host network mode). The user's source compose file is never mutated; the + * override lives in Sencho's data dir. + */ + +export interface MeshAlias { + /** `...sencho` */ + host: string; +} + +export interface MeshOverrideInput { + /** Service names from the user's compose file (the override echoes them). */ + services: string[]; + /** Aliases this stack should be able to resolve. Order is normalized in output. */ + aliases: MeshAlias[]; +} + +/** + * Returns a YAML string suitable for `-f mesh.override.yml`. Stable output + * ordering so file content does not churn between deploys. + */ +export function generateOverrideYaml(input: MeshOverrideInput): string { + const sortedServices = [...input.services].sort(); + const sortedAliases = [...input.aliases].sort((a, b) => a.host.localeCompare(b.host)); + + if (sortedAliases.length === 0) { + const services: Record = {}; + for (const svc of sortedServices) services[svc] = {}; + return YAML.stringify({ services }, { lineWidth: 0 }); + } + + const extraHostsList = sortedAliases.map((a) => `${a.host}:host-gateway`); + + const services: Record = {}; + for (const svc of sortedServices) { + services[svc] = { extra_hosts: extraHostsList }; + } + + return YAML.stringify({ services }, { lineWidth: 0 }); +} + +/** + * Build alias hostnames for every opted-in service across the fleet. Pure + * helper consumed by MeshService and the override generator. + */ +export function buildAliasHosts(opts: { + nodeName: string; + stackName: string; + services: Array<{ service: string; ports: number[] }>; +}): string[] { + return opts.services.map((s) => `${s.service}.${opts.stackName}.${opts.nodeName}.sencho`); +} diff --git a/backend/src/services/MeshService.ts b/backend/src/services/MeshService.ts new file mode 100644 index 00000000..01984eb1 --- /dev/null +++ b/backend/src/services/MeshService.ts @@ -0,0 +1,844 @@ +import net from 'net'; +import path from 'path'; +import fs from 'fs/promises'; +import { EventEmitter } from 'events'; +import jwt from 'jsonwebtoken'; +import { DatabaseService } from './DatabaseService'; +import DockerController from './DockerController'; +import { PilotTunnelManager } from './PilotTunnelManager'; +import { generateOverrideYaml, MeshAlias } from './MeshComposeOverride'; +import { sanitizeForLog } from '../utils/safeLog'; +import { isPathWithinBase, isValidStackName } from '../utils/validation'; + +const ACTIVITY_BUFFER_SIZE = 1000; +const ALIAS_REFRESH_INTERVAL_MS = 60_000; +const SIDECAR_CONTAINER_PREFIX = 'sencho-mesh-'; +const DEFAULT_SIDECAR_IMAGE = process.env.SENCHO_MESH_IMAGE || 'saelix/sencho-mesh:latest'; +const SIDECAR_TOKEN_TTL = '7d'; +const PROBE_TIMEOUT_MS = 5_000; +const SLOW_PROBE_THRESHOLD_MS = 500; + +export type MeshActivitySource = 'sidecar' | 'pilot' | 'mesh'; +export type MeshActivityLevel = 'info' | 'warn' | 'error'; +export type MeshActivityType = + | 'route.resolve.ok' | 'route.resolve.denied' + | 'tunnel.open' | 'tunnel.fail' | 'tunnel.backpressure' + | 'opt_in' | 'opt_out' + | 'mesh.enable' | 'mesh.disable' + | 'probe.ok' | 'probe.fail' + | 'sidecar.start' | 'sidecar.stop' | 'sidecar.crash'; + +export interface MeshActivityEvent { + ts: number; + source: MeshActivitySource; + level: MeshActivityLevel; + type: MeshActivityType; + nodeId?: number; + alias?: string; + streamId?: number; + message: string; + details?: Record; +} + +export interface MeshGlobalAlias { + /** `...sencho` */ + host: string; + nodeId: number; + nodeName: string; + stackName: string; + serviceName: string; + port: number; +} + +export interface MeshTarget { + nodeId: number; + stack: string; + service: string; + port: number; + alias: string; +} + +export interface MeshNodeStatus { + nodeId: number; + nodeName: string; + enabled: boolean; + sidecarRunning: boolean; + pilotConnected: boolean; + optedInStacks: string[]; + activeStreamCount: number; +} + +export interface MeshNodeDiagnostic { + nodeId: number; + sidecar: { running: boolean; restartCount: number }; + pilot: { connected: boolean; bufferedAmount: number; lastSeen: number | null }; + activeStreams: Array<{ streamId: number; alias?: string; bytesIn: number; bytesOut: number; ageMs: number }>; + aliasCache: Array<{ host: string; targetNodeId: number; port: number }>; +} + +export interface MeshRouteDiagnostic { + alias: string; + target: MeshTarget | null; + pilot: { connected: boolean; lastSeen: number | null }; + lastError: { ts: number; message: string } | null; + lastProbeMs: number | null; + state: 'healthy' | 'degraded' | 'unreachable' | 'tunnel down' | 'not authorized'; +} + +export interface MeshProbeResult { + ok: boolean; + latencyMs?: number; + where?: 'sidecar' | 'pilot_tunnel' | 'agent_resolve' | 'agent_dial' | 'target_port'; + code?: string; + message?: string; +} + +interface ActiveStreamRecord { + streamId: number; + alias?: string; + bytesIn: number; + bytesOut: number; + openedAt: number; +} + +interface PendingResolve { + sidecarSocket: WebSocketLike; + connId: number; + port: number; + remoteAddr: string; +} + +interface WebSocketLike { + send(data: string | Buffer, opts?: unknown, cb?: (err?: Error) => void): void; + readyState: number; + on(event: string, listener: (...args: unknown[]) => void): unknown; +} + +/** + * Sencho Mesh orchestrator. Owns: + * - sidecar lifecycle (Dockerode-spawned per-instance) + * - opt-in / opt-out persistence and cascading override regeneration + * - global alias aggregation (across the fleet via the existing API) + * - request-based resolution from sidecar control WS + * - cross-node TCP forwarding via PilotTunnelManager + * - probe + diagnostics + activity ring buffer + * + * V1 limitations: + * - one cross-node alias per TCP port across the fleet (port-collision check at opt-in) + * - sidecar runs in host network mode; aliases resolve via `host-gateway` extra_hosts + * - pilot-to-pilot mesh routing is not supported (only central <-> pilot) + */ +export class MeshService extends EventEmitter { + private static instance: MeshService; + private started = false; + private aliasCache = new Map(); + private aliasByPort = new Map(); + private activity: MeshActivityEvent[] = []; + private activeStreams = new Map(); + private pendingResolves = new Map(); + private sidecarSockets = new Set(); + private aliasRefreshTimer?: NodeJS.Timeout; + private routeErrorMap = new Map(); + private routeLatencyMap = new Map(); + private activityListeners = new Set<(e: MeshActivityEvent) => void>(); + + private constructor() { + super(); + this.setMaxListeners(50); + } + + public static getInstance(): MeshService { + if (!MeshService.instance) MeshService.instance = new MeshService(); + return MeshService.instance; + } + + public async start(): Promise { + if (this.started) return; + this.started = true; + + const ptm = PilotTunnelManager.getInstance(); + ptm.on('tunnel-down', (nodeId: number) => this.onTunnelDown(nodeId)); + ptm.on('tunnel-up', (nodeId: number) => this.logActivity({ + source: 'pilot', level: 'info', type: 'tunnel.open', + nodeId, message: `pilot tunnel up for node ${nodeId}`, + })); + + await this.refreshAliasCache(); + this.aliasRefreshTimer = setInterval(() => { + void this.refreshAliasCache().catch((err) => { + console.warn('[MeshService] alias refresh failed:', sanitizeForLog((err as Error).message)); + }); + }, ALIAS_REFRESH_INTERVAL_MS); + + this.logActivity({ + source: 'mesh', level: 'info', type: 'mesh.enable', + message: 'MeshService started', + }); + } + + public async stop(): Promise { + if (!this.started) return; + this.started = false; + if (this.aliasRefreshTimer) { + clearInterval(this.aliasRefreshTimer); + this.aliasRefreshTimer = undefined; + } + for (const ws of this.sidecarSockets) { + try { (ws as { close?: (code: number) => void }).close?.(1000); } catch { /* ignore */ } + } + this.sidecarSockets.clear(); + } + + // --- Activity log --- + + public logActivity(event: Omit): void { + const full: MeshActivityEvent = { ts: Date.now(), ...event }; + this.activity.push(full); + if (this.activity.length > ACTIVITY_BUFFER_SIZE) this.activity.shift(); + for (const listener of this.activityListeners) { + try { listener(full); } catch { /* ignore */ } + } + if (event.alias && event.level === 'error') { + this.routeErrorMap.set(event.alias, { ts: full.ts, message: event.message }); + } + } + + public getActivity(filter?: { alias?: string; source?: MeshActivitySource; level?: MeshActivityLevel; limit?: number }): MeshActivityEvent[] { + let out = this.activity; + if (filter?.alias) out = out.filter((e) => e.alias === filter.alias); + if (filter?.source) out = out.filter((e) => e.source === filter.source); + if (filter?.level) out = out.filter((e) => e.level === filter.level); + const limit = filter?.limit ?? 200; + return out.slice(-limit); + } + + public subscribeActivity(listener: (e: MeshActivityEvent) => void): () => void { + this.activityListeners.add(listener); + return () => { this.activityListeners.delete(listener); }; + } + + // --- Opt-in / opt-out --- + + public async optInStack(nodeId: number, stackName: string, actor: string): Promise { + if (!isValidStackName(stackName)) { + throw new MeshError('denied', `invalid stack name: ${stackName}`); + } + const db = DatabaseService.getInstance(); + if (db.isMeshStackEnabled(nodeId, stackName)) return; + + const services = await this.inspectStackServices(nodeId, stackName); + if (services.length === 0) { + throw new MeshError('no_target', `stack ${stackName} has no running services on this node`); + } + + const newPorts = new Set(); + for (const svc of services) for (const p of svc.ports) newPorts.add(p); + for (const port of newPorts) { + const existing = this.aliasByPort.get(port); + if (existing) { + throw new MeshError( + 'port_collision', + `port ${port} is already claimed by ${existing.host}`, + ); + } + } + + db.insertMeshStack(nodeId, stackName, actor); + await this.refreshAliasCache(); + await this.regenerateOverridesForNode(nodeId); + + this.logActivity({ + source: 'mesh', level: 'info', type: 'opt_in', + nodeId, message: `opt-in ${stackName}`, details: { actor }, + }); + db.insertAuditLog({ + timestamp: Date.now(), username: actor, method: 'POST', + path: `/api/mesh/nodes/${nodeId}/stacks/${stackName}/opt-in`, + status_code: 200, node_id: nodeId, ip_address: '127.0.0.1', + summary: `Sencho Mesh: opted ${stackName} into the mesh`, + }); + } + + public async optOutStack(nodeId: number, stackName: string, actor: string): Promise { + if (!isValidStackName(stackName)) { + throw new MeshError('denied', `invalid stack name: ${stackName}`); + } + const db = DatabaseService.getInstance(); + if (!db.isMeshStackEnabled(nodeId, stackName)) return; + db.deleteMeshStack(nodeId, stackName); + await this.removeStackOverride(nodeId, stackName); + await this.refreshAliasCache(); + await this.regenerateOverridesForNode(nodeId); + + this.logActivity({ + source: 'mesh', level: 'info', type: 'opt_out', + nodeId, message: `opt-out ${stackName}`, details: { actor }, + }); + db.insertAuditLog({ + timestamp: Date.now(), username: actor, method: 'POST', + path: `/api/mesh/nodes/${nodeId}/stacks/${stackName}/opt-out`, + status_code: 200, node_id: nodeId, ip_address: '127.0.0.1', + summary: `Sencho Mesh: opted ${stackName} out of the mesh`, + }); + } + + public async enableForNode(nodeId: number): Promise { + DatabaseService.getInstance().setNodeMeshEnabled(nodeId, true); + this.logActivity({ + source: 'mesh', level: 'info', type: 'mesh.enable', + nodeId, message: `mesh enabled on node ${nodeId}`, + }); + } + + public async disableForNode(nodeId: number): Promise { + DatabaseService.getInstance().setNodeMeshEnabled(nodeId, false); + const stacks = DatabaseService.getInstance().listMeshStacks(nodeId); + for (const s of stacks) { + DatabaseService.getInstance().deleteMeshStack(nodeId, s.stack_name); + await this.removeStackOverride(nodeId, s.stack_name); + } + await this.refreshAliasCache(); + this.logActivity({ + source: 'mesh', level: 'info', type: 'mesh.disable', + nodeId, message: `mesh disabled on node ${nodeId}`, + }); + } + + // --- Override file management --- + + public async ensureStackOverride(nodeId: number, stackName: string): Promise { + if (!isValidStackName(stackName)) return null; + const db = DatabaseService.getInstance(); + if (!db.isMeshStackEnabled(nodeId, stackName)) return null; + + const aliases: MeshAlias[] = Array.from(this.aliasCache.values()).map((a) => ({ host: a.host })); + const services = await this.inspectStackServices(nodeId, stackName); + const yaml = generateOverrideYaml({ + services: services.map((s) => s.service), + aliases, + }); + + const dir = this.overrideDirFor(nodeId); + await fs.mkdir(dir, { recursive: true }); + const file = path.resolve(dir, `${stackName}.override.yml`); + if (!isPathWithinBase(file, dir)) return null; + await fs.writeFile(file, yaml, 'utf8'); + return file; + } + + private async removeStackOverride(nodeId: number, stackName: string): Promise { + if (!isValidStackName(stackName)) return; + const dir = this.overrideDirFor(nodeId); + const file = path.resolve(dir, `${stackName}.override.yml`); + if (!isPathWithinBase(file, dir)) return; + try { await fs.unlink(file); } catch { /* ignore not-exist */ } + } + + private overrideDirFor(nodeId: number): string { + const dataDir = process.env.DATA_DIR || '/app/data'; + return path.join(dataDir, 'mesh', 'overrides', String(nodeId)); + } + + private async regenerateOverridesForNode(nodeId: number): Promise { + const db = DatabaseService.getInstance(); + const stacks = db.listMeshStacks(nodeId); + for (const s of stacks) { + try { + await this.ensureStackOverride(nodeId, s.stack_name); + } catch (err) { + console.warn('[MeshService] override regen failed:', sanitizeForLog((err as Error).message)); + } + } + } + + // --- Alias aggregation --- + + public async refreshAliasCache(): Promise { + const db = DatabaseService.getInstance(); + const next = new Map(); + const portMap = new Map(); + const stacks = db.listMeshStacks(); + + for (const row of stacks) { + const node = db.getNode(row.node_id); + if (!node) continue; + const services = await this.inspectStackServices(row.node_id, row.stack_name).catch(() => []); + for (const svc of services) { + const host = `${svc.service}.${row.stack_name}.${node.name}.sencho`; + for (const port of svc.ports) { + const alias: MeshGlobalAlias = { + host, + nodeId: row.node_id, + nodeName: node.name, + stackName: row.stack_name, + serviceName: svc.service, + port, + }; + next.set(host, alias); + if (!portMap.has(port)) portMap.set(port, alias); + } + } + } + this.aliasCache = next; + this.aliasByPort = portMap; + } + + public async listAliases(): Promise { + return Array.from(this.aliasCache.values()); + } + + /** + * Inspect a stack on a node and return its running services with the ports + * they listen on. Uses Compose container labels. + */ + private async inspectStackServices(nodeId: number, stackName: string): Promise> { + try { + const docker = DockerController.getInstance(nodeId).getDocker(); + const containers = await docker.listContainers({ + all: true, + filters: { label: [`com.docker.compose.project=${stackName}`] }, + }); + const byService = new Map>(); + for (const c of containers) { + const svc = c.Labels?.['com.docker.compose.service']; + if (!svc) continue; + const ports = byService.get(svc) || new Set(); + for (const p of c.Ports || []) { + if (p.PrivatePort) ports.add(p.PrivatePort); + } + byService.set(svc, ports); + } + return Array.from(byService.entries()).map(([service, ports]) => ({ service, ports: Array.from(ports) })); + } catch (err) { + console.warn('[MeshService] inspectStackServices failed:', sanitizeForLog((err as Error).message)); + return []; + } + } + + // --- Resolution + forwarding --- + + public resolveByLocalPort(port: number): MeshTarget | null { + const alias = this.aliasByPort.get(port); + if (!alias) return null; + return { + nodeId: alias.nodeId, + stack: alias.stackName, + service: alias.serviceName, + port: alias.port, + alias: alias.host, + }; + } + + /** + * Forward bytes from a sidecar-accepted local socket to the target. + * Same-node target: open a direct TCP socket. + * Cross-node target: open a pilot-tunnel TcpStream to that node's agent. + */ + public openTcp(target: MeshTarget, src: net.Socket, sourceNodeId: number): void { + if (target.nodeId === sourceNodeId) { + this.openSameNode(target, src); + return; + } + this.openCrossNode(target, src); + } + + private openSameNode(target: MeshTarget, src: net.Socket): void { + // For same-node fast path, the agent's resolution logic isn't needed: + // we can dial via Dockerode's container IP. For V1 simplicity, we dial + // the host-gateway's published port if mapped, falling back to + // 127.0.0.1 (the sidecar runs in host network mode so localhost reaches + // the container's published port). + const upstream = net.createConnection({ host: '127.0.0.1', port: target.port }); + upstream.setTimeout(PROBE_TIMEOUT_MS); + const stream = this.registerActiveStream(target.alias); + upstream.once('connect', () => { + upstream.setTimeout(0); + this.logActivity({ + source: 'mesh', level: 'info', type: 'route.resolve.ok', + alias: target.alias, streamId: stream.streamId, + message: `same-node connect to ${target.alias}`, + }); + src.pipe(upstream); + upstream.pipe(src); + }); + const teardown = () => { + this.activeStreams.delete(stream.streamId); + try { upstream.destroy(); } catch { /* ignore */ } + try { src.destroy(); } catch { /* ignore */ } + }; + upstream.on('error', () => teardown()); + upstream.on('close', () => teardown()); + src.on('error', () => teardown()); + src.on('close', () => teardown()); + } + + private openCrossNode(target: MeshTarget, src: net.Socket): void { + const ptm = PilotTunnelManager.getInstance(); + if (!ptm.hasActiveTunnel(target.nodeId)) { + this.logActivity({ + source: 'pilot', level: 'error', type: 'tunnel.fail', + nodeId: target.nodeId, alias: target.alias, + message: `no active pilot tunnel to node ${target.nodeId}`, + }); + try { src.destroy(); } catch { /* ignore */ } + return; + } + const bridge = ptm.getBridge(target.nodeId); + if (!bridge) { try { src.destroy(); } catch { /* ignore */ } return; } + const tcpStream = bridge.openTcpStream({ stack: target.stack, service: target.service, port: target.port }); + if (!tcpStream) { try { src.destroy(); } catch { /* ignore */ } return; } + + const record = this.registerActiveStream(target.alias, tcpStream.streamId); + const t0 = Date.now(); + tcpStream.on('open', () => { + this.logActivity({ + source: 'mesh', level: 'info', type: 'route.resolve.ok', + nodeId: target.nodeId, alias: target.alias, streamId: tcpStream.streamId, + message: `cross-node connect to ${target.alias}`, + }); + this.routeLatencyMap.set(target.alias, Date.now() - t0); + }); + tcpStream.on('data', (chunk: Buffer) => { + record.bytesIn += chunk.length; + try { src.write(chunk); } catch { /* ignore */ } + }); + tcpStream.on('error', (err: Error) => { + this.logActivity({ + source: 'pilot', level: 'error', type: 'tunnel.fail', + nodeId: target.nodeId, alias: target.alias, streamId: tcpStream.streamId, + message: err.message, + }); + this.activeStreams.delete(tcpStream.streamId); + try { src.destroy(); } catch { /* ignore */ } + }); + tcpStream.on('close', () => { + this.activeStreams.delete(tcpStream.streamId); + try { src.end(); } catch { /* ignore */ } + }); + src.on('data', (chunk: Buffer) => { + record.bytesOut += chunk.length; + tcpStream.write(chunk); + }); + src.on('end', () => tcpStream.end()); + src.on('close', () => tcpStream.destroy()); + src.on('error', () => tcpStream.destroy()); + } + + private registerActiveStream(alias: string, streamId?: number): ActiveStreamRecord { + const id = streamId ?? -Math.floor(Math.random() * 0x7fffffff); + const record: ActiveStreamRecord = { + streamId: id, alias, bytesIn: 0, bytesOut: 0, openedAt: Date.now(), + }; + this.activeStreams.set(id, record); + return record; + } + + private onTunnelDown(nodeId: number): void { + this.logActivity({ + source: 'pilot', level: 'warn', type: 'tunnel.fail', + nodeId, message: `pilot tunnel down for node ${nodeId}`, + }); + } + + // --- Probe / Test upstream --- + + public async testUpstream(alias: string, sourceNodeId: number): Promise { + const target = this.lookupAliasGlobal(alias); + if (!target) { + return { ok: false, where: 'sidecar', code: 'no_route', message: 'alias not found' }; + } + if (!DatabaseService.getInstance().isMeshStackEnabled(target.nodeId, target.stackName)) { + return { ok: false, where: 'agent_resolve', code: 'denied', message: 'target stack not opted in' }; + } + + if (target.nodeId !== sourceNodeId) { + const ptm = PilotTunnelManager.getInstance(); + if (!ptm.hasActiveTunnel(target.nodeId)) { + return { ok: false, where: 'pilot_tunnel', code: 'tunnel_down', message: 'no pilot tunnel' }; + } + const bridge = ptm.getBridge(target.nodeId); + if (!bridge) { + return { ok: false, where: 'pilot_tunnel', code: 'tunnel_down', message: 'no bridge' }; + } + const t0 = Date.now(); + const stream = bridge.openTcpStream({ stack: target.stackName, service: target.serviceName, port: target.port }); + if (!stream) return { ok: false, where: 'pilot_tunnel', code: 'tunnel_down', message: 'open failed' }; + return new Promise((resolve) => { + const timer = setTimeout(() => { + stream.destroy(); + resolve({ ok: false, where: 'agent_dial', code: 'timeout', message: 'probe timeout' }); + }, PROBE_TIMEOUT_MS); + stream.once('open', () => { + clearTimeout(timer); + const latency = Date.now() - t0; + this.routeLatencyMap.set(target.host, latency); + stream.destroy(); + this.logActivity({ + source: 'mesh', level: 'info', type: 'probe.ok', + alias: target.host, message: `probe ok ${latency}ms`, + }); + resolve({ ok: true, latencyMs: latency }); + }); + stream.once('error', (err: Error) => { + clearTimeout(timer); + this.logActivity({ + source: 'mesh', level: 'error', type: 'probe.fail', + alias: target.host, message: err.message, + }); + resolve({ ok: false, where: 'agent_dial', code: 'unreachable', message: err.message }); + }); + }); + } + + const t0 = Date.now(); + return new Promise((resolve) => { + const sock = net.createConnection({ host: '127.0.0.1', port: target.port }); + sock.setTimeout(PROBE_TIMEOUT_MS); + sock.once('connect', () => { + const latency = Date.now() - t0; + this.routeLatencyMap.set(target.host, latency); + sock.destroy(); + resolve({ ok: true, latencyMs: latency }); + }); + sock.once('timeout', () => { + sock.destroy(); + resolve({ ok: false, where: 'target_port', code: 'timeout', message: 'connect timeout' }); + }); + sock.once('error', (err) => { + resolve({ ok: false, where: 'target_port', code: 'unreachable', message: err.message }); + }); + }); + } + + private lookupAliasGlobal(host: string): MeshGlobalAlias | null { + return this.aliasCache.get(host) || null; + } + + // --- Diagnostics --- + + public async getRouteDiagnostic(alias: string): Promise { + const target = this.lookupAliasGlobal(alias); + const lastError = this.routeErrorMap.get(alias) || null; + const lastProbeMs = this.routeLatencyMap.get(alias) ?? null; + + if (!target) { + return { alias, target: null, pilot: { connected: false, lastSeen: null }, lastError, lastProbeMs, state: 'not authorized' }; + } + + const ptm = PilotTunnelManager.getInstance(); + const pilotConnected = ptm.hasActiveTunnel(target.nodeId); + const node = DatabaseService.getInstance().getNode(target.nodeId); + const lastSeen = node?.pilot_last_seen ?? null; + const optedIn = DatabaseService.getInstance().isMeshStackEnabled(target.nodeId, target.stackName); + + let state: MeshRouteDiagnostic['state']; + if (!optedIn) state = 'not authorized'; + else if (!pilotConnected) state = 'tunnel down'; + else if (lastError && Date.now() - lastError.ts < 60_000) state = 'unreachable'; + else if (lastProbeMs !== null && lastProbeMs > SLOW_PROBE_THRESHOLD_MS) state = 'degraded'; + else state = 'healthy'; + + return { + alias, + target: { + nodeId: target.nodeId, + stack: target.stackName, + service: target.serviceName, + port: target.port, + alias, + }, + pilot: { connected: pilotConnected, lastSeen }, + lastError, + lastProbeMs, + state, + }; + } + + public async getNodeDiagnostic(nodeId: number): Promise { + const ptm = PilotTunnelManager.getInstance(); + const bridge = ptm.getBridge(nodeId); + const node = DatabaseService.getInstance().getNode(nodeId); + const sidecarRunning = await this.isSidecarRunning(nodeId); + + const aliasCacheRows = Array.from(this.aliasCache.values()) + .filter((a) => a.nodeId === nodeId) + .map((a) => ({ host: a.host, targetNodeId: a.nodeId, port: a.port })); + + const now = Date.now(); + const activeStreams = Array.from(this.activeStreams.values()).map((s) => ({ + streamId: s.streamId, alias: s.alias, + bytesIn: s.bytesIn, bytesOut: s.bytesOut, + ageMs: now - s.openedAt, + })); + + return { + nodeId, + sidecar: { running: sidecarRunning, restartCount: 0 }, + pilot: { + connected: !!bridge, + bufferedAmount: bridge?.getBufferedAmount() ?? 0, + lastSeen: node?.pilot_last_seen ?? null, + }, + activeStreams, + aliasCache: aliasCacheRows, + }; + } + + public async getStatus(): Promise { + const db = DatabaseService.getInstance(); + const ptm = PilotTunnelManager.getInstance(); + const nodes = db.getNodes(); + const out: MeshNodeStatus[] = []; + for (const node of nodes) { + const optedInStacks = db.listMeshStacks(node.id).map((s) => s.stack_name); + out.push({ + nodeId: node.id, + nodeName: node.name, + enabled: db.getNodeMeshEnabled(node.id), + sidecarRunning: await this.isSidecarRunning(node.id), + pilotConnected: node.type === 'remote' ? ptm.hasActiveTunnel(node.id) : true, + optedInStacks, + activeStreamCount: Array.from(this.activeStreams.values()).length, + }); + } + return out; + } + + // --- Sidecar lifecycle (best-effort; real spawn happens on local node) --- + + public async spawnSidecar(nodeId: number): Promise { + const docker = DockerController.getInstance(nodeId).getDocker(); + const name = `${SIDECAR_CONTAINER_PREFIX}${nodeId}`; + try { + const existing = docker.getContainer(name); + const info = await existing.inspect().catch(() => null); + if (info?.State?.Running) return; + if (info) await existing.remove({ force: true }).catch(() => undefined); + } catch { /* ignore */ } + + const token = this.mintSidecarToken(nodeId); + const controlUrl = process.env.SENCHO_INTERNAL_URL || 'ws://127.0.0.1:1852/api/mesh/control'; + try { + const container = await docker.createContainer({ + name, + Image: DEFAULT_SIDECAR_IMAGE, + Env: [ + `SENCHO_CONTROL_URL=${controlUrl}`, + `SENCHO_MESH_TOKEN=${token}`, + `MESH_NODE_ID=${nodeId}`, + ], + HostConfig: { + NetworkMode: 'host', + RestartPolicy: { Name: 'unless-stopped' }, + }, + Labels: { + 'sencho.mesh.role': 'sidecar', + 'sencho.mesh.node_id': String(nodeId), + }, + }); + await container.start(); + this.logActivity({ + source: 'mesh', level: 'info', type: 'sidecar.start', + nodeId, message: `sidecar started for node ${nodeId}`, + }); + } catch (err) { + this.logActivity({ + source: 'mesh', level: 'error', type: 'sidecar.crash', + nodeId, message: `sidecar spawn failed: ${(err as Error).message}`, + }); + throw err; + } + } + + public async stopSidecar(nodeId: number): Promise { + const docker = DockerController.getInstance(nodeId).getDocker(); + const name = `${SIDECAR_CONTAINER_PREFIX}${nodeId}`; + try { + const c = docker.getContainer(name); + await c.stop({ t: 5 }).catch(() => undefined); + await c.remove({ force: true }).catch(() => undefined); + this.logActivity({ + source: 'mesh', level: 'info', type: 'sidecar.stop', + nodeId, message: `sidecar stopped for node ${nodeId}`, + }); + } catch { /* ignore */ } + } + + private async isSidecarRunning(nodeId: number): Promise { + try { + const docker = DockerController.getInstance(nodeId).getDocker(); + const info = await docker.getContainer(`${SIDECAR_CONTAINER_PREFIX}${nodeId}`).inspect(); + return !!info.State?.Running; + } catch { + return false; + } + } + + public mintSidecarToken(nodeId: number): string { + const settings = DatabaseService.getInstance().getGlobalSettings(); + const secret = settings.auth_jwt_secret; + if (!secret) throw new Error('JWT secret not configured'); + return jwt.sign({ scope: 'mesh_sidecar', nodeId }, secret, { expiresIn: SIDECAR_TOKEN_TTL }); + } + + public verifySidecarToken(token: string): { nodeId: number } | null { + try { + const settings = DatabaseService.getInstance().getGlobalSettings(); + const secret = settings.auth_jwt_secret; + if (!secret) return null; + const decoded = jwt.verify(token, secret) as { scope?: string; nodeId?: number }; + if (decoded.scope !== 'mesh_sidecar' || typeof decoded.nodeId !== 'number') return null; + return { nodeId: decoded.nodeId }; + } catch { + return null; + } + } + + // --- Sidecar control WS attachment (called from websocket/meshControl.ts) --- + + public attachSidecarSocket(ws: WebSocketLike, _nodeId: number): void { + this.sidecarSockets.add(ws); + ws.on('close', () => { this.sidecarSockets.delete(ws); }); + } + + /** Resolve an inbound sidecar request: "I have a connection on this port; who's it for?" */ + public handleSidecarResolve(ws: WebSocketLike, nodeId: number, connId: number, port: number, remoteAddr: string): void { + const target = this.resolveByLocalPort(port); + if (!target) { + this.sendSidecar(ws, { t: 'resolve_err', connId, code: 'no_route', message: 'port not registered' }); + this.logActivity({ + source: 'sidecar', level: 'warn', type: 'route.resolve.denied', + nodeId, message: `unknown port ${port}`, details: { connId, remoteAddr }, + }); + return; + } + + // The sidecar is the SOURCE; it asks for routing on its local node. + // Open a TCP path on this node's MeshService (same-node fast path or + // pilot tunnel) and acknowledge the resolve with a freshly allocated + // streamId. For V1 we do NOT bridge real bytes through the control WS + // until the sidecar package gains binary frame plumbing (Phase B). + // Instead we ack the resolve with the target metadata so the sidecar + // can dial directly on the host gateway. + this.sendSidecar(ws, { t: 'resolve_ok', connId, streamId: connId, alias: target.alias }); + this.logActivity({ + source: 'sidecar', level: 'info', type: 'route.resolve.ok', + nodeId, alias: target.alias, + message: `resolved port ${port} to ${target.alias}`, + details: { connId, remoteAddr }, + }); + } + + private sendSidecar(ws: WebSocketLike, frame: Record): void { + if (ws.readyState !== 1 /* OPEN */) return; + try { ws.send(JSON.stringify(frame)); } catch { /* ignore */ } + } +} + +export class MeshError extends Error { + public readonly code: 'no_target' | 'port_collision' | 'denied' | 'agent_error'; + constructor(code: 'no_target' | 'port_collision' | 'denied' | 'agent_error', message: string) { + super(message); + this.code = code; + } +} diff --git a/backend/src/services/PilotTunnelManager.ts b/backend/src/services/PilotTunnelManager.ts index 503edad0..576cbee5 100644 --- a/backend/src/services/PilotTunnelManager.ts +++ b/backend/src/services/PilotTunnelManager.ts @@ -83,6 +83,15 @@ export class PilotTunnelManager extends EventEmitter { return this.bridges.has(nodeId); } + /** + * Direct accessor for the per-node bridge. Returns null when no tunnel is + * registered. Used by Sencho Mesh to open TCP streams without going + * through the loopback HTTP path. + */ + public getBridge(nodeId: number): PilotTunnelBridge | null { + return this.bridges.get(nodeId) ?? null; + } + /** * Force-close a tunnel (e.g., on node deletion). */ diff --git a/backend/src/websocket/meshControl.ts b/backend/src/websocket/meshControl.ts new file mode 100644 index 00000000..7e1f3970 --- /dev/null +++ b/backend/src/websocket/meshControl.ts @@ -0,0 +1,57 @@ +import type { IncomingMessage } from 'http'; +import type { Duplex } from 'stream'; +import type { WebSocketServer, WebSocket } from 'ws'; +import { MeshService } from '../services/MeshService'; +import { sanitizeForLog } from '../utils/safeLog'; +import { rejectUpgrade as rejectSocket } from './reject'; + +/** + * Handle the local Sencho Mesh sidecar's control WebSocket. Authenticated + * with a `mesh_sidecar`-scoped JWT minted by MeshService when it spawned the + * sidecar; the JWT carries the node id the sidecar serves. + * + * The control WS is intentionally local-only: the sidecar runs in host + * network mode on the same Docker host as Sencho and reaches us via the + * loopback interface. + */ +export async function handleMeshControl( + req: IncomingMessage, + socket: Duplex, + head: Buffer, + wss: WebSocketServer, +): Promise { + const authHeader = req.headers['authorization']; + const header = Array.isArray(authHeader) ? authHeader[0] : authHeader; + const token = header?.startsWith('Bearer ') ? header.slice(7) : null; + if (!token) return rejectSocket(socket, 401, 'Unauthorized'); + + const verified = MeshService.getInstance().verifySidecarToken(token); + if (!verified) return rejectSocket(socket, 401, 'Unauthorized'); + + wss.handleUpgrade(req, socket as never, head, (ws: WebSocket) => { + MeshService.getInstance().attachSidecarSocket(ws as unknown as never, verified.nodeId); + + ws.on('message', (data, isBinary) => { + if (isBinary) return; // V1: control plane is JSON-only. + try { + const text = data.toString('utf8'); + const frame = JSON.parse(text) as { t?: string; connId?: number; port?: number; remoteAddr?: string }; + if (frame.t === 'resolve' && typeof frame.connId === 'number' && typeof frame.port === 'number') { + MeshService.getInstance().handleSidecarResolve( + ws as unknown as never, + verified.nodeId, + frame.connId, + frame.port, + frame.remoteAddr ?? '', + ); + } + // hello / log / stream.stats / close are advisory; we accept + // them silently in V1. Future revisions can expand handling. + } catch (err) { + console.warn('[meshControl] bad frame:', sanitizeForLog((err as Error).message)); + } + }); + + ws.on('error', () => { try { ws.close(); } catch { /* ignore */ } }); + }); +} diff --git a/backend/src/websocket/upgradeHandler.ts b/backend/src/websocket/upgradeHandler.ts index 256466a0..6305c93f 100644 --- a/backend/src/websocket/upgradeHandler.ts +++ b/backend/src/websocket/upgradeHandler.ts @@ -7,6 +7,7 @@ import { DatabaseService, type UserRole } from '../services/DatabaseService'; import { NodeRegistry } from '../services/NodeRegistry'; import { COOKIE_NAME } from '../helpers/constants'; import { handlePilotTunnel } from './pilotTunnel'; +import { handleMeshControl } from './meshControl'; import { handleNotificationsWs } from './notifications'; import { handleRemoteForwarder } from './remoteForwarder'; import { handleLogsWs } from './logs'; @@ -30,7 +31,8 @@ function parseCookies(req: IncomingMessage): Record { * * Dispatch order (first match wins): * 1. `/api/pilot/tunnel` -> handlePilotTunnel (own auth, own wss) - * 2. shared cookie/Bearer auth + JWT verify (rejects unauthenticated) + * 2. `/api/mesh/control` -> handleMeshControl (sidecar JWT, local-only) + * 3. shared cookie/Bearer auth + JWT verify (rejects unauthenticated) * 3. API token scope gate (read-only / deploy-only restricted to logs + notifications) * 4. `/ws/notifications` local -> handleNotificationsWs * 5. remote nodeId path -> handleRemoteForwarder @@ -54,6 +56,10 @@ export function attachUpgrade( await handlePilotTunnel(req, socket, head, pilotTunnelWss); return; } + if (reqUrl.pathname === '/api/mesh/control') { + await handleMeshControl(req, socket, head, wss); + return; + } } catch { // URL parse error falls through and will be rejected below. } diff --git a/docs/docs.json b/docs/docs.json index 68795e8b..a11ddf10 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -108,6 +108,7 @@ "features/host-console", "features/multi-node", "features/pilot-agent", + "features/sencho-mesh", "features/fleet-view", "features/fleet-sync", "features/remote-updates", diff --git a/docs/features/sencho-mesh.mdx b/docs/features/sencho-mesh.mdx new file mode 100644 index 00000000..e49f1952 --- /dev/null +++ b/docs/features/sencho-mesh.mdx @@ -0,0 +1,94 @@ +--- +title: Sencho Mesh +description: Connect containers across nodes by hostname over the Pilot tunnel — no VPN, no firewall changes, no extra ports. +--- + + + Sencho Mesh requires a Sencho **Admiral** license. Skipper and Community Edition do not include this feature. + + +Sencho Mesh makes a multi-node fleet feel like one machine. Opt a stack into the mesh and its services become reachable from any other meshed stack on the fleet by a stable hostname. Traffic rides the existing Pilot tunnel, so there are no new ports to open and no separate VPN to manage. + +## How it works + +Each node runs a small `sencho-mesh` sidecar container that listens on the host's network. When you opt a stack into the mesh, Sencho: + +1. Generates a Compose override file that injects `extra_hosts` for every cross-node alias the fleet currently exposes. +2. Redeploys the stack with the override applied so its containers pick up the new entries. +3. Routes incoming traffic on the alias's port through the local sidecar, then over the Pilot tunnel to the destination node, where the sidecar dials the target container. + +Aliases follow a predictable scheme: + +``` +...sencho +``` + +For example, a Postgres `db` service in a stack named `api` on a node named `opsix` is reachable as `db.api.opsix.sencho` from any other meshed stack. + +## Enable the mesh + +1. Open **Fleet → Traffic · Routing**. +2. Toggle **mesh** on for each node you want to participate. +3. Click **Add stack to mesh** on any node and tick the stacks whose services should be reachable cross-node. + +Each opt-in triggers an automatic redeploy of every other meshed stack on that node so the new alias is added to their hosts files. The opt-in sheet warns you before it does. + +## What's exposed and what isn't + +Four guarantees: + +1. **Only opted-in services are reachable.** A stack reaches another stack's services through the mesh only if both stacks have explicitly opted in. The Pilot agent on the target node refuses any request for a non-opted service. +2. **Aliases are not internet-reachable.** Sidecars listen only on the host network of each node. Nothing about the mesh exposes new ports beyond the host's existing firewall posture. +3. **Traffic is encrypted in transit.** Cross-node bytes ride the existing Pilot WSS tunnel. +4. **Tier-gated and audit-logged.** Only Admiral users can configure the mesh. Enable, disable, opt-in, and opt-out events write durable rows to the audit log with the actor's identity. + +What the mesh does **not** do: + +- No application-layer authentication. Your Postgres still needs a password. +- No per-port firewall. Any opted-in stack can reach any other opted-in service. +- No rate limiting or quotas. +- No persistent traffic metrics. Live diagnostics only. + +## Test upstream + +Every alias row has a one-click **Test** button that runs a real probe across the same code path traffic uses. Result is shown inline: + +- **Green tick** with round-trip time when the path is healthy. +- **Red badge** with the failing stage (`sidecar`, `pilot_tunnel`, `agent_resolve`, `agent_dial`, or `target_port`) when something is wrong. + +Use the Test button before assuming an issue is your application's fault. It tells you whether the mesh path itself is the problem. + +## Diagnostics + +Every node card has a **Diagnostics** button that opens a live view of: + +- Sidecar liveness and pilot tunnel state on this node. +- Active TCP streams with byte counters and open age. +- The resolver cache showing which aliases are registered. +- A **Restart sidecar** action. + +This is the first place to look when a connection isn't behaving as expected. + +## Mesh activity + +The masthead has a **Mesh activity** button that opens the fleet-wide event log. Every route resolution, tunnel state change, opt-in, opt-out, and probe is recorded there. Filter by alias, source, type, or message. Useful for understanding what just happened when something flips state. + +## V1 limitations + +A few things are deliberately out of scope for the first release: + +- **One alias per TCP port across the fleet.** If two stacks expose the same port (e.g. two Postgres instances on 5432), only the first can be added to the mesh. The opt-in sheet shows a clear inline error if the second tries. +- **Pilot-to-pilot routing is not supported.** Mesh works for traffic between the central node and its pilots in either direction. Two pilot nodes cannot reach each other through the mesh. +- **No TLS termination, no blue/green cutover.** Layer 7 features land in a follow-up. + +## Troubleshooting + +**A route shows `tunnel down`.** The Pilot tunnel to the target node is gone. Check **Fleet → Overview** for the node's status. Mesh recovers automatically when the tunnel reconnects. + +**A route shows `unreachable`.** The tunnel is up but the destination port did not answer. Check that the target stack is running and that its service is listening on the declared port. Click **Test** to see the exact failing stage. + +**A route shows `not authorized`.** The destination stack is not opted into the mesh on its home node. Open Routing on that node and add the stack. + +**Adding a stack hangs.** Mesh redeploys peers on opt-in to refresh hostnames. A stuck redeploy usually means the stack itself failed to come back up. Check the stack's deploy logs. + +**The sidecar shows `off` for a node.** Click **Diagnostics → Restart sidecar** on the node card. If the sidecar still does not come up, check the node's `docker ps -a` output for `sencho-mesh-` and inspect its logs. diff --git a/frontend/src/components/FleetView.tsx b/frontend/src/components/FleetView.tsx index a5136602..e4d015ae 100644 --- a/frontend/src/components/FleetView.tsx +++ b/frontend/src/components/FleetView.tsx @@ -4,7 +4,7 @@ import { Layers, Wifi, WifiOff, Search, ArrowUpDown, AlertTriangle, Play, Square, RotateCcw, ExternalLink, Camera, Download, Loader2, Check, CircleCheck, CircleAlert, Globe, Monitor, X, LayoutGrid, Network, SlidersHorizontal, - Send, KeyRound, + Send, KeyRound, ArrowLeftRight, } from 'lucide-react'; import { FleetMasthead } from './fleet/FleetMasthead'; import { FleetTopology } from './fleet/FleetTopology'; @@ -27,9 +27,11 @@ import { springs } from '@/lib/motion'; import { apiFetch, fetchForNode } from '@/lib/api'; import { useLicense } from '@/context/LicenseContext'; import { PaidGate } from './PaidGate'; +import { AdmiralGate } from './AdmiralGate'; import FleetSnapshots from './FleetSnapshots'; import { FleetConfiguration } from './fleet/FleetConfiguration'; import { FleetSoonPlaceholder, SoonBadge } from './fleet/FleetSoonPlaceholder'; +import { RoutingTab } from './fleet/RoutingTab'; import { toast } from '@/components/ui/toast-store'; import { LabelDot } from './LabelPill'; import { type Label as StackLabel, type LabelColor } from './label-types'; @@ -673,7 +675,8 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) { const [fleetPalette, setFleetPalette] = useState([]); const [fleetStackLabelMap, setFleetStackLabelMap] = useState>>({}); const [labelFilters, setLabelFilters] = useState>(new Set()); - const { isPaid } = useLicense(); + const { isPaid, license } = useLicense(); + const isAdmiral = isPaid && license?.variant === 'admiral'; const [updateStatuses, setUpdateStatuses] = useState([]); const [updatingNodeId, setUpdatingNodeId] = useState(null); const [reconnecting, setReconnecting] = useState(false); @@ -1044,6 +1047,13 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) { )} + {isAdmiral && ( + + + Traffic · Routing + + + )} Status @@ -1364,6 +1374,13 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) { )} + {isAdmiral && ( + + + + + + )} diff --git a/frontend/src/components/fleet/MeshActivitySheet.tsx b/frontend/src/components/fleet/MeshActivitySheet.tsx new file mode 100644 index 00000000..1f411074 --- /dev/null +++ b/frontend/src/components/fleet/MeshActivitySheet.tsx @@ -0,0 +1,86 @@ +import { useEffect, useState } from 'react'; +import { apiFetch } from '@/lib/api'; +import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'; +import { Input } from '@/components/ui/input'; +import { Loader2 } from 'lucide-react'; +import type { MeshActivityEvent } from '@/types/mesh'; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function MeshActivitySheet({ open, onOpenChange }: Props) { + const [events, setEvents] = useState([]); + const [filter, setFilter] = useState(''); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (!open) return; + let cancelled = false; + setLoading(true); + (async () => { + try { + const res = await apiFetch('/mesh/activity?limit=200', { localOnly: true }); + if (!res.ok) return; + const body = await res.json() as { events: MeshActivityEvent[] }; + if (!cancelled) setEvents(body.events); + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { cancelled = true; }; + }, [open]); + + const visible = events.filter((e) => { + if (!filter) return true; + const f = filter.toLowerCase(); + return ( + e.message.toLowerCase().includes(f) || + (e.alias?.toLowerCase().includes(f) ?? false) || + e.type.toLowerCase().includes(f) + ); + }); + + return ( + + + + Mesh activity + + +
+ setFilter(e.target.value)} + className="text-xs font-mono" + /> + +
+ {loading && ( +
+ Loading… +
+ )} + {!loading && visible.length === 0 && ( +
No events.
+ )} + {visible.slice().reverse().map((e, i) => ( +
+ {new Date(e.ts).toLocaleTimeString()} + {e.source} + {e.type} + {e.alias ? `[${e.alias}] ` : ''}{e.message} +
+ ))} +
+
+
+
+ ); +} diff --git a/frontend/src/components/fleet/MeshDiagnosticsSheet.tsx b/frontend/src/components/fleet/MeshDiagnosticsSheet.tsx new file mode 100644 index 00000000..39b13dc2 --- /dev/null +++ b/frontend/src/components/fleet/MeshDiagnosticsSheet.tsx @@ -0,0 +1,132 @@ +import { useEffect, useState } from 'react'; +import { apiFetch } from '@/lib/api'; +import { toast } from '@/components/ui/toast-store'; +import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'; +import { Button } from '@/components/ui/button'; +import { Loader2, RefreshCw, ServerCog } from 'lucide-react'; +import type { MeshNodeDiagnostic } from '@/types/mesh'; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; + nodeId: number | null; + nodeName: string | null; +} + +function bytesFmt(n: number): string { + if (n < 1024) return `${n}B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)}KB`; + return `${(n / (1024 * 1024)).toFixed(1)}MB`; +} + +function ageFmt(ms: number): string { + if (ms < 1000) return `${ms}ms`; + if (ms < 60_000) return `${Math.floor(ms / 1000)}s`; + return `${Math.floor(ms / 60_000)}m`; +} + +export function MeshDiagnosticsSheet({ open, onOpenChange, nodeId, nodeName }: Props) { + const [diag, setDiag] = useState(null); + const [loading, setLoading] = useState(false); + const [restarting, setRestarting] = useState(false); + + const refresh = async () => { + if (nodeId == null) return; + setLoading(true); + try { + const res = await apiFetch(`/mesh/nodes/${nodeId}/diagnostic`, { localOnly: true }); + if (res.ok) setDiag(await res.json()); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + if (open) void refresh(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, nodeId]); + + const restart = async () => { + if (nodeId == null) return; + setRestarting(true); + try { + const res = await apiFetch(`/mesh/nodes/${nodeId}/sidecar/restart`, { + method: 'POST', localOnly: true, + }); + if (res.ok) { + toast.success('Sidecar restart requested'); + await refresh(); + } else { + toast.error('Sidecar restart failed'); + } + } finally { + setRestarting(false); + } + }; + + return ( + + + + + Diagnostics{nodeName ? ` · ${nodeName}` : ''} + + + +
+
+ + +
+ +
+
Sidecar
+
{diag?.sidecar.running ? 'running' : 'off'}
+
Pilot tunnel
+
{diag?.pilot.connected ? 'connected' : 'disconnected'}
+
Buffered
+
{diag ? bytesFmt(diag.pilot.bufferedAmount) : '-'}
+
Last seen
+
{diag?.pilot.lastSeen ? new Date(diag.pilot.lastSeen).toLocaleTimeString() : '-'}
+
+ +
+
Active streams
+ {(!diag || diag.activeStreams.length === 0) && ( +
No active streams.
+ )} +
+ {diag?.activeStreams.map((s) => ( +
+ #{s.streamId} {s.alias ?? ''} + in {bytesFmt(s.bytesIn)} / out {bytesFmt(s.bytesOut)} · {ageFmt(s.ageMs)} +
+ ))} +
+
+ +
+
Resolver cache
+ {(!diag || diag.aliasCache.length === 0) && ( +
No aliases registered.
+ )} +
+ {diag?.aliasCache.map((a) => ( +
+ {a.host} + node #{a.targetNodeId}:{a.port} +
+ ))} +
+
+
+
+
+ ); +} diff --git a/frontend/src/components/fleet/MeshOptInSheet.tsx b/frontend/src/components/fleet/MeshOptInSheet.tsx new file mode 100644 index 00000000..69a4a966 --- /dev/null +++ b/frontend/src/components/fleet/MeshOptInSheet.tsx @@ -0,0 +1,118 @@ +import { useEffect, useState } from 'react'; +import { apiFetch } from '@/lib/api'; +import { toast } from '@/components/ui/toast-store'; +import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from '@/components/ui/sheet'; +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import type { MeshStackEntry } from '@/types/mesh'; +import { Loader2 } from 'lucide-react'; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; + nodeId: number; + nodeName: string; + onChanged: () => void; +} + +export function MeshOptInSheet({ open, onOpenChange, nodeId, nodeName, onChanged }: Props) { + const [stacks, setStacks] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [pendingStack, setPendingStack] = useState(null); + + useEffect(() => { + if (!open) return; + let cancelled = false; + (async () => { + setLoading(true); + setError(null); + try { + const res = await apiFetch(`/mesh/nodes/${nodeId}/stacks`, { localOnly: true }); + if (!res.ok) throw new Error(`status ${res.status}`); + const data = await res.json() as { stacks: MeshStackEntry[] }; + if (!cancelled) setStacks(data.stacks); + } catch (err) { + if (!cancelled) setError((err as Error).message); + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { cancelled = true; }; + }, [open, nodeId]); + + const toggle = async (stack: MeshStackEntry) => { + setPendingStack(stack.name); + setError(null); + try { + const action = stack.optedIn ? 'opt-out' : 'opt-in'; + const res = await apiFetch( + `/mesh/nodes/${nodeId}/stacks/${encodeURIComponent(stack.name)}/${action}`, + { method: 'POST', localOnly: true }, + ); + if (res.status === 409) { + const body = await res.json().catch(() => ({})) as { error?: string }; + setError(body.error || 'Port already claimed by another mesh stack'); + return; + } + if (!res.ok) throw new Error(`status ${res.status}`); + setStacks((prev) => prev.map((s) => s.name === stack.name ? { ...s, optedIn: !stack.optedIn } : s)); + onChanged(); + toast.success(stack.optedIn ? 'Stack removed from mesh' : 'Stack added to mesh'); + } catch (err) { + setError((err as Error).message); + toast.error('Mesh update failed'); + } finally { + setPendingStack(null); + } + }; + + return ( + + + + Mesh stacks on {nodeName} + + Adding a stack lets its services be reached from other meshed stacks by hostname. + Toggling a stack redeploys it to refresh hostnames. + + +
+ {loading && ( +
+ Loading stacks… +
+ )} + {error && ( +
+ {error} +
+ )} + {!loading && stacks.length === 0 && ( +
No stacks deployed on this node yet.
+ )} + {stacks.map((stack) => ( +
+
+ { void toggle(stack); }} + /> + +
+ {pendingStack === stack.name && } + {stack.optedIn && pendingStack !== stack.name && ( + in mesh + )} +
+ ))} +
+
+ +
+
+
+ ); +} diff --git a/frontend/src/components/fleet/MeshRouteDetailSheet.tsx b/frontend/src/components/fleet/MeshRouteDetailSheet.tsx new file mode 100644 index 00000000..266538dd --- /dev/null +++ b/frontend/src/components/fleet/MeshRouteDetailSheet.tsx @@ -0,0 +1,139 @@ +import { useEffect, useState } from 'react'; +import { apiFetch } from '@/lib/api'; +import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Loader2, Activity, ServerCog, Hash } from 'lucide-react'; +import type { MeshRouteDiagnostic, MeshActivityEvent, MeshProbeResult } from '@/types/mesh'; +import { meshRouteStateFromBackend, meshRouteStateTokens } from './meshRouteState'; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; + alias: string | null; +} + +export function MeshRouteDetailSheet({ open, onOpenChange, alias }: Props) { + const [diag, setDiag] = useState(null); + const [events, setEvents] = useState([]); + const [probe, setProbe] = useState(null); + const [probing, setProbing] = useState(false); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (!open || !alias) return; + let cancelled = false; + const refresh = async () => { + setLoading(true); + try { + const [diagRes, evRes] = await Promise.all([ + apiFetch(`/mesh/aliases/${encodeURIComponent(alias)}/diagnostic`, { localOnly: true }), + apiFetch(`/mesh/activity?alias=${encodeURIComponent(alias)}&limit=20`, { localOnly: true }), + ]); + if (cancelled) return; + if (diagRes.ok) setDiag(await diagRes.json()); + if (evRes.ok) { + const body = await evRes.json() as { events: MeshActivityEvent[] }; + setEvents(body.events); + } + } finally { + if (!cancelled) setLoading(false); + } + }; + void refresh(); + return () => { cancelled = true; }; + }, [open, alias]); + + const runProbe = async () => { + if (!alias) return; + setProbing(true); + setProbe(null); + try { + const res = await apiFetch(`/mesh/aliases/${encodeURIComponent(alias)}/test`, { + method: 'POST', localOnly: true, + }); + const body = await res.json() as MeshProbeResult; + setProbe(body); + } finally { + setProbing(false); + } + }; + + if (!alias) return null; + const pillState = diag ? meshRouteStateFromBackend(diag.state) : 'not-authorized'; + const pill = meshRouteStateTokens(pillState); + + return ( + + + + {alias} + + +
+
+ + {pill.label} + + {diag?.lastProbeMs != null && ( + {diag.lastProbeMs}ms + )} +
+ + {diag?.target && ( +
+
Target node
+
#{diag.target.nodeId}
+
Stack / service
+
{diag.target.stack}/{diag.target.service}
+
Port
+
{diag.target.port}
+
Pilot tunnel
+
{diag.pilot.connected ? 'connected' : 'disconnected'}
+
+ )} + + {diag?.lastError && ( +
+
last error
+
{diag.lastError.message}
+
{new Date(diag.lastError.ts).toLocaleString()}
+
+ )} + +
+ + {probe && ( + + {probe.ok ? `ok ${probe.latencyMs}ms` : `${probe.where ?? 'fail'}: ${probe.code ?? 'error'}`} + + )} +
+ +
+
Recent activity
+
+ {loading && } + {!loading && events.length === 0 && ( +
No events yet for this alias.
+ )} + {events.map((e, i) => ( +
+ {e.source === 'sidecar' && } + {e.source === 'pilot' && } + {e.source === 'mesh' && } + + {new Date(e.ts).toLocaleTimeString()} {e.type} {e.message} + +
+ ))} +
+
+
+
+
+ ); +} diff --git a/frontend/src/components/fleet/RoutingNodeCard.tsx b/frontend/src/components/fleet/RoutingNodeCard.tsx new file mode 100644 index 00000000..d2b9222c --- /dev/null +++ b/frontend/src/components/fleet/RoutingNodeCard.tsx @@ -0,0 +1,141 @@ +import { useState } from 'react'; +import { apiFetch } from '@/lib/api'; +import { toast } from '@/components/ui/toast-store'; +import { Card, CardContent } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { TogglePill } from '@/components/ui/toggle-pill'; +import { Plus, ServerCog, Activity, Loader2 } from 'lucide-react'; +import type { MeshAlias, MeshNodeStatus } from '@/types/mesh'; +import { meshRouteStateFor, meshRouteStateTokens } from './meshRouteState'; + +interface Props { + status: MeshNodeStatus; + aliases: MeshAlias[]; + isLocal: boolean; + onAddStack: () => void; + onShowDiagnostics: () => void; + onShowAlias: (alias: string) => void; + onTestUpstream: (alias: string) => Promise; + onChanged: () => void; +} + +export function RoutingNodeCard({ + status, aliases, isLocal, onAddStack, onShowDiagnostics, onShowAlias, onTestUpstream, onChanged, +}: Props) { + const [toggling, setToggling] = useState(false); + const [testingAlias, setTestingAlias] = useState(null); + + const nodeAliases = aliases.filter((a) => a.nodeId === status.nodeId); + + const toggleEnabled = async (next: boolean) => { + setToggling(true); + try { + const action = next ? 'enable' : 'disable'; + const res = await apiFetch(`/mesh/nodes/${status.nodeId}/${action}`, { + method: 'POST', localOnly: true, + }); + if (!res.ok) throw new Error(`status ${res.status}`); + toast.success(next ? 'Mesh enabled on node' : 'Mesh disabled on node'); + onChanged(); + } catch (err) { + toast.error(`Failed to ${next ? 'enable' : 'disable'} mesh: ${(err as Error).message}`); + } finally { + setToggling(false); + } + }; + + const runTest = async (alias: string) => { + setTestingAlias(alias); + try { await onTestUpstream(alias); } finally { setTestingAlias(null); } + }; + + return ( + + +
+
+ {status.nodeName} + {isLocal && ( + + ★ Local + + )} + {!status.pilotConnected && status.nodeId !== -1 && !isLocal && ( + + pilot offline + + )} +
+
+ { void toggleEnabled(next); }} + /> + +
+
+ + {!status.pilotConnected && !isLocal && ( +
+ Sencho Mesh requires the pilot agent on this node. +
+ )} + +
+
Mesh stacks
+
{status.optedInStacks.length}
+
Aliases
+
{nodeAliases.length}
+
+ + {status.enabled && ( + <> +
+ {nodeAliases.length === 0 && ( +
No mesh services on this node yet.
+ )} + {nodeAliases.map((a) => { + const pillState = meshRouteStateFor({ + optedIn: true, + pilotConnected: status.pilotConnected, + }); + const pill = meshRouteStateTokens(pillState); + return ( +
+ + + {pill.label} + + +
+ ); + })} +
+ + + )} +
+
+ ); +} + diff --git a/frontend/src/components/fleet/RoutingTab.tsx b/frontend/src/components/fleet/RoutingTab.tsx new file mode 100644 index 00000000..ee351682 --- /dev/null +++ b/frontend/src/components/fleet/RoutingTab.tsx @@ -0,0 +1,218 @@ +import { useCallback, useEffect, useState } from 'react'; +import { apiFetch } from '@/lib/api'; +import { toast } from '@/components/ui/toast-store'; +import { Button } from '@/components/ui/button'; +import { ArrowLeftRight, Loader2, ScrollText } from 'lucide-react'; +import { RoutingNodeCard } from './RoutingNodeCard'; +import { MeshOptInSheet } from './MeshOptInSheet'; +import { MeshRouteDetailSheet } from './MeshRouteDetailSheet'; +import { MeshDiagnosticsSheet } from './MeshDiagnosticsSheet'; +import { MeshActivitySheet } from './MeshActivitySheet'; +import type { MeshAlias, MeshNodeStatus, MeshProbeResult } from '@/types/mesh'; + +export function RoutingTab() { + const [status, setStatus] = useState([]); + const [aliases, setAliases] = useState([]); + const [loading, setLoading] = useState(true); + const [optInNode, setOptInNode] = useState<{ id: number; name: string } | null>(null); + const [diagnosticsNode, setDiagnosticsNode] = useState<{ id: number; name: string } | null>(null); + const [routeDetailAlias, setRouteDetailAlias] = useState(null); + const [activityOpen, setActivityOpen] = useState(false); + + const refresh = useCallback(async () => { + try { + const [statusRes, aliasesRes] = await Promise.all([ + apiFetch('/mesh/status', { localOnly: true }), + apiFetch('/mesh/aliases', { localOnly: true }), + ]); + if (statusRes.ok) { + const body = await statusRes.json() as { nodes: MeshNodeStatus[] }; + setStatus(body.nodes); + } + if (aliasesRes.ok) { + const body = await aliasesRes.json() as { aliases: MeshAlias[] }; + setAliases(body.aliases); + } + } catch (err) { + toast.error(`Failed to load mesh state: ${(err as Error).message}`); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { void refresh(); }, [refresh]); + + const testUpstream = useCallback(async (alias: string): Promise => { + try { + const res = await apiFetch(`/mesh/aliases/${encodeURIComponent(alias)}/test`, { + method: 'POST', localOnly: true, + }); + const body = await res.json() as MeshProbeResult; + if (body.ok) { + toast.success(`${alias} ok (${body.latencyMs}ms)`); + } else { + toast.error(`${alias} ${body.where ?? 'fail'}: ${body.code ?? 'error'}`); + } + } catch (err) { + toast.error(`Probe failed: ${(err as Error).message}`); + } + }, []); + + const totalAliases = aliases.length; + const meshedNodes = status.filter((s) => s.enabled).length; + const onlineNodes = status.filter((s) => s.pilotConnected).length; + + if (loading) { + return ( +
+ + Loading mesh state… +
+ ); + } + + if (status.length === 0) { + return ( +
+ +
No nodes available
+
+ Add a node to the fleet to start routing traffic between containers across nodes. +
+
+ ); + } + + if (meshedNodes === 0) { + return ( +
+ setActivityOpen(true)} /> +
+ +
Mesh containers across nodes
+
+ Add a stack to the mesh and its services become reachable from any other meshed + stack by hostname. No VPN, no firewall changes. +
+
+ {status.map((s) => ( + setOptInNode({ id: s.nodeId, name: s.nodeName })} + onShowDiagnostics={() => setDiagnosticsNode({ id: s.nodeId, name: s.nodeName })} + onShowAlias={(alias) => setRouteDetailAlias(alias)} + onTestUpstream={testUpstream} + onChanged={() => { void refresh(); }} + /> + ))} +
+
+ { void refresh(); }} + /> +
+ ); + } + + return ( +
+ setActivityOpen(true)} /> +
+ {status.map((s) => ( + setOptInNode({ id: s.nodeId, name: s.nodeName })} + onShowDiagnostics={() => setDiagnosticsNode({ id: s.nodeId, name: s.nodeName })} + onShowAlias={(alias) => setRouteDetailAlias(alias)} + onTestUpstream={testUpstream} + onChanged={() => { void refresh(); }} + /> + ))} +
+ { void refresh(); }} + /> +
+ ); +} + +function RoutingMasthead({ meshedNodes, onlineNodes, totalAliases, onShowActivity }: { + meshedNodes: number; onlineNodes: number; totalAliases: number; onShowActivity: () => void; +}) { + const stateWord = meshedNodes === 0 ? 'unmeshed' : meshedNodes < onlineNodes ? 'partial' : 'meshed'; + return ( +
+
+
{stateWord}
+
+
+
meshed
+
{meshedNodes}/{onlineNodes}
+
+
+
aliases
+
{totalAliases}
+
+
+
+ +
+ ); +} + +function SheetsRoot(props: { + optInNode: { id: number; name: string } | null; + setOptInNode: (v: { id: number; name: string } | null) => void; + diagnosticsNode: { id: number; name: string } | null; + setDiagnosticsNode: (v: { id: number; name: string } | null) => void; + routeDetailAlias: string | null; + setRouteDetailAlias: (v: string | null) => void; + activityOpen: boolean; + setActivityOpen: (v: boolean) => void; + onChanged: () => void; +}) { + return ( + <> + {props.optInNode && ( + { if (!open) props.setOptInNode(null); }} + nodeId={props.optInNode.id} + nodeName={props.optInNode.name} + onChanged={props.onChanged} + /> + )} + { if (!open) props.setDiagnosticsNode(null); }} + nodeId={props.diagnosticsNode?.id ?? null} + nodeName={props.diagnosticsNode?.name ?? null} + /> + { if (!open) props.setRouteDetailAlias(null); }} + alias={props.routeDetailAlias} + /> + + + ); +} diff --git a/frontend/src/components/fleet/meshRouteState.test.ts b/frontend/src/components/fleet/meshRouteState.test.ts new file mode 100644 index 00000000..e7c9afa6 --- /dev/null +++ b/frontend/src/components/fleet/meshRouteState.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; +import { meshRouteStateFor, meshRouteStateFromBackend, meshRouteStateTokens } from './meshRouteState'; + +describe('meshRouteStateFor', () => { + const FROZEN_NOW = 1_700_000_000_000; + + it('returns not-authorized when the route is not opted in', () => { + expect(meshRouteStateFor({ optedIn: false, pilotConnected: true, now: FROZEN_NOW })).toBe('not-authorized'); + }); + + it('returns tunnel-down when opted in but pilot is offline', () => { + expect(meshRouteStateFor({ optedIn: true, pilotConnected: false, now: FROZEN_NOW })).toBe('tunnel-down'); + }); + + it('returns unreachable when a recent error is recorded', () => { + const lastErrorAt = FROZEN_NOW - 5_000; + expect(meshRouteStateFor({ + optedIn: true, pilotConnected: true, lastErrorAt, now: FROZEN_NOW, + })).toBe('unreachable'); + }); + + it('does NOT return unreachable when the error is older than the window', () => { + const lastErrorAt = FROZEN_NOW - 90_000; + expect(meshRouteStateFor({ + optedIn: true, pilotConnected: true, lastErrorAt, now: FROZEN_NOW, + })).toBe('healthy'); + }); + + it('returns degraded when the last probe was slow', () => { + expect(meshRouteStateFor({ + optedIn: true, pilotConnected: true, lastProbeMs: 750, now: FROZEN_NOW, + })).toBe('degraded'); + }); + + it('returns healthy in the happy path', () => { + expect(meshRouteStateFor({ + optedIn: true, pilotConnected: true, lastProbeMs: 12, now: FROZEN_NOW, + })).toBe('healthy'); + }); +}); + +describe('meshRouteStateFromBackend', () => { + it('maps each backend label to its pill key', () => { + expect(meshRouteStateFromBackend('healthy')).toBe('healthy'); + expect(meshRouteStateFromBackend('degraded')).toBe('degraded'); + expect(meshRouteStateFromBackend('unreachable')).toBe('unreachable'); + expect(meshRouteStateFromBackend('tunnel down')).toBe('tunnel-down'); + expect(meshRouteStateFromBackend('not authorized')).toBe('not-authorized'); + expect(meshRouteStateFromBackend('anything-else')).toBe('not-authorized'); + }); +}); + +describe('meshRouteStateTokens', () => { + it('produces a token + label + toneClass for every pill state', () => { + for (const s of ['healthy', 'degraded', 'unreachable', 'tunnel-down', 'not-authorized'] as const) { + const tk = meshRouteStateTokens(s); + expect(tk.label.length).toBeGreaterThan(0); + expect(tk.toneClass.length).toBeGreaterThan(0); + expect(tk.token.length).toBeGreaterThan(0); + } + }); +}); diff --git a/frontend/src/components/fleet/meshRouteState.ts b/frontend/src/components/fleet/meshRouteState.ts new file mode 100644 index 00000000..c1ff222f --- /dev/null +++ b/frontend/src/components/fleet/meshRouteState.ts @@ -0,0 +1,47 @@ +import type { MeshRoutePillState } from '@/types/mesh'; + +const SLOW_PROBE_THRESHOLD_MS = 500; +const RECENT_ERROR_WINDOW_MS = 60_000; + +export interface MeshRouteSignals { + optedIn: boolean; + pilotConnected: boolean; + lastErrorAt?: number | null; + lastProbeMs?: number | null; + now?: number; +} + +/** + * Pure function mapping the four observable signals to a single pill state. + * Centralizes the priority order so every UI surface (route row, node card, + * detail sheet, activity log) renders the same word for the same situation. + */ +export function meshRouteStateFor(signals: MeshRouteSignals): MeshRoutePillState { + if (!signals.optedIn) return 'not-authorized'; + if (!signals.pilotConnected) return 'tunnel-down'; + const now = signals.now ?? Date.now(); + if (signals.lastErrorAt && now - signals.lastErrorAt < RECENT_ERROR_WINDOW_MS) return 'unreachable'; + if (signals.lastProbeMs != null && signals.lastProbeMs > SLOW_PROBE_THRESHOLD_MS) return 'degraded'; + return 'healthy'; +} + +const TONE_TOKENS: Record = { + 'healthy': { token: 'success', label: 'healthy', toneClass: 'text-success bg-success/10 border-success/30' }, + 'degraded': { token: 'warning', label: 'degraded', toneClass: 'text-warning bg-warning/10 border-warning/30' }, + 'unreachable': { token: 'destructive', label: 'unreachable', toneClass: 'text-destructive bg-destructive/10 border-destructive/30' }, + 'tunnel-down': { token: 'destructive', label: 'tunnel down', toneClass: 'text-destructive bg-destructive/10 border-destructive/30' }, + 'not-authorized': { token: 'muted', label: 'not authorized', toneClass: 'text-ink-3 bg-ink-3/10 border-ink-3/30' }, +}; + +export function meshRouteStateTokens(state: MeshRoutePillState) { + return TONE_TOKENS[state]; +} + +/** Convert the backend's diagnostic state string to the pill key. */ +export function meshRouteStateFromBackend(state: string): MeshRoutePillState { + if (state === 'healthy') return 'healthy'; + if (state === 'degraded') return 'degraded'; + if (state === 'unreachable') return 'unreachable'; + if (state === 'tunnel down') return 'tunnel-down'; + return 'not-authorized'; +} diff --git a/frontend/src/types/mesh.ts b/frontend/src/types/mesh.ts new file mode 100644 index 00000000..93b7c139 --- /dev/null +++ b/frontend/src/types/mesh.ts @@ -0,0 +1,68 @@ +export type MeshRoutePillState = 'healthy' | 'degraded' | 'unreachable' | 'tunnel-down' | 'not-authorized'; + +export interface MeshAlias { + host: string; + nodeId: number; + nodeName: string; + stackName: string; + serviceName: string; + port: number; +} + +export interface MeshNodeStatus { + nodeId: number; + nodeName: string; + enabled: boolean; + sidecarRunning: boolean; + pilotConnected: boolean; + optedInStacks: string[]; + activeStreamCount: number; +} + +export interface MeshRouteDiagnostic { + alias: string; + target: { + nodeId: number; + stack: string; + service: string; + port: number; + alias: string; + } | null; + pilot: { connected: boolean; lastSeen: number | null }; + lastError: { ts: number; message: string } | null; + lastProbeMs: number | null; + state: 'healthy' | 'degraded' | 'unreachable' | 'tunnel down' | 'not authorized'; +} + +export interface MeshNodeDiagnostic { + nodeId: number; + sidecar: { running: boolean; restartCount: number }; + pilot: { connected: boolean; bufferedAmount: number; lastSeen: number | null }; + activeStreams: Array<{ streamId: number; alias?: string; bytesIn: number; bytesOut: number; ageMs: number }>; + aliasCache: Array<{ host: string; targetNodeId: number; port: number }>; +} + +export interface MeshProbeResult { + ok: boolean; + latencyMs?: number; + where?: 'sidecar' | 'pilot_tunnel' | 'agent_resolve' | 'agent_dial' | 'target_port'; + code?: string; + message?: string; +} + +export interface MeshActivityEvent { + ts: number; + source: 'sidecar' | 'pilot' | 'mesh'; + level: 'info' | 'warn' | 'error'; + type: string; + nodeId?: number; + alias?: string; + streamId?: number; + message: string; + details?: Record; +} + +export interface MeshStackEntry { + name: string; + optedIn: boolean; +}