diff --git a/src/commands/preflight.test.ts b/src/commands/preflight.test.ts index 0785fbd29..f4c53ac48 100644 --- a/src/commands/preflight.test.ts +++ b/src/commands/preflight.test.ts @@ -296,6 +296,79 @@ describe('resolveAllowedDomains', () => { expect(mockedRules.loadAndMergeDomains).not.toHaveBeenCalled(); expect(result.allowedDomains).toEqual([]); }); + + it('auto-adds topology-attached container names to allowedDomains in network-isolation mode', () => { + const result = resolveAllowedDomains({ + networkIsolation: true, + topologyAttach: ['awmg-mcpg', 'awmg-cli-proxy'], + }); + expect(result.allowedDomains).toContain('awmg-mcpg'); + expect(result.allowedDomains).toContain('awmg-cli-proxy'); + expect(mockedLogger.debug).toHaveBeenCalledWith( + expect.stringContaining('auto-allowing topology peer "awmg-mcpg"') + ); + expect(mockedLogger.debug).toHaveBeenCalledWith( + expect.stringContaining('auto-allowing topology peer "awmg-cli-proxy"') + ); + }); + + it('does not add topology-attached container names when networkIsolation is false', () => { + const result = resolveAllowedDomains({ + networkIsolation: false, + topologyAttach: ['awmg-mcpg'], + }); + expect(result.allowedDomains).not.toContain('awmg-mcpg'); + }); + + it('does not duplicate topology container names already in allowedDomains', () => { + mockedDomainUtils.parseDomains.mockReturnValue(['awmg-mcpg']); + mockedOptionParsers.processLocalhostKeyword.mockReturnValue({ + allowedDomains: ['awmg-mcpg'], + localhostDetected: false, + shouldEnableHostAccess: false, + }); + + const result = resolveAllowedDomains({ + allowDomains: 'awmg-mcpg', + networkIsolation: true, + topologyAttach: ['awmg-mcpg'], + }); + expect(result.allowedDomains.filter(d => d === 'awmg-mcpg')).toHaveLength(1); + }); + + it('does not add topology containers when topologyAttach is empty', () => { + const result = resolveAllowedDomains({ + networkIsolation: true, + topologyAttach: [], + }); + expect(result.allowedDomains).toEqual([]); + }); + + it('does not add topology container names for a non-compose runtime (e.g. sbx)', () => { + const result = resolveAllowedDomains({ + networkIsolation: true, + containerRuntime: 'sbx', + topologyAttach: ['awmg-mcpg'], + }); + expect(result.allowedDomains).not.toContain('awmg-mcpg'); + }); + + it('adds topology container names for an explicit compose runtime', () => { + const result = resolveAllowedDomains({ + networkIsolation: true, + containerRuntime: 'runc', + topologyAttach: ['awmg-mcpg'], + }); + expect(result.allowedDomains).toContain('awmg-mcpg'); + }); + + it('auto-allows the DIFC/cli-proxy host even when not listed in topologyAttach', () => { + const result = resolveAllowedDomains({ + networkIsolation: true, + difcProxyHost: 'https://awmg-cli-proxy:18443', + }); + expect(result.allowedDomains).toContain('awmg-cli-proxy'); + }); }); describe('parseDomainOptions', () => { diff --git a/src/commands/preflight.ts b/src/commands/preflight.ts index 03a86eea1..08be581ed 100644 --- a/src/commands/preflight.ts +++ b/src/commands/preflight.ts @@ -8,6 +8,7 @@ import { parseDomains, parseDomainsFile } from '../domain-utils'; import { processLocalhostKeyword } from '../option-parsers'; import { resolveCopilotApiRouting } from '../copilot-api-resolver'; import { resolveApiTargetsToAllowedDomains } from '../api-proxy-config'; +import { resolveTopologyPeerHosts } from '../topology-peers'; /** * Resolves the Commander option-value source for a given option name. @@ -176,6 +177,30 @@ export function resolveAllowedDomains(options: Record): Allowed logger.debug.bind(logger) ); + // In network-isolation (topology) mode, automatically add trusted topology + // peer hostnames to the Squid allowed-domain ACL. NO_PROXY is also set for + // these peers (in proxy-environment.ts) so that proxy-aware clients + // (undici/rmcp) connect directly; adding them here ensures Squid does not + // block requests from tools that honour HTTP(S)_PROXY but ignore NO_PROXY. + // + // This covers the standard-port (80/443) path. Non-standard MCP ports (e.g. + // http://awmg-mcpg:8080) and Squid's DNS resolution of these Docker-only + // hostnames are handled separately via SquidConfig.topologyPeers and the + // squid-proxy extra_hosts patch (see config-writer.ts / topology.ts). + // + // NOTE ON SQUID SEMANTICS: these names are emitted as dstdomain ACL entries + // via formatDomainForSquid, which prepends a leading dot (e.g. "awmg-mcpg" -> + // ".awmg-mcpg"). Squid therefore matches the host itself *and* any subdomain + // (*.awmg-mcpg). This is safe for internal Docker hostnames (a bare label + // like "github" matches host "github", not "github.com"), but operators + // should avoid topology names that collide with trusted public labels. + for (const peer of resolveTopologyPeerHosts(options)) { + if (!allowedDomains.includes(peer)) { + allowedDomains.push(peer); + logger.debug(`Network-isolation: auto-allowing topology peer "${peer}" in Squid ACL`); + } + } + validateAllowedDomains(allowedDomains); return { allowedDomains, localhostResult, resolvedCopilotApiTarget, resolvedCopilotApiBasePath }; diff --git a/src/config-writer.ts b/src/config-writer.ts index f19878a0f..02bf25fa7 100644 --- a/src/config-writer.ts +++ b/src/config-writer.ts @@ -4,6 +4,7 @@ import * as yaml from 'js-yaml'; import { WrapperConfig, API_PROXY_PORTS, DockerComposeConfig } from './types'; import { logger } from './logger'; import { generatePolicyManifest, generateSquidConfig } from './squid-config'; +import { resolveTopologyPeerHosts } from './topology-peers'; import { generateSessionCa, initSslDb, isOpenSslAvailable } from './ssl-bump'; import { parseUrlPatterns } from './domain-matchers'; import { SslConfig, SQUID_PORT } from './host-env'; @@ -331,6 +332,11 @@ export async function writeConfigs(config: WrapperConfig): Promise { apiProxyIp: networkConfig.proxyIp, apiProxyPorts: Object.values(API_PROXY_PORTS), } : {}), + // Allow trusted topology peers (MCP gateway, DIFC/cli-proxy) on any port in + // network-isolation mode, for proxy clients that ignore NO_PROXY. DNS for + // these Docker-only names is provided via the squid-proxy extra_hosts patch + // (see patchComposeWithTopologyHosts in topology.ts). + topologyPeers: resolveTopologyPeerHosts(config), }); const squidConfigPath = path.join(config.workDir, 'squid.conf'); fs.writeFileSync(squidConfigPath, squidConfig, { mode: 0o644 }); diff --git a/src/squid-config-topology-peers.test.ts b/src/squid-config-topology-peers.test.ts new file mode 100644 index 000000000..df6d8fb0b --- /dev/null +++ b/src/squid-config-topology-peers.test.ts @@ -0,0 +1,67 @@ +import { generateSquidConfig } from './squid-config'; + +describe('generateSquidConfig: topology peers (network-isolation)', () => { + const port = 3128; + + it('emits a dstdomain allow rule for each topology peer', () => { + const config = generateSquidConfig({ + domains: ['github.com'], + port, + topologyPeers: ['awmg-mcpg', 'awmg-cli-proxy'], + }); + expect(config).toContain('acl topology_peer_awmg_mcpg dstdomain .awmg-mcpg'); + expect(config).toContain('http_access allow topology_peer_awmg_mcpg'); + expect(config).toContain('acl topology_peer_awmg_cli_proxy dstdomain .awmg-cli-proxy'); + expect(config).toContain('http_access allow topology_peer_awmg_cli_proxy'); + }); + + it('places the peer allow before the Safe_ports deny (so any port is reachable)', () => { + const config = generateSquidConfig({ + domains: ['github.com'], + port, + topologyPeers: ['awmg-mcpg'], + }); + const allowPos = config.indexOf('http_access allow topology_peer_awmg_mcpg'); + const safePortsDenyPos = config.indexOf('http_access deny !Safe_ports'); + expect(allowPos).toBeGreaterThan(-1); + expect(safePortsDenyPos).toBeGreaterThan(-1); + expect(allowPos).toBeLessThan(safePortsDenyPos); + }); + + it('places the peer allow before the raw-IP deny rules', () => { + const config = generateSquidConfig({ + domains: ['github.com'], + port, + topologyPeers: ['awmg-mcpg'], + }); + const allowPos = config.indexOf('http_access allow topology_peer_awmg_mcpg'); + const rawIpDenyPos = config.indexOf('http_access deny dst_ipv4'); + expect(allowPos).toBeLessThan(rawIpDenyPos); + }); + + it('emits no topology peer rules when the list is empty or omitted', () => { + const withEmpty = generateSquidConfig({ domains: ['github.com'], port, topologyPeers: [] }); + const withUndefined = generateSquidConfig({ domains: ['github.com'], port }); + expect(withEmpty).not.toContain('topology_peer_'); + expect(withUndefined).not.toContain('topology_peer_'); + }); + + it('sanitizes peer names into safe ACL identifiers', () => { + const config = generateSquidConfig({ + domains: ['github.com'], + port, + topologyPeers: ['mcp.gateway-01'], + }); + expect(config).toContain('acl topology_peer_mcp_gateway_01 dstdomain .mcp.gateway-01'); + }); + + it('rejects a peer name containing squid-config-breaking characters', () => { + expect(() => + generateSquidConfig({ + domains: ['github.com'], + port, + topologyPeers: ['evil\nhttp_access allow all'], + }) + ).toThrow(/unsafe for Squid config/i); + }); +}); diff --git a/src/squid/config-generator.ts b/src/squid/config-generator.ts index 6e644dfe3..e5ce04797 100644 --- a/src/squid/config-generator.ts +++ b/src/squid/config-generator.ts @@ -32,7 +32,7 @@ const { version: AWF_VERSION } = require('../../package.json') as { version: str * // Blocked: internal.example.com -> acl blocked_domains dstdomain .internal.example.com */ export function generateSquidConfig(config: SquidConfig): string { - const { domains, blockedDomains, port, sslBump, caFiles, sslDbPath, urlPatterns, enableHostAccess, allowHostPorts, enableDlp, dnsServers, upstreamProxy, apiProxyIp, apiProxyPorts } = config; + const { domains, blockedDomains, port, sslBump, caFiles, sslDbPath, urlPatterns, enableHostAccess, allowHostPorts, enableDlp, dnsServers, upstreamProxy, apiProxyIp, apiProxyPorts, topologyPeers } = config; validateApiProxyIp(apiProxyIp); @@ -61,6 +61,7 @@ export function generateSquidConfig(config: SquidConfig): string { apiProxySection, allowedIpSection, dnsSection, + topologyPeersSection, } = buildConfigSections({ enableDlp, port, @@ -76,6 +77,7 @@ export function generateSquidConfig(config: SquidConfig): string { apiProxyPorts, apiProxyIp, dnsServers, + topologyPeers, }); return `# Squid configuration for egress traffic control @@ -119,7 +121,7 @@ acl localnet src 192.168.0.0/16 acl localnet src fc00::/7 acl localnet src fe80::/10 -${portAclsAndRules} +${topologyPeersSection}${portAclsAndRules} ${apiProxySection} ${allowedIpSection}# Deny CONNECT to raw IP addresses (IPv4 and IPv6) # Prevents bypassing domain-based filtering via direct IP connections diff --git a/src/squid/config-sections.ts b/src/squid/config-sections.ts index 2dfa59dfe..f03c396c4 100644 --- a/src/squid/config-sections.ts +++ b/src/squid/config-sections.ts @@ -3,6 +3,7 @@ import { generateDlpSquidConfig } from '../dlp'; import { DEFAULT_DNS_SERVERS } from '../dns-resolver'; import { generateSslBumpSection } from './ssl-bump'; import { validateAndSanitizeHostAccessPort, validateApiProxyPort } from './validation'; +import { formatDomainForSquid } from './domain-acl'; type DomainsByProto = ReturnType['domainsByProto']; type PatternsByProto = ReturnType['patternsByProto']; @@ -141,6 +142,37 @@ http_access allow from_api_proxy const IPV4_REGEX = /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/; +/** + * Generate allow rules for trusted topology-peer hostnames in network-isolation + * (topology) mode. + * + * Docker-internal peers such as an MCP gateway (`awmg-mcpg`) are typically + * served on non-standard ports (e.g. 8080). The normal domain allowlist cannot + * reach them because `http_access deny !Safe_ports` (80/443) fires first. These + * peers are operator-attached and trusted (the same trust level as the direct + * NO_PROXY connection), so we emit an explicit `http_access allow` per peer + * *before* the Safe_ports and raw-IP deny rules, permitting any port. + * + * Scoped by dstdomain to the exact peer hostnames, so this does not widen access + * to arbitrary internet destinations. + */ +function generateTopologyPeersSection(topologyPeers?: string[]): string { + if (!topologyPeers || topologyPeers.length === 0) return ''; + + const lines = [ + '# Allow trusted topology peers (network-isolation mode) on any port before', + '# the Safe_ports and raw-IP deny rules. These are operator-attached', + '# containers (e.g. an MCP gateway) served on non-standard ports; clients', + '# that honour HTTP(S)_PROXY but ignore NO_PROXY reach them through Squid.', + ]; + for (const peer of topologyPeers) { + const aclName = `topology_peer_${peer.replace(/[^a-zA-Z0-9]/g, '_')}`; + lines.push(`acl ${aclName} dstdomain ${formatDomainForSquid(peer)}`); + lines.push(`http_access allow ${aclName}`); + } + return lines.join('\n') + '\n\n'; +} + /** * Generate allow rules for raw IP addresses that appear in the allowed domains list. * @@ -185,6 +217,7 @@ function generateConfigSections(options: { apiProxyPorts?: number[]; apiProxyIp?: string; dnsServers?: string[]; + topologyPeers?: string[]; }): { dlpAclSection: string; dlpAccessSection: string; @@ -195,6 +228,7 @@ function generateConfigSections(options: { apiProxySection: string; allowedIpSection: string; dnsSection: string; + topologyPeersSection: string; } { const { enableDlp, @@ -211,6 +245,7 @@ function generateConfigSections(options: { apiProxyPorts, apiProxyIp, dnsServers, + topologyPeers, } = options; const { aclSection: dlpAclSection, accessSection: dlpAccessSection } = generateDlpSections(enableDlp); @@ -226,6 +261,7 @@ function generateConfigSections(options: { const portAclsAndRules = generatePortAclsAndRules(enableHostAccess, allowHostPorts, apiProxyPorts); const apiProxySection = generateApiProxySection(apiProxyIp); const allowedIpSection = generateAllowedIpSection(domains ?? []); + const topologyPeersSection = generateTopologyPeersSection(topologyPeers); const dnsSection = generateDnsSection(dnsServers); return { @@ -238,6 +274,7 @@ function generateConfigSections(options: { apiProxySection, allowedIpSection, dnsSection, + topologyPeersSection, }; } diff --git a/src/topology-peers.test.ts b/src/topology-peers.test.ts new file mode 100644 index 000000000..8ae3d9eda --- /dev/null +++ b/src/topology-peers.test.ts @@ -0,0 +1,63 @@ +import { resolveTopologyPeerHosts } from './topology-peers'; + +describe('resolveTopologyPeerHosts', () => { + it('returns topology-attach names in network-isolation + compose runtime', () => { + expect( + resolveTopologyPeerHosts({ + networkIsolation: true, + topologyAttach: ['awmg-mcpg', 'awmg-cli-proxy'], + }) + ).toEqual(['awmg-mcpg', 'awmg-cli-proxy']); + }); + + it('returns [] when network isolation is off', () => { + expect( + resolveTopologyPeerHosts({ + networkIsolation: false, + topologyAttach: ['awmg-mcpg'], + }) + ).toEqual([]); + }); + + it('returns [] for a non-compose runtime (e.g. sbx)', () => { + expect( + resolveTopologyPeerHosts({ + networkIsolation: true, + containerRuntime: 'sbx', + topologyAttach: ['awmg-mcpg'], + }) + ).toEqual([]); + }); + + it('includes the DIFC/cli-proxy host with scheme and port stripped', () => { + expect( + resolveTopologyPeerHosts({ + networkIsolation: true, + difcProxyHost: 'https://awmg-cli-proxy:18443', + }) + ).toEqual(['awmg-cli-proxy']); + }); + + it('deduplicates when the DIFC host is also in topologyAttach', () => { + expect( + resolveTopologyPeerHosts({ + networkIsolation: true, + topologyAttach: ['awmg-cli-proxy'], + difcProxyHost: 'awmg-cli-proxy:18443', + }) + ).toEqual(['awmg-cli-proxy']); + }); + + it('ignores empty / non-string topologyAttach entries', () => { + expect( + resolveTopologyPeerHosts({ + networkIsolation: true, + topologyAttach: ['awmg-mcpg', '', ' ', 42 as unknown as string], + }) + ).toEqual(['awmg-mcpg']); + }); + + it('returns [] when nothing is attached', () => { + expect(resolveTopologyPeerHosts({ networkIsolation: true, topologyAttach: [] })).toEqual([]); + }); +}); diff --git a/src/topology-peers.ts b/src/topology-peers.ts new file mode 100644 index 000000000..f0267c06d --- /dev/null +++ b/src/topology-peers.ts @@ -0,0 +1,48 @@ +import { runtimeUsesComposeAgent } from './container-runtime'; +import { parseDifcProxyHost } from './host-env'; + +/** + * The subset of options needed to resolve trusted topology-peer hostnames. + * Deliberately structural so both raw CLI options (preflight) and the resolved + * {@link WrapperConfig} (config-writer) can be passed without adapters. + */ +export interface TopologyPeerOptions { + networkIsolation?: unknown; + containerRuntime?: string; + topologyAttach?: unknown; + difcProxyHost?: unknown; +} + +/** + * Resolves the set of trusted topology-peer hostnames that must be reachable + * through Squid in network-isolation (topology) mode. + * + * Peers are the `--topology-attach` container names plus the DIFC/cli-proxy + * host (`--difc-proxy-host`, scheme/port stripped). The gating mirrors + * `buildProxyEnvironment()` exactly: only meaningful when the agent actually + * shares `awf-net`, i.e. a compose-managed agent (runc, gVisor). microVM + * backends (Docker sbx) run the agent off `awf-net` and reach peers via their + * own proxy-chaining path, so peers are skipped there — keeping the Squid ACL, + * DNS, and NO_PROXY code paths consistent. + * + * @returns Deduplicated peer hostnames, or `[]` when not applicable. + */ +export function resolveTopologyPeerHosts(options: TopologyPeerOptions): string[] { + if (!options.networkIsolation || !runtimeUsesComposeAgent(options.containerRuntime)) { + return []; + } + + const peers: string[] = []; + if (Array.isArray(options.topologyAttach)) { + for (const name of options.topologyAttach) { + if (typeof name === 'string' && name.trim() !== '') { + peers.push(name); + } + } + } + if (typeof options.difcProxyHost === 'string' && options.difcProxyHost.trim() !== '') { + peers.push(parseDifcProxyHost(options.difcProxyHost).host); + } + + return [...new Set(peers)]; +} diff --git a/src/topology.test.ts b/src/topology.test.ts index bb1561eb0..2dcda3dee 100644 --- a/src/topology.test.ts +++ b/src/topology.test.ts @@ -252,6 +252,36 @@ describe('topology', () => { }); }); + it('also patches the squid-proxy service extra_hosts so Squid can resolve peers', () => { + const compose = { + services: { + agent: { container_name: 'awf-agent' }, + 'squid-proxy': { container_name: 'awf-squid' }, + }, + }; + fs.writeFileSync(path.join(tmpDir, 'docker-compose.yml'), yaml.dump(compose)); + const log = { info: jest.fn(), warn: jest.fn() }; + + const peerIps = new Map([['awmg-mcpg', '172.30.0.40']]); + patchComposeWithTopologyHosts(tmpDir, peerIps, log); + + const patched = yaml.load(fs.readFileSync(path.join(tmpDir, 'docker-compose.yml'), 'utf8')) as any; + expect(patched.services['squid-proxy'].extra_hosts).toEqual({ 'awmg-mcpg': '172.30.0.40' }); + expect(patched.services.agent.extra_hosts).toEqual({ 'awmg-mcpg': '172.30.0.40' }); + }); + + it('warns when squid-proxy service is missing but still patches the agent', () => { + const compose = { services: { agent: { container_name: 'awf-agent' } } }; + fs.writeFileSync(path.join(tmpDir, 'docker-compose.yml'), yaml.dump(compose)); + const log = { info: jest.fn(), warn: jest.fn() }; + + patchComposeWithTopologyHosts(tmpDir, new Map([['awmg-mcpg', '172.30.0.40']]), log); + + const patched = yaml.load(fs.readFileSync(path.join(tmpDir, 'docker-compose.yml'), 'utf8')) as any; + expect(patched.services.agent.extra_hosts).toEqual({ 'awmg-mcpg': '172.30.0.40' }); + expect(log.warn).toHaveBeenCalledWith(expect.stringContaining('Could not find squid-proxy service')); + }); + it('warns and returns when agent service is not found', () => { const compose = { services: { squid: {} } }; fs.writeFileSync(path.join(tmpDir, 'docker-compose.yml'), yaml.dump(compose)); diff --git a/src/topology.ts b/src/topology.ts index 16e0797c3..3caae70ca 100644 --- a/src/topology.ts +++ b/src/topology.ts @@ -210,6 +210,26 @@ export function patchComposeWithTopologyHosts( agentService.extra_hosts[name] = ip; } + // Also give the squid-proxy container the same name→IP mappings. Squid resolves + // proxied destinations via its external `dns_nameservers`, which cannot resolve + // Docker-only peer hostnames (e.g. `awmg-mcpg`). Squid reads `/etc/hosts` + // (hosts_file directive) before falling back to DNS, so extra_hosts lets it + // resolve these peers. Without this, a NO_PROXY-ignoring client's request + // passes the domain ACL but fails when Squid tries to resolve the peer name. + // The phased startup patches this before the full `docker compose up`, which + // recreates squid-proxy to pick up the new extra_hosts. + const squidService = compose?.services?.['squid-proxy']; + if (squidService) { + if (!squidService.extra_hosts) { + squidService.extra_hosts = {}; + } + for (const [name, ip] of peerIps) { + squidService.extra_hosts[name] = ip; + } + } else { + log.warn('Could not find squid-proxy service in docker-compose.yml; skipping Squid topology DNS patch'); + } + fs.writeFileSync(composePath, yaml.dump(compose, { lineWidth: -1 }), { mode: 0o600 }); log.info(`Patched docker-compose.yml with ${peerIps.size} topology peer host(s) for static DNS compatibility`); diff --git a/src/types/squid.ts b/src/types/squid.ts index 242983cb9..96cc8f700 100644 --- a/src/types/squid.ts +++ b/src/types/squid.ts @@ -148,4 +148,22 @@ export interface SquidConfig { * do not block connections to the api-proxy before the allow rule fires. */ apiProxyPorts?: number[]; + + /** + * Trusted topology-peer hostnames reachable through Squid in network-isolation + * (topology) mode — the `--topology-attach` container names plus the DIFC/ + * cli-proxy host. + * + * These Docker-internal peers (e.g. an MCP gateway `awmg-mcpg`) are served on + * non-standard ports (e.g. 8080) and cannot be reached via the normal + * domain allowlist, which is gated behind `http_access deny !Safe_ports` + * (80/443 only). For each peer an explicit `http_access allow` (dstdomain) is + * emitted *before* the Safe_ports and raw-IP deny rules, so proxy clients that + * honour `HTTP(S)_PROXY` but ignore `NO_PROXY` can reach the peer on any port. + * + * DNS resolution of these Docker-only names is provided separately by patching + * the squid-proxy container's `extra_hosts` (see topology.ts), since Squid's + * external `dns_nameservers` cannot resolve them. + */ + topologyPeers?: string[]; }