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.
This commit is contained in:
ryan4yin | 二花 authored and GitHub committed 2026-10-09 12:31:59 +08:00
1 parent fe302fa950
commit 63b7183381
7 files changed
+184 -51

No files matched your search

+40 -6
View File
@@ -276,12 +276,46 @@ k3s-test mode="switch":
[group('agents')]
dsh-web:
#!/usr/bin/env nu
# dsh's web fetch refuses a DNS answer it does not call public, and a fake-ip
# resolver answers with 198.18.0.0/15. A proxied hop lets mihomo resolve the
# origin instead, which skips that check and leaves fake-ip acceleration
# intact. The policy reaches every Node fetch the harness makes, so mihomo
# becomes a dependency for LLM, web search and HTTP MCP traffic too.
with-env { HTTPS_PROXY: "http://127.0.0.1:7897", HTTP_PROXY: "http://127.0.0.1:7897" } { ^dsh web --no-open }
# no_proxy keeps local endpoints out of the proxy; the proxy is here for fake-ip.
# dsh's web fetch refuses a DNS answer it does not call public and fake-ip answers
# with 198.18.0.0/15, so a proxied hop makes mihomo resolve the origin instead.
# That makes mihomo a dependency of every Node fetch the harness makes, so it has
# to stop at the LAN: without no_proxy this host's own llama-swap endpoint goes
# through it too. Node reads only lowercase `no_proxy` and matches exact hosts,
# not CIDR; curl and Go do honor CIDR.
with-env {
HTTPS_PROXY: "http://127.0.0.1:7897"
HTTP_PROXY: "http://127.0.0.1:7897"
no_proxy: "localhost,127.0.0.1,::1,[::1],192.168.5.100,192.168.5.178,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
NO_PROXY: "localhost,127.0.0.1,::1,[::1],192.168.5.100,192.168.5.178,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
} { ^dsh web --no-open }
# Seed mihomo's geodata into the service's state dir, so a cold start needs no DNS.
[linux]
[group('services')]
mihomo-geo:
#!/usr/bin/env nu
# Copy geodata in by hand, so a cold start never has to fetch it. The core
# fetches it while parsing rules, before its own DNS is up and while the link
# DNS points at it -- that fetch can only fail.
let src = ($env.HOME | path join .config mihomo)
let dst = "/var/lib/private/mihomo"
sudo mkdir -p $dst
mut seeded = []
for f in [GeoSite.dat geoip.metadb ASN.mmdb] {
let p = ($src | path join $f)
if ($p | path exists) {
sudo cp $p $dst
$seeded = ($seeded | append ($dst | path join $f))
} else {
print $"(!) ($f) is not in ($src) -- fetch it first: mihomo -t -d ($src) -f ($src)/config.yaml"
}
}
if ($seeded | is-not-empty) {
sudo chmod 644 ...$seeded
let names = ($seeded | path basename | str join ', ')
print $"seeded ($names) -- restart mihomo to pick them up"
}
# =================================================
#
+26 -10
View File
@@ -138,17 +138,33 @@ in
linkConfig.RequiredForOnline = "routable";
};
# Dynamic DNS takeover: sing-tun normally asks resolved to use the TUN DNS
# address through resolvectl. That built-in call is not usable from nixpkgs'
# DynamicUser+hardened unit: the D-Bus socket is blocked, and resolve1's
# SetLinkDNS/SetLinkDomains methods require privileged authorization. Without
# it, resolved's upstream queries bypass dns-hijack and leave via the NIC.
# Run the takeover as a privileged lifecycle hook instead. ExecStopPost also
# runs on crashes, so a dead mihomo leaves the network usable.
# The '+' prefix escapes the unit's sandbox; without it, resolvectl's D-Bus
# connect fails and a failed ExecStartPost kills the otherwise healthy service.
# Point this link's DNS at mihomo while it runs, and revert it when it stops.
#
# Why a lifecycle hook: mihomo's sing-tun stack would call resolvectl itself,
# but nixpkgs' DynamicUser+hardened unit closes the D-Bus socket and SetLinkDNS
# needs privileged authorization; without the takeover resolved's upstream
# queries bypass dns-hijack and leave via the NIC. The '+' prefix escapes that
# sandbox -- a failed ExecStartPost would kill a healthy service -- and
# ExecStopPost also runs on crashes, so a dead mihomo leaves the network usable.
#
# Why the probe: the core opens :1053 only after parsing the rules, and rule
# parsing is where missing geodata gets fetched. Flipping at fork time pointed
# that fetch at a port nobody was listening on -- SERVFAIL, a fatal config
# error, a unit that could never start.
systemd.services.mihomo.serviceConfig = {
ExecStartPost = lib.mkDefault "+${pkgs.systemd}/bin/resolvectl dns ${iface} 127.0.0.1:1053";
ExecStartPost = lib.mkDefault "+${pkgs.writeShellScript "mihomo-dns-takeover" ''
probe() { (exec 3<>/dev/tcp/127.0.0.1/1053) 2>/dev/null; }
up=0
for ((i = 0; i < 100; i++)); do
if probe; then up=1; break; fi
sleep 0.1
done
if [ "$up" = 0 ]; then
echo "mihomo never opened 127.0.0.1:1053; leaving ${iface} on its own DNS" >&2
exit 0
fi
exec ${pkgs.systemd}/bin/resolvectl dns ${iface} 127.0.0.1:1053
''}";
ExecStopPost = lib.mkDefault "+${pkgs.systemd}/bin/resolvectl revert ${iface}";
};
@@ -24,10 +24,15 @@ config before enabling the module, or the service will not start.
| `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.
@@ -45,12 +50,15 @@ targets instead of writing a config the core rejects.
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 Valve's AS32590 ranges DIRECT and keeps `steamcommunity.com` on the proxy;
the IP rules are not decoration, Steam dials cached CM addresses without a lookup. Not
`GEOSITE,steam` -- that category includes the community. Sources and how to verify: "Steam
download region" below.
`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.
@@ -76,19 +84,15 @@ foreign download caches. Nothing in the Steam client overrides that. What the St
| 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 |
| [xPaw's Web API reference](https://steamapi.xpaw.me/IContentServerDirectoryService) | the endpoint and its parameters | community, mirrors the shipped client |
| [steam-lancache-prefill: CDN Regions](https://tpill90.github.io/steam-lancache-prefill/en/steam-docs/CDN-Regions/) | the `CellId` to city table, and that since 2024-10-04 the Download Region override selects no CDN at all | community; the page calls itself "non-exhaustive and likely inaccurate", so trust the API over the table |
| `~/.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; the IP list in both is stale, regenerate it |
| [RIPE stat: AS32590 announced prefixes](https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS32590) | which ranges Valve actually owns today | registry data; regenerate the IP rules from it, never hand-copy a blog's list |
| [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) |
| [Steam support: slow downloads and content servers](https://help.steampowered.com/en/faqs/view/5AC5-8056-E88F-F3FF) | Valve's own troubleshooting page | canonical, but JS-rendered, so nothing scrapes it |
| [Steam forum: honour the download region setting](https://steamcommunity.com/discussions/forum/10/6741412150961222653/) | users have failed to pin a region for years | Valve forum, anecdotal |
| [PAM 2024 measurement of Steam's CDN (PDF)](https://www.iijlab.net/en/members/romain/pdf/chris_pam2024.pdf) | how Steam picks caches, measured | peer-reviewed background; no rule here depends on it |
Two things that look like levers and are not: the Download Region dropdown, and
`CellIDServerOverride` in `config.vdf` -- this host has it pinned to 35 (Singapore) while the log
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
@@ -0,0 +1,68 @@
#!/usr/bin/env nu
# Render the gateway config (suzi, /etc/mihomo/config.yaml) from the same sources
# as the desktop config: generate.nu's output plus the keys a gateway needs. The
# gateway is not a host in this flake, so this script is the only place its shape
# lives. Never commit the output -- it carries the subscriptions and the private
# domains. `secret` stays a placeholder; the deploy fills it in on the box.
#
# nu gateway-config.nu [-o gateway.yaml] [--address 192.168.5.178] [--redact]
# scp gateway.yaml root@<gateway>:/tmp/ # fill the secret in on the box, -t, restart
# ---------------------------------------------------------------------------
const UI_URL = "https://github.com/MetaCubeX/metacubexd/archive/refs/heads/gh-pages.zip"
def main [
--out (-o): path = "/tmp/gateway.yaml"
--sources (-s): path = "~/.config/mihomo/sources.yaml"
--policy (-p): path
--address (-a): string = "192.168.5.178"
--redact # replace the secret and the subscription URLs: what a shareable template needs
] {
let out = ($out | path expand)
let tmp = ($out + ".base")
let gen = ($env.FILE_PWD | path join "generate.nu")
let args = if $policy == null {
["-s", ($sources | path expand), "-o", $tmp]
} else {
["-s", ($sources | path expand), "-p", ($policy | path expand), "-o", $tmp]
}
nu $gen ...$args
let gw = (open --raw $tmp
| from yaml
| update ipv6 true
| update allow-lan true
| update external-controller $"($address):9090"
| insert tproxy-port 7893
| insert external-ui "ui"
| insert external-ui-url $UI_URL
| update secret "GATEWAY-SECRET-PLACEHOLDER"
| update tun.stack "mixed"
| update tun.strict-route false
| insert tun.route-address ["0.0.0.0/1", "128.0.0.0/1", "::/1", "8000::/1"]
| update dns.listen ":1053"
| update dns.ipv6 true)
let gw = (if $redact {
# a file-backed provider (localyaml) has no url to hide
let pp = ($gw.proxy-providers
| items {|k, v| {
name: $k,
conf: (if ($v | columns | any {|c| $c == "url" }) {
$v | update url "https://xx.xx.xx.xx"
} else {
$v
})
} })
$gw
| update secret "NOTE-PLEASE-CHANGE-ME"
| update proxy-providers ($pp | reduce -f {} {|it, acc| $acc | insert $it.name $it.conf })
} else {
$gw
})
$gw | to yaml | save -f $out
rm $tmp
print $"gateway config -> ($out)"
}
@@ -366,6 +366,8 @@ def main [
let policy_rules = (sanitize-rules ($policy_doc | get -o rules | default []) "policy.yaml" true)
let policy_rp = ($policy_doc | get -o rule_providers | default {})
let node_groups = ($policy_doc | get -o node_groups | default [])
# kept out of TUN; sources.yaml may override the whole list
let policy_exclude = ($policy_doc | get -o tun_exclude_address | default [])
let spec = (open $src)
# an empty secret leaves the dashboard open to anything on the machine
@@ -520,6 +522,7 @@ def main [
"strict-route": ($spec | get -o tun_strict_route | default true)
"auto-detect-interface": true
"dns-hijack": ["any:53"]
"route-exclude-address": ($spec | get -o tun_exclude_address | default $policy_exclude)
} | upsert 'exclude-process' $excluded)
)
dns: {
@@ -550,6 +553,8 @@ def main [
geoip: "https://testingcf.jsdelivr.net/gh/MetaCubeX/meta-rules-dat@release/geoip.dat"
geosite: "https://testingcf.jsdelivr.net/gh/MetaCubeX/meta-rules-dat@release/geosite.dat"
mmdb: "https://testingcf.jsdelivr.net/gh/MetaCubeX/meta-rules-dat@release/country.mmdb"
# backs the IP-ASN rules in policy.yaml
asn: "https://testingcf.jsdelivr.net/gh/MetaCubeX/meta-rules-dat@release/GeoLite2-ASN.mmdb"
}
"proxy-providers": $providers_map
"proxy-groups": $groups
@@ -47,6 +47,27 @@ dns_filters:
- +.market.xiaomi.com
- "*.tailscale.com"
# CIDRs kept out of TUN: a destination that can never be proxied should not pay
# the fake-ip round trip. Never add `198.18.0.0/15` -- that is the fake-ip range.
# Override per machine with `tun_exclude_address:` in sources.yaml.
tun_exclude_address:
- 0.0.0.0/8 # this network on this host
- 10.0.0.0/8
- 100.64.0.0/10 # RFC 6598 shared space: Tailscale, cloud metadata
- 169.254.0.0/16 # link-local: 169.254.169.254
- 172.16.0.0/12
- 192.0.2.0/24 # documentation prefixes
- 192.88.99.0/24 # deprecated 6to4 relay anycast
- 192.168.0.0/16
- 198.51.100.0/24
- 203.0.113.0/24
- 224.0.0.0/4 # multicast: mDNS, SSDP
- 240.0.0.0/4 # reserved, includes limited broadcast
- fc00::/7 # ULA: home network and Tailscale IPv6
- fe80::/10
- 2001:db8::/32 # IPv6 documentation
- 2002::/16 # deprecated 6to4
# Optional extra rule-sets, usable by the rules below:
# rule_providers:
# my_list:
@@ -80,28 +101,9 @@ rules:
- DOMAIN-SUFFIX,nsclouds.cn,DIRECT
- DOMAIN-SUFFIX,wanwang.space,DIRECT
- DOMAIN-SUFFIX,baishancdnx.cn,DIRECT
# Steam dials cached CM IPs on 27018/27020/27024 without a lookup, so domain rules
# leak. Regenerate: stat.ripe.net/data/announced-prefixes/data.json?resource=AS32590
- IP-CIDR,45.121.184.0/24,DIRECT,no-resolve
- IP-CIDR,103.10.124.0/23,DIRECT,no-resolve
- IP-CIDR,103.28.54.0/24,DIRECT,no-resolve
- IP-CIDR,146.66.152.0/24,DIRECT,no-resolve
- IP-CIDR,146.66.155.0/24,DIRECT,no-resolve
- IP-CIDR,155.133.224.0/21,DIRECT,no-resolve
- IP-CIDR,155.133.236.0/22,DIRECT,no-resolve
- IP-CIDR,155.133.240.0/23,DIRECT,no-resolve
- IP-CIDR,155.133.244.0/24,DIRECT,no-resolve
- IP-CIDR,155.133.246.0/24,DIRECT,no-resolve
- IP-CIDR,155.133.248.0/22,DIRECT,no-resolve
- IP-CIDR,155.133.252.0/24,DIRECT,no-resolve
- IP-CIDR,155.133.254.0/23,DIRECT,no-resolve
- IP-CIDR,162.254.192.0/21,DIRECT,no-resolve
- IP-CIDR,185.25.180.0/24,DIRECT,no-resolve
- IP-CIDR,185.25.182.0/23,DIRECT,no-resolve
- IP-CIDR,192.69.96.0/22,DIRECT,no-resolve
- IP-CIDR,205.196.6.0/24,DIRECT,no-resolve
- IP-CIDR,208.64.200.0/22,DIRECT,no-resolve
- IP-CIDR,208.78.164.0/22,DIRECT,no-resolve
# Match Valve by ASN so its content servers stay direct. Steam dials cached CM
# IPs without a lookup, so domain rules leak; needs ASN.mmdb via `geox-url.asn`.
- IP-ASN,32590,DIRECT,no-resolve
- DOMAIN,launcher-webstatic.hoyoverse.com,DIRECT
# CN consumer services: proxied they fail in ways that look like app bugs --
@@ -29,6 +29,10 @@ tun_strict_route: true
tun_exclude_process:
# - some-app
# CIDRs kept out of TUN; the default is policy.yaml's list. Setting this replaces
# the whole list, and `[]` puts everything back through TUN.
# tun_exclude_address: [192.168.0.0/16]
providers:
# The core imports a subscription's proxies only; `rules_from` carries its
# rules and groups over from a local snapshot copy. Only worth it when the