diff --git a/.gitignore b/.gitignore
index f65bff60..5a1390a1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -70,3 +70,6 @@ subscription-cache/
# Any local override configs
local.config.*
*.local.json
+
+# NaiveProxy 核心库(由 npm run fetch:cronet 下载,打包时随 resources 打入;体积大不入库)
+resources/*/libcronet.*
diff --git a/README.md b/README.md
index eaed04a6..29d90198 100644
--- a/README.md
+++ b/README.md
@@ -34,6 +34,9 @@
- ✅ 支持排除进程代理模式
- ✅ 跨平台支持(Windows / macOS / Linux(测试))
- ✅ 支持中英文语言切换
+- ✅ 无缝切换节点(selector + clash_api 热切换,默认优雅不断流;可选「切换时中断现有连接」)
+- ✅ Block QUIC(节点无关:reject 代理向 QUIC/UDP 443、逼浏览器回退 TCP,解决节点 UDP relay 不通导致的网页卡顿)
+- ✅ 抗封增强:TLS Fragment(全局)/ ECH / Multiplex / httpupgrade / Hysteria2 端口跳跃(订阅自动识别,部分提供手动开关)
---
## 🖼 界面预览
@@ -68,7 +71,7 @@
打开 `.dmg` 并拖入 Applications
### macOS (Intel)
-需要从源码构建
+打开 `.dmg` 并拖入 Applications(已提供 Intel / x64 预构建包)
若 macOS 提示“软件已损坏”:
@@ -91,11 +94,7 @@ npm run package:win
npm run package:mac
```
-macOS Intel 用户需要修改 `electron-builder.json`:
-
-```json
-"arch": ["x64"]
-```
+`package:mac` 默认构建 **arm64 + x64** 两个架构(见 `electron-builder.json` 的 `mac.target.arch`)。
---
@@ -127,6 +126,28 @@ macOS Intel 用户需要修改 `electron-builder.json`:
---
+## 🛡 抗封 / 切换 / NaiveProxy 说明
+
+### 无缝切换节点
+默认采用 **selector + clash_api 热切换**:切换节点不重启核心、现有连接保留至自然关闭,新连接走新节点(优雅不断流)。高级设置中的「**切换时中断现有连接**」开关(默认关)开启后,切换会强制断开现有连接、立即在新节点重建。跨模式/端口/TUN/规则等改动仍会重启以应用。
+
+### Block QUIC(高级设置)
+开启后对**代理向的 QUIC(UDP 443)**执行 reject、逼浏览器回退 TCP,解决「节点 UDP relay 不通导致网页卡顿/断流」。**节点无关**,对所有协议一视同仁;hysteria2/tuic/naive 等以 QUIC 拨号的节点其**自身拨号不受影响**(受 fwmark 保护)。默认关。
+
+### 抗封增强
+- **TLS Fragment**(高级设置全局开关):切分 TLS ClientHello,规避基于 SNI 关键词的 DPI 阻断。对所有 TCP-TLS 节点生效;hysteria2/tuic/naive 自动排除(其 TLS 不在 TCP 层)。
+- **ECH / Multiplex / httpupgrade / Hysteria2 端口跳跃**:从 **sing-box JSON 订阅自动识别并生效**(Multiplex 对 reality+vision 节点自动跳过;端口跳跃支持多段范围)。
+
+### ⚠️ NaiveProxy(naive)核心库说明
+naive 出站底层走 **Chromium 的 Cronet 网络库** 以获得与浏览器一致的指纹。各平台链接方式不同:
+
+- **Linux / Windows**:cronet 走**动态库**(`libcronet.so` / `libcronet.dll`),需与核心同目录。打包时由 `npm run fetch:cronet` 从 [SagerNet/cronet-go](https://github.com/SagerNet/cronet-go/releases) 拉取并随安装包打入(体积大,不入库、CI/打包时拉取)。
+- **macOS(arm64 与 x64)**:cronet 由 sing-box 核心二进制**静态编入**(CGO),naive **开箱即用、无需任何外部库**;两个架构均随安装包发布(核心统一 sing-box 1.13.13)。
+
+> 在缺少 cronet 的平台/架构上,naive 节点会被**自动跳过**(不影响其它协议节点;若选中的正是 naive 节点会给出明确提示)。
+
+---
+
## 🔧 技术栈
- Electron
diff --git a/electron-builder.json b/electron-builder.json
index 5bfab101..3fce1a1d 100644
--- a/electron-builder.json
+++ b/electron-builder.json
@@ -74,7 +74,8 @@
{
"target": "dir",
"arch": [
- "arm64"
+ "arm64",
+ "x64"
]
}
],
diff --git a/package.json b/package.json
index 1a2387a7..f1974b63 100644
--- a/package.json
+++ b/package.json
@@ -27,9 +27,10 @@
"lint": "eslint src --ext .ts,.tsx",
"lint:fix": "eslint src --ext .ts,.tsx --fix",
"format": "prettier --write \"src/**/*.{ts,tsx,json,css}\"",
+ "fetch:cronet": "node scripts/fetch-cronet.mjs",
"release:tag": "node scripts/push-release.js",
"release:tag:update": "node scripts/push-release.js -y -u",
- "release:prepare": "npm run build && npm run package:all"
+ "release:prepare": "npm run fetch:cronet && npm run build && npm run package:all"
},
"keywords": [
"electron",
diff --git a/resources/linux/sing-box b/resources/linux/sing-box
index fc583476..6b296289 100755
Binary files a/resources/linux/sing-box and b/resources/linux/sing-box differ
diff --git a/resources/mac-arm64/sing-box b/resources/mac-arm64/sing-box
index 70111a40..6c4caa79 100755
Binary files a/resources/mac-arm64/sing-box and b/resources/mac-arm64/sing-box differ
diff --git a/resources/mac-x64/sing-box b/resources/mac-x64/sing-box
index b195ffae..a8f77651 100755
Binary files a/resources/mac-x64/sing-box and b/resources/mac-x64/sing-box differ
diff --git a/resources/win/sing-box.exe b/resources/win/sing-box.exe
index 80b4dfed..7071a98b 100644
Binary files a/resources/win/sing-box.exe and b/resources/win/sing-box.exe differ
diff --git a/scripts/fetch-cronet.mjs b/scripts/fetch-cronet.mjs
new file mode 100644
index 00000000..f21b4a99
--- /dev/null
+++ b/scripts/fetch-cronet.mjs
@@ -0,0 +1,99 @@
+#!/usr/bin/env node
+/**
+ * fetch-cronet.mjs — 下载各平台 NaiveProxy 核心库 libcronet 到 resources/{平台}/,
+ * 供 electron-builder 的 extraResources(`**/*` filter) 随安装包打包(与 sing-box 二进制同模式)。
+ *
+ * 用法:node scripts/fetch-cronet.mjs [--force]
+ *
+ * ⚠️ macOS 不在此脚本范围:cronet 在 mac 上不走动态库。FlowZ 的 mac-arm64 sing-box 二进制已把 cronet
+ * 静态编入(CGO,实测二进制内含 cronet 符号、无 dlopen libcronet.dylib),naive 开箱即用、无需任何
+ * 外部库。mac-x64 二进制未编入 cronet → naive 暂不可用(需重编带 naive 的 x64 核心)。详见 README。
+ *
+ * ⚠️ 版本耦合:CRONET_VERSION 应与「随 app 打包的 sing-box 所用 cronet-go 版本」对应。cronet 走
+ * C API(Chromium 稳定 ABI),跨 sing-box 小版本一般兼容;若升级 sing-box 后 naive 报符号错,提高
+ * 此处版本并重打包。来源:https://github.com/SagerNet/cronet-go/releases
+ */
+import { createWriteStream, existsSync, mkdirSync, renameSync, unlinkSync } from 'fs';
+import { get } from 'https';
+import { dirname, join } from 'path';
+import { fileURLToPath } from 'url';
+
+const CRONET_VERSION = 'v148.0.7778.96-1'; // ← 与打包 sing-box 的 cronet-go 版本对齐
+const REPO = 'SagerNet/cronet-go';
+
+const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
+const FORCE = process.argv.includes('--force');
+
+// 仅 linux/windows 走动态库;mac 静态编入核心二进制,不需下载(见文件头)。
+// resources 目标目录 ← cronet-go 资产名 → 落地文件名(purego 期望)
+const TARGETS = [
+ { dir: 'resources/linux', asset: 'libcronet-linux-amd64.so', out: 'libcronet.so' },
+ { dir: 'resources/win', asset: 'libcronet-windows-amd64.dll', out: 'libcronet.dll' },
+];
+
+function download(url, dest, redirects = 0) {
+ return new Promise((resolve, reject) => {
+ if (redirects > 5) return reject(new Error('too many redirects'));
+ get(url, { headers: { 'User-Agent': 'FlowZ-fetch-cronet' } }, (res) => {
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
+ res.resume();
+ return resolve(download(res.headers.location, dest, redirects + 1));
+ }
+ if (res.statusCode !== 200) {
+ res.resume();
+ return reject(new Error(`HTTP ${res.statusCode} for ${url}`));
+ }
+ // 原子写:先写 .tmp,完成再 rename;失败/中断则删 .tmp,避免把截断文件当成"已存在"误用
+ const tmp = `${dest}.tmp`;
+ const file = createWriteStream(tmp);
+ const fail = (e) => {
+ try {
+ unlinkSync(tmp);
+ } catch {
+ /* ignore */
+ }
+ reject(e);
+ };
+ res.on('error', fail);
+ file.on('error', fail);
+ res.pipe(file);
+ file.on('finish', () =>
+ file.close((err) => {
+ if (err) return fail(err);
+ try {
+ renameSync(tmp, dest);
+ resolve();
+ } catch (e) {
+ fail(e);
+ }
+ })
+ );
+ }).on('error', reject);
+ });
+}
+
+let ok = 0;
+let failed = 0;
+for (const t of TARGETS) {
+ const absDir = join(ROOT, t.dir);
+ const dest = join(absDir, t.out);
+ if (existsSync(dest) && !FORCE) {
+ console.log(`skip (exists): ${t.dir}/${t.out}`);
+ ok++;
+ continue;
+ }
+ mkdirSync(absDir, { recursive: true });
+ const url = `https://github.com/${REPO}/releases/download/${CRONET_VERSION}/${t.asset}`;
+ try {
+ console.log(`downloading ${t.asset} → ${t.dir}/${t.out} ...`);
+ await download(url, dest);
+ console.log(` ok`);
+ ok++;
+ } catch (e) {
+ console.error(` FAILED: ${e.message}`);
+ failed++;
+ }
+}
+console.log(`\ncronet libs: ${ok} ready, ${failed} failed (version ${CRONET_VERSION}).`);
+console.log('macOS: cronet 静态编入 mac-arm64 核心,无需下载(见脚本头注)。');
+process.exit(failed > 0 ? 1 : 0);
diff --git a/src/main/index.ts b/src/main/index.ts
index db827b11..6347b40f 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -583,6 +583,7 @@ if (gotTheLock) {
// 初始化 ProxyManager(需要在窗口创建后)
proxyManager = new ProxyManager(logManager, mainWindow || undefined);
coreUpdateService.setProxyManager(proxyManager);
+ coreUpdateService.setConfigProvider(() => configManager.loadConfig());
// 初始化自动换节点服务
autoSwitchService = new AutoSwitchService(
@@ -629,6 +630,18 @@ if (gotTheLock) {
'Main'
);
}
+
+ // 若是核心更新后新核心首次启动失败 → 自动回滚到旧核心并以旧核心重启代理
+ try {
+ const rolledBack = await coreUpdateService.autoRollbackIfPendingUpdate();
+ if (rolledBack) {
+ logManager.addLog('warn', '新核心启动失败,已自动回滚,正在以旧核心重启代理...', 'Main');
+ const cfg = await configManager.loadConfig();
+ await proxyManager?.start(cfg);
+ }
+ } catch (rollbackErr) {
+ logManager.addLog('error', `自动回滚重启失败: ${rollbackErr}`, 'Main');
+ }
});
proxyManager.on('started', async () => {
@@ -796,11 +809,11 @@ if (gotTheLock) {
await configManager.saveConfig(config);
logManager.addLog('info', `Server selected from tray: ${serverId}`, 'Main');
- // 如果代理正在运行,重启以应用新服务器
+ // 如果代理正在运行,应用新服务器:切节点走 switchMode(clash_api 热切换、不断流),
+ // 失败自动退回重启——与渲染端切换路径行为一致,避免托盘切节点硬重启断流。
if (proxyManager && proxyManager.getStatus().running) {
- await proxyManager.stop();
- await proxyManager.start(config);
- logManager.addLog('info', 'Proxy restarted with new server', 'Main');
+ await proxyManager.switchMode(config);
+ logManager.addLog('info', 'Applied server switch from tray', 'Main');
}
// 更新托盘菜单
@@ -1104,26 +1117,20 @@ if (gotTheLock) {
const isRunning = proxyManager?.getStatus().running ?? false;
updateTrayMenuState(isRunning);
- // 2. 如果代理正在运行,自动重启以应用新配置
+ // 2. 如果代理正在运行,应用新配置:仅切节点走 clash_api 热切换(不断流),其余重启(见 switchMode)
if (isRunning && proxyManager) {
- logManager.addLog('info', 'Configuration changed, restarting proxy...', 'Main');
try {
- await proxyManager.stop();
// 重新加载配置以确保使用最新值
const latestConfig = await configManager.loadConfig();
- await proxyManager.start(latestConfig);
- logManager.addLog('info', 'Proxy restarted successfully with new configuration', 'Main');
+ await proxyManager.switchMode(latestConfig);
+ logManager.addLog('info', 'Applied configuration change', 'Main');
- // 重启后再次更新托盘(以防状态有变)
- updateTrayMenuState(true);
+ // 应用后再次更新托盘(以防状态有变)
+ updateTrayMenuState(proxyManager.getStatus().running);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
- logManager.addLog(
- 'error',
- `Failed to restart proxy after config change: ${errorMessage}`,
- 'Main'
- );
- // 重启失败,更新托盘状态为停止
+ logManager.addLog('error', `Failed to apply config change: ${errorMessage}`, 'Main');
+ // 应用失败,更新托盘状态为停止
updateTrayMenuState(false, true);
}
}
diff --git a/src/main/services/CoreUpdateService.ts b/src/main/services/CoreUpdateService.ts
index dcbb0cca..fedf10e6 100644
--- a/src/main/services/CoreUpdateService.ts
+++ b/src/main/services/CoreUpdateService.ts
@@ -11,6 +11,8 @@ import { LogManager } from './LogManager';
import { ProxyManager } from './ProxyManager';
import { resourceManager } from './ResourceManager';
+import type { UserConfig } from '../../shared/types';
+
export interface CoreUpdateCheckResult {
hasUpdate: boolean;
currentVersion: string;
@@ -27,10 +29,28 @@ export interface CoreVersionInfo {
lastKnownVersion: string | null;
}
+/**
+ * FlowZ 配置生成器已验证适配的 sing-box 版本带上限,编码为 major*1000+minor(1013 = 1.13)。
+ * 跨越此版本带(如 1.14)可能因 sing-box 配置 schema 破坏性变更导致生成的配置无法解析;默认不
+ * 自动跨带更新(可在设置关闭)。用整数编码比较,避免 parseFloat 把 "1.20" 误判为 1.2 < 1.13 而
+ * 漏放跨带更新。sing-box 升级并验证配置生成兼容后,应调高此常量。
+ */
+const COMPATIBLE_CEILING = 1013;
+
export class CoreUpdateService {
private logManager: LogManager;
private proxyManager: ProxyManager | null = null;
private isUpdating: boolean = false;
+ // 更新后等待「首次成功运行」验证的新版本号;首启成功→清除并删备份,首启失败→自动回滚
+ private pendingUpdateVersion: string | null = null;
+ private pendingUpdateAt: number = 0; // 更新落盘时间戳,用于"待验证"过期保护(防陈旧 pending 误回滚)
+ private stabilityTimer: ReturnType | null = null;
+ private configProvider: (() => Promise) | null = null;
+ // 新核心首启成功后需"稳定运行"此时长(无 error)才删旧备份——防 'started'(仅1s存活) 假成功删掉回滚网。
+ // 不变量:必须 > ProxyManager 健康检查间隔(10s),否则 TUN 模式下崩溃在轮询检测到之前就被判稳定、误删备份。
+ private static readonly STABILITY_DWELL_MS = 30000;
+ // "待验证"最长有效期:超过则后续 error 视为与本次更新无关,不再触发回滚(防陈旧 pending 误回滚)
+ private static readonly PENDING_MAX_AGE_MS = 5 * 60 * 1000;
constructor(logManager: LogManager) {
this.logManager = logManager;
@@ -40,6 +60,11 @@ export class CoreUpdateService {
this.proxyManager = proxyManager;
}
+ /** 注入配置读取器(用于读取"仅兼容版本带内更新"开关)。 */
+ setConfigProvider(provider: () => Promise): void {
+ this.configProvider = provider;
+ }
+
/**
* 检查核心更新
*/
@@ -71,6 +96,39 @@ export class CoreUpdateService {
);
if (this.compareVersions(latestVersion, currentVersion) > 0) {
+ // 跳过曾预检/启动失败的问题版本(手动更新可绕过)
+ if (this.isKnownBad(latestVersion)) {
+ this.logManager.addLog(
+ 'info',
+ `最新版本 ${latestVersion} 曾验证失败,已跳过自动更新`,
+ 'CoreUpdateService'
+ );
+ return {
+ hasUpdate: false,
+ currentVersion,
+ latestVersion,
+ error: `版本 ${latestVersion} 与当前配置不兼容(已跳过);如需仍可手动更新`,
+ };
+ }
+
+ // 版本带闸门:默认不自动跨越配置生成器已验证的版本带上限(防 schema 破坏导致无法解析)
+ if (await this.isRestrictToCompatibleMinor()) {
+ const latest = this.encodeMajorMinor(latestVersion);
+ if (!isNaN(latest) && latest > COMPATIBLE_CEILING) {
+ this.logManager.addLog(
+ 'info',
+ `最新版本 ${latestVersion} 跨越兼容版本带(>1.13),不自动更新`,
+ 'CoreUpdateService'
+ );
+ return {
+ hasUpdate: false,
+ currentVersion,
+ latestVersion,
+ error: `新版本 ${latestVersion} 跨越兼容版本带,建议随 App 升级(可在设置关闭"仅兼容版本带内更新"以手动尝试)`,
+ };
+ }
+ }
+
// 找到适合当前平台的资源
const asset = this.findSuitableAsset(latestRelease.assets);
if (asset) {
@@ -111,6 +169,7 @@ export class CoreUpdateService {
}
this.isUpdating = true;
+ let backupMade = false;
try {
// 1. 下载文件
@@ -122,6 +181,21 @@ export class CoreUpdateService {
this.logManager.addLog('info', '正在解压核心文件...', 'CoreUpdateService');
const { corePath, extractDir: tempExtractDir } = await this.extractCore(tempPath);
+ // 2.5 预检:新核心可执行 + 可解析当前配置。不通过则不动现役核心(代理继续运行,永不 brick)。
+ const preflight = await this.preflightValidate(corePath);
+ if (!preflight.ok) {
+ if (preflight.version) this.markKnownBad(preflight.version);
+ try {
+ fs.unlinkSync(tempPath);
+ if (tempExtractDir && fs.existsSync(tempExtractDir)) {
+ fs.rmSync(tempExtractDir, { recursive: true, force: true });
+ }
+ } catch {
+ /* ignore */
+ }
+ throw new Error(`核心更新预检失败,已放弃(现役核心继续运行):${preflight.reason}`);
+ }
+
// 3. 停止代理
let wasRunning = false;
if (this.proxyManager) {
@@ -137,6 +211,7 @@ export class CoreUpdateService {
// 4. 备份旧核心
await this.backupCurrentCore();
+ backupMade = true;
// 5. 替换核心
this.logManager.addLog('info', '正在替换核心文件...', 'CoreUpdateService');
@@ -172,6 +247,10 @@ export class CoreUpdateService {
fs.chmodSync(targetPath, 0o755);
}
+ // 核心单独更新后,确保 NaiveProxy 库 libcronet 与新核心同目录(官方 sing-box 包不含 libcronet,
+ // naive 仍依赖随 app 打包的那个;purego 从核心同目录加载)。
+ await resourceManager.ensureCronetBeside(targetDir);
+
// macOS: 清除下载隔离标记并重新 ad-hoc 签名
// 原因: macOS Gatekeeper 对新放入的未公证二进制会拦截执行 (SIGKILL)
// xattr -cr 清除 quarantine 标记, codesign --force -s - 重新 ad-hoc 签名使其被系统接受
@@ -192,6 +271,12 @@ export class CoreUpdateService {
this.logManager.addLog('info', '核心文件替换成功', 'CoreUpdateService');
+ // 标记"待首启验证":稳定运行→删备份(稳定即弃);首启失败→自动回滚(见 index 'error' 钩子)。
+ // 同时抑制 ProxyManager 自动重启——让新核心首次异常退出立即上报,而非在坏核心上空转重试。
+ this.pendingUpdateVersion = preflight.version;
+ this.pendingUpdateAt = Date.now();
+ this.proxyManager?.setAutoRestartSuppressed(true);
+
// 6. 清理临时文件
try {
fs.unlinkSync(tempPath);
@@ -221,8 +306,8 @@ export class CoreUpdateService {
const msg = error instanceof Error ? error.message : String(error);
this.logManager.addLog('error', `更新核心失败: ${msg}`, 'CoreUpdateService');
- // 尝试恢复备份
- await this.restoreBackup();
+ // 尝试恢复备份(仅当本次确已备份;预检阶段失败时尚无新备份,避免误恢复陈旧 .bak 致降级)
+ if (backupMade) await this.restoreBackup();
throw error;
} finally {
@@ -290,6 +375,15 @@ export class CoreUpdateService {
const data = { version, recordedAt: new Date().toISOString() };
fs.writeFileSync(versionFilePath, JSON.stringify(data, null, 2), 'utf-8');
this.logManager.addLog('info', `已记录成功版本: ${version}`, 'CoreUpdateService');
+
+ // 该版本成功运行 → 从问题版本名单移除(曾因瞬时原因误标记的版本恢复自动更新资格)
+ this.clearKnownBad(version);
+
+ // 若处于"更新后待验证"窗口:'started' 仅代表存活 1s、可能假成功,不立即删备份;改启动稳定
+ // 观察期,期内无 error 才判稳定→删备份(详见 startStabilityWatch / 修 review #3 假成功删备份)
+ if (this.pendingUpdateVersion) {
+ this.startStabilityWatch();
+ }
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
this.logManager.addLog('warn', `记录版本失败: ${msg}`, 'CoreUpdateService');
@@ -462,6 +556,10 @@ export class CoreUpdateService {
fs.chmodSync(targetPath, 0o755);
}
+ // 手动替换核心后,确保 libcronet 与新核心同目录(naive 依赖随 app 打包的库;修 review M4,
+ // 与自动核心更新路径一致)
+ await resourceManager.ensureCronetBeside(targetDir);
+
// macOS: 清除隔离标记并重新签名
if (process.platform === 'darwin') {
try {
@@ -505,6 +603,210 @@ export class CoreUpdateService {
this.logManager.addLog('info', '手动替换核心成功', 'CoreUpdateService');
}
+ // === 核心更新健壮性:预检 / 问题版本跳过 / 备份生命周期 / 自动回滚 ===
+
+ /** 取任意 sing-box 二进制的版本号(执行 ` version` 解析);不可执行返回 null。 */
+ private async getBinaryVersion(binPath: string): Promise {
+ try {
+ const execFileAsync = require('util').promisify(require('child_process').execFile);
+ const { stdout } = await execFileAsync(binPath, ['version']);
+ const m = String(stdout).match(/(?:version\s+|v)(\d+\.\d+(?:\.\d+)?)/i);
+ return m ? m[1] : null;
+ } catch {
+ return null;
+ }
+ }
+
+ private getKnownBadPath(): string {
+ return path.join(app.getPath('userData'), 'core-known-bad.json');
+ }
+
+ private loadKnownBad(): string[] {
+ try {
+ const p = this.getKnownBadPath();
+ if (!fs.existsSync(p)) return [];
+ const d = JSON.parse(fs.readFileSync(p, 'utf-8'));
+ return Array.isArray(d?.versions) ? d.versions : [];
+ } catch {
+ return [];
+ }
+ }
+
+ /** 标记某版本为"问题版本"(预检/启动失败),自动更新将跳过;手动更新仍可强制安装。 */
+ private markKnownBad(version: string): void {
+ try {
+ const list = this.loadKnownBad();
+ if (!list.includes(version)) list.push(version);
+ fs.writeFileSync(
+ this.getKnownBadPath(),
+ JSON.stringify({ versions: list }, null, 2),
+ 'utf-8'
+ );
+ this.logManager.addLog(
+ 'warn',
+ `已标记问题版本 ${version},自动更新将跳过`,
+ 'CoreUpdateService'
+ );
+ } catch {
+ /* ignore */
+ }
+ }
+
+ isKnownBad(version: string): boolean {
+ return this.loadKnownBad().includes(version);
+ }
+
+ /** 从问题版本名单移除某版本(该版本成功运行后调用,使误标记可恢复)。 */
+ private clearKnownBad(version: string): void {
+ try {
+ const list = this.loadKnownBad();
+ if (!list.includes(version)) return;
+ const next = list.filter((v) => v !== version);
+ fs.writeFileSync(
+ this.getKnownBadPath(),
+ JSON.stringify({ versions: next }, null, 2),
+ 'utf-8'
+ );
+ } catch {
+ /* ignore */
+ }
+ }
+
+ /** 把版本编码为可比较整数 major*1000+minor("1.20.3"→1020),用于版本带比较;无法解析返回 NaN。 */
+ private encodeMajorMinor(version: string): number {
+ const m = version.match(/^(\d+)\.(\d+)/);
+ return m ? parseInt(m[1], 10) * 1000 + parseInt(m[2], 10) : NaN;
+ }
+
+ private async isRestrictToCompatibleMinor(): Promise {
+ try {
+ if (!this.configProvider) return true;
+ const cfg = await this.configProvider();
+ return cfg?.restrictCoreUpdateToCompatibleMinor !== false; // 默认 true
+ } catch {
+ return true;
+ }
+ }
+
+ /**
+ * 核心更新预检:新核心二进制可执行 + 能解析"针对其版本生成的"当前配置(sing-box check)。
+ * 在替换现役核心之前调用 —— 不通过则现役核心不动、代理继续运行(永不 brick)。
+ */
+ private async preflightValidate(
+ newCorePath: string
+ ): Promise<{ ok: boolean; version: string | null; reason?: string }> {
+ const version = await this.getBinaryVersion(newCorePath);
+ if (!version) {
+ return { ok: false, version: null, reason: '新核心无法执行(架构不符或文件损坏)' };
+ }
+
+ const cfgJson = this.proxyManager ? this.proxyManager.buildPreflightConfigJson(version) : null;
+ if (!cfgJson) {
+ // 无活动配置(代理从未启动)→ 二进制可执行即视为通过
+ this.logManager.addLog(
+ 'info',
+ `预检:新核心 ${version} 可执行(无活动配置,跳过 check)`,
+ 'CoreUpdateService'
+ );
+ return { ok: true, version };
+ }
+
+ const tmpCfg = path.join(app.getPath('temp'), `flowz-preflight-${Date.now()}.json`);
+ try {
+ fs.writeFileSync(tmpCfg, cfgJson, 'utf-8');
+ const execFileAsync = require('util').promisify(require('child_process').execFile);
+ await execFileAsync(newCorePath, ['check', '-c', tmpCfg]);
+ this.logManager.addLog(
+ 'info',
+ `预检通过:新核心 ${version} 可解析当前配置`,
+ 'CoreUpdateService'
+ );
+ return { ok: true, version };
+ } catch (e: any) {
+ const detail = String(e?.stderr || e?.message || e).split('\n')[0];
+ return { ok: false, version, reason: `新核心无法解析当前配置:${detail}` };
+ } finally {
+ try {
+ fs.unlinkSync(tmpCfg);
+ } catch {
+ /* ignore */
+ }
+ }
+ }
+
+ /** 删除旧核心备份(新核心已稳定运行后调用 —— 稳定即不再回滚,省磁盘)。 */
+ private pruneBackup(): void {
+ try {
+ const bak = this.getBackupPath();
+ if (fs.existsSync(bak)) {
+ fs.unlinkSync(bak);
+ this.logManager.addLog('info', '新核心已稳定运行,已删除旧核心备份', 'CoreUpdateService');
+ }
+ } catch {
+ /* ignore */
+ }
+ }
+
+ /**
+ * 新核心首启成功后启动"稳定观察期":STABILITY_DWELL_MS 内无 'error' 才判定稳定 → 删旧备份、清待
+ * 验证标记、恢复自动重启。期内若 'error' 触发 autoRollback 会先取消本计时器(备份仍在,可回滚)。
+ */
+ private startStabilityWatch(): void {
+ if (this.stabilityTimer) clearTimeout(this.stabilityTimer);
+ const watching = this.pendingUpdateVersion;
+ this.stabilityTimer = setTimeout(() => {
+ this.stabilityTimer = null;
+ if (this.pendingUpdateVersion !== watching) return; // 已被回滚/其它路径清除
+ this.logManager.addLog(
+ 'info',
+ `新核心 ${watching} 已稳定运行 ${CoreUpdateService.STABILITY_DWELL_MS / 1000}s,删除旧核心备份`,
+ 'CoreUpdateService'
+ );
+ this.pruneBackup();
+ this.pendingUpdateVersion = null;
+ this.proxyManager?.setAutoRestartSuppressed(false);
+ }, CoreUpdateService.STABILITY_DWELL_MS);
+ }
+
+ /**
+ * 更新后新核心"首次启动失败"时调用(由上层在 proxy 'error' 事件中触发):回滚到备份核心并标记
+ * 问题版本。返回 true 表示已回滚(调用方应以旧核心重启代理)。先清除待验证标记防重入/回滚循环。
+ */
+ async autoRollbackIfPendingUpdate(): Promise {
+ if (this.stabilityTimer) {
+ clearTimeout(this.stabilityTimer);
+ this.stabilityTimer = null;
+ }
+ const pending = this.pendingUpdateVersion;
+ const pendingAge = Date.now() - this.pendingUpdateAt;
+ this.pendingUpdateVersion = null;
+ this.proxyManager?.setAutoRestartSuppressed(false); // 回滚后旧核心恢复正常自动重启
+ if (!pending) return false;
+ // 过期保护:距本次更新过久仍未成功运行,此刻的 error 多半与核心更新无关(如用户改了配置)→ 不回滚
+ if (pendingAge > CoreUpdateService.PENDING_MAX_AGE_MS) {
+ this.logManager.addLog(
+ 'info',
+ `待验证版本 ${pending} 距更新已超 ${CoreUpdateService.PENDING_MAX_AGE_MS / 60000} 分钟仍未成功运行,本次错误不触发回滚`,
+ 'CoreUpdateService'
+ );
+ return false;
+ }
+ if (!this.hasBackup()) return false;
+ this.logManager.addLog(
+ 'error',
+ `新核心 ${pending} 启动失败,自动回滚到备份核心`,
+ 'CoreUpdateService'
+ );
+ try {
+ await this.rollbackCore(); // 恢复 .bak → 现役(内部停代理 + 删备份 + 记录版本)
+ this.markKnownBad(pending);
+ return true;
+ } catch (e) {
+ this.logManager.addLog('error', `自动回滚失败: ${e}`, 'CoreUpdateService');
+ return false;
+ }
+ }
+
/**
* 版本记录文件路径
*/
diff --git a/src/main/services/ProxyManager.ts b/src/main/services/ProxyManager.ts
index a5b35b07..5bb36d0e 100644
--- a/src/main/services/ProxyManager.ts
+++ b/src/main/services/ProxyManager.ts
@@ -191,6 +191,8 @@ interface SingBoxOutbound {
password: string;
};
network?: string;
+ // naive specific: 走 HTTP/3 (QUIC) 传输
+ quic?: boolean;
// TUIC specific
congestion_control?: string;
udp_relay_mode?: string;
@@ -217,14 +219,28 @@ interface SingBoxOutbound {
public_key: string;
short_id: string;
};
+ ech?: { enabled: boolean };
+ fragment?: boolean;
};
// Transport
transport?: {
type: string;
path?: string;
+ host?: string;
headers?: Record;
service_name?: string;
};
+ // Multiplex 多路复用
+ multiplex?: {
+ enabled: boolean;
+ protocol?: string;
+ max_connections?: number;
+ min_streams?: number;
+ padding?: boolean;
+ };
+ // Hysteria2 端口跳跃
+ server_ports?: string[];
+ hop_interval?: string;
// DNS resolver for outbound server domain
domain_resolver?: string;
// UDP over TCP (UoT)
@@ -242,6 +258,10 @@ interface SingBoxOutbound {
host_key?: string[];
host_key_algorithms?: string[];
client_version?: string;
+ // selector specific(用于 clash_api 热切换节点:default=当前选中,interrupt_exist_connections=切换时是否中断现有连接)
+ outbounds?: string[];
+ default?: string;
+ interrupt_exist_connections?: boolean;
}
interface SingBoxRouteRule {
@@ -315,11 +335,13 @@ export interface IProxyManager {
start(config: UserConfig): Promise;
stop(): Promise;
restart(config: UserConfig): Promise;
+ switchMode(config: UserConfig): Promise;
getStatus(): ProxyStatus;
generateSingBoxConfig(config: UserConfig, resolvedIps?: Record): SingBoxConfig;
on(event: 'started' | 'stopped' | 'error', listener: (...args: any[]) => void): void;
off(event: 'started' | 'stopped' | 'error', listener: (...args: any[]) => void): void;
getCoreVersion(): Promise;
+ buildPreflightConfigJson(targetVersion: string): string | null;
}
export class ProxyManager extends EventEmitter implements IProxyManager {
@@ -328,6 +350,8 @@ export class ProxyManager extends EventEmitter implements IProxyManager {
private pid: number | null = null;
private singboxPid: number | null = null; // macOS TUN 模式下实际的 sing-box PID
private currentConfig: UserConfig | null = null;
+ // 启动时生成的「节点 id → selector 成员 tag」映射,用于 clash_api 热切换时定位目标 tag
+ private currentIdToTagMap: Map | null = null;
private configPath: string;
private singboxPath: string;
private logManager: ILogManager | null = null;
@@ -346,6 +370,9 @@ export class ProxyManager extends EventEmitter implements IProxyManager {
// 自动重启相关
private autoRestartEnabled: boolean = true;
+ // 核心更新"待验证窗口"内由 CoreUpdateService 置 true:抑制自动重启,让新核心首次异常退出立即
+ // 上报 'error' 触发回滚(而非在已知有问题的新核心上空转 MAX_RESTART_COUNT 次后才上报)。
+ private autoRestartSuppressed: boolean = false;
private restartCount: number = 0;
private lastRestartTime: number = 0;
private static readonly MAX_RESTART_COUNT = 3; // 最大重启次数
@@ -433,6 +460,11 @@ export class ProxyManager extends EventEmitter implements IProxyManager {
await this.copyRuleSetsToUserData();
// 3.5. 预解析所有节点的域名为 IP(仅针对 TUN 模式),防止 Windows 下回流死循环
+ // 【待真机验证 / CDN 风险】:对"域名节点"预解析得到的 IP 会进 route_exclude_address,若节点在
+ // 共享 CDN(如 Cloudflare) 后,等于把共享 IP 加直连 → 误伤同 IP 的被墙站点、且抗不住 IP 轮换。
+ // 现已在 generateRouteConfig 用"全节点域名 → direct"的纯域名规则(sniff SNI)做 CDN 安全豁免;
+ // 此处 Windows IP 预解析是否仍为断环所必需,需 Wintun 真机验证:若域名规则+sniff 足够 → 删除本块;
+ // 若 Windows 确需 IP 级兜底 → 应仅对 IP-literal 节点保留、域名节点不预解析。无 Windows 环境暂不擅改。
const resolvedServerIps: Record = {};
if (isTunMode && process.platform === 'win32') {
this.logToManager('info', '正在预解析节点域名以防止 TUN 回流...');
@@ -513,6 +545,38 @@ export class ProxyManager extends EventEmitter implements IProxyManager {
if (config.proxyModeType === 'systemProxy') {
await this.setSystemProxy(config);
}
+
+ // H3 修复:sing-box 的 cache_file 会持久化 selector 的 clash_api 选择,重启后缓存会覆盖 config
+ // 的 default。故启动后用 clash_api 把 selector 校正回 config.selectedServerId,让 FlowZ 配置成为
+ // 单一真值、压过缓存。best-effort(不阻塞启动成功)。
+ void this.reassertSelectorSelection(config);
+ }
+
+ /**
+ * 启动后把 selector 选择校正回 config.selectedServerId(压过 cache_file 持久化的旧选择,修 H3)。
+ * best-effort + 短重试,clash_api 刚起可能未就绪;失败不影响启动(cache/default 仍是有效节点)。
+ */
+ private async reassertSelectorSelection(config: UserConfig): Promise {
+ for (let i = 0; i < 10; i++) {
+ if (!this.singboxProcess && !this.singboxPid) return; // 已停止则放弃
+ // 每轮读最新 currentConfig.selectedServerId:若启动窗口内用户已热切到别的节点,则用新节点、
+ // 不要把它 revert 回启动时的旧节点。
+ const targetId = this.currentConfig?.selectedServerId ?? config.selectedServerId;
+ const tag = this.currentIdToTagMap?.get(targetId as string);
+ if (!tag) return;
+ try {
+ const res = await fetch('http://127.0.0.1:9090/proxies/proxy-selector', {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ name: tag }),
+ signal: AbortSignal.timeout(2000),
+ });
+ if (res.ok) return;
+ } catch {
+ // clash_api 未就绪/瞬时失败 → 短延迟后重试
+ }
+ await new Promise((r) => setTimeout(r, 300));
+ }
}
/**
@@ -530,6 +594,8 @@ export class ProxyManager extends EventEmitter implements IProxyManager {
}
await this.stopSingBoxProcess();
+ // 进程已停 → 清掉旧的 id→tag 映射,防止对一个已不存在的 selector 误发 clash_api 切换
+ this.currentIdToTagMap = null;
}
/**
@@ -545,77 +611,79 @@ export class ProxyManager extends EventEmitter implements IProxyManager {
* 检测模式变化,如果代理正在运行则重启
*/
async switchMode(newConfig: UserConfig): Promise {
- // 检查是否有模式变化
- const modeChanged = this.hasModeChanged(newConfig);
-
- if (!modeChanged) {
- // 模式没有变化,只更新配置
+ // 代理未运行:只更新配置(下次 start 时按新配置生成)
+ if (!this.singboxProcess && !this.singboxPid) {
this.currentConfig = newConfig;
return;
}
- // 如果代理正在运行,需要重启
- if (this.singboxProcess) {
- this.logToManager('info', '代理模式已更改,正在重启代理...');
- await this.restart(newConfig);
- } else {
- // 代理未运行,只更新配置
- this.currentConfig = newConfig;
+ // 唯一变化是「切节点」→ clash_api 热切换 selector,不重启 sing-box(优雅切换,连接保留与否由
+ // selector.interrupt_exist_connections 开关决定)。失败则退回重启式切换,保证一定能应用。
+ if (this.canHotSwitch(newConfig)) {
+ if (await this.hotSwitchNode(newConfig)) {
+ this.currentConfig = newConfig;
+ return;
+ }
+ this.logToManager('warn', '热切换失败,退回重启式切换');
}
+
+ // 其余变化(模式/端口/TUN/规则/节点集合/interrupt 开关 等需重生成配置的项)→ 重启应用。
+ this.logToManager('info', '配置已更改,正在重启代理以应用...');
+ await this.restart(newConfig);
}
/**
- * 检查模式是否变化
+ * 是否可走 clash_api 热切换:当且仅当唯一变化是「切到一个已在运行中 selector 里的节点」,
+ * 其余影响配置生成的项(模式/端口/TUN/customRules/servers 集合/appRules/interrupt 开关)都未变。
*/
- private hasModeChanged(newConfig: UserConfig): boolean {
- if (!this.currentConfig) {
- return true;
- }
-
- // 检查代理模式
- if (this.currentConfig.proxyMode !== newConfig.proxyMode) {
- return true;
- }
-
- // 检查代理模式类型
- if (this.currentConfig.proxyModeType !== newConfig.proxyModeType) {
- return true;
- }
-
- // 检查选中的服务器
- if (this.currentConfig.selectedServerId !== newConfig.selectedServerId) {
- return true;
- }
-
- // 检查端口
- if (
- this.currentConfig.socksPort !== newConfig.socksPort ||
- this.currentConfig.httpPort !== newConfig.httpPort
- ) {
- return true;
- }
-
- // 检查 TUN 配置(如果是 TUN 模式)
- if (newConfig.proxyModeType === 'tun') {
- const oldTun = this.currentConfig.tunConfig;
- const newTun = newConfig.tunConfig;
+ private canHotSwitch(newConfig: UserConfig): boolean {
+ const old = this.currentConfig;
+ if (!old) return false;
+ // 必须确实是切节点
+ if (old.selectedServerId === newConfig.selectedServerId) return false;
+ if (!newConfig.selectedServerId) return false;
+ // 目标节点必须已存在于运行中的 selector(= 启动时的 servers),否则 PUT 指向不存在的成员
+ if (!old.servers.some((s) => s.id === newConfig.selectedServerId)) return false;
+ // Windows TUN:route_exclude_address 在 start 时仅按「选中 + appRules」节点构建;热切到其它
+ // IP-literal 节点时其 server IP 不在排除集 → Wintun 会把 sing-box 自身出向包回捕进 TUN 成环。
+ // 配置不重生成无法补排除集,故 Windows TUN 一律退回重启(由重启重生成排除集)。
+ if (process.platform === 'win32' && newConfig.proxyModeType === 'tun') return false;
+ // 唯一允许变化的就是 selectedServerId:对齐它、servers 按 id 归一化后整体深比较——任何其它影响
+ // 配置生成的字段(blockQuic/tlsFragment/dnsConfig/各 TUN 子字段/appRules/customRules/端口/interrupt
+ // 开关 等)有差异都退回重启,避免「切节点 + 改某设置」同时发生时把那个设置静默丢掉。
+ const norm = (c: UserConfig) =>
+ JSON.stringify({
+ ...c,
+ selectedServerId: null,
+ servers: [...c.servers].sort((a, b) => a.id.localeCompare(b.id)),
+ });
+ return norm(old) === norm(newConfig);
+ }
- if (
- oldTun.mtu !== newTun.mtu ||
- oldTun.stack !== newTun.stack ||
- oldTun.autoRoute !== newTun.autoRoute ||
- oldTun.strictRoute !== newTun.strictRoute
- ) {
- return true;
+ /**
+ * 通过 clash_api `PUT /proxies/proxy-selector` 把 selector 切到目标节点(无需重启)。
+ * 成功返回 true;任何异常/非 2xx 返回 false(调用方退回重启)。
+ */
+ private async hotSwitchNode(newConfig: UserConfig): Promise {
+ const targetTag = this.currentIdToTagMap?.get(newConfig.selectedServerId as string);
+ if (!targetTag) return false;
+ try {
+ const res = await fetch('http://127.0.0.1:9090/proxies/proxy-selector', {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ name: targetTag }),
+ signal: AbortSignal.timeout(2000),
+ });
+ if (!res.ok) {
+ this.logToManager('warn', `clash_api 热切换返回 HTTP ${res.status}`);
+ return false;
}
- }
-
- // 检查自定义规则
- if (JSON.stringify(this.currentConfig.customRules) !== JSON.stringify(newConfig.customRules)) {
+ this.logToManager('info', `已热切换节点 → ${targetTag}(clash_api,无重启)`);
return true;
+ } catch (e: any) {
+ this.logToManager('warn', `clash_api 热切换异常: ${e?.message ?? e}`);
+ return false;
}
-
- return false;
}
/**
@@ -679,6 +747,26 @@ export class ProxyManager extends EventEmitter implements IProxyManager {
}
}
+ /**
+ * 为「核心更新预检」生成针对目标版本的配置 JSON:用当前活动配置 + 目标核心版本的生成风格
+ * (<1.13 走 inbound sniff,≥1.13 走 route action),供 sing-box check 校验新核心能否解析。
+ * 无活动配置(代理从未启动)时返回 null —— 调用方仅校验二进制可执行即可。
+ */
+ buildPreflightConfigJson(targetVersion: string): string | null {
+ if (!this.currentConfig) return null;
+ const savedVersion = this.coreVersion;
+ try {
+ this.coreVersion = targetVersion;
+ const cfg = this.generateSingBoxConfig(this.currentConfig);
+ return JSON.stringify(cfg, null, 2);
+ } catch (e) {
+ this.logToManager('warn', `预检配置生成失败: ${(e as any)?.message ?? e}`);
+ return null;
+ } finally {
+ this.coreVersion = savedVersion;
+ }
+ }
+
/**
* 生成 sing-box 配置(sing-box 1.12.x / 1.13.x 兼容格式)
*/
@@ -687,6 +775,12 @@ export class ProxyManager extends EventEmitter implements IProxyManager {
if (!selectedServer) {
throw new Error('Selected server not found');
}
+ // 选中节点不可用(naive 缺 libcronet)→ 明确报错,不静默切到别的节点(修 review M1)
+ if (!this.isNodeUsable(selectedServer)) {
+ throw new Error(
+ `选中的节点「${selectedServer.name}」是 NaiveProxy,但未找到 libcronet 核心库(macOS 暂无官方预编译库)。请选择其它协议的节点。`
+ );
+ }
// 调试日志
console.log('[ProxyManager] Generating config with:', {
@@ -703,7 +797,8 @@ export class ProxyManager extends EventEmitter implements IProxyManager {
// 关键优化:预先生成 ID 到 Tag 的唯一映射,使用服务器名称作为 Tag,确保拓扑和日志显示友好名称
// 这样做之后内容拓扑(Clash API)和日志中显示的将是“香港 01”而不是“proxy-uuid”
const idToTagMap = new Map();
- const usedTags = new Set();
+ // 预占内置出站 tag,防止用户把节点命名为 proxy-selector/direct/block 等导致 tag 撞车启动 FATAL
+ const usedTags = new Set(['proxy-selector', 'direct', 'block', 'direct-loopback']);
const getUniqueTag = (server: ServerConfig) => {
let baseTag = server.name.trim() || '未命名节点';
@@ -721,13 +816,12 @@ export class ProxyManager extends EventEmitter implements IProxyManager {
for (const s of config.servers) {
idToTagMap.set(s.id, getUniqueTag(s));
}
+ // 记录本次生成的 id→tag 映射,供 clash_api 热切换定位 selector 成员 tag(见 hotSwitchNode)
+ this.currentIdToTagMap = idToTagMap;
const singboxConfig: SingBoxConfig = {
log: this.generateLogConfig(config),
- dns: this.generateDnsConfig(
- config,
- idToTagMap.get(config.selectedServerId as string) || 'proxy'
- ),
+ dns: this.generateDnsConfig(config, 'proxy-selector'),
inbounds: this.generateInbounds(config, resolvedIps),
outbounds: this.generateOutbounds(selectedServer, config, idToTagMap),
route: this.generateRouteConfig(config, idToTagMap),
@@ -747,6 +841,19 @@ export class ProxyManager extends EventEmitter implements IProxyManager {
},
};
+ // 路由规则若指向「已被跳过/不存在的出站」(如缺 libcronet 被跳过的 naive 节点),sing-box 会以
+ // "outbound not found" 启动失败。统一把这类死引用修正为 selector(修 review H2:app/custom 分流
+ // 指向被跳过 naive 节点的情况)。
+ const validTags = new Set(
+ singboxConfig.outbounds.map((o) => o.tag).filter((t): t is string => !!t)
+ );
+ for (const rule of singboxConfig.route?.rules ?? []) {
+ const r = rule as { action?: string; outbound?: string };
+ if (r.action === 'route' && r.outbound && !validTags.has(r.outbound)) {
+ r.outbound = 'proxy-selector';
+ }
+ }
+
// 调试日志
console.log('[ProxyManager] Generated inbounds count:', singboxConfig.inbounds.length);
console.log('[ProxyManager] Generated outbounds count:', singboxConfig.outbounds.length);
@@ -1148,7 +1255,7 @@ export class ProxyManager extends EventEmitter implements IProxyManager {
route_exclude_address: excludeAddr,
};
- // 兼容 sing-box 1.12.x 版本 (即 Windows 上的 1.12.13),必须在 inbound 定义 sniff 否则无法域名分流。
+ // 兼容 sing-box 1.12.x 版本(打包核心现已全部 ≥1.13.13,此分支仅为向后兼容旧 userData 核心保留),必须在 inbound 定义 sniff 否则无法域名分流。
// 对于 1.13.0+,嗅探逻辑已经统一由后方 route.rules 承担,但在入站开启会报错,因此需精准版本判断。
const inboundVersionMatch = this.coreVersion.match(/^(\d+\.\d+)/);
const inboundVersionNum = inboundVersionMatch ? parseFloat(inboundVersionMatch[1]) : 1.13;
@@ -1173,35 +1280,12 @@ export class ProxyManager extends EventEmitter implements IProxyManager {
return inbounds;
}
- /**
- * 递归获取代理链中的所有前置节点
- */
- private getDetourChain(server: ServerConfig, allServers: ServerConfig[]): ServerConfig[] {
- const chain: ServerConfig[] = [];
- const visitedIds = new Set();
- visitedIds.add(server.id);
-
- let currentServer = server;
- while (currentServer.detour) {
- if (visitedIds.has(currentServer.detour)) {
- console.warn(
- `[ProxyManager] Detected proxy chain loop: ${currentServer.name} -> ${currentServer.detour}`
- );
- break;
- }
-
- const detourServer = allServers.find((s) => s.id === currentServer.detour);
- if (!detourServer) {
- console.warn(`[ProxyManager] Detour server not found: ${currentServer.detour}`);
- break;
- }
-
- chain.push(detourServer);
- visitedIds.add(detourServer.id);
- currentServer = detourServer;
+ /** 节点是否可用:naive 需要 libcronet 核心库,缺库时不可用(会被跳过、分流/选中回退到 selector)。 */
+ private isNodeUsable(server: ServerConfig): boolean {
+ if (server.protocol.toLowerCase() === 'naive' && !resourceManager.hasCronetLib()) {
+ return false;
}
-
- return chain;
+ return true;
}
private generateOutbounds(
@@ -1212,84 +1296,80 @@ export class ProxyManager extends EventEmitter implements IProxyManager {
const outbounds: SingBoxOutbound[] = [];
if (config) {
- // 1. 生成主选节点的 Outbound 及其前置节点
- const mainChain = this.getDetourChain(selectedServer, config.servers);
-
- // 添加前置节点
- for (const detourServer of mainChain.reverse()) {
- const detourOutbound = this.generateProxyOutbound(detourServer, idToTagMap);
- detourOutbound.tag = idToTagMap.get(detourServer.id) || `proxy-${detourServer.id}`;
- // 避免重复添加
- if (!outbounds.some((o) => o.tag === detourOutbound.tag)) {
- outbounds.push(detourOutbound);
- }
- }
-
- // 添加主节点
- const mainOutbound = this.generateProxyOutbound(selectedServer, idToTagMap);
- // 主节点默认使用 'proxy' tag,为了兼容老的路由规则
- // 但对于拓扑显示,我们希望看到名称,所以这里我们保留一个名为 'proxy' 的 outbound
- // 并在最后将其 tag 设为服务器名称(或者通过 detour 链处理)
- // 这里的策略是:主选节点 tag 设为人类可读名称,并同步给路由规则使用。
- const selectedServerTag = idToTagMap.get(selectedServer.id) || 'proxy';
- mainOutbound.tag = selectedServerTag;
-
- if (selectedServer.detour && config.servers.some((s) => s.id === selectedServer.detour)) {
- mainOutbound.detour = idToTagMap.get(selectedServer.detour);
- }
- outbounds.push(mainOutbound);
-
- // 2. 生成自定义规则中指定的目标节点的 Outbound
- // 遍历所有启用且指定了 targetServerId 的规则(包括 customRules 和 appRules)
- const targetServerIds = new Set();
- if (config.customRules) {
- for (const rule of config.customRules) {
- if (rule.enabled && rule.action === 'proxy' && rule.targetServerId) {
- targetServerIds.add(rule.targetServerId);
- }
+ // 生成【全部】节点的 Outbound:selector 需要列出所有可切换节点;detour 前置节点亦在 config.servers
+ // 中,一并生成、通过 detour 字段链接。单个节点配置异常不应拖垮整体配置,逐节点 try/catch 跳过。
+ // (app/custom 分流规则指向的固定节点 tag 不变,仍直接命中其节点出站,不经 selector。)
+ for (const server of config.servers) {
+ const tag = idToTagMap.get(server.id) || `proxy-${server.id}`;
+ if (outbounds.some((o) => o.tag === tag)) continue; // 去重
+ // 不可用节点跳过:naive 缺 libcronet 时,sing-box 启动会预初始化全部出站、缺库的 naive 会让
+ // 整个代理启动 FATAL(连非 naive 节点也用不了)。跳过后,路由层对该节点 tag 的死引用会在
+ // generateSingBoxConfig 末尾被统一修正为 selector(见 H2 修复)。
+ if (!this.isNodeUsable(server)) {
+ this.logToManager(
+ 'warn',
+ `跳过不可用节点「${server.name}」:NaiveProxy 缺少 libcronet 核心库`
+ );
+ continue;
}
- }
- if (config.appRules) {
- for (const rule of config.appRules) {
- if (rule.enabled && rule.action === 'proxy' && rule.targetServerId) {
- targetServerIds.add(rule.targetServerId);
+ try {
+ const ob = this.generateProxyOutbound(server, idToTagMap);
+ ob.tag = tag;
+ if (server.detour && config.servers.some((s) => s.id === server.detour)) {
+ // 环检测:沿 detour 链行进,若回到本节点即成环 → 不设 detour,避免 sing-box 报循环引用启动失败
+ const seen = new Set([server.id]);
+ let cur: string | undefined = server.detour;
+ let looped = false;
+ while (cur) {
+ if (seen.has(cur)) {
+ looped = true;
+ break;
+ }
+ seen.add(cur);
+ cur = config.servers.find((s) => s.id === cur)?.detour;
+ }
+ if (looped) {
+ this.logToManager('warn', `检测到代理链成环,已跳过 detour: ${server.name}`);
+ } else {
+ ob.detour = idToTagMap.get(server.detour);
+ }
}
+ outbounds.push(ob);
+ } catch (e: any) {
+ this.logToManager(
+ 'warn',
+ `生成节点出站失败,已跳过: ${server.name} (${e?.message ?? e})`
+ );
}
}
- for (const targetId of Array.from(targetServerIds)) {
- // 如果目标节点就是主节点,不需要额外添加(主节点已有 'proxy' tag)
- if (targetId === selectedServer.id) continue;
-
- // 查找目标服务器配置
- const targetServer = config.servers.find((s) => s.id === targetId);
- if (!targetServer) continue;
-
- // 获取目标节点的前置链
- const targetChain = this.getDetourChain(targetServer, config.servers);
-
- // 添加目标节点的前置节点
- for (const detourServer of targetChain.reverse()) {
- const detourOutbound = this.generateProxyOutbound(detourServer, idToTagMap);
- detourOutbound.tag = idToTagMap.get(detourServer.id) || `proxy-${detourServer.id}`;
- // 避免重复添加
- if (!outbounds.some((o) => o.tag === detourOutbound.tag)) {
- outbounds.push(detourOutbound);
+ // 全局 TLS 分片(PR-6):开启后对所有已生成的 TCP-TLS 节点出站切分 ClientHello,抗 SNI-DPI。
+ // 跳过 hy2/tuic(QUIC 内 TLS、无 TCP ClientHello,死配置)与 naive(Cronet 自管 TLS,拒绝
+ // fragment 字段 → 启动 FATAL)。
+ if (config.tlsFragment) {
+ for (const ob of outbounds) {
+ if (ob.tls && ob.type !== 'hysteria2' && ob.type !== 'tuic' && ob.type !== 'naive') {
+ ob.tls.fragment = true;
}
}
+ }
- // 添加目标节点本身
- const targetOutbound = this.generateProxyOutbound(targetServer, idToTagMap);
- targetOutbound.tag = idToTagMap.get(targetServer.id) || `proxy-${targetServer.id}`; // 使用节点名称作为 Tag
- if (targetServer.detour && config.servers.some((s) => s.id === targetServer.detour)) {
- targetOutbound.detour = idToTagMap.get(targetServer.detour);
- }
-
- // 避免重复添加
- if (!outbounds.some((o) => o.tag === targetOutbound.tag)) {
- outbounds.push(targetOutbound);
- }
+ // selector:列出已生成的全部节点 tag,default 指向当前选中节点;interrupt_exist_connections 由用户
+ // 开关决定(默认 false=优雅切换,现有连接保留至自然关闭)。clash_api `PUT /proxies/proxy-selector`
+ // 据此热切换、无需重启 sing-box(详见 switchMode)。路由的 final 与「→代理」规则统一指向本 selector。
+ const nodeTags = outbounds.map((o) => o.tag).filter((t): t is string => !!t);
+ // 所有节点生成失败 → 空 selector 会让 sing-box 启动报含糊错误;这里提前给出清晰原因
+ if (nodeTags.length === 0) {
+ throw new Error('没有可用的代理节点出站(所有节点配置生成失败)');
}
+ const selectedServerTag = idToTagMap.get(selectedServer.id) || 'proxy';
+ outbounds.push({
+ type: 'selector',
+ tag: 'proxy-selector',
+ outbounds: nodeTags,
+ default: nodeTags.includes(selectedServerTag) ? selectedServerTag : nodeTags[0],
+ interrupt_exist_connections: config.interruptConnectionsOnSwitch === true,
+ });
} else {
// Fallback if config is missing (shouldn't happen)
outbounds.push(this.generateProxyOutbound(selectedServer, idToTagMap));
@@ -1324,11 +1404,8 @@ export class ProxyManager extends EventEmitter implements IProxyManager {
// 为每个使用 Shadow-TLS 的节点插入内层 SS outbound
const stlsOutbounds: SingBoxOutbound[] = [];
for (const ob of outbounds) {
- // 根据 tag 找到对应的 ServerConfig
- const srv =
- ob.tag === 'proxy'
- ? selectedServer
- : config?.servers.find((s) => `proxy-${s.id}` === ob.tag);
+ // 根据 tag(节点名称)反查对应的 ServerConfig;selector/direct/block 等非节点出站匹配不到 → 跳过
+ const srv = config?.servers.find((s) => idToTagMap.get(s.id) === ob.tag);
if (srv?.shadowTlsSettings) {
// 创建独立的外层 ShadowTLS outbound
const stlsTag = `stls-out-${srv.id}`;
@@ -1501,6 +1578,15 @@ export class ProxyManager extends EventEmitter implements IProxyManager {
enabled: true,
server_name: server.tlsSettings?.serverName || server.address,
};
+
+ // HTTP/3:naive 经 quic:true 走 h3(QUIC/UDP) 拨号传输,对应服务端 `--listen=quic://`。
+ // 注意这只改变"拨号传输":naive 仍只过 TCP(HTTP CONNECT)、不能中继客户端 UDP(除非
+ // udp_over_tcp,FlowZ 不下发)。客户端 QUIC 若走到 naive,由 blockQuic(udp443 reject 逼回退 TCP)
+ // 或 sing-box 出站层("UDP is not supported by outbound")处理;该拨号本身受 fwmark 保护、
+ // 绕过 route 规则,不被 reject 误杀(已实测)。
+ if (server.naiveSettings?.useHttp3) {
+ outbound.quic = true;
+ }
}
// SOCKS 特定配置
@@ -1620,9 +1706,66 @@ export class ProxyManager extends EventEmitter implements IProxyManager {
outbound.transport = this.generateTransportConfig(server);
}
+ // PR-6 抗封增强(ECH / TLS 分片 / Multiplex / Hy2 端口跳跃)统一后处理
+ this.applyAntiCensorshipOptions(outbound, server);
+
return outbound;
}
+ /**
+ * PR-6 抗封增强统一后处理:ECH、每节点 TLS 分片、Multiplex(reality+vision 跳过)、Hy2 端口跳跃。
+ * 放在 outbound 构建完成后统一处理,避免散落到各协议的 tls/transport 构建点。
+ */
+ private applyAntiCensorshipOptions(outbound: SingBoxOutbound, server: ServerConfig): void {
+ const protocolLower = server.protocol.toLowerCase();
+
+ // fragment 仅对「标准 sing-box TCP-TLS 栈」有意义,以下协议必须排除(否则死配置或直接启动 FATAL):
+ // · hy2/tuic:TLS 在 QUIC 内、无 TCP ClientHello(死配置);
+ // · naive:TLS 由 Cronet 自管,naive 出站直接拒绝 fragment 字段(实测
+ // "fragment is not supported on naive outbound" → 启动 FATAL),无论 h2/h3。
+ // 注:ECH 不受此限——QUIC 与 naive(Cronet) 均原生支持 ECH。
+ const fragmentUnsupported =
+ protocolLower === 'hysteria2' || protocolLower === 'tuic' || protocolLower === 'naive';
+
+ // ECH(隐藏 SNI)+ 每节点 TLS 分片(抗 SNI-DPI):需已有 tls 块
+ if (outbound.tls) {
+ if (server.tlsSettings?.ech) outbound.tls.ech = { enabled: true };
+ if (server.tlsSettings?.fragment && !fragmentUnsupported) outbound.tls.fragment = true;
+ }
+
+ // Multiplex(vless/trojan/vmess/shadowsocks);vision flow(xtls-rprx-vision) 自带流分帧、与 mux
+ // 不兼容(与是否 reality 无关,普通 TLS+vision 同样不兼容)→ 跳过
+ const mux = server.multiplexSettings;
+ const hasVisionFlow = (server.flow || '').toLowerCase().includes('vision');
+ if (
+ mux?.enabled &&
+ ['vless', 'trojan', 'vmess', 'shadowsocks'].includes(protocolLower) &&
+ !hasVisionFlow
+ ) {
+ outbound.multiplex = {
+ enabled: true,
+ protocol: mux.protocol || 'h2mux',
+ ...(mux.maxConnections ? { max_connections: mux.maxConnections } : {}),
+ ...(mux.minStreams ? { min_streams: mux.minStreams } : {}),
+ ...(mux.padding ? { padding: true } : {}),
+ };
+ }
+
+ // Hysteria2 端口跳跃(serverPorts 为逗号分隔的范围串,如 "20000:30000",支持多段)
+ if (protocolLower === 'hysteria2' && server.hysteria2Settings?.serverPorts) {
+ const ports = server.hysteria2Settings.serverPorts
+ .split(',')
+ .map((s) => s.trim())
+ .filter(Boolean);
+ if (ports.length > 0) {
+ outbound.server_ports = ports;
+ if (server.hysteria2Settings.hopInterval) {
+ outbound.hop_interval = server.hysteria2Settings.hopInterval;
+ }
+ }
+ }
+ }
+
/**
* 生成传输层配置
*/
@@ -1642,6 +1785,15 @@ export class ProxyManager extends EventEmitter implements IProxyManager {
};
}
+ // httpupgrade:较 ws 更隐蔽的 HTTP Upgrade 传输(复用 ws 的 path / Host)
+ if (server.network === 'httpupgrade') {
+ return {
+ type: 'httpupgrade',
+ path: server.wsSettings?.path || '/',
+ host: server.wsSettings?.headers?.['Host'] || server.tlsSettings?.serverName,
+ };
+ }
+
return undefined;
}
@@ -1655,9 +1807,28 @@ export class ProxyManager extends EventEmitter implements IProxyManager {
const rules: SingBoxRouteRule[] = [];
const proxyMode = (config.proxyMode || 'smart').toLowerCase();
- const selectedServerTag = idToTagMap.get(config.selectedServerId as string) || 'proxy';
- // 获取当前选中的服务器,用于排除代理服务器域名
- const selectedServer = config.servers.find((s) => s.id === config.selectedServerId);
+ // 主代理出站统一走 selector(proxy-selector):clash_api 热切换即改 selector 指向、路由无需重生成。
+ // 具体 targetServerId 的 app/custom 分流在各自逻辑里直指节点 tag,不经此变量。
+ const selectedServerTag = 'proxy-selector';
+
+ // blockQuic(节点无关):开启时对"将走代理"的 QUIC(UDP443) 执行 reject,逼浏览器回退 TCP。
+ // 「禁 QUIC」即禁 QUIC,与选中节点的协议/中继能力无关,对所有节点一视同仁。两点实测保证安全:
+ // · 节点自身的 UDP 拨号(naive-h3/hy2/tuic dial server)无害——拨号是 sing-box 进程自有 socket,
+ // 受 fwmark/auto_detect_interface 保护、绕过 route 规则;netns TUN 抓包实测:带 reject udp443
+ // 时 hy2 拨号包仍正常逸出(证伪旧假设"reject 经 strict_route 回流误杀拨号")。
+ // · 不下发"全 UDP reject"——只禁 QUIC(443)。非 QUIC 的代理向 UDP 若节点不能中继(naive/ssh/http),
+ // 由 sing-box 出站层自动拒绝(实测日志 "UDP is not supported by outbound",不漏 direct、不黑洞),
+ // 无需路由层按节点固化。这也使路由配置与选中节点解耦 → 支持 selector 跨协议无缝热切换。
+ // 节点无关:只要开了 blockQuic 且存在代理路径(非 direct 模式、有节点)就拦——不依赖 selectedServer
+ // 解析成功(避免 selectedServerId 失效但 selector default 仍出流量时 QUIC 漏过)。
+ const blockProxyQuic =
+ config.blockQuic === true && proxyMode !== 'direct' && config.servers.length > 0;
+
+ // 给定域名匹配器,返回应配对的 udp443 reject 规则(smart 模式放在每条 →代理 规则之前),否则 null。
+ const proxyUdpRejectFor = (matcher: Record): SingBoxRouteRule | null =>
+ blockProxyQuic
+ ? ({ ...matcher, network: ['udp'], port: [443], action: 'reject' } as any)
+ : null;
const versionArr = this.coreVersion.split('.');
const versionNum = parseFloat(versionArr[0] + '.' + (versionArr[1] || '0'));
@@ -1770,40 +1941,45 @@ export class ProxyManager extends EventEmitter implements IProxyManager {
action: 'reject',
} as any);
- // 排除代理服务器域名,确保代理服务器的连接走直连
- // 这必须放在其他规则之前,否则可能被 geosite-cn 匹配导致死循环
- if (selectedServer?.address) {
- const proxyHosts = [selectedServer.address];
- if (selectedServer.tlsSettings?.serverName) {
- proxyHosts.push(selectedServer.tlsSettings.serverName);
- }
- const uniqueHosts = Array.from(new Set(proxyHosts));
-
- const ips: string[] = [];
- const domains: string[] = [];
-
+ // 排除全部代理节点的域名/IP,确保到任一节点的连接走直连(防回流死循环 + 兼容无缝切换/代理链)。
+ // CDN 安全:域名节点用纯域名规则(domain + domain_suffix,靠 sniff 出的 SNI 精确匹配节点域名),
+ // 不预解析为共享 CDN IP(共享 IP 加直连会误伤同 IP 的被墙站点、且抗不住 IP 轮换);
+ // 去掉过宽的 domain_keyword(会误匹配任意"含该域名串"的无关域名)。
+ // 仅用户显式填的 IP-literal 节点用 ip_cidr 排除(专用 IP、非共享,安全)。
+ // 扩展到全部节点(不止选中):切节点 / detour 前置代理无需重生成配置即被豁免。
+ // 必须放在其他规则之前,否则可能被 geosite-cn 匹配导致死循环。
+ {
const isIpv4 = (host: string) => /^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$/.test(host);
const isIpv6 = (host: string) => /^[0-9a-fA-F:]+$/.test(host) && host.includes(':');
- uniqueHosts.forEach((host) => {
- if (isIpv4(host)) ips.push(`${host}/32`);
- else if (isIpv6(host)) ips.push(`${host}/128`);
- else domains.push(host);
- });
+ const ipSet = new Set();
+ const domainSet = new Set();
+ for (const s of config.servers) {
+ const hosts = [s.address, s.tlsSettings?.serverName].filter(
+ (h): h is string => !!h && h.length > 0
+ );
+ for (const host of hosts) {
+ if (isIpv4(host)) ipSet.add(`${host}/32`);
+ else if (isIpv6(host)) ipSet.add(`${host}/128`);
+ else domainSet.add(host);
+ }
+ }
- if (domains.length > 0) {
+ if (domainSet.size > 0) {
+ const domains = Array.from(domainSet);
rules.push({
+ // domain(精确,= 节点 SNI) + domain_suffix(仅 .${d},匹配子域)。不放裸 d 进 domain_suffix:
+ // 那是 raw 后缀匹配,会把共享 apex 下别的真实站点也沉降到直连。
domain: domains,
- domain_suffix: domains.flatMap((d) => [d, `.${d}`]),
- domain_keyword: domains,
+ domain_suffix: domains.map((d) => `.${d}`),
action: 'route',
outbound: 'direct',
});
}
- if (ips.length > 0) {
+ if (ipSet.size > 0) {
rules.push({
- ip_cidr: ips,
+ ip_cidr: Array.from(ipSet),
action: 'route',
outbound: 'direct',
});
@@ -1870,7 +2046,28 @@ export class ProxyManager extends EventEmitter implements IProxyManager {
idToTagMap,
selectedServerTag
);
- rules.push(...customRules);
+ // 走代理的自定义规则同样要配对 udp443 reject(终止规则、在末尾兜底前命中)。逐条插入:
+ // 代理向规则前先放一条同匹配器的 udp443 reject;direct/block 规则不配对。
+ for (const cr of customRules) {
+ if (
+ cr.action === 'route' &&
+ cr.outbound &&
+ cr.outbound !== 'direct' &&
+ cr.outbound !== 'block'
+ ) {
+ const matcher: Record = {};
+ if (cr.domain) matcher.domain = cr.domain;
+ if (cr.domain_suffix) matcher.domain_suffix = cr.domain_suffix;
+ if (cr.domain_keyword) matcher.domain_keyword = cr.domain_keyword;
+ if (cr.rule_set) matcher.rule_set = cr.rule_set;
+ if (cr.ip_cidr) matcher.ip_cidr = cr.ip_cidr;
+ if (Object.keys(matcher).length > 0) {
+ const r = proxyUdpRejectFor(matcher);
+ if (r) rules.push(r);
+ }
+ }
+ rules.push(cr);
+ }
if (customRuleSets.length > 0) {
if (!routeConfig.rule_set) {
@@ -1910,8 +2107,16 @@ export class ProxyManager extends EventEmitter implements IProxyManager {
outbound = 'block';
}
+ // 走代理的 app 分流也要配对 udp443 reject(这些是终止规则、在末尾兜底之前命中,否则 blockQuic
+ // 对该应用的 QUIC 失效)。direct/block 不配对。
+ const appOutIsProxy = outbound !== 'direct' && outbound !== 'block';
+
// a. 基于进程名的规则(最精准,适用于 macOS/Windows TUN 模式)
if (preset.processNames && preset.processNames.length > 0) {
+ if (appOutIsProxy) {
+ const r = proxyUdpRejectFor({ process_name: preset.processNames });
+ if (r) rules.push(r);
+ }
rules.push({
process_name: preset.processNames,
action: 'route',
@@ -1926,6 +2131,10 @@ export class ProxyManager extends EventEmitter implements IProxyManager {
];
if (ruleSets.length > 0) {
+ if (appOutIsProxy) {
+ const r = proxyUdpRejectFor({ rule_set: ruleSets });
+ if (r) rules.push(r);
+ }
rules.push({
rule_set: ruleSets,
action: 'route',
@@ -2015,13 +2224,22 @@ export class ProxyManager extends EventEmitter implements IProxyManager {
// 针对 Google 核心服务(搜索/YouTube/Gmail 等)的关键词兜底规则(仅在未专门设置应用分流时作为备份)
// 注意:这些规则在 AppRules 之后,所以不会覆盖用户手动指定的节点
+ const googleKeywords = ['google', 'gmail', 'youtube', 'gstatic', 'googleapis', 'googlevideo'];
+
+ // 代理向 UDP(smart):在每条"→代理"规则之前配对一条 reject,使该走代理的 UDP 在被路由到代理
+ // 前就 reject——不能中继的节点拦全部 UDP,能中继+blockQuic 仅拦 QUIC(UDP443)。下方 CN 直连规则
+ // 不配对,故 CN/直连 UDP 不受影响(兜底见 generateRouteConfig 末尾)。
+ const googleUdpReject = proxyUdpRejectFor({ domain_keyword: googleKeywords });
+ if (googleUdpReject) rules.push(googleUdpReject);
rules.push({
- domain_keyword: ['google', 'gmail', 'youtube', 'gstatic', 'googleapis', 'googlevideo'],
+ domain_keyword: googleKeywords,
action: 'route',
outbound: selectedServerTag,
});
// 国外域名走代理
+ const foreignUdpReject = proxyUdpRejectFor({ rule_set: 'geosite-geolocation-!cn' });
+ if (foreignUdpReject) rules.push(foreignUdpReject);
rules.push({
rule_set: 'geosite-geolocation-!cn',
action: 'route',
@@ -2120,38 +2338,11 @@ export class ProxyManager extends EventEmitter implements IProxyManager {
}
}
- // 【UDP 黑洞防范兜底】:对于不支持 UDP 的代理协议(如 SSH, HTTP, AnyTLS),
- // 凡是没有被上面的直连规则(如国内 IP/域名、局域网等)匹配的剩余 UDP 流量,
- // 原本会分配给 final 出站。但由于这些代理协议在 sing-box 中不支持 UDP,
- // 流量会穿透 final 跌落到系统 direct,导致被墙的 UDP 请求(如 QUIC)静默丢包假死 30 秒。
- // 在这里统一 reject 剩余 UDP,促使浏览器立即 fallback 到 TCP 走代理。
- if (proxyMode !== 'direct' && selectedServer) {
- const tcpOnlyProtocols = ['ssh', 'http', 'anytls'];
- if (tcpOnlyProtocols.includes(selectedServer.protocol.toLowerCase())) {
- rules.push({
- network: ['udp'],
- action: 'reject',
- } as any);
- }
-
- // T2-4: 用户开启"阻止 QUIC"时 reject 入站 UDP 443,让浏览器 QUIC 立即回退 TCP,
- // 消除节点 UDP relay 不通时的 QUIC 黑洞(网页打开一会卡死)。协议三分类:
- // - ssh/http/anytls:上面已全量 reject UDP,此处对其为冗余 no-op;
- // - hysteria2/tuic:节点自身走 QUIC dial-server,会因 strict_route 回流被这条 reject
- // 误杀,必须按协议 guard 跳过;
- // - vless/vmess/trojan/shadowsocks/naive/socks:本条真正生效对象(均 TCP dial-server;
- // naive 在 sing-box 是 H2/TLS/TCP、不支持 h3/QUIC,故安全;若将来支持 naive-h3 需更新此表)。
- const udpTransportProtocols = ['hysteria2', 'tuic'];
- if (
- config.blockQuic === true &&
- !udpTransportProtocols.includes(selectedServer.protocol.toLowerCase())
- ) {
- rules.push({
- network: ['udp'],
- port: [443],
- action: 'reject',
- } as any);
- }
+ // 【代理向 QUIC 兜底】:放在所有直连/分流规则之后,拦截"会落到 final(代理)"的剩余 QUIC(udp443)。
+ // global 模式拦全部代理向 QUIC;smart 模式拦未被上方 →代理 配对 reject 命中的(CN 已直连豁免)。
+ // 只拦 QUIC——非 QUIC 的代理向 UDP 若节点不能中继,由 sing-box 出站层自动拒绝(见上方 blockProxyQuic)。
+ if (blockProxyQuic) {
+ rules.push({ network: ['udp'], port: [443], action: 'reject' } as any);
}
return routeConfig;
@@ -2461,7 +2652,9 @@ export class ProxyManager extends EventEmitter implements IProxyManager {
if (!caps.includes('cap_net_admin')) {
this.logToManager('info', 'Linux 核心缺失网络权限,正在请求提权...');
// 使用 pkexec 调用 setcap 赋权
- execSync(`pkexec setcap 'cap_net_admin,cap_net_bind_service,cap_net_raw=+ep' "${this.singboxPath}"`);
+ execSync(
+ `pkexec setcap 'cap_net_admin,cap_net_bind_service,cap_net_raw=+ep' "${this.singboxPath}"`
+ );
this.logToManager('info', 'Linux 核心提权成功');
}
} catch (err) {
@@ -3327,6 +3520,10 @@ export class ProxyManager extends EventEmitter implements IProxyManager {
if (!this.autoRestartEnabled || !this.currentConfig) {
return false;
}
+ // 核心更新待验证窗口:禁止自动重启,使新核心首次异常退出立即上报 error → 触发回滚
+ if (this.autoRestartSuppressed) {
+ return false;
+ }
const now = Date.now();
@@ -3339,6 +3536,11 @@ export class ProxyManager extends EventEmitter implements IProxyManager {
return this.restartCount < ProxyManager.MAX_RESTART_COUNT;
}
+ /** 核心更新待验证窗口内由 CoreUpdateService 置 true:抑制自动重启,首次失败即上报触发回滚。 */
+ setAutoRestartSuppressed(suppressed: boolean): void {
+ this.autoRestartSuppressed = suppressed;
+ }
+
/**
* 尝试自动重启
*/
diff --git a/src/main/services/ResourceManager.ts b/src/main/services/ResourceManager.ts
index dd962e96..36a4ae16 100644
--- a/src/main/services/ResourceManager.ts
+++ b/src/main/services/ResourceManager.ts
@@ -240,9 +240,11 @@ export class ResourceManager {
const userDataPath = app.getPath('userData');
const updateDir = path.join(userDataPath, 'core_update');
const targetPath = path.join(updateDir, 'sing-box');
-
+
// 检查是否已经有可写核心
if (await this.fileExists(targetPath)) {
+ // 已有可写核心:仍需确保 libcronet 在旁(naive 出站靠 purego 同目录/系统库路径加载)
+ await this.ensureCronetBeside(updateDir);
return targetPath;
}
@@ -258,9 +260,54 @@ export class ResourceManager {
await fs.chmod(targetPath, 0o755); // 赋予可执行权限
}
+ // naive 节点需要 libcronet 与 sing-box 同目录(purego 加载),随核心一并放过去
+ await this.ensureCronetBeside(updateDir);
+
return targetPath;
}
+ /** 各平台 NaiveProxy 核心库文件名(purego 期望的名字) */
+ getCronetLibFilename(): string {
+ if (this.platform === 'win32') return 'libcronet.dll';
+ if (this.platform === 'darwin') return 'libcronet.dylib';
+ return 'libcronet.so';
+ }
+
+ /** 内置的 libcronet 是否存在(用于 naive 可用性判断) */
+ hasCronetLib(): boolean {
+ try {
+ // macOS:cronet 由 sing-box 二进制静态编入(CGO,无 .dylib)。打包的 mac-arm64 与 mac-x64
+ // 核心(均 ≥1.13.13,with_naive_outbound)都含静态 cronet → naive 两 arch 皆可用、无需外部库。
+ if (this.platform === 'darwin') {
+ return true;
+ }
+ // linux/windows:cronet 走 dlopen 动态加载。检查 libcronet 是否在 sing-box 实际加载目录
+ // (purego 从二进制同目录加载):Linux/便携=可写核心目录(已由 ensureCronetBeside 拷入),
+ // 非便携 win=内置 resources 目录。不查内置兜底——否则 beside 拷贝失败仍误判"可用"→ FATAL。
+ const coreDir = path.dirname(this.getSingBoxPath());
+ return require('fs').existsSync(path.join(coreDir, this.getCronetLibFilename()));
+ } catch {
+ return false;
+ }
+ }
+
+ /**
+ * 把内置的 libcronet 复制到与(可写/已更新)核心同一目录,供 naive 出站(purego) 加载。
+ * 供 ensureWritableCore 与核心更新写盘后调用。内置无 libcronet 或已存在则跳过。
+ */
+ async ensureCronetBeside(coreDir: string): Promise {
+ const name = this.getCronetLibFilename();
+ const src = path.join(this.getPlatformResourceDir(), name);
+ const dst = path.join(coreDir, name);
+ try {
+ if ((await this.fileExists(src)) && !(await this.fileExists(dst))) {
+ await fs.copyFile(src, dst);
+ }
+ } catch {
+ // 复制失败不应阻断启动;naive 不可用时 sing-box 会自报 cronet 错误
+ }
+ }
+
/**
* 获取资源信息(用于调试)
*/
diff --git a/src/main/services/SubscriptionService.ts b/src/main/services/SubscriptionService.ts
index 6b2e8400..ff84da37 100644
--- a/src/main/services/SubscriptionService.ts
+++ b/src/main/services/SubscriptionService.ts
@@ -21,27 +21,55 @@ type SingboxTls = {
alpn?: string[];
utls?: { enabled?: boolean; fingerprint?: string };
reality?: { enabled?: boolean; public_key?: string; short_id?: string };
+ ech?: { enabled?: boolean };
+ fragment?: boolean;
};
type SingboxTransport = {
type?: string;
path?: string;
+ host?: string;
headers?: Record;
service_name?: string;
};
+type SingboxMultiplex = {
+ enabled?: boolean;
+ protocol?: string;
+ max_connections?: number;
+ min_streams?: number;
+ padding?: boolean;
+};
type SingboxOutbound = {
type: string;
tag: string;
server?: string;
server_port?: number;
+ server_ports?: string[];
+ hop_interval?: string;
uuid?: string;
flow?: string;
+ username?: string;
password?: string;
method?: string;
plugin?: string;
plugin_opts?: string;
obfs?: { type?: string; password?: string };
+ // naive:是否启用 HTTP/3 (QUIC) 传输
+ quic?: boolean;
+ // vmess:security=加密方式(auto/none/aes-128-gcm/...),alter_id=0 走 AEAD
+ alter_id?: number;
+ security?: string;
+ // tuic
+ congestion_control?: string;
+ udp_relay_mode?: string;
+ zero_rtt_handshake?: boolean;
+ heartbeat?: string;
+ // anytls
+ idle_session_check_interval?: string;
+ idle_session_timeout?: string;
+ min_idle_session?: number;
tls?: SingboxTls;
transport?: SingboxTransport;
+ multiplex?: SingboxMultiplex;
};
export class SubscriptionService {
@@ -75,26 +103,40 @@ export class SubscriptionService {
/**
* 将 sing-box outbounds 数组转换为 ServerConfig 列表
- * 支持: shadowsocks, vless, trojan, hysteria2
+ * 支持: shadowsocks, vless, trojan, hysteria2, naive, vmess, tuic, anytls, socks
*/
private parseSingboxOutbounds(
outbounds: SingboxOutbound[],
subscriptionId: string
): ServerConfig[] {
- const SUPPORTED = new Set(['shadowsocks', 'vless', 'trojan', 'hysteria2']);
+ const SUPPORTED = new Set([
+ 'shadowsocks',
+ 'vless',
+ 'trojan',
+ 'hysteria2',
+ 'naive',
+ 'vmess',
+ 'tuic',
+ 'anytls',
+ 'socks',
+ ]);
const servers: ServerConfig[] = [];
const now = new Date().toISOString();
for (const ob of outbounds) {
if (!SUPPORTED.has(ob.type)) continue;
- if (!ob.server || !ob.server_port) continue;
+ // 支持仅含 server_ports(端口跳跃、无 server_port)的 Hy2 节点:从首个范围的低位端口推导 port
+ const effectivePort =
+ ob.server_port ??
+ (ob.server_ports?.[0] ? parseInt(ob.server_ports[0].split(':')[0], 10) : undefined);
+ if (!ob.server || !effectivePort) continue;
try {
const base: Partial = {
id: randomUUID(),
- name: ob.tag || `${ob.server}:${ob.server_port}`,
+ name: ob.tag || `${ob.server}:${effectivePort}`,
address: ob.server,
- port: ob.server_port,
+ port: effectivePort,
subscriptionId,
createdAt: now,
updatedAt: now,
@@ -109,6 +151,8 @@ export class SubscriptionService {
allowInsecure: ob.tls.insecure ?? false,
alpn: ob.tls.alpn,
fingerprint: ob.tls.utls?.fingerprint,
+ ech: ob.tls.ech?.enabled === true ? true : undefined,
+ fragment: ob.tls.fragment === true ? true : undefined,
};
if (hasReality && ob.tls.reality) {
base.realitySettings = {
@@ -121,7 +165,7 @@ export class SubscriptionService {
// Transport
if (ob.transport?.type) {
const t = ob.transport;
- const netType = t.type as 'ws' | 'grpc' | 'http' | 'tcp';
+ const netType = t.type as 'ws' | 'grpc' | 'http' | 'httpupgrade' | 'tcp';
base.network = netType;
if (netType === 'ws') {
base.wsSettings = { path: t.path, headers: t.headers };
@@ -129,9 +173,23 @@ export class SubscriptionService {
base.grpcSettings = { serviceName: t.service_name };
} else if (netType === 'http') {
base.httpSettings = { path: t.path };
+ } else if (netType === 'httpupgrade') {
+ // httpupgrade 复用 ws 设置承载 path/Host
+ base.wsSettings = { path: t.path, headers: t.host ? { Host: t.host } : t.headers };
}
}
+ // Multiplex(vless/trojan/vmess/ss)
+ if (ob.multiplex?.enabled) {
+ base.multiplexSettings = {
+ enabled: true,
+ protocol: (ob.multiplex.protocol as 'smux' | 'yamux' | 'h2mux') || 'h2mux',
+ maxConnections: ob.multiplex.max_connections,
+ minStreams: ob.multiplex.min_streams,
+ padding: ob.multiplex.padding,
+ };
+ }
+
// Protocol-specific
if (ob.type === 'shadowsocks') {
servers.push({
@@ -164,12 +222,88 @@ export class SubscriptionService {
password: ob.password ?? '',
security: 'tls',
};
+ const hy2Settings: NonNullable = {};
if (ob.obfs?.type === 'salamander' && ob.obfs.password) {
- hy2.hysteria2Settings = {
- obfs: { type: 'salamander', password: ob.obfs.password },
- };
+ hy2Settings.obfs = { type: 'salamander', password: ob.obfs.password };
+ }
+ // 端口跳跃:server_ports 形如 ["20000:30000"],存为逗号分隔字符串
+ if (ob.server_ports && ob.server_ports.length > 0) {
+ hy2Settings.serverPorts = ob.server_ports.join(',');
+ if (ob.hop_interval) hy2Settings.hopInterval = ob.hop_interval;
+ }
+ if (Object.keys(hy2Settings).length > 0) {
+ hy2.hysteria2Settings = hy2Settings;
}
servers.push(hy2);
+ } else if (ob.type === 'naive') {
+ // sing-box naive 的 quic:true 表示走 HTTP/3 (QUIC) 传输(h3 节点)
+ servers.push({
+ ...(base as ServerConfig),
+ protocol: 'naive',
+ username: ob.username ?? '',
+ password: ob.password ?? '',
+ naiveSettings: ob.quic ? { useHttp3: true } : undefined,
+ });
+ } else if (ob.type === 'vmess') {
+ servers.push({
+ ...(base as ServerConfig),
+ protocol: 'vmess',
+ uuid: ob.uuid ?? '',
+ alterId: ob.alter_id ?? 0,
+ vmessSecurity: ob.security || 'auto',
+ });
+ } else if (ob.type === 'tuic') {
+ const tuicSettings: NonNullable = {};
+ if (
+ ob.congestion_control === 'bbr' ||
+ ob.congestion_control === 'cubic' ||
+ ob.congestion_control === 'new_reno'
+ ) {
+ tuicSettings.congestionControl = ob.congestion_control;
+ }
+ if (ob.udp_relay_mode === 'native' || ob.udp_relay_mode === 'quic') {
+ tuicSettings.udpRelayMode = ob.udp_relay_mode;
+ }
+ if (ob.zero_rtt_handshake !== undefined) {
+ tuicSettings.zeroRttHandshake = ob.zero_rtt_handshake;
+ }
+ if (ob.heartbeat) tuicSettings.heartbeat = ob.heartbeat;
+ servers.push({
+ ...(base as ServerConfig),
+ protocol: 'tuic',
+ uuid: ob.uuid ?? '',
+ password: ob.password ?? '',
+ security: 'tls',
+ tuicSettings: Object.keys(tuicSettings).length > 0 ? tuicSettings : undefined,
+ });
+ } else if (ob.type === 'anytls') {
+ const anyTlsSettings: NonNullable = {};
+ if (ob.idle_session_check_interval) {
+ anyTlsSettings.idleSessionCheckInterval = ob.idle_session_check_interval;
+ }
+ if (ob.idle_session_timeout) {
+ anyTlsSettings.idleSessionTimeout = ob.idle_session_timeout;
+ }
+ if (ob.min_idle_session !== undefined) {
+ anyTlsSettings.minIdleSession = ob.min_idle_session;
+ }
+ servers.push({
+ ...(base as ServerConfig),
+ protocol: 'anytls',
+ password: ob.password ?? '',
+ security: 'tls',
+ anyTlsSettings: Object.keys(anyTlsSettings).length > 0 ? anyTlsSettings : undefined,
+ });
+ } else if (ob.type === 'socks') {
+ // socks 无 TLS:覆盖 base 可能因 transport 推断的 network,固定为 tcp/none
+ servers.push({
+ ...(base as ServerConfig),
+ protocol: 'socks',
+ username: ob.username,
+ password: ob.password,
+ network: 'tcp',
+ security: 'none',
+ });
}
} catch (e: any) {
this.logManager.addLog(
diff --git a/src/renderer/components/settings/advanced-settings.tsx b/src/renderer/components/settings/advanced-settings.tsx
index 2b064327..72d60385 100644
--- a/src/renderer/components/settings/advanced-settings.tsx
+++ b/src/renderer/components/settings/advanced-settings.tsx
@@ -1,4 +1,4 @@
-import { useState, useMemo } from 'react';
+import { useState } from 'react';
import { Card, CardContent } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
@@ -22,14 +22,6 @@ export function AdvancedSettings() {
const [isLoading, setIsLoading] = useState(false);
const { t } = useTranslation();
- // 当前选中节点为 QUIC 协议(hy2/tuic)时,"阻止 QUIC"会误杀其传输,禁用该开关
- const isQuicNode = useMemo(() => {
- const selectedProtocol = config?.servers
- ?.find((s) => s.id === config?.selectedServerId)
- ?.protocol?.toLowerCase();
- return selectedProtocol === 'hysteria2' || selectedProtocol === 'tuic';
- }, [config?.selectedServerId, config?.servers]);
-
const handleSavePorts = async () => {
if (!config) return;
@@ -311,7 +303,6 @@ export function AdvancedSettings() {
{
const updatedConfig = { ...config, blockQuic: checked as boolean };
saveConfig(updatedConfig);
@@ -325,6 +316,43 @@ export function AdvancedSettings() {
{t('settings.advanced.blockQuicDesc')}
+
+ {
+ const updatedConfig = {
+ ...config,
+ interruptConnectionsOnSwitch: checked as boolean,
+ };
+ saveConfig(updatedConfig);
+ }}
+ />
+
+
+
+ {t('settings.advanced.interruptOnSwitchDesc')}
+
+
+
+ {
+ const updatedConfig = { ...config, tlsFragment: checked as boolean };
+ saveConfig(updatedConfig);
+ }}
+ />
+
+
+
+ {t('settings.advanced.tlsFragmentDesc')}
+
+
{/* 自动换节点 */}
+
+ {/* 核心更新:仅在兼容版本带内自动更新 */}
+
+ {
+ saveConfig({
+ ...config,
+ restrictCoreUpdateToCompatibleMinor: checked as boolean,
+ });
+ }}
+ />
+
+
+
+ {t('settings.advanced.restrictCoreUpdateDesc' as any) ||
+ '仅自动更新到与当前配置生成器兼容的 sing-box 版本带(如 1.13.x);跨版本带(如 1.14)不自动更新、转为提示随 App 升级,避免配置不兼容。手动更新不受此限制。'}
+
diff --git a/src/renderer/components/settings/naive-form.tsx b/src/renderer/components/settings/naive-form.tsx
index 22ab5efa..606271ae 100644
--- a/src/renderer/components/settings/naive-form.tsx
+++ b/src/renderer/components/settings/naive-form.tsx
@@ -14,6 +14,7 @@ import {
FormDescription,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
+import { Switch } from '@/components/ui/switch';
import {
Select,
SelectContent,
@@ -32,6 +33,7 @@ const createNaiveSchema = (t: any) =>
password: z.string().min(1, t('servers.passwordRequired')),
tlsServerName: z.string().optional(),
tlsFingerprint: z.string().optional(),
+ useHttp3: z.boolean().optional(),
});
type NaiveFormValues = z.infer>;
@@ -54,6 +56,7 @@ export function NaiveForm({ serverConfig, onSubmit }: NaiveFormProps) {
password: serverConfig.password || '',
tlsServerName: serverConfig.tlsSettings?.serverName || '',
tlsFingerprint: serverConfig.tlsSettings?.fingerprint || 'none',
+ useHttp3: serverConfig.naiveSettings?.useHttp3 ?? false,
};
}
return {
@@ -63,6 +66,7 @@ export function NaiveForm({ serverConfig, onSubmit }: NaiveFormProps) {
password: '',
tlsServerName: '',
tlsFingerprint: 'none',
+ useHttp3: false,
};
};
@@ -85,6 +89,7 @@ export function NaiveForm({ serverConfig, onSubmit }: NaiveFormProps) {
allowInsecure: false,
fingerprint: values.tlsFingerprint || 'none',
},
+ naiveSettings: values.useHttp3 ? { useHttp3: true } : undefined,
};
await onSubmit(config);
};
@@ -219,6 +224,24 @@ export function NaiveForm({ serverConfig, onSubmit }: NaiveFormProps) {
)}
/>
+
+ (
+
+
+
+ {t('servers.naive.useHttp3', 'HTTP/3 (QUIC)')}
+
+ {t('servers.naive.useHttp3Desc')}
+
+
+
+
+
+ )}
+ />
diff --git a/src/renderer/i18n/locales/en-US.json b/src/renderer/i18n/locales/en-US.json
index 775500b0..8e3b1f6d 100644
--- a/src/renderer/i18n/locales/en-US.json
+++ b/src/renderer/i18n/locales/en-US.json
@@ -234,7 +234,13 @@
"bypassLAN": "Bypass LAN",
"bypassLANDesc": "Force requests destined for the local network to bypass the proxy (disable to allow proxying for 192.168.x etc.)",
"blockQuic": "Block QUIC (force TCP)",
- "blockQuicDesc": "Reject QUIC (UDP 443) so browsers fall back to TCP. Fixes pages stalling when the node's UDP relay is unreliable. Off by default; not applicable to Hysteria2/TUIC nodes.",
+ "blockQuicDesc": "Reject proxy-bound QUIC (UDP 443) so browsers fall back to TCP. Fixes pages stalling when a node's UDP relay is unreliable. Off by default; applies to all nodes.",
+ "interruptOnSwitch": "Interrupt existing connections on switch",
+ "interruptOnSwitchDesc": "Default is graceful switching: switching nodes does not restart the proxy and existing connections persist until they close naturally; new connections use the new node. When enabled, switching forcibly drops existing connections and rebuilds them on the new node immediately.",
+ "tlsFragment": "TLS fragmentation (anti-SNI blocking)",
+ "tlsFragmentDesc": "Splits the TLS ClientHello across multiple TCP packets so the SNI is fragmented, evading SNI-keyword DPI blocking. Off by default; may slightly increase handshake latency on some networks.",
+ "restrictCoreUpdate": "Auto-update core only within compatible version band",
+ "restrictCoreUpdateDesc": "Only auto-update sing-box to versions compatible with the current config generator (e.g. 1.13.x). Updates crossing a version band (e.g. 1.14) are not applied automatically and prompt an app upgrade instead, avoiding config incompatibility. Manual updates are not restricted.",
"terminalProxy": "Terminal Proxy Settings",
"terminalProxyDesc": "Copy the following commands to set proxy in terminal (proxy must be started first)",
"gitProxy": "Git Proxy Settings",
@@ -519,7 +525,9 @@
"alpnDesc": "Application-Layer Protocol Negotiation, e.g. h3",
"naive": {
"versionWarning": "⚠️ Naive protocol requires sing-box v1.13+ core. If you are using v1.12, please go to 'Settings -> About' to update your core.",
- "goUpdate": "Update Core"
+ "goUpdate": "Update Core",
+ "useHttp3": "HTTP/3 (QUIC)",
+ "useHttp3Desc": "This node dials over QUIC/UDP (server must listen on quic://, with HTTP/3 enabled). Its own QUIC dial is protected and not killed by \"Block QUIC\"; browser QUIC to this node is still handled per the \"Block QUIC\" setting."
}
},
"rules": {
diff --git a/src/renderer/i18n/locales/zh-CN.json b/src/renderer/i18n/locales/zh-CN.json
index 3c21ad9a..b5faf894 100644
--- a/src/renderer/i18n/locales/zh-CN.json
+++ b/src/renderer/i18n/locales/zh-CN.json
@@ -233,7 +233,13 @@
"bypassLAN": "绕过局域网 (Bypass LAN)",
"bypassLANDesc": "默认将发往局域网的请求强制直连(关闭此选项后,192.168.x 等内网 IP 才可能走代理)",
"blockQuic": "阻止 QUIC(强制 TCP)",
- "blockQuicDesc": "reject QUIC(UDP 443),让浏览器回退 TCP,解决节点 UDP relay 不通导致的网页卡顿/断流。默认关;对 Hysteria2/TUIC 节点不适用。",
+ "blockQuicDesc": "reject 代理向 QUIC(UDP 443),让浏览器回退 TCP,解决节点 UDP relay 不通导致的网页卡顿/断流。默认关;对所有节点生效。",
+ "interruptOnSwitch": "切换节点时断开现有连接",
+ "interruptOnSwitchDesc": "默认优雅切换:切换节点不重启代理、现有连接保留至自然关闭,新连接走新节点。开启后切换时强制断开现有连接、立即在新节点重建。",
+ "tlsFragment": "TLS 分片(抗 SNI 封锁)",
+ "tlsFragmentDesc": "对所有 TLS 节点切分 ClientHello,使 SNI 跨多个 TCP 包、规避基于 SNI 关键词的 DPI 阻断。默认关;个别网络下可能轻微增加握手延迟。",
+ "restrictCoreUpdate": "仅在兼容版本带内自动更新内核",
+ "restrictCoreUpdateDesc": "仅自动更新到与当前配置生成器兼容的 sing-box 版本带(如 1.13.x);跨版本带(如 1.14)不自动更新、转为提示随 App 升级,避免配置不兼容。手动更新不受此限制。",
"terminalProxy": "终端代理设置",
"terminalProxyDesc": "复制以下命令到终端中设置代理(需要先启动代理)",
"gitProxy": "Git 代理设置",
@@ -518,7 +524,9 @@
"alpnDesc": "应用层协议协商,例如 h3",
"naive": {
"versionWarning": "⚠️ Naive 协议需要 sing-box v1.13+ 核心支持。如果您当前使用的是 v1.12 核心,请前往“设置 -> 关于”点击更新核心。",
- "goUpdate": "去更新核心"
+ "goUpdate": "去更新核心",
+ "useHttp3": "HTTP/3 (QUIC)",
+ "useHttp3Desc": "该节点用 QUIC/UDP 拨号(对应服务端 quic:// 监听,需服务端开启 HTTP/3)。其自身的 QUIC 拨号受保护、不被“阻止 QUIC”误杀;浏览器到该节点的 QUIC 仍按“阻止 QUIC”设置处理。"
}
},
"rules": {
diff --git a/src/shared/types.ts b/src/shared/types.ts
index 8847ec0e..04db2f64 100644
--- a/src/shared/types.ts
+++ b/src/shared/types.ts
@@ -21,7 +21,7 @@ export type Protocol =
| 'socks'
| 'http'
| 'ssh';
-export type Network = 'tcp' | 'ws' | 'grpc' | 'http';
+export type Network = 'tcp' | 'ws' | 'grpc' | 'http' | 'httpupgrade';
export type Hysteria2Network = 'tcp' | 'udp';
export type Security = 'none' | 'tls' | 'reality';
export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'fatal';
@@ -37,6 +37,8 @@ export interface TlsSettings {
allowInsecure?: boolean;
alpn?: string[];
fingerprint?: string;
+ ech?: boolean; // Encrypted Client Hello(隐藏 SNI);sing-box tls.ech.enabled
+ fragment?: boolean; // TLS ClientHello 分片,抗 SNI-DPI;sing-box tls.fragment
}
export interface RealitySettings {
@@ -75,6 +77,17 @@ export interface Hysteria2Settings {
downMbps?: number;
obfs?: Hysteria2ObfsSettings;
network?: Hysteria2Network;
+ serverPorts?: string; // 端口跳跃范围,如 "20000:30000";sing-box server_ports
+ hopInterval?: string; // 端口跳跃间隔,如 "30s";sing-box hop_interval
+}
+
+// Multiplex 多路复用设置(vless/trojan/vmess/shadowsocks);注意 reality+vision(xtls-rprx-vision) 不兼容
+export interface MultiplexSettings {
+ enabled?: boolean;
+ protocol?: 'smux' | 'yamux' | 'h2mux'; // 默认 h2mux
+ maxConnections?: number;
+ minStreams?: number;
+ padding?: boolean; // 流量填充,增强抗特征
}
// TUIC 协议设置
@@ -85,6 +98,11 @@ export interface TuicSettings {
heartbeat?: string;
}
+// Naive 协议设置
+export interface NaiveSettings {
+ useHttp3?: boolean; // 使用 HTTP/3 (QUIC) 传输;sing-box naive outbound 的 quic 字段
+}
+
// Shadowsocks 协议设置
export interface ShadowsocksSettings {
method: string;
@@ -163,6 +181,7 @@ export interface ServerConfig {
// Naive 特定
username?: string;
+ naiveSettings?: NaiveSettings;
// VMess 特定
alterId?: number;
@@ -177,6 +196,9 @@ export interface ServerConfig {
// AnyTLS 特定
anyTlsSettings?: AnyTlsSettings;
+ // Multiplex 多路复用(vless/trojan/vmess/ss;reality+vision 不兼容,生成侧 guard)
+ multiplexSettings?: MultiplexSettings;
+
// Shadowsocks 特定
shadowsocksSettings?: ShadowsocksSettings;
@@ -313,6 +335,7 @@ export interface UserConfig {
autoPrivacyMode?: boolean; // 自动进入隐私模式
privacyPassword?: string; // 隐私模式解锁密码
autoSwitchNode?: boolean; // 节点故障时自动切换到可用节点
+ interruptConnectionsOnSwitch?: boolean; // 切换节点时中断现有连接、强制在新节点重建(默认 false=优雅切换,现有连接保留至自然关闭)
// 窗口尺寸(仅在 rememberWindowSize 启用时使用)
windowBounds?: { width: number; height: number };
@@ -335,7 +358,11 @@ export interface UserConfig {
mixedPort?: number; // 混合端口(可选,同时支持 HTTP 和 SOCKS5,0 或 undefined 表示禁用)
allowLan?: boolean; // 局域网共享代理(允许其他设备连接)
bypassLAN?: boolean; // 绕过局域网(将内网 IP 设置为直连)
- blockQuic?: boolean; // 阻止 QUIC(reject 入站 UDP 443 强制浏览器回退 TCP);默认关;hy2/tuic 节点自动跳过
+ blockQuic?: boolean; // 阻止 QUIC(对代理向 UDP 443 执行 reject,逼浏览器回退 TCP);默认关;节点无关,对所有协议一视同仁
+ tlsFragment?: boolean; // 全局 TLS 分片:对所有 TLS 节点切分 ClientHello 抗 SNI-DPI;默认关
+ // 核心更新:仅在配置生成器已验证的 sing-box minor 版本带内自动更新(默认 true)。关闭后允许自动
+ // 更新跨越 minor(如 1.13→1.14),但跨 minor 的 schema 变更可能导致配置不兼容、需手动处理。
+ restrictCoreUpdateToCompatibleMinor?: boolean;
bypassProcesses?: string[]; // 排除进程(指定进程直连,不走代理)
// 日志设置