policy.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343
  1. /**
  2. * Proxy policy resolution: the pure, transport-free half of this package. It turns the launch
  3. * environment into one {@link ProxyPolicy}, and answers which proxy
  4. * (if any) a given URL goes through.
  5. *
  6. * Nothing here imports `undici`, so the module stays loadable in the browser-worker runtime that
  7. * evaluates `dsh-web-fetch-http` without a Node transport.
  8. * @module @deepseek-ai/dsh-http-proxy/policy
  9. */
  10. /**
  11. * The one thing resolution needs from an environment: a name in, the winning value out. The
  12. * launcher's snapshot satisfies this structurally and is passed unchanged, so this module names no
  13. * package to describe its input — and a test builds one from an object literal.
  14. */
  15. export interface EnvLookup {
  16. /**
  17. * Resolve one variable name.
  18. * @param name - the variable name.
  19. * @returns the winning entry, or `undefined` when nothing supplies it.
  20. */
  21. get(name: string): { readonly value: string } | undefined
  22. }
  23. /**
  24. * Loopback entries merged into every policy's `noProxy`. A proxy that also serves the harness's own
  25. * loopback traffic turns the Web UI, the Connection transport, and every local test server into a
  26. * routing loop, so the bypass is not optional.
  27. *
  28. * `::1` and `[::1]` are both listed because the resolved string is also handed to undici, whose
  29. * matcher reads a bare `::1` as host `:` port `1` and therefore never bypasses it.
  30. */
  31. export const LOOPBACK_NO_PROXY: readonly string[] = ['localhost', '127.0.0.1', '::1', '[::1]']
  32. /**
  33. * The environment names each policy field owns, lowercase first — undici reads the lowercase name
  34. * first, so both casings are always written or cleared together.
  35. */
  36. export const POLICY_ENV_NAMES = {
  37. httpProxy: ['http_proxy', 'HTTP_PROXY'],
  38. httpsProxy: ['https_proxy', 'HTTPS_PROXY'],
  39. noProxy: ['no_proxy', 'NO_PROXY'],
  40. } as const
  41. /**
  42. * Every environment name that carries proxy configuration, including the `ALL_PROXY` fallback this
  43. * package resolves but never writes back. A caller that must isolate a child from the machine's
  44. * network policy clears exactly these.
  45. */
  46. export const PROXY_ENV_NAMES: readonly string[] = [
  47. ...Object.values(POLICY_ENV_NAMES).flat(),
  48. 'all_proxy',
  49. 'ALL_PROXY',
  50. ]
  51. /** Proxy URL schemes this package routes through. Everything else is reported, never silently dropped. */
  52. const SUPPORTED_PROTOCOLS = new Set(['http:', 'https:'])
  53. /** Schemes recognised well enough to name in a diagnostic instead of calling them malformed. */
  54. const SOCKS_PROTOCOLS = new Set(['socks:', 'socks4:', 'socks4a:', 'socks5:', 'socks5h:'])
  55. /**
  56. * One resolved outbound proxy policy. Plain data with no methods: worker threads receive it through
  57. * `workerData`'s structured clone, so both sides run the identical policy rather than each re-reading
  58. * an environment they may not share.
  59. */
  60. export interface ProxyPolicy {
  61. /** Proxy for `http:` origins, or absent for a direct connection. Always a validated `http(s):` URL. */
  62. readonly httpProxy?: string
  63. /** Proxy for `https:` origins, or absent for a direct connection. Always a validated `http(s):` URL. */
  64. readonly httpsProxy?: string
  65. /** The bypass list, already merged with {@link LOOPBACK_NO_PROXY}. Empty when nothing is bypassed. */
  66. readonly noProxy: string
  67. /** Which layer supplied the winning proxy URL; `env` when either field came from the environment. */
  68. readonly source: 'env' | 'none'
  69. }
  70. /** A policy that proxies nothing. Callers that have not installed a policy resolve URLs against this. */
  71. export const DIRECT_POLICY: ProxyPolicy = { noProxy: '', source: 'none' }
  72. /** Why one candidate proxy value was not used. Callers decide whether this warns or fails the load. */
  73. export interface ProxyDiagnostic {
  74. /** `socks` for a SOCKS or PAC URL this package cannot route; `invalid` for anything unparseable. */
  75. readonly kind: 'socks' | 'invalid'
  76. /** The environment variable that supplied the rejected value. */
  77. readonly origin: string
  78. /** Operator-facing sentence naming the rejection and the way forward. Carries no credential. */
  79. readonly message: string
  80. }
  81. /** A resolved policy plus every candidate value that was rejected on the way to it. */
  82. export interface ProxyResolution {
  83. /** The policy to install. Never carries a rejected value. */
  84. readonly policy: ProxyPolicy
  85. /** Rejections, in the order the candidates were considered. Empty on a clean resolution. */
  86. readonly diagnostics: readonly ProxyDiagnostic[]
  87. }
  88. /**
  89. * Read one environment name in undici's precedence order — lowercase first, uppercase as the
  90. * fallback — treating a blank value as unset. Blank matters: undici's own `??` chain lets an empty
  91. * lowercase name shadow a populated uppercase one.
  92. *
  93. * @param env - the launch environment snapshot to read.
  94. * @param lower - the lowercase variable name.
  95. * @returns the trimmed value and the name that supplied it, or `undefined` when neither is set.
  96. */
  97. function readEnv(
  98. env: EnvLookup,
  99. lower: string,
  100. ): { value: string; name: string } | undefined {
  101. for (const name of [lower, lower.toUpperCase()]) {
  102. const value = env.get(name)?.value.trim()
  103. if (value !== undefined && value !== '') return { value, name }
  104. }
  105. return undefined
  106. }
  107. /**
  108. * What one environment variable supplied. A rejected slot is distinct from an absent
  109. * one: the user named a proxy for that scheme, so falling back to another scheme's proxy would route
  110. * the request somewhere they never asked for while the diagnostic said it stayed direct.
  111. */
  112. type ProxyCandidate =
  113. | { readonly kind: 'accepted'; readonly value: string }
  114. | { readonly kind: 'rejected' }
  115. | { readonly kind: 'absent' }
  116. /** A slot nobody filled. */
  117. const ABSENT: ProxyCandidate = { kind: 'absent' }
  118. /**
  119. * Validate one candidate proxy URL.
  120. *
  121. * @param candidate - the raw value and the origin to name in a diagnostic.
  122. * @param diagnostics - collector the rejection is appended to.
  123. * @returns the candidate's usability, distinguishing a rejected slot from an empty one.
  124. */
  125. function acceptProxyUrl(
  126. candidate: { value: string; name: string } | undefined,
  127. diagnostics: ProxyDiagnostic[],
  128. ): ProxyCandidate {
  129. if (candidate === undefined) return ABSENT
  130. const parsed = URL.parse(candidate.value)
  131. if (parsed === null) {
  132. diagnostics.push({
  133. kind: 'invalid',
  134. origin: candidate.name,
  135. message: `${candidate.name} is not a valid URL; connecting directly`,
  136. })
  137. return { kind: 'rejected' }
  138. }
  139. if (SOCKS_PROTOCOLS.has(parsed.protocol)) {
  140. diagnostics.push({
  141. kind: 'socks',
  142. origin: candidate.name,
  143. message: `${candidate.name} names a SOCKS proxy, which is not supported; connecting directly for that scheme — set an http:// or https:// proxy URL instead`,
  144. })
  145. return { kind: 'rejected' }
  146. }
  147. if (!SUPPORTED_PROTOCOLS.has(parsed.protocol)) {
  148. diagnostics.push({
  149. kind: 'invalid',
  150. origin: candidate.name,
  151. message: `${candidate.name} uses the unsupported ${parsed.protocol}// scheme; connecting directly for that scheme — set an http:// or https:// proxy URL instead`,
  152. })
  153. return { kind: 'rejected' }
  154. }
  155. return { kind: 'accepted', value: candidate.value }
  156. }
  157. /**
  158. * Whether a proxy URL is one this package accepts: parseable, with an `http:` or `https:` scheme.
  159. * The same test {@link acceptProxyUrl} applies, without its diagnostics.
  160. *
  161. * @param value - the proxy URL as an environment variable holds it.
  162. * @returns true when the URL would be accepted.
  163. */
  164. export function isSupportedProxyUrl(value: string): boolean {
  165. const parsed = URL.parse(value)
  166. return parsed !== null && SUPPORTED_PROTOCOLS.has(parsed.protocol)
  167. }
  168. /**
  169. * Resolve one scheme's proxy from its own slot, then the fallbacks — but only when the scheme's own
  170. * slot was empty. A rejected slot keeps that scheme direct, so the diagnostic and the route agree.
  171. *
  172. * @param own - what the scheme's own name supplied.
  173. * @param fallbacks - values to try in order when `own` is absent.
  174. * @returns the proxy URL for that scheme, or `undefined` for a direct connection.
  175. */
  176. function resolveScheme(own: ProxyCandidate, ...fallbacks: (string | undefined)[]): string | undefined {
  177. if (own.kind === 'accepted') return own.value
  178. if (own.kind === 'rejected') return undefined
  179. return fallbacks.find(value => value !== undefined)
  180. }
  181. /**
  182. * Merge {@link LOOPBACK_NO_PROXY} into a bypass list, preserving the caller's entries and order.
  183. * A list of `*` already bypasses everything and is returned unchanged.
  184. *
  185. * @param noProxy - the bypass list as the environment supplied it.
  186. * @returns the effective bypass list.
  187. */
  188. function withLoopback(noProxy: string | undefined): string {
  189. const entries = (noProxy ?? '').split(/[,\s]+/).map(entry => entry.trim()).filter(entry => entry !== '')
  190. if (entries.includes('*')) return '*'
  191. const present = new Set(entries.map(entry => entry.toLowerCase()))
  192. return [...entries, ...LOOPBACK_NO_PROXY.filter(entry => !present.has(entry))].join(',')
  193. }
  194. /**
  195. * Split one bypass entry into host and optional port.
  196. *
  197. * A bare IPv6 literal carries several colons and no port, so only a single-colon entry splits;
  198. * a bracketed literal takes its port from after the bracket. Getting this wrong is how undici
  199. * turns `::1` into host `:` port `1`.
  200. *
  201. * @param entry - one already-trimmed bypass entry.
  202. * @returns the entry's host and, when it carries one, its port.
  203. */
  204. function splitHostPort(entry: string): { host: string; port?: string } {
  205. if (entry.startsWith('[')) {
  206. const close = entry.indexOf(']')
  207. if (close !== -1) {
  208. const rest = entry.slice(close + 1)
  209. const host = entry.slice(1, close)
  210. return rest.startsWith(':') ? { host, port: rest.slice(1) } : { host }
  211. }
  212. }
  213. const colon = entry.indexOf(':')
  214. if (colon !== -1 && entry.indexOf(':', colon + 1) === -1) {
  215. return { host: entry.slice(0, colon), port: entry.slice(colon + 1) }
  216. }
  217. return { host: entry }
  218. }
  219. /** One IPv4 octet, so a loopback match cannot accept `127.999.1.1`. */
  220. const OCTET = '(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)'
  221. /** The whole `127.0.0.0/8` block, not just its first address. */
  222. const LOOPBACK_IPV4 = new RegExp(`^127\\.${OCTET}\\.${OCTET}\\.${OCTET}$`)
  223. /**
  224. * Whether a host names this machine.
  225. *
  226. * A proxy cannot meaningfully reach one: it would resolve the address in its own network, and a
  227. * proxy running on this machine would reach a service that only listens on loopback. The bypass
  228. * list carries {@link LOOPBACK_NO_PROXY} for the consumers that read an environment rather than a
  229. * policy, but those are four literal entries — matching them alone leaves `127.0.0.2`, the whole
  230. * rest of `127.0.0.0/8`, and the IPv4-mapped spelling routed through the proxy.
  231. *
  232. * @param hostname - a URL's hostname, bracketed or not.
  233. * @returns true when the host is loopback or the unspecified address.
  234. */
  235. export function isLoopbackHost(hostname: string): boolean {
  236. const host = hostname.replace(/^\[|\]$/g, '').replace(/\.$/, '').toLowerCase()
  237. if (host === 'localhost' || host.endsWith('.localhost')) return true
  238. if (host === '::1' || host === '::' || host === '0.0.0.0') return true
  239. // An IPv4-mapped IPv6 address may keep its dotted tail or, once a URL has normalized it, carry
  240. // the same four bytes as two hex groups: `::ffff:127.0.0.1` and `::ffff:7f00:1` are one address.
  241. const mappedHigh = /^::ffff:([0-9a-f]{1,4}):[0-9a-f]{1,4}$/.exec(host)?.[1]
  242. if (mappedHigh !== undefined) return Number.parseInt(mappedHigh, 16) >>> 8 === 127
  243. return LOOPBACK_IPV4.test(host.startsWith('::ffff:') ? host.slice('::ffff:'.length) : host)
  244. }
  245. /**
  246. * Decide whether a bypass list exempts one URL. An entry names a host and matches it together with
  247. * every subdomain under it — `example.com` also bypasses `api.example.com` — and a leading `.` or
  248. * `*.` is accepted as the same thing; an entry may carry a `:port`, and `*` bypasses everything.
  249. * CIDR notation is not matched —
  250. * an operating system's bypass list often carries `10.0.0.0/8`, which must be rewritten as suffixes.
  251. *
  252. * @param noProxy - the effective bypass list.
  253. * @param url - the request URL.
  254. * @returns true when the URL must bypass the proxy.
  255. */
  256. export function bypassesProxy(noProxy: string, url: URL): boolean {
  257. // `URL.hostname` keeps the brackets around an IPv6 literal, while a bypass entry may be written
  258. // either way, so both sides are unbracketed before they are compared.
  259. const host = url.hostname.replace(/^\[|\]$/g, '').replace(/\.$/, '').toLowerCase()
  260. const port = url.port !== '' ? url.port : url.protocol === 'https:' ? '443' : '80'
  261. for (const raw of noProxy.split(/[,\s]+/)) {
  262. const entry = raw.trim().toLowerCase()
  263. if (entry === '') continue
  264. if (entry === '*') return true
  265. const split = splitHostPort(entry)
  266. if (split.port !== undefined && split.port !== port) continue
  267. const candidate = split.host.replace(/^\*?\./, '').replace(/\.$/, '')
  268. if (candidate === '') continue
  269. if (host === candidate || host.endsWith(`.${candidate}`)) return true
  270. }
  271. return false
  272. }
  273. /**
  274. * Resolve the outbound proxy policy for this process.
  275. *
  276. * A scheme's own variable wins, then `ALL_PROXY`, then — for HTTPS only — the HTTP proxy, matching
  277. * undici so this function and the installed dispatcher never disagree about one URL.
  278. *
  279. * @param env - the launch environment, whose own layering already prefers real variables over `.env` files.
  280. * @returns the policy to install plus every rejected candidate.
  281. */
  282. export function resolveProxyPolicy(env: EnvLookup): ProxyResolution {
  283. const diagnostics: ProxyDiagnostic[] = []
  284. const all = acceptProxyUrl(readEnv(env, 'all_proxy'), diagnostics)
  285. const allValue = all.kind === 'accepted' ? all.value : undefined
  286. const envHttp = acceptProxyUrl(readEnv(env, 'http_proxy'), diagnostics)
  287. const envHttps = acceptProxyUrl(readEnv(env, 'https_proxy'), diagnostics)
  288. const httpProxy = resolveScheme(envHttp, allValue)
  289. // HTTPS falls back to the HTTP proxy last, matching undici — but never past a value the user named
  290. // for HTTPS and this package refused.
  291. const httpsProxy = resolveScheme(envHttps, allValue, httpProxy)
  292. if (httpProxy === undefined && httpsProxy === undefined) return { policy: DIRECT_POLICY, diagnostics }
  293. return {
  294. policy: {
  295. ...httpProxy === undefined ? {} : { httpProxy },
  296. ...httpsProxy === undefined ? {} : { httpsProxy },
  297. noProxy: withLoopback(readEnv(env, 'no_proxy')?.value),
  298. source: 'env',
  299. },
  300. diagnostics,
  301. }
  302. }
  303. /**
  304. * Resolve which proxy one URL goes through under a policy.
  305. *
  306. * This is the single answer both the installed dispatcher and `dsh-web-fetch-http` consult, so a URL
  307. * can never be pinned to a resolved address by one and tunnelled by the other.
  308. *
  309. * @param policy - the active policy.
  310. * @param url - the request URL.
  311. * @returns the proxy URL to tunnel through, or `undefined` for a direct connection.
  312. */
  313. export function proxyForUrl(policy: ProxyPolicy, url: URL): string | undefined {
  314. const proxy = url.protocol === 'https:' ? policy.httpsProxy : url.protocol === 'http:' ? policy.httpProxy : undefined
  315. if (proxy === undefined) return undefined
  316. if (isLoopbackHost(url.hostname)) return undefined
  317. return bypassesProxy(policy.noProxy, url) ? undefined : proxy
  318. }