Initial release v0.1.0
This commit is contained in:
commit
f3ad51ae68
62 changed files with 56224 additions and 0 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
target/
|
||||||
|
.**/
|
||||||
|
profile/
|
||||||
1956
Cargo.lock
generated
Normal file
1956
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
15
Cargo.toml
Normal file
15
Cargo.toml
Normal 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
188
README-en.md
Normal 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
188
README.md
Normal 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
8
TZ.code-workspace
Executable file
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"folders": [
|
||||||
|
{
|
||||||
|
"path": ".",
|
||||||
|
"name": "TZ",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
BIN
cores/mihomo/Country.mmdb
Normal file
BIN
cores/mihomo/Country.mmdb
Normal file
Binary file not shown.
41655
cores/mihomo/GeoSite.dat
Normal file
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
30
cores/mihomo/core.toml
Normal 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
BIN
cores/mihomo/mihomo
Executable file
Binary file not shown.
30
cores/sing-box/core.toml
Normal file
30
cores/sing-box/core.toml
Normal 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
BIN
cores/sing-box/sing-box
Executable file
Binary file not shown.
189
docs/COMMAND_ARCHITECTURE.md
Normal file
189
docs/COMMAND_ARCHITECTURE.md
Normal 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-box,mihomo。用*指出当前内核,选择可换
|
||||||
|
| |-- 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
102
docs/control-interface.md
Normal 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-Agent:Clash 对齐 `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
96
docs/core-package.md
Normal 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
10
docs/scope.md
Normal 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 在线安装或自动更新。
|
||||||
976
docs/第一次开发日志.md
Normal file
976
docs/第一次开发日志.md
Normal 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 ,需要展示当前使用的core,profile,节点及其测速
|
||||||
|
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、原子 rename;profile 索引和 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/remove:URL 与本地文件都变成受管副本,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-Agent,sing-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
386
docs/第一版本.md
Normal 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
|
||||||
|
└── 发布与安装脚本
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
9
examples/cores/mihomo/README.md
Normal file
9
examples/cores/mihomo/README.md
Normal 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.
|
||||||
30
examples/cores/mihomo/core.toml
Normal file
30
examples/cores/mihomo/core.toml
Normal 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"]
|
||||||
677
profiles/nano-clash/source.yaml
Normal file
677
profiles/nano-clash/source.yaml
Normal 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,🚀 节点选择'
|
||||||
1
profiles/nano-sb/source.json
Normal file
1
profiles/nano-sb/source.json
Normal file
File diff suppressed because one or more lines are too long
52
profiles/profiles.toml
Normal file
52
profiles/profiles.toml
Normal 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(通用)"
|
||||||
1535
profiles/tnt_luo-clash/source.yaml
Normal file
1535
profiles/tnt_luo-clash/source.yaml
Normal file
File diff suppressed because it is too large
Load diff
269
refers/mh
Executable file
269
refers/mh
Executable 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
684
refers/sb
Executable 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
515
src/application/config.rs
Normal 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
575
src/application/core.rs
Normal 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
247
src/application/init.rs
Normal 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
14
src/application/mod.rs
Normal 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
552
src/application/profile.rs
Normal 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
455
src/application/proxy.rs
Normal 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
742
src/application/service.rs
Normal 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
397
src/application/setting.rs
Normal 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
137
src/application/tun.rs
Normal 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
334
src/cli/args.rs
Normal 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, .. }
|
||||||
|
})
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
143
src/cli/commands/completion.rs
Normal file
143
src/cli/commands/completion.rs
Normal 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'
|
||||||
|
"#
|
||||||
|
}
|
||||||
35
src/cli/commands/config.rs
Normal file
35
src/cli/commands/config.rs
Normal 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
174
src/cli/commands/core.rs
Normal 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("选择 core(0 保持当前): ")?;
|
||||||
|
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("选择 core(0 取消): ")?;
|
||||||
|
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
3
src/cli/commands/init.rs
Normal 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
11
src/cli/commands/mod.rs
Normal 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
22
src/cli/commands/node.rs
Normal 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
228
src/cli/commands/profile.rs
Normal 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("选择 profile(0 保持当前): ", 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!("选择 profile(0 取消): ");
|
||||||
|
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
37
src/cli/commands/proxy.rs
Normal 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",
|
||||||
|
}
|
||||||
|
}
|
||||||
21
src/cli/commands/service.rs
Normal file
21
src/cli/commands/service.rs
Normal 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)
|
||||||
|
}
|
||||||
28
src/cli/commands/setting.rs
Normal file
28
src/cli/commands/setting.rs
Normal 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`。",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
13
src/cli/commands/status.rs
Normal file
13
src/cli/commands/status.rs
Normal 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
11
src/cli/commands/tun.rs
Normal 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
74
src/cli/mod.rs
Normal 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
123
src/domain/active.rs
Normal 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
492
src/domain/core_manifest.rs
Normal 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
14
src/domain/mod.rs
Normal 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
317
src/domain/profiles.rs
Normal 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
161
src/domain/runtime.rs
Normal 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
158
src/domain/settings.rs
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::{fs, io, path::Path};
|
||||||
|
|
||||||
|
const SCHEMA_VERSION: u32 = 1;
|
||||||
|
|
||||||
|
/// settings.toml:tz 软件自身的全局策略,与 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
4
src/lib.rs
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
pub mod application;
|
||||||
|
pub mod cli;
|
||||||
|
pub mod domain;
|
||||||
|
pub mod platform;
|
||||||
10
src/main.rs
Normal file
10
src/main.rs
Normal 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
15
src/platform/mod.rs
Normal 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
451
src/platform/network.rs
Normal 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
356
src/platform/paths.rs
Normal 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
149
src/platform/process.rs
Normal 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
117
src/platform/storage.rs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue