diff --git a/packages/core/package.json b/packages/core/package.json index f634204541..7fbfb0b603 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -23,6 +23,7 @@ "lodash": "^4.18.1", "log4js": "^6.9.1", "node-powershell": "^4.0.0", + "request": "^2.88.2", "spawn-sync": "^2.0.0", "winreg": "^1.2.5" }, diff --git a/packages/core/src/shell/scripts/set-system-proxy/index.js b/packages/core/src/shell/scripts/set-system-proxy/index.js index d58c3c5337..630152ca34 100644 --- a/packages/core/src/shell/scripts/set-system-proxy/index.js +++ b/packages/core/src/shell/scripts/set-system-proxy/index.js @@ -5,6 +5,7 @@ const fs = require('node:fs') const path = require('node:path') const request = require('request') const Registry = require('winreg') +const sudoPrompt = require('@vscode/sudo-prompt') const log = require('../../../utils/util.log.core') const Shell = require('../../shell') const extraPath = require('../extra-path') @@ -183,6 +184,151 @@ function getProxyExcludeIpStr (split) { return excludeIpStr } +function parseMacNetworkServiceByDevice (networkServiceOrder, device) { + if (!networkServiceOrder || !device) { + return null + } + const lines = networkServiceOrder.split(/\r?\n/) + for (let i = 0; i < lines.length; i++) { + if (lines[i].includes(`Device: ${device}`)) { + for (let j = i - 1; j >= 0; j--) { + const serviceLine = lines[j].trim() + const markerIndex = serviceLine.indexOf(') ') + if (serviceLine.startsWith('(') && markerIndex > 0) { + return serviceLine.slice(markerIndex + 2).trim() + } + } + } + } + return null +} + +function parseMacRouteDevice (routeOutput) { + if (!routeOutput) { + return null + } + const routeLines = routeOutput.split(/\r?\n/) + for (const routeLine of routeLines) { + const trimmedLine = routeLine.trim() + if (trimmedLine.startsWith('interface:')) { + return trimmedLine.slice('interface:'.length).trim() || null + } + } + return null +} + +function pickMacNetworkService (listAllNetworkServicesOutput) { + if (!listAllNetworkServicesOutput) { + return null + } + const services = listAllNetworkServicesOutput + .split(/\r?\n/) + .map(item => item.replace(/^\*/, '').trim()) + .filter(item => item && !item.startsWith('An asterisk (*) denotes')) + if (services.length === 0) { + return null + } + const preferredServices = ['Wi-Fi', 'WiFi', 'Ethernet'] + for (const preferredService of preferredServices) { + const matched = services.find(item => item === preferredService) + if (matched) { + return matched + } + } + return services[0] +} + +async function getMacNetworkService (exec) { + try { + const routeOutput = await exec('route -n get 0.0.0.0') + const device = parseMacRouteDevice(routeOutput) + if (device) { + log.info('macOS 代理服务检测:当前网络设备:', device) + try { + const networkServiceOrder = await exec('networksetup -listnetworkserviceorder') + const matchedService = parseMacNetworkServiceByDevice(networkServiceOrder, device) + if (matchedService) { + log.info('macOS 代理服务检测:通过设备名匹配到网络服务:', matchedService) + return matchedService + } + log.warn('macOS 代理服务检测:未通过设备名匹配到网络服务,尝试备用方法') + } catch (e) { + log.warn('macOS 代理服务检测:获取网络服务列表失败:', e.message, ',尝试备用方法') + } + } else { + log.warn('macOS 代理服务检测:未检测到当前网络设备,尝试备用方法') + } + } catch (e) { + log.warn('macOS 代理服务检测:获取路由信息失败:', e.message, ',尝试备用方法') + } + + try { + const allServicesOutput = await exec('networksetup -listallnetworkservices') + const fallbackService = pickMacNetworkService(allServicesOutput) + if (fallbackService) { + log.info('macOS 代理服务检测:通过服务列表备用方法找到网络服务:', fallbackService) + return fallbackService + } + log.warn('macOS 代理服务检测:未通过服务列表找到可用网络服务') + } catch (e) { + log.warn('macOS 代理服务检测:获取所有网络服务列表失败:', e.message) + } + + throw new Error('未找到可用的 macOS 网络服务,无法设置系统代理') +} + +// macOS exit code 14 = "You don't have permission to change the system preferences." +const MACOS_NETWORKSETUP_PERMISSION_ERROR_CODE = 14 + +/** + * POSIX single-quote escaping: wraps `arg` in single quotes, escaping any + * embedded single quotes with the '\''-idiom. This prevents shell + * metacharacter expansion regardless of the character set of the value. + * @param {string|number} arg + * @returns {string} + */ +function shellEscapeArg (arg) { + return "'" + String(arg).replace(/'/g, "'\\''") + "'" +} + +/** + * Strict-validate a proxy host (IPv4 / IPv6 / hostname) and throw if the + * value looks suspicious. This is a defence-in-depth guard for the sudo + * execution path; the primary protection is `shellEscapeArg`. + */ +function validateProxyIp (ip) { + if (typeof ip !== 'string' || !/^[\w.\-:[\]]+$/.test(ip)) { + throw new Error(`无效的代理 IP 地址: ${ip}`) + } +} + +/** + * Strict-validate a TCP port number. + */ +function validateProxyPort (port) { + const n = Number(port) + if (!Number.isInteger(n) || n < 1 || n > 65535) { + throw new Error(`无效的代理端口号: ${port}`) + } +} + +function sudoExecMac (cmd) { + return new Promise((resolve, reject) => { + log.info('以管理员权限执行命令:', cmd) + sudoPrompt.exec(cmd, { name: 'dev-sidecar' }, (error, stdout, stderr) => { + if (stderr) { + log.warn('以管理员权限执行命令,stderr:', stderr) + } + if (error) { + log.error('以管理员权限执行命令失败:', error) + reject(error) + } else { + resolve(stdout) + } + }) + }) +} + const executor = { async windows (exec, params = {}) { const { ip, port, setEnv } = params @@ -324,51 +470,56 @@ const executor = { } }, async mac (exec, params = {}) { - // exec = _exec - let wifiAdaptor = await exec('sh -c "networksetup -listnetworkserviceorder | grep `route -n get 0.0.0.0 | grep \'interface\' | cut -d \':\' -f2` -B 1 | head -n 1 "') - wifiAdaptor = wifiAdaptor.trim() - wifiAdaptor = wifiAdaptor.substring(wifiAdaptor.indexOf(' ')).trim() + const wifiAdaptor = await getMacNetworkService(exec) const { ip, port } = params + + let cmds if (ip != null) { // 设置代理 // 延迟加载config loadConfig() // https - await exec(`networksetup -setsecurewebproxy "${wifiAdaptor}" ${ip} ${port}`) + cmds = [`networksetup -setsecurewebproxy "${wifiAdaptor}" ${ip} ${port}`] // http if (config.get().proxy.proxyHttp) { - await exec(`networksetup -setwebproxy "${wifiAdaptor}" ${ip} ${port - 1}`) + cmds.push(`networksetup -setwebproxy "${wifiAdaptor}" ${ip} ${port - 1}`) } else { - await exec(`networksetup -setwebproxystate "${wifiAdaptor}" off`) + cmds.push(`networksetup -setwebproxystate "${wifiAdaptor}" off`) } // 设置排除域名 const excludeIpStr = getProxyExcludeIpStr('" "') - await exec(`networksetup -setproxybypassdomains "${wifiAdaptor}" "${excludeIpStr}"`) - - // const setEnv = `cat <> ~/.zshrc - // export http_proxy="http://${ip}:${port}" - // export https_proxy="http://${ip}:${port}" - // ENDOF - // source ~/.zshrc - // ` - // await exec(setEnv) + cmds.push(`networksetup -setproxybypassdomains "${wifiAdaptor}" "${excludeIpStr}"`) } else { // 关闭代理 - // https - await exec(`networksetup -setsecurewebproxystate "${wifiAdaptor}" off`) - // http - await exec(`networksetup -setwebproxystate "${wifiAdaptor}" off`) - - // const removeEnv = ` - // sed -ie '/export http_proxy/d' ~/.zshrc - // sed -ie '/export https_proxy/d' ~/.zshrc - // source ~/.zshrc - // ` - // await exec(removeEnv) + // https + http + cmds = [ + `networksetup -setsecurewebproxystate "${wifiAdaptor}" off`, + `networksetup -setwebproxystate "${wifiAdaptor}" off`, + ] + } + + // 先尝试直接执行;若因权限不足(exit code 14)失败,弹出系统授权对话框后重试 + try { + for (const cmd of cmds) { + await exec(cmd) + } + } catch (e) { + if (e.code === MACOS_NETWORKSETUP_PERMISSION_ERROR_CODE) { + log.warn('networksetup 命令需要管理员权限(exit code 14),正在弹出系统授权对话框...') + await sudoExecMac(cmds.join(' && ')) + log.info('以管理员权限执行 networksetup 命令成功') + } else { + throw e + } } }, } -module.exports = async function (args) { +const setSystemProxy = async function (args) { return execute(executor, args) } + +module.exports = setSystemProxy +module.exports.parseMacNetworkServiceByDevice = parseMacNetworkServiceByDevice +module.exports.parseMacRouteDevice = parseMacRouteDevice +module.exports.pickMacNetworkService = pickMacNetworkService diff --git a/packages/core/src/shell/shell.js b/packages/core/src/shell/shell.js index 7010f983c6..6d35d90701 100644 --- a/packages/core/src/shell/shell.js +++ b/packages/core/src/shell/shell.js @@ -97,7 +97,9 @@ function childExec (composeCmds, options = {}) { if (options.printErrorLog !== false) { log.error('cmd 命令执行错误:\n===>\ncommands:', composeCmds, '\n error:', error, '\n<===') } - reject(new Error(stderr)) + const err = new Error(`${stderr || error.message} (command: ${composeCmds})`) + err.code = error.code + reject(err) } else { // log.info('cmd 命令完成:', stdout) resolve(stdout.replace('Active code page: 65001\r\n', '')) diff --git a/packages/core/test/setSystemProxyMacTest.js b/packages/core/test/setSystemProxyMacTest.js new file mode 100644 index 0000000000..a7abbe3c54 --- /dev/null +++ b/packages/core/test/setSystemProxyMacTest.js @@ -0,0 +1,79 @@ +const assert = require('node:assert') +const setSystemProxy = require('../src/shell/scripts/set-system-proxy') + +// eslint-disable-next-line no-undef +describe('set-system-proxy mac helpers', () => { + // eslint-disable-next-line no-undef + it('should parse service by device from listnetworkserviceorder output', () => { + const networkServiceOrder = ` +(1) Wi-Fi +(Hardware Port: Wi-Fi, Device: en0) +(2) Thunderbolt Bridge +(Hardware Port: Thunderbolt Bridge, Device: bridge0) +`.trim() + const service = setSystemProxy.parseMacNetworkServiceByDevice(networkServiceOrder, 'en0') + assert.strictEqual(service, 'Wi-Fi') + assert.strictEqual(setSystemProxy.parseMacNetworkServiceByDevice('', 'en0'), null) + assert.strictEqual(setSystemProxy.parseMacNetworkServiceByDevice(networkServiceOrder, ''), null) + }) + + // eslint-disable-next-line no-undef + it('should parse route device from route output', () => { + const routeOutput = ` +route to: default +interface: en0 +flags: +`.trim() + const device = setSystemProxy.parseMacRouteDevice(routeOutput) + assert.strictEqual(device, 'en0') + assert.strictEqual(setSystemProxy.parseMacRouteDevice(''), null) + assert.strictEqual(setSystemProxy.parseMacRouteDevice(null), null) + }) + + // eslint-disable-next-line no-undef + it('should fallback to preferred Wi-Fi service when available', () => { + const listAllNetworkServicesOutput = ` +USB 10/100/1000 LAN +Wi-Fi +Thunderbolt Bridge +`.trim() + const service = setSystemProxy.pickMacNetworkService(listAllNetworkServicesOutput) + assert.strictEqual(service, 'Wi-Fi') + }) + + // eslint-disable-next-line no-undef + it('should fallback to first service when preferred service is unavailable', () => { + const listAllNetworkServicesOutput = ` +USB 10/100/1000 LAN +Thunderbolt Bridge +`.trim() + const service = setSystemProxy.pickMacNetworkService(listAllNetworkServicesOutput) + assert.strictEqual(service, 'USB 10/100/1000 LAN') + }) + + // eslint-disable-next-line no-undef + it('should support disabled service prefix and empty input', () => { + const listAllNetworkServicesOutput = ` +*Wi-Fi +Thunderbolt Bridge +`.trim() + const service = setSystemProxy.pickMacNetworkService(listAllNetworkServicesOutput) + assert.strictEqual(service, 'Wi-Fi') + assert.strictEqual(setSystemProxy.pickMacNetworkService(''), null) + assert.strictEqual(setSystemProxy.pickMacNetworkService(null), null) + }) + + // eslint-disable-next-line no-undef + it('should ignore the "An asterisk" header line produced by networksetup -listallnetworkservices', () => { + const fullOutput = `An asterisk (*) denotes that a network service is disabled. +Ethernet +Wi-Fi +Thunderbolt Bridge` + assert.strictEqual(setSystemProxy.pickMacNetworkService(fullOutput), 'Wi-Fi') + + const fullOutputEthernetOnly = `An asterisk (*) denotes that a network service is disabled. +Ethernet +Thunderbolt Bridge` + assert.strictEqual(setSystemProxy.pickMacNetworkService(fullOutputEthernetOnly), 'Ethernet') + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e038560e55..996411db7e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,6 +50,9 @@ importers: node-powershell: specifier: ^4.0.0 version: 4.0.0 + request: + specifier: ^2.88.2 + version: 2.88.2 spawn-sync: specifier: ^2.0.0 version: 2.0.0 @@ -11602,8 +11605,7 @@ snapshots: extsprintf@1.3.0: {} - extsprintf@1.4.1: - optional: true + extsprintf@1.4.1: {} fast-deep-equal@3.1.3: {} @@ -14994,7 +14996,7 @@ snapshots: dependencies: assert-plus: 1.0.0 core-util-is: 1.0.2 - extsprintf: 1.3.0 + extsprintf: 1.4.1 verror@1.10.1: dependencies: