From bbfe1fa53b1f03925fc8e94ac38914680e452933 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?ryan4yin=20=7C=20=E4=BA=8C=E8=8A=B1?= Date: Sun, 4 Oct 2026 21:04:41 +0800 Subject: [PATCH] feat(desktop): replace Clash Verge with native mihomo on all NixOS hosts (#379) --- .typos.toml | 3 + WORKAROUNDS.md | 11 +- home/base/tui/ssh.nix | 2 +- home/linux/gui/base/xdg/autostart.nix | 4 - hosts/idols-ai/default.nix | 4 +- hosts/idols-ai/preservation.nix | 9 +- modules/nixos/desktop/computer-use.nix | 15 +- .../nixos/desktop/networking/clash-verge.nix | 23 - .../nixos/desktop/networking/mihomo/README.md | 62 ++ .../desktop/networking/mihomo/default.nix | 35 ++ .../desktop/networking/mihomo/generate.nu | 572 ++++++++++++++++++ .../desktop/networking/mihomo/policy.yaml | 97 +++ .../networking/mihomo/sources.example.yaml | 44 ++ .../aarch64-linux/src/12kingdoms-shoukei.nix | 3 + outputs/default.nix | 4 +- outputs/x86_64-linux/src/idols-ai.nix | 3 + .../tests/computer-use-headless/expected.nix | 2 +- .../tests/computer-use-headless/expr.nix | 2 +- 18 files changed, 845 insertions(+), 50 deletions(-) delete mode 100644 modules/nixos/desktop/networking/clash-verge.nix create mode 100644 modules/nixos/desktop/networking/mihomo/README.md create mode 100644 modules/nixos/desktop/networking/mihomo/default.nix create mode 100644 modules/nixos/desktop/networking/mihomo/generate.nu create mode 100644 modules/nixos/desktop/networking/mihomo/policy.yaml create mode 100644 modules/nixos/desktop/networking/mihomo/sources.example.yaml diff --git a/.typos.toml b/.typos.toml index 8abd3921..9493ba73 100644 --- a/.typos.toml +++ b/.typos.toml @@ -9,6 +9,9 @@ ignore-files = true extend-exclude = [ "data/", "rime-data/", + # small-duck flypy user dictionary: entries are pinyin codes ("delt", "juli"), + # not English words, so the spell checker flags every one of them + "overlays/fcitx5/flypy_user.txt", ] [default] diff --git a/WORKAROUNDS.md b/WORKAROUNDS.md index 6834133e..db6a4439 100644 --- a/WORKAROUNDS.md +++ b/WORKAROUNDS.md @@ -46,11 +46,12 @@ recovery procedure, not its removal condition. A known limitation with no fix yet. -| ID | What & where | Why | Removal condition | Added | Revisit | Status | -| ------ | ---------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | ---------- | -------------------------------------- | ------ | -| WA-012 | `muvm` (x86_64 apps/games via microVM) disabled — `hosts/12kingdoms-shoukei/apple-silicon.nix` | Apple Silicon's 16k page size means x86_64 apps need `muvm`; it is off because it does not build/run on nixos-apple-silicon yet ([nixos-apple-silicon#237](https://github.com/nix-community/nixos-apple-silicon/issues/237)) | Re-enable once `muvm` builds and works | — | on nixos-apple-silicon or muvm updates | active | -| WA-015 | Darwin secret ownership — `secrets/darwin.nix` | Blanket `chown /etc/agenix/*` can override declared root ownership through symlinks; source concern, not a verified runtime leak | Verify consumers/runtime ownership, replace blanket ownership with explicit per-file ownership, and recheck target modes | 2026-10-03 | next Darwin activation review | active | -| WA-017 | NPU inference for X-ASR — `home/linux/gui/base/vinput/` | OpenVINO and the NPU plugin load and the daemon opens `/dev/accel/accel0`, but no profiling evidence confirms model computation on the NPU; `npu_busy_time_us` did not increase in the sample test | Remove once per-layer NPU profiling confirms execution on `ai` | 2026-10-04 | when model/provider changes | active | +| ID | What & where | Why | Removal condition | Added | Revisit | Status | +| ------ | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------- | ---------- | -------------------------------------- | ------ | +| WA-012 | `muvm` (x86_64 apps/games via microVM) disabled — `hosts/12kingdoms-shoukei/apple-silicon.nix` | Apple Silicon's 16k page size means x86_64 apps need `muvm`; it is off because it does not build/run on nixos-apple-silicon yet ([nixos-apple-silicon#237](https://github.com/nix-community/nixos-apple-silicon/issues/237)) | Re-enable once `muvm` builds and works | — | on nixos-apple-silicon or muvm updates | active | +| WA-015 | Darwin secret ownership — `secrets/darwin.nix` | Blanket `chown /etc/agenix/*` can override declared root ownership through symlinks; source concern, not a verified runtime leak | Verify consumers/runtime ownership, replace blanket ownership with explicit per-file ownership, and recheck target modes | 2026-10-03 | next Darwin activation review | active | +| WA-016 | subscription rule snapshots — `modules/nixos/desktop/networking/mihomo/` (`rules_from:`) | mihomo's `proxy-providers` imports proxies only, so the `rules` and `proxy-groups` a subscription ships with are dropped by the core; the generator re-imports them from a local YAML copy that has to be refreshed by hand when the provider changes its routing policy | Drop the snapshots once mihomo can import rules from a proxy-provider, or the providers publish their rules as rule-providers | 2026-10-04 | when mihomo gains rule import | active | +| WA-017 | NPU inference for X-ASR — `home/linux/gui/base/vinput/` | OpenVINO and the NPU plugin load and the daemon opens `/dev/accel/accel0`, but no profiling evidence confirms model computation on the NPU; `npu_busy_time_us` did not increase in the sample test | Remove once per-layer NPU profiling confirms execution on `ai` | 2026-10-04 | when model/provider changes | active | ## WIP diff --git a/home/base/tui/ssh.nix b/home/base/tui/ssh.nix index 3e243fcf..8b325c20 100644 --- a/home/base/tui/ssh.nix +++ b/home/base/tui/ssh.nix @@ -25,7 +25,7 @@ settings = { "github.com" = { - # avoid clash fake-IP6 hang, see modules/nixos/desktop/networking/clash-verge.nix + # avoid mihomo fake-IP6 hang, see modules/nixos/desktop/networking/mihomo/ AddressFamily = "inet"; HostName = "ssh.github.com"; Port = 443; diff --git a/home/linux/gui/base/xdg/autostart.nix b/home/linux/gui/base/xdg/autostart.nix index a41c27f5..b633e6b5 100644 --- a/home/linux/gui/base/xdg/autostart.nix +++ b/home/linux/gui/base/xdg/autostart.nix @@ -1,7 +1,5 @@ { pkgs, - pkgs-master, - lib, ... }: { @@ -16,8 +14,6 @@ "${pkgs.ghostty}/share/applications/com.mitchellh.ghostty.desktop" "${pkgs.kitty}/share/applications/kitty.desktop" - "${pkgs-master.clash-verge-rev}/share/applications/clash-verge.desktop" - # nixpaks "${pkgs.nixpaks.firefox}/share/applications/org.mozilla.firefox.desktop" "${pkgs.nixpaks.telegram-desktop}/share/applications/org.telegram.desktop.desktop" diff --git a/hosts/idols-ai/default.nix b/hosts/idols-ai/default.nix index 8f6cb8b8..d2e6a068 100644 --- a/hosts/idols-ai/default.nix +++ b/hosts/idols-ai/default.nix @@ -118,8 +118,8 @@ in IPv6AcceptRA = true; # for Stateless IPv6 Autoconfiguraton (SLAAC) LinkLocalAddressing = "ipv6"; # r8169 drops carrier on S3 resume; without this, networkd tears down the - # static address/route and clash's TUN auto-detect briefly loses its - # outbound interface, leaving routing broken until clash is restarted. + # static address/route and mihomo's TUN auto-detect briefly loses its + # outbound interface, leaving routing broken until mihomo is restarted. IgnoreCarrierLoss = "10s"; }; routes = [ diff --git a/hosts/idols-ai/preservation.nix b/hosts/idols-ai/preservation.nix index 1d7ad79c..3d8f1345 100644 --- a/hosts/idols-ai/preservation.nix +++ b/hosts/idols-ai/preservation.nix @@ -321,9 +321,12 @@ in # Misc # ====================================== - # Clash Verge Rev - ".local/share/io.github.clash-verge-rev.clash-verge-rev" - ".local/share/clash-verge" + # Mihomo (native core): sources spec, generator and config all live here + # (contains subscription secrets, hence 0700). + { + directory = ".config/mihomo"; + mode = "0700"; + } # Orca Slicer - 3D Printer Slicer ".local/share/orca-slicer" diff --git a/modules/nixos/desktop/computer-use.nix b/modules/nixos/desktop/computer-use.nix index 43d22024..30ee1e81 100644 --- a/modules/nixos/desktop/computer-use.nix +++ b/modules/nixos/desktop/computer-use.nix @@ -2,7 +2,6 @@ config, lib, pkgs, - pkgs-master, myvars, ... }: @@ -15,6 +14,8 @@ let timeZone = if cfg.proxyRegion == "jp" then "Asia/Tokyo" else "America/Los_Angeles"; in { + imports = [ ./networking/mihomo ]; # also pulled in by the desktop scanPaths; explicit for clarity + options.modules.desktop.computerUse = { enable = lib.mkEnableOption "headless computer-use environment (X11 + desktop automation)"; proxyRegion = lib.mkOption { @@ -46,13 +47,9 @@ in # Browsers keep their profile/preferences in dconf. programs.dconf.enable = true; - # Clash Verge provides the proxy. Its core is started by the GUI, which is - # launched inside the session; service mode + TUN mode come from the module. - programs.clash-verge = { - enable = true; - package = pkgs-master.clash-verge-rev; - serviceMode = true; - tunMode = true; - }; + # Native mihomo runs the proxy as a system service (TUN), so the headless + # session does not depend on a GUI starting the core. Its config lives at + # ~/.config/mihomo/config.yaml (out of the Nix store). + modules.networking.mihomo.enable = true; }; } diff --git a/modules/nixos/desktop/networking/clash-verge.nix b/modules/nixos/desktop/networking/clash-verge.nix deleted file mode 100644 index 546274f4..00000000 --- a/modules/nixos/desktop/networking/clash-verge.nix +++ /dev/null @@ -1,23 +0,0 @@ -{ pkgs-master, ... }: -# NOTE: There is a known pitfall with mihomo's fake-IP for IPv6 -# (fake-ip-range6: fdfe:dcba:9876::/64): TCP connections to IPv6 fake-IPs are -# not proxied correctly and simply hang until connect() times out. -# Clients without a fast IPv4 fallback stall for ~2 minutes per attempt: -# every git remote operation (fetch/pull/push alike, they all go through ssh) -# hangs on "Connecting to ssh.github.com [fdfe:dcba:9876::x] port 443" -# before falling back to IPv4. -# Browsers recover silently thanks to Happy Eyeballs (RFC 8305: when a name -# resolves to both IPv4 and IPv6, the client tries one stack first, then races -# the other after a ~50ms delay and keeps whichever connects first). -# Fix: disable IPv6 in Clash Verge's DNS/TUN settings (confirmed working); -# or work around per-client, e.g. `AddressFamily inet` in ssh_config -# (see home/base/tui/ssh.nix). -{ - programs.clash-verge = { - enable = true; - package = pkgs-master.clash-verge-rev; - autoStart = false; - serviceMode = true; - tunMode = true; - }; -} diff --git a/modules/nixos/desktop/networking/mihomo/README.md b/modules/nixos/desktop/networking/mihomo/README.md new file mode 100644 index 00000000..581073f2 --- /dev/null +++ b/modules/nixos/desktop/networking/mihomo/README.md @@ -0,0 +1,62 @@ +# Mihomo + +Native mihomo core + metacubexd dashboard, replacing the Clash Verge GUI. Enable per host with +`modules.networking.mihomo.enable = true` (`ai`, `shoukei`, `ruby`, `kana`). + +## 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 +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` | +| `sources.example.yaml` | schema for `sources.yaml` | + +Rule order: `private_domains`, your `rules`, imported rules, then `policy.yaml`, ending in `MATCH`. + +## 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. +- Rule payloads are bare domains: `DOMAIN-SUFFIX,https://qlogo.cn` is invalid. + +## 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. +- [WeChat-under-TUN retrospective](https://x.com/realchendahuang/status/2104381806862795161) — the + IPv6 black hole logic; JD and Taobao break the same way. +- [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`. diff --git a/modules/nixos/desktop/networking/mihomo/default.nix b/modules/nixos/desktop/networking/mihomo/default.nix new file mode 100644 index 00000000..3baabd49 --- /dev/null +++ b/modules/nixos/desktop/networking/mihomo/default.nix @@ -0,0 +1,35 @@ +{ + config, + lib, + myvars, + pkgs, + ... +}: +# Native mihomo core + metacubexd dashboard, replacing the Clash Verge GUI: the +# GUI crashed (SIGFPE) and reopened a fake-IP leak window on every core restart. +# +# The config stays OUT of the Nix store (it holds subscription URLs and the +# controller secret) and reaches the service through systemd's LoadCredential. +# This module only wires up the service; generate the file with ./generate.nu. +# See README.md for usage and the routing notes, in particular why the config +# must keep `ipv6: false`: mihomo does not proxy TCP to an IPv6 fake-IP +# (fdfe:dcba:9876::/64), so ssh/git connections hang until they time out +# (home/base/tui/ssh.nix keeps `AddressFamily inet` as a per-client fallback). +let + cfg = config.modules.networking.mihomo; + configFile = "${config.users.users.${myvars.username}.home}/.config/mihomo/config.yaml"; +in +{ + options.modules.networking.mihomo.enable = + lib.mkEnableOption "the native Mihomo core with TUN and a local web UI"; + + config = lib.mkIf cfg.enable { + services.mihomo = { + enable = true; + package = pkgs.mihomo; + inherit configFile; + webui = pkgs.metacubexd; + tunMode = true; + }; + }; +} diff --git a/modules/nixos/desktop/networking/mihomo/generate.nu b/modules/nixos/desktop/networking/mihomo/generate.nu new file mode 100644 index 00000000..4a201d24 --- /dev/null +++ b/modules/nixos/desktop/networking/mihomo/generate.nu @@ -0,0 +1,572 @@ +#!/usr/bin/env nu +# Generate ~/.config/mihomo/config.yaml from ~/.config/mihomo/sources.yaml: +# +# nu generate.nu [-s sources.yaml] [-o config.yaml] +# +# sources.yaml is the only file you edit. It holds secrets, so keep it mode 0600 +# and never commit it. README.md explains why the config is generated and what +# the keys mean; sources.example.yaml is the schema. +# --------------------------------------------------------------------------- + +const HEALTH_URL = "https://www.gstatic.com/generate_204" + +# The portable tail -- dns fake-ip-filter entries, optional rule-providers and +# the rules that close every config -- lives in policy.yaml next to this script. + +# --------------------------------------------------------------------------- +# small helpers +# --------------------------------------------------------------------------- + +def fail [msg: string] { + error make { msg: $msg } +} + +# drop keys whose value is null or an empty list, to keep the output readable +def clean [rec: record] { + let drop = ( + $rec + | columns + | where { |c| + let v = ($rec | get $c) + if ($v == null) { + true + } else if (($v | describe) | str starts-with "list") { + ($v | length) == 0 + } else { + false + } + } + ) + if ($drop | is-empty) { + $rec + } else { + $rec | reject ...$drop + } +} + +def dupes [names: list] { + $names + | group-by + | items { |k, v| if (($v | length) > 1) { $k } else { null } } + | where { |x| $x != null } +} + +def node-names [doc: record] { + $doc + | get -o proxies + | default [] + | each { |p| ($p | get -o name) } + | where { |n| $n != null } +} + +# mihomo has no wildcard rule type, so a private_domains entry is normalized to +# a bare domain: `DOMAIN-SUFFIX` already matches the apex and every label below +# it, which is what `*.lab.example.com` means anyway. +def private_bare [d: string] { + let bare = (($d | str trim) | str replace --all --regex '^\*\.' "") + if ($bare | str contains "*") { + fail $"private_domains entry '($d)': mihomo has no wildcard rules, use a plain domain or a leading '*.'" + } + $bare +} + +def private-rule [d: string] { + let bare = (private_bare $d) + if ($bare | is-empty) { + return null + } + $"DOMAIN-SUFFIX,($bare),DIRECT" +} + +# fake-ip-filter entry: `+.d` matches the apex and all labels +def private-filter [d: string] { + let bare = (private_bare $d) + if ($bare | is-empty) { + return null + } + $"+.($bare)" +} + +# rewrite whole comma-separated fields, so both `...,` and composite +# rules such as `AND,(...),` get remapped +def rewrite-rule [rule: string, map: record] { + $rule + | split row "," + | each { |part| + let t = ($part | str trim) + if ($t in $map) { $map | get $t } else { $part } + } + | str join "," +} + +# the group or policy a rule sends traffic to: always the last field, except +# that `no-resolve` trails it for IP rules. Composite rules keep the target +# last as well, so splitting on commas is safe here. +def rule-target [rule: string] { + mut parts = ($rule | split row "," | each { |p| $p | str trim }) + if (($parts | last | default "" | str lowercase) == "no-resolve") { + $parts = ($parts | slice 0..(($parts | length) - 2)) + } + $parts | last | default "" +} + +# Rules copied out of a browser or a chat log often carry a scheme, e.g. +# `DOMAIN-SUFFIX,https://qlogo.cn,DIRECT`; mihomo rejects that. A rule split +# across two lines (`DOMAIN-SUFFIX,https://qlogo.cn` then `,DIRECT`) becomes two +# invalid entries. Fix the scheme, drop what is still broken, and say so. +# +# A `MATCH` rule from a source or from `sources.yaml` is dropped: it is a +# catch-all, so keeping one there would shadow every policy rule after it. +# policy.yaml may keep one, but only as its last rule, which is where the +# catch-all belongs. +def sanitize-rules [rules: list, who: string, keep_match: bool = false] { + mut ok = [] + mut bad = [] + let last = (($rules | length) - 1) + for e in ($rules | enumerate) { + let i = $e.index + let raw = ($e.item | into string | str trim) + if ($raw | is-empty) { + continue + } + let fixed = ($raw | str replace --all --regex '(?i)\bhttps?://' "") + let parts = ($fixed | split row ",") + let head = ($parts | first | default "" | str trim | str uppercase) + let tail = ($parts | last | default "" | str trim) + if ($head == "MATCH") and ($keep_match and ($i == $last)) { + $ok = ($ok ++ [$fixed]) + } else if ($head == "MATCH") { + $bad = ($bad ++ [$"($who): catch-all dropped, it belongs last: ($raw)"]) + } else if (($parts | length) < 3) or ($head | is-empty) or ($tail | is-empty) { + $bad = ($bad ++ [$"($who): malformed rule dropped: ($raw)"]) + } else { + $ok = ($ok ++ [$fixed]) + } + } + { ok: $ok, bad: $bad } +} + +# PROCESS-* rules and tun.exclude-process both need the core to look up the +# owning process, which `find-process-mode: off` disables. +def process_mode [rules: list, excluded: list] { + let needs = ( + ($rules | any { |r| ($r | str uppercase | str contains "PROCESS-") }) + or (($excluded | length) > 0) + ) + if $needs { + "always" + } else { + "off" + } +} + +# --------------------------------------------------------------------------- +# importing groups / rules / rule-providers from a local YAML copy +# --------------------------------------------------------------------------- + +# Node names are replaced by a provider reference so that a subscription +# refresh (nodes come and go) cannot break the imported groups. A group named +# exactly like its provider is dropped and remapped onto the " 节点" +# group this generator already builds, because a proxy-provider and a +# proxy-group cannot share a name. +def import-doc [doc: record, provider: string] { + let names = (node-names $doc) + let own_group = $"($provider) 节点" + # `直连` is the conventional Chinese name for a built-in DIRECT node in these + # configs; inline proxy nodes are not imported, so point such references at + # the DIRECT policy instead -- unless the document defines its own 直连 group. + let declared_groups = ($doc | get -o proxy-groups | default [] | each { |g| ($g | get -o name | default "") | str trim }) + let map = ( + if ("直连" in $declared_groups) { + { ($provider): $own_group } + } else { + { ($provider): $own_group, "直连": "DIRECT" } + } + ) + + let groups = ( + $doc + | get -o proxy-groups + | default [] + | where { |g| (($g | get -o name | default "") | str trim) != $provider } + | each { |g| + let declared = ($g | get -o proxies | default []) + let kept = ( + $declared + | each { |x| + let t = ($x | into string | str trim) + if ($t in $map) { $map | get $t } else { $x } + } + | where { |x| not (($x | into string) in $names) } + ) + let refs = ($declared | where { |x| ($x | into string) in $names }) + let use_list = (($g | get -o use | default []) ++ (if ($refs | is-empty) { [] } else { [$provider] })) + clean ($g | upsert 'proxies' $kept | upsert 'use' $use_list) + } + | where { |g| + let np = (($g | get -o proxies | default []) | length) + let n_use = (($g | get -o use | default []) | length) + ($np > 0) or ($n_use > 0) or (($g | get -o include-all | default false) == true) + } + ) + + let rules = (sanitize-rules ($doc | get -o rules | default []) $"($provider) snapshot") + # a rule pointing at a group this generator dropped would make the core refuse + # the whole config, so drop the rule and report it instead + let kept = ($groups | each { |g| $g.name }) + let dropped = ( + $doc + | get -o proxy-groups + | default [] + | each { |g| ($g | get -o name | default "") | str trim } + # PROXY/AUTO always exist in the output, so rules targeting them are never orphans + | where { |n| (not ($n in $kept)) and ($n != $provider) and ($n not-in ["PROXY", "AUTO"]) } + ) + mut final_rules = [] + mut orphans = [] + for r in ($rules.ok | each { |x| rewrite-rule $x $map }) { + let target = (rule-target $r) + if ($target in $dropped) { + $orphans = ($orphans ++ [$"($provider) snapshot: rule target has no group, dropped: ($r)"]) + } else { + $final_rules = ($final_rules ++ [$r]) + } + } + { + groups: $groups + rules: $final_rules + bad_rules: ($rules.bad ++ $orphans) + "rule-providers": ($doc | get -o rule-providers | default {}) + } +} + +def open-doc [path: string, who: string] { + let f = ($path | path expand) + if not ($f | path exists) { + fail $"($who): file not found: ($f)" + } + let doc = ( + try { + open $f + } catch { + fail $"($who): ($f) is not readable as YAML/JSON. Save the decoded subscription, the provider's Clash YAML, not a base64 payload." + } + ) + if (($doc | describe) | str starts-with "record") { + $doc + } else { + fail $"($who): ($f) has no top-level mapping" + } +} + +# --------------------------------------------------------------------------- +# providers +# --------------------------------------------------------------------------- + +def health-check [] { + { enable: true, url: $HEALTH_URL, interval: 300 } +} + +# -> { name, provider, groups, rules, bad_rules, rule-providers } +def build-entry [p: record] { + let name = ($p | get -o name | default "" | str trim) + if ($name | is-empty) { + fail "every provider needs a `name`" + } + let url = ($p | get -o url) + let file = ($p | get -o file) + let rules_from = ($p | get -o rules_from) + + if ($url != null) { + let provider = { + type: "http" + url: $url + interval: ($p | get -o interval | default 86400) + path: $"./providers/($name).yaml" + "health-check": (health-check) + } + let imported = ( + if ($rules_from != null) { + import-doc (open-doc $rules_from $"provider ($name)") $name + } else { + { groups: [], rules: [], bad_rules: [], "rule-providers": {} } + } + ) + { + name: $name + provider: $provider + groups: $imported.groups + rules: $imported.rules + bad_rules: $imported.bad_rules + "rule-providers": $imported."rule-providers" + } + } else if ($file != null) { + # a local YAML file: inline its proxies (the service user cannot read files + # under /home), and carry over its groups, rules and rule-providers too + let doc = (open-doc $file $"provider ($name)") + let payload = ($doc | get -o proxies) + if (($payload == null) or (($payload | describe) | str starts-with "list") and (($payload | length) == 0)) { + fail $"provider ($name): ($file) has no top-level `proxies:` list" + } + let provider = { + type: "inline" + "health-check": (health-check) + payload: $payload + } + let imported = (import-doc $doc $name) + { + name: $name + provider: $provider + groups: $imported.groups + rules: $imported.rules + bad_rules: $imported.bad_rules + "rule-providers": $imported."rule-providers" + } + } else { + fail $"provider ($name): set either `url` or `file`" + } +} + +# --------------------------------------------------------------------------- +# main +# --------------------------------------------------------------------------- + +def main [ + --sources (-s): path = "~/.config/mihomo/sources.yaml" + --out (-o): path = "~/.config/mihomo/config.yaml" + --policy (-p): path +] { + let src = ($sources | path expand) + let out = ($out | path expand) + let pol = ( + if $policy == null { + ($env.FILE_PWD | path join "policy.yaml") + } else { + $policy | path expand + } + ) + if not ($src | path exists) { + fail $"sources file not found: ($src) -- copy sources.example.yaml to sources.yaml first" + } + if not ($pol | path exists) { + fail $"policy file not found: ($pol)" + } + + # the portable tail: fake-ip-filter entries, optional rule-providers, rules + let policy_doc = (open $pol) + let policy_filters = ($policy_doc | get -o dns_filters | default []) + 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 []) + + let spec = (open $src) + # an empty secret leaves the dashboard open to anything on the machine + if (($spec | get -o secret | default "") | is-empty) { + fail "sources.yaml: 'secret' is required -- it protects the dashboard at 127.0.0.1:9090" + } + let declared = ($spec | get -o providers | default []) + if ($declared | is-empty) { + print --stderr "warning: no providers defined; everything will go DIRECT" + } + + let names = ($declared | each { |p| ($p | get -o name | default "") }) + let name_dups = (dupes $names) + if ($name_dups | is-not-empty) { + fail $"duplicate provider names: ($name_dups | str join ', ')" + } + + let entries = ($declared | each { |p| build-entry $p }) + + let providers_map = ( + $entries + | reduce --fold {} { |it, acc| $acc | insert $it.name ($it.provider) } + ) + + # groups: entry point, per-region auto+manual, an "everything else" and an + # "all nodes" manual select, one select per provider, plus everything the + # sources declare. `filter`/`exclude-filter` are Go regexes on the node name; + # region auto groups are `lazy` so unselected regions cost no health checks. + let per_provider = ($names | each { |n| { name: $"($n) 节点", type: "select", use: [$n] } }) + let region_groups = ( + $node_groups + | each { |g| + let base = ($g | get name) + [ + { name: $"($base) 自动", type: "url-test", use: $names, filter: ($g | get filter), lazy: true, url: $HEALTH_URL, interval: 300 } + { name: $"($base) 手动", type: "select", use: $names, filter: ($g | get filter) } + ] + } + | flatten + ) + let other_groups = ( + if ($node_groups | is-empty) { + [] + } else { + let joined = ($node_groups | each { |g| ($g | get filter) | str replace --all "(?i)" "" } | str join "|") + let excl = "(?i)(" + $joined + ")" + [{ name: "🌍 其他地区", type: "select", use: $names, "exclude-filter": $excl }] + } + ) + let all_group = [{ name: "🌐 全部地区", type: "select", use: $names }] + let generated_groups = ($region_groups ++ $other_groups ++ $all_group) + let imported_groups = ($entries | each { |e| $e.groups } | flatten) + # an imported group with the same name wins; dropping ours still leaves a + # usable config, failing would not + let taken = ( + ["PROXY", "AUTO"] ++ ($per_provider | get name) ++ ($imported_groups | each { |g| $g.name }) + ) + let region_kept = ($generated_groups | where { |g| $g.name not-in $taken }) + let region_skipped = ($generated_groups | where { |g| $g.name in $taken } | each { |g| $g.name }) + let groups = ( + [ + { + name: "PROXY" + type: "select" + proxies: (["AUTO"] ++ ($region_kept | get name) ++ ($per_provider | get name) ++ ["DIRECT"]) + } + { name: "AUTO", type: "url-test", use: $names, url: $HEALTH_URL, interval: 300 } + ] + ++ $region_kept + ++ $per_provider + ++ $imported_groups + ) + let group_dups = (dupes ($groups | each { |g| $g.name })) + if ($group_dups | is-not-empty) { + fail $"duplicate proxy-group names: ($group_dups | str join ', ')" + } + + # rule-providers: the policy file first, then every source document. + # `insert` would abort with an internal nushell trace on a collision, so + # detect duplicates across documents first and report the names. + let rp_docs = ([$policy_rp] ++ ($entries | each { |e| $e."rule-providers" })) + let rp_dups = (dupes ($rp_docs | each { |d| $d | columns } | flatten)) + if ($rp_dups | is-not-empty) { + fail $"duplicate rule-provider names: ($rp_dups | str join ', ')" + } + let rule_providers = ( + $rp_docs + | reduce --fold {} { |it, acc| + $it | columns | reduce --fold $acc { |k, a| $a | insert $k ($it | get $k) } + } + ) + + let priv = ($spec | get -o private_domains | default []) + let priv_rules = ($priv | each { |d| private-rule $d } | where { |x| $x != null }) + let priv_filters = ($priv | each { |d| private-filter $d } | where { |x| $x != null }) + let user_rules = (sanitize-rules ($spec | get -o rules | default []) "sources.rules") + let imported_rules = ($entries | each { |e| $e.rules } | flatten) + + let excluded = ($spec | get -o tun_exclude_process | default []) + # evaluation order: private hosts, hand-written rules, provider rules, policy tail + let body_rules = ($priv_rules ++ $user_rules.ok ++ $imported_rules ++ $policy_rules.ok) + # a RULE-SET pointing at a rule-provider nobody declared, or a target that + # is not one of the final groups, makes the core refuse the whole config; + # drop the rule and report it. This pass sees every source at once, so it + # also catches targets dangling across documents. + let rp_names = ($rule_providers | columns) + let valid_targets = (($groups | each { |g| $g.name }) ++ ["DIRECT", "PROXY", "REJECT", "PASS"]) + mut kept_rules = [] + mut rp_missing = [] + for r in $body_rules { + let parts = ($r | split row ",") + let ref = ($parts | get -o 1 | default "") + let target = (rule-target $r) + if (($parts | first | str uppercase) == "RULE-SET") and ($ref not-in $rp_names) { + $rp_missing = ($rp_missing ++ [$"rule set '($ref)' is not declared, dropped: ($r)"]) + } else if ($target not-in $valid_targets) { + $rp_missing = ($rp_missing ++ [$"rule target '($target)' has no group, dropped: ($r)"]) + } else { + $kept_rules = ($kept_rules ++ [$r]) + } + } + let bad_rules = (($entries | each { |e| $e.bad_rules } | flatten) ++ $user_rules.bad ++ $policy_rules.bad ++ $rp_missing) + # the core needs a catch-all; policy.yaml normally supplies it as its last rule + let has_catch_all = ($kept_rules | last | default "" | str uppercase | str starts-with "MATCH") + let all_rules = ($kept_rules ++ (if $has_catch_all { [] } else { ["MATCH,PROXY"] })) + let fpm = (process_mode $all_rules $excluded) + + let config = { + # IPv6 stays off on purpose: most proxy nodes have no IPv6 egress, so an + # AAAA record becomes a routing black hole that apps such as WeChat, JD, + # Taobao and Feishu sit on until they fall back to IPv4. + ipv6: false + mode: "rule" + "mixed-port": 7897 + "allow-lan": false + "log-level": "warning" + "unified-delay": true + tcp-concurrent: true + "find-process-mode": $fpm + "external-controller": "127.0.0.1:9090" + secret: ($spec | get -o secret | default "") + "external-controller-cors": { + "allow-private-network": true + "allow-origins": ["http://127.0.0.1:9090", "http://localhost:9090"] + } + profile: { "store-selected": true, "store-fake-ip": true } + tun: ( + clean ({ + enable: true + stack: "gvisor" + "auto-route": true + "strict-route": ($spec | get -o tun_strict_route | default true) + "auto-detect-interface": true + "dns-hijack": ["any:53"] + } | upsert 'exclude-process' $excluded) + ) + dns: { + enable: true + # port 53 is unreachable here: the unit runs DynamicUser with only + # CAP_NET_ADMIN, so a privileged bind fails. TUN's dns-hijack covers + # resolution; this listener is a loopback convenience only. + listen: "127.0.0.1:1053" + ipv6: false + "enhanced-mode": "fake-ip" + "fake-ip-range": "198.18.0.1/16" + "fake-ip-filter-mode": "blacklist" + "prefer-h3": false + "respect-rules": false + "use-hosts": false + "use-system-hosts": false + "default-nameserver": ["223.5.5.5" "119.29.29.29" "223.6.6.6"] + nameserver: ["223.5.5.5" "119.29.29.29" "https://dns.alidns.com/dns-query"] + "proxy-server-nameserver": ["223.5.5.5" "https://dns.alidns.com/dns-query" "tls://223.5.5.5"] + "fake-ip-filter": (($priv_filters | append $policy_filters) | uniq) + } + "geodata-mode": false + "geo-auto-update": true + "geo-update-interval": 24 + "geox-url": { + 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" + } + "proxy-providers": $providers_map + "proxy-groups": $groups + "rule-providers": $rule_providers + rules: ($all_rules | uniq) + } + + if not ($out | path dirname | path exists) { + mkdir ($out | path dirname) + } + $config | to yaml | save --force $out + ^chmod 600 $out + + let breakdown = $"private ($priv_rules | length), user ($user_rules.ok | length), imported ($imported_rules | length), policy ($policy_rules.ok | length)" + print $"wrote ($out)" + print $" providers: ($names | str join ', ')" + print $" proxy-groups: ($groups | length)" + print $" rule-providers: ($rule_providers | columns | length)" + print $" rules: (($config | get rules) | length) -- ($breakdown)" + print $" find-process-mode: ($fpm)" + if ($region_skipped | is-not-empty) { + print --stderr $"warning: region groups skipped, an imported group already uses: ($region_skipped | str join ', ')" + } + if ($bad_rules | is-not-empty) { + print --stderr $"warning: dropped ($bad_rules | length) rules:" + for b in $bad_rules { + print --stderr $" ($b)" + } + } +} diff --git a/modules/nixos/desktop/networking/mihomo/policy.yaml b/modules/nixos/desktop/networking/mihomo/policy.yaml new file mode 100644 index 00000000..61802632 --- /dev/null +++ b/modules/nixos/desktop/networking/mihomo/policy.yaml @@ -0,0 +1,97 @@ +# Portable routing policy: nothing provider-specific lives here. +# +# Order matters. The generator appends these last, after the `private_domains` +# rules, the rules from `sources.yaml` and the rules imported from your +# subscriptions, so this file is the tail that closes every config. +# +# GEOSITE categories come from meta-rules-dat, which the module keeps current +# (`geo-auto-update`). `mihomo -t` validates the names: `taobao`, `alipay` and +# `wechat` do not exist in it, hence the hand-written domains below. + +# Region node groups. Each entry becomes two groups offered inside PROXY: +# " 自动" (url-test, lazy: only health-checked once selected) and +# " 手动" (select), both over the provider nodes whose name matches +# `filter` (Go regex, case-insensitive). The generator also adds "🌍 其他地区" +# (manual, everything not matched below) and "🌐 全部地区" (manual, all nodes); +# AUTO is the all-node auto pick. Edit the regions freely; the single-char +# keywords (港/台/日/新/美/韩) deliberately catch naming variants like +# 美西, 日服 and 狮城. +node_groups: + - name: "🇭🇰 香港" + filter: "(?i)(港|hk|hong ?kong|🇭🇰)" + - name: "🇹🇼 台湾" + filter: "(?i)(台|tw|taiwan|🇹🇼)" + - name: "🇯🇵 日本" + filter: "(?i)(日|jp|japan|东京|🇯🇵)" + - name: "🇸🇬 新加坡" + filter: "(?i)(新|狮城|sg|singapore|🇸🇬)" + - name: "🇰🇷 韩国" + filter: "(?i)(韩|kr|korea|🇰🇷)" + - name: "🇺🇸 美国" + filter: "(?i)(美|usa|us[ -]|united states|america|🇺🇸)" + +# fake-ip-filter: these must resolve to real IPs, never a fake one. Tencent's +# multimedia hosts matter because WeChat/QQ fetch images over bare CDN IPs. +dns_filters: + - "*.lan" + - "*.local" + - "*.arpa" + - localhost.ptlogin2.qq.com + - localhost.sec.qq.com + - localhost.work.weixin.qq.com + - multimedia.nt.qq.com.cn + - time.*.com + - ntp.*.com + - "*.msftncsi.com" + - www.msftconnecttest.com + - +.market.xiaomi.com + - "*.tailscale.com" + +# Optional extra rule-sets, usable by the rules below: +# rule_providers: +# my_list: +# type: http +# behavior: domain +# format: text +# url: "https://example.invalid/list.txt" +# path: ./rulesets/my_list.txt +# interval: 86400 +rule_providers: {} + +rules: + - GEOSITE,private,DIRECT + - GEOSITE,category-ads-all,REJECT + + # CN consumer services: proxied they fail in ways that look like app bugs -- + # WeChat pictures spin, JD and Taobao product images go blank. Much of it is + # served over bare CDN IPs and IPv6, so pin the whole category DIRECT. + - GEOSITE,tencent,DIRECT + - GEOSITE,bilibili,DIRECT + - GEOSITE,jd,DIRECT + - DOMAIN-SUFFIX,taobao.com,DIRECT + - DOMAIN-SUFFIX,tmall.com,DIRECT + - DOMAIN-SUFFIX,alicdn.com,DIRECT + - DOMAIN-SUFFIX,360buyimg.com,DIRECT + - DOMAIN-SUFFIX,feishu.cn,DIRECT + # WeChat/QQ image CDNs spelled out too, since they are fetched by IP + - DOMAIN-SUFFIX,qlogo.cn,DIRECT + - DOMAIN-SUFFIX,qpic.cn,DIRECT + - DOMAIN-SUFFIX,gtimg.com,DIRECT + - DOMAIN-SUFFIX,gtimg.cn,DIRECT + - DOMAIN-SUFFIX,gtimg.com.cn,DIRECT + + # local ranges, by address as well as by name + - DOMAIN-SUFFIX,lan,DIRECT + - IP-CIDR,0.0.0.0/8,DIRECT,no-resolve + - IP-CIDR,10.0.0.0/8,DIRECT,no-resolve + - IP-CIDR,100.64.0.0/10,DIRECT,no-resolve + - IP-CIDR,127.0.0.0/8,DIRECT,no-resolve + - IP-CIDR,172.16.0.0/12,DIRECT,no-resolve + - IP-CIDR,192.168.0.0/16,DIRECT,no-resolve + - IP-CIDR,224.0.0.0/4,DIRECT,no-resolve + - IP-CIDR6,fe80::/10,DIRECT,no-resolve + + # everything else that is CN by geolocation, then the catch-all + - GEOSITE,cn,DIRECT + - GEOIP,CN,DIRECT + - MATCH,PROXY diff --git a/modules/nixos/desktop/networking/mihomo/sources.example.yaml b/modules/nixos/desktop/networking/mihomo/sources.example.yaml new file mode 100644 index 00000000..c76f5b44 --- /dev/null +++ b/modules/nixos/desktop/networking/mihomo/sources.example.yaml @@ -0,0 +1,44 @@ +# Copy to ~/.config/mihomo/sources.yaml, fill it in, then run `nu generate.nu`. +# Keep it mode 0600 and never commit it: subscription URLs, the dashboard +# password and private domain names all live here. +# +# The portable routing policy (ads, CN consumer services, local ranges) is not +# here -- it lives in policy.yaml, which is safe to keep in the repository. + +# Password for the dashboard at http://127.0.0.1:9090/ui +secret: "replace-me" + +# Domains that must never be proxied or fake-IPed: each becomes a DIRECT rule +# plus a fake-ip-filter entry, so it resolves to a real IP. `*.lab.example.com` +# is an alias for the plain form; any other `*` placement is rejected. +private_domains: + # - private.example.com + # - "*.lab.example.com" + +# Extra rules, before the ones the sources bring in. Payloads are bare domains, +# never `https://…`. A `MATCH` here is dropped: policy.yaml closes the list. +rules: + # - DOMAIN-SUFFIX,download.example.com,PROXY + # - PROCESS-NAME,some-app,DIRECT + +# Keep on unless an app misbehaves under TUN (WeChat and office suites usually). +tun_strict_route: true + +# Processes kept off TUN entirely; also re-enables find-process-mode, which +# PROCESS-* rules need. +tun_exclude_process: + # - some-app + +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 + # provider ships policy you actually need -- see README.md. + - name: provider-a + url: "https://example.invalid/subscribe/replace-me" + # interval: 86400 + # rules_from: "~/.config/mihomo/snapshots/provider-a.yaml" + + # A local Clash YAML file: proxies are inlined (the service user cannot read + # /home), and its groups, rules and rule-providers are carried over too. + - name: provider-b + file: "~/work/example/client-proxy.yaml" diff --git a/outputs/aarch64-linux/src/12kingdoms-shoukei.nix b/outputs/aarch64-linux/src/12kingdoms-shoukei.nix index 74c80f2a..37b863a9 100644 --- a/outputs/aarch64-linux/src/12kingdoms-shoukei.nix +++ b/outputs/aarch64-linux/src/12kingdoms-shoukei.nix @@ -34,6 +34,9 @@ let # not supported yet modules.desktop.gaming.enable = false; modules.desktop.canokey.enable = true; + # Native mihomo core + local web dashboard (config is + # ~/.config/mihomo/config.yaml, out of the store). + modules.networking.mihomo.enable = true; } ]; home-modules = map mylib.relativeToRoot [ diff --git a/outputs/default.nix b/outputs/default.nix index 0cd143c9..59231dae 100644 --- a/outputs/default.nix +++ b/outputs/default.nix @@ -190,7 +190,9 @@ in settings = { write = true; # Automatically fix typos configPath = ".typos.toml"; # relative to the flake root - exclude = "rime-data/"; + # git-hooks passes paths on the command line, which makes typos + # ignore .typos.toml's extend-exclude, so repeat it here + exclude = "rime-data/|overlays/fcitx5/flypy_user.txt"; }; }; prettier = { diff --git a/outputs/x86_64-linux/src/idols-ai.nix b/outputs/x86_64-linux/src/idols-ai.nix index 614944a5..8764d595 100644 --- a/outputs/x86_64-linux/src/idols-ai.nix +++ b/outputs/x86_64-linux/src/idols-ai.nix @@ -34,6 +34,9 @@ let modules.secrets.preservation.enable = true; modules.desktop.gaming.enable = true; modules.desktop.canokey.enable = true; + # Native mihomo core + local web dashboard instead of the Clash Verge + # GUI; its config is ~/.config/mihomo/config.yaml (out of the store). + modules.networking.mihomo.enable = true; } ]; home-modules = map mylib.relativeToRoot [ diff --git a/outputs/x86_64-linux/tests/computer-use-headless/expected.nix b/outputs/x86_64-linux/tests/computer-use-headless/expected.nix index c4bb8f7f..145a9e34 100644 --- a/outputs/x86_64-linux/tests/computer-use-headless/expected.nix +++ b/outputs/x86_64-linux/tests/computer-use-headless/expected.nix @@ -11,7 +11,7 @@ in lib.genAttrs hosts (name: { computerUse = true; atspi = true; - clashVerge = true; + mihomo = true; timeZone = "America/Los_Angeles"; xvfbRestart = "always"; i3Restart = "always"; diff --git a/outputs/x86_64-linux/tests/computer-use-headless/expr.nix b/outputs/x86_64-linux/tests/computer-use-headless/expr.nix index 22cc34e5..8195c094 100644 --- a/outputs/x86_64-linux/tests/computer-use-headless/expr.nix +++ b/outputs/x86_64-linux/tests/computer-use-headless/expr.nix @@ -19,7 +19,7 @@ lib.genAttrs hosts ( { computerUse = system.modules.desktop.computerUse.enable; atspi = system.services.gnome.at-spi2-core.enable; - clashVerge = system.programs.clash-verge.enable; + mihomo = system.services.mihomo.enable; timeZone = system.time.timeZone; xvfbRestart = home.systemd.user.services.xvfb.Service.Restart; i3Restart = home.systemd.user.services.i3.Service.Restart;