diff --git a/package.json b/package.json index f9962150d..a5736a9b5 100644 --- a/package.json +++ b/package.json @@ -142,7 +142,6 @@ "got": "^13.0.0", "got-ssrf": "^3.0.0", "helmet": "^7.0.0", - "hpagent": "^1.0.0", "http-problem-details": "^0.1.5", "ioredis": "^5.2.3", "ip-anonymize": "^0.1.0", diff --git a/server/core/helpers/hpagent.ts b/server/core/helpers/hpagent.ts new file mode 100644 index 000000000..28912194b --- /dev/null +++ b/server/core/helpers/hpagent.ts @@ -0,0 +1,158 @@ +// Copy of un-maintained hpagent package with https://github.com/delvedor/hpagent/pull/114 fix + +import http from 'http' +import https from 'https' +import { Socket, TcpNetConnectOpts } from 'net' +import { URL } from 'url' + +type Options = { + keepAlive: boolean + keepAliveMsecs: number + maxSockets: number + maxFreeSockets: number + scheduling: 'lifo' + proxy: string +} + +export class HttpProxyAgent extends http.Agent { + private readonly proxy: URL + private readonly keepAlive: boolean + + constructor (options: Options) { + const { proxy, ...opts } = options + + super(opts) + + this.keepAlive = options.keepAlive + + this.proxy = typeof proxy === 'string' + ? new URL(proxy) + : proxy + } + + createConnection (options: TcpNetConnectOpts, callback?: (err: Error, socket: Socket) => void) { + const requestOptions = { + method: 'CONNECT', + host: this.proxy.hostname, + port: this.proxy.port, + path: `${options.host}:${options.port}`, + setHost: false, + headers: { connection: this.keepAlive ? 'keep-alive' : 'close', host: `${options.host}:${options.port}` }, + agent: false, + timeout: options.timeout || 0, + servername: undefined as string + } + + if (this.proxy.username || this.proxy.password) { + const base64 = Buffer.from( + `${decodeURIComponent(this.proxy.username || '')}:${decodeURIComponent(this.proxy.password || '')}` + ).toString('base64') + + requestOptions.headers['proxy-authorization'] = `Basic ${base64}` + } + + if (this.proxy.protocol === 'https:') { + requestOptions.servername = this.proxy.hostname + } + + const request = (this.proxy.protocol === 'http:' ? http : https).request(requestOptions) + request.once('connect', (response, socket, head) => { + request.removeAllListeners() + socket.removeAllListeners() + if (response.statusCode === 200) { + callback(null, socket) + } else { + socket.destroy() + callback(new Error(`Bad response: ${response.statusCode}`), null) + } + }) + + request.once('timeout', () => { + request.destroy(new Error('Proxy timeout')) + }) + + request.once('error', err => { + request.removeAllListeners() + callback(err, null) + }) + + request.end() + } +} + +export class HttpsProxyAgent extends https.Agent { + private readonly proxy: URL + private readonly keepAlive: boolean + + constructor (options: Options) { + const { proxy, ...opts } = options + + super(opts) + + this.keepAlive = options.keepAlive + + this.proxy = typeof proxy === 'string' + ? new URL(proxy) + : proxy + } + + createConnection (options: TcpNetConnectOpts, callback?: (err: Error, socket: Socket) => void) { + const requestOptions = { + method: 'CONNECT', + host: this.proxy.hostname, + port: this.proxy.port, + path: `${options.host}:${options.port}`, + setHost: false, + headers: { connection: this.keepAlive ? 'keep-alive' : 'close', host: `${options.host}:${options.port}` }, + agent: false, + timeout: options.timeout || 0, + servername: undefined as string + } + + if (this.proxy.username || this.proxy.password) { + const base64 = Buffer.from( + `${decodeURIComponent(this.proxy.username || '')}:${decodeURIComponent(this.proxy.password || '')} + `).toString('base64') + + requestOptions.headers['proxy-authorization'] = `Basic ${base64}` + } + + // Necessary for the TLS check with the proxy to succeed. + if (this.proxy.protocol === 'https:') { + requestOptions.servername = this.proxy.hostname + } + + const request = (this.proxy.protocol === 'http:' ? http : https).request(requestOptions) + request.once('connect', (response, socket, head) => { + request.removeAllListeners() + socket.removeAllListeners() + + if (response.statusCode === 200) { + try { + // FIXME: typings doesn't include createConnection type in HTTP agent + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + const secureSocket = super.createConnection({ ...options, socket }) + callback(null, secureSocket) + } catch (err) { + socket.destroy() + callback(err, null) + } + } else { + socket.destroy() + callback(new Error(`Bad response: ${response.statusCode}`), null) + } + }) + + request.once('timeout', () => { + request.destroy(new Error('Proxy timeout')) + }) + + request.once('error', err => { + request.removeAllListeners() + callback(err, null) + }) + + request.end() + } +} diff --git a/server/core/helpers/proxy.ts b/server/core/helpers/proxy.ts index 8b82ccae0..52841a005 100644 --- a/server/core/helpers/proxy.ts +++ b/server/core/helpers/proxy.ts @@ -1,14 +1,9 @@ -function getProxy () { +export function getProxy () { return process.env.HTTPS_PROXY || process.env.HTTP_PROXY || undefined } -function isProxyEnabled () { +export function isProxyEnabled () { return !!getProxy() } - -export { - getProxy, - isProxyEnabled -} diff --git a/server/core/helpers/requests.ts b/server/core/helpers/requests.ts index f8a0c3e2f..24610bdae 100644 --- a/server/core/helpers/requests.ts +++ b/server/core/helpers/requests.ts @@ -4,7 +4,7 @@ import { createWriteStream } from 'fs' import { remove } from 'fs-extra/esm' import got, { CancelableRequest, OptionsInit, OptionsOfTextResponseBody, OptionsOfUnknownResponseBody, RequestError, Response } from 'got' import { gotSsrf } from 'got-ssrf' -import { HttpProxyAgent, HttpsProxyAgent } from 'hpagent' +import { HttpProxyAgent, HttpsProxyAgent } from '../helpers/hpagent.js' import { ACTIVITY_PUB, BINARY_CONTENT_TYPES, PEERTUBE_VERSION, REQUEST_TIMEOUTS, WEBSERVER } from '../initializers/constants.js' import { pipelinePromise } from './core-utils.js' import { logger, loggerTagsFactory } from './logger.js' diff --git a/yarn.lock b/yarn.lock index a44f8a7d1..dfba5da19 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6386,11 +6386,6 @@ homedir-polyfill@^1.0.1: dependencies: parse-passwd "^1.0.0" -hpagent@^1.0.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/hpagent/-/hpagent-1.2.0.tgz#0ae417895430eb3770c03443456b8d90ca464903" - integrity sha512-A91dYTeIB6NoXG+PxTQpCCDDnfHsW9kc06Lvpu1TEe9gnd6ZFeiBoRO9JvzEv6xK7EX97/dUE8g/vBMTqTS3CA== - html-to-text@9.0.5, html-to-text@^9.0.5: version "9.0.5" resolved "https://registry.yarnpkg.com/html-to-text/-/html-to-text-9.0.5.tgz#6149a0f618ae7a0db8085dca9bbf96d32bb8368d"