Files
nix-config/modules/nixos/desktop/networking/mihomo/README.md
T
Ryan Yin 7c2f52c9d5 fix(mihomo): proxy the Google Play CN API
Assisted-by: DeepSeek Harness (gpt-6-astra)
2026-11-10 08:31:35 +08:00

18 KiB

Mihomo

Native mihomo core + metacubexd dashboard, replacing the Clash Verge GUI. Enable per host with modules.networking.mihomo.enable = true (ai, shoukei).

Usage

cp sources.example.yaml ~/.config/mihomo/sources.yaml && chmod 600 ~/.config/mihomo/sources.yaml
$EDITOR ~/.config/mihomo/sources.yaml           # fill in: secret, private_domains, providers
just mihomo-gen                                 # -> ~/.config/mihomo/config.yaml, mode 0600
sudo systemctl restart mihomo.service           # dashboard: http://127.0.0.1:9090/ui

~/.config/mihomo holds sources.yaml and the generated config.yaml, which the service loads through systemd LoadCredential; the core's own state is in /var/lib/private/mihomo (StateDirectory, DynamicUser), where the module links geodata from nixpkgs (v2ray-rules-dat, dbip-country-lite, dbip-asn-lite), so it updates with nixpkgs, not at runtime. The generator, policy.yaml and gateway-config.nu are run from here and never copied over. just mihomo-gen renders the config and validates it with the core the service runs.

sources.yaml holds everything private (subscription URLs, secret, private domains) and stays out of the repo and the Nix store; the module loads config.yaml via LoadCredential. Generate the config before enabling the module, or the service will not start.

Files

File Role
default.nix the service: services.mihomo + TUN + metacubexd
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:

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 from the same sources.yaml and policy.yaml. The gateway uses nftables TProxy, disables the desktop TUN auto-route, and uses fake-ip DNS with dns.ipv6: false: LAN clients receive fake A mappings so mihomo retains the domain for routing and sniffing while their traffic uses the working IPv4 TProxy path. The gateway withholds AAAA records because LAN IPv6 transparent handling is unreliable on this gateway. Its output carries the subscriptions and the private domains, so never commit it; the secret is filled in on the box, not here. Its binary is a static release build under /usr/local/bin, not a package: update it by hand, validate the live config with the new core (mihomo -t -d /etc/mihomo -f <candidate>) before swapping, and keep the previous binary. The release ships no checksums, so TLS plus -v is the whole verification.

Gateway mode contract: keep nftables TProxy with both tun.enable and tun.auto-route disabled, and keep dns.ipv6 disabled. The gateway currently withholds AAAA records because LAN IPv6 transparent handling is unreliable; clients therefore use the working fake-IPv4 path. Do not combine LAN TProxy with the desktop TUN auto-route: in this gateway topology it can recapture fake dials and cause timeouts.

Why generate

proxy-provider imports proxies only — a subscription's rules and groups are dropped by the core. The portable policy is therefore written once in policy.yaml as GEOSITE categories. Provider-bound policy is opt-in: rules_from: (a decoded snapshot) or file: carry groups, rules and rule-providers over. The generator drops and reports malformed rules, misplaced MATCH and orphaned targets instead of writing a config the core rejects.

Google Play routing

policy.yaml routes services.googleapis.cn through PROXY on desktops and the gateway. This addresses Google Play downloads stalling when direct API access selects a mainland CDN that is then accessed through an overseas proxy.

References: Google Play CDN report and Google Play domain classifications.

Gotchas

  • ipv6, dns.ipv6, fake-ip-range6: the fake v6 pool is a lookup key, so the family the client picks never picks the egress family -- a fake-IPv6 client dials an IPv4 proxy target, and CN services answer identically over -6 and -4. What does break is a fake v6 pool on a line whose IPv6 is dead: apps that resolve for themselves (WeChat) dial a real AAAA, stall, and do not fall back. mihomo has no IPv6 reachability probe (mihomo#2233), so the config has to match the line. SSH over a fake AAAA is the local exception: it connects, authenticates, then never closes -- WA-028's IPv4 pin covers it. The pool is 2001:2::/64, not the shipped fdfe:dcba:9876::/64: that is ULA (fc00::/7), which Local Network Access calls local, so a public page pulling a 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. 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.
  • SKIP_SYSTEM_IPV6_CHECK=1 on the unit: the core's startup IPv6 check plus strict-route's v6 blackhole otherwise lock the host out of ever getting a GUA (WA-030).
  • 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 themselves, and a node may not reach an IP a foreign resolver handed out, so the gateway sniffs the name out of the handshake (sniffer, TLS and QUIC on 443, HTTP on 80) and dials that. The cost: a bare-IP connection is judged by its SNI, so IP-CIDR/IP-ASN rules stop applying to it. On the desktop the fake-ip table already names every flow.
  • private_domains: each entry becomes a DIRECT rule + a fake-ip-filter entry.
  • WeChat resolves for itself, but its queries still cross the TUN, so dns-hijack: any:53 catches them and the fake-ip table hands the name back to the rules: mmbiz.qpic.cn measured DIRECT. multimedia.nt.qq.com.cn stays in fake-ip-filter for the same reason. The exclude-process fix that won clash-verge-rev#1762 is not available here: nixpak unshares the PID namespace and the sandbox reports uid 0, so no process name resolves.
  • find-process-mode: off unless PROCESS-* rules or tun_exclude_process need it.
  • tun_exclude_address: a destination that can never be a proxy target should not pay a fake-ip round trip -- RFC1918, RFC 6598's 100.64.0.0/10 (where Tailscale addresses come from), link-local, multicast, reserved and documentation prefixes, the cluster ULA fd05:5::/64 and Tailscale's fd7a:115c:a1e0::/48. Never exclude a range that holds a fake-ip pool: that is 198.18.0.0/16 and 2001:2::/64. The two ULAs are spelled out instead of taking all of fc00::/7, which would also exempt any other ULA a client dials; tun_exclude_address: [] puts everything back in TUN.
  • Steam's download caches follow the public IP Steam sees on the login (CM) connection, so a proxied Steam downloads from Tokyo/Singapore/HK/Los Angeles. policy.yaml pins the CM hosts, steamcontent.com and IP-ASN,32590 DIRECT and keeps steamcommunity.com on the proxy -- Steam dials cached CM addresses without a lookup, so that rule is not decoration. Not GEOSITE,steam: that category includes the community. Sources and how to verify: "Steam download region" below.
  • CDN selection depends on launcher probes, not the patch download route. On this host, launcher-webstatic.hoyoverse.com went through a US proxy node while autopatchcn.bhsr.com was DIRECT; the download rose from ~0.5 to 102 MB/s after the proxy group was switched to DIRECT. policy.yaml bypasses proxy only for that observed probe, not game CDNs or broad vendor domains. Verify future probes in /connections and add only confirmed hosts. Reference: HoyoPlay API endpoints.
  • Rule payloads are bare domains: DOMAIN-SUFFIX,https://qlogo.cn is invalid.
  • On a systemd-resolved host, sing-tun normally points resolved at the TUN DNS with resolvectl. The nixpkgs DynamicUser sandbox cannot make those privileged D-Bus changes, so resolved's own upstream queries bypass dns-hijack any:53 and leave via the NIC. Tie a resolver takeover to the service lifetime (resolvectl dns/revert in ExecStartPost/ExecStopPost, see hosts/idols-ai/default.nix), prefixed with + to run outside the unit sandbox. Never set link DNS statically to mihomo: a dead mihomo would take DNS down with it, and resolved does not fall back.
  • On shoukei, modules.networking.mihomo.resolvedIntegration enables resolved and the NetworkManager resolved backend. Once DNS is listening, the service registers 127.0.0.1:1053 and ~. on Meta, which NetworkManager leaves unmanaged. Wi-Fi reconnects can update their own DNS without overwriting this route. Stopping mihomo reverts only Meta; removing the TUN also removes its DNS configuration, leaving the physical links' DNS available. More-specific VPN DNS routes still win; another VPN claiming ~. (including Tailscale's full DNS override) would share the catch-all and must not be used concurrently. This relies on mihomo's default Meta device name and the generator's local DNS port; apply TUN changes with a service restart so the DNS hook runs for the new interface. After activation, verify normal and reconnected Wi-Fi queries return fake IPs, and that stopping mihomo restores network DNS. See systemd's VPN DNS routing guide.

Steam download region

Steam hands the client a content-server list keyed by a cell_id, and the cell follows the public IP Steam sees on the login (CM) connection. A proxied login means a foreign cell, which means foreign download caches. Nothing in the Steam client overrides that. What the Steam rules in policy.yaml rest on, and how far to trust each source:

Source Establishes Trust
Valve's content-server directory API the list is keyed by cell_id; cell_id=0 means "use geolocation". Ask for any cell with ?cell_id=<n>&max_servers=2000 Valve, live, authoritative
~/.local/share/Steam/logs/content_log.txt what this host actually got: 2024 (CellID 201) came from mainland CDN partners, 2025-2026 (CellID 171/177) from the tyo3/sgp1/hkg1/lax1 Valve caches your own client, the strongest evidence here
羽翼城 / Dogfight360 the trick itself: pin *.cm.steampowered.com, *.steamserver.net and Valve's ranges direct; 2022 article, still maintained (2026-05) the origin every other recipe copies; single-operator, but the mechanism checks out against the API and the local log
femoon, teapotium the same rules for Clash/mihomo, and the CM-IP-cached-without-DNS detail derivative reports; their prefix lists are stale, which is why the config matches on the ASN
RIPE stat: AS32590 announced prefixes which ranges Valve actually owns today registry data; cross-checks what the ASN match covers
meta-rules-dat geosite category names: steam and category-games exist, category-games@cn (in several guides) does not upstream data, checked against GeoSite.dat (2026-10)

Two things that look like levers and are not: the Download Region dropdown -- since 2024-10-04 it selects no CDN at all -- and CellIDServerOverride in config.vdf, pinned to 35 (Singapore) on this host while the log shows downloads arriving from Tokyo. The IP Steam sees is the only lever.

Check the result with steam://open/console then user_info (IPCountry must read CN), and by listing the cache groups the client was actually served:

grep -oE 'cache[0-9]+-[a-z]{3}[0-9]+' ~/.local/share/Steam/logs/content_log.txt |
  sed -E 's/^cache[0-9]+-//' | sort -u

References

  • clash-verge-rev#1762 — WeChat media under TUN: fake-ip-filter, qlogo/qpic, the exclude-process fix. Its closing comment is the one that matters: a fake v6 pool works on a line with working IPv6 and hangs on a line without.
  • mihomo#2233 — open: the core has no IPv6 reachability probe, so ipv6 cannot stay on for a line that lost IPv6.
  • WeChat-under-TUN retrospective — five community fixes; "turn IPv6 off" only ever worked where the line's IPv6 was already broken.
  • mihomo wiki, proxy providers — why subscription rules are dropped.
  • meta-rules-dat — the GEOSITE/GEOIP data.
  • metacubexd — the dashboard.
  • nixpkgs mihomo module — DynamicUser, LoadCredential, only CAP_NET_ADMIN under tunMode.