Files
codex-market-app/electron/lib/proxy.js
T
2026-07-15 18:01:56 +08:00

674 lines
24 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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.*;<local>", "/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,
};