mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
feat: node-scoped Networking operator page (#1603)
* feat: add node-scoped Networking operator page Adds a Networking view with overview, topology, inventory, and findings. Shared aggregate reads back the page; Resources keeps prune and redirects here. Includes fail-closed network delete guards, operator docs, and /nodes/:slug/networking routing. * fix: rename unused variable n to _n to satisfy no-unused-vars lint * fix: keep top bar search clickable when nav grows * feat: complete Compose-first Networking Phase 2 operator assistant * fix: move networking action visibility helper out of component module * feat(networking): complete Compose-first Networking operator page Finish the node-scoped Networking page (Overview, Networks, Topology, Findings) with design-system parity and correct finding semantics. - Rebuild detail sheets on SystemSheet/SheetSection; align the tab band, masthead, and mobile tone with Fleet and Security. - Encode the host-mode and exposure severity matrix; fix collision counts so intentional shared externals are not flagged; add one typed drift predicate shared by inventory, topology, badges, and overview counts. - Preserve per-container attachments and IPs on topology node clicks; drawer-only click with an explicit logs action; ownership and boolean filters; bound large graphs before layout. - Aggregate cached Compose Doctor findings into the Findings tab with honest source labels, structural merge and dedupe, staleness reconciliation, and a shared exposure-context helper both engines use. - Networks tab: privacy-safe service search, precise ownership counts, schema v3 with version-2 adapters on every endpoint, pre-confirm delete reasons, and the shared sortable table with an internal scroll region. - Interop: Fleet node-card networking signal with pending-intent navigation, stack-to-node backlink, and Dossier/Drift deep links. - Enrich sanitized inspect with an allowlisted connected-container list; fetch topology once and filter client-side. - Docs and tests across every new finding kind, adapter, and flow. * fix(networking): correct drift count, exposure fail-soft, and inspect crash paths Address code-review findings on the Networking page implementation: - Fix the Overview drift count to use the shared drift-kind predicate instead of a hardcoded list that omitted external-network-missing. - Gate Compose Doctor's unclassified-exposure and reverse-proxy-undocumented rules on exposure-context availability, so a DB read failure no longer fabricates findings (mirrors the live engine's existing fail-soft behavior). - Guard the per-stack exposure-intent read in topology aggregation so a transient DB failure degrades to unknown intent instead of failing the whole response. - Harden the network detail drawer against a partial inspect payload from an older remote node, and log the real error instead of a bare catch. - Remove now-duplicated severity-rank and drift-kind helpers in favor of the shared modules; drop dead backend-only exports; widen the frontend schema version type to a plain number instead of casting past a literal type. - Add coverage for the delete-guard precedence, the full host-mode severity matrix, the schema-2 compatibility adapter, and the sanitized connected- container allowlist; tighten two tests that were not exercising the behavior they claimed to. * fix: add missing onOpenNodeNetworking prop to FleetView experimental test The added required prop on FleetViewProps broke the merge-build when the test file (on main but not on this branch) was compiled against the updated FleetView interface.
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* Doctor networking findings adapter: reads ONLY cached Compose Doctor reports
|
||||
* (never triggers a fresh preflight run), merges overlapping findings into the
|
||||
* matching live card, and surfaces Doctor-only rules as standalone findings.
|
||||
*/
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { ComposeDoctorService } from '../services/ComposeDoctorService';
|
||||
import { applyDoctorNetworkingFindings } from '../services/network/doctorNetworkingFindings';
|
||||
import type { NetworkingFinding } from '../services/network/networkingTypes';
|
||||
import type { StackNetworkFacts } from '../services/network/types';
|
||||
import type { PreflightReport } from '../services/preflight/types';
|
||||
|
||||
function stubReport(overrides: Partial<PreflightReport> & { findings: PreflightReport['findings'] }): PreflightReport {
|
||||
return {
|
||||
stack: 'stack1',
|
||||
ranAt: Date.now(),
|
||||
ranBy: 'admin',
|
||||
renderable: true,
|
||||
renderError: null,
|
||||
status: 'high',
|
||||
highestSeverity: 'high',
|
||||
sourceHash: 'h', renderedHash: 'h',
|
||||
activeStatus: 'high', activeHighestSeverity: 'high', activeCount: overrides.findings.length, acknowledgedCount: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function stubFacts(overrides: Partial<StackNetworkFacts> = {}): StackNetworkFacts {
|
||||
return {
|
||||
stack: 'stack1',
|
||||
renderable: true,
|
||||
renderError: null,
|
||||
runtime: 'available',
|
||||
networks: [],
|
||||
services: [{ name: 'web', networks: [], publishedPorts: [], extraHosts: [] }],
|
||||
drift: { runtimeOnlyAttachments: [], declaredButUnused: [], missingFromRuntime: [], foreignNetworkAttachments: [] },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function liveHostFinding(stack: string, service: string): NetworkingFinding {
|
||||
return {
|
||||
id: 'live-host-1',
|
||||
kind: 'network-mode-host',
|
||||
severity: 'medium',
|
||||
title: 'Host network mode',
|
||||
message: `Service "${service}" uses network_mode: host.`,
|
||||
stack,
|
||||
service,
|
||||
evidence: [],
|
||||
recommendedActions: [{ kind: 'open-stack-networking', label: 'Open stack networking', stack }],
|
||||
sources: ['live'],
|
||||
doctorFindings: [],
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('applyDoctorNetworkingFindings', () => {
|
||||
it('never calls runPreflight; reads only getLatest', () => {
|
||||
const getLatest = vi.spyOn(ComposeDoctorService, 'getInstance').mockReturnValue({
|
||||
getLatest: vi.fn().mockReturnValue(stubReport({ stack: 'stack1', findings: [] })),
|
||||
} as unknown as ComposeDoctorService);
|
||||
|
||||
applyDoctorNetworkingFindings([], { nodeId: 1, stackNames: ['stack1'], stackFacts: [stubFacts()], snapshot: null });
|
||||
|
||||
expect(getLatest).toHaveBeenCalled();
|
||||
const instance = getLatest.mock.results[0].value as { getLatest: unknown; runPreflight?: unknown };
|
||||
expect(instance.runPreflight).toBeUndefined();
|
||||
});
|
||||
|
||||
it('merges a Doctor host-mode finding into the matching live card instead of duplicating it', () => {
|
||||
vi.spyOn(ComposeDoctorService, 'getInstance').mockReturnValue({
|
||||
getLatest: vi.fn().mockReturnValue(stubReport({
|
||||
stack: 'stack1',
|
||||
findings: [{
|
||||
ruleId: 'network-mode-host', severity: 'high', title: 'Host mode', message: 'uses host networking',
|
||||
service: 'web', sourcePath: 'services.web.network_mode',
|
||||
}],
|
||||
})),
|
||||
} as unknown as ComposeDoctorService);
|
||||
|
||||
const live = [liveHostFinding('stack1', 'web')];
|
||||
const result = applyDoctorNetworkingFindings(live, {
|
||||
nodeId: 1, stackNames: ['stack1'], stackFacts: [stubFacts()], snapshot: null,
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].sources).toEqual(['live', 'doctor']);
|
||||
expect(result[0].doctorFindings).toHaveLength(1);
|
||||
expect(result[0].severity).toBe('medium'); // canonical severity stays the live one
|
||||
expect(result[0].doctorFindings[0].severity).toBe('high'); // Doctor's own severity is preserved
|
||||
expect(result[0].recommendedActions.some((a) => a.kind === 'open-stack-doctor')).toBe(true);
|
||||
});
|
||||
|
||||
it('surfaces a Doctor-only rule (no live counterpart) as a standalone finding', () => {
|
||||
vi.spyOn(ComposeDoctorService, 'getInstance').mockReturnValue({
|
||||
getLatest: vi.fn().mockReturnValue(stubReport({
|
||||
stack: 'stack1',
|
||||
findings: [{
|
||||
ruleId: 'sensitive-service-broad-exposure', severity: 'high',
|
||||
title: 'Sensitive service broadly exposed', message: 'db is broadly exposed', service: 'db',
|
||||
}],
|
||||
})),
|
||||
} as unknown as ComposeDoctorService);
|
||||
|
||||
const result = applyDoctorNetworkingFindings([], {
|
||||
nodeId: 1, stackNames: ['stack1'],
|
||||
stackFacts: [stubFacts({ services: [{ name: 'db', networks: [], publishedPorts: [], extraHosts: [] }] })],
|
||||
snapshot: null,
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].sources).toEqual(['doctor']);
|
||||
expect(result[0].kind).toBe('sensitive-service-broad-exposure');
|
||||
expect(result[0].recommendedActions.some((a) => a.kind === 'open-stack-doctor')).toBe(true);
|
||||
expect(result[0].recommendedActions.some((a) => a.kind === 'open-stack-networking')).toBe(true);
|
||||
});
|
||||
|
||||
it('collapses two occurrences on the SAME service into one merged card (one-to-many)', () => {
|
||||
vi.spyOn(ComposeDoctorService, 'getInstance').mockReturnValue({
|
||||
getLatest: vi.fn().mockReturnValue(stubReport({
|
||||
stack: 'stack1',
|
||||
findings: [
|
||||
{ ruleId: 'port-conflict-internal', severity: 'warning', title: 'Port conflict', message: 'port 80 conflicts', service: 'web' },
|
||||
{ ruleId: 'port-conflict-internal', severity: 'blocker', title: 'Port conflict', message: 'port 443 conflicts', service: 'web' },
|
||||
],
|
||||
})),
|
||||
} as unknown as ComposeDoctorService);
|
||||
|
||||
const result = applyDoctorNetworkingFindings([], {
|
||||
nodeId: 1, stackNames: ['stack1'], stackFacts: [stubFacts()], snapshot: null,
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].doctorFindings).toHaveLength(2);
|
||||
// Mixed severities: the merged card takes the worst (blocker -> critical).
|
||||
expect(result[0].severity).toBe('critical');
|
||||
});
|
||||
|
||||
it('gives distinct services distinct cards, never collapsing them together', () => {
|
||||
vi.spyOn(ComposeDoctorService, 'getInstance').mockReturnValue({
|
||||
getLatest: vi.fn().mockReturnValue(stubReport({
|
||||
stack: 'stack1',
|
||||
findings: [
|
||||
{ ruleId: 'port-conflict-internal', severity: 'blocker', title: 'Port conflict', message: 'port 80 conflicts', service: 'web' },
|
||||
{ ruleId: 'port-conflict-internal', severity: 'blocker', title: 'Port conflict', message: 'port 90 conflicts', service: 'api' },
|
||||
],
|
||||
})),
|
||||
} as unknown as ComposeDoctorService);
|
||||
|
||||
const result = applyDoctorNetworkingFindings([], {
|
||||
nodeId: 1, stackNames: ['stack1'],
|
||||
stackFacts: [stubFacts({ services: [
|
||||
{ name: 'web', networks: [], publishedPorts: [], extraHosts: [] },
|
||||
{ name: 'api', networks: [], publishedPorts: [], extraHosts: [] },
|
||||
] })],
|
||||
snapshot: null,
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(new Set(result.map((f) => f.id)).size).toBe(2);
|
||||
});
|
||||
|
||||
it('excludes acknowledged findings', () => {
|
||||
vi.spyOn(ComposeDoctorService, 'getInstance').mockReturnValue({
|
||||
getLatest: vi.fn().mockReturnValue(stubReport({
|
||||
stack: 'stack1',
|
||||
findings: [{
|
||||
ruleId: 'sensitive-service-broad-exposure', severity: 'high', title: 't', message: 'm',
|
||||
service: 'db', acknowledged: true,
|
||||
}],
|
||||
})),
|
||||
} as unknown as ComposeDoctorService);
|
||||
|
||||
const result = applyDoctorNetworkingFindings([], {
|
||||
nodeId: 1, stackNames: ['stack1'], stackFacts: [stubFacts()], snapshot: null,
|
||||
});
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('discards a stale finding when the referenced service no longer exists', () => {
|
||||
vi.spyOn(ComposeDoctorService, 'getInstance').mockReturnValue({
|
||||
getLatest: vi.fn().mockReturnValue(stubReport({
|
||||
stack: 'stack1',
|
||||
findings: [{
|
||||
ruleId: 'sensitive-service-broad-exposure', severity: 'high', title: 't', message: 'm', service: 'removed-service',
|
||||
}],
|
||||
})),
|
||||
} as unknown as ComposeDoctorService);
|
||||
|
||||
const result = applyDoctorNetworkingFindings([], {
|
||||
nodeId: 1, stackNames: ['stack1'], stackFacts: [stubFacts()], snapshot: null,
|
||||
});
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('is fail-soft: a getLatest() failure for one stack does not throw and other stacks still contribute', () => {
|
||||
vi.spyOn(ComposeDoctorService, 'getInstance').mockReturnValue({
|
||||
getLatest: vi.fn().mockImplementation((_nodeId: number, stack: string) => {
|
||||
if (stack === 'broken') throw new Error('db unavailable');
|
||||
return stubReport({
|
||||
stack,
|
||||
findings: [{ ruleId: 'sensitive-service-broad-exposure', severity: 'high', title: 't', message: 'm', service: 'web' }],
|
||||
});
|
||||
}),
|
||||
} as unknown as ComposeDoctorService);
|
||||
|
||||
expect(() => applyDoctorNetworkingFindings([], {
|
||||
nodeId: 1, stackNames: ['broken', 'stack1'], stackFacts: [stubFacts({ stack: 'stack1' })], snapshot: null,
|
||||
})).not.toThrow();
|
||||
|
||||
const result = applyDoctorNetworkingFindings([], {
|
||||
nodeId: 1, stackNames: ['broken', 'stack1'], stackFacts: [stubFacts({ stack: 'stack1' })], snapshot: null,
|
||||
});
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].stack).toBe('stack1');
|
||||
});
|
||||
|
||||
it('never-run stacks are absent (no "never ran" nagging finding)', () => {
|
||||
vi.spyOn(ComposeDoctorService, 'getInstance').mockReturnValue({
|
||||
getLatest: vi.fn().mockReturnValue(stubReport({ stack: 'stack1', status: 'never-run', findings: [] })),
|
||||
} as unknown as ComposeDoctorService);
|
||||
|
||||
const result = applyDoctorNetworkingFindings([], {
|
||||
nodeId: 1, stackNames: ['stack1'], stackFacts: [stubFacts()], snapshot: null,
|
||||
});
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Complete host-mode severity matrix as a pure-function unit test, so a
|
||||
* refactor that inverts one intent's severity is caught without the full route.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { hostModeSeverity } from '../services/network/networkingFindings';
|
||||
import type { ExposureIntent } from '../services/network/types';
|
||||
|
||||
describe('hostModeSeverity matrix', () => {
|
||||
// Contradiction rows: high regardless of whether the Dossier documents access.
|
||||
const contradiction: (ExposureIntent | null)[] = ['internal', 'same-node', 'unknown', null];
|
||||
for (const intent of contradiction) {
|
||||
it(`${intent ?? 'unset'} is high with and without documentation`, () => {
|
||||
expect(hostModeSeverity(intent, false)).toBe('high');
|
||||
expect(hostModeSeverity(intent, true)).toBe('high');
|
||||
});
|
||||
}
|
||||
|
||||
// Deliberate-exposure rows: medium undocumented, downgraded to info once a
|
||||
// Dossier access URL documents the exposure.
|
||||
const deliberate: ExposureIntent[] = ['lan', 'public', 'reverse-proxy', 'temporary'];
|
||||
for (const intent of deliberate) {
|
||||
it(`${intent} is medium undocumented and info once documented`, () => {
|
||||
expect(hostModeSeverity(intent, false)).toBe('medium');
|
||||
expect(hostModeSeverity(intent, true)).toBe('info');
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* Node networking operator routes: auth boundaries, aggregate reads, delete guards.
|
||||
*/
|
||||
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';
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
import { evaluateNetworkDeleteGuard } from '../services/network/networkDeleteGuards';
|
||||
import SelfIdentityService from '../services/SelfIdentityService';
|
||||
|
||||
let tmpDir: string;
|
||||
let app: import('express').Express;
|
||||
let authHeader: string;
|
||||
|
||||
const STACK = 'netop';
|
||||
|
||||
function token(username: string, role: string, tokenVersion = 1): string {
|
||||
const user = DatabaseService.getInstance().getUserByUsername(username);
|
||||
return `Bearer ${jwt.sign(
|
||||
{ username, role, tokenVersion: user?.token_version ?? tokenVersion },
|
||||
TEST_JWT_SECRET,
|
||||
{ expiresIn: '5m' },
|
||||
)}`;
|
||||
}
|
||||
|
||||
const NET_ID = 'abcdefabcdef';
|
||||
|
||||
function stubAggregate() {
|
||||
vi.spyOn(DockerController, 'getInstance').mockReturnValue({
|
||||
getDependencySnapshot: vi.fn().mockResolvedValue({
|
||||
containers: [],
|
||||
networks: [
|
||||
{ id: NET_ID, name: 'orphan_net', driver: 'bridge', scope: 'local', isSystem: false, composeProject: null, stack: null },
|
||||
],
|
||||
volumes: [],
|
||||
}),
|
||||
inspectNetwork: vi.fn().mockResolvedValue({
|
||||
Id: NET_ID,
|
||||
Name: 'orphan_net',
|
||||
Driver: 'bridge',
|
||||
Scope: 'local',
|
||||
Labels: { 'com.example.key': 'secret-value' },
|
||||
Containers: {},
|
||||
}),
|
||||
removeNetwork: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as DockerController);
|
||||
|
||||
vi.spyOn(ComposeService, 'getInstance').mockReturnValue({
|
||||
renderConfig: vi.fn().mockResolvedValue({
|
||||
rendered: JSON.stringify({
|
||||
name: STACK,
|
||||
services: { web: { image: 'nginx:latest', networks: { backend: null } } },
|
||||
networks: { backend: { name: `${STACK}_backend` } },
|
||||
volumes: {},
|
||||
}),
|
||||
stderr: '',
|
||||
code: 0,
|
||||
timedOut: false,
|
||||
}),
|
||||
} as unknown as ComposeService);
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ app } = await import('../index'));
|
||||
authHeader = token(TEST_USERNAME, 'admin');
|
||||
});
|
||||
|
||||
afterAll(() => cleanupTestDb(tmpDir));
|
||||
|
||||
describe('networking operator 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');
|
||||
stubAggregate();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
fs.rmSync(stackDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('keeps GET /api/networking/summary auth-only for viewers', async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
db.addUser({ username: 'net-viewer', password_hash: 'x', role: 'viewer' });
|
||||
const viewerHeader = token('net-viewer', 'viewer');
|
||||
const res = await request(app).get('/api/networking/summary').set('Authorization', viewerHeader);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('requires authentication on new overview route and allows stack:read roles', async () => {
|
||||
expect((await request(app).get('/api/networking/overview')).status).toBe(401);
|
||||
|
||||
const db = DatabaseService.getInstance();
|
||||
db.addUser({ username: 'net-viewer2', password_hash: 'x', role: 'viewer' });
|
||||
const viewerRes = await request(app).get('/api/networking/overview').set('Authorization', token('net-viewer2', 'viewer'));
|
||||
expect(viewerRes.status).toBe(200);
|
||||
|
||||
const ok = await request(app).get('/api/networking/overview').set('Authorization', authHeader);
|
||||
expect(ok.status).toBe(200);
|
||||
expect(ok.body.schemaVersion).toBe(3);
|
||||
expect(ok.body.runtimeAvailable).toBe(true);
|
||||
expect(ok.body.overview).toBeDefined();
|
||||
expect(Array.isArray(ok.body.networks)).toBe(true);
|
||||
expect(Array.isArray(ok.body.findings)).toBe(true);
|
||||
});
|
||||
|
||||
it('sanitized network inspect returns label keys only', async () => {
|
||||
const res = await request(app).get(`/api/networking/networks/${NET_ID}`).set('Authorization', authHeader);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.schemaVersion).toBe(3);
|
||||
expect(res.body.network.labelKeys).toEqual(['com.example.key']);
|
||||
expect(JSON.stringify(res.body)).not.toContain('secret-value');
|
||||
});
|
||||
|
||||
it('returns a degraded schema envelope when Docker networking is unavailable', async () => {
|
||||
vi.spyOn(DockerController, 'getInstance').mockReturnValue({
|
||||
getDependencySnapshot: vi.fn().mockRejectedValue(new Error('runtime unavailable')),
|
||||
} as unknown as DockerController);
|
||||
|
||||
const res = await request(app).get('/api/networking/overview').set('Authorization', authHeader);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({
|
||||
schemaVersion: 3,
|
||||
runtimeAvailable: false,
|
||||
networks: [],
|
||||
});
|
||||
expect(res.body.findings).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ kind: 'runtime-unavailable', severity: 'info' }),
|
||||
]));
|
||||
expect(res.body.findings.every((finding: { severity: string }) => !['warning', 'error'].includes(finding.severity))).toBe(true);
|
||||
});
|
||||
|
||||
it('blocks admin delete when network is attached', async () => {
|
||||
vi.spyOn(DockerController, 'getInstance').mockReturnValue({
|
||||
getDependencySnapshot: vi.fn().mockResolvedValue({
|
||||
containers: [{ id: 'c1', name: 'web', service: 'web', composeProject: STACK, stack: STACK, state: 'running', image: 'nginx', networks: [{ name: 'orphan_net', id: NET_ID, ip: '' }], volumes: [], ports: [] }],
|
||||
networks: [{ id: NET_ID, name: 'orphan_net', driver: 'bridge', scope: 'local', isSystem: false, composeProject: null, stack: null }],
|
||||
volumes: [],
|
||||
}),
|
||||
removeNetwork: vi.fn(),
|
||||
} as unknown as DockerController);
|
||||
|
||||
const removeSpy = vi.spyOn(DockerController.getInstance(1), 'removeNetwork');
|
||||
const res = await request(app)
|
||||
.post('/api/system/networks/delete')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ id: NET_ID });
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body.code).toBe('attached');
|
||||
expect(removeSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifySnapshotNetworks', () => {
|
||||
it('preserves composeProject on external ownership rows', () => {
|
||||
const snapshot = {
|
||||
containers: [],
|
||||
networks: [{
|
||||
id: 'ext1',
|
||||
name: 'external_shared',
|
||||
driver: 'bridge',
|
||||
scope: 'local',
|
||||
isSystem: false,
|
||||
composeProject: 'other-project',
|
||||
stack: null,
|
||||
}],
|
||||
volumes: [],
|
||||
};
|
||||
const rows = DockerController.classifySnapshotNetworks(snapshot, ['localstack']);
|
||||
expect(rows[0].composeProject).toBe('other-project');
|
||||
expect(rows[0].stack).toBeNull();
|
||||
expect(rows[0].ownership).toBe('compose-managed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('evaluateNetworkDeleteGuard', () => {
|
||||
it('fails closed with stack-declaration-unknown when stacks are unrenderable', () => {
|
||||
const snapshot = {
|
||||
containers: [],
|
||||
networks: [{ id: 'n1', name: 'app_net', driver: 'bridge', scope: 'local', isSystem: false, composeProject: STACK, stack: STACK }],
|
||||
volumes: [],
|
||||
};
|
||||
const guard = evaluateNetworkDeleteGuard('n1', snapshot, [
|
||||
{ stack: STACK, renderable: false, renderError: 'x', runtime: 'available', networks: [], services: [], drift: { runtimeOnlyAttachments: [], declaredButUnused: [], missingFromRuntime: [], foreignNetworkAttachments: [] } },
|
||||
]);
|
||||
expect(guard.blocked).toBe(true);
|
||||
expect(guard.code).toBe('stack-declaration-unknown');
|
||||
});
|
||||
|
||||
it('blocks a system network ahead of every other reason', () => {
|
||||
const snapshot = {
|
||||
containers: [], volumes: [],
|
||||
networks: [{ id: 'sys', name: 'bridge', driver: 'bridge', scope: 'local', isSystem: true, composeProject: null, stack: null }],
|
||||
};
|
||||
expect(evaluateNetworkDeleteGuard('sys', snapshot, []).code).toBe('system-network');
|
||||
});
|
||||
|
||||
it('blocks a Sencho-owned network', () => {
|
||||
const spy = vi.spyOn(SelfIdentityService.getInstance(), 'isOwnNetwork').mockReturnValue(true);
|
||||
try {
|
||||
const snapshot = {
|
||||
containers: [], volumes: [],
|
||||
networks: [{ id: 'own', name: 'sencho_mesh', driver: 'bridge', scope: 'local', isSystem: false, composeProject: null, stack: null }],
|
||||
};
|
||||
expect(evaluateNetworkDeleteGuard('own', snapshot, []).code).toBe('sencho-owned');
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('blocks a network that still has an attached container', () => {
|
||||
const snapshot = {
|
||||
volumes: [],
|
||||
containers: [{ id: 'c1', name: 'web', service: 'web', composeProject: STACK, stack: STACK, state: 'running', image: 'img', networks: [{ name: 'app_net', id: 'n1', ip: '' }], volumes: [], ports: [] }],
|
||||
networks: [{ id: 'n1', name: 'app_net', driver: 'bridge', scope: 'local', isSystem: false, composeProject: STACK, stack: STACK }],
|
||||
};
|
||||
expect(evaluateNetworkDeleteGuard('n1', snapshot, []).code).toBe('attached');
|
||||
});
|
||||
|
||||
it('blocks a network a renderable stack declares', () => {
|
||||
const snapshot = {
|
||||
containers: [], volumes: [],
|
||||
networks: [{ id: 'n1', name: 'app_net', driver: 'bridge', scope: 'local', isSystem: false, composeProject: STACK, stack: STACK }],
|
||||
};
|
||||
const guard = evaluateNetworkDeleteGuard('n1', snapshot, [
|
||||
{ stack: STACK, renderable: true, renderError: null, runtime: 'available',
|
||||
networks: [{ key: 'app_net', name: 'app_net', external: false, internal: false, createdByStack: true }],
|
||||
services: [], drift: { runtimeOnlyAttachments: [], declaredButUnused: [], missingFromRuntime: [], foreignNetworkAttachments: [] } },
|
||||
]);
|
||||
expect(guard.code).toBe('stack-declared');
|
||||
});
|
||||
|
||||
it('allows deleting an unattached, undeclared network', () => {
|
||||
const snapshot = {
|
||||
containers: [], volumes: [],
|
||||
networks: [{ id: 'n1', name: 'orphan_net', driver: 'bridge', scope: 'local', isSystem: false, composeProject: null, stack: null }],
|
||||
};
|
||||
expect(evaluateNetworkDeleteGuard('n1', snapshot, [])).toEqual({ blocked: false });
|
||||
});
|
||||
|
||||
it('does not block when the network is absent from the snapshot', () => {
|
||||
expect(evaluateNetworkDeleteGuard('ghost', { containers: [], networks: [], volumes: [] }, [])).toEqual({ blocked: false });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* Severity matrix and collision correctness for the live Networking findings
|
||||
* engine: host-mode severity across exposure intents (with/without a
|
||||
* documented Dossier access URL), and the network-name-collision fix that
|
||||
* must not flag intentional shared-external-network declarations.
|
||||
*/
|
||||
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';
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
|
||||
let tmpDir: string;
|
||||
let app: import('express').Express;
|
||||
let authHeader: string;
|
||||
|
||||
function renderedModel(stack: string, opts: { networkMode?: string; hostIp?: string } = {}) {
|
||||
const service: Record<string, unknown> = { image: 'nginx:latest' };
|
||||
if (opts.networkMode) {
|
||||
service.network_mode = opts.networkMode;
|
||||
} else {
|
||||
service.ports = [{ published: '8080', target: '80', host_ip: opts.hostIp ?? '' }];
|
||||
}
|
||||
return {
|
||||
rendered: JSON.stringify({
|
||||
name: stack,
|
||||
services: { web: service },
|
||||
networks: {},
|
||||
volumes: {},
|
||||
}),
|
||||
stderr: '',
|
||||
code: 0,
|
||||
timedOut: false,
|
||||
};
|
||||
}
|
||||
|
||||
function stubEmptySnapshot() {
|
||||
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));
|
||||
|
||||
function writeStack(stack: string) {
|
||||
const dir = path.join(process.env.COMPOSE_DIR as string, stack);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'compose.yaml'), 'services:\n web:\n image: nginx:latest\n');
|
||||
return dir;
|
||||
}
|
||||
|
||||
async function findingsFor(stack: string): Promise<Array<{ kind: string; severity: string; stack?: string }>> {
|
||||
const res = await request(app).get('/api/networking/overview').set('Authorization', authHeader);
|
||||
expect(res.status).toBe(200);
|
||||
return (res.body.findings as Array<{ kind: string; severity: string; stack?: string }>)
|
||||
.filter((f) => f.stack === stack);
|
||||
}
|
||||
|
||||
describe('networking host-mode severity matrix', () => {
|
||||
const STACK = 'sevhost';
|
||||
let stackDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
stackDir = writeStack(STACK);
|
||||
stubEmptySnapshot();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
DatabaseService.getInstance().deleteStackExposureIntents(1, STACK);
|
||||
DatabaseService.getInstance().deleteStackDossier(1, STACK);
|
||||
fs.rmSync(stackDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('unset intent is high severity for network_mode: host', async () => {
|
||||
vi.spyOn(ComposeService, 'getInstance').mockReturnValue({
|
||||
renderConfig: vi.fn().mockResolvedValue(renderedModel(STACK, { networkMode: 'host' })),
|
||||
} as unknown as ComposeService);
|
||||
const findings = await findingsFor(STACK);
|
||||
const hostFinding = findings.find((f) => f.kind === 'network-mode-host');
|
||||
expect(hostFinding?.severity).toBe('high');
|
||||
});
|
||||
|
||||
it('internal intent is high severity for network_mode: host regardless of documentation', async () => {
|
||||
vi.spyOn(ComposeService, 'getInstance').mockReturnValue({
|
||||
renderConfig: vi.fn().mockResolvedValue(renderedModel(STACK, { networkMode: 'host' })),
|
||||
} as unknown as ComposeService);
|
||||
DatabaseService.getInstance().setStackExposureIntent(1, STACK, '', 'internal', null);
|
||||
DatabaseService.getInstance().upsertStackDossier(1, STACK, {
|
||||
purpose: '', owner: '', access_urls: 'http://localhost:8080', static_ip: '', vlan: '',
|
||||
firewall_notes: '', reverse_proxy_notes: '', backup_notes: '', upgrade_notes: '',
|
||||
recovery_notes: '', custom_notes: '',
|
||||
});
|
||||
const findings = await findingsFor(STACK);
|
||||
const hostFinding = findings.find((f) => f.kind === 'network-mode-host');
|
||||
expect(hostFinding?.severity).toBe('high');
|
||||
});
|
||||
|
||||
it('lan intent without documented access is medium severity', async () => {
|
||||
vi.spyOn(ComposeService, 'getInstance').mockReturnValue({
|
||||
renderConfig: vi.fn().mockResolvedValue(renderedModel(STACK, { networkMode: 'host' })),
|
||||
} as unknown as ComposeService);
|
||||
DatabaseService.getInstance().setStackExposureIntent(1, STACK, '', 'lan', null);
|
||||
const findings = await findingsFor(STACK);
|
||||
const hostFinding = findings.find((f) => f.kind === 'network-mode-host');
|
||||
expect(hostFinding?.severity).toBe('medium');
|
||||
});
|
||||
|
||||
it('lan intent with a documented Dossier access URL downgrades to info', async () => {
|
||||
vi.spyOn(ComposeService, 'getInstance').mockReturnValue({
|
||||
renderConfig: vi.fn().mockResolvedValue(renderedModel(STACK, { networkMode: 'host' })),
|
||||
} as unknown as ComposeService);
|
||||
DatabaseService.getInstance().setStackExposureIntent(1, STACK, '', 'lan', null);
|
||||
DatabaseService.getInstance().upsertStackDossier(1, STACK, {
|
||||
purpose: '', owner: '', access_urls: 'http://localhost:8080', static_ip: '', vlan: '',
|
||||
firewall_notes: '', reverse_proxy_notes: '', backup_notes: '', upgrade_notes: '',
|
||||
recovery_notes: '', custom_notes: '',
|
||||
});
|
||||
const findings = await findingsFor(STACK);
|
||||
const hostFinding = findings.find((f) => f.kind === 'network-mode-host');
|
||||
expect(hostFinding?.severity).toBe('info');
|
||||
});
|
||||
});
|
||||
|
||||
describe('networking collision correctness', () => {
|
||||
const STACK_A = 'colla';
|
||||
const STACK_B = 'collb';
|
||||
let dirA: string;
|
||||
let dirB: string;
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
fs.rmSync(dirA, { recursive: true, force: true });
|
||||
fs.rmSync(dirB, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('two stacks declaring the SAME external network are not a name collision', async () => {
|
||||
dirA = writeStack(STACK_A);
|
||||
dirB = writeStack(STACK_B);
|
||||
stubEmptySnapshot();
|
||||
vi.spyOn(ComposeService, 'getInstance').mockReturnValue({
|
||||
renderConfig: vi.fn().mockImplementation((stack: string) => Promise.resolve({
|
||||
rendered: JSON.stringify({
|
||||
name: stack,
|
||||
services: { web: { image: 'nginx:latest' } },
|
||||
networks: { edge: { name: 'edge-net', external: true } },
|
||||
volumes: {},
|
||||
}),
|
||||
stderr: '', code: 0, timedOut: false,
|
||||
})),
|
||||
} as unknown as ComposeService);
|
||||
|
||||
const res = await request(app).get('/api/networking/overview').set('Authorization', authHeader);
|
||||
expect(res.status).toBe(200);
|
||||
const findings = res.body.findings as Array<{ kind: string; network?: string }>;
|
||||
expect(findings.some((f) => f.kind === 'network-name-collision' && f.network === 'edge-net')).toBe(false);
|
||||
});
|
||||
|
||||
it('two stacks declaring a non-external network with the same forced literal name IS a collision', async () => {
|
||||
dirA = writeStack(STACK_A);
|
||||
dirB = writeStack(STACK_B);
|
||||
stubEmptySnapshot();
|
||||
vi.spyOn(ComposeService, 'getInstance').mockReturnValue({
|
||||
renderConfig: vi.fn().mockImplementation((stack: string) => Promise.resolve({
|
||||
rendered: JSON.stringify({
|
||||
name: stack,
|
||||
services: { web: { image: 'nginx:latest' } },
|
||||
networks: { app: { name: 'shared-literal' } },
|
||||
volumes: {},
|
||||
}),
|
||||
stderr: '', code: 0, timedOut: false,
|
||||
})),
|
||||
} as unknown as ComposeService);
|
||||
|
||||
const res = await request(app).get('/api/networking/overview').set('Authorization', authHeader);
|
||||
expect(res.status).toBe(200);
|
||||
const findings = res.body.findings as Array<{ kind: string; network?: string }>;
|
||||
expect(findings.some((f) => f.kind === 'network-name-collision' && f.network === 'shared-literal')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -32,6 +32,7 @@ function ctx(over: Partial<PreflightContext> = {}): PreflightContext {
|
||||
nodePorts: [], existingNetworkNames: new Set(), existingVolumeNames: new Set(),
|
||||
existingContainers: [], nodeStateAvailable: true, bindChecks: [],
|
||||
stackIntent: null, serviceIntents: {}, accessUrlPorts: new Set(), hasAccessUrls: false,
|
||||
exposureAvailable: true,
|
||||
isSelfStack: false, ...over,
|
||||
};
|
||||
}
|
||||
@@ -354,6 +355,15 @@ describe('exposure-intent rules', () => {
|
||||
it('does not warn unclassified when no port is published', () => {
|
||||
expect(ids(runRules(ctx({ model: model([svc()]), stackIntent: null })), 'exposure-unclassified')).toHaveLength(0);
|
||||
});
|
||||
it('does not fabricate intent findings when the exposure context is unavailable', () => {
|
||||
// A DB read failure leaves every intent null and no access URLs; the
|
||||
// interpretation rules must stay silent rather than read that as unclassified
|
||||
// or undocumented.
|
||||
const rp = model([svc({ name: 'web', labelKeys: ['traefik.enable'], ports: [{ startPort: 8080, endPort: 8080, hostIp: '0.0.0.0', protocol: 'tcp' }] })]);
|
||||
const f = runRules(ctx({ model: rp, exposureAvailable: false }));
|
||||
expect(ids(f, 'exposure-unclassified')).toHaveLength(0);
|
||||
expect(ids(f, 'reverse-proxy-undocumented')).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);
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* The connected-container section of the sanitized inspect DTO must expose only
|
||||
* the allowlisted fields and must never leak label values, MAC addresses, or
|
||||
* endpoint IDs from the raw Docker inspect payload.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { sanitizeNetworkInspect } from '../services/network/sanitizeNetworkInspect';
|
||||
import type { DependencySnapshot } from '../services/DockerController';
|
||||
|
||||
describe('sanitizeNetworkInspect connected containers', () => {
|
||||
it('exposes only name/service/stack/ipv4 (CIDR stripped) and no raw label values or MAC/endpoint data', () => {
|
||||
const snapshot: DependencySnapshot = {
|
||||
networks: [{ id: 'net1', name: 'app_net', driver: 'bridge', scope: 'local', isSystem: false, composeProject: 'app', stack: 'app' }],
|
||||
volumes: [],
|
||||
containers: [{
|
||||
id: 'c1', name: 'app-web-1', service: 'web', composeProject: 'app', stack: 'app', state: 'running', image: 'nginx',
|
||||
networks: [{ name: 'app_net', id: 'net1', ip: '172.20.0.5/16' }], volumes: [], ports: [],
|
||||
}],
|
||||
};
|
||||
const raw = {
|
||||
Id: 'net1', Name: 'app_net', Driver: 'bridge', Scope: 'local',
|
||||
Labels: { 'com.docker.compose.project': 'app', 'secret.token': 'do-not-leak' },
|
||||
Containers: { c1: { Name: 'app-web-1', MacAddress: '02:42:ac:14:00:05', EndpointID: 'endpoint-xyz', IPv4Address: '172.20.0.5/16' } },
|
||||
};
|
||||
|
||||
const result = sanitizeNetworkInspect(raw, snapshot.networks[0], snapshot);
|
||||
|
||||
expect(result.connectedContainers).toEqual([
|
||||
{ name: 'app-web-1', service: 'web', stack: 'app', ipv4: '172.20.0.5' },
|
||||
]);
|
||||
// Label keys are exposed, values never are.
|
||||
expect(result.labelKeys).toContain('secret.token');
|
||||
const serialized = JSON.stringify(result);
|
||||
expect(serialized).not.toContain('do-not-leak');
|
||||
expect(serialized).not.toContain('02:42:ac:14:00:05');
|
||||
expect(serialized).not.toContain('endpoint-xyz');
|
||||
});
|
||||
|
||||
it('yields an empty connected list when no snapshot is provided', () => {
|
||||
const result = sanitizeNetworkInspect({ Id: 'net1', Name: 'app_net' }, undefined, undefined);
|
||||
expect(result.connectedContainers).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -51,6 +51,13 @@ function stubDockerControllerNoops() {
|
||||
removeNetwork: vi.fn().mockResolvedValue(undefined),
|
||||
removeVolume: vi.fn().mockResolvedValue(undefined),
|
||||
removeContainers: vi.fn().mockResolvedValue([]),
|
||||
getDependencySnapshot: vi.fn().mockResolvedValue({
|
||||
containers: [],
|
||||
networks: [
|
||||
{ id: OTHER_NETWORK, name: 'other_net', driver: 'bridge', scope: 'local', isSystem: false, composeProject: null, stack: null },
|
||||
],
|
||||
volumes: [],
|
||||
}),
|
||||
};
|
||||
vi.spyOn(DockerController, 'getInstance').mockReturnValue(fake as unknown as ReturnType<typeof DockerController.getInstance>);
|
||||
return fake;
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import { requirePermission } from '../middleware/permissions';
|
||||
import { computeNodeNetworkingSummary } from '../services/network/networkingSummary';
|
||||
import {
|
||||
buildNodeNetworkingAggregate,
|
||||
loadNetworkingSnapshot,
|
||||
} from '../services/network/networkingAggregate';
|
||||
import DockerController from '../services/DockerController';
|
||||
import { findSnapshotNetwork, sanitizeNetworkInspect } from '../services/network/sanitizeNetworkInspect';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
import { sanitizeForLog } from '../utils/safeLog';
|
||||
import { isValidDockerResourceId } from '../utils/validation';
|
||||
import { okEnvelope, runtimeUnavailableEnvelope } from '../services/network/networkingEnvelope';
|
||||
|
||||
export const networkingRouter = Router();
|
||||
|
||||
@@ -17,3 +26,107 @@ networkingRouter.get('/summary', async (req: Request, res: Response): Promise<vo
|
||||
res.status(500).json({ error: 'Failed to build networking summary' });
|
||||
}
|
||||
});
|
||||
|
||||
networkingRouter.get('/overview', async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requirePermission(req, res, 'stack:read')) return;
|
||||
try {
|
||||
const aggregate = await buildNodeNetworkingAggregate(req.nodeId, {});
|
||||
res.json(okEnvelope(aggregate.runtimeAvailable, {
|
||||
overview: aggregate.overview,
|
||||
networks: aggregate.networks,
|
||||
findings: aggregate.findings,
|
||||
recentActivity: aggregate.recentActivity,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('[Networking] Failed to build overview:', sanitizeForLog(getErrorMessage(error, 'unknown')));
|
||||
res.status(500).json({ error: 'Failed to build networking overview' });
|
||||
}
|
||||
});
|
||||
|
||||
networkingRouter.get('/networks', async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requirePermission(req, res, 'stack:read')) return;
|
||||
try {
|
||||
const aggregate = await buildNodeNetworkingAggregate(req.nodeId, {});
|
||||
res.json(okEnvelope(aggregate.runtimeAvailable, { networks: aggregate.networks }));
|
||||
} catch (error) {
|
||||
console.error('[Networking] Failed to list networks:', sanitizeForLog(getErrorMessage(error, 'unknown')));
|
||||
res.status(500).json({ error: 'Failed to list networks' });
|
||||
}
|
||||
});
|
||||
|
||||
networkingRouter.get('/networks/:id', async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requirePermission(req, res, 'stack:read')) return;
|
||||
const id = req.params.id as string;
|
||||
if (!id || (!isValidDockerResourceId(id) && !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(id))) {
|
||||
res.status(400).json({ error: 'Invalid network ID format' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { snapshot } = await loadNetworkingSnapshot(req.nodeId);
|
||||
if (!snapshot) {
|
||||
res.status(503).json(runtimeUnavailableEnvelope());
|
||||
return;
|
||||
}
|
||||
const snapNet = findSnapshotNetwork(snapshot, id);
|
||||
const raw = await DockerController.getInstance(req.nodeId).inspectNetwork(snapNet?.id ?? id);
|
||||
res.json(okEnvelope(true, { network: sanitizeNetworkInspect(raw, snapNet, snapshot) }));
|
||||
} catch (error: unknown) {
|
||||
console.error('[Networking] Failed to inspect network:', sanitizeForLog(getErrorMessage(error, 'unknown')));
|
||||
const statusCode = (error as { statusCode?: number }).statusCode;
|
||||
const is404 = statusCode === 404
|
||||
|| (error instanceof Error && error.message.includes('404'));
|
||||
res.status(is404 ? 404 : 500).json({
|
||||
error: is404 ? 'Network not found' : 'Failed to inspect network',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
networkingRouter.get('/topology', async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requirePermission(req, res, 'stack:read')) return;
|
||||
const includeSystem = req.query.includeSystem === 'true';
|
||||
try {
|
||||
const aggregate = await buildNodeNetworkingAggregate(req.nodeId, {
|
||||
includeTopology: true,
|
||||
includeSystem,
|
||||
});
|
||||
res.json(okEnvelope(aggregate.runtimeAvailable, {
|
||||
networks: aggregate.topology?.networks ?? [],
|
||||
includeSystem,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('[Networking] Failed to build topology:', sanitizeForLog(getErrorMessage(error, 'unknown')));
|
||||
res.status(500).json({ error: 'Failed to build networking topology' });
|
||||
}
|
||||
});
|
||||
|
||||
networkingRouter.get('/findings', async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requirePermission(req, res, 'stack:read')) return;
|
||||
try {
|
||||
const aggregate = await buildNodeNetworkingAggregate(req.nodeId, {});
|
||||
res.json(okEnvelope(aggregate.runtimeAvailable, { findings: aggregate.findings }));
|
||||
} catch (error) {
|
||||
console.error('[Networking] Failed to list findings:', sanitizeForLog(getErrorMessage(error, 'unknown')));
|
||||
res.status(500).json({ error: 'Failed to list networking findings' });
|
||||
}
|
||||
});
|
||||
|
||||
networkingRouter.get('/findings/:id', async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requirePermission(req, res, 'stack:read')) return;
|
||||
const findingId = req.params.id as string;
|
||||
if (!findingId) {
|
||||
res.status(400).json({ error: 'Finding ID is required' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const aggregate = await buildNodeNetworkingAggregate(req.nodeId, {});
|
||||
const match = aggregate.findings.find(f => f.id === findingId);
|
||||
if (!match) {
|
||||
res.status(404).json({ error: 'Finding not found' });
|
||||
return;
|
||||
}
|
||||
res.json(okEnvelope(aggregate.runtimeAvailable, { finding: match }));
|
||||
} catch (error) {
|
||||
console.error('[Networking] Failed to load finding:', sanitizeForLog(getErrorMessage(error, 'unknown')));
|
||||
res.status(500).json({ error: 'Failed to load networking finding' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -19,6 +19,9 @@ import { withTimeout, TimeoutError } from '../utils/withTimeout';
|
||||
import { buildNodeLabelInventory } from '../services/LabelInventoryService';
|
||||
import { labelInventoryOptionsFromRequest, requireRevealAdmin } from '../helpers/labelInventoryRequest';
|
||||
import { requirePermission } from '../middleware/permissions';
|
||||
import { buildStackNetworkFacts } from '../services/network/composeNetworkInspector';
|
||||
import { evaluateNetworkDeleteGuard } from '../services/network/networkDeleteGuards';
|
||||
import { loadNetworkingSnapshot } from '../services/network/networkingAggregate';
|
||||
|
||||
// `docker system df` (the call backing estimateSystemReclaim) can take 30+
|
||||
// seconds on Docker Desktop with many volumes; 8s matches the MonitorService
|
||||
@@ -469,6 +472,21 @@ systemMaintenanceRouter.post('/networks/delete', async (req: Request, res: Respo
|
||||
return res.status(400).json({ error: 'Invalid network ID format' });
|
||||
}
|
||||
if (rejectIfSelf('network', id, res)) return;
|
||||
|
||||
const { stacks, snapshot } = await loadNetworkingSnapshot(req.nodeId);
|
||||
if (!snapshot) {
|
||||
return res.status(503).json({ error: 'Docker networking runtime is unavailable' });
|
||||
}
|
||||
const stackFacts = await Promise.all(
|
||||
stacks.map(stack => buildStackNetworkFacts(req.nodeId, stack, snapshot)),
|
||||
);
|
||||
const baseRow = DockerController.classifySnapshotNetworks(snapshot, stacks)
|
||||
.find(n => n.id === id);
|
||||
const guard = evaluateNetworkDeleteGuard(id, snapshot, stackFacts, baseRow);
|
||||
if (guard.blocked) {
|
||||
return res.status(409).json({ error: guard.error, code: guard.code });
|
||||
}
|
||||
|
||||
console.log(`[Resources] Delete network: ${id.substring(0, 12)}`);
|
||||
const dockerController = DockerController.getInstance(req.nodeId);
|
||||
await dockerController.removeNetwork(id);
|
||||
|
||||
@@ -9,7 +9,7 @@ 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 { getExposureContext } from './network/exposureContext';
|
||||
import type { ExposureIntent } from './network/types';
|
||||
import { runRules, SEVERITY_RANK, RULE_IDS, RENDER_FAILED_RULE_ID } from './preflight/rules';
|
||||
import type {
|
||||
@@ -266,7 +266,7 @@ export class ComposeDoctorService {
|
||||
|
||||
const { nodePorts, existingNetworkNames, existingVolumeNames, existingContainers, nodeStateAvailable } = await this.nodeState(nodeId, fsSvc, stackName);
|
||||
const bindChecks = model ? await this.resolveBindChecks(model, baseDir) : [];
|
||||
const { stackIntent, serviceIntents, accessUrlPorts, hasAccessUrls } = this.exposureState(nodeId, stackName);
|
||||
const { stackIntent, serviceIntents, accessUrlPorts, hasAccessUrls, exposureAvailable } = this.exposureState(nodeId, stackName);
|
||||
const selfStack = await isSelfStack(stackName);
|
||||
|
||||
return {
|
||||
@@ -290,6 +290,7 @@ export class ComposeDoctorService {
|
||||
serviceIntents,
|
||||
accessUrlPorts,
|
||||
hasAccessUrls,
|
||||
exposureAvailable,
|
||||
isSelfStack: selfStack,
|
||||
};
|
||||
}
|
||||
@@ -297,6 +298,8 @@ export class ComposeDoctorService {
|
||||
/**
|
||||
* The user's stored exposure intent (resolved into stack-level + per-service)
|
||||
* and the dossier's documented access-URL ports, for the exposure rules.
|
||||
* Delegates to the shared exposure-context helper (also used by the live
|
||||
* Networking findings engine) so both engines can never diverge on severity.
|
||||
* Fail-soft: a read error defaults to unset/empty so the rules simply do not
|
||||
* fire rather than the whole preflight failing.
|
||||
*/
|
||||
@@ -305,26 +308,19 @@ export class ComposeDoctorService {
|
||||
serviceIntents: Record<string, ExposureIntent>;
|
||||
accessUrlPorts: Set<number>;
|
||||
hasAccessUrls: boolean;
|
||||
exposureAvailable: boolean;
|
||||
} {
|
||||
try {
|
||||
const db = DatabaseService.getInstance();
|
||||
const rows = db.getStackExposureIntents(nodeId, stackName);
|
||||
const stackIntent = rows.find(r => r.service === '')?.intent ?? null;
|
||||
const serviceIntents: Record<string, ExposureIntent> = {};
|
||||
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 };
|
||||
const context = getExposureContext(nodeId, stackName);
|
||||
if (!context.available) {
|
||||
return { stackIntent: null, serviceIntents: {}, accessUrlPorts: new Set(), hasAccessUrls: false, exposureAvailable: false };
|
||||
}
|
||||
return {
|
||||
stackIntent: context.stackIntent,
|
||||
serviceIntents: context.serviceIntents,
|
||||
accessUrlPorts: context.accessUrlPorts,
|
||||
hasAccessUrls: context.hasAccessUrls,
|
||||
exposureAvailable: true,
|
||||
};
|
||||
}
|
||||
|
||||
/** Snapshot the node's ports/networks/volumes/containers. Degrades to empty if Docker is unreachable. */
|
||||
|
||||
@@ -3113,6 +3113,22 @@ export class DatabaseService {
|
||||
return (this.db.prepare(sql).all(...args) as unknown[]).map(row => this.mapNotificationRow(row as any));
|
||||
}
|
||||
|
||||
public getNodeStackActivity(nodeId: number, opts: { limit: number }): NotificationHistory[] {
|
||||
const categories = [
|
||||
'deploy_success', 'deploy_failure', 'stack_started', 'stack_stopped', 'stack_restarted',
|
||||
'image_update_applied', 'update_started', 'health_gate_passed', 'health_gate_failed',
|
||||
];
|
||||
const placeholders = categories.map(() => '?').join(', ');
|
||||
const sql = `
|
||||
SELECT * FROM notification_history
|
||||
WHERE node_id = ? AND category IN (${placeholders})
|
||||
ORDER BY timestamp DESC, id DESC
|
||||
LIMIT ?
|
||||
`;
|
||||
return (this.db.prepare(sql).all(nodeId, ...categories, opts.limit) as unknown[])
|
||||
.map(row => this.mapNotificationRow(row as any));
|
||||
}
|
||||
|
||||
public addNotificationHistory(nodeId: number, notification: Omit<NotificationHistory, 'id' | 'is_read'>): NotificationHistory {
|
||||
const stmt = this.db.prepare(
|
||||
'INSERT INTO notification_history (node_id, level, message, timestamp, is_read, stack_name, container_name, category, actor_username) VALUES (?, ?, ?, ?, 0, ?, ?, ?, ?)'
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
type PruneScope,
|
||||
type PruneTarget,
|
||||
} from './prunePlan';
|
||||
import type { NetworkingNetworkBase } from './network/networkingTypes';
|
||||
import { isPathWithinBase } from '../utils/validation';
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
import { sanitizeForLog } from '../utils/safeLog';
|
||||
@@ -220,6 +221,8 @@ export interface DependencyNetwork {
|
||||
driver: string;
|
||||
scope: string;
|
||||
isSystem: boolean;
|
||||
ingress?: boolean;
|
||||
enableIPv6?: boolean;
|
||||
/** Raw com.docker.compose.project label (may not map to a known stack). */
|
||||
composeProject: string | null;
|
||||
/** Resolved Sencho stack this network belongs to, or null. */
|
||||
@@ -1773,12 +1776,15 @@ class DockerController {
|
||||
|
||||
const networks: DependencyNetwork[] = networksRaw.map((net: any) => {
|
||||
const project = net.Labels?.['com.docker.compose.project'] ?? null;
|
||||
const ingress = net.Ingress === true;
|
||||
return {
|
||||
id: net.Id,
|
||||
name: net.Name,
|
||||
driver: net.Driver ?? 'bridge',
|
||||
scope: net.Scope ?? 'local',
|
||||
isSystem: DockerController.SYSTEM_NETWORKS.has(net.Name),
|
||||
isSystem: DockerController.SYSTEM_NETWORKS.has(net.Name) || ingress,
|
||||
ingress,
|
||||
...(typeof net.EnableIPv6 === 'boolean' ? { enableIPv6: net.EnableIPv6 } : {}),
|
||||
composeProject: project,
|
||||
stack: DockerController.resolveProjectLabel(project ?? undefined, knownSet, projectToStack),
|
||||
};
|
||||
@@ -1797,6 +1803,51 @@ class DockerController {
|
||||
return { containers, networks, volumes };
|
||||
}
|
||||
|
||||
/**
|
||||
* Classifies snapshot networks into base inventory rows (phase A). Uses only
|
||||
* the provided snapshot; no additional Docker API calls.
|
||||
*/
|
||||
public static classifySnapshotNetworks(
|
||||
snapshot: DependencySnapshot,
|
||||
_knownStackNames: string[],
|
||||
): NetworkingNetworkBase[] {
|
||||
const selfIdentity = SelfIdentityService.getInstance();
|
||||
const connectedByNetwork = new Map<string, number>();
|
||||
for (const c of snapshot.containers) {
|
||||
for (const attached of c.networks) {
|
||||
const key = attached.id || attached.name;
|
||||
connectedByNetwork.set(key, (connectedByNetwork.get(key) ?? 0) + 1);
|
||||
if (attached.name && attached.name !== key) {
|
||||
connectedByNetwork.set(attached.name, (connectedByNetwork.get(attached.name) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return snapshot.networks.map(net => ({
|
||||
id: net.id,
|
||||
name: net.name,
|
||||
driver: net.driver,
|
||||
scope: net.scope,
|
||||
isSystem: net.isSystem,
|
||||
ingress: net.ingress === true,
|
||||
enableIPv6: net.enableIPv6,
|
||||
composeProject: net.composeProject,
|
||||
stack: net.stack,
|
||||
connectedCount: connectedByNetwork.get(net.id) ?? connectedByNetwork.get(net.name) ?? 0,
|
||||
isSencho: selfIdentity.isOwnNetwork(net.id) || selfIdentity.isOwnNetwork(net.name),
|
||||
ownership: net.isSystem || net.ingress === true
|
||||
? 'system'
|
||||
: net.stack
|
||||
? 'sencho-managed'
|
||||
: net.composeProject
|
||||
? 'compose-managed'
|
||||
: 'unmanaged',
|
||||
declaredByStacks: [],
|
||||
declaredExternalByStacks: [],
|
||||
isExternalDependency: false,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Resolves a Docker Compose project label to a known Sencho stack name, or null. */
|
||||
private static resolveProjectLabel(
|
||||
project: string | undefined,
|
||||
|
||||
@@ -77,8 +77,12 @@ export function assembleStackNetworkFacts(
|
||||
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<StackNetworkFacts> {
|
||||
/** Render the effective model and assemble facts, optionally reusing a snapshot. */
|
||||
export async function buildStackNetworkFacts(
|
||||
nodeId: number,
|
||||
stackName: string,
|
||||
sharedSnapshot?: DependencySnapshot | null,
|
||||
): Promise<StackNetworkFacts> {
|
||||
const fsSvc = FileSystemService.getInstance(nodeId);
|
||||
|
||||
let model: EffectiveModel | null = null;
|
||||
@@ -89,34 +93,33 @@ export async function buildStackNetworkFacts(nodeId: number, stackName: string):
|
||||
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')));
|
||||
let snapshot: DependencySnapshot | null;
|
||||
if (sharedSnapshot !== undefined) {
|
||||
snapshot = sharedSnapshot;
|
||||
} else {
|
||||
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')));
|
||||
snapshot = null;
|
||||
}
|
||||
}
|
||||
|
||||
return assembleStackNetworkFacts(stackName, model, renderError, snapshot);
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
interface SemaphoreState {
|
||||
active: number;
|
||||
waiters: Array<() => void>;
|
||||
}
|
||||
|
||||
const MAX_ACTIVE_RENDERS = 4;
|
||||
const states = new Map<number, SemaphoreState>();
|
||||
|
||||
async function acquire(nodeId: number): Promise<() => void> {
|
||||
const state = states.get(nodeId) ?? { active: 0, waiters: [] };
|
||||
states.set(nodeId, state);
|
||||
|
||||
if (state.active >= MAX_ACTIVE_RENDERS) {
|
||||
await new Promise<void>(resolve => state.waiters.push(resolve));
|
||||
}
|
||||
state.active += 1;
|
||||
|
||||
return () => {
|
||||
state.active -= 1;
|
||||
const next = state.waiters.shift();
|
||||
if (next) next();
|
||||
if (state.active === 0 && state.waiters.length === 0) states.delete(nodeId);
|
||||
};
|
||||
}
|
||||
|
||||
export async function withComposeRenderSlot<T>(nodeId: number, work: () => Promise<T>): Promise<T> {
|
||||
const release = await acquire(nodeId);
|
||||
try {
|
||||
return await work();
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* Aggregates Compose Doctor's networking-relevant rules (cached runs ONLY, via
|
||||
* ComposeDoctorService.getLatest, never a fresh node-wide runPreflight/render)
|
||||
* into the unified Networking findings list. Doctor findings are either
|
||||
* structurally merged into a matching live finding (dedupe key match) or
|
||||
* surfaced as a standalone Doctor-only card. Cached data is never presented as
|
||||
* fresher than it is: Doctor-only cards carry their `ranAt` and are revalidated
|
||||
* against currently loaded facts where a structural check is cheap; otherwise
|
||||
* they are retained as cached rather than silently dropped or fabricated as
|
||||
* live.
|
||||
*/
|
||||
import { createHash } from 'crypto';
|
||||
import { ComposeDoctorService } from '../ComposeDoctorService';
|
||||
import type { PreflightFinding } from '../preflight/types';
|
||||
import type { DependencySnapshot } from '../DockerController';
|
||||
import type { StackNetworkFacts } from './types';
|
||||
import { getErrorMessage } from '../../utils/errors';
|
||||
import { sanitizeForLog } from '../../utils/safeLog';
|
||||
import { NETWORKING_SEVERITY_RANK } from './networkingSeverity';
|
||||
import type {
|
||||
DoctorFindingMetadata,
|
||||
NetworkingFinding,
|
||||
NetworkingFindingKind,
|
||||
NetworkingFindingSeverity,
|
||||
NetworkingRecommendedAction,
|
||||
} from './networkingTypes';
|
||||
|
||||
/** The 11 Compose Doctor rules with a networking-relevant interpretation. */
|
||||
export const DOCTOR_NETWORKING_RULE_KIND: Record<string, NetworkingFindingKind> = {
|
||||
'port-conflict-node': 'port-conflict-node',
|
||||
'port-conflict-internal': 'port-conflict-internal',
|
||||
'external-network-missing': 'external-network-missing',
|
||||
'port-exposed-all-interfaces': 'exposure-all-interfaces',
|
||||
'network-mode-host': 'network-mode-host',
|
||||
'exposure-internal-published': 'exposure-intent-mismatch',
|
||||
'sensitive-service-broad-exposure': 'sensitive-service-broad-exposure',
|
||||
'exposure-unclassified': 'exposure-unclassified',
|
||||
'exposure-port-vs-dossier': 'exposure-port-vs-dossier',
|
||||
'reverse-proxy-undocumented': 'reverse-proxy-undocumented',
|
||||
'new-network': 'new-network',
|
||||
};
|
||||
|
||||
export const DOCTOR_NETWORKING_RULE_IDS = Object.keys(DOCTOR_NETWORKING_RULE_KIND);
|
||||
|
||||
const DOCTOR_SEVERITY_MAP: Record<string, NetworkingFindingSeverity> = {
|
||||
blocker: 'critical',
|
||||
high: 'high',
|
||||
warning: 'medium',
|
||||
info: 'info',
|
||||
};
|
||||
|
||||
// Kinds the live engine can also produce; a dedupe-key match merges the Doctor
|
||||
// occurrence into the live card instead of creating a Doctor-only duplicate.
|
||||
const LIVE_OVERLAP_KINDS = new Set<NetworkingFindingKind>([
|
||||
'external-network-missing',
|
||||
'network-mode-host',
|
||||
'exposure-all-interfaces',
|
||||
'exposure-intent-mismatch',
|
||||
'exposure-unclassified',
|
||||
'network-missing', // new-network reconciles into this when the stack is running
|
||||
]);
|
||||
|
||||
function resolveNetworkName(sourcePath: string | undefined, facts: StackNetworkFacts | undefined): string | undefined {
|
||||
if (!sourcePath || !facts) return undefined;
|
||||
const key = sourcePath.replace(/^networks\./, '');
|
||||
return facts.networks.find((n) => n.key === key)?.name ?? key;
|
||||
}
|
||||
|
||||
/** Structural dedupe key (never derived from title/message). Returns null when
|
||||
* the finding has no live counterpart to merge into (a Doctor-only kind, or an
|
||||
* interpretable kind missing the structural fields needed to key it). */
|
||||
function liveDedupeKey(
|
||||
kind: NetworkingFindingKind,
|
||||
stack: string,
|
||||
f: PreflightFinding,
|
||||
facts: StackNetworkFacts | undefined,
|
||||
): { key: string; mergeKind: NetworkingFindingKind } | null {
|
||||
switch (kind) {
|
||||
case 'external-network-missing': {
|
||||
const network = resolveNetworkName(f.sourcePath, facts);
|
||||
return network ? { key: `${kind}\0${stack}\0${network}`, mergeKind: kind } : null;
|
||||
}
|
||||
case 'network-mode-host':
|
||||
case 'exposure-all-interfaces':
|
||||
case 'exposure-intent-mismatch':
|
||||
case 'exposure-unclassified':
|
||||
return f.service ? { key: `${kind}\0${stack}\0${f.service}`, mergeKind: kind } : null;
|
||||
case 'new-network': {
|
||||
const network = resolveNetworkName(f.sourcePath, facts);
|
||||
// Reconciles against the live "declared network missing at runtime" finding.
|
||||
return network ? { key: `network-missing\0${stack}\0${network}`, mergeKind: 'network-missing' } : null;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function toMetadata(ruleId: string, ranAt: number | null, f: PreflightFinding): DoctorFindingMetadata {
|
||||
return {
|
||||
ruleId,
|
||||
ranAt: ranAt ? new Date(ranAt).toISOString() : new Date(0).toISOString(),
|
||||
title: f.title,
|
||||
message: f.message,
|
||||
service: f.service,
|
||||
sourcePath: f.sourcePath,
|
||||
remediation: f.remediation,
|
||||
severity: DOCTOR_SEVERITY_MAP[f.severity] ?? 'info',
|
||||
};
|
||||
}
|
||||
|
||||
/** Rule-specific recommended actions for a Doctor-only finding card. Each rule
|
||||
* keeps its Doctor link and adds the destination most useful for that rule. */
|
||||
function doctorOnlyActions(kind: NetworkingFindingKind, stack: string): NetworkingRecommendedAction[] {
|
||||
const doctorAction: NetworkingRecommendedAction = { kind: 'open-stack-doctor', label: 'Open Doctor', stack };
|
||||
switch (kind) {
|
||||
case 'port-conflict-node':
|
||||
case 'port-conflict-internal':
|
||||
return [doctorAction, { kind: 'open-stack-editor', label: 'Open stack editor', stack }];
|
||||
case 'exposure-port-vs-dossier':
|
||||
case 'reverse-proxy-undocumented':
|
||||
return [doctorAction, { kind: 'open-stack-dossier', label: 'Open Dossier', stack }];
|
||||
default:
|
||||
return [doctorAction, { kind: 'open-stack-networking', label: 'Open stack networking', stack }];
|
||||
}
|
||||
}
|
||||
|
||||
/** Revalidates a Doctor occurrence against already-loaded facts, never an extra
|
||||
* render/snapshot call. Returns false when current data conclusively proves
|
||||
* the finding resolved (it should be discarded), true when it should be kept
|
||||
* (either confirmed current, or simply not cheaply checkable so kept cached). */
|
||||
function isStillPlausible(
|
||||
kind: NetworkingFindingKind,
|
||||
f: PreflightFinding,
|
||||
facts: StackNetworkFacts | undefined,
|
||||
snapshot: DependencySnapshot | null,
|
||||
): boolean {
|
||||
if (!facts?.renderable) return true; // can't check; keep cached
|
||||
switch (kind) {
|
||||
case 'network-mode-host':
|
||||
case 'exposure-all-interfaces':
|
||||
case 'exposure-intent-mismatch':
|
||||
case 'exposure-unclassified':
|
||||
case 'sensitive-service-broad-exposure':
|
||||
case 'port-conflict-node':
|
||||
case 'port-conflict-internal': {
|
||||
// These are all service-scoped: if the service no longer exists in the
|
||||
// current effective model, the finding is stale.
|
||||
if (!f.service) return true;
|
||||
return facts.services.some((svc) => svc.name === f.service);
|
||||
}
|
||||
case 'external-network-missing':
|
||||
case 'new-network': {
|
||||
const network = resolveNetworkName(f.sourcePath, facts);
|
||||
if (!network || !snapshot) return true;
|
||||
return !snapshot.networks.some((n) => n.name === network);
|
||||
}
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
interface DoctorAggregationOptions {
|
||||
nodeId: number;
|
||||
stackNames: string[];
|
||||
stackFacts: StackNetworkFacts[];
|
||||
snapshot: DependencySnapshot | null;
|
||||
}
|
||||
|
||||
/** Merges cached Doctor findings into the live findings list. Returns the full
|
||||
* unified list (live findings enriched in place, plus new Doctor-only cards
|
||||
* appended). Fail-soft per stack: a getLatest() failure is logged and skipped,
|
||||
* never fails the whole aggregate. */
|
||||
export function applyDoctorNetworkingFindings(
|
||||
live: NetworkingFinding[],
|
||||
options: DoctorAggregationOptions,
|
||||
): NetworkingFinding[] {
|
||||
const { nodeId, stackNames, stackFacts, snapshot } = options;
|
||||
const factsByStack = new Map(stackFacts.map((f) => [f.stack, f]));
|
||||
|
||||
// Index live findings by their own structural key for O(1) merge lookup.
|
||||
const liveByKey = new Map<string, NetworkingFinding>();
|
||||
for (const f of live) {
|
||||
if (!LIVE_OVERLAP_KINDS.has(f.kind)) continue;
|
||||
const keyParts = f.service
|
||||
? [f.kind, f.stack ?? '', f.service]
|
||||
: f.network
|
||||
? [f.kind, f.stack ?? '', f.network]
|
||||
: null;
|
||||
if (keyParts) liveByKey.set(keyParts.join('\0'), f);
|
||||
}
|
||||
|
||||
// Doctor-only groups: same (mergeKind, stack, service-or-network) collapse
|
||||
// into ONE card carrying every matched occurrence; distinct
|
||||
// services/networks/stacks always get distinct cards.
|
||||
const doctorOnlyGroups = new Map<string, { kind: NetworkingFindingKind; stack: string; service?: string; network?: string; entries: DoctorFindingMetadata[] }>();
|
||||
let ordinal = 0;
|
||||
|
||||
for (const stack of stackNames) {
|
||||
let report;
|
||||
try {
|
||||
report = ComposeDoctorService.getInstance().getLatest(nodeId, stack);
|
||||
} catch (error) {
|
||||
console.warn('[Networking] Doctor lookup failed for stack %s; skipped:',
|
||||
sanitizeForLog(stack), sanitizeForLog(getErrorMessage(error, 'unknown')));
|
||||
continue;
|
||||
}
|
||||
if (report.status === 'never-run' || report.findings.length === 0) continue;
|
||||
const facts = factsByStack.get(stack);
|
||||
|
||||
for (const f of report.findings) {
|
||||
if (f.acknowledged === true) continue;
|
||||
const kind = DOCTOR_NETWORKING_RULE_KIND[f.ruleId];
|
||||
if (!kind) continue;
|
||||
if (!isStillPlausible(kind, f, facts, snapshot)) continue;
|
||||
|
||||
const dedupe = liveDedupeKey(kind, stack, f, facts);
|
||||
const metadata = toMetadata(f.ruleId, report.ranAt, f);
|
||||
|
||||
if (dedupe) {
|
||||
const liveMatch = liveByKey.get(dedupe.key);
|
||||
if (liveMatch) {
|
||||
if (!liveMatch.sources.includes('doctor')) liveMatch.sources.push('doctor');
|
||||
liveMatch.doctorFindings.push(metadata);
|
||||
if (!liveMatch.recommendedActions.some((a) => a.kind === 'open-stack-doctor')) {
|
||||
liveMatch.recommendedActions.push({ kind: 'open-stack-doctor', label: 'Open Doctor', stack });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// new-network with no live network-missing counterpart: the network is
|
||||
// either present (already filtered by isStillPlausible) or the stack is
|
||||
// stopped/not-yet-deployed, so show the Doctor informational read
|
||||
// instead of a misleading live drift interpretation. Falls through to
|
||||
// the Doctor-only group below, keyed on the reconciled mergeKind so a
|
||||
// network-missing counterpart appearing later still collapses.
|
||||
}
|
||||
|
||||
const groupKey = dedupe
|
||||
? dedupe.key
|
||||
: f.service
|
||||
? `${kind}\0${stack}\0${f.service}`
|
||||
: `${kind}\0${stack}\0ordinal-${ordinal++}`;
|
||||
const group = doctorOnlyGroups.get(groupKey) ?? { kind, stack, service: f.service, entries: [] };
|
||||
group.entries.push(metadata);
|
||||
doctorOnlyGroups.set(groupKey, group);
|
||||
}
|
||||
}
|
||||
|
||||
const doctorOnlyFindings: NetworkingFinding[] = [];
|
||||
for (const group of doctorOnlyGroups.values()) {
|
||||
const worstSeverity = group.entries.reduce<NetworkingFindingSeverity>(
|
||||
(worst, entry) => (NETWORKING_SEVERITY_RANK[entry.severity] > NETWORKING_SEVERITY_RANK[worst] ? entry.severity : worst),
|
||||
'info',
|
||||
);
|
||||
const idPayload = [group.kind, group.stack, group.service ?? '', group.entries.map((e) => e.ruleId).join(',')].join('\0');
|
||||
doctorOnlyFindings.push({
|
||||
id: createHash('sha256').update(idPayload).digest('hex').slice(0, 16),
|
||||
kind: group.kind,
|
||||
severity: worstSeverity,
|
||||
title: group.entries[0].title,
|
||||
message: group.entries[0].message,
|
||||
stack: group.stack,
|
||||
service: group.service,
|
||||
evidence: [
|
||||
{ label: 'Stack', value: group.stack },
|
||||
...(group.service ? [{ label: 'Service', value: group.service }] : []),
|
||||
],
|
||||
recommendedActions: doctorOnlyActions(group.kind, group.stack),
|
||||
sources: ['doctor'],
|
||||
doctorFindings: group.entries,
|
||||
});
|
||||
}
|
||||
|
||||
return [...live, ...doctorOnlyFindings];
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Shared exposure-context reader consumed by BOTH ComposeDoctorService (preflight
|
||||
* exposure rules) and the live networking findings engine, so their severities can
|
||||
* never diverge. Fail-soft via a discriminated result: a DB read failure is distinct
|
||||
* from a genuinely unset intent, so callers must not treat `available: false` as
|
||||
* "no intent" (that would fabricate false unclassified/mismatch findings).
|
||||
*/
|
||||
import { DatabaseService } from '../DatabaseService';
|
||||
import { parseAccessUrlPorts } from './normalize';
|
||||
import type { ExposureIntent } from './types';
|
||||
import { getErrorMessage } from '../../utils/errors';
|
||||
import { sanitizeForLog } from '../../utils/safeLog';
|
||||
|
||||
export type ExposureContext =
|
||||
| { available: false }
|
||||
| {
|
||||
available: true;
|
||||
stackIntent: ExposureIntent | null;
|
||||
serviceIntents: Record<string, ExposureIntent>;
|
||||
accessUrlPorts: Set<number>;
|
||||
hasAccessUrls: boolean;
|
||||
};
|
||||
|
||||
export function getExposureContext(nodeId: number, stackName: string): ExposureContext {
|
||||
try {
|
||||
const db = DatabaseService.getInstance();
|
||||
const rows = db.getStackExposureIntents(nodeId, stackName);
|
||||
const stackIntent = rows.find((r) => r.service === '')?.intent ?? null;
|
||||
const serviceIntents: Record<string, ExposureIntent> = {};
|
||||
for (const r of rows) if (r.service !== '') serviceIntents[r.service] = r.intent;
|
||||
|
||||
const accessUrls = db.getStackDossier(nodeId, stackName)?.access_urls ?? '';
|
||||
return {
|
||||
available: true,
|
||||
stackIntent,
|
||||
serviceIntents,
|
||||
accessUrlPorts: parseAccessUrlPorts(accessUrls),
|
||||
hasAccessUrls: accessUrls.trim().length > 0,
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn('[Networking] Exposure context unavailable for %s; exposure interpretation skipped:',
|
||||
sanitizeForLog(stackName), sanitizeForLog(getErrorMessage(error, 'unknown')));
|
||||
return { available: false };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Fail-closed guards for admin network delete on /api/system/networks/delete.
|
||||
*/
|
||||
import SelfIdentityService from '../SelfIdentityService';
|
||||
import type { DependencySnapshot } from '../DockerController';
|
||||
import type { StackNetworkFacts } from './types';
|
||||
import type { NetworkingNetworkBase } from './networkingTypes';
|
||||
|
||||
export type NetworkDeleteBlockCode =
|
||||
| 'system-network'
|
||||
| 'sencho-owned'
|
||||
| 'attached'
|
||||
| 'stack-declared'
|
||||
| 'stack-declaration-unknown';
|
||||
|
||||
export interface NetworkDeleteGuardResult {
|
||||
blocked: boolean;
|
||||
code?: NetworkDeleteBlockCode;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function evaluateNetworkDeleteGuard(
|
||||
networkId: string,
|
||||
snapshot: DependencySnapshot,
|
||||
stackFacts: StackNetworkFacts[],
|
||||
baseRow?: NetworkingNetworkBase,
|
||||
): NetworkDeleteGuardResult {
|
||||
const net = baseRow
|
||||
?? snapshot.networks.find(n => n.id === networkId || n.name === networkId);
|
||||
|
||||
if (!net) {
|
||||
return { blocked: false };
|
||||
}
|
||||
|
||||
if (net.isSystem) {
|
||||
return { blocked: true, code: 'system-network', error: 'System networks cannot be deleted.' };
|
||||
}
|
||||
|
||||
const self = SelfIdentityService.getInstance();
|
||||
if (self.isOwnNetwork(net.id) || self.isOwnNetwork(net.name)) {
|
||||
return { blocked: true, code: 'sencho-owned', error: 'Sencho-managed networks cannot be deleted.' };
|
||||
}
|
||||
|
||||
const connected = snapshot.containers.some(c =>
|
||||
c.networks.some(a => a.id === net.id || a.name === net.name),
|
||||
);
|
||||
if (connected) {
|
||||
return { blocked: true, code: 'attached', error: 'Detach all containers before deleting this network.' };
|
||||
}
|
||||
|
||||
const hasUnrenderable = stackFacts.some(f => !f.renderable);
|
||||
const declaredNames = new Set<string>();
|
||||
for (const facts of stackFacts) {
|
||||
if (!facts.renderable) continue;
|
||||
for (const n of facts.networks) declaredNames.add(n.name);
|
||||
}
|
||||
|
||||
if (declaredNames.has(net.name)) {
|
||||
return { blocked: true, code: 'stack-declared', error: 'This network is declared by a Compose stack.' };
|
||||
}
|
||||
|
||||
if (hasUnrenderable && (net.composeProject || net.stack)) {
|
||||
return {
|
||||
blocked: true,
|
||||
code: 'stack-declaration-unknown',
|
||||
error: 'Cannot verify stack declarations while one or more stacks failed to render.',
|
||||
};
|
||||
}
|
||||
|
||||
return { blocked: false };
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Shared node networking aggregate: one Docker snapshot per request, effective-model
|
||||
* stack facts, findings, enriched inventory, and optional topology.
|
||||
*/
|
||||
import DockerController from '../DockerController';
|
||||
import { FileSystemService } from '../FileSystemService';
|
||||
import { DatabaseService } from '../DatabaseService';
|
||||
import { buildStackNetworkFacts } from './composeNetworkInspector';
|
||||
import { buildNodeNetworkingFindings } from './networkingFindings';
|
||||
import { applyDoctorNetworkingFindings } from './doctorNetworkingFindings';
|
||||
import { enrichNetworkRows } from './networkingInventory';
|
||||
import { buildNetworkingTopology } from './networkingTopology';
|
||||
import { rankFindings } from './networkingSeverity';
|
||||
import { isHostNetwork, isLoopback } from './normalize';
|
||||
import type { ExposureIntent } from './types';
|
||||
import type { DependencySnapshot } from '../DockerController';
|
||||
import type { NodeNetworkingAggregate, NodeNetworkingOverview } from './networkingTypes';
|
||||
import { getErrorMessage } from '../../utils/errors';
|
||||
import { sanitizeForLog } from '../../utils/safeLog';
|
||||
import { mapWithConcurrency } from '../../utils/mapWithConcurrency';
|
||||
import { withComposeRenderSlot } from './composeRenderSemaphore';
|
||||
|
||||
export async function buildNodeNetworkingAggregate(
|
||||
nodeId: number,
|
||||
options: { includeTopology?: boolean; includeSystem?: boolean },
|
||||
): Promise<NodeNetworkingAggregate> {
|
||||
const fsSvc = FileSystemService.getInstance(nodeId);
|
||||
const stacks = await fsSvc.getStacks();
|
||||
|
||||
let snapshot: DependencySnapshot | null = null;
|
||||
try {
|
||||
snapshot = await DockerController.getInstance(nodeId).getDependencySnapshot(stacks);
|
||||
} catch (error) {
|
||||
console.warn('[Networking] Snapshot failed on node %d:', nodeId, sanitizeForLog(getErrorMessage(error, 'unknown')));
|
||||
}
|
||||
const baseRows = snapshot ? DockerController.classifySnapshotNetworks(snapshot, stacks) : [];
|
||||
|
||||
const stackFacts = await mapWithConcurrency(stacks, 4, stack => withComposeRenderSlot(
|
||||
nodeId,
|
||||
() => buildStackNetworkFacts(nodeId, stack, snapshot),
|
||||
));
|
||||
|
||||
// Pipeline order matters: live findings, then cached Doctor adaptation
|
||||
// and merge, BEFORE inventory enrichment / overview / topology all consume
|
||||
// the same unified list, so counts and findingIds never disagree.
|
||||
const liveFindings = buildNodeNetworkingFindings(nodeId, snapshot, stackFacts, baseRows);
|
||||
const findings = rankFindings(applyDoctorNetworkingFindings(liveFindings, {
|
||||
nodeId, stackNames: stacks, stackFacts, snapshot,
|
||||
}));
|
||||
const networks = snapshot ? enrichNetworkRows(nodeId, baseRows, stackFacts, snapshot, findings) : [];
|
||||
const overview = buildOverview(nodeId, stacks, snapshot, stackFacts, findings, networks);
|
||||
|
||||
const aggregate: NodeNetworkingAggregate = {
|
||||
overview,
|
||||
networks,
|
||||
findings,
|
||||
stackFacts,
|
||||
runtimeAvailable: snapshot !== null,
|
||||
recentActivity: DatabaseService.getInstance().getNodeStackActivity(nodeId, { limit: 20 }),
|
||||
};
|
||||
|
||||
if (options.includeTopology) {
|
||||
aggregate.topology = buildNetworkingTopology(
|
||||
nodeId,
|
||||
snapshot,
|
||||
stackFacts,
|
||||
findings,
|
||||
options.includeSystem === true,
|
||||
);
|
||||
}
|
||||
|
||||
return aggregate;
|
||||
}
|
||||
|
||||
function buildOverview(
|
||||
nodeId: number,
|
||||
stacks: string[],
|
||||
snapshot: DependencySnapshot | null,
|
||||
stackFacts: Awaited<ReturnType<typeof buildStackNetworkFacts>>[],
|
||||
findings: ReturnType<typeof buildNodeNetworkingFindings>,
|
||||
networks: import('./networkingTypes').NetworkingNetworkRow[],
|
||||
): NodeNetworkingOverview {
|
||||
const db = DatabaseService.getInstance();
|
||||
const renderFailedStacks = stackFacts.filter(f => !f.renderable).map(f => f.stack);
|
||||
|
||||
let exposedStackCount = 0;
|
||||
let unknownExposureStackCount = 0;
|
||||
let missingExternalCount = 0;
|
||||
|
||||
for (const facts of stackFacts) {
|
||||
if (!facts.renderable) continue;
|
||||
|
||||
const intents = db.getStackExposureIntents(nodeId, facts.stack);
|
||||
const stackIntent = intents.find((intent) => intent.service === '')?.intent ?? null;
|
||||
const byService = new Map(
|
||||
intents.filter((intent) => intent.service !== '').map((intent) => [intent.service, intent.intent]),
|
||||
);
|
||||
|
||||
const publishes = (service: (typeof facts.services)[number]): boolean =>
|
||||
service.publishedPorts.length > 0 || isHostNetwork(service.networkMode);
|
||||
|
||||
if (facts.services.some((service) =>
|
||||
isHostNetwork(service.networkMode) || service.publishedPorts.some((port) => !isLoopback(port.hostIp)),
|
||||
)) {
|
||||
exposedStackCount += 1;
|
||||
}
|
||||
|
||||
const hasUnclassifiedPublish = facts.services.some((service) => {
|
||||
if (!publishes(service)) return false;
|
||||
const intent: ExposureIntent | null = byService.get(service.name) ?? stackIntent;
|
||||
return intent === null || intent === 'unknown';
|
||||
});
|
||||
if (hasUnclassifiedPublish) unknownExposureStackCount += 1;
|
||||
|
||||
missingExternalCount += facts.networks.filter((network) =>
|
||||
network.external && facts.drift.missingFromRuntime.includes(network.name),
|
||||
).length;
|
||||
}
|
||||
|
||||
const connectedContainerCount = snapshot
|
||||
? snapshot.containers.filter((container) => container.networks.length > 0).length
|
||||
: null;
|
||||
|
||||
return {
|
||||
runtimeAvailable: snapshot !== null,
|
||||
networkCount: snapshot?.networks.length ?? null,
|
||||
stackCount: stacks.length,
|
||||
connectedContainerCount,
|
||||
systemNetworkCount: snapshot?.networks.filter(n => n.isSystem).length ?? null,
|
||||
senchoManagedNetworkCount: snapshot ? networks.filter((r) => r.ownership === 'sencho-managed').length : null,
|
||||
composeManagedNetworkCount: snapshot ? networks.filter((r) => r.ownership === 'compose-managed').length : null,
|
||||
unmanagedNetworkCount: snapshot ? networks.filter((r) => r.ownership === 'unmanaged').length : null,
|
||||
externalDependencyNetworkCount: snapshot ? networks.filter((r) => r.isExternalDependency).length : null,
|
||||
exposedStackCount,
|
||||
unknownExposureStackCount,
|
||||
missingExternalCount,
|
||||
// Alias/service DNS collisions plus genuine (non-intentional-sharing) name
|
||||
// collisions all count toward the overview number.
|
||||
networkCollisionCount: findings.filter(f =>
|
||||
f.kind === 'network-name-collision' || f.kind === 'alias-collision' || f.kind === 'service-name-collision',
|
||||
).length,
|
||||
findingCount: findings.length,
|
||||
renderFailedStacks,
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadNetworkingSnapshot(nodeId: number): Promise<{
|
||||
stacks: string[];
|
||||
snapshot: DependencySnapshot | null;
|
||||
}> {
|
||||
const stacks = await FileSystemService.getInstance(nodeId).getStacks();
|
||||
try {
|
||||
const snapshot = await DockerController.getInstance(nodeId).getDependencySnapshot(stacks);
|
||||
return { stacks, snapshot };
|
||||
} catch (error) {
|
||||
console.error('[Networking] Snapshot failed on node %d:', nodeId, sanitizeForLog(getErrorMessage(error, 'unknown')));
|
||||
return { stacks, snapshot: null };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { NETWORKING_SCHEMA_VERSION, type NetworkingEnvelope } from './networkingTypes';
|
||||
|
||||
export function okEnvelope<T extends object>(
|
||||
runtimeAvailable: boolean,
|
||||
fields: T,
|
||||
): NetworkingEnvelope & T {
|
||||
return {
|
||||
schemaVersion: NETWORKING_SCHEMA_VERSION,
|
||||
runtimeAvailable,
|
||||
generatedAt: new Date().toISOString(),
|
||||
...fields,
|
||||
};
|
||||
}
|
||||
|
||||
export function runtimeUnavailableEnvelope() {
|
||||
return {
|
||||
schemaVersion: NETWORKING_SCHEMA_VERSION,
|
||||
runtimeAvailable: false,
|
||||
generatedAt: new Date().toISOString(),
|
||||
error: 'Docker networking runtime is unavailable',
|
||||
code: 'runtime-unavailable' as const,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
/**
|
||||
* Node-scoped networking findings derived from effective-model stack facts,
|
||||
* exposure intents, and the shared dependency snapshot.
|
||||
*/
|
||||
import { createHash } from 'crypto';
|
||||
import type { DependencySnapshot } from '../DockerController';
|
||||
import type { ExposureIntent, StackNetworkFacts } from './types';
|
||||
import { isHostNetwork } from './normalize';
|
||||
import { getExposureContext, type ExposureContext } from './exposureContext';
|
||||
import type { NetworkingFinding, NetworkingFindingKind, NetworkingNetworkBase, NetworkingRecommendedAction } from './networkingTypes';
|
||||
|
||||
type FindingExtra = Pick<NetworkingFinding, 'stack' | 'network' | 'service'>;
|
||||
|
||||
function finding(
|
||||
kind: NetworkingFindingKind,
|
||||
severity: NetworkingFinding['severity'],
|
||||
title: string,
|
||||
message: string,
|
||||
extra: FindingExtra = {},
|
||||
recommendedActions: NetworkingRecommendedAction[] = [],
|
||||
): NetworkingFinding {
|
||||
const idPayload = [kind, message, extra.stack ?? '', extra.network ?? '', extra.service ?? ''].join('\0');
|
||||
return {
|
||||
id: createHash('sha256').update(idPayload).digest('hex').slice(0, 16),
|
||||
kind,
|
||||
severity,
|
||||
title,
|
||||
message,
|
||||
...extra,
|
||||
evidence: [
|
||||
...(extra.stack ? [{ label: 'Stack', value: extra.stack }] : []),
|
||||
...(extra.service ? [{ label: 'Service', value: extra.service }] : []),
|
||||
...(extra.network ? [{ label: 'Network', value: extra.network }] : []),
|
||||
],
|
||||
recommendedActions,
|
||||
sources: ['live'],
|
||||
doctorFindings: [],
|
||||
};
|
||||
}
|
||||
|
||||
function stackActions(stack: string): NetworkingRecommendedAction[] {
|
||||
return [{ kind: 'open-stack-networking', label: 'Open stack networking', stack }];
|
||||
}
|
||||
|
||||
function effectiveIntent(service: string, stackIntent: ExposureIntent | null, byService: Record<string, ExposureIntent>): ExposureIntent | null {
|
||||
return byService[service] ?? stackIntent ?? null;
|
||||
}
|
||||
|
||||
/** Host-mode severity matrix, parameterized by documented Dossier access.
|
||||
* Internal/same-node are a contradiction regardless of documentation: high. Unset
|
||||
* and unknown are unclassified exposure under host networking (no network
|
||||
* isolation): high.
|
||||
* Everything else (lan/public/reverse-proxy/temporary) is medium undocumented,
|
||||
* info when a Dossier access URL documents the exposure. */
|
||||
export function hostModeSeverity(intent: ExposureIntent | null, hasAccessUrls: boolean): NetworkingFinding['severity'] {
|
||||
if (intent === 'internal' || intent === 'same-node') return 'high';
|
||||
if (intent === null || intent === 'unknown') return 'high';
|
||||
return hasAccessUrls ? 'info' : 'medium';
|
||||
}
|
||||
|
||||
function addExposureFindings(
|
||||
out: NetworkingFinding[],
|
||||
facts: StackNetworkFacts,
|
||||
context: ExposureContext,
|
||||
): void {
|
||||
const stackIntent = context.available ? context.stackIntent : null;
|
||||
const byService = context.available ? context.serviceIntents : {};
|
||||
const hasAccessUrls = context.available && context.hasAccessUrls;
|
||||
|
||||
for (const service of facts.services) {
|
||||
const hostMode = isHostNetwork(service.networkMode);
|
||||
const publishes = hostMode || service.publishedPorts.length > 0;
|
||||
if (!publishes) continue;
|
||||
const intent = effectiveIntent(service.name, stackIntent, byService);
|
||||
const intentAction: NetworkingRecommendedAction = {
|
||||
kind: 'set-exposure-intent', label: 'Set exposure intent', stack: facts.stack, service: service.name,
|
||||
};
|
||||
if (hostMode) {
|
||||
// Structural fact; stays visible even when exposure context is unavailable,
|
||||
// at the neutral fallback severity (no intent/Dossier interpretation).
|
||||
out.push(finding(
|
||||
'network-mode-host',
|
||||
context.available ? hostModeSeverity(intent, hasAccessUrls) : 'medium',
|
||||
'Host network mode',
|
||||
`Service "${service.name}" in stack "${facts.stack}" uses network_mode: host.`,
|
||||
{ stack: facts.stack, service: service.name },
|
||||
[intentAction, ...stackActions(facts.stack)],
|
||||
));
|
||||
continue;
|
||||
}
|
||||
const broad = service.publishedPorts.some(port => port.allInterfaces);
|
||||
if (!context.available) {
|
||||
// Structural all-interface bind fact stays visible at fallback severity;
|
||||
// intent-dependent interpretation (unclassified/mismatch) is skipped.
|
||||
if (broad) {
|
||||
out.push(finding(
|
||||
'exposure-all-interfaces',
|
||||
'medium',
|
||||
'Port bound on all interfaces',
|
||||
`Service "${service.name}" in stack "${facts.stack}" publishes ports on all interfaces.`,
|
||||
{ stack: facts.stack, service: service.name },
|
||||
[intentAction],
|
||||
));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (intent === null || intent === 'unknown') {
|
||||
out.push(finding(
|
||||
'exposure-unclassified',
|
||||
'info',
|
||||
'Publishing without exposure intent',
|
||||
`Stack "${facts.stack}" publishes ports from "${service.name}" without a classified exposure intent.`,
|
||||
{ stack: facts.stack, service: service.name },
|
||||
[intentAction],
|
||||
));
|
||||
}
|
||||
if ((intent === 'internal' || intent === 'same-node') && service.publishedPorts.some(port => !port.loopbackOnly)) {
|
||||
out.push(finding(
|
||||
'exposure-intent-mismatch',
|
||||
'high',
|
||||
intent === 'internal' ? 'Internal intent with host publish' : 'Same-node intent with host publish',
|
||||
`Service "${service.name}" is classified ${intent} but publishes ports to the host.`,
|
||||
{ stack: facts.stack, service: service.name },
|
||||
[intentAction],
|
||||
));
|
||||
} else if (intent === 'temporary' && broad) {
|
||||
out.push(finding(
|
||||
'exposure-intent-mismatch',
|
||||
'medium',
|
||||
'Temporary exposure is broadly bound',
|
||||
`Service "${service.name}" is marked temporary and publishes on all interfaces.`,
|
||||
{ stack: facts.stack, service: service.name },
|
||||
[intentAction],
|
||||
));
|
||||
} else if (broad && intent !== 'public' && intent !== 'reverse-proxy') {
|
||||
out.push(finding(
|
||||
'exposure-all-interfaces',
|
||||
'medium',
|
||||
'Port bound on all interfaces',
|
||||
`Service "${service.name}" in stack "${facts.stack}" publishes ports on all interfaces.`,
|
||||
{ stack: facts.stack, service: service.name },
|
||||
[intentAction],
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function addCrossStackDnsFindings(out: NetworkingFinding[], stackFacts: StackNetworkFacts[], baseNetworks: NetworkingNetworkBase[]): void {
|
||||
const runtimeNetworks = new Map(baseNetworks.map(network => [network.name, network]));
|
||||
const entriesByNetwork = new Map<string, Array<{ stack: string; service: string; names: string[] }>>();
|
||||
for (const facts of stackFacts.filter(facts => facts.renderable)) {
|
||||
for (const service of facts.services) {
|
||||
for (const membership of service.networks) {
|
||||
const declared = facts.networks.find(network => network.key === membership.key);
|
||||
const networkName = declared?.name ?? membership.key;
|
||||
const network = runtimeNetworks.get(networkName);
|
||||
if (network?.isSystem || network?.ingress) continue;
|
||||
const shared = declared?.external === true || (network?.connectedCount ?? 0) > 1;
|
||||
if (!shared) continue;
|
||||
const entries = entriesByNetwork.get(networkName) ?? [];
|
||||
entries.push({ stack: facts.stack, service: service.name, names: [service.name, ...membership.aliases] });
|
||||
entriesByNetwork.set(networkName, entries);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [networkName, entries] of entriesByNetwork) {
|
||||
const names = new Map<string, Array<{ stack: string; service: string; isAlias: boolean }>>();
|
||||
for (const entry of entries) {
|
||||
entry.names.forEach((name, index) => {
|
||||
const values = names.get(name) ?? [];
|
||||
values.push({ stack: entry.stack, service: entry.service, isAlias: index > 0 });
|
||||
names.set(name, values);
|
||||
});
|
||||
}
|
||||
for (const [name, owners] of names) {
|
||||
const distinct = new Set(owners.map(owner => `${owner.stack}\0${owner.service}`));
|
||||
if (distinct.size < 2) continue;
|
||||
const allServices = owners.every(owner => !owner.isAlias);
|
||||
out.push(finding(
|
||||
allServices ? 'service-name-collision' : 'alias-collision',
|
||||
allServices ? 'medium' : 'high',
|
||||
allServices ? 'Duplicate service name on shared network' : 'Duplicate DNS name on shared network',
|
||||
`Name "${name}" resolves to multiple services on shared network "${networkName}".`,
|
||||
{ network: networkName },
|
||||
[{ kind: 'filter-topology', label: 'View network topology', networkName }],
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function addComposeDriftFindings(
|
||||
out: NetworkingFinding[],
|
||||
facts: StackNetworkFacts,
|
||||
baseNetworks: NetworkingNetworkBase[],
|
||||
): void {
|
||||
const networkIds = new Map(baseNetworks.map((network) => [network.name, network.id]));
|
||||
for (const network of facts.networks) {
|
||||
if (!network.external && network.createdByStack && facts.drift.missingFromRuntime.includes(network.name)) {
|
||||
out.push(finding(
|
||||
'network-missing',
|
||||
'high',
|
||||
'Declared network missing',
|
||||
`Stack "${facts.stack}" declares network "${network.name}" but it does not exist in the runtime.`,
|
||||
{ stack: facts.stack, network: network.name },
|
||||
[...stackActions(facts.stack), { kind: 'copy-docker-command', label: 'Copy Docker command', commandKind: 'network-create', networkName: network.name }],
|
||||
));
|
||||
}
|
||||
if (!network.external && network.createdByStack && facts.drift.declaredButUnused.includes(network.key)) {
|
||||
out.push(finding(
|
||||
'declared-network-unused',
|
||||
'medium',
|
||||
'Declared network unused',
|
||||
`Stack "${facts.stack}" declares network "${network.name}" but no running service is attached.`,
|
||||
{ stack: facts.stack, network: network.name },
|
||||
stackActions(facts.stack),
|
||||
));
|
||||
}
|
||||
}
|
||||
for (const attachment of facts.drift.runtimeOnlyAttachments) {
|
||||
out.push(finding(
|
||||
'network-undeclared',
|
||||
'high',
|
||||
'Undeclared network attachment',
|
||||
`Container "${attachment.container}" (${attachment.service ?? 'unknown service'}) is attached to undeclared network "${attachment.network}".`,
|
||||
{ stack: facts.stack, network: attachment.network, service: attachment.service ?? undefined },
|
||||
[...stackActions(facts.stack), ...(networkIds.has(attachment.network) ? [{ kind: 'inspect-network', label: 'Inspect network', networkId: networkIds.get(attachment.network)! } satisfies NetworkingRecommendedAction] : [])],
|
||||
));
|
||||
}
|
||||
for (const attachment of facts.drift.foreignNetworkAttachments) {
|
||||
out.push(finding(
|
||||
'foreign-network-attachment',
|
||||
'high',
|
||||
'Foreign network attachment',
|
||||
`Container "${attachment.container}" in stack "${facts.stack}" is attached to network "${attachment.network}" owned elsewhere.`,
|
||||
{ stack: facts.stack, network: attachment.network },
|
||||
[...stackActions(facts.stack), ...(networkIds.has(attachment.network) ? [{ kind: 'inspect-network', label: 'Inspect network', networkId: networkIds.get(attachment.network)! } satisfies NetworkingRecommendedAction] : [])],
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
function addSharedNetworkFindings(
|
||||
out: NetworkingFinding[],
|
||||
snapshot: DependencySnapshot,
|
||||
stackFacts: StackNetworkFacts[],
|
||||
): void {
|
||||
const stacksByNetwork = new Map<string, Set<string>>();
|
||||
for (const container of snapshot.containers) {
|
||||
if (!container.stack) continue;
|
||||
for (const attached of container.networks) {
|
||||
const stacks = stacksByNetwork.get(attached.name) ?? new Set<string>();
|
||||
stacks.add(container.stack);
|
||||
stacksByNetwork.set(attached.name, stacks);
|
||||
}
|
||||
}
|
||||
for (const [networkName, stacks] of stacksByNetwork) {
|
||||
if (stacks.size < 2) continue;
|
||||
out.push(finding(
|
||||
'shared-network',
|
||||
'info',
|
||||
'Shared network',
|
||||
`Network "${networkName}" connects containers from ${stacks.size} stacks: ${[...stacks].sort().join(', ')}.`,
|
||||
{ network: networkName },
|
||||
[{ kind: 'filter-topology', label: 'View network topology', networkName }],
|
||||
));
|
||||
}
|
||||
|
||||
// Only genuinely unsafe name collisions are worth a warning: two or more stacks
|
||||
// declaring a NON-external network that happens to resolve to the same literal
|
||||
// name (a forced `name:` override colliding by accident). Two or more stacks
|
||||
// that all declare the SAME network as `external: true` is the ordinary
|
||||
// shared-external-network pattern, already covered above as an info-level
|
||||
// "shared network" finding, not a collision.
|
||||
const declaredNames = new Map<string, { stack: string; external: boolean }[]>();
|
||||
for (const facts of stackFacts) {
|
||||
if (!facts.renderable) continue;
|
||||
for (const network of facts.networks) {
|
||||
const entries = declaredNames.get(network.name) ?? [];
|
||||
entries.push({ stack: facts.stack, external: network.external === true });
|
||||
declaredNames.set(network.name, entries);
|
||||
}
|
||||
}
|
||||
for (const [networkName, entries] of declaredNames) {
|
||||
const uniqueStacks = [...new Set(entries.map((e) => e.stack))];
|
||||
if (uniqueStacks.length < 2) continue;
|
||||
const allExternal = entries.every((e) => e.external);
|
||||
if (allExternal) continue;
|
||||
out.push(finding(
|
||||
'network-name-collision',
|
||||
'medium',
|
||||
'Network name collision',
|
||||
`Multiple stacks resolve to the same network name "${networkName}": ${uniqueStacks.join(', ')}.`,
|
||||
{ network: networkName },
|
||||
[{ kind: 'filter-topology', label: 'View network topology', networkName }],
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
function largeFlatSeverity(connectedCount: number): NetworkingFinding['severity'] {
|
||||
if (connectedCount >= 1000) return 'high';
|
||||
if (connectedCount >= 500) return 'medium';
|
||||
return 'info';
|
||||
}
|
||||
|
||||
export function buildNodeNetworkingFindings(
|
||||
nodeId: number,
|
||||
snapshot: DependencySnapshot | null,
|
||||
stackFacts: StackNetworkFacts[],
|
||||
baseNetworks: NetworkingNetworkBase[],
|
||||
): NetworkingFinding[] {
|
||||
const out: NetworkingFinding[] = [];
|
||||
if (!snapshot) {
|
||||
out.push(finding(
|
||||
'runtime-unavailable', 'info', 'Runtime networking unavailable',
|
||||
'Sencho could not read Docker networking state on this node.',
|
||||
{},
|
||||
[
|
||||
{ kind: 'refresh', label: 'Refresh runtime data' },
|
||||
{ kind: 'open-docs', label: 'Open troubleshooting guide', docsPath: '/operations/troubleshooting' },
|
||||
],
|
||||
));
|
||||
}
|
||||
|
||||
for (const facts of stackFacts) {
|
||||
if (!facts.renderable) continue;
|
||||
const context = getExposureContext(nodeId, facts.stack);
|
||||
addExposureFindings(out, facts, context);
|
||||
|
||||
if (!snapshot) continue;
|
||||
addComposeDriftFindings(out, facts, baseNetworks);
|
||||
for (const network of facts.networks) {
|
||||
if (network.external && facts.drift.missingFromRuntime.includes(network.name)) {
|
||||
const isRunning = snapshot.containers.some(container => container.stack === facts.stack && ['running', 'restarting'].includes(container.state));
|
||||
out.push(finding(
|
||||
'external-network-missing', isRunning ? 'critical' : 'high', 'External network not found',
|
||||
`Stack "${facts.stack}" requires the external network "${network.name}", which is not present on this node.`,
|
||||
{ stack: facts.stack, network: network.name },
|
||||
[
|
||||
{ kind: 'create-network', label: 'Create network', networkName: network.name, requiresAdmin: true },
|
||||
{ kind: 'copy-compose-snippet', label: 'Copy Compose snippet', snippetKind: 'external-network', networkName: network.name },
|
||||
{ kind: 'copy-docker-command', label: 'Copy Docker command', commandKind: 'network-create', networkName: network.name },
|
||||
{ kind: 'open-stack-editor', label: 'Open stack editor', stack: facts.stack },
|
||||
],
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!snapshot) return out;
|
||||
addSharedNetworkFindings(out, snapshot, stackFacts);
|
||||
addCrossStackDnsFindings(out, stackFacts, baseNetworks);
|
||||
for (const network of baseNetworks) {
|
||||
if (network.isSystem || network.ingress || ['host', 'none'].includes(network.driver)) continue;
|
||||
if (network.connectedCount >= 100) {
|
||||
out.push(finding(
|
||||
'large-flat-network', largeFlatSeverity(network.connectedCount), 'Large flat network',
|
||||
`Network "${network.name}" has ${network.connectedCount} connected endpoints.`,
|
||||
{ network: network.name },
|
||||
[{ kind: 'filter-topology', label: 'View network topology', networkName: network.name }],
|
||||
));
|
||||
}
|
||||
if (['overlay', 'macvlan', 'ipvlan'].includes(network.driver) || network.enableIPv6) {
|
||||
out.push(finding(
|
||||
'advanced-driver-caveat', 'info', 'Advanced network configuration',
|
||||
`Network "${network.name}" uses ${network.enableIPv6 ? 'IPv6 or ' : ''}${network.driver} networking.`,
|
||||
{ network: network.name },
|
||||
[{ kind: 'inspect-network', label: 'Inspect network', networkId: network.id }],
|
||||
));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Phase B enrichment for network inventory rows.
|
||||
*/
|
||||
import type { DependencySnapshot } from '../DockerController';
|
||||
import { DatabaseService } from '../DatabaseService';
|
||||
import type { StackNetworkFacts } from './types';
|
||||
import { isHostNetwork } from './normalize';
|
||||
import type {
|
||||
NetworkingExposureSummary,
|
||||
NetworkingFinding,
|
||||
NetworkingNetworkBase,
|
||||
NetworkingNetworkRow,
|
||||
} from './networkingTypes';
|
||||
|
||||
/** Stacks whose effective model declares a network (optionally external-only). */
|
||||
export function stacksDeclaringNetwork(
|
||||
stackFacts: StackNetworkFacts[],
|
||||
networkName: string,
|
||||
externalOnly = false,
|
||||
): string[] {
|
||||
return stackFacts
|
||||
.filter((facts) =>
|
||||
facts.renderable
|
||||
&& facts.networks.some((network) =>
|
||||
network.name === networkName && (!externalOnly || network.external),
|
||||
),
|
||||
)
|
||||
.map((facts) => facts.stack);
|
||||
}
|
||||
|
||||
export function indexFindingIdsByNetwork(findings: NetworkingFinding[]): Map<string, string[]> {
|
||||
const byNetwork = new Map<string, string[]>();
|
||||
for (const finding of findings) {
|
||||
if (!finding.network) continue;
|
||||
const list = byNetwork.get(finding.network) ?? [];
|
||||
list.push(finding.id);
|
||||
byNetwork.set(finding.network, list);
|
||||
}
|
||||
return byNetwork;
|
||||
}
|
||||
|
||||
export function enrichNetworkRows(
|
||||
nodeId: number,
|
||||
baseRows: NetworkingNetworkBase[],
|
||||
stackFacts: StackNetworkFacts[],
|
||||
snapshot: DependencySnapshot,
|
||||
findings: NetworkingFinding[],
|
||||
): NetworkingNetworkRow[] {
|
||||
const stacksByNetwork = new Map<string, Set<string>>();
|
||||
const serviceNamesByNetwork = new Map<string, Set<string>>();
|
||||
for (const container of snapshot.containers) {
|
||||
for (const attached of container.networks) {
|
||||
const set = stacksByNetwork.get(attached.name) ?? new Set<string>();
|
||||
if (container.stack) set.add(container.stack);
|
||||
stacksByNetwork.set(attached.name, set);
|
||||
|
||||
if (container.service) {
|
||||
const services = serviceNamesByNetwork.get(attached.name) ?? new Set<string>();
|
||||
services.add(container.service);
|
||||
serviceNamesByNetwork.set(attached.name, services);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const findingsByNetwork = indexFindingIdsByNetwork(findings);
|
||||
|
||||
return baseRows.map((row) => {
|
||||
const declaredByStacks = stacksDeclaringNetwork(stackFacts, row.name);
|
||||
const declaredExternalByStacks = stacksDeclaringNetwork(stackFacts, row.name, true);
|
||||
return {
|
||||
...row,
|
||||
declaredByStacks,
|
||||
declaredExternalByStacks,
|
||||
isExternalDependency: declaredExternalByStacks.length > 0,
|
||||
sharedStackCount: stacksByNetwork.get(row.name)?.size ?? 0,
|
||||
exposureSummary: buildExposureSummary(nodeId, row.name, stackFacts, snapshot),
|
||||
findingIds: findingsByNetwork.get(row.name) ?? [],
|
||||
serviceNames: [...(serviceNamesByNetwork.get(row.name) ?? [])].sort(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function buildExposureSummary(
|
||||
nodeId: number,
|
||||
networkName: string,
|
||||
stackFacts: StackNetworkFacts[],
|
||||
snapshot: DependencySnapshot,
|
||||
): NetworkingExposureSummary | null {
|
||||
const stacksOnNetwork = new Set<string>();
|
||||
for (const c of snapshot.containers) {
|
||||
if (c.networks.some(n => n.name === networkName) && c.stack) stacksOnNetwork.add(c.stack);
|
||||
}
|
||||
if (stacksOnNetwork.size === 0) return null;
|
||||
|
||||
let broadExposureCount = 0;
|
||||
let unclassifiedStackCount = 0;
|
||||
const db = DatabaseService.getInstance();
|
||||
|
||||
for (const stack of stacksOnNetwork) {
|
||||
const facts = stackFacts.find(f => f.stack === stack);
|
||||
if (!facts?.renderable) continue;
|
||||
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]));
|
||||
|
||||
let stackBroad = false;
|
||||
let stackUnclassified = false;
|
||||
for (const svc of facts.services) {
|
||||
const attached = svc.networks.some(n => {
|
||||
const net = facts.networks.find(nn => nn.key === n.key);
|
||||
return (net?.name ?? n.key) === networkName;
|
||||
});
|
||||
if (!attached) continue;
|
||||
const publishes = svc.publishedPorts.length > 0 || isHostNetwork(svc.networkMode);
|
||||
if (svc.publishedPorts.some(p => p.allInterfaces) || isHostNetwork(svc.networkMode)) stackBroad = true;
|
||||
const intent = byService.get(svc.name) ?? stackIntent;
|
||||
if (publishes && (intent === null || intent === 'unknown')) stackUnclassified = true;
|
||||
}
|
||||
if (stackBroad) broadExposureCount += 1;
|
||||
if (stackUnclassified) unclassifiedStackCount += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
publishingStackCount: stacksOnNetwork.size,
|
||||
broadExposureCount,
|
||||
unclassifiedStackCount,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Shared severity ranking, reused by the Overview top-N attention list and the
|
||||
* Findings tab grouping, so the two views can never disagree on ordering.
|
||||
*/
|
||||
import type { NetworkingFinding, NetworkingFindingSeverity } from './networkingTypes';
|
||||
|
||||
export const NETWORKING_SEVERITY_RANK: Record<NetworkingFindingSeverity, number> = {
|
||||
info: 0,
|
||||
medium: 1,
|
||||
high: 2,
|
||||
critical: 3,
|
||||
};
|
||||
|
||||
/** Severity descending; at equal severity, a live-sourced finding ranks ahead of a
|
||||
* Doctor-only finding (cached data should not outrank current runtime truth). */
|
||||
export function compareFindingsForRanking(a: NetworkingFinding, b: NetworkingFinding): number {
|
||||
const rankDiff = NETWORKING_SEVERITY_RANK[b.severity] - NETWORKING_SEVERITY_RANK[a.severity];
|
||||
if (rankDiff !== 0) return rankDiff;
|
||||
const aIsLive = a.sources.includes('live');
|
||||
const bIsLive = b.sources.includes('live');
|
||||
if (aIsLive !== bIsLive) return aIsLive ? -1 : 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function rankFindings(findings: NetworkingFinding[]): NetworkingFinding[] {
|
||||
return [...findings].sort(compareFindingsForRanking);
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* Compose-aware topology built from the aggregate snapshot and stack facts.
|
||||
* No additional Docker API calls.
|
||||
*/
|
||||
import type { DependencySnapshot } from '../DockerController';
|
||||
import { DatabaseService } from '../DatabaseService';
|
||||
import type { ExposureIntent, StackNetworkFacts } from './types';
|
||||
import { isHostNetwork } from './normalize';
|
||||
import { getErrorMessage } from '../../utils/errors';
|
||||
import { sanitizeForLog } from '../../utils/safeLog';
|
||||
import type {
|
||||
NetworkingFinding,
|
||||
NetworkingOwnership,
|
||||
NetworkingTopology,
|
||||
NetworkingTopologyContainer,
|
||||
NetworkingTopologyNetwork,
|
||||
} from './networkingTypes';
|
||||
import { indexFindingIdsByNetwork, stacksDeclaringNetwork } from './networkingInventory';
|
||||
|
||||
const RUNNING_STATES = new Set(['running', 'restarting']);
|
||||
|
||||
function aliasMapForStack(facts: StackNetworkFacts): Map<string, Map<string, string[]>> {
|
||||
const byRuntime = new Map<string, Map<string, string[]>>();
|
||||
for (const service of facts.services) {
|
||||
for (const membership of service.networks) {
|
||||
const network = facts.networks.find((item) => item.key === membership.key);
|
||||
const runtimeName = network?.name ?? membership.key;
|
||||
const bucket = byRuntime.get(runtimeName) ?? new Map<string, string[]>();
|
||||
for (const alias of membership.aliases) {
|
||||
const list = bucket.get(alias) ?? [];
|
||||
list.push(service.name);
|
||||
bucket.set(alias, list);
|
||||
}
|
||||
byRuntime.set(runtimeName, bucket);
|
||||
}
|
||||
}
|
||||
return byRuntime;
|
||||
}
|
||||
|
||||
function ownershipForNetwork(net: DependencySnapshot['networks'][number]): NetworkingOwnership {
|
||||
if (net.isSystem || net.ingress) return 'system';
|
||||
if (net.stack) return 'sencho-managed';
|
||||
if (net.composeProject) return 'compose-managed';
|
||||
return 'unmanaged';
|
||||
}
|
||||
|
||||
function aliasesForService(
|
||||
aliasesByStack: Map<string, Map<string, Map<string, string[]>>>,
|
||||
stack: string | null,
|
||||
service: string | null,
|
||||
networkName: string,
|
||||
): string[] {
|
||||
if (!stack || !service) return [];
|
||||
const networkAliases = aliasesByStack.get(stack)?.get(networkName);
|
||||
if (!networkAliases) return [];
|
||||
return [...networkAliases.entries()]
|
||||
.filter(([, services]) => services.includes(service))
|
||||
.map(([alias]) => alias);
|
||||
}
|
||||
|
||||
export function buildNetworkingTopology(
|
||||
nodeId: number,
|
||||
snapshot: DependencySnapshot | null,
|
||||
stackFacts: StackNetworkFacts[],
|
||||
findings: NetworkingFinding[],
|
||||
includeSystem: boolean,
|
||||
): NetworkingTopology {
|
||||
if (!snapshot) return { networks: [], includeSystem };
|
||||
|
||||
const findingsByNetwork = indexFindingIdsByNetwork(findings);
|
||||
const aliasesByStack = new Map(stackFacts.map((facts) => [facts.stack, aliasMapForStack(facts)]));
|
||||
const intentsByStack = new Map(stackFacts.map((facts) => {
|
||||
// A per-stack exposure-intent read failure must not 500 the whole topology;
|
||||
// degrade to no intents for that stack (the badge simply reads "unknown").
|
||||
try {
|
||||
const intents = DatabaseService.getInstance().getStackExposureIntents(nodeId, facts.stack);
|
||||
return [facts.stack, new Map(intents.map((intent) => [intent.service, intent.intent]))] as const;
|
||||
} catch (error) {
|
||||
console.warn('[Networking] Exposure intents unavailable for %s in topology:',
|
||||
sanitizeForLog(facts.stack), sanitizeForLog(getErrorMessage(error, 'unknown')));
|
||||
return [facts.stack, new Map<string, ExposureIntent>()] as const;
|
||||
}
|
||||
}));
|
||||
|
||||
const networks: NetworkingTopologyNetwork[] = [];
|
||||
for (const net of snapshot.networks) {
|
||||
if (net.isSystem && !includeSystem) continue;
|
||||
|
||||
const containers: NetworkingTopologyContainer[] = [];
|
||||
for (const container of snapshot.containers) {
|
||||
if (!RUNNING_STATES.has(container.state)) continue;
|
||||
for (const attached of container.networks) {
|
||||
if (attached.name !== net.name && attached.id !== net.id) continue;
|
||||
containers.push({
|
||||
id: container.id,
|
||||
name: container.name,
|
||||
ip: attached.ip,
|
||||
state: container.state,
|
||||
image: container.image,
|
||||
stack: container.stack,
|
||||
service: container.service,
|
||||
composeAliases: aliasesForService(aliasesByStack, container.stack, container.service, net.name),
|
||||
publishedPorts: findService(container.stack, container.service, stackFacts)?.publishedPorts ?? [],
|
||||
exposureIntent: container.stack && container.service
|
||||
? getExposureIntent(container.stack, container.service, intentsByStack)
|
||||
: null,
|
||||
findingIds: findingsByNetwork.get(net.name) ?? [],
|
||||
driftFlags: getDriftFlags(container.stack, container.service, net.name, stackFacts),
|
||||
hostMode: isHostNetwork(findService(container.stack, container.service, stackFacts)?.networkMode),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const declaredExternalByStacks = stacksDeclaringNetwork(stackFacts, net.name, true);
|
||||
networks.push({
|
||||
id: net.id,
|
||||
name: net.name,
|
||||
driver: net.driver,
|
||||
scope: net.scope,
|
||||
stack: net.stack,
|
||||
isSystem: net.isSystem,
|
||||
ingress: net.ingress === true,
|
||||
enableIPv6: net.enableIPv6,
|
||||
ownership: ownershipForNetwork(net),
|
||||
declaredByStacks: stacksDeclaringNetwork(stackFacts, net.name),
|
||||
declaredExternalByStacks,
|
||||
isExternalDependency: declaredExternalByStacks.length > 0,
|
||||
runtimeState: 'present',
|
||||
findingIds: findingsByNetwork.get(net.name) ?? [],
|
||||
containers,
|
||||
});
|
||||
}
|
||||
|
||||
const presentNames = new Set(snapshot.networks.map((network) => network.name));
|
||||
for (const facts of stackFacts) {
|
||||
if (!facts.renderable) continue;
|
||||
for (const declared of facts.networks) {
|
||||
if (!declared.external || presentNames.has(declared.name)) continue;
|
||||
const existing = networks.find((network) => network.name === declared.name);
|
||||
if (existing) {
|
||||
if (!existing.declaredExternalByStacks.includes(facts.stack)) {
|
||||
existing.declaredExternalByStacks.push(facts.stack);
|
||||
existing.isExternalDependency = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
networks.push({
|
||||
id: `missing:${encodeURIComponent(declared.name)}`,
|
||||
name: declared.name,
|
||||
driver: 'unknown',
|
||||
scope: 'unknown',
|
||||
stack: null,
|
||||
isSystem: false,
|
||||
ingress: false,
|
||||
ownership: 'unmanaged',
|
||||
declaredByStacks: [facts.stack],
|
||||
declaredExternalByStacks: [facts.stack],
|
||||
isExternalDependency: true,
|
||||
runtimeState: 'missing',
|
||||
findingIds: findingsByNetwork.get(declared.name) ?? [],
|
||||
containers: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { networks, includeSystem };
|
||||
}
|
||||
|
||||
function findService(stack: string | null, service: string | null, facts: StackNetworkFacts[]) {
|
||||
return facts.find((item) => item.stack === stack)?.services.find((candidate) => candidate.name === service);
|
||||
}
|
||||
|
||||
function getExposureIntent(
|
||||
stack: string,
|
||||
service: string,
|
||||
intentsByStack: Map<string, Map<string, ExposureIntent>>,
|
||||
): ExposureIntent | null {
|
||||
const intents = intentsByStack.get(stack);
|
||||
return intents?.get(service) ?? intents?.get('') ?? null;
|
||||
}
|
||||
|
||||
function getDriftFlags(
|
||||
stack: string | null,
|
||||
service: string | null,
|
||||
network: string,
|
||||
facts: StackNetworkFacts[],
|
||||
): string[] {
|
||||
if (!stack) return [];
|
||||
const current = facts.find((item) => item.stack === stack);
|
||||
if (!current) return [];
|
||||
const flags: string[] = [];
|
||||
if (current.drift.runtimeOnlyAttachments.some((item) => item.network === network && item.service === service)) {
|
||||
flags.push('undeclared-attachment');
|
||||
}
|
||||
if (current.drift.foreignNetworkAttachments.some((item) => item.network === network)) {
|
||||
flags.push('foreign-attachment');
|
||||
}
|
||||
if (current.drift.missingFromRuntime.includes(network)) {
|
||||
flags.push('missing-runtime-network');
|
||||
}
|
||||
return flags;
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* DTOs for the node-scoped Networking operator page. These payloads never carry
|
||||
* raw Docker label values or inspect secrets; label keys only on detail views.
|
||||
*/
|
||||
import type { StackNetworkFacts } from './types';
|
||||
|
||||
export const NETWORKING_SCHEMA_VERSION = 3;
|
||||
export type NetworkingOwnership = 'system' | 'sencho-managed' | 'compose-managed' | 'unmanaged';
|
||||
|
||||
/** Phase A row: identity and ownership from the dependency snapshot only. */
|
||||
export interface NetworkingNetworkBase {
|
||||
id: string;
|
||||
name: string;
|
||||
driver: string;
|
||||
scope: string;
|
||||
isSystem: boolean;
|
||||
ingress: boolean;
|
||||
enableIPv6?: boolean;
|
||||
composeProject: string | null;
|
||||
stack: string | null;
|
||||
connectedCount: number;
|
||||
isSencho: boolean;
|
||||
ownership: NetworkingOwnership;
|
||||
declaredByStacks: string[];
|
||||
declaredExternalByStacks: string[];
|
||||
isExternalDependency: boolean;
|
||||
}
|
||||
|
||||
export interface NetworkingExposureSummary {
|
||||
publishingStackCount: number;
|
||||
broadExposureCount: number;
|
||||
unclassifiedStackCount: number;
|
||||
}
|
||||
|
||||
/** Phase B row: inventory enrichment after stack facts and findings exist. */
|
||||
export interface NetworkingNetworkRow extends NetworkingNetworkBase {
|
||||
sharedStackCount: number;
|
||||
exposureSummary: NetworkingExposureSummary | null;
|
||||
findingIds: string[];
|
||||
/** Compose service names attached to this network, from the dependency snapshot only (never labels). */
|
||||
serviceNames: string[];
|
||||
}
|
||||
|
||||
export const NETWORKING_FINDING_KINDS = [
|
||||
'external-network-missing',
|
||||
'network-missing',
|
||||
'network-undeclared',
|
||||
'declared-network-unused',
|
||||
'foreign-network-attachment',
|
||||
'alias-collision',
|
||||
'network-mode-host',
|
||||
'exposure-unclassified',
|
||||
'exposure-all-interfaces',
|
||||
'shared-network',
|
||||
'network-name-collision',
|
||||
'service-name-collision',
|
||||
'large-flat-network',
|
||||
'advanced-driver-caveat',
|
||||
'runtime-unavailable',
|
||||
'exposure-intent-mismatch',
|
||||
// Doctor-only rules (cached, aggregated via doctorNetworkingFindings.ts)
|
||||
'port-conflict-node',
|
||||
'port-conflict-internal',
|
||||
'sensitive-service-broad-exposure',
|
||||
'exposure-port-vs-dossier',
|
||||
'reverse-proxy-undocumented',
|
||||
'new-network',
|
||||
] as const;
|
||||
|
||||
export type NetworkingFindingKind = typeof NETWORKING_FINDING_KINDS[number];
|
||||
|
||||
export type NetworkingFindingSeverity = 'critical' | 'high' | 'medium' | 'info';
|
||||
|
||||
export type NetworkingFindingSource = 'live' | 'doctor';
|
||||
|
||||
/** One retained Doctor occurrence merged into (or standing in for) a unified finding.
|
||||
* Never derived from message text; ruleId + structural fields only. */
|
||||
export interface DoctorFindingMetadata {
|
||||
ruleId: string;
|
||||
ranAt: string;
|
||||
title: string;
|
||||
message: string;
|
||||
service?: string;
|
||||
sourcePath?: string;
|
||||
remediation?: string;
|
||||
/** Doctor's own severity translation, kept even when the merged card's canonical
|
||||
* severity (live) differs, so provenance is never lost. */
|
||||
severity: NetworkingFindingSeverity;
|
||||
}
|
||||
|
||||
export type NetworkingRecommendedAction =
|
||||
| { kind: 'open-stack'; label: string; stack: string }
|
||||
| { kind: 'open-stack-networking'; label: string; stack: string }
|
||||
| { kind: 'open-stack-doctor'; label: string; stack: string }
|
||||
| { kind: 'open-stack-editor'; label: string; stack: string }
|
||||
| { kind: 'open-stack-dossier'; label: string; stack: string }
|
||||
| { kind: 'open-stack-drift'; label: string; stack: string }
|
||||
| { kind: 'set-exposure-intent'; label: string; stack: string; service?: string }
|
||||
| { kind: 'create-network'; label: string; networkName: string; requiresAdmin: true }
|
||||
| { kind: 'copy-compose-snippet'; label: string; snippetKind: 'external-network'; networkName: string }
|
||||
| { kind: 'copy-docker-command'; label: string; commandKind: 'network-create'; networkName: string }
|
||||
| { kind: 'filter-topology'; label: string; networkName?: string; stack?: string }
|
||||
| { kind: 'inspect-network'; label: string; networkId: string }
|
||||
| { kind: 'open-docs'; label: string; docsPath: string }
|
||||
| { kind: 'refresh'; label: string };
|
||||
|
||||
export interface NetworkingFinding {
|
||||
id: string;
|
||||
kind: NetworkingFindingKind;
|
||||
severity: NetworkingFindingSeverity;
|
||||
title: string;
|
||||
message: string;
|
||||
stack?: string;
|
||||
network?: string;
|
||||
service?: string;
|
||||
evidence: { label: string; value: string }[];
|
||||
recommendedActions: NetworkingRecommendedAction[];
|
||||
/** Which engine(s) detected this finding. A card merged from both engines keeps
|
||||
* live severity/title/message as canonical and carries Doctor context in doctorFindings. */
|
||||
sources: NetworkingFindingSource[];
|
||||
/** Every retained Doctor occurrence that structurally matches this card (one-to-many:
|
||||
* e.g. two broad-bind ports on one service both attach here). Empty for live-only findings. */
|
||||
doctorFindings: DoctorFindingMetadata[];
|
||||
}
|
||||
|
||||
export interface NodeNetworkingOverview {
|
||||
runtimeAvailable: boolean;
|
||||
networkCount: number | null;
|
||||
stackCount: number;
|
||||
connectedContainerCount: number | null;
|
||||
systemNetworkCount: number | null;
|
||||
senchoManagedNetworkCount: number | null;
|
||||
composeManagedNetworkCount: number | null;
|
||||
unmanagedNetworkCount: number | null;
|
||||
externalDependencyNetworkCount: number | null;
|
||||
exposedStackCount: number;
|
||||
unknownExposureStackCount: number;
|
||||
missingExternalCount: number;
|
||||
networkCollisionCount: number;
|
||||
findingCount: number;
|
||||
renderFailedStacks: string[];
|
||||
}
|
||||
|
||||
export interface NetworkingTopologyContainer {
|
||||
id: string;
|
||||
name: string;
|
||||
ip: string;
|
||||
state: string;
|
||||
image: string;
|
||||
stack: string | null;
|
||||
service: string | null;
|
||||
composeAliases: string[];
|
||||
publishedPorts: import('./types').NetworkFactPort[];
|
||||
exposureIntent: import('./types').ExposureIntent | null;
|
||||
findingIds: string[];
|
||||
driftFlags: string[];
|
||||
hostMode: boolean;
|
||||
}
|
||||
|
||||
export interface NetworkingTopologyNetwork {
|
||||
id: string;
|
||||
name: string;
|
||||
driver: string;
|
||||
scope: string;
|
||||
stack: string | null;
|
||||
isSystem: boolean;
|
||||
ingress: boolean;
|
||||
enableIPv6?: boolean;
|
||||
ownership: NetworkingOwnership;
|
||||
declaredByStacks: string[];
|
||||
declaredExternalByStacks: string[];
|
||||
isExternalDependency: boolean;
|
||||
runtimeState?: 'present' | 'missing';
|
||||
findingIds: string[];
|
||||
containers: NetworkingTopologyContainer[];
|
||||
}
|
||||
|
||||
export interface NetworkingTopology {
|
||||
networks: NetworkingTopologyNetwork[];
|
||||
includeSystem: boolean;
|
||||
}
|
||||
|
||||
export interface NodeNetworkingAggregate {
|
||||
overview: NodeNetworkingOverview;
|
||||
networks: NetworkingNetworkRow[];
|
||||
findings: NetworkingFinding[];
|
||||
topology?: NetworkingTopology;
|
||||
stackFacts: StackNetworkFacts[];
|
||||
runtimeAvailable: boolean;
|
||||
recentActivity: import('../DatabaseService').NotificationHistory[];
|
||||
}
|
||||
|
||||
export interface SanitizedNetworkInspectContainer {
|
||||
name: string;
|
||||
service: string | null;
|
||||
stack: string | null;
|
||||
ipv4: string | null;
|
||||
}
|
||||
|
||||
export interface SanitizedNetworkInspect {
|
||||
id: string;
|
||||
name: string;
|
||||
driver: string;
|
||||
scope: string;
|
||||
internal: boolean;
|
||||
attachable: boolean;
|
||||
ingress: boolean;
|
||||
enableIPv6: boolean;
|
||||
stack: string | null;
|
||||
composeProject: string | null;
|
||||
connectedCount: number;
|
||||
labelKeys: string[];
|
||||
subnets: string[];
|
||||
gateways: string[];
|
||||
/** Strict allowlist join against the DependencySnapshot; never labels, MAC addresses,
|
||||
* endpoint IDs, or raw Docker options. */
|
||||
connectedContainers: SanitizedNetworkInspectContainer[];
|
||||
}
|
||||
|
||||
export interface NetworkingEnvelope {
|
||||
schemaVersion: typeof NETWORKING_SCHEMA_VERSION;
|
||||
runtimeAvailable: boolean;
|
||||
generatedAt: string;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Privacy-safe network inspect DTO for the Networking page. Returns structural
|
||||
* fields and label keys only; never label values or IPAM secrets.
|
||||
*/
|
||||
import type { DependencyNetwork, DependencySnapshot } from '../DockerController';
|
||||
import type { SanitizedNetworkInspect } from './networkingTypes';
|
||||
|
||||
interface RawNetworkInspect {
|
||||
Id?: string;
|
||||
Name?: string;
|
||||
Driver?: string;
|
||||
Scope?: string;
|
||||
Internal?: boolean;
|
||||
Attachable?: boolean;
|
||||
Ingress?: boolean;
|
||||
EnableIPv6?: boolean;
|
||||
Labels?: Record<string, string>;
|
||||
IPAM?: {
|
||||
Config?: Array<{ Subnet?: string; Gateway?: string }>;
|
||||
};
|
||||
Containers?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function sanitizeNetworkInspect(
|
||||
raw: RawNetworkInspect,
|
||||
snapshotNet: DependencyNetwork | undefined,
|
||||
snapshot?: DependencySnapshot,
|
||||
): SanitizedNetworkInspect {
|
||||
const subnets: string[] = [];
|
||||
const gateways: string[] = [];
|
||||
for (const cfg of raw.IPAM?.Config ?? []) {
|
||||
if (typeof cfg.Subnet === 'string' && cfg.Subnet) subnets.push(cfg.Subnet);
|
||||
if (typeof cfg.Gateway === 'string' && cfg.Gateway) gateways.push(cfg.Gateway);
|
||||
}
|
||||
|
||||
const networkId = raw.Id ?? snapshotNet?.id ?? '';
|
||||
const networkName = raw.Name ?? snapshotNet?.name ?? '';
|
||||
const connectedContainers = snapshot
|
||||
? snapshot.containers
|
||||
.filter((c) => c.networks.some((n) => n.id === networkId || n.name === networkName))
|
||||
.map((c) => {
|
||||
const attachment = c.networks.find((n) => n.id === networkId || n.name === networkName);
|
||||
return {
|
||||
name: c.name,
|
||||
service: c.service,
|
||||
stack: c.stack,
|
||||
ipv4: attachment?.ip ? attachment.ip.replace(/\/\d+$/, '') : null,
|
||||
};
|
||||
})
|
||||
: [];
|
||||
|
||||
return {
|
||||
id: networkId,
|
||||
name: networkName,
|
||||
driver: raw.Driver ?? snapshotNet?.driver ?? 'bridge',
|
||||
scope: raw.Scope ?? snapshotNet?.scope ?? 'local',
|
||||
internal: raw.Internal === true,
|
||||
attachable: raw.Attachable === true,
|
||||
ingress: raw.Ingress === true,
|
||||
enableIPv6: raw.EnableIPv6 === true,
|
||||
stack: snapshotNet?.stack ?? null,
|
||||
composeProject: snapshotNet?.composeProject ?? null,
|
||||
connectedCount: raw.Containers ? Object.keys(raw.Containers).length : 0,
|
||||
labelKeys: Object.keys(raw.Labels ?? {}).sort(),
|
||||
subnets,
|
||||
gateways,
|
||||
connectedContainers,
|
||||
};
|
||||
}
|
||||
|
||||
export function findSnapshotNetwork(
|
||||
snapshot: DependencySnapshot,
|
||||
idOrName: string,
|
||||
): DependencyNetwork | undefined {
|
||||
return snapshot.networks.find(n => n.id === idOrName || n.name === idOrName);
|
||||
}
|
||||
@@ -666,6 +666,8 @@ const exposureUnclassified: PreflightRule = {
|
||||
id: 'exposure-unclassified',
|
||||
run(ctx) {
|
||||
if (!ctx.model) return [];
|
||||
// A read failure leaves every intent null; do not read that as "unclassified".
|
||||
if (!ctx.exposureAvailable) 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
|
||||
@@ -712,6 +714,9 @@ const reverseProxyUndocumented: PreflightRule = {
|
||||
id: 'reverse-proxy-undocumented',
|
||||
run(ctx) {
|
||||
if (!ctx.model) return [];
|
||||
// A read failure hides both the intent and any documented access URLs; do not
|
||||
// interpret that absence as an undocumented reverse proxy.
|
||||
if (!ctx.exposureAvailable) return [];
|
||||
// Already documented or intentionally reverse-proxied at the stack level.
|
||||
if (ctx.hasAccessUrls || ctx.stackIntent === 'reverse-proxy') return [];
|
||||
const findings: PreflightFinding[] = [];
|
||||
|
||||
@@ -125,6 +125,10 @@ export interface PreflightContext {
|
||||
accessUrlPorts: Set<number>;
|
||||
/** Whether the dossier records any access URL (gates the port-vs-documented rule). */
|
||||
hasAccessUrls: boolean;
|
||||
/** False when the exposure context could not be read (a DB failure), distinct
|
||||
* from a genuinely unset intent. Gates the intent-interpretation rules so a
|
||||
* transient read failure does not fabricate unclassified/undocumented findings. */
|
||||
exposureAvailable: boolean;
|
||||
/** True when this stack is the running Sencho instance on the node. */
|
||||
isSelfStack: boolean;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
export async function mapWithConcurrency<T, R>(
|
||||
items: readonly T[],
|
||||
limit: number,
|
||||
fn: (item: T, index: number) => Promise<R>,
|
||||
): Promise<R[]> {
|
||||
if (!Number.isInteger(limit) || limit < 1) {
|
||||
throw new Error('Concurrency limit must be a positive integer');
|
||||
}
|
||||
|
||||
const results = new Array<R>(items.length);
|
||||
let nextIndex = 0;
|
||||
const worker = async (): Promise<void> => {
|
||||
while (nextIndex < items.length) {
|
||||
const index = nextIndex;
|
||||
nextIndex += 1;
|
||||
results[index] = await fn(items[index], index);
|
||||
}
|
||||
};
|
||||
|
||||
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
||||
return results;
|
||||
}
|
||||
Reference in New Issue
Block a user