Skip to content
Merged
1 change: 1 addition & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"lodash": "^4.18.1",
"log4js": "^6.9.1",
"node-powershell": "^4.0.0",
"request": "^2.88.2",
Comment thread
cute-omega marked this conversation as resolved.
"spawn-sync": "^2.0.0",
"winreg": "^1.2.5"
},
Expand Down
207 changes: 179 additions & 28 deletions packages/core/src/shell/scripts/set-system-proxy/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}`)
Comment thread
cute-omega marked this conversation as resolved.
} 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 <<ENDOF >> ~/.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
4 changes: 3 additions & 1 deletion packages/core/src/shell/shell.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', ''))
Expand Down
79 changes: 79 additions & 0 deletions packages/core/test/setSystemProxyMacTest.js
Original file line number Diff line number Diff line change
@@ -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: <UP,GATEWAY,DONE,STATIC,PRCLONING,GLOBAL>
`.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')
})
})
8 changes: 5 additions & 3 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading