feat(desktop): replace Clash Verge with native mihomo on all NixOS hosts (#379)

This commit is contained in:
ryan4yin | 二花 authored and GitHub committed 2026-10-04 21:04:41 +08:00
1 parent 0a672060a4
commit bbfe1fa53b
18 files changed
+845 -50

No files matched your search

+3
View File
@@ -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]
+6 -5
View File
@@ -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
+1 -1
View File
@@ -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;
-4
View File
@@ -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"
+2 -2
View File
@@ -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 = [
+6 -3
View File
@@ -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"
+6 -9
View File
@@ -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;
};
}
@@ -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;
};
}
@@ -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`.
@@ -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;
};
};
}
@@ -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<string>] {
$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 `...,<group>` and composite
# rules such as `AND,(...),<group>` 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<any>, 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<string>, excluded: list<string>] {
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 "<name> 节点"
# 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)"
}
}
}
@@ -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:
# "<name> 自动" (url-test, lazy: only health-checked once selected) and
# "<name> 手动" (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
@@ -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"
@@ -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 [
+3 -1
View File
@@ -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 = {
+3
View File
@@ -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 [
@@ -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";
@@ -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;