diff --git a/backend/src/__tests__/compose-dependency-parse.test.ts b/backend/src/__tests__/compose-dependency-parse.test.ts index 19e9ae1f..a178e04c 100644 --- a/backend/src/__tests__/compose-dependency-parse.test.ts +++ b/backend/src/__tests__/compose-dependency-parse.test.ts @@ -66,6 +66,15 @@ describe('parseComposeDependencies - ports', () => { }); }); +describe('parseComposeDependencies - network_mode', () => { + it('captures network_mode and leaves it undefined when absent', () => { + const host = parseComposeDependencies(svc('web', 'image: nginx\nnetwork_mode: host')); + expect(host.services[0].networkMode).toBe('host'); + const none = parseComposeDependencies(svc('web', 'image: nginx')); + expect(none.services[0].networkMode).toBeUndefined(); + }); +}); + describe('parseComposeDependencies - top-level resources', () => { it('normalizes external (bool), legacy external object, and name: override', () => { const r = parseComposeDependencies('services:\n web:\n image: nginx\nnetworks:\n a:\n b:\n external: true\n c:\n external:\n name: legacy_net\n d:\n name: custom_net\nvolumes:\n v:\n external: true\n'); diff --git a/backend/src/__tests__/networking-summary.test.ts b/backend/src/__tests__/networking-summary.test.ts index e15d154d..c1ca54dc 100644 --- a/backend/src/__tests__/networking-summary.test.ts +++ b/backend/src/__tests__/networking-summary.test.ts @@ -105,6 +105,47 @@ describe('networking summary', () => { expect(res.body.unknownExposure.stacks).toContain(STACK); }); + it('marks a host-network stack exposed and unknown-exposure even with no published ports', async () => { + fs.writeFileSync(path.join(stackDir, 'compose.yaml'), 'services:\n app:\n image: nginx:latest\n network_mode: host\n'); + const res = await request(app).get('/api/networking/summary').set('Authorization', authHeader); + expect(res.status).toBe(200); + expect(res.body.exposed.stacks).toContain(STACK); + expect(res.body.unknownExposure.stacks).toContain(STACK); + }); + + it('drops a host-network stack from unknown-exposure once an intent is set', async () => { + fs.writeFileSync(path.join(stackDir, 'compose.yaml'), 'services:\n app:\n image: nginx:latest\n network_mode: host\n'); + DatabaseService.getInstance().setStackExposureIntent(1, STACK, '', 'lan', 'admin'); + const res = await request(app).get('/api/networking/summary').set('Authorization', authHeader); + expect(res.body.exposed.stacks).toContain(STACK); + expect(res.body.unknownExposure.stacks).not.toContain(STACK); + }); + + it('keeps a stack unknown when an unclassified host-network service sits beside a classified ports service', async () => { + fs.writeFileSync(path.join(stackDir, 'compose.yaml'), + 'services:\n metrics:\n image: nginx:latest\n network_mode: host\n web:\n image: nginx:latest\n ports:\n - "8080:80"\n'); + // web classified, the host-network metrics service still unset, so the stack stays unknown. + DatabaseService.getInstance().setStackExposureIntent(1, STACK, 'web', 'public', 'admin'); + const res = await request(app).get('/api/networking/summary').set('Authorization', authHeader); + expect(res.body.exposed.stacks).toContain(STACK); + expect(res.body.unknownExposure.stacks).toContain(STACK); + }); + + it('does not treat a non-host network_mode (none) as exposed', async () => { + fs.writeFileSync(path.join(stackDir, 'compose.yaml'), 'services:\n app:\n image: nginx:latest\n network_mode: none\n'); + const res = await request(app).get('/api/networking/summary').set('Authorization', authHeader); + expect(res.body.exposed.stacks).not.toContain(STACK); + expect(res.body.unknownExposure.stacks).not.toContain(STACK); + }); + + it('the fleet aggregate counts a host-network stack as exposed', async () => { + fs.writeFileSync(path.join(stackDir, 'compose.yaml'), 'services:\n app:\n image: nginx:latest\n network_mode: host\n'); + const res = await request(app).get('/api/fleet/networking-summary').set('Authorization', authHeader); + expect(res.status).toBe(200); + const local = res.body.nodes.find((n: { summary: { exposed: { stacks: string[] } } | null }) => n.summary?.exposed.stacks.includes(STACK)); + expect(local).toBeDefined(); + }); + it('the fleet aggregate returns a per-node summary for the hub', async () => { const res = await request(app).get('/api/fleet/networking-summary').set('Authorization', authHeader); expect(res.status).toBe(200); diff --git a/backend/src/helpers/composeDependencyParse.ts b/backend/src/helpers/composeDependencyParse.ts index f3e39703..2eb2e700 100644 --- a/backend/src/helpers/composeDependencyParse.ts +++ b/backend/src/helpers/composeDependencyParse.ts @@ -24,6 +24,8 @@ export interface DeclaredService { ports: DeclaredPort[]; /** Service `image:` reference as declared, or undefined for build-only services. */ image?: string; + /** Service `network_mode:` as declared (e.g. `host`), or undefined. */ + networkMode?: string; } /** A top-level networks:/volumes: entry. */ @@ -194,6 +196,7 @@ export function parseComposeDependencies(content: string): DeclaredCompose { volumes, ports, image: asString(svc.image), + networkMode: asString(svc.network_mode), }); } diff --git a/backend/src/services/network/networkingSummary.ts b/backend/src/services/network/networkingSummary.ts index 5d8c569f..aeb24696 100644 --- a/backend/src/services/network/networkingSummary.ts +++ b/backend/src/services/network/networkingSummary.ts @@ -10,7 +10,7 @@ import { FileSystemService } from '../FileSystemService'; import { DatabaseService } from '../DatabaseService'; import { parseComposeDependencies } from '../../helpers/composeDependencyParse'; import { assembleStackDrift } from '../DriftDetectionService'; -import { isLoopback } from './normalize'; +import { isHostNetwork, isLoopback } from './normalize'; import { getErrorMessage } from '../../utils/errors'; import { sanitizeForLog } from '../../utils/safeLog'; @@ -60,8 +60,13 @@ export async function computeNodeNetworkingSummary(nodeId: number): Promise s.ports.length > 0); - if (declared.services.some(s => s.ports.some(p => !isLoopback(p.hostIp)))) exposed.push(stack); + // A host-network service publishes every container port directly on the host, + // so it counts as exposed (beyond loopback) and as publishing even with no + // declared `ports:`. This keeps the summary honest about host networking, + // matching the Compose Doctor's host-network finding. + const publishes = (s: typeof declared.services[number]): boolean => s.ports.length > 0 || isHostNetwork(s.networkMode); + const publishesPort = declared.services.some(publishes); + if (declared.services.some(s => isHostNetwork(s.networkMode) || s.ports.some(p => !isLoopback(p.hostIp)))) exposed.push(stack); if (publishesPort) { // Unknown only when a publishing service is effectively unclassified: a @@ -70,7 +75,7 @@ export async function computeNodeNetworkingSummary(nodeId: number): Promise i.service === '')?.intent ?? null; const byService = new Map(intents.filter(i => i.service !== '').map(i => [i.service, i.intent])); const anyUnclassified = declared.services - .filter(s => s.ports.length > 0) + .filter(publishes) .some(s => { const intent = byService.get(s.name) ?? stackIntent; return intent === null || intent === 'unknown'; diff --git a/backend/src/services/network/normalize.ts b/backend/src/services/network/normalize.ts index 6e2d74c9..f1d7a796 100644 --- a/backend/src/services/network/normalize.ts +++ b/backend/src/services/network/normalize.ts @@ -23,6 +23,13 @@ export function isLoopback(ip: string): boolean { return ip === '127.0.0.1' || ip === '::1' || ip === '[::1]'; } +/** True for `network_mode: host`, which publishes every container port directly + * on the host regardless of any declared `ports:` (so it is always exposed + * beyond loopback). Other modes (none, bridge, service:, container:) are not. */ +export function isHostNetwork(mode: string | undefined): boolean { + return mode === 'host'; +} + /** Resolved runtime name of a top-level network/volume: a `name:` override wins, * otherwise compose prefixes the project (`_`). An external * resource is never project-prefixed: it references a pre-existing network/volume diff --git a/docs/features/compose-networking.mdx b/docs/features/compose-networking.mdx index ac1b8425..7aec2efe 100644 --- a/docs/features/compose-networking.mdx +++ b/docs/features/compose-networking.mdx @@ -26,6 +26,8 @@ Each service lists its published ports with the interface they bind to: - **loopback** marks a port bound only to `127.0.0.1`, reachable only from the host itself. - A specific address is shown as-is. +A service running with `network_mode: host` is treated as **host-exposed**: it publishes every container port directly on the host, so it counts as exposed regardless of whether it declares any `ports:`. The exposure summary reflects that even when the port list is empty. + The tab also surfaces each service's network membership and aliases, `network_mode` (`host`, `none`, `service:`, and `container:` modes are called out), and `extra_hosts` entries. ## Exposure intent diff --git a/frontend/src/components/stack/StackNetworkingPanel.test.tsx b/frontend/src/components/stack/StackNetworkingPanel.test.tsx index 680deb41..fee253d3 100644 --- a/frontend/src/components/stack/StackNetworkingPanel.test.tsx +++ b/frontend/src/components/stack/StackNetworkingPanel.test.tsx @@ -64,6 +64,16 @@ describe('StackNetworkingPanel', () => { expect(screen.getByText(/runtime matches compose/i)).toBeInTheDocument(); // no-drift success card }); + it('marks a host-network service as host-exposed', async () => { + mockApi(facts({ + services: [{ name: 'app', networks: [], publishedPorts: [], networkMode: 'host', extraHosts: [] }], + })); + render(); + await screen.findByText('web_backend'); + expect(screen.getByText('host-exposed')).toBeInTheDocument(); + expect(screen.getByText('all container ports')).toBeInTheDocument(); + }); + it('saves a stack-level exposure intent on click', async () => { mockApi(facts()); render(); diff --git a/frontend/src/components/stack/StackNetworkingPanel.tsx b/frontend/src/components/stack/StackNetworkingPanel.tsx index 15b63dbc..cab81581 100644 --- a/frontend/src/components/stack/StackNetworkingPanel.tsx +++ b/frontend/src/components/stack/StackNetworkingPanel.tsx @@ -255,6 +255,14 @@ export default function StackNetworkingPanel({ stackName, canEdit, doctorEnabled ))} )} + {svc.networkMode === 'host' && ( + // Host networking publishes every container port on the host, so + // the service is host-exposed even with no published-port rows. +
+ all container ports + host-exposed +
+ )} {svc.publishedPorts.length > 0 && (
{svc.publishedPorts.map((p, i) => ( diff --git a/frontend/src/lib/dossierMarkdown.test.ts b/frontend/src/lib/dossierMarkdown.test.ts index a655d7e6..9cf1eb97 100644 --- a/frontend/src/lib/dossierMarkdown.test.ts +++ b/frontend/src/lib/dossierMarkdown.test.ts @@ -30,7 +30,7 @@ describe('buildStackDossierMarkdown', () => { const md = buildStackDossierMarkdown(anatomy, fields(), { stackIntent: 'internal', networks: [{ name: 'plex_default', external: false, internal: false }], - services: [{ name: 'plex', intent: null, ports: ['32400/tcp (all interfaces)'] }], + services: [{ name: 'plex', intent: null, ports: ['32400/tcp (all interfaces)'], hostNetwork: false }], }); expect(md).toContain('## Network exposure'); expect(md).toContain('**Stack intent:** internal'); diff --git a/frontend/src/lib/networkExposureSummary.test.ts b/frontend/src/lib/networkExposureSummary.test.ts index e6b96b57..5a5bdef6 100644 --- a/frontend/src/lib/networkExposureSummary.test.ts +++ b/frontend/src/lib/networkExposureSummary.test.ts @@ -31,7 +31,15 @@ describe('buildNetworkExposureSummary', () => { expect(s).toEqual({ stackIntent: 'internal', networks: [{ name: 'app_backend', external: false, internal: true }], - services: [{ name: 'web', intent: 'public', ports: ['8080/tcp (all interfaces)', '9000/tcp (loopback)'] }], + services: [{ name: 'web', intent: 'public', ports: ['8080/tcp (all interfaces)', '9000/tcp (loopback)'], hostNetwork: false }], + }); + }); + it('flags a host-network service as host-exposed even with no published ports', () => { + const s = buildNetworkExposureSummary({ renderable: true, networks: [], services: [{ name: 'app', publishedPorts: [], networkMode: 'host' }] }, []); + expect(s).toEqual({ + stackIntent: null, + networks: [], + services: [{ name: 'app', intent: null, ports: [], hostNetwork: true }], }); }); }); @@ -47,6 +55,10 @@ describe('networkExposureSection', () => { it('returns null for a null summary', () => { expect(networkExposureSection(null)).toBeNull(); }); + it('renders the host-network phrase for a host-mode service', () => { + const md = networkExposureSection(buildNetworkExposureSummary({ renderable: true, networks: [], services: [{ name: 'app', publishedPorts: [], networkMode: 'host' }] }, [])) ?? ''; + expect(md).toContain('host network (all ports exposed on host)'); + }); it('never includes a value that lives in an ignored field (no env or label leak)', () => { // A secret planted in fields the builder does not read must not surface. const leaky = facts({ diff --git a/frontend/src/lib/networkExposureSummary.ts b/frontend/src/lib/networkExposureSummary.ts index 0b8cf9a2..7ee4e894 100644 --- a/frontend/src/lib/networkExposureSummary.ts +++ b/frontend/src/lib/networkExposureSummary.ts @@ -9,13 +9,13 @@ export interface NetworkExposureSummary { stackIntent: string | null; networks: { name: string; external: boolean; internal: boolean }[]; - services: { name: string; intent: string | null; ports: string[] }[]; + services: { name: string; intent: string | null; ports: string[]; hostNetwork: boolean }[]; } // Loose input shapes: the builder reads the raw parsed /networking and // /exposure JSON, so it stays decoupled from the panel's local interfaces. interface FactsPort { startPort: number; endPort: number; protocol: string; allInterfaces: boolean; loopbackOnly: boolean } -interface FactsService { name: string; publishedPorts?: FactsPort[] } +interface FactsService { name: string; publishedPorts?: FactsPort[]; networkMode?: string } interface FactsNetwork { name: string; external: boolean; internal: boolean } export interface NetworkFactsInput { renderable?: boolean; networks?: FactsNetwork[]; services?: FactsService[] } export interface ExposureIntentInput { service: string; intent: string } @@ -36,9 +36,12 @@ export function buildNetworkExposureSummary(facts: NetworkFactsInput | null, int name: s.name, intent: byService.get(s.name) ?? null, ports: (s.publishedPorts ?? []).map(portLabel), + // network_mode: host publishes every container port on the host, so it is + // exposure-relevant even with no declared ports. + hostNetwork: s.networkMode === 'host', })); const empty = networks.length === 0 && stackIntent === null - && services.every(s => s.ports.length === 0 && s.intent === null); + && services.every(s => s.ports.length === 0 && s.intent === null && !s.hostNetwork); return empty ? null : { stackIntent, networks, services }; } @@ -53,11 +56,12 @@ export function networkExposureSection(summary: NetworkExposureSummary | null): return `- ${n.name}${flags ? ` (${flags})` : ''}`; }).join('\n')); } - const services = summary.services.filter(s => s.ports.length > 0 || s.intent); + const services = summary.services.filter(s => s.ports.length > 0 || s.intent || s.hostNetwork); if (services.length > 0) { parts.push('### Services', services.map(s => { const bits: string[] = []; if (s.intent) bits.push(`intent ${s.intent}`); + if (s.hostNetwork) bits.push('host network (all ports exposed on host)'); if (s.ports.length > 0) bits.push(`ports ${s.ports.join(', ')}`); return `- **${s.name}:** ${bits.join('; ')}`; }).join('\n'));