feat: add ctx.lib.proxyFetch method

This commit is contained in:
purocean
2024-05-17 20:51:27 +08:00
parent 5d96325409
commit bc00a28e46
9 changed files with 244 additions and 8 deletions
+2
View File
@@ -56,9 +56,11 @@
"ripgrep-wrapper": "^1.1.1",
"safe-buffer": "^5.2.1",
"socket.io": "^4.7.2",
"socks": "^2.8.3",
"socks-proxy-agent": "^6.1.1",
"tar-stream": "^2.2.0",
"transliteration": "^2.2.0",
"undici": "^6.16.1",
"uuid": "^9.0.0",
"yaml": "^2.2.2",
"yargs": "^15.3.1"
+3 -1
View File
@@ -15,7 +15,7 @@ import { registerAction } from './action'
import { registerShortcut } from './shortcut'
import { initJSONRPCClient, jsonRPCClient } from './jsonrpc'
import { $t } from './i18n'
import { getProxyAgent, newProxyAgent } from './proxy-agent'
import { getProxyAgent, getProxyDispatcher, newProxyAgent, newProxyDispatcher } from './proxy-agent'
import config from './config'
import { initProxy } from './proxy'
import { initEnvs } from './envs'
@@ -529,6 +529,8 @@ registerAction('quit', quit)
registerAction('show-open-dialog', showOpenDialog)
registerAction('refresh-menus', refreshMenus)
registerAction('get-proxy-agent', getProxyAgent)
registerAction('get-proxy-dispatcher', getProxyDispatcher)
registerAction('new-proxy-dispatcher', newProxyDispatcher)
registerAction('new-proxy-agent', newProxyAgent)
powerMonitor.on('shutdown', quit)
+2
View File
@@ -48,6 +48,8 @@ export async function transformProtocolRequest (request: ProtocolRequest) {
if (body) {
req.headers['content-length'] = body.length.toString()
req._read = Readable.from(body)._read
} else {
req._read = () => req.push(null)
}
const out = new Transform({
+39
View File
@@ -1,5 +1,7 @@
/* eslint-disable @typescript-eslint/no-var-requires */
import { session } from 'electron'
import { Dispatcher, ProxyAgent } from 'undici'
import { socksDispatcher } from './undici-socket-agent'
const SocksProxyAgent = require('socks-proxy-agent')
const HttpsProxyAgent = require('https-proxy-agent')
const HttpProxyAgent = require('http-proxy-agent')
@@ -14,6 +16,19 @@ export function newProxyAgent (proxyUrl: string) {
}
}
export function newProxyDispatcher (proxyUrl: string): Dispatcher {
if (proxyUrl.toLowerCase().startsWith('socks://') || proxyUrl.toLowerCase().startsWith('socks5://')) {
const url = new URL(proxyUrl)
return socksDispatcher({
type: 5,
host: url.hostname,
port: +url.port
})
}
return new ProxyAgent(proxyUrl)
}
export async function getProxyAgent (url: string) {
const proxy = await session.defaultSession.resolveProxy(url)
if (!proxy) {
@@ -41,3 +56,27 @@ export async function getProxyAgent (url: string) {
return undefined
}
export async function getProxyDispatcher (url: string): Promise<Dispatcher | undefined> {
const proxy = await session.defaultSession.resolveProxy(url)
if (!proxy) {
return undefined
}
const proxies = String(proxy).trim().split(/\s*;\s*/g).filter(Boolean)
const first = proxies[0]
const parts = first.split(/\s+/)
const type = parts[0]
let proxyUrl = ''
if (type === 'SOCKS' || type === 'SOCKS5') {
// use a SOCKS proxy
proxyUrl = 'socks://' + parts[1]
} else if (type === 'PROXY' || type === 'HTTPS') {
proxyUrl = (type === 'HTTPS' ? 'https' : 'http') + '://' + parts[1]
} else {
return undefined
}
return newProxyDispatcher(proxyUrl)
}
+44 -1
View File
@@ -8,6 +8,7 @@ import Koa from 'koa'
import bodyParser from 'koa-body'
import * as mime from 'mime'
import request from 'request'
import * as undici from 'undici'
import { promisify } from 'util'
import { STATIC_DIR, HOME_DIR, HELP_DIR, USER_PLUGIN_DIR, FLAG_DISABLE_SERVER, APP_NAME, USER_THEME_DIR, RESOURCES_DIR, BUILD_IN_STYLES, USER_EXTENSION_DIR, USER_DATA } from '../constant'
import * as file from './file'
@@ -37,6 +38,10 @@ const noCache = (ctx: any) => {
}
const checkPermission = (ctx: any, next: any) => {
if (ctx.path.startsWith('/api/proxy-fetch')) {
return next()
}
const token = ctx.query._token || (ctx.headers.authorization || '').replace('Bearer ', '')
if (ctx.req._protocol || (!token && isLocalhost(ctx.request.ip))) {
@@ -319,7 +324,45 @@ const userFile = async (ctx: any, next: any) => {
}
const proxy = async (ctx: any, next: any) => {
if (ctx.path.startsWith('/api/proxy')) {
if (ctx.path.startsWith('/api/proxy-fetch/')) {
const url = ctx.originalUrl.replace(/^.*\/api\/proxy-fetch\//, '')
// TODO ssrf
// const _url = new URL(url)
// if (
// _url.hostname === 'localhost' ||
// (
// (ip.isV4Format(_url.hostname) || ip.isV6Format(_url.hostname)) &&
// ip.isPrivate(_url.hostname)
// )
// ) {
// throw new Error('Invalid URL')
// }
try {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { 'x-proxy-url': proxyUrl, host, ...headers } = ctx.headers
const dispatcher = proxyUrl
? getAction('new-proxy-dispatcher')(proxyUrl)
: await getAction('get-proxy-dispatcher')(url)
const response = await undici.request(url, {
dispatcher,
method: ctx.method,
headers,
body: ctx.req,
})
// Set the response status, headers, and body
ctx.status = response.statusCode
ctx.set(response.headers)
ctx.body = response.body
} catch (error: any) {
ctx.status = 500
throw error
}
} else if (ctx.path.startsWith('/api/proxy')) {
const data = ctx.method === 'POST' ? ctx.request.body : ctx.query
const url = data.url
const options = typeof data.options === 'string' ? JSON.parse(ctx.query.options) : data.options
+100
View File
@@ -0,0 +1,100 @@
// https://github.com/Kaciras/fetch-socks/blob/master/index.ts
import { SocksClient, SocksProxy } from 'socks'
import { Agent, buildConnector } from 'undici'
import Connector = buildConnector.connector;
import TLSOptions = buildConnector.BuildOptions;
export type SocksProxies = SocksProxy | SocksProxy[];
/**
* Since socks does not guess HTTP ports, we need to do that.
*
* @param protocol Upper layer protocol, "http:" or "https:"
* @param port A string containing the port number of the URL, maybe empty.
*/
function resolvePort (protocol: string, port: string) {
return port ? Number.parseInt(port) : protocol === 'http:' ? 80 : 443
}
/**
* Create an Undici connector which establish the connection through socks proxies.
*
* If the proxies is an empty array, it will connect directly.
*
* @param proxies The proxy server to use or the list of proxy servers to chain.
* @param tlsOpts TLS upgrade options.
*/
export function socksConnector (proxies: SocksProxies, tlsOpts: TLSOptions = {}): Connector {
const chain = Array.isArray(proxies) ? proxies : [proxies]
const { timeout = 1e4 } = tlsOpts
const undiciConnect = buildConnector(tlsOpts)
return async (options, callback) => {
let { protocol, hostname, port, httpSocket } = options
for (let i = 0; i < chain.length; i++) {
const next = chain[i + 1]
const destination = i === chain.length - 1 ? {
host: hostname,
port: resolvePort(protocol, port),
} : {
port: next.port,
host: next.host ?? next.ipaddress!,
}
const socksOpts = {
command: 'connect' as const,
proxy: chain[i],
timeout,
destination,
existing_socket: httpSocket,
}
try {
const r = await SocksClient.createConnection(socksOpts)
httpSocket = r.socket
} catch (error: any) {
return callback(error, null)
}
}
// httpSocket may not exist when the chain is empty.
if (httpSocket && protocol !== 'https:') {
return callback(null, httpSocket.setNoDelay())
}
/*
* There are 2 cases here:
* If httpSocket doesn't exist, let Undici make a connection.
* If httpSocket exists & protocol is HTTPS, do TLS upgrade.
*/
return undiciConnect({ ...options, httpSocket }, callback)
}
}
export interface SocksDispatcherOptions extends Agent.Options {
/**
* TLS upgrade options, see:
* https://undici.nodejs.org/#/docs/api/Client?id=parameter-connectoptions
*
* The connect function is not supported.
* If you want to create a custom connector, you can use `socksConnector`.
*/
connect?: TLSOptions;
}
/**
* Create a Undici Agent with socks connector.
*
* If the proxies is an empty array, it will connect directly.
*
* @param proxies The proxy server to use or the list of proxy servers to chain.
* @param options Additional options passed to the Agent constructor.
*/
export function socksDispatcher (proxies: SocksProxies, options: SocksDispatcherOptions = {}) {
const { connect, ...rest } = options
return new Agent({ ...rest, connect: socksConnector(proxies, connect) })
}
+17
View File
@@ -83,6 +83,23 @@ export async function proxyRequest (
return res
}
/**
* Proxy fetch.
* @param url string
* @param init RequestInit
* @returns
*/
export async function proxyFetch (url: string, init?: RequestInit) {
const res: Response = await fetch(`/api/proxy-fetch/${url}`, init)
if (res.headers.get('x-yank-note-api-status') === 'error') {
const msg = res.headers.get('x-yank-note-api-message') || 'error'
throw new Error(decodeURIComponent(msg))
}
return res
}
/**
* Fetch help file content.
* @param docName
+6 -6
View File
@@ -24,22 +24,22 @@ export default defineConfig({
port: 8066,
proxy: {
'/static': {
target: 'http://localhost:3044'
target: 'http://127.0.0.1:3044'
},
'/custom-css': {
target: 'http://localhost:3044'
target: 'http://127.0.0.1:3044'
},
'/extension': {
target: 'http://localhost:3044'
target: 'http://127.0.0.1:3044'
},
'/github.css': {
target: 'http://localhost:3044'
target: 'http://127.0.0.1:3044'
},
'/api': {
target: 'http://localhost:3044'
target: 'http://127.0.0.1:3044'
},
'/ws': {
target: 'http://localhost:3044',
target: 'http://127.0.0.1:3044',
ws: true
}
}
+31
View File
@@ -5462,6 +5462,14 @@ internal-slot@^1.0.5:
hasown "^2.0.0"
side-channel "^1.0.4"
ip-address@^9.0.5:
version "9.0.5"
resolved "https://registry.yarnpkg.com/ip-address/-/ip-address-9.0.5.tgz#117a960819b08780c3bd1f14ef3c1cc1d3f3ea5a"
integrity sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==
dependencies:
jsbn "1.1.0"
sprintf-js "^1.1.3"
ip@^1.1.5, ip@^1.1.9:
version "1.1.9"
resolved "https://registry.yarnpkg.com/ip/-/ip-1.1.9.tgz#8dfbcc99a754d07f425310b86a99546b1151e396"
@@ -6259,6 +6267,11 @@ js-yaml@^4.1.0:
dependencies:
argparse "^2.0.1"
jsbn@1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-1.1.0.tgz#b01307cb29b618a1ed26ec79e911f803c4da0040"
integrity sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==
jsbn@~0.1.0:
version "0.1.1"
resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513"
@@ -8246,6 +8259,14 @@ socks@^2.6.2:
ip "^2.0.0"
smart-buffer "^4.2.0"
socks@^2.8.3:
version "2.8.3"
resolved "https://registry.yarnpkg.com/socks/-/socks-2.8.3.tgz#1ebd0f09c52ba95a09750afe3f3f9f724a800cb5"
integrity sha512-l5x7VUUWbjVFbafGLxPWkYsHIhEvmF85tbIeFZWc8ZPtoMyybuEhL7Jye/ooC4/d48FgOjSJXgsF/AJPYCW8Zw==
dependencies:
ip-address "^9.0.5"
smart-buffer "^4.2.0"
sort-keys-length@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/sort-keys-length/-/sort-keys-length-1.0.1.tgz#9cb6f4f4e9e48155a6aa0671edd336ff1479a188"
@@ -8339,6 +8360,11 @@ sprintf-js@^1.1.2:
resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.1.2.tgz#da1765262bf8c0f571749f2ad6c26300207ae673"
integrity sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug==
sprintf-js@^1.1.3:
version "1.1.3"
resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.1.3.tgz#4914b903a2f8b685d17fdf78a70e917e872e444a"
integrity sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==
sprintf-js@~1.0.2:
version "1.0.3"
resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c"
@@ -8937,6 +8963,11 @@ undici-types@~5.26.4:
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617"
integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==
undici@^6.16.1:
version "6.16.1"
resolved "https://registry.yarnpkg.com/undici/-/undici-6.16.1.tgz#ff8f49c56e5a8629f92ad2ce00d4841b9619b19e"
integrity sha512-NeNiTT7ixpeiL1qOIU/xTVpHpVP0svmI6PwoCKaMGaI5AsHOaRdwqU/f7Fi9eyU4u03nd5U/BC8wmRMnS9nqoA==
unique-filename@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/unique-filename/-/unique-filename-3.0.0.tgz#48ba7a5a16849f5080d26c760c86cf5cf05770ea"