Skip to content

Commit 77c55ca

Browse files
authored
Merge pull request #617 from docmirror/copilot/fix-system-proxy-error-macos
Harden macOS system-proxy service resolution to prevent `apiInvoke` toggle failures
2 parents 93a956b + 6b442b3 commit 77c55ca

5 files changed

Lines changed: 267 additions & 32 deletions

File tree

packages/core/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
"lodash": "^4.18.1",
2424
"log4js": "^6.9.1",
2525
"node-powershell": "^4.0.0",
26+
"request": "^2.88.2",
2627
"spawn-sync": "^2.0.0",
2728
"winreg": "^1.2.5"
2829
},

packages/core/src/shell/scripts/set-system-proxy/index.js

Lines changed: 179 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ const fs = require('node:fs')
55
const path = require('node:path')
66
const request = require('request')
77
const Registry = require('winreg')
8+
const sudoPrompt = require('@vscode/sudo-prompt')
89
const log = require('../../../utils/util.log.core')
910
const Shell = require('../../shell')
1011
const extraPath = require('../extra-path')
@@ -183,6 +184,151 @@ function getProxyExcludeIpStr (split) {
183184
return excludeIpStr
184185
}
185186

187+
function parseMacNetworkServiceByDevice (networkServiceOrder, device) {
188+
if (!networkServiceOrder || !device) {
189+
return null
190+
}
191+
const lines = networkServiceOrder.split(/\r?\n/)
192+
for (let i = 0; i < lines.length; i++) {
193+
if (lines[i].includes(`Device: ${device}`)) {
194+
for (let j = i - 1; j >= 0; j--) {
195+
const serviceLine = lines[j].trim()
196+
const markerIndex = serviceLine.indexOf(') ')
197+
if (serviceLine.startsWith('(') && markerIndex > 0) {
198+
return serviceLine.slice(markerIndex + 2).trim()
199+
}
200+
}
201+
}
202+
}
203+
return null
204+
}
205+
206+
function parseMacRouteDevice (routeOutput) {
207+
if (!routeOutput) {
208+
return null
209+
}
210+
const routeLines = routeOutput.split(/\r?\n/)
211+
for (const routeLine of routeLines) {
212+
const trimmedLine = routeLine.trim()
213+
if (trimmedLine.startsWith('interface:')) {
214+
return trimmedLine.slice('interface:'.length).trim() || null
215+
}
216+
}
217+
return null
218+
}
219+
220+
function pickMacNetworkService (listAllNetworkServicesOutput) {
221+
if (!listAllNetworkServicesOutput) {
222+
return null
223+
}
224+
const services = listAllNetworkServicesOutput
225+
.split(/\r?\n/)
226+
.map(item => item.replace(/^\*/, '').trim())
227+
.filter(item => item && !item.startsWith('An asterisk (*) denotes'))
228+
if (services.length === 0) {
229+
return null
230+
}
231+
const preferredServices = ['Wi-Fi', 'WiFi', 'Ethernet']
232+
for (const preferredService of preferredServices) {
233+
const matched = services.find(item => item === preferredService)
234+
if (matched) {
235+
return matched
236+
}
237+
}
238+
return services[0]
239+
}
240+
241+
async function getMacNetworkService (exec) {
242+
try {
243+
const routeOutput = await exec('route -n get 0.0.0.0')
244+
const device = parseMacRouteDevice(routeOutput)
245+
if (device) {
246+
log.info('macOS 代理服务检测:当前网络设备:', device)
247+
try {
248+
const networkServiceOrder = await exec('networksetup -listnetworkserviceorder')
249+
const matchedService = parseMacNetworkServiceByDevice(networkServiceOrder, device)
250+
if (matchedService) {
251+
log.info('macOS 代理服务检测:通过设备名匹配到网络服务:', matchedService)
252+
return matchedService
253+
}
254+
log.warn('macOS 代理服务检测:未通过设备名匹配到网络服务,尝试备用方法')
255+
} catch (e) {
256+
log.warn('macOS 代理服务检测:获取网络服务列表失败:', e.message, ',尝试备用方法')
257+
}
258+
} else {
259+
log.warn('macOS 代理服务检测:未检测到当前网络设备,尝试备用方法')
260+
}
261+
} catch (e) {
262+
log.warn('macOS 代理服务检测:获取路由信息失败:', e.message, ',尝试备用方法')
263+
}
264+
265+
try {
266+
const allServicesOutput = await exec('networksetup -listallnetworkservices')
267+
const fallbackService = pickMacNetworkService(allServicesOutput)
268+
if (fallbackService) {
269+
log.info('macOS 代理服务检测:通过服务列表备用方法找到网络服务:', fallbackService)
270+
return fallbackService
271+
}
272+
log.warn('macOS 代理服务检测:未通过服务列表找到可用网络服务')
273+
} catch (e) {
274+
log.warn('macOS 代理服务检测:获取所有网络服务列表失败:', e.message)
275+
}
276+
277+
throw new Error('未找到可用的 macOS 网络服务,无法设置系统代理')
278+
}
279+
280+
// macOS exit code 14 = "You don't have permission to change the system preferences."
281+
const MACOS_NETWORKSETUP_PERMISSION_ERROR_CODE = 14
282+
283+
/**
284+
* POSIX single-quote escaping: wraps `arg` in single quotes, escaping any
285+
* embedded single quotes with the '\''-idiom. This prevents shell
286+
* metacharacter expansion regardless of the character set of the value.
287+
* @param {string|number} arg
288+
* @returns {string}
289+
*/
290+
function shellEscapeArg (arg) {
291+
return "'" + String(arg).replace(/'/g, "'\\''") + "'"
292+
}
293+
294+
/**
295+
* Strict-validate a proxy host (IPv4 / IPv6 / hostname) and throw if the
296+
* value looks suspicious. This is a defence-in-depth guard for the sudo
297+
* execution path; the primary protection is `shellEscapeArg`.
298+
*/
299+
function validateProxyIp (ip) {
300+
if (typeof ip !== 'string' || !/^[\w.\-:[\]]+$/.test(ip)) {
301+
throw new Error(`无效的代理 IP 地址: ${ip}`)
302+
}
303+
}
304+
305+
/**
306+
* Strict-validate a TCP port number.
307+
*/
308+
function validateProxyPort (port) {
309+
const n = Number(port)
310+
if (!Number.isInteger(n) || n < 1 || n > 65535) {
311+
throw new Error(`无效的代理端口号: ${port}`)
312+
}
313+
}
314+
315+
function sudoExecMac (cmd) {
316+
return new Promise((resolve, reject) => {
317+
log.info('以管理员权限执行命令:', cmd)
318+
sudoPrompt.exec(cmd, { name: 'dev-sidecar' }, (error, stdout, stderr) => {
319+
if (stderr) {
320+
log.warn('以管理员权限执行命令,stderr:', stderr)
321+
}
322+
if (error) {
323+
log.error('以管理员权限执行命令失败:', error)
324+
reject(error)
325+
} else {
326+
resolve(stdout)
327+
}
328+
})
329+
})
330+
}
331+
186332
const executor = {
187333
async windows (exec, params = {}) {
188334
const { ip, port, setEnv } = params
@@ -324,51 +470,56 @@ const executor = {
324470
}
325471
},
326472
async mac (exec, params = {}) {
327-
// exec = _exec
328-
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 "')
329-
wifiAdaptor = wifiAdaptor.trim()
330-
wifiAdaptor = wifiAdaptor.substring(wifiAdaptor.indexOf(' ')).trim()
473+
const wifiAdaptor = await getMacNetworkService(exec)
331474
const { ip, port } = params
475+
476+
let cmds
332477
if (ip != null) { // 设置代理
333478
// 延迟加载config
334479
loadConfig()
335480

336481
// https
337-
await exec(`networksetup -setsecurewebproxy "${wifiAdaptor}" ${ip} ${port}`)
482+
cmds = [`networksetup -setsecurewebproxy "${wifiAdaptor}" ${ip} ${port}`]
338483
// http
339484
if (config.get().proxy.proxyHttp) {
340-
await exec(`networksetup -setwebproxy "${wifiAdaptor}" ${ip} ${port - 1}`)
485+
cmds.push(`networksetup -setwebproxy "${wifiAdaptor}" ${ip} ${port - 1}`)
341486
} else {
342-
await exec(`networksetup -setwebproxystate "${wifiAdaptor}" off`)
487+
cmds.push(`networksetup -setwebproxystate "${wifiAdaptor}" off`)
343488
}
344489

345490
// 设置排除域名
346491
const excludeIpStr = getProxyExcludeIpStr('" "')
347-
await exec(`networksetup -setproxybypassdomains "${wifiAdaptor}" "${excludeIpStr}"`)
348-
349-
// const setEnv = `cat <<ENDOF >> ~/.zshrc
350-
// export http_proxy="http://${ip}:${port}"
351-
// export https_proxy="http://${ip}:${port}"
352-
// ENDOF
353-
// source ~/.zshrc
354-
// `
355-
// await exec(setEnv)
492+
cmds.push(`networksetup -setproxybypassdomains "${wifiAdaptor}" "${excludeIpStr}"`)
356493
} else { // 关闭代理
357-
// https
358-
await exec(`networksetup -setsecurewebproxystate "${wifiAdaptor}" off`)
359-
// http
360-
await exec(`networksetup -setwebproxystate "${wifiAdaptor}" off`)
361-
362-
// const removeEnv = `
363-
// sed -ie '/export http_proxy/d' ~/.zshrc
364-
// sed -ie '/export https_proxy/d' ~/.zshrc
365-
// source ~/.zshrc
366-
// `
367-
// await exec(removeEnv)
494+
// https + http
495+
cmds = [
496+
`networksetup -setsecurewebproxystate "${wifiAdaptor}" off`,
497+
`networksetup -setwebproxystate "${wifiAdaptor}" off`,
498+
]
499+
}
500+
501+
// 先尝试直接执行;若因权限不足(exit code 14)失败,弹出系统授权对话框后重试
502+
try {
503+
for (const cmd of cmds) {
504+
await exec(cmd)
505+
}
506+
} catch (e) {
507+
if (e.code === MACOS_NETWORKSETUP_PERMISSION_ERROR_CODE) {
508+
log.warn('networksetup 命令需要管理员权限(exit code 14),正在弹出系统授权对话框...')
509+
await sudoExecMac(cmds.join(' && '))
510+
log.info('以管理员权限执行 networksetup 命令成功')
511+
} else {
512+
throw e
513+
}
368514
}
369515
},
370516
}
371517

372-
module.exports = async function (args) {
518+
const setSystemProxy = async function (args) {
373519
return execute(executor, args)
374520
}
521+
522+
module.exports = setSystemProxy
523+
module.exports.parseMacNetworkServiceByDevice = parseMacNetworkServiceByDevice
524+
module.exports.parseMacRouteDevice = parseMacRouteDevice
525+
module.exports.pickMacNetworkService = pickMacNetworkService

packages/core/src/shell/shell.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,9 @@ function childExec (composeCmds, options = {}) {
9797
if (options.printErrorLog !== false) {
9898
log.error('cmd 命令执行错误:\n===>\ncommands:', composeCmds, '\n error:', error, '\n<===')
9999
}
100-
reject(new Error(stderr))
100+
const err = new Error(`${stderr || error.message} (command: ${composeCmds})`)
101+
err.code = error.code
102+
reject(err)
101103
} else {
102104
// log.info('cmd 命令完成:', stdout)
103105
resolve(stdout.replace('Active code page: 65001\r\n', ''))
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
const assert = require('node:assert')
2+
const setSystemProxy = require('../src/shell/scripts/set-system-proxy')
3+
4+
// eslint-disable-next-line no-undef
5+
describe('set-system-proxy mac helpers', () => {
6+
// eslint-disable-next-line no-undef
7+
it('should parse service by device from listnetworkserviceorder output', () => {
8+
const networkServiceOrder = `
9+
(1) Wi-Fi
10+
(Hardware Port: Wi-Fi, Device: en0)
11+
(2) Thunderbolt Bridge
12+
(Hardware Port: Thunderbolt Bridge, Device: bridge0)
13+
`.trim()
14+
const service = setSystemProxy.parseMacNetworkServiceByDevice(networkServiceOrder, 'en0')
15+
assert.strictEqual(service, 'Wi-Fi')
16+
assert.strictEqual(setSystemProxy.parseMacNetworkServiceByDevice('', 'en0'), null)
17+
assert.strictEqual(setSystemProxy.parseMacNetworkServiceByDevice(networkServiceOrder, ''), null)
18+
})
19+
20+
// eslint-disable-next-line no-undef
21+
it('should parse route device from route output', () => {
22+
const routeOutput = `
23+
route to: default
24+
interface: en0
25+
flags: <UP,GATEWAY,DONE,STATIC,PRCLONING,GLOBAL>
26+
`.trim()
27+
const device = setSystemProxy.parseMacRouteDevice(routeOutput)
28+
assert.strictEqual(device, 'en0')
29+
assert.strictEqual(setSystemProxy.parseMacRouteDevice(''), null)
30+
assert.strictEqual(setSystemProxy.parseMacRouteDevice(null), null)
31+
})
32+
33+
// eslint-disable-next-line no-undef
34+
it('should fallback to preferred Wi-Fi service when available', () => {
35+
const listAllNetworkServicesOutput = `
36+
USB 10/100/1000 LAN
37+
Wi-Fi
38+
Thunderbolt Bridge
39+
`.trim()
40+
const service = setSystemProxy.pickMacNetworkService(listAllNetworkServicesOutput)
41+
assert.strictEqual(service, 'Wi-Fi')
42+
})
43+
44+
// eslint-disable-next-line no-undef
45+
it('should fallback to first service when preferred service is unavailable', () => {
46+
const listAllNetworkServicesOutput = `
47+
USB 10/100/1000 LAN
48+
Thunderbolt Bridge
49+
`.trim()
50+
const service = setSystemProxy.pickMacNetworkService(listAllNetworkServicesOutput)
51+
assert.strictEqual(service, 'USB 10/100/1000 LAN')
52+
})
53+
54+
// eslint-disable-next-line no-undef
55+
it('should support disabled service prefix and empty input', () => {
56+
const listAllNetworkServicesOutput = `
57+
*Wi-Fi
58+
Thunderbolt Bridge
59+
`.trim()
60+
const service = setSystemProxy.pickMacNetworkService(listAllNetworkServicesOutput)
61+
assert.strictEqual(service, 'Wi-Fi')
62+
assert.strictEqual(setSystemProxy.pickMacNetworkService(''), null)
63+
assert.strictEqual(setSystemProxy.pickMacNetworkService(null), null)
64+
})
65+
66+
// eslint-disable-next-line no-undef
67+
it('should ignore the "An asterisk" header line produced by networksetup -listallnetworkservices', () => {
68+
const fullOutput = `An asterisk (*) denotes that a network service is disabled.
69+
Ethernet
70+
Wi-Fi
71+
Thunderbolt Bridge`
72+
assert.strictEqual(setSystemProxy.pickMacNetworkService(fullOutput), 'Wi-Fi')
73+
74+
const fullOutputEthernetOnly = `An asterisk (*) denotes that a network service is disabled.
75+
Ethernet
76+
Thunderbolt Bridge`
77+
assert.strictEqual(setSystemProxy.pickMacNetworkService(fullOutputEthernetOnly), 'Ethernet')
78+
})
79+
})

pnpm-lock.yaml

Lines changed: 5 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)