diff --git a/README.md b/README.md index 0dfda7f..a13a3f9 100644 --- a/README.md +++ b/README.md @@ -20,22 +20,26 @@ vpn-rules/ ## 为什么需要它 - 香港代理节点有流量配额(如 BygCloud 100GiB 用满),国内站点/内网流量被隧道进代理会白白烧配额。 -- 必须直连的对象:FRP 控制面(mTLS)、B 站全系、DeepSeek/MiniMax 等国内 LLM API、 +- 必须直连的对象:Windows OneDrive 客户端、FRP 控制面(mTLS)、B 站全系、DeepSeek/MiniMax 等国内 LLM API、 内网/私有 IP 段、国区 iCloud、UGREEN 绿联 NAS 云。 - **踩坑**:Clash Verge 全局 `Merge.yaml` 的 `prepend-rules` 会被内核忽略(不生效), 全局直连规则的唯一正确机制是 `profiles/Script.js`(`main(config)` 把规则 prepend 进 `config.rules`)。 ## 消费端接入 -### Clash Verge(macOS) +### Clash Verge(Windows / macOS) ```bash -# 生成并写入 Clash Verge 数据目录的 Script.js +# 按当前平台生成并写入 Clash Verge 数据目录的全局 Script.js python3 scripts/gen_clash_verge_script.py # 然后在 Clash Verge 界面点「重启内核」生效 ``` 对当前及以后新增/切换的所有订阅生效;订阅更新不会覆盖它。 +脚本还会把国内可达的阿里 DNS 和腾讯 DNS DoH 端点补入 `dns.fallback`,避免境外域名 +命中 `DIRECT` 后因供应商的境外 DNS fallback 不可达而解析失败。 +Windows 默认写入 `%APPDATA%\io.github.clash-verge-rev.clash-verge-rev\profiles\Script.js`; +macOS 默认写入 `~/Library/Application Support/io.github.clash-verge-rev.clash-verge-rev/profiles/Script.js`。 ### 路由器 mihomo(Magic Catling2 / OpenWrt) diff --git a/direct.yaml b/direct.yaml index 52ecf5b..b4e8c7b 100644 --- a/direct.yaml +++ b/direct.yaml @@ -20,6 +20,9 @@ # 路由器端则等 rule-provider 的 interval 到期或手动 reload。 payload: + # Windows OneDrive 客户端始终直连,避免被供应商 Microsoft 规则送入代理 + - PROCESS-NAME,OneDrive.exe + # FRP 控制面(mTLS)保持直连 - DOMAIN,frp.puyuanfh.cn - IP-CIDR,8.133.177.3/32,no-resolve diff --git a/router/direct-plain.txt b/router/direct-plain.txt index e4005a6..a1a0a2e 100644 --- a/router/direct-plain.txt +++ b/router/direct-plain.txt @@ -1,3 +1,4 @@ +PROCESS-NAME,OneDrive.exe DOMAIN,frp.puyuanfh.cn IP-CIDR,8.133.177.3/32,no-resolve DOMAIN-SUFFIX,bilibili.com diff --git a/router/direct-rules.yaml b/router/direct-rules.yaml index 4f20ea7..8b6c273 100644 --- a/router/direct-rules.yaml +++ b/router/direct-rules.yaml @@ -11,6 +11,7 @@ # 内网源:http://192.168.50.2:3000/ageorge156/vpn-rules/raw/branch/main/direct.yaml rules: + - PROCESS-NAME,OneDrive.exe,DIRECT - DOMAIN,frp.puyuanfh.cn,DIRECT - IP-CIDR,8.133.177.3/32,DIRECT,no-resolve - DOMAIN-SUFFIX,bilibili.com,DIRECT diff --git a/scripts/gen_clash_verge_script.py b/scripts/gen_clash_verge_script.py index 122853f..dd1f4d4 100644 --- a/scripts/gen_clash_verge_script.py +++ b/scripts/gen_clash_verge_script.py @@ -4,14 +4,39 @@ 用法: python3 scripts/gen_clash_verge_script.py [--out 目标路径] -默认输出到 Clash Verge 数据目录 profiles/Script.js(全局直连规则的唯一正确机制, -见 clash-verge-config 记忆)。生成后需在 Clash Verge 界面点「重启内核」生效。 +默认按 Windows/macOS 平台输出到 Clash Verge 数据目录 profiles/Script.js(全局直连 +规则的唯一正确机制)。生成后需在 Clash Verge 界面点「重启内核」生效。 """ import argparse +import os from pathlib import Path +import sys REPO_ROOT = Path(__file__).resolve().parent.parent -DEFAULT_OUT = Path.home() / "Library/Application Support/io.github.clash-verge-rev.clash-verge-rev/profiles/Script.js" +APP_ID = "io.github.clash-verge-rev.clash-verge-rev" + + +def default_output_path( + platform_name: str = sys.platform, + *, + home: Path | None = None, + appdata: Path | None = None, +) -> Path: + """返回当前平台的 Clash Verge 全局 Script.js 路径。""" + home = home or Path.home() + if platform_name == "win32": + if appdata is None: + roaming = os.environ.get("APPDATA") + if not roaming: + raise SystemExit("[error] Windows 环境缺少 APPDATA,无法定位 Clash Verge 数据目录") + appdata = Path(roaming) + return appdata / APP_ID / "profiles" / "Script.js" + if platform_name == "darwin": + return home / "Library/Application Support" / APP_ID / "profiles" / "Script.js" + return home / ".local/share" / APP_ID / "profiles" / "Script.js" + + +DEFAULT_OUT = default_output_path() def load_payload(path: Path) -> list[str]: @@ -22,7 +47,7 @@ def load_payload(path: Path) -> list[str]: if line.startswith("- "): payload.append(line[2:].strip()) if not payload: - raise SystemExit(f"❌ direct.yaml 里没有 payload 条目: {path}") + raise SystemExit(f"[error] direct.yaml 里没有 payload 条目: {path}") return payload @@ -37,19 +62,37 @@ def render_script(payload: list[str]) -> str: rules = [f" {to_rule(e)!r}," for e in payload] lines = "\n".join(rules) return f"""// 由 vpn-rules/direct.yaml 生成 — 勿手改,改真源后重跑 gen_clash_verge_script.py。 -// 全局直连规则:FRP 控制面、B站、国内 LLM、内网段、国区 iCloud、UGREEN 一律 DIRECT。 +// 全局直连规则:OneDrive、FRP 控制面、B站、国内 LLM、内网段、国区 iCloud、UGREEN 一律 DIRECT。 const prependRules = [ {lines} ]; +// 国内可达的 DoH 同时作为 fallback,确保 OneDrive 等境外域名在 DIRECT 出站前可解析。 +const directDnsFallbacks = [ + 'https://dns.alidns.com/dns-query', + 'https://doh.pub/dns-query', +]; + function main(config, profileName) {{ const existingRules = Array.isArray(config.rules) ? config.rules : []; const existingRuleSet = new Set(existingRules); + const dns = config.dns && typeof config.dns === 'object' && !Array.isArray(config.dns) + ? config.dns + : {{}}; + const existingFallbacks = Array.isArray(dns.fallback) + ? dns.fallback + : (typeof dns.fallback === 'string' ? [dns.fallback] : []); + const existingFallbackSet = new Set(existingFallbacks); config.rules = [ ...prependRules.filter((rule) => !existingRuleSet.has(rule)), ...existingRules, ]; + dns.fallback = [ + ...directDnsFallbacks.filter((server) => !existingFallbackSet.has(server)), + ...existingFallbacks, + ]; + config.dns = dns; return config; }} @@ -66,7 +109,7 @@ def main() -> None: out = Path(args.out) out.parent.mkdir(parents=True, exist_ok=True) out.write_text(script, encoding="utf-8") - print(f"✅ 已生成 {out}({len(payload)} 条规则)") + print(f"[ok] 已生成 {out}({len(payload)} 条规则)") print(" 请在 Clash Verge 界面点「重启内核」生效。") diff --git a/tests/test_module_spec.py b/tests/test_module_spec.py index d82cec2..270278d 100644 --- a/tests/test_module_spec.py +++ b/tests/test_module_spec.py @@ -1,7 +1,10 @@ from __future__ import annotations import json +import os +import subprocess import sys +import tempfile import unittest from pathlib import Path, PurePosixPath @@ -10,10 +13,60 @@ ROOT = Path(__file__).resolve().parents[1] SPEC_PATH = ROOT / "MODULE_SPEC.yaml" sys.path.insert(0, str(ROOT / "scripts")) -from gen_clash_verge_script import load_payload, to_rule # noqa: E402 +from gen_clash_verge_script import ( # noqa: E402 + default_output_path, + load_payload, + render_script, + to_rule, +) class ModuleSpecTests(unittest.TestCase): + def test_clash_verge_default_output_path_is_platform_aware(self) -> None: + home = Path("C:/Users/example") + appdata = Path("C:/Users/example/AppData/Roaming") + + self.assertEqual( + default_output_path("win32", home=home, appdata=appdata), + appdata + / "io.github.clash-verge-rev.clash-verge-rev" + / "profiles" + / "Script.js", + ) + self.assertEqual( + default_output_path("darwin", home=home), + home + / "Library/Application Support" + / "io.github.clash-verge-rev.clash-verge-rev" + / "profiles" + / "Script.js", + ) + + def test_clash_verge_generator_supports_windows_gbk_console(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + output = Path(temp_dir) / "Script.js" + env = os.environ.copy() + env["PYTHONIOENCODING"] = "gbk" + result = subprocess.run( + [ + sys.executable, + str(ROOT / "scripts/gen_clash_verge_script.py"), + "--out", + str(output), + ], + cwd=ROOT, + env=env, + capture_output=True, + check=False, + ) + + self.assertEqual( + result.returncode, + 0, + result.stderr.decode("gbk", errors="replace"), + ) + self.assertTrue(output.is_file()) + def test_module_spec_declares_rule_authority_and_safe_context(self) -> None: spec = json.loads(SPEC_PATH.read_text(encoding="utf-8")) @@ -141,6 +194,23 @@ class ModuleSpecTests(unittest.TestCase): ) self.assertTrue(all(",DIRECT" not in entry for entry in payload)) + def test_onedrive_process_is_forced_to_use_direct_routing(self) -> None: + payload = load_payload(ROOT / "direct.yaml") + + self.assertIn("PROCESS-NAME,OneDrive.exe", payload) + self.assertEqual( + to_rule("PROCESS-NAME,OneDrive.exe"), + "PROCESS-NAME,OneDrive.exe,DIRECT", + ) + + def test_clash_verge_script_adds_reachable_direct_dns_fallbacks(self) -> None: + script = render_script(["PROCESS-NAME,OneDrive.exe"]) + + self.assertIn("const directDnsFallbacks", script) + self.assertIn("https://dns.alidns.com/dns-query", script) + self.assertIn("https://doh.pub/dns-query", script) + self.assertIn("config.dns = dns", script) + if __name__ == "__main__": unittest.main()