Initial release v0.1.0

This commit is contained in:
Lihatoo 2026-08-21 18:34:18 +08:00
commit b997bd6078
62 changed files with 56224 additions and 0 deletions

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
target/
.**/
profile/

1956
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

15
Cargo.toml Normal file
View file

@ -0,0 +1,15 @@
[package]
name = "tz"
version = "0.1.0"
edition = "2024"
[dependencies]
clap = { version = "4.6.5", features = ["derive"] }
fs2 = "0.4.3"
jiff = "0.2.35"
reqwest = { version = "0.13.4", default-features = false, features = ["blocking", "rustls"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0.151"
serde_yaml = "0.9.34"
tempfile = "3.27.0"
toml = "0.8"

188
README-en.md Normal file
View file

@ -0,0 +1,188 @@
[中文](./README.md) | [English](./README-en.md)
# TZ
TZ is a terminal proxy manager for unified management of Mihomo, the sing-box core, subscription profiles, node latency tests, TUN, and terminal/system proxies. The current version is `v0.1.0` and supports Linux x86_64.
## Installation
### Cargo
The Rust 2024 edition toolchain is required. Install the release version from the repository directory:
```bash
git clone https://github.com/Lihatoo/TZ.git
cd TZ
cargo install --path .
```
The default installation path is `~/.cargo/bin/tz`. Make sure `~/.cargo/bin` is included in your `PATH`.
### Release binary
You can also download `tz` from Releases, make it executable, and place it in a directory on your `PATH`, such as `~/.local/bin`.
## Quick Start
Initialize the directories before using TZ for the first time:
```bash
tz init
```
The default path configuration is located at `~/.config/tz/paths.toml`. To use a custom location, set `TZ_PATHS_TOML` as prompted during initialization.
Import the core directories prepared in the repository:
```bash
tz core add ./cores/mihomo
tz core add ./cores/sing-box
tz core list
tz core use mihomo
```
The argument to `tz core add` must be a complete directory containing `core.toml` and the binary; it cannot be just the binary file. The two built-in cores correspond as follows:
| core | profile family | profile format |
| --- | --- | --- |
| `mihomo` | `clash` | YAML |
| `sing-box` | `sing-box` | JSON |
Add and select profiles:
```bash
tz profile add nano-clash '<subscription URL or local file>' --family clash
tz profile add nano-sb '<subscription URL or local file>' --family sing-box
tz profile list
```
By default, `tz profile list` only lists families supported by the current core. In an interactive terminal, enter a number to select a profile directly; `*` marks the current profile. Use `tz profile list --all` to view all families. Profile names must be unique across all families. Adding a `-clash` or `-sb` suffix is recommended for easier identification.
Start TZ and view its status:
```bash
tz on
tz
```
`tz on` uses the last valid profile selected for the current core. To switch the core or profile, run `tz off` first. Switching is rejected while TZ is running to prevent the recorded state from diverging from the actual process.
## Nodes and Proxies
```bash
tz -l # Test all nodes, sort by latency, and select interactively
tz -l hk # Search, test, and select nodes whose names contain hk
tz node test --select # Test nodes and automatically select the fastest one
```
Terminal proxies must be `eval`-ed in the current shell to take effect:
```bash
eval "$(tz proxy env bash)"
eval "$(tz proxy noenv bash)"
```
For Zsh or Fish, replace the trailing `bash` with the corresponding shell. You can also install a shell hook so that `tz proxy terminal on|off` can modify the current shell:
```bash
eval "$(tz proxy shell-init bash)"
```
After the core starts, you can control the GNOME system proxy or control both the terminal and system proxies:
```bash
tz proxy system on
tz proxy system off
tz proxy on
tz proxy off
```
TUN is independent of the proxy switches above. Its configuration is checked after changes, and the service restarts automatically while it is running:
```bash
tz tun status
tz tun on
tz tun off
```
Enabling TUN requires `/dev/net/tun` to exist on the system. Grant the current core binary `CAP_NET_ADMIN`/`CAP_NET_RAW` as instructed by any command errors.
## Profile Download and Updates
For remote profiles, TZ attempts downloads both through an existing TZ proxy and via a direct connection. As long as either route succeeds, the successful route is recorded as `download_via`. Use `tz profile info <name>` to view this information; URLs are stored only in the local profile index and are hidden from command output.
Download requests use the corresponding client's User-Agent for each family. TZ only validates and manages the original formats; it does not convert Clash YAML to sing-box JSON or vice versa.
```bash
tz profile update # Update all remote profiles
tz profile info nano-sb
tz profile remove nano-sb
```
If neither a direct connection nor the current TZ proxy can download a profile, start an available TZ profile first, or temporarily enable another proxy and retry.
`Country.mmdb` and `GeoSite.dat` in the Mihomo core directory are GEOIP/GEOSITE rule databases, not plugins that each user needs to install separately. When a profile uses the corresponding rules, TZ copies these files into the runtime directory to prevent Mihomo from attempting a temporary download from GitHub at startup.
## Shell Completion
Enable completion temporarily in the current shell:
```bash
# Bash
eval "$(tz completion generate bash)"
# Zsh
eval "$(tz completion generate zsh)"
# Fish
tz completion generate fish | source
```
To enable completion permanently, add the corresponding command to your shell's startup file.
## Full Commands
```text
tz status|start|stop|restart
tz list [keyword]
tz node test [keyword] [--url <url>] [--timeout <ms>] [--select]
tz tun status|on|off
tz proxy status|on|off
tz proxy terminal|system status|on|off
tz proxy env|noenv [bash|zsh|fish]
tz proxy shell-init bash|zsh|fish
tz setting [list|get|set|reset]
tz profile add|list|info|use|update|remove
tz core add|list|info|use|remove
tz config build|check|show
tz completion generate bash|zsh|fish
```
## Short Commands
```bash
tz # Show status and test the current node
tz on # Start with the last valid profile and show status
tz off # Stop
tz -l [keyword] # Test nodes, sort by latency, search, and select
tz select # List and select a profile from the current family
```
## Shortcuts
Shortcuts are abbreviations for the full commands:
```text
tz st -> tz status
tz r -> tz restart
tz end -> tz stop
tz set -> tz setting
tz p -> tz profile
tz c -> tz core
tz cfg -> tz config
tz comp -> tz completion
tz p a|l|i|u|up|rm -> add|list|info|use|update|remove
tz c a|l|i|u|rm -> add|list|info|use|remove
```
Run `tz --help` or `tz <command> --help` for detailed parameter information.

188
README.md Normal file
View file

@ -0,0 +1,188 @@
[中文](./README.md) | [English](./README-en.md)
# TZ
TZ 是一个终端代理管理器,用于统一管理 Mihomo、sing-box core、订阅 profile、节点测速、TUN 以及终端/系统代理。当前版本为 `v0.1.0`,支持 Linux x86_64。
## 安装
### Cargo
需要 Rust 2024 edition 对应的工具链。在仓库目录安装 release 版本:
```bash
git clone https://github.com/Lihatoo/TZ.git
cd TZ
cargo install --path .
```
默认安装到 `~/.cargo/bin/tz`。请确认 `~/.cargo/bin` 已加入 `PATH`
### Release 二进制
也可以从 Releases 下载 `tz`,赋予执行权限后放入 `PATH` 中的目录,例如 `~/.local/bin`
## 快速开始
首次使用先初始化目录:
```bash
tz init
```
默认路径配置位于 `~/.config/tz/paths.toml`。选择自定义位置时,按初始化提示设置 `TZ_PATHS_TOML`
导入仓库中准备好的 core 目录:
```bash
tz core add ./cores/mihomo
tz core add ./cores/sing-box
tz core list
tz core use mihomo
```
`tz core add` 的参数必须是包含 `core.toml` 和二进制的完整目录,不能只传二进制文件。两个内置 core 的对应关系如下:
| core | profile family | profile 格式 |
| --- | --- | --- |
| `mihomo` | `clash` | YAML |
| `sing-box` | `sing-box` | JSON |
添加并选择 profile
```bash
tz profile add nano-clash '<订阅 URL 或本地文件>' --family clash
tz profile add nano-sb '<订阅 URL 或本地文件>' --family sing-box
tz profile list
```
`tz profile list` 默认只列出当前 core 支持的 family在交互式终端中可直接输入序号选择`*` 表示当前 profile。使用 `tz profile list --all` 查看全部 family。profile 名称在所有 family 中必须唯一,建议加入 `-clash``-sb` 后缀,便于识别。
启动并查看状态:
```bash
tz on
tz
```
`tz on` 使用当前 core 上次选择的有效 profile。core 或 profile 需要切换时先执行 `tz off`,运行期间会拒绝切换,以免状态与实际进程不一致。
## 节点与代理
```bash
tz -l # 测速全部节点,按延迟排序,并可交互选择
tz -l hk # 搜索、测速并选择名称包含 hk 的节点
tz node test --select # 测速并自动选择最快节点
```
终端代理必须在当前 shell 中 `eval` 才能生效:
```bash
eval "$(tz proxy env bash)"
eval "$(tz proxy noenv bash)"
```
Zsh 或 Fish 将末尾的 `bash` 换成对应 shell。也可以安装 shell hook使 `tz proxy terminal on|off` 能修改当前 shell
```bash
eval "$(tz proxy shell-init bash)"
```
core 启动后,可以控制 GNOME 系统代理或同时控制终端与系统代理:
```bash
tz proxy system on
tz proxy system off
tz proxy on
tz proxy off
```
TUN 独立于上述代理开关;修改后会校验配置,服务运行时自动重启:
```bash
tz tun status
tz tun on
tz tun off
```
启用 TUN 需要系统存在 `/dev/net/tun`,并按命令报错提示为当前 core 二进制授予 `CAP_NET_ADMIN`/`CAP_NET_RAW`
## Profile 下载与更新
远程 profile 会尝试通过已有 TZ 代理和直连下载,只要其中一条路线成功即可,并把成功路线记录为 `download_via`。使用 `tz profile info <name>` 查看该信息URL 仅在本地 profile 索引中保存,命令输出会隐藏它。
下载请求会根据 family 使用对应客户端的 User-Agent。TZ 只校验并管理原始格式,不会把 Clash YAML 与 sing-box JSON 相互转换。
```bash
tz profile update # 更新全部远程 profile
tz profile info nano-sb
tz profile remove nano-sb
```
如果直连和当前 TZ 代理都无法下载,先启动一个可用的 TZ profile或临时启用其他代理后重试。
Mihomo core 目录内的 `Country.mmdb``GeoSite.dat` 是 GEOIP/GEOSITE 规则数据库不是需要每位用户单独安装的插件。profile 使用相应规则时TZ 会把它们复制到运行目录,避免 Mihomo 启动时临时访问 GitHub 下载。
## Shell 补全
当前 shell 临时启用:
```bash
# Bash
eval "$(tz completion generate bash)"
# Zsh
eval "$(tz completion generate zsh)"
# Fish
tz completion generate fish | source
```
要永久启用,把对应命令加入 shell 的启动文件。
## 完整指令
```text
tz status|start|stop|restart
tz list [keyword]
tz node test [keyword] [--url <url>] [--timeout <ms>] [--select]
tz tun status|on|off
tz proxy status|on|off
tz proxy terminal|system status|on|off
tz proxy env|noenv [bash|zsh|fish]
tz proxy shell-init bash|zsh|fish
tz setting [list|get|set|reset]
tz profile add|list|info|use|update|remove
tz core add|list|info|use|remove
tz config build|check|show
tz completion generate bash|zsh|fish
```
## 简洁指令
```bash
tz # 状态,并测速当前节点
tz on # 使用上次的有效 profile 启动并显示状态
tz off # 停止
tz -l [keyword] # 节点测速、延迟排序、搜索和选择
tz select # 当前 family 的 profile 列表和选择
```
## 快捷键
快捷键是完整指令的缩写:
```text
tz st -> tz status
tz r -> tz restart
tz end -> tz stop
tz set -> tz setting
tz p -> tz profile
tz c -> tz core
tz cfg -> tz config
tz comp -> tz completion
tz p a|l|i|u|up|rm -> add|list|info|use|update|remove
tz c a|l|i|u|rm -> add|list|info|use|remove
```
使用 `tz --help``tz <command> --help` 查看参数详情。

8
TZ.code-workspace Executable file
View file

@ -0,0 +1,8 @@
{
"folders": [
{
"path": ".",
"name": "TZ",
}
]
}

BIN
cores/mihomo/Country.mmdb Normal file

Binary file not shown.

41655
cores/mihomo/GeoSite.dat Normal file

File diff suppressed because one or more lines are too long

30
cores/mihomo/core.toml Normal file
View file

@ -0,0 +1,30 @@
schema_version = 1
[core]
name = "mihomo"
family = "clash"
version = "1.19.18"
binary = "mihomo"
os = "linux"
arch = "x86_64"
[runtime]
entrypoint = "config.yaml"
format = "yaml"
[capabilities.config]
mixed_proxy = true
http_proxy = true
socks_proxy = true
api = true
dns = true
tun = true
[commands.start]
args = ["-d", "{workdir}", "-f", "{config}"]
[commands.check]
args = ["-t", "-d", "{workdir}", "-f", "{config}"]
[commands.version]
args = ["-v"]

BIN
cores/mihomo/mihomo Executable file

Binary file not shown.

30
cores/sing-box/core.toml Normal file
View file

@ -0,0 +1,30 @@
schema_version = 1
[core]
name = "sing-box"
family = "sing-box"
version = "1.13.14"
binary = "sing-box"
os = "linux"
arch = "x86_64"
[runtime]
entrypoint = "config.json"
format = "json"
[capabilities.config]
mixed_proxy = true
http_proxy = false
socks_proxy = false
api = true
dns = true
tun = true
[commands.start]
args = ["run", "-D", "{workdir}", "-c", "{config}"]
[commands.check]
args = ["check", "-D", "{workdir}", "-c", "{config}"]
[commands.version]
args = ["version"]

BIN
cores/sing-box/sing-box Executable file

Binary file not shown.

View file

@ -0,0 +1,189 @@
# TZ 统一控制指令架构(讨论稿)
状态Draft 0.1
范围Clash/Mihomo、sing-box以及待确认的 NinjaDesktop Lite 适配器
参考:`/mnt/data_4t2/lht_self/sing-box-13/sb`
第一版:下面的架构以假设使用.sh调用原二进制内核开发
## 1. 目标
对用户只暴露一套稳定的 `tz` 指令。切换内核后用户的日常命令、配置档名称、当前节点、路由模式、TUN、终端代理和系统代理等状态不变由内核适配器负责生成配置并执行不同的底层指令。
```text
用户 / shell
|
v
tz CLI解析、状态、锁、事务、统一输出
|
+-- clash adapter ------> mihomo/clash + Clash API
+-- sing-box adapter ---> sing-box + Clash API
`-- ninja adapter ------> 待探测
```
基本原则:
1. 正式命令统一为 `tz <对象> <动作> [参数]`
2. 常用操作保留短命令,但短命令只是正式命令的别名。
3. 用户状态由 `tz` 保存,不能以某个内核的运行配置作为唯一状态源。
4. 原始订阅/配置档只读保存端口、DNS、TUN、绕过规则等系统配置在启动前覆盖合并。
5. 所有内核差异只进入 adapter主控制逻辑中不散落 `if clash` / `if sing-box`
6. 不支持的能力必须明确报错并返回非零退出码,不能假装执行成功。
7. 修改内核、配置档、模式、TUN 等操作均先生成并校验配置,再应用;失败时保留旧的可运行状态。
## 2. 完整指令树
我想做的就是普通clash界面的终端操控指令
```text
tz
快速启动
|-- status [--watch] # 显示整体状态使用的内核profile情况节点延迟等
|-- start # 启动直接启动上次使用的profile之后立刻执行status做参考
|-- stop/end # 停止服务
|-- restart # 强制完整重启,等效于 stop 后 start
|-- reload # 重新生成配置并热加载,必要时自动重启
|
|-- list|-l [keyword] # 列出默认策略组节点并按延迟排序,快速切换节点
| [--group|-g <name>]
| [--fresh] # 这是什么?
|-- use <node> [--group <name>] # 快速选择节点
整体服务
|-- service 服务,选择节点(对应当前profile开关等
| |-- status [--watch]
| |-- stop/end # 停止进程
| |-- restart [profile] # 重启多用来刷新 bypass.list
|
|-- core
| |-- list 查看有哪些内核比如clash sing-boxmihomo。用*指出当前内核,选择可换
| |-- info [name] 当前内核的版本二进制位置、导入时的url是否正常启动等信息或者是指定的
| |-- add <name> <url>不需要这个我后面会给出内核加入的标准格式,只做上传到网上,下载即可。如果名字重复,可以选择覆盖或者输入新的名称
| |-- remove <name> 删除对应内核
| |-- use <name> [--no-start] 切换内核,
| `-- update [name] 去查看url的内核版本可选是否更新否则提示无更新
|-- setting 这个是所有的core共用(配置一次就可以用了)?还是每个core有自己的单独配置(麻烦)
| |-- list # 列表展示 项目(key+ 出来。可以选择设置哪一个
| |-- get <key> # 这个是只展示某个key的配置
| |-- set <key> <value> # 这个是单独配置
| `-- reset [key] # 恢复默认值
|
|-- key 这里展示setting可以打开哪些项目及其配置。下面指出的是默认配置
| |-- core mihomo # 等效于 core list
| |-- sysproxy on/off # 系统proxy开启注意需要识别bypass.list 配置ignore host
| |-- proxy on/off 终端~/.bashrc中的变量配置
| |-- http-proxy : 127.0.0.1:7892
| |-- socks-proxy : 127.0.0.1:7891
| |-- mixed-proxy : 127.0.0.1:7890
| |-- WebUI 127.0.0.1:9189
| |-- autostart : true/false
|-- env 环境配置
| |-- list # 展示出来,有哪些可以配置
| |-- sysproxy on/off # 系统proxy开启注意需要识别bypass.list 配置ignore host
| |-- proxy on/off 终端~/.bashrc中的变量配置
| |-- http-proxy : 127.0.0.1:7892
| |-- socks-proxy : 127.0.0.1:7891
| |-- mixed-proxy : 127.0.0.1:7890
| |-- WebUI 127.0.0.1:9189
|-- profile # 由于内核不同profile也不同所以注意区分。profile是对应core的
| |-- list # 列出订阅,*指出当前的订阅注意profile与core对应
| |-- show [name]
| |-- add <name> <url-or-file> [--format auto|clash|sing-box] # 新增订阅,重名选择覆盖(remove后add还是重命名
| |-- remove <name> # 删除
| |-- use <name> [--restart]
| |-- current
| |-- update [name]
| |-- update-all
| |-- check [name] [--core <name>]
| `-- source [name]
|
|-- group # profile对应的group可选择切换与node 节点应该在一起
| |-- list
| |-- current [group]
| `-- select <group> <node>
|-- node 节点
| |-- list [--group <name>] [--match <keyword>]
| |-- current [--group <name>]
| |-- select <node> [--group <name>]
| `-- test [--group <name>] [--match <keyword>]
| [--url <url>] [--timeout <ms>] [--select]
|
|-- mode # 这个放在这儿?为什么不放在setting
| |-- get
| `-- set rule|global|direct
|
|-- tun
| |-- status
| |-- on
| `-- off
|
|-- proxy
| |-- status
| |-- on
| |-- off
| |-- env
| |-- noenv
| |-- shell-init [bash|zsh|fish]
| |-- terminal on|off
| `-- system on|off
|
|-- rule
| `-- bypass
| |-- list
| |-- add <domain-or-cidr>
| |-- remove <domain-or-cidr>
| |-- import <file>
| |-- reset
| `-- apply
|
|-- connection
| |-- list
| |-- close <id>
| `-- close-all
|
|-- config
| |-- path
| |-- show [--effective|--source]
| |-- build [--core <name>] [--profile <name>]
| |-- check [--core <name>] [--profile <name>]
| `-- apply
|
|-- log # 日志没必要吧?
| `-- show [--lines <n>] [--follow] [--level <level>]
|
|-- diagnose # 这是什么?
| |-- run [--full]
| |-- ports
| |-- process [pid]
| |-- network direct|proxy|compare
| |-- dependencies
| |-- interfaces
| |-- dns
| |-- routes
| |-- api
| `-- config
|
|-- api status
|-- api get <path>
`-- completion generate bash|zsh|fish
```
# 文件树
##
# 具体配置

102
docs/control-interface.md Normal file
View file

@ -0,0 +1,102 @@
# TZ Control Interface v0.1
TZ 的公开命令围绕用户动作设计。配置文件是可查阅的内部存储格式,修改由固定命令完成并经过校验、锁保护和原子保存。
## 当前指令
```text
tz init
tz status
tz start
tz stop
tz restart
tz list [keyword]
tz node test [keyword] [--url <url>] [--timeout <ms>] [--select]
tz tun status|on|off
tz proxy status|on|off
tz proxy terminal status|on|off
tz proxy system status|on|off
tz proxy env|noenv [bash|zsh|fish]
tz proxy shell-init bash|zsh|fish
tz setting [list]
tz setting get <key>
tz setting set <key> [value]
tz setting reset [key]
tz profile add <name> <url-or-file> --family clash|sing-box
tz profile list [--family clash|sing-box] [--all]
tz profile info <name>
tz profile use [name]
tz profile update
tz profile remove <name>
tz core add <directory>
tz core list
tz core info [name]
tz core use [name]
tz core remove <name>
tz config build|check|show
tz completion generate bash|zsh|fish
```
`profile list` 默认只显示当前 core family`--all` 显示全部。`profile update` 不接名称,更新所有远程 profile本地 profile 自动跳过。
`profile list``core list` 和节点 `list` 在 TTY 中显示编号、用 `*` 标出当前项并允许直接选择;非交互环境只输出简洁列表。`profile/core use` 保留,作为脚本和明确指定名称的稳定入口。详细来源和路径使用 `info` 查看。
`list/-l``node test` 都通过当前 core controller API 最多并发测试 8 个节点并按延迟排序。`list/-l` 在 TTY 中允许从测速后的列表选择节点;`node test --select` 自动选择最快的成功节点。默认 URL 为 Google 204默认超时 1800ms最新结果保存在 `cache/speedtest/latest.json`
Mihomo 的 family 固定为 `clash`sing-box 的 family 固定为 `sing-box`,不能互换。
## 简洁指令
```text
tz # status并测速当前节点
tz on # 使用上次的可用 profile 启动并显示 status
tz off | tz end # stop
tz -l [keyword] # 测速、按延迟排序、搜索和选择
tz select # profile list
```
节点 keyword 使用不区分大小写的子串匹配。`list/-l` 在 TTY 中可输入测速后列表的编号切换当前节点;选择写入当前 profile并在下次启动后恢复。
## 快捷键
快捷键是当前指令的命令缩写,完整写法始终可用:
```text
tz st # status
tz r # restart
tz set # setting
tz p # profile
tz c # core
tz cfg # config
tz comp # completion
tz p a|l|i|u|up|rm
tz c a|l|i|u|rm
```
Tab 补全通过 `tz completion generate <shell>` 生成。例如 Bash 当前会话可执行 `eval "$(tz completion generate bash)"`
## Proxy 与 TUN
`proxy terminal on|off` 保存终端代理状态。子进程不能直接修改父 shell因此当前 shell 使用 `eval "$(tz proxy env)"``eval "$(tz proxy noenv)"`;长期使用把 `eval "$(tz proxy shell-init bash)"` 加入对应 shell 启动文件。Fish 和 Zsh 使用各自的 shell 参数。
`proxy system on|off` 使用 GNOME `gsettings` 设置 HTTP、HTTPS、SOCKS 和 ignore-hosts端口与 bypass 均来自 TZ 配置。开启前要求 core 正在运行避免桌面流量指向空端口。TZ 会先私有备份原桌面代理,关闭或应用失败时逐项恢复;未由 TZ 开启时,`system off` 不修改桌面设置。`proxy on|off` 同时控制 terminal 和 system。
`tun on|off` 检查当前 core 的 TUN capability 和 `/dev/net/tun`。开启还要求受管 core 二进制具有 `CAP_NET_ADMIN`;缺少时打印对应的 `sudo setcap cap_net_admin,cap_net_raw+ep ...` 命令。运行中的切换会安全重启,失败时恢复原状态。
## Profile 下载
URL 只允许 HTTP/HTTPS并校验 DNS 与重定向目标。下载按 family 使用对应 provider User-AgentClash 对齐 `mh`sing-box 对齐 `sb`,以支持服务端按客户端返回不同格式。下载优先尝试环境代理,失败后回退直连,只要一种路径成功即完成添加或更新。实际成功路径保存为 profile 的 `download_via=proxy|direct`,可用 `tz profile info <name>` 查看;错误信息不会打印订阅 token。
## 运行闭环
`config build/check` 根据当前 core family 生成 Clash YAML 或 sing-box JSON并调用 core manifest 的 check 命令。Mihomo profile 引用 GEOIP/GEOSITE 时builder 从 core 包按需复制 `Country.mmdb``GeoSite.dat` 到独立工作目录;标准 core 包已携带这两份规则数据库,用户无需手工处理。自定义 core 包缺失时会明确提示,可先启用其他代理取得资源后补入 core 包。
`start/on` 读取当前 family 上次选择且 source 可用的 profile在校验通过后启动受管进程、等待 API 并显示 status没有可用 profile 时提示运行 `tz profile list``stop` 在发送信号前核对进程用户和 `/proc/<pid>/exe``status/tz` 简洁显示 core、profile、服务 PID并实时测试当前节点延迟失败时才回退显示缓存结果。
当前尚未开放的是 core 在线下载与自动更新。System proxy 的 v0.1 平台适配范围是 GNOME其他 Linux 桌面环境后续增加 adapter。

96
docs/core-package.md Normal file
View file

@ -0,0 +1,96 @@
# TZ Core Package v1
本规范定义 TZ 如何发现、校验和调用本地代理内核。core 包包含运行契约、二进制和该 core 固定的只读运行资源,不包含 profile、用户配置、secret、日志、PID 或缓存。
## 目录结构
稳定槽位名推荐使用 `mihomo``sing-box`
```text
<data_dir>/cores/
└── mihomo/
├── core.toml
├── mihomo
├── Country.mmdb # Mihomo GEOIP 规则数据库
├── GeoSite.dat # Mihomo GEOSITE 规则数据库
├── LICENSE # 可选
├── NOTICE # 可选
└── README.md # 可选TZ 不解析
```
目录名必须与 `core.name` 完全一致。完整版本保存在 `core.version`;需要多版本并存时使用完整槽位名,例如 `mihomo-1.19.14`
Mihomo 标准包固定携带 `Country.mmdb``GeoSite.dat`。生成配置实际引用 GEOIP/GEOSITE 时TZ 才把缺失资源复制到 `state/runtime/<core>/`;用户无需逐个 profile 手工下载。自制 Mihomo core 包也应携带这两个文件,否则 TZ 会在 build/check 阶段给出明确提示。
## Manifest
```toml
schema_version = 1
[core]
name = "mihomo"
family = "clash"
version = "1.19.18"
binary = "mihomo"
os = "linux"
arch = "x86_64"
[runtime]
entrypoint = "config.yaml"
format = "yaml"
[capabilities.config]
mixed_proxy = true
http_proxy = true
socks_proxy = true
api = true
dns = true
tun = true
[commands.start]
args = ["-d", "{workdir}", "-f", "{config}"]
[commands.check]
args = ["-t", "-d", "{workdir}", "-f", "{config}"]
[commands.version]
args = ["-v"]
```
字段约束:
- schema 当前仅支持 `1`,未知字段会被拒绝。
- name 只允许 ASCII 字母、数字、点、下划线和连字符。
- family/format 当前只允许 `clash/yaml``sing-box/json`
- os/arch 必须等于当前运行平台的 Rust target 常量。
- binary 与 entrypoint 必须是单个相对文件名,禁止绝对路径和 `..`
- binary 必须是普通可执行文件。
- start 必填check、version、reload 可选。命令存在即表示支持对应动作。
- 参数只支持 `{config}``{workdir}`TZ 直接执行 binary不经过 shell。
## 安装方式
手工复制是标准方式,无注册数据库:
```bash
cp -a ./mihomo <data_dir>/cores/mihomo
chmod +x <data_dir>/cores/mihomo/mihomo
tz core list
```
本地便捷导入:
```bash
tz core add ./mihomo
tz core info mihomo
tz core use mihomo
tz core remove mihomo
```
`core add` 只接收本地目录,不接收 URL。它会拒绝符号链接和特殊文件复制到 staging重新校验后原子移动重名直接拒绝。成功后不自动选择或启动。
`core remove` 在受管进程运行时拒绝。服务停止时可以删除当前 core并原子清空 current同时清理同名 generated/runtime 派生目录。
## 网络分发
网络下载不属于 `core add`。未来 `core install` 必须使用可信 registry 提供的外部 SHA256且 URL 只能是 HTTP/HTTPS请求和每次重定向前必须拒绝 localhost、环回、私有和保留地址。

10
docs/scope.md Normal file
View file

@ -0,0 +1,10 @@
# tz v0.1 范围
- 只支持 Linux同一时间只运行一个当前用户的受管 core不使用 systemd。
- 支持本地导入 Mihomo 1.19.18`family=clash`)和 sing-box 1.13.14`family=sing-box`)。
- 支持本地或 HTTP(S) profile远程下载在环境代理与直连之间回退并记录实际成功路径。
- 支持 Clash YAML 与 sing-box JSON 的生成、真实 core 校验、启动、状态、节点选择与测速、重启和安全停止。
- `profile list` 默认跟随当前 core family`--all` 才跨 family 显示。
- 支持 Bash/Zsh/Fish 终端代理环境输出与 shell hook支持 GNOME `gsettings` system proxy 和 bypass。
- 支持独立 TUN 开关、能力与权限检查、运行中重启及失败回滚;二进制 capability 由用户显式设置。
- 暂不支持非 GNOME 桌面 system proxy adapter、core 在线安装或自动更新。

View file

@ -0,0 +1,976 @@
# 环境搭建
省略
# 初始文件树
`cargo init --bin --name tz .`
#文件树1
```
TZ/
├── Cargo.toml
├── Cargo.lock
└── src/
├── main.rs # tz 可执行程序入口
└── lib.rs # 项目的主要代码入口
```
## 常用代码
```bash
# 检查代码,不生成最终程序
cargo check
# 编译
cargo build
# 编译并运行
cargo run
# 向 tz 传入参数
cargo run -- status
cargo run -- core list
# 运行测试
cargo test
# 格式代码
cargo fmt
# 静态检查
cargo clippy
# 添加依赖
cargo add clap --features derive
# 删除依赖
cargo remove clap
# 查看依赖树
cargo tree
# 查看项目元数据
cargo metadata
--format-version 1
```
# CLI 解析层的第一个小闭环
1. 添加clap依赖
```bash
cargo add clap --features derive
```
`cargo add` 会修改当前 Package 的 `Cargo.toml``derive` feature 让我们可以使用 `#[derive(Parser)]``#[derive(Subcommand)]`
2. 写文件功能 #文件树2
```bash
mkdir -p src/cli
touch src/cli/mod.rs # 管理并导出 cli 模块
touch src/cli/args.rs # 定义用户可以输入什么
```
```
src/
├── main.rs # 程序入口
├── lib.rs # 整个项目的库模块入口
└── cli/
├── mod.rs # 管理并导出 cli 模块
└── args.rs # 定义用户可以输入什么
```
3. `lib.rs`中添加声明
```rust
pub mod cli;
```
声明项目中存在 `cli` 模块Rust 会自动寻找`src/cli.rs`或者`src/cli/mod.rs`
4. `src/cli/mod.rs`中管理子模块
```rust
mod args; // cli 模块内部还有一个 args 子模块。
pub use args::{Cli, CliCommand, CoreCommand}; // 表示把三个类型重新导出到 cli 模块表面
```
没有写 `pub`,所以外部不能直接访问`args`/`tz::cli::args::Cli`;
然后补充[args.rs](../src/cli/args.rs)即可,注释也写在里面
即使`main.rs``lib.rs`在同一个 Cargo package 中, 仍然是两个独立 crate。Rust 官方文档明确说明:同时存在 `src/lib.rs``src/main.rs`package 中包含一个库 crate 和一个二进制 crate。
```tcl
package tz
├── library crate tz
│ └── cli
└── binary crate tz
└── main
```
5. 补充main.rs
```rust
use clap::Parser;
use tz::cli::Cli; // cli 当前是由 lib.rs 管理的,它属于库 crate tz不是 main.rs 所属二进制 crate 的直接模块。
fn main() {
let cli = Cli::parse();
println!("{cli:#?}");
}
```
6. 测试,`--`表示隔开,后面的参数是输入给二进制文件的
```bash
cargo run -- --help # 查看帮助
cargo run -- status #
cargo run -- core --help # 查看 core 的帮助
cargo run -- unknown # 测试错误输入
```
## 常用代码
```bash
cargo tree # 检查依赖
cargo tree -i clap
cargo fmt # 统一代码格式
cargo check # 类型检查和编译检查
cargo clippy # 检查潜在问题和不规范写法
```
# CLI 解析run态
1. 新增命令分发层 #文件树3
```bash
src/
├── main.rs
├── lib.rs
├── cli/
│ ├── mod.rs
│ ├── args.rs # 用户可以使用什么命令
│ └── commands/ # 定义“收到命令之后调用什么”
│ ├── mod.rs
│ ├── status.rs
│ ├── service.rs
│ └── core.rs
```
创建
```bash
mkdir -p src/cli/commands
touch src/cli/commands/mod.rs
touch src/cli/commands/status.rs
touch src/cli/commands/service.rs
touch src/cli/commands/core.rs
```
2. 补充command代码
补充 [src/cli/mod.rs](../src/cli/mod.rs)
补充 [src/cli/commands/mod.rs](../src/cli/commands/mod.rs)
补充 [src/cli/commands/status.rs](../src/cli/commands/status.rs)
补充 [src/cli/commands/service.rs](../src/cli/commands/service.rs)
补充 [src/cli/commands/core.rs](../src/cli/commands/core.rs)
补充 [src/main.rs](../src/main.rs)
3. 测试
```bash
cargo fmt
cargo check
cargo clippy
```
然后
```bash
cargo run -- status
cargo run -- start
cargo run -- stop
cargo run -- restart
cargo run -- core list
```
```
之前:
用户输入 → Debug 打印
现在:
用户输入
Cli
CliCommand
match
具体 command handler
```
# application
```bash
mkdir -p src/application
touch src/application/mod.rs
touch src/application/service.rs
```
文件树4
````bash
src/
├── main.rs # 程序入口
├── lib.rs # 整个项目的库模块入口
└── cli/
│ ├── mod.rs # 管理并导出 cli 模块
│ ├── args.rs # 定义用户可以输入什么
│ └── commands/ # 定义“收到命令之后调用什么”
│ ├── mod.rs # commands 本体
│ ├── status.rs # commands 的 子模块
│ ├── service.rs
│ └── core.rs
└── application/
├── mod.rs
└── service.rs # 这个替换commands/service.rs的服务
````
随后需要补充的 `lib.rs`中定义新的模块
之后将commands中的相关模块替换成
# 前部小结
这里主要是创建两个模块
cli : 指令列表,以及管理\\调用
application : 真实实现
目前只是搭建了基础框架没有实际执行,
1. 搭建cli
2. 搭建application,改cli的调用路线为application
主要完成了指令的分发
# 路径系统
设定软件的路径系统,
```bash
mkdir -p src/platform
touch src/platform/mod.rs
touch src/platform/paths.rs
```
文件树5
```bash
src/
├── main.rs # 程序入口
├── lib.rs # 整个项目的库模块入口
└── cli/
│ ├── mod.rs # 管理并导出 cli 模块
│ ├── args.rs # 定义用户可以输入什么
│ └── commands/ # 定义“收到命令之后调用什么”
│ ├── mod.rs # commands 本体
│ ├── status.rs # commands 的 子模块
│ ├── service.rs
│ └── core.rs
└── application/
│ ├── mod.rs
│ └── service.rs # 这个替换commands/service.rs的服务
└── platform/
├── mod.rs
└── paths.rs
```
要求:
- 默认配置不依赖环境变量即可使用。
- 路径配置统一保存在 `paths.toml`,由 `TZ_PATHS_TOML` 指向;未设置时使用 `$HOME/.config/tz/paths.toml`
- `tz init` 可以在用户确认后把 `TZ_PATHS_TOML` 的 export 追加到 `~/.bashrc`
## 文件系统初始化规则
`paths.toml` 是文件系统路径的唯一配置源,文件只保存一次选定的四个目录,不保存布局类型或多个方案:
```toml
[layout]
config_dir = "~/.config/tz"
data_dir = "~/.local/share/tz"
state_dir = "~/.local/state/tz"
cache_dir = "~/.cache/tz"
```
路径文件定位规则:
1. 设置 `TZ_PATHS_TOML` 时,读取该绝对路径文件;文件不存在或格式错误直接报错,不回退。
2. 未设置时,读取 `$HOME/.config/tz/paths.toml`
3. 默认路径文件不存在时,提示运行 `tz init`
程序固定读取 `[layout]` 下的四个字段。路径可以写成绝对路径或 `~/...`;读取时展开 `~`,展开后必须是绝对路径。普通命令不再读取 `TZ_ROOT_DIR` 或四个 `XDG_*` 环境变量。
`tz init` 的首次选择提供三个模板:
1. 默认 XDG`~/.config/tz``~/.local/share/tz``~/.local/state/tz``~/.cache/tz`
2. 默认 Unified`~/.tz/config``~/.tz/data``~/.tz/state``~/.tz/cache`
3. 开发测试:项目目录下的 `target/tz-dev/{config,data,state,cache}`
选择模板后,交互询问四个目录,回车保留模板值,也可以修改。最终只写入一个 `[layout]` 表。
重复运行 `tz init` 时,如果 paths 文件已存在,先询问是否继续;拒绝则退出且不修改文件,确认后才重新选择和写入。写入路径文件后,初始化根据四个目录补齐结构和初始文件,已有文件一律跳过,不覆盖内容。
## `tz init` 交互
- 第一次运行时选择三个内置模板:默认 XDG、默认 Unified、开发测试 `target/tz-dev`
- 选择后分别询问 `config_dir``data_dir``state_dir``cache_dir`,回车使用模板值。
- 如果 paths 文件已经存在,先询问是否继续;默认退出,不修改现有路径。
- 初始化结束后始终打印 `export TZ_PATHS_TOML='...'` 提示。默认 `$HOME/.config/tz/paths.toml` 不需要设置变量;自定义 paths 文件可以确认后追加到 `~/.bashrc`
## 开发测试
开发测试模板使用项目目录下的 `target/tz-dev`,示例:
```bash
cargo run -- init
# 选择 3) 开发测试 target/tz-dev
TZ_PATHS_TOML="$PWD/target/tz-paths.toml" cargo run -- status
```
如果 paths 文件放在默认 `$HOME/.config/tz/paths.toml`,后续命令不需要设置环境变量;如果使用自定义路径文件,需要设置 `TZ_PATHS_TOML` 或按初始化结束时的提示加入 bashrc。
## 本次修改的代码文件
- `src/platform/paths.rs`
- 使用 `paths.toml` 作为唯一路径配置源。
- 支持 `TZ_PATHS_TOML` 和默认 `$HOME/.config/tz/paths.toml`
- 解析 `[layout]` 的四个固定目录,支持 `~/` 展开和绝对路径校验。
- 保留初始化目录、五个初始文件和幂等补全。
- `src/application/init.rs`
- 提供默认 XDG、默认 Unified、开发 `target/tz-dev` 三个模板。
- 已有 paths 文件先确认,再允许重新选择。
- 初始化结束打印 `TZ_PATHS_TOML` export自定义路径可确认后追加到 `~/.bashrc`
验证命令:
```bash
cargo fmt --check
cargo check
cargo test --all-targets
cargo clippy --all-targets -- -D warnings
```
# 文件作用
接下来固定文件作用
`paths.toml` TZ_PATHS_TOML
```toml
[layout]
config_dir = "/mnt/data_4t2/lht_self/TZ/target/tz-dev/config"
data_dir = "/mnt/data_4t2/lht_self/TZ/target/tz-dev/data"
state_dir = "/mnt/data_4t2/lht_self/TZ/target/tz-dev/state"
cache_dir = "/mnt/data_4t2/lht_self/TZ/target/tz-dev/cache"
```
## 三级配置
### 一级配置
> 全局、长期、比较稳定的信息。
`settings.toml`:tz软件的统一配置信息与core的运行不太相关。默认不会修改只是展示一下
```toml
schema_version = 1
[bypass]
enabled = true
# bypass.list 之外的内联补充项,生成规则时合并并去重。
inline = [
"localhost",
"127.0.0.0/8",
]
[log]
level = "warn"
# tz.log 超限后直接清除重建,不保留归档。
max_size_mb = 10
[update.profiles]
auto_update = false
interval_minutes = 4320
[update.cores]
auto_update = false
interval_minutes = 14400
```
`runtime.toml`TZ 可以跨 core 表达的运行参数包括端口、API、DNS 和 TUN 的复杂参数。是否开启 TUN 不在这里,开关保存在 `active.toml`
```toml
schema_version = 1
[proxy]
mode = "rule"
listen = "127.0.0.1"
mixed_port = 7890
http_port = 7892
socks_port = 7891
allow_lan = false
ipv6 = false
[api]
enabled = true
listen = "127.0.0.1"
port = 9189
[dns]
enabled = true
listen = "127.0.0.1"
port = 1053
ipv6 = false
[tun]
stack = "system"
auto_route = true
auto_detect_interface = true
dns_hijack = true
```
### 二级配置
`active.toml`:保存主页需要展示和切换的状态,只放当前 core 与三个开关。当前 profile 和节点选择由 `profiles.toml` 保存PID 与锁由 `state/runtime/` 保存。
```toml
schema_version = 1
[current]
core = "mihomo"
[tun]
enabled = false
[shell_proxy]
enabled = false
bypass = true
[system_proxy]
enabled = false
bypass = true
```
### 三级配置
`state/generated/<core>/` 只保存根据配置生成的内核入口文件,可随时删除重建。`state/runtime/<core>/` 才是 `{workdir}` 指向的内核工作目录,用来隔离 cache.db 等副产物。
## 介绍文档
> 除了上面的一些控制文档,还需要有一些总结性的文档,避免每次都重新扫描文件。
`profiles.toml`
```toml
schema_version = 1
# 每个 family 各自保存当前 profile切换 core 后可以恢复对应选择。
[current]
clash = "home"
sing-box = "sid"
[[profiles]]
name = "home"
family = "clash"
format = "yaml"
source_file = "home/source.yaml"
[profiles.origin]
kind = "remote"
url = "https://example.com/subscription"
[profiles.update]
updated_at = "2026-08-14T10:00:00Z"
# 一个 profile 可以保存多个策略组的节点选择。
[profiles.state.selected]
Proxy = "Hong Kong 01"
Final = "Proxy"
[[profiles]]
name = "company"
family = "clash"
format = "yaml"
source_file = "company/source.yaml"
[profiles.origin]
kind = "local"
original_path = "/home/lht/config/company.yaml"
```
# cores标准设计
> 下面的不需要tz程序初始化也不需要其修改基本上手动制作tz读取使用即可
```bash
cores/
└── mihomo/
├── core.toml # Core 描述文件
└── mihomo # 二进制
```
这一部分还作为后续cores制作的参考标准
`core.toml`
```toml
schema_version = 1
[core]
name = "mihomo"
family = "clash"
version = "1.19.18"
binary = "mihomo"
os = "linux"
arch = "x86_64"
[runtime]
entrypoint = "config.yaml"
format = "yaml"
[capabilities.config]
mixed_proxy = true
http_proxy = true
socks_proxy = true
api = true
dns = true
tun = true
# start 必填check/version/reload 为可选表。
# 某个命令表存在就表示支持对应 CLI 动作,不再维护重复的 actions 布尔值。
[commands.start]
args = ["-d", "{workdir}", "-f", "{config}"]
[commands.check]
args = ["-t", "-d", "{workdir}", "-f", "{config}"]
[commands.version]
args = ["-v"]
```
`core.toml` 只允许 `{config}``{workdir}` 两个占位符。`binary``entrypoint` 必须是单个相对文件名;加载 core 时还会检查 schema、目录名、family/format 组合、二进制是否存在且可执行。
## 最终文件树
> 这里是结合文件作用+cores的
```toml
TZ_PATHS_TOML
└── paths.toml # 保存四个基础路径
config/
├── settings.toml # TZ 自身的长期策略bypass、日志、更新配置
├── runtime.toml # 跨 core 的端口、API、DNS、TUN 参数
├── shell/ # 后续生成 shell 代理脚本;当前阶段尚未实装
└── bypass.list
data/
├── profiles/
│ ├── profiles.toml # profile 索引、当前选择和策略组节点选择
│ ├── tnt/
│ │ └── source.yaml
│ └── sid/
│ └── source.json
└── cores/ # 手工制作、TZ 只读;目录名必须等于 core.name
├── mihomo/
│ ├── core.toml
│ └── mihomo
└── sing-box/
├── core.toml
└── sing-box
state/
├── active.toml # 当前 core 与主页开关
├── generated/ # 只放可删除重建的生成配置
│ ├── mihomo/
│ │ └── config.yaml
│ └── sing-box/
│ └── config.json
├── runtime/
│ ├── mihomo/ # Mihomo cache.db 等运行副产物,{workdir} 指向这里
│ ├── sing-box/
│ ├── tz.lock # 防止多个 TZ 同时修改运行状态
│ └── core.pid # 停止时还必须通过 /proc/<pid>/exe 校验身份
└── logs/
├── tz.log
└── core.log
cache/
├── downloads/
└── speedtest/
```
# 配置层 domain 与命令实装
## 本次目标
把“文件作用 + cores 标准”定稿落到 Rust用 domain 结构体描述 `settings.toml``runtime.toml``active.toml``profiles.toml``core.toml`,由 `tz init` 生成默认文件,由 `tz status``tz core list` 严格读取。配置 builder 和真实进程启停留到下一阶段。
## 代码结构
`src/domain/` 只负责配置结构、序列化和业务校验;`application` 负责行为;`platform` 负责路径:
```
src/
├── domain/
│ ├── mod.rs # domain 类型重导出
│ ├── settings.rs # settings.toml[bypass]/[log]/[update]
│ ├── runtime.rs # runtime.toml[proxy]/[api]/[dns]/[tun]
│ ├── active.rs # active.toml当前 core + 三个主页开关
│ ├── profiles.rs # profiles.toml当前 profile、索引、节点选择
│ └── core_manifest.rs # core.toml 加载、校验和 list_cores
├── platform/paths.rs # 四个根目录和固定子路径
├── application/
│ ├── init.rs # 生成默认配置
│ └── service.rs # status 与未实现启停错误
└── cli/commands/ # CLI 薄分发
```
## 配置职责与校验
- `settings.toml` 保存 TZ 自身的长期策略。日志超限直接清除重建,因此没有 `keep` 字段。
- `runtime.toml` 保存端口、API、DNS 与 TUN 复杂参数;默认端口为 mixed `7890`、HTTP `7892`、SOCKS `7891`
- `active.toml` 只保存 `[current].core``tun.enabled`、shell proxy 和 system proxy 开关。
- `profiles.toml` 在顶层 `[current]` 按 family 保存当前 profile`[profiles.state.selected]` 可以保存多个策略组各自选择的节点。
- `state/generated/<core>/` 只保存可删除重建的入口配置;`state/runtime/<core>/``{workdir}`,用于隔离内核数据库和缓存。
- 四份配置都只支持 `schema_version = 1` 并拒绝未知字段。`Default` 只供 `tz init` 生成模板;普通命令严格读取,文件缺失、损坏或版本不支持都会报错。
- profile 会校验名称、family/format、来源字段、重复项、当前选择引用和 `source_file` 安全相对路径。
## cores 标准
- `core.name` 必须等于目录名,名称只能使用 ASCII 字母、数字、点、下划线和连字符。
- `family/format` 当前支持 `clash/yaml``sing-box/json`
- `binary``runtime.entrypoint` 必须是单个相对文件名binary 还必须实际存在且可执行。
- `commands.start` 必填;`commands.check``commands.version``commands.reload` 可选。命令表存在即表示支持对应动作,不再使用重复的 `[capabilities.actions]` 布尔值。
- 命令参数只支持 `{config}``{workdir}`,由 `CoreDescriptor::render_args` 展开。
- `[capabilities.config].api` 只表示内核提供控制 API不等同于支持 CLI reload。
## paths.rs 与初始化
- `paths.toml` 仍是四个根目录的唯一来源;设置 `TZ_PATHS_TOML` 时必须是绝对路径。
- `AppPaths::from_env_or_none()` 让非 init 命令自己输出未初始化提示,不再提前重复解析路径。
- `initialize_files` 只建目录并幂等写 `bypass.list`domain 默认值负责生成四份 TOML已有文件不覆盖。
- 初始化先准备目录与默认配置,最后写 `paths.toml`,避免配置生成失败后留下已提交的路径文件。
- `generated_dir``core_workdir` 分离;没有独立的 `active.example.toml`
## 当前命令行为
- `tz init`:选择模板,确认四个路径,准备目录和默认配置,最后写 `paths.toml`,再提示 `TZ_PATHS_TOML` export。
- `tz status`:显示当前 core、profile、受管进程和当前节点PID 检查会排除僵尸进程。
- `tz core list`:扫描 `data/cores/*/core.toml`,通过完整校验后按名称排序,并在 TTY 中允许直接选择。
- `tz start``tz stop``tz restart`:生成并校验配置后真实控制受管进程;停止前核对进程用户和可执行文件。
`stop` 不会仅凭 PID 发送信号;`/proc/<pid>/exe` 与当前受管 core 不一致时直接拒绝。
## 验证
```bash
cargo fmt --check
cargo check
cargo test --all-targets
cargo clippy --all-targets -- -D warnings
TZ_PATHS_TOML="$PWD/target/tz-paths.toml" cargo run -- status
TZ_PATHS_TOML="$PWD/target/tz-paths.toml" cargo run -- core list
```
# 控制逻辑界面
TZ 的公开控制入口围绕对象和业务动作组织,不提供任意 TOML 编辑器。配置文件是内部存储格式,所有写操作都经过类型校验、运行状态检查、锁保护和原子保存。
## 当前命令
```bash
tz init
tz status
tz setting
tz setting list
tz setting get <key>
tz setting set <key> [value]
tz setting reset [key]
tz profile add <name> <url-or-file> --family clash|sing-box
tz profile list [--family clash|sing-box] [--all]
tz profile info <name>
tz profile use [name]
tz profile update
tz profile remove <na
tz core add <directory>
tz core list
tz core info [name]
tz core use [name]
tz core remove <name>
tz completion generate bash|zsh|fish # 生成tab服务
```
`tz setting` 无子命令时在 TTY 中进入选择界面;非交互调用使用 `list``set` 缺少 value 时只允许 TTY 交互。`start``stop``restart` 当前明确返回未实现错误,不打印成功状态。
## 简明指令
```bash
tz start|on # 直接读取上一次的配置
tz off|stop|end # 关闭服务,清理环境
tz -l # 列出节点
tz -l <name> # 不区分大小写的关键词搜索
tz completion generate bash|zsh|fish # 生成 shell 补全脚本
# eval "$(tz completion generate bash)"
```
## 快捷键
```bash
tz # tz status 需要展示当前使用的coreprofile节点及其测速
tz select # tz profile list
```
## 状态归属
- `settings.toml``runtime.toml``tz setting` 的固定 key registry 管理,禁止任意路径写入。
- `active.toml` 只由 core 选择等业务命令维护,`tz status` 负责展示,不提供通用字段编辑。
- `profiles.toml` 与受管 profile source 由 `tz profile` 管理;远程来源保存 URL 和实际下载路径,下载优先使用环境代理,失败后回退直连。
- `profile list` 默认按当前 core 的 family 过滤;只有 `--all` 才显示全部 family。
- `core.toml` 与二进制由 `tz core` 只读校验和管理PID、锁、日志属于运行状态。
- generated 配置由当前 family builder 生成并调用真实 core 校验节点选择、测速、真实启停、TUN 独立开关及 shell/GNOME system proxy 已开放。
## 修改规则
1. 命令先重新读取并校验当前状态,再持有 `tz.lock` 执行修改。
2. 文件使用同目录临时文件和原子替换;失败时保留旧状态。
3. profile/core 在受管进程运行时拒绝 `use``update``remove` 等可能改变运行输入的操作。
4. profile URL 只允许 HTTP(S)校验公网地址、DNS 全部结果和每次重定向;本地文件复制为受管副本。
5. core 只接受本地目录,不接受 URL导入成功后不自动选择、不自动启动。
# core 制作
## 目标与范围
TZ 当前支持统一的本地 core 包格式,用于识别和调用已经存在的 Mihomo 或 sing-box 二进制。core 包只描述运行契约,不包含 profile、用户配置、secret、PID、日志或缓存。
当前稳定槽位为 `mihomo``sing-box`,目录名必须与 `core.name` 相同:
```text
data/cores/
├── mihomo/
│ ├── core.toml
│ └── mihomo
└── sing-box/
├── core.toml
└── sing-box
```
## 当前 schema v1
`core.toml` 必须声明 schema、名称、family、版本、二进制、目标平台、配置格式、能力和命令参数。Mihomo 使用 `family = "clash"``format = "yaml"`sing-box 使用 `family = "sing-box"``format = "json"`。命令参数只支持 `{config}``{workdir}` 占位符,由 TZ 展开。完整字段以 [`docs/core-package.md`](core-package.md) 为准。
```toml
schema_version = 1
[core]
name = "mihomo"
family = "clash"
version = "1.19.18"
binary = "mihomo"
os = "linux"
arch = "x86_64"
[runtime]
entrypoint = "config.yaml"
format = "yaml"
[commands.start]
args = ["-d", "{workdir}", "-f", "{config}"]
```
## 当前制作和导入流程
1. 用户自行下载或制作二进制,在本地准备包含 `core.toml` 和可执行文件的目录。
2. 使用 `tz core add <directory>` 导入TZ 校验目录、manifest、平台、family、格式、二进制权限和命令占位符。
3. 如 manifest 声明 `commands.version`,导入前执行该命令;参数按数组传递,不经过 shell。
4. 目标名称已存在时拒绝覆盖;通过 staging 目录复制并原子重命名到 `data/cores/<name>`
5. 导入成功后不自动 `core use`,也不自动启动;后续用 `tz core list/info/use/remove` 管理。
`core remove` 在运行中拒绝,删除前检查当前选择和受管 PID并清理该 core 的派生配置。手工复制到 `data/cores/` 也会被扫描,但不会绕过运行状态和安全校验。
## 当前不做
core 不通过 URL 安装、不负责下载更新、不覆盖正在运行的 core。远端 registry、归档校验和 core update 不在当前路线内。任意其他代理二进制也不能只凭一份 `core.toml` 接入,必须先提供对应 family 的配置 builder 和控制 adapter。
## 本轮实现结果
- 新增 `platform/storage.rs``tz.lock` 非阻塞独占锁、同目录临时文件、flush/fsync、原子 renameprofile 索引和 source 使用当前用户私有权限。
- 新增 `platform/process.rs`:统一正整数 PID、存活和僵尸状态判断供 status、profile/core use/remove 共用。
- 新增 `platform/network.rs`profile URL 只允许 HTTP/HTTPS拒绝凭据、本机、环回、私有和保留地址DNS 全结果与重定向逐次复核,优先使用环境代理并在失败后回退直连,限制超时、重定向和响应大小,错误不泄露订阅 URL。
- 新增 typed setting registry完成 `setting/list/get/set/reset`;不允许任意 TOML 路径编辑,保存后明确提示需要重新 build/start。
- 完成 profile add/list/info/use/update/removeURL 与本地文件都变成受管副本family 固定映射格式,运行中禁止 use/update/remove删除不触碰用户原文件。
- core schema v1 新增必填 os/arch稳定槽位统一为 `mihomo``sing-box`
- 完成 core add/list/info/use/remove手工复制仍可直接扫描add 只做本地安全导入version 命令不经过 shell运行中禁止 use/remove。
- 新增正式规范 `docs/control-interface.md``docs/core-package.md``examples/cores/mihomo/` 制作模板。
## 当前边界与下一阶段
当前路线已完成 config builder、节点选择与测速、受管进程启停、TUN 独立控制及 shell/GNOME system proxy。core 在线更新与其他桌面环境 adapter 留在后续阶段。
# v0.1 可运行闭环
本章覆盖当前可运行行为;前文中的阶段性设计以这里和 `docs/control-interface.md` 为准。
## 当前指令
```bash
tz status|start|stop|restart
tz list [keyword]
tz node test [keyword] [--url <url>] [--timeout <ms>] [--select]
tz tun status|on|off
tz proxy status|on|off
tz proxy terminal|system status|on|off
tz proxy env|noenv [bash|zsh|fish]
tz proxy shell-init bash|zsh|fish
tz setting [list|get|set|reset]
tz profile add|list|info|use|update|remove
tz core add|list|info|use|remove
tz config build|check|show
tz completion generate bash|zsh|fish
```
`profile update` 不接名称,一次更新全部远程 profile。`profile list` 默认只看当前 core family只有 `--all` 跨 family。profile/core/node 的 list 在终端中均可编号选择并以 `*` 标记当前项;`use` 保留给脚本和显式操作。列表保持简洁,路径和来源等详情由 `info` 或对应文件提供。
```text
nano_clash family=clash
* mihomo version=1.19.18 family=clash
```
Mihomo 读取 Clash 配置,所以其 family 必须为 `clash`sing-box 才使用 `family=sing-box`
## 简洁指令
```bash
tz # status并测速当前节点
tz on # 使用上次的可用 profile 启动并显示 status
tz off | tz end # stop
tz -l [keyword] # 节点测速、延迟排序、搜索和选择
tz select # 当前 family 的 profile 列表和选择
```
## 快捷键
```bash
tz st # status
tz r # restart
tz set # setting
tz p # profile
tz c # core
tz cfg # config
tz comp # completion
tz p a|l|i|u|up|rm
tz c a|l|i|u|rm
```
Tab 提示由 completion generator 提供。节点选择会写入 profile state 并在下次启动后恢复。`tz -l``node test` 都走 controller delay API最多 8 路并发、按延迟排序并缓存最新结果;`--select` 选择最快节点。`tz` 对当前节点实时测速,失败时才显示缓存结果。
Mihomo 标准 core 包携带 `Country.mmdb``GeoSite.dat`。Clash profile 实际引用 GEOIP/GEOSITE 时builder 按需复制到该 core 的独立 runtime 工作目录,避免首次校验因 GitHub 下载受阻而误报超时;自制 core 包缺失资源时直接提示用户启用其他代理或补齐 core 包。`tz on/start` 始终使用当前 family 上次选择且 source 可用的 profile启动后立即显示含节点测速的 status。
profile 下载的 User-Agent 按 family 选择Clash 对齐 `mh` 的 Mihomo provider User-Agentsing-box 对齐 `sb`。同一订阅 URL 若按客户端返回 Clash YAML 或 sing-box JSON`add` 与批量 `update` 都能取得对应 family 的原生格式;`--family` 仍只负责选择契约,不做跨格式转换。
## Proxy、TUN 与权限
- `proxy env/noenv` 输出可由当前 shell `eval/source` 的环境命令;`shell-init` 为 Bash、Zsh、Fish 生成持久 hook。
- `proxy system` 按 mh/sb 的当前参考路线使用 GNOME `gsettings`,读取 runtime/capability 端口并把 bypass 转成 ignore-hosts开启前备份原桌面设置关闭或失败时恢复。
- `tun on/off` 检查 core capability、`/dev/net/tun``CAP_NET_ADMIN`;运行中切换会重启,失败回滚,不自动执行 sudo/setcap。
配置生成已通过 Mihomo 1.19.18 和 sing-box 1.13.14 的真实 check。隔离测试覆盖 sing-box 的 start/status/list/select/stop并继续覆盖 proxy 环境输出、TUN 状态和节点测速错误/超时路径。

386
docs/第一版本.md Normal file
View file

@ -0,0 +1,386 @@
# 项目组织
> 使用语言 Go
> 第一版本需要实现的指令
```BASH
tz status
tz start
tz stop
tz restart
tz core list
tz core add
tz core remove
tz core use
tz core info
tz profile list
tz profile add
tz profile remove
tz profile use
tz profile update
tz config build
tz config check
tz node list
tz node select
tz node test
tz log
```
> 文件树
```
tz/
├── Cargo.toml
├── Cargo.lock
├── rust-toolchain.toml
├── README.md
├── LICENSE
├── src/
│ ├── main.rs
│ ├── lib.rs
│ │
│ ├── cli/
│ │ ├── mod.rs
│ │ ├── args.rs
│ │ ├── output.rs
│ │ └── commands/
│ │ ├── mod.rs
│ │ ├── status.rs
│ │ ├── service.rs
│ │ ├── core.rs
│ │ ├── profile.rs
│ │ ├── group.rs
│ │ ├── node.rs
│ │ ├── mode.rs
│ │ ├── tun.rs
│ │ ├── proxy.rs
│ │ ├── setting.rs
│ │ ├── rule.rs
│ │ ├── connection.rs
│ │ ├── config.rs
│ │ ├── log.rs
│ │ └── diagnose.rs
│ │
│ ├── application/
│ │ ├── mod.rs
│ │ ├── context.rs
│ │ └── usecase/
│ │ ├── mod.rs
│ │ ├── start.rs
│ │ ├── stop.rs
│ │ ├── restart.rs
│ │ ├── reload.rs
│ │ ├── switch_core.rs
│ │ ├── switch_profile.rs
│ │ ├── update_profile.rs
│ │ └── select_node.rs
│ │
│ ├── domain/
│ │ ├── mod.rs
│ │ ├── core.rs
│ │ ├── profile.rs
│ │ ├── settings.rs
│ │ ├── runtime.rs
│ │ ├── capability.rs
│ │ ├── group.rs
│ │ ├── node.rs
│ │ ├── connection.rs
│ │ └── error.rs
│ │
│ ├── adapter/
│ │ ├── mod.rs
│ │ ├── traits.rs
│ │ ├── registry.rs
│ │ │
│ │ ├── mihomo/
│ │ │ ├── mod.rs
│ │ │ ├── adapter.rs
│ │ │ ├── command.rs
│ │ │ ├── config.rs
│ │ │ ├── controller.rs
│ │ │ └── response.rs
│ │ │
│ │ └── sing_box/
│ │ ├── mod.rs
│ │ ├── adapter.rs
│ │ ├── command.rs
│ │ ├── config.rs
│ │ ├── controller.rs
│ │ └── response.rs
│ │
│ ├── core_manager/
│ │ ├── mod.rs
│ │ ├── manager.rs
│ │ ├── registry.rs
│ │ ├── package.rs
│ │ ├── installer.rs
│ │ └── remover.rs
│ │
│ ├── profile/
│ │ ├── mod.rs
│ │ ├── manager.rs
│ │ ├── store.rs
│ │ ├── fetcher.rs
│ │ ├── detector.rs
│ │ └── format/
│ │ ├── mod.rs
│ │ ├── clash.rs
│ │ ├── sing_box.rs
│ │ └── uri_list.rs
│ │
│ ├── config/
│ │ ├── mod.rs
│ │ ├── builder.rs
│ │ ├── overlay.rs
│ │ ├── effective.rs
│ │ ├── bypass.rs
│ │ └── validator.rs
│ │
│ ├── runtime/
│ │ ├── mod.rs
│ │ ├── manager.rs
│ │ ├── process.rs
│ │ ├── pid.rs
│ │ ├── lock.rs
│ │ ├── health.rs
│ │ ├── state.rs
│ │ └── log.rs
│ │
│ ├── platform/
│ │ ├── mod.rs
│ │ ├── paths.rs
│ │ ├── shell.rs
│ │ ├── system_proxy.rs
│ │ ├── service.rs
│ │ └── linux/
│ │ ├── mod.rs
│ │ ├── systemd.rs
│ │ ├── signal.rs
│ │ ├── tun.rs
│ │ └── routes.rs
│ │
│ ├── storage/
│ │ ├── mod.rs
│ │ ├── atomic_file.rs
│ │ ├── core_store.rs
│ │ ├── profile_store.rs
│ │ ├── settings_store.rs
│ │ └── runtime_store.rs
│ │
│ ├── infrastructure/
│ │ ├── mod.rs
│ │ ├── command.rs
│ │ ├── http.rs
│ │ ├── download.rs
│ │ ├── checksum.rs
│ │ └── filesystem.rs
│ │
│ └── diagnose/
│ ├── mod.rs
│ ├── process.rs
│ ├── ports.rs
│ ├── api.rs
│ ├── config.rs
│ └── network.rs
├── assets/
│ ├── defaults/
│ │ └── settings.toml
│ ├── templates/
│ │ ├── mihomo.yaml
│ │ └── sing-box.json
│ └── systemd/
│ └── tz.service
├── tests/
│ ├── cli.rs
│ ├── start_stop.rs
│ ├── profile.rs
│ └── adapter.rs
├── testdata/
│ ├── profiles/
│ │ ├── clash.yaml
│ │ └── sing-box.json
│ ├── api/
│ │ ├── mihomo-proxies.json
│ │ ├── mihomo-connections.json
│ │ └── mihomo-version.json
│ └── bin/
│ └── fake-core
├── docs/
│ ├── scope.md
│ ├── domain-model.md
│ ├── workflows.md
│ ├── adapter-contract.md
│ └── filesystem-layout.md
├── packaging/
│ ├── systemd/
│ └── completions/
└── scripts/
├── install.sh
├── uninstall.sh
└── release.sh
```
> 运行树
```
语言 Rust
异步运行时 Tokio
CLI clap derive
序列化 Serde
HTTP reqwest
错误类型 thiserror + anyhow
日志 tracing
项目配置 TOML
Clash Profile YAML
sing-box JSON
```
```
~/.config/tz/
├── settings.toml
├── current.toml
├── bypass.list
├── profiles/
│ ├── home/
│ │ ├── manifest.toml
│ │ └── source.yaml
│ └── company/
│ ├── manifest.toml
│ └── source.json
└── overlays/
├── common.toml
├── mihomo.yaml
└── sing-box.json
~/.local/share/tz/
└── cores/
├── mihomo/
│ ├── current
│ └── versions/
│ └── 1.20.0/
│ ├── manifest.toml
│ └── bin/
│ └── mihomo
└── sing-box/
├── current
└── versions/
└── 1.x/
├── manifest.toml
└── bin/
└── sing-box
~/.local/state/tz/
├── logs/
│ ├── tz.log
│ └── core.log
├── builds/
│ ├── effective-mihomo.yaml
│ └── effective-sing-box.json
├── history/
└── runtime-history/
~/.cache/tz/
├── downloads/
├── subscriptions/
└── latency/
${XDG_RUNTIME_DIR}/tz/
├── tz.lock
├── core.pid
├── runtime.json
├── effective-config
└── control.sock
```
# rust开发环境
```
Rust + Tokio + clap
Mihomo
Clash YAML profile
start / stop / status / log
node list / select
```
**现在使用一个 Package包含一个 Library target 和一个 Binary target。**
不使用 Workspace也不使用多 Bin。
# 开发顺序
```
第一阶段
├── 路径系统
├── SettingsStore
├── CoreRegistry
├── ProfileStore
├── MihomoAdapter
├── RuntimeManager
├── tz start
├── tz stop
├── tz status
└── tz log
```
```
第二阶段
├── Mihomo Controller API
├── group list/select
├── node list/select/test
├── mode
├── reload
└── profile update
```
```
第三阶段
├── sing-box Adapter
├── Capability 检测
├── sing-box 配置生成
├── TUN
├── system proxy
└── bypass
```
```
第四阶段
├── core install/update
├── diagnose
├── connection
├── completion
└── 发布与安装脚本
```

View file

@ -0,0 +1,9 @@
Place the downloaded Mihomo binary in this directory as `mihomo`, make it executable, and replace `REPLACE_WITH_REAL_VERSION` in `core.toml`.
Then import it with:
```bash
tz core add ./examples/cores/mihomo
```
The example manifest targets Linux x86_64. Adjust `arch` for the current Rust target architecture when needed.

View file

@ -0,0 +1,30 @@
schema_version = 1
[core]
name = "mihomo"
family = "clash"
version = "REPLACE_WITH_REAL_VERSION"
binary = "mihomo"
os = "linux"
arch = "x86_64"
[runtime]
entrypoint = "config.yaml"
format = "yaml"
[capabilities.config]
mixed_proxy = true
http_proxy = true
socks_proxy = true
api = true
dns = true
tun = true
[commands.start]
args = ["-d", "{workdir}", "-f", "{config}"]
[commands.check]
args = ["-t", "-d", "{workdir}", "-f", "{config}"]
[commands.version]
args = ["-v"]

View file

@ -0,0 +1,677 @@
mixed-port: 7890
allow-lan: true
unified-delay: true
tcp-concurrent: true
ipv6: true
bind-address: '*'
mode: rule
log-level: info
external-controller: '127.0.0.1:9090'
hosts:
dns.alidns.com: 223.6.6.6
doh.pub: 1.12.12.12
dns:
enable: true
ipv6: true
default-nameserver: [223.6.6.6, 119.29.29.29, 180.184.1.1]
enhanced-mode: fake-ip
fake-ip-range: 198.18.0.1/16
use-hosts: true
nameserver: ['https://dns.alidns.com/dns-query', 'https://doh.pub/dns-query']
proxy-server-nameserver: ['https://223.6.6.6/dns-query', 'https://doh.pub/dns-query', 'https://doh.360.cn/dns-query']
fallback: ['https://223.5.5.5/dns-query', 'https://223.6.6.6/dns-query', 'https://doh.360.cn/dns-query']
fallback-filter: { geoip: true, geoip-code: CN, geosite: [gfw], ipcidr: [240.0.0.0/4], domain: [+.google.com, +.facebook.com, +.youtube.com] }
proxies:
- { name: ❇️白羊座-A(通用), type: tuic, server: hkv6a.8c7d12ac-b133-4847-9e45-4684e612d4b8.a5e6e977.edu-header-airport.com, port: 52892, uuid: 50800dff-624a-479f-aa07-e1595f3eb20f, password: 50800dff-624a-479f-aa07-e1595f3eb20f, alpn: [h3], disable-sni: false, reduce-rtt: false, udp-relay-mode: native, congestion-controller: bbr, skip-cert-verify: true, sni: www.bing.com }
- { name: ❇️白羊座-B(通用), type: tuic, server: hkv6b.4eb56bd3-45b8-49c5-a967-a1e497dceb67.7cc0aa7a.edu-header-airport.com, port: 52892, uuid: 50800dff-624a-479f-aa07-e1595f3eb20f, password: 50800dff-624a-479f-aa07-e1595f3eb20f, alpn: [h3], disable-sni: false, reduce-rtt: false, udp-relay-mode: native, congestion-controller: bbr, skip-cert-verify: true, sni: www.bing.com }
- { name: 🇯🇵日本-Y(通用), type: vless, server: cos-cdn-u.alicdn.win, port: 443, uuid: 50800dff-624a-479f-aa07-e1595f3eb20f, udp: true, tls: true, servername: mms-statlc-yuag-y.vivocdn.sbs, client-fingerprint: edge, network: ws, ws-opts: { path: /newlogin/login.do, headers: { Host: mms-static-main-y.vivocdn.sbs, User-Agent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' }, max-early-data: 2560, early-data-header-name: Sec-WebSocket-Protocol } }
- { name: 🇺🇸美国-A(通用), type: tuic, server: usa.e974c2d6-0b71-4913-931a-4d38f58a71fc.bea0aeb5.demo-chat-airport.com, port: 52892, uuid: 50800dff-624a-479f-aa07-e1595f3eb20f, password: 50800dff-624a-479f-aa07-e1595f3eb20f, alpn: [h3], disable-sni: false, reduce-rtt: false, udp-relay-mode: native, congestion-controller: bbr, skip-cert-verify: true, sni: www.bing.com }
- { name: 🇺🇸美国-B(流量), type: tuic, server: usb.e974c2d6-0b71-4913-931a-4d38f58a71fc.6786edb8.demo-chat-airport.com, port: 52892, uuid: 50800dff-624a-479f-aa07-e1595f3eb20f, password: 50800dff-624a-479f-aa07-e1595f3eb20f, alpn: [h3], disable-sni: false, reduce-rtt: false, udp-relay-mode: native, congestion-controller: bbr, skip-cert-verify: true, sni: www.bing.com }
- { name: 🇺🇸美国-C(通用), type: tuic, server: usc.935520ac-0b37-4759-a52b-54eb2a2b523b.d09b2cbc.edu-header-airport.com, port: 52892, uuid: 50800dff-624a-479f-aa07-e1595f3eb20f, password: 50800dff-624a-479f-aa07-e1595f3eb20f, alpn: [h3], disable-sni: false, reduce-rtt: false, udp-relay-mode: native, congestion-controller: bbr, skip-cert-verify: true, sni: www.bing.com }
- { name: 🇺🇸美国-D(流量), type: tuic, server: usd.935520ac-0b37-4759-a52b-54eb2a2b523b.d5cb6ba2.edu-header-airport.com, port: 52892, uuid: 50800dff-624a-479f-aa07-e1595f3eb20f, password: 50800dff-624a-479f-aa07-e1595f3eb20f, alpn: [h3], disable-sni: false, reduce-rtt: false, udp-relay-mode: native, congestion-controller: bbr, skip-cert-verify: true, sni: www.bing.com }
- { name: 🇭🇰香江-E(通用), type: vless, server: cos-cdn-c.alicdn.win, port: 443, uuid: 50800dff-624a-479f-aa07-e1595f3eb20f, udp: true, tls: true, servername: mms-statlc-mhym7-e.codeu.men, client-fingerprint: edge, network: ws, ws-opts: { path: /newlogin/login.do, headers: { Host: mms-static-rende-e.codeu.men, User-Agent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' }, max-early-data: 2560, early-data-header-name: Sec-WebSocket-Protocol } }
- { name: 🇭🇰香江-F(通用), type: vless, server: cos-cdn-u.alicdn.win, port: 443, uuid: 50800dff-624a-479f-aa07-e1595f3eb20f, udp: true, tls: true, servername: mms-statlc-zcgi0-f.codeu.men, client-fingerprint: edge, network: ws, ws-opts: { path: /newlogin/login.do, headers: { Host: mms-static-rende-f.codeu.men, User-Agent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' }, max-early-data: 2560, early-data-header-name: Sec-WebSocket-Protocol } }
- { name: 🇭🇰香江-G(流量), type: vless, server: cos-cdn-f.alicdn.win, port: 443, uuid: 50800dff-624a-479f-aa07-e1595f3eb20f, udp: true, tls: true, servername: mms-statlc-fj61v-g.aliapp.men, client-fingerprint: firefox, network: ws, ws-opts: { path: /newlogin/login.do, headers: { Host: mms-static-rende-g.aliapp.men, User-Agent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' }, max-early-data: 2560, early-data-header-name: Sec-WebSocket-Protocol } }
- { name: 🇭🇰香江-H(流量), type: vless, server: cos-cdn-z.alicdn.win, port: 443, uuid: 50800dff-624a-479f-aa07-e1595f3eb20f, udp: true, tls: true, servername: mms-statlc-36ez6-h.aliapp.men, client-fingerprint: safari, network: ws, ws-opts: { path: /newlogin/login.do, headers: { Host: mms-static-rende-h.aliapp.men, User-Agent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' }, max-early-data: 2560, early-data-header-name: Sec-WebSocket-Protocol } }
- { name: 🇭🇰香港-A(通用), type: tuic, server: hk.de180997-0810-4174-8f14-eb8d79d47a0c.4c8f0b93.demo-chat-airport.com, port: 52892, uuid: 50800dff-624a-479f-aa07-e1595f3eb20f, password: 50800dff-624a-479f-aa07-e1595f3eb20f, alpn: [h3], disable-sni: false, reduce-rtt: false, udp-relay-mode: native, congestion-controller: bbr, skip-cert-verify: true, sni: www.bing.com }
- { name: 🇭🇰香港-A(流量), type: tuic, server: hkv6.de180997-0810-4174-8f14-eb8d79d47a0c.4612afe1.demo-chat-airport.com, port: 52892, uuid: 50800dff-624a-479f-aa07-e1595f3eb20f, password: 50800dff-624a-479f-aa07-e1595f3eb20f, alpn: [h3], disable-sni: false, reduce-rtt: false, udp-relay-mode: native, congestion-controller: bbr, skip-cert-verify: true, sni: www.bing.com }
- { name: 🇯🇵日本-A(通用), type: tuic, server: jp.539bb8de-73bb-4398-8c9b-d145a8901e8f.5201a354.demo-chat-airport.com, port: 52892, uuid: 50800dff-624a-479f-aa07-e1595f3eb20f, password: 50800dff-624a-479f-aa07-e1595f3eb20f, alpn: [h3], disable-sni: false, reduce-rtt: false, udp-relay-mode: native, congestion-controller: bbr, skip-cert-verify: true, sni: www.bing.com }
- { name: 🇯🇵日本-A(流量), type: tuic, server: jpv6.539bb8de-73bb-4398-8c9b-d145a8901e8f.5e63934c.demo-chat-airport.com, port: 52892, uuid: 50800dff-624a-479f-aa07-e1595f3eb20f, password: 50800dff-624a-479f-aa07-e1595f3eb20f, alpn: [h3], disable-sni: false, reduce-rtt: false, udp-relay-mode: native, congestion-controller: bbr, skip-cert-verify: true, sni: www.bing.com }
- { name: 🇯🇵日本-B(通用), type: tuic, server: jpa.539bb8de-73bb-4398-8c9b-d145a8901e8f.b01401d1.demo-chat-airport.com, port: 52892, uuid: 50800dff-624a-479f-aa07-e1595f3eb20f, password: 50800dff-624a-479f-aa07-e1595f3eb20f, alpn: [h3], disable-sni: false, reduce-rtt: false, udp-relay-mode: native, congestion-controller: bbr, skip-cert-verify: true, sni: www.bing.com }
- { name: 🇯🇵日本-B(流量), type: tuic, server: jpb.539bb8de-73bb-4398-8c9b-d145a8901e8f.693242dc.demo-chat-airport.com, port: 52892, uuid: 50800dff-624a-479f-aa07-e1595f3eb20f, password: 50800dff-624a-479f-aa07-e1595f3eb20f, alpn: [h3], disable-sni: false, reduce-rtt: false, udp-relay-mode: native, congestion-controller: bbr, skip-cert-verify: true, sni: www.bing.com }
- { name: 🇰🇷韩国-A(通用), type: tuic, server: kr.91b30040-0142-4050-a005-142dc68a5ba8.e0fb388f.demo-chat-airport.com, port: 52892, uuid: 50800dff-624a-479f-aa07-e1595f3eb20f, password: 50800dff-624a-479f-aa07-e1595f3eb20f, alpn: [h3], disable-sni: false, reduce-rtt: false, udp-relay-mode: native, congestion-controller: bbr, skip-cert-verify: true, sni: www.bing.com }
- { name: 🇰🇷韩国-A(流量), type: tuic, server: krv6.91b30040-0142-4050-a005-142dc68a5ba8.e7a04f93.demo-chat-airport.com, port: 52892, uuid: 50800dff-624a-479f-aa07-e1595f3eb20f, password: 50800dff-624a-479f-aa07-e1595f3eb20f, alpn: [h3], disable-sni: false, reduce-rtt: false, udp-relay-mode: native, congestion-controller: bbr, skip-cert-verify: true, sni: www.bing.com }
- { name: 🇸🇬新加坡-A(通用), type: tuic, server: sg.173a00e1-f7e7-4018-9d07-3850d2ca48ad.ba93f54e.demo-chat-airport.com, port: 52892, uuid: 50800dff-624a-479f-aa07-e1595f3eb20f, password: 50800dff-624a-479f-aa07-e1595f3eb20f, alpn: [h3], disable-sni: false, reduce-rtt: false, udp-relay-mode: native, congestion-controller: bbr, skip-cert-verify: true, sni: www.bing.com }
- { name: 🇸🇬新加坡-A(流量), type: tuic, server: sgv6.173a00e1-f7e7-4018-9d07-3850d2ca48ad.a5c38186.demo-chat-airport.com, port: 52892, uuid: 50800dff-624a-479f-aa07-e1595f3eb20f, password: 50800dff-624a-479f-aa07-e1595f3eb20f, alpn: [h3], disable-sni: false, reduce-rtt: false, udp-relay-mode: native, congestion-controller: bbr, skip-cert-verify: true, sni: www.bing.com }
- { name: 🇲🇾马来-A(通用), type: tuic, server: hkv6c.de180997-0810-4174-8f14-eb8d79d47a0c.cbdd6b7e.demo-chat-airport.com, port: 52892, uuid: 50800dff-624a-479f-aa07-e1595f3eb20f, password: 50800dff-624a-479f-aa07-e1595f3eb20f, alpn: [h3], disable-sni: false, reduce-rtt: false, udp-relay-mode: native, congestion-controller: bbr, skip-cert-verify: true, sni: www.bing.com }
- { name: 🇲🇾马来-A(流量), type: tuic, server: hkv6d.de180997-0810-4174-8f14-eb8d79d47a0c.ce8d2c60.demo-chat-airport.com, port: 52892, uuid: 50800dff-624a-479f-aa07-e1595f3eb20f, password: 50800dff-624a-479f-aa07-e1595f3eb20f, alpn: [h3], disable-sni: false, reduce-rtt: false, udp-relay-mode: native, congestion-controller: bbr, skip-cert-verify: true, sni: www.bing.com }
- { name: 🇹🇼台湾-A(通用), type: tuic, server: twa.8b437e21-4951-49e6-9587-3a4be48e9e30.902d52f1.demo-chat-airport.com, port: 52892, uuid: 50800dff-624a-479f-aa07-e1595f3eb20f, password: 50800dff-624a-479f-aa07-e1595f3eb20f, alpn: [h3], disable-sni: false, reduce-rtt: false, udp-relay-mode: native, congestion-controller: bbr, skip-cert-verify: true, sni: www.bing.com }
- { name: 🇹🇼台湾-A(流量), type: tuic, server: twb.8b437e21-4951-49e6-9587-3a4be48e9e30.490b11fc.demo-chat-airport.com, port: 52892, uuid: 50800dff-624a-479f-aa07-e1595f3eb20f, password: 50800dff-624a-479f-aa07-e1595f3eb20f, alpn: [h3], disable-sni: false, reduce-rtt: false, udp-relay-mode: native, congestion-controller: bbr, skip-cert-verify: true, sni: www.bing.com }
- { name: 🇸🇬狮城-E(通用), type: vless, server: cos-cdn-c.alicdn.win, port: 443, uuid: 50800dff-624a-479f-aa07-e1595f3eb20f, udp: true, tls: true, servername: mms-statlc-pr17q-e.bjedu.men, client-fingerprint: safari, network: ws, ws-opts: { path: /newlogin/login.do, headers: { Host: mms-static-gecko-e.bjedu.men, User-Agent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' }, max-early-data: 2560, early-data-header-name: Sec-WebSocket-Protocol } }
- { name: 🇸🇬狮城-F(通用), type: vless, server: cos-cdn-u.alicdn.win, port: 443, uuid: 50800dff-624a-479f-aa07-e1595f3eb20f, udp: true, tls: true, servername: mms-statlc-xppte-f.bjedu.men, client-fingerprint: edge, network: ws, ws-opts: { path: /newlogin/login.do, headers: { Host: mms-static-gecko-f.bjedu.men, User-Agent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' }, max-early-data: 2560, early-data-header-name: Sec-WebSocket-Protocol } }
- { name: 🇸🇬狮城-G(流量), type: vless, server: cos-cdn-f.alicdn.win, port: 443, uuid: 50800dff-624a-479f-aa07-e1595f3eb20f, udp: true, tls: true, servername: mms-statlc-2dbxu-g.aliapp.men, client-fingerprint: safari, network: ws, ws-opts: { path: /newlogin/login.do, headers: { Host: mms-static-gecko-g.aliapp.men, User-Agent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' }, max-early-data: 2560, early-data-header-name: Sec-WebSocket-Protocol } }
- { name: 🇸🇬狮城-H(流量), type: vless, server: cos-cdn-z.alicdn.win, port: 443, uuid: 50800dff-624a-479f-aa07-e1595f3eb20f, udp: true, tls: true, servername: mms-statlc-gtpd7-h.aliapp.men, client-fingerprint: safari, network: ws, ws-opts: { path: /newlogin/login.do, headers: { Host: mms-static-gecko-h.aliapp.men, User-Agent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' }, max-early-data: 2560, early-data-header-name: Sec-WebSocket-Protocol } }
- { name: 🇯🇵东京-E(通用), type: vless, server: cos-cdn-a.alicdn.win, port: 443, uuid: 50800dff-624a-479f-aa07-e1595f3eb20f, udp: true, tls: true, servername: mms-statlc-fkva-e.tjedu.men, client-fingerprint: ios, network: ws, ws-opts: { path: /newlogin/login.do, headers: { Host: mms-static-main-e.tjedu.men, User-Agent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' }, max-early-data: 2560, early-data-header-name: Sec-WebSocket-Protocol } }
- { name: 🇯🇵东京-F(通用), type: vless, server: cos-cdn-b.alicdn.win, port: 443, uuid: 50800dff-624a-479f-aa07-e1595f3eb20f, udp: true, tls: true, servername: mms-statlc-h9gh-f.uedu.men, client-fingerprint: safari, network: ws, ws-opts: { path: /newlogin/login.do, headers: { Host: mms-static-main-f.uedu.men, User-Agent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' }, max-early-data: 2560, early-data-header-name: Sec-WebSocket-Protocol } }
- { name: 🇯🇵东京-G(流量), type: vless, server: cos-cdn-t.alicdn.win, port: 443, uuid: 50800dff-624a-479f-aa07-e1595f3eb20f, udp: true, tls: true, servername: mms-statlc-ruck-g.aliapp.men, client-fingerprint: qq, network: ws, ws-opts: { path: /newlogin/login.do, headers: { Host: mms-static-main-g.aliapp.men, User-Agent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' }, max-early-data: 2560, early-data-header-name: Sec-WebSocket-Protocol } }
- { name: 🇯🇵东京-H(流量), type: vless, server: cos-cdn-y.alicdn.win, port: 443, uuid: 50800dff-624a-479f-aa07-e1595f3eb20f, udp: true, tls: true, servername: mms-statlc-e0ks-h.aliapp.men, client-fingerprint: qq, network: ws, ws-opts: { path: /newlogin/login.do, headers: { Host: mms-static-main-h.aliapp.men, User-Agent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' }, max-early-data: 2560, early-data-header-name: Sec-WebSocket-Protocol } }
- { name: 🇺🇸西美-E(通用), type: vless, server: cos-cdn-k.alicdn.win, port: 443, uuid: 50800dff-624a-479f-aa07-e1595f3eb20f, udp: true, tls: true, servername: mms-statlc-z18nge-e.hzedu.men, client-fingerprint: edge, network: ws, ws-opts: { path: /newlogin/login.do, headers: { Host: mms-static-bottom-e.hzedu.men, User-Agent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' }, max-early-data: 2560, early-data-header-name: Sec-WebSocket-Protocol } }
- { name: 🇺🇸西美-F(通用), type: vless, server: cos-cdn-m.alicdn.win, port: 443, uuid: 50800dff-624a-479f-aa07-e1595f3eb20f, udp: true, tls: true, servername: mms-statlc-8rt3k8-f.hzedu.men, client-fingerprint: safari, network: ws, ws-opts: { path: /newlogin/login.do, headers: { Host: mms-static-bottom-f.hzedu.men, User-Agent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' }, max-early-data: 2560, early-data-header-name: Sec-WebSocket-Protocol } }
- { name: 🇺🇸西美-G(流量), type: vless, server: cos-cdn-l.alicdn.win, port: 443, uuid: 50800dff-624a-479f-aa07-e1595f3eb20f, udp: true, tls: true, servername: mms-statlc-qp9a1a-g.aliapp.men, client-fingerprint: qq, network: ws, ws-opts: { path: /newlogin/login.do, headers: { Host: mms-static-bottom-g.aliapp.men, User-Agent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' }, max-early-data: 2560, early-data-header-name: Sec-WebSocket-Protocol } }
- { name: 🇺🇸西美-H(流量), type: vless, server: cos-cdn-n.alicdn.win, port: 443, uuid: 50800dff-624a-479f-aa07-e1595f3eb20f, udp: true, tls: true, servername: mms-statlc-u5lxk8-h.aliapp.men, client-fingerprint: ios, network: ws, ws-opts: { path: /newlogin/login.do, headers: { Host: mms-static-bottom-h.aliapp.men, User-Agent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' }, max-early-data: 2560, early-data-header-name: Sec-WebSocket-Protocol } }
proxy-groups:
- { name: '🚀 节点选择', type: select, proxies: ['♻️ 自动选择', ❇️白羊座-A(通用), ❇️白羊座-B(通用), 🇯🇵日本-Y(通用), 🇺🇸美国-A(通用), 🇺🇸美国-B(流量), 🇺🇸美国-C(通用), 🇺🇸美国-D(流量), 🇭🇰香江-E(通用), 🇭🇰香江-F(通用), 🇭🇰香江-G(流量), 🇭🇰香江-H(流量), 🇭🇰香港-A(通用), 🇭🇰香港-A(流量), 🇯🇵日本-A(通用), 🇯🇵日本-A(流量), 🇯🇵日本-B(通用), 🇯🇵日本-B(流量), 🇰🇷韩国-A(通用), 🇰🇷韩国-A(流量), 🇸🇬新加坡-A(通用), 🇸🇬新加坡-A(流量), 🇲🇾马来-A(通用), 🇲🇾马来-A(流量), 🇹🇼台湾-A(通用), 🇹🇼台湾-A(流量), 🇸🇬狮城-E(通用), 🇸🇬狮城-F(通用), 🇸🇬狮城-G(流量), 🇸🇬狮城-H(流量), 🇯🇵东京-E(通用), 🇯🇵东京-F(通用), 🇯🇵东京-G(流量), 🇯🇵东京-H(流量), 🇺🇸西美-E(通用), 🇺🇸西美-F(通用), 🇺🇸西美-G(流量), 🇺🇸西美-H(流量)] }
- { name: '♻️ 自动选择', type: url-test, proxies: [❇️白羊座-A(通用), ❇️白羊座-B(通用), 🇯🇵日本-Y(通用), 🇺🇸美国-A(通用), 🇺🇸美国-B(流量), 🇺🇸美国-C(通用), 🇺🇸美国-D(流量), 🇭🇰香江-E(通用), 🇭🇰香江-F(通用), 🇭🇰香江-G(流量), 🇭🇰香江-H(流量), 🇭🇰香港-A(通用), 🇭🇰香港-A(流量), 🇯🇵日本-A(通用), 🇯🇵日本-A(流量), 🇯🇵日本-B(通用), 🇯🇵日本-B(流量), 🇰🇷韩国-A(通用), 🇰🇷韩国-A(流量), 🇸🇬新加坡-A(通用), 🇸🇬新加坡-A(流量), 🇲🇾马来-A(通用), 🇲🇾马来-A(流量), 🇹🇼台湾-A(通用), 🇹🇼台湾-A(流量), 🇸🇬狮城-E(通用), 🇸🇬狮城-F(通用), 🇸🇬狮城-G(流量), 🇸🇬狮城-H(流量), 🇯🇵东京-E(通用), 🇯🇵东京-F(通用), 🇯🇵东京-G(流量), 🇯🇵东京-H(流量), 🇺🇸西美-E(通用), 🇺🇸西美-F(通用), 🇺🇸西美-G(流量), 🇺🇸西美-H(流量)], url: 'http://www.apple.com/library/test/success.html', interval: 300 }
rules:
- 'IP-CIDR,104.18.31.24/32,DIRECT,no-resolve'
- 'IP-CIDR,104.18.31.79/32,DIRECT,no-resolve'
- 'IP-CIDR,104.19.255.11/32,DIRECT,no-resolve'
- 'IP-CIDR,172.64.79.200/32,DIRECT,no-resolve'
- 'IP-CIDR,172.64.79.237/32,DIRECT,no-resolve'
- 'IP-CIDR,172.64.79.54/32,DIRECT,no-resolve'
- 'IP-CIDR,172.64.79.87/32,DIRECT,no-resolve'
- 'IP-CIDR,104.17.31.233/32,DIRECT,no-resolve'
- 'IP-CIDR,104.17.31.250/32,DIRECT,no-resolve'
- 'IP-CIDR,104.18.31.228/32,DIRECT,no-resolve'
- 'IP-CIDR,104.17.31.152/32,DIRECT,no-resolve'
- 'IP-CIDR,172.64.79.46/32,DIRECT,no-resolve'
- 'IP-CIDR,172.64.229.74/32,DIRECT,no-resolve'
- 'IP-CIDR,104.26.0.232/32,DIRECT,no-resolve'
- 'IP-CIDR,104.26.0.195/32,DIRECT,no-resolve'
- 'IP-CIDR,104.26.0.175/32,DIRECT,no-resolve'
- 'IP-CIDR,104.19.255.50/32,DIRECT,no-resolve'
- 'IP-CIDR,104.18.30.194/32,DIRECT,no-resolve'
- 'IP-CIDR,104.18.30.115/32,DIRECT,no-resolve'
- 'IP-CIDR,104.18.30.102/32,DIRECT,no-resolve'
- 'IP-CIDR,162.159.143.125/32,DIRECT,no-resolve'
- 'IP-CIDR,162.159.143.150/32,DIRECT,no-resolve'
- 'IP-CIDR,162.159.152.154/32,DIRECT,no-resolve'
- 'IP-CIDR,162.159.152.204/32,DIRECT,no-resolve'
- 'IP-CIDR,198.41.208.192/32,DIRECT,no-resolve'
- 'IP-CIDR,198.41.208.198/32,DIRECT,no-resolve'
- 'IP-CIDR,104.18.43.53/32,DIRECT,no-resolve'
- 'IP-CIDR,104.18.88.132/32,DIRECT,no-resolve'
- 'IP-CIDR,104.19.49.110/32,DIRECT,no-resolve'
- 'IP-CIDR,162.159.134.49/32,DIRECT,no-resolve'
- 'IP-CIDR,104.18.88.96/32,DIRECT,no-resolve'
- 'IP-CIDR,162.159.143.31/32,DIRECT,no-resolve'
- 'IP-CIDR,162.159.143.66/32,DIRECT,no-resolve'
- 'IP-CIDR,162.159.152.109/32,DIRECT,no-resolve'
- 'IP-CIDR,162.159.152.21/32,DIRECT,no-resolve'
- 'IP-CIDR,172.64.146.153/32,DIRECT,no-resolve'
- 'IP-CIDR,172.64.146.197/32,DIRECT,no-resolve'
- 'IP-CIDR,198.41.208.99/32,DIRECT,no-resolve'
- 'IP-CIDR,104.16.248.125/32,DIRECT,no-resolve'
- 'IP-CIDR,104.18.43.155/32,DIRECT,no-resolve'
- 'IP-CIDR,48.193.44.2/32,DIRECT,no-resolve'
- 'IP-CIDR,4.193.176.7/32,DIRECT,no-resolve'
- 'IP-CIDR,20.194.48.43/32,DIRECT,no-resolve'
- 'IP-CIDR,20.89.192.233/32,DIRECT,no-resolve'
- 'IP-CIDR,20.89.49.217/32,DIRECT,no-resolve'
- 'IP-CIDR,104.208.83.84/32,DIRECT,no-resolve'
- 'IP-CIDR,162.159.152.212/32,DIRECT,no-resolve'
- 'IP-CIDR,172.64.229.139/32,DIRECT,no-resolve'
- 'IP-CIDR,172.64.229.211/32,DIRECT,no-resolve'
- 'IP-CIDR,104.16.248.221/32,DIRECT,no-resolve'
- 'IP-CIDR,104.18.88.15/32,DIRECT,no-resolve'
- 'IP-CIDR,162.159.143.223/32,DIRECT,no-resolve'
- 'IP-CIDR,162.159.143.61/32,DIRECT,no-resolve'
- 'IP-CIDR,162.159.143.86/32,DIRECT,no-resolve'
- 'IP-CIDR,162.159.152.117/32,DIRECT,no-resolve'
- 'IP-CIDR,162.159.152.126/32,DIRECT,no-resolve'
- 'IP-CIDR,20.114.49.182/32,DIRECT,no-resolve'
- 'IP-CIDR,13.66.155.53/32,DIRECT,no-resolve'
- 'IP-CIDR,104.19.49.151/32,DIRECT,no-resolve'
- 'IP-CIDR,104.19.49.190/32,DIRECT,no-resolve'
- 'IP-CIDR,162.159.134.21/32,DIRECT,no-resolve'
- 'IP-CIDR,162.159.134.228/32,DIRECT,no-resolve'
- 'IP-CIDR,162.159.143.10/32,DIRECT,no-resolve'
- 'IP-CIDR,162.159.143.166/32,DIRECT,no-resolve'
- 'IP-CIDR,162.159.152.147/32,DIRECT,no-resolve'
- 'IP-CIDR,172.64.229.202/32,DIRECT,no-resolve'
- 'IP-CIDR,172.64.229.95/32,DIRECT,no-resolve'
- 'IP-CIDR,198.41.208.4/32,DIRECT,no-resolve'
- 'IP-CIDR,48.193.40.174/32,DIRECT,no-resolve'
- 'IP-CIDR,85.211.195.182/32,DIRECT,no-resolve'
- 'DOMAIN,nano-github.52iplc.com,DIRECT'
- 'DOMAIN-SUFFIX,services.googleapis.cn,🚀 节点选择'
- 'DOMAIN-SUFFIX,xn--ngstr-lra8j.com,🚀 节点选择'
- 'DOMAIN,safebrowsing.urlsec.qq.com,DIRECT'
- 'DOMAIN,safebrowsing.googleapis.com,DIRECT'
- 'DOMAIN,developer.apple.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,digicert.com,🚀 节点选择'
- 'DOMAIN,ocsp.apple.com,🚀 节点选择'
- 'DOMAIN,ocsp.comodoca.com,🚀 节点选择'
- 'DOMAIN,ocsp.usertrust.com,🚀 节点选择'
- 'DOMAIN,ocsp.sectigo.com,🚀 节点选择'
- 'DOMAIN,ocsp.verisign.net,🚀 节点选择'
- 'DOMAIN-SUFFIX,apple-dns.net,🚀 节点选择'
- 'DOMAIN,testflight.apple.com,🚀 节点选择'
- 'DOMAIN,sandbox.itunes.apple.com,🚀 节点选择'
- 'DOMAIN,itunes.apple.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,apps.apple.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,blobstore.apple.com,🚀 节点选择'
- 'DOMAIN,cvws.icloud-content.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,mzstatic.com,DIRECT'
- 'DOMAIN-SUFFIX,itunes.apple.com,DIRECT'
- 'DOMAIN-SUFFIX,icloud.com,DIRECT'
- 'DOMAIN-SUFFIX,icloud-content.com,DIRECT'
- 'DOMAIN-SUFFIX,me.com,DIRECT'
- 'DOMAIN-SUFFIX,aaplimg.com,DIRECT'
- 'DOMAIN-SUFFIX,cdn20.com,DIRECT'
- 'DOMAIN-SUFFIX,cdn-apple.com,DIRECT'
- 'DOMAIN-SUFFIX,akadns.net,DIRECT'
- 'DOMAIN-SUFFIX,akamaiedge.net,DIRECT'
- 'DOMAIN-SUFFIX,edgekey.net,DIRECT'
- 'DOMAIN-SUFFIX,mwcloudcdn.com,DIRECT'
- 'DOMAIN-SUFFIX,mwcname.com,DIRECT'
- 'DOMAIN-SUFFIX,apple.com,DIRECT'
- 'DOMAIN-SUFFIX,apple-cloudkit.com,DIRECT'
- 'DOMAIN-SUFFIX,apple-mapkit.com,DIRECT'
- 'DOMAIN,cn.bing.com,DIRECT'
- 'DOMAIN-SUFFIX,126.com,DIRECT'
- 'DOMAIN-SUFFIX,126.net,DIRECT'
- 'DOMAIN-SUFFIX,127.net,DIRECT'
- 'DOMAIN-SUFFIX,163.com,DIRECT'
- 'DOMAIN-SUFFIX,360buyimg.com,DIRECT'
- 'DOMAIN-SUFFIX,36kr.com,DIRECT'
- 'DOMAIN-SUFFIX,acfun.tv,DIRECT'
- 'DOMAIN-SUFFIX,air-matters.com,DIRECT'
- 'DOMAIN-SUFFIX,aixifan.com,DIRECT'
- 'DOMAIN-KEYWORD,alicdn,DIRECT'
- 'DOMAIN-KEYWORD,alipay,DIRECT'
- 'DOMAIN-KEYWORD,taobao,DIRECT'
- 'DOMAIN-SUFFIX,amap.com,DIRECT'
- 'DOMAIN-SUFFIX,autonavi.com,DIRECT'
- 'DOMAIN-KEYWORD,baidu,DIRECT'
- 'DOMAIN-SUFFIX,bdimg.com,DIRECT'
- 'DOMAIN-SUFFIX,bdstatic.com,DIRECT'
- 'DOMAIN-SUFFIX,bilibili.com,DIRECT'
- 'DOMAIN-SUFFIX,bilivideo.com,DIRECT'
- 'DOMAIN-SUFFIX,caiyunapp.com,DIRECT'
- 'DOMAIN-SUFFIX,clouddn.com,DIRECT'
- 'DOMAIN-SUFFIX,cnbeta.com,DIRECT'
- 'DOMAIN-SUFFIX,cnbetacdn.com,DIRECT'
- 'DOMAIN-SUFFIX,cootekservice.com,DIRECT'
- 'DOMAIN-SUFFIX,csdn.net,DIRECT'
- 'DOMAIN-SUFFIX,ctrip.com,DIRECT'
- 'DOMAIN-SUFFIX,dgtle.com,DIRECT'
- 'DOMAIN-SUFFIX,dianping.com,DIRECT'
- 'DOMAIN-SUFFIX,douban.com,DIRECT'
- 'DOMAIN-SUFFIX,doubanio.com,DIRECT'
- 'DOMAIN-SUFFIX,duokan.com,DIRECT'
- 'DOMAIN-SUFFIX,easou.com,DIRECT'
- 'DOMAIN-SUFFIX,ele.me,DIRECT'
- 'DOMAIN-SUFFIX,feng.com,DIRECT'
- 'DOMAIN-SUFFIX,fir.im,DIRECT'
- 'DOMAIN-SUFFIX,frdic.com,DIRECT'
- 'DOMAIN-SUFFIX,g-cores.com,DIRECT'
- 'DOMAIN-SUFFIX,godic.net,DIRECT'
- 'DOMAIN-SUFFIX,gtimg.com,DIRECT'
- 'DOMAIN,cdn.hockeyapp.net,DIRECT'
- 'DOMAIN-SUFFIX,hongxiu.com,DIRECT'
- 'DOMAIN-SUFFIX,hxcdn.net,DIRECT'
- 'DOMAIN-SUFFIX,iciba.com,DIRECT'
- 'DOMAIN-SUFFIX,ifeng.com,DIRECT'
- 'DOMAIN-SUFFIX,ifengimg.com,DIRECT'
- 'DOMAIN-SUFFIX,ipip.net,DIRECT'
- 'DOMAIN-SUFFIX,iqiyi.com,DIRECT'
- 'DOMAIN-SUFFIX,jd.com,DIRECT'
- 'DOMAIN-SUFFIX,jianshu.com,DIRECT'
- 'DOMAIN-SUFFIX,knewone.com,DIRECT'
- 'DOMAIN-SUFFIX,le.com,DIRECT'
- 'DOMAIN-SUFFIX,lecloud.com,DIRECT'
- 'DOMAIN-SUFFIX,lemicp.com,DIRECT'
- 'DOMAIN-SUFFIX,licdn.com,DIRECT'
- 'DOMAIN-SUFFIX,luoo.net,DIRECT'
- 'DOMAIN-SUFFIX,meituan.com,DIRECT'
- 'DOMAIN-SUFFIX,meituan.net,DIRECT'
- 'DOMAIN-SUFFIX,mi.com,DIRECT'
- 'DOMAIN-SUFFIX,miaopai.com,DIRECT'
- 'DOMAIN-SUFFIX,microsoft.com,DIRECT'
- 'DOMAIN-SUFFIX,microsoftonline.com,DIRECT'
- 'DOMAIN-SUFFIX,miui.com,DIRECT'
- 'DOMAIN-SUFFIX,miwifi.com,DIRECT'
- 'DOMAIN-SUFFIX,mob.com,DIRECT'
- 'DOMAIN-SUFFIX,netease.com,DIRECT'
- 'DOMAIN-SUFFIX,office.com,DIRECT'
- 'DOMAIN-SUFFIX,office365.com,DIRECT'
- 'DOMAIN-KEYWORD,officecdn,DIRECT'
- 'DOMAIN-SUFFIX,oschina.net,DIRECT'
- 'DOMAIN-SUFFIX,ppsimg.com,DIRECT'
- 'DOMAIN-SUFFIX,pstatp.com,DIRECT'
- 'DOMAIN-SUFFIX,qcloud.com,DIRECT'
- 'DOMAIN-SUFFIX,qdaily.com,DIRECT'
- 'DOMAIN-SUFFIX,qdmm.com,DIRECT'
- 'DOMAIN-SUFFIX,qhimg.com,DIRECT'
- 'DOMAIN-SUFFIX,qhres.com,DIRECT'
- 'DOMAIN-SUFFIX,qidian.com,DIRECT'
- 'DOMAIN-SUFFIX,qihucdn.com,DIRECT'
- 'DOMAIN-SUFFIX,qiniu.com,DIRECT'
- 'DOMAIN-SUFFIX,qiniucdn.com,DIRECT'
- 'DOMAIN-SUFFIX,qiyipic.com,DIRECT'
- 'DOMAIN-SUFFIX,qq.com,DIRECT'
- 'DOMAIN-SUFFIX,qqurl.com,DIRECT'
- 'DOMAIN-SUFFIX,rarbg.to,DIRECT'
- 'DOMAIN-SUFFIX,ruguoapp.com,DIRECT'
- 'DOMAIN-SUFFIX,segmentfault.com,DIRECT'
- 'DOMAIN-SUFFIX,sinaapp.com,DIRECT'
- 'DOMAIN-SUFFIX,smzdm.com,DIRECT'
- 'DOMAIN-SUFFIX,snapdrop.net,DIRECT'
- 'DOMAIN-SUFFIX,sogou.com,DIRECT'
- 'DOMAIN-SUFFIX,sogoucdn.com,DIRECT'
- 'DOMAIN-SUFFIX,sohu.com,DIRECT'
- 'DOMAIN-SUFFIX,soku.com,DIRECT'
- 'DOMAIN-SUFFIX,speedtest.net,DIRECT'
- 'DOMAIN-SUFFIX,sspai.com,DIRECT'
- 'DOMAIN-SUFFIX,suning.com,DIRECT'
- 'DOMAIN-SUFFIX,taobao.com,DIRECT'
- 'DOMAIN-SUFFIX,tencent.com,DIRECT'
- 'DOMAIN-SUFFIX,tenpay.com,DIRECT'
- 'DOMAIN-SUFFIX,tianyancha.com,DIRECT'
- 'DOMAIN-SUFFIX,tmall.com,DIRECT'
- 'DOMAIN-SUFFIX,tudou.com,DIRECT'
- 'DOMAIN-SUFFIX,umetrip.com,DIRECT'
- 'DOMAIN-SUFFIX,upaiyun.com,DIRECT'
- 'DOMAIN-SUFFIX,upyun.com,DIRECT'
- 'DOMAIN-SUFFIX,veryzhun.com,DIRECT'
- 'DOMAIN-SUFFIX,weather.com,DIRECT'
- 'DOMAIN-SUFFIX,weibo.com,DIRECT'
- 'DOMAIN-SUFFIX,xiami.com,DIRECT'
- 'DOMAIN-SUFFIX,xiami.net,DIRECT'
- 'DOMAIN-SUFFIX,xiaomicp.com,DIRECT'
- 'DOMAIN-SUFFIX,ximalaya.com,DIRECT'
- 'DOMAIN-SUFFIX,xmcdn.com,DIRECT'
- 'DOMAIN-SUFFIX,xunlei.com,DIRECT'
- 'DOMAIN-SUFFIX,yhd.com,DIRECT'
- 'DOMAIN-SUFFIX,yihaodianimg.com,DIRECT'
- 'DOMAIN-SUFFIX,yinxiang.com,DIRECT'
- 'DOMAIN-SUFFIX,ykimg.com,DIRECT'
- 'DOMAIN-SUFFIX,youdao.com,DIRECT'
- 'DOMAIN-SUFFIX,youku.com,DIRECT'
- 'DOMAIN-SUFFIX,zealer.com,DIRECT'
- 'DOMAIN-SUFFIX,zhihu.com,DIRECT'
- 'DOMAIN-SUFFIX,zhimg.com,DIRECT'
- 'DOMAIN-SUFFIX,zimuzu.tv,DIRECT'
- 'DOMAIN-SUFFIX,zoho.com,DIRECT'
- 'DOMAIN-SUFFIX,api.pandalive.co.kr,DIRECT'
- 'DOMAIN,steamcdn-a.akamaihd.net,DIRECT'
- 'DOMAIN-SUFFIX,cm.steampowered.com,DIRECT'
- 'DOMAIN-SUFFIX,steamserver.net,DIRECT'
- 'IP-CIDR,45.121.184.0/24,DIRECT'
- 'IP-CIDR,103.10.124.0/23,DIRECT'
- 'IP-CIDR,103.28.54.0/24,DIRECT'
- 'IP-CIDR,146.66.152.0/24,DIRECT'
- 'IP-CIDR,146.66.155.0/24,DIRECT'
- 'IP-CIDR,153.254.86.0/24,DIRECT'
- 'IP-CIDR,155.133.224.0/22,DIRECT'
- 'IP-CIDR,155.133.230.0/24,DIRECT'
- 'IP-CIDR,155.133.232.0/23,DIRECT'
- 'IP-CIDR,155.133.234.0/24,DIRECT'
- 'IP-CIDR,155.133.236.0/22,DIRECT'
- 'IP-CIDR,155.133.240.0/23,DIRECT'
- 'IP-CIDR,155.133.244.0/23,DIRECT'
- 'IP-CIDR,155.133.246.0/24,DIRECT'
- 'IP-CIDR,155.133.248.0/21,DIRECT'
- 'IP-CIDR,162.254.192.0/21,DIRECT'
- 'IP-CIDR,185.25.182.0/23,DIRECT'
- 'IP-CIDR,190.217.32.0/22,DIRECT'
- 'IP-CIDR,192.69.96.0/22,DIRECT'
- 'IP-CIDR,205.196.6.0/24,DIRECT'
- 'IP-CIDR,208.64.200.0/22,DIRECT'
- 'IP-CIDR,208.78.164.0/22,DIRECT'
- 'IP-CIDR,205.185.194.0/24,DIRECT'
- 'DOMAIN-KEYWORD,amazon,🚀 节点选择'
- 'DOMAIN-KEYWORD,google,🚀 节点选择'
- 'DOMAIN-KEYWORD,gmail,🚀 节点选择'
- 'DOMAIN-KEYWORD,youtube,🚀 节点选择'
- 'DOMAIN-KEYWORD,facebook,🚀 节点选择'
- 'DOMAIN-SUFFIX,fb.me,🚀 节点选择'
- 'DOMAIN-SUFFIX,fbcdn.net,🚀 节点选择'
- 'DOMAIN-KEYWORD,twitter,🚀 节点选择'
- 'DOMAIN-KEYWORD,instagram,🚀 节点选择'
- 'DOMAIN-KEYWORD,dropbox,🚀 节点选择'
- 'DOMAIN-SUFFIX,twimg.com,🚀 节点选择'
- 'DOMAIN-KEYWORD,blogspot,🚀 节点选择'
- 'DOMAIN-SUFFIX,youtu.be,🚀 节点选择'
- 'DOMAIN-KEYWORD,whatsapp,🚀 节点选择'
- 'DOMAIN-KEYWORD,admarvel,REJECT'
- 'DOMAIN-KEYWORD,admaster,REJECT'
- 'DOMAIN-KEYWORD,adsage,REJECT'
- 'DOMAIN-KEYWORD,adsmogo,REJECT'
- 'DOMAIN-KEYWORD,adsrvmedia,REJECT'
- 'DOMAIN-KEYWORD,adwords,REJECT'
- 'DOMAIN-KEYWORD,adservice,REJECT'
- 'DOMAIN-SUFFIX,appsflyer.com,REJECT'
- 'DOMAIN-KEYWORD,domob,REJECT'
- 'DOMAIN-SUFFIX,doubleclick.net,REJECT'
- 'DOMAIN-KEYWORD,duomeng,REJECT'
- 'DOMAIN-KEYWORD,dwtrack,REJECT'
- 'DOMAIN-KEYWORD,guanggao,REJECT'
- 'DOMAIN-KEYWORD,lianmeng,REJECT'
- 'DOMAIN-SUFFIX,mmstat.com,REJECT'
- 'DOMAIN-KEYWORD,mopub,REJECT'
- 'DOMAIN-KEYWORD,omgmta,REJECT'
- 'DOMAIN-KEYWORD,openx,REJECT'
- 'DOMAIN-KEYWORD,partnerad,REJECT'
- 'DOMAIN-KEYWORD,pingfore,REJECT'
- 'DOMAIN-KEYWORD,supersonicads,REJECT'
- 'DOMAIN-KEYWORD,uedas,REJECT'
- 'DOMAIN-KEYWORD,umeng,REJECT'
- 'DOMAIN-KEYWORD,usage,REJECT'
- 'DOMAIN-SUFFIX,vungle.com,REJECT'
- 'DOMAIN-KEYWORD,wlmonitor,REJECT'
- 'DOMAIN-KEYWORD,zjtoolbar,REJECT'
- 'DOMAIN-SUFFIX,9to5mac.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,abpchina.org,🚀 节点选择'
- 'DOMAIN-SUFFIX,adblockplus.org,🚀 节点选择'
- 'DOMAIN-SUFFIX,adobe.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,akamaized.net,🚀 节点选择'
- 'DOMAIN-SUFFIX,alfredapp.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,amplitude.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,ampproject.org,🚀 节点选择'
- 'DOMAIN-SUFFIX,android.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,angularjs.org,🚀 节点选择'
- 'DOMAIN-SUFFIX,aolcdn.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,apkpure.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,appledaily.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,appshopper.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,appspot.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,arcgis.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,archive.org,🚀 节点选择'
- 'DOMAIN-SUFFIX,armorgames.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,aspnetcdn.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,att.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,awsstatic.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,azureedge.net,🚀 节点选择'
- 'DOMAIN-SUFFIX,azurewebsites.net,🚀 节点选择'
- 'DOMAIN-SUFFIX,bing.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,bintray.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,bit.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,bit.ly,🚀 节点选择'
- 'DOMAIN-SUFFIX,bitbucket.org,🚀 节点选择'
- 'DOMAIN-SUFFIX,bjango.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,bkrtx.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,blog.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,blogcdn.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,blogger.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,blogsmithmedia.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,blogspot.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,blogspot.hk,🚀 节点选择'
- 'DOMAIN-SUFFIX,bloomberg.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,box.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,box.net,🚀 节点选择'
- 'DOMAIN-SUFFIX,cachefly.net,🚀 节点选择'
- 'DOMAIN-SUFFIX,chromium.org,🚀 节点选择'
- 'DOMAIN-SUFFIX,cl.ly,🚀 节点选择'
- 'DOMAIN-SUFFIX,cloudflare.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,cloudfront.net,🚀 节点选择'
- 'DOMAIN-SUFFIX,cloudmagic.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,cmail19.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,cnet.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,cocoapods.org,🚀 节点选择'
- 'DOMAIN-SUFFIX,comodoca.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,crashlytics.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,culturedcode.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,d.pr,🚀 节点选择'
- 'DOMAIN-SUFFIX,danilo.to,🚀 节点选择'
- 'DOMAIN-SUFFIX,dayone.me,🚀 节点选择'
- 'DOMAIN-SUFFIX,db.tt,🚀 节点选择'
- 'DOMAIN-SUFFIX,deskconnect.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,disq.us,🚀 节点选择'
- 'DOMAIN-SUFFIX,disqus.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,disquscdn.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,dnsimple.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,docker.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,dribbble.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,droplr.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,duckduckgo.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,dueapp.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,dytt8.net,🚀 节点选择'
- 'DOMAIN-SUFFIX,edgecastcdn.net,🚀 节点选择'
- 'DOMAIN-SUFFIX,edgekey.net,🚀 节点选择'
- 'DOMAIN-SUFFIX,edgesuite.net,🚀 节点选择'
- 'DOMAIN-SUFFIX,engadget.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,entrust.net,🚀 节点选择'
- 'DOMAIN-SUFFIX,eurekavpt.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,evernote.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,fabric.io,🚀 节点选择'
- 'DOMAIN-SUFFIX,fast.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,fastly.net,🚀 节点选择'
- 'DOMAIN-SUFFIX,fc2.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,feedburner.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,feedly.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,feedsportal.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,fiftythree.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,firebaseio.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,flexibits.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,flickr.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,flipboard.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,g.co,🚀 节点选择'
- 'DOMAIN-SUFFIX,gabia.net,🚀 节点选择'
- 'DOMAIN-SUFFIX,geni.us,🚀 节点选择'
- 'DOMAIN-SUFFIX,gfx.ms,🚀 节点选择'
- 'DOMAIN-SUFFIX,ggpht.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,ghostnoteapp.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,git.io,🚀 节点选择'
- 'DOMAIN-KEYWORD,github,🚀 节点选择'
- 'DOMAIN-SUFFIX,globalsign.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,gmodules.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,godaddy.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,golang.org,🚀 节点选择'
- 'DOMAIN-SUFFIX,gongm.in,🚀 节点选择'
- 'DOMAIN-SUFFIX,goo.gl,🚀 节点选择'
- 'DOMAIN-SUFFIX,goodreaders.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,goodreads.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,gravatar.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,gstatic.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,gvt0.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,hockeyapp.net,🚀 节点选择'
- 'DOMAIN-SUFFIX,hotmail.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,icons8.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,ifixit.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,ift.tt,🚀 节点选择'
- 'DOMAIN-SUFFIX,ifttt.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,iherb.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,imageshack.us,🚀 节点选择'
- 'DOMAIN-SUFFIX,img.ly,🚀 节点选择'
- 'DOMAIN-SUFFIX,imgur.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,imore.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,instapaper.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,ipn.li,🚀 节点选择'
- 'DOMAIN-SUFFIX,is.gd,🚀 节点选择'
- 'DOMAIN-SUFFIX,issuu.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,itgonglun.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,itun.es,🚀 节点选择'
- 'DOMAIN-SUFFIX,ixquick.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,j.mp,🚀 节点选择'
- 'DOMAIN-SUFFIX,js.revsci.net,🚀 节点选择'
- 'DOMAIN-SUFFIX,jshint.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,jtvnw.net,🚀 节点选择'
- 'DOMAIN-SUFFIX,justgetflux.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,kat.cr,🚀 节点选择'
- 'DOMAIN-SUFFIX,klip.me,🚀 节点选择'
- 'DOMAIN-SUFFIX,libsyn.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,linkedin.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,line-apps.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,linode.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,lithium.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,littlehj.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,live.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,live.net,🚀 节点选择'
- 'DOMAIN-SUFFIX,livefilestore.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,llnwd.net,🚀 节点选择'
- 'DOMAIN-SUFFIX,macid.co,🚀 节点选择'
- 'DOMAIN-SUFFIX,macromedia.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,macrumors.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,mashable.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,mathjax.org,🚀 节点选择'
- 'DOMAIN-SUFFIX,medium.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,mega.co.nz,🚀 节点选择'
- 'DOMAIN-SUFFIX,mega.nz,🚀 节点选择'
- 'DOMAIN-SUFFIX,megaupload.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,microsofttranslator.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,mindnode.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,mobile01.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,modmyi.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,msedge.net,🚀 节点选择'
- 'DOMAIN-SUFFIX,myfontastic.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,name.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,nextmedia.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,nsstatic.net,🚀 节点选择'
- 'DOMAIN-SUFFIX,nssurge.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,nyt.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,nytimes.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,omnigroup.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,onedrive.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,onenote.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,ooyala.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,openvpn.net,🚀 节点选择'
- 'DOMAIN-SUFFIX,openwrt.org,🚀 节点选择'
- 'DOMAIN-SUFFIX,orkut.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,osxdaily.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,outlook.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,ow.ly,🚀 节点选择'
- 'DOMAIN-SUFFIX,paddleapi.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,parallels.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,parse.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,pdfexpert.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,periscope.tv,🚀 节点选择'
- 'DOMAIN-SUFFIX,pinboard.in,🚀 节点选择'
- 'DOMAIN-SUFFIX,pinterest.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,pixelmator.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,pixiv.net,🚀 节点选择'
- 'DOMAIN-SUFFIX,playpcesor.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,playstation.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,playstation.com.hk,🚀 节点选择'
- 'DOMAIN-SUFFIX,playstation.net,🚀 节点选择'
- 'DOMAIN-SUFFIX,playstationnetwork.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,pushwoosh.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,rime.im,🚀 节点选择'
- 'DOMAIN-SUFFIX,servebom.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,sfx.ms,🚀 节点选择'
- 'DOMAIN-SUFFIX,shadowsocks.org,🚀 节点选择'
- 'DOMAIN-SUFFIX,sharethis.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,shazam.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,skype.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,smartdns🚀 节点选择.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,smartmailcloud.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,sndcdn.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,sony.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,soundcloud.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,sourceforge.net,🚀 节点选择'
- 'DOMAIN-SUFFIX,spotify.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,squarespace.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,sstatic.net,🚀 节点选择'
- 'DOMAIN-SUFFIX,st.luluku.pw,🚀 节点选择'
- 'DOMAIN-SUFFIX,stackoverflow.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,startpage.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,staticflickr.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,steamcommunity.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,symauth.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,symcb.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,symcd.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,tapbots.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,tapbots.net,🚀 节点选择'
- 'DOMAIN-SUFFIX,tdesktop.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,techcrunch.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,techsmith.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,thepiratebay.org,🚀 节点选择'
- 'DOMAIN-SUFFIX,theverge.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,time.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,timeinc.net,🚀 节点选择'
- 'DOMAIN-SUFFIX,tiny.cc,🚀 节点选择'
- 'DOMAIN-SUFFIX,tinypic.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,tmblr.co,🚀 节点选择'
- 'DOMAIN-SUFFIX,todoist.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,trello.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,trustasiassl.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,tumblr.co,🚀 节点选择'
- 'DOMAIN-SUFFIX,tumblr.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,tweetdeck.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,tweetmarker.net,🚀 节点选择'
- 'DOMAIN-SUFFIX,twitch.tv,🚀 节点选择'
- 'DOMAIN-SUFFIX,txmblr.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,typekit.net,🚀 节点选择'
- 'DOMAIN-SUFFIX,ubertags.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,ublock.org,🚀 节点选择'
- 'DOMAIN-SUFFIX,ubnt.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,ulyssesapp.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,urchin.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,usertrust.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,v.gd,🚀 节点选择'
- 'DOMAIN-SUFFIX,v2ex.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,vimeo.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,vimeocdn.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,vine.co,🚀 节点选择'
- 'DOMAIN-SUFFIX,vivaldi.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,vox-cdn.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,vsco.co,🚀 节点选择'
- 'DOMAIN-SUFFIX,vultr.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,w.org,🚀 节点选择'
- 'DOMAIN-SUFFIX,w3schools.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,webtype.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,wikiwand.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,wikileaks.org,🚀 节点选择'
- 'DOMAIN-SUFFIX,wikimedia.org,🚀 节点选择'
- 'DOMAIN-SUFFIX,wikipedia.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,wikipedia.org,🚀 节点选择'
- 'DOMAIN-SUFFIX,windows.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,windows.net,🚀 节点选择'
- 'DOMAIN-SUFFIX,wire.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,wordpress.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,workflowy.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,wp.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,wsj.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,wsj.net,🚀 节点选择'
- 'DOMAIN-SUFFIX,xda-developers.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,xeeno.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,xiti.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,yahoo.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,yimg.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,ying.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,yoyo.org,🚀 节点选择'
- 'DOMAIN-SUFFIX,ytimg.com,🚀 节点选择'
- 'DOMAIN-SUFFIX,telegra.ph,🚀 节点选择'
- 'DOMAIN-SUFFIX,telegram.org,🚀 节点选择'
- 'IP-CIDR,91.108.4.0/22,🚀 节点选择,no-resolve'
- 'IP-CIDR,91.108.8.0/21,🚀 节点选择,no-resolve'
- 'IP-CIDR,91.108.16.0/22,🚀 节点选择,no-resolve'
- 'IP-CIDR,91.108.56.0/22,🚀 节点选择,no-resolve'
- 'IP-CIDR,149.154.160.0/20,🚀 节点选择,no-resolve'
- 'IP-CIDR6,2001:67c:4e8::/48,🚀 节点选择,no-resolve'
- 'IP-CIDR6,2001:b28:f23d::/48,🚀 节点选择,no-resolve'
- 'IP-CIDR6,2001:b28:f23f::/48,🚀 节点选择,no-resolve'
- 'IP-CIDR,120.232.181.162/32,🚀 节点选择,no-resolve'
- 'IP-CIDR,120.241.147.226/32,🚀 节点选择,no-resolve'
- 'IP-CIDR,120.253.253.226/32,🚀 节点选择,no-resolve'
- 'IP-CIDR,120.253.255.162/32,🚀 节点选择,no-resolve'
- 'IP-CIDR,120.253.255.34/32,🚀 节点选择,no-resolve'
- 'IP-CIDR,120.253.255.98/32,🚀 节点选择,no-resolve'
- 'IP-CIDR,180.163.150.162/32,🚀 节点选择,no-resolve'
- 'IP-CIDR,180.163.150.34/32,🚀 节点选择,no-resolve'
- 'IP-CIDR,180.163.151.162/32,🚀 节点选择,no-resolve'
- 'IP-CIDR,180.163.151.34/32,🚀 节点选择,no-resolve'
- 'IP-CIDR,203.208.39.0/24,🚀 节点选择,no-resolve'
- 'IP-CIDR,203.208.40.0/24,🚀 节点选择,no-resolve'
- 'IP-CIDR,203.208.41.0/24,🚀 节点选择,no-resolve'
- 'IP-CIDR,203.208.43.0/24,🚀 节点选择,no-resolve'
- 'IP-CIDR,203.208.50.0/24,🚀 节点选择,no-resolve'
- 'IP-CIDR,220.181.174.162/32,🚀 节点选择,no-resolve'
- 'IP-CIDR,220.181.174.226/32,🚀 节点选择,no-resolve'
- 'IP-CIDR,220.181.174.34/32,🚀 节点选择,no-resolve'
- 'DOMAIN,injections.adguard.org,DIRECT'
- 'DOMAIN,local.adguard.org,DIRECT'
- 'DOMAIN-SUFFIX,local,DIRECT'
- 'IP-CIDR,127.0.0.0/8,DIRECT'
- 'IP-CIDR,172.16.0.0/12,DIRECT'
- 'IP-CIDR,192.168.0.0/16,DIRECT'
- 'IP-CIDR,10.0.0.0/8,DIRECT'
- 'IP-CIDR,17.0.0.0/8,DIRECT'
- 'IP-CIDR,100.64.0.0/10,DIRECT'
- 'IP-CIDR,224.0.0.0/4,DIRECT'
- 'IP-CIDR6,fe80::/10,DIRECT'
- 'DOMAIN-SUFFIX,cn,DIRECT'
- 'DOMAIN-KEYWORD,-cn,DIRECT'
- 'GEOIP,CN,DIRECT'
- 'MATCH,🚀 节点选择'

File diff suppressed because one or more lines are too long

52
profiles/profiles.toml Normal file
View file

@ -0,0 +1,52 @@
schema_version = 1
[current]
clash = "tnt_luo-clash"
sing-box = "nano-sb"
[[profiles]]
name = "tnt_luo-clash"
family = "clash"
format = "yaml"
source_file = "tnt_luo-clash/source.yaml"
[profiles.origin]
kind = "remote"
url = "https://linkuserssnk.xxyjx.cc/s/jBdoEVwWpmI3-a9d96b27?clash=1"
download_via = "proxy"
[profiles.update]
[profiles.state.selected]
Proxy = "[Lv3·2.0x] 美国LA05"
[[profiles]]
name = "nano-clash"
family = "clash"
format = "yaml"
source_file = "nano-clash/source.yaml"
[profiles.origin]
kind = "remote"
url = "https://47.76.155.27/iv/verify_mode.htm?token=f52f7188d4ed4201e21463d136733689&sid=nano"
download_via = "proxy"
[profiles.update]
[profiles.state]
[[profiles]]
name = "nano-sb"
family = "sing-box"
format = "json"
source_file = "nano-sb/source.json"
[profiles.origin]
kind = "remote"
url = "https://47.76.155.27/iv/verify_mode.htm?token=f52f7188d4ed4201e21463d136733689&sid=nano"
download_via = "proxy"
[profiles.update]
[profiles.state.selected]
Proxy = "🇺🇸美国-C(通用)"

File diff suppressed because it is too large Load diff

269
refers/mh Executable file
View file

@ -0,0 +1,269 @@
#!/usr/bin/env bash
set -euo pipefail
shopt -s nullglob
SCRIPT_PATH="$(readlink -f "${BASH_SOURCE[0]}")"
SCRIPT_DIR="$(cd -- "$(dirname -- "$SCRIPT_PATH")" && pwd)"
MH_ROOT="${MIHOMO_ROOT:-$SCRIPT_DIR}"
MH_BIN="${MIHOMO_BIN:-${MH_ROOT}/mihomo}"
MH_SYSTEM="${MH_ROOT}/system/config.yaml"
MH_BYPASS="${MH_ROOT}/bypass.list"
MH_PROFILES="${MH_ROOT}/profiles"
MH_CURRENT="${MH_ROOT}/current.profile"
MH_SETTINGS="${MH_ROOT}/settings.json"
MH_RUNTIME="${MIHOMO_RUN_DIR:-${MH_ROOT}/.runtime}"
MH_CONFIG="${MH_RUNTIME}/config.yaml"
MH_RULES="${MH_RUNTIME}/bypass-rules.json"
MH_PID_FILE="${MH_RUNTIME}/run.pid"
MH_LOG="${MH_RUNTIME}/mihomo.log"
MH_MIXED_PORT="${MIHOMO_PORT:-7890}"
MH_SOCKS_PORT="${MIHOMO_SOCKS_PORT:-7891}"
MH_API_PORT="${MIHOMO_API_PORT:-9090}"
MH_API="http://127.0.0.1:${MH_API_PORT}"
die(){ printf 'mh: %s\n' "$*" >&2; exit 1; }
info(){ printf 'mh: %s\n' "$*"; }
warn(){ printf 'mh: warning: %s\n' "$*" >&2; }
need(){ command -v "$1" >/dev/null 2>&1 || die "missing command: $1"; }
init_layout(){
[[ -x "$MH_BIN" ]] || die "mihomo binary not executable: $MH_BIN"
[[ -f "$MH_SYSTEM" ]] || die "system config missing: $MH_SYSTEM"
[[ -f "$MH_BYPASS" ]] || die "bypass list missing: $MH_BYPASS"
need jq; need yq
mkdir -p "$MH_PROFILES" "$MH_RUNTIME"
[[ -f "$MH_SETTINGS" ]] || printf '%s\n' '{"mode":"rule","tun":false,"cliproxy":false,"sysproxy":false}' >"$MH_SETTINGS"
}
setting(){ jq -r --arg k "$1" '.[$k]' "$MH_SETTINGS"; }
set_setting(){
local k="$1" v="$2"
if [[ "$v" == true || "$v" == false ]]; then jq --arg k "$k" --argjson v "$v" '.[$k]=$v' "$MH_SETTINGS" >"${MH_SETTINGS}.tmp"
else jq --arg k "$k" --arg v "$v" '.[$k]=$v' "$MH_SETTINGS" >"${MH_SETTINGS}.tmp"; fi
mv -f "${MH_SETTINGS}.tmp" "$MH_SETTINGS"
}
profile_dir(){ printf '%s/%s\n' "$MH_PROFILES" "$1"; }
provider_file(){ printf '%s/provider.yaml\n' "$(profile_dir "$1")"; }
metadata_file(){ printf '%s/source.json\n' "$(profile_dir "$1")"; }
validate_name(){ [[ "$1" =~ ^[A-Za-z0-9._-]+$ ]] || die 'profile name contains unsupported characters'; }
current_profile(){
[[ -f "$MH_CURRENT" ]] || return 1
local n="$(tr -d '[:space:]' <"$MH_CURRENT")"
[[ -n "$n" && -f "$(provider_file "$n")" ]] || return 1
echo "$n"
}
list_profiles(){ local f; for f in "$MH_PROFILES"/*/provider.yaml; do [[ -f "$f" ]] && basename "$(dirname "$f")"; done | sort; }
validate_provider(){ yq -e '.proxies | type == "array" and length > 0' "$1" >/dev/null || die "provider must be Clash YAML with non-empty proxies: $1"; }
normalize_provider(){ validate_provider "$1"; yq -y '{proxies:.proxies}' "$1" >"$2"; validate_provider "$2"; }
fetch_provider(){
local src="$1" dest="$2" raw
raw="${dest}.raw"
if [[ "$src" =~ ^https?:// ]]; then need curl; curl -fL --connect-timeout 15 --max-time 90 -A 'mihomo/1.19 mh-provider' "$src" -o "$raw"
elif [[ -f "$src" ]]; then cp -f -- "$src" "$raw"
else die "source is neither HTTP URL nor file: $src"; fi
normalize_provider "$raw" "$dest"; rm -f "$raw"
}
add_cmd(){
local name="${1:-}" src="${2:-}" dir tmp kind saved
[[ -n "$name" && -n "$src" ]] || die 'usage: mh add <name> <url|file>'
validate_name "$name"; dir="$(profile_dir "$name")"; [[ ! -e "$dir" ]] || die "profile exists: $name"
mkdir -p "$dir"; tmp="${dir}/provider.yaml.tmp"
if [[ "$src" =~ ^https?:// ]]; then kind=http; saved="$src"; else kind=file; saved="$(readlink -f "$src")"; fi
if ! fetch_provider "$src" "$tmp"; then rm -f "$tmp" "${tmp}.raw"; rmdir "$dir" 2>/dev/null || true; return 1; fi
mv -f "$tmp" "${dir}/provider.yaml"
jq -n --arg name "$name" --arg type "$kind" --arg source "$saved" --arg added_at "$(date -Iseconds)" '{name:$name,type:$type,source:$source,added_at:$added_at}' >"${dir}/source.json"
echo "$name" >"$MH_CURRENT"; info "added profile=${name} type=${kind}"; info "run: mh start ${name}"
}
compile_bypass(){
local line value prefix; : >"${MH_RULES}.lines"
while IFS= read -r line || [[ -n "$line" ]]; do
line="${line%%#*}"; line="$(sed -E 's/^[[:space:]]+|[[:space:]]+$//g' <<<"$line")"; [[ -n "$line" ]] || continue
if [[ "$line" == */* ]]; then [[ "$line" == *:* ]] && echo "IP-CIDR6,${line},DIRECT,no-resolve" || echo "IP-CIDR,${line},DIRECT,no-resolve"
elif [[ "$line" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo "IP-CIDR,${line}/32,DIRECT,no-resolve"
elif [[ "$line" =~ ^(10|127|172|192)(\..*)?\*$ ]]; then prefix="${BASH_REMATCH[1]}"; case "$prefix" in 10)value=10.0.0.0/8;;127)value=127.0.0.0/8;;172)value=172.16.0.0/12;;192)value=192.168.0.0/16;;esac; echo "IP-CIDR,${value},DIRECT,no-resolve"
elif [[ "$line" == \** || "$line" == .* ]]; then value="${line#\*}"; value="${value#.}"; [[ -n "$value" ]] && echo "DOMAIN-SUFFIX,${value},DIRECT"
else echo "DOMAIN,${line},DIRECT"; fi
done <"$MH_BYPASS" | awk '!seen[$0]++' >"${MH_RULES}.lines"
jq -R -s 'split("\n")|map(select(length>0))' "${MH_RULES}.lines" >"$MH_RULES"; rm -f "${MH_RULES}.lines"
}
proxy_bypass_items(){
local mode="$1" line value
while IFS= read -r line || [[ -n "$line" ]]; do
line="${line%%#*}"; line="$(sed -E 's/^[[:space:]]+|[[:space:]]+$//g' <<<"$line")"; [[ -n "$line" ]] || continue
value="$line"
if [[ "$mode" == no_proxy && "$value" == \*.* ]]; then value=".${value#*.}"
elif [[ "$mode" == gsettings && "$value" == .* ]]; then value="*${value}"; fi
printf '%s\n' "$value"
done <"$MH_BYPASS" | awk '!seen[$0]++'
}
no_proxy_value(){ proxy_bypass_items no_proxy | paste -sd, -; }
gsettings_bypass_value(){ proxy_bypass_items gsettings | jq -Rsc 'split("\n") | map(select(length > 0))'; }
build_config(){
local profile="$1" provider sys rules mode tun
provider="$(provider_file "$profile")"; [[ -f "$provider" ]] || die "profile not found: $profile"; validate_provider "$provider"
compile_bypass; sys="$(yq -c . "$MH_SYSTEM")"; rules="$(<"$MH_RULES")"; mode="$(setting mode)"; tun="$(setting tun)"
jq -n --argjson s "$sys" --argjson rules "$rules" --arg profile "$profile" --arg provider "$provider" --arg mode "$mode" --argjson tun "$tun" \
--argjson mixed "$MH_MIXED_PORT" --argjson socks "$MH_SOCKS_PORT" --arg controller "127.0.0.1:${MH_API_PORT}" '
$s
| .["mixed-port"]=$mixed | .["socks-port"]=$socks | .["external-controller"]=$controller | .mode=$mode
| .["proxy-providers"]={($profile):{type:"file",path:$provider,health_check:{enable:true,url:"https://www.gstatic.com/generate_204",interval:600}}}
| .["proxy-groups"]=[{name:"Proxy",type:"select",use:[$profile]},{name:"Final",type:"select",proxies:["Proxy","DIRECT"]}]
| .rules=($rules+["MATCH,Final"])
| .tun={enable:$tun,stack:"system",device:"mihomo","auto-route":true,"auto-redir":true,"auto-detect-interface":true,"dns-hijack":["any:53"]}
| if $tun then .dns={enable:true,listen:"127.0.0.1:1053","enhanced-mode":"redir-host",nameserver:["223.5.5.5","119.29.29.29"],fallback:["1.1.1.1","8.8.8.8"],"fallback-filter":{geoip:false}} else del(.dns) end
' | yq -y '.' >"${MH_CONFIG}.tmp"
mv -f "${MH_CONFIG}.tmp" "$MH_CONFIG"
}
process_alive(){ local pid="$1" state _; [[ "$pid" =~ ^[0-9]+$ ]] && kill -0 "$pid" 2>/dev/null || return 1; read -r _ _ state _ <"/proc/${pid}/stat" 2>/dev/null || return 1; [[ "$state" != Z ]]; }
is_running(){ [[ -f "$MH_PID_FILE" ]] && process_alive "$(<"$MH_PID_FILE")"; }
port_in_use(){ ss -ltnH 2>/dev/null | awk -v s=":$1" 'substr($4,length($4)-length(s)+1)== s{f=1}END{exit !f}'; }
api_curl(){ local secret="$(yq -r '.secret // ""' "$MH_CONFIG" 2>/dev/null || true)"; if [[ -n "$secret" ]]; then curl -fsS --noproxy '*' -H "Authorization: Bearer $secret" "$@"; else curl -fsS --noproxy '*' "$@"; fi; }
wait_api(){ local i; for i in {1..50}; do api_curl "$MH_API/proxies" >/dev/null 2>&1 && return; sleep .1; done; return 1; }
stop_cmd(){
if ! is_running; then rm -f "$MH_PID_FILE"; info 'not running'; return; fi
local pid="$(<"$MH_PID_FILE")" expected actual comm cmdline i
expected="$(readlink -f "$MH_BIN")"; actual="$(readlink -f "/proc/$pid/exe" 2>/dev/null || true)"; comm="$(cat "/proc/$pid/comm" 2>/dev/null || true)"; cmdline="$(tr '\0' ' ' <"/proc/$pid/cmdline" 2>/dev/null || true)"
if [[ "$actual" != "$expected" ]]; then [[ "$comm" == "$(basename "$MH_BIN")" && "$cmdline" == "$MH_BIN -d "* && "$cmdline" == *" -f $MH_CONFIG"* ]] || die "refusing to stop pid $pid: identity mismatch"; fi
kill "$pid" 2>/dev/null || true; for i in {1..30}; do process_alive "$pid" || break; sleep .1; done; process_alive "$pid" && die "pid $pid did not stop"
rm -f "$MH_PID_FILE"; info "stopped pid $pid"
}
choose_profile(){
local requested="${1:-}" current pick i; local -a profiles; mapfile -t profiles < <(list_profiles); ((${#profiles[@]})) || die 'no providers; use mh add'
current="$(current_profile 2>/dev/null || true)"
if [[ -n "$requested" ]]; then [[ -f "$(provider_file "$requested")" ]] || die "profile not found: $requested"; echo "$requested"; return; fi
if [[ ! -t 0 ]]; then [[ -n "$current" ]] || die 'non-interactive start needs name'; echo "$current"; return; fi
echo 'Available providers:' >&2; for i in "${!profiles[@]}"; do [[ "${profiles[$i]}" == "$current" ]] && printf ' %d) %s (current)\n' "$((i+1))" "${profiles[$i]}" >&2 || printf ' %d) %s\n' "$((i+1))" "${profiles[$i]}" >&2; done
while true; do read -r -p "Choose provider (1-${#profiles[@]}, 0 cancel): " pick; [[ "$pick" =~ ^[0-9]+$ ]] || continue; ((pick== 0)) && return 1; ((pick>=1&&pick<=${#profiles[@]})) && { echo "${profiles[$((pick-1))]}"; return; }; done
}
start_cmd(){
local profile pid i; profile="$(choose_profile "${1:-}")" || { info cancelled; return; }; build_config "$profile"
"$MH_BIN" -t -d "$MH_ROOT" -f "$MH_CONFIG" >/dev/null; is_running && stop_cmd; need ss
for i in "$MH_MIXED_PORT" "$MH_SOCKS_PORT" "$MH_API_PORT"; do port_in_use "$i" && die "port $i is in use"; done
: >"$MH_LOG"; nohup "$MH_BIN" -d "$MH_ROOT" -f "$MH_CONFIG" >>"$MH_LOG" 2>&1 & pid=$!; echo "$pid" >"$MH_PID_FILE"; echo "$profile" >"$MH_CURRENT"
for i in {1..50}; do ! process_alive "$pid" && { rm -f "$MH_PID_FILE"; tail -n 40 "$MH_LOG" >&2; die 'mihomo exited'; }; port_in_use "$MH_MIXED_PORT" && port_in_use "$MH_API_PORT" && { info "started profile=$profile pid=$pid mode=$(setting mode) tun=$(setting tun)"; wait_api || warn 'API not ready'; return; }; sleep .1; done
die 'startup timeout'
}
restart_if_running(){ local p="$(current_profile 2>/dev/null || true)"; if is_running; then stop_cmd; start_cmd "$p"; fi; }
check_cmd(){ local p="${1:-}"; [[ -n "$p" ]] || p="$(current_profile 2>/dev/null || true)"; [[ -n "$p" ]] || die 'no profile'; build_config "$p"; "$MH_BIN" -t -d "$MH_ROOT" -f "$MH_CONFIG"; info "configuration OK: profile=$p"; }
require_api(){ is_running || die 'mihomo is not running'; api_curl "$MH_API/proxies" >/dev/null || die "API unavailable: $MH_API"; }
urlencode(){ jq -nr --arg v "$1" '$v|@uri'; }
api_delay(){ local n="$1" e u; e="$(urlencode "$n")"; u="$(urlencode 'https://www.gstatic.com/generate_204')"; api_curl "$MH_API/proxies/$e/delay?timeout=1800&url=$u" 2>/dev/null | jq -r 'if (.delay|type)=="number" then .delay else 999999 end' 2>/dev/null || echo 999999; }
delay_text(){ local d="${1:-}"; [[ "$d" =~ ^[0-9]+$ && "$d" != 999999 ]] && printf '%sms\n' "$d" || printf 'timeout\n'; }
main_group(){ jq -r 'if .proxies.Proxy.all then "Proxy" else [.proxies|to_entries[]|select(.value.all!=null)][0].key//empty end' <<<"$1"; }
display_grid(){ local -n r=$1; local n=${#r[@]} w c=1 rows i j k; w="$(tput cols 2>/dev/null||echo 120)"; ((n>18&&w>=110))&&c=2; ((n>36&&w>=170))&&c=3; rows=$(((n+c-1)/c)); for((i=0;i<rows;i++));do for((j=0;j<c;j++));do k=$((i+j*rows));((k<n))||continue; if((j+1<c&&k+rows<n));then printf '%-56s' "${r[$k]}";else printf '%s' "${r[$k]}";fi;done;echo;done; }
list_nodes_cmd(){
local kw="${1:-}" json group now candidates tmp running=0 i d pick enc body; local -a nodes sorted=() delays=() view=()
require_api; json="$(api_curl "$MH_API/proxies")"; group="$(main_group "$json")"; [[ -n "$group" ]]||die 'no selectable group'; now="$(jq -r --arg g "$group" '.proxies[$g].now//empty'<<<"$json")"
candidates="$(jq -r --arg g "$group" --arg kw "$kw" 'def lc:ascii_downcase;. as $r|.proxies[$g].all[] as $n|($r.proxies[$n].type//"")as$t|select(["Direct","Reject","Selector","URLTest","Fallback","LoadBalance"]|index($t)== null)|select($kw==""or($n|contains($kw))or(($n|lc)|contains($kw|lc)))|$n'<<<"$json")"
[[ -n "$candidates" ]]||die "no nodes matched: ${kw:-<all>}"; mapfile -t nodes<<<"$candidates"; tmp="$(mktemp -d "$MH_RUNTIME/delay.XXXXXX")"; info "testing ${#nodes[@]} nodes (1800ms, 8 jobs)..."
for i in "${!nodes[@]}";do(api_delay "${nodes[$i]}">"$tmp/$i")& running=$((running+1));if((running>=8));then wait -n 2>/dev/null||wait;running=$((running-1));fi;done;wait;:>"$tmp/index"
for i in "${!nodes[@]}";do d="$(<"$tmp/$i")";[[ "$d" =~ ^[0-9]+$ ]]||d=999999;printf '%s\t%s\n' "$d" "$i">>"$tmp/index";done
while IFS=$'\t' read -r d i;do delays+=("$d");sorted+=("${nodes[$i]}");done< <(sort -n "$tmp/index");rm -rf -- "$tmp"
printf 'Group: %s Current: %s' "$group" "$now";[[ -n "$kw" ]]&&printf ' Filter: %s' "$kw";echo
for i in "${!sorted[@]}";do d="$(delay_text "${delays[$i]}")";[[ "${sorted[$i]}" == "$now" ]]&&view+=("[$((i+1))] * ${sorted[$i]} $d")||view+=("[$((i+1))] ${sorted[$i]} $d");done;display_grid view
[[ -t 0 ]]||return;while true;do read -r -p "Choose node (1-${#sorted[@]}, 0 cancel): " pick;[[ "$pick" =~ ^[0-9]+$ ]]||continue;((pick== 0))&&return;((pick>=1&&pick<=${#sorted[@]}))&&break;done
enc="$(urlencode "$group")";body="$(jq -nc --arg name "${sorted[$((pick-1))]}" '{name:$name}')";api_curl -X PUT -H 'Content-Type: application/json' --data "$body" "$MH_API/proxies/$enc">/dev/null;info "$group -> ${sorted[$((pick-1))]}"
}
show_env(){ local no_proxy; no_proxy="$(no_proxy_value)"; cat <<EOF
export http_proxy="http://127.0.0.1:${MH_MIXED_PORT}"
export https_proxy="http://127.0.0.1:${MH_MIXED_PORT}"
export all_proxy="socks5h://127.0.0.1:${MH_SOCKS_PORT}"
export HTTP_PROXY="\$http_proxy"
export HTTPS_PROXY="\$https_proxy"
export ALL_PROXY="\$all_proxy"
EOF
printf 'export NO_PROXY=%q\n' "$no_proxy"
printf '%s\n' 'export no_proxy="$NO_PROXY"'
}
show_noenv(){ echo 'unset http_proxy https_proxy all_proxy HTTP_PROXY HTTPS_PROXY ALL_PROXY NO_PROXY no_proxy'; }
shell_init_cmd(){ local real="$(printf %q "$SCRIPT_PATH")";cat <<EOF
mh() {
MH_SHELL_HOOK=1 ${real} "\$@"
local rc=\$?
if ((rc== 0)) && [[ "\${1:-}" == set ]] && { [[ "\${2:-}" == cliproxy ]] || [[ "\${2:-}" == proxy ]]; }; then
if [[ "\${3:-}" == on ]]; then eval "\$(${real} env)"; else eval "\$(${real} noenv)"; fi
fi
return \$rc
}
EOF
[[ "$(setting cliproxy)" == true ]]&&show_env||show_noenv; }
sysproxy_cmd(){ local a="$1" ignore_hosts;need gsettings;case "$a" in on)ignore_hosts="$(gsettings_bypass_value)";gsettings set org.gnome.system.proxy mode manual;for s in http https;do gsettings set org.gnome.system.proxy.$s host 127.0.0.1;gsettings set org.gnome.system.proxy.$s port "$MH_MIXED_PORT";done;gsettings set org.gnome.system.proxy.socks host 127.0.0.1;gsettings set org.gnome.system.proxy.socks port "$MH_SOCKS_PORT";gsettings set org.gnome.system.proxy ignore-hosts "$ignore_hosts";set_setting sysproxy true;info 'system proxy ON';;off)gsettings set org.gnome.system.proxy mode none;set_setting sysproxy false;info 'system proxy OFF';;status)echo "setting=$(setting sysproxy) mode=$(gsettings get org.gnome.system.proxy mode)";;*)die 'usage: mh set sysproxy on|off';;esac; }
default_bypass(){ cat <<'EOF'
# mh system bypass rules
localhost
*.local
127.0.0.0/8
10.0.0.0/8
172.16.0.0/12
192.168.0.0/16
100.64.0.2
100.64.0.0/10
::1/128
fc00::/7
fe80::/10
EOF
}
rules_cmd(){ case "${1:-show}" in show)echo "bypass.list: $MH_BYPASS";nl -ba "$MH_BYPASS";;update)compile_bypass;[[ "$(setting sysproxy)" == true ]]&&sysproxy_cmd on;info "compiled $(jq length "$MH_RULES") bypass rules";restart_if_running;;reset)default_bypass>"$MH_BYPASS.tmp";mv -f "$MH_BYPASS.tmp" "$MH_BYPASS";compile_bypass;[[ "$(setting sysproxy)" == true ]]&&sysproxy_cmd on;info 'bypass reset';restart_if_running;;*)die 'usage: mh set rules show|update|reset';;esac;}
set_cmd(){ local t="${1:-}" a="${2:-}";[[ -n "$t" ]]|| { jq . "$MH_SETTINGS";sysproxy_cmd status 2>/dev/null||true;return;};case "$t" in cliproxy)[[ "$a" == on||"$a" == off ]]||die 'usage: mh set cliproxy on|off';set_setting cliproxy "$([[ "$a" == on ]]&&echo true||echo false)";info "CLI proxy $a";[[ -n "${MH_SHELL_HOOK:-}" ]]||warn 'run once: eval "$(mh shell-init)"';;sysproxy)sysproxy_cmd "$a";;proxy)[[ "$a" == on||"$a" == off ]]||die 'usage: mh set proxy on|off';set_setting cliproxy "$([[ "$a" == on ]]&&echo true||echo false)";sysproxy_cmd "$a";info "CLI + system proxy $a";;tun)[[ "$a" == on||"$a" == off ]]||die 'usage: mh set tun on|off';set_setting tun "$([[ "$a" == on ]]&&echo true||echo false)";[[ "$a" == on ]]&&! getcap "$MH_BIN" 2>/dev/null|grep -q cap_net_admin&&warn "TUN may need setcap on $MH_BIN";info "TUN $a";restart_if_running;;rules)rules_cmd "$a";;*)die 'usage: mh set cliproxy|sysproxy|proxy|tun|rules';;esac; }
update_cmd(){ local p meta type src tmp running=false;p="$(current_profile 2>/dev/null||true)";[[ -n "$p" ]]||die 'no current profile';meta="$(metadata_file "$p")";type="$(jq -r .type "$meta")";src="$(jq -r .source "$meta")";[[ "$type" == http ]]||die 'current provider is local file';is_running&&running=true;tmp="$(provider_file "$p").tmp";fetch_provider "$src" "$tmp";mv -f "$tmp" "$(provider_file "$p")";jq --arg t "$(date -Iseconds)" '.updated_at=$t' "$meta">"$meta.tmp";mv -f "$meta.tmp" "$meta";info "updated HTTP provider: $p";[[ "$running" == true ]]&& { stop_cmd;start_cmd "$p";};}
mode_cmd(){ local m="${1:-}";[[ "$m" == rule||"$m" == global||"$m" == direct ]]||die 'usage: mh mode rule|global|direct';set_setting mode "$m";info "mode -> $m";restart_if_running;}
status_cmd(){
local p="$(current_profile 2>/dev/null||true)" now='' delay=''
if is_running;then
now="$(api_curl "$MH_API/proxies/Proxy" 2>/dev/null|jq -r '.now//empty'||true)"
[[ -n "$now" ]]&&delay="$(delay_text "$(api_delay "$now")")"
echo "mihomo: RUNNING pid=$(<"$MH_PID_FILE")"
else
echo 'mihomo: STOPPED'
fi
printf 'root: %s\nprofile: %s\nmode: %s\ntun: %s\ncliproxy: %s\nsysproxy: %s\nmixed: 127.0.0.1:%s\nsocks: 127.0.0.1:%s\napi: 127.0.0.1:%s\n' "$MH_ROOT" "${p:-<none>}" "$(setting mode)" "$(setting tun)" "$(setting cliproxy)" "$(setting sysproxy)" "$MH_MIXED_PORT" "$MH_SOCKS_PORT" "$MH_API_PORT"
[[ -n "$now" ]]&&printf 'node: %s %s\n' "$now" "$delay"
}
db_ports(){ echo '== proxy ports ==';ss -ltnp 2>/dev/null|awk -v a=":$MH_MIXED_PORT" -v b=":$MH_SOCKS_PORT" -v c=":$MH_API_PORT" 'NR== 1||index($4,a)||index($4,b)||index($4,c)';}
db_process(){ local p="${1:-}";[[ -n "$p" ]]|| { [[ -f "$MH_PID_FILE" ]]&&p="$(<"$MH_PID_FILE")"||true;};[[ "$p" =~ ^[0-9]+$ ]]||die 'no managed PID';ps -p "$p" -o pid,ppid,user,state,etime,%cpu,%mem,cmd;}
db_probe(){ local mode="$1" url="$2";if [[ "$mode" == direct ]];then curl -fsSIL --max-time 10 --noproxy '*' "$url" -o /dev/null;else curl -fsSIL --max-time 10 --proxy "http://127.0.0.1:$MH_MIXED_PORT" "$url" -o /dev/null;fi;}
db_net(){ local mode="${1:-proxy}" u x y;local -a urls=('https://www.google.com' 'https://www.cloudflare.com' 'https://www.github.com' 'https://www.youtube.com' 'https://www.baidu.com' 'https://www.bing.com');[[ "$mode" == direct ]]||require_api;for u in "${urls[@]}";do if [[ "$mode" == compare ]];then db_probe direct "$u"&&x=OK||x=FAIL;db_probe proxy "$u"&&y=OK||y=FAIL;printf '%-32s DIRECT:%-4s PROXY:%-4s\n' "$u" "$x" "$y";else db_probe "$mode" "$u"&&x=OK||x=FAIL;printf '%-32s %s:%s\n' "$u" "${mode^^}" "$x";fi;done;}
db_deps(){ local x p rc=0;for x in jq yq curl ss sed sort readlink;do p="$(command -v "$x" 2>/dev/null||true)";[[ -n "$p" ]]&&printf 'OK %-10s %s\n' "$x" "$p"|| { echo "MISSING $x";rc=1;};done;[[ -x "$MH_BIN" ]]&&echo "OK mihomo $MH_BIN"||rc=1;return "$rc";}
db_interfaces(){ ip -brief link show 2>/dev/null||true;echo "TUN setting=$(setting tun)";}
db_dns(){ echo '== generated DNS ==';yq .dns "$MH_CONFIG" 2>/dev/null||echo disabled;echo '== host resolver ==';resolvectl status 2>/dev/null|sed -n '1,140p'||cat /etc/resolv.conf;}
db_routes(){ ip route show;echo;ip rule show;}
db_api(){ require_api;api_curl "$MH_API/version"|jq .;api_curl "$MH_API/proxies"|jq -r '.proxies|to_entries[]|select(.value.all!=null)|"\(.key) -> \(.value.now//"-") (\(.value.all|length))"';}
db_config(){ check_cmd; yq -c '{mode,ports:{mixed:."mixed-port",socks:."socks-port",api:."external-controller"},tun:.tun,providers:(."proxy-providers"|keys),groups:(."proxy-groups"|map(.name)),rule_count:(.rules|length)}' "$MH_CONFIG"|jq .;}
db_log(){ local n="${1:-80}";[[ "$n" =~ ^[0-9]+$ ]]||die 'lines must be number';tail -n "$n" "$MH_LOG" 2>/dev/null||echo 'no log';}
db_menu(){ local pick i;local -a c=(ports process net compare deps interfaces dns routes api config log);echo '== mh db ==';for i in "${!c[@]}";do printf ' %2d) %s\n' "$((i+1))" "${c[$i]}";done;[[ -t 0 ]]||return;read -r -p 'Choose: ' pick;[[ "$pick" =~ ^[0-9]+$ ]]&&((pick>=1&&pick<=${#c[@]}))&&db_cmd "${c[$((pick-1))]}";}
db_cmd(){ local a="${1:-menu}";shift||true;case "$a" in menu)db_menu;;ports)db_ports;;process)db_process "${1:-}";;net)db_net "${1:-proxy}";;compare)db_net compare;;deps)db_deps;;interfaces)db_interfaces;;dns)db_dns;;routes)db_routes;;api)db_api;;config)db_config;;log)db_log "${1:-80}";;*)die 'usage: mh db [ports|process|net|compare|deps|interfaces|dns|routes|api|config|log]';;esac;}
doctor_cmd(){ echo '== mh doctor ==';status_cmd;echo;db_deps;echo;db_ports;echo;db_config;echo;db_log 30;}
help_cmd(){ local p="$(current_profile 2>/dev/null||true)" count="$(list_profiles|wc -l)";cat <<EOF
mh - mihomo profile/provider CLI
add <name> <url|file> Add Clash YAML provider
start [name] Choose/start provider profile
stop | end Stop managed mihomo
update Update current HTTP provider
-l, --list [keyword] Speed-test/sort/choose nodes
mode rule|global|direct Set mode and restart
set cliproxy on|off Terminal env (shell hook)
set sysproxy on|off GNOME system proxy
set proxy on|off Both terminal/system proxy
set tun on|off TUN and restart
set rules show|update|reset Manage root bypass.list
status | check [name] Status/config check
shell-init | env | noenv Shell integration
db [action] Debug menu/actions
log | doctor Logs/diagnostics
root: $MH_ROOT
profiles: $count current: ${p:-<none>}
mode: $(setting mode) tun: $(setting tun) cliproxy: $(setting cliproxy) sysproxy: $(setting sysproxy)
ports: mixed=$MH_MIXED_PORT socks=$MH_SOCKS_PORT api=$MH_API_PORT
EOF
}
init_layout
case "${1:-status}" in add)shift;add_cmd "$@";;start)shift;start_cmd "${1:-}";;stop|end)stop_cmd;;update)update_cmd;;-l|--list|list)shift;list_nodes_cmd "${1:-}";;mode)shift;mode_cmd "${1:-}";;set)shift;set_cmd "$@";;status)status_cmd;;check)shift;check_cmd "${1:-}";;env)show_env;;noenv)show_noenv;;shell-init)shell_init_cmd;;db)shift;db_cmd "$@";;log)touch "$MH_LOG";tail -n 80 -f "$MH_LOG";;doctor)doctor_cmd;;-h|--help|help)help_cmd;;*)help_cmd>&2;exit 2;;esac

684
refers/sb Executable file
View file

@ -0,0 +1,684 @@
#!/usr/bin/env bash
set -euo pipefail
shopt -s nullglob
SCRIPT_PATH="$(readlink -f "${BASH_SOURCE[0]}")"
SCRIPT_DIR="$(cd -- "$(dirname -- "$SCRIPT_PATH")" && pwd)"
SB_ROOT="${SING_BOX_ROOT:-$SCRIPT_DIR}"
SB_BIN="${SING_BOX_BIN:-${SB_ROOT}/sing-box/sing-box}"
SB_SYSTEM="${SB_ROOT}/system/config.json"
SB_BYPASS="${SB_ROOT}/bypass.list"
SB_PROFILES="${SB_ROOT}/profiles"
SB_CURRENT="${SB_ROOT}/current.profile"
SB_SETTINGS="${SB_ROOT}/settings.json"
SB_RUN_DIR="${SING_BOX_RUN_DIR:-${SB_ROOT}/.runtime}"
SB_CONFIG="${SB_RUN_DIR}/config.json"
SB_RULES="${SB_RUN_DIR}/bypass-rules.json"
SB_PID_FILE="${SB_RUN_DIR}/run.pid"
SB_LOG="${SB_RUN_DIR}/sing-box.log"
SB_PORT="${SING_BOX_PORT:-7890}"
SB_API_PORT="${SING_BOX_API_PORT:-9090}"
SB_API="http://127.0.0.1:${SB_API_PORT}"
die() { printf 'sb: %s\n' "$*" >&2; exit 1; }
info() { printf 'sb: %s\n' "$*"; }
warn() { printf 'sb: warning: %s\n' "$*" >&2; }
need() { command -v "$1" >/dev/null 2>&1 || die "missing command: $1"; }
init_layout() {
[[ -x "$SB_BIN" ]] || die "sing-box binary is not executable: $SB_BIN"
[[ -f "$SB_SYSTEM" ]] || die "system config not found: $SB_SYSTEM"
[[ -f "$SB_BYPASS" ]] || die "bypass list not found: $SB_BYPASS"
need jq
mkdir -p "$SB_PROFILES" "$SB_RUN_DIR"
if [[ ! -f "$SB_SETTINGS" ]]; then
printf '%s\n' '{"mode":"rule","tun":false,"cliproxy":false,"sysproxy":false}' >"$SB_SETTINGS"
fi
}
setting() { jq -r --arg key "$1" '.[$key]' "$SB_SETTINGS"; }
set_setting() {
local key="$1" value="$2"
if [[ "$value" == true || "$value" == false ]]; then
jq --arg key "$key" --argjson value "$value" '.[$key] = $value' "$SB_SETTINGS" >"${SB_SETTINGS}.tmp"
else
jq --arg key "$key" --arg value "$value" '.[$key] = $value' "$SB_SETTINGS" >"${SB_SETTINGS}.tmp"
fi
mv -f "${SB_SETTINGS}.tmp" "$SB_SETTINGS"
}
profile_dir() { printf '%s/%s\n' "$SB_PROFILES" "$1"; }
provider_file() { printf '%s/provider.json\n' "$(profile_dir "$1")"; }
metadata_file() { printf '%s/source.json\n' "$(profile_dir "$1")"; }
validate_name() { [[ "$1" =~ ^[A-Za-z0-9._-]+$ ]] || die 'profile name contains unsupported characters'; }
current_profile() {
[[ -f "$SB_CURRENT" ]] || return 1
local name
name="$(tr -d '[:space:]' <"$SB_CURRENT")"
[[ -n "$name" && -f "$(provider_file "$name")" ]] || return 1
printf '%s\n' "$name"
}
list_profiles() {
local file
for file in "$SB_PROFILES"/*/provider.json; do
[[ -f "$file" ]] && basename "$(dirname "$file")"
done | sort
}
validate_provider() {
jq -e 'type == "object" and (.outbounds | type == "array") and (.outbounds | length > 0)' "$1" >/dev/null \
|| die "provider must be sing-box JSON with non-empty .outbounds: $1"
}
fetch_provider() {
local src="$1" dest="$2"
if [[ "$src" =~ ^https?:// ]]; then
need curl
curl -fL --connect-timeout 15 --max-time 90 -A 'sb/1.0 sing-box-provider' "$src" -o "$dest"
elif [[ -f "$src" ]]; then
cp -f -- "$src" "$dest"
else
die "source is neither an HTTP URL nor a file: $src"
fi
validate_provider "$dest"
}
add_cmd() {
local name="${1:-}" src="${2:-}" dir tmp kind saved_src
[[ -n "$name" && -n "$src" ]] || die 'usage: sb add <name> <url|file>'
validate_name "$name"
dir="$(profile_dir "$name")"
[[ ! -e "$dir" ]] || die "profile already exists: $name"
mkdir -p "$dir"
tmp="${dir}/provider.json.tmp"
if [[ "$src" =~ ^https?:// ]]; then kind=http; saved_src="$src"; else kind=file; saved_src="$(readlink -f "$src")"; fi
if ! fetch_provider "$src" "$tmp"; then rm -f "$tmp"; rmdir "$dir" 2>/dev/null || true; return 1; fi
mv -f "$tmp" "${dir}/provider.json"
jq -n --arg name "$name" --arg type "$kind" --arg source "$saved_src" --arg added_at "$(date -Iseconds)" \
'{name:$name,type:$type,source:$source,added_at:$added_at}' >"${dir}/source.json"
printf '%s\n' "$name" >"$SB_CURRENT"
info "added profile=${name} type=${kind}"
info "run: sb start ${name}"
}
compile_bypass_rules() {
local line value prefix
: >"${SB_RULES}.ndjson"
while IFS= read -r line || [[ -n "$line" ]]; do
line="${line%%#*}"
line="$(sed -E 's/^[[:space:]]+|[[:space:]]+$//g' <<<"$line")"
[[ -n "$line" ]] || continue
if [[ "$line" == */* ]]; then
jq -nc --arg v "$line" '{ip_cidr:[$v],action:"route",outbound:"DIRECT"}' >>"${SB_RULES}.ndjson"
elif [[ "$line" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
jq -nc --arg v "${line}/32" '{ip_cidr:[$v],action:"route",outbound:"DIRECT"}' >>"${SB_RULES}.ndjson"
elif [[ "$line" =~ ^(10|127|172|192)(\..*)?\*$ ]]; then
prefix="${BASH_REMATCH[1]}"
case "$prefix" in 10) value='10.0.0.0/8';; 127) value='127.0.0.0/8';; 172) value='172.16.0.0/12';; 192) value='192.168.0.0/16';; esac
jq -nc --arg v "$value" '{ip_cidr:[$v],action:"route",outbound:"DIRECT"}' >>"${SB_RULES}.ndjson"
elif [[ "$line" == \** || "$line" == .* ]]; then
value="${line#\*}"; value="${value#.}"
[[ -n "$value" ]] && jq -nc --arg v "$value" '{domain_suffix:[$v],action:"route",outbound:"DIRECT"}' >>"${SB_RULES}.ndjson"
else
jq -nc --arg v "$line" '{domain:[$v],action:"route",outbound:"DIRECT"}' >>"${SB_RULES}.ndjson"
fi
done <"$SB_BYPASS"
jq -s 'unique' "${SB_RULES}.ndjson" >"${SB_RULES}.tmp"
mv -f "${SB_RULES}.tmp" "$SB_RULES"
rm -f "${SB_RULES}.ndjson"
}
proxy_bypass_items() {
local mode="$1" line value
while IFS= read -r line || [[ -n "$line" ]]; do
line="${line%%#*}"
line="$(sed -E 's/^[[:space:]]+|[[:space:]]+$//g' <<<"$line")"
[[ -n "$line" ]] || continue
value="$line"
if [[ "$mode" == no_proxy && "$value" == \*.* ]]; then
value=".${value#*.}"
elif [[ "$mode" == gsettings && "$value" == .* ]]; then
value="*${value}"
fi
printf '%s\n' "$value"
done <"$SB_BYPASS" | awk '!seen[$0]++'
}
no_proxy_value() { proxy_bypass_items no_proxy | paste -sd, -; }
gsettings_bypass_value() { proxy_bypass_items gsettings | jq -Rsc 'split("\n") | map(select(length > 0))'; }
build_config() {
local profile="$1" provider mode tun
provider="$(provider_file "$profile")"
[[ -f "$provider" ]] || die "profile not found: $profile"
validate_provider "$provider"
compile_bypass_rules
mode="$(setting mode)"; tun="$(setting tun)"
jq -n --slurpfile provider "$provider" --slurpfile system "$SB_SYSTEM" --slurpfile bypass "$SB_RULES" \
--arg mode "$mode" --argjson tun "$tun" --argjson port "$SB_PORT" --arg controller "127.0.0.1:${SB_API_PORT}" '
($provider[0]) as $p | ($system[0]) as $s | ($bypass[0]) as $b
| ($p.outbounds // []) as $original
| ($original | map(.tag // empty)) as $tags0
| ($original + if ($tags0|index("DIRECT")) == null then [{type:"direct",tag:"DIRECT"}] else [] end) as $direct_added
| ($direct_added | map(.tag // empty)) as $tags1
| ($direct_added + if ($tags1|index("Proxy")) == null then
[{type:"selector",tag:"Proxy",outbounds:[$direct_added[]|select(.type!="direct" and .type!="block" and .type!="dns" and .type!="selector")|.tag]}]
else [] end) as $outbounds
| ($outbounds | map(.tag // empty)) as $outbound_tags
| ([{type:"mixed",tag:"mixed-in",listen:"127.0.0.1",listen_port:$port,set_system_proxy:false}]
+ if $tun then [($p.inbounds // [] | map(select(.type=="tun")) | .[0]
// {type:"tun",tag:"tun-in",address:["172.19.0.1/30","fdfe:dcba:9876::1/126"],auto_route:true,strict_route:true})] else [] end) as $inbounds
| ([{action:"sniff"}] + if $tun then [{protocol:"dns",action:"hijack-dns"}] else [] end + $b) as $system_rules
| (($p.route.rules // []) | map(select(.action!="sniff" and .action!="hijack-dns"))) as $provider_rules
| (if $mode=="rule" then $system_rules+$provider_rules else $system_rules end) as $rules
| (if $mode=="direct" then "DIRECT" elif $mode=="global" then "Proxy"
elif (($p.route.final // "") as $f | ($outbound_tags|index($f)) != null) then $p.route.final else "Proxy" end) as $final
| $p
| .log=$s.log | .dns=$s.dns | .inbounds=$inbounds | .outbounds=$outbounds
| .route=(($p.route // {}) * ($s.route // {}))
| .route.auto_detect_interface=$tun | .route.rules=$rules | .route.final=$final
| .experimental=(($p.experimental // {}) * ($s.experimental // {}))
| .experimental.clash_api.external_controller=$controller
' >"${SB_CONFIG}.tmp"
mv -f "${SB_CONFIG}.tmp" "$SB_CONFIG"
}
process_alive() {
local pid="$1" state _
[[ "$pid" =~ ^[0-9]+$ ]] && kill -0 "$pid" 2>/dev/null || return 1
read -r _ _ state _ <"/proc/${pid}/stat" 2>/dev/null || return 1
[[ "$state" != Z ]]
}
is_running() {
[[ -f "$SB_PID_FILE" ]] || return 1
process_alive "$(<"$SB_PID_FILE")"
}
port_in_use() {
ss -ltnH 2>/dev/null | awk -v suffix=":$1" 'substr($4,length($4)-length(suffix)+1)==suffix{f=1} END{exit !f}'
}
api_curl() {
local secret="$(jq -r '.experimental.clash_api.secret // empty' "$SB_CONFIG" 2>/dev/null || true)"
if [[ -n "$secret" ]]; then curl -fsS --noproxy '*' -H "Authorization: Bearer ${secret}" "$@"; else curl -fsS --noproxy '*' "$@"; fi
}
wait_api() { local i; for i in {1..50}; do api_curl "${SB_API}/proxies" >/dev/null 2>&1 && return 0; sleep 0.1; done; return 1; }
stop_cmd() {
if ! is_running; then rm -f "$SB_PID_FILE"; info 'not running'; return 0; fi
local pid="$(<"$SB_PID_FILE")" expected actual comm cmdline i
expected="$(readlink -f "$SB_BIN")"
actual="$(readlink -f "/proc/${pid}/exe" 2>/dev/null || true)"
comm="$(cat "/proc/${pid}/comm" 2>/dev/null || true)"
cmdline="$(tr '\0' ' ' <"/proc/${pid}/cmdline" 2>/dev/null || true)"
if [[ -z "$actual" ]] && ! process_alive "$pid"; then
rm -f "$SB_PID_FILE"; info 'process already exited'; return 0
fi
if [[ "$actual" != "$expected" ]]; then
[[ "$comm" == "$(basename "$SB_BIN")" && "$cmdline" == "$SB_BIN run "* && "$cmdline" == *" -c $SB_CONFIG"* ]] \
|| die "refusing to stop pid ${pid}: process identity does not match sb runtime"
fi
kill "$pid" 2>/dev/null || true
for i in {1..30}; do process_alive "$pid" || break; sleep 0.1; done
process_alive "$pid" && die "pid ${pid} did not stop"
rm -f "$SB_PID_FILE"; info "stopped pid ${pid}"
}
choose_profile() {
local requested="${1:-}" current pick i
local -a profiles
mapfile -t profiles < <(list_profiles)
(( ${#profiles[@]} )) || die 'no providers; use: sb add <name> <url|file>'
current="$(current_profile 2>/dev/null || true)"
if [[ -n "$requested" ]]; then [[ -f "$(provider_file "$requested")" ]] || die "profile not found: $requested"; echo "$requested"; return; fi
if [[ ! -t 0 ]]; then [[ -n "$current" ]] || die 'non-interactive start requires profile name'; echo "$current"; return; fi
echo 'Available providers:' >&2
for i in "${!profiles[@]}"; do
[[ "${profiles[$i]}" == "$current" ]] && printf ' %d) %s (current)\n' "$((i+1))" "${profiles[$i]}" >&2 || printf ' %d) %s\n' "$((i+1))" "${profiles[$i]}" >&2
done
while true; do
read -r -p "Choose provider (1-${#profiles[@]}, 0 cancel): " pick
[[ "$pick" =~ ^[0-9]+$ ]] || { echo 'Please enter a number.' >&2; continue; }
(( pick==0 )) && return 1
(( pick>=1 && pick<=${#profiles[@]} )) || { echo 'Out of range.' >&2; continue; }
echo "${profiles[$((pick-1))]}"; return
done
}
start_cmd() {
local profile pid i
profile="$(choose_profile "${1:-}")" || { info 'cancelled'; return; }
is_running && stop_cmd
build_config "$profile"
"$SB_BIN" check -D "$SB_RUN_DIR" -c "$SB_CONFIG"
need ss
port_in_use "$SB_PORT" && die "port ${SB_PORT} is in use (stop mihomo before sb)"
port_in_use "$SB_API_PORT" && die "API port ${SB_API_PORT} is in use (stop mihomo before sb)"
: >"$SB_LOG"
nohup "$SB_BIN" run -D "$SB_RUN_DIR" -c "$SB_CONFIG" >>"$SB_LOG" 2>&1 & pid=$!
echo "$pid" >"$SB_PID_FILE"; echo "$profile" >"$SB_CURRENT"
for i in {1..40}; do
if ! kill -0 "$pid" 2>/dev/null; then rm -f "$SB_PID_FILE"; tail -n 50 "$SB_LOG" >&2 || true; die 'sing-box exited during startup'; fi
if port_in_use "$SB_PORT" && port_in_use "$SB_API_PORT"; then
info "started profile=${profile} pid=${pid} mode=$(setting mode) tun=$(setting tun)"; wait_api || warn 'API not ready'; return
fi
sleep 0.1
done
tail -n 50 "$SB_LOG" >&2 || true; die 'startup timed out'
}
restart_if_running() {
local profile="$(current_profile 2>/dev/null || true)"
if is_running; then stop_cmd; start_cmd "$profile"; fi
}
require_api() { is_running || die 'sing-box is not running'; api_curl "${SB_API}/proxies" >/dev/null || die "API unavailable: $SB_API"; }
urlencode() { jq -nr --arg v "$1" '$v|@uri'; }
api_delay() {
local node="$1" timeout="${2:-1800}" encoded url
encoded="$(urlencode "$node")"; url="$(urlencode 'https://www.gstatic.com/generate_204')"
api_curl "${SB_API}/proxies/${encoded}/delay?timeout=${timeout}&url=${url}" 2>/dev/null \
| jq -r 'if (.delay|type)=="number" then .delay else 999999 end' 2>/dev/null || echo 999999
}
delay_text() {
local delay="${1:-}"
[[ "$delay" =~ ^[0-9]+$ && "$delay" != 999999 ]] && printf '%sms\n' "$delay" || printf 'timeout\n'
}
main_group() { jq -r 'if .proxies.Proxy.all then "Proxy" else [.proxies|to_entries[]|select(.value.all!=null)][0].key//empty end' <<<"$1"; }
display_node_grid() {
local -n ref=$1
local count=${#ref[@]} width cols=1 rows r c idx
width="$(tput cols 2>/dev/null || echo 120)"
(( count>18 && width>=110 )) && cols=2
(( count>36 && width>=170 )) && cols=3
rows=$(((count+cols-1)/cols))
for ((r=0;r<rows;r++)); do
for ((c=0;c<cols;c++)); do
idx=$((r+c*rows)); ((idx<count)) || continue
if ((c+1<cols && idx+rows<count)); then printf '%-56s' "${ref[$idx]}"; else printf '%s' "${ref[$idx]}"; fi
done
echo
done
}
list_nodes_cmd() {
local keyword="${1:-}" json group now candidates tmp running=0 i d pick encoded body
local -a nodes sorted_nodes=() sorted_delays=() display=()
require_api; json="$(api_curl "${SB_API}/proxies")"; group="$(main_group "$json")"; [[ -n "$group" ]] || die 'no selectable group'
now="$(jq -r --arg g "$group" '.proxies[$g].now//empty' <<<"$json")"
candidates="$(jq -r --arg g "$group" --arg kw "$keyword" '
def lc: ascii_downcase; . as $r | .proxies[$g].all[] as $n | ($r.proxies[$n].type//"") as $t
| select(["Direct","Reject","Block","Selector","URLTest","Fallback","LoadBalance"]|index($t)==null)
| select($kw=="" or ($n|contains($kw)) or (($n|lc)|contains($kw|lc))) | $n' <<<"$json")"
[[ -n "$candidates" ]] || die "no nodes matched: ${keyword:-<all>}"; mapfile -t nodes <<<"$candidates"
tmp="$(mktemp -d "${SB_RUN_DIR}/delay.XXXXXX")"; info "testing ${#nodes[@]} nodes (1800ms, 8 jobs)..."
for i in "${!nodes[@]}"; do
(api_delay "${nodes[$i]}" >"${tmp}/${i}") & running=$((running+1))
if ((running>=8)); then wait -n 2>/dev/null || wait; running=$((running-1)); fi
done
wait; : >"${tmp}/index"
for i in "${!nodes[@]}"; do d="$(<"${tmp}/${i}")"; [[ "$d" =~ ^[0-9]+$ ]] || d=999999; printf '%s\t%s\n' "$d" "$i" >>"${tmp}/index"; done
while IFS=$'\t' read -r d i; do sorted_delays+=("$d"); sorted_nodes+=("${nodes[$i]}"); done < <(sort -n "${tmp}/index")
rm -rf -- "$tmp"
printf 'Group: %s Current: %s' "$group" "$now"; [[ -n "$keyword" ]] && printf ' Filter: %s' "$keyword"; echo
for i in "${!sorted_nodes[@]}"; do
d="$(delay_text "${sorted_delays[$i]}")"
[[ "${sorted_nodes[$i]}" == "$now" ]] && display+=("[$((i+1))] * ${sorted_nodes[$i]} ${d}") || display+=("[$((i+1))] ${sorted_nodes[$i]} ${d}")
done
display_node_grid display
[[ -t 0 ]] || return
while true; do
read -r -p "Choose node (1-${#sorted_nodes[@]}, 0 cancel): " pick
[[ "$pick" =~ ^[0-9]+$ ]] || { echo 'Please enter a number.'; continue; }
((pick==0)) && { info cancelled; return; }
((pick>=1 && pick<=${#sorted_nodes[@]})) || { echo 'Out of range.'; continue; }; break
done
encoded="$(urlencode "$group")"; body="$(jq -nc --arg name "${sorted_nodes[$((pick-1))]}" '{name:$name}')"
api_curl -X PUT -H 'Content-Type: application/json' --data "$body" "${SB_API}/proxies/${encoded}" >/dev/null
info "${group} -> ${sorted_nodes[$((pick-1))]} (${sorted_delays[$((pick-1))]}ms)"
}
show_env() {
local no_proxy
no_proxy="$(no_proxy_value)"
cat <<EOF
export http_proxy="http://127.0.0.1:${SB_PORT}"
export https_proxy="http://127.0.0.1:${SB_PORT}"
export all_proxy="socks5h://127.0.0.1:${SB_PORT}"
export HTTP_PROXY="\$http_proxy"
export HTTPS_PROXY="\$https_proxy"
export ALL_PROXY="\$all_proxy"
EOF
printf 'export NO_PROXY=%q\n' "$no_proxy"
printf '%s\n' 'export no_proxy="$NO_PROXY"'
}
show_noenv() { echo 'unset http_proxy https_proxy all_proxy HTTP_PROXY HTTPS_PROXY ALL_PROXY NO_PROXY no_proxy'; }
shell_init_cmd() {
local real="$(printf '%q' "$SCRIPT_PATH")"
cat <<EOF
sb() {
SB_SHELL_HOOK=1 ${real} "\$@"
local rc=\$?
if ((rc==0)) && [[ "\${1:-}" == set ]] && { [[ "\${2:-}" == cliproxy ]] || [[ "\${2:-}" == proxy ]]; }; then
if [[ "\${3:-}" == on ]]; then eval "\$(${real} env)"; else eval "\$(${real} noenv)"; fi
fi
return \$rc
}
EOF
[[ "$(setting cliproxy)" == true ]] && show_env || show_noenv
}
sysproxy_cmd() {
local action="$1" ignore_hosts; need gsettings
case "$action" in
on)
ignore_hosts="$(gsettings_bypass_value)"
gsettings set org.gnome.system.proxy mode manual
gsettings set org.gnome.system.proxy.http host 127.0.0.1; gsettings set org.gnome.system.proxy.http port "$SB_PORT"
gsettings set org.gnome.system.proxy.https host 127.0.0.1; gsettings set org.gnome.system.proxy.https port "$SB_PORT"
gsettings set org.gnome.system.proxy.socks host 127.0.0.1; gsettings set org.gnome.system.proxy.socks port "$SB_PORT"
gsettings set org.gnome.system.proxy ignore-hosts "$ignore_hosts"
set_setting sysproxy true; info "system proxy ON -> 127.0.0.1:${SB_PORT}" ;;
off) gsettings set org.gnome.system.proxy mode none; set_setting sysproxy false; info 'system proxy OFF' ;;
status) printf 'sysproxy setting=%s gnome-mode=%s\n' "$(setting sysproxy)" "$(gsettings get org.gnome.system.proxy mode)" ;;
*) die 'usage: sb set sysproxy on|off' ;;
esac
}
default_bypass() {
cat <<'EOF'
# sb system bypass rules (one item per line)
localhost
*.local
127.0.0.0/8
10.0.0.0/8
172.16.0.0/12
192.168.0.0/16
100.64.0.2
100.64.0.0/10
::1/128
fc00::/7
fe80::/10
EOF
}
rules_cmd() {
case "${1:-show}" in
show) echo "bypass.list: $SB_BYPASS"; nl -ba "$SB_BYPASS" ;;
update) compile_bypass_rules; [[ "$(setting sysproxy)" == true ]] && sysproxy_cmd on; info "compiled $(jq length "$SB_RULES") bypass rules"; restart_if_running ;;
reset) default_bypass >"${SB_BYPASS}.tmp"; mv -f "${SB_BYPASS}.tmp" "$SB_BYPASS"; compile_bypass_rules; [[ "$(setting sysproxy)" == true ]] && sysproxy_cmd on; info 'bypass.list reset'; restart_if_running ;;
*) die 'usage: sb set rules show|update|reset' ;;
esac
}
set_cmd() {
local target="${1:-}" action="${2:-}"
[[ -n "$target" ]] || { jq . "$SB_SETTINGS"; sysproxy_cmd status 2>/dev/null || true; return; }
case "$target" in
cliproxy)
[[ "$action" == on || "$action" == off ]] || die 'usage: sb set cliproxy on|off'
set_setting cliproxy "$([[ "$action" == on ]] && echo true || echo false)"; info "CLI proxy ${action}"
[[ -n "${SB_SHELL_HOOK:-}" ]] || warn 'current shell needs one-time: eval "$(sb shell-init)"' ;;
sysproxy) sysproxy_cmd "$action" ;;
proxy)
[[ "$action" == on || "$action" == off ]] || die 'usage: sb set proxy on|off'
set_setting cliproxy "$([[ "$action" == on ]] && echo true || echo false)"; sysproxy_cmd "$action"; info "CLI + system proxy ${action}"
[[ -n "${SB_SHELL_HOOK:-}" ]] || warn 'current shell needs one-time: eval "$(sb shell-init)"' ;;
tun)
[[ "$action" == on || "$action" == off ]] || die 'usage: sb set tun on|off'
set_setting tun "$([[ "$action" == on ]] && echo true || echo false)"
if [[ "$action" == on ]] && ! getcap "$SB_BIN" 2>/dev/null | grep -q cap_net_admin; then warn "TUN may need: sudo setcap cap_net_admin,cap_net_raw+ep '$SB_BIN'"; fi
info "TUN ${action}"; restart_if_running ;;
rules) rules_cmd "$action" ;;
*) die 'usage: sb set cliproxy|sysproxy|proxy|tun|rules ...' ;;
esac
}
update_cmd() {
local profile meta source type dir tmp running=false
profile="$(current_profile 2>/dev/null || true)"; [[ -n "$profile" ]] || die 'no current profile'
meta="$(metadata_file "$profile")"; [[ -f "$meta" ]] || die "missing metadata: $meta"
type="$(jq -r .type "$meta")"; source="$(jq -r .source "$meta")"; [[ "$type" == http ]] || die 'current provider is a local file, not HTTP'
is_running && running=true; dir="$(profile_dir "$profile")"; tmp="${dir}/provider.json.tmp"
fetch_provider "$source" "$tmp"; mv -f "$tmp" "${dir}/provider.json"
jq --arg t "$(date -Iseconds)" '.updated_at=$t' "$meta" >"${meta}.tmp"; mv -f "${meta}.tmp" "$meta"
info "updated HTTP provider: $profile"; [[ "$running" == true ]] && { stop_cmd; start_cmd "$profile"; }
}
mode_cmd() {
local mode="${1:-}"; [[ "$mode" == rule || "$mode" == global || "$mode" == direct ]] || die 'usage: sb mode rule|global|direct'
set_setting mode "$mode"; info "mode -> $mode"; restart_if_running
}
status_cmd() {
local profile="$(current_profile 2>/dev/null || true)" now='' delay=''
if is_running; then
now="$(api_curl "${SB_API}/proxies/Proxy" 2>/dev/null | jq -r '.now//empty' || true)"
[[ -n "$now" ]] && delay="$(delay_text "$(api_delay "$now")")"
printf 'sing-box: RUNNING pid=%s\n' "$(<"$SB_PID_FILE")"
else
echo 'sing-box: STOPPED'
fi
printf 'root: %s\nprofile: %s\nmode: %s\ntun: %s\ncliproxy: %s\nsysproxy: %s\nmixed: 127.0.0.1:%s\napi: 127.0.0.1:%s\n' \
"$SB_ROOT" "${profile:-<none>}" "$(setting mode)" "$(setting tun)" "$(setting cliproxy)" "$(setting sysproxy)" "$SB_PORT" "$SB_API_PORT"
[[ -n "$now" ]] && printf 'node: %s %s\n' "$now" "$delay"
}
check_cmd() {
local profile="${1:-}"; [[ -n "$profile" ]] || profile="$(current_profile 2>/dev/null || true)"; [[ -n "$profile" ]] || die 'no profile to check'
build_config "$profile"; "$SB_BIN" check -D "$SB_RUN_DIR" -c "$SB_CONFIG"; info "configuration OK: profile=$profile"
}
db_ports() {
echo "== listening proxy ports =="
ss -ltnp 2>/dev/null | awk -v p1=":${SB_PORT}" -v p2=":${SB_API_PORT}" 'NR==1 || index($4,p1) || index($4,p2)'
if ! port_in_use "$SB_PORT" && ! port_in_use "$SB_API_PORT"; then echo 'no sb ports listening'; fi
}
db_process() {
local pid="${1:-}"
[[ -n "$pid" ]] || { [[ -f "$SB_PID_FILE" ]] && pid="$(<"$SB_PID_FILE")" || true; }
[[ "$pid" =~ ^[0-9]+$ ]] || { echo 'no managed PID'; return 1; }
ps -p "$pid" -o pid,ppid,user,state,etime,%cpu,%mem,cmd
echo
printf 'comm: '; cat "/proc/${pid}/comm" 2>/dev/null || echo '<unavailable>'
printf 'cmdline: '; tr '\0' ' ' <"/proc/${pid}/cmdline" 2>/dev/null || true; echo
}
db_probe_one() {
local mode="$1" url="$2"
case "$mode" in
direct) curl -fsSIL --max-time 10 --noproxy '*' "$url" -o /dev/null ;;
proxy) curl -fsSIL --max-time 10 --proxy "http://127.0.0.1:${SB_PORT}" "$url" -o /dev/null ;;
esac
}
db_net() {
local mode="${1:-proxy}" url direct_result proxy_result
local -a urls=(
'https://www.google.com'
'https://www.cloudflare.com'
'https://www.github.com'
'https://www.youtube.com'
'https://www.baidu.com'
'https://www.bing.com'
)
case "$mode" in
proxy)
is_running || die 'sing-box is not running; proxy test unavailable'
echo "== proxy network test (127.0.0.1:${SB_PORT}) =="
for url in "${urls[@]}"; do db_probe_one proxy "$url" && proxy_result=OK || proxy_result=FAIL; printf '%-32s PROXY:%s\n' "$url" "$proxy_result"; done
;;
direct)
echo '== direct network test =='
for url in "${urls[@]}"; do db_probe_one direct "$url" && direct_result=OK || direct_result=FAIL; printf '%-32s DIRECT:%s\n' "$url" "$direct_result"; done
;;
compare)
is_running || die 'sing-box is not running; comparison needs the mixed proxy'
echo '== DIRECT vs PROXY =='
for url in "${urls[@]}"; do
db_probe_one direct "$url" && direct_result=OK || direct_result=FAIL
db_probe_one proxy "$url" && proxy_result=OK || proxy_result=FAIL
printf '%-32s DIRECT:%-4s PROXY:%-4s\n' "$url" "$direct_result" "$proxy_result"
done
;;
*) die 'usage: sb db net [proxy|direct] | sb db compare' ;;
esac
}
db_deps() {
local item path rc=0
local -a required=(jq curl ss sed sort readlink)
local -a optional=(gsettings getcap ip resolvectl tput)
echo '== required commands =='
for item in "${required[@]}"; do path="$(command -v "$item" 2>/dev/null || true)"; [[ -n "$path" ]] && printf 'OK %-12s %s\n' "$item" "$path" || { printf 'MISSING %s\n' "$item"; rc=1; }; done
echo '== optional commands =='
for item in "${optional[@]}"; do path="$(command -v "$item" 2>/dev/null || true)"; [[ -n "$path" ]] && printf 'OK %-12s %s\n' "$item" "$path" || printf 'MISSING %s\n' "$item"; done
[[ -x "$SB_BIN" ]] && echo "OK sing-box $SB_BIN" || { echo "MISSING sing-box $SB_BIN"; rc=1; }
jq -e . "$SB_SYSTEM" "$SB_SETTINGS" >/dev/null && echo 'OK system/settings JSON' || rc=1
return "$rc"
}
db_interfaces() {
echo '== interfaces =='
if command -v ip >/dev/null 2>&1; then ip -brief link show; else echo 'ip command not found'; fi
echo
echo '== TUN expectation =='
printf 'setting: %s\n' "$(setting tun)"
if command -v ip >/dev/null 2>&1; then ip -brief link show | awk '$1 ~ /^(tun|sing|utun)/ {print}' || true; fi
}
db_dns() {
echo '== sb DNS configuration =='
jq .dns "$SB_SYSTEM"
echo
echo '== host resolver =='
if command -v resolvectl >/dev/null 2>&1; then resolvectl status 2>/dev/null | sed -n '1,140p'; else cat /etc/resolv.conf; fi
}
db_routes() {
if ! command -v ip >/dev/null 2>&1; then echo 'ip command not found'; return 1; fi
echo '== routes =='; ip route show
echo; echo '== policy rules =='; ip rule show
echo; echo '== IPv6 routes =='; ip -6 route show 2>/dev/null || true
}
db_api() {
local json
require_api
echo "== Clash API ${SB_API} =="
api_curl "${SB_API}/version" | jq .
json="$(api_curl "${SB_API}/proxies")"
echo; echo '== selector groups =='
jq -r '.proxies | to_entries[] | select(.value.all != null) | "\(.key) -> \(.value.now // "-") (\(.value.all|length) choices)"' <<<"$json"
}
db_config() {
local profile
profile="$(current_profile 2>/dev/null || true)"
[[ -n "$profile" ]] || die 'no current profile'
check_cmd "$profile"
jq '{log,inbounds,outbounds:(.outbounds|map({type,tag})),dns,route:{final:.route.final,rule_count:(.route.rules|length),rule_set_count:(.route.rule_set//[]|length)},experimental}' "$SB_CONFIG"
}
db_log() {
local lines="${1:-80}"
[[ "$lines" =~ ^[0-9]+$ ]] || die 'usage: sb db log [lines]'
[[ -f "$SB_LOG" ]] && tail -n "$lines" "$SB_LOG" || echo 'no log file'
}
db_menu() {
local pick i
local -a commands=(ports process net compare deps interfaces dns routes api config log)
local -a descriptions=(
'proxy/API port listeners'
'managed process details'
'proxy-only network test'
'direct versus proxy network test'
'required and optional dependencies'
'network interfaces and TUN expectation'
'sing-box DNS and host resolver'
'routes and policy routing'
'Clash API and selector groups'
'merged configuration summary and check'
'recent sing-box log'
)
echo '== sb db =='
for i in "${!commands[@]}"; do printf ' %2d) %-12s %s\n' "$((i+1))" "${commands[$i]}" "${descriptions[$i]}"; done
[[ -t 0 ]] || return 0
while true; do
read -r -p "Choose debug action (1-${#commands[@]}, 0 cancel): " pick
[[ "$pick" =~ ^[0-9]+$ ]] || { echo 'Please enter a number.'; continue; }
(( pick==0 )) && { info cancelled; return 0; }
(( pick>=1 && pick<=${#commands[@]} )) || { echo 'Out of range.'; continue; }
db_cmd "${commands[$((pick-1))]}"; return
done
}
db_cmd() {
local action="${1:-menu}"; shift || true
case "$action" in
menu) db_menu;; ports) db_ports;; process) db_process "${1:-}";;
net) db_net "${1:-proxy}";; compare) db_net compare;; deps) db_deps;;
interfaces|ifaces) db_interfaces;; dns) db_dns;; routes|route) db_routes;;
api) db_api;; config) db_config;; log) db_log "${1:-80}";;
help|-h|--help) db_menu;;
*) die 'usage: sb db [ports|process|net|compare|deps|interfaces|dns|routes|api|config|log]' ;;
esac
}
doctor_cmd() {
echo '== sb doctor =='; status_cmd; echo; "$SB_BIN" version | sed -n '1,2p'; getcap "$SB_BIN" 2>/dev/null || true
echo; echo '== profiles =='; list_profiles || true; echo; echo '== config =='; check_cmd
echo; echo '== log =='; [[ -f "$SB_LOG" ]] && tail -n 30 "$SB_LOG" || echo 'no log'
}
help_cmd() {
local profile="$(current_profile 2>/dev/null || true)" count="$(list_profiles | wc -l)"
cat <<EOF
sb - sing-box profile/provider CLI
add <name> <url|file> Add a sing-box JSON provider
start [name] Choose and start a provider profile
stop | end Stop managed sing-box
update Update current HTTP provider
-l, --list [keyword] Speed-test, sort, show, choose nodes
mode rule|global|direct Set route mode and restart
set cliproxy on|off Terminal proxy state (shell hook)
set sysproxy on|off GNOME system proxy
set proxy on|off Both terminal and system proxy
set tun on|off TUN and restart
set rules show|update|reset Manage root bypass.list
status | check [name] Status / merged config check
shell-init Print Bash integration
env | noenv Print exports / unsets
db [action] Debug menu: ports/process/net/DNS/routes/API/config
log | doctor Log / diagnostics
-h, --help Help plus concise configuration
root: $SB_ROOT
profiles: $count current: ${profile:-<none>}
mode: $(setting mode) tun: $(setting tun) cliproxy: $(setting cliproxy) sysproxy: $(setting sysproxy)
mixed: 127.0.0.1:$SB_PORT api: 127.0.0.1:$SB_API_PORT
EOF
}
init_layout
case "${1:-status}" in
add) shift; add_cmd "$@";; start) shift; start_cmd "${1:-}";; stop|end) stop_cmd;; update) update_cmd;;
-l|--list|list) shift; list_nodes_cmd "${1:-}";; mode) shift; mode_cmd "${1:-}";; set) shift; set_cmd "$@";;
status) status_cmd;; check) shift; check_cmd "${1:-}";; env) show_env;; noenv) show_noenv;; shell-init) shell_init_cmd;;
db) shift; db_cmd "$@";;
log) touch "$SB_LOG"; tail -n 80 -f "$SB_LOG";; doctor) doctor_cmd;; -h|--help|help) help_cmd;;
*) help_cmd >&2; exit 2;;
esac

515
src/application/config.rs Normal file
View file

@ -0,0 +1,515 @@
use std::{
fs, io,
path::{Path, PathBuf},
process::{Command, Stdio},
thread,
time::{Duration, Instant},
};
use serde_json::{Map, Value, json};
use crate::{
domain::{ActiveConfig, CoreDescriptor, ProfilesIndex, RuntimeConfig, Settings, load_manifest},
platform::{AppPaths, atomic_write_private},
};
const CHECK_TIMEOUT: Duration = Duration::from_secs(10);
#[derive(Debug, Clone)]
pub struct BuiltConfig {
pub core: CoreDescriptor,
pub profile_name: String,
pub config_path: PathBuf,
pub workdir: PathBuf,
}
pub fn build(paths: &AppPaths) -> Result<BuiltConfig, io::Error> {
let active = ActiveConfig::load(&paths.active_file())?;
if active.current.core.is_empty() {
return Err(invalid(
"未选择 core请先运行 `tz core list` 或 `tz core use <name>`",
));
}
let core_dir = paths.cores_dir().join(&active.current.core);
let manifest = load_manifest(&core_dir)?;
let core = CoreDescriptor {
name: active.current.core.clone(),
dir: core_dir,
manifest,
};
let index = ProfilesIndex::load(&paths.profiles_file())?;
let profile_name = index
.current
.get(&core.manifest.core.family)
.ok_or_else(|| {
invalid(format!(
"未选择 {} profile请先运行 `tz profile list`",
core.manifest.core.family
))
})?
.clone();
let profile = index
.profiles
.iter()
.find(|profile| profile.name == profile_name && profile.family == core.manifest.core.family)
.ok_or_else(|| {
invalid(format!(
"上次使用的 {} profile `{profile_name}` 不可用;请运行 `tz profile list` 重新选择",
core.manifest.core.family
))
})?;
let source_path = paths.profiles_dir().join(&profile.source_file);
let source = fs::read(&source_path).map_err(|error| {
io::Error::new(
error.kind(),
format!(
"上次使用的 profile `{profile_name}` 缺少 source 文件 {}: {error}",
source_path.display()
),
)
})?;
let runtime = RuntimeConfig::load(&paths.runtime_file())?;
let settings = Settings::load(&paths.settings_file())?;
let bypass = load_bypass(paths, &settings)?;
let generated = match core.manifest.core.family.as_str() {
"clash" => build_clash(&source, &runtime, &settings, &active, &bypass)?,
"sing-box" => build_sing_box(&source, &runtime, &settings, &active, &bypass)?,
family => return Err(invalid(format!("不支持的 core family `{family}`"))),
};
let generated_dir = paths.generated_dir().join(&core.name);
let workdir = paths.core_workdir(&core.name);
fs::create_dir_all(&generated_dir)?;
fs::create_dir_all(&workdir)?;
if core.manifest.core.family == "clash" && requires_clash_geodata(&generated)? {
install_clash_geodata(&core, &workdir)?;
}
let config_path = generated_dir.join(&core.manifest.runtime.entrypoint);
atomic_write_private(&config_path, &generated)?;
Ok(BuiltConfig {
core,
profile_name,
config_path,
workdir,
})
}
pub fn check(paths: &AppPaths) -> Result<BuiltConfig, io::Error> {
let built = build(paths)?;
let command = built
.core
.manifest
.commands
.check
.as_ref()
.ok_or_else(|| invalid(format!("core `{}` 未声明 check 命令", built.core.name)))?;
let args = built
.core
.render_args(&command.args, &built.config_path, &built.workdir);
let mut child = Command::new(built.core.binary_path())
.args(args)
.current_dir(&built.workdir)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
let started = Instant::now();
loop {
if let Some(status) = child.try_wait()? {
let output = child.wait_with_output()?;
if !status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_owned();
let detail = if stderr.is_empty() { stdout } else { stderr };
return Err(invalid(format!("core 配置校验失败: {detail}")));
}
return Ok(built);
}
if started.elapsed() >= CHECK_TIMEOUT {
let _ = child.kill();
let output = child.wait_with_output()?;
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_owned();
let detail = if stderr.is_empty() { stdout } else { stderr };
let message = if detail.is_empty() {
"core 配置校验超时10s".into()
} else {
format!("core 配置校验超时10s: {detail}")
};
return Err(io::Error::new(io::ErrorKind::TimedOut, message));
}
thread::sleep(Duration::from_millis(20));
}
}
fn requires_clash_geodata(generated: &[u8]) -> Result<bool, io::Error> {
let root: Value = serde_yaml::from_slice(generated).map_err(parse_error)?;
Ok(contains_geo_reference(&root))
}
fn contains_geo_reference(value: &Value) -> bool {
match value {
Value::String(value) => {
let value = value.to_ascii_uppercase();
value.contains("GEOIP") || value.contains("GEOSITE")
}
Value::Array(values) => values.iter().any(contains_geo_reference),
Value::Object(values) => values.iter().any(|(key, value)| {
let key = key.to_ascii_uppercase();
key.contains("GEOIP") || key.contains("GEOSITE") || contains_geo_reference(value)
}),
_ => false,
}
}
fn install_clash_geodata(core: &CoreDescriptor, workdir: &Path) -> Result<(), io::Error> {
for name in ["Country.mmdb", "GeoSite.dat"] {
let target = workdir.join(name);
if target.is_file() {
continue;
}
let source = core.dir.join(name);
if !source.is_file() {
return Err(io::Error::new(
io::ErrorKind::NotFound,
format!(
"当前配置需要 Mihomo Geo 数据,但 core 包缺少 `{name}`;请使用带 Geo 数据的 Mihomo core 包,或先打开其他代理后补齐该文件"
),
));
}
copy_runtime_asset(&source, &target)?;
}
Ok(())
}
fn copy_runtime_asset(source: &Path, target: &Path) -> Result<(), io::Error> {
let temporary = target.with_file_name(format!(
".{}.{}.tmp",
target
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("asset"),
std::process::id()
));
fs::copy(source, &temporary)?;
if let Err(error) = fs::rename(&temporary, target) {
let _ = fs::remove_file(&temporary);
return Err(error);
}
Ok(())
}
fn build_clash(
source: &[u8],
runtime: &RuntimeConfig,
settings: &Settings,
active: &ActiveConfig,
bypass: &[String],
) -> Result<Vec<u8>, io::Error> {
let mut root: Value = serde_yaml::from_slice(source).map_err(parse_error)?;
let object = object_mut(&mut root)?;
object.insert("mixed-port".into(), json!(runtime.proxy.mixed_port));
object.insert("port".into(), json!(runtime.proxy.http_port));
object.insert("socks-port".into(), json!(runtime.proxy.socks_port));
object.insert("allow-lan".into(), json!(runtime.proxy.allow_lan));
object.insert("bind-address".into(), json!(runtime.proxy.listen));
object.insert("mode".into(), json!(runtime.proxy.mode));
object.insert("ipv6".into(), json!(runtime.proxy.ipv6));
object.insert(
"log-level".into(),
json!(match settings.log.level.as_str() {
// TZ uses the cross-core spelling accepted by sing-box.
"warn" => "warning",
level => level,
}),
);
if runtime.api.enabled {
object.insert(
"external-controller".into(),
json!(format!("{}:{}", runtime.api.listen, runtime.api.port)),
);
} else {
object.remove("external-controller");
}
object.insert(
"tun".into(),
json!({
"enable": active.tun.enabled,
"stack": runtime.tun.stack,
"auto-route": runtime.tun.auto_route,
"auto-detect-interface": runtime.tun.auto_detect_interface,
"dns-hijack": if runtime.tun.dns_hijack { json!(["any:53"]) } else { json!([]) },
}),
);
if runtime.dns.enabled {
let dns = object.entry("dns").or_insert_with(|| json!({}));
let dns = object_mut(dns)?;
dns.insert("enable".into(), json!(true));
dns.insert(
"listen".into(),
json!(format!("{}:{}", runtime.dns.listen, runtime.dns.port)),
);
dns.insert("ipv6".into(), json!(runtime.dns.ipv6));
dns.entry("nameserver")
.or_insert_with(|| json!(["223.5.5.5", "119.29.29.29"]));
} else {
object.remove("dns");
}
let proxy_names: Vec<_> = object
.get("proxies")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|proxy| proxy.get("name").and_then(Value::as_str))
.map(str::to_owned)
.collect();
if object
.get("proxy-groups")
.and_then(Value::as_array)
.is_none_or(Vec::is_empty)
{
let mut choices = proxy_names;
choices.push("DIRECT".into());
object.insert(
"proxy-groups".into(),
json!([{"name":"Proxy", "type":"select", "proxies":choices}]),
);
}
let mut rules: Vec<Value> = bypass
.iter()
.map(|item| Value::String(clash_bypass_rule(item)))
.collect();
if let Some(existing) = object.get("rules").and_then(Value::as_array) {
rules.extend(existing.iter().cloned());
}
if rules
.iter()
.all(|rule| !rule.as_str().is_some_and(|rule| rule.starts_with("MATCH,")))
{
rules.push(json!("MATCH,Proxy"));
}
object.insert("rules".into(), Value::Array(rules));
serde_yaml::to_string(&root)
.map(String::into_bytes)
.map_err(parse_error)
}
fn build_sing_box(
source: &[u8],
runtime: &RuntimeConfig,
settings: &Settings,
active: &ActiveConfig,
bypass: &[String],
) -> Result<Vec<u8>, io::Error> {
let mut root: Value = serde_json::from_slice(source).map_err(parse_error)?;
let object = object_mut(&mut root)?;
object.insert("log".into(), json!({"level": settings.log.level}));
let mut outbounds = object
.remove("outbounds")
.and_then(|value| value.as_array().cloned())
.unwrap_or_default();
let mut tags: Vec<String> = outbounds
.iter()
.filter_map(|item| item.get("tag").and_then(Value::as_str).map(str::to_owned))
.collect();
if !tags.iter().any(|tag| tag == "DIRECT") {
outbounds.push(json!({"type":"direct", "tag":"DIRECT"}));
tags.push("DIRECT".into());
}
if !tags.iter().any(|tag| tag == "Proxy") {
let choices: Vec<_> = outbounds
.iter()
.filter(|item| {
!matches!(
item.get("type").and_then(Value::as_str),
Some("direct" | "block" | "dns" | "selector")
)
})
.filter_map(|item| item.get("tag").and_then(Value::as_str))
.collect();
outbounds.push(json!({"type":"selector", "tag":"Proxy", "outbounds":choices}));
}
object.insert("outbounds".into(), Value::Array(outbounds));
let mut inbounds = vec![json!({
"type":"mixed", "tag":"mixed-in", "listen":runtime.proxy.listen,
"listen_port":runtime.proxy.mixed_port,
})];
if active.tun.enabled {
inbounds.push(json!({
"type":"tun", "tag":"tun-in", "address":["172.19.0.1/30", "fdfe:dcba:9876::1/126"],
"auto_route":runtime.tun.auto_route,
}));
}
object.insert("inbounds".into(), Value::Array(inbounds));
let route = object.entry("route").or_insert_with(|| json!({}));
let route = object_mut(route)?;
route.insert(
"auto_detect_interface".into(),
json!(runtime.tun.auto_detect_interface),
);
route.insert(
"final".into(),
json!(match runtime.proxy.mode.as_str() {
"direct" => "DIRECT",
_ => "Proxy",
}),
);
let mut rules = route
.remove("rules")
.and_then(|value| value.as_array().cloned())
.unwrap_or_default();
let mut system_rules: Vec<_> = bypass
.iter()
.map(|item| sing_box_bypass_rule(item))
.collect();
system_rules.append(&mut rules);
route.insert("rules".into(), Value::Array(system_rules));
if runtime.api.enabled {
let experimental = object.entry("experimental").or_insert_with(|| json!({}));
let experimental = object_mut(experimental)?;
experimental.insert(
"clash_api".into(),
json!({
"external_controller": format!("{}:{}", runtime.api.listen, runtime.api.port)
}),
);
}
serde_json::to_vec_pretty(&root).map_err(parse_error)
}
fn load_bypass(paths: &AppPaths, settings: &Settings) -> Result<Vec<String>, io::Error> {
if !settings.bypass.enabled {
return Ok(Vec::new());
}
let mut items = settings.bypass.inline.clone();
let content = fs::read_to_string(paths.bypass_file()).unwrap_or_default();
items.extend(content.lines().filter_map(|line| {
let value = line.split('#').next().unwrap_or_default().trim();
(!value.is_empty()).then(|| value.to_owned())
}));
items.sort();
items.dedup();
Ok(items)
}
fn clash_bypass_rule(item: &str) -> String {
if item.contains('/') {
format!("IP-CIDR,{item},DIRECT,no-resolve")
} else if item.starts_with("*.") || item.starts_with('.') {
format!(
"DOMAIN-SUFFIX,{},DIRECT",
item.trim_start_matches("*.").trim_start_matches('.')
)
} else {
format!("DOMAIN,{item},DIRECT")
}
}
fn sing_box_bypass_rule(item: &str) -> Value {
if item.contains('/') {
json!({"ip_cidr":[item], "action":"route", "outbound":"DIRECT"})
} else if item.starts_with("*.") || item.starts_with('.') {
json!({"domain_suffix":[item.trim_start_matches("*.").trim_start_matches('.')], "action":"route", "outbound":"DIRECT"})
} else {
json!({"domain":[item], "action":"route", "outbound":"DIRECT"})
}
}
fn object_mut(value: &mut Value) -> Result<&mut Map<String, Value>, io::Error> {
value
.as_object_mut()
.ok_or_else(|| invalid("配置根节点必须是 object"))
}
fn parse_error(error: impl std::fmt::Display) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, error.to_string())
}
fn invalid(message: impl Into<String>) -> io::Error {
io::Error::new(io::ErrorKind::InvalidInput, message.into())
}
#[cfg(test)]
mod tests {
use serde_json::Value;
use super::{build_clash, build_sing_box, requires_clash_geodata};
use crate::domain::{ActiveConfig, RuntimeConfig, Settings};
#[test]
fn clash_maps_cross_core_warning_level() {
let source = br#"
proxies:
- name: test
type: socks5
server: 127.0.0.1
port: 1080
"#;
let generated = build_clash(
source,
&RuntimeConfig::default(),
&Settings::default(),
&ActiveConfig::default(),
&[],
)
.unwrap();
let root: Value = serde_yaml::from_slice(&generated).unwrap();
assert_eq!(root["log-level"], "warning");
assert_eq!(root["proxy-groups"][0]["name"], "Proxy");
assert_eq!(
root["rules"].as_array().unwrap().last().unwrap(),
"MATCH,Proxy"
);
}
#[test]
fn detects_clash_geo_resource_references() {
assert!(requires_clash_geodata(b"rules:\n - GEOIP,CN,DIRECT\n").unwrap());
assert!(
requires_clash_geodata(b"dns:\n nameserver-policy:\n geosite:cn: 223.5.5.5\n")
.unwrap()
);
assert!(!requires_clash_geodata(b"rules:\n - MATCH,Proxy\n").unwrap());
}
#[test]
fn sing_box_builds_clash_api_and_selector() {
let source = br#"{
"outbounds": [
{"type":"socks", "tag":"test", "server":"127.0.0.1", "server_port":1080}
]
}"#;
let mut active = ActiveConfig::default();
active.tun.enabled = true;
let generated = build_sing_box(
source,
&RuntimeConfig::default(),
&Settings::default(),
&active,
&[],
)
.unwrap();
let root: Value = serde_json::from_slice(&generated).unwrap();
assert_eq!(root["log"]["level"], "warn");
assert_eq!(root["inbounds"][0]["type"], "mixed");
assert_eq!(root["inbounds"][1]["type"], "tun");
assert!(root["inbounds"][1].get("auto_detect_interface").is_none());
assert_eq!(root["route"]["auto_detect_interface"], true);
assert_eq!(
root["experimental"]["clash_api"]["external_controller"],
"127.0.0.1:9189"
);
assert!(
root["outbounds"]
.as_array()
.unwrap()
.iter()
.any(|item| { item["type"] == "selector" && item["tag"] == "Proxy" })
);
}
}

575
src/application/core.rs Normal file
View file

@ -0,0 +1,575 @@
use std::{
fs::{self, Metadata, Permissions},
io::{self, Read},
os::unix::fs::PermissionsExt,
path::{Path, PathBuf},
process::{Command, Stdio},
sync::atomic::{AtomicU64, Ordering},
thread,
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
};
use crate::domain::{
ActiveConfig, CoreDescriptor, CoreManifest, load_import_manifest, load_manifest,
};
use crate::platform::{AppLock, AppPaths, atomic_write, ensure_not_running};
const VERSION_TIMEOUT: Duration = Duration::from_secs(2);
const VERSION_OUTPUT_LIMIT: u64 = 64 * 1024;
static UNIQUE_COUNTER: AtomicU64 = AtomicU64::new(0);
#[derive(Debug, Clone)]
pub struct CoreInfo {
pub descriptor: CoreDescriptor,
pub version_output: Option<String>,
}
pub fn add(paths: &AppPaths, source: &Path) -> Result<CoreInfo, io::Error> {
let source_metadata = fs::symlink_metadata(source)?;
require_directory(source, &source_metadata)?;
let source = fs::canonicalize(source)?;
fs::create_dir_all(paths.cores_dir())?;
let cores_dir = fs::canonicalize(paths.cores_dir())?;
if source.starts_with(&cores_dir) {
return Err(invalid_input(format!(
"core source {} must not be inside {}",
source.display(),
cores_dir.display()
)));
}
validate_tree(&source)?;
let manifest = load_import_manifest(&source)?;
let target = cores_dir.join(&manifest.core.name);
let _lock = AppLock::acquire(&paths.lock_file())?;
match fs::symlink_metadata(&target) {
Ok(_) => {
return Err(io::Error::new(
io::ErrorKind::AlreadyExists,
format!("core `{}` already exists", manifest.core.name),
));
}
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => return Err(error),
}
let version_output = run_version_command(&source, &manifest)?;
if let Some(output) = version_output.as_deref()
&& !output.contains(&manifest.core.version)
{
return Err(invalid_input(format!(
"core version output does not contain manifest version `{}`",
manifest.core.version
)));
}
let staging = cores_dir.join(format!(
".staging-{}-{}",
manifest.core.name,
unique_suffix()
));
let cleanup = StagingGuard(staging.clone());
copy_tree(&source, &staging)?;
validate_tree(&staging)?;
load_import_manifest(&staging)?;
fs::rename(&staging, &target)?;
std::mem::forget(cleanup);
let descriptor = CoreDescriptor {
name: manifest.core.name.clone(),
dir: target,
manifest: load_manifest(&cores_dir.join(&manifest.core.name))?,
};
Ok(CoreInfo {
descriptor,
version_output,
})
}
pub fn info(paths: &AppPaths, name: Option<&str>) -> Result<CoreInfo, io::Error> {
let active = ActiveConfig::load(&paths.active_file())?;
let name = match name {
Some(name) => name,
None if !active.current.core.is_empty() => &active.current.core,
None => {
return Err(io::Error::new(
io::ErrorKind::NotFound,
"no active core is selected",
));
}
};
let dir = paths.cores_dir().join(valid_core_name(name)?);
let manifest = load_manifest(&dir).map_err(|error| map_missing_core(name, error))?;
let version_output = run_version_command(&dir, &manifest)?;
Ok(CoreInfo {
descriptor: CoreDescriptor {
name: name.to_owned(),
dir,
manifest,
},
version_output,
})
}
pub fn use_core(paths: &AppPaths, name: &str) -> Result<(), io::Error> {
let _lock = AppLock::acquire(&paths.lock_file())?;
ensure_not_running(&paths.core_pid_file())?;
let name = valid_core_name(name)?;
load_manifest(&paths.cores_dir().join(name)).map_err(|error| map_missing_core(name, error))?;
let mut active = ActiveConfig::load(&paths.active_file())?;
active.current.core = name.to_owned();
write_active(paths, &active)
}
pub fn remove(paths: &AppPaths, name: &str) -> Result<(), io::Error> {
let _lock = AppLock::acquire(&paths.lock_file())?;
ensure_not_running(&paths.core_pid_file())?;
let name = valid_core_name(name)?;
let target = paths.cores_dir().join(name);
load_manifest(&target).map_err(|error| map_missing_core(name, error))?;
let mut active = ActiveConfig::load(&paths.active_file())?;
let tombstone = paths
.cores_dir()
.join(format!(".removing-{name}-{}", unique_suffix()));
fs::rename(&target, &tombstone)?;
if active.current.core == name {
active.current.core.clear();
if let Err(error) = write_active(paths, &active) {
let _ = fs::rename(&tombstone, &target);
return Err(error);
}
}
fs::remove_dir_all(&tombstone)?;
remove_tree_if_present(&paths.generated_dir().join(name))?;
remove_tree_if_present(&paths.core_workdir(name))?;
Ok(())
}
fn write_active(paths: &AppPaths, active: &ActiveConfig) -> Result<(), io::Error> {
let content = toml::to_string_pretty(active)
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error.to_string()))?;
atomic_write(&paths.active_file(), content.as_bytes())
}
fn validate_tree(root: &Path) -> Result<(), io::Error> {
walk_tree(root, &mut |path, metadata| {
if metadata.file_type().is_symlink() {
return Err(invalid_input(format!(
"core package contains symlink: {}",
path.display()
)));
}
if !metadata.is_dir() && !metadata.is_file() {
return Err(invalid_input(format!(
"core package contains special file: {}",
path.display()
)));
}
Ok(())
})
}
fn copy_tree(source: &Path, destination: &Path) -> Result<(), io::Error> {
let metadata = fs::symlink_metadata(source)?;
require_directory(source, &metadata)?;
fs::create_dir(destination)?;
fs::set_permissions(destination, cloned_permissions(&metadata))?;
for entry in fs::read_dir(source)? {
let entry = entry?;
let source_path = entry.path();
let destination_path = destination.join(entry.file_name());
let metadata = fs::symlink_metadata(&source_path)?;
if metadata.file_type().is_symlink() {
return Err(invalid_input(format!(
"core package contains symlink: {}",
source_path.display()
)));
} else if metadata.is_dir() {
copy_tree(&source_path, &destination_path)?;
} else if metadata.is_file() {
fs::copy(&source_path, &destination_path)?;
fs::set_permissions(&destination_path, cloned_permissions(&metadata))?;
} else {
return Err(invalid_input(format!(
"core package contains special file: {}",
source_path.display()
)));
}
}
Ok(())
}
fn walk_tree(
root: &Path,
visit: &mut impl FnMut(&Path, &Metadata) -> Result<(), io::Error>,
) -> Result<(), io::Error> {
let metadata = fs::symlink_metadata(root)?;
visit(root, &metadata)?;
if metadata.is_dir() {
for entry in fs::read_dir(root)? {
walk_tree(&entry?.path(), visit)?;
}
}
Ok(())
}
fn run_version_command(dir: &Path, manifest: &CoreManifest) -> Result<Option<String>, io::Error> {
let Some(version) = &manifest.commands.version else {
return Ok(None);
};
let args = CoreDescriptor {
name: manifest.core.name.clone(),
dir: dir.to_owned(),
manifest: manifest.clone(),
}
.render_args(&version.args, Path::new(""), dir);
let mut child = Command::new(dir.join(&manifest.core.binary))
.args(args)
.current_dir(dir)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
let stdout = child.stdout.take().expect("piped stdout");
let stderr = child.stderr.take().expect("piped stderr");
let stdout_reader = thread::spawn(move || read_limited(stdout));
let stderr_reader = thread::spawn(move || read_limited(stderr));
let started = Instant::now();
let status = loop {
if let Some(status) = child.try_wait()? {
break status;
}
if started.elapsed() >= VERSION_TIMEOUT {
let _ = child.kill();
let _ = child.wait();
return Err(io::Error::new(
io::ErrorKind::TimedOut,
format!("core version command exceeded {VERSION_TIMEOUT:?}"),
));
}
thread::sleep(Duration::from_millis(10));
};
let mut bytes = stdout_reader
.join()
.map_err(|_| io::Error::other("core version stdout reader panicked"))??;
let stderr = stderr_reader
.join()
.map_err(|_| io::Error::other("core version stderr reader panicked"))??;
if !status.success() {
return Err(io::Error::other(format!(
"core version command exited with {status}"
)));
}
if bytes.len() < VERSION_OUTPUT_LIMIT as usize {
bytes.extend_from_slice(
&stderr[..stderr
.len()
.min(VERSION_OUTPUT_LIMIT as usize - bytes.len())],
);
}
Ok(Some(String::from_utf8_lossy(&bytes).trim().to_owned()))
}
fn read_limited(mut input: impl Read) -> Result<Vec<u8>, io::Error> {
let mut kept = Vec::new();
let mut buffer = [0_u8; 8192];
loop {
let read = input.read(&mut buffer)?;
if read == 0 {
break;
}
let remaining = VERSION_OUTPUT_LIMIT as usize - kept.len();
kept.extend_from_slice(&buffer[..read.min(remaining)]);
}
Ok(kept)
}
fn require_directory(path: &Path, metadata: &Metadata) -> Result<(), io::Error> {
if metadata.file_type().is_symlink() || !metadata.is_dir() {
return Err(invalid_input(format!(
"core source must be a regular directory: {}",
path.display()
)));
}
Ok(())
}
fn cloned_permissions(metadata: &Metadata) -> Permissions {
Permissions::from_mode(metadata.permissions().mode())
}
fn remove_tree_if_present(path: &Path) -> Result<(), io::Error> {
match fs::symlink_metadata(path) {
Ok(metadata) if metadata.is_dir() && !metadata.file_type().is_symlink() => {
fs::remove_dir_all(path)
}
Ok(_) => fs::remove_file(path),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(error),
}
}
fn valid_core_name(name: &str) -> Result<&str, io::Error> {
if name.is_empty()
|| !name
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
{
return Err(invalid_input(format!(
"invalid core name `{name}`; expected ASCII letters, numbers, '.', '_' or '-'"
)));
}
Ok(name)
}
fn map_missing_core(name: &str, error: io::Error) -> io::Error {
if error.kind() == io::ErrorKind::NotFound {
io::Error::new(
io::ErrorKind::NotFound,
format!("core `{name}` does not exist"),
)
} else {
error
}
}
fn invalid_input(message: String) -> io::Error {
io::Error::new(io::ErrorKind::InvalidInput, message)
}
fn unique_suffix() -> String {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let counter = UNIQUE_COUNTER.fetch_add(1, Ordering::Relaxed);
format!("{}-{nanos}-{counter}", std::process::id())
}
struct StagingGuard(PathBuf);
impl Drop for StagingGuard {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::{os::unix::fs::symlink, os::unix::net::UnixListener};
use tempfile::tempdir;
fn setup() -> (tempfile::TempDir, AppPaths) {
let root = tempdir().unwrap();
let paths = AppPaths::unified(root.path());
paths.ensure_dirs().unwrap();
ActiveConfig::default().save(&paths.active_file()).unwrap();
(root, paths)
}
fn source_core(root: &Path, source_name: &str, name: &str) -> PathBuf {
let dir = root.join(source_name);
fs::create_dir(&dir).unwrap();
let manifest = format!(
r#"schema_version = 1
[core]
name = "{name}"
family = "clash"
version = "1.0.0"
binary = "mihomo"
os = "{}"
arch = "{}"
[runtime]
entrypoint = "config.yaml"
format = "yaml"
[capabilities.config]
mixed_proxy = true
http_proxy = true
socks_proxy = true
api = true
dns = true
tun = true
[commands.start]
args = ["-f", "{{config}}"]
[commands.version]
args = ["--version"]
"#,
std::env::consts::OS,
std::env::consts::ARCH
);
fs::write(dir.join("core.toml"), manifest).unwrap();
fs::write(dir.join("mihomo"), "#!/bin/sh\necho version-1.0.0\n").unwrap();
fs::set_permissions(dir.join("mihomo"), Permissions::from_mode(0o755)).unwrap();
dir
}
#[test]
fn add_imports_from_differently_named_source_via_staging() {
let (_root, paths) = setup();
let sources = tempdir().unwrap();
let source = source_core(sources.path(), "downloaded-core", "mihomo");
let added = add(&paths, &source).unwrap();
assert_eq!(added.descriptor.name, "mihomo");
assert_eq!(added.version_output.as_deref(), Some("version-1.0.0"));
assert!(paths.cores_dir().join("mihomo/core.toml").is_file());
assert!(fs::read_dir(paths.cores_dir()).unwrap().all(|entry| {
!entry
.unwrap()
.file_name()
.to_string_lossy()
.starts_with(".staging-")
}));
}
#[test]
fn add_rejects_version_mismatch_without_creating_target() {
let (_root, paths) = setup();
let sources = tempdir().unwrap();
let source = source_core(sources.path(), "source", "mihomo");
fs::write(source.join("mihomo"), "#!/bin/sh\necho version-2.0.0\n").unwrap();
fs::set_permissions(source.join("mihomo"), Permissions::from_mode(0o755)).unwrap();
assert_eq!(
add(&paths, &source).unwrap_err().kind(),
io::ErrorKind::InvalidInput
);
assert!(!paths.cores_dir().join("mihomo").exists());
}
#[test]
fn add_rejects_symlinks_and_existing_target() {
let (_root, paths) = setup();
let sources = tempdir().unwrap();
let source = source_core(sources.path(), "source", "mihomo");
symlink(source.join("core.toml"), source.join("linked.toml")).unwrap();
assert_eq!(
add(&paths, &source).unwrap_err().kind(),
io::ErrorKind::InvalidInput
);
fs::remove_file(source.join("linked.toml")).unwrap();
add(&paths, &source).unwrap();
assert_eq!(
add(&paths, &source).unwrap_err().kind(),
io::ErrorKind::AlreadyExists
);
}
#[test]
fn add_rejects_special_files() {
let (_root, paths) = setup();
let sources = tempdir().unwrap();
let source = source_core(sources.path(), "source", "mihomo");
let _socket = UnixListener::bind(source.join("runtime.sock")).unwrap();
assert_eq!(
add(&paths, &source).unwrap_err().kind(),
io::ErrorKind::InvalidInput
);
assert!(!paths.cores_dir().join("mihomo").exists());
}
#[test]
fn use_and_remove_reject_a_running_managed_process() {
let (_root, paths) = setup();
let sources = tempdir().unwrap();
let source = source_core(sources.path(), "source", "mihomo");
add(&paths, &source).unwrap();
fs::write(paths.core_pid_file(), std::process::id().to_string()).unwrap();
assert_eq!(
use_core(&paths, "mihomo").unwrap_err().kind(),
io::ErrorKind::WouldBlock
);
assert_eq!(
remove(&paths, "mihomo").unwrap_err().kind(),
io::ErrorKind::WouldBlock
);
assert!(paths.cores_dir().join("mihomo").is_dir());
}
#[test]
fn remove_current_clears_active_and_derived_state() {
let (_root, paths) = setup();
let sources = tempdir().unwrap();
let source = source_core(sources.path(), "source", "mihomo");
add(&paths, &source).unwrap();
use_core(&paths, "mihomo").unwrap();
fs::create_dir_all(paths.generated_dir().join("mihomo")).unwrap();
fs::create_dir_all(paths.core_workdir("mihomo")).unwrap();
fs::write(paths.generated_dir().join("mihomo/config.yaml"), "old").unwrap();
remove(&paths, "mihomo").unwrap();
assert!(
ActiveConfig::load(&paths.active_file())
.unwrap()
.current
.core
.is_empty()
);
assert!(!paths.cores_dir().join("mihomo").exists());
assert!(!paths.generated_dir().join("mihomo").exists());
assert!(!paths.core_workdir("mihomo").exists());
}
#[test]
fn version_command_times_out_without_a_shell() {
let (_root, paths) = setup();
let sources = tempdir().unwrap();
let source = source_core(sources.path(), "source", "mihomo");
fs::write(source.join("mihomo"), "#!/bin/sh\nexec sleep 5\n").unwrap();
fs::set_permissions(source.join("mihomo"), Permissions::from_mode(0o755)).unwrap();
let started = Instant::now();
assert_eq!(
add(&paths, &source).unwrap_err().kind(),
io::ErrorKind::TimedOut
);
assert!(started.elapsed() < Duration::from_secs(4));
}
#[test]
fn version_arguments_are_not_shell_interpreted() {
let (_root, paths) = setup();
let sources = tempdir().unwrap();
let source = source_core(sources.path(), "source", "mihomo");
let marker = sources.path().join("should-not-exist");
let manifest_file = source.join("core.toml");
let body = fs::read_to_string(&manifest_file).unwrap().replace(
"args = [\"--version\"]",
&format!("args = [\";touch {}\"]", marker.display()),
);
fs::write(manifest_file, body).unwrap();
let result = add(&paths, &source).unwrap();
assert!(result.version_output.unwrap().contains("version-1.0.0"));
assert!(!marker.exists());
}
#[test]
fn rejects_unsafe_lookup_names() {
let (_root, paths) = setup();
assert_eq!(
use_core(&paths, "../outside").unwrap_err().kind(),
io::ErrorKind::InvalidInput
);
assert_eq!(
remove(&paths, "../outside").unwrap_err().kind(),
io::ErrorKind::InvalidInput
);
assert_eq!(
info(&paths, Some("../outside")).unwrap_err().kind(),
io::ErrorKind::InvalidInput
);
}
#[test]
fn info_defaults_to_active_core() {
let (_root, paths) = setup();
let sources = tempdir().unwrap();
let source = source_core(sources.path(), "source", "mihomo");
add(&paths, &source).unwrap();
use_core(&paths, "mihomo").unwrap();
assert_eq!(info(&paths, None).unwrap().descriptor.name, "mihomo");
}
}

247
src/application/init.rs Normal file
View file

@ -0,0 +1,247 @@
use std::{
env,
fs::{self, OpenOptions},
io::{self, Write},
path::{Path, PathBuf},
};
use crate::domain::{ActiveConfig, ProfilesIndex, RuntimeConfig, Settings};
use crate::platform::{AppPaths, LayoutFile, load_paths_file, paths_file, save_paths_file};
const PATHS_FILE_ENV: &str = "TZ_PATHS_TOML";
const BASHRC_FILE: &str = ".bashrc";
pub fn initialize() -> Result<(), Box<dyn std::error::Error>> {
let paths_file = paths_file()?;
if paths_file.is_file() {
println!("已找到路径配置:{}", paths_file.display());
match load_paths_file(&paths_file) {
Ok(current) => {
println!("当前路径:");
println!("config: {}", current.config_dir.display());
println!("data: {}", current.data_dir.display());
println!("state: {}", current.state_dir.display());
println!("cache: {}", current.cache_dir.display());
}
Err(error) => return Err(error.into()),
}
let choice = prompt("是否继续重新初始化?[y/N]: ", "")?;
if !matches!(choice.to_ascii_lowercase().as_str(), "y" | "yes") {
println!("已退出,未修改路径配置。");
return Ok(());
}
}
let layout = choose_layout()?;
let paths = AppPaths::from_layout(layout)?;
paths.initialize_files()?;
seed_default_configs(&paths)?;
save_paths_file(&paths_file, &paths)?;
println!("初始化完成。");
println!("paths: {}", paths_file.display());
println!("config: {}", paths.config_dir.display());
println!("data: {}", paths.data_dir.display());
println!("state: {}", paths.state_dir.display());
println!("cache: {}", paths.cache_dir.display());
remind_paths_environment(&paths_file)?;
Ok(())
}
/// 用 domain 默认值幂等写出 settings.toml / runtime.toml / active.toml / profiles.toml。
/// 已存在的文件一律跳过,不覆盖用户改动。
fn seed_default_configs(paths: &AppPaths) -> Result<(), io::Error> {
if !paths.settings_file().is_file() {
Settings::default().save(&paths.settings_file())?;
}
if !paths.runtime_file().is_file() {
RuntimeConfig::default().save(&paths.runtime_file())?;
}
if !paths.active_file().is_file() {
ActiveConfig::default().save(&paths.active_file())?;
}
if !paths.profiles_file().is_file() {
ProfilesIndex::default().save(&paths.profiles_file())?;
}
Ok(())
}
fn choose_layout() -> Result<LayoutFile, io::Error> {
let templates = templates()?;
println!("选择路径模板:");
for (index, (name, layout)) in templates.iter().enumerate() {
println!(" {}) {name}", index + 1);
print_layout(layout);
}
let choice = prompt("请选择 [1/2/3](默认 1: ", "1")?;
let index = choice
.parse::<usize>()
.map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "请输入 1、2 或 3"))?;
let Some((_, template)) = templates.get(index.saturating_sub(1)) else {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"请输入 1、2 或 3",
));
};
let config_dir = prompt_path("config_dir", &template.config_dir)?;
let data_dir = prompt_path("data_dir", &template.data_dir)?;
let state_dir = prompt_path("state_dir", &template.state_dir)?;
let cache_dir = prompt_path("cache_dir", &template.cache_dir)?;
Ok(LayoutFile {
config_dir,
data_dir,
state_dir,
cache_dir,
})
}
fn templates() -> Result<Vec<(&'static str, LayoutFile)>, io::Error> {
let development_root = env::current_dir()
.map_err(io::Error::other)?
.join("target/tz-dev");
Ok(vec![
(
"默认 XDG",
LayoutFile {
config_dir: "~/.config/tz".into(),
data_dir: "~/.local/share/tz".into(),
state_dir: "~/.local/state/tz".into(),
cache_dir: "~/.cache/tz".into(),
},
),
(
"默认 Unified",
LayoutFile {
config_dir: "~/.tz/config".into(),
data_dir: "~/.tz/data".into(),
state_dir: "~/.tz/state".into(),
cache_dir: "~/.tz/cache".into(),
},
),
(
"开发测试 target/tz-dev",
LayoutFile {
config_dir: development_root.join("config").display().to_string(),
data_dir: development_root.join("data").display().to_string(),
state_dir: development_root.join("state").display().to_string(),
cache_dir: development_root.join("cache").display().to_string(),
},
),
])
}
fn print_layout(layout: &LayoutFile) {
println!(" config: {}", layout.config_dir);
println!(" data: {}", layout.data_dir);
println!(" state: {}", layout.state_dir);
println!(" cache: {}", layout.cache_dir);
}
fn prompt_path(name: &str, default: &str) -> Result<String, io::Error> {
let value = prompt(&format!("{name} [{default}]: "), "")?;
if value.is_empty() {
return Ok(default.to_owned());
}
let path = PathBuf::from(&value);
if !path.is_absolute() && !value.starts_with("~/") {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("{name} 必须是绝对路径或以 ~/ 开头"),
));
}
Ok(value)
}
fn remind_paths_environment(file: &Path) -> Result<(), io::Error> {
let home = home_dir()?;
let default_file = home.join(".config/tz/paths.toml");
let export = format!("export {PATHS_FILE_ENV}={}", shell_quote(file));
println!("如需显式指定路径配置,可执行:{export}");
if file == default_file {
println!("当前 paths.toml 位于默认位置,不设置环境变量也可以使用。");
return Ok(());
}
let choice = prompt("是否将路径配置追加到 ~/.bashrc[y/N]: ", "")?;
if !matches!(choice.to_ascii_lowercase().as_str(), "y" | "yes") {
println!("未修改 ~/.bashrc新 shell 需要上述 export 才能找到此 paths.toml。");
return Ok(());
}
let bashrc = home.join(BASHRC_FILE);
let existing = fs::read_to_string(&bashrc).unwrap_or_default();
if existing
.lines()
.any(|line| line.starts_with("export TZ_PATHS_TOML="))
&& !existing.lines().any(|line| line == export)
{
println!(
"~/.bashrc 已存在不同的 TZ_PATHS_TOML未覆盖。请手动检查{}",
bashrc.display()
);
return Ok(());
}
append_unique_line(&bashrc, &export)?;
println!(
"已将环境变量追加到 {};重新打开 shell 或执行 source ~/.bashrc 后生效。",
bashrc.display()
);
Ok(())
}
fn append_unique_line(path: &Path, line: &str) -> Result<(), io::Error> {
let existing = fs::read_to_string(path).unwrap_or_default();
if existing.lines().any(|current| current == line) {
return Ok(());
}
let mut file = OpenOptions::new().create(true).append(true).open(path)?;
if !existing.is_empty() && !existing.ends_with('\n') {
writeln!(file)?;
}
writeln!(file, "{line}")?;
Ok(())
}
fn shell_quote(path: &Path) -> String {
format!("'{}'", path.to_string_lossy().replace('\'', "'\\''"))
}
fn prompt(message: &str, default: &str) -> Result<String, io::Error> {
print!("{message}");
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
let value = input.trim();
Ok(if value.is_empty() {
default.to_owned()
} else {
value.to_owned()
})
}
fn home_dir() -> Result<PathBuf, io::Error> {
env::var_os("HOME")
.filter(|home| !home.is_empty())
.map(PathBuf::from)
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "HOME 未设置或为空"))
}
#[cfg(test)]
mod tests {
use super::append_unique_line;
#[test]
fn paths_export_is_appended_once() {
let path = std::env::temp_dir().join(format!("tz-bashrc-test-{}", std::process::id()));
let line = "export TZ_PATHS_TOML='/tmp/paths.toml'";
append_unique_line(&path, line).expect("append should work");
append_unique_line(&path, line).expect("second append should work");
let content = std::fs::read_to_string(&path).expect("read fixture");
assert_eq!(content.matches(line).count(), 1);
std::fs::remove_file(path).expect("remove fixture");
}
}

14
src/application/mod.rs Normal file
View file

@ -0,0 +1,14 @@
mod config;
mod core;
mod init;
pub mod profile;
pub mod proxy;
mod service;
pub mod setting;
pub mod tun;
pub use config::{BuiltConfig, build as build_config, check as check_config};
pub use core::{CoreInfo, add as add_core, info as core_info, remove as remove_core, use_core};
pub use init::initialize;
pub use profile::{AddProfile, ProfileError, ProfileService, ProfileSummary};
pub use service::{NodeTestOptions, list, restart, start, status, stop, test_nodes};

552
src/application/profile.rs Normal file
View file

@ -0,0 +1,552 @@
use std::{error::Error, fmt, fs, io, os::unix::fs::PermissionsExt, path::Path};
use serde_json::Value as JsonValue;
use serde_yaml::Value as YamlValue;
use crate::{
domain::{ProfileEntry, ProfileOrigin, ProfileUpdate, ProfilesIndex},
platform::{AppLock, AppPaths, ProfileSource, atomic_write_private, ensure_not_running},
};
#[derive(Debug)]
pub enum ProfileError {
InvalidInput(String),
NotFound(String),
AlreadyExists(String),
Unsupported(String),
Io(io::Error),
Source(Box<dyn Error + Send + Sync>),
}
impl fmt::Display for ProfileError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidInput(message) => write!(formatter, "invalid profile: {message}"),
Self::NotFound(name) => write!(formatter, "profile `{name}` does not exist"),
Self::AlreadyExists(name) => write!(formatter, "profile `{name}` already exists"),
Self::Unsupported(message) => formatter.write_str(message),
Self::Io(error) => write!(formatter, "profile operation failed: {error}"),
Self::Source(error) => write!(formatter, "profile download failed: {error}"),
}
}
}
impl Error for ProfileError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
Self::Io(error) => Some(error),
Self::Source(error) => Some(error.as_ref()),
_ => None,
}
}
}
impl From<io::Error> for ProfileError {
fn from(error: io::Error) -> Self {
Self::Io(error)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AddProfile<'a> {
pub name: &'a str,
pub family: &'a str,
pub source: &'a str,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProfileSummary {
pub name: String,
pub family: String,
pub format: String,
pub current: bool,
}
pub struct ProfileService<'a, D> {
paths: &'a AppPaths,
downloader: &'a D,
}
impl<'a, D: ProfileSource> ProfileService<'a, D> {
pub fn new(paths: &'a AppPaths, downloader: &'a D) -> Self {
Self { paths, downloader }
}
pub fn add(&self, request: AddProfile<'_>) -> Result<ProfileEntry, ProfileError> {
validate_name(request.name)?;
let format = format_for_family(request.family)?;
let (content, origin) = if is_http_url(request.source) {
let (content, download_via) = self
.downloader
.download_with_route(request.source)
.map_err(|error| ProfileError::Source(Box::new(error)))?;
(
content,
ProfileOrigin {
kind: "remote".into(),
url: request.source.into(),
original_path: String::new(),
download_via: download_via.as_str().into(),
},
)
} else if request.source.contains("://") {
return Err(ProfileError::InvalidInput(
"remote source must use http:// or https://".into(),
));
} else {
read_local_source(request.source)?
};
validate_content(request.family, &content)?;
let _lock = AppLock::acquire(&self.paths.lock_file())?;
fs::create_dir_all(self.paths.profiles_dir())?;
fs::set_permissions(self.paths.profiles_dir(), fs::Permissions::from_mode(0o700))?;
let mut index = self.load_index()?;
if index
.profiles
.iter()
.any(|profile| profile.name == request.name)
{
return Err(ProfileError::AlreadyExists(request.name.into()));
}
let entry = ProfileEntry {
name: request.name.into(),
family: request.family.into(),
format: format.into(),
source_file: managed_relative_path(request.name, format),
origin,
update: ProfileUpdate::default(),
state: Default::default(),
};
let source_path = self.paths.profiles_dir().join(&entry.source_file);
commit_source_and_index(
&source_path,
&content,
&mut index,
|index| {
index.profiles.push(entry.clone());
},
&self.paths.profiles_file(),
)?;
invalidate_generated(self.paths)?;
Ok(entry)
}
pub fn list(&self, family: Option<&str>) -> Result<Vec<ProfileSummary>, ProfileError> {
if let Some(family) = family {
format_for_family(family)?;
}
let index = self.load_index()?;
let mut profiles: Vec<_> = index
.profiles
.iter()
.filter(|profile| family.is_none_or(|family| profile.family == family))
.map(|profile| ProfileSummary {
name: profile.name.clone(),
family: profile.family.clone(),
format: profile.format.clone(),
current: index.current.get(&profile.family) == Some(&profile.name),
})
.collect();
profiles.sort_by(|left, right| {
left.family
.cmp(&right.family)
.then(left.name.cmp(&right.name))
});
Ok(profiles)
}
pub fn info(&self, name: &str) -> Result<ProfileEntry, ProfileError> {
self.load_index()?
.profiles
.into_iter()
.find(|profile| profile.name == name)
.ok_or_else(|| ProfileError::NotFound(name.into()))
}
pub fn use_profile(&self, name: &str) -> Result<ProfileEntry, ProfileError> {
let _lock = AppLock::acquire(&self.paths.lock_file())?;
ensure_not_running(&self.paths.core_pid_file())?;
let mut index = self.load_index()?;
let profile = index
.profiles
.iter()
.find(|profile| profile.name == name)
.cloned()
.ok_or_else(|| ProfileError::NotFound(name.into()))?;
index
.current
.insert(profile.family.clone(), profile.name.clone());
save_index(&index, &self.paths.profiles_file())?;
invalidate_generated(self.paths)?;
Ok(profile)
}
pub fn update(&self, name: &str) -> Result<ProfileEntry, ProfileError> {
let snapshot = self.info(name)?;
if snapshot.origin.kind != "remote" {
return Err(ProfileError::Unsupported(format!(
"local profile `{name}` cannot be updated"
)));
}
let (content, download_via) = self
.downloader
.download_with_route(&snapshot.origin.url)
.map_err(|error| ProfileError::Source(Box::new(error)))?;
validate_content(&snapshot.family, &content)?;
let _lock = AppLock::acquire(&self.paths.lock_file())?;
ensure_not_running(&self.paths.core_pid_file())?;
let mut index = self.load_index()?;
let position = index
.profiles
.iter()
.position(|profile| profile.name == name)
.ok_or_else(|| ProfileError::NotFound(name.into()))?;
if index.profiles[position].origin.url != snapshot.origin.url {
return Err(ProfileError::InvalidInput(format!(
"profile `{name}` changed while it was being downloaded"
)));
}
index.profiles[position].origin.download_via = download_via.as_str().into();
let source_path = self
.paths
.profiles_dir()
.join(&index.profiles[position].source_file);
let previous = fs::read(&source_path)?;
atomic_write_private(&source_path, &content)?;
index.profiles[position].update.updated_at = jiff::Timestamp::now().to_string();
if let Err(error) = save_index(&index, &self.paths.profiles_file()) {
let _ = atomic_write_private(&source_path, &previous);
return Err(error);
}
invalidate_generated(self.paths)?;
Ok(index.profiles[position].clone())
}
pub fn remove(&self, name: &str) -> Result<ProfileEntry, ProfileError> {
let _lock = AppLock::acquire(&self.paths.lock_file())?;
ensure_not_running(&self.paths.core_pid_file())?;
let mut index = self.load_index()?;
let position = index
.profiles
.iter()
.position(|profile| profile.name == name)
.ok_or_else(|| ProfileError::NotFound(name.into()))?;
let removed = index.profiles.remove(position);
if index.current.get(&removed.family) == Some(&removed.name) {
index.current.remove(&removed.family);
}
let profile_dir = self.paths.profiles_dir().join(&removed.name);
let tombstone = self.paths.profiles_dir().join(format!(
".removing-{}-{}",
removed.name,
std::process::id()
));
if profile_dir.exists() {
fs::rename(&profile_dir, &tombstone)?;
}
if let Err(error) = save_index(&index, &self.paths.profiles_file()) {
if tombstone.exists() {
let _ = fs::rename(&tombstone, &profile_dir);
}
return Err(error);
}
if tombstone.exists() {
fs::remove_dir_all(&tombstone)?;
}
invalidate_generated(self.paths)?;
Ok(removed)
}
fn load_index(&self) -> Result<ProfilesIndex, ProfileError> {
if self.paths.profiles_file().is_file() {
Ok(ProfilesIndex::load(&self.paths.profiles_file())?)
} else {
Ok(ProfilesIndex::default())
}
}
}
fn invalidate_generated(paths: &AppPaths) -> Result<(), ProfileError> {
if paths.generated_dir().is_dir() {
fs::remove_dir_all(paths.generated_dir())?;
}
fs::create_dir_all(paths.generated_dir())?;
Ok(())
}
fn read_local_source(value: &str) -> Result<(Vec<u8>, ProfileOrigin), ProfileError> {
let path = Path::new(value);
let canonical = fs::canonicalize(path)?;
if !canonical.metadata()?.is_file() {
return Err(ProfileError::InvalidInput(format!(
"local source is not a regular file: {}",
canonical.display()
)));
}
let content = fs::read(&canonical)?;
Ok((
content,
ProfileOrigin {
kind: "local".into(),
url: String::new(),
original_path: canonical.to_string_lossy().into_owned(),
download_via: String::new(),
},
))
}
fn commit_source_and_index(
source_path: &Path,
content: &[u8],
index: &mut ProfilesIndex,
update: impl FnOnce(&mut ProfilesIndex),
index_path: &Path,
) -> Result<(), ProfileError> {
atomic_write_private(source_path, content)?;
update(index);
if let Err(error) = save_index(index, index_path) {
let _ = fs::remove_file(source_path);
return Err(error);
}
Ok(())
}
fn save_index(index: &ProfilesIndex, path: &Path) -> Result<(), ProfileError> {
let content = toml::to_string_pretty(index)
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error.to_string()))?;
atomic_write_private(path, content.as_bytes())?;
Ok(())
}
fn validate_name(name: &str) -> Result<(), ProfileError> {
if name.is_empty()
|| !name
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
{
return Err(ProfileError::InvalidInput(
"name must contain only ASCII letters, numbers, '.', '_' or '-'".into(),
));
}
Ok(())
}
fn format_for_family(family: &str) -> Result<&'static str, ProfileError> {
match family {
"clash" => Ok("yaml"),
"sing-box" => Ok("json"),
_ => Err(ProfileError::InvalidInput(format!(
"unsupported family `{family}`"
))),
}
}
fn managed_relative_path(name: &str, format: &str) -> String {
format!("{name}/source.{format}")
}
fn is_http_url(value: &str) -> bool {
value.starts_with("http://") || value.starts_with("https://")
}
fn validate_content(family: &str, content: &[u8]) -> Result<(), ProfileError> {
match family {
"clash" => validate_clash(content),
"sing-box" => validate_sing_box(content),
_ => Err(ProfileError::InvalidInput(format!(
"unsupported family `{family}`"
))),
}
}
fn validate_clash(content: &[u8]) -> Result<(), ProfileError> {
let value: YamlValue = serde_yaml::from_slice(content)
.map_err(|error| ProfileError::InvalidInput(format!("invalid Clash YAML: {error}")))?;
let proxies = value
.as_mapping()
.and_then(|mapping| mapping.get(YamlValue::String("proxies".into())))
.and_then(YamlValue::as_sequence);
if proxies.is_none_or(Vec::is_empty) {
return Err(ProfileError::InvalidInput(
"Clash YAML must contain at least one proxy".into(),
));
}
Ok(())
}
fn validate_sing_box(content: &[u8]) -> Result<(), ProfileError> {
let value: JsonValue = serde_json::from_slice(content)
.map_err(|error| ProfileError::InvalidInput(format!("invalid sing-box JSON: {error}")))?;
let outbounds = value.get("outbounds").and_then(JsonValue::as_array);
if outbounds.is_none_or(Vec::is_empty) {
return Err(ProfileError::InvalidInput(
"sing-box JSON must contain at least one outbound".into(),
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::{AddProfile, ProfileError, ProfileService};
use crate::{
domain::ProfilesIndex,
platform::{AppPaths, DownloadError, ProfileSource},
};
use std::{cell::RefCell, fs};
use tempfile::tempdir;
struct FakeSource {
response: RefCell<Result<Vec<u8>, DownloadError>>,
}
impl FakeSource {
fn success(content: &[u8]) -> Self {
Self {
response: RefCell::new(Ok(content.to_vec())),
}
}
}
impl ProfileSource for FakeSource {
fn download(&self, _url: &str) -> Result<Vec<u8>, DownloadError> {
self.response.borrow().clone()
}
}
fn initialized_paths(root: &std::path::Path) -> AppPaths {
let paths = AppPaths::unified(root);
paths.ensure_dirs().unwrap();
ProfilesIndex::default()
.save(&paths.profiles_file())
.unwrap();
paths
}
#[test]
fn local_add_creates_private_managed_copy_without_touching_original() {
let root = tempdir().unwrap();
let paths = initialized_paths(root.path());
let original = root.path().join("original.yaml");
fs::write(&original, "proxies:\n - name: node\n type: direct\n").unwrap();
let fake = FakeSource::success(b"unused");
let service = ProfileService::new(&paths, &fake);
let entry = service
.add(AddProfile {
name: "home",
family: "clash",
source: original.to_str().unwrap(),
})
.unwrap();
assert_eq!(
entry.origin.original_path,
original.canonicalize().unwrap().to_string_lossy()
);
let managed = paths.profiles_dir().join(entry.source_file);
assert_eq!(
fs::read_to_string(&managed).unwrap(),
fs::read_to_string(&original).unwrap()
);
fs::remove_file(managed).unwrap();
assert!(original.is_file());
}
#[test]
fn supports_family_specific_validation_and_list_use_info() {
let root = tempdir().unwrap();
let paths = initialized_paths(root.path());
let fake = FakeSource::success(br#"{"outbounds":[{"type":"direct"}]}"#);
let service = ProfileService::new(&paths, &fake);
service
.add(AddProfile {
name: "remote",
family: "sing-box",
source: "https://example.com/sub?token=secret",
})
.unwrap();
assert_eq!(service.info("remote").unwrap().format, "json");
service.use_profile("remote").unwrap();
let listed = service.list(Some("sing-box")).unwrap();
assert_eq!(listed.len(), 1);
assert!(listed[0].current);
}
#[test]
fn rejects_duplicate_invalid_and_family_mismatched_content() {
let root = tempdir().unwrap();
let paths = initialized_paths(root.path());
let fake = FakeSource::success(b"proxies:\n - {name: node}\n");
let service = ProfileService::new(&paths, &fake);
let request = AddProfile {
name: "home",
family: "clash",
source: "https://example.com/sub",
};
service.add(request.clone()).unwrap();
assert!(matches!(
service.add(request),
Err(ProfileError::AlreadyExists(_))
));
let bad = FakeSource::success(br#"{"outbounds":[]}"#);
let service = ProfileService::new(&paths, &bad);
assert!(matches!(
service.add(AddProfile {
name: "bad",
family: "sing-box",
source: "https://example.com/bad"
}),
Err(ProfileError::InvalidInput(_))
));
}
#[test]
fn failed_update_preserves_previous_managed_copy() {
let root = tempdir().unwrap();
let paths = initialized_paths(root.path());
let fake = FakeSource::success(b"proxies:\n - {name: old}\n");
let service = ProfileService::new(&paths, &fake);
let entry = service
.add(AddProfile {
name: "remote",
family: "clash",
source: "https://example.com/sub",
})
.unwrap();
let managed = paths.profiles_dir().join(&entry.source_file);
*fake.response.borrow_mut() = Ok(b"proxies: []\n".to_vec());
assert!(service.update("remote").is_err());
assert_eq!(
fs::read_to_string(managed).unwrap(),
"proxies:\n - {name: old}\n"
);
}
#[test]
fn remove_clears_current_and_only_deletes_managed_copy() {
let root = tempdir().unwrap();
let paths = initialized_paths(root.path());
let original = root.path().join("original.yaml");
fs::write(&original, "proxies:\n - {name: node}\n").unwrap();
let fake = FakeSource::success(b"unused");
let service = ProfileService::new(&paths, &fake);
let entry = service
.add(AddProfile {
name: "home",
family: "clash",
source: original.to_str().unwrap(),
})
.unwrap();
service.use_profile("home").unwrap();
service.remove("home").unwrap();
assert!(original.is_file());
assert!(!paths.profiles_dir().join(entry.source_file).exists());
let index = ProfilesIndex::load(&paths.profiles_file()).unwrap();
assert!(index.current.is_empty());
assert!(index.profiles.is_empty());
}
}

455
src/application/proxy.rs Normal file
View file

@ -0,0 +1,455 @@
use std::{
env, fs, io,
process::{Command, Output},
};
use serde::{Deserialize, Serialize};
use crate::{
domain::{ActiveConfig, RuntimeConfig, Settings, load_manifest},
platform::{AppLock, AppPaths, ManagedProcess, atomic_write_private, managed_process},
};
#[derive(Debug, Serialize, Deserialize)]
struct SystemProxyBackup {
mode: String,
http_host: String,
http_port: String,
https_host: String,
https_port: String,
socks_host: String,
socks_port: String,
ignore_hosts: String,
}
pub fn status(paths: &AppPaths) -> Result<(), io::Error> {
let active = ActiveConfig::load(&paths.active_file())?;
let runtime = RuntimeConfig::load(&paths.runtime_file())?;
let desktop = gsettings(&["get", "org.gnome.system.proxy", "mode"])
.map(|output| String::from_utf8_lossy(&output.stdout).trim().to_owned())
.unwrap_or_else(|_| "unavailable".into());
println!(
"terminal={} system={} desktop={} mixed=127.0.0.1:{}",
on_off(active.shell_proxy.enabled),
on_off(active.system_proxy.enabled),
desktop,
runtime.proxy.mixed_port
);
Ok(())
}
pub fn terminal(paths: &AppPaths, enabled: bool) -> Result<(), io::Error> {
update_active(paths, |active| active.shell_proxy.enabled = enabled)?;
println!("terminal proxy {}", on_off(enabled));
println!(
"当前 shell 请执行: eval \"$(tz proxy {})\"",
if enabled { "env" } else { "noenv" }
);
Ok(())
}
pub fn system(paths: &AppPaths, enabled: bool) -> Result<(), io::Error> {
if enabled
&& !matches!(
managed_process(&paths.core_pid_file())?,
ManagedProcess::Running(_)
)
{
return Err(io::Error::new(
io::ErrorKind::NotConnected,
"服务未运行,拒绝把桌面代理指向未监听端口",
));
}
let _lock = AppLock::acquire(&paths.lock_file())?;
let runtime = RuntimeConfig::load(&paths.runtime_file())?;
let mut active = ActiveConfig::load(&paths.active_file())?;
let (http_port, socks_port) = proxy_ports(paths, &runtime)?;
let backup_path = paths.runtime_dir().join("system-proxy-backup.json");
if !enabled && !active.system_proxy.enabled && !backup_path.is_file() {
println!("system proxy 已经是 off");
return Ok(());
}
let backup = if enabled {
if active.system_proxy.enabled {
load_backup(&backup_path)?
} else {
let backup = capture_system_proxy()?;
let content = serde_json::to_vec_pretty(&backup)
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
atomic_write_private(&backup_path, &content)?;
Some(backup)
}
} else {
load_backup(&backup_path)?
};
let applied = if enabled {
let bypass = bypass_items(paths, active.system_proxy.bypass, true)?;
let http_port = http_port.to_string();
let socks_port = socks_port.to_string();
apply_system_proxy(&http_port, &socks_port, &bypass)
} else if let Some(backup) = &backup {
restore_system_proxy(backup)
} else {
gsettings(&["set", "org.gnome.system.proxy", "mode", "none"]).map(|_| ())
};
if let Err(error) = applied {
if let Some(backup) = &backup {
let _ = restore_system_proxy(backup);
}
return Err(error);
}
active.system_proxy.enabled = enabled;
if let Err(error) = active.save(&paths.active_file()) {
if let Some(backup) = &backup {
let _ = restore_system_proxy(backup);
}
return Err(error);
}
if !enabled {
match fs::remove_file(&backup_path) {
Ok(()) => {}
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => return Err(error),
}
}
println!("system proxy {}", on_off(enabled));
Ok(())
}
pub fn both(paths: &AppPaths, enabled: bool) -> Result<(), io::Error> {
if enabled {
update_active(paths, |active| active.shell_proxy.enabled = true)?;
if let Err(error) = system(paths, true) {
let _ = update_active(paths, |active| active.shell_proxy.enabled = false);
return Err(error);
}
} else {
system(paths, false)?;
update_active(paths, |active| active.shell_proxy.enabled = false)?;
}
println!("terminal + system proxy {}", on_off(enabled));
println!(
"当前 shell 请执行: eval \"$(tz proxy {})\"",
if enabled { "env" } else { "noenv" }
);
Ok(())
}
pub fn env(paths: &AppPaths, shell: &str) -> Result<(), io::Error> {
let runtime = RuntimeConfig::load(&paths.runtime_file())?;
let active = ActiveConfig::load(&paths.active_file())?;
let bypass = bypass_items(paths, active.shell_proxy.bypass, false)?.join(",");
let (http_port, socks_port) = proxy_ports(paths, &runtime)?;
print_env(shell, http_port, socks_port, &bypass);
Ok(())
}
pub fn noenv(shell: &str) -> Result<(), io::Error> {
match shell {
"bash" | "zsh" => println!(
"unset http_proxy https_proxy all_proxy HTTP_PROXY HTTPS_PROXY ALL_PROXY NO_PROXY no_proxy"
),
"fish" => println!(
"set -e http_proxy https_proxy all_proxy HTTP_PROXY HTTPS_PROXY ALL_PROXY NO_PROXY no_proxy"
),
_ => return Err(invalid_shell(shell)),
}
Ok(())
}
pub fn shell_init(paths: &AppPaths, shell: &str) -> Result<(), io::Error> {
match shell {
"bash" | "zsh" => print!(
r#"tz() {{
command tz "$@"
local rc=$?
if [ "$rc" -eq 0 ] && [ "${{1:-}}" = proxy ]; then
case "${{2:-}}:${{3:-}}" in
on:|terminal:on) eval "$(command tz proxy env {shell})" ;;
off:|terminal:off) eval "$(command tz proxy noenv {shell})" ;;
esac
fi
return "$rc"
}}
"#
),
"fish" => print!(
r#"function tz
command tz $argv
set -l rc $status
if test $rc -eq 0; and test (count $argv) -ge 2; and test $argv[1] = proxy
if test $argv[2] = on; or test (count $argv) -ge 3; and test $argv[2] = terminal; and test $argv[3] = on
command tz proxy env fish | source
else if test $argv[2] = off; or test (count $argv) -ge 3; and test $argv[2] = terminal; and test $argv[3] = off
command tz proxy noenv fish | source
end
end
return $rc
end
"#
),
_ => return Err(invalid_shell(shell)),
}
let active = ActiveConfig::load(&paths.active_file())?;
if active.shell_proxy.enabled {
env(paths, shell)
} else {
noenv(shell)
}
}
fn print_env(shell: &str, http_port: u16, socks_port: u16, bypass: &str) {
let http = format!("http://127.0.0.1:{http_port}");
let socks = format!("socks5h://127.0.0.1:{socks_port}");
match shell {
"fish" => {
println!("set -gx http_proxy {};", fish_quote(&http));
println!("set -gx https_proxy $http_proxy;");
println!("set -gx all_proxy {};", fish_quote(&socks));
println!("set -gx HTTP_PROXY $http_proxy;");
println!("set -gx HTTPS_PROXY $https_proxy;");
println!("set -gx ALL_PROXY $all_proxy;");
println!("set -gx NO_PROXY {};", fish_quote(bypass));
println!("set -gx no_proxy $NO_PROXY;");
}
"bash" | "zsh" => {
println!("export http_proxy={}", shell_quote(&http));
println!("export https_proxy=\"$http_proxy\"");
println!("export all_proxy={}", shell_quote(&socks));
println!("export HTTP_PROXY=\"$http_proxy\"");
println!("export HTTPS_PROXY=\"$https_proxy\"");
println!("export ALL_PROXY=\"$all_proxy\"");
println!("export NO_PROXY={}", shell_quote(bypass));
println!("export no_proxy=\"$NO_PROXY\"");
}
_ => unreachable!("shell validated by CLI"),
}
}
fn proxy_ports(paths: &AppPaths, runtime: &RuntimeConfig) -> Result<(u16, u16), io::Error> {
let active = ActiveConfig::load(&paths.active_file())?;
if active.current.core.is_empty() {
return Ok((runtime.proxy.mixed_port, runtime.proxy.mixed_port));
}
let manifest = load_manifest(&paths.cores_dir().join(&active.current.core))?;
let capabilities = manifest.capabilities.config;
Ok((
if capabilities.http_proxy {
runtime.proxy.http_port
} else {
runtime.proxy.mixed_port
},
if capabilities.socks_proxy {
runtime.proxy.socks_port
} else {
runtime.proxy.mixed_port
},
))
}
fn apply_system_proxy(
http_port: &str,
socks_port: &str,
bypass: &[String],
) -> Result<(), io::Error> {
for args in [
["set", "org.gnome.system.proxy.http", "host", "127.0.0.1"],
["set", "org.gnome.system.proxy.http", "port", http_port],
["set", "org.gnome.system.proxy.https", "host", "127.0.0.1"],
["set", "org.gnome.system.proxy.https", "port", http_port],
["set", "org.gnome.system.proxy.socks", "host", "127.0.0.1"],
["set", "org.gnome.system.proxy.socks", "port", socks_port],
] {
gsettings(&args)?;
}
let value = gvariant_array(bypass);
gsettings(&["set", "org.gnome.system.proxy", "ignore-hosts", &value])?;
// Switch modes only after every dependent value is valid and applied.
gsettings(&["set", "org.gnome.system.proxy", "mode", "manual"])?;
Ok(())
}
fn capture_system_proxy() -> Result<SystemProxyBackup, io::Error> {
Ok(SystemProxyBackup {
mode: gsettings_get("org.gnome.system.proxy", "mode")?,
http_host: gsettings_get("org.gnome.system.proxy.http", "host")?,
http_port: gsettings_get("org.gnome.system.proxy.http", "port")?,
https_host: gsettings_get("org.gnome.system.proxy.https", "host")?,
https_port: gsettings_get("org.gnome.system.proxy.https", "port")?,
socks_host: gsettings_get("org.gnome.system.proxy.socks", "host")?,
socks_port: gsettings_get("org.gnome.system.proxy.socks", "port")?,
ignore_hosts: gsettings_get("org.gnome.system.proxy", "ignore-hosts")?,
})
}
fn restore_system_proxy(backup: &SystemProxyBackup) -> Result<(), io::Error> {
for args in [
[
"set",
"org.gnome.system.proxy.http",
"host",
backup.http_host.as_str(),
],
[
"set",
"org.gnome.system.proxy.http",
"port",
backup.http_port.as_str(),
],
[
"set",
"org.gnome.system.proxy.https",
"host",
backup.https_host.as_str(),
],
[
"set",
"org.gnome.system.proxy.https",
"port",
backup.https_port.as_str(),
],
[
"set",
"org.gnome.system.proxy.socks",
"host",
backup.socks_host.as_str(),
],
[
"set",
"org.gnome.system.proxy.socks",
"port",
backup.socks_port.as_str(),
],
[
"set",
"org.gnome.system.proxy",
"ignore-hosts",
backup.ignore_hosts.as_str(),
],
] {
gsettings(&args)?;
}
gsettings(&["set", "org.gnome.system.proxy", "mode", &backup.mode])?;
Ok(())
}
fn load_backup(path: &std::path::Path) -> Result<Option<SystemProxyBackup>, io::Error> {
let content = match fs::read(path) {
Ok(content) => content,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(error),
};
serde_json::from_slice(&content)
.map(Some)
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))
}
fn gsettings_get(schema: &str, key: &str) -> Result<String, io::Error> {
let output = gsettings(&["get", schema, key])?;
Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
}
fn bypass_items(
paths: &AppPaths,
enabled: bool,
gsettings_mode: bool,
) -> Result<Vec<String>, io::Error> {
if !enabled {
return Ok(Vec::new());
}
let settings = Settings::load(&paths.settings_file())?;
if !settings.bypass.enabled {
return Ok(Vec::new());
}
let mut items = settings.bypass.inline;
let content = fs::read_to_string(paths.bypass_file()).unwrap_or_default();
items.extend(content.lines().filter_map(|line| {
let value = line.split('#').next().unwrap_or_default().trim();
(!value.is_empty()).then(|| value.to_owned())
}));
for item in &mut items {
if gsettings_mode && item.starts_with('.') {
*item = format!("*{item}");
} else if !gsettings_mode && item.starts_with("*.") {
*item = format!(".{}", item.trim_start_matches("*."));
}
}
items.sort();
items.dedup();
Ok(items)
}
fn update_active(
paths: &AppPaths,
update: impl FnOnce(&mut ActiveConfig),
) -> Result<(), io::Error> {
let _lock = AppLock::acquire(&paths.lock_file())?;
let mut active = ActiveConfig::load(&paths.active_file())?;
update(&mut active);
active.save(&paths.active_file())
}
fn gsettings(args: &[&str]) -> Result<Output, io::Error> {
let command = env::var_os("TZ_GSETTINGS_BIN").unwrap_or_else(|| "gsettings".into());
let output = Command::new(command).args(args).output().map_err(|error| {
if error.kind() == io::ErrorKind::NotFound {
io::Error::new(
io::ErrorKind::Unsupported,
"当前 v0.1 system proxy 需要 GNOME gsettings",
)
} else {
error
}
})?;
if output.status.success() {
Ok(output)
} else {
Err(io::Error::other(format!(
"gsettings 执行失败: {}",
String::from_utf8_lossy(&output.stderr).trim()
)))
}
}
fn gvariant_array(items: &[String]) -> String {
let items = items
.iter()
.map(|item| format!("'{}'", item.replace('\\', "\\\\").replace('\'', "\\'")))
.collect::<Vec<_>>()
.join(", ");
format!("[{items}]")
}
fn shell_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', "'\"'\"'"))
}
fn fish_quote(value: &str) -> String {
format!("'{}'", value.replace('\\', "\\\\").replace('\'', "\\'"))
}
fn invalid_shell(shell: &str) -> io::Error {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("不支持的 shell `{shell}`"),
)
}
fn on_off(value: bool) -> &'static str {
if value { "on" } else { "off" }
}
#[cfg(test)]
mod tests {
use super::{fish_quote, gvariant_array, shell_quote};
#[test]
fn escapes_shell_and_gvariant_values() {
assert_eq!(shell_quote("a'b"), "'a'\"'\"'b'");
assert_eq!(fish_quote("a'b"), "'a\\'b'");
assert_eq!(
gvariant_array(&["localhost".into(), "*.local".into()]),
"['localhost', '*.local']"
);
}
}

742
src/application/service.rs Normal file
View file

@ -0,0 +1,742 @@
use std::{
cmp::Ordering,
fs::{self, OpenOptions},
io::{self, IsTerminal, Write},
net::{IpAddr, SocketAddr, TcpStream},
process::{Command, Stdio},
thread,
time::{Duration, Instant},
};
use reqwest::{Url, blocking::Client};
use serde_json::{Value, json};
use crate::{
application::config,
domain::{ActiveConfig, ConfigCapabilities, ProfilesIndex, RuntimeConfig, load_manifest},
platform::{
AppLock, AppPaths, ManagedProcess, atomic_write_private, ensure_not_running,
ensure_owned_process, managed_process, terminate_process,
},
};
const START_TIMEOUT: Duration = Duration::from_secs(5);
const READY_STABILITY: Duration = Duration::from_millis(300);
const PORT_CONNECT_TIMEOUT: Duration = Duration::from_millis(100);
const STOP_TIMEOUT: Duration = Duration::from_secs(5);
const DEFAULT_TEST_URL: &str = "https://www.gstatic.com/generate_204";
const DEFAULT_TEST_TIMEOUT_MS: u64 = 1800;
#[derive(Debug)]
struct NodeGroup {
name: String,
current: String,
nodes: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct NodeTestOptions<'a> {
pub keyword: Option<&'a str>,
pub url: &'a str,
pub timeout_ms: u64,
pub select: bool,
}
pub fn status(paths: &AppPaths) -> Result<(), io::Error> {
let active = ActiveConfig::load(&paths.active_file())?;
let core = if active.current.core.is_empty() {
None
} else {
let manifest = load_manifest(&paths.cores_dir().join(&active.current.core))?;
Some((active.current.core.as_str(), manifest))
};
if let Some((name, manifest)) = &core {
println!(
"core {} version={} family={}",
name, manifest.core.version, manifest.core.family
);
let profiles = ProfilesIndex::load(&paths.profiles_file())?;
let profile = profiles
.current
.get(&manifest.core.family)
.map(String::as_str)
.unwrap_or("-");
println!("profile {profile} family={}", manifest.core.family);
} else {
println!("core -");
println!("profile -");
}
println!(
"features tun={} terminal={} system={}",
on_off(active.tun.enabled),
on_off(active.shell_proxy.enabled),
on_off(active.system_proxy.enabled)
);
match managed_process(&paths.core_pid_file())? {
ManagedProcess::Running(pid) => {
println!("service running pid={pid}");
let runtime = RuntimeConfig::load(&paths.runtime_file())?;
let node = fetch_group(&runtime)
.map(|group| group.current)
.unwrap_or_default();
if node.is_empty() {
println!("node -");
} else {
match test_node_delay(&runtime, &node, DEFAULT_TEST_URL, DEFAULT_TEST_TIMEOUT_MS) {
Ok(delay) => println!("node {node} delay={delay}ms"),
Err(_) => match cached_delay(paths, &node) {
Some(delay) => println!("node {node} delay={delay}ms cached"),
None => println!("node {node} delay=timeout"),
},
}
}
}
ManagedProcess::Stale(pid) => println!("service stopped stale-pid={pid}"),
ManagedProcess::NotRunning => println!("service stopped"),
}
Ok(())
}
pub fn start(paths: &AppPaths) -> Result<(), io::Error> {
let _lock = AppLock::acquire(&paths.lock_file())?;
ensure_not_running(&paths.core_pid_file())?;
remove_stale_pid(paths)?;
let built = config::check(paths)?;
fs::create_dir_all(paths.logs_dir())?;
let log = OpenOptions::new()
.create(true)
.append(true)
.open(paths.core_log_file())?;
let stderr = log.try_clone()?;
let args = built.core.render_args(
&built.core.manifest.commands.start.args,
&built.config_path,
&built.workdir,
);
let mut child = Command::new(built.core.binary_path())
.args(args)
.current_dir(&built.workdir)
.stdin(Stdio::null())
.stdout(Stdio::from(log))
.stderr(Stdio::from(stderr))
.spawn()?;
let pid = i32::try_from(child.id()).map_err(|_| io::Error::other("core PID 超出范围"))?;
atomic_write_private(&paths.core_pid_file(), format!("{pid}\n").as_bytes())?;
let runtime = RuntimeConfig::load(&paths.runtime_file())?;
let started = Instant::now();
let mut ready_since = None;
loop {
if let Some(status) = child.try_wait()? {
let _ = fs::remove_file(paths.core_pid_file());
return Err(io::Error::other(format!(
"core 启动后立即退出({status}),请查看 {}",
paths.core_log_file().display()
)));
}
let api_ready = !runtime.api.enabled || fetch_group(&runtime).is_ok();
let ports_ready = proxy_ports_ready(&runtime, &built.core.manifest.capabilities.config);
if api_ready && ports_ready {
let stable_since = ready_since.get_or_insert_with(Instant::now);
if stable_since.elapsed() < READY_STABILITY {
thread::sleep(Duration::from_millis(100));
continue;
}
if runtime.api.enabled
&& let Err(error) = restore_selected_nodes(paths, &runtime, &built.profile_name)
{
eprintln!("恢复节点选择失败: {error}");
}
println!(
"已启动 {} profile={} pid={pid}",
built.core.name, built.profile_name
);
return Ok(());
} else {
ready_since = None;
}
if started.elapsed() >= START_TIMEOUT {
let _ = terminate_process(pid, false);
let _ = child.wait();
let _ = fs::remove_file(paths.core_pid_file());
return Err(io::Error::new(
io::ErrorKind::TimedOut,
format!(
"core API 或代理端口启动超时,请查看 {}",
paths.core_log_file().display()
),
));
}
thread::sleep(Duration::from_millis(100));
}
}
fn proxy_ports_ready(runtime: &RuntimeConfig, capabilities: &ConfigCapabilities) -> bool {
let host = match runtime.proxy.listen.as_str() {
"0.0.0.0" => IpAddr::V4(std::net::Ipv4Addr::LOCALHOST),
"::" => IpAddr::V6(std::net::Ipv6Addr::LOCALHOST),
value => match value.parse() {
Ok(address) => address,
Err(_) => return false,
},
};
required_proxy_ports(runtime, capabilities)
.into_iter()
.all(|port| {
TcpStream::connect_timeout(&SocketAddr::new(host, port), PORT_CONNECT_TIMEOUT).is_ok()
})
}
fn required_proxy_ports(runtime: &RuntimeConfig, capabilities: &ConfigCapabilities) -> Vec<u16> {
let mut ports = Vec::with_capacity(3);
if capabilities.mixed_proxy {
ports.push(runtime.proxy.mixed_port);
}
if capabilities.http_proxy {
ports.push(runtime.proxy.http_port);
}
if capabilities.socks_proxy {
ports.push(runtime.proxy.socks_port);
}
ports
}
pub fn stop(paths: &AppPaths) -> Result<(), io::Error> {
let _lock = AppLock::acquire(&paths.lock_file())?;
let active = ActiveConfig::load(&paths.active_file())?;
let state = managed_process(&paths.core_pid_file())?;
let pid = match state {
ManagedProcess::NotRunning => {
println!("服务未运行");
return Ok(());
}
ManagedProcess::Stale(_) => {
fs::remove_file(paths.core_pid_file())?;
println!("服务未运行,已清理陈旧 PID");
return Ok(());
}
ManagedProcess::Running(pid) => pid,
};
if active.current.core.is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"存在运行 PID但 active.toml 没有当前 core拒绝停止",
));
}
let manifest = load_manifest(&paths.cores_dir().join(&active.current.core))?;
ensure_owned_process(
pid,
&paths
.cores_dir()
.join(&active.current.core)
.join(&manifest.core.binary),
)?;
terminate_process(pid, false)?;
let started = Instant::now();
while crate::platform::process::is_process_alive(pid) {
if started.elapsed() >= STOP_TIMEOUT {
ensure_owned_process(
pid,
&paths
.cores_dir()
.join(&active.current.core)
.join(&manifest.core.binary),
)?;
terminate_process(pid, true)?;
break;
}
thread::sleep(Duration::from_millis(50));
}
fs::remove_file(paths.core_pid_file())?;
println!("已停止 {}", active.current.core);
Ok(())
}
pub fn restart(paths: &AppPaths) -> Result<(), io::Error> {
stop(paths)?;
start(paths)
}
pub fn list(paths: &AppPaths, keyword: Option<&str>) -> Result<(), io::Error> {
if !matches!(
managed_process(&paths.core_pid_file())?,
ManagedProcess::Running(_)
) {
return Err(io::Error::new(
io::ErrorKind::NotConnected,
"服务未运行,请先执行 `tz start`",
));
}
let runtime = RuntimeConfig::load(&paths.runtime_file())?;
let group = fetch_group(&runtime)?;
let needle = keyword.unwrap_or_default().to_lowercase();
let nodes: Vec<_> = group
.nodes
.iter()
.filter(|name| needle.is_empty() || name.to_lowercase().contains(&needle))
.cloned()
.collect();
if nodes.is_empty() {
println!("没有匹配的节点");
return Ok(());
}
let results = measure_node_delays(&runtime, &nodes, DEFAULT_TEST_URL, DEFAULT_TEST_TIMEOUT_MS);
save_speedtest(paths, DEFAULT_TEST_URL, DEFAULT_TEST_TIMEOUT_MS, &results)?;
for (index, (node, delay)) in results.iter().enumerate() {
let marker = if *node == group.current { "*" } else { " " };
let delay = delay.map_or_else(|| "timeout".into(), |delay| format!("{delay}ms"));
if io::stdin().is_terminal() {
println!("{marker} {}) {node} {delay}", index + 1);
} else {
println!("{marker} {node} {delay}");
}
}
if io::stdin().is_terminal() {
print!("选择节点0 保持当前): ");
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
let selected = input
.trim()
.parse::<usize>()
.map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "请输入列表中的数字"))?;
if selected > results.len() {
return Err(io::Error::new(io::ErrorKind::InvalidInput, "选择超出范围"));
}
if selected > 0 {
let node = &results[selected - 1].0;
select_node(&runtime, &group.name, node)?;
persist_selected_node(paths, &group.name, node)?;
println!("当前节点: {node}");
}
}
Ok(())
}
pub fn test_nodes(paths: &AppPaths, options: NodeTestOptions<'_>) -> Result<(), io::Error> {
if !matches!(
managed_process(&paths.core_pid_file())?,
ManagedProcess::Running(_)
) {
return Err(io::Error::new(
io::ErrorKind::NotConnected,
"服务未运行,请先执行 `tz start`",
));
}
let test_url = Url::parse(options.url)
.map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?;
if !matches!(test_url.scheme(), "http" | "https")
|| !test_url.username().is_empty()
|| test_url.password().is_some()
{
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"测速 URL 必须是无凭据的 HTTP(S) URL",
));
}
if !(100..=60_000).contains(&options.timeout_ms) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"timeout 必须在 100..=60000 ms",
));
}
let runtime = RuntimeConfig::load(&paths.runtime_file())?;
let group = fetch_group(&runtime)?;
let needle = options.keyword.unwrap_or_default().to_lowercase();
let nodes: Vec<_> = group
.nodes
.iter()
.filter(|node| needle.is_empty() || node.to_lowercase().contains(&needle))
.cloned()
.collect();
if nodes.is_empty() {
return Err(io::Error::new(io::ErrorKind::NotFound, "没有匹配的节点"));
}
let results = measure_node_delays(&runtime, &nodes, options.url, options.timeout_ms);
for (node, delay) in &results {
let marker = if *node == group.current { "*" } else { " " };
match delay {
Some(delay) => println!("{marker} {node} {delay}ms"),
None => println!("{marker} {node} timeout"),
}
}
save_speedtest(paths, options.url, options.timeout_ms, &results)?;
if options.select {
let fastest = results
.iter()
.find_map(|(node, delay)| delay.map(|delay| (node, delay)))
.ok_or_else(|| io::Error::new(io::ErrorKind::TimedOut, "全部节点测速超时"))?;
select_node(&runtime, &group.name, fastest.0)?;
persist_selected_node(paths, &group.name, fastest.0)?;
println!("当前节点: {} {}ms", fastest.0, fastest.1);
}
Ok(())
}
fn measure_node_delays(
runtime: &RuntimeConfig,
nodes: &[String],
url: &str,
timeout_ms: u64,
) -> Vec<(String, Option<u64>)> {
let mut results = Vec::with_capacity(nodes.len());
for chunk in nodes.chunks(8) {
thread::scope(|scope| {
let handles: Vec<_> = chunk
.iter()
.map(|node| {
let node = node.clone();
scope.spawn(move || {
let delay = test_node_delay(runtime, &node, url, timeout_ms).ok();
(node, delay)
})
})
.collect();
for (node, handle) in chunk.iter().zip(handles) {
results.push(handle.join().unwrap_or_else(|_| (node.clone(), None)));
}
});
}
sort_node_delays(&mut results);
results
}
fn sort_node_delays(results: &mut [(String, Option<u64>)]) {
results.sort_by(|left, right| match (left.1, right.1) {
(Some(a), Some(b)) => a.cmp(&b).then_with(|| left.0.cmp(&right.0)),
(Some(_), None) => Ordering::Less,
(None, Some(_)) => Ordering::Greater,
(None, None) => left.0.cmp(&right.0),
});
}
fn fetch_group(runtime: &RuntimeConfig) -> Result<NodeGroup, io::Error> {
if !runtime.api.enabled {
return Err(io::Error::new(
io::ErrorKind::Unsupported,
"core API 未启用",
));
}
let response = api_client()?
.get(api_url(runtime, "proxies")?)
.send()
.map_err(http_error)?
.error_for_status()
.map_err(http_error)?;
let root: Value = serde_json::from_str(&response.text().map_err(http_error)?)
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
let proxies = root
.get("proxies")
.and_then(Value::as_object)
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "core API 缺少 proxies"))?;
let selected = proxies
.get("Proxy")
.filter(|value| value.get("all").and_then(Value::as_array).is_some())
.map(|value| ("Proxy", value))
.or_else(|| {
proxies.iter().find_map(|(name, value)| {
value
.get("all")
.and_then(Value::as_array)
.filter(|items| !items.is_empty())
.map(|_| (name.as_str(), value))
})
})
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "没有可选择的节点组"))?;
let nodes = selected
.1
.get("all")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(Value::as_str)
.filter(|name| is_concrete_proxy(proxies.get(*name)))
.map(str::to_owned)
.collect();
Ok(NodeGroup {
name: selected.0.to_owned(),
current: selected
.1
.get("now")
.and_then(Value::as_str)
.unwrap_or_default()
.to_owned(),
nodes,
})
}
fn is_concrete_proxy(value: Option<&Value>) -> bool {
let kind = value
.and_then(|value| value.get("type"))
.and_then(Value::as_str)
.unwrap_or_default()
.to_ascii_lowercase()
.replace(['-', '_'], "");
!matches!(
kind.as_str(),
"direct"
| "reject"
| "selector"
| "urltest"
| "fallback"
| "loadbalance"
| "compatible"
| "pass"
| "block"
| "dns"
)
}
fn test_node_delay(
runtime: &RuntimeConfig,
node: &str,
test_url: &str,
timeout_ms: u64,
) -> Result<u64, io::Error> {
let mut url = api_url(runtime, "proxies")?;
url.path_segments_mut()
.map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "无效 API 地址"))?
.push(node)
.push("delay");
url.query_pairs_mut()
.append_pair("timeout", &timeout_ms.to_string())
.append_pair("url", test_url);
let response = Client::builder()
.no_proxy()
.timeout(Duration::from_millis(timeout_ms + 500))
.build()
.map_err(http_error)?
.get(url)
.send()
.map_err(http_error)?
.error_for_status()
.map_err(http_error)?;
let root: Value = serde_json::from_str(&response.text().map_err(http_error)?)
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
root.get("delay")
.and_then(Value::as_u64)
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "测速 API 缺少 delay"))
}
fn save_speedtest(
paths: &AppPaths,
url: &str,
timeout_ms: u64,
results: &[(String, Option<u64>)],
) -> Result<(), io::Error> {
fs::create_dir_all(paths.speedtest_dir())?;
let entries: Vec<_> = results
.iter()
.map(|(node, delay)| json!({"node": node, "delay_ms": delay}))
.collect();
let content = serde_json::to_vec_pretty(&json!({
"tested_at": jiff::Timestamp::now().to_string(),
"url": url,
"timeout_ms": timeout_ms,
"results": entries,
}))
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
atomic_write_private(&paths.speedtest_dir().join("latest.json"), &content)
}
fn cached_delay(paths: &AppPaths, node: &str) -> Option<u64> {
let content = fs::read(paths.speedtest_dir().join("latest.json")).ok()?;
let root: Value = serde_json::from_slice(&content).ok()?;
root.get("results")?
.as_array()?
.iter()
.find(|entry| entry.get("node").and_then(Value::as_str) == Some(node))?
.get("delay_ms")?
.as_u64()
}
fn select_node(runtime: &RuntimeConfig, group: &str, node: &str) -> Result<(), io::Error> {
let mut url = api_url(runtime, "proxies")?;
url.path_segments_mut()
.map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "无效 API 地址"))?
.push(group);
api_client()?
.put(url)
.header("content-type", "application/json")
.body(json!({"name": node}).to_string())
.send()
.map_err(http_error)?
.error_for_status()
.map_err(http_error)?;
Ok(())
}
fn persist_selected_node(paths: &AppPaths, group: &str, node: &str) -> Result<(), io::Error> {
let _lock = AppLock::acquire(&paths.lock_file())?;
let active = ActiveConfig::load(&paths.active_file())?;
let manifest = load_manifest(&paths.cores_dir().join(&active.current.core))?;
let mut index = ProfilesIndex::load(&paths.profiles_file())?;
let profile_name = index
.current
.get(&manifest.core.family)
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "当前 family 没有 profile"))?
.clone();
let profile = index
.profiles
.iter_mut()
.find(|profile| profile.name == profile_name && profile.family == manifest.core.family)
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "当前 profile 不存在"))?;
profile
.state
.selected
.insert(group.to_owned(), node.to_owned());
index.save(&paths.profiles_file())
}
fn restore_selected_nodes(
paths: &AppPaths,
runtime: &RuntimeConfig,
profile_name: &str,
) -> Result<(), io::Error> {
let index = ProfilesIndex::load(&paths.profiles_file())?;
let profile = index
.find(profile_name)
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "当前 profile 不存在"))?;
for (group, node) in &profile.state.selected {
select_node(runtime, group, node)?;
}
Ok(())
}
fn api_client() -> Result<Client, io::Error> {
Client::builder()
.no_proxy()
.timeout(Duration::from_millis(500))
.build()
.map_err(http_error)
}
fn api_url(runtime: &RuntimeConfig, resource: &str) -> Result<Url, io::Error> {
let host = match runtime.api.listen.as_str() {
"0.0.0.0" | "::" => "127.0.0.1",
host => host,
};
Url::parse(&format!("http://{host}:{}/{resource}", runtime.api.port))
.map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))
}
fn remove_stale_pid(paths: &AppPaths) -> Result<(), io::Error> {
if matches!(
managed_process(&paths.core_pid_file())?,
ManagedProcess::Stale(_)
) {
fs::remove_file(paths.core_pid_file())?;
}
Ok(())
}
fn http_error(error: reqwest::Error) -> io::Error {
io::Error::new(io::ErrorKind::ConnectionRefused, error)
}
fn on_off(value: bool) -> &'static str {
if value { "on" } else { "off" }
}
#[cfg(test)]
mod tests {
use std::{
io::{Read, Write},
net::TcpListener,
thread,
};
use serde_json::json;
use super::{is_concrete_proxy, proxy_ports_ready, sort_node_delays, test_node_delay};
use crate::domain::{ConfigCapabilities, RuntimeConfig};
fn proxy_capabilities() -> ConfigCapabilities {
ConfigCapabilities {
mixed_proxy: true,
http_proxy: false,
socks_proxy: false,
api: true,
dns: true,
tun: true,
}
}
#[test]
fn classifies_selectors_and_real_proxy_nodes() {
assert!(!is_concrete_proxy(Some(&json!({"type":"Direct"}))));
assert!(!is_concrete_proxy(Some(&json!({"type":"URLTest"}))));
assert!(is_concrete_proxy(Some(&json!({"type":"Shadowsocks"}))));
assert!(is_concrete_proxy(Some(&json!({"type":"VLESS"}))));
}
#[test]
fn sorts_successful_delays_before_timeouts() {
let mut results = vec![
("timeout-b".into(), None),
("slow".into(), Some(180)),
("fast".into(), Some(20)),
("timeout-a".into(), None),
];
sort_node_delays(&mut results);
assert_eq!(
results,
vec![
("fast".into(), Some(20)),
("slow".into(), Some(180)),
("timeout-a".into(), None),
("timeout-b".into(), None),
]
);
}
#[test]
fn readiness_requires_every_declared_proxy_port() {
let mixed = TcpListener::bind("127.0.0.1:0").unwrap();
let mut runtime = RuntimeConfig::default();
runtime.proxy.mixed_port = mixed.local_addr().unwrap().port();
let mut capabilities = proxy_capabilities();
assert!(proxy_ports_ready(&runtime, &capabilities));
capabilities.http_proxy = true;
runtime.proxy.http_port = 0;
assert!(!proxy_ports_ready(&runtime, &capabilities));
}
#[test]
fn parses_controller_delay_response() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let server = thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let mut request = [0_u8; 2048];
let read = stream.read(&mut request).unwrap();
let request = String::from_utf8_lossy(&request[..read]);
assert!(request.starts_with("GET /proxies/HK%20Node/delay?"));
assert!(request.contains("timeout=800"));
let body = r#"{"delay":42}"#;
write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
)
.unwrap();
});
let mut runtime = RuntimeConfig::default();
runtime.api.port = port;
assert_eq!(
test_node_delay(
&runtime,
"HK Node",
"https://www.gstatic.com/generate_204",
800
)
.unwrap(),
42
);
server.join().unwrap();
}
}

397
src/application/setting.rs Normal file
View file

@ -0,0 +1,397 @@
use std::{
fs, io,
io::{IsTerminal, Write},
net::IpAddr,
};
use crate::domain::{RuntimeConfig, Settings};
use crate::platform::{AppLock, AppPaths};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Target {
Settings,
Runtime,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ValueKind {
Bool,
Port,
PositiveInteger,
Ip,
LogLevel,
ProxyMode,
TunStack,
}
#[derive(Debug, Clone, Copy)]
struct SettingSpec {
key: &'static str,
target: Target,
kind: ValueKind,
}
const SPECS: &[SettingSpec] = &[
spec("bypass.enabled", Target::Settings, ValueKind::Bool),
spec("log.level", Target::Settings, ValueKind::LogLevel),
spec(
"log.max_size_mb",
Target::Settings,
ValueKind::PositiveInteger,
),
spec("proxy.mode", Target::Runtime, ValueKind::ProxyMode),
spec("proxy.listen", Target::Runtime, ValueKind::Ip),
spec("proxy.mixed_port", Target::Runtime, ValueKind::Port),
spec("proxy.http_port", Target::Runtime, ValueKind::Port),
spec("proxy.socks_port", Target::Runtime, ValueKind::Port),
spec("proxy.allow_lan", Target::Runtime, ValueKind::Bool),
spec("proxy.ipv6", Target::Runtime, ValueKind::Bool),
spec("api.enabled", Target::Runtime, ValueKind::Bool),
spec("api.listen", Target::Runtime, ValueKind::Ip),
spec("api.port", Target::Runtime, ValueKind::Port),
spec("dns.enabled", Target::Runtime, ValueKind::Bool),
spec("dns.listen", Target::Runtime, ValueKind::Ip),
spec("dns.port", Target::Runtime, ValueKind::Port),
spec("dns.ipv6", Target::Runtime, ValueKind::Bool),
spec("tun.stack", Target::Runtime, ValueKind::TunStack),
spec("tun.auto_route", Target::Runtime, ValueKind::Bool),
spec(
"tun.auto_detect_interface",
Target::Runtime,
ValueKind::Bool,
),
spec("tun.dns_hijack", Target::Runtime, ValueKind::Bool),
];
const fn spec(key: &'static str, target: Target, kind: ValueKind) -> SettingSpec {
SettingSpec { key, target, kind }
}
pub fn interactive(paths: &AppPaths) -> Result<(), io::Error> {
if !io::stdin().is_terminal() {
return list(paths);
}
let settings = Settings::load(&paths.settings_file())?;
let runtime = RuntimeConfig::load(&paths.runtime_file())?;
for (index, spec) in SPECS.iter().enumerate() {
println!(
"{:>2}) {:<28} {}",
index + 1,
spec.key,
get_value(spec.key, &settings, &runtime)?
);
}
let selected = prompt("选择项目0 取消): ")?;
let number = selected
.parse::<usize>()
.map_err(|_| invalid("请输入列表中的数字"))?;
if number == 0 {
return Ok(());
}
let spec = SPECS
.get(number.saturating_sub(1))
.ok_or_else(|| invalid("选择超出范围"))?;
let value = prompt(&format!("{} 新值: ", spec.key))?;
set(paths, spec.key, Some(&value))
}
pub fn list(paths: &AppPaths) -> Result<(), io::Error> {
let settings = Settings::load(&paths.settings_file())?;
let runtime = RuntimeConfig::load(&paths.runtime_file())?;
for spec in SPECS {
println!(
"{:<28} {:<8} {}",
spec.key,
kind_name(spec.kind),
get_value(spec.key, &settings, &runtime)?
);
}
Ok(())
}
pub fn get(paths: &AppPaths, key: &str) -> Result<(), io::Error> {
find_spec(key)?;
let settings = Settings::load(&paths.settings_file())?;
let runtime = RuntimeConfig::load(&paths.runtime_file())?;
println!("{}", get_value(key, &settings, &runtime)?);
Ok(())
}
pub fn set(paths: &AppPaths, key: &str, value: Option<&str>) -> Result<(), io::Error> {
let spec = find_spec(key)?;
let value = match value {
Some(value) => value.to_owned(),
None if io::stdin().is_terminal() => prompt(&format!("{key} 新值: "))?,
None => return Err(invalid("非交互调用必须提供 value")),
};
let _lock = AppLock::acquire(&paths.lock_file())?;
let mut settings = Settings::load(&paths.settings_file())?;
let mut runtime = RuntimeConfig::load(&paths.runtime_file())?;
set_value(key, &value, &mut settings, &mut runtime)?;
validate_runtime(&runtime)?;
match spec.target {
Target::Settings => settings.save(&paths.settings_file())?,
Target::Runtime => runtime.save(&paths.runtime_file())?,
}
invalidate_generated(paths)?;
println!("已保存 {key}={value};需要重新 build/start 后生效。");
Ok(())
}
pub fn reset(paths: &AppPaths, key: Option<&str>) -> Result<(), io::Error> {
let _lock = AppLock::acquire(&paths.lock_file())?;
let mut settings = Settings::load(&paths.settings_file())?;
let mut runtime = RuntimeConfig::load(&paths.runtime_file())?;
let defaults_settings = Settings::default();
let defaults_runtime = RuntimeConfig::default();
match key {
Some(key) => {
let spec = find_spec(key)?;
let value = get_value(key, &defaults_settings, &defaults_runtime)?;
set_value(key, &value, &mut settings, &mut runtime)?;
validate_runtime(&runtime)?;
match spec.target {
Target::Settings => settings.save(&paths.settings_file())?,
Target::Runtime => runtime.save(&paths.runtime_file())?,
}
println!("已恢复 {key}={value};需要重新 build/start 后生效。");
}
None => {
for spec in SPECS {
let value = get_value(spec.key, &defaults_settings, &defaults_runtime)?;
set_value(spec.key, &value, &mut settings, &mut runtime)?;
}
validate_runtime(&runtime)?;
settings.save(&paths.settings_file())?;
runtime.save(&paths.runtime_file())?;
println!("已恢复全部公开设置;需要重新 build/start 后生效。");
}
}
invalidate_generated(paths)?;
Ok(())
}
fn find_spec(key: &str) -> Result<&'static SettingSpec, io::Error> {
SPECS
.iter()
.find(|spec| spec.key == key)
.ok_or_else(|| invalid(format!("未知 setting key `{key}`")))
}
fn get_value(key: &str, settings: &Settings, runtime: &RuntimeConfig) -> Result<String, io::Error> {
let value = match key {
"bypass.enabled" => settings.bypass.enabled.to_string(),
"log.level" => settings.log.level.clone(),
"log.max_size_mb" => settings.log.max_size_mb.to_string(),
"proxy.mode" => runtime.proxy.mode.clone(),
"proxy.listen" => runtime.proxy.listen.clone(),
"proxy.mixed_port" => runtime.proxy.mixed_port.to_string(),
"proxy.http_port" => runtime.proxy.http_port.to_string(),
"proxy.socks_port" => runtime.proxy.socks_port.to_string(),
"proxy.allow_lan" => runtime.proxy.allow_lan.to_string(),
"proxy.ipv6" => runtime.proxy.ipv6.to_string(),
"api.enabled" => runtime.api.enabled.to_string(),
"api.listen" => runtime.api.listen.clone(),
"api.port" => runtime.api.port.to_string(),
"dns.enabled" => runtime.dns.enabled.to_string(),
"dns.listen" => runtime.dns.listen.clone(),
"dns.port" => runtime.dns.port.to_string(),
"dns.ipv6" => runtime.dns.ipv6.to_string(),
"tun.stack" => runtime.tun.stack.clone(),
"tun.auto_route" => runtime.tun.auto_route.to_string(),
"tun.auto_detect_interface" => runtime.tun.auto_detect_interface.to_string(),
"tun.dns_hijack" => runtime.tun.dns_hijack.to_string(),
_ => return Err(invalid(format!("未知 setting key `{key}`"))),
};
Ok(value)
}
fn set_value(
key: &str,
value: &str,
settings: &mut Settings,
runtime: &mut RuntimeConfig,
) -> Result<(), io::Error> {
let spec = find_spec(key)?;
validate_value(spec.kind, value)?;
match key {
"bypass.enabled" => settings.bypass.enabled = parse_bool(value)?,
"log.level" => settings.log.level = value.to_ascii_lowercase(),
"log.max_size_mb" => settings.log.max_size_mb = value.parse().map_err(parse_error)?,
"proxy.mode" => runtime.proxy.mode = value.to_ascii_lowercase(),
"proxy.listen" => runtime.proxy.listen = normalize_ip(value)?,
"proxy.mixed_port" => runtime.proxy.mixed_port = parse_port(value)?,
"proxy.http_port" => runtime.proxy.http_port = parse_port(value)?,
"proxy.socks_port" => runtime.proxy.socks_port = parse_port(value)?,
"proxy.allow_lan" => runtime.proxy.allow_lan = parse_bool(value)?,
"proxy.ipv6" => runtime.proxy.ipv6 = parse_bool(value)?,
"api.enabled" => runtime.api.enabled = parse_bool(value)?,
"api.listen" => runtime.api.listen = normalize_ip(value)?,
"api.port" => runtime.api.port = parse_port(value)?,
"dns.enabled" => runtime.dns.enabled = parse_bool(value)?,
"dns.listen" => runtime.dns.listen = normalize_ip(value)?,
"dns.port" => runtime.dns.port = parse_port(value)?,
"dns.ipv6" => runtime.dns.ipv6 = parse_bool(value)?,
"tun.stack" => runtime.tun.stack = value.to_ascii_lowercase(),
"tun.auto_route" => runtime.tun.auto_route = parse_bool(value)?,
"tun.auto_detect_interface" => runtime.tun.auto_detect_interface = parse_bool(value)?,
"tun.dns_hijack" => runtime.tun.dns_hijack = parse_bool(value)?,
_ => return Err(invalid(format!("未知 setting key `{key}`"))),
}
Ok(())
}
fn validate_value(kind: ValueKind, value: &str) -> Result<(), io::Error> {
match kind {
ValueKind::Bool => {
parse_bool(value)?;
}
ValueKind::Port => {
parse_port(value)?;
}
ValueKind::PositiveInteger => {
let number: u32 = value.parse().map_err(parse_error)?;
if number == 0 || number > 4096 {
return Err(invalid("数值必须在 1..=4096"));
}
}
ValueKind::Ip => {
normalize_ip(value)?;
}
ValueKind::LogLevel => {
if !matches!(
value.to_ascii_lowercase().as_str(),
"error" | "warn" | "info" | "debug" | "trace"
) {
return Err(invalid("日志级别必须是 error|warn|info|debug|trace"));
}
}
ValueKind::ProxyMode => {
if !matches!(
value.to_ascii_lowercase().as_str(),
"rule" | "global" | "direct"
) {
return Err(invalid("代理模式必须是 rule|global|direct"));
}
}
ValueKind::TunStack => {
if !matches!(
value.to_ascii_lowercase().as_str(),
"system" | "gvisor" | "mixed"
) {
return Err(invalid("TUN stack 必须是 system|gvisor|mixed"));
}
}
}
Ok(())
}
fn validate_runtime(runtime: &RuntimeConfig) -> Result<(), io::Error> {
let ports = [
("proxy.mixed_port", runtime.proxy.mixed_port),
("proxy.http_port", runtime.proxy.http_port),
("proxy.socks_port", runtime.proxy.socks_port),
("api.port", runtime.api.port),
("dns.port", runtime.dns.port),
];
for (index, (left_name, left)) in ports.iter().enumerate() {
for (right_name, right) in ports.iter().skip(index + 1) {
if left == right {
return Err(invalid(format!(
"端口冲突:{left_name} 和 {right_name} 都是 {left}"
)));
}
}
}
Ok(())
}
fn parse_bool(value: &str) -> Result<bool, io::Error> {
match value.to_ascii_lowercase().as_str() {
"true" | "on" => Ok(true),
"false" | "off" => Ok(false),
_ => Err(invalid("布尔值必须是 on|off|true|false")),
}
}
fn parse_port(value: &str) -> Result<u16, io::Error> {
let port: u16 = value.parse().map_err(parse_error)?;
if port == 0 {
return Err(invalid("端口必须在 1..=65535"));
}
Ok(port)
}
fn normalize_ip(value: &str) -> Result<String, io::Error> {
value
.parse::<IpAddr>()
.map(|ip| ip.to_string())
.map_err(|_| invalid(format!("无效 IP 地址 `{value}`")))
}
fn parse_error(error: std::num::ParseIntError) -> io::Error {
invalid(error.to_string())
}
fn invalidate_generated(paths: &AppPaths) -> Result<(), io::Error> {
let generated = paths.generated_dir();
if generated.is_dir() {
fs::remove_dir_all(&generated)?;
}
fs::create_dir_all(generated)
}
fn prompt(message: &str) -> Result<String, io::Error> {
print!("{message}");
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
Ok(input.trim().to_owned())
}
fn kind_name(kind: ValueKind) -> &'static str {
match kind {
ValueKind::Bool => "bool",
ValueKind::Port => "port",
ValueKind::PositiveInteger => "integer",
ValueKind::Ip => "ip",
ValueKind::LogLevel | ValueKind::ProxyMode | ValueKind::TunStack => "enum",
}
}
fn invalid(message: impl Into<String>) -> io::Error {
io::Error::new(io::ErrorKind::InvalidInput, message.into())
}
#[cfg(test)]
mod tests {
use super::{SPECS, set_value, validate_runtime};
use crate::domain::{RuntimeConfig, Settings};
use std::collections::HashSet;
#[test]
fn registry_keys_are_unique() {
let mut keys = HashSet::new();
assert!(SPECS.iter().all(|spec| keys.insert(spec.key)));
}
#[test]
fn accepts_typed_values_and_rejects_unknown_keys() {
let mut settings = Settings::default();
let mut runtime = RuntimeConfig::default();
set_value("proxy.mode", "global", &mut settings, &mut runtime).unwrap();
set_value("proxy.allow_lan", "on", &mut settings, &mut runtime).unwrap();
assert_eq!(runtime.proxy.mode, "global");
assert!(runtime.proxy.allow_lan);
assert!(set_value("active.tun", "on", &mut settings, &mut runtime).is_err());
}
#[test]
fn rejects_port_conflicts() {
let mut runtime = RuntimeConfig::default();
runtime.api.port = runtime.proxy.mixed_port;
assert!(validate_runtime(&runtime).is_err());
}
}

137
src/application/tun.rs Normal file
View file

@ -0,0 +1,137 @@
use std::{fs, io, process::Command};
use crate::{
domain::{ActiveConfig, load_manifest},
platform::{AppLock, AppPaths, ManagedProcess, managed_process},
};
pub fn status(paths: &AppPaths) -> Result<(), io::Error> {
let active = ActiveConfig::load(&paths.active_file())?;
let (supported, privileged) = tun_capability(paths).unwrap_or((false, false));
println!(
"tun={} supported={} permission={}",
on_off(active.tun.enabled),
yes_no(supported),
if privileged { "ready" } else { "missing" }
);
Ok(())
}
pub fn set(paths: &AppPaths, enabled: bool) -> Result<(), io::Error> {
if enabled {
validate_tun(paths)?;
}
let original = ActiveConfig::load(&paths.active_file())?;
if original.tun.enabled == enabled {
println!("tun 已经是 {}", on_off(enabled));
return Ok(());
}
let was_running = matches!(
managed_process(&paths.core_pid_file())?,
ManagedProcess::Running(_)
);
save_enabled(paths, enabled)?;
if was_running && let Err(error) = super::service::restart(paths) {
let rollback =
save_enabled(paths, original.tun.enabled).and_then(|_| super::service::start(paths));
return Err(io::Error::other(match rollback {
Ok(()) => format!("TUN 切换失败,已恢复原运行状态: {error}"),
Err(rollback_error) => {
format!("TUN 切换失败且恢复失败: {error}; {rollback_error}")
}
}));
}
println!("tun {}", on_off(enabled));
Ok(())
}
fn validate_tun(paths: &AppPaths) -> Result<(), io::Error> {
let active = ActiveConfig::load(&paths.active_file())?;
if active.current.core.is_empty() {
return Err(io::Error::new(io::ErrorKind::NotFound, "未选择 core"));
}
let core_dir = paths.cores_dir().join(&active.current.core);
let manifest = load_manifest(&core_dir)?;
if !manifest.capabilities.config.tun {
return Err(io::Error::new(
io::ErrorKind::Unsupported,
format!("core `{}` 不支持 TUN", active.current.core),
));
}
if !std::path::Path::new("/dev/net/tun").exists() {
return Err(io::Error::new(
io::ErrorKind::NotFound,
"系统不存在 /dev/net/tun",
));
}
let binary = core_dir.join(&manifest.core.binary);
if !has_net_admin(&binary) {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
format!(
"TUN 需要 CAP_NET_ADMIN请执行 `sudo setcap cap_net_admin,cap_net_raw+ep '{}'` 后重试",
shell_path(&binary)
),
));
}
Ok(())
}
fn tun_capability(paths: &AppPaths) -> Result<(bool, bool), io::Error> {
let active = ActiveConfig::load(&paths.active_file())?;
if active.current.core.is_empty() {
return Ok((false, false));
}
let core_dir = paths.cores_dir().join(&active.current.core);
let manifest = load_manifest(&core_dir)?;
let supported = manifest.capabilities.config.tun;
let privileged = std::path::Path::new("/dev/net/tun").exists()
&& has_net_admin(&core_dir.join(&manifest.core.binary));
Ok((supported, privileged))
}
fn has_net_admin(binary: &std::path::Path) -> bool {
if effective_uid() == 0 {
return true;
}
Command::new("getcap")
.arg(binary)
.output()
.ok()
.filter(|output| output.status.success())
.is_some_and(|output| String::from_utf8_lossy(&output.stdout).contains("cap_net_admin"))
}
fn save_enabled(paths: &AppPaths, enabled: bool) -> Result<(), io::Error> {
let _lock = AppLock::acquire(&paths.lock_file())?;
let mut active = ActiveConfig::load(&paths.active_file())?;
active.tun.enabled = enabled;
if !active.current.core.is_empty() {
let generated = paths.generated_dir().join(&active.current.core);
match fs::remove_dir_all(generated) {
Ok(()) => {}
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => return Err(error),
}
}
active.save(&paths.active_file())
}
fn effective_uid() -> u32 {
unsafe extern "C" {
fn geteuid() -> u32;
}
unsafe { geteuid() }
}
fn on_off(value: bool) -> &'static str {
if value { "on" } else { "off" }
}
fn yes_no(value: bool) -> &'static str {
if value { "yes" } else { "no" }
}
fn shell_path(path: &std::path::Path) -> String {
path.to_string_lossy().replace('\'', "'\"'\"'")
}

334
src/cli/args.rs Normal file
View file

@ -0,0 +1,334 @@
use clap::{Parser, Subcommand, ValueEnum};
use std::path::PathBuf;
/// Manage local proxy cores, profiles, and runtime state.
#[derive(Debug, Parser)]
#[command(name = "tz", version, about)]
pub struct Cli {
/// List, search, and interactively select nodes.
#[arg(short = 'l', long = "list", value_name = "KEYWORD", num_args = 0..=1, default_missing_value = "")]
pub quick_list: Option<String>,
#[command(subcommand)]
pub command: Option<CliCommand>,
}
#[derive(Debug, Subcommand)]
pub enum CliCommand {
/// Initialize the filesystem layout and default files.
Init,
/// Show the current runtime status.
#[command(visible_alias = "st")]
Status,
/// Start the selected proxy core.
#[command(visible_alias = "on")]
Start,
/// Stop the running proxy core.
#[command(visible_alias = "off", alias = "end")]
Stop,
/// Restart the selected proxy core.
#[command(visible_alias = "r")]
Restart,
/// List or search nodes from the active profile.
List { keyword: Option<String> },
/// Open the profile selector for the current core family.
Select,
/// Test proxy node latency through the active core.
Node {
#[command(subcommand)]
command: NodeCommand,
},
/// Control TUN mode.
Tun {
#[command(subcommand)]
command: ToggleCommand,
},
/// Control terminal and desktop proxy integration.
Proxy {
#[command(subcommand)]
command: ProxyCommand,
},
/// Inspect and modify persistent settings.
#[command(visible_alias = "set")]
Setting {
#[command(subcommand)]
command: Option<SettingCommand>,
},
/// Manage profiles.
#[command(visible_alias = "p")]
Profile {
#[command(subcommand)]
command: ProfileCommand,
},
/// Manage proxy cores.
#[command(visible_alias = "c")]
Core {
#[command(subcommand)]
command: CoreCommand,
},
/// Build, validate, or show the effective core configuration.
#[command(visible_alias = "cfg")]
Config {
#[command(subcommand)]
command: ConfigCommand,
},
/// Generate shell completion scripts.
#[command(visible_alias = "comp")]
Completion {
#[command(subcommand)]
command: CompletionCommand,
},
}
#[derive(Debug, Subcommand)]
pub enum CompletionCommand {
/// Generate a completion script for the selected shell.
Generate { shell: CompletionShell },
}
#[derive(Debug, Subcommand)]
pub enum ConfigCommand {
Build,
Check,
Show,
}
#[derive(Debug, Subcommand)]
pub enum NodeCommand {
/// Test matching nodes and sort them by latency.
Test {
keyword: Option<String>,
#[arg(long, default_value = "https://www.gstatic.com/generate_204")]
url: String,
#[arg(long, default_value_t = 1800)]
timeout: u64,
/// Select the fastest node after testing.
#[arg(long)]
select: bool,
},
}
#[derive(Debug, Subcommand)]
pub enum ToggleCommand {
Status,
On,
Off,
}
#[derive(Debug, Subcommand)]
pub enum ProxyCommand {
Status,
/// Enable terminal and system proxy state.
On,
/// Disable terminal and system proxy state.
Off,
/// Print environment exports for eval/source.
Env {
#[arg(value_enum, default_value = "bash")]
shell: CompletionShell,
},
/// Print environment unset commands for eval/source.
Noenv {
#[arg(value_enum, default_value = "bash")]
shell: CompletionShell,
},
/// Print a persistent shell integration function.
ShellInit {
shell: CompletionShell,
},
/// Control terminal proxy state only.
Terminal {
#[command(subcommand)]
command: ToggleCommand,
},
/// Control GNOME system proxy state only.
System {
#[command(subcommand)]
command: ToggleCommand,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum CompletionShell {
Bash,
Zsh,
Fish,
}
#[derive(Debug, Subcommand)]
pub enum SettingCommand {
/// List all supported setting keys and current values.
List,
/// Print one setting value.
Get { key: String },
/// Set one setting value. Missing value is interactive only.
Set { key: String, value: Option<String> },
/// Reset one key, or every public key when omitted.
Reset { key: Option<String> },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum ProfileFamily {
Clash,
SingBox,
}
impl ProfileFamily {
pub fn as_str(self) -> &'static str {
match self {
Self::Clash => "clash",
Self::SingBox => "sing-box",
}
}
}
#[derive(Debug, Subcommand)]
pub enum ProfileCommand {
/// Import a remote URL or local file as a managed profile.
#[command(visible_alias = "a")]
Add {
name: String,
source: String,
#[arg(long, value_enum)]
family: ProfileFamily,
},
/// List managed profiles.
#[command(visible_alias = "l")]
List {
#[arg(long, value_enum)]
family: Option<ProfileFamily>,
/// List profiles from every supported core family.
#[arg(long, conflicts_with = "family")]
all: bool,
},
/// Show one profile and its origin.
#[command(visible_alias = "i")]
Info { name: String },
/// Select a profile. Missing name is interactive only.
#[command(visible_alias = "u")]
Use { name: Option<String> },
/// Refresh every remote profile.
#[command(visible_alias = "up")]
Update,
/// Remove a managed profile.
#[command(visible_alias = "rm")]
Remove { name: String },
}
#[derive(Debug, Subcommand)]
pub enum CoreCommand {
/// Import a locally prepared core directory.
#[command(visible_alias = "a")]
Add { directory: PathBuf },
/// List registered proxy cores.
#[command(visible_alias = "l")]
List,
/// Show one core, or the current core when omitted.
#[command(visible_alias = "i")]
Info { name: Option<String> },
/// Select a core. Missing name is interactive only.
#[command(visible_alias = "u")]
Use { name: Option<String> },
/// Remove a managed core.
#[command(visible_alias = "rm")]
Remove { name: String },
}
#[cfg(test)]
mod tests {
use super::{
Cli, CliCommand, NodeCommand, ProfileCommand, ProfileFamily, ProxyCommand, SettingCommand,
ToggleCommand,
};
use clap::Parser;
#[test]
fn parses_setting_and_profile_commands() {
let cli = Cli::try_parse_from(["tz", "setting", "set", "proxy.mode", "global"])
.expect("setting parses");
assert!(matches!(
cli.command,
Some(CliCommand::Setting {
command: Some(SettingCommand::Set { .. })
})
));
let cli = Cli::try_parse_from(["tz", "p", "up"]).expect("short update parses");
assert!(matches!(
cli.command,
Some(CliCommand::Profile {
command: ProfileCommand::Update
})
));
let cli = Cli::try_parse_from(["tz", "select"]).expect("selector parses");
assert!(matches!(cli.command, Some(CliCommand::Select)));
let cli = Cli::try_parse_from(["tz", "tun", "on"]).expect("tun parses");
assert!(matches!(
cli.command,
Some(CliCommand::Tun {
command: ToggleCommand::On
})
));
let cli =
Cli::try_parse_from(["tz", "proxy", "terminal", "off"]).expect("terminal proxy parses");
assert!(matches!(
cli.command,
Some(CliCommand::Proxy {
command: ProxyCommand::Terminal {
command: ToggleCommand::Off
}
})
));
let cli = Cli::try_parse_from(["tz", "node", "test", "hk", "--select"])
.expect("node test parses");
assert!(matches!(
cli.command,
Some(CliCommand::Node {
command: NodeCommand::Test { select: true, .. }
})
));
let cli = Cli::try_parse_from([
"tz",
"profile",
"add",
"home",
"/tmp/home.yaml",
"--family",
"clash",
])
.expect("profile parses");
assert!(matches!(
cli.command,
Some(CliCommand::Profile {
command: ProfileCommand::Add {
family: ProfileFamily::Clash,
..
}
})
));
let cli = Cli::try_parse_from(["tz", "on"]).expect("start alias parses");
assert!(matches!(cli.command, Some(CliCommand::Start)));
for alias in ["off", "end"] {
let cli = Cli::try_parse_from(["tz", alias]).expect("stop alias parses");
assert!(matches!(cli.command, Some(CliCommand::Stop)));
}
let cli = Cli::try_parse_from(["tz", "-l", "hong"]).expect("quick list parses");
assert_eq!(cli.quick_list.as_deref(), Some("hong"));
let cli = Cli::try_parse_from(["tz", "profile", "list", "--all"])
.expect("profile list all parses");
assert!(matches!(
cli.command,
Some(CliCommand::Profile {
command: ProfileCommand::List { all: true, .. }
})
));
}
}

View file

@ -0,0 +1,143 @@
use std::io;
use crate::cli::{CompletionCommand, CompletionShell};
pub fn run(command: CompletionCommand) -> Result<(), io::Error> {
match command {
CompletionCommand::Generate { shell } => {
let script = match shell {
CompletionShell::Bash => bash_script(),
CompletionShell::Zsh => zsh_script(),
CompletionShell::Fish => fish_script(),
};
print!("{script}");
Ok(())
}
}
}
fn bash_script() -> &'static str {
r#"_tz_complete() {
local cur prev command subcommand
cur="${COMP_WORDS[COMP_CWORD]}"
prev="${COMP_WORDS[COMP_CWORD-1]}"
command="${COMP_WORDS[1]}"
if (( COMP_CWORD == 1 )); then
COMPREPLY=( $(compgen -W 'init status st start on stop off end restart r list select node tun proxy setting set profile p core c config cfg completion comp -l --list' -- "$cur") )
return
fi
case "$command" in
profile|p)
if (( COMP_CWORD == 2 )); then
COMPREPLY=( $(compgen -W 'add a list l info i use u update up remove rm' -- "$cur") )
elif [[ "$prev" == "--family" ]]; then
COMPREPLY=( $(compgen -W 'clash sing-box' -- "$cur") )
elif [[ "$cur" == -* ]]; then
COMPREPLY=( $(compgen -W '--family --all' -- "$cur") )
fi
;;
core|c)
if (( COMP_CWORD == 2 )); then
COMPREPLY=( $(compgen -W 'add a list l info i use u remove rm' -- "$cur") )
fi
;;
setting|set)
if (( COMP_CWORD == 2 )); then
COMPREPLY=( $(compgen -W 'list get set reset' -- "$cur") )
fi
;;
config|cfg)
if (( COMP_CWORD == 2 )); then
COMPREPLY=( $(compgen -W 'build check show' -- "$cur") )
fi
;;
node)
if (( COMP_CWORD == 2 )); then
COMPREPLY=( $(compgen -W 'test' -- "$cur") )
elif [[ "$cur" == -* ]]; then
COMPREPLY=( $(compgen -W '--url --timeout --select' -- "$cur") )
fi
;;
tun)
if (( COMP_CWORD == 2 )); then
COMPREPLY=( $(compgen -W 'status on off' -- "$cur") )
fi
;;
proxy)
if (( COMP_CWORD == 2 )); then
COMPREPLY=( $(compgen -W 'status on off env noenv shell-init terminal system' -- "$cur") )
elif [[ "$prev" == "env" || "$prev" == "noenv" || "$prev" == "shell-init" ]]; then
COMPREPLY=( $(compgen -W 'bash zsh fish' -- "$cur") )
elif [[ "$prev" == "terminal" || "$prev" == "system" ]]; then
COMPREPLY=( $(compgen -W 'status on off' -- "$cur") )
fi
;;
completion|comp)
if (( COMP_CWORD == 2 )); then
COMPREPLY=( $(compgen -W 'generate' -- "$cur") )
elif (( COMP_CWORD == 3 )); then
COMPREPLY=( $(compgen -W 'bash zsh fish' -- "$cur") )
fi
;;
esac
}
complete -F _tz_complete tz
"#
}
fn zsh_script() -> &'static str {
r#"#compdef tz
_arguments '1:command:(init status st start on stop off end restart r list select node tun proxy setting set profile p core c config cfg completion comp)' \
'*::arg:->args'
case "$words[2]" in
profile|p)
_arguments '1:action:(add a list l info i use u update up remove rm)' \
'*:options:(--family --all)'
;;
core|c)
_arguments '1:action:(add a list l info i use u remove rm)'
;;
setting|set)
_arguments '1:action:(list get set reset)'
;;
config|cfg)
_arguments '1:action:(build check show)'
;;
node)
_arguments '1:action:(test)' '*:options:(--url --timeout --select)'
;;
tun)
_arguments '1:action:(status on off)'
;;
proxy)
_arguments '1:action:(status on off env noenv shell-init terminal system)' \
'2:value:(status on off bash zsh fish)'
;;
completion|comp)
_arguments '1:action:(generate)' '2:shell:(bash zsh fish)'
;;
esac
"#
}
fn fish_script() -> &'static str {
r#"complete -c tz -f -n '__fish_use_subcommand' -a 'init status st start on stop off end restart r list select node tun proxy setting set profile p core c config cfg completion comp'
complete -c tz -s l -l list -r -a '(__fish_print_minimal)' -d 'list or search nodes'
complete -c tz -f -n '__fish_seen_subcommand_from profile p' -a 'add a list l info i use u update up remove rm'
complete -c tz -l family -f -n '__fish_seen_subcommand_from profile p' -a 'clash sing-box'
complete -c tz -l all -f -n '__fish_seen_subcommand_from profile p; and __fish_seen_subcommand_from list l'
complete -c tz -f -n '__fish_seen_subcommand_from core c' -a 'add a list l info i use u remove rm'
complete -c tz -f -n '__fish_seen_subcommand_from setting set' -a 'list get set reset'
complete -c tz -f -n '__fish_seen_subcommand_from config cfg' -a 'build check show'
complete -c tz -f -n '__fish_seen_subcommand_from node' -a 'test'
complete -c tz -l url -l timeout -l select -n '__fish_seen_subcommand_from node test'
complete -c tz -f -n '__fish_seen_subcommand_from tun' -a 'status on off'
complete -c tz -f -n '__fish_seen_subcommand_from proxy' -a 'status on off env noenv shell-init terminal system'
complete -c tz -f -n '__fish_seen_subcommand_from proxy; and __fish_seen_subcommand_from env noenv shell-init' -a 'bash zsh fish'
complete -c tz -f -n '__fish_seen_subcommand_from proxy; and __fish_seen_subcommand_from terminal system' -a 'status on off'
complete -c tz -f -n '__fish_seen_subcommand_from completion comp' -a 'generate'
complete -c tz -f -n '__fish_seen_subcommand_from completion generate' -a 'bash zsh fish'
"#
}

View file

@ -0,0 +1,35 @@
use std::{fs, io};
use crate::{cli::ConfigCommand, platform::AppPaths};
pub fn run(command: ConfigCommand, paths: Option<&AppPaths>) -> Result<(), io::Error> {
let paths = paths.ok_or_else(|| {
io::Error::new(
io::ErrorKind::NotFound,
"tz 尚未初始化,请先运行 `tz init`。",
)
})?;
match command {
ConfigCommand::Build => {
let built = crate::application::build_config(paths)?;
println!(
"已生成 core={} profile={} config={}",
built.core.name,
built.profile_name,
built.config_path.display()
);
}
ConfigCommand::Check => {
let built = crate::application::check_config(paths)?;
println!(
"配置有效 core={} profile={}",
built.core.name, built.profile_name
);
}
ConfigCommand::Show => {
let built = crate::application::build_config(paths)?;
print!("{}", fs::read_to_string(built.config_path)?);
}
}
Ok(())
}

174
src/cli/commands/core.rs Normal file
View file

@ -0,0 +1,174 @@
use std::{io, io::IsTerminal};
use crate::cli::CoreCommand;
use crate::domain::list_cores;
use crate::platform::AppPaths;
pub fn run(command: CoreCommand, paths: Option<&AppPaths>) -> Result<(), io::Error> {
let paths = require_paths(paths)?;
match command {
CoreCommand::Add { directory } => {
let added = crate::application::add_core(paths, &directory)?;
println!(
"已导入 core {} family={} version={}",
added.descriptor.name,
added.descriptor.manifest.core.family,
added.descriptor.manifest.core.version
);
if let Some(output) = added.version_output.filter(|output| !output.is_empty()) {
println!("version output: {output}");
}
Ok(())
}
CoreCommand::List => list(paths),
CoreCommand::Info { name } => info(paths, name.as_deref()),
CoreCommand::Use { name } => select(paths, name.as_deref()),
CoreCommand::Remove { name } => {
crate::application::remove_core(paths, &name)?;
println!("已删除 core {name}");
Ok(())
}
}
}
fn list(paths: &AppPaths) -> Result<(), io::Error> {
let active = crate::domain::ActiveConfig::load(&paths.active_file())?;
let cores = list_cores(&paths.cores_dir())?;
if cores.is_empty() {
println!(
"暂无已注册 core请把 core 目录放到 {}",
paths.cores_dir().display()
);
return Ok(());
}
for (index, core) in cores.iter().enumerate() {
let marker = if active.current.core == core.name {
"*"
} else {
" "
};
let manifest = &core.manifest;
if io::stdin().is_terminal() {
println!(
"{marker} {}) {} version={} family={}",
index + 1,
core.name,
manifest.core.version,
manifest.core.family
);
} else {
println!(
"{marker} {} version={} family={}",
core.name, manifest.core.version, manifest.core.family
);
}
}
if io::stdin().is_terminal() {
let value = prompt("选择 core0 保持当前): ")?;
let selected = value
.parse::<usize>()
.map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "请输入列表中的数字"))?;
if selected > cores.len() {
return Err(io::Error::new(io::ErrorKind::InvalidInput, "选择超出范围"));
}
if selected > 0 {
let name = &cores[selected - 1].name;
crate::application::use_core(paths, name)?;
println!("当前 core: {name}");
}
}
Ok(())
}
fn info(paths: &AppPaths, name: Option<&str>) -> Result<(), io::Error> {
let info = crate::application::core_info(paths, name)?;
let core = &info.descriptor;
let manifest = &core.manifest;
println!("name : {}", core.name);
println!("family : {}", manifest.core.family);
println!("version : {}", manifest.core.version);
println!("platform : {}/{}", manifest.core.os, manifest.core.arch);
println!("directory : {}", core.dir.display());
println!("binary : {}", core.binary_path().display());
println!(
"entrypoint : {} ({})",
manifest.runtime.entrypoint, manifest.runtime.format
);
println!(
"commands : start=yes check={} version={} reload={}",
yes_no(manifest.commands.check.is_some()),
yes_no(manifest.commands.version.is_some()),
yes_no(manifest.commands.reload.is_some())
);
if let Some(output) = info.version_output.filter(|output| !output.is_empty()) {
println!("actual : {output}");
if !output.contains(&manifest.core.version) {
println!("warning : version output does not contain manifest version");
}
}
Ok(())
}
fn select(paths: &AppPaths, name: Option<&str>) -> Result<(), io::Error> {
let selected = match name {
Some(name) => name.to_owned(),
None if io::stdin().is_terminal() => choose_core(paths)?,
None => {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"非交互调用必须提供 core name",
));
}
};
crate::application::use_core(paths, &selected)?;
println!("当前 core: {selected}");
Ok(())
}
fn choose_core(paths: &AppPaths) -> Result<String, io::Error> {
let cores = list_cores(&paths.cores_dir())?;
if cores.is_empty() {
return Err(io::Error::new(io::ErrorKind::NotFound, "没有可选择的 core"));
}
for (index, core) in cores.iter().enumerate() {
println!(
"{}) {} ({})",
index + 1,
core.name,
core.manifest.core.version
);
}
let value = prompt("选择 core0 取消): ")?;
let index = value
.parse::<usize>()
.map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "请输入列表中的数字"))?;
if index == 0 {
return Err(io::Error::new(io::ErrorKind::Interrupted, "已取消"));
}
cores
.get(index.saturating_sub(1))
.map(|core| core.name.clone())
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "选择超出范围"))
}
fn prompt(message: &str) -> Result<String, io::Error> {
use std::io::Write;
print!("{message}");
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
Ok(input.trim().to_owned())
}
fn require_paths(paths: Option<&AppPaths>) -> Result<&AppPaths, io::Error> {
paths.ok_or_else(|| {
io::Error::new(
io::ErrorKind::NotFound,
"tz 尚未初始化,请先运行 `tz init`。",
)
})
}
fn yes_no(value: bool) -> &'static str {
if value { "yes" } else { "no" }
}

3
src/cli/commands/init.rs Normal file
View file

@ -0,0 +1,3 @@
pub fn run() -> Result<(), Box<dyn std::error::Error>> {
crate::application::initialize()
}

11
src/cli/commands/mod.rs Normal file
View file

@ -0,0 +1,11 @@
pub mod completion;
pub mod config;
pub mod core;
pub mod init;
pub mod node;
pub mod profile;
pub mod proxy;
pub mod service;
pub mod setting;
pub mod status;
pub mod tun;

22
src/cli/commands/node.rs Normal file
View file

@ -0,0 +1,22 @@
use std::io;
use crate::{application::NodeTestOptions, cli::NodeCommand, platform::AppPaths};
pub fn run(command: NodeCommand, paths: &AppPaths) -> Result<(), io::Error> {
match command {
NodeCommand::Test {
keyword,
url,
timeout,
select,
} => crate::application::test_nodes(
paths,
NodeTestOptions {
keyword: keyword.as_deref(),
url: &url,
timeout_ms: timeout,
select,
},
),
}
}

228
src/cli/commands/profile.rs Normal file
View file

@ -0,0 +1,228 @@
use std::{io, io::IsTerminal};
use crate::application::{AddProfile, ProfileError, ProfileService};
use crate::cli::ProfileCommand;
use crate::domain::{ActiveConfig, ProfilesIndex};
use crate::platform::{AppPaths, SecureDownloader};
pub fn run(
command: ProfileCommand,
paths: Option<&AppPaths>,
) -> Result<(), Box<dyn std::error::Error>> {
let paths = require_paths(paths)?;
let downloader = SecureDownloader::default();
let service = ProfileService::new(paths, &downloader);
match command {
ProfileCommand::Add {
name,
source,
family,
} => {
let downloader = SecureDownloader::for_family(family.as_str());
let service = ProfileService::new(paths, &downloader);
let entry = service.add(AddProfile {
name: &name,
family: family.as_str(),
source: &source,
})?;
println!(
"已添加 profile {} family={} source={}",
entry.name, entry.family, entry.origin.kind
);
println!("使用 `tz profile use {}` 选择它。", entry.name);
}
ProfileCommand::List { family, all } => {
let current_family;
let family = if all {
None
} else if let Some(family) = family {
Some(family.as_str())
} else {
current_family = current_core_family(paths)?;
Some(current_family.as_str())
};
let profiles = service.list(family)?;
if profiles.is_empty() {
println!("暂无 profile");
} else {
for (index, profile) in profiles.iter().enumerate() {
let marker = if profile.current { "*" } else { " " };
if io::stdin().is_terminal() {
println!(
"{marker} {}) {} family={}",
index + 1,
profile.name,
profile.family
);
} else {
println!("{marker} {} family={}", profile.name, profile.family);
}
}
if io::stdin().is_terminal() {
let selected = prompt_index("选择 profile0 保持当前): ", profiles.len())?;
if let Some(selected) = selected {
let entry = service.use_profile(&profiles[selected].name)?;
println!("当前 {} profile: {}", entry.family, entry.name);
}
}
}
}
ProfileCommand::Info { name } => {
let entry = service.info(&name)?;
let index = ProfilesIndex::load(&paths.profiles_file())?;
println!("name : {}", entry.name);
println!("family : {}", entry.family);
println!("format : {}", entry.format);
println!("source : {}", entry.source_file);
println!("origin : {}", entry.origin.kind);
if entry.origin.kind == "remote" {
println!("url : <redacted>");
println!(
"download_via: {}",
if entry.origin.download_via.is_empty() {
"unknown"
} else {
&entry.origin.download_via
}
);
} else {
println!("original : {}", entry.origin.original_path);
}
println!(
"current : {}",
yes_no(index.current.get(&entry.family) == Some(&entry.name))
);
if !entry.update.updated_at.is_empty() {
println!("updated_at : {}", entry.update.updated_at);
}
}
ProfileCommand::Use { name } => {
let name = select_name(paths, name.as_deref())?;
let entry = service.use_profile(&name)?;
println!("当前 {} profile: {}", entry.family, entry.name);
}
ProfileCommand::Update => {
crate::platform::ensure_not_running(&paths.core_pid_file())?;
let index = ProfilesIndex::load(&paths.profiles_file())?;
let profiles: Vec<_> = index
.profiles
.iter()
.filter(|profile| profile.origin.kind == "remote")
.map(|profile| (profile.name.clone(), profile.family.clone()))
.collect();
if profiles.is_empty() {
println!("没有可更新的远程 profile");
return Ok(());
}
let mut failures = Vec::new();
for (name, family) in profiles {
let downloader = SecureDownloader::for_family(&family);
let service = ProfileService::new(paths, &downloader);
match service.update(&name) {
Ok(entry) => {
println!("已更新 {} via={}", entry.name, entry.origin.download_via)
}
Err(error) => {
eprintln!("更新失败 {name}: {error}");
failures.push(name);
}
}
}
if !failures.is_empty() {
return Err(ProfileError::InvalidInput(format!(
"{} 个 profile 更新失败: {}",
failures.len(),
failures.join(", ")
))
.into());
}
}
ProfileCommand::Remove { name } => {
let removed = service.remove(&name)?;
println!("已删除 profile {}", removed.name);
}
}
Ok(())
}
fn select_name(paths: &AppPaths, name: Option<&str>) -> Result<String, ProfileError> {
match name {
Some(name) => Ok(name.to_owned()),
None if io::stdin().is_terminal() => choose_profile(paths),
None => Err(ProfileError::InvalidInput(
"非交互调用必须提供 profile name".into(),
)),
}
}
fn choose_profile(paths: &AppPaths) -> Result<String, ProfileError> {
use std::io::Write;
let downloader = SecureDownloader::default();
let service = ProfileService::new(paths, &downloader);
let family = current_core_family(paths)?;
let profiles = service.list(Some(&family))?;
if profiles.is_empty() {
return Err(ProfileError::NotFound("any profile".into()));
}
for (index, profile) in profiles.iter().enumerate() {
println!("{}) {} ({})", index + 1, profile.name, profile.family);
}
print!("选择 profile0 取消): ");
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
let index = input
.trim()
.parse::<usize>()
.map_err(|_| ProfileError::InvalidInput("请输入列表中的数字".into()))?;
if index == 0 {
return Err(ProfileError::InvalidInput("已取消".into()));
}
profiles
.get(index.saturating_sub(1))
.map(|profile| profile.name.clone())
.ok_or_else(|| ProfileError::InvalidInput("选择超出范围".into()))
}
fn current_core_family(paths: &AppPaths) -> Result<String, ProfileError> {
let active = ActiveConfig::load(&paths.active_file())?;
if active.current.core.is_empty() {
return Err(ProfileError::InvalidInput(
"未选择 core默认 profile list 需要先选择 core如需查看全部请使用 --all".into(),
));
}
let manifest = crate::domain::load_manifest(&paths.cores_dir().join(active.current.core))?;
Ok(manifest.core.family)
}
fn prompt_index(message: &str, count: usize) -> Result<Option<usize>, ProfileError> {
use std::io::Write;
print!("{message}");
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
let value = input
.trim()
.parse::<usize>()
.map_err(|_| ProfileError::InvalidInput("请输入列表中的数字".into()))?;
if value == 0 {
return Ok(None);
}
if value > count {
return Err(ProfileError::InvalidInput("选择超出范围".into()));
}
Ok(Some(value - 1))
}
fn require_paths(paths: Option<&AppPaths>) -> Result<&AppPaths, io::Error> {
paths.ok_or_else(|| {
io::Error::new(
io::ErrorKind::NotFound,
"tz 尚未初始化,请先运行 `tz init`。",
)
})
}
fn yes_no(value: bool) -> &'static str {
if value { "yes" } else { "no" }
}

37
src/cli/commands/proxy.rs Normal file
View file

@ -0,0 +1,37 @@
use std::io;
use crate::{
cli::{CompletionShell, ProxyCommand, ToggleCommand},
platform::AppPaths,
};
pub fn run(command: ProxyCommand, paths: &AppPaths) -> Result<(), io::Error> {
match command {
ProxyCommand::Status => crate::application::proxy::status(paths),
ProxyCommand::On => crate::application::proxy::both(paths, true),
ProxyCommand::Off => crate::application::proxy::both(paths, false),
ProxyCommand::Env { shell } => crate::application::proxy::env(paths, shell_name(shell)),
ProxyCommand::Noenv { shell } => crate::application::proxy::noenv(shell_name(shell)),
ProxyCommand::ShellInit { shell } => {
crate::application::proxy::shell_init(paths, shell_name(shell))
}
ProxyCommand::Terminal { command } => match command {
ToggleCommand::Status => crate::application::proxy::status(paths),
ToggleCommand::On => crate::application::proxy::terminal(paths, true),
ToggleCommand::Off => crate::application::proxy::terminal(paths, false),
},
ProxyCommand::System { command } => match command {
ToggleCommand::Status => crate::application::proxy::status(paths),
ToggleCommand::On => crate::application::proxy::system(paths, true),
ToggleCommand::Off => crate::application::proxy::system(paths, false),
},
}
}
fn shell_name(shell: CompletionShell) -> &'static str {
match shell {
CompletionShell::Bash => "bash",
CompletionShell::Zsh => "zsh",
CompletionShell::Fish => "fish",
}
}

View file

@ -0,0 +1,21 @@
use std::io;
use crate::platform::AppPaths;
pub fn start(paths: &AppPaths) -> Result<(), io::Error> {
crate::application::start(paths)?;
crate::application::status(paths)
}
pub fn stop(paths: &AppPaths) -> Result<(), io::Error> {
crate::application::stop(paths)
}
pub fn restart(paths: &AppPaths) -> Result<(), io::Error> {
crate::application::restart(paths)?;
crate::application::status(paths)
}
pub fn list(paths: &AppPaths, keyword: Option<&str>) -> Result<(), io::Error> {
crate::application::list(paths, keyword)
}

View file

@ -0,0 +1,28 @@
use std::io;
use crate::cli::SettingCommand;
use crate::platform::AppPaths;
pub fn run(command: Option<SettingCommand>, paths: Option<&AppPaths>) -> Result<(), io::Error> {
let paths = require_paths(paths)?;
match command {
None => crate::application::setting::interactive(paths),
Some(SettingCommand::List) => crate::application::setting::list(paths),
Some(SettingCommand::Get { key }) => crate::application::setting::get(paths, &key),
Some(SettingCommand::Set { key, value }) => {
crate::application::setting::set(paths, &key, value.as_deref())
}
Some(SettingCommand::Reset { key }) => {
crate::application::setting::reset(paths, key.as_deref())
}
}
}
fn require_paths(paths: Option<&AppPaths>) -> Result<&AppPaths, io::Error> {
paths.ok_or_else(|| {
io::Error::new(
io::ErrorKind::NotFound,
"tz 尚未初始化,请先运行 `tz init`。",
)
})
}

View file

@ -0,0 +1,13 @@
use std::io;
use crate::platform::AppPaths;
pub fn run(paths: Option<&AppPaths>) -> Result<(), io::Error> {
let Some(paths) = paths else {
return Err(io::Error::new(
io::ErrorKind::NotFound,
"tz 尚未初始化,请先运行 `tz init`。",
));
};
crate::application::status(paths)
}

11
src/cli/commands/tun.rs Normal file
View file

@ -0,0 +1,11 @@
use std::io;
use crate::{cli::ToggleCommand, platform::AppPaths};
pub fn run(command: ToggleCommand, paths: &AppPaths) -> Result<(), io::Error> {
match command {
ToggleCommand::Status => crate::application::tun::status(paths),
ToggleCommand::On => crate::application::tun::set(paths, true),
ToggleCommand::Off => crate::application::tun::set(paths, false),
}
}

74
src/cli/mod.rs Normal file
View file

@ -0,0 +1,74 @@
mod args;
mod commands;
use std::error::Error;
use crate::platform::AppPaths;
pub use args::{
Cli, CliCommand, CompletionCommand, CompletionShell, ConfigCommand, CoreCommand, NodeCommand,
ProfileCommand, ProfileFamily, ProxyCommand, SettingCommand, ToggleCommand,
};
pub fn run(cli: Cli) -> Result<(), Box<dyn Error>> {
let command = match (cli.quick_list, cli.command) {
(Some(keyword), None) => CliCommand::List {
keyword: (!keyword.is_empty()).then_some(keyword),
},
(Some(_), Some(_)) => {
return Err("-l/--list cannot be combined with another command".into());
}
(None, Some(command)) => command,
(None, None) => CliCommand::Status,
};
if let CliCommand::Completion { command } = command {
commands::completion::run(command)?;
return Ok(());
}
let paths = if matches!(command, CliCommand::Init) {
None
} else {
AppPaths::from_env_or_none()?
};
match command {
CliCommand::Init => commands::init::run()?,
CliCommand::Status => commands::status::run(paths.as_ref())?,
CliCommand::Start => commands::service::start(require_paths(paths.as_ref())?)?,
CliCommand::Stop => commands::service::stop(require_paths(paths.as_ref())?)?,
CliCommand::Restart => commands::service::restart(require_paths(paths.as_ref())?)?,
CliCommand::List { keyword } => {
commands::service::list(require_paths(paths.as_ref())?, keyword.as_deref())?
}
CliCommand::Select => commands::profile::run(
ProfileCommand::List {
family: None,
all: false,
},
paths.as_ref(),
)?,
CliCommand::Node { command } => {
commands::node::run(command, require_paths(paths.as_ref())?)?
}
CliCommand::Tun { command } => commands::tun::run(command, require_paths(paths.as_ref())?)?,
CliCommand::Proxy { command } => {
commands::proxy::run(command, require_paths(paths.as_ref())?)?
}
CliCommand::Setting { command } => commands::setting::run(command, paths.as_ref())?,
CliCommand::Profile { command } => commands::profile::run(command, paths.as_ref())?,
CliCommand::Core { command } => commands::core::run(command, paths.as_ref())?,
CliCommand::Config { command } => commands::config::run(command, paths.as_ref())?,
CliCommand::Completion { .. } => unreachable!("completion handled before path resolution"),
}
Ok(())
}
fn require_paths(paths: Option<&AppPaths>) -> Result<&AppPaths, std::io::Error> {
paths.ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::NotFound,
"tz 尚未初始化,请先运行 `tz init`。",
)
})
}

123
src/domain/active.rs Normal file
View file

@ -0,0 +1,123 @@
use serde::{Deserialize, Serialize};
use std::{fs, io, path::Path};
const SCHEMA_VERSION: u32 = 1;
/// active.toml主页开关 + 当前 core 选择。
/// profile 选择由 profiles.toml 维护PID 等实时状态走 state/runtime/。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ActiveConfig {
pub schema_version: u32,
#[serde(default)]
pub current: Current,
#[serde(default)]
pub tun: Tun,
#[serde(default)]
pub shell_proxy: ShellProxy,
#[serde(default)]
pub system_proxy: SystemProxy,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct Current {
/// 当前使用 core 的注册名(= cores 目录名)。
#[serde(default, skip_serializing_if = "String::is_empty")]
pub core: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct Tun {
pub enabled: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ShellProxy {
pub enabled: bool,
pub bypass: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SystemProxy {
pub enabled: bool,
pub bypass: bool,
}
impl Default for ActiveConfig {
fn default() -> Self {
Self {
schema_version: SCHEMA_VERSION,
current: Current::default(),
tun: Tun::default(),
shell_proxy: ShellProxy::default(),
system_proxy: SystemProxy::default(),
}
}
}
impl Default for ShellProxy {
fn default() -> Self {
Self {
enabled: false,
bypass: true,
}
}
}
impl Default for SystemProxy {
fn default() -> Self {
Self {
enabled: false,
bypass: true,
}
}
}
impl ActiveConfig {
pub fn load(path: &Path) -> Result<Self, io::Error> {
let content = fs::read_to_string(path)?;
let active: Self = toml::from_str(&content).map_err(|error| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("invalid active.toml: {error}"),
)
})?;
if active.schema_version != SCHEMA_VERSION {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"unsupported active.toml schema_version {}; expected {SCHEMA_VERSION}",
active.schema_version
),
));
}
Ok(active)
}
pub fn save(&self, path: &Path) -> Result<(), io::Error> {
let content = toml::to_string_pretty(self)
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error.to_string()))?;
crate::platform::atomic_write(path, content.as_bytes())
}
}
#[cfg(test)]
mod tests {
use super::ActiveConfig;
#[test]
fn default_active_contains_only_homepage_state() {
let text = toml::to_string_pretty(&ActiveConfig::default()).expect("serialize");
assert!(text.contains("[current]"));
assert!(text.contains("[tun]"));
assert!(text.contains("[shell_proxy]"));
assert!(text.contains("[system_proxy]"));
assert!(!text.contains("profile"));
assert!(!text.contains("stack"));
assert!(!text.contains("auto_route"));
}
}

492
src/domain/core_manifest.rs Normal file
View file

@ -0,0 +1,492 @@
use serde::{Deserialize, Serialize};
use std::{
fs, io,
os::unix::fs::PermissionsExt,
path::{Component, Path, PathBuf},
};
const SCHEMA_VERSION: u32 = 1;
/// data/cores/<name>/core.toml手动制作、tz 只读。
/// core.name 必须和目录名一致;不一致直接报错。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CoreManifest {
pub schema_version: u32,
pub core: CoreSection,
pub runtime: RuntimeSection,
pub capabilities: Capabilities,
pub commands: Commands,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CoreSection {
pub name: String,
pub family: String,
pub version: String,
/// 相对 core 目录的二进制文件名。
pub binary: String,
pub os: String,
pub arch: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RuntimeSection {
/// 生成配置目录内的入口文件名(例如 config.yaml / config.json
pub entrypoint: String,
pub format: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Capabilities {
pub config: ConfigCapabilities,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ConfigCapabilities {
pub mixed_proxy: bool,
pub http_proxy: bool,
pub socks_proxy: bool,
pub api: bool,
pub dns: bool,
pub tun: bool,
}
/// start 必填check/version/reload 是否存在就是对应 CLI 动作的能力来源。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Commands {
pub start: CommandArgs,
#[serde(default)]
pub check: Option<CommandArgs>,
#[serde(default)]
pub version: Option<CommandArgs>,
#[serde(default)]
pub reload: Option<CommandArgs>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CommandArgs {
#[serde(default)]
pub args: Vec<String>,
}
/// 已加载的 core解析后带目录路径可直接拼 spawn 命令。
#[derive(Debug, Clone)]
pub struct CoreDescriptor {
/// core 注册名 = 目录名mihomo、sing-box 等)。
pub name: String,
pub dir: PathBuf,
pub manifest: CoreManifest,
}
impl CoreDescriptor {
pub fn binary_path(&self) -> PathBuf {
self.dir.join(&self.manifest.core.binary)
}
pub fn entrypoint_name(&self) -> &str {
&self.manifest.runtime.entrypoint
}
/// 将 {config} {workdir} 占位符替换为真实路径。
pub fn render_args(&self, args: &[String], config: &Path, workdir: &Path) -> Vec<String> {
let config = config.display().to_string();
let workdir = workdir.display().to_string();
args.iter()
.map(|arg| {
arg.replace("{config}", &config)
.replace("{workdir}", &workdir)
})
.collect()
}
}
pub fn load_manifest(dir: &Path) -> Result<CoreManifest, io::Error> {
load_manifest_impl(dir, true)
}
/// 加载待导入的 core 包。来源目录名可以与稳定注册名不同;包内容仍执行完整校验。
pub fn load_import_manifest(dir: &Path) -> Result<CoreManifest, io::Error> {
load_manifest_impl(dir, false)
}
fn load_manifest_impl(dir: &Path, require_directory_name: bool) -> Result<CoreManifest, io::Error> {
let path = dir.join("core.toml");
let content = fs::read_to_string(&path)?;
let manifest: CoreManifest = toml::from_str(&content).map_err(|error| {
invalid(format!(
"cannot parse core.toml at {}: {error}",
path.display()
))
})?;
validate_manifest(dir, &manifest, require_directory_name)?;
Ok(manifest)
}
pub fn list_cores(cores_dir: &Path) -> Result<Vec<CoreDescriptor>, io::Error> {
if !cores_dir.is_dir() {
return Ok(Vec::new());
}
let mut entries = Vec::new();
for entry in fs::read_dir(cores_dir)? {
let entry = entry?;
let dir = entry.path();
if !dir.is_dir() || !dir.join("core.toml").is_file() {
continue;
}
let manifest = load_manifest(&dir)?;
let name = dir_name(&dir)?.to_owned();
entries.push(CoreDescriptor {
name,
dir,
manifest,
});
}
entries.sort_by(|a, b| a.name.cmp(&b.name));
Ok(entries)
}
fn validate_manifest(
dir: &Path,
manifest: &CoreManifest,
require_directory_name: bool,
) -> Result<(), io::Error> {
if manifest.schema_version != SCHEMA_VERSION {
return Err(invalid(format!(
"unsupported schema_version {}; expected {SCHEMA_VERSION}",
manifest.schema_version
)));
}
validate_name(&manifest.core.name)?;
if require_directory_name {
let directory_name = dir_name(dir)?;
if manifest.core.name != directory_name {
return Err(invalid(format!(
"core name mismatch: dir is `{directory_name}` but core.toml declares `{}`",
manifest.core.name
)));
}
}
if manifest.core.version.trim().is_empty() {
return Err(invalid("core.version must not be empty".into()));
}
validate_platform(&manifest.core.os, &manifest.core.arch)?;
validate_family_format(&manifest.core.family, &manifest.runtime.format)?;
validate_file_name(&manifest.core.binary, "core.binary")?;
validate_file_name(&manifest.runtime.entrypoint, "runtime.entrypoint")?;
let binary = dir.join(&manifest.core.binary);
let metadata = fs::metadata(&binary).map_err(|error| {
invalid(format!(
"core binary {} is not accessible: {error}",
binary.display()
))
})?;
if !metadata.is_file() || metadata.permissions().mode() & 0o111 == 0 {
return Err(invalid(format!(
"core binary {} must be an executable file",
binary.display()
)));
}
validate_command("commands.start", &manifest.commands.start)?;
for (name, command) in [
("commands.check", manifest.commands.check.as_ref()),
("commands.version", manifest.commands.version.as_ref()),
("commands.reload", manifest.commands.reload.as_ref()),
] {
if let Some(command) = command {
validate_command(name, command)?;
}
}
Ok(())
}
fn validate_name(name: &str) -> Result<(), io::Error> {
if name.is_empty()
|| !name
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
{
return Err(invalid(format!(
"core.name `{name}` must contain only ASCII letters, numbers, '.', '_' or '-'"
)));
}
Ok(())
}
fn validate_platform(os: &str, arch: &str) -> Result<(), io::Error> {
if os != std::env::consts::OS || arch != std::env::consts::ARCH {
return Err(invalid(format!(
"core platform `{os}/{arch}` does not match host `{}/{}`",
std::env::consts::OS,
std::env::consts::ARCH
)));
}
Ok(())
}
fn validate_family_format(family: &str, format: &str) -> Result<(), io::Error> {
if !matches!((family, format), ("clash", "yaml") | ("sing-box", "json")) {
return Err(invalid(format!(
"unsupported core family/format combination `{family}/{format}`"
)));
}
Ok(())
}
fn validate_file_name(value: &str, field: &str) -> Result<(), io::Error> {
let path = Path::new(value);
let mut components = path.components();
if value.is_empty()
|| path.is_absolute()
|| !matches!(components.next(), Some(Component::Normal(_)))
|| components.next().is_some()
{
return Err(invalid(format!(
"{field} must be a single relative file name: `{value}`"
)));
}
Ok(())
}
fn validate_command(name: &str, command: &CommandArgs) -> Result<(), io::Error> {
for argument in &command.args {
let remaining = argument.replace("{config}", "").replace("{workdir}", "");
if remaining.contains('{') || remaining.contains('}') {
return Err(invalid(format!(
"{name} contains an unsupported placeholder in `{argument}`"
)));
}
}
Ok(())
}
fn dir_name(dir: &Path) -> Result<&str, io::Error> {
dir.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| {
invalid(format!(
"core dir has no valid UTF-8 name: {}",
dir.display()
))
})
}
fn invalid(message: String) -> io::Error {
io::Error::new(
io::ErrorKind::InvalidData,
format!("invalid core: {message}"),
)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
const MIHOMO: &str = r#"
schema_version = 1
[core]
name = "mihomo"
family = "clash"
version = "1.19.14"
binary = "mihomo"
os = "linux"
arch = "x86_64"
[runtime]
entrypoint = "config.yaml"
format = "yaml"
[capabilities.config]
mixed_proxy = true
http_proxy = true
socks_proxy = true
api = true
dns = true
tun = true
[commands.start]
args = ["-d", "{workdir}", "-f", "{config}"]
[commands.check]
args = ["-t", "-d", "{workdir}", "-f", "{config}"]
[commands.version]
args = ["-v"]
"#;
const SING_BOX: &str = r#"
schema_version = 1
[core]
name = "sing-box"
family = "sing-box"
version = "1.13.0"
binary = "sing-box"
os = "linux"
arch = "x86_64"
[runtime]
entrypoint = "config.json"
format = "json"
[capabilities.config]
mixed_proxy = true
http_proxy = false
socks_proxy = false
api = true
dns = true
tun = true
[commands.start]
args = ["run", "-D", "{workdir}", "-c", "{config}"]
[commands.check]
args = ["check", "-D", "{workdir}", "-c", "{config}"]
[commands.version]
args = ["version"]
"#;
fn write_core(root: &Path, name: &str, binary: &str, body: &str) -> PathBuf {
let dir = root.join(name);
fs::create_dir_all(&dir).expect("mkdir");
fs::write(dir.join("core.toml"), body).expect("write core.toml");
let binary = dir.join(binary);
fs::write(&binary, "fake core").expect("write binary");
let mut permissions = fs::metadata(&binary).unwrap().permissions();
permissions.set_mode(0o755);
fs::set_permissions(&binary, permissions).unwrap();
dir
}
fn temp_root(prefix: &str) -> PathBuf {
std::env::temp_dir().join(format!("{prefix}-{}", std::process::id()))
}
#[test]
fn loads_mihomo_and_sing_box_command_forms() {
let root = temp_root("tz-cores-ok");
fs::create_dir_all(&root).unwrap();
write_core(&root, "mihomo", "mihomo", MIHOMO);
write_core(&root, "sing-box", "sing-box", SING_BOX);
let cores = list_cores(&root).expect("list");
assert_eq!(cores.len(), 2);
assert_eq!(cores[0].name, "mihomo");
assert_eq!(cores[1].name, "sing-box");
fs::remove_dir_all(&root).unwrap();
}
#[test]
fn rejects_dir_name_mismatch() {
let root = temp_root("tz-cores-mismatch");
fs::create_dir_all(&root).unwrap();
write_core(&root, "mihomo-15", "mihomo", MIHOMO);
let error = list_cores(&root).expect_err("should reject mismatch");
assert!(error.to_string().contains("name mismatch"));
fs::remove_dir_all(&root).unwrap();
}
#[test]
fn rejects_unsafe_binary_and_unknown_placeholder() {
let root = temp_root("tz-cores-invalid");
fs::create_dir_all(&root).unwrap();
let unsafe_binary = MIHOMO.replace("binary = \"mihomo\"", "binary = \"../mihomo\"");
let dir = root.join("mihomo");
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("core.toml"), unsafe_binary).unwrap();
assert!(load_manifest(&dir).is_err());
let bad_placeholder = MIHOMO.replace("{config}", "{unknown}");
fs::write(dir.join("core.toml"), bad_placeholder).unwrap();
let binary = dir.join("mihomo");
fs::write(&binary, "fake").unwrap();
let mut permissions = fs::metadata(&binary).unwrap().permissions();
permissions.set_mode(0o755);
fs::set_permissions(binary, permissions).unwrap();
assert!(load_manifest(&dir).is_err());
fs::remove_dir_all(&root).unwrap();
}
#[test]
fn rejects_non_executable_binary_and_schema_version() {
let root = temp_root("tz-cores-permission");
fs::create_dir_all(&root).unwrap();
let dir = write_core(&root, "mihomo", "mihomo", MIHOMO);
let binary = dir.join("mihomo");
let mut permissions = fs::metadata(&binary).unwrap().permissions();
permissions.set_mode(0o644);
fs::set_permissions(&binary, permissions).unwrap();
assert!(load_manifest(&dir).is_err());
let mut permissions = fs::metadata(&binary).unwrap().permissions();
permissions.set_mode(0o755);
fs::set_permissions(&binary, permissions).unwrap();
fs::write(
dir.join("core.toml"),
MIHOMO.replace("schema_version = 1", "schema_version = 2"),
)
.unwrap();
assert!(load_manifest(&dir).is_err());
fs::remove_dir_all(&root).unwrap();
}
#[test]
fn rejects_missing_or_mismatched_platform() {
let root = temp_root("tz-cores-platform");
fs::create_dir_all(&root).unwrap();
let dir = root.join("mihomo");
fs::create_dir_all(&dir).unwrap();
fs::write(
dir.join("core.toml"),
MIHOMO.replace("os = \"linux\"\n", ""),
)
.unwrap();
assert!(load_manifest(&dir).is_err());
let wrong_os = if std::env::consts::OS == "linux" {
"windows"
} else {
"linux"
};
let body = MIHOMO
.replace("os = \"linux\"", &format!("os = \"{wrong_os}\""))
.replace(
"arch = \"x86_64\"",
&format!("arch = \"{}\"", std::env::consts::ARCH),
);
fs::write(dir.join("core.toml"), body).unwrap();
assert!(load_manifest(&dir).is_err());
fs::remove_dir_all(&root).unwrap();
}
#[test]
fn renders_placeholders() {
let root = temp_root("tz-cores-args");
fs::create_dir_all(&root).unwrap();
let dir = write_core(&root, "mihomo", "mihomo", MIHOMO);
let manifest = load_manifest(&dir).expect("load");
let descriptor = CoreDescriptor {
name: "mihomo".into(),
dir: dir.clone(),
manifest,
};
let config = PathBuf::from("/tmp/config.yaml");
let workdir = PathBuf::from("/tmp/workdir");
let args =
descriptor.render_args(&descriptor.manifest.commands.start.args, &config, &workdir);
assert_eq!(args, vec!["-d", "/tmp/workdir", "-f", "/tmp/config.yaml"]);
fs::remove_dir_all(&root).unwrap();
}
}

14
src/domain/mod.rs Normal file
View file

@ -0,0 +1,14 @@
mod active;
mod core_manifest;
mod profiles;
mod runtime;
mod settings;
pub use active::{ActiveConfig, Current, ShellProxy, SystemProxy, Tun};
pub use core_manifest::{
Capabilities, CommandArgs, Commands, ConfigCapabilities, CoreDescriptor, CoreManifest,
CoreSection, RuntimeSection, list_cores, load_import_manifest, load_manifest,
};
pub use profiles::{ProfileEntry, ProfileOrigin, ProfileState, ProfileUpdate, ProfilesIndex};
pub use runtime::{ApiConfig, DnsConfig, ProxyConfig, RuntimeConfig, TunConfig};
pub use settings::{BypassConfig, LogConfig, Settings, UpdateConfig, UpdateSection};

317
src/domain/profiles.rs Normal file
View file

@ -0,0 +1,317 @@
use serde::{Deserialize, Serialize};
use std::{
collections::{BTreeMap, HashSet},
fs, io,
path::{Component, Path},
};
const SCHEMA_VERSION: u32 = 1;
/// data/profiles/profiles.toml集中式 profile 索引、各 family 当前选择,
/// 以及每个 profile 的策略组节点选择。不会写回 source 文件。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ProfilesIndex {
pub schema_version: u32,
#[serde(default)]
pub current: BTreeMap<String, String>,
#[serde(default)]
pub profiles: Vec<ProfileEntry>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct ProfileEntry {
pub name: String,
pub family: String,
pub format: String,
/// 相对 profiles_dir 的路径,例如 "home/source.yaml"。
pub source_file: String,
#[serde(default)]
pub origin: ProfileOrigin,
#[serde(default)]
pub update: ProfileUpdate,
#[serde(default)]
pub state: ProfileState,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ProfileOrigin {
/// "remote" | "local"
pub kind: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub url: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub original_path: String,
/// Route used to obtain a remote source: direct, proxy, or unknown.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub download_via: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct ProfileUpdate {
#[serde(default, skip_serializing_if = "String::is_empty")]
pub updated_at: String,
}
/// builder 生成配置时,把策略组及其默认节点选择写入生成配置。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct ProfileState {
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub selected: BTreeMap<String, String>,
}
impl Default for ProfilesIndex {
fn default() -> Self {
Self {
schema_version: SCHEMA_VERSION,
current: BTreeMap::new(),
profiles: Vec::new(),
}
}
}
impl Default for ProfileOrigin {
fn default() -> Self {
Self {
kind: "local".into(),
url: String::new(),
original_path: String::new(),
download_via: String::new(),
}
}
}
impl ProfilesIndex {
pub fn load(path: &Path) -> Result<Self, io::Error> {
let content = fs::read_to_string(path)?;
let index: Self = toml::from_str(&content).map_err(|error| invalid(error.to_string()))?;
index.validate()?;
Ok(index)
}
pub fn save(&self, path: &Path) -> Result<(), io::Error> {
self.validate()?;
let content = toml::to_string_pretty(self)
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error.to_string()))?;
crate::platform::atomic_write_private(path, content.as_bytes())
}
pub fn find(&self, name: &str) -> Option<&ProfileEntry> {
self.profiles.iter().find(|profile| profile.name == name)
}
fn validate(&self) -> Result<(), io::Error> {
if self.schema_version != SCHEMA_VERSION {
return Err(invalid(format!(
"unsupported schema_version {}; expected {SCHEMA_VERSION}",
self.schema_version
)));
}
let mut names = HashSet::new();
for profile in &self.profiles {
validate_name(&profile.name)?;
validate_family_format(&profile.family, &profile.format)?;
validate_relative_path(&profile.source_file, "source_file")?;
validate_origin(&profile.origin)?;
if !names.insert(profile.name.as_str()) {
return Err(invalid(format!(
"duplicate profile name `{}`; names must be unique across families",
profile.name
)));
}
for (group, node) in &profile.state.selected {
if group.trim().is_empty() || node.trim().is_empty() {
return Err(invalid(format!(
"profile `{}` has an empty group or node selection",
profile.name
)));
}
}
}
for (family, name) in &self.current {
validate_family(family)?;
if !self
.profiles
.iter()
.any(|profile| profile.family == *family && profile.name == *name)
{
return Err(invalid(format!(
"current profile `{name}` does not exist for family `{family}`"
)));
}
}
Ok(())
}
}
fn validate_name(name: &str) -> Result<(), io::Error> {
if name.is_empty()
|| !name
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
{
return Err(invalid(format!(
"profile name `{name}` must contain only ASCII letters, numbers, '.', '_' or '-'"
)));
}
Ok(())
}
fn validate_family(family: &str) -> Result<(), io::Error> {
if !matches!(family, "clash" | "sing-box") {
return Err(invalid(format!("unsupported profile family `{family}`")));
}
Ok(())
}
fn validate_family_format(family: &str, format: &str) -> Result<(), io::Error> {
validate_family(family)?;
if !matches!((family, format), ("clash", "yaml") | ("sing-box", "json")) {
return Err(invalid(format!(
"profile family `{family}` does not support format `{format}`"
)));
}
Ok(())
}
fn validate_relative_path(value: &str, field: &str) -> Result<(), io::Error> {
let path = Path::new(value);
if value.is_empty()
|| path.is_absolute()
|| !path
.components()
.all(|component| matches!(component, Component::Normal(_)))
{
return Err(invalid(format!(
"{field} must be a non-empty relative path without '.' or '..': `{value}`"
)));
}
Ok(())
}
fn validate_origin(origin: &ProfileOrigin) -> Result<(), io::Error> {
match origin.kind.as_str() {
"remote" if origin.url.starts_with("https://") || origin.url.starts_with("http://") => {
if !origin.original_path.is_empty() {
return Err(invalid(
"remote profile origin must not set original_path".into(),
));
}
if !origin.download_via.is_empty()
&& !matches!(origin.download_via.as_str(), "direct" | "proxy" | "unknown")
{
return Err(invalid(
"remote profile origin has invalid download_via".into(),
));
}
}
"local"
if !origin.original_path.is_empty()
&& Path::new(&origin.original_path).is_absolute() =>
{
if !origin.url.is_empty() {
return Err(invalid("local profile origin must not set url".into()));
}
}
"remote" => {
return Err(invalid(
"remote profile origin requires an HTTP(S) url".into(),
));
}
"local" => {
return Err(invalid(
"local profile origin requires an absolute original_path".into(),
));
}
kind => return Err(invalid(format!("unsupported profile origin kind `{kind}`"))),
}
Ok(())
}
fn invalid(message: String) -> io::Error {
io::Error::new(
io::ErrorKind::InvalidData,
format!("invalid profiles.toml: {message}"),
)
}
#[cfg(test)]
mod tests {
use super::{ProfileEntry, ProfileOrigin, ProfilesIndex};
fn local_profile(name: &str) -> ProfileEntry {
ProfileEntry {
name: name.into(),
family: "clash".into(),
format: "yaml".into(),
source_file: format!("{name}/source.yaml"),
origin: ProfileOrigin {
kind: "local".into(),
url: String::new(),
original_path: format!("/tmp/{name}.yaml"),
download_via: String::new(),
},
..Default::default()
}
}
#[test]
fn profiles_index_defaults_to_empty_list() {
let index = ProfilesIndex::default();
assert!(index.profiles.is_empty());
assert!(index.current.is_empty());
assert_eq!(index.schema_version, 1);
}
#[test]
fn profile_entry_serializes_current_and_group_selections() {
let mut index = ProfilesIndex::default();
let mut profile = local_profile("home");
profile
.state
.selected
.insert("Proxy".into(), "Hong Kong 01".into());
index.current.insert("clash".into(), "home".into());
index.profiles.push(profile);
let text = toml::to_string_pretty(&index).expect("serialize");
assert!(text.contains("[current]"));
assert!(text.contains("[[profiles]]"));
assert!(text.contains("[profiles.state.selected]"));
}
#[test]
fn rejects_duplicate_profiles_and_unsafe_sources() {
let mut index = ProfilesIndex::default();
index.profiles.push(local_profile("home"));
index.profiles.push(local_profile("home"));
assert!(index.validate().is_err());
let mut index = ProfilesIndex::default();
index.profiles.push(local_profile("home"));
let mut sing_box = local_profile("home");
sing_box.family = "sing-box".into();
sing_box.format = "json".into();
sing_box.source_file = "home/source.json".into();
index.profiles.push(sing_box);
assert!(index.validate().is_err());
let mut index = ProfilesIndex::default();
let mut profile = local_profile("home");
profile.source_file = "../outside.yaml".into();
index.profiles.push(profile);
assert!(index.validate().is_err());
}
#[test]
fn rejects_missing_current_profile() {
let mut index = ProfilesIndex::default();
index.current.insert("clash".into(), "missing".into());
assert!(index.validate().is_err());
}
}

161
src/domain/runtime.rs Normal file
View file

@ -0,0 +1,161 @@
use serde::{Deserialize, Serialize};
use std::{fs, io, path::Path};
const SCHEMA_VERSION: u32 = 1;
/// runtime.toml跨 core 都能表达的运行参数层端口、API、DNS、TUN 参数)。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RuntimeConfig {
pub schema_version: u32,
#[serde(default)]
pub proxy: ProxyConfig,
#[serde(default)]
pub api: ApiConfig,
#[serde(default)]
pub dns: DnsConfig,
#[serde(default)]
pub tun: TunConfig,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ProxyConfig {
pub mode: String,
pub listen: String,
pub mixed_port: u16,
pub http_port: u16,
pub socks_port: u16,
pub allow_lan: bool,
pub ipv6: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ApiConfig {
pub enabled: bool,
pub listen: String,
pub port: u16,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DnsConfig {
pub enabled: bool,
pub listen: String,
pub port: u16,
pub ipv6: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TunConfig {
pub stack: String,
pub auto_route: bool,
pub auto_detect_interface: bool,
pub dns_hijack: bool,
}
impl Default for RuntimeConfig {
fn default() -> Self {
Self {
schema_version: SCHEMA_VERSION,
proxy: ProxyConfig::default(),
api: ApiConfig::default(),
dns: DnsConfig::default(),
tun: TunConfig::default(),
}
}
}
impl Default for ProxyConfig {
fn default() -> Self {
Self {
mode: "rule".into(),
listen: "127.0.0.1".into(),
mixed_port: 7890,
http_port: 7892,
socks_port: 7891,
allow_lan: false,
ipv6: false,
}
}
}
impl Default for ApiConfig {
fn default() -> Self {
Self {
enabled: true,
listen: "127.0.0.1".into(),
port: 9189,
}
}
}
impl Default for DnsConfig {
fn default() -> Self {
Self {
enabled: true,
listen: "127.0.0.1".into(),
port: 1053,
ipv6: false,
}
}
}
impl Default for TunConfig {
fn default() -> Self {
Self {
stack: "system".into(),
auto_route: true,
auto_detect_interface: true,
dns_hijack: true,
}
}
}
impl RuntimeConfig {
pub fn load(path: &Path) -> Result<Self, io::Error> {
let content = fs::read_to_string(path)?;
let runtime: Self = toml::from_str(&content).map_err(|error| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("invalid runtime.toml: {error}"),
)
})?;
if runtime.schema_version != SCHEMA_VERSION {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"unsupported runtime.toml schema_version {}; expected {SCHEMA_VERSION}",
runtime.schema_version
),
));
}
Ok(runtime)
}
pub fn save(&self, path: &Path) -> Result<(), io::Error> {
let content = toml::to_string_pretty(self)
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error.to_string()))?;
crate::platform::atomic_write(path, content.as_bytes())
}
}
#[cfg(test)]
mod tests {
use super::RuntimeConfig;
#[test]
fn default_runtime_matches_doc() {
let text = toml::to_string_pretty(&RuntimeConfig::default()).expect("serialize");
assert!(text.contains("[proxy]"));
assert!(text.contains("mixed_port = 7890"));
assert!(text.contains("http_port = 7892"));
assert!(text.contains("socks_port = 7891"));
assert!(text.contains("[api]"));
assert!(text.contains("port = 9189"));
assert!(text.contains("[dns]"));
assert!(text.contains("[tun]"));
}
}

158
src/domain/settings.rs Normal file
View file

@ -0,0 +1,158 @@
use serde::{Deserialize, Serialize};
use std::{fs, io, path::Path};
const SCHEMA_VERSION: u32 = 1;
/// settings.tomltz 软件自身的全局策略,与 core 运行关系不大。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Settings {
pub schema_version: u32,
#[serde(default)]
pub bypass: BypassConfig,
#[serde(default)]
pub log: LogConfig,
#[serde(default)]
pub update: UpdateConfig,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct BypassConfig {
pub enabled: bool,
/// 除 bypass.list 外,直接内联的补充条目。
pub inline: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct LogConfig {
pub level: String,
/// tz.log 超过该大小直接清除重建,不保留归档。
pub max_size_mb: u32,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct UpdateConfig {
pub profiles: UpdateSection,
pub cores: UpdateSection,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct UpdateSection {
pub auto_update: bool,
pub interval_minutes: u32,
}
impl Default for Settings {
fn default() -> Self {
Self {
schema_version: SCHEMA_VERSION,
bypass: BypassConfig::default(),
log: LogConfig::default(),
update: UpdateConfig::default(),
}
}
}
impl Default for BypassConfig {
fn default() -> Self {
Self {
enabled: true,
inline: vec!["localhost".into(), "127.0.0.0/8".into()],
}
}
}
impl Default for LogConfig {
fn default() -> Self {
Self {
level: "warn".into(),
max_size_mb: 10,
}
}
}
impl Default for UpdateConfig {
fn default() -> Self {
Self {
profiles: UpdateSection {
auto_update: false,
interval_minutes: 4320,
},
cores: UpdateSection {
auto_update: false,
interval_minutes: 14400,
},
}
}
}
impl Settings {
pub fn load(path: &Path) -> Result<Self, io::Error> {
let content = fs::read_to_string(path)?;
let settings: Self = toml::from_str(&content).map_err(|error| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("invalid settings.toml: {error}"),
)
})?;
if settings.schema_version != SCHEMA_VERSION {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"unsupported settings.toml schema_version {}; expected {SCHEMA_VERSION}",
settings.schema_version
),
));
}
Ok(settings)
}
pub fn save(&self, path: &Path) -> Result<(), io::Error> {
let content = toml::to_string_pretty(self)
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error.to_string()))?;
crate::platform::atomic_write(path, content.as_bytes())
}
}
#[cfg(test)]
mod tests {
use super::Settings;
#[test]
fn default_settings_write_log_and_bypass() {
let settings = Settings::default();
let text = toml::to_string_pretty(&settings).expect("serialize");
assert!(text.contains("[bypass]"));
assert!(text.contains("max_size_mb = 10"));
assert!(!text.contains("keep"));
}
#[test]
fn default_settings_contain_update_sections() {
let settings = Settings::default();
let text = toml::to_string_pretty(&settings).expect("serialize");
assert!(text.contains("[update.profiles]"));
assert!(text.contains("[update.cores]"));
}
#[test]
fn rejects_unknown_fields_and_schema_versions() {
let file = std::env::temp_dir().join(format!("tz-settings-{}", std::process::id()));
let mut text = toml::to_string_pretty(&Settings::default()).unwrap();
text.insert_str(text.find("[bypass]").unwrap(), "unknown = true\n\n");
std::fs::write(&file, text).unwrap();
assert!(Settings::load(&file).is_err());
let settings = Settings {
schema_version: 2,
..Default::default()
};
std::fs::write(&file, toml::to_string(&settings).unwrap()).unwrap();
assert!(Settings::load(&file).is_err());
std::fs::remove_file(file).unwrap();
}
}

4
src/lib.rs Normal file
View file

@ -0,0 +1,4 @@
pub mod application;
pub mod cli;
pub mod domain;
pub mod platform;

10
src/main.rs Normal file
View file

@ -0,0 +1,10 @@
use clap::Parser;
use tz::cli::Cli; // cli 当前是由 lib.rs 管理的,它属于库 crate tz不是 main.rs 所属二进制 crate 的直接模块。
fn main() {
let cli: Cli = Cli::parse();
if let Err(error) = tz::cli::run(cli) {
eprintln!("{error}"); // Err → 不是函数,也不是普通变量
std::process::exit(1); // error → 变量名
}
}

15
src/platform/mod.rs Normal file
View file

@ -0,0 +1,15 @@
pub mod network;
pub mod paths;
pub mod process;
pub mod storage;
pub use network::{DownloadError, DownloadVia, ProfileSource, SecureDownloader};
pub use paths::{
AppPaths, LayoutFile, PathError, PathsFile, load_paths_file, paths_file, resolve_paths,
save_paths_file,
};
pub use process::{
ManagedProcess, ensure_not_running, ensure_owned_process, managed_process, read_pid,
terminate_process,
};
pub use storage::{AppLock, atomic_write, atomic_write_private};

451
src/platform/network.rs Normal file
View file

@ -0,0 +1,451 @@
use std::{
env, fmt, io,
net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, ToSocketAddrs},
time::Duration,
};
use reqwest::{
Url,
blocking::{Client, ClientBuilder},
header::{CONTENT_LENGTH, LOCATION},
redirect::Policy,
};
pub const DEFAULT_MAX_DOWNLOAD_BYTES: usize = 8 * 1024 * 1024;
const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
const DEFAULT_REDIRECT_LIMIT: usize = 5;
const CLASH_USER_AGENT: &str = "mihomo/1.19 mh-provider";
const SING_BOX_USER_AGENT: &str = "sb/1.0 sing-box-provider";
pub trait ProfileSource {
fn download(&self, url: &str) -> Result<Vec<u8>, DownloadError>;
fn download_with_route(&self, url: &str) -> Result<(Vec<u8>, DownloadVia), DownloadError> {
let content = self.download(url)?;
Ok((content, self.download_via(url)))
}
fn download_via(&self, _url: &str) -> DownloadVia {
DownloadVia::Direct
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DownloadVia {
Direct,
Proxy,
Unknown,
}
impl DownloadVia {
pub const fn as_str(self) -> &'static str {
match self {
Self::Direct => "direct",
Self::Proxy => "proxy",
Self::Unknown => "unknown",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DownloadError(String);
impl DownloadError {
fn new(message: impl Into<String>) -> Self {
Self(message.into())
}
}
impl fmt::Display for DownloadError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.0)
}
}
impl std::error::Error for DownloadError {}
#[derive(Debug, Clone)]
pub struct SecureDownloader {
connect_timeout: Duration,
request_timeout: Duration,
max_bytes: usize,
redirect_limit: usize,
user_agent: String,
}
impl Default for SecureDownloader {
fn default() -> Self {
Self {
connect_timeout: DEFAULT_CONNECT_TIMEOUT,
request_timeout: DEFAULT_REQUEST_TIMEOUT,
max_bytes: DEFAULT_MAX_DOWNLOAD_BYTES,
redirect_limit: DEFAULT_REDIRECT_LIMIT,
user_agent: CLASH_USER_AGENT.into(),
}
}
}
impl SecureDownloader {
pub fn with_limits(
connect_timeout: Duration,
request_timeout: Duration,
max_bytes: usize,
redirect_limit: usize,
) -> Self {
Self {
connect_timeout,
request_timeout,
max_bytes,
redirect_limit,
user_agent: CLASH_USER_AGENT.into(),
}
}
pub fn for_family(family: &str) -> Self {
Self {
user_agent: match family {
"sing-box" => SING_BOX_USER_AGENT,
_ => CLASH_USER_AGENT,
}
.into(),
..Self::default()
}
}
fn client_for(&self, url: &Url, proxy: Option<&str>) -> Result<Client, DownloadError> {
let host = validated_host(url)?;
let port = url
.port_or_known_default()
.ok_or_else(|| DownloadError::new("download URL has no usable port"))?;
let addresses = resolve_public(&host, port)?;
let socket_addresses: Vec<_> = addresses
.into_iter()
.map(|address| SocketAddr::new(address, port))
.collect();
let mut builder = ClientBuilder::new()
// Match mh's curl behavior while keeping proxy selection explicit.
.no_proxy()
.redirect(Policy::none())
.connect_timeout(self.connect_timeout)
.timeout(self.request_timeout)
.resolve_to_addrs(&host, &socket_addresses)
.user_agent(&self.user_agent);
if let Some(proxy) = proxy {
let proxy = reqwest::Proxy::all(proxy)
.map_err(|_| DownloadError::new("invalid download proxy"))?;
builder = builder.proxy(proxy);
}
builder
.build()
.map_err(|_| DownloadError::new("failed to initialize secure downloader"))
}
fn parse_url(value: &str) -> Result<Url, DownloadError> {
let url = Url::parse(value).map_err(|_| DownloadError::new("invalid download URL"))?;
validate_url(&url)?;
Ok(url)
}
}
impl ProfileSource for SecureDownloader {
fn download(&self, value: &str) -> Result<Vec<u8>, DownloadError> {
self.download_with_route(value).map(|(content, _)| content)
}
fn download_with_route(&self, value: &str) -> Result<(Vec<u8>, DownloadVia), DownloadError> {
let parsed = Self::parse_url(value)?;
let configured_proxy = proxy_for(&parsed);
let candidates = configured_proxy.as_deref().map_or_else(
|| vec![(DownloadVia::Direct, None)],
|proxy| {
vec![
(DownloadVia::Proxy, Some(proxy)),
(DownloadVia::Direct, None),
]
},
);
let mut last_error = None;
for (route, proxy) in candidates {
match self.download_once(value, proxy) {
Ok(content) => return Ok((content, route)),
Err(error) => last_error = Some(error),
}
}
Err(last_error.unwrap_or_else(|| DownloadError::new("download failed")))
}
fn download_via(&self, value: &str) -> DownloadVia {
Self::parse_url(value)
.ok()
.and_then(|url| proxy_for(&url))
.map(|_| DownloadVia::Proxy)
.unwrap_or(DownloadVia::Direct)
}
}
impl SecureDownloader {
fn download_once(&self, value: &str, proxy: Option<&str>) -> Result<Vec<u8>, DownloadError> {
let mut url = Self::parse_url(value)?;
for redirects in 0..=self.redirect_limit {
let redirect_proxy = proxy.and_then(|_| proxy_for(&url));
let client = self.client_for(&url, redirect_proxy.as_deref())?;
let mut response = client.get(url.clone()).send().map_err(|_| {
DownloadError::new(format!("request to {} failed", redact_url(&url)))
})?;
if response.status().is_redirection() {
if redirects == self.redirect_limit {
return Err(DownloadError::new("download redirect limit exceeded"));
}
let location = response
.headers()
.get(LOCATION)
.and_then(|value| value.to_str().ok())
.ok_or_else(|| DownloadError::new("redirect response has no valid Location"))?;
url = url
.join(location)
.map_err(|_| DownloadError::new("redirect has an invalid Location"))?;
validate_url(&url)?;
continue;
}
if !response.status().is_success() {
return Err(DownloadError::new(format!(
"request to {} returned HTTP {}",
redact_url(&url),
response.status().as_u16()
)));
}
if response
.headers()
.get(CONTENT_LENGTH)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.parse::<u64>().ok())
.is_some_and(|length| length > self.max_bytes as u64)
{
return Err(DownloadError::new("download exceeds maximum allowed size"));
}
let mut content = Vec::new();
io::Read::read_to_end(
&mut io::Read::take(&mut response, self.max_bytes as u64 + 1),
&mut content,
)
.map_err(|_| DownloadError::new("failed while reading download response"))?;
if content.len() > self.max_bytes {
return Err(DownloadError::new("download exceeds maximum allowed size"));
}
return Ok(content);
}
unreachable!("redirect loop always returns")
}
}
fn proxy_for(url: &Url) -> Option<String> {
let host = url.host_str()?;
let no_proxy = env::var("NO_PROXY").or_else(|_| env::var("no_proxy")).ok();
if no_proxy
.as_deref()
.is_some_and(|items| no_proxy_matches(items, host))
{
return None;
}
let keys = if url.scheme() == "https" {
["HTTPS_PROXY", "https_proxy", "ALL_PROXY", "all_proxy"]
} else {
["HTTP_PROXY", "http_proxy", "ALL_PROXY", "all_proxy"]
};
keys.into_iter()
.find_map(|key| env::var(key).ok().filter(|value| !value.trim().is_empty()))
}
fn no_proxy_matches(value: &str, host: &str) -> bool {
value.split(',').map(str::trim).any(|item| {
if item.is_empty() {
return false;
}
if item == "*" {
return true;
}
let item = item
.rsplit_once(':')
.filter(|(_, suffix)| suffix.parse::<u16>().is_ok())
.map_or(item, |(host, _)| host);
let item = item.trim_start_matches('.').trim_matches(['[', ']']);
let host = host.trim_matches(['[', ']']);
host.eq_ignore_ascii_case(item) || host.to_ascii_lowercase().ends_with(&format!(".{item}"))
})
}
pub fn validate_url(url: &Url) -> Result<(), DownloadError> {
if !matches!(url.scheme(), "http" | "https") {
return Err(DownloadError::new("only HTTP(S) download URLs are allowed"));
}
if !url.username().is_empty() || url.password().is_some() {
return Err(DownloadError::new(
"download URL must not include user credentials",
));
}
if url.host_str().is_none() {
return Err(DownloadError::new("download URL must include a host"));
}
validated_host(url).map(|_| ())
}
fn validated_host(url: &Url) -> Result<String, DownloadError> {
let host = url
.host_str()
.ok_or_else(|| DownloadError::new("download URL must include a host"))?;
let normalized = host.trim_end_matches('.').to_ascii_lowercase();
if normalized == "localhost" || normalized.ends_with(".localhost") {
return Err(DownloadError::new("download host is not publicly routable"));
}
if let Ok(address) = normalized.parse::<IpAddr>()
&& !is_public_ip(address)
{
return Err(DownloadError::new("download host is not publicly routable"));
}
Ok(normalized)
}
fn resolve_public(host: &str, port: u16) -> Result<Vec<IpAddr>, DownloadError> {
let addresses: Vec<_> = (host, port)
.to_socket_addrs()
.map_err(|_| DownloadError::new("download host DNS resolution failed"))?
.map(|address| address.ip())
.collect();
if addresses.is_empty() {
return Err(DownloadError::new(
"download host DNS returned no addresses",
));
}
if addresses.iter().any(|address| !is_public_ip(*address)) {
return Err(DownloadError::new(
"download host DNS returned a non-public address",
));
}
Ok(addresses)
}
pub fn is_public_ip(address: IpAddr) -> bool {
match address {
IpAddr::V4(address) => is_public_v4(address),
IpAddr::V6(address) => is_public_v6(address),
}
}
fn is_public_v4(address: Ipv4Addr) -> bool {
let value = u32::from(address);
!address.is_unspecified()
&& !address.is_loopback()
&& !address.is_private()
&& !address.is_link_local()
&& !address.is_multicast()
&& !address.is_broadcast()
&& !in_v4(value, [0, 0, 0, 0], 8)
&& !in_v4(value, [100, 64, 0, 0], 10)
&& !in_v4(value, [192, 0, 0, 0], 24)
&& !in_v4(value, [192, 0, 2, 0], 24)
&& !in_v4(value, [198, 18, 0, 0], 15)
&& !in_v4(value, [198, 51, 100, 0], 24)
&& !in_v4(value, [203, 0, 113, 0], 24)
&& !in_v4(value, [240, 0, 0, 0], 4)
}
fn in_v4(value: u32, network: [u8; 4], prefix: u32) -> bool {
let mask = u32::MAX.checked_shl(32 - prefix).unwrap_or(0);
value & mask == u32::from(Ipv4Addr::from(network)) & mask
}
fn is_public_v6(address: Ipv6Addr) -> bool {
if let Some(mapped) = address.to_ipv4_mapped() {
return is_public_v4(mapped);
}
let segments = address.segments();
!(address.is_unspecified()
|| address.is_loopback()
|| address.is_multicast()
|| segments[0] & 0xfe00 == 0xfc00
|| segments[0] & 0xffc0 == 0xfe80
|| (segments[0] == 0x2001 && segments[1] == 0x0db8)
|| (segments[0] == 0x0100 && segments[1] == 0 && segments[2] == 0 && segments[3] == 0))
}
pub fn redact_url(url: &Url) -> String {
let host = url.host_str().unwrap_or("<invalid-host>");
let port = url
.port()
.map(|port| format!(":{port}"))
.unwrap_or_default();
format!("{}://{host}{port}/<redacted>", url.scheme())
}
#[cfg(test)]
mod tests {
use super::{
CLASH_USER_AGENT, SING_BOX_USER_AGENT, SecureDownloader, is_public_ip, redact_url,
validate_url,
};
use reqwest::Url;
use std::net::IpAddr;
#[test]
fn rejects_local_private_reserved_and_documentation_addresses() {
for address in [
"0.1.2.3",
"127.0.0.1",
"10.0.0.1",
"169.254.1.1",
"100.64.0.1",
"192.0.2.1",
"198.18.0.1",
"224.0.0.1",
"240.0.0.1",
"::1",
"fc00::1",
"fe80::1",
"2001:db8::1",
"::ffff:127.0.0.1",
] {
assert!(
!is_public_ip(address.parse::<IpAddr>().unwrap()),
"{address}"
);
}
assert!(is_public_ip("1.1.1.1".parse().unwrap()));
assert!(is_public_ip("2606:4700:4700::1111".parse().unwrap()));
}
#[test]
fn rejects_non_http_and_localhost_urls() {
assert!(validate_url(&Url::parse("file:///etc/passwd").unwrap()).is_err());
assert!(validate_url(&Url::parse("http://localhost/sub").unwrap()).is_err());
assert!(validate_url(&Url::parse("https://api.localhost./sub").unwrap()).is_err());
assert!(validate_url(&Url::parse("https://127.0.0.1/sub").unwrap()).is_err());
assert!(validate_url(&Url::parse("https://user:pass@example.com/sub").unwrap()).is_err());
}
#[test]
fn redaction_removes_credentials_path_query_and_fragment() {
let url = Url::parse("https://user:pass@example.com:8443/private?token=secret#x").unwrap();
let redacted = redact_url(&url);
assert_eq!(redacted, "https://example.com:8443/<redacted>");
assert!(!redacted.contains("secret"));
assert!(!redacted.contains("user"));
}
#[test]
fn selects_provider_user_agent_by_family() {
assert_eq!(
SecureDownloader::for_family("clash").user_agent,
CLASH_USER_AGENT
);
assert_eq!(
SecureDownloader::for_family("sing-box").user_agent,
SING_BOX_USER_AGENT
);
}
}

356
src/platform/paths.rs Normal file
View file

@ -0,0 +1,356 @@
use std::{
env,
error::Error,
fmt, fs, io,
path::{Path, PathBuf},
};
use serde::{Deserialize, Serialize};
const PATHS_FILE_ENV: &str = "TZ_PATHS_TOML";
const DEFAULT_PATHS_FILE: &str = ".config/tz/paths.toml";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PathsFile {
pub layout: LayoutFile,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LayoutFile {
pub config_dir: String,
pub data_dir: String,
pub state_dir: String,
pub cache_dir: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AppPaths {
pub config_dir: PathBuf,
pub data_dir: PathBuf,
pub state_dir: PathBuf,
pub cache_dir: PathBuf,
}
#[derive(Debug)]
pub enum PathError {
HomeNotFound,
Io(io::Error),
InvalidPath(String),
InvalidFile(String),
NotInitialized { checked: Vec<PathBuf> },
}
impl fmt::Display for PathError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::HomeNotFound => write!(f, "cannot determine user home directory"),
Self::Io(error) => write!(f, "path operation failed: {error}"),
Self::InvalidPath(error) => write!(f, "invalid path: {error}"),
Self::InvalidFile(error) => write!(f, "invalid paths.toml: {error}"),
Self::NotInitialized { checked } => {
write!(f, "tz is not initialized; checked: ")?;
for (index, path) in checked.iter().enumerate() {
if index > 0 {
write!(f, ", ")?;
}
write!(f, "{}", path.display())?;
}
write!(f, "; run tz init or check TZ_PATHS_TOML")
}
}
}
}
impl Error for PathError {}
impl From<io::Error> for PathError {
fn from(error: io::Error) -> Self {
Self::Io(error)
}
}
pub fn paths_file() -> Result<PathBuf, PathError> {
if let Some(path) = env::var_os(PATHS_FILE_ENV).filter(|value| !value.is_empty()) {
let path = PathBuf::from(path);
if !path.is_absolute() {
return Err(PathError::InvalidPath(format!(
"{PATHS_FILE_ENV} must be an absolute path"
)));
}
return Ok(path);
}
Ok(home_dir()?.join(DEFAULT_PATHS_FILE))
}
pub fn resolve_paths() -> Result<AppPaths, PathError> {
let file = paths_file()?;
if !file.is_file() {
return Err(PathError::NotInitialized {
checked: vec![file],
});
}
load_paths_file(&file)
}
/// 加载路径,未初始化时返回错误而不是回退默认值。
/// cli::run 在调用前已先 resolve_paths 做过校验,因此这里通常不会报错。
pub fn load_or_fail() -> Result<AppPaths, PathError> {
resolve_paths()
}
pub fn load_paths_file(file: &Path) -> Result<AppPaths, PathError> {
let content = fs::read_to_string(file)?;
let parsed: PathsFile =
toml::from_str(&content).map_err(|error| PathError::InvalidFile(error.to_string()))?;
AppPaths::from_layout_file(parsed.layout)
}
pub fn save_paths_file(file: &Path, paths: &AppPaths) -> Result<(), PathError> {
if let Some(parent) = file.parent() {
fs::create_dir_all(parent)?;
}
let home = home_dir()?;
let document = PathsFile {
layout: LayoutFile {
config_dir: display_path(&paths.config_dir, &home),
data_dir: display_path(&paths.data_dir, &home),
state_dir: display_path(&paths.state_dir, &home),
cache_dir: display_path(&paths.cache_dir, &home),
},
};
let content = toml::to_string_pretty(&document)
.map_err(|error| PathError::InvalidFile(error.to_string()))?;
fs::write(file, content)?;
Ok(())
}
fn display_path(path: &Path, home: &Path) -> String {
path.strip_prefix(home)
.map(|relative| format!("~/{}", relative.display()))
.unwrap_or_else(|_| path.display().to_string())
}
impl AppPaths {
pub fn from_layout_file(layout: LayoutFile) -> Result<Self, PathError> {
Ok(Self {
config_dir: expand_path(&layout.config_dir)?,
data_dir: expand_path(&layout.data_dir)?,
state_dir: expand_path(&layout.state_dir)?,
cache_dir: expand_path(&layout.cache_dir)?,
})
}
pub fn from_layout(layout: LayoutFile) -> Result<Self, PathError> {
Self::from_layout_file(layout)
}
/// 从当前进程环境加载路径配置。未初始化时返回错误。
pub fn from_env_or_none() -> Result<Option<Self>, PathError> {
match resolve_paths() {
Ok(paths) => Ok(Some(paths)),
Err(PathError::NotInitialized { .. }) => Ok(None),
Err(error) => Err(error),
}
}
pub fn unified(root: impl Into<PathBuf>) -> Self {
let root = root.into();
Self {
config_dir: root.join("config"),
data_dir: root.join("data"),
state_dir: root.join("state"),
cache_dir: root.join("cache"),
}
}
pub fn ensure_dirs(&self) -> io::Result<()> {
for directory in [
&self.config_dir,
&self.data_dir,
&self.state_dir,
&self.cache_dir,
&self.profiles_dir(),
&self.cores_dir(),
&self.generated_dir(),
&self.runtime_dir(),
&self.logs_dir(),
&self.downloads_dir(),
&self.speedtest_dir(),
] {
fs::create_dir_all(directory)?;
}
Ok(())
}
pub fn is_initialized(&self) -> bool {
self.settings_file().is_file()
}
pub fn initialize_files(&self) -> io::Result<()> {
self.ensure_dirs()?;
// bypass.list 没有 domain 结构体,直接写占位。
write_if_missing(
&self.bypass_file(),
"# One domain, host, or CIDR per line.\n",
)?;
Ok(())
}
pub fn settings_file(&self) -> PathBuf {
self.config_dir.join("settings.toml")
}
pub fn runtime_file(&self) -> PathBuf {
self.config_dir.join("runtime.toml")
}
pub fn bypass_file(&self) -> PathBuf {
self.config_dir.join("bypass.list")
}
pub fn profiles_dir(&self) -> PathBuf {
self.data_dir.join("profiles")
}
pub fn profiles_file(&self) -> PathBuf {
self.profiles_dir().join("profiles.toml")
}
pub fn cores_dir(&self) -> PathBuf {
self.data_dir.join("cores")
}
pub fn active_file(&self) -> PathBuf {
self.state_dir.join("active.toml")
}
pub fn generated_dir(&self) -> PathBuf {
self.state_dir.join("generated")
}
/// state/runtime/<core>/core 的工作目录({workdir} 展开目标),
/// 避免 cache.db 等运行副产物污染 generated/<core>/。
pub fn core_workdir(&self, core_name: &str) -> PathBuf {
self.runtime_dir().join(core_name)
}
pub fn runtime_dir(&self) -> PathBuf {
self.state_dir.join("runtime")
}
pub fn core_pid_file(&self) -> PathBuf {
self.runtime_dir().join("core.pid")
}
pub fn lock_file(&self) -> PathBuf {
self.runtime_dir().join("tz.lock")
}
pub fn logs_dir(&self) -> PathBuf {
self.state_dir.join("logs")
}
pub fn tz_log_file(&self) -> PathBuf {
self.logs_dir().join("tz.log")
}
pub fn core_log_file(&self) -> PathBuf {
self.logs_dir().join("core.log")
}
pub fn downloads_dir(&self) -> PathBuf {
self.cache_dir.join("downloads")
}
pub fn speedtest_dir(&self) -> PathBuf {
self.cache_dir.join("speedtest")
}
}
fn expand_path(value: &str) -> Result<PathBuf, PathError> {
let path = if value == "~" {
home_dir()?
} else if let Some(relative) = value.strip_prefix("~/") {
home_dir()?.join(relative)
} else {
PathBuf::from(value)
};
if !path.is_absolute() {
return Err(PathError::InvalidPath(format!(
"path must be absolute or start with ~/ (got {value})"
)));
}
Ok(path)
}
fn home_dir() -> Result<PathBuf, PathError> {
env::var_os("HOME")
.filter(|home| !home.is_empty())
.map(PathBuf::from)
.ok_or(PathError::HomeNotFound)
}
fn write_if_missing(path: &Path, content: &str) -> io::Result<()> {
if !path.exists() {
fs::write(path, content)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::{AppPaths, LayoutFile, load_paths_file, save_paths_file};
use std::path::PathBuf;
#[test]
fn paths_file_expands_home_prefix() {
let home = std::env::var_os("HOME")
.map(PathBuf::from)
.expect("test HOME should be set");
let file = unique_temp_path("tz-paths");
std::fs::write(
&file,
"[layout]\nconfig_dir = \"~/config\"\ndata_dir = \"/tmp/data\"\nstate_dir = \"/tmp/state\"\ncache_dir = \"/tmp/cache\"\n",
)
.expect("write paths fixture");
let paths = load_paths_file(&file).expect("paths should load");
assert_eq!(paths.config_dir, home.join("config"));
std::fs::remove_file(file).expect("remove paths fixture");
}
#[test]
fn paths_file_round_trips() {
let root = unique_temp_path("tz-roundtrip");
let paths = AppPaths::unified(&root);
let file = root.join("paths.toml");
save_paths_file(&file, &paths).expect("paths should save");
let loaded = load_paths_file(&file).expect("paths should load");
assert_eq!(loaded, paths);
std::fs::remove_dir_all(root).expect("remove fixture");
}
#[test]
fn missing_layout_field_is_rejected() {
let file = unique_temp_path("tz-invalid-paths");
std::fs::write(&file, "[layout]\nconfig_dir = \"/tmp/config\"\n")
.expect("write invalid fixture");
assert!(load_paths_file(&file).is_err());
std::fs::remove_file(file).expect("remove invalid fixture");
}
#[test]
fn layout_file_has_four_fixed_fields() {
let layout = LayoutFile {
config_dir: "/tmp/config".into(),
data_dir: "/tmp/data".into(),
state_dir: "/tmp/state".into(),
cache_dir: "/tmp/cache".into(),
};
let paths = AppPaths::from_layout(layout).expect("layout should load");
assert_eq!(paths.cache_dir, PathBuf::from("/tmp/cache"));
}
fn unique_temp_path(prefix: &str) -> PathBuf {
std::env::temp_dir().join(format!("{prefix}-{}", std::process::id()))
}
}

149
src/platform/process.rs Normal file
View file

@ -0,0 +1,149 @@
use std::{
fs, io,
os::unix::fs::MetadataExt,
path::{Path, PathBuf},
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ManagedProcess {
NotRunning,
Running(i32),
Stale(i32),
}
pub fn managed_process(pid_file: &Path) -> Result<ManagedProcess, io::Error> {
let Some(pid) = read_pid(pid_file)? else {
return Ok(ManagedProcess::NotRunning);
};
if is_process_alive(pid) {
Ok(ManagedProcess::Running(pid))
} else {
Ok(ManagedProcess::Stale(pid))
}
}
pub fn ensure_not_running(pid_file: &Path) -> Result<(), io::Error> {
match managed_process(pid_file)? {
ManagedProcess::Running(pid) => Err(io::Error::new(
io::ErrorKind::WouldBlock,
format!("managed core is running with pid {pid}"),
)),
ManagedProcess::NotRunning | ManagedProcess::Stale(_) => Ok(()),
}
}
pub fn read_pid(file: &Path) -> Result<Option<i32>, io::Error> {
if !file.is_file() {
return Ok(None);
}
let content = fs::read_to_string(file)?;
let trimmed = content.trim();
if trimmed.is_empty() {
return Ok(None);
}
let pid = trimmed
.parse::<i32>()
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error.to_string()))?;
if pid <= 0 {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("invalid managed PID `{pid}` in {}", file.display()),
));
}
Ok(Some(pid))
}
pub fn is_process_alive(pid: i32) -> bool {
if unsafe { libc_kill(pid, 0) } != 0 {
return false;
}
!matches!(process_state(pid), Ok('Z'))
}
pub fn process_executable(pid: i32) -> Result<PathBuf, io::Error> {
fs::read_link(format!("/proc/{pid}/exe"))
}
pub fn ensure_owned_process(pid: i32, expected_binary: &Path) -> Result<(), io::Error> {
let process_dir = PathBuf::from(format!("/proc/{pid}"));
let owner = fs::metadata(&process_dir)?.uid();
if owner != effective_uid() {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
format!("pid {pid} 不属于当前用户,拒绝停止"),
));
}
let actual = process_executable(pid)?;
let expected = fs::canonicalize(expected_binary)?;
if actual != expected {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"pid {pid} 不是受管 core实际 {},预期 {}",
actual.display(),
expected.display()
),
));
}
Ok(())
}
pub fn terminate_process(pid: i32, force: bool) -> Result<(), io::Error> {
let signal = if force { 9 } else { 15 };
if unsafe { libc_kill(pid, signal) } == 0 {
Ok(())
} else {
Err(io::Error::last_os_error())
}
}
fn process_state(pid: i32) -> Result<char, io::Error> {
let stat = fs::read_to_string(format!("/proc/{pid}/stat"))?;
let fields = stat
.rsplit_once(')')
.map(|(_, fields)| fields.trim_start())
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "invalid /proc stat"))?;
fields
.chars()
.next()
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "missing process state"))
}
unsafe fn libc_kill(pid: i32, sig: i32) -> i32 {
unsafe extern "C" {
fn kill(pid: i32, sig: i32) -> i32;
}
unsafe { kill(pid, sig) }
}
fn effective_uid() -> u32 {
unsafe extern "C" {
fn geteuid() -> u32;
}
unsafe { geteuid() }
}
#[cfg(test)]
mod tests {
use super::{ManagedProcess, managed_process, read_pid};
use tempfile::tempdir;
#[test]
fn rejects_non_positive_pid() {
let root = tempdir().unwrap();
let file = root.path().join("core.pid");
std::fs::write(&file, "-1\n").unwrap();
assert!(read_pid(&file).is_err());
}
#[test]
fn detects_current_process() {
let root = tempdir().unwrap();
let file = root.path().join("core.pid");
std::fs::write(&file, std::process::id().to_string()).unwrap();
assert_eq!(
managed_process(&file).unwrap(),
ManagedProcess::Running(std::process::id() as i32)
);
}
}

117
src/platform/storage.rs Normal file
View file

@ -0,0 +1,117 @@
use fs2::FileExt;
use std::{
fs::{self, File, OpenOptions, Permissions},
io::{self, Write},
os::unix::fs::PermissionsExt,
path::Path,
};
use tempfile::NamedTempFile;
#[derive(Debug)]
pub struct AppLock {
file: File,
}
impl AppLock {
pub fn acquire(path: &Path) -> Result<Self, io::Error> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let mut file = OpenOptions::new()
.create(true)
.read(true)
.write(true)
.truncate(false)
.open(path)?;
file.try_lock_exclusive().map_err(|error| {
if error.kind() == io::ErrorKind::WouldBlock {
io::Error::new(
io::ErrorKind::WouldBlock,
format!("another tz operation holds {}", path.display()),
)
} else {
error
}
})?;
file.set_len(0)?;
writeln!(file, "{}", std::process::id())?;
file.sync_data()?;
Ok(Self { file })
}
}
impl Drop for AppLock {
fn drop(&mut self) {
let _ = self.file.unlock();
}
}
pub fn atomic_write(path: &Path, content: &[u8]) -> Result<(), io::Error> {
atomic_write_with_mode(path, content, 0o644)
}
pub fn atomic_write_private(path: &Path, content: &[u8]) -> Result<(), io::Error> {
atomic_write_with_mode(path, content, 0o600)
}
fn atomic_write_with_mode(path: &Path, content: &[u8], mode: u32) -> Result<(), io::Error> {
let parent = path.parent().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("path has no parent: {}", path.display()),
)
})?;
fs::create_dir_all(parent)?;
let mut temporary = NamedTempFile::new_in(parent)?;
temporary
.as_file()
.set_permissions(Permissions::from_mode(mode))?;
temporary.write_all(content)?;
temporary.flush()?;
temporary.as_file().sync_all()?;
temporary.persist(path).map_err(|error| error.error)?;
File::open(parent)?.sync_all()?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::{AppLock, atomic_write, atomic_write_private};
use std::{fs, os::unix::fs::PermissionsExt};
use tempfile::tempdir;
#[test]
fn atomically_replaces_existing_content() {
let root = tempdir().unwrap();
let file = root.path().join("config.toml");
atomic_write(&file, b"old").unwrap();
atomic_write(&file, b"new").unwrap();
assert_eq!(fs::read(&file).unwrap(), b"new");
}
#[test]
fn private_write_uses_user_only_permissions() {
let root = tempdir().unwrap();
let file = root.path().join("profiles.toml");
atomic_write_private(&file, b"secret").unwrap();
assert_eq!(
fs::metadata(file).unwrap().permissions().mode() & 0o777,
0o600
);
}
#[test]
fn second_lock_is_rejected_until_drop() {
let root = tempdir().unwrap();
let path = root.path().join("tz.lock");
let lock = AppLock::acquire(&path).unwrap();
assert_eq!(
AppLock::acquire(&path).unwrap_err().kind(),
std::io::ErrorKind::WouldBlock
);
drop(lock);
AppLock::acquire(&path).unwrap();
}
}