mirror of
https://github.com/ryan4yin/nix-config.git
synced 2026-11-13 12:13:36 +01:00
218 lines
18 KiB
Markdown
218 lines
18 KiB
Markdown
# 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 <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](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=<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](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`.
|