| 1 | import log from 'electron-log/main.js' |
| 2 | import type Agent from 'undici/types/agent' |
| 3 | import type Dispatcher from 'undici/types/dispatcher' |
| 4 | import { |
| 5 | Agent as UndiciAgent, |
| 6 | Dispatcher as UndiciDispatcher, |
| 7 | ProxyAgent, |
| 8 | Socks5ProxyAgent, |
| 9 | getGlobalDispatcher, |
| 10 | setGlobalDispatcher |
| 11 | } from 'undici' |
| 12 | import { containsNoProxyWildcard, matchesNoProxy, parseNoProxyEntries } from './no-proxy' |
| 13 | |
| 14 | export interface ProxySettings { |
| 15 | url: string |
| 16 | username?: string |
| 17 | password?: string |
| 18 | noProxy?: string |
| 19 | } |
| 20 | |
| 21 | let originalDispatcher: Dispatcher | null = null |
| 22 | let currentProxyDispatcher: Dispatcher | null = null |
| 23 | |
| 24 | function isSocksUrl(url: string): boolean { |
| 25 | return url.startsWith('socks5://') || url.startsWith('socks://') |
| 26 | } |
| 27 | |
| 28 | /** 命中 no_proxy 的请求直连,其余走代理。 */ |
| 29 | class NoProxyAwareDispatcher extends UndiciDispatcher { |
| 30 | private readonly directDispatcher: Dispatcher |
| 31 | private readonly proxiedDispatcher: Dispatcher |
| 32 | private readonly bypassEntries: ReturnType<typeof parseNoProxyEntries> |
| 33 | |
| 34 | constructor( |
| 35 | directDispatcher: Dispatcher, |
| 36 | proxiedDispatcher: Dispatcher, |
| 37 | bypassEntries: ReturnType<typeof parseNoProxyEntries> |
| 38 | ) { |
| 39 | super() |
| 40 | this.directDispatcher = directDispatcher |
| 41 | this.proxiedDispatcher = proxiedDispatcher |
| 42 | this.bypassEntries = bypassEntries |
| 43 | } |
| 44 | |
| 45 | dispatch(options: Agent.DispatchOptions, handler: Dispatcher.DispatchHandler): boolean { |
| 46 | const bypass = (() => { |
| 47 | try { |
| 48 | const origin = new URL(String(options.origin ?? '')) |
| 49 | const port = origin.port |
| 50 | ? Number(origin.port) |
| 51 | : origin.protocol === 'https:' |
| 52 | ? 443 |
| 53 | : 80 |
| 54 | return matchesNoProxy(this.bypassEntries, origin.hostname, port) |
| 55 | } catch { |
| 56 | return false |
| 57 | } |
| 58 | })() |
| 59 | return bypass |
| 60 | ? this.directDispatcher.dispatch(options, handler) |
| 61 | : this.proxiedDispatcher.dispatch(options, handler) |
| 62 | } |
| 63 | |
| 64 | close(callback: () => void): void |
| 65 | close(): Promise<void> |
| 66 | close(callback?: () => void): Promise<void> | void { |
| 67 | const closing = Promise.allSettled([ |
| 68 | this.directDispatcher.close(), |
| 69 | this.proxiedDispatcher.close() |
| 70 | ]) |
| 71 | if (typeof callback === 'function') { |
| 72 | void closing.then(() => callback()) |
| 73 | return |
| 74 | } |
| 75 | return closing.then(() => undefined) |
| 76 | } |
| 77 | |
| 78 | destroy(error: Error | null, callback: () => void): void |
| 79 | destroy(callback: () => void): void |
| 80 | destroy(error: Error | null): Promise<void> |
| 81 | destroy(): Promise<void> |
| 82 | destroy(error?: Error | null | (() => void), callback?: () => void): Promise<void> | void { |
| 83 | const reason = typeof error === 'function' ? null : (error ?? null) |
| 84 | const done = typeof error === 'function' ? error : callback |
| 85 | const teardown = Promise.allSettled([ |
| 86 | this.directDispatcher.destroy(reason), |
| 87 | this.proxiedDispatcher.destroy(reason) |
| 88 | ]) |
| 89 | if (typeof done === 'function') { |
| 90 | void teardown.then(() => done()) |
| 91 | return |
| 92 | } |
| 93 | return teardown.then(() => undefined) |
| 94 | } |
| 95 | } |
| 96 | |
| 97 | export function applyProxy(settings?: ProxySettings): void { |
| 98 | if (originalDispatcher === null) { |
| 99 | originalDispatcher = getGlobalDispatcher() |
| 100 | } |
| 101 | |
| 102 | const url = (settings?.url || '').trim() |
| 103 | if (!url) { |
| 104 | clearProxy() |
| 105 | return |
| 106 | } |
| 107 | |
| 108 | const username = (settings?.username || '').trim() |
| 109 | const password = settings?.password || '' |
| 110 | const noProxyValue = (settings?.noProxy || '').trim() |
| 111 | |
| 112 | if (containsNoProxyWildcard(noProxyValue)) { |
| 113 | log.info('[proxy] no_proxy wildcard set, all requests bypass the proxy') |
| 114 | clearProxy() |
| 115 | return |
| 116 | } |
| 117 | |
| 118 | const hasAuth = username.length > 0 |
| 119 | let proxiedDispatcher: Dispatcher |
| 120 | if (isSocksUrl(url)) { |
| 121 | proxiedDispatcher = new Socks5ProxyAgent(url, { |
| 122 | username: username || undefined, |
| 123 | password: password || undefined |
| 124 | }) |
| 125 | } else if (hasAuth) { |
| 126 | proxiedDispatcher = new ProxyAgent({ |
| 127 | uri: url, |
| 128 | token: `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}` |
| 129 | }) |
| 130 | } else { |
| 131 | proxiedDispatcher = new ProxyAgent(url) |
| 132 | } |
| 133 | |
| 134 | const bypassEntries = parseNoProxyEntries(noProxyValue) |
| 135 | const dispatcher = |
| 136 | bypassEntries.length > 0 |
| 137 | ? new NoProxyAwareDispatcher(new UndiciAgent(), proxiedDispatcher, bypassEntries) |
| 138 | : proxiedDispatcher |
| 139 | |
| 140 | closeCurrentProxy() |
| 141 | currentProxyDispatcher = dispatcher |
| 142 | setGlobalDispatcher(dispatcher) |
| 143 | log.info('[proxy] applied', { |
| 144 | url, |
| 145 | hasAuth, |
| 146 | noProxy: noProxyValue, |
| 147 | bypassEntries: bypassEntries.length |
| 148 | }) |
| 149 | } |
| 150 | |
| 151 | export function clearProxy(): void { |
| 152 | if (!originalDispatcher) return |
| 153 | closeCurrentProxy() |
| 154 | setGlobalDispatcher(originalDispatcher) |
| 155 | log.info('[proxy] cleared, restored default dispatcher') |
| 156 | } |
| 157 | |
| 158 | function closeCurrentProxy(): void { |
| 159 | if (currentProxyDispatcher && typeof currentProxyDispatcher.close === 'function') { |
| 160 | void currentProxyDispatcher.close().catch(() => {}) |
| 161 | } |
| 162 | currentProxyDispatcher = null |
| 163 | } |
| 164 |