Skip to content

Commit 501f0b9

Browse files
lpcoxCopilot
andauthored
feat: add upstream corporate proxy support for self-hosted runners (#1976)
* feat: add upstream corporate proxy support for self-hosted runners Add --upstream-proxy flag and auto-detection from host https_proxy/ http_proxy/no_proxy environment variables. When configured, Squid chains outbound traffic through the corporate proxy via cache_peer. Key changes: - New upstream-proxy.ts with parseProxyUrl(), parseNoProxy(), detectUpstreamProxy(), and PROXY_ENV_VARS constant - UpstreamProxyConfig interface in types.ts - generateUpstreamProxySection() in squid-config.ts for cache_peer, always_direct (no_proxy bypass), and never_direct directives - CLI auto-detection with --upstream-proxy explicit override - Host proxy env vars excluded from --env-all passthrough - Security: reject credentials, loopback, HTTPS scheme, injection chars - 35 new tests across upstream-proxy, squid-config, docker-manager - Documentation in docs/environment.md Closes #1975 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: address PR review feedback for upstream proxy support - Robust loopback detection: check full 127.0.0.0/8 range and IPv6 variants via isLoopback() helper instead of exact-match list - Fix misleading comments in squid-config.ts: non-dot no_proxy entries are treated as suffix matches (domain + subdomains), not exact-only - Update docs/environment.md: clarify that host proxy vars are excluded from container passthrough but are read for upstream proxy detection Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 80e0622 commit 501f0b9

9 files changed

Lines changed: 692 additions & 4 deletions

docs/environment.md

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ Using `--env-all` passes all host environment variables to the container, which
4040

4141
**Excluded variables** (even with `--env-all`): `PATH`, `PWD`, `OLDPWD`, `SHLVL`, `_`, `SUDO_*`
4242

43-
**Proxy variables:** `HTTP_PROXY`, `HTTPS_PROXY`, `https_proxy` (and their lowercase/uppercase variants) from the host are ignored when using `--env-all` because the firewall always sets these to point to Squid. Host proxy settings cannot be passed through as they would conflict with the firewall's traffic routing.
43+
**Proxy variables:** `HTTP_PROXY`, `HTTPS_PROXY`, `http_proxy`, `https_proxy`, `NO_PROXY`, `no_proxy`, `ALL_PROXY`, and `FTP_PROXY` (all case variants) from the host are **excluded from container passthrough** when using `--env-all`. The firewall sets its own proxy variables pointing to Squid inside the container. However, host proxy variables **are read** for upstream proxy auto-detection — if the host has `https_proxy`/`http_proxy` set, AWF configures Squid to chain outbound traffic through that corporate proxy (see [Upstream Proxy Support](#upstream-corporate-proxy-support)).
4444

4545
## `--env-file` Support
4646

@@ -246,6 +246,46 @@ The DinD TCP address (e.g., `tcp://localhost:2375`) typically refers to the runn
246246
- **`--enable-host-access`** — allows the agent to reach `host.docker.internal` and set `DOCKER_HOST=tcp://host.docker.internal:2375` inside the agent.
247247
- **`--enable-dind`** — mounts the local Docker socket (`/var/run/docker.sock`) directly into the agent container (only works when using the local daemon, not a remote DinD TCP socket).
248248

249+
## Upstream (Corporate) Proxy Support
250+
251+
When running on self-hosted runners behind a corporate proxy, AWF can chain Squid
252+
through the upstream proxy using the `cache_peer` directive.
253+
254+
### Auto-detection
255+
256+
If the host has `https_proxy`/`HTTPS_PROXY` or `http_proxy`/`HTTP_PROXY` set, AWF
257+
automatically configures Squid to route outbound traffic through that proxy.
258+
`no_proxy`/`NO_PROXY` domain suffixes are honored as bypass rules (`always_direct`).
259+
260+
```bash
261+
# Auto-detected — no flags needed when host proxy env vars are set
262+
export https_proxy=http://proxy.corp.com:3128
263+
export no_proxy=.internal.corp.com,localhost
264+
awf --allow-domains github.com 'curl https://api.github.com'
265+
```
266+
267+
### Explicit override
268+
269+
Use `--upstream-proxy <url>` to specify the proxy explicitly (overrides auto-detection):
270+
271+
```bash
272+
awf --upstream-proxy http://proxy.corp.com:3128 --allow-domains github.com 'curl https://api.github.com'
273+
```
274+
275+
### Limitations (v1)
276+
277+
- **HTTP proxies only** — Squid `cache_peer` requires an HTTP proxy (HTTPS tunneling uses CONNECT)
278+
- **No proxy credentials** — `user:pass@proxy` URLs are rejected; configure auth on the proxy server
279+
- **No loopback** — `localhost`/`127.0.0.1` proxies are rejected (Squid is in a container)
280+
- **Single proxy** — If `http_proxy` and `https_proxy` differ, use `--upstream-proxy` to disambiguate
281+
- **Domain-only bypass** — `no_proxy` IPs, CIDRs, and wildcards are ignored (only domain suffixes work)
282+
283+
### Proxy environment variable exclusion
284+
285+
Host proxy environment variables (`HTTP_PROXY`, `HTTPS_PROXY`, `http_proxy`, `https_proxy`,
286+
`ALL_PROXY`, `NO_PROXY`, etc.) are **always excluded** from container passthrough, even with
287+
`--env-all`. AWF sets its own proxy variables pointing to Squid (`172.30.0.10:3128`).
288+
249289
## Troubleshooting
250290

251291
**Variable not accessible:** Use `sudo -E` or pass explicitly with `--env VAR="$VAR"`

src/cli.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { redactSecrets } from './redact-secrets';
2828
import { validateDomainOrPattern, SQUID_DANGEROUS_CHARS } from './domain-patterns';
2929
import { loadAndMergeDomains } from './rules';
3030
import { detectHostDnsServers } from './dns-resolver';
31+
import { detectUpstreamProxy, parseProxyUrl, parseNoProxy } from './upstream-proxy';
3132
import { OutputFormat } from './types';
3233
import { version } from '../package.json';
3334

@@ -1236,6 +1237,7 @@ const optionGroupHeaders: Record<string, string> = {
12361237
'build-local': 'Image Management:',
12371238
'env': 'Container Configuration:',
12381239
'dns-servers': 'Network & Security:',
1240+
'upstream-proxy': 'Network & Security:',
12391241
'enable-api-proxy': 'API Proxy:',
12401242
'log-level': 'Logging & Debug:',
12411243
};
@@ -1430,6 +1432,12 @@ program
14301432
'--dns-over-https [resolver-url]',
14311433
'Enable DNS-over-HTTPS via sidecar proxy (default: https://dns.google/dns-query)'
14321434
)
1435+
.option(
1436+
'--upstream-proxy <url>',
1437+
'Upstream (corporate) proxy URL for Squid to chain through.\n' +
1438+
' Auto-detected from host https_proxy/http_proxy if not set.\n' +
1439+
' Example: http://proxy.corp.com:3128'
1440+
)
14331441
.option(
14341442
'--enable-host-access',
14351443
'Enable access to host services via host.docker.internal',
@@ -1785,6 +1793,31 @@ program
17851793
logger.info(`DNS-over-HTTPS enabled: ${dnsOverHttps}`);
17861794
}
17871795

1796+
// Detect or parse upstream proxy configuration
1797+
let upstreamProxy: import('./types').UpstreamProxyConfig | undefined;
1798+
if (options.upstreamProxy) {
1799+
// Explicit --upstream-proxy flag
1800+
try {
1801+
const { host, port } = parseProxyUrl(options.upstreamProxy);
1802+
// Parse no_proxy from environment even when --upstream-proxy is explicit
1803+
const noProxyStr = (process.env.no_proxy || process.env.NO_PROXY || '').trim();
1804+
const noProxy = noProxyStr ? parseNoProxy(noProxyStr) : [];
1805+
upstreamProxy = { host, port, ...(noProxy.length > 0 ? { noProxy } : {}) };
1806+
logger.info(`Upstream proxy (explicit): ${host}:${port}`);
1807+
} catch (error) {
1808+
logger.error(`Invalid --upstream-proxy: ${error instanceof Error ? error.message : error}`);
1809+
process.exit(1);
1810+
}
1811+
} else {
1812+
// Auto-detect from host environment variables
1813+
try {
1814+
upstreamProxy = detectUpstreamProxy();
1815+
} catch (error) {
1816+
logger.error(`Upstream proxy auto-detection failed: ${error instanceof Error ? error.message : error}`);
1817+
process.exit(1);
1818+
}
1819+
}
1820+
17881821
// Parse --allow-urls for SSL Bump mode
17891822
let allowedUrls: string[] | undefined;
17901823
if (options.allowUrls) {
@@ -1919,6 +1952,7 @@ program
19191952
githubToken: process.env.GITHUB_TOKEN || process.env.GH_TOKEN,
19201953
diagnosticLogs: options.diagnosticLogs || false,
19211954
awfDockerHost: options.dockerHost,
1955+
upstreamProxy,
19221956
};
19231957

19241958
// Apply --docker-host override for AWF's own container operations.

src/docker-manager.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1616,6 +1616,35 @@ describe('docker-manager', () => {
16161616
}
16171617
});
16181618

1619+
it('should exclude host proxy env vars from env-all passthrough to prevent routing conflicts', () => {
1620+
const saved: Record<string, string | undefined> = {};
1621+
const proxyVars = ['HTTP_PROXY', 'HTTPS_PROXY', 'http_proxy', 'https_proxy', 'NO_PROXY', 'no_proxy'];
1622+
1623+
for (const v of proxyVars) {
1624+
saved[v] = process.env[v];
1625+
process.env[v] = `http://host-proxy.corp.com:3128`;
1626+
}
1627+
1628+
try {
1629+
const configWithEnvAll = { ...mockConfig, envAll: true };
1630+
const result = generateDockerCompose(configWithEnvAll, mockNetworkConfig);
1631+
const env = result.services.agent.environment as Record<string, string>;
1632+
1633+
// Host proxy vars must not leak — AWF sets its own proxy vars pointing to Squid
1634+
for (const v of proxyVars) {
1635+
// The value should either be absent or overwritten to Squid's address
1636+
if (env[v] !== undefined) {
1637+
expect(env[v]).not.toBe('http://host-proxy.corp.com:3128');
1638+
}
1639+
}
1640+
} finally {
1641+
for (const v of proxyVars) {
1642+
if (saved[v] !== undefined) process.env[v] = saved[v];
1643+
else delete process.env[v];
1644+
}
1645+
}
1646+
});
1647+
16191648
it('should auto-inject GH_HOST from GITHUB_SERVER_URL when envAll is true', () => {
16201649
const prevServerUrl = process.env.GITHUB_SERVER_URL;
16211650
const prevGhHost = process.env.GH_HOST;

src/docker-manager.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { logger } from './logger';
88
import { generateSquidConfig, generatePolicyManifest } from './squid-config';
99
import { generateSessionCa, initSslDb, CaFiles, parseUrlPatterns, cleanupSslKeyMaterial, unmountSslTmpfs } from './ssl-bump';
1010
import { DEFAULT_DNS_SERVERS } from './dns-resolver';
11+
import { PROXY_ENV_VARS } from './upstream-proxy';
1112

1213
const SQUID_PORT = 3128;
1314

@@ -640,6 +641,10 @@ export function generateDockerCompose(
640641
// Actions runner itself, not by the agent.
641642
'ACTIONS_RUNTIME_TOKEN',
642643
'ACTIONS_RESULTS_URL',
644+
// Proxy environment variables — excluded to prevent host proxy settings from
645+
// conflicting with AWF's internal routing (agent → Squid → internet).
646+
// AWF sets its own HTTP_PROXY/HTTPS_PROXY pointing to Squid.
647+
...PROXY_ENV_VARS,
643648
]);
644649

645650
// When api-proxy is enabled, exclude API keys from agent environment
@@ -2132,6 +2137,7 @@ export async function writeConfigs(config: WrapperConfig): Promise<void> {
21322137
allowHostPorts: config.allowHostPorts,
21332138
enableDlp: config.enableDlp,
21342139
dnsServers: config.dnsServers,
2140+
upstreamProxy: config.upstreamProxy,
21352141
});
21362142
const squidConfigPath = path.join(config.workDir, 'squid.conf');
21372143
fs.writeFileSync(squidConfigPath, squidConfig, { mode: 0o644 });

src/squid-config.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1868,4 +1868,55 @@ describe('generatePolicyManifest', () => {
18681868
const denyRule = manifest.rules.find(r => r.id === 'deny-default');
18691869
expect(httpRule!.order).toBeLessThan(denyRule!.order);
18701870
});
1871+
1872+
describe('Upstream Proxy Configuration', () => {
1873+
it('generates cache_peer directive for upstream proxy', () => {
1874+
const config: SquidConfig = {
1875+
domains: ['github.com'],
1876+
port: defaultPort,
1877+
upstreamProxy: { host: 'proxy.corp.com', port: 3128 },
1878+
};
1879+
const result = generateSquidConfig(config);
1880+
expect(result).toContain('cache_peer proxy.corp.com parent 3128 0 no-query default');
1881+
expect(result).toContain('never_direct allow all');
1882+
});
1883+
1884+
it('generates always_direct bypass for noProxy domains', () => {
1885+
const config: SquidConfig = {
1886+
domains: ['github.com'],
1887+
port: defaultPort,
1888+
upstreamProxy: {
1889+
host: 'proxy.corp.com',
1890+
port: 3128,
1891+
noProxy: ['.corp.com', 'internal.example.com'],
1892+
},
1893+
};
1894+
const result = generateSquidConfig(config);
1895+
expect(result).toContain('acl upstream_bypass dstdomain .corp.com');
1896+
expect(result).toContain('acl upstream_bypass dstdomain internal.example.com');
1897+
expect(result).toContain('acl upstream_bypass dstdomain .internal.example.com');
1898+
expect(result).toContain('always_direct allow upstream_bypass');
1899+
expect(result).toContain('never_direct allow all');
1900+
});
1901+
1902+
it('omits upstream proxy section when not configured', () => {
1903+
const config: SquidConfig = {
1904+
domains: ['github.com'],
1905+
port: defaultPort,
1906+
};
1907+
const result = generateSquidConfig(config);
1908+
expect(result).not.toContain('cache_peer');
1909+
expect(result).not.toContain('never_direct');
1910+
});
1911+
1912+
it('generates upstream proxy with custom port', () => {
1913+
const config: SquidConfig = {
1914+
domains: ['github.com'],
1915+
port: defaultPort,
1916+
upstreamProxy: { host: '10.0.0.50', port: 8080 },
1917+
};
1918+
const result = generateSquidConfig(config);
1919+
expect(result).toContain('cache_peer 10.0.0.50 parent 8080 0 no-query default');
1920+
});
1921+
});
18711922
});

src/squid-config.ts

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { SquidConfig, PolicyManifest, PolicyRule } from './types';
1+
import { SquidConfig, PolicyManifest, PolicyRule, UpstreamProxyConfig } from './types';
22
import {
33
parseDomainList,
44
isDomainMatchedByPattern,
@@ -9,6 +9,45 @@ import {
99
import { generateDlpSquidConfig } from './dlp';
1010
import { DEFAULT_DNS_SERVERS } from './dns-resolver';
1111

12+
/**
13+
* Generates Squid cache_peer / always_direct / never_direct directives for
14+
* upstream (corporate) proxy chaining.
15+
*
16+
* When an upstream proxy is configured, ALL outbound traffic goes through
17+
* the parent proxy except domains in the no_proxy bypass list.
18+
*/
19+
function generateUpstreamProxySection(upstream: UpstreamProxyConfig): string {
20+
const lines: string[] = [
21+
'# Upstream corporate proxy — route outbound traffic through parent proxy',
22+
'# Required for self-hosted runners where direct egress is blocked',
23+
`cache_peer ${upstream.host} parent ${upstream.port} 0 no-query default`,
24+
];
25+
26+
// Generate always_direct ACL for no_proxy bypass domains
27+
if (upstream.noProxy && upstream.noProxy.length > 0) {
28+
lines.push('');
29+
lines.push('# Bypass upstream proxy for these domains (from host no_proxy)');
30+
for (const domain of upstream.noProxy) {
31+
// All entries are treated as suffix matches (domain + subdomains),
32+
// matching standard no_proxy semantics:
33+
// .corp.com → *.corp.com
34+
// internal.corp.com → internal.corp.com AND *.internal.corp.com
35+
const squidDomain = domain.startsWith('.') ? domain : `.${domain}`;
36+
lines.push(`acl upstream_bypass dstdomain ${squidDomain}`);
37+
// For non-dot entries, also add the exact domain for Squid dstdomain matching
38+
if (!domain.startsWith('.')) {
39+
lines.push(`acl upstream_bypass dstdomain ${domain}`);
40+
}
41+
}
42+
lines.push('always_direct allow upstream_bypass');
43+
}
44+
45+
// Force all non-bypass traffic through the parent proxy
46+
lines.push('never_direct allow all');
47+
48+
return lines.join('\n');
49+
}
50+
1251
/**
1352
* Ports that should never be allowed, even with --allow-host-ports
1453
* These ports are blocked for security reasons to prevent access to sensitive services
@@ -265,7 +304,7 @@ ${urlAclSection}${urlAccessRules}`;
265304
* // Blocked: internal.example.com -> acl blocked_domains dstdomain .internal.example.com
266305
*/
267306
export function generateSquidConfig(config: SquidConfig): string {
268-
const { domains, blockedDomains, port, sslBump, caFiles, sslDbPath, urlPatterns, enableHostAccess, allowHostPorts, enableDlp, dnsServers } = config;
307+
const { domains, blockedDomains, port, sslBump, caFiles, sslDbPath, urlPatterns, enableHostAccess, allowHostPorts, enableDlp, dnsServers, upstreamProxy } = config;
269308

270309
// Parse, deduplicate, and group domains by protocol (shared logic)
271310
const { domainsByProto, patternsByProto } = parseDomainConfig(domains);
@@ -609,7 +648,7 @@ cache deny all
609648
610649
# DNS settings - Squid resolves all domains for HTTP/HTTPS traffic
611650
dns_nameservers ${(dnsServers && dnsServers.length > 0) ? dnsServers.join(' ') : DEFAULT_DNS_SERVERS.join(' ')}
612-
651+
${upstreamProxy ? '\n' + generateUpstreamProxySection(upstreamProxy) : ''}
613652
# Forwarded headers
614653
forwarded_for delete
615654
via off

src/types.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -920,6 +920,38 @@ export interface WrapperConfig {
920920
* @example 45
921921
*/
922922
agentTimeout?: number;
923+
924+
/**
925+
* Upstream (corporate) proxy for Squid to route outbound traffic through.
926+
*
927+
* When set, Squid uses `cache_peer` to forward all outbound HTTP/HTTPS
928+
* traffic through this parent proxy instead of connecting directly to the
929+
* internet. This is required on self-hosted runners behind corporate proxies
930+
* where direct egress is blocked.
931+
*
932+
* Auto-detected from host `https_proxy`/`HTTPS_PROXY`/`http_proxy`/`HTTP_PROXY`
933+
* environment variables, or explicitly set via `--upstream-proxy <url>`.
934+
*
935+
* @example { host: 'proxy.corp.com', port: 3128 }
936+
*/
937+
upstreamProxy?: UpstreamProxyConfig;
938+
}
939+
940+
/**
941+
* Upstream proxy configuration for Squid cache_peer routing
942+
*/
943+
export interface UpstreamProxyConfig {
944+
/** Hostname or IP of the upstream proxy (e.g., 'proxy.corp.com') */
945+
host: string;
946+
/** Port of the upstream proxy (e.g., 3128) */
947+
port: number;
948+
/**
949+
* Domains that should bypass the upstream proxy and connect directly.
950+
* Parsed from host `no_proxy`/`NO_PROXY`. Only domain suffixes are
951+
* supported (e.g., '.corp.com', 'internal.example.com').
952+
* IPs, CIDRs, and wildcards are ignored with a warning.
953+
*/
954+
noProxy?: string[];
923955
}
924956

925957
/**
@@ -1067,6 +1099,14 @@ export interface SquidConfig {
10671099
* @default ['8.8.8.8', '8.8.4.4']
10681100
*/
10691101
dnsServers?: string[];
1102+
1103+
/**
1104+
* Upstream (corporate) proxy for Squid to chain outbound traffic through.
1105+
*
1106+
* When set, generates `cache_peer` / `never_direct` / `always_direct`
1107+
* directives so Squid forwards traffic through the parent proxy.
1108+
*/
1109+
upstreamProxy?: UpstreamProxyConfig;
10701110
}
10711111

10721112
/**

0 commit comments

Comments
 (0)