diff --git a/src/commands/main-action.ts b/src/commands/main-action.ts index 9c92f496c..ecc917363 100644 --- a/src/commands/main-action.ts +++ b/src/commands/main-action.ts @@ -32,7 +32,7 @@ import type { WrapperConfig } from '../types'; import { buildAgentEnvironment } from '../services/agent-service'; import { buildAgentCredentialEnv } from '../services/api-proxy-credential-env'; import { DEFAULT_DNS_SERVERS } from '../dns-resolver'; -import { AGENT_IP, CLI_PROXY_IP, DOH_PROXY_IP, SQUID_IP } from '../host-iptables-shared'; +import { AGENT_IP, CLI_PROXY_IP, DOH_PROXY_IP, NETWORK_SUBNET, SQUID_IP } from '../host-iptables-shared'; /** Report whether a secret is set (and its length) without exposing the value. */ function redactSecret(value: string | undefined): string { @@ -279,7 +279,7 @@ export function createMainAction(getOptionValueSource: OptionSourceResolver) { sbxEnvironment = buildAgentEnvironment({ config, networkConfig: { - subnet: '172.30.0.0/24', + subnet: NETWORK_SUBNET, squidIp: SBX_GATEWAY_IP, agentIp: AGENT_IP, proxyIp: config.enableApiProxy ? SBX_HOST_DOCKER_INTERNAL : undefined, @@ -297,7 +297,7 @@ export function createMainAction(getOptionValueSource: OptionSourceResolver) { const credentialEnv = buildAgentCredentialEnv({ config, networkConfig: { - subnet: '172.30.0.0/24', + subnet: NETWORK_SUBNET, squidIp: SBX_GATEWAY_IP, agentIp: AGENT_IP, proxyIp: SBX_HOST_DOCKER_INTERNAL, diff --git a/src/compose-generator.ts b/src/compose-generator.ts index 53ae3c6ae..0fbb164f1 100644 --- a/src/compose-generator.ts +++ b/src/compose-generator.ts @@ -12,6 +12,7 @@ import { assembleOptionalServices } from './services/optional-services'; import { buildComposeNetworks } from './compose-network'; import { runtimeUsesComposeAgent } from './container-runtime'; import { API_PROXY_PORTS } from './types/ports'; +import { EXTERNAL_BRIDGE_NAME } from './config/network-policy'; /** * Generates Docker Compose configuration @@ -153,7 +154,7 @@ export function generateDockerCompose( if (config.networkIsolation) { proxyService.networks = { ...(proxyService.networks || {}), - 'awf-ext': {}, + [EXTERNAL_BRIDGE_NAME]: {}, }; } } diff --git a/src/compose-network.ts b/src/compose-network.ts index 0abd02911..52072c233 100644 --- a/src/compose-network.ts +++ b/src/compose-network.ts @@ -1,5 +1,6 @@ import { DockerComposeConfig } from './types'; import { TOPOLOGY_NETWORK_NAME } from './topology'; +import { EXTERNAL_BRIDGE_NAME, EMBEDDED_DNS_RESOLVER } from './config/network-policy'; import { NetworkConfig } from './services/squid-service'; interface BuildComposeNetworksParams { @@ -35,14 +36,14 @@ export function buildComposeNetworks(params: BuildComposeNetworksParams): Docker // sole egress path. No host iptables and no NET_ADMIN are involved. squidService.networks = { ...(squidService.networks || {}), - 'awf-ext': {}, + [EXTERNAL_BRIDGE_NAME]: {}, }; // The agent must resolve names via Docker's embedded resolver (127.0.0.11), // which forwards through the daemon's network rather than the agent's, so it // still works on an internal network. The configured external DNS servers are // unreachable from an internal network. - agentService.dns = ['127.0.0.11']; + agentService.dns = [EMBEDDED_DNS_RESOLVER]; return { services, @@ -54,14 +55,13 @@ export function buildComposeNetworks(params: BuildComposeNetworksParams): Docker config: [{ subnet: networkConfig.subnet }], }, }, - 'awf-ext': { + [EXTERNAL_BRIDGE_NAME]: { driver: 'bridge', }, }, ...(namedVolumes && { volumes: namedVolumes }), }; } - return { services, networks: { diff --git a/src/config/network-policy.test.ts b/src/config/network-policy.test.ts new file mode 100644 index 000000000..0783931a8 --- /dev/null +++ b/src/config/network-policy.test.ts @@ -0,0 +1,228 @@ +import { + networkPolicy, + NETWORK_NAME, + EXTERNAL_BRIDGE_NAME, + NETWORK_SUBNET, + SQUID_IP, + AGENT_IP, + API_PROXY_IP, + DOH_PROXY_IP, + CLI_PROXY_IP, + SQUID_PORT, + CLI_PROXY_PORT, + DOH_UPSTREAM_PORT, + EMBEDDED_DNS_RESOLVER, + DEFAULT_DNS_SERVERS, + DNS_PORT, + API_PROXY_HEALTH_PORT, + HOST_GATEWAY, + apiProxyPorts, + blockedPortNumbers, + blockedCidrs, +} from './network-policy'; + +import * as iptablesShared from '../host-iptables-shared'; +import { SQUID_PORT as CONSTANTS_SQUID_PORT } from '../constants'; +import { DEFAULT_DNS_SERVERS as RESOLVER_DEFAULT_DNS } from '../dns-resolver'; +import { TOPOLOGY_NETWORK_NAME } from '../topology'; +import { API_PROXY_PORTS, API_PROXY_HEALTH_PORT as PORTS_HEALTH, CLI_PROXY_PORT as PORTS_CLI } from '../types/ports'; + +const IPV4 = /^(?:\d{1,3}\.){3}\d{1,3}$/; + +/** Returns true if `ip` falls inside the /24 CIDR `subnet` (e.g. 172.30.0.0/24). */ +function ipInSubnet24(ip: string, subnet: string): boolean { + const [net] = subnet.split('/'); + const netPrefix = net.split('.').slice(0, 3).join('.'); + const ipPrefix = ip.split('.').slice(0, 3).join('.'); + return netPrefix === ipPrefix; +} + +describe('network-policy', () => { + describe('loads and validates the JSON', () => { + it('exposes a frozen, fully-typed policy', () => { + expect(networkPolicy.topology.networkName).toBe('awf-net'); + expect(networkPolicy.proxies.squid.port).toBe(3128); + expect(networkPolicy.dns.embeddedResolver).toBe('127.0.0.11'); + expect(networkPolicy.legacyIptables.blockedPorts.length).toBeGreaterThan(0); + }); + + it('deeply freezes the policy tree, including nested objects and arrays', () => { + expect(Object.isFrozen(networkPolicy)).toBe(true); + expect(Object.isFrozen(networkPolicy.topology)).toBe(true); + expect(Object.isFrozen(networkPolicy.topology.hosts)).toBe(true); + expect(Object.isFrozen(networkPolicy.topology.hosts.squid)).toBe(true); + expect(Object.isFrozen(networkPolicy.proxies.apiProxy.ports)).toBe(true); + expect(Object.isFrozen(networkPolicy.dns.defaultUpstreamServers)).toBe(true); + expect(Object.isFrozen(networkPolicy.legacyIptables.blockedPorts)).toBe(true); + expect(Object.isFrozen(networkPolicy.legacyIptables.blockedPorts[0])).toBe(true); + }); + + it('prevents live accessors from diverging via mutation', () => { + // The object returned by apiProxyPorts() is the frozen policy node, so a + // stray mutation cannot silently change it out from under other callers. + expect(Object.isFrozen(apiProxyPorts())).toBe(true); + expect(() => { + (apiProxyPorts() as { openai: number }).openai = 1; + }).toThrow(); + }); + }); + + describe('topology invariants', () => { + const hosts = networkPolicy.topology.hosts; + + it('assigns every host a valid IPv4 within the subnet', () => { + for (const [name, host] of Object.entries(hosts)) { + expect(host.ip).toMatch(IPV4); + expect(ipInSubnet24(host.ip, networkPolicy.topology.subnet)).toBe(true); + expect(name).toBeTruthy(); + } + }); + + it('assigns unique IPs to all hosts', () => { + const ips = Object.values(hosts).map((h) => h.ip); + expect(new Set(ips).size).toBe(ips.length); + }); + + it('marks squid and agent required, sidecars optional', () => { + expect(hosts.squid.required).toBe(true); + expect(hosts.agent.required).toBe(true); + expect(hosts.apiProxy.required).toBe(false); + expect(hosts.dohProxy.required).toBe(false); + expect(hosts.cliProxy.required).toBe(false); + }); + + it('dual-homes only squid (the sole egress)', () => { + expect(hosts.squid.dualHomed).toBe(true); + for (const [name, host] of Object.entries(hosts)) { + if (name !== 'squid') expect(host.dualHomed).toBeUndefined(); + } + }); + }); + + describe('port invariants', () => { + it('keeps every port in the valid 1-65535 range', () => { + const allPorts = [ + SQUID_PORT, + CLI_PROXY_PORT, + DOH_UPSTREAM_PORT, + DNS_PORT, + API_PROXY_HEALTH_PORT, + ...Object.values(apiProxyPorts()), + ...blockedPortNumbers(), + ]; + for (const port of allPorts) { + expect(Number.isInteger(port)).toBe(true); + expect(port).toBeGreaterThanOrEqual(1); + expect(port).toBeLessThanOrEqual(65535); + } + }); + + it('uses the OpenAI port as the API proxy health port', () => { + expect(API_PROXY_HEALTH_PORT).toBe(apiProxyPorts().openai); + }); + + it('keeps the CLI proxy port clear of the api-proxy range', () => { + const api = Object.values(apiProxyPorts()); + expect(api).not.toContain(CLI_PROXY_PORT); + }); + + it('assigns unique api-proxy provider ports', () => { + const api = Object.values(apiProxyPorts()); + expect(new Set(api).size).toBe(api.length); + }); + }); + + describe('legacy iptables deny lists', () => { + it('lists unique blocked ports', () => { + const ports = blockedPortNumbers(); + expect(new Set(ports).size).toBe(ports.length); + }); + + it('blocks the well-known dangerous ports', () => { + // Guards against silent drops from the historical DANGEROUS_PORTS list. + for (const p of [22, 23, 25, 445, 1433, 3306, 3389, 5432, 6379, 27017]) { + expect(blockedPortNumbers()).toContain(p); + } + }); + + it('blocks the cloud metadata / link-local and multicast ranges', () => { + expect(blockedCidrs()).toContain('169.254.0.0/16'); + expect(blockedCidrs()).toContain('224.0.0.0/4'); + }); + + it('lists unique blocked CIDRs', () => { + const cidrs = blockedCidrs(); + expect(new Set(cidrs).size).toBe(cidrs.length); + }); + }); + + describe('non-behavioral refactor parity', () => { + it('preserves the historical topology constants', () => { + expect(NETWORK_NAME).toBe('awf-net'); + expect(EXTERNAL_BRIDGE_NAME).toBe('awf-ext'); + expect(NETWORK_SUBNET).toBe('172.30.0.0/24'); + expect(HOST_GATEWAY).toBe('172.30.0.1'); + expect(SQUID_IP).toBe('172.30.0.10'); + expect(AGENT_IP).toBe('172.30.0.20'); + expect(API_PROXY_IP).toBe('172.30.0.30'); + expect(DOH_PROXY_IP).toBe('172.30.0.40'); + expect(CLI_PROXY_IP).toBe('172.30.0.50'); + }); + + it('preserves the historical port constants', () => { + expect(SQUID_PORT).toBe(3128); + expect(CLI_PROXY_PORT).toBe(11000); + expect(DOH_UPSTREAM_PORT).toBe(443); + expect(apiProxyPorts()).toEqual({ + openai: 10000, + anthropic: 10001, + copilot: 10002, + gemini: 10003, + vertex: 10004, + }); + }); + + it('preserves the historical DNS constants', () => { + expect(EMBEDDED_DNS_RESOLVER).toBe('127.0.0.11'); + expect(DEFAULT_DNS_SERVERS).toEqual(['8.8.8.8', '8.8.4.4']); + expect(DNS_PORT).toBe(53); + }); + + it('returns a fresh mutable DEFAULT_DNS_SERVERS array each import site can pass by value', () => { + expect(Array.isArray(DEFAULT_DNS_SERVERS)).toBe(true); + // Not the frozen policy array — callers historically received a mutable list. + expect(DEFAULT_DNS_SERVERS).not.toBe(networkPolicy.dns.defaultUpstreamServers); + }); + }); + + describe('downstream modules stay in sync with the policy', () => { + it('host-iptables-shared re-exports the same topology values', () => { + expect(iptablesShared.NETWORK_NAME).toBe(NETWORK_NAME); + expect(iptablesShared.NETWORK_SUBNET).toBe(NETWORK_SUBNET); + expect(iptablesShared.AWF_NETWORK_GATEWAY).toBe(HOST_GATEWAY); + expect(iptablesShared.SQUID_IP).toBe(SQUID_IP); + expect(iptablesShared.AGENT_IP).toBe(AGENT_IP); + expect(iptablesShared.API_PROXY_IP).toBe(API_PROXY_IP); + expect(iptablesShared.DOH_PROXY_IP).toBe(DOH_PROXY_IP); + expect(iptablesShared.CLI_PROXY_IP).toBe(CLI_PROXY_IP); + }); + + it('constants, dns-resolver and topology re-export policy values', () => { + expect(CONSTANTS_SQUID_PORT).toBe(SQUID_PORT); + expect(RESOLVER_DEFAULT_DNS).toEqual(DEFAULT_DNS_SERVERS); + expect(TOPOLOGY_NETWORK_NAME).toBe(NETWORK_NAME); + }); + + it('types/ports maps the policy into the historical shape', () => { + expect(API_PROXY_PORTS).toEqual({ + OPENAI: 10000, + ANTHROPIC: 10001, + COPILOT: 10002, + GEMINI: 10003, + VERTEX: 10004, + }); + expect(PORTS_HEALTH).toBe(API_PROXY_HEALTH_PORT); + expect(PORTS_CLI).toBe(CLI_PROXY_PORT); + }); + }); +}); diff --git a/src/config/network-policy.ts b/src/config/network-policy.ts new file mode 100644 index 000000000..b4d524a19 --- /dev/null +++ b/src/config/network-policy.ts @@ -0,0 +1,393 @@ +import rawPolicy from './sandbox-network-policy.json'; + +/** + * Central, declarative NETWORK policy shared by every runtime. + * + * The data lives in {@link ./sandbox-network-policy.json} so the network + * topology (network name, subnet, static sidecar IPs), proxy ports, DNS + * configuration, and the legacy iptables deny lists have a single source of + * truth and cannot drift between the CLI, the compose generators, the Squid + * config, the host-iptables backend, and the container NAT script. + * + * The PRIMARY, default path is network-isolation (topology) mode, where the + * agent runs on an `internal` Docker network whose only egress is the + * dual-homed Squid proxy — no iptables, host or container. The values consumed + * by that path live under {@link NetworkPolicy.topology}, {@link + * NetworkPolicy.proxies}, and {@link NetworkPolicy.dns}. The {@link + * NetworkPolicy.legacyIptables} section is consumed only by the uncommon + * `--legacy-security` / `--enable-host-access` path. + * + * Every value here is a logical Docker network address (subnet, static IP, + * port, network name) resolved by the daemon — none carries a host-filesystem + * assumption, so the policy is ARC/DinD safe and needs no + * `--docker-host-path-prefix` translation. + * + * The JSON is imported statically (via `resolveJsonModule`) so it is emitted to + * `dist/` by `tsc` and inlined by the esbuild release bundle — no runtime file + * read or extra packaging step is required. + */ + +/** Role of a static host on the internal topology network (documentation only). */ +export type NetworkHostRole = + | 'egress-proxy' + | 'workload' + | 'credential-proxy' + | 'dns-proxy' + | 'difc-proxy'; + +/** A single static host assigned a fixed IP on the internal topology network. */ +export interface NetworkHost { + /** Fixed IPv4 address on the internal `awf-net` network. */ + readonly ip: string; + /** Human-readable role of the host (documentation only). */ + readonly role: NetworkHostRole; + /** Whether this host is always present (Squid, agent) or optional (sidecars). */ + readonly required: boolean; + /** Whether the host is dual-homed onto the external bridge (Squid only). */ + readonly dualHomed?: boolean; +} + +/** A blocked TCP port in the legacy iptables deny list. */ +export interface BlockedPort { + readonly port: number; + readonly reason: string; +} + +/** A blocked destination CIDR in the legacy iptables deny list. */ +export interface BlockedCidr { + readonly cidr: string; + readonly reason: string; +} + +/** The fully-typed, validated network policy. */ +export interface NetworkPolicy { + readonly topology: { + readonly networkName: string; + readonly externalBridgeName: string; + readonly subnet: string; + readonly hosts: { + readonly squid: NetworkHost; + readonly agent: NetworkHost; + readonly apiProxy: NetworkHost; + readonly dohProxy: NetworkHost; + readonly cliProxy: NetworkHost; + }; + }; + readonly proxies: { + readonly squid: { readonly port: number }; + readonly apiProxy: { + readonly ports: { + readonly openai: number; + readonly anthropic: number; + readonly copilot: number; + readonly gemini: number; + readonly vertex: number; + }; + readonly healthPort: number; + }; + readonly cliProxy: { readonly port: number }; + readonly dohProxy: { readonly upstreamPort: number }; + }; + readonly dns: { + readonly embeddedResolver: string; + readonly defaultUpstreamServers: readonly string[]; + readonly port: number; + }; + readonly legacyIptables: { + readonly hostGateway: string; + readonly blockedPorts: readonly BlockedPort[]; + readonly blockedCidrs: readonly BlockedCidr[]; + }; +} + +function fail(message: string): never { + throw new Error(`Invalid sandbox-network-policy.json: ${message}`); +} + +function asObject(value: unknown, label: string): Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + fail(`${label} must be an object`); + } + return value as Record; +} + +function assertString(value: unknown, label: string): string { + if (typeof value !== 'string' || value.length === 0) { + fail(`${label} must be a non-empty string`); + } + return value; +} + +/** Validates a TCP/UDP port: an integer in the range 1–65535. */ +function assertPort(value: unknown, label: string): number { + if (typeof value !== 'number' || !Number.isInteger(value) || value < 1 || value > 65535) { + fail(`${label} must be an integer port in 1-65535`); + } + return value; +} + +const IPV4_RE = /^(?:\d{1,3}\.){3}\d{1,3}$/; +const CIDR_RE = /^(?:\d{1,3}\.){3}\d{1,3}\/(?:\d|[12]\d|3[0-2])$/; + +function assertIpv4(value: unknown, label: string): string { + const s = assertString(value, label); + if (!IPV4_RE.test(s) || s.split('.').some((o) => Number(o) > 255)) { + fail(`${label} must be a valid IPv4 address: ${s}`); + } + return s; +} + +function assertCidr(value: unknown, label: string): string { + const s = assertString(value, label); + const [addr] = s.split('/'); + if (!CIDR_RE.test(s) || addr.split('.').some((o) => Number(o) > 255)) { + fail(`${label} must be a valid IPv4 CIDR: ${s}`); + } + return s; +} + +const VALID_ROLES: ReadonlySet = new Set([ + 'egress-proxy', + 'workload', + 'credential-proxy', + 'dns-proxy', + 'difc-proxy', +]); + +function parseHost(value: unknown, label: string): NetworkHost { + const h = asObject(value, label); + const ip = assertIpv4(h.ip, `${label}.ip`); + const role = assertString(h.role, `${label}.role`); + if (!VALID_ROLES.has(role)) { + fail(`${label}.role is not a recognized role: ${role}`); + } + if (typeof h.required !== 'boolean') { + fail(`${label}.required must be a boolean`); + } + if (h.dualHomed !== undefined && typeof h.dualHomed !== 'boolean') { + fail(`${label}.dualHomed must be a boolean when present`); + } + return { + ip, + role: role as NetworkHostRole, + required: h.required, + ...(h.dualHomed !== undefined && { dualHomed: h.dualHomed }), + }; +} + +function parseHosts(value: unknown): NetworkPolicy['topology']['hosts'] { + const h = asObject(value, 'topology.hosts'); + const hosts = { + squid: parseHost(h.squid, 'topology.hosts.squid'), + agent: parseHost(h.agent, 'topology.hosts.agent'), + apiProxy: parseHost(h.apiProxy, 'topology.hosts.apiProxy'), + dohProxy: parseHost(h.dohProxy, 'topology.hosts.dohProxy'), + cliProxy: parseHost(h.cliProxy, 'topology.hosts.cliProxy'), + }; + + const seen = new Set(); + for (const [name, host] of Object.entries(hosts)) { + if (seen.has(host.ip)) { + fail(`topology.hosts.${name}.ip is a duplicate address: ${host.ip}`); + } + seen.add(host.ip); + } + return hosts; +} + +function parseTopology(value: unknown): NetworkPolicy['topology'] { + const t = asObject(value, 'topology'); + return { + networkName: assertString(t.networkName, 'topology.networkName'), + externalBridgeName: assertString(t.externalBridgeName, 'topology.externalBridgeName'), + subnet: assertCidr(t.subnet, 'topology.subnet'), + hosts: parseHosts(t.hosts), + }; +} + +function parseProxies(value: unknown): NetworkPolicy['proxies'] { + const p = asObject(value, 'proxies'); + const squid = asObject(p.squid, 'proxies.squid'); + const apiProxy = asObject(p.apiProxy, 'proxies.apiProxy'); + const apiPorts = asObject(apiProxy.ports, 'proxies.apiProxy.ports'); + const cliProxy = asObject(p.cliProxy, 'proxies.cliProxy'); + const dohProxy = asObject(p.dohProxy, 'proxies.dohProxy'); + return { + squid: { port: assertPort(squid.port, 'proxies.squid.port') }, + apiProxy: { + ports: { + openai: assertPort(apiPorts.openai, 'proxies.apiProxy.ports.openai'), + anthropic: assertPort(apiPorts.anthropic, 'proxies.apiProxy.ports.anthropic'), + copilot: assertPort(apiPorts.copilot, 'proxies.apiProxy.ports.copilot'), + gemini: assertPort(apiPorts.gemini, 'proxies.apiProxy.ports.gemini'), + vertex: assertPort(apiPorts.vertex, 'proxies.apiProxy.ports.vertex'), + }, + healthPort: assertPort(apiProxy.healthPort, 'proxies.apiProxy.healthPort'), + }, + cliProxy: { port: assertPort(cliProxy.port, 'proxies.cliProxy.port') }, + dohProxy: { upstreamPort: assertPort(dohProxy.upstreamPort, 'proxies.dohProxy.upstreamPort') }, + }; +} + +function parseDns(value: unknown): NetworkPolicy['dns'] { + const d = asObject(value, 'dns'); + const servers = d.defaultUpstreamServers; + if (!Array.isArray(servers) || servers.length === 0) { + fail('dns.defaultUpstreamServers must be a non-empty array'); + } + const upstream = servers.map((s, i) => assertIpv4(s, `dns.defaultUpstreamServers[${i}]`)); + return { + embeddedResolver: assertIpv4(d.embeddedResolver, 'dns.embeddedResolver'), + defaultUpstreamServers: upstream, + port: assertPort(d.port, 'dns.port'), + }; +} + +function parseLegacyIptables(value: unknown): NetworkPolicy['legacyIptables'] { + const l = asObject(value, 'legacyIptables'); + + const rawPorts = l.blockedPorts; + if (!Array.isArray(rawPorts) || rawPorts.length === 0) { + fail('legacyIptables.blockedPorts must be a non-empty array'); + } + const seenPorts = new Set(); + const blockedPorts = rawPorts.map((entry, i) => { + const e = asObject(entry, `legacyIptables.blockedPorts[${i}]`); + const port = assertPort(e.port, `legacyIptables.blockedPorts[${i}].port`); + if (seenPorts.has(port)) { + fail(`legacyIptables.blockedPorts has duplicate port: ${port}`); + } + seenPorts.add(port); + return { port, reason: assertString(e.reason, `legacyIptables.blockedPorts[${i}].reason`) }; + }); + + const rawCidrs = l.blockedCidrs; + if (!Array.isArray(rawCidrs) || rawCidrs.length === 0) { + fail('legacyIptables.blockedCidrs must be a non-empty array'); + } + const seenCidrs = new Set(); + const blockedCidrs = rawCidrs.map((entry, i) => { + const e = asObject(entry, `legacyIptables.blockedCidrs[${i}]`); + const cidr = assertCidr(e.cidr, `legacyIptables.blockedCidrs[${i}].cidr`); + if (seenCidrs.has(cidr)) { + fail(`legacyIptables.blockedCidrs has duplicate cidr: ${cidr}`); + } + seenCidrs.add(cidr); + return { cidr, reason: assertString(e.reason, `legacyIptables.blockedCidrs[${i}].reason`) }; + }); + + return { + hostGateway: assertIpv4(l.hostGateway, 'legacyIptables.hostGateway'), + blockedPorts, + blockedCidrs, + }; +} + +function validate(input: unknown): NetworkPolicy { + const p = asObject(input, 'root'); + return { + topology: parseTopology(p.topology), + proxies: parseProxies(p.proxies), + dns: parseDns(p.dns), + legacyIptables: parseLegacyIptables(p.legacyIptables), + }; +} + +/** + * Recursively freezes an object tree so the exported policy is immutable at + * runtime, not just in the type system (`readonly` is erased by `tsc`). This + * guarantees live accessors like {@link apiProxyPorts} can never diverge from + * constants captured during module initialization. + */ +function deepFreeze(value: T): T { + if (value !== null && typeof value === 'object') { + for (const child of Object.values(value)) { + deepFreeze(child); + } + Object.freeze(value); + } + return value; +} + +/** The validated, deeply-frozen network policy loaded from the JSON config. */ +export const networkPolicy: NetworkPolicy = deepFreeze(validate(rawPolicy)); + +// --------------------------------------------------------------------------- +// CORE accessors — consumed by the default network-isolation (topology) path. +// --------------------------------------------------------------------------- + +/** Name of the internal Docker network the agent and sidecars share. */ +export const NETWORK_NAME: string = networkPolicy.topology.networkName; + +/** Name of the external bridge network Squid is dual-homed onto (sole egress). */ +export const EXTERNAL_BRIDGE_NAME: string = networkPolicy.topology.externalBridgeName; + +/** IPv4 subnet (CIDR) of the internal topology network, shared by all runtimes. */ +export const NETWORK_SUBNET: string = networkPolicy.topology.subnet; + +/** Fixed IP of the Squid egress proxy on the internal network. */ +export const SQUID_IP: string = networkPolicy.topology.hosts.squid.ip; + +/** Fixed IP of the agent workload container on the internal network. */ +export const AGENT_IP: string = networkPolicy.topology.hosts.agent.ip; + +/** Fixed IP of the optional credential-injecting API proxy sidecar. */ +export const API_PROXY_IP: string = networkPolicy.topology.hosts.apiProxy.ip; + +/** Fixed IP of the optional DNS-over-HTTPS proxy sidecar. */ +export const DOH_PROXY_IP: string = networkPolicy.topology.hosts.dohProxy.ip; + +/** Fixed IP of the optional DIFC/CLI proxy sidecar. */ +export const CLI_PROXY_IP: string = networkPolicy.topology.hosts.cliProxy.ip; + +/** Port the Squid forward proxy listens on. */ +export const SQUID_PORT: number = networkPolicy.proxies.squid.port; + +/** Port the CLI proxy sidecar listens on. */ +export const CLI_PROXY_PORT: number = networkPolicy.proxies.cliProxy.port; + +/** Upstream port the DoH proxy dials (HTTPS). */ +// ts-prune-ignore-next -- part of the policy surface; wired in the step-2 legacy consolidation. +export const DOH_UPSTREAM_PORT: number = networkPolicy.proxies.dohProxy.upstreamPort; + +/** Docker embedded DNS resolver address used by the agent in topology mode. */ +export const EMBEDDED_DNS_RESOLVER: string = networkPolicy.dns.embeddedResolver; + +/** + * Fallback upstream DNS servers when no `--dns-servers` are provided. + * Returned as a fresh mutable array to match the historical `string[]` type + * that callers pass into APIs expecting a mutable list. + */ +export const DEFAULT_DNS_SERVERS: string[] = [...networkPolicy.dns.defaultUpstreamServers]; + +/** The sole permitted DNS port. */ +// ts-prune-ignore-next -- part of the policy surface; wired in the step-2 legacy consolidation. +export const DNS_PORT: number = networkPolicy.dns.port; + +/** Per-provider API proxy listen ports. */ +export function apiProxyPorts(): NetworkPolicy['proxies']['apiProxy']['ports'] { + return networkPolicy.proxies.apiProxy.ports; +} + +/** Health-check port for the API proxy sidecar. */ +export const API_PROXY_HEALTH_PORT: number = networkPolicy.proxies.apiProxy.healthPort; + +// --------------------------------------------------------------------------- +// LEGACY accessors — consumed only by the uncommon host/container iptables path. +// --------------------------------------------------------------------------- + +/** Gateway IP of the internal network (host-iptables path only). */ +// ts-prune-ignore-next -- consumed via host-iptables-shared's AWF_NETWORK_GATEWAY alias. +export const HOST_GATEWAY: string = networkPolicy.legacyIptables.hostGateway; + +/** Blocked TCP port numbers enforced by the iptables backends. */ +// ts-prune-ignore-next -- part of the policy surface; wired in the step-2 legacy consolidation. +export function blockedPortNumbers(): number[] { + return networkPolicy.legacyIptables.blockedPorts.map((p) => p.port); +} + +/** Blocked destination CIDRs enforced by the iptables backends. */ +// ts-prune-ignore-next -- part of the policy surface; wired in the step-2 legacy consolidation. +export function blockedCidrs(): string[] { + return networkPolicy.legacyIptables.blockedCidrs.map((c) => c.cidr); +} diff --git a/src/config/sandbox-network-policy.json b/src/config/sandbox-network-policy.json new file mode 100644 index 000000000..d395dc300 --- /dev/null +++ b/src/config/sandbox-network-policy.json @@ -0,0 +1,61 @@ +{ + "$comment": "Central declarative NETWORK policy shared by ALL runtimes. The PRIMARY, default path is network-isolation (topology) mode: the agent runs on an `internal` Docker network whose only egress is the dual-homed Squid proxy — NO iptables (host or container), no NET_ADMIN. In that mode egress restriction is STRUCTURAL (default-deny by routing): blocked ports/CIDRs have no route out, so they are denied by construction rather than by rules. The `legacyIptables` section below is consumed ONLY by the uncommon --legacy-security / --enable-host-access path (host iptables + the container setup-iptables.sh NAT script). Every value here is a logical Docker network address (subnet, static IP, port, network name) that the daemon resolves — it carries NO host-filesystem or host-network assumptions, so the policy is ARC/DinD safe and needs no --docker-host-path-prefix translation. Loaded and validated by src/config/network-policy.ts.", + + "topology": { + "$comment": "CORE — consumed by the default iptables-less path. Compose IPAM assigns these static IPs on the internal `networkName`; Squid is dual-homed onto `externalBridgeName` as the sole egress. `subnet` is the fixed subnet all runtimes use. `hostGateway` lives in `legacyIptables`, not here — the default path never touches the host network.", + "networkName": "awf-net", + "externalBridgeName": "awf-ext", + "subnet": "172.30.0.0/24", + "hosts": { + "squid": { "ip": "172.30.0.10", "role": "egress-proxy", "required": true, "dualHomed": true }, + "agent": { "ip": "172.30.0.20", "role": "workload", "required": true }, + "apiProxy": { "ip": "172.30.0.30", "role": "credential-proxy", "required": false }, + "dohProxy": { "ip": "172.30.0.40", "role": "dns-proxy", "required": false }, + "cliProxy": { "ip": "172.30.0.50", "role": "difc-proxy", "required": false } + } + }, + + "proxies": { + "$comment": "CORE — sidecar listen ports. In topology mode the agent reaches these by IP:port over the internal network (Squid via HTTP(S)_PROXY, api-proxy directly). Replaces the manual 'keep in sync' list that used to live in src/types/ports.ts.", + "squid": { "port": 3128 }, + "apiProxy": { + "ports": { "openai": 10000, "anthropic": 10001, "copilot": 10002, "gemini": 10003, "vertex": 10004 }, + "healthPort": 10000 + }, + "cliProxy": { "port": 11000 }, + "dohProxy": { "upstreamPort": 443 } + }, + + "dns": { + "$comment": "CORE — in topology mode the agent resolves ONLY via Docker's embedded resolver (`embeddedResolver`, 127.0.0.11), which forwards through the daemon; external DNS is unreachable from the internal network by design. `defaultUpstreamServers` is the fallback the daemon/Squid use (and the legacy path allow-lists) when the user passes no --dns-servers. `port` is the sole permitted DNS port.", + "embeddedResolver": "127.0.0.11", + "defaultUpstreamServers": ["8.8.8.8", "8.8.4.4"], + "port": 53 + }, + + "legacyIptables": { + "$comment": "UNCOMMON — consumed ONLY by --legacy-security (host + container iptables) and --enable-host-access. In the default topology path these are enforced structurally by routing and are NOT applied as rules. Centralized here so the host-iptables backend, the container setup-iptables.sh NAT script, and sbx share one list instead of drifting. `hostGateway` is the host-network touch point that makes this path ARC/DinD-fragile — another reason it is not the default.", + "hostGateway": "172.30.0.1", + "blockedPorts": [ + { "port": 22, "reason": "SSH" }, + { "port": 23, "reason": "Telnet" }, + { "port": 25, "reason": "SMTP" }, + { "port": 110, "reason": "POP3" }, + { "port": 143, "reason": "IMAP" }, + { "port": 445, "reason": "SMB" }, + { "port": 1433, "reason": "Microsoft SQL Server" }, + { "port": 1521, "reason": "Oracle DB" }, + { "port": 3306, "reason": "MySQL" }, + { "port": 3389, "reason": "RDP" }, + { "port": 5432, "reason": "PostgreSQL" }, + { "port": 6379, "reason": "Redis" }, + { "port": 27017, "reason": "MongoDB" }, + { "port": 27018, "reason": "MongoDB shard" }, + { "port": 28017, "reason": "MongoDB web status" } + ], + "blockedCidrs": [ + { "cidr": "169.254.0.0/16", "reason": "Link-local incl. cloud metadata 169.254.169.254 (SSRF)" }, + { "cidr": "224.0.0.0/4", "reason": "Multicast" } + ] + } +} diff --git a/src/constants.ts b/src/constants.ts index 8a6ae4e4d..9d9c1e09b 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -10,7 +10,9 @@ export const API_PROXY_CONTAINER_NAME = 'awf-api-proxy'; export const DOH_PROXY_CONTAINER_NAME = 'awf-doh-proxy'; export const CLI_PROXY_CONTAINER_NAME = 'awf-cli-proxy'; -export const SQUID_PORT = 3128; +// SQUID_PORT is centralized in src/config/sandbox-network-policy.json and +// re-exported here so existing import sites keep working unchanged. +export { SQUID_PORT } from './config/network-policy'; /** * Maximum size (bytes) of a single environment variable value allowed through diff --git a/src/dns-resolver.ts b/src/dns-resolver.ts index ab384984d..c8833e21e 100644 --- a/src/dns-resolver.ts +++ b/src/dns-resolver.ts @@ -1,11 +1,16 @@ import * as fs from 'fs'; import { isIP } from 'net'; import { logger as defaultLogger } from './logger'; +import { DEFAULT_DNS_SERVERS } from './config/network-policy'; type Logger = typeof defaultLogger; -/** Fallback when no usable resolvers are detected on the host */ -export const DEFAULT_DNS_SERVERS = ['8.8.8.8', '8.8.4.4']; +/** + * Fallback when no usable resolvers are detected on the host. + * Centralized in src/config/sandbox-network-policy.json and re-exported here so + * existing import sites keep working unchanged. + */ +export { DEFAULT_DNS_SERVERS }; /** * Paths to try for resolv.conf, in priority order. diff --git a/src/host-iptables-shared.ts b/src/host-iptables-shared.ts index 55ecb3767..633284ee1 100644 --- a/src/host-iptables-shared.ts +++ b/src/host-iptables-shared.ts @@ -1,17 +1,33 @@ import execa from 'execa'; import { logger } from './logger'; import { getLocalDockerEnv } from './docker-host'; +import { + NETWORK_NAME, + NETWORK_SUBNET, + SQUID_IP, + AGENT_IP, + API_PROXY_IP, + DOH_PROXY_IP, + CLI_PROXY_IP, + HOST_GATEWAY, +} from './config/network-policy'; + +// Network topology (names, subnet, static IPs) is centralized in +// src/config/sandbox-network-policy.json and re-exported here so existing +// import sites keep working unchanged. +export { + NETWORK_NAME, + NETWORK_SUBNET, + SQUID_IP, + AGENT_IP, + API_PROXY_IP, + DOH_PROXY_IP, + CLI_PROXY_IP, +}; -export const NETWORK_NAME = 'awf-net'; export const CHAIN_NAME = 'FW_WRAPPER'; export const CHAIN_NAME_V6 = 'FW_WRAPPER_V6'; -export const NETWORK_SUBNET = '172.30.0.0/24'; -export const AWF_NETWORK_GATEWAY = '172.30.0.1'; -export const SQUID_IP = '172.30.0.10'; -export const AGENT_IP = '172.30.0.20'; -export const API_PROXY_IP = '172.30.0.30'; -export const DOH_PROXY_IP = '172.30.0.40'; -export const CLI_PROXY_IP = '172.30.0.50'; +export const AWF_NETWORK_GATEWAY = HOST_GATEWAY; // Cache for ip6tables availability check (only checked once per run) let ip6tablesAvailableCache: boolean | null = null; diff --git a/src/topology.ts b/src/topology.ts index 4f85dfc0c..16e0797c3 100644 --- a/src/topology.ts +++ b/src/topology.ts @@ -4,14 +4,16 @@ import execa from 'execa'; import * as yaml from 'js-yaml'; import { getLocalDockerEnv } from './docker-host'; import { logger } from './logger'; +import { NETWORK_NAME } from './config/network-policy'; /** * Deterministic name of the internal Docker network used by network-isolation * (topology) mode. Pinned via `name:` in the generated compose file so that * externally-launched trusted containers (mcp-gateway, DIFC proxy) can be * attached to it with a stable `docker network connect `. + * Centralized in src/config/sandbox-network-policy.json. */ -export const TOPOLOGY_NETWORK_NAME = 'awf-net'; +export const TOPOLOGY_NETWORK_NAME = NETWORK_NAME; const DAEMON_PING_TIMEOUT_MS = 5000; const DAEMON_PING_RETRIES = 3; diff --git a/src/types/ports.ts b/src/types/ports.ts index 5caeaf6d5..b53728dc4 100644 --- a/src/types/ports.ts +++ b/src/types/ports.ts @@ -1,9 +1,22 @@ /** * API proxy and CLI proxy port constants. + * + * The port values are centralized in src/config/sandbox-network-policy.json + * (the single source of truth shared by all runtimes). This module adapts them + * to the historical `API_PROXY_PORTS` / `API_PROXY_HEALTH_PORT` / `CLI_PROXY_PORT` + * shape so existing import sites keep working unchanged. */ +import { + apiProxyPorts, + API_PROXY_HEALTH_PORT, + CLI_PROXY_PORT, +} from '../config/network-policy'; + +const ports = apiProxyPorts(); + /** - * API Proxy port configuration + * API Proxy port configuration. * * These ports are used by the api-proxy sidecar container to expose * authentication-injecting proxies for different LLM providers. @@ -14,55 +27,34 @@ * - containers/agent/setup-iptables.sh (NAT rules) */ export const API_PROXY_PORTS = { - /** - * OpenAI API proxy port - * Also serves as the health check endpoint for Docker healthcheck - * @see containers/api-proxy/server.js - */ - OPENAI: 10000, - - /** - * Anthropic (Claude) API proxy port - * @see containers/api-proxy/server.js - */ - ANTHROPIC: 10001, - - /** - * GitHub Copilot API proxy port - * @see containers/api-proxy/server.js - */ - COPILOT: 10002, - - /** - * Google Gemini API proxy port - * @see containers/api-proxy/server.js - */ - GEMINI: 10003, - - /** - * Google Vertex AI API proxy port - * @see containers/api-proxy/server.js - */ - VERTEX: 10004, - + /** OpenAI API proxy port (also the healthcheck endpoint). */ + OPENAI: ports.openai, + /** Anthropic (Claude) API proxy port. */ + ANTHROPIC: ports.anthropic, + /** GitHub Copilot API proxy port. */ + COPILOT: ports.copilot, + /** Google Gemini API proxy port. */ + GEMINI: ports.gemini, + /** Google Vertex AI API proxy port. */ + VERTEX: ports.vertex, } as const; /** - * Health check port for the API proxy sidecar - * Always uses the OpenAI port (10000) for Docker healthcheck + * Health check port for the API proxy sidecar. + * Always uses the OpenAI port (10000) for Docker healthcheck. */ -export const API_PROXY_HEALTH_PORT = API_PROXY_PORTS.OPENAI; +export { API_PROXY_HEALTH_PORT }; /** * Port for the CLI proxy sidecar HTTP server. * * The CLI proxy sidecar listens on this port for gh CLI invocations forwarded * from the agent container. Port 11000 is chosen to avoid collision with the - * api-proxy ports (10000-10003). + * api-proxy ports (10000-10004). * * All ports must be allowed in: * - containers/cli-proxy/Dockerfile (EXPOSE directive) * - containers/agent/setup-iptables.sh (NAT rules) * @see containers/cli-proxy/server.js */ -export const CLI_PROXY_PORT = 11000; +export { CLI_PROXY_PORT };