Files
nix-config-ryan4yin/modules/nixos/desktop/networking/mihomo/README.md
T
ryan4yin | 二花 63b7183381 feat(mihomo): keep unproxiable ranges out of TUN, fix the startup DNS race (#419)
* feat(mihomo): keep ranges that can never be proxied out of TUN

The list lives in policy.yaml as tun_exclude_address; sources.yaml may override
it. 198.18.0.0/15 stays in TUN: that is the fake-ip range itself.

* feat(mihomo): match Valve's content servers by ASN

The 20 hand-copied prefixes had drifted from what AS32590 announces. The rule
type needs GeoLite2-ASN.mmdb, so geox-url.asn points at the same mirror.

* feat(mihomo): render the gateway config from the same sources

The hand-kept copy had drifted: a manual sync carried the desktop's secret onto
the gateway and dropped external-ui-url. --redact yields a shareable template.

* fix(mihomo): open the link DNS takeover only once the core serves it

The core opens :1053 after parsing rules, and rule parsing is where missing
geodata gets fetched. Flipping at fork time made that fetch SERVFAIL, the config
fatal, and the unit unstartable.

* fix(justfile): keep local endpoints out of the agent proxy

With only HTTP_PROXY set, a request to this host's own llama-swap endpoint went
to mihomo. Node matches exact hosts only, so the literals carry it; the CIDRs
serve curl and Go.

* feat(justfile): seed mihomo geodata into the service state dir

So a cold start never needs the download that the DNS takeover made unstartable.
2026-10-09 12:31:59 +08:00

10 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
nu generate.nu                                  # -> ~/.config/mihomo/config.yaml, mode 0600
mihomo -t -f ~/.config/mihomo/config.yaml       # validate before restarting
sudo systemctl restart mihomo.service           # dashboard: http://127.0.0.1:9090/ui

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
sources.example.yaml schema for sources.yaml

Rule order: private_domains, your rules, imported rules, then policy.yaml, ending in MATCH.

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.

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: false: nodes without IPv6 egress + AAAA = routing black hole (WeChat/JD/Taobao images); mihomo's IPv6 fake-ip also hung ssh/git locally for ~2 minutes, so it stays off.
  • private_domains: each entry becomes a DIRECT rule + a fake-ip-filter entry.
  • WeChat/QQ media go over bare CDN IPs, hence multimedia.nt.qq.com.cn in fake-ip-filter. If media still stalls: tun_exclude_process: [wechat, WeChatAppEx, qq] (Linux names; the fix that won clash-verge-rev#1762).
  • 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, fc00::/7, fe80::/10. Never exclude 198.18.0.0/15, the fake-ip range itself; 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.

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