feat(mihomo): sync the policy to Clash Verge on macOS (#424)

This commit is contained in:
ryan4yin | 二花 authored and GitHub committed 2026-10-09 23:27:59 +08:00
1 parent dbe0f73ed5
commit e11375fbb8
3 files changed
+287 -1

No files matched your search

+9
View File
@@ -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
@@ -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
@@ -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"
}
}