mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
fix(networking): treat host-network services as host-exposed in summaries (#1430)
The exposure summaries derived a stack's exposure solely from the declared published-port list, so a service running with network_mode: host (which publishes every container port on the host but declares no ports:) was under-reported as less exposed than it actually is. Capture network_mode in the lightweight dependency parser, add an isHostNetwork predicate, and treat a host-network service as exposed and publishing across the Fleet networking summary, the Stack Dossier export, and the Networking panel, matching how the Compose Doctor already flags host networking.
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Node
|
||||
const declared = parseComposeDependencies(content);
|
||||
if (declared.parseError) continue;
|
||||
|
||||
const publishesPort = declared.services.some(s => s.ports.length > 0);
|
||||
if (declared.services.some(s => s.ports.some(p => !isLoopback(p.hostIp)))) exposed.push(stack);
|
||||
// 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<Node
|
||||
const stackIntent = intents.find(i => i.service === '')?.intent ?? null;
|
||||
const byService = new Map(intents.filter(i => i.service !== '').map(i => [i.service, i.intent]));
|
||||
const anyUnclassified = declared.services
|
||||
.filter(s => s.ports.length > 0)
|
||||
.filter(publishes)
|
||||
.some(s => {
|
||||
const intent = byService.get(s.name) ?? stackIntent;
|
||||
return intent === null || intent === 'unknown';
|
||||
|
||||
@@ -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 (`<project>_<key>`). An external
|
||||
* resource is never project-prefixed: it references a pre-existing network/volume
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(<StackNetworkingPanel stackName="web" canEdit doctorEnabled />);
|
||||
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(<StackNetworkingPanel stackName="web" canEdit doctorEnabled />);
|
||||
|
||||
@@ -255,6 +255,14 @@ export default function StackNetworkingPanel({ stackName, canEdit, doctorEnabled
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{svc.networkMode === 'host' && (
|
||||
// Host networking publishes every container port on the host, so
|
||||
// the service is host-exposed even with no published-port rows.
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-mono text-[11px] text-foreground/80">all container ports</span>
|
||||
<span className="rounded border border-warning/40 bg-warning/[0.08] px-1 py-0.5 font-mono text-[10px] text-warning">host-exposed</span>
|
||||
</div>
|
||||
)}
|
||||
{svc.publishedPorts.length > 0 && (
|
||||
<div className="flex flex-col gap-1">
|
||||
{svc.publishedPorts.map((p, i) => (
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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'));
|
||||
|
||||
Reference in New Issue
Block a user