diff --git a/Justfile b/Justfile index f9870463..9ee0fb15 100644 --- a/Justfile +++ b/Justfile @@ -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" + } # ================================================= # diff --git a/hosts/idols-ai/default.nix b/hosts/idols-ai/default.nix index b65a935f..d712300b 100644 --- a/hosts/idols-ai/default.nix +++ b/hosts/idols-ai/default.nix @@ -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}"; }; diff --git a/modules/nixos/desktop/networking/mihomo/README.md b/modules/nixos/desktop/networking/mihomo/README.md index 00f4e1fc..ce3f4f6c 100644 --- a/modules/nixos/desktop/networking/mihomo/README.md +++ b/modules/nixos/desktop/networking/mihomo/README.md @@ -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=&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 diff --git a/modules/nixos/desktop/networking/mihomo/gateway-config.nu b/modules/nixos/desktop/networking/mihomo/gateway-config.nu new file mode 100644 index 00000000..5cc05a75 --- /dev/null +++ b/modules/nixos/desktop/networking/mihomo/gateway-config.nu @@ -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@:/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)" +} diff --git a/modules/nixos/desktop/networking/mihomo/generate.nu b/modules/nixos/desktop/networking/mihomo/generate.nu index 39109d04..25d540b0 100644 --- a/modules/nixos/desktop/networking/mihomo/generate.nu +++ b/modules/nixos/desktop/networking/mihomo/generate.nu @@ -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 diff --git a/modules/nixos/desktop/networking/mihomo/policy.yaml b/modules/nixos/desktop/networking/mihomo/policy.yaml index bedc0b98..61f6d1f2 100644 --- a/modules/nixos/desktop/networking/mihomo/policy.yaml +++ b/modules/nixos/desktop/networking/mihomo/policy.yaml @@ -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 -- diff --git a/modules/nixos/desktop/networking/mihomo/sources.example.yaml b/modules/nixos/desktop/networking/mihomo/sources.example.yaml index c76f5b44..f357d902 100644 --- a/modules/nixos/desktop/networking/mihomo/sources.example.yaml +++ b/modules/nixos/desktop/networking/mihomo/sources.example.yaml @@ -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