`just mihomo-geo` copied geodata into the state dir so a cold start never fetched it. tmpfiles now links v2ray-rules-dat and the DB-IP lite mmdbs there, so geo-auto-update moves to the gateway config only. Assisted-by: Claude Code (claude-opus-5-5)
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.
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-6and-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 is2001:2::/64, not the shippedfdfe: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::/48is RFC 5180 benchmarking, the v6 twin of the198.18.0.0/15v4 default. Never grant that prompt broadly -- it also covers192.168.5.0/24,fd05:5::/64and 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.comon China Telecom ->240e:..., thencurl -6): hardcoded cross-carrier literals (Unicom2408:..., 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=1on the unit: the core's startup IPv6 check plusstrict-route's v6 blackhole otherwise lock the host out of ever getting a GUA (WA-030).tun.stackstays at upstream's defaultmips; nftables rules outsystemandmixed. The gateway runsmixed.- 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, soIP-CIDR/IP-ASNrules stop applying to it. On the desktop the fake-ip table already names every flow. private_domains: each entry becomes a DIRECT rule + afake-ip-filterentry.- WeChat resolves for itself, but its queries still cross the TUN, so
dns-hijack: any:53catches them and the fake-ip table hands the name back to the rules:mmbiz.qpic.cnmeasured DIRECT.multimedia.nt.qq.com.cnstays infake-ip-filterfor 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: offunlessPROCESS-*rules ortun_exclude_processneed it.tun_exclude_address: a destination that can never be a proxy target should not pay a fake-ip round trip -- RFC1918, RFC 6598's100.64.0.0/10(where Tailscale addresses come from), link-local, multicast, reserved and documentation prefixes, the cluster ULAfd05:5::/64and Tailscale'sfd7a:115c:a1e0::/48. Never exclude a range that holds a fake-ip pool: that is198.18.0.0/16and2001:2::/64. The two ULAs are spelled out instead of taking all offc00::/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.yamlpins the CM hosts,steamcontent.comandIP-ASN,32590DIRECT and keepssteamcommunity.comon the proxy -- Steam dials cached CM addresses without a lookup, so that rule is not decoration. NotGEOSITE,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.comwent through a US proxy node whileautopatchcn.bhsr.comwas DIRECT; the download rose from ~0.5 to 102 MB/s after the proxy group was switched to DIRECT.policy.yamlbypasses proxy only for that observed probe, not game CDNs or broad vendor domains. Verify future probes in/connectionsand add only confirmed hosts. Reference: HoyoPlay API endpoints. - Rule payloads are bare domains:
DOMAIN-SUFFIX,https://qlogo.cnis invalid. - On a systemd-resolved host, sing-tun normally points resolved at the TUN DNS with
resolvectl. The nixpkgsDynamicUsersandbox cannot make those privileged D-Bus changes, so resolved's own upstream queries bypassdns-hijack any:53and leave via the NIC. Tie a resolver takeover to the service lifetime (resolvectl dns/revertinExecStartPost/ExecStopPost, seehosts/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.resolvedIntegrationenables resolved and the NetworkManager resolved backend. Once DNS is listening, the service registers127.0.0.1:1053and~.onMeta, which NetworkManager leaves unmanaged. Wi-Fi reconnects can update their own DNS without overwriting this route. Stopping mihomo reverts onlyMeta; 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 defaultMetadevice 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
ipv6cannot 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, onlyCAP_NET_ADMINundertunMode.