// proxy.js —— 全局 / 系统代理:内置 sing-box,把「要密码的上游 SOCKS5」转成 // ① 系统代理:本地 mixed 端口(不要密码) + 设置 macOS/Windows 系统代理指过来; // ② 全局 TUN:创建虚拟网卡接管整机流量(需管理员权限,启动时弹一次密码框)。 // macOS 系统代理不会发送用户名/密码,所以必须走本地中转——这正是本模块存在的原因。 const { app, safeStorage } = require("electron"); const { spawn, execFile } = require("child_process"); const { promisify } = require("util"); const fs = require("fs"); const net = require("net"); const path = require("path"); const http = require("http"); const pExecFile = promisify(execFile); // —— 运行时状态(内存)—— let state = { mode: "off", // off | system | global child: null, // sing-box 子进程(系统模式) 或 osascript/powershell 提权进程(全局模式) localPort: 0, // 系统模式本地 mixed 端口 services: [], // macOS:已设置系统代理的网络服务名,停止时逐一还原 onChange: null, // 状态变化回调(通知前端 + 托盘) }; function setOnChange(cb) { state.onChange = cb; } function emit() { if (typeof state.onChange === "function") { try { state.onChange(status()); } catch {} } } // --------------------------------------------------------------------------- // 路径:sing-box 二进制 / 运行时目录 / 配置文件 // --------------------------------------------------------------------------- function resDir() { // 打包后二进制在 resources/singbox;开发时在项目 resources/singbox return app.isPackaged ? path.join(process.resourcesPath, "singbox") : path.join(__dirname, "..", "..", "resources", "singbox"); } function binPath() { const plat = process.platform; let sub; let name = "sing-box"; if (plat === "darwin") sub = process.arch === "arm64" ? "darwin-arm64" : "darwin-x64"; else if (plat === "win32") { sub = "windows-x64"; name = "sing-box.exe"; } else { throw new Error("全局代理暂只支持 macOS 与 Windows"); } return path.join(resDir(), sub, name); } function binExists() { try { return fs.existsSync(binPath()); } catch { return false; } } function runtimeDir() { const d = path.join(app.getPath("userData"), "proxy-runtime"); fs.mkdirSync(d, { recursive: true }); return d; } function cfgPath() { return path.join(app.getPath("userData"), "proxy.json"); } // --------------------------------------------------------------------------- // 配置持久化(proxyStr 用 safeStorage 加密) // --------------------------------------------------------------------------- function enc(s) { if (!s) return ""; try { if (safeStorage.isEncryptionAvailable()) return "v1:" + safeStorage.encryptString(s).toString("base64"); } catch {} return "b64:" + Buffer.from(s, "utf-8").toString("base64"); } function dec(s) { if (!s) return ""; try { if (s.startsWith("v1:")) return safeStorage.decryptString(Buffer.from(s.slice(3), "base64")); if (s.startsWith("b64:")) return Buffer.from(s.slice(4), "base64").toString("utf-8"); } catch {} return ""; } let conf = null; function defaultConf() { return { proxyStr: "", localPort: 0, appliedPort: 0 }; } function loadConf() { if (conf) return conf; try { const raw = JSON.parse(fs.readFileSync(cfgPath(), "utf-8")); raw.proxyStr = raw.proxyStr ? dec(raw.proxyStr) : ""; conf = { ...defaultConf(), ...raw }; } catch { conf = defaultConf(); } return conf; } function saveConf() { const out = { ...conf, proxyStr: enc(conf.proxyStr) }; const p = cfgPath(); fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, JSON.stringify(out, null, 2)); try { fs.chmodSync(p, 0o600); } catch {} } function getConfig() { loadConf(); return { proxyStr: conf.proxyStr, localPort: conf.localPort || 0 }; } function setConfig({ proxyStr, localPort } = {}) { loadConf(); if (proxyStr !== undefined) conf.proxyStr = String(proxyStr || "").trim(); if (localPort !== undefined) conf.localPort = parseInt(localPort, 10) || 0; saveConf(); return getConfig(); } // --------------------------------------------------------------------------- // 解析上游代理字符串:支持 // host:port:user:pass / host:port / socks5://user:pass@host:port // --------------------------------------------------------------------------- function parseProxy(str) { str = String(str || "").trim(); if (!str) return null; const m = str.match(/^socks5?:\/\/(?:([^:@/]+):([^@/]+)@)?([^:/@]+):(\d+)/i); if (m) return { host: m[3], port: parseInt(m[4], 10), user: m[1] || "", pass: m[2] || "" }; const parts = str.split(":"); if (parts.length === 4 && /^\d+$/.test(parts[1])) { return { host: parts[0], port: parseInt(parts[1], 10), user: parts[2], pass: parts[3] }; } if (parts.length === 2 && /^\d+$/.test(parts[1])) { return { host: parts[0], port: parseInt(parts[1], 10), user: "", pass: "" }; } return null; } // --------------------------------------------------------------------------- // 找空闲端口(优先用用户指定端口,占用则随机取) // --------------------------------------------------------------------------- function findFreePort(preferred) { return new Promise((resolve) => { const tryListen = (port, fallback) => { const srv = net.createServer(); srv.once("error", () => { if (fallback) tryListen(0, false); else resolve(0); }); srv.listen(port, "127.0.0.1", () => { const p = srv.address().port; srv.close(() => resolve(p)); }); }; tryListen(preferred || 0, !!preferred); }); } // --------------------------------------------------------------------------- // sing-box 配置生成 // --------------------------------------------------------------------------- function outbounds(p) { const proxy = { type: "socks", tag: "proxy", server: p.host, server_port: p.port, version: "5" }; if (p.user) { proxy.username = p.user; proxy.password = p.pass; } return [proxy, { type: "direct", tag: "direct" }]; } function systemConfig(p, port) { return { log: { level: "warn" }, inbounds: [{ type: "mixed", tag: "mixed-in", listen: "127.0.0.1", listen_port: port }], outbounds: outbounds(p), route: { final: "proxy" }, }; } function globalConfig(p, logPath) { return { // 让 sing-box 直接把日志写到文件(UTF-8),不依赖 shell 重定向 → 中文路径无损、判断启动可靠 log: { level: "info", output: logPath, timestamp: true }, dns: { servers: [ // 用 TCP-53 远程 DNS(经代理解析)——比 DoT(853) 更通用,避免代理不放行加密 DNS 导致全站解析失败 { type: "tcp", tag: "remote-dns", server: "8.8.8.8", detour: "proxy" }, { type: "local", tag: "local-dns" }, ], final: "remote-dns", strategy: "ipv4_only", }, inbounds: [ // gvisor 用户态协议栈:Windows 上比 system 栈更稳、兼容性好 { type: "tun", tag: "tun-in", address: ["172.19.0.1/30"], auto_route: true, strict_route: true, stack: "gvisor" }, ], outbounds: outbounds(p), route: { auto_detect_interface: true, default_domain_resolver: "local-dns", rules: [ { action: "sniff" }, // 嗅探协议,供下面识别 DNS / QUIC // 关键:把 DNS 查询劫持给 sing-box 自己用 TCP 解析。否则浏览器的 UDP DNS 被塞进 TUN → 交给只支持 TCP 的 SOCKS5 → 丢弃 → 全站解析失败 = 没网 { protocol: "dns", action: "hijack-dns" }, { port: 53, action: "hijack-dns" }, // QUIC(HTTP/3, UDP 443)经只支持 TCP 的 SOCKS5 会全丢 → 直接拒绝,逼浏览器回落到 TCP { network: "udp", port: 443, action: "reject" }, ], final: "proxy", }, }; } function writeCfg(name, obj) { const p = path.join(runtimeDir(), name); fs.writeFileSync(p, JSON.stringify(obj, null, 2)); return p; } // --------------------------------------------------------------------------- // macOS:系统代理设置 / 还原 // --------------------------------------------------------------------------- async function macServices() { // 列出所有网络服务,去掉标题行与被禁用(带*)的 const { stdout } = await pExecFile("networksetup", ["-listallnetworkservices"]); return stdout .split("\n") .slice(1) .map((s) => s.trim()) .filter((s) => s && !s.startsWith("*")); } async function macSetSystemProxy(port) { const services = await macServices(); const applied = []; for (const svc of services) { try { await pExecFile("networksetup", ["-setwebproxy", svc, "127.0.0.1", String(port)]); await pExecFile("networksetup", ["-setsecurewebproxy", svc, "127.0.0.1", String(port)]); await pExecFile("networksetup", ["-setsocksfirewallproxy", svc, "127.0.0.1", String(port)]); applied.push(svc); } catch { /* 某个服务设置失败(如未连接)则跳过 */ } } return applied; } async function macUnsetSystemProxy(services) { for (const svc of services || []) { for (const kind of ["-setwebproxystate", "-setsecurewebproxystate", "-setsocksfirewallproxystate"]) { try { await pExecFile("networksetup", [kind, svc, "off"]); } catch {} } } } // --------------------------------------------------------------------------- // Windows:系统代理设置 / 还原(HKCU,无需管理员) // --------------------------------------------------------------------------- const WIN_INET = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings"; // 通知 WinINET 代理设置已变更,让浏览器(Chrome/Edge 等)立即重新读取,而不是等重启 async function winRefreshInet() { const ps = `$s='[DllImport("wininet.dll",SetLastError=true)]public static extern bool InternetSetOption(IntPtr h,int o,IntPtr b,int l);';` + `$t=Add-Type -MemberDefinition $s -Name W -Namespace I -PassThru;` + `$t::InternetSetOption([IntPtr]::Zero,39,[IntPtr]::Zero,0)|Out-Null;` + // INTERNET_OPTION_SETTINGS_CHANGED `$t::InternetSetOption([IntPtr]::Zero,37,[IntPtr]::Zero,0)|Out-Null`; // INTERNET_OPTION_REFRESH await pExecFile("powershell", ["-NoProfile", "-Command", ps]).catch(() => {}); } async function winSetSystemProxy(port) { await pExecFile("reg", ["add", WIN_INET, "/v", "ProxyEnable", "/t", "REG_DWORD", "/d", "1", "/f"]); await pExecFile("reg", ["add", WIN_INET, "/v", "ProxyServer", "/d", `127.0.0.1:${port}`, "/f"]); // 排除本地地址直连 await pExecFile("reg", ["add", WIN_INET, "/v", "ProxyOverride", "/d", "localhost;127.*;", "/f"]).catch(() => {}); await winRefreshInet(); } async function winUnsetSystemProxy() { await pExecFile("reg", ["add", WIN_INET, "/v", "ProxyEnable", "/t", "REG_DWORD", "/d", "0", "/f"]).catch(() => {}); await winRefreshInet(); } // --------------------------------------------------------------------------- // 系统代理模式:启动本地 sing-box mixed + 设系统代理 // --------------------------------------------------------------------------- async function startSystem(p) { const port = await findFreePort(conf.localPort || 0); if (!port) throw new Error("找不到可用的本地端口"); const cfg = writeCfg("system.json", systemConfig(p, port)); const child = spawn(binPath(), ["run", "-c", cfg], { stdio: ["ignore", "pipe", "pipe"] }); let stderr = ""; child.stderr.on("data", (d) => (stderr += d.toString())); await new Promise((resolve, reject) => { let settled = false; child.on("error", (e) => { if (!settled) { settled = true; reject(e); } }); child.on("exit", (code) => { if (!settled) { settled = true; reject(new Error(`sing-box 启动失败(${code}):${stderr.slice(0, 300)}`)); } }); // 轮询本地端口,通了就算启动成功 const t0 = Date.now(); const poll = () => { const s = net.connect(port, "127.0.0.1"); s.on("connect", () => { s.destroy(); if (!settled) { settled = true; resolve(); } }); s.on("error", () => { s.destroy(); if (Date.now() - t0 > 8000) { if (!settled) { settled = true; reject(new Error("sing-box 端口未就绪:" + stderr.slice(0, 200))); } } else setTimeout(poll, 300); }); }; poll(); }); // 端口通了 → 设系统代理 let services = []; if (process.platform === "darwin") services = await macSetSystemProxy(port); else if (process.platform === "win32") await winSetSystemProxy(port); state.mode = "system"; state.child = child; state.localPort = port; state.services = services; conf.appliedPort = port; saveConf(); child.on("exit", () => { // 子进程意外退出 → 复位(并尝试还原系统代理) if (state.child === child) { cleanupSystemProxy().catch(() => {}); state.mode = "off"; state.child = null; emit(); } }); emit(); return status(); } async function cleanupSystemProxy() { if (process.platform === "darwin") await macUnsetSystemProxy(state.services); else if (process.platform === "win32") await winUnsetSystemProxy(); state.services = []; } // --------------------------------------------------------------------------- // 全局 TUN 模式:提权启动 sing-box(flag 文件做“可停止”,全程只弹一次密码) // 原理:提权 shell 里后台跑 sing-box,前台 while 循环盯住 flag 文件; // 我方(普通权限)删除 flag → 提权循环发现文件没了 → kill sing-box 退出。 // --------------------------------------------------------------------------- function flagPath() { return path.join(runtimeDir(), "global.flag"); } function stripAnsi(s) { return String(s).replace(/\x1b\[[0-9;]*m/g, ""); } // 读日志:Windows 的系统错误常是 GBK 中文,UTF-8 读出来是乱码 → 检测到乱码就用 GBK 兜底 function readLogSmart(logf) { let buf; try { buf = fs.readFileSync(logf); } catch { return ""; } let s = buf.toString("utf-8"); if (s.includes("�")) { for (const enc of ["gbk", "gb18030", "big5"]) { try { const d = new TextDecoder(enc).decode(buf); if (!d.includes("�")) { s = d; break; } } catch {} } } return stripAnsi(s); } // 盯 sing-box 日志判断启动成败(mac / Windows 通用): // 出现 "sing-box started" → 成功;出现 FATAL → 失败; // 提权进程(仅 mac)提前退出且无 started → 取消授权;超时同理。 function waitForStart(logf, timeoutMs, child, watchChildExit) { return new Promise((resolve) => { const t0 = Date.now(); let childExited = false; if (watchChildExit && child) child.on("exit", () => (childExited = true)); const tick = () => { const txt = readLogSmart(logf); if (/sing-box started/i.test(txt)) return resolve({ ok: true }); const fatal = txt.match(/FATAL[^\n]*/i) || txt.match(/ERROR[^\n]*(bind|permission|denied|wintun|tun)[^\n]*/i); if (fatal) return resolve({ ok: false, reason: "引擎启动失败:" + fatal[0].trim().slice(0, 260) }); if (childExited && Date.now() - t0 > 1500) { return resolve({ ok: false, reason: txt ? "启动失败:" + txt.trim().slice(-260) : "已取消管理员授权,全局代理未启动。", }); } if (Date.now() - t0 > timeoutMs) { return resolve({ ok: false, reason: txt ? "启动超时:" + txt.trim().slice(-260) : "未获得管理员授权(密码框 / UAC 被取消),或引擎无法启动。", }); } setTimeout(tick, 400); }; tick(); }); } async function startGlobal(p) { const flag = flagPath(); const logf = path.join(runtimeDir(), "global.log"); const cfg = writeCfg("global.json", globalConfig(p, logf)); // 先自检配置,避免弹了密码才发现配置错 await pExecFile(binPath(), ["check", "-c", cfg]); try { fs.unlinkSync(logf); // 清掉上次日志,保证这次判断读到的是本次输出 } catch {} fs.writeFileSync(flag, "run"); let child; if (process.platform === "darwin") { // 提权 bash:后台跑 sing-box(日志由 sing-box 自己写文件),盯 flag;flag 删了或引擎挂了就退出 const sh = path.join(runtimeDir(), "run-global.sh"); fs.writeFileSync( sh, `#!/bin/bash "${binPath()}" run -c "${cfg}" & SB=$! while [ -f "${flag}" ]; do sleep 1; kill -0 $SB 2>/dev/null || break; done kill $SB 2>/dev/null wait $SB 2>/dev/null ` ); fs.chmodSync(sh, 0o755); const osa = `do shell script "/bin/bash '${sh}'" with administrator privileges`; child = spawn("osascript", ["-e", osa]); } else if (process.platform === "win32") { // PowerShell(Unicode 原生,兼容中文用户名路径):提权后台跑 sing-box,盯 flag,删了就杀。 // sing-box 自己把日志写进 log.output,不用 shell 重定向 → 避开 cmd 的 GBK 编码坑。 const ps = path.join(runtimeDir(), "run-global.ps1"); const psBody = "" + // UTF-8 BOM,保证 PowerShell 按 UTF-8 读取(路径含中文也不乱) `$ErrorActionPreference='SilentlyContinue'\r\n` + // 先杀掉上次残留的引擎(此脚本已提权,能杀掉之前遗留的管理员进程),释放被占用的 wintun 网卡 `Stop-Process -Name sing-box -Force\r\n` + `Start-Sleep -Milliseconds 800\r\n` + `$proc = Start-Process -FilePath '${binPath()}' -ArgumentList @('run','-c','${cfg}') -WindowStyle Hidden -PassThru\r\n` + `while (Test-Path '${flag}') { if ($proc.HasExited) { break }; Start-Sleep -Seconds 1 }\r\n` + `if (-not $proc.HasExited) { Stop-Process -Id $proc.Id -Force }\r\n` + `Stop-Process -Name sing-box -Force\r\n`; fs.writeFileSync(ps, psBody, "utf-8"); // 非提权的外层 powershell 负责触发一次 UAC,把上面的脚本以管理员+隐藏窗口运行 const outer = `Start-Process powershell -Verb RunAs -WindowStyle Hidden -ArgumentList '-NoProfile','-ExecutionPolicy','Bypass','-File','${ps}'`; child = spawn("powershell", ["-NoProfile", "-Command", outer], { windowsHide: true }); } else { throw new Error("全局代理暂只支持 macOS 与 Windows"); } // mac 的提权进程会随 sing-box 存活;win 的外层进程点完 UAC 立刻退出,故只在 mac 盯它早退 // 开 wintun/utun 网卡可能较慢,给 40 秒;那句 "open interface take too much time" 只是 WARN,会继续尝试 const res = await waitForStart(logf, 40000, child, process.platform === "darwin"); if (!res.ok) { try { fs.unlinkSync(flag); } catch {} try { if (child) child.kill(); } catch {} throw new Error(res.reason); } state.mode = "global"; state.localPort = 0; if (process.platform === "darwin") { // mac:提权进程存活即代理存活,它退出就复位状态 state.child = child; child.on("exit", () => { if (state.child === child) { try { fs.unlinkSync(flag); } catch {} state.mode = "off"; state.child = null; emit(); } }); } else { // win:外层进程已退出,靠 flag 文件控制生命周期 state.child = null; } emit(); return status(); } // --------------------------------------------------------------------------- // 统一启动 / 停止 // --------------------------------------------------------------------------- async function start(mode) { loadConf(); if (!binExists()) throw new Error("缺少 sing-box 引擎文件,请重新安装本软件"); const p = parseProxy(conf.proxyStr); if (!p) throw new Error("请先填写正确的代理地址,格式:host:port:user:pass"); if (state.mode !== "off") await stop(); if (mode === "system") return startSystem(p); if (mode === "global") return startGlobal(p); throw new Error("未知模式:" + mode); } async function stop() { const prev = state.mode; if (prev === "system") { await cleanupSystemProxy(); if (state.child) { try { state.child.kill(); } catch {} } } else if (prev === "global") { // 删 flag → 提权循环自行 kill sing-box 并拆除 TUN try { fs.unlinkSync(flagPath()); } catch {} // 兜底:等提权进程收尾 await new Promise((r) => setTimeout(r, 1500)); } state.mode = "off"; state.child = null; state.localPort = 0; state.services = []; emit(); return status(); } // 退出前清理:确保不把系统代理 / TUN 残留给用户 async function shutdown() { if (state.mode === "off") return; try { await stop(); } catch {} } // 启动时的残留清理:上次若崩溃遗留了「指向我们端口的系统代理」,且引擎已不在 → 关掉它, // 但不动用户自己(如 Clash)设的其它端口,避免误伤。 async function cleanupStale() { loadConf(); if (process.platform !== "darwin" || !conf.appliedPort) return; try { const services = await macServices(); for (const svc of services) { const { stdout } = await pExecFile("networksetup", ["-getsocksfirewallproxy", svc]).catch(() => ({ stdout: "" })); if (/Enabled:\s*Yes/i.test(stdout) && stdout.includes("127.0.0.1") && stdout.includes(String(conf.appliedPort))) { await macUnsetSystemProxy([svc]); } } } catch {} } // --------------------------------------------------------------------------- // 测试连通性:临时起一个本地 mixed 口,经它请求取出口 IP // --------------------------------------------------------------------------- async function testProxy() { loadConf(); if (!binExists()) throw new Error("缺少 sing-box 引擎文件"); const p = parseProxy(conf.proxyStr); if (!p) throw new Error("代理地址格式不对,应为 host:port:user:pass"); const port = await findFreePort(0); const cfg = writeCfg("test.json", systemConfig(p, port)); const child = spawn(binPath(), ["run", "-c", cfg], { stdio: ["ignore", "ignore", "pipe"] }); let stderr = ""; child.stderr.on("data", (d) => (stderr += d.toString())); try { // 等端口就绪 await new Promise((resolve, reject) => { const t0 = Date.now(); const poll = () => { const s = net.connect(port, "127.0.0.1"); s.on("connect", () => { s.destroy(); resolve(); }); s.on("error", () => { s.destroy(); if (Date.now() - t0 > 6000) reject(new Error("引擎未就绪:" + stderr.slice(0, 200))); else setTimeout(poll, 250); }); }; poll(); }); const ip = await httpGetViaProxy(port, "http://api.ipify.org/"); return { ok: true, ip, proxy: `${p.host}:${p.port}` }; } finally { try { child.kill(); } catch {} } } // 经本地 HTTP 代理端口发一个 GET,返回响应体(用于取出口 IP) function httpGetViaProxy(port, targetUrl) { return new Promise((resolve, reject) => { const u = new URL(targetUrl); const req = http.request( { host: "127.0.0.1", port, method: "GET", path: targetUrl, // 绝对 URL → 走代理 headers: { Host: u.host, "User-Agent": "codex-market", Connection: "close" }, timeout: 15000, }, (res) => { const chunks = []; res.on("data", (c) => chunks.push(c)); res.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8").trim())); } ); req.on("timeout", () => req.destroy(new Error("请求超时(15s)"))); req.on("error", reject); req.end(); }); } // --------------------------------------------------------------------------- // 状态 // --------------------------------------------------------------------------- function status() { loadConf(); const p = parseProxy(conf.proxyStr); return { mode: state.mode, // off | system | global running: state.mode !== "off", localPort: state.localPort || 0, binOk: binExists(), platform: process.platform, proxy: p ? { host: p.host, port: p.port, hasAuth: !!p.user } : null, hasConfig: !!p, }; } module.exports = { getConfig, setConfig, start, stop, status, testProxy, shutdown, cleanupStale, setOnChange, };