# Mihomo Native mihomo core + metacubexd dashboard, replacing the Clash Verge GUI. Enable per host with `modules.networking.mihomo.enable = true` (`ai`, `shoukei`). ## Usage ```sh 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: ```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 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 `) 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](https://blog.l3zc.com/2025/03/chinese-cdn-used-by-playstore/) and [Google Play domain classifications](https://github.com/v2fly/domain-list-community/blob/master/data/google-play). ## 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](https://gist.github.com/DynamiByte/0ad250bbe1930e2736a6d4e6e842bcb2). - 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](https://github.com/systemd/systemd/blob/main/docs/RESOLVED-VPNS.md). ## 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](https://api.steampowered.com/IContentServerDirectoryService/GetServersForSteamPipe/v1/?cell_id=0&max_servers=2000) | the list is keyed by `cell_id`; `cell_id=0` means "use geolocation". Ask for any cell with `?cell_id=&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](https://www.dogfight360.com/blog/knowledge-base/fix_steamdl_region/) | 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](https://femoon.top/blog/steam-slow-download-clash-split-routing), [teapotium](https://teapotium.com/2024/12/let-steam-downloads-bypass-proxy/) | 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](https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS32590) | which ranges Valve actually owns today | registry data; cross-checks what the ASN match covers | | [meta-rules-dat](https://github.com/MetaCubeX/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](https://tpill90.github.io/steam-lancache-prefill/en/steam-docs/CDN-Regions/) -- 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: ```sh 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](https://github.com/clash-verge-rev/clash-verge-rev/issues/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](https://github.com/MetaCubeX/mihomo/issues/2233) — open: the core has no IPv6 reachability probe, so `ipv6` cannot stay on for a line that lost IPv6. - [WeChat-under-TUN retrospective](https://x.com/realchendahuang/status/2104381806862795161) — five community fixes; "turn IPv6 off" only ever worked where the line's IPv6 was already broken. - [mihomo wiki](https://wiki.metacubex.one/), [proxy providers](https://wiki.metacubex.one/config/proxy-providers/) — why subscription rules are dropped. - [meta-rules-dat](https://github.com/MetaCubeX/meta-rules-dat) — the GEOSITE/GEOIP data. - [metacubexd](https://github.com/MetaCubeX/metacubexd) — the dashboard. - [nixpkgs mihomo module](https://github.com/NixOS/nixpkgs/blob/nixos-unstable/nixos/modules/services/networking/mihomo.nix) — `DynamicUser`, `LoadCredential`, only `CAP_NET_ADMIN` under `tunMode`.