Files

674 lines
24 KiB
JavaScript
Raw Permalink Normal View History

2026-07-15 18:01:56 +08:00
// 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,
};