Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/commands/build-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ interface BuildConfigInputs {
volumeMounts: string[] | undefined;
upstreamProxy: UpstreamProxyConfig | undefined;
dnsServers: string[];
dnsServersExplicit?: boolean;
dnsOverHttps: string | undefined;
allowedUrls: string[] | undefined;
memoryLimit: string | undefined;
Expand Down Expand Up @@ -91,6 +92,7 @@ export function buildConfig(inputs: BuildConfigInputs): WrapperConfig {
volumeMounts,
upstreamProxy,
dnsServers,
dnsServersExplicit,
dnsOverHttps,
allowedUrls,
memoryLimit,
Expand Down Expand Up @@ -142,6 +144,7 @@ export function buildConfig(inputs: BuildConfigInputs): WrapperConfig {
volumeMounts,
containerWorkDir: options.containerWorkdir as string | undefined,
dnsServers,
dnsServersExplicit,
dnsOverHttps,
memoryLimit,
proxyLogsDir: options.proxyLogsDir as string | undefined,
Expand Down
7 changes: 6 additions & 1 deletion src/commands/network-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { UpstreamProxyConfig } from '../types';
interface NetworkSetupResult {
upstreamProxy: UpstreamProxyConfig | undefined;
dnsServers: string[];
/** True when DNS servers were supplied explicitly (--dns-servers / config file); false when auto-detected. */
dnsServersExplicit: boolean;
dnsOverHttps: string | undefined;
}

Expand All @@ -26,15 +28,18 @@ interface NetworkSetupResult {
export function resolveNetworkConfig(options: Record<string, unknown>): NetworkSetupResult {
// Parse and validate DNS servers (auto-detect if not explicitly provided)
let dnsServers: string[];
let dnsServersExplicit: boolean;
if (options.dnsServers) {
try {
dnsServers = parseDnsServers(options.dnsServers as string);
dnsServersExplicit = true;
} catch (error) {
logger.error(`Invalid DNS servers: ${error instanceof Error ? error.message : error}`);
process.exit(1);
}
} else {
dnsServers = detectHostDnsServers(logger);
dnsServersExplicit = false;
}

// Parse and validate --dns-over-https
Expand Down Expand Up @@ -73,5 +78,5 @@ export function resolveNetworkConfig(options: Record<string, unknown>): NetworkS
}
}

return { upstreamProxy, dnsServers, dnsOverHttps };
return { upstreamProxy, dnsServers, dnsServersExplicit, dnsOverHttps };
}
1 change: 1 addition & 0 deletions src/commands/validate-options.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ describe('validateOptions', () => {
mockedNetworkSetup.resolveNetworkConfig.mockReturnValue({
upstreamProxy: undefined,
dnsServers: ['8.8.8.8'],
dnsServersExplicit: false,
dnsOverHttps: undefined,
});

Expand Down
1 change: 1 addition & 0 deletions src/commands/validators/config-assembly.test-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ export const createMinimalNetworkOptions = (): NetworkOptionsResult => ({
},
upstreamProxy: undefined,
dnsServers: ['8.8.8.8'],
dnsServersExplicit: false,
dnsOverHttps: undefined,
resolvedCopilotApiTarget: undefined,
resolvedCopilotApiBasePath: undefined,
Expand Down
1 change: 1 addition & 0 deletions src/commands/validators/config-assembly.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export function assembleAndValidateConfig(
volumeMounts: agentOptions.volumeMounts,
upstreamProxy: networkOptions.upstreamProxy,
dnsServers: networkOptions.dnsServers,
dnsServersExplicit: networkOptions.dnsServersExplicit,
dnsOverHttps: networkOptions.dnsOverHttps,
allowedUrls: agentOptions.allowedUrls,
memoryLimit: logAndLimits.memoryLimit,
Expand Down
5 changes: 4 additions & 1 deletion src/commands/validators/network-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ export interface NetworkOptionsResult {
resolvedCopilotApiBasePath: string | undefined;
upstreamProxy: UpstreamProxyConfig | undefined;
dnsServers: string[];
/** True when DNS servers were supplied explicitly; false when auto-detected. */
dnsServersExplicit: boolean;
dnsOverHttps: string | undefined;
}

Expand Down Expand Up @@ -107,7 +109,7 @@ export function validateNetworkOptions(options: Record<string, unknown>): Networ
// --- Network configuration -----------------------------------------------

// Resolve network configuration (upstream proxy, DNS servers, DNS-over-HTTPS)
const { upstreamProxy, dnsServers, dnsOverHttps } = resolveNetworkConfig(options);
const { upstreamProxy, dnsServers, dnsServersExplicit, dnsOverHttps } = resolveNetworkConfig(options);

return {
dockerHostCheck,
Expand All @@ -120,6 +122,7 @@ export function validateNetworkOptions(options: Record<string, unknown>): Networ
resolvedCopilotApiBasePath,
upstreamProxy,
dnsServers,
dnsServersExplicit,
dnsOverHttps,
};
}
170 changes: 170 additions & 0 deletions src/config-writer-dns-isolation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
/**
* Config-writer integration tests for DNS filtering in network-isolation mode.
*
* Covers the gate at src/config-writer.ts lines 330-333:
* - Non-portable resolvers are filtered only when networkIsolation is enabled
* and dnsServersExplicit is false (auto-detected).
* - The filtered (effective) DNS list is passed to generateSquidConfig.
* - The filtered (effective) DNS list is also used in the policy-manifest audit
* artifact, not the raw config.dnsServers list.
* - Explicitly-supplied DNS servers are never filtered in isolation mode.
*/

// Hoisted jest.mock() registrations live in the shared helper — must remain first.
import './test-helpers/config-writer-dependency-mocks.test-utils';

import { writeConfigs } from './config-writer';
import {
buildWriteConfig,
setupConfigWriterTempDir,
cleanupConfigWriterTempDir,
} from './test-helpers/config-writer-test-harness.test-utils';

// The mock factories from squid-config and squid-config are registered in
// config-writer-dependency-mocks.test-utils above; access them via requireMock.
function getSquidConfigMock() {
return jest.requireMock('./squid-config') as {
generateSquidConfig: jest.Mock;
generatePolicyManifest: jest.Mock;
};
}

describe('writeConfigs — DNS filtering in network-isolation mode', () => {
let tempDir: string;

beforeEach(() => {
tempDir = setupConfigWriterTempDir('config-writer-dns-isolation-');
getSquidConfigMock().generateSquidConfig.mockReturnValue('# mock squid config');
getSquidConfigMock().generatePolicyManifest.mockReturnValue({});
});

afterEach(() => {
cleanupConfigWriterTempDir(tempDir);
});

describe('non-isolation mode — no filtering regardless of portability', () => {
it('passes Azure DHCP DNS unchanged to Squid when networkIsolation is false', async () => {
await writeConfigs(
buildWriteConfig(tempDir, {
networkIsolation: false,
dnsServers: ['168.63.129.16'],
dnsServersExplicit: false,
})
);

const squidCall = getSquidConfigMock().generateSquidConfig.mock.calls[0][0];
expect(squidCall.dnsServers).toEqual(['168.63.129.16']);
});

it('passes Azure DHCP DNS unchanged to policy manifest when networkIsolation is false', async () => {
await writeConfigs(
buildWriteConfig(tempDir, {
networkIsolation: false,
dnsServers: ['168.63.129.16'],
dnsServersExplicit: false,
})
);

const manifestCall = getSquidConfigMock().generatePolicyManifest.mock.calls[0][0];
expect(manifestCall.dnsServers).toEqual(['168.63.129.16']);
});
});

describe('isolation mode + auto-detected DNS — non-portable servers are filtered', () => {
it('filters Azure DHCP DNS from Squid config', async () => {
await writeConfigs(
buildWriteConfig(tempDir, {
networkIsolation: true,
dnsServers: ['168.63.129.16'],
dnsServersExplicit: false,
})
);

const squidCall = getSquidConfigMock().generateSquidConfig.mock.calls[0][0];
// 168.63.129.16 is non-portable; fallback to default public DNS
expect(squidCall.dnsServers).toEqual(['8.8.8.8', '8.8.4.4']);
});

it('filters Tailscale Magic DNS from Squid config', async () => {
await writeConfigs(
buildWriteConfig(tempDir, {
networkIsolation: true,
dnsServers: ['100.100.100.100'],
dnsServersExplicit: false,
})
);

const squidCall = getSquidConfigMock().generateSquidConfig.mock.calls[0][0];
expect(squidCall.dnsServers).toEqual(['8.8.8.8', '8.8.4.4']);
});

it('keeps portable servers from a mixed list and removes non-portable ones', async () => {
await writeConfigs(
buildWriteConfig(tempDir, {
networkIsolation: true,
dnsServers: ['168.63.129.16', '8.8.8.8', '1.1.1.1'],
dnsServersExplicit: false,
})
);

const squidCall = getSquidConfigMock().generateSquidConfig.mock.calls[0][0];
expect(squidCall.dnsServers).toEqual(['8.8.8.8', '1.1.1.1']);
});

it('passes the filtered list (not config.dnsServers) to the policy manifest', async () => {
await writeConfigs(
buildWriteConfig(tempDir, {
networkIsolation: true,
dnsServers: ['168.63.129.16', '8.8.8.8'],
dnsServersExplicit: false,
})
);

const manifestCall = getSquidConfigMock().generatePolicyManifest.mock.calls[0][0];
// Policy manifest must reflect what Squid actually uses, not the raw detected list
expect(manifestCall.dnsServers).toEqual(['8.8.8.8']);
expect(manifestCall.dnsServers).not.toContain('168.63.129.16');
});
});

describe('isolation mode + explicit DNS — operator choice is respected', () => {
it('does not filter explicitly-specified Azure DHCP DNS in isolation mode', async () => {
await writeConfigs(
buildWriteConfig(tempDir, {
networkIsolation: true,
dnsServers: ['168.63.129.16'],
dnsServersExplicit: true,
})
);

const squidCall = getSquidConfigMock().generateSquidConfig.mock.calls[0][0];
expect(squidCall.dnsServers).toEqual(['168.63.129.16']);
});

it('passes explicit non-portable DNS unchanged to the policy manifest', async () => {
await writeConfigs(
buildWriteConfig(tempDir, {
networkIsolation: true,
dnsServers: ['168.63.129.16'],
dnsServersExplicit: true,
})
);

const manifestCall = getSquidConfigMock().generatePolicyManifest.mock.calls[0][0];
expect(manifestCall.dnsServers).toEqual(['168.63.129.16']);
});

it('does not filter explicitly-specified portable DNS in isolation mode', async () => {
await writeConfigs(
buildWriteConfig(tempDir, {
networkIsolation: true,
dnsServers: ['1.1.1.1', '9.9.9.9'],
dnsServersExplicit: true,
})
);

const squidCall = getSquidConfigMock().generateSquidConfig.mock.calls[0][0];
expect(squidCall.dnsServers).toEqual(['1.1.1.1', '9.9.9.9']);
});
});
});
27 changes: 23 additions & 4 deletions src/config-writer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { parseUrlPatterns } from './domain-matchers';
import { SslConfig, SQUID_PORT } from './host-env';
import { generateDockerCompose, redactDockerComposeSecrets } from './compose-generator';
import { resolveLogPaths } from './log-paths';
import { DEFAULT_DNS_SERVERS, filterForNetworkIsolation } from './dns-resolver';
import {
AGENT_IP,
API_PROXY_IP,
Expand Down Expand Up @@ -214,7 +215,8 @@ function writeAuditArtifacts(
config: WrapperConfig,
networkConfig: NetworkConfig,
dockerCompose: DockerComposeConfig,
squidConfig: string
squidConfig: string,
squidDnsServers?: string[]
): void {
const auditDir = config.auditDir || path.join(config.workDir, 'audit');
fs.mkdirSync(auditDir, { recursive: true, mode: 0o755 });
Expand Down Expand Up @@ -248,7 +250,7 @@ function writeAuditArtifacts(
enableHostAccess: config.enableHostAccess,
allowHostPorts: config.allowHostPorts,
enableDlp: config.enableDlp,
dnsServers: config.dnsServers,
dnsServers: squidDnsServers ?? config.dnsServers,
...(config.enableApiProxy && networkConfig.proxyIp ? {
apiProxyIp: networkConfig.proxyIp,
} : {}),
Expand Down Expand Up @@ -315,6 +317,23 @@ export async function writeConfigs(config: WrapperConfig): Promise<void> {
logger.debug(`Parsed ${urlPatterns.length} URL pattern(s) for SSL Bump filtering`);
}

// In network-isolation (topology) mode the Squid container is dual-homed: it
// has a static IP on the internal `awf-net` network and an auto-assigned IP on
// the external `awf-ext` Docker bridge. All DNS queries leave through `awf-ext`.
// When the host's routing is later modified by tools like Tailscale (e.g. an
// accepted exit-node or subnet route that captures 0.0.0.0/0 or the specific
// DNS server address), DNS servers that depend on host-specific routing — such
// as Azure DHCP DNS (168.63.129.16) or Tailscale Magic DNS (100.100.100.100) —
// can become unreachable from the Docker bridge, causing every Squid DNS lookup
// to fail with TCP_TUNNEL:HIER_NONE 503. Filter them out in isolation mode when
// the DNS list was auto-detected (not explicitly supplied by the operator via
// --dns-servers), so Squid falls back to publicly-routable servers that are not
// affected by VPN route changes. Explicitly-specified servers are trusted as-is.
const resolvedDnsServers = config.dnsServers ?? DEFAULT_DNS_SERVERS;
const squidDnsServers = config.networkIsolation && !config.dnsServersExplicit
? filterForNetworkIsolation(resolvedDnsServers, logger)
: resolvedDnsServers;
Comment on lines +332 to +335

// Note: Use container path for SSL database since it's mounted at /var/spool/squid_ssl_db
const squidConfig = generateSquidConfig({
// Combine non-sensitive and sensitive (secret-derived) domains so Squid allows
Expand All @@ -330,7 +349,7 @@ export async function writeConfigs(config: WrapperConfig): Promise<void> {
enableHostAccess: config.enableHostAccess,
allowHostPorts: config.allowHostPorts,
enableDlp: config.enableDlp,
dnsServers: config.dnsServers,
dnsServers: squidDnsServers,
upstreamProxy: config.upstreamProxy,
// Allow the api-proxy sidecar IP through Squid before the raw-IP deny rule.
// Some HTTP clients (e.g., Node.js fetch / undici ProxyAgent) route requests
Expand Down Expand Up @@ -363,7 +382,7 @@ export async function writeConfigs(config: WrapperConfig): Promise<void> {
// These files contain no secrets (redacted compose, domain ACLs, policy rules)
// and are made world-readable so the gh-aw post-run audit step (running as
// non-root runner user) can stat/read them even if AWF cleanup is interrupted.
writeAuditArtifacts(config, networkConfig, dockerCompose, squidConfig);
writeAuditArtifacts(config, networkConfig, dockerCompose, squidConfig, squidDnsServers);
}

/** @internal Exposed only for unit tests — not part of the public API. */
Expand Down
Loading
Loading