From e11375fbb8ca3e35e28dac501bdd69d819a39a9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?ryan4yin=20=7C=20=E4=BA=8C=E8=8A=B1?= Date: Fri, 9 Oct 2026 23:27:59 +0800 Subject: [PATCH] feat(mihomo): sync the policy to Clash Verge on macOS (#424) --- Justfile | 9 + .../nixos/desktop/networking/mihomo/README.md | 34 ++- .../desktop/networking/mihomo/verge-sync.nu | 245 ++++++++++++++++++ 3 files changed, 287 insertions(+), 1 deletion(-) create mode 100644 modules/nixos/desktop/networking/mihomo/verge-sync.nu diff --git a/Justfile b/Justfile index 739be72f..ee8ebf1e 100644 --- a/Justfile +++ b/Justfile @@ -336,6 +336,15 @@ mihomo-geo: print $"seeded ($names) -- restart mihomo to pick them up" } +# Render the mihomo config into a Clash Verge Rev local profile on macOS. +# sources.yaml comes from the encrypted dotfiles sync (nix-secrets `just restore`). +[macos] +[group('services')] +mihomo-verge *args: + #!/usr/bin/env nu + let mod = ("{{ justfile() }}" | path dirname | path join modules nixos desktop networking mihomo) + nu ($mod | path join verge-sync.nu) {{ args }} + # ================================================= # # Other useful commands diff --git a/modules/nixos/desktop/networking/mihomo/README.md b/modules/nixos/desktop/networking/mihomo/README.md index ae11c35d..61b19689 100644 --- a/modules/nixos/desktop/networking/mihomo/README.md +++ b/modules/nixos/desktop/networking/mihomo/README.md @@ -28,10 +28,37 @@ config before enabling the module, or the service will not start. | `policy.yaml` | portable routing policy: ads, CN services, local ranges, tail | | `generate.nu` | renders `config.yaml` from `sources.yaml` + `policy.yaml` | | `gateway-config.nu` | the same output plus the gateway deltas | +| `verge-sync.nu` | renders the same config into a Clash Verge profile (macOS) | | `sources.example.yaml` | schema for `sources.yaml` | Rule order: `private_domains`, your `rules`, imported rules, then `policy.yaml`, ending in `MATCH`. +## Clash Verge on macOS + +The Mac (`frieren`) runs the same policy through Clash Verge Rev rather than the native service. +`sources.yaml` and the inline proxy yaml reach it via the encrypted dotfiles sync +(`~/codes/nix-secrets`, `just restore`); the generated config is built on the Mac and never synced: + +```sh +just mihomo-verge # generate, validate with verge-mihomo -t, install as a local profile +``` + +`verge-sync.nu` reuses `generate.nu` + `policy.yaml`, drops the sections Verge owns unconditionally +(ports, TUN, controller, geodata), and installs the rest -- providers, groups, rules, and the whole +`dns` section -- as one local profile under a fixed uid, so re-running replaces it. It enables +Verge's per-profile DNS override (`profile_dns_settings` in verge.yaml), so the profile's `dns` +replaces the global panel for it, and it replaces `fake-ip-filter`, the fake-ip ranges and `ipv6` in +the global panel (`dns_config.yaml`) too, so subscription profiles get the same table: +sources.yaml + policy.yaml are the single source of truth, and panel-only entries are a sources.yaml +fix, not a per-machine patch. Because the panel then hands out fake AAAA records, `Merge.yaml` +(Verge's global extension) gets `ipv6: true` pinned when absent: subscription profiles set no +top-level ipv6 and Verge's base config hardcodes false, and fake v6 answers on a core that refuses +v6 dials is the clash-verge-rev#1762 WeChat failure. TUN is global, so `tun_exclude_address` is +merged into verge.yaml's `tun_config.route_exclude_address` instead. It quits and restarts Verge +around the edit (`--dry-run` to preview, `--no-restart` to skip that); select the profile in the +Verge UI. Verge logs `Unregistered Clash configuration key "ipv6"` for the pin -- harmless, the key +still reaches the core. + The gateway (`suzi`, not a host in this flake) runs the same config with a fixed set of deltas -- `gateway-config.nu` renders it, `--redact` for a shareable template. Its output carries the subscriptions, so never commit it; the secret is filled in on the box, not here. Its binary is a @@ -60,7 +87,12 @@ targets instead of writing a config the core rejects. dual-stack subresource prompts. `2001:2::/48` is RFC 5180 benchmarking, the v6 twin of the `198.18.0.0/15` v4 default. Never grant that prompt broadly -- it also covers `192.168.5.0/24`, `fd05:5::/64` and Tailscale. Moving the pool leaves mihomo's own TUN addresses (`198.18.0.1`, - `fdfe:dcba:9876::1`) alone; no client is ever handed those as an answer. + `fdfe:dcba:9876::1`) alone; no client is ever handed those as an answer. To check the line, + resolve through GSLB and dial the carrier's own-block AAAA (`dig AAAA www.jd.com` on China Telecom + -> `240e:...`, then `curl -6`): hardcoded cross-carrier literals (Unicom `2408:...`, some + Alibaba/Baidu blocks) are CT v6 interconnect dead zones that GSLB normally steers around, and a + bare-IP TLS test needs `-k` -- a certificate name mismatch (curl error 60) means the connection + itself worked. A browser IPv6 test under TUN measures the proxy node, not the line. - `tun.stack` stays at upstream's default `mips`; nftables rules out `system` and `mixed`. The gateway runs `mixed`. - The gateway sniffs, the desktop does not. LAN clients that use DoH dial the IP they resolved diff --git a/modules/nixos/desktop/networking/mihomo/verge-sync.nu b/modules/nixos/desktop/networking/mihomo/verge-sync.nu new file mode 100644 index 00000000..04a353ca --- /dev/null +++ b/modules/nixos/desktop/networking/mihomo/verge-sync.nu @@ -0,0 +1,245 @@ +#!/usr/bin/env nu +# Render the mihomo config for Clash Verge Rev on macOS and install it as a +# local profile: +# +# nu verge-sync.nu [--dry-run] [--no-restart] [--sources path] [--verge-dir path] +# [--core path] [--name string] +# +# sources.yaml comes from the encrypted dotfiles sync (nix-secrets, `just +# restore`). The profile keeps providers, groups, rules, rule-providers and the +# dns section (per-profile DNS override enabled); Verge-owned sections (ports, +# TUN, controller, geodata) are dropped. tun.route-exclude-address goes into +# verge.yaml's tun_config (TUN is global), dns_config.yaml gets the same +# fake-ip table, and Merge.yaml pins top-level ipv6 for subscription profiles. +# The core validates the profile before anything is installed. +# --------------------------------------------------------------------------- + +const profile_uid = "mihomoSyncV1" +const profile_name = "mihomo (nix-config)" + +# Verge regenerates these from its GUI settings; a profile carrying them would +# fight the panel. `dns` is deliberately NOT here: Verge 2.5+ has a per-profile +# DNS override (profile_dns_settings), so the generated dns travels with the +# profile. `tun` stays stripped: TUN is global, its exclusions go into +# verge.yaml's tun_config. +const verge_managed = [ + "mixed-port" + "port" + "socks-port" + "redir-port" + "tproxy-port" + "allow-lan" + "log-level" + "mode" + "external-controller" + "external-controller-unix" + "external-controller-cors" + "secret" + "profile" + "tun" + "geodata-mode" + "geo-auto-update" + "geo-update-interval" + "geox-url" +] + +def fail [msg: string] { + error make { msg: $msg } +} + +def verge-running [] { + (pgrep -x clash-verge | lines | length) > 0 +} + +def main [ + --dry-run # generate and validate, install nothing + --no-restart # edit the profile files but leave Verge stopped/running as it is + --sources: path = "~/.config/mihomo/sources.yaml" + --verge-dir: path # defaults to the Clash Verge Rev app-support directory + --core: path # defaults to the core inside the Clash Verge.app bundle + --name: string # profile name shown in the Verge UI +] { + let mod = $env.FILE_PWD + let src = ($sources | path expand) + if not ($src | path exists) { + fail $"sources file not found: ($src) -- run 'just restore' in ~/codes/nix-secrets first" + } + let vdir = ( + if $verge_dir == null { + [$env.HOME "Library" "Application Support" "io.github.clash-verge-rev.clash-verge-rev"] | path join + } else { + $verge_dir | path expand + } + ) + if not ($vdir | path exists) { + fail $"clash verge directory not found: ($vdir)" + } + let core = ( + if $core == null { "/Applications/Clash Verge.app/Contents/MacOS/verge-mihomo" } else { $core } + ) | path expand + if not ($core | path exists) { + fail $"verge core not found: ($core)" + } + let name = ($name | default $profile_name) + + # 1. generate the full config with the repo's generator and policy, staged in + # the system temp dir so a failed run never touches the Verge directory. + let tmp = ($env.TMPDIR? | default "/tmp" | path join "mihomo-verge") + mkdir $tmp + let staged = ($tmp | path join "generated.yaml") + let gen = ( + ^nu ($mod | path join "generate.nu") + -s $src + -o $staged + -p ($mod | path join "policy.yaml") + | complete + ) + print ($gen.stdout | str trim) + if ($gen.stderr | str trim) != "" { + print --stderr ($gen.stderr | str trim) + } + if $gen.exit_code != 0 { + fail $"generate.nu failed (staged in ($staged))" + } + + # 2. drop the Verge-managed sections. dns travels in the profile and replaces + # the global panel for it: sources.yaml + policy.yaml are the single source + # of truth; missing entries are a sources.yaml fix, not a per-machine patch. + let doc = (open $staged) + let present = ($doc | columns | where { |c| $c in $verge_managed }) + let profile_doc = ($doc | reject ...$present) + let stripped = ($tmp | path join "profile.yaml") + $profile_doc | to yaml | save --force $stripped + ^chmod 600 $stripped + print $"dropped Verge-managed sections: ($present | str join ', ')" + print $"dns.fake-ip-filter: ($profile_doc | get dns.fake-ip-filter | length) entries, replacing the global panel for this profile" + + # 3. validate with the core Verge runs; -d resolves provider paths and + # geodata exactly as at runtime. + let check = (^$core -t -d $vdir -f $stripped | complete) + if $check.exit_code != 0 { + print --stderr ($check.stderr | str trim) + fail $"core rejected the profile (staged in ($stripped))" + } + print "validated by verge-mihomo -t" + + let target = ($vdir | path join "profiles" $"($profile_uid).yaml") + let pfile = ($vdir | path join "profiles.yaml") + let vfile = ($vdir | path join "verge.yaml") + let gen_excl = ($doc | get -o tun.route-exclude-address | default []) + if $dry_run { + print $"dry run: would install ($target) and upsert ($profile_uid) in ($pfile)" + print $"dry run: would enable profile_dns_settings.($profile_uid) and merge ($gen_excl | length) tun.route-exclude-address entries into ($vfile)" + print $"dry run: would replace fake-ip-filter/ranges/ipv6 in ($vdir | path join 'dns_config.yaml')" + print $"dry run: would pin ipv6: true in ($vdir | path join 'profiles' 'Merge.yaml') if absent" + return + } + + # 4. Verge rewrites profiles.yaml and verge.yaml while it runs, so quit + # first, edit, restart. + let was_running = (verge-running) + if $was_running and (not $no_restart) { + ^osascript -e 'tell application "Clash Verge" to quit' | ignore + mut waited = 0 + while ($waited < 10) and (verge-running) { + sleep 1sec + $waited = ($waited + 1) + } + if (verge-running) { + fail "Clash Verge did not quit within 10s -- nothing installed" + } + } + + ^cp $pfile $"($pfile).bak" + ^cp $vfile $"($vfile).bak" + $stripped | open | to yaml | save --force $target + ^chmod 600 $target + + let pconf = (open $pfile) + let items = ($pconf | get -o items | default []) + let entry = { + uid: $profile_uid + type: "local" + name: $name + file: $"($profile_uid).yaml" + updated: ((date now | into int) / 1000000000 | into int) + } + let new_items = ( + if ($items | any { |it| ($it | get -o uid) == $profile_uid }) { + $items | each { |it| if ($it | get -o uid) == $profile_uid { $entry } else { $it } } + } else { + $items ++ [$entry] + } + ) + $pconf | upsert items $new_items | to yaml | save --force $pfile + print $"installed ($target) -- backups: ($pfile).bak, ($vfile).bak" + + # 5. verge.yaml: the per-profile DNS override and the TUN exclusions (TUN is + # global). When tun_config is absent, seed stack/mtu/dns-hijack from the + # live runtime config.yaml. + let vconf = (open $vfile) + let vconf = ($vconf | upsert profile_dns_settings ( + ($vconf | get -o profile_dns_settings | default {}) | upsert $profile_uid { enabled: true } + )) + let cur_tun = ($vconf | get -o tun_config | default {}) + let tun = ($cur_tun | upsert route_exclude_address ( + (($cur_tun | get -o route_exclude_address | default []) ++ $gen_excl | uniq) + )) + let tun = ( + if ($vconf | get -o tun_config) == null { + let rt = (open ($vdir | path join "config.yaml") | get -o tun | default {}) + $tun + | upsert enable true + | upsert stack ($rt | get -o stack | default "system") + | upsert mtu ($rt | get -o mtu | default 1500) + | upsert dns_hijack ($rt | get -o dns-hijack | default ["any:53"]) + | upsert strict_route ($rt | get -o strict-route | default false) + } else { + $tun + } + ) + $vconf | upsert tun_config $tun | to yaml | save --force $vfile + print $"enabled profile_dns_settings.($profile_uid); tun_config.route_exclude_address: ($tun | get route_exclude_address | length) entries" + + # 6. dns_config.yaml: the global panel serves every profile without an + # override, so its fake-ip table and ipv6 follow the generated config -- + # replace, not merge. + let dfile = ($vdir | path join "dns_config.yaml") + if ($dfile | path exists) { + ^cp $dfile $"($dfile).bak" + let gen_dns = ($doc | get -o dns | default {}) + let dconf = (open $dfile) + let dconf = ($dconf + | upsert dns.fake-ip-filter ($gen_dns | get -o fake-ip-filter | default []) + | upsert dns.fake-ip-range ($gen_dns | get -o fake-ip-range | default "198.18.0.1/16") + | upsert dns.fake-ip-range6 ($gen_dns | get -o fake-ip-range6 | default "2001:2::1/64") + | upsert dns.ipv6 ($gen_dns | get -o ipv6 | default true) + ) + $dconf | to yaml | save --force $dfile + print $"global dns_config.yaml: fake-ip-filter ($gen_dns | get fake-ip-filter | length) entries, fake-ip-range6 ($gen_dns | get fake-ip-range6), ipv6 ($gen_dns | get ipv6)" + } + + # 7. Merge.yaml: subscription profiles set no top-level ipv6 and Verge's base + # config hardcodes false, while step 6 hands every panel profile fake + # AAAAs -- fake v6 answers on a core that refuses v6 dials is the + # clash-verge-rev#1762 failure. Pin ipv6 true (line must have working v6, + # see README). Appended as text to keep the user's comments; skipped when + # the key is already set. + let mfile = ($vdir | path join "profiles" "Merge.yaml") + if ($mfile | path exists) { + if (open $mfile | get -o ipv6) == null { + ^cp $mfile $"($mfile).bak" + "\n# Synced by verge-sync.nu: the panel hands out fake AAAAs, so every\n# profile needs top-level ipv6 (README: only on a line whose IPv6 works).\nipv6: true\n" | save --append $mfile + print $"pinned ipv6: true in ($mfile)" + } else { + print $"ipv6 already set in ($mfile), left alone" + } + } + + if $was_running and (not $no_restart) { + ^open -a "Clash Verge" + print "restarted Clash Verge -- select the profile in its UI" + } else { + print "select the profile in the Verge UI after (re)starting it" + } +}