diff --git a/src/app/main/electron.ts b/src/app/main/electron.ts index ffce67d..77d1ce6 100644 --- a/src/app/main/electron.ts +++ b/src/app/main/electron.ts @@ -30,6 +30,7 @@ export type Menu = import('electron').Menu; export type MenuItem = import('electron').MenuItem; export type MessageBoxOptions = import('electron').MessageBoxOptions; export type Notification = import('electron').Notification; +export type Session = import('electron').Session; export type Settings = import('electron').Settings; export type ShareMenu = import('electron').ShareMenu; export type SharingItem = import('electron').SharingItem; diff --git a/src/app/main/index.ts b/src/app/main/index.ts index 93ea2e3..a2ca2d3 100644 --- a/src/app/main/index.ts +++ b/src/app/main/index.ts @@ -1,14 +1,15 @@ -import { app, BrowserWindow, ipcMain, LoginItemSettingsOptions } from './electron.js'; +import { app, BrowserWindow, ipcMain, LoginItemSettingsOptions, session } from './electron.js'; import process from 'node:process'; import * as path from 'node:path'; import { EventEmitter } from 'node:events'; +import { setGlobalDispatcher } from 'undici'; import * as persist from 'node-persist'; import MenuApp from './menu.js'; import { handleOpenWebServiceUri } from './webservices.js'; import { EmbeddedPresenceMonitor, PresenceMonitorManager } from './monitor.js'; import { createModalWindow, createWindow } from './windows.js'; import { setupIpc } from './ipc.js'; -import { askUserForUri, showErrorDialog } from './util.js'; +import { askUserForUri, buildElectronProxyAgent, showErrorDialog } from './util.js'; import { setAppInstance } from './app-menu.js'; import { handleAuthUri } from './na-auth.js'; import { DiscordPresenceConfiguration, LoginItem, LoginItemOptions, WindowType } from '../common/types.js'; @@ -119,6 +120,11 @@ export async function init() { initGlobals(); addUserAgent('nxapi-app (Chromium ' + process.versions.chrome + '; Electron ' + process.versions.electron + ')'); + const agent = buildElectronProxyAgent({ + session: session.defaultSession, + }); + setGlobalDispatcher(agent); + app.setAboutPanelOptions({ applicationName: 'nxapi-app', applicationVersion: process.platform === 'darwin' ? version : version + diff --git a/src/app/main/util.ts b/src/app/main/util.ts index d9cce9f..6e1f2dc 100644 --- a/src/app/main/util.ts +++ b/src/app/main/util.ts @@ -1,11 +1,15 @@ -import { BrowserWindow, dialog, Menu, MenuItem, MessageBoxOptions, nativeImage } from './electron.js'; +import { BrowserWindow, dialog, Menu, MenuItem, MessageBoxOptions, nativeImage, Session } from './electron.js'; import path from 'node:path'; import { Buffer } from 'node:buffer'; +import createDebug from '../../util/debug.js'; import { fetch } from 'undici'; import { dir } from '../../util/product.js'; import { App, Store } from './index.js'; import { SavedToken } from '../../common/auth/coral.js'; import { ErrorDescription } from '../../util/errors.js'; +import { buildProxyAgent, ProxyAgentOptions } from '../../util/undici-proxy.js'; + +const debug = createDebug('app:main:util'); export const bundlepath = path.resolve(dir, 'dist', 'app', 'bundle'); @@ -87,3 +91,35 @@ export function showErrorDialog(options: ErrorBoxOptions) { dialog.showMessageBox(window, message_box_options) : dialog.showMessageBox(message_box_options); } + +export function buildElectronProxyAgent(options: ProxyAgentOptions & { + session: Session; +}) { + let warned_proxy_unsupported: string | null = null; + + return buildProxyAgent({ + ...options, + resolveProxy: async origin => { + // https://chromium.googlesource.com/chromium/src/+/HEAD/net/docs/proxy.md + const proxies = await options.session.resolveProxy(origin); + const proxy = proxies.split(';')[0].trim(); + + if (proxy === 'DIRECT') return null; + + if (proxy.startsWith('PROXY ')) { + return new URL('http://' + proxy.substr(6)); + } + if (proxy.startsWith('HTTPS ')) { + return new URL('https://' + proxy.substr(6)); + } + + if (warned_proxy_unsupported !== proxy) { + warned_proxy_unsupported = proxy; + + debug('Unsupported proxy', proxy); + } + + return null; + }, + }); +} diff --git a/src/cli.ts b/src/cli.ts index db8d3c5..42489d2 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,5 +1,6 @@ import process from 'node:process'; import Yargs from 'yargs'; +import { setGlobalDispatcher } from 'undici'; import * as commands from './cli/index.js'; import { checkUpdates } from './common/update.js'; import createDebug from './util/debug.js'; @@ -9,11 +10,15 @@ import { YargsArguments } from './util/yargs.js'; import { addUserAgent } from './util/useragent.js'; import { USER_AGENT_INFO_URL } from './common/constants.js'; import { init as initGlobals } from './common/globals.js'; +import { buildEnvironmentProxyAgent } from './util/undici-proxy.js'; const debug = createDebug('cli'); initGlobals(); +const agent = buildEnvironmentProxyAgent(); +setGlobalDispatcher(agent); + export function createYargs(argv: string[]) { const yargs = Yargs(argv).option('data-path', { describe: 'Data storage path', diff --git a/src/util/errors.ts b/src/util/errors.ts index d4b2507..3465109 100644 --- a/src/util/errors.ts +++ b/src/util/errors.ts @@ -20,12 +20,12 @@ export class ErrorDescription { if (description) { return description.message + - (err instanceof Error ? '\n\n--\n\n' + (err.stack ?? err.message) : ''); + (err instanceof Error ? '\n\n--\n\n' + util.inspect(err) : ''); } } if (err instanceof Error) { - return err.stack || err.message; + return util.inspect(err); } return util.inspect(err, {compact: true}); diff --git a/src/util/undici-proxy.ts b/src/util/undici-proxy.ts new file mode 100644 index 0000000..fc88b24 --- /dev/null +++ b/src/util/undici-proxy.ts @@ -0,0 +1,94 @@ +import { Agent, buildConnector, Dispatcher, errors } from 'undici'; +import createDebug from './debug.js'; + +const debug = createDebug('nxapi:util:undici-proxy'); + +function defaultProtocolPort(protocol: string) { + return protocol === 'https:' ? 443 : 80; +} + +export interface ProxyAgentOptions { + agent?: Agent; + requestTls?: buildConnector.BuildOptions; +} + +export function buildProxyAgent(options: ProxyAgentOptions & { + resolveProxy: (origin: string) => Promise; +}) { + const agent = options.agent ?? new Agent(); + const connectEndpoint = buildConnector(options.requestTls ?? {}); + + return new Agent({ + connect: async (opts, callback) => { + let requestedHost = opts.host!; + + if (!opts.port) { + requestedHost += `:${defaultProtocolPort(opts.protocol)}`; + } + + try { + const request_origin = opts.protocol + '//' + opts.hostname + + (opts.port ? ':' + opts.port : ''); + + const proxy = await options.resolveProxy.call(null, request_origin); + + debug('resolved proxy for %s as %s', request_origin, proxy?.toString()); + + if (!proxy) { + connectEndpoint(opts, callback); + return; + } + + const { origin, port, host } = proxy; + + const { socket, statusCode } = await agent.connect({ + // @ts-expect-error + origin, + port, + path: requestedHost, + // @ts-expect-error + signal: opts.signal, + headers: { + host, + }, + }) as unknown as Dispatcher.ConnectData; + + if (statusCode !== 200) { + socket.on('error', () => {}).destroy(); + callback(new errors.RequestAbortedError('Proxy response !== 200 when HTTP Tunneling'), null); + } + + if (opts.protocol !== 'https:') { + // @ts-expect-error + callback(null, socket); + return; + } + + // @ts-expect-error + connectEndpoint({ ...opts, httpSocket: socket }, callback); + } catch (err) { + callback(err as Error, null); + } + }, + }); +} + +export function buildEnvironmentProxyAgent(options?: ProxyAgentOptions) { + return buildProxyAgent({ + ...options, + resolveProxy: resolveProxyFromEnvironment, + }); +} + +export async function resolveProxyFromEnvironment(origin: string) { + const { protocol } = new URL(origin); + + if (protocol === 'http:' && process.env.HTTP_PROXY) { + return new URL(process.env.HTTP_PROXY); + } + if (protocol === 'https:' && process.env.HTTPS_PROXY) { + return new URL(process.env.HTTPS_PROXY); + } + + return null; +}