diff --git a/backend/src/__tests__/compose-doctor-service.test.ts b/backend/src/__tests__/compose-doctor-service.test.ts index 9cca1780..3124b094 100644 --- a/backend/src/__tests__/compose-doctor-service.test.ts +++ b/backend/src/__tests__/compose-doctor-service.test.ts @@ -221,3 +221,32 @@ describe('node deletion cleanup', () => { expect(db().getPreflightFindings('run-x')).toEqual([]); }); }); + +describe('exposure state feeds the exposure rules end to end', () => { + const ruleIds = (stack: string) => doctor().getLatest(nodeId, stack).findings.map(f => f.ruleId); + + afterEach(() => { + db().deleteStackExposureIntents(nodeId, 'expe2e'); + db().deleteStackDossier(nodeId, 'expe2e'); + fs.rmSync(path.join(process.env.COMPOSE_DIR as string, 'expe2e'), { recursive: true, force: true }); + }); + + it('fires exposure-internal-published from a stored stack intent', async () => { + writeStack('expe2e'); + db().setStackExposureIntent(nodeId, 'expe2e', '', 'internal', 'tester'); + stubDocker({ name: 'expe2e', services: { web: { image: 'nginx:latest', ports: [{ target: 80, published: '8080', protocol: 'tcp' }] } }, networks: {}, volumes: {} }); + await doctor().runPreflight(nodeId, 'expe2e', 'tester'); + expect(ruleIds('expe2e')).toContain('exposure-internal-published'); + }); + + it('fires exposure-port-vs-dossier from the dossier access URLs', async () => { + writeStack('expe2e'); + db().upsertStackDossier(nodeId, 'expe2e', { + purpose: '', owner: '', access_urls: 'https://app.example.com:443', static_ip: '', vlan: '', + firewall_notes: '', reverse_proxy_notes: '', backup_notes: '', upgrade_notes: '', recovery_notes: '', custom_notes: '', + }); + stubDocker({ name: 'expe2e', services: { web: { image: 'nginx:latest', ports: [{ target: 80, published: '8080', protocol: 'tcp' }] } }, networks: {}, volumes: {} }); + await doctor().runPreflight(nodeId, 'expe2e', 'tester'); + expect(ruleIds('expe2e')).toContain('exposure-port-vs-dossier'); + }); +}); diff --git a/backend/src/__tests__/compose-network-inspector.test.ts b/backend/src/__tests__/compose-network-inspector.test.ts new file mode 100644 index 00000000..32802070 --- /dev/null +++ b/backend/src/__tests__/compose-network-inspector.test.ts @@ -0,0 +1,247 @@ +/** + * The Compose Network Inspector: the normalized-model adapters (rendered vs raw + * declared produce the same shape), the pure facts assembler, and the + * runtime-vs-Compose drift comparison (system/default/external networks and + * stopped containers are not flagged). + */ +import { describe, it, expect } from 'vitest'; +import type { EffectiveModel, EffService } from '../services/preflight/effectiveModel'; +import type { DeclaredCompose } from '../helpers/composeDependencyParse'; +import type { DependencySnapshot, DependencyContainer, DependencyNetwork } from '../services/DockerController'; +import { + fromEffectiveModel, fromDeclaredCompose, compareStackNetworks, runtimeResourceName, parseAccessUrlPorts, +} from '../services/network/normalize'; +import { assembleStackNetworkFacts } from '../services/network/composeNetworkInspector'; + +function effSvc(over: Partial = {}): EffService { + return { + name: 'web', image: 'nginx:1.27', ports: [], binds: [], namedVolumes: [], + privileged: false, hasHealthcheck: true, restart: 'unless-stopped', envKeys: [], + networks: [], extraHosts: [], labelKeys: [], ...over, + }; +} + +function container(over: Partial = {}): DependencyContainer { + return { + id: 'c1', name: 'web1', service: 'web', composeProject: 'myapp', stack: 'myapp', + state: 'running', image: 'nginx:1.27', networks: [], volumes: [], ports: [], ...over, + }; +} + +function depNet(over: Partial = {}): DependencyNetwork { + return { id: 'n', name: 'myapp_backend', driver: 'bridge', scope: 'local', isSystem: false, composeProject: 'myapp', stack: 'myapp', ...over }; +} + +describe('normalized-model adapters', () => { + it('rendered and raw declared models normalize to the same shape', () => { + const eff: EffectiveModel = { + projectName: 'myapp', + services: [effSvc({ name: 'web', networks: [{ key: 'backend', aliases: [] }, { key: 'shared', aliases: [] }] })], + networks: { + backend: { name: 'myapp_backend', external: false, internal: false }, + shared: { name: 'shared_net', external: true, internal: false }, + custom: { name: 'custom_name', external: false, internal: false }, + }, + volumes: {}, + }; + const declared: DeclaredCompose = { + services: [{ name: 'web', dependsOn: [], networks: ['backend', 'shared'], volumes: [], ports: [] }], + networks: { + backend: { external: false }, + shared: { external: true, name: 'shared_net' }, + custom: { external: false, name: 'custom_name' }, + }, + volumes: {}, + }; + expect(fromDeclaredCompose(declared, 'myapp')).toEqual(fromEffectiveModel(eff)); + }); +}); + +describe('assembleStackNetworkFacts', () => { + const model: EffectiveModel = { + projectName: 'myapp', + services: [effSvc({ + name: 'web', + networks: [{ key: 'backend', aliases: ['www'] }], + extraHosts: ['host.docker.internal:host-gateway'], + ports: [ + { startPort: 8080, endPort: 8080, hostIp: '0.0.0.0', protocol: 'tcp' }, + { startPort: 9000, endPort: 9000, hostIp: '127.0.0.1', protocol: 'tcp' }, + ], + })], + networks: { + default: { name: 'myapp_default', external: false, internal: false }, + backend: { name: 'myapp_backend', external: false, internal: true }, + shared: { name: 'shared_net', external: true, internal: false }, + }, + volumes: {}, + }; + + it('reports networks with external/internal/createdByStack flags', () => { + const facts = assembleStackNetworkFacts('myapp', model, null, null); + expect(facts.renderable).toBe(true); + expect(facts.networks).toEqual([ + { key: 'default', name: 'myapp_default', external: false, internal: false, createdByStack: false }, + { key: 'backend', name: 'myapp_backend', external: false, internal: true, createdByStack: true }, + { key: 'shared', name: 'shared_net', external: true, internal: false, createdByStack: false }, + ]); + }); + + it('reports service membership, aliases, extra_hosts, and port binding flags', () => { + const svc = assembleStackNetworkFacts('myapp', model, null, null).services[0]; + expect(svc.networks).toEqual([{ key: 'backend', aliases: ['www'] }]); + expect(svc.extraHosts).toEqual(['host.docker.internal:host-gateway']); + expect(svc.publishedPorts[0]).toMatchObject({ startPort: 8080, allInterfaces: true, loopbackOnly: false }); + expect(svc.publishedPorts[1]).toMatchObject({ startPort: 9000, allInterfaces: false, loopbackOnly: true }); + }); + + it('marks the runtime unavailable and leaves drift empty when there is no snapshot', () => { + const facts = assembleStackNetworkFacts('myapp', model, null, null); + expect(facts.runtime).toBe('unavailable'); + expect(facts.drift.runtimeOnlyAttachments).toEqual([]); + }); + + it('returns a non-renderable facts payload when the model is null', () => { + const facts = assembleStackNetworkFacts('myapp', null, 'render failed', null); + expect(facts.renderable).toBe(false); + expect(facts.renderError).toBe('render failed'); + expect(facts.networks).toEqual([]); + }); + + it('computes real drift through to the payload when a snapshot is present', () => { + const snapshot: DependencySnapshot = { + containers: [container({ networks: [{ name: 'myapp_backend', id: 'a', ip: '' }, { name: 'myapp_rogue', id: 'b', ip: '' }] })], + networks: [depNet({ name: 'myapp_backend' }), depNet({ name: 'myapp_rogue' })], + volumes: [], + }; + const facts = assembleStackNetworkFacts('myapp', model, null, snapshot); + expect(facts.runtime).toBe('available'); + expect(facts.drift.runtimeOnlyAttachments).toEqual([{ container: 'web1', service: 'web', network: 'myapp_rogue' }]); + }); +}); + +describe('runtimeResourceName', () => { + it('uses a name override, else the project prefix', () => { + expect(runtimeResourceName('myapp', 'backend', undefined)).toBe('myapp_backend'); + expect(runtimeResourceName('myapp', 'backend', 'backend')).toBe('myapp_backend'); // name == key is not an override + expect(runtimeResourceName('myapp', 'shared', 'shared_net')).toBe('shared_net'); + }); +}); + +describe('parseAccessUrlPorts', () => { + it('extracts host ports from access-URL text', () => { + expect([...parseAccessUrlPorts('http://host:8080/path and https://host:443')].sort((a, b) => a - b)).toEqual([443, 8080]); + }); + it('finds no port when the URL has none (implicit scheme port)', () => { + expect([...parseAccessUrlPorts('https://app.example.com/dashboard')]).toEqual([]); + }); + it('rejects out-of-range numbers and returns an empty set for empty input', () => { + expect([...parseAccessUrlPorts('http://host:99999')]).toEqual([]); + expect([...parseAccessUrlPorts('')]).toEqual([]); + }); +}); + +describe('compareStackNetworks', () => { + const declared = fromEffectiveModel({ + projectName: 'myapp', + services: [], + networks: { + backend: { name: 'myapp_backend', external: false, internal: false }, + shared: { name: 'shared_net', external: true, internal: false }, + }, + volumes: {}, + }); + + function snapshot(containers: DependencyContainer[], networks: DependencyNetwork[]): DependencySnapshot { + return { containers, networks, volumes: [] }; + } + + it('flags a runtime-only attachment to a stack-owned undeclared network', () => { + const snap = snapshot( + [container({ networks: [{ name: 'myapp_backend', id: 'a', ip: '' }, { name: 'myapp_extra', id: 'b', ip: '' }] })], + [depNet({ name: 'myapp_backend' }), depNet({ name: 'myapp_extra' }), depNet({ name: 'shared_net', stack: null })], + ); + const drift = compareStackNetworks(declared, snap, 'myapp'); + expect(drift.runtimeOnlyAttachments).toEqual([{ container: 'web1', service: 'web', network: 'myapp_extra' }]); + }); + + it('flags a foreign network owned by another stack', () => { + const snap = snapshot( + [container({ networks: [{ name: 'other_net', id: 'x', ip: '' }] })], + [depNet({ name: 'other_net', stack: 'other', composeProject: 'other' })], + ); + const drift = compareStackNetworks(declared, snap, 'myapp'); + expect(drift.foreignNetworkAttachments).toEqual([{ container: 'web1', network: 'other_net' }]); + }); + + it('treats a stack-owned network with no project prefix as runtime-only (ownership via snapshot.stack)', () => { + const snap = snapshot( + [container({ networks: [{ name: 'odd-named-net', id: 'x', ip: '' }] })], + [depNet({ name: 'odd-named-net', stack: 'myapp', composeProject: 'myapp' })], + ); + const drift = compareStackNetworks(declared, snap, 'myapp'); + expect(drift.runtimeOnlyAttachments).toEqual([{ container: 'web1', service: 'web', network: 'odd-named-net' }]); + expect(drift.foreignNetworkAttachments).toEqual([]); + }); + + it('treats an attachment to a network absent from the snapshot as foreign', () => { + const snap = snapshot( + [container({ networks: [{ name: 'ghost-net', id: 'x', ip: '' }] })], + [], // ghost-net is not in the snapshot network list + ); + const drift = compareStackNetworks(declared, snap, 'myapp'); + expect(drift.foreignNetworkAttachments).toEqual([{ container: 'web1', network: 'ghost-net' }]); + }); + + it('drives the declared-compose adapter through the comparison (name override resolves)', () => { + const declaredFromCompose = fromDeclaredCompose({ + services: [{ name: 'web', dependsOn: [], networks: ['edge'], volumes: [], ports: [] }], + networks: { edge: { external: false, name: 'edge_override' } }, + volumes: {}, + }, 'myapp'); + const snap = snapshot( + [container({ networks: [{ name: 'myapp_default', id: 'd', ip: '' }] })], + [depNet({ name: 'myapp_default' })], + ); + // edge_override is declared but missing from the runtime. + expect(compareStackNetworks(declaredFromCompose, snap, 'myapp').missingFromRuntime).toEqual(['edge_override']); + }); + + it('ignores system networks, the default network, and external networks', () => { + const snap = snapshot( + [container({ networks: [ + { name: 'bridge', id: 's', ip: '' }, + { name: 'myapp_default', id: 'd', ip: '' }, + { name: 'shared_net', id: 'e', ip: '' }, + ] })], + [depNet({ name: 'bridge', isSystem: true, stack: null }), depNet({ name: 'myapp_default' }), depNet({ name: 'shared_net', stack: null })], + ); + const drift = compareStackNetworks(declared, snap, 'myapp'); + expect(drift.runtimeOnlyAttachments).toEqual([]); + expect(drift.foreignNetworkAttachments).toEqual([]); + }); + + it('does not flag attachments from stopped containers', () => { + const snap = snapshot( + [container({ state: 'exited', networks: [{ name: 'myapp_extra', id: 'b', ip: '' }] })], + [depNet({ name: 'myapp_extra' })], + ); + expect(compareStackNetworks(declared, snap, 'myapp').runtimeOnlyAttachments).toEqual([]); + }); + + it('reports a declared network no running service uses', () => { + const snap = snapshot( + [container({ networks: [{ name: 'myapp_default', id: 'd', ip: '' }] })], + [depNet({ name: 'myapp_backend' }), depNet({ name: 'myapp_default' })], + ); + expect(compareStackNetworks(declared, snap, 'myapp').declaredButUnused).toEqual(['backend']); + }); + + it('reports a declared network missing from the runtime', () => { + const snap = snapshot( + [container({ networks: [{ name: 'myapp_default', id: 'd', ip: '' }] })], + [depNet({ name: 'myapp_default' })], + ); + expect(compareStackNetworks(declared, snap, 'myapp').missingFromRuntime).toEqual(['myapp_backend']); + }); +}); diff --git a/backend/src/__tests__/drift-detection.test.ts b/backend/src/__tests__/drift-detection.test.ts index a4dc2871..e669ff85 100644 --- a/backend/src/__tests__/drift-detection.test.ts +++ b/backend/src/__tests__/drift-detection.test.ts @@ -11,7 +11,7 @@ import { buildStackDriftReport, } from '../services/DriftDetectionService'; import DockerController from '../services/DockerController'; -import type { DependencyContainer, DependencySnapshot } from '../services/DockerController'; +import type { DependencyContainer, DependencyNetwork, DependencySnapshot } from '../services/DockerController'; import { FileSystemService } from '../services/FileSystemService'; import type { DeclaredCompose, DeclaredService, DeclaredPort } from '../helpers/composeDependencyParse'; @@ -250,6 +250,107 @@ describe('assembleStackDrift - findings', () => { }); }); +// ── assembleStackDrift: network drift ───────────────────────────────────── + +function depNet(name: string, p: Partial = {}): DependencyNetwork { + return { id: name, name, driver: 'bridge', scope: 'local', isSystem: false, composeProject: 'app', stack: 'app', ...p }; +} + +describe('assembleStackDrift - network drift', () => { + it('flags a runtime-only attachment to a stack-owned undeclared network', () => { + const report = assembleStackDrift({ + stack: 'app', + declared: declared([service({ name: 'web' })]), + containers: [container({ id: 'c1', service: 'web', networks: [{ name: 'app_default', id: 'd', ip: '' }, { name: 'app_extra', id: 'e', ip: '' }] })], + networks: [depNet('app_default'), depNet('app_extra')], + }); + const f = report.findings.find(x => x.kind === 'network-undeclared'); + expect(f).toMatchObject({ service: 'web', actual: 'app_extra' }); + expect(f?.detail).not.toContain('app_default'); // the implicit default is declared + }); + + it('maps a foreign network attachment back to its service (not the container name)', () => { + const report = assembleStackDrift({ + stack: 'app', + declared: declared([service({ name: 'web' })]), + // Distinct container name vs service proves the service-map lookup ran, not the name fallback. + containers: [container({ id: 'c1', name: 'app-web-1', service: 'web', networks: [{ name: 'other_net', id: 'o', ip: '' }] })], + networks: [depNet('other_net', { stack: 'other', composeProject: 'other' })], + }); + expect(report.findings.find(x => x.kind === 'network-undeclared')).toMatchObject({ service: 'web', actual: 'other_net' }); + }); + + it('aggregates multiple undeclared networks on one service into a single finding', () => { + const report = assembleStackDrift({ + stack: 'app', + declared: declared([service({ name: 'web' })]), + containers: [container({ id: 'c1', service: 'web', networks: [{ name: 'app_extra1', id: '1', ip: '' }, { name: 'app_extra2', id: '2', ip: '' }] })], + networks: [depNet('app_extra1'), depNet('app_extra2')], + }); + const f = report.findings.filter(x => x.kind === 'network-undeclared'); + expect(f).toHaveLength(1); + expect(f[0]).toMatchObject({ service: 'web', actual: 'app_extra1, app_extra2' }); + expect(f[0].detail).toContain('networks not declared'); // plural wording + }); + + it('flags a declared network that no running service uses, by its runtime name', () => { + const report = assembleStackDrift({ + stack: 'app', + declared: { services: [service({ name: 'web', networks: ['frontend'] })], networks: { frontend: { external: false }, backend: { external: false } }, volumes: {} }, + containers: [container({ id: 'c1', service: 'web', networks: [{ name: 'app_frontend', id: 'f', ip: '' }] })], + networks: [depNet('app_frontend'), depNet('app_backend')], + }); + expect(report.findings.find(x => x.kind === 'network-missing')).toMatchObject({ service: '', expected: 'app_backend' }); + }); + + it('reports unused and absent declared networks together in one consistent namespace', () => { + const report = assembleStackDrift({ + stack: 'app', + declared: { services: [service({ name: 'web', networks: ['frontend'] })], networks: { frontend: { external: false }, backend: { external: false }, gamma: { external: false } }, volumes: {} }, + containers: [container({ id: 'c1', service: 'web', networks: [{ name: 'app_frontend', id: 'f', ip: '' }] })], + // app_backend exists but is unused; app_gamma is absent from the runtime. + networks: [depNet('app_frontend'), depNet('app_backend')], + }); + const f = report.findings.filter(x => x.kind === 'network-missing'); + expect(f).toHaveLength(1); + expect(f[0].expected).toBe('app_backend, app_gamma'); // both as runtime names, not mixed keys + }); + + it('ignores system and default networks', () => { + const report = assembleStackDrift({ + stack: 'app', + declared: declared([service({ name: 'web' })]), + containers: [container({ id: 'c1', service: 'web', networks: [{ name: 'bridge', id: 'b', ip: '' }, { name: 'app_default', id: 'd', ip: '' }] })], + networks: [depNet('bridge', { isSystem: true, stack: null }), depNet('app_default')], + }); + expect(report.findings.filter(x => x.kind.startsWith('network-'))).toEqual([]); + }); + + it('reports no network drift for a stopped stack', () => { + const report = assembleStackDrift({ + stack: 'app', + declared: declared([service({ name: 'web' })]), + containers: [container({ id: 'c1', service: 'web', state: 'exited', networks: [{ name: 'app_extra', id: 'e', ip: '' }] })], + networks: [depNet('app_extra')], + }); + expect(report.status).toBe('missing-runtime'); + expect(report.findings).toEqual([]); + }); + + it('resolves runtime network names via the compose top-level name (no false drift)', () => { + // Stack dir is "app" but the compose declares `name: acme`, so Docker names + // the network acme_backend. With the project name carried through, that + // matches and produces no network-undeclared / network-missing. + const report = assembleStackDrift({ + stack: 'app', + declared: { services: [service({ name: 'web', networks: ['backend'] })], networks: { backend: { external: false } }, volumes: {}, projectName: 'acme' }, + containers: [container({ id: 'c1', service: 'web', networks: [{ name: 'acme_default', id: 'd', ip: '' }, { name: 'acme_backend', id: 'b', ip: '' }] })], + networks: [depNet('acme_default'), depNet('acme_backend')], + }); + expect(report.findings.filter(f => f.kind.startsWith('network-'))).toEqual([]); + }); +}); + // ── normalizeImageRef ───────────────────────────────────────────────────── describe('normalizeImageRef', () => { @@ -321,4 +422,23 @@ describe('buildStackDriftReport - boundaries', () => { expect(findingKinds(report)).toEqual(['image-mismatch']); vi.restoreAllMocks(); }); + + it('threads the snapshot networks through into a network-undeclared finding', async () => { + const snapshot: DependencySnapshot = { + containers: [container({ id: 'c1', service: 'web', stack: 'app', image: 'nginx:1.25', networks: [{ name: 'app_rogue', id: 'r', ip: '' }] })], + networks: [depNet('app_rogue')], + volumes: [], + }; + vi.spyOn(FileSystemService, 'getInstance').mockReturnValue({ + getStackContent: vi.fn().mockResolvedValue('services:\n web:\n image: nginx:1.25\n'), + getStacks: vi.fn().mockResolvedValue(['app']), + } as unknown as FileSystemService); + vi.spyOn(DockerController, 'getInstance').mockReturnValue({ + getDependencySnapshot: vi.fn().mockResolvedValue(snapshot), + } as unknown as DockerController); + + const report = await buildStackDriftReport(0, 'app'); + expect(findingKinds(report)).toContain('network-undeclared'); + vi.restoreAllMocks(); + }); }); diff --git a/backend/src/__tests__/exposure-route.test.ts b/backend/src/__tests__/exposure-route.test.ts new file mode 100644 index 00000000..58b658cd --- /dev/null +++ b/backend/src/__tests__/exposure-route.test.ts @@ -0,0 +1,105 @@ +/** + * GET/PUT /api/stacks/:stackName/exposure: read and write the per-stack and + * per-service exposure classification. PUT requires write access, validates the + * intent value, and supports clearing a row (intent null) so a service inherits + * the stack intent again. + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; +import fs from 'fs'; +import path from 'path'; +import crypto from 'crypto'; +import request from 'supertest'; +import jwt from 'jsonwebtoken'; +import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb'; +import { DatabaseService } from '../services/DatabaseService'; +import { generateApiToken } from '../utils/apiTokenFormat'; + +let tmpDir: string; +let app: import('express').Express; +let authHeader: string; +let readOnlyToken: string; + +const STACK = 'exproute'; + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ app } = await import('../index')); + authHeader = `Bearer ${jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '5m' })}`; + + const db = DatabaseService.getInstance(); + readOnlyToken = generateApiToken(); + db.addApiToken({ + token_hash: crypto.createHash('sha256').update(readOnlyToken).digest('hex'), + name: 'exposure-readonly', scope: 'read-only', + user_id: db.getUserByUsername(TEST_USERNAME)!.id, created_at: Date.now(), expires_at: null, + }); +}); + +afterAll(() => cleanupTestDb(tmpDir)); + +describe('exposure intent routes', () => { + let stackDir: string; + beforeEach(() => { + stackDir = path.join(process.env.COMPOSE_DIR as string, STACK); + fs.mkdirSync(stackDir, { recursive: true }); + fs.writeFileSync(path.join(stackDir, 'compose.yaml'), 'services:\n web:\n image: nginx:latest\n'); + }); + afterEach(() => { + DatabaseService.getInstance().deleteStackExposureIntents(1, STACK); + fs.rmSync(stackDir, { recursive: true, force: true }); + }); + + const put = (body: object) => request(app).put(`/api/stacks/${STACK}/exposure`).set('Authorization', authHeader).send(body); + const get = () => request(app).get(`/api/stacks/${STACK}/exposure`).set('Authorization', authHeader); + + it('starts with no intents', async () => { + const res = await get(); + expect(res.status).toBe(200); + expect(res.body.intents).toEqual([]); + }); + + it('sets a stack-level and a per-service intent and records the author', async () => { + expect((await put({ intent: 'internal' })).status).toBe(200); + const res = await put({ service: 'api', intent: 'public' }); + expect(res.status).toBe(200); + expect(res.body.intents).toEqual([ + expect.objectContaining({ service: '', intent: 'internal' }), + expect.objectContaining({ service: 'api', intent: 'public' }), + ]); + const stackRow = res.body.intents.find((i: { service: string }) => i.service === ''); + expect(stackRow.updatedBy).toBe(TEST_USERNAME); + expect(typeof stackRow.updatedAt).toBe('number'); + }); + + it('clears a service row (intent null) so it inherits the stack intent', async () => { + await put({ intent: 'internal' }); + await put({ service: 'api', intent: 'public' }); + expect((await put({ service: 'api', intent: null })).status).toBe(200); + const res = await get(); + expect(res.body.intents).toEqual([expect.objectContaining({ service: '', intent: 'internal' })]); + }); + + it('clears the stack row (intent null) so the stack is unclassified again', async () => { + await put({ intent: 'internal' }); + await put({ intent: null }); + expect((await get()).body.intents).toEqual([]); + }); + + it('rejects an invalid intent value', async () => { + expect((await put({ intent: 'bogus' })).status).toBe(400); + }); + + it('blocks a write from a read-only token', async () => { + const res = await request(app).put(`/api/stacks/${STACK}/exposure`) + .set('Authorization', `Bearer ${readOnlyToken}`).send({ intent: 'internal' }); + expect(res.status).toBe(403); + }); + + it('rejects an unauthenticated request', async () => { + expect((await request(app).get(`/api/stacks/${STACK}/exposure`)).status).toBe(401); + }); + + it('returns 404 for a stack that does not exist', async () => { + expect((await request(app).get('/api/stacks/nope-not-here/exposure').set('Authorization', authHeader)).status).toBe(404); + }); +}); diff --git a/backend/src/__tests__/networking-route.test.ts b/backend/src/__tests__/networking-route.test.ts new file mode 100644 index 00000000..5ecf7c06 --- /dev/null +++ b/backend/src/__tests__/networking-route.test.ts @@ -0,0 +1,98 @@ +/** + * GET /api/stacks/:stackName/networking: returns facts, requires stack:read, + * 404s a missing stack, surfaces a structural (never raw) error on render + * failure, and never leaks an env value or a label value into the response. + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest'; +import fs from 'fs'; +import path from 'path'; +import request from 'supertest'; +import jwt from 'jsonwebtoken'; +import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb'; +import DockerController from '../services/DockerController'; +import { ComposeService } from '../services/ComposeService'; + +let tmpDir: string; +let app: import('express').Express; +let authHeader: string; + +const STACK = 'netroute'; +const ENV_SECRET = 'env-secret-44ad-value'; +const LABEL_SECRET = 'label-secret-90fe-value'; + +function stubRender(rendered: string | null, stderr = '') { + vi.spyOn(ComposeService, 'getInstance').mockReturnValue({ + renderConfig: vi.fn().mockResolvedValue({ rendered, stderr, code: rendered === null ? 1 : 0, timedOut: false }), + } as unknown as ComposeService); + vi.spyOn(DockerController, 'getInstance').mockReturnValue({ + getDependencySnapshot: vi.fn().mockResolvedValue({ containers: [], networks: [], volumes: [] }), + } as unknown as DockerController); +} + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ app } = await import('../index')); + authHeader = `Bearer ${jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '5m' })}`; +}); + +afterAll(() => cleanupTestDb(tmpDir)); + +describe('networking route', () => { + let stackDir: string; + beforeEach(() => { + stackDir = path.join(process.env.COMPOSE_DIR as string, STACK); + fs.mkdirSync(stackDir, { recursive: true }); + fs.writeFileSync(path.join(stackDir, 'compose.yaml'), 'services:\n web:\n image: nginx:latest\n'); + }); + afterEach(() => { + vi.restoreAllMocks(); + fs.rmSync(stackDir, { recursive: true, force: true }); + }); + + it('returns networking facts for a renderable stack', async () => { + stubRender(JSON.stringify({ + name: STACK, + services: { web: { image: 'nginx:latest', ports: [{ target: 80, published: '8080', protocol: 'tcp' }], networks: { backend: null } } }, + networks: { backend: { name: `${STACK}_backend` } }, + volumes: {}, + })); + const res = await request(app).get(`/api/stacks/${STACK}/networking`).set('Authorization', authHeader); + expect(res.status).toBe(200); + expect(res.body.renderable).toBe(true); + expect(res.body.runtime).toBe('available'); + expect(res.body.services[0].networks).toEqual([{ key: 'backend', aliases: [] }]); + }); + + it('surfaces a structural error and never raw stderr on render failure', async () => { + stubRender(null, `error: the "${ENV_SECRET}" variable is not set\nservices.web.image: ${ENV_SECRET}`); + const res = await request(app).get(`/api/stacks/${STACK}/networking`).set('Authorization', authHeader); + expect(res.status).toBe(200); + expect(res.body.renderable).toBe(false); + expect(JSON.stringify(res.body)).not.toContain(ENV_SECRET); + }); + + it('never leaks env or label values into the facts', async () => { + stubRender(JSON.stringify({ + name: STACK, + services: { web: { image: 'nginx:latest', environment: { TOKEN: ENV_SECRET }, labels: { 'x.secret': LABEL_SECRET } } }, + networks: {}, + volumes: {}, + })); + const res = await request(app).get(`/api/stacks/${STACK}/networking`).set('Authorization', authHeader); + expect(res.status).toBe(200); + const body = JSON.stringify(res.body); + expect(body).not.toContain(ENV_SECRET); + expect(body).not.toContain(LABEL_SECRET); + }); + + it('rejects an unauthenticated request', async () => { + const res = await request(app).get(`/api/stacks/${STACK}/networking`); + expect(res.status).toBe(401); + }); + + it('returns 404 for a stack that does not exist', async () => { + stubRender(JSON.stringify({ name: 'x', services: {}, networks: {}, volumes: {} })); + const res = await request(app).get('/api/stacks/nope-not-here/networking').set('Authorization', authHeader); + expect(res.status).toBe(404); + }); +}); diff --git a/backend/src/__tests__/networking-summary.test.ts b/backend/src/__tests__/networking-summary.test.ts new file mode 100644 index 00000000..e15d154d --- /dev/null +++ b/backend/src/__tests__/networking-summary.test.ts @@ -0,0 +1,140 @@ +/** + * The per-node networking summary and its routes: a stack that publishes a + * non-loopback port counts as exposed and, with no intent set, as + * unknown-exposure; the node-local route and the proxy-exempt fleet aggregate + * both return it. + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest'; +import fs from 'fs'; +import path from 'path'; +import request from 'supertest'; +import jwt from 'jsonwebtoken'; +import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb'; +import DockerController from '../services/DockerController'; +import { DatabaseService } from '../services/DatabaseService'; +import { NodeRegistry } from '../services/NodeRegistry'; + +let tmpDir: string; +let app: import('express').Express; +let authHeader: string; + +const STACK = 'netsummary'; + +function stubSnapshot() { + vi.spyOn(DockerController, 'getInstance').mockReturnValue({ + getDependencySnapshot: vi.fn().mockResolvedValue({ containers: [], networks: [], volumes: [] }), + } as unknown as DockerController); +} + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ app } = await import('../index')); + authHeader = `Bearer ${jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '5m' })}`; +}); + +afterAll(() => cleanupTestDb(tmpDir)); + +describe('networking summary', () => { + let stackDir: string; + beforeEach(() => { + stackDir = path.join(process.env.COMPOSE_DIR as string, STACK); + fs.mkdirSync(stackDir, { recursive: true }); + fs.writeFileSync(path.join(stackDir, 'compose.yaml'), 'services:\n web:\n image: nginx:latest\n ports:\n - "0.0.0.0:8080:80"\n'); + stubSnapshot(); + }); + afterEach(() => { + vi.restoreAllMocks(); + DatabaseService.getInstance().deleteStackExposureIntents(1, STACK); + fs.rmSync(stackDir, { recursive: true, force: true }); + }); + + it('node-local summary marks a published stack exposed and unknown-exposure', async () => { + const res = await request(app).get('/api/networking/summary').set('Authorization', authHeader); + expect(res.status).toBe(200); + expect(res.body.exposed.stacks).toContain(STACK); + expect(res.body.unknownExposure.stacks).toContain(STACK); + expect(res.body.networkDrift.stacks).toEqual([]); // empty snapshot, no running containers + expect(res.body.exposed.count).toBe(res.body.exposed.stacks.length); + }); + + it('flags a stack with an undeclared runtime network as network drift', async () => { + vi.spyOn(DockerController, 'getInstance').mockReturnValue({ + getDependencySnapshot: vi.fn().mockResolvedValue({ + containers: [{ id: 'c1', name: 'web1', service: 'web', composeProject: STACK, stack: STACK, state: 'running', image: 'nginx', networks: [{ name: `${STACK}_default`, id: 'd', ip: '' }, { name: `${STACK}_rogue`, id: 'r', ip: '' }], volumes: [], ports: [] }], + networks: [ + { id: 'd', name: `${STACK}_default`, driver: 'bridge', scope: 'local', isSystem: false, composeProject: STACK, stack: STACK }, + { id: 'r', name: `${STACK}_rogue`, driver: 'bridge', scope: 'local', isSystem: false, composeProject: STACK, stack: STACK }, + ], + volumes: [], + }), + } as unknown as DockerController); + const res = await request(app).get('/api/networking/summary').set('Authorization', authHeader); + expect(res.body.networkDrift.stacks).toContain(STACK); + }); + + it('still reports declared signals when the snapshot is unavailable (drift skipped)', async () => { + vi.spyOn(DockerController, 'getInstance').mockReturnValue({ + getDependencySnapshot: vi.fn().mockRejectedValue(new Error('docker down')), + } as unknown as DockerController); + const res = await request(app).get('/api/networking/summary').set('Authorization', authHeader); + expect(res.status).toBe(200); + expect(res.body.exposed.stacks).toContain(STACK); + expect(res.body.networkDrift.stacks).toEqual([]); + }); + + it('drops the stack from unknown-exposure once an intent is set', async () => { + DatabaseService.getInstance().setStackExposureIntent(1, STACK, '', 'public', 'admin'); + const res = await request(app).get('/api/networking/summary').set('Authorization', authHeader); + expect(res.body.exposed.stacks).toContain(STACK); + expect(res.body.unknownExposure.stacks).not.toContain(STACK); + }); + + it('a service-level intent on the only publishing service drops it from unknown-exposure', async () => { + // No stack-level row; classifying the publishing service is enough. + DatabaseService.getInstance().setStackExposureIntent(1, STACK, 'web', 'public', 'admin'); + const res = await request(app).get('/api/networking/summary').set('Authorization', authHeader); + expect(res.body.unknownExposure.stacks).not.toContain(STACK); + }); + + it('keeps the stack unknown when only some publishing services are classified', async () => { + fs.writeFileSync(path.join(stackDir, 'compose.yaml'), + 'services:\n web:\n image: nginx:latest\n ports:\n - "8080:80"\n api:\n image: nginx:latest\n ports:\n - "9090:90"\n'); + // web classified, api still unset, so the stack remains effectively unknown. + DatabaseService.getInstance().setStackExposureIntent(1, STACK, 'web', 'public', 'admin'); + const res = await request(app).get('/api/networking/summary').set('Authorization', authHeader); + expect(res.body.unknownExposure.stacks).toContain(STACK); + }); + + it('the fleet aggregate returns a per-node summary for the hub', async () => { + const res = await request(app).get('/api/fleet/networking-summary').set('Authorization', authHeader); + expect(res.status).toBe(200); + expect(Array.isArray(res.body.nodes)).toBe(true); + const local = res.body.nodes.find((n: { status: string; summary: { exposed: { stacks: string[] } } | null }) => n.summary?.exposed.stacks.includes(STACK)); + expect(local).toBeDefined(); + expect(local.status).toBe('ok'); + expect(local.summary.unknownExposure).toBeDefined(); + expect(local.summary.networkDrift).toBeDefined(); + }); + + it('degrades a remote that errors to a node-error while keeping the hub', async () => { + const db = DatabaseService.getInstance(); + const remoteId = db.addNode({ name: 'remote-degrade', type: 'remote', compose_dir: '/app/compose', is_default: false, api_url: 'http://remote.invalid', api_token: 't' }); + vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) => id === remoteId ? { apiUrl: 'http://remote.invalid', apiToken: 't' } : null); + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('not found', { status: 404 })); + try { + const res = await request(app).get('/api/fleet/networking-summary').set('Authorization', authHeader); + expect(res.status).toBe(200); + const hub = res.body.nodes.find((n: { summary: unknown }) => n.summary !== null); + const remote = res.body.nodes.find((n: { nodeId: number }) => n.nodeId === remoteId); + expect(hub.status).toBe('ok'); + expect(remote.status).toBe('error'); + expect(remote.summary).toBeNull(); + } finally { + db.deleteNode(remoteId); + } + }); + + it('rejects an unauthenticated request to the node-local summary', async () => { + expect((await request(app).get('/api/networking/summary')).status).toBe(401); + }); +}); diff --git a/backend/src/__tests__/preflight-effective-model.test.ts b/backend/src/__tests__/preflight-effective-model.test.ts index db66576a..772cf724 100644 --- a/backend/src/__tests__/preflight-effective-model.test.ts +++ b/backend/src/__tests__/preflight-effective-model.test.ts @@ -7,6 +7,7 @@ import { describe, it, expect } from 'vitest'; import { parseEffectiveModel } from '../services/preflight/effectiveModel'; const SECRET = 'topsecret-9f3a-value'; +const LABEL_SECRET = 'label-secret-7b21-value'; function render() { return { @@ -31,10 +32,14 @@ function render() { container_name: 'web1', user: '1000:1000', environment: { DB_PASSWORD: SECRET, PUID: '1000' }, + networks: { backend: { aliases: ['www', 'web'] }, shared: null }, + extra_hosts: ['host.docker.internal:host-gateway'], + labels: { 'traefik.enable': 'true', 'secret.label': LABEL_SECRET }, }, }, networks: { default: { name: 'myapp_default' }, + backend: { name: 'myapp_backend', internal: true }, shared: { name: 'shared_net', external: true }, }, volumes: { @@ -71,12 +76,49 @@ describe('parseEffectiveModel', () => { ]); }); - it('resolves top-level networks and volumes with external flags', () => { + it('resolves top-level networks and volumes with external and internal flags', () => { const m = parseEffectiveModel(render(), 'fallback'); - expect(m.networks.shared).toEqual({ name: 'shared_net', external: true }); - expect(m.networks.default).toEqual({ name: 'myapp_default', external: false }); - expect(m.volumes.ext).toEqual({ name: 'shared_vol', external: true }); - expect(m.volumes.cache).toEqual({ name: 'myapp_cache', external: false }); + expect(m.networks.shared).toEqual({ name: 'shared_net', external: true, internal: false }); + expect(m.networks.default).toEqual({ name: 'myapp_default', external: false, internal: false }); + expect(m.networks.backend).toEqual({ name: 'myapp_backend', external: false, internal: true }); + expect(m.volumes.ext).toEqual({ name: 'shared_vol', external: true, internal: false }); + expect(m.volumes.cache).toEqual({ name: 'myapp_cache', external: false, internal: false }); + }); + + it('parses service network membership (key-space) with aliases', () => { + const web = parseEffectiveModel(render(), 'fallback').services[0]; + expect(web.networks).toEqual([ + { key: 'backend', aliases: ['www', 'web'] }, + { key: 'shared', aliases: [] }, + ]); + }); + + it('parses service network membership from the list form', () => { + const m = parseEffectiveModel({ services: { s: { networks: ['frontend', 'backend'] } } }, 'p'); + expect(m.services[0].networks).toEqual([ + { key: 'frontend', aliases: [] }, + { key: 'backend', aliases: [] }, + ]); + }); + + it('parses extra_hosts in list and map form', () => { + const web = parseEffectiveModel(render(), 'fallback').services[0]; + expect(web.extraHosts).toEqual(['host.docker.internal:host-gateway']); + const mapForm = parseEffectiveModel({ services: { s: { extra_hosts: { 'db.local': '10.0.0.5' } } } }, 'p'); + expect(mapForm.services[0].extraHosts).toEqual(['db.local:10.0.0.5']); + }); + + it('reads label KEY names only, never label values', () => { + const web = parseEffectiveModel(render(), 'fallback').services[0]; + expect(web.labelKeys).toEqual(['traefik.enable', 'secret.label']); + // A label value can carry a secret; it must not survive into the model. + expect(JSON.stringify(parseEffectiveModel(render(), 'fallback'))).not.toContain(LABEL_SECRET); + }); + + it('reads label key names from the list form without keeping the value', () => { + const m = parseEffectiveModel({ services: { s: { labels: [`secret.label=${LABEL_SECRET}`, 'plain'] } } }, 'p'); + expect(m.services[0].labelKeys).toEqual(['secret.label', 'plain']); + expect(JSON.stringify(m)).not.toContain(LABEL_SECRET); }); it('reads environment KEY names only, never values', () => { diff --git a/backend/src/__tests__/preflight-rules.test.ts b/backend/src/__tests__/preflight-rules.test.ts index a57311a3..5eddb896 100644 --- a/backend/src/__tests__/preflight-rules.test.ts +++ b/backend/src/__tests__/preflight-rules.test.ts @@ -13,7 +13,8 @@ import type { PreflightContext, PreflightFinding } from '../services/preflight/t function svc(over: Partial = {}): EffService { return { name: 'web', image: 'nginx:1.27', ports: [], binds: [], namedVolumes: [], - privileged: false, hasHealthcheck: true, restart: 'unless-stopped', envKeys: [], ...over, + privileged: false, hasHealthcheck: true, restart: 'unless-stopped', envKeys: [], + networks: [], extraHosts: [], labelKeys: [], ...over, }; } @@ -27,7 +28,8 @@ function ctx(over: Partial = {}): PreflightContext { stackName: 'proj', platform: 'linux', model: m, renderable: true, renderError: null, unsetEnvVars: [], sourceServiceNames: m ? m.services.map(s => s.name) : [], sourceReadable: true, nodePorts: [], existingNetworkNames: new Set(), existingVolumeNames: new Set(), - existingContainers: [], bindChecks: [], ...over, + existingContainers: [], bindChecks: [], + stackIntent: null, serviceIntents: {}, accessUrlPorts: new Set(), hasAccessUrls: false, ...over, }; } @@ -178,18 +180,18 @@ describe('hygiene rules', () => { describe('network / volume rules', () => { it('blocks a missing external network and volume', () => { - const m = model([svc()], { networks: { ext: { name: 'shared', external: true } }, volumes: { v: { name: 'data', external: true } } }); + const m = model([svc()], { networks: { ext: { name: 'shared', external: true, internal: false } }, volumes: { v: { name: 'data', external: true, internal: false } } }); const f = runRules(ctx({ model: m })); expect(ids(f, 'external-network-missing')).toHaveLength(1); expect(ids(f, 'external-volume-missing')).toHaveLength(1); }); it('does not block an external resource that exists', () => { - const m = model([svc()], { networks: { ext: { name: 'shared', external: true } } }); + const m = model([svc()], { networks: { ext: { name: 'shared', external: true, internal: false } } }); const f = runRules(ctx({ model: m, existingNetworkNames: new Set(['shared']) })); expect(ids(f, 'external-network-missing')).toHaveLength(0); }); it('reports a new network/volume as info when absent on the node', () => { - const m = model([svc()], { networks: { backend: { name: 'backend', external: false } }, volumes: { data: { name: 'data', external: false } } }); + const m = model([svc()], { networks: { backend: { name: 'backend', external: false, internal: false } }, volumes: { data: { name: 'data', external: false, internal: false } } }); const f = runRules(ctx({ model: m })); expect(ids(f, 'new-network')[0].severity).toBe('info'); expect(ids(f, 'new-volume')[0].message).toContain('proj_data'); @@ -231,6 +233,93 @@ describe('effective-model-expanded', () => { }); }); +describe('exposure-intent rules', () => { + const withPort = (hostIp = '0.0.0.0', over: Partial = {}) => + model([svc({ name: 'web', ports: [{ startPort: 8080, endPort: 8080, hostIp, protocol: 'tcp' }], ...over })]); + + it('flags a service classified internal that publishes a host port', () => { + const f = runRules(ctx({ model: withPort(), stackIntent: 'internal' })); + expect(ids(f, 'exposure-internal-published')).toHaveLength(1); + expect(ids(f, 'exposure-internal-published')[0].severity).toBe('high'); + }); + it('lets same-node tolerate a loopback bind but not a broad one', () => { + expect(ids(runRules(ctx({ model: withPort('127.0.0.1'), stackIntent: 'same-node' })), 'exposure-internal-published')).toHaveLength(0); + expect(ids(runRules(ctx({ model: withPort('0.0.0.0'), stackIntent: 'same-node' })), 'exposure-internal-published')).toHaveLength(1); + }); + it('lets a per-service intent override the stack intent', () => { + // Stack is internal, but the service is reclassified public, so no finding. + const f = runRules(ctx({ model: withPort(), stackIntent: 'internal', serviceIntents: { web: 'public' } })); + expect(ids(f, 'exposure-internal-published')).toHaveLength(0); + }); + it('same-node lists only the broad port when a service binds both loopback and broad', () => { + const m = model([svc({ name: 'web', ports: [ + { startPort: 9000, endPort: 9000, hostIp: '127.0.0.1', protocol: 'tcp' }, + { startPort: 8080, endPort: 8080, hostIp: '0.0.0.0', protocol: 'tcp' }, + ] })]); + const f = ids(runRules(ctx({ model: m, stackIntent: 'same-node' })), 'exposure-internal-published'); + expect(f).toHaveLength(1); + expect(f[0].message).toContain('8080'); + expect(f[0].message).not.toContain('9000'); + }); + it('warns when a port-publishing stack has no exposure intent', () => { + expect(ids(runRules(ctx({ model: withPort(), stackIntent: null })), 'exposure-unclassified')).toHaveLength(1); + expect(ids(runRules(ctx({ model: withPort(), stackIntent: 'unknown' })), 'exposure-unclassified')).toHaveLength(1); + expect(ids(runRules(ctx({ model: withPort(), stackIntent: 'lan' })), 'exposure-unclassified')).toHaveLength(0); + }); + it('lets a service-level intent suppress the unclassified warning even when the stack is unset', () => { + // web is the only publishing service; classifying it removes the gap. + expect(ids(runRules(ctx({ model: withPort(), stackIntent: null, serviceIntents: { web: 'public' } })), 'exposure-unclassified')).toHaveLength(0); + }); + it('still warns when a publishing service is explicitly unknown over a classified stack', () => { + expect(ids(runRules(ctx({ model: withPort(), stackIntent: 'public', serviceIntents: { web: 'unknown' } })), 'exposure-unclassified')).toHaveLength(1); + }); + it('does not warn unclassified when no port is published', () => { + expect(ids(runRules(ctx({ model: model([svc()]), stackIntent: null })), 'exposure-unclassified')).toHaveLength(0); + }); + it('flags a published port absent from the documented access URLs', () => { + const f = runRules(ctx({ model: withPort(), hasAccessUrls: true, accessUrlPorts: new Set([443]) })); + expect(ids(f, 'exposure-port-vs-dossier')).toHaveLength(1); + // No finding once the port is documented. + expect(ids(runRules(ctx({ model: withPort(), hasAccessUrls: true, accessUrlPorts: new Set([8080]) })), 'exposure-port-vs-dossier')).toHaveLength(0); + // Gated off when the dossier records no access URL. + expect(ids(runRules(ctx({ model: withPort(), hasAccessUrls: false })), 'exposure-port-vs-dossier')).toHaveLength(0); + }); + it('flags a published port absent from the documented access URLs, listing all undocumented ports', () => { + const m = model([svc({ name: 'web', ports: [ + { startPort: 8080, endPort: 8080, hostIp: '0.0.0.0', protocol: 'tcp' }, + { startPort: 9090, endPort: 9090, hostIp: '0.0.0.0', protocol: 'tcp' }, + ] })]); + const f = ids(runRules(ctx({ model: m, hasAccessUrls: true, accessUrlPorts: new Set([8080]) })), 'exposure-port-vs-dossier'); + expect(f).toHaveLength(1); + expect(f[0].message).toContain('9090'); + expect(f[0].message).not.toContain('8080'); + }); + it('flags reverse-proxy labels with no documented URL or intent', () => { + const m = model([svc({ name: 'web', labelKeys: ['traefik.enable', 'traefik.http.routers.web.rule'] })]); + expect(ids(runRules(ctx({ model: m })), 'reverse-proxy-undocumented')).toHaveLength(1); + // A caddy-docker-proxy label also trips it. + const caddy = model([svc({ name: 'web', labelKeys: ['caddy', 'caddy.reverse_proxy'] })]); + expect(ids(runRules(ctx({ model: caddy })), 'reverse-proxy-undocumented')).toHaveLength(1); + // An unrelated vendor label that merely contains "caddy" does not. + const vendor = model([svc({ name: 'web', labelKeys: ['com.caddyserver.unrelated'] })]); + expect(ids(runRules(ctx({ model: vendor })), 'reverse-proxy-undocumented')).toHaveLength(0); + // Silenced once documented, stack-intent reverse-proxy, or service-intent reverse-proxy. + expect(ids(runRules(ctx({ model: m, hasAccessUrls: true })), 'reverse-proxy-undocumented')).toHaveLength(0); + expect(ids(runRules(ctx({ model: m, stackIntent: 'reverse-proxy' })), 'reverse-proxy-undocumented')).toHaveLength(0); + expect(ids(runRules(ctx({ model: m, serviceIntents: { web: 'reverse-proxy' } })), 'reverse-proxy-undocumented')).toHaveLength(0); + }); + it('flags a sensitive image exposed on all interfaces', () => { + const m = model([svc({ name: 'db', image: 'postgres:16', ports: [{ startPort: 5432, endPort: 5432, hostIp: '0.0.0.0', protocol: 'tcp' }] })]); + expect(ids(runRules(ctx({ model: m })), 'sensitive-service-broad-exposure')[0].severity).toBe('high'); + // A loopback bind of the same image does not flag. + const loop = model([svc({ name: 'db', image: 'postgres:16', ports: [{ startPort: 5432, endPort: 5432, hostIp: '127.0.0.1', protocol: 'tcp' }] })]); + expect(ids(runRules(ctx({ model: loop })), 'sensitive-service-broad-exposure')).toHaveLength(0); + // A build-only service with no image is not matched, even on a broad bind. + const build = model([svc({ name: 'postgres-ish', image: undefined, ports: [{ startPort: 5432, endPort: 5432, hostIp: '0.0.0.0', protocol: 'tcp' }] })]); + expect(ids(runRules(ctx({ model: build })), 'sensitive-service-broad-exposure')).toHaveLength(0); + }); +}); + describe('rule registry completeness', () => { // The canonical rule set. Adding or removing a rule must update this list, // which forces a deliberate pass over the docs and the frontend severity map. @@ -239,7 +328,9 @@ describe('rule registry completeness', () => { 'bind-path-missing', 'bind-path-permission', 'docker-socket-mount', 'privileged', 'network-mode-host', 'uid-gid-risk', 'image-latest', 'no-restart-policy', 'no-healthcheck', 'deploy-swarm-only', 'external-network-missing', 'external-volume-missing', 'new-network', 'new-volume', - 'container-name-internal-dup', 'container-name-collision', 'effective-model-expanded', + 'container-name-internal-dup', 'container-name-collision', + 'exposure-internal-published', 'sensitive-service-broad-exposure', 'exposure-unclassified', + 'exposure-port-vs-dossier', 'reverse-proxy-undocumented', 'effective-model-expanded', ]; it('the registry contains exactly the expected rules', () => { expect([...RULE_IDS].sort()).toEqual([...EXPECTED_RULE_IDS].sort()); diff --git a/backend/src/__tests__/stack-exposure-intent.test.ts b/backend/src/__tests__/stack-exposure-intent.test.ts new file mode 100644 index 00000000..6f78c95f --- /dev/null +++ b/backend/src/__tests__/stack-exposure-intent.test.ts @@ -0,0 +1,56 @@ +/** + * Stack exposure-intent DAO: stack-level ('') and per-service rows, upsert, + * single-row clear, clear-all, the CHECK constraint, and node-delete cleanup. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb'; +import { DatabaseService } from '../services/DatabaseService'; + +let tmpDir: string; +let db: DatabaseService; + +beforeAll(async () => { + tmpDir = await setupTestDb(); + db = DatabaseService.getInstance(); +}); + +afterAll(() => cleanupTestDb(tmpDir)); + +describe('stack exposure intent DAO', () => { + it('stores and reads stack-level and per-service intents', () => { + db.setStackExposureIntent(1, 'web', '', 'internal', 'admin'); + db.setStackExposureIntent(1, 'web', 'api', 'public', 'admin'); + const rows = db.getStackExposureIntents(1, 'web'); + expect(rows.map(r => [r.service, r.intent])).toEqual([['', 'internal'], ['api', 'public']]); + expect(rows[0].updated_by).toBe('admin'); + }); + + it('upserts an existing row in place', () => { + db.setStackExposureIntent(1, 'web', '', 'lan', 'admin2'); + const stack = db.getStackExposureIntents(1, 'web').find(r => r.service === ''); + expect(stack?.intent).toBe('lan'); + expect(stack?.updated_by).toBe('admin2'); + }); + + it('clears one row and clears all rows for a stack', () => { + db.deleteStackExposureIntent(1, 'web', 'api'); + expect(db.getStackExposureIntents(1, 'web').map(r => r.service)).toEqual(['']); + db.deleteStackExposureIntents(1, 'web'); + expect(db.getStackExposureIntents(1, 'web')).toEqual([]); + }); + + it('rejects an out-of-range intent via the CHECK constraint', () => { + const raw = (db as unknown as { db: import('better-sqlite3').Database }).db; + expect(() => raw + .prepare("INSERT INTO stack_exposure_intent (node_id, stack_name, service, intent, updated_at) VALUES (1, 'x', '', 'bogus', 1)") + .run()).toThrow(/CHECK constraint failed/); + }); + + it('removes intent rows when the owning node is deleted', () => { + const nodeId = db.addNode({ name: 'expnode', type: 'remote', compose_dir: '/app/compose', is_default: false, api_url: 'http://x', api_token: 't' }); + db.setStackExposureIntent(nodeId, 'svc', '', 'public', null); + expect(db.getStackExposureIntents(nodeId, 'svc')).toHaveLength(1); + db.deleteNode(nodeId); + expect(db.getStackExposureIntents(nodeId, 'svc')).toEqual([]); + }); +}); diff --git a/backend/src/helpers/composeDependencyParse.ts b/backend/src/helpers/composeDependencyParse.ts index 6d26842b..f3e39703 100644 --- a/backend/src/helpers/composeDependencyParse.ts +++ b/backend/src/helpers/composeDependencyParse.ts @@ -37,6 +37,9 @@ export interface DeclaredCompose { services: DeclaredService[]; networks: Record; volumes: Record; + /** Top-level `name:` (the Compose project name), when set; it determines the + * `_` runtime names, overriding the stack directory name. */ + projectName?: string; /** Set when the file could not be parsed; the other fields are then empty. */ parseError?: string; } @@ -198,5 +201,6 @@ export function parseComposeDependencies(content: string): DeclaredCompose { services, networks: collectResources(root.networks), volumes: collectResources(root.volumes), + projectName: asString(root.name), }; } diff --git a/backend/src/index.ts b/backend/src/index.ts index c1e8789c..4dd10761 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -55,6 +55,7 @@ import { stackActivityMetricsRouter } from './routes/stackActivityMetrics'; import { secretsRouter } from './routes/secrets'; import { diagnosticsRouter } from './routes/diagnostics'; import { dependencyMapRouter } from './routes/dependencyMap'; +import { networkingRouter } from './routes/networking'; // Suppress [DEP0060] DeprecationWarning emitted by http-proxy@1.18.1 which calls // util._extend internally. The warning fires at runtime when createProxyServer() is @@ -145,6 +146,7 @@ app.use('/api/ports', portsRouter); app.use('/api/dashboard', dashboardRouter); app.use('/api/diagnostics', diagnosticsRouter); app.use('/api/dependency-map', dependencyMapRouter); +app.use('/api/networking', networkingRouter); app.use('/api/nodes', nodesRouter); app.use('/api/stacks', stackActivityRouter); app.use('/api/stacks', stacksRouter); diff --git a/backend/src/routes/fleet.ts b/backend/src/routes/fleet.ts index 4cc97476..a542fee0 100644 --- a/backend/src/routes/fleet.ts +++ b/backend/src/routes/fleet.ts @@ -8,6 +8,7 @@ import { ControlIdentityMismatchError, FleetSyncService, StaleSyncPushError } fr import { MAX_SYNC_ROWS, SYNC_ERROR_CODES } from '../services/fleetSyncConstants'; import { FleetUpdateTrackerService, type UpdateTracker, type TerminalStatus, UPDATE_TIMEOUT_MS, UPDATE_TIMEOUT_MSG, TERMINAL_TTL_MS } from '../services/FleetUpdateTrackerService'; import { NodeRegistry } from '../services/NodeRegistry'; +import { computeNodeNetworkingSummary, type NodeNetworkingSummary } from '../services/network/networkingSummary'; import DockerController from '../services/DockerController'; import { FileSystemService } from '../services/FileSystemService'; import { ComposeService } from '../services/ComposeService'; @@ -702,6 +703,75 @@ fleetRouter.get('/dependency-map', authMiddleware, async (_req: Request, res: Re } }); +interface FleetNetworkingSummaryNode { + nodeId: number; + nodeName: string; + status: 'ok' | 'error'; + summary: NodeNetworkingSummary | null; + error: string | null; +} + +function isNodeNetworkingSummary(v: unknown): v is NodeNetworkingSummary { + if (!v || typeof v !== 'object') return false; + const o = v as Record; + return (['exposed', 'unknownExposure', 'networkDrift'] as const).every(k => { + const b = o[k] as { count?: unknown; stacks?: unknown } | undefined; + return !!b && typeof b.count === 'number' && Array.isArray(b.stacks); + }); +} + +/** + * Fleet-wide networking summary for the overview filter. Auth-only (read-only, + * Community). Hub-exempt under /api/fleet, so it is never proxied: it builds the + * hub's summary in-process and reaches each remote through its node-local + * /api/networking/summary route. A remote on an older version (no route) returns + * 404 and degrades to a skip, so one unreachable or unsupported node never fails + * the filter for the rest. + */ +fleetRouter.get('/networking-summary', authMiddleware, async (_req: Request, res: Response): Promise => { + try { + const db = DatabaseService.getInstance(); + const nodes = db.getNodes(); + + const results = await Promise.allSettled( + nodes.map(async (node: Node): Promise => { + if (node.type === 'local') { + const summary = await computeNodeNetworkingSummary(node.id); + return { nodeId: node.id, nodeName: node.name, status: 'ok', summary, error: null }; + } + const target = NodeRegistry.getInstance().getProxyTarget(node.id); + if (!target) { + return { nodeId: node.id, nodeName: node.name, status: 'error', summary: null, error: formatNoTargetError(node) }; + } + const resp = await fetch( + `${target.apiUrl.replace(/\/$/, '')}/api/networking/summary`, + { headers: { ...(target.apiToken ? { Authorization: `Bearer ${target.apiToken}` } : {}) }, signal: AbortSignal.timeout(15000) }, + ); + if (!resp.ok) { + return { nodeId: node.id, nodeName: node.name, status: 'error', summary: null, error: `Remote returned ${resp.status}` }; + } + const summary = await resp.json().catch(() => null); + if (!isNodeNetworkingSummary(summary)) { + console.error(`[Fleet] Networking summary: node ${sanitizeForLog(node.name)} returned an unexpected payload (status ${resp.status})`); + return { nodeId: node.id, nodeName: node.name, status: 'error', summary: null, error: 'Remote returned an unexpected summary payload' }; + } + return { nodeId: node.id, nodeName: node.name, status: 'ok', summary, error: null }; + }), + ); + + const perNode: FleetNetworkingSummaryNode[] = results.map((result, i) => { + if (result.status === 'fulfilled') return result.value; + console.error(`[Fleet] Networking summary fetch failed for node ${nodes[i].name}:`, result.reason); + return { nodeId: nodes[i].id, nodeName: nodes[i].name, status: 'error', summary: null, error: getErrorMessage(result.reason, 'Failed to reach node') }; + }); + + res.json({ nodes: perNode }); + } catch (error) { + console.error('[Fleet] Networking summary error:', error); + res.status(500).json({ error: 'Failed to build fleet networking summary' }); + } +}); + fleetRouter.get('/node/:nodeId/stacks', authMiddleware, async (req: Request, res: Response): Promise => { try { const nodeId = parseIntParam(req, res, 'nodeId', 'node ID'); diff --git a/backend/src/routes/networking.ts b/backend/src/routes/networking.ts new file mode 100644 index 00000000..11d5f59b --- /dev/null +++ b/backend/src/routes/networking.ts @@ -0,0 +1,19 @@ +import { Router, type Request, type Response } from 'express'; +import { computeNodeNetworkingSummary } from '../services/network/networkingSummary'; +import { getErrorMessage } from '../utils/errors'; +import { sanitizeForLog } from '../utils/safeLog'; + +export const networkingRouter = Router(); + +// Node-local networking summary for the Fleet view filter. Auth-only and +// read-only (Community). The fleet aggregate computes the hub's summary by +// calling the underlying service in-process and reaches each remote through +// this route, so a remote is summarized on the node that owns its stacks. +networkingRouter.get('/summary', async (req: Request, res: Response): Promise => { + try { + res.json(await computeNodeNetworkingSummary(req.nodeId)); + } catch (error) { + console.error('[Networking] Failed to build node summary:', sanitizeForLog(getErrorMessage(error, 'unknown'))); + res.status(500).json({ error: 'Failed to build networking summary' }); + } +}); diff --git a/backend/src/routes/stacks.ts b/backend/src/routes/stacks.ts index 1729ec24..b31b8e93 100644 --- a/backend/src/routes/stacks.ts +++ b/backend/src/routes/stacks.ts @@ -16,6 +16,8 @@ import { enforcePolicyPreDeploy } from '../services/PolicyEnforcement'; import { buildStackDriftReport, type DriftFindingKind, type StackDriftReport } from '../services/DriftDetectionService'; import { DriftLedgerService, type DriftTemporal } from '../services/DriftLedgerService'; import { ComposeDoctorService } from '../services/ComposeDoctorService'; +import { buildStackNetworkFacts } from '../services/network/composeNetworkInspector'; +import { EXPOSURE_INTENTS, type ExposureIntent } from '../services/network/types'; import { UpdateGuardService } from '../services/UpdateGuardService'; import { HealthGateService } from '../services/HealthGateService'; import { classifyFailure } from '../services/updateGuard/failureClassifier'; @@ -950,6 +952,7 @@ stacksRouter.delete('/:stackName', async (req: Request, res: Response) => { DatabaseService.getInstance().deleteGitSource(stackName); DatabaseService.getInstance().deleteStackDossier(req.nodeId, stackName); DatabaseService.getInstance().deleteStackDriftFindings(req.nodeId, stackName); + DatabaseService.getInstance().deleteStackExposureIntents(req.nodeId, stackName); if (debug) console.debug(`[Stacks:debug] Delete: db OK`, { stackName: sanitizedName }); } catch (dbErr) { console.error('[Stacks] Database cleanup failed for %s; files already removed:', sanitizeForLog(stackName), dbErr); @@ -1127,6 +1130,82 @@ stacksRouter.post('/:stackName/preflight/run', async (req: Request, res: Respons } }); +// Compose Network Inspector: per-stack networking facts (network map, service +// membership, published ports/bindings, network_mode, extra_hosts, runtime +// drift) derived from the authored effective model + live snapshot. Read-only +// and advisory; auto-proxies to the active node. Never returns raw render +// stderr, env values, or label values. +stacksRouter.get('/:stackName/networking', async (req: Request, res: Response) => { + const stackName = req.params.stackName as string; + if (!requirePermission(req, res, 'stack:read', 'stack', stackName)) return; + if (!(await requireStackExists(req.nodeId, stackName, res))) return; + try { + res.json(await buildStackNetworkFacts(req.nodeId, stackName)); + } catch (error) { + console.error('[Stacks] Failed to build networking facts for %s:', sanitizeForLog(stackName), + sanitizeForLog(inspect(error, { depth: 4 }))); + res.status(500).json({ error: 'Failed to build networking facts' }); + } +}); + +// Exposure intent: the user's per-stack (service '') and per-service exposure +// classification, stored separately from generated facts so mismatches stay +// detectable. Rows are stored independently; precedence (a service row taking +// priority over the stack row, an absent service row inheriting the stack +// intent) is applied by the consumers that read these rows, not enforced here. +// Clearing a row (intent null) deletes it, returning that scope to unset. +const ExposurePutSchema = z.object({ + service: z.string().max(255).optional().default(''), + intent: z.enum(EXPOSURE_INTENTS).nullable(), +}); + +function exposurePayload(nodeId: number, stackName: string): { + intents: { service: string; intent: ExposureIntent; updatedAt: number; updatedBy: string | null }[]; +} { + return { + intents: DatabaseService.getInstance().getStackExposureIntents(nodeId, stackName).map(r => ({ + service: r.service, intent: r.intent, updatedAt: r.updated_at, updatedBy: r.updated_by, + })), + }; +} + +stacksRouter.get('/:stackName/exposure', async (req: Request, res: Response) => { + const stackName = req.params.stackName as string; + if (!requirePermission(req, res, 'stack:read', 'stack', stackName)) return; + if (!(await requireStackExists(req.nodeId, stackName, res))) return; + try { + res.json(exposurePayload(req.nodeId, stackName)); + } catch (error) { + console.error('[Stacks] Failed to read exposure intent for %s:', sanitizeForLog(stackName), + sanitizeForLog(getErrorMessage(error, 'unknown'))); + res.status(500).json({ error: 'Failed to read exposure intent' }); + } +}); + +stacksRouter.put('/:stackName/exposure', async (req: Request, res: Response) => { + const stackName = req.params.stackName as string; + if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return; + if (!(await requireStackExists(req.nodeId, stackName, res))) return; + const parsed = ExposurePutSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: 'Invalid exposure intent' }); + return; + } + const { service, intent } = parsed.data; + try { + if (intent === null) { + DatabaseService.getInstance().deleteStackExposureIntent(req.nodeId, stackName, service); + } else { + DatabaseService.getInstance().setStackExposureIntent(req.nodeId, stackName, service, intent, req.user?.username ?? null); + } + res.json(exposurePayload(req.nodeId, stackName)); + } catch (error) { + console.error('[Stacks] Failed to save exposure intent for %s:', sanitizeForLog(stackName), + sanitizeForLog(getErrorMessage(error, 'unknown'))); + res.status(500).json({ error: 'Failed to save exposure intent' }); + } +}); + // Update guard: readiness reports computed on demand from existing stores // (preflight runs, drift findings, backup slot, update preview, live Docker // state). Node-scoped like preflight: a remote stack is evaluated on the node diff --git a/backend/src/services/CapabilityRegistry.ts b/backend/src/services/CapabilityRegistry.ts index 29de6c97..eb0c2e01 100644 --- a/backend/src/services/CapabilityRegistry.ts +++ b/backend/src/services/CapabilityRegistry.ts @@ -37,6 +37,7 @@ export const CAPABILITIES = [ 'vulnerability-scanning', 'compose-doctor', 'update-guard', + 'compose-networking', ] as const; export type Capability = (typeof CAPABILITIES)[number]; diff --git a/backend/src/services/ComposeDoctorService.ts b/backend/src/services/ComposeDoctorService.ts index cf9638ad..56acfb96 100644 --- a/backend/src/services/ComposeDoctorService.ts +++ b/backend/src/services/ComposeDoctorService.ts @@ -9,6 +9,8 @@ import { DatabaseService } from './DatabaseService'; import { computeStackHashes } from './DriftLedgerService'; import { parseComposeDependencies } from '../helpers/composeDependencyParse'; import { parseEffectiveModel, type EffectiveModel } from './preflight/effectiveModel'; +import { parseAccessUrlPorts } from './network/normalize'; +import type { ExposureIntent } from './network/types'; import { runRules, SEVERITY_RANK, RULE_IDS, RENDER_FAILED_RULE_ID } from './preflight/rules'; import type { BindCheck, NodePortBinding, PreflightContext, PreflightFinding, PreflightReport, PreflightSeverity, PreflightStatus, @@ -196,6 +198,7 @@ export class ComposeDoctorService { const { nodePorts, existingNetworkNames, existingVolumeNames, existingContainers } = await this.nodeState(nodeId, fsSvc, stackName); const bindChecks = model ? await this.resolveBindChecks(model, baseDir) : []; + const { stackIntent, serviceIntents, accessUrlPorts, hasAccessUrls } = this.exposureState(nodeId, stackName); return { stackName, @@ -211,9 +214,46 @@ export class ComposeDoctorService { existingVolumeNames, existingContainers, bindChecks, + stackIntent, + serviceIntents, + accessUrlPorts, + hasAccessUrls, }; } + /** + * The user's stored exposure intent (resolved into stack-level + per-service) + * and the dossier's documented access-URL ports, for the exposure rules. + * Fail-soft: a read error defaults to unset/empty so the rules simply do not + * fire rather than the whole preflight failing. + */ + private exposureState(nodeId: number, stackName: string): { + stackIntent: ExposureIntent | null; + serviceIntents: Record; + accessUrlPorts: Set; + hasAccessUrls: boolean; + } { + try { + const db = DatabaseService.getInstance(); + const rows = db.getStackExposureIntents(nodeId, stackName); + const stackIntent = rows.find(r => r.service === '')?.intent ?? null; + const serviceIntents: Record = {}; + for (const r of rows) if (r.service !== '') serviceIntents[r.service] = r.intent; + + const accessUrls = db.getStackDossier(nodeId, stackName)?.access_urls ?? ''; + return { + stackIntent, + serviceIntents, + accessUrlPorts: parseAccessUrlPorts(accessUrls), + hasAccessUrls: accessUrls.trim().length > 0, + }; + } catch (error) { + console.warn('[ComposeDoctor] Exposure state unavailable for %s; exposure rules skipped:', + sanitizeForLog(stackName), sanitizeForLog(getErrorMessage(error, 'unknown'))); + return { stackIntent: null, serviceIntents: {}, accessUrlPorts: new Set(), hasAccessUrls: false }; + } + } + /** Snapshot the node's ports/networks/volumes/containers. Degrades to empty if Docker is unreachable. */ private async nodeState(nodeId: number, fsSvc: FileSystemService, stackName: string): Promise<{ nodePorts: NodePortBinding[]; diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index 41825d24..4a9e3e04 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -4,6 +4,7 @@ import fs from 'fs'; import { CryptoService } from './CryptoService'; import { isSeverityAtLeast } from '../utils/severity'; import type { AuditStatsInput } from './AuditAnomalyService'; +import { EXPOSURE_INTENTS, type ExposureIntent } from './network/types'; function isPilotMode(): boolean { return process.env.SENCHO_MODE === 'pilot'; @@ -152,6 +153,17 @@ export interface StackDriftFindingRow { resolved_at: number | null; } +export interface StackExposureIntentRow { + id: number; + node_id: number; + stack_name: string; + /** '' = the stack-level classification; otherwise a service name. */ + service: string; + intent: ExposureIntent; + updated_at: number; + updated_by: string | null; +} + export interface Node { id: number; name: string; @@ -1261,6 +1273,19 @@ export class DatabaseService { CREATE INDEX IF NOT EXISTS idx_stack_drift_findings_open ON stack_drift_findings(node_id, stack_name, resolved_at); + CREATE TABLE IF NOT EXISTS stack_exposure_intent ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + node_id INTEGER NOT NULL, + stack_name TEXT NOT NULL, + service TEXT NOT NULL DEFAULT '', + intent TEXT NOT NULL CHECK(intent IN (${EXPOSURE_INTENTS.map(i => `'${i}'`).join(', ')})), + updated_at INTEGER NOT NULL, + updated_by TEXT, + UNIQUE(node_id, stack_name, service) + ); + CREATE INDEX IF NOT EXISTS idx_stack_exposure_intent_stack + ON stack_exposure_intent(node_id, stack_name); + CREATE TABLE IF NOT EXISTS preflight_runs ( id TEXT PRIMARY KEY, node_id INTEGER NOT NULL, @@ -2298,6 +2323,37 @@ export class DatabaseService { this.db.prepare('DELETE FROM stack_drift_findings WHERE node_id = ? AND stack_name = ?').run(nodeId, stackName); } + // --- Stack Exposure Intent (per-stack and per-service exposure classification) --- + + /** All intent rows for a stack: the stack-level row (service '') and any per-service rows. */ + public getStackExposureIntents(nodeId: number, stackName: string): StackExposureIntentRow[] { + return this.db.prepare( + 'SELECT * FROM stack_exposure_intent WHERE node_id = ? AND stack_name = ? ORDER BY service ASC' + ).all(nodeId, stackName) as StackExposureIntentRow[]; + } + + /** Upsert one intent row. `service` is '' for the stack-level classification. */ + public setStackExposureIntent(nodeId: number, stackName: string, service: string, intent: ExposureIntent, updatedBy: string | null): void { + this.db.prepare( + `INSERT INTO stack_exposure_intent (node_id, stack_name, service, intent, updated_at, updated_by) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(node_id, stack_name, service) DO UPDATE SET + intent = excluded.intent, + updated_at = excluded.updated_at, + updated_by = excluded.updated_by` + ).run(nodeId, stackName, service, intent, Date.now(), updatedBy); + } + + /** Clear one intent row, leaving that scope unset; consumers treat a service with no row as inheriting the stack intent. */ + public deleteStackExposureIntent(nodeId: number, stackName: string, service: string): void { + this.db.prepare('DELETE FROM stack_exposure_intent WHERE node_id = ? AND stack_name = ? AND service = ?').run(nodeId, stackName, service); + } + + /** Clear every intent row for a stack (used when the stack is deleted). */ + public deleteStackExposureIntents(nodeId: number, stackName: string): void { + this.db.prepare('DELETE FROM stack_exposure_intent WHERE node_id = ? AND stack_name = ?').run(nodeId, stackName); + } + // --- Compose Doctor / Preflight --- /** Store a run and its findings, replacing any prior run for this (node, stack). */ @@ -2730,6 +2786,7 @@ export class DatabaseService { this.db.prepare('DELETE FROM stack_labels WHERE node_id = ?').run(id); this.db.prepare('DELETE FROM stack_dossiers WHERE node_id = ?').run(id); this.db.prepare('DELETE FROM stack_drift_findings WHERE node_id = ?').run(id); + this.db.prepare('DELETE FROM stack_exposure_intent WHERE node_id = ?').run(id); this.db.prepare('DELETE FROM preflight_findings WHERE run_id IN (SELECT id FROM preflight_runs WHERE node_id = ?)').run(id); this.db.prepare('DELETE FROM preflight_runs WHERE node_id = ?').run(id); this.db.prepare('DELETE FROM health_gate_runs WHERE node_id = ?').run(id); diff --git a/backend/src/services/DriftDetectionService.ts b/backend/src/services/DriftDetectionService.ts index 6a7c786d..5db25a97 100644 --- a/backend/src/services/DriftDetectionService.ts +++ b/backend/src/services/DriftDetectionService.ts @@ -1,8 +1,9 @@ import DockerController from './DockerController'; -import type { DependencyContainer } from './DockerController'; +import type { DependencyContainer, DependencyNetwork } from './DockerController'; import { FileSystemService } from './FileSystemService'; import { parseComposeDependencies } from '../helpers/composeDependencyParse'; import type { DeclaredCompose, DeclaredService } from '../helpers/composeDependencyParse'; +import { compareStackNetworks, fromDeclaredCompose } from './network/normalize'; import { sanitizeForLog } from '../utils/safeLog'; import { getErrorMessage } from '../utils/errors'; @@ -25,7 +26,9 @@ export type DriftFindingKind = | 'service-missing' | 'service-undeclared' | 'image-mismatch' - | 'ports-mismatch'; + | 'ports-mismatch' + | 'network-undeclared' + | 'network-missing'; export interface StackDriftFinding { kind: DriftFindingKind; @@ -101,10 +104,72 @@ export interface AssembleStackDriftInput { declared: DeclaredCompose; /** All runtime containers belonging to this stack (any state). */ containers: DependencyContainer[]; + /** Every network on the node (for resolving foreign vs stack-owned attachments). */ + networks?: DependencyNetwork[]; /** Set when the compose file could not be parsed. */ parseError?: string; } +/** Add a network to a service's accumulated undeclared-attachment set. */ +function addNetwork(map: Map>, service: string, network: string): void { + let set = map.get(service); + if (!set) { set = new Set(); map.set(service, set); } + set.add(network); +} + +/** + * Network-level drift: a service attached to a network not declared in compose + * (stack-owned-undeclared or owned by another stack) is one finding per service; + * declared networks that no running service uses or that are absent from the + * runtime are one stack-level finding. Reuses the same comparison the Network + * Inspector uses, so the two surfaces never disagree. + */ +function networkDriftFindings( + stack: string, + declared: DeclaredCompose, + containers: DependencyContainer[], + networks: DependencyNetwork[], +): StackDriftFinding[] { + // Runtime resource names use the Compose project (top-level `name:` when set), + // not the stack directory, so a stack with `name:` resolves its networks the + // same way Docker does. Containers are still attributed to the stack directory. + const normalized = fromDeclaredCompose(declared, declared.projectName ?? stack); + const facts = compareStackNetworks(normalized, { containers, networks, volumes: [] }, stack); + const findings: StackDriftFinding[] = []; + + const serviceByContainer = new Map(containers.map(c => [c.name, c.service ?? c.name])); + const undeclaredByService = new Map>(); + for (const a of facts.runtimeOnlyAttachments) addNetwork(undeclaredByService, a.service ?? a.container, a.network); + for (const a of facts.foreignNetworkAttachments) addNetwork(undeclaredByService, serviceByContainer.get(a.container) ?? a.container, a.network); + + for (const [service, nets] of undeclaredByService) { + const list = [...nets].sort(); + const joined = list.join(', '); + findings.push({ + kind: 'network-undeclared', + service, + detail: `Service "${service}" is attached to ${list.length > 1 ? 'networks' : 'a network'} not declared in compose: ${joined}.`, + actual: joined, + }); + } + + // declaredButUnused holds compose keys; resolve them to runtime names so the + // finding lists every missing network in one consistent namespace. + const unusedNames = facts.declaredButUnused.map(key => normalized.networks[key]?.runtimeName ?? key); + const missing = [...new Set([...unusedNames, ...facts.missingFromRuntime])].sort(); + if (missing.length > 0) { + const joined = missing.join(', '); + findings.push({ + kind: 'network-missing', + service: '', + detail: `Declared ${missing.length > 1 ? 'networks are' : 'network is'} unused by running services or absent from the runtime: ${joined}.`, + expected: joined, + }); + } + + return findings; +} + /** * Pure diff step (no Docker / FS access) so it is directly unit-testable. Only * running containers are compared, since a stopped container publishes no ports @@ -115,6 +180,7 @@ export interface AssembleStackDriftInput { */ export function assembleStackDrift(input: AssembleStackDriftInput): StackDriftReport { const { stack, declared, containers, parseError } = input; + const networks = input.networks ?? []; const hasContainers = containers.some((c) => RUNNING_STATES.has(c.state)); // A parse failure means the declared model is untrustworthy: report drift @@ -204,6 +270,8 @@ export function assembleStackDrift(input: AssembleStackDriftInput): StackDriftRe } } + findings.push(...networkDriftFindings(stack, declared, containers, networks)); + const status: StackDriftStatus = findings.length > 0 ? 'drifted' : 'in-sync'; return { stack, status, hasComposeFile: true, hasContainers, findings }; } @@ -235,6 +303,7 @@ export async function buildStackDriftReport(nodeId: number, stackName: string): const declared = parseComposeDependencies(content); let containers: DependencyContainer[]; + let networks: DependencyNetwork[] = []; try { // The snapshot needs the full known-stacks set to resolve each container to // its stack; we then filter to this one. Do not narrow to [stackName] or @@ -242,6 +311,7 @@ export async function buildStackDriftReport(nodeId: number, stackName: string): const stacks = await fs.getStacks(); const snapshot = await DockerController.getInstance(nodeId).getDependencySnapshot(stacks); containers = snapshot.containers.filter((c) => c.stack === stackName); + networks = snapshot.networks; } catch (error) { // Docker is unreachable, so runtime drift cannot be assessed. The headline // failure is reachability; a separate parse error (if any) surfaces as @@ -256,5 +326,5 @@ export async function buildStackDriftReport(nodeId: number, stackName: string): }; } - return assembleStackDrift({ stack: stackName, declared, containers, parseError: declared.parseError }); + return assembleStackDrift({ stack: stackName, declared, containers, networks, parseError: declared.parseError }); } diff --git a/backend/src/services/network/composeNetworkInspector.ts b/backend/src/services/network/composeNetworkInspector.ts new file mode 100644 index 00000000..3d40810b --- /dev/null +++ b/backend/src/services/network/composeNetworkInspector.ts @@ -0,0 +1,118 @@ +/** + * Compose Network Inspector: renders the authored effective model and pairs it + * with the live Docker snapshot to produce the per-stack networking facts a + * Community user reads (network map, membership, published ports/bindings, + * network_mode, extra_hosts, and runtime drift). Advisory and read-only; it + * renders the AUTHORED model only (no Mesh overrides) and never returns or logs + * raw docker stderr, env values, or label values. + */ +import DockerController, { type DependencySnapshot } from '../DockerController'; +import { ComposeService } from '../ComposeService'; +import { FileSystemService } from '../FileSystemService'; +import { parseEffectiveModel, type EffectiveModel } from '../preflight/effectiveModel'; +import { parseMissingRequiredVars } from '../ComposeDoctorService'; +import { + compareStackNetworks, fromEffectiveModel, isAllInterfaces, isLoopback, +} from './normalize'; +import type { + NetworkDriftFacts, NetworkFactNetwork, NetworkFactService, NetworkRuntimeState, StackNetworkFacts, +} from './types'; + +import { getErrorMessage } from '../../utils/errors'; +import { redactSensitiveText, sanitizeForLog } from '../../utils/safeLog'; + +const MAX_RENDER_ERROR = 600; +const EMPTY_DRIFT: NetworkDriftFacts = { + runtimeOnlyAttachments: [], declaredButUnused: [], missingFromRuntime: [], foreignNetworkAttachments: [], +}; + +/** + * Pure assembler: turns a rendered model plus an optional runtime snapshot into + * the facts payload. A null model means the render failed (renderError carries a + * redacted reason); a null snapshot means the runtime is unavailable, so drift + * is left empty rather than computed against an empty snapshot. + */ +export function assembleStackNetworkFacts( + stackName: string, + model: EffectiveModel | null, + renderError: string | null, + snapshot: DependencySnapshot | null, +): StackNetworkFacts { + const runtime: NetworkRuntimeState = snapshot ? 'available' : 'unavailable'; + + if (!model) { + return { stack: stackName, renderable: false, renderError, runtime, networks: [], services: [], drift: EMPTY_DRIFT }; + } + + const networks: NetworkFactNetwork[] = Object.entries(model.networks).map(([key, res]) => ({ + key, + name: res.name, + external: res.external, + internal: res.internal, + createdByStack: !res.external && key !== 'default', + })); + + const services: NetworkFactService[] = model.services.map(s => ({ + name: s.name, + networks: s.networks.map(n => ({ key: n.key, aliases: n.aliases })), + publishedPorts: s.ports.map(p => ({ + hostIp: p.hostIp, + startPort: p.startPort, + endPort: p.endPort, + protocol: p.protocol, + allInterfaces: isAllInterfaces(p.hostIp), + loopbackOnly: isLoopback(p.hostIp), + })), + networkMode: s.networkMode, + extraHosts: s.extraHosts, + })); + + const drift = snapshot ? compareStackNetworks(fromEffectiveModel(model), snapshot, stackName) : EMPTY_DRIFT; + + return { stack: stackName, renderable: true, renderError: null, runtime, networks, services, drift }; +} + +/** Render the effective model and snapshot the node, then assemble the facts. */ +export async function buildStackNetworkFacts(nodeId: number, stackName: string): Promise { + const fsSvc = FileSystemService.getInstance(nodeId); + + let model: EffectiveModel | null = null; + let renderError: string | null = null; + try { + const result = await ComposeService.getInstance(nodeId).renderConfig(stackName); + if (result.rendered !== null) { + try { + model = parseEffectiveModel(JSON.parse(result.rendered), stackName); + } catch (parseErr) { + // JSON.parse errors carry no file content, so the message is safe to log. + console.warn('[NetworkInspector] Effective model parse failed for %s:', + sanitizeForLog(stackName), sanitizeForLog(getErrorMessage(parseErr, 'unknown'))); + renderError = 'Sencho could not parse the rendered Compose model.'; + } + } else { + // Raw stderr can echo file content/secrets and is never surfaced; only the + // names of any missing required variables, otherwise a generic nudge. + const missing = parseMissingRequiredVars(result.stderr); + renderError = missing.length + ? `Required variable${missing.length > 1 ? 's' : ''} ${missing.join(', ')} ${missing.length > 1 ? 'have' : 'has'} no value, so the effective model cannot be rendered.` + : 'Sencho could not render the effective Compose model. Check the compose and env files for a YAML syntax error, an unresolved include or merge, or a required variable with no value.'; + } + } catch (err) { + // Spawn failure (docker unavailable). Redact defensively. + renderError = redactSensitiveText(getErrorMessage(err, 'docker compose could not be started.')).slice(0, MAX_RENDER_ERROR).trim() + || 'Sencho could not run docker compose on this node.'; + } + + // A null snapshot means the runtime is unavailable (drift is then left empty), + // never confused with a real empty snapshot. + let snapshot: DependencySnapshot | null = null; + try { + const knownStacks = await fsSvc.getStacks(); + snapshot = await DockerController.getInstance(nodeId).getDependencySnapshot(knownStacks); + } catch (error) { + console.warn('[NetworkInspector] Node snapshot unavailable for %s; runtime facts skipped:', + sanitizeForLog(stackName), sanitizeForLog(getErrorMessage(error, 'unknown'))); + } + + return assembleStackNetworkFacts(stackName, model, renderError, snapshot); +} diff --git a/backend/src/services/network/networkingSummary.ts b/backend/src/services/network/networkingSummary.ts new file mode 100644 index 00000000..5d8c569f --- /dev/null +++ b/backend/src/services/network/networkingSummary.ts @@ -0,0 +1,90 @@ +/** + * A cheap per-node networking summary for the Fleet view filter: which stacks + * are exposed beyond loopback, which publish ports without an exposure intent, + * and which have network drift. It reads each stack's compose with the light + * dependency parser (no `docker compose config` render) and one Docker snapshot, + * so it stays inexpensive across a node's full stack set. + */ +import DockerController, { type DependencySnapshot } from '../DockerController'; +import { FileSystemService } from '../FileSystemService'; +import { DatabaseService } from '../DatabaseService'; +import { parseComposeDependencies } from '../../helpers/composeDependencyParse'; +import { assembleStackDrift } from '../DriftDetectionService'; +import { isLoopback } from './normalize'; +import { getErrorMessage } from '../../utils/errors'; +import { sanitizeForLog } from '../../utils/safeLog'; + +/** One signal bucket: how many stacks match, and which. */ +export interface NetworkingSummaryBucket { + count: number; + stacks: string[]; +} + +export interface NodeNetworkingSummary { + /** Stacks that publish a host port on a non-loopback interface. */ + exposed: NetworkingSummaryBucket; + /** Stacks that publish ports but have no stack-level exposure intent set. */ + unknownExposure: NetworkingSummaryBucket; + /** Stacks whose running networking disagrees with the Compose file. */ + networkDrift: NetworkingSummaryBucket; +} + +const bucket = (stacks: string[]): NetworkingSummaryBucket => ({ count: stacks.length, stacks }); + +export async function computeNodeNetworkingSummary(nodeId: number): Promise { + const fsSvc = FileSystemService.getInstance(nodeId); + const db = DatabaseService.getInstance(); + const stacks = await fsSvc.getStacks(); + + // One snapshot for the whole node; absent when Docker is unreachable, in which + // case drift is simply not computed (the declared signals still work). + let snapshot: DependencySnapshot | null = null; + try { + snapshot = await DockerController.getInstance(nodeId).getDependencySnapshot(stacks); + } catch (error) { + console.warn('[NetworkingSummary] Snapshot unavailable on node %d; drift skipped:', + nodeId, sanitizeForLog(getErrorMessage(error, 'unknown'))); + } + + const exposed: string[] = []; + const unknownExposure: string[] = []; + const networkDrift: string[] = []; + + for (const stack of stacks) { + let content: string; + try { + content = await fsSvc.getStackContent(stack); + } catch { + continue; // unreadable compose: nothing to summarize for this stack + } + const declared = parseComposeDependencies(content); + if (declared.parseError) continue; + + const publishesPort = declared.services.some(s => s.ports.length > 0); + if (declared.services.some(s => s.ports.some(p => !isLoopback(p.hostIp)))) exposed.push(stack); + + if (publishesPort) { + // Unknown only when a publishing service is effectively unclassified: a + // service-level intent overrides the stack-level row for that service. + const intents = db.getStackExposureIntents(nodeId, stack); + const stackIntent = intents.find(i => i.service === '')?.intent ?? null; + const byService = new Map(intents.filter(i => i.service !== '').map(i => [i.service, i.intent])); + const anyUnclassified = declared.services + .filter(s => s.ports.length > 0) + .some(s => { + const intent = byService.get(s.name) ?? stackIntent; + return intent === null || intent === 'unknown'; + }); + if (anyUnclassified) unknownExposure.push(stack); + } + + if (snapshot) { + // declared.parseError is already excluded above, so the drift report is authoritative. + const containers = snapshot.containers.filter(c => c.stack === stack); + const report = assembleStackDrift({ stack, declared, containers, networks: snapshot.networks }); + if (report.findings.some(f => f.kind === 'network-undeclared' || f.kind === 'network-missing')) networkDrift.push(stack); + } + } + + return { exposed: bucket(exposed), unknownExposure: bucket(unknownExposure), networkDrift: bucket(networkDrift) }; +} diff --git a/backend/src/services/network/normalize.ts b/backend/src/services/network/normalize.ts new file mode 100644 index 00000000..ba4666d1 --- /dev/null +++ b/backend/src/services/network/normalize.ts @@ -0,0 +1,127 @@ +/** + * Backend-local networking helpers and the normalized network model that lets + * the Inspector (rendered EffectiveModel) and Drift (raw DeclaredCompose) feed + * one comparison. The rendered model already resolves resource names; the raw + * declared model does not, so each shape gets its own adapter and both emit the + * same key-space NormalizedNetworkModel. Do not import the frontend's access-url + * parser here; this is the backend copy. + */ +import type { EffectiveModel } from '../preflight/effectiveModel'; +import type { DeclaredCompose } from '../../helpers/composeDependencyParse'; +import type { DependencySnapshot } from '../DockerController'; +import type { NetworkDriftFacts } from './types'; + +/** Container states that count as "deployed" for drift, matching DriftDetectionService. */ +const RUNNING_STATES = new Set(['running', 'restarting']); +const SYSTEM_NETWORK_NAMES = new Set(['bridge', 'host', 'none']); + +export function isAllInterfaces(ip: string): boolean { + return ip === '' || ip === '0.0.0.0' || ip === '::' || ip === '[::]'; +} + +export function isLoopback(ip: string): boolean { + return ip === '127.0.0.1' || ip === '::1' || ip === '[::1]'; +} + +/** Resolved runtime name of a top-level network/volume: a `name:` override wins, + * otherwise compose prefixes the project (`_`). */ +export function runtimeResourceName(projectName: string, key: string, declaredName: string | undefined): string { + return declaredName && declaredName !== key ? declaredName : `${projectName}_${key}`; +} + +/** Extract host port numbers referenced by free-text access URLs, for the + * port-vs-documented finding. Heuristic: matches `:PORT` boundaries. */ +export function parseAccessUrlPorts(text: string): Set { + const ports = new Set(); + const re = /:(\d{1,5})(?=[/\s)\];]|$)/g; + let m: RegExpExecArray | null; + while ((m = re.exec(text)) !== null) { + const p = parseInt(m[1], 10); + if (p > 0 && p <= 65535) ports.add(p); + } + return ports; +} + +/** Key-space network model both adapters emit, so the comparison is shape-agnostic. */ +export interface NormalizedNetworkModel { + projectName: string; + /** By network key → resolved runtime name + external flag. */ + networks: Record; + services: { name: string; networkKeys: string[]; networkMode?: string }[]; +} + +/** Rendered model: resource names are already resolved by `docker compose config`. */ +export function fromEffectiveModel(m: EffectiveModel): NormalizedNetworkModel { + const networks: NormalizedNetworkModel['networks'] = {}; + for (const [key, res] of Object.entries(m.networks)) { + networks[key] = { runtimeName: res.name, external: res.external }; + } + return { + projectName: m.projectName, + networks, + services: m.services.map(s => ({ name: s.name, networkKeys: s.networks.map(n => n.key), networkMode: s.networkMode })), + }; +} + +/** Raw declared model: resolve runtime names here (project prefix / `name:` override). */ +export function fromDeclaredCompose(m: DeclaredCompose, projectName: string): NormalizedNetworkModel { + const networks: NormalizedNetworkModel['networks'] = {}; + for (const [key, res] of Object.entries(m.networks)) { + networks[key] = { runtimeName: runtimeResourceName(projectName, key, res.name), external: res.external }; + } + return { + projectName, + networks, + services: m.services.map(s => ({ name: s.name, networkKeys: s.networks })), + }; +} + +/** + * Compare declared networks against the live snapshot. Only running/restarting + * containers of this stack count; system networks (bridge/host/none), the + * implicit default network, and external (shared) networks are not flagged. + */ +export function compareStackNetworks( + declared: NormalizedNetworkModel, + snapshot: DependencySnapshot, + stackName: string, +): NetworkDriftFacts { + const runtimeOnlyAttachments: NetworkDriftFacts['runtimeOnlyAttachments'] = []; + const foreignNetworkAttachments: NetworkDriftFacts['foreignNetworkAttachments'] = []; + + // Every declared network (external included) resolves into this set, so an + // attachment to a declared external/shared network is treated as declared + // below, not as foreign. + const declaredRuntimeNames = new Set(); + for (const net of Object.values(declared.networks)) declaredRuntimeNames.add(net.runtimeName); + // The implicit default network compose always provisions counts as declared. + declaredRuntimeNames.add(`${declared.projectName}_default`); + + const networkByName = new Map(snapshot.networks.map(n => [n.name, n])); + const stackContainers = snapshot.containers.filter(c => c.stack === stackName && RUNNING_STATES.has(c.state)); + const usedRuntimeNames = new Set(); + + for (const c of stackContainers) { + for (const attached of c.networks) { + const net = networkByName.get(attached.name); + if (SYSTEM_NETWORK_NAMES.has(attached.name) || net?.isSystem) continue; + if (declaredRuntimeNames.has(attached.name)) { usedRuntimeNames.add(attached.name); continue; } + if (net?.stack === stackName || attached.name.startsWith(`${declared.projectName}_`)) { + runtimeOnlyAttachments.push({ container: c.name, service: c.service, network: attached.name }); + } else { + foreignNetworkAttachments.push({ container: c.name, network: attached.name }); + } + } + } + + const runtimeNetworkNames = new Set(snapshot.networks.map(n => n.name)); + const declaredButUnused: string[] = []; + const missingFromRuntime: string[] = []; + for (const [key, net] of Object.entries(declared.networks)) { + if (net.external || key === 'default') continue; + if (!runtimeNetworkNames.has(net.runtimeName)) { missingFromRuntime.push(net.runtimeName); continue; } + if (!usedRuntimeNames.has(net.runtimeName)) declaredButUnused.push(key); + } + + return { runtimeOnlyAttachments, declaredButUnused, missingFromRuntime, foreignNetworkAttachments }; +} diff --git a/backend/src/services/network/types.ts b/backend/src/services/network/types.ts new file mode 100644 index 00000000..07090439 --- /dev/null +++ b/backend/src/services/network/types.ts @@ -0,0 +1,86 @@ +/** + * Types for the Compose Network Inspector: the structural networking facts a + * Community user can read for a stack, derived from the rendered effective model + * and the live Docker snapshot. Like the preflight model, these facts never + * carry an environment value or a label value. + */ + +/** Valid exposure-intent values (stored separately from these facts). The + * union is derived from this array so the two cannot drift; the frontend + * mirrors the same set. */ +export const EXPOSURE_INTENTS = [ + 'internal', 'same-node', 'lan', 'reverse-proxy', 'public', 'temporary', 'unknown', +] as const; + +export type ExposureIntent = typeof EXPOSURE_INTENTS[number]; + +/** A top-level network declared by the stack's effective model. */ +export interface NetworkFactNetwork { + /** Compose network key. */ + key: string; + /** Resolved docker network name. */ + name: string; + external: boolean; + internal: boolean; + /** True when deploying this stack creates the network (not external, not the implicit default). */ + createdByStack: boolean; +} + +/** A host-published port of a service. */ +export interface NetworkFactPort { + /** '' / '0.0.0.0' / '::' means all interfaces. */ + hostIp: string; + startPort: number; + endPort: number; + protocol: string; + /** Bound on every interface (broad exposure). */ + allInterfaces: boolean; + /** Bound only to loopback (127.0.0.1 / ::1). */ + loopbackOnly: boolean; +} + +/** One service's networking facts. */ +export interface NetworkFactService { + name: string; + /** Network membership by network key, with aliases. */ + networks: { key: string; aliases: string[] }[]; + publishedPorts: NetworkFactPort[]; + networkMode?: string; + extraHosts: string[]; +} + +/** Runtime-vs-Compose disagreements, computed only when the runtime is available. */ +export interface NetworkDriftFacts { + /** Running container attached to a stack-owned network not declared in Compose. */ + runtimeOnlyAttachments: { container: string; service: string | null; network: string }[]; + /** Declared network (non-external, non-default) that no running service uses. */ + declaredButUnused: string[]; + /** Declared network whose runtime network does not exist. */ + missingFromRuntime: string[]; + /** Running container attached to a network owned by another stack or unmanaged. */ + foreignNetworkAttachments: { container: string; network: string }[]; +} + +export type NetworkRuntimeState = 'available' | 'unavailable'; + +/** + * The full per-stack networking facts payload returned by GET /:stackName/networking. + * Kept a flat DTO (the frontend mirrors it) rather than a discriminated union; + * the pairing invariants are enforced by the single producer + * (`assembleStackNetworkFacts`), not by the type: when `renderable` is false, + * `renderError` is set and `networks`/`services`/`drift` are empty; when + * `runtime` is `'unavailable'`, `drift` is empty (never computed against an + * absent snapshot). + */ +export interface StackNetworkFacts { + stack: string; + /** True when the effective model rendered; false carries only a redacted, structural error. */ + renderable: boolean; + /** Redacted render error when not renderable; never raw docker stderr. */ + renderError: string | null; + /** Whether the live Docker snapshot was available; drift is computed only when 'available'. */ + runtime: NetworkRuntimeState; + networks: NetworkFactNetwork[]; + services: NetworkFactService[]; + drift: NetworkDriftFacts; +} diff --git a/backend/src/services/preflight/effectiveModel.ts b/backend/src/services/preflight/effectiveModel.ts index d9ef2209..fcb03468 100644 --- a/backend/src/services/preflight/effectiveModel.ts +++ b/backend/src/services/preflight/effectiveModel.ts @@ -21,6 +21,14 @@ export interface EffBind { target: string; } +/** A service's membership in one top-level network, keyed by the network KEY + * (not the resolved docker name) so it lines up with the `networks` map and + * with the authored `DeclaredService.networks`. */ +export interface EffServiceNetwork { + key: string; + aliases: string[]; +} + export interface EffService { name: string; image?: string; @@ -37,12 +45,20 @@ export interface EffService { user?: string; /** Environment KEY names only. Values are never extracted. */ envKeys: string[]; + /** Network membership by network key, with any aliases. */ + networks: EffServiceNetwork[]; + /** `extra_hosts` entries as `host:value` strings (host names / static IPs, never secrets). */ + extraHosts: string[]; + /** Label KEY names only. Values are never extracted (a label value can carry a secret). */ + labelKeys: string[]; } export interface EffResource { /** Resolved docker name (compose config fills this in). */ name: string; external: boolean; + /** Top-level `internal: true` (no outbound/host connectivity for the network). */ + internal: boolean; } export interface EffectiveModel { @@ -132,12 +148,56 @@ function envKeysOf(env: unknown): string[] { return []; } +/** Label KEY names only. A label VALUE can carry a secret, so it is never read. */ +function labelKeysOf(labels: unknown): string[] { + if (Array.isArray(labels)) { + return labels + .map(e => str(e)) + .filter((s): s is string => s !== undefined) + .map(s => s.split('=')[0]) + .filter(Boolean); + } + if (labels && typeof labels === 'object') return Object.keys(labels as Record); + return []; +} + +/** Service network membership (list or map form), keyed by network key, with aliases. */ +function parseServiceNetworks(networks: unknown): EffServiceNetwork[] { + if (Array.isArray(networks)) { + return networks + .map(n => str(n)) + .filter((s): s is string => s !== undefined) + .map(key => ({ key, aliases: [] as string[] })); + } + if (networks && typeof networks === 'object') { + return Object.entries(networks as Record).map(([key, cfg]) => { + const aliasesRaw = (cfg && typeof cfg === 'object') ? (cfg as Record).aliases : undefined; + const aliases = Array.isArray(aliasesRaw) + ? aliasesRaw.map(a => str(a)).filter((a): a is string => a !== undefined) + : []; + return { key, aliases }; + }); + } + return []; +} + +/** `extra_hosts` (list `host:ip` or map `{host: ip}`) → `host:value` strings. Infra facts, not secrets. */ +function parseExtraHosts(extraHosts: unknown): string[] { + if (Array.isArray(extraHosts)) { + return extraHosts.map(e => str(e)).filter((s): s is string => s !== undefined); + } + if (extraHosts && typeof extraHosts === 'object') { + return Object.entries(extraHosts as Record).map(([host, ip]) => `${host}:${str(ip) ?? ''}`); + } + return []; +} + function parseResources(value: unknown): Record { const out: Record = {}; if (value && typeof value === 'object' && !Array.isArray(value)) { for (const [key, entry] of Object.entries(value as Record)) { const o = (entry ?? {}) as Record; - out[key] = { name: str(o.name) ?? key, external: o.external === true }; + out[key] = { name: str(o.name) ?? key, external: o.external === true, internal: o.internal === true }; } } return out; @@ -177,6 +237,9 @@ export function parseEffectiveModel(parsed: unknown, fallbackProjectName: string containerName: str(svc.container_name), user: str(svc.user), envKeys: envKeysOf(svc.environment), + networks: parseServiceNetworks(svc.networks), + extraHosts: parseExtraHosts(svc.extra_hosts), + labelKeys: labelKeysOf(svc.labels), }); } diff --git a/backend/src/services/preflight/rules.ts b/backend/src/services/preflight/rules.ts index b26dab6f..39855a62 100644 --- a/backend/src/services/preflight/rules.ts +++ b/backend/src/services/preflight/rules.ts @@ -1,5 +1,7 @@ import type { PreflightContext, PreflightFinding, PreflightSeverity, NodePortBinding } from './types'; import type { EffService, EffPortSpec } from './effectiveModel'; +import type { ExposureIntent } from '../network/types'; +import { isLoopback } from '../network/normalize'; /** Higher number = more severe. Used to derive a run's overall status. */ export const SEVERITY_RANK: Record = { info: 0, warning: 1, high: 2, blocker: 3 }; @@ -521,6 +523,150 @@ const effectiveModelExpanded: PreflightRule = { }, }; +// ----- exposure-intent rules ------------------------------------------------ +// These read the user's stored exposure classification (resolved per service) +// and the dossier's documented access URLs from the context, plus a sensitivity +// heuristic on the image name for the broad-exposure rule. + +/** Image-name hints for a database or admin service that should rarely be broadly exposed. */ +const SENSITIVE_IMAGE_HINTS = [ + 'postgres', 'mysql', 'mariadb', 'mongo', 'redis', 'memcached', 'elasticsearch', + 'adminer', 'phpmyadmin', 'portainer', 'docker-socket-proxy', +]; +/** Reverse-proxy label-key base names; matched as the key itself or a `base.` prefix (case-insensitive). */ +const REVERSE_PROXY_LABEL_HINTS = ['traefik', 'caddy', 'virtual.host']; + +/** A service's effective intent: its own override, else the stack-level intent. */ +function effectiveIntent(ctx: PreflightContext, service: string): ExposureIntent | null { + return ctx.serviceIntents[service] ?? ctx.stackIntent; +} + +const exposureInternalPublished: PreflightRule = { + id: 'exposure-internal-published', + run(ctx) { + if (!ctx.model) return []; + const findings: PreflightFinding[] = []; + for (const svc of ctx.model.services) { + const intent = effectiveIntent(ctx, svc.name); + if (intent !== 'internal' && intent !== 'same-node') continue; + // same-node tolerates a loopback binding; internal tolerates no host port. + const offending = svc.ports.filter(p => intent === 'internal' || !isLoopback(p.hostIp)); + if (offending.length === 0) continue; + findings.push({ + ruleId: 'exposure-internal-published', + severity: 'high', + title: `"${svc.name}" is classified ${intent} but publishes a host port`, + message: `Service "${svc.name}" is classified as ${intent} exposure, but it publishes ${offending.map(specLabel).join(', ')} to the host, which contradicts that intent.`, + sourcePath: svc.name, + service: svc.name, + remediation: intent === 'same-node' + ? 'Bind the port to loopback (127.0.0.1), remove it, or reclassify the exposure intent.' + : 'Remove the published port or reclassify the exposure intent.', + }); + } + return findings; + }, +}; + +const exposureUnclassified: PreflightRule = { + id: 'exposure-unclassified', + run(ctx) { + if (!ctx.model) return []; + const publishing = ctx.model.services.filter(s => s.ports.length > 0); + if (publishing.length === 0) return []; + // Fire only when a publishing service is still effectively unclassified: a + // service-level intent suppresses the warning for that service even when the + // stack itself is unset. + const unclassified = publishing.some(s => { + const intent = effectiveIntent(ctx, s.name); + return intent === null || intent === 'unknown'; + }); + if (!unclassified) return []; + return [{ + ruleId: 'exposure-unclassified', + severity: 'warning', + title: 'Stack publishes ports without an exposure intent', + message: 'This stack publishes one or more host ports but has no exposure intent set. Classifying it (internal, LAN, reverse proxy, public) lets Sencho flag mismatches later.', + remediation: 'Set the stack exposure intent in the Networking tab.', + }]; + }, +}; + +const exposurePortVsDossier: PreflightRule = { + id: 'exposure-port-vs-dossier', + run(ctx) { + if (!ctx.model || !ctx.hasAccessUrls) return []; + const findings: PreflightFinding[] = []; + for (const svc of ctx.model.services) { + const undocumented = [...new Set(svc.ports.flatMap(portsOf).filter(p => !ctx.accessUrlPorts.has(p)))]; + if (undocumented.length === 0) continue; + findings.push({ + ruleId: 'exposure-port-vs-dossier', + severity: 'warning', + title: 'Published port is not in the documented access URLs', + message: `Service "${svc.name}" publishes ${undocumented.join(', ')}, which ${undocumented.length > 1 ? 'are' : 'is'} not referenced by the dossier's documented access URLs. The documentation may be stale.`, + sourcePath: svc.name, + service: svc.name, + remediation: 'Update the access URLs in the Stack Dossier, or change the published port.', + }); + } + return findings; + }, +}; + +const reverseProxyUndocumented: PreflightRule = { + id: 'reverse-proxy-undocumented', + run(ctx) { + if (!ctx.model) return []; + // Already documented or intentionally reverse-proxied at the stack level. + if (ctx.hasAccessUrls || ctx.stackIntent === 'reverse-proxy') return []; + const findings: PreflightFinding[] = []; + for (const svc of ctx.model.services) { + if (effectiveIntent(ctx, svc.name) === 'reverse-proxy') continue; + const hasRpLabel = svc.labelKeys.some(k => { + const lk = k.toLowerCase(); + return REVERSE_PROXY_LABEL_HINTS.some(h => lk === h || lk.startsWith(`${h}.`)); + }); + if (!hasRpLabel) continue; + findings.push({ + ruleId: 'reverse-proxy-undocumented', + severity: 'warning', + title: `"${svc.name}" has reverse-proxy labels but no documented URL`, + message: `Service "${svc.name}" carries reverse-proxy labels, but the stack has no documented access URL or reverse-proxy intent, so how it is reached is unclear.`, + sourcePath: svc.name, + service: svc.name, + remediation: 'Document the access URL in the Stack Dossier or set the exposure intent to reverse proxy.', + }); + } + return findings; + }, +}; + +const sensitiveServiceBroadExposure: PreflightRule = { + id: 'sensitive-service-broad-exposure', + run(ctx) { + if (!ctx.model) return []; + const findings: PreflightFinding[] = []; + for (const svc of ctx.model.services) { + if (svc.image === undefined) continue; + const image = svc.image.toLowerCase(); + if (!SENSITIVE_IMAGE_HINTS.some(h => image.includes(h))) continue; + const broad = svc.ports.filter(p => isAllInterfaces(p.hostIp)); + if (broad.length === 0) continue; + findings.push({ + ruleId: 'sensitive-service-broad-exposure', + severity: 'high', + title: `Sensitive service "${svc.name}" is exposed on all interfaces`, + message: `Service "${svc.name}" looks like a database or admin service (${svc.image}) and publishes ${broad.map(specLabel).join(', ')} on all interfaces. Broadly exposing it is a common source of compromise.`, + sourcePath: svc.name, + service: svc.name, + remediation: 'Bind it to a specific interface such as 127.0.0.1, or keep it on an internal network only.', + }); + } + return findings; + }, +}; + /** The ordered registry. Order is the display order within a severity group. */ export const PREFLIGHT_RULES: PreflightRule[] = [ renderFailed, @@ -544,6 +690,11 @@ export const PREFLIGHT_RULES: PreflightRule[] = [ newVolume, containerNameInternalDup, containerNameCollision, + exposureInternalPublished, + sensitiveServiceBroadExposure, + exposureUnclassified, + exposurePortVsDossier, + reverseProxyUndocumented, effectiveModelExpanded, ]; diff --git a/backend/src/services/preflight/types.ts b/backend/src/services/preflight/types.ts index c62bfb85..c72cb67c 100644 --- a/backend/src/services/preflight/types.ts +++ b/backend/src/services/preflight/types.ts @@ -1,4 +1,5 @@ import type { EffectiveModel } from './effectiveModel'; +import type { ExposureIntent } from '../network/types'; /** Graded severity of a single preflight finding. */ export type PreflightSeverity = 'blocker' | 'high' | 'warning' | 'info'; @@ -92,4 +93,12 @@ export interface PreflightContext { existingVolumeNames: Set; existingContainers: { name: string; stack: string | null }[]; bindChecks: BindCheck[]; + /** Stack-level exposure classification, or null when unset. */ + stackIntent: ExposureIntent | null; + /** Per-service exposure overrides (a service falls back to stackIntent when absent). */ + serviceIntents: Record; + /** Host ports referenced by the dossier's documented access URLs. */ + accessUrlPorts: Set; + /** Whether the dossier records any access URL (gates the port-vs-documented rule). */ + hasAccessUrls: boolean; } diff --git a/docs/docs.json b/docs/docs.json index e509fe14..ca77adf2 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -106,6 +106,7 @@ "features/stack-dossier", "features/stack-drift", "features/compose-doctor", + "features/compose-networking", "features/stack-labels", "features/sidebar" ] diff --git a/docs/features/compose-networking.mdx b/docs/features/compose-networking.mdx new file mode 100644 index 00000000..fd583198 --- /dev/null +++ b/docs/features/compose-networking.mdx @@ -0,0 +1,76 @@ +--- +title: Compose Networking +description: Inspect how a stack is networked and exposed, classify what its exposure should be, and see where the running containers disagree with the Compose file, all without opening a terminal. +--- + +The **Networking** tab in the right-hand **Anatomy** panel answers a Compose-first question: *is this stack networked and exposed the way the Compose file says it is, and are the dangerous or confusing parts visible before I deploy?* It renders the effective Compose model, the fully resolved result after interpolation, includes, profiles, `.env`, and `env_file` are applied, pairs it with the live Docker state when that node is reachable, and shows the result as plain facts. + +The view is read-only with respect to the stack: it never changes a deployment. The one thing you can edit here is the stack's *exposure intent*, which is stored separately so Sencho can flag mismatches over time. + +Compose Networking never shows a secret value. It reads the structure of the model, network names, service-to-network membership, published ports, and the *names* of environment variables and labels, but never their values, so nothing sensitive appears in the view or the logs. + +## Networks + +The top of the tab lists the stack's networks with the facts that matter: + +- The resolved Docker network name, and the Compose key it came from. +- **external** when the network is one the stack expects to already exist on the node. +- **internal** when the network has no outbound or host connectivity. +- **created by stack** when deploying the stack will create the network. + +## Published ports and bindings + +Each service lists its published ports with the interface they bind to: + +- **all interfaces** marks a port published on `0.0.0.0`, reachable from every network the host is attached to. +- **loopback** marks a port bound only to `127.0.0.1`, reachable only from the host itself. +- A specific address is shown as-is. + +The tab also surfaces each service's network membership and aliases, `network_mode` (`host`, `none`, `service:`, and `container:` modes are called out), and `extra_hosts` entries. + +## Exposure intent + +Exposure intent is how you tell Sencho what a stack *should* be reachable from, so it can warn you when the Compose file says otherwise. Set it for the whole stack, or override it per service: + +| Intent | Meaning | +|--------|---------| +| **internal** | Not published to the host at all. | +| **same-node** | Reachable only from the host (loopback bindings). | +| **LAN** | Published for the local network. | +| **reverse proxy** | Reached through a reverse proxy, not a direct host port. | +| **public** | Intentionally reachable from the internet. | +| **temporary** | A short-lived exposure (a classification label only). | +| **unknown** | Not yet classified. | + +A service with no intent of its own inherits the stack's. Clearing a service returns it to **inherit**; clearing the stack returns it to unclassified. Sencho stores the intent apart from the generated facts, so when the two drift apart the **Doctor** tab can point it out. + +## Findings + +Risk findings live in the **Doctor** tab, which reads the same model. On top of its existing deploy and security checks (host-port conflicts, missing external networks, host networking, broad exposure, and a mounted Docker socket), it adds exposure-aware findings: + +- A service classified **internal** or **same-node** that publishes a host port. +- A database or admin image published on every interface. +- A stack that publishes ports but has no exposure intent set. +- A published port that the Stack Dossier's documented access URLs do not mention. +- Reverse-proxy labels with no documented URL or reverse-proxy intent. + +## Runtime drift + +When the node is reachable, the tab compares the running containers to the Compose file and reports where they disagree: a container on a network the file does not declare, a container on a network owned by another stack, and a declared network that no running service uses or that is missing from the runtime. These also appear on the **Drift** tab, where their history is tracked over time. When the node is not reachable, the tab shows the declared model only and says so. + +## Troubleshooting + + + + The tab appears once the node advertises support for it. A node running an older Sencho version hides the tab until it is updated. + + + Sencho could not reach Docker on that node, so it shows the declared Compose model only and skips runtime drift. The networks, ports, and exposure facts are still accurate; the live comparison resumes once the node is reachable. + + + `docker compose config` could not produce an effective model, usually a YAML error, an unresolved include or merge, or a required variable with no value. Fix the reported problem and reopen the tab. + + + Editing the intent needs stack edit access. With read access you can see the current classification but not change it. + + diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index e0f7616c..910d0fb0 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -25,8 +25,8 @@ import { GlobalCommandPaletteProvider, GlobalCommandPaletteTrigger, } from './GlobalCommandPalette'; -import { SENCHO_OPEN_LOGS_EVENT } from '@/lib/events'; -import type { SenchoOpenLogsDetail } from '@/lib/events'; +import { SENCHO_OPEN_LOGS_EVENT, SENCHO_OPEN_STACK_EVENT } from '@/lib/events'; +import type { SenchoOpenLogsDetail, SenchoOpenStackDetail } from '@/lib/events'; import { useNodes } from '@/context/NodeContext'; import { useAuth } from '@/context/AuthContext'; import { useDeployFeedback } from '@/context/DeployFeedbackContext'; @@ -337,6 +337,21 @@ export default function EditorLayout() { } }; + // Open a stack from another surface (e.g. a Resources network card). Reuses + // the Fleet navigation, which loads the stack on its node (switching nodes if + // needed) and flips to the editor view. A latest-ref keeps the window handler + // current without re-subscribing every render. + const openStackFromEventRef = useRef(handleFleetNavigateToNode); + useEffect(() => { openStackFromEventRef.current = handleFleetNavigateToNode; }); + useEffect(() => { + const handler = (e: Event) => { + const detail = (e as CustomEvent).detail; + if (detail) openStackFromEventRef.current(detail.nodeId, detail.stackName); + }; + window.addEventListener(SENCHO_OPEN_STACK_EVENT, handler); + return () => window.removeEventListener(SENCHO_OPEN_STACK_EVENT, handler); + }, []); + // "Inspect" a node from the mobile Fleet screen: switch to it and land on its // stack list. const handleInspectNode = (nodeId: number) => { diff --git a/frontend/src/components/FleetView/OverviewToolbar.tsx b/frontend/src/components/FleetView/OverviewToolbar.tsx index 38e9d7a5..f6716f98 100644 --- a/frontend/src/components/FleetView/OverviewToolbar.tsx +++ b/frontend/src/components/FleetView/OverviewToolbar.tsx @@ -12,7 +12,7 @@ import { MultiSelectCombobox } from '@/components/ui/multi-select-combobox'; import { SegmentedControl, type SegmentedControlOption } from '@/components/ui/segmented-control'; import { LabelDot } from '../LabelPill'; import type { LabelColor } from '../label-types'; -import type { ViewMode, SortField, FilterStatus, FilterType, FleetPreferences, FleetPaletteEntry } from './types'; +import type { ViewMode, SortField, FilterStatus, FilterType, FilterNetworking, FleetPreferences, FleetPaletteEntry } from './types'; const FILTER_SECTION_LABEL_CLASS = 'text-[10px] leading-3 font-mono uppercase tracking-[0.18em] text-stat-subtitle'; @@ -70,6 +70,7 @@ export function OverviewToolbar({ (prefs.filterStatus !== 'all' ? 1 : 0) + (prefs.filterType !== 'all' ? 1 : 0) + (prefs.filterCritical ? 1 : 0) + + (prefs.filterNetworking !== 'all' ? 1 : 0) + (labelFilters.size > 0 ? 1 : 0); const paletteOptions = useMemo( @@ -199,6 +200,22 @@ export function OverviewToolbar({ Critical Only +
+ +
+ {([['all', 'All'], ['exposed', 'Exposed'], ['unknown', 'Unknown'], ['drift', 'Drift']] as [FilterNetworking, string][]).map(([value, label]) => ( + + ))} +
+
{fleetPalette.length > 0 && (
diff --git a/frontend/src/components/FleetView/__tests__/OverviewTab.test.tsx b/frontend/src/components/FleetView/__tests__/OverviewTab.test.tsx index fc5824e2..825b2e66 100644 --- a/frontend/src/components/FleetView/__tests__/OverviewTab.test.tsx +++ b/frontend/src/components/FleetView/__tests__/OverviewTab.test.tsx @@ -9,7 +9,7 @@ vi.mock('../../fleet/FleetTopology', () => ({ FleetTopology: () =>
> = {}) { return { diff --git a/frontend/src/components/FleetView/hooks/__tests__/useFleetOverview.test.tsx b/frontend/src/components/FleetView/hooks/__tests__/useFleetOverview.test.tsx index 86d126cb..e971cf6b 100644 --- a/frontend/src/components/FleetView/hooks/__tests__/useFleetOverview.test.tsx +++ b/frontend/src/components/FleetView/hooks/__tests__/useFleetOverview.test.tsx @@ -30,7 +30,7 @@ const NODES: FleetNode[] = [ { id: 3, name: 'Charlie', type: 'remote', status: 'offline', stats: null, systemStats: null, stacks: null, cordoned: false, cordoned_at: null, cordoned_reason: null }, ]; -const DEFAULT_PREFS: FleetPreferences = { sortBy: 'name', sortDir: 'asc', filterStatus: 'all', filterType: 'all', filterCritical: false }; +const DEFAULT_PREFS: FleetPreferences = { sortBy: 'name', sortDir: 'asc', filterStatus: 'all', filterType: 'all', filterCritical: false, filterNetworking: 'all' }; function setup(prefs: Partial = {}) { const updatePrefs = vi.fn(); @@ -47,6 +47,13 @@ beforeEach(() => { fetchForNodeMock.mockReset(); apiFetchMock.mockImplementation((path: string) => { if (path === '/fleet/overview') return Promise.resolve(okJson(NODES)); + if (path === '/fleet/networking-summary') return Promise.resolve(okJson({ + nodes: [ + // Alpha (1) is exposed only; Bravo (2) has a summary but is unknown + drift, not exposed. + { nodeId: 1, summary: { exposed: { count: 1, stacks: ['web'] }, unknownExposure: { count: 0, stacks: [] }, networkDrift: { count: 0, stacks: [] } } }, + { nodeId: 2, summary: { exposed: { count: 0, stacks: [] }, unknownExposure: { count: 1, stacks: ['db'] }, networkDrift: { count: 1, stacks: ['db'] } } }, + ], + })); if (path === '/node-labels') return Promise.resolve(okJson({})); return Promise.resolve(okJson({})); }); @@ -108,6 +115,37 @@ describe('useFleetOverview', () => { const { result, updatePrefs } = setup({ filterStatus: 'online' }); await act(async () => { await result.current.fetchOverview(); }); act(() => result.current.clearFilters()); - expect(updatePrefs).toHaveBeenCalledWith({ filterStatus: 'all', filterType: 'all', filterCritical: false }); + expect(updatePrefs).toHaveBeenCalledWith({ filterStatus: 'all', filterType: 'all', filterCritical: false, filterNetworking: 'all' }); + }); + + it('narrows to nodes with an exposed stack when the networking filter is set', async () => { + const { result } = setup({ filterNetworking: 'exposed' }); + await act(async () => { await result.current.fetchOverview(); }); + // Bravo has a summary but exposed.count is 0, so it is excluded, proving the + // filter checks the signal rather than just presence of a summary. + await waitFor(() => expect(result.current.processedNodes.map(n => n.name)).toEqual(['Alpha'])); + }); + + it('narrows by the unknown-exposure and network-drift signals', async () => { + const unknown = setup({ filterNetworking: 'unknown' }); + await act(async () => { await unknown.result.current.fetchOverview(); }); + await waitFor(() => expect(unknown.result.current.processedNodes.map(n => n.name)).toEqual(['Bravo'])); + + const drift = setup({ filterNetworking: 'drift' }); + await act(async () => { await drift.result.current.fetchOverview(); }); + await waitFor(() => expect(drift.result.current.processedNodes.map(n => n.name)).toEqual(['Bravo'])); + }); + + it('keeps the overview loaded when the networking summary fetch fails', async () => { + apiFetchMock.mockImplementation((path: string) => { + if (path === '/fleet/overview') return Promise.resolve(okJson(NODES)); + if (path === '/fleet/networking-summary') return Promise.reject(new Error('summary down')); + return Promise.resolve(okJson({})); + }); + // With no networking filter active, the overview must render all nodes even + // though the summary fetch threw (fail-soft, detached from the load path). + const { result } = setup({ filterNetworking: 'all' }); + await act(async () => { await result.current.fetchOverview(); }); + expect(result.current.processedNodes.length).toBe(3); }); }); diff --git a/frontend/src/components/FleetView/hooks/__tests__/useFleetPreferences.test.tsx b/frontend/src/components/FleetView/hooks/__tests__/useFleetPreferences.test.tsx index e59a12dc..11da58fe 100644 --- a/frontend/src/components/FleetView/hooks/__tests__/useFleetPreferences.test.tsx +++ b/frontend/src/components/FleetView/hooks/__tests__/useFleetPreferences.test.tsx @@ -12,7 +12,7 @@ describe('useFleetPreferences', () => { it('starts from defaults when nothing is stored', () => { const { result } = renderHook(() => useFleetPreferences()); expect(result.current.prefs).toEqual({ - sortBy: 'name', sortDir: 'asc', filterStatus: 'all', filterType: 'all', filterCritical: false, + sortBy: 'name', sortDir: 'asc', filterStatus: 'all', filterType: 'all', filterCritical: false, filterNetworking: 'all', }); }); diff --git a/frontend/src/components/FleetView/hooks/useFleetDossierExport.ts b/frontend/src/components/FleetView/hooks/useFleetDossierExport.ts index d45e21c0..6deaf4d4 100644 --- a/frontend/src/components/FleetView/hooks/useFleetDossierExport.ts +++ b/frontend/src/components/FleetView/hooks/useFleetDossierExport.ts @@ -8,6 +8,12 @@ import { type FleetDossierStack, } from '@/lib/fleetDossier'; import { EMPTY_DOSSIER_FIELDS, type StackDossierFields } from '@/lib/dossierMarkdown'; +import { + buildNetworkExposureSummary, + type NetworkExposureSummary, + type NetworkFactsInput, + type ExposureIntentInput, +} from '@/lib/networkExposureSummary'; import { downloadBlob } from '@/lib/download'; import { toast } from '@/components/ui/toast-store'; @@ -86,8 +92,23 @@ async function loadDossier(stackName: string, nodeId: number): Promise { + try { + const [facts, exposure] = await Promise.all([ + getJson(`/stacks/${encodeURIComponent(stackName)}/networking`, nodeId), + getJson<{ intents?: ExposureIntentInput[] }>(`/stacks/${encodeURIComponent(stackName)}/exposure`, nodeId), + ]); + return buildNetworkExposureSummary(facts, exposure.intents ?? []); + } catch (err) { + if (isUnauthorized(err)) throw err; + console.warn(`[FleetDossier] networking load failed for "${stackName}" on node ${nodeId}:`, err); + return null; + } +} + async function collectStack(stackName: string, nodeId: number): Promise { const dossier = await loadDossier(stackName, nodeId); + const networking = await loadNetworking(stackName, nodeId); let content: string; try { content = await getText(`/stacks/${encodeURIComponent(stackName)}`, nodeId); @@ -95,7 +116,7 @@ async function collectStack(stackName: string, nodeId: number): Promise { diff --git a/frontend/src/components/FleetView/hooks/useFleetOverview.ts b/frontend/src/components/FleetView/hooks/useFleetOverview.ts index a5b21825..24d982ac 100644 --- a/frontend/src/components/FleetView/hooks/useFleetOverview.ts +++ b/frontend/src/components/FleetView/hooks/useFleetOverview.ts @@ -31,11 +31,32 @@ export function useFleetOverview({ prefs, updatePrefs, updateStatuses }: UseFlee const [lastSyncAt, setLastSyncAt] = useState(null); const [viewMode, setViewMode] = useState('grid'); const [labelFilters, setLabelFilters] = useState>(new Set()); + // Per-node networking signals (which nodes have an exposed / unknown-exposure + // / network-drift stack), for the networking filter. Loaded fail-soft. + const [networkingByNode, setNetworkingByNode] = useState>(new Map()); const abortRef = useRef(null); const { fleetPalette, fleetStackLabelMap } = useFleetLabels({ nodes }); const { labelsByNodeId, distinctLabels } = useNodeLabels({ nodes }); + // The networking summary fans out to every remote (each with its own + // timeout), so it is loaded detached: it must never gate the overview's + // loading state, and a failure just leaves the networking filter empty. + const loadNetworkingSummary = useCallback(async (signal: AbortSignal) => { + try { + const res = await apiFetch('/fleet/networking-summary', { localOnly: true, signal }); + if (!res.ok) return; + const data = await res.json() as { nodes?: { nodeId: number; summary: { exposed: { count: number }; unknownExposure: { count: number }; networkDrift: { count: number } } | null }[] }; + const map = new Map(); + for (const n of data.nodes ?? []) { + if (n.summary) map.set(n.nodeId, { exposed: n.summary.exposed.count > 0, unknown: n.summary.unknownExposure.count > 0, drift: n.summary.networkDrift.count > 0 }); + } + setNetworkingByNode(map); + } catch (error) { + if (!(error instanceof DOMException && error.name === 'AbortError')) console.warn('Failed to fetch fleet networking summary:', error); + } + }, []); + const fetchOverview = useCallback(async (showRefresh = false) => { abortRef.current?.abort(); const controller = new AbortController(); @@ -48,6 +69,8 @@ export function useFleetOverview({ prefs, updatePrefs, updateStatuses }: UseFlee setNodes(await res.json()); setLastSyncAt(Date.now()); } + // Detached: it must never gate the loading state cleared in `finally`. + void loadNetworkingSummary(controller.signal); } catch (error) { if (error instanceof DOMException && error.name === 'AbortError') return; console.error('Failed to fetch fleet overview:', error); @@ -55,7 +78,7 @@ export function useFleetOverview({ prefs, updatePrefs, updateStatuses }: UseFlee setLoading(false); setRefreshing(false); } - }, []); + }, [loadNetworkingSummary]); const onlineNodes = useMemo(() => nodes.filter(n => n.status === 'online'), [nodes]); @@ -110,6 +133,19 @@ export function useFleetOverview({ prefs, updatePrefs, updateStatuses }: UseFlee if (prefs.filterType === 'remote') filtered = filtered.filter(n => n.type !== 'local'); if (prefs.filterCritical) filtered = filtered.filter(isCritical); + if (prefs.filterNetworking !== 'all') { + const signal = prefs.filterNetworking; + filtered = filtered.filter(n => { + const s = networkingByNode.get(n.id); + if (!s) return false; + switch (signal) { + case 'exposed': return s.exposed; + case 'unknown': return s.unknown; + case 'drift': return s.drift; + } + }); + } + if (labelFilters.size > 0) { filtered = filtered.filter(n => { const nodeStackLabels = fleetStackLabelMap[n.id] ?? {}; @@ -143,7 +179,7 @@ export function useFleetOverview({ prefs, updatePrefs, updateStatuses }: UseFlee }); return filtered; - }, [nodes, searchQuery, prefs, labelFilters, fleetStackLabelMap]); + }, [nodes, searchQuery, prefs, labelFilters, fleetStackLabelMap, networkingByNode]); const localNode = useMemo( () => processedNodes.find(n => n.type === 'local') ?? null, @@ -184,12 +220,13 @@ export function useFleetOverview({ prefs, updatePrefs, updateStatuses }: UseFlee if (prefs.filterStatus !== 'all') count++; if (prefs.filterType !== 'all') count++; if (prefs.filterCritical) count++; + if (prefs.filterNetworking !== 'all') count++; count += labelFilters.size; return count; }, [prefs, labelFilters]); const clearFilters = useCallback(() => { - updatePrefs({ filterStatus: 'all', filterType: 'all', filterCritical: false }); + updatePrefs({ filterStatus: 'all', filterType: 'all', filterCritical: false, filterNetworking: 'all' }); setLabelFilters(new Set()); }, [updatePrefs]); diff --git a/frontend/src/components/FleetView/hooks/useFleetPreferences.ts b/frontend/src/components/FleetView/hooks/useFleetPreferences.ts index 474d1ee0..bfb528d5 100644 --- a/frontend/src/components/FleetView/hooks/useFleetPreferences.ts +++ b/frontend/src/components/FleetView/hooks/useFleetPreferences.ts @@ -4,7 +4,7 @@ import type { FleetPreferences } from '../types'; const PREFS_KEY = 'sencho-fleet-preferences'; const DEFAULT_PREFS: FleetPreferences = { - sortBy: 'name', sortDir: 'asc', filterStatus: 'all', filterType: 'all', filterCritical: false, + sortBy: 'name', sortDir: 'asc', filterStatus: 'all', filterType: 'all', filterCritical: false, filterNetworking: 'all', }; function loadPreferences(): FleetPreferences { diff --git a/frontend/src/components/FleetView/types.ts b/frontend/src/components/FleetView/types.ts index f9701188..7bc4255a 100644 --- a/frontend/src/components/FleetView/types.ts +++ b/frontend/src/components/FleetView/types.ts @@ -47,6 +47,7 @@ export type SortField = 'name' | 'cpu' | 'memory' | 'containers' | 'status'; export type SortDir = 'asc' | 'desc'; export type FilterStatus = 'all' | 'online' | 'offline'; export type FilterType = 'all' | 'local' | 'remote'; +export type FilterNetworking = 'all' | 'exposed' | 'unknown' | 'drift'; export interface FleetPreferences { sortBy: SortField; @@ -54,6 +55,8 @@ export interface FleetPreferences { filterStatus: FilterStatus; filterType: FilterType; filterCritical: boolean; + /** Narrow to nodes that have an exposed / unknown-exposure / network-drift stack. */ + filterNetworking: FilterNetworking; } export interface FleetPaletteEntry { diff --git a/frontend/src/components/ResourcesView.tsx b/frontend/src/components/ResourcesView.tsx index 7b6d4b4b..78934e9b 100644 --- a/frontend/src/components/ResourcesView.tsx +++ b/frontend/src/components/ResourcesView.tsx @@ -28,7 +28,8 @@ import { CapabilityGate } from './CapabilityGate'; import LazyBoundary from './LazyBoundary'; import { formatBytes } from '@/lib/utils'; import { cn } from '@/lib/utils'; -import { SENCHO_OPEN_LOGS_EVENT } from '@/lib/events'; +import { SENCHO_OPEN_LOGS_EVENT, SENCHO_OPEN_STACK_EVENT } from '@/lib/events'; +import type { SenchoOpenStackDetail } from '@/lib/events'; import type { SenchoOpenLogsDetail } from '@/lib/events'; import { lazy, Suspense } from 'react'; import { ReclaimHero } from './resources/ReclaimHero'; @@ -173,17 +174,23 @@ function FilterToggle({ value, onChange, counts }: FilterToggleProps) { // ── Managed Status Badge ─────────────────────────────────────────────────────── -function ManagedBadge({ status, managedBy }: { +function ManagedBadge({ status, managedBy, onOpenStack }: { status: 'managed' | 'unmanaged' | 'unused' | 'system'; managedBy: string | null; + /** When provided on a managed resource, the owning-stack badge becomes a link to that stack. */ + onOpenStack?: (stack: string) => void; }) { if (status === 'managed') { - return ( - - - {managedBy} - - ); + const cls = "inline-flex items-center gap-1 px-1.5 py-0.5 rounded border border-success/25 bg-success/8 text-success text-[10px] font-medium"; + const inner = (<>{managedBy}); + if (onOpenStack && managedBy) { + return ( + + ); + } + return {inner}; } if (status === 'unmanaged') { return ( @@ -1209,7 +1216,13 @@ export default function ResourcesView() { {net.Scope}
- + window.dispatchEvent( + new CustomEvent(SENCHO_OPEN_STACK_EVENT, { detail: { nodeId: activeNode.id, stackName: stack } }), + ) : undefined} + /> {net.isSencho && }
diff --git a/frontend/src/components/StackAnatomyPanel.doctor.test.tsx b/frontend/src/components/StackAnatomyPanel.doctor.test.tsx index c2ba629e..b36f4e74 100644 --- a/frontend/src/components/StackAnatomyPanel.doctor.test.tsx +++ b/frontend/src/components/StackAnatomyPanel.doctor.test.tsx @@ -70,4 +70,10 @@ describe('StackAnatomyPanel Doctor tab (capability on)', () => { await waitFor(() => expect(vi.mocked(apiFetch).mock.calls.some(([u]) => String(u).includes('/preflight'))).toBe(true)); expect(screen.queryByTestId('doctor-tab-dot')).not.toBeInTheDocument(); }); + + it('renders the Networking tab when the capability is present', async () => { + badgeSeverity = 'warning'; + render(panel()); + expect(await screen.findByTestId('networking-tab')).toBeInTheDocument(); + }); }); diff --git a/frontend/src/components/StackAnatomyPanel.test.tsx b/frontend/src/components/StackAnatomyPanel.test.tsx index ccb42051..abb72869 100644 --- a/frontend/src/components/StackAnatomyPanel.test.tsx +++ b/frontend/src/components/StackAnatomyPanel.test.tsx @@ -337,3 +337,13 @@ describe('StackAnatomyPanel exposed footer', () => { expect(screen.queryByRole('link', { name: /:8989/ })).toBeNull(); }); }); + +describe('StackAnatomyPanel capability gating (capability off)', () => { + it('hides the Networking and Doctor tabs when the capabilities are absent', async () => { + render(panel(false)); + // The always-on Anatomy tab confirms the panel mounted. + expect(await screen.findByRole('tab', { name: 'Anatomy' })).toBeInTheDocument(); + expect(screen.queryByTestId('networking-tab')).not.toBeInTheDocument(); + expect(screen.queryByTestId('doctor-tab')).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/StackAnatomyPanel.tsx b/frontend/src/components/StackAnatomyPanel.tsx index 6045e8d2..53bcd431 100644 --- a/frontend/src/components/StackAnatomyPanel.tsx +++ b/frontend/src/components/StackAnatomyPanel.tsx @@ -11,6 +11,7 @@ import { StackActivityTimeline } from './stack/StackActivityTimeline'; import StackDossierPanel from './stack/StackDossierPanel'; import DriftPanel from './stack/DriftPanel'; import PreflightPanel from './stack/PreflightPanel'; +import StackNetworkingPanel from './stack/StackNetworkingPanel'; import { useNodes } from '@/context/NodeContext'; import type { NotificationItem } from '@/components/dashboard/types'; @@ -80,6 +81,7 @@ export default function StackAnatomyPanel({ const { hasCapability, activeNode } = useNodes(); const doctorEnabled = hasCapability('compose-doctor'); + const networkingEnabled = hasCapability('compose-networking'); const [gitSource, setGitSource] = useState<{ stack: string; info: GitSourceInfo } | null>(null); const [updatePreview, setUpdatePreview] = useState(null); @@ -319,6 +321,9 @@ export default function StackAnatomyPanel({ Activity Dossier Drift + {networkingEnabled && ( + Networking + )} {doctorEnabled && ( @@ -579,6 +584,11 @@ export default function StackAnatomyPanel({ + {networkingEnabled && ( + + + + )} {doctorEnabled && ( diff --git a/frontend/src/components/stack/DriftPanel.tsx b/frontend/src/components/stack/DriftPanel.tsx index 6081cda8..7e79e291 100644 --- a/frontend/src/components/stack/DriftPanel.tsx +++ b/frontend/src/components/stack/DriftPanel.tsx @@ -11,7 +11,9 @@ import { useNodes } from '@/context/NodeContext'; // Mirrors the backend payload shape (the frontend never imports backend). type StackDriftStatus = 'in-sync' | 'drifted' | 'missing-runtime' | 'unreachable'; -type DriftFindingKind = 'service-missing' | 'service-undeclared' | 'image-mismatch' | 'ports-mismatch'; +type DriftFindingKind = + | 'service-missing' | 'service-undeclared' | 'image-mismatch' | 'ports-mismatch' + | 'network-undeclared' | 'network-missing'; interface StackDriftFinding { kind: DriftFindingKind; @@ -84,6 +86,8 @@ const FINDING_LABEL: Record = { 'service-undeclared': 'undeclared', 'image-mismatch': 'image', 'ports-mismatch': 'ports', + 'network-undeclared': 'network', + 'network-missing': 'network missing', }; /** The temporal overlay: how the on-disk compose compares to the last deploy baseline. */ diff --git a/frontend/src/components/stack/StackDossierPanel.test.tsx b/frontend/src/components/stack/StackDossierPanel.test.tsx index 4467d0f2..4a939e73 100644 --- a/frontend/src/components/stack/StackDossierPanel.test.tsx +++ b/frontend/src/components/stack/StackDossierPanel.test.tsx @@ -126,7 +126,7 @@ describe('StackDossierPanel', () => { expect(vi.mocked(copyToClipboard).mock.calls[0][0]).toContain('# web'); fireEvent.click(screen.getByTestId('dossier-download-btn')); - expect(downloadTextFile).toHaveBeenCalledWith('web-dossier.md', expect.stringContaining('# web')); + await waitFor(() => expect(downloadTextFile).toHaveBeenCalledWith('web-dossier.md', expect.stringContaining('# web'))); }); // Anatomy that publishes a single TCP host port, for documentation-drift tests. diff --git a/frontend/src/components/stack/StackDossierPanel.tsx b/frontend/src/components/stack/StackDossierPanel.tsx index 71af0b2c..5f036158 100644 --- a/frontend/src/components/stack/StackDossierPanel.tsx +++ b/frontend/src/components/stack/StackDossierPanel.tsx @@ -12,6 +12,7 @@ import { type StackDossierFields, } from '@/lib/dossierMarkdown'; import type { AnatomyMarkdownInput } from '@/lib/anatomyMarkdown'; +import { buildNetworkExposureSummary, type NetworkExposureSummary } from '@/lib/networkExposureSummary'; import { computeDocDrift, type DocDriftFinding } from '@/lib/docDrift'; import { RollbackReadinessSection } from './RollbackReadinessSection'; import { useNodes } from '@/context/NodeContext'; @@ -201,6 +202,23 @@ export default function StackDossierPanel({ stackName, anatomy, canEdit }: Stack return () => { cancelled = true; }; }, [stackName, nodeId, reloadKey]); + // Fetched only when the user exports, so opening the panel costs nothing. + // Fail-soft: if it is unavailable the export simply omits the section. + const loadNetworkingSummary = async (): Promise => { + try { + const [factsRes, exposureRes] = await Promise.all([ + apiFetch(`/stacks/${stackName}/networking`), + apiFetch(`/stacks/${stackName}/exposure`), + ]); + const facts = factsRes.ok ? await factsRes.json() : null; + const intents = exposureRes.ok ? (await exposureRes.json()).intents ?? [] : []; + return buildNetworkExposureSummary(facts, intents); + } catch (err) { + console.warn(`[Dossier] networking summary load failed for "${stackName}":`, err); + return null; + } + }; + const dirty = useMemo( () => FIELD_KEYS.some(k => fields[k] !== serverFields[k]), [fields, serverFields], @@ -247,20 +265,22 @@ export default function StackDossierPanel({ stackName, anatomy, canEdit }: Stack const handleCopy = async () => { if (!anatomy) return; try { - await copyToClipboard(buildStackDossierMarkdown(anatomy, fields)); + const networking = await loadNetworkingSummary(); + await copyToClipboard(buildStackDossierMarkdown(anatomy, fields, networking)); toast.success('Stack dossier copied as Markdown.'); } catch { toast.error('Failed to copy to clipboard.'); } }; - const handleDownload = () => { + const handleDownload = async () => { if (!anatomy) return; try { + const networking = await loadNetworkingSummary(); // Stack names are already constrained, but sanitize defensively so the // file always has a coherent, safe name ending in .md. const base = stackName.replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^[-.]+|[-.]+$/g, '') || 'stack'; - downloadTextFile(`${base}-dossier.md`, buildStackDossierMarkdown(anatomy, fields)); + downloadTextFile(`${base}-dossier.md`, buildStackDossierMarkdown(anatomy, fields, networking)); } catch { toast.error('Failed to download the dossier.'); } @@ -274,7 +294,7 @@ export default function StackDossierPanel({ stackName, anatomy, canEdit }: Stack -
diff --git a/frontend/src/components/stack/StackNetworkingPanel.test.tsx b/frontend/src/components/stack/StackNetworkingPanel.test.tsx new file mode 100644 index 00000000..680deb41 --- /dev/null +++ b/frontend/src/components/stack/StackNetworkingPanel.test.tsx @@ -0,0 +1,145 @@ +/** + * Covers the Networking panel: rendering facts (networks, service membership, + * port binding badges), setting an exposure intent (PUT), the read-only state + * when the user cannot edit, the runtime-unavailable note, and the unrenderable + * banner. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; + +vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() })); +vi.mock('@/components/ui/toast-store', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); +vi.mock('@/context/NodeContext', () => ({ useNodes: () => ({ activeNode: { id: 1 } }) })); + +import { apiFetch } from '@/lib/api'; +import StackNetworkingPanel from './StackNetworkingPanel'; + +function jsonRes(body: unknown, ok = true) { + return { ok, status: ok ? 200 : 500, json: async () => body, text: async () => '' } as unknown as Response; +} + +function facts(partial: Record = {}) { + return { + stack: 'web', renderable: true, renderError: null, runtime: 'available', + networks: [{ key: 'backend', name: 'web_backend', external: false, internal: true, createdByStack: true }], + services: [{ + name: 'api', + networks: [{ key: 'backend', aliases: ['db'] }], + publishedPorts: [ + { hostIp: '0.0.0.0', startPort: 8080, endPort: 8080, protocol: 'tcp', allInterfaces: true, loopbackOnly: false }, + { hostIp: '127.0.0.1', startPort: 9000, endPort: 9000, protocol: 'tcp', allInterfaces: false, loopbackOnly: true }, + ], + extraHosts: [], + }], + drift: { runtimeOnlyAttachments: [], declaredButUnused: [], missingFromRuntime: [], foreignNetworkAttachments: [] }, + ...partial, + }; +} + +/** Route apiFetch by URL + method; a PUT echoes its request into the response. */ +function mockApi(factsBody: Record, intents: unknown[] = [], factsOk = true) { + vi.mocked(apiFetch).mockImplementation((url: string, opts?: RequestInit) => { + if (url.endsWith('/networking')) return Promise.resolve(jsonRes(factsBody, factsOk)); + if (url.endsWith('/exposure') && opts?.method === 'PUT') { + const body = JSON.parse(opts.body as string); + return Promise.resolve(jsonRes({ intents: body.intent === null ? [] : [{ service: body.service, intent: body.intent }] })); + } + if (url.endsWith('/exposure')) return Promise.resolve(jsonRes({ intents })); + return Promise.resolve(jsonRes({})); + }); +} + +const putBodies = () => vi.mocked(apiFetch).mock.calls.filter(c => c[1]?.method === 'PUT').map(c => JSON.parse(c[1]!.body as string)); + +beforeEach(() => { vi.clearAllMocks(); }); + +describe('StackNetworkingPanel', () => { + it('renders networks, service membership, and both binding badges', async () => { + mockApi(facts()); + render(); + expect(await screen.findByText('web_backend')).toBeInTheDocument(); + expect(screen.getByText(/\(db\)/)).toBeInTheDocument(); // network alias in parens + expect(screen.getByText('all interfaces')).toBeInTheDocument(); + expect(screen.getByText('loopback')).toBeInTheDocument(); + expect(screen.getByText(/runtime matches compose/i)).toBeInTheDocument(); // no-drift success card + }); + + it('saves a stack-level exposure intent on click', async () => { + mockApi(facts()); + render(); + await screen.findByText('web_backend'); + fireEvent.click(screen.getAllByRole('button', { name: 'internal' })[0]); + await waitFor(() => expect(putBodies()).toContainEqual({ service: '', intent: 'internal' })); + }); + + it('saves a per-service intent under the service name', async () => { + mockApi(facts()); + render(); + await screen.findByText('web_backend'); + // [0] is the stack row, [1] is the 'api' service row. + fireEvent.click(screen.getAllByRole('button', { name: 'internal' })[1]); + await waitFor(() => expect(putBodies()).toContainEqual({ service: 'api', intent: 'internal' })); + }); + + it('clears a service intent (inherit) by sending intent null', async () => { + mockApi(facts()); + render(); + await screen.findByText('web_backend'); + // The stack row shows "unset"; the service row shows "inherit". + expect(screen.getByRole('button', { name: 'unset' })).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'inherit' })); + await waitFor(() => expect(putBodies()).toContainEqual({ service: 'api', intent: null })); + }); + + it('disables the intent controls when the user cannot edit', async () => { + mockApi(facts()); + render(); + await screen.findByText('web_backend'); + const internalPills = screen.getAllByRole('button', { name: 'internal' }); + expect(internalPills[0]).toBeDisabled(); + fireEvent.click(internalPills[0]); + expect(vi.mocked(apiFetch).mock.calls.some(c => c[1]?.method === 'PUT')).toBe(false); + }); + + it('shows a runtime-unavailable note instead of computing drift', async () => { + mockApi(facts({ runtime: 'unavailable' })); + render(); + expect(await screen.findByText(/runtime unavailable/i)).toBeInTheDocument(); + }); + + it('shows the unrenderable banner when the model cannot render', async () => { + mockApi(facts({ renderable: false, renderError: 'bad compose', networks: [], services: [] })); + render(); + expect(await screen.findByText(/cannot render/i)).toBeInTheDocument(); + expect(screen.getByText('bad compose')).toBeInTheDocument(); + }); + + it('renders runtime drift rows', async () => { + mockApi(facts({ + drift: { + runtimeOnlyAttachments: [{ container: 'api-1', service: 'api', network: 'web_rogue' }], + foreignNetworkAttachments: [{ container: 'api-1', network: 'other_net' }], + declaredButUnused: ['web_idle'], + missingFromRuntime: ['web_gone'], + }, + })); + render(); + await screen.findByText('web_backend'); + expect(screen.getByText('web_rogue')).toBeInTheDocument(); + expect(screen.getByText('other_net')).toBeInTheDocument(); + expect(screen.getByText('web_idle')).toBeInTheDocument(); + expect(screen.getByText('web_gone')).toBeInTheDocument(); + }); + + it('shows an error with retry when the facts fetch fails, and retry refetches', async () => { + mockApi(facts(), [], false); + render(); + expect(await screen.findByText(/Could not load the networking view/i)).toBeInTheDocument(); + const before = vi.mocked(apiFetch).mock.calls.filter(c => String(c[0]).endsWith('/networking')).length; + fireEvent.click(screen.getByRole('button', { name: 'retry' })); + await waitFor(() => { + const after = vi.mocked(apiFetch).mock.calls.filter(c => String(c[0]).endsWith('/networking')).length; + expect(after).toBeGreaterThan(before); + }); + }); +}); diff --git a/frontend/src/components/stack/StackNetworkingPanel.tsx b/frontend/src/components/stack/StackNetworkingPanel.tsx new file mode 100644 index 00000000..15b63dbc --- /dev/null +++ b/frontend/src/components/stack/StackNetworkingPanel.tsx @@ -0,0 +1,318 @@ +import { useEffect, useState, useCallback } from 'react'; +import { Network, Globe, Lock, ShieldQuestion, RefreshCw, ArrowRight } from 'lucide-react'; +import { apiFetch } from '@/lib/api'; +import { cn } from '@/lib/utils'; +import { toast } from '@/components/ui/toast-store'; +import { useNodes } from '@/context/NodeContext'; + +// Mirrors the backend networking payload shapes (the frontend never imports +// backend). IntentEntry intentionally keeps only the fields this panel reads. +type ExposureIntent = 'internal' | 'same-node' | 'lan' | 'reverse-proxy' | 'public' | 'temporary' | 'unknown'; +const INTENTS: readonly ExposureIntent[] = ['internal', 'same-node', 'lan', 'reverse-proxy', 'public', 'temporary', 'unknown']; + +interface NetworkFactNetwork { key: string; name: string; external: boolean; internal: boolean; createdByStack: boolean } +interface NetworkFactPort { hostIp: string; startPort: number; endPort: number; protocol: string; allInterfaces: boolean; loopbackOnly: boolean } +interface NetworkFactService { + name: string; + networks: { key: string; aliases: string[] }[]; + publishedPorts: NetworkFactPort[]; + networkMode?: string; + extraHosts: string[]; +} +interface NetworkDriftFacts { + runtimeOnlyAttachments: { container: string; service: string | null; network: string }[]; + declaredButUnused: string[]; + missingFromRuntime: string[]; + foreignNetworkAttachments: { container: string; network: string }[]; +} +interface StackNetworkFacts { + stack: string; + renderable: boolean; + renderError: string | null; + runtime: 'available' | 'unavailable'; + networks: NetworkFactNetwork[]; + services: NetworkFactService[]; + drift: NetworkDriftFacts; +} +interface IntentEntry { service: string; intent: ExposureIntent } + +const LABEL_CLASS = 'font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle'; +const ACTION_CLASS = 'inline-flex items-center gap-1 font-mono text-[10px] uppercase tracking-wide text-stat-subtitle hover:text-brand transition-colors disabled:opacity-40'; +const CARD_CLASS = 'rounded-lg border px-3 py-2.5'; + +function portLabel(p: NetworkFactPort): string { + const range = p.startPort === p.endPort ? `${p.startPort}` : `${p.startPort}-${p.endPort}`; + return `${range}/${p.protocol}`; +} + +/** Defensively read the intents array from an exposure response body. */ +function asIntents(body: unknown): IntentEntry[] { + const list = (body as { intents?: unknown })?.intents; + return Array.isArray(list) ? (list as IntentEntry[]) : []; +} + +/** A small chip that states the binding scope of a published port. */ +function BindingBadge({ port }: { port: NetworkFactPort }) { + if (port.allInterfaces) { + return all interfaces; + } + if (port.loopbackOnly) { + return loopback; + } + return {port.hostIp}; +} + +/** + * Exposure-intent picker: a row of pills plus a clear option. `value` null means + * the scope is cleared. The clear option reads "unset" on the stack row and + * "inherit" on a per-service row, where the service then falls back to the stack + * intent. Disabled and read-only when the user cannot edit the stack. + */ +function IntentControl({ value, inherited, canEdit, onChange }: { + value: ExposureIntent | null; + inherited?: ExposureIntent | null; + canEdit: boolean; + onChange: (intent: ExposureIntent | null) => void; +}) { + const pill = (active: boolean) => cn( + 'rounded px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-wide border transition-colors', + active ? 'border-brand/50 bg-brand/15 text-brand' : 'border-muted bg-card/40 text-stat-subtitle', + canEdit ? 'hover:border-brand/40' : 'cursor-default opacity-90', + ); + const clearLabel = inherited !== undefined ? 'inherit' : 'unset'; + const cleared = value === null; + return ( +
+ {INTENTS.map(opt => ( + + ))} + + {cleared && inherited && ( + → {inherited} + )} +
+ ); +} + +export default function StackNetworkingPanel({ stackName, canEdit, doctorEnabled }: { + stackName: string; + canEdit: boolean; + doctorEnabled: boolean; +}) { + const { activeNode } = useNodes(); + const nodeId = activeNode?.id; + const [facts, setFacts] = useState(null); + const [intents, setIntents] = useState([]); + const [loadError, setLoadError] = useState(false); + const [refreshing, setRefreshing] = useState(false); + const [reloadKey, setReloadKey] = useState(0); + + useEffect(() => { + let cancelled = false; + const run = async () => { + setLoadError(false); + setRefreshing(true); + try { + const [factsRes, exposureRes] = await Promise.all([ + apiFetch(`/stacks/${stackName}/networking`), + apiFetch(`/stacks/${stackName}/exposure`), + ]); + if (cancelled) return; + if (!factsRes.ok) { + setLoadError(true); + toast.error('Failed to load the networking view.'); + return; + } + setFacts((await factsRes.json()) as StackNetworkFacts); + // The exposure overlay is secondary: a bad exposure body must not tear + // down a working facts view, so its parse is tolerated on its own. + if (exposureRes.ok) { + try { setIntents(asIntents(await exposureRes.json())); } catch { /* keep intents unset */ } + } + } catch { + if (!cancelled) { + setLoadError(true); + toast.error('Failed to load the networking view.'); + } + } finally { + if (!cancelled) setRefreshing(false); + } + }; + void run(); + return () => { cancelled = true; }; + }, [stackName, nodeId, reloadKey]); + + const stackIntent = intents.find(i => i.service === '')?.intent ?? null; + const intentFor = (service: string): ExposureIntent | null => intents.find(i => i.service === service)?.intent ?? null; + + const saveIntent = useCallback(async (service: string, intent: ExposureIntent | null) => { + try { + const res = await apiFetch(`/stacks/${stackName}/exposure`, { + method: 'PUT', + body: JSON.stringify({ service, intent }), + }); + if (!res.ok) { + toast.error('Failed to save the exposure intent.'); + return; + } + setIntents(asIntents(await res.json())); + } catch { + toast.error('Failed to save the exposure intent.'); + } + }, [stackName]); + + if (loadError) { + return ( +
+
+ Could not load the networking view. + +
+
+ ); + } + if (!facts) { + return
Loading networking…
; + } + if (!facts.renderable) { + return ( +
+
+
cannot render
+

{facts.renderError ?? 'Sencho could not render the effective Compose model.'}

+
+
+ ); + } + + const drift = facts.drift; + const hasDrift = drift.runtimeOnlyAttachments.length > 0 || drift.foreignNetworkAttachments.length > 0 + || drift.declaredButUnused.length > 0 || drift.missingFromRuntime.length > 0; + + return ( +
+
+ networking + +
+ + {/* Exposure intent */} +
+
exposure intent
+
+
+ stack + saveIntent('', intent)} /> +
+ {facts.services.map(svc => ( +
+ {svc.name} + saveIntent(svc.name, intent)} /> +
+ ))} +
+
+ + {/* Networks */} +
+
networks
+
+ {facts.networks.length === 0 &&
default network only
} + {facts.networks.map(net => ( +
+ {net.name} + {net.key !== net.name && ({net.key})} + {net.external && external} + {net.internal && internal} + {net.createdByStack && created by stack} +
+ ))} +
+
+ + {/* Services */} +
+
services
+
+ {facts.services.map(svc => ( +
+
+ {svc.name} + {svc.networkMode && network_mode: {svc.networkMode}} +
+ {svc.networks.length > 0 && ( +
+ {svc.networks.map(n => ( + + {n.key}{n.aliases.length > 0 && ({n.aliases.join(', ')})} + + ))} +
+ )} + {svc.publishedPorts.length > 0 && ( +
+ {svc.publishedPorts.map((p, i) => ( +
+ {portLabel(p)} + +
+ ))} +
+ )} + {svc.extraHosts.length > 0 && ( +
extra_hosts: {svc.extraHosts.join(', ')}
+ )} +
+ ))} +
+
+ + {/* Runtime drift */} +
+
runtime drift
+ {facts.runtime === 'unavailable' ? ( +
+ + runtime unavailable, showing the declared model only +
+ ) : !hasDrift ? ( +
+ + runtime matches compose +
+ ) : ( +
+ {drift.runtimeOnlyAttachments.map((d, i) => ( +
+ {d.service ?? d.container} attached to undeclared network {d.network} +
+ ))} + {drift.foreignNetworkAttachments.map((d, i) => ( +
+ {d.container} attached to a network owned by another stack: {d.network} +
+ ))} + {drift.declaredButUnused.length > 0 && ( +
declared but unused by any running service: {drift.declaredButUnused.join(', ')}
+ )} + {drift.missingFromRuntime.length > 0 && ( +
declared but missing from the runtime: {drift.missingFromRuntime.join(', ')}
+ )} +
+ )} +
+ + {doctorEnabled && ( +
+ deploy and security findings are in the Doctor tab +
+ )} +
+ ); +} diff --git a/frontend/src/lib/capabilities.ts b/frontend/src/lib/capabilities.ts index 0fdcc136..57d80533 100644 --- a/frontend/src/lib/capabilities.ts +++ b/frontend/src/lib/capabilities.ts @@ -25,6 +25,7 @@ export const CAPABILITIES = [ 'vulnerability-scanning', 'compose-doctor', 'update-guard', + 'compose-networking', ] as const; export type Capability = (typeof CAPABILITIES)[number]; diff --git a/frontend/src/lib/dossierMarkdown.test.ts b/frontend/src/lib/dossierMarkdown.test.ts index 4341f4e7..a655d7e6 100644 --- a/frontend/src/lib/dossierMarkdown.test.ts +++ b/frontend/src/lib/dossierMarkdown.test.ts @@ -23,6 +23,18 @@ describe('buildStackDossierMarkdown', () => { expect(md).toContain('# plex'); expect(md).toContain('## Services'); expect(md).not.toContain('## Operator notes'); + expect(md).not.toContain('## Network exposure'); + }); + + it('appends the network exposure section when a summary is provided', () => { + const md = buildStackDossierMarkdown(anatomy, fields(), { + stackIntent: 'internal', + networks: [{ name: 'plex_default', external: false, internal: false }], + services: [{ name: 'plex', intent: null, ports: ['32400/tcp (all interfaces)'] }], + }); + expect(md).toContain('## Network exposure'); + expect(md).toContain('**Stack intent:** internal'); + expect(md).toContain('32400/tcp (all interfaces)'); }); it('appends an Operator notes section with the filled fields', () => { diff --git a/frontend/src/lib/dossierMarkdown.ts b/frontend/src/lib/dossierMarkdown.ts index eabe73dd..c0f51eec 100644 --- a/frontend/src/lib/dossierMarkdown.ts +++ b/frontend/src/lib/dossierMarkdown.ts @@ -11,6 +11,7 @@ */ import { buildStackAnatomyMarkdown, type AnatomyMarkdownInput } from './anatomyMarkdown'; +import { networkExposureSection, type NetworkExposureSummary } from './networkExposureSummary'; /** * Operator-authored dossier fields. Mirrors the backend `StackDossierFields` @@ -84,8 +85,12 @@ export function operatorNotesSection(d: StackDossierFields): string | null { export function buildStackDossierMarkdown( anatomy: AnatomyMarkdownInput, dossier: StackDossierFields, + networking?: NetworkExposureSummary | null, ): string { - const anatomyMarkdown = buildStackAnatomyMarkdown(anatomy); - const notes = operatorNotesSection(dossier); - return notes ? `${anatomyMarkdown}\n\n${notes}` : anatomyMarkdown; + const sections = [ + buildStackAnatomyMarkdown(anatomy), + networkExposureSection(networking ?? null), + operatorNotesSection(dossier), + ].filter((s): s is string => s !== null && s !== ''); + return sections.join('\n\n'); } diff --git a/frontend/src/lib/events.ts b/frontend/src/lib/events.ts index b8c21ce6..5aa0b766 100644 --- a/frontend/src/lib/events.ts +++ b/frontend/src/lib/events.ts @@ -14,3 +14,11 @@ export interface SenchoSettingsChangedDetail { } export const SENCHO_LABELS_CHANGED = 'sencho-labels-changed'; + +/** Open a stack on a given node from elsewhere in the app (e.g. a Resources network card). */ +export const SENCHO_OPEN_STACK_EVENT = 'sencho-open-stack'; + +export interface SenchoOpenStackDetail { + nodeId: number; + stackName: string; +} diff --git a/frontend/src/lib/fleetDossier.ts b/frontend/src/lib/fleetDossier.ts index 67e8472a..fc9a1b17 100644 --- a/frontend/src/lib/fleetDossier.ts +++ b/frontend/src/lib/fleetDossier.ts @@ -14,12 +14,15 @@ import type { AnatomyMarkdownInput } from './anatomyMarkdown'; import { buildStackDossierMarkdown, operatorNotesSection, type StackDossierFields } from './dossierMarkdown'; +import type { NetworkExposureSummary } from './networkExposureSummary'; export interface FleetDossierStack { stackName: string; /** Generated anatomy, or null when the stack's compose.yaml could not be parsed. */ anatomy: AnatomyMarkdownInput | null; dossier: StackDossierFields; + /** Redacted networking + exposure summary, or null when unavailable. */ + networking?: NetworkExposureSummary | null; } interface FleetDossierNodeBase { @@ -103,7 +106,7 @@ function stackSlugs(names: string[]): Map { function stackPageMarkdown(stack: FleetDossierStack): string { if (stack.anatomy) { - return `${buildStackDossierMarkdown(stack.anatomy, stack.dossier)}\n`; + return `${buildStackDossierMarkdown(stack.anatomy, stack.dossier, stack.networking ?? null)}\n`; } // Compose could not be parsed: keep the operator's notes rather than dropping // the stack from the export entirely. diff --git a/frontend/src/lib/networkExposureSummary.test.ts b/frontend/src/lib/networkExposureSummary.test.ts new file mode 100644 index 00000000..e6b96b57 --- /dev/null +++ b/frontend/src/lib/networkExposureSummary.test.ts @@ -0,0 +1,61 @@ +/** + * The redacted networking + exposure summary for the dossier export: it carries + * only names, intents, port numbers, and binding scope, never env or label + * values, and renders nothing when there is nothing to document. + */ +import { describe, it, expect } from 'vitest'; +import { buildNetworkExposureSummary, networkExposureSection } from './networkExposureSummary'; + +const facts = (over: Record = {}) => ({ + renderable: true, + networks: [{ name: 'app_backend', external: false, internal: true }], + services: [{ + name: 'web', + publishedPorts: [ + { startPort: 8080, endPort: 8080, protocol: 'tcp', allInterfaces: true, loopbackOnly: false }, + { startPort: 9000, endPort: 9000, protocol: 'tcp', allInterfaces: false, loopbackOnly: true }, + ], + }], + ...over, +}); + +describe('buildNetworkExposureSummary', () => { + it('returns null when the model is not renderable', () => { + expect(buildNetworkExposureSummary({ renderable: false }, [])).toBeNull(); + }); + it('returns null when there is nothing worth documenting', () => { + expect(buildNetworkExposureSummary({ renderable: true, networks: [], services: [{ name: 'web', publishedPorts: [] }] }, [])).toBeNull(); + }); + it('summarizes networks, intents, and ports with their binding scope', () => { + const s = buildNetworkExposureSummary(facts(), [{ service: '', intent: 'internal' }, { service: 'web', intent: 'public' }]); + expect(s).toEqual({ + stackIntent: 'internal', + networks: [{ name: 'app_backend', external: false, internal: true }], + services: [{ name: 'web', intent: 'public', ports: ['8080/tcp (all interfaces)', '9000/tcp (loopback)'] }], + }); + }); +}); + +describe('networkExposureSection', () => { + it('renders a section with the redacted facts', () => { + const md = networkExposureSection(buildNetworkExposureSummary(facts(), [{ service: '', intent: 'public' }])); + expect(md).toContain('## Network exposure'); + expect(md).toContain('**Stack intent:** public'); + expect(md).toContain('app_backend (internal)'); + expect(md).toContain('8080/tcp (all interfaces)'); + }); + it('returns null for a null summary', () => { + expect(networkExposureSection(null)).toBeNull(); + }); + it('never includes a value that lives in an ignored field (no env or label leak)', () => { + // A secret planted in fields the builder does not read must not surface. + const leaky = facts({ + services: [{ name: 'web', publishedPorts: [], env: { TOKEN: 'SECRET-9f3a' }, labels: { x: 'LABEL-SECRET' } }], + networks: [{ name: 'app_backend', external: false, internal: false, driver: 'SECRET-DRIVER' }], + }); + const md = networkExposureSection(buildNetworkExposureSummary(leaky, [{ service: '', intent: 'internal' }])) ?? ''; + expect(md).not.toContain('SECRET-9f3a'); + expect(md).not.toContain('LABEL-SECRET'); + expect(md).not.toContain('SECRET-DRIVER'); + }); +}); diff --git a/frontend/src/lib/networkExposureSummary.ts b/frontend/src/lib/networkExposureSummary.ts new file mode 100644 index 00000000..0b8cf9a2 --- /dev/null +++ b/frontend/src/lib/networkExposureSummary.ts @@ -0,0 +1,66 @@ +/** + * Redacted networking + exposure summary for the Stack Dossier export, derived + * from the /networking facts and /exposure intents. It carries only network + * names, exposure intents, published port numbers, and binding scope; never an + * env value or a label value, so nothing sensitive reaches the exported text. + * Pure and side-effect free. + */ + +export interface NetworkExposureSummary { + stackIntent: string | null; + networks: { name: string; external: boolean; internal: boolean }[]; + services: { name: string; intent: string | null; ports: string[] }[]; +} + +// Loose input shapes: the builder reads the raw parsed /networking and +// /exposure JSON, so it stays decoupled from the panel's local interfaces. +interface FactsPort { startPort: number; endPort: number; protocol: string; allInterfaces: boolean; loopbackOnly: boolean } +interface FactsService { name: string; publishedPorts?: FactsPort[] } +interface FactsNetwork { name: string; external: boolean; internal: boolean } +export interface NetworkFactsInput { renderable?: boolean; networks?: FactsNetwork[]; services?: FactsService[] } +export interface ExposureIntentInput { service: string; intent: string } + +function portLabel(p: FactsPort): string { + const range = p.startPort === p.endPort ? `${p.startPort}` : `${p.startPort}-${p.endPort}`; + const scope = p.allInterfaces ? ' (all interfaces)' : p.loopbackOnly ? ' (loopback)' : ''; + return `${range}/${p.protocol}${scope}`; +} + +/** Assemble the summary, or null when there is nothing worth documenting. */ +export function buildNetworkExposureSummary(facts: NetworkFactsInput | null, intents: ExposureIntentInput[]): NetworkExposureSummary | null { + if (!facts || facts.renderable === false) return null; + const stackIntent = intents.find(i => i.service === '')?.intent ?? null; + const byService = new Map(intents.filter(i => i.service !== '').map(i => [i.service, i.intent])); + const networks = (facts.networks ?? []).map(n => ({ name: n.name, external: n.external, internal: n.internal })); + const services = (facts.services ?? []).map(s => ({ + name: s.name, + intent: byService.get(s.name) ?? null, + ports: (s.publishedPorts ?? []).map(portLabel), + })); + const empty = networks.length === 0 && stackIntent === null + && services.every(s => s.ports.length === 0 && s.intent === null); + return empty ? null : { stackIntent, networks, services }; +} + +/** Render the summary as a Markdown section, or null when there is nothing to show. */ +export function networkExposureSection(summary: NetworkExposureSummary | null): string | null { + if (!summary) return null; + const parts = ['## Network exposure']; + if (summary.stackIntent) parts.push(`- **Stack intent:** ${summary.stackIntent}`); + if (summary.networks.length > 0) { + parts.push('### Networks', summary.networks.map(n => { + const flags = [n.external && 'external', n.internal && 'internal'].filter(Boolean).join(', '); + return `- ${n.name}${flags ? ` (${flags})` : ''}`; + }).join('\n')); + } + const services = summary.services.filter(s => s.ports.length > 0 || s.intent); + if (services.length > 0) { + parts.push('### Services', services.map(s => { + const bits: string[] = []; + if (s.intent) bits.push(`intent ${s.intent}`); + if (s.ports.length > 0) bits.push(`ports ${s.ports.join(', ')}`); + return `- **${s.name}:** ${bits.join('; ')}`; + }).join('\n')); + } + return parts.join('\n\n'); +}