From 852933d4d77c203aa3e39fe8955053efa6387e52 Mon Sep 17 00:00:00 2001 From: qiye45 <138199658+qiye45@users.noreply.github.com> Date: Sun, 11 May 2025 01:04:00 +0800 Subject: [PATCH] feat(plugin): support authenticated proxy (#3175) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: JellyBrick Co-authored-by: qiye45 --- package.json | 1 + pnpm-lock.yaml | 3 + src/i18n/resources/en.json | 24 ++ src/i18n/resources/zh-CN.json | 24 ++ src/index.ts | 21 +- .../auth-proxy-adapter/backend/index.ts | 246 ++++++++++++++++++ .../auth-proxy-adapter/backend/types.ts | 21 ++ src/plugins/auth-proxy-adapter/config.ts | 11 + src/plugins/auth-proxy-adapter/index.ts | 16 ++ src/plugins/auth-proxy-adapter/menu.ts | 68 +++++ 10 files changed, 434 insertions(+), 1 deletion(-) create mode 100644 src/plugins/auth-proxy-adapter/backend/index.ts create mode 100644 src/plugins/auth-proxy-adapter/backend/types.ts create mode 100644 src/plugins/auth-proxy-adapter/config.ts create mode 100644 src/plugins/auth-proxy-adapter/index.ts create mode 100644 src/plugins/auth-proxy-adapter/menu.ts diff --git a/package.json b/package.json index be544d0e..941fa432 100644 --- a/package.json +++ b/package.json @@ -295,6 +295,7 @@ "semver": "7.7.1", "serve": "14.2.4", "simple-youtube-age-restriction-bypass": "github:organization/Simple-YouTube-Age-Restriction-Bypass#v2.5.9", + "socks": "2.8.4", "solid-floating-ui": "0.3.1", "solid-js": "1.9.5", "solid-styled-components": "0.28.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bf27e8a0..07291bfe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -201,6 +201,9 @@ importers: simple-youtube-age-restriction-bypass: specifier: github:organization/Simple-YouTube-Age-Restriction-Bypass#v2.5.9 version: https://codeload.github.com/organization/Simple-YouTube-Age-Restriction-Bypass/tar.gz/4e2db89ccb2fb880c5110add9ff3f1dfb78d0ff6 + socks: + specifier: ^2.8.4 + version: 2.8.4 solid-floating-ui: specifier: 0.3.1 version: 0.3.1(@floating-ui/dom@1.6.13)(solid-js@1.9.5) diff --git a/src/i18n/resources/en.json b/src/i18n/resources/en.json index fff45c01..41e1242e 100644 --- a/src/i18n/resources/en.json +++ b/src/i18n/resources/en.json @@ -333,6 +333,30 @@ "description": "Apply compression to audio (lowers the volume of the loudest parts of the signal and raises the volume of the softest parts)", "name": "Audio Compressor" }, + "auth-proxy-adapter": { + "name": "Auth Proxy Adapter", + "description": "Support for the use of authentication proxy services", + "menu": { + "disable": "Disable Proxy Adapter", + "enable": "Enable Proxy Adapter", + "hostname": { + "label": "Hostname" + }, + "port": { + "label": "Port" + } + }, + "prompt": { + "hostname": { + "title": "Proxy Hostname", + "label": "Enter hostname for local proxy server (requires restart):" + }, + "port": { + "title": "Proxy Port", + "label": "Enter port for local proxy server (requires restart):" + } + } + }, "blur-nav-bar": { "description": "Makes navigation bar transparent and blurry", "name": "Blur Navigation Bar" diff --git a/src/i18n/resources/zh-CN.json b/src/i18n/resources/zh-CN.json index 7dd34c3e..d54ef98d 100644 --- a/src/i18n/resources/zh-CN.json +++ b/src/i18n/resources/zh-CN.json @@ -333,6 +333,30 @@ "description": "对音频应用压缩(压低响亮部分,提升柔和部分)", "name": "音频压缩器" }, + "auth-proxy-adapter": { + "name": "认证代理适配", + "description": "支持使用需要身份验证的代理", + "menu": { + "disable": "禁用代理适配", + "enable": "启用代理适配", + "hostname": { + "label": "主机名" + }, + "port": { + "label": "端口" + } + }, + "prompt": { + "hostname": { + "title": "代理主机名", + "label": "请输入本地代理服务器的主机名(需要重启):" + }, + "port": { + "title": "代理端口", + "label": "请输入本地代理服务器的端口号(需要重启):" + } + } + }, "blur-nav-bar": { "description": "让导航栏透明及模糊", "name": "模糊导航栏" diff --git a/src/index.ts b/src/index.ts index 53d30604..cc36a9d7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -57,6 +57,8 @@ import { loadI18n, setLanguage, t } from '@/i18n'; import ErrorHtmlAsset from '@assets/error.html?asset'; +import { defaultAuthProxyConfig } from '@/plugins/auth-proxy-adapter/config'; + import type { PluginConfig } from '@/types/plugins'; if (!is.macOS()) { @@ -141,7 +143,24 @@ if (is.linux()) { } if (config.get('options.proxy')) { - app.commandLine.appendSwitch('proxy-server', config.get('options.proxy')); + const authProxyEnabled = config.plugins.isEnabled('auth-proxy-adapter'); + + let proxyToUse = ''; + if (authProxyEnabled) { + // Use proxy from Auth-Proxy-Adapter plugin + const authProxyConfig = deepmerge( + defaultAuthProxyConfig, + config.get('plugins.auth-proxy-adapter') ?? {}, + ) as typeof defaultAuthProxyConfig; + + const { hostname, port } = authProxyConfig; + proxyToUse = `socks5://${hostname}:${port}`; + } else if (config.get('options.proxy')) { + // Use global proxy settings + proxyToUse = config.get('options.proxy'); + } + console.log(LoggerPrefix, `Using proxy: ${proxyToUse}`); + app.commandLine.appendSwitch('proxy-server', proxyToUse); } // Adds debug features like hotkeys for triggering dev tools and reload diff --git a/src/plugins/auth-proxy-adapter/backend/index.ts b/src/plugins/auth-proxy-adapter/backend/index.ts new file mode 100644 index 00000000..34ba2dce --- /dev/null +++ b/src/plugins/auth-proxy-adapter/backend/index.ts @@ -0,0 +1,246 @@ +import net from 'net'; + +import { SocksClient, SocksClientOptions } from 'socks'; + +import is from 'electron-is'; + +import { createBackend, LoggerPrefix } from '@/utils'; + +import { BackendType } from './types'; + +import config from '@/config'; + +import { AuthProxyConfig, defaultAuthProxyConfig } from '../config'; + +import type { BackendContext } from '@/types/contexts'; + +// Parsing the upstream authentication SOCK proxy URL +const parseSocksUrl = (socksUrl: string) => { + // Format: socks5://username:password@your_server_ip:port + + const url = new URL(socksUrl); + return { + host: url.hostname, + port: parseInt(url.port, 10), + type: url.protocol === 'socks5:' ? 5 : 4, + username: url.username, + password: url.password, + }; +}; + +export const backend = createBackend({ + async start(ctx: BackendContext) { + const pluginConfig = await ctx.getConfig(); + this.startServer(pluginConfig); + }, + stop() { + this.stopServer(); + }, + onConfigChange(config: AuthProxyConfig) { + if ( + this.oldConfig?.hostname === config.hostname && + this.oldConfig?.port === config.port + ) { + this.oldConfig = config; + return; + } + this.stopServer(); + this.startServer(config); + + this.oldConfig = config; + }, + + // Custom + // Start proxy server - SOCKS5 + startServer(serverConfig: AuthProxyConfig) { + if (this.server) { + this.stopServer(); + } + + const { port, hostname } = serverConfig; + // Upstream proxy from system settings + const upstreamProxyUrl = config.get('options.proxy'); + // Create SOCKS proxy server + const socksServer = net.createServer((socket) => { + socket.once('data', (chunk) => { + if (chunk[0] === 0x05) { + // SOCKS5 + this.handleSocks5(socket, chunk, upstreamProxyUrl); + } else { + socket.end(); + } + }); + + socket.on('error', (err) => { + console.error(LoggerPrefix, '[SOCKS] Socket error:', err.message); + }); + }); + + // Listen for errors + socksServer.on('error', (err) => { + console.error(LoggerPrefix, '[SOCKS Server Error]', err.message); + }); + + // Start server + socksServer.listen(port, hostname, () => { + console.log(LoggerPrefix, '==========================================='); + console.log( + LoggerPrefix, + `[Auth-Proxy-Adapter] Enable SOCKS proxy at socks5://${hostname}:${port}`, + ); + console.log( + LoggerPrefix, + `[Auth-Proxy-Adapter] Using upstream proxy: ${upstreamProxyUrl}`, + ); + console.log(LoggerPrefix, '==========================================='); + }); + + this.server = socksServer; + }, + + // Handle SOCKS5 request + handleSocks5( + clientSocket: net.Socket, + chunk: Buffer, + upstreamProxyUrl: string, + ) { + // Handshake phase + const numMethods = chunk[1]; + const methods = chunk.subarray(2, 2 + numMethods); + + // Check if client supports no authentication method (0x00) + if (methods.includes(0x00)) { + // Reply to client, choose no authentication method + clientSocket.write(Buffer.from([0x05, 0x00])); + + // Wait for client's connection request + clientSocket.once('data', (data) => { + this.processSocks5Request(clientSocket, data, upstreamProxyUrl); + }); + } else { + // Authentication methods not supported by the client + clientSocket.write(Buffer.from([0x05, 0xff])); + clientSocket.end(); + } + }, + + // Handle SOCKS5 connection request + processSocks5Request( + clientSocket: net.Socket, + data: Buffer, + upstreamProxyUrl: string, + ) { + // Parse target address and port + let targetHost, targetPort; + const cmd = data[1]; // Command: 0x01=CONNECT, 0x02=BIND, 0x03=UDP + const atyp = data[3]; // Address type: 0x01=IPv4, 0x03=Domain, 0x04=IPv6 + + if (cmd !== 0x01) { + // Currently only support CONNECT command + clientSocket.write( + Buffer.from([0x05, 0x07, 0x00, 0x01, 0, 0, 0, 0, 0, 0]), + ); + clientSocket.end(); + return; + } + + if (atyp === 0x01) { + // IPv4 + targetHost = `${data[4]}.${data[5]}.${data[6]}.${data[7]}`; + targetPort = data.readUInt16BE(8); + } else if (atyp === 0x03) { + // Domain + const hostLen = data[4]; + targetHost = data.subarray(5, 5 + hostLen).toString(); + targetPort = data.readUInt16BE(5 + hostLen); + } else if (atyp === 0x04) { + // IPv6 + const ipv6Buffer = data.subarray(4, 20); + targetHost = Array.from(new Array(8), (_, i) => + ipv6Buffer.readUInt16BE(i * 2).toString(16), + ).join(':'); + targetPort = data.readUInt16BE(20); + } + if (is.dev()) { + console.debug( + LoggerPrefix, + `[SOCKS5] Request to connect to ${targetHost}:${targetPort}`, + ); + } + + const socksProxy = parseSocksUrl(upstreamProxyUrl); + + if (!socksProxy) { + // Failed to parse proxy URL + clientSocket.write( + Buffer.from([0x05, 0x01, 0x00, 0x01, 0, 0, 0, 0, 0, 0]), + ); + clientSocket.end(); + return; + } + + const options: SocksClientOptions = { + proxy: { + host: socksProxy.host, + port: socksProxy.port, + type: socksProxy.type as 4 | 5, + userId: socksProxy.username, + password: socksProxy.password, + }, + command: 'connect', + destination: { + host: targetHost || defaultAuthProxyConfig.hostname, + port: targetPort || defaultAuthProxyConfig.port, + }, + }; + SocksClient.createConnection(options) + .then((info) => { + const { socket: proxySocket } = info; + + // Connection successful, send success response to client + const responseBuffer = Buffer.from([ + 0x05, // VER: SOCKS5 + 0x00, // REP: Success + 0x00, // RSV: Reserved field + 0x01, // ATYP: IPv4 + 0, + 0, + 0, + 0, // BND.ADDR: 0.0.0.0 (Bound address, usually not important) + 0, + 0, // BND.PORT: 0 (Bound port, usually not important) + ]); + clientSocket.write(responseBuffer); + + // Establish bidirectional data stream + proxySocket.pipe(clientSocket); + clientSocket.pipe(proxySocket); + + proxySocket.on('error', (error) => { + console.error(LoggerPrefix, '[SOCKS5] Proxy socket error:', error); + if (clientSocket.writable) clientSocket.end(); + }); + + clientSocket.on('error', (error) => { + console.error(LoggerPrefix, '[SOCKS5] Client socket error:', error); + if (proxySocket.writable) proxySocket.end(); + }); + }) + .catch((error) => { + console.error(LoggerPrefix, '[SOCKS5] Connection error:', error); + // Send failure response to client + clientSocket.write( + Buffer.from([0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0]), + ); + clientSocket.end(); + }); + }, + + // Stop proxy server + stopServer() { + if (this.server) { + this.server.close(); + this.server = undefined; + } + }, +}); diff --git a/src/plugins/auth-proxy-adapter/backend/types.ts b/src/plugins/auth-proxy-adapter/backend/types.ts new file mode 100644 index 00000000..99599ef3 --- /dev/null +++ b/src/plugins/auth-proxy-adapter/backend/types.ts @@ -0,0 +1,21 @@ +import net from 'net'; + +import type { AuthProxyConfig } from '../config'; +import type { Server } from 'http'; + +export type BackendType = { + server?: Server | net.Server; + oldConfig?: AuthProxyConfig; + startServer: (serverConfig: AuthProxyConfig) => void; + stopServer: () => void; + handleSocks5: ( + clientSocket: net.Socket, + chunk: Buffer, + upstreamProxyUrl: string, + ) => void; + processSocks5Request: ( + clientSocket: net.Socket, + data: Buffer, + upstreamProxyUrl: string, + ) => void; +}; diff --git a/src/plugins/auth-proxy-adapter/config.ts b/src/plugins/auth-proxy-adapter/config.ts new file mode 100644 index 00000000..025582db --- /dev/null +++ b/src/plugins/auth-proxy-adapter/config.ts @@ -0,0 +1,11 @@ +export interface AuthProxyConfig { + enabled: boolean; + hostname: string; + port: number; +} + +export const defaultAuthProxyConfig: AuthProxyConfig = { + enabled: false, + hostname: '127.0.0.1', + port: 4545, +}; diff --git a/src/plugins/auth-proxy-adapter/index.ts b/src/plugins/auth-proxy-adapter/index.ts new file mode 100644 index 00000000..b70a43dd --- /dev/null +++ b/src/plugins/auth-proxy-adapter/index.ts @@ -0,0 +1,16 @@ +import { createPlugin } from '@/utils'; +import { t } from '@/i18n'; + +import { defaultAuthProxyConfig } from './config'; +import { onMenu } from './menu'; +import { backend } from './backend'; + +export default createPlugin({ + name: () => t('plugins.auth-proxy-adapter.name'), + description: () => t('plugins.auth-proxy-adapter.description'), + restartNeeded: true, + config: defaultAuthProxyConfig, + addedVersion: '3.10.X', + menu: onMenu, + backend, +}); diff --git a/src/plugins/auth-proxy-adapter/menu.ts b/src/plugins/auth-proxy-adapter/menu.ts new file mode 100644 index 00000000..e4b2a69c --- /dev/null +++ b/src/plugins/auth-proxy-adapter/menu.ts @@ -0,0 +1,68 @@ +import prompt from 'custom-electron-prompt'; + +import { t } from '@/i18n'; +import promptOptions from '@/providers/prompt-options'; + +import { type AuthProxyConfig, defaultAuthProxyConfig } from './config'; + +import type { MenuContext } from '@/types/contexts'; +import type { MenuTemplate } from '@/menu'; + +export const onMenu = async ({ + getConfig, + setConfig, + window, +}: MenuContext): Promise => { + await getConfig(); + return [ + { + label: t('plugins.auth-proxy-adapter.menu.hostname.label'), + type: 'normal', + async click() { + const config = await getConfig(); + + const newHostname = + (await prompt( + { + title: t('plugins.auth-proxy-adapter.prompt.hostname.title'), + label: t('plugins.auth-proxy-adapter.prompt.hostname.label'), + value: config.hostname, + type: 'input', + width: 380, + ...promptOptions(), + }, + window, + )) ?? + config.hostname ?? + defaultAuthProxyConfig.hostname; + + setConfig({ ...config, hostname: newHostname }); + }, + }, + { + label: t('plugins.auth-proxy-adapter.menu.port.label'), + type: 'normal', + async click() { + const config = await getConfig(); + + const newPort = + (await prompt( + { + title: t('plugins.auth-proxy-adapter.prompt.port.title'), + label: t('plugins.auth-proxy-adapter.prompt.port.label'), + value: config.port, + type: 'counter', + counterOptions: { minimum: 0, maximum: 65535 }, + width: 380, + ...promptOptions(), + }, + window, + )) ?? + config.port ?? + defaultAuthProxyConfig.port; + + setConfig({ ...config, port: newPort }); + }, + }, + ]; +};