重写为多协议管理器: 6 协议, CLI + curses TUI, 用户级 systemd
This commit is contained in:
1 parent
c43ab4a16e
commit
237b958e41
71 files changed
+4599
-1171
No files matched your search
@@ -0,0 +1,5 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
@@ -1,112 +1,207 @@
|
||||
# sbp
|
||||
|
||||
`sbp` 在用户级别搭建和管理 sing-box Hysteria2 服务. 不需要 root. 服务由 systemd user 管理.
|
||||
`sbp` 在用户级别安装和管理 sing-box 代理服务. 不需要 root. 服务由 systemd user 管理.
|
||||
它提供六个协议, 一个命令行接口, 和一个全屏 TUI.
|
||||
|
||||
## 部署到 VPS
|
||||
## 安装
|
||||
|
||||
一条命令下载并安装 `sbp`:
|
||||
一条命令安装 `sbp` 命令:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/justypist/sbp/main/install.sh | bash
|
||||
sbp install
|
||||
sbp
|
||||
```
|
||||
|
||||
下载后直接开始搭建:
|
||||
`install.sh` 把程序放到 `~/.local/share/sbp/app`, 并建立 `~/.local/bin/sbp` 符号链接.
|
||||
环境变量 `SBP_BIN` 改变命令目录. `SBP_APP_DIR` 改变程序目录.
|
||||
|
||||
安装后运行 `sbp install` 搭建服务. 也可以让安装脚本直接搭建:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/justypist/sbp/main/install.sh | bash -s -- --run
|
||||
```
|
||||
|
||||
`install.sh` 只安装 `sbp` 命令. 环境变量 `SBP_BIN` 可以改变安装目录 (默认 `~/.local/bin`).
|
||||
依赖: Linux, systemd user, openssl, python3 3.9 或更高版本, curl. 网络必须能访问 GitHub.
|
||||
|
||||
也可以手动复制:
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
install -m 755 sbp ~/.local/bin/sbp
|
||||
sbp --version
|
||||
sbp install # 安装全部六个协议, 自动分配端口, 生成用户 u1
|
||||
sbp # 打开 TUI
|
||||
sbp status # 查看状态
|
||||
sbp user show u1 # 打印 u1 的全部分享链接
|
||||
```
|
||||
|
||||
依赖: Linux, systemd user, openssl, python3 (标准库). 网络可访问 GitHub.
|
||||
## 协议
|
||||
|
||||
## 用法
|
||||
| 协议 | 传输 | 证书 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| Hysteria2 | UDP | 需要 | 默认开启 salamander 混淆 |
|
||||
| VLESS + REALITY | TCP | 不需要 | 借用真实网站的 TLS 握手 |
|
||||
| Trojan | TCP | 需要 | 经典 TLS 代理 |
|
||||
| Shadowsocks 2022 | TCP | 不需要 | 每个用户独立密钥 |
|
||||
| TUIC v5 | UDP | 需要 | QUIC 传输 |
|
||||
| AnyTLS | TCP | 需要 | 使用填充方案 |
|
||||
|
||||
默认安装全部协议. 用 `--protocols` 选择子集:
|
||||
|
||||
```bash
|
||||
sbp install # 下载最新 sing-box, 生成证书和用户, 启动服务, 打印配置
|
||||
sbp install --port 8443 --sni www.example.com --host 203.0.113.10
|
||||
sbp install --protocols hysteria2,vless-reality
|
||||
```
|
||||
|
||||
每个协议使用独立端口. 默认从 `20000` 开始分配空闲端口. 用 `--base-port` 改变起点.
|
||||
|
||||
REALITY 需要一个域名作为握手目标. 默认使用 `--sni` 的值. 如果 `--sni` 是 IP,
|
||||
REALITY 会拒绝启动. 请显式指定域名:
|
||||
|
||||
```bash
|
||||
sbp install --sni www.example.com
|
||||
```
|
||||
|
||||
## 命令行
|
||||
|
||||
### 安装与升级
|
||||
|
||||
```bash
|
||||
sbp install --host 203.0.113.10 --sni www.example.com
|
||||
sbp install --cert fullchain.pem --key privkey.pem --sni example.com
|
||||
sbp new phone # 新增用户并打印配置
|
||||
sbp list # 列出用户和分享链接
|
||||
sbp show phone # 打印用户的完整配置
|
||||
sbp show phone --uri # 只打印分享链接
|
||||
sbp show phone --json # 只打印 sing-box 客户端配置
|
||||
sbp show --cert # 打印服务端证书
|
||||
sbp del phone # 删除用户
|
||||
sbp status # 服务状态
|
||||
sbp log -f # 查看日志
|
||||
sbp install --protocols hysteria2,trojan --base-port 30000
|
||||
sbp upgrade # 升级 sing-box
|
||||
sbp uninstall # 停止并删除服务和程序, 保留配置
|
||||
sbp uninstall # 删除服务和程序, 保留配置
|
||||
sbp uninstall --purge # 同时删除配置和证书
|
||||
```
|
||||
|
||||
`install` 的选项:
|
||||
### 协议
|
||||
|
||||
| 选项 | 默认值 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `--port` | 随机空闲端口 | UDP 监听端口, 必须 >= 1024 |
|
||||
| `--listen` | `::` | 监听地址, 默认同时监听 IPv4 和 IPv6 |
|
||||
| `--sni` | `--host` 是域名时用它, 否则 `www.bing.com` | TLS server name 和证书名 |
|
||||
| `--host` | 自动探测公网 IP | 打印给客户端使用的地址 |
|
||||
| `--cert` / `--key` | 自签名证书 | 使用已有证书, 证书和私钥必须匹配 |
|
||||
| `--masquerade` | `https://<sni>` | 伪装地址 |
|
||||
| `--no-obfs` | 关闭 | 关闭 salamander 混淆 (不推荐) |
|
||||
| `--sing-box-version` | 最新版 | 指定 sing-box 版本 |
|
||||
| `--force` | 关闭 | 重装并保留已有用户 |
|
||||
```bash
|
||||
sbp protocol list
|
||||
sbp protocol enable tuic --port 24000
|
||||
sbp protocol disable tuic
|
||||
sbp protocol port tuic 24001
|
||||
sbp protocol set hysteria2 obfs false
|
||||
sbp protocol set vless-reality short_id 0123456789abcdef
|
||||
```
|
||||
|
||||
## 安全设计
|
||||
### 用户
|
||||
|
||||
- 密码和混淆密码由 `secrets.token_urlsafe(32)` 生成, 每个用户独立.
|
||||
- 默认自签名 ECDSA P-256 证书, 有效期 10 年, 只含一个 SAN.
|
||||
- TLS 最低版本 1.3, ALPN 只用 `h3`.
|
||||
- 客户端配置使用 `certificate_public_key_sha256` 固定证书公钥. 不开启 `insecure`.
|
||||
- 服务以非特权用户运行, 只监听 >= 1024 的端口.
|
||||
- 下载的 sing-box 压缩包与 GitHub 发布的 `sha256` 摘要比对.
|
||||
- systemd 加固: `NoNewPrivileges`, `ProtectSystem=strict`, `ProtectHome=read-only`,
|
||||
`PrivateTmp`, `PrivateDevices`, `ProtectProc=invisible`, `ProcSubset=pid`,
|
||||
`ProtectKernelTunables`, `ProtectKernelModules`, `ProtectKernelLogs`,
|
||||
`RestrictAddressFamilies` (含 `AF_NETLINK`), `RestrictNamespaces`,
|
||||
`MemoryDenyWriteExecute`, `SystemCallFilter=@system-service`, 空 capability 集等.
|
||||
- `InaccessiblePaths` 屏蔽家目录中的常见凭据目录 (`~/.ssh`, `~/.gnupg`, `~/.aws`,
|
||||
`~/.kube`, `~/.docker`, 浏览器配置等). 只屏蔽已存在的路径.
|
||||
- 配置和状态文件权限 `0600`, 证书目录权限 `0700`.
|
||||
- 日志级别 `warn`, 不记录访问日志.
|
||||
```bash
|
||||
sbp user list
|
||||
sbp user add phone
|
||||
sbp user del phone
|
||||
sbp user show phone
|
||||
sbp user show phone --protocol hysteria2 --uri
|
||||
sbp user show phone --protocol hysteria2 --json
|
||||
```
|
||||
|
||||
## 分享链接与 pinSHA256
|
||||
`--json` 输出完整的 sing-box 客户端配置. 客户端保存为 `client.json` 后运行:
|
||||
|
||||
分享链接中的 `pinSHA256` 是证书 DER 的 SHA-256, 十六进制. 这与 Hysteria 2 URI 规范一致.
|
||||
```bash
|
||||
sing-box run -c client.json
|
||||
```
|
||||
|
||||
部分客户端不支持 `pinSHA256`. 这些客户端会拒绝自签名证书. 请改用 `sbp show <name> --json`
|
||||
输出的 sing-box 客户端配置. 不要为了兼容而关闭证书校验.
|
||||
客户端配置包含 `127.0.0.1:1080` 的 mixed 入站. 它同时提供 SOCKS5 和 HTTP 代理.
|
||||
|
||||
客户端配置使用 `certificate_public_key_sha256`. 该字段要求 sing-box 1.13 或更高版本.
|
||||
旧客户端无法解析.
|
||||
### 服务与设置
|
||||
|
||||
```bash
|
||||
sbp service start|stop|restart
|
||||
sbp service logs -n 200
|
||||
sbp settings host 203.0.113.10
|
||||
sbp settings sni www.example.com
|
||||
sbp settings cert --cert fullchain.pem --key privkey.pem --sni example.com
|
||||
sbp cert show
|
||||
sbp cert renew
|
||||
```
|
||||
|
||||
### TUI
|
||||
|
||||
```bash
|
||||
sbp tui
|
||||
```
|
||||
|
||||
TUI 提供这些页面:
|
||||
|
||||
- Dashboard: 状态总览, 启动, 停止, 重启.
|
||||
- Protocols: 启用, 停用, 改端口, 改选项.
|
||||
- Users: 增加, 删除, 查看分享链接和客户端配置.
|
||||
- Settings: 修改 host 和 SNI, 更换证书, 升级, 重装, 卸载.
|
||||
- Logs: 查看 journal.
|
||||
|
||||
操作键: `↑`/`↓` 或 `j`/`k` 移动, `Enter` 确认, `q` 返回.
|
||||
|
||||
## 证书
|
||||
|
||||
默认生成自签名 ECDSA P-256 证书, 有效期 10 年. 客户端配置使用
|
||||
`certificate_public_key_sha256` 固定证书公钥. 分享链接使用 `pinSHA256` 或
|
||||
`allowInsecure=1`.
|
||||
|
||||
自签名证书适合个人使用. 如果需要兼容更多客户端, 使用已有证书:
|
||||
|
||||
```bash
|
||||
sbp settings cert --cert fullchain.pem --key privkey.pem --sni example.com
|
||||
```
|
||||
|
||||
## 文件位置
|
||||
|
||||
| 路径 | 内容 |
|
||||
| --- | --- |
|
||||
| `~/.config/sbp/state.json` | 用户, 端口, 密码, 证书指纹 |
|
||||
| `~/.config/sbp/state.json` | 用户, 协议, 端口, 密钥 |
|
||||
| `~/.config/sbp/sing-box.json` | 生成的 sing-box 服务端配置 |
|
||||
| `~/.config/sbp/certs/` | 证书和私钥 |
|
||||
| `~/.local/share/sbp/bin/sing-box` | sing-box 程序 |
|
||||
| `~/.local/share/sbp/app/` | sbp 程序文件 |
|
||||
| `~/.config/systemd/user/sbp.service` | systemd user 单元 |
|
||||
|
||||
路径遵循 `XDG_CONFIG_HOME` 和 `XDG_DATA_HOME`.
|
||||
|
||||
## 安全设计
|
||||
|
||||
- 密码, UUID, Shadowsocks 密钥由 `secrets` 或 `os.urandom` 生成, 每个用户独立.
|
||||
- 默认自签名 ECDSA P-256 证书, 只含一个 SAN.
|
||||
- TLS 最低版本 1.3, Hysteria2 和 TUIC 只用 `h3` ALPN.
|
||||
- 客户端配置固定证书公钥. 不开启 `insecure`.
|
||||
- 服务以非特权用户运行, 只监听 >= 1024 的端口.
|
||||
- 下载的 sing-box 压缩包与 GitHub 发布的 `sha256` 摘要比对.
|
||||
- 新版本 sing-box 必须通过当前配置的 `check`, 才会替换旧程序.
|
||||
- systemd 加固: `NoNewPrivileges`, `ProtectSystem=strict`, `ProtectHome=read-only`,
|
||||
`PrivateTmp`, `PrivateDevices`, `ProtectProc=invisible`, `ProtectKernelTunables`,
|
||||
`MemoryDenyWriteExecute`, `SystemCallFilter=@system-service`, 空 capability 集.
|
||||
- `InaccessiblePaths` 屏蔽家目录中的常见凭据目录.
|
||||
- 配置和状态文件权限 `0600`, 证书目录权限 `0700`.
|
||||
- 日志级别 `warn`, 不记录访问日志.
|
||||
|
||||
## 代码结构
|
||||
|
||||
```
|
||||
src/sbp/
|
||||
cli.py 命令行入口和参数解析
|
||||
commands/ 每个命令组一个模块
|
||||
state.py 状态模型, JSON 读写, 迁移
|
||||
config.py 生成 sing-box 服务端和客户端配置
|
||||
share.py 生成分享链接
|
||||
protocols/ 每个协议一个模块
|
||||
ops/ 业务操作, CLI 和 TUI 共用
|
||||
tui/ curses 界面
|
||||
```
|
||||
|
||||
协议模块只做三件事: 生成 inbound, 生成分享链接, 生成客户端 outbound.
|
||||
它不读写文件, 不操作服务, 不访问网络. 新增协议只需要在 `protocols/` 增加一个模块,
|
||||
并在 `protocols/__init__.py` 注册.
|
||||
|
||||
## 测试
|
||||
|
||||
```bash
|
||||
PYTHONPATH=src:. python3 -m unittest discover -s tests
|
||||
```
|
||||
|
||||
测试覆盖状态迁移, 配置生成, 分享链接, 端口分配, 以及安装流程.
|
||||
测试不访问网络, 也不依赖已安装的 sing-box.
|
||||
|
||||
## 说明
|
||||
|
||||
- 主机需要公网可达的 UDP 端口. 路由器 NAT 需要端口转发.
|
||||
- 防火墙需要放行 UDP 端口. `install` 会打印示例命令.
|
||||
- 主机需要公网可达的端口. 路由器 NAT 需要端口转发.
|
||||
- 防火墙需要放行协议端口. UDP 协议放行 UDP, TCP 协议放行 TCP.
|
||||
- 用户级服务依赖 lingering. 确认 `loginctl show-user $USER -p Linger` 输出 `Linger=yes`.
|
||||
`sbp install` 在未开启 lingering 时会给出警告.
|
||||
- 主机没有 IPv6 时, 默认监听 `0.0.0.0`, 不会监听 `::`.
|
||||
- 首次安装后, `sbp` 会等待服务进入 `active`. 如果失败, 用 `sbp log` 查看原因.
|
||||
`sbp install` 在未开启 lingering 时给出警告.
|
||||
- 主机没有 IPv6 时, 默认监听 `0.0.0.0`.
|
||||
- Python 的 `urllib` 不读取 `ALL_PROXY`. 使用 SOCKS 代理时请设置 `HTTPS_PROXY`.
|
||||
- 文件位置遵循 `XDG_CONFIG_HOME` 和 `XDG_DATA_HOME`. 上表按默认值列出.
|
||||
+25
-17
@@ -3,31 +3,39 @@ set -euo pipefail
|
||||
|
||||
repo="${SBP_REPO:-justypist/sbp}"
|
||||
branch="${SBP_BRANCH:-main}"
|
||||
dest="${SBP_BIN:-$HOME/.local/bin}"
|
||||
url="https://raw.githubusercontent.com/${repo}/${branch}/sbp"
|
||||
app_dir="${SBP_APP_DIR:-$HOME/.local/share/sbp/app}"
|
||||
bin_dir="${SBP_BIN:-$HOME/.local/bin}"
|
||||
url="https://github.com/${repo}/archive/refs/heads/${branch}.tar.gz"
|
||||
|
||||
command -v curl >/dev/null 2>&1 || { echo "error: curl is required" >&2; exit 1; }
|
||||
command -v tar >/dev/null 2>&1 || { echo "error: tar is required" >&2; exit 1; }
|
||||
command -v python3 >/dev/null 2>&1 || { echo "error: python3 is required" >&2; exit 1; }
|
||||
|
||||
mkdir -p "$dest"
|
||||
tmp="$(mktemp)"
|
||||
trap 'rm -f "$tmp"' EXIT
|
||||
tmp="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
|
||||
curl -fsSL "$url" -o "$tmp"
|
||||
head -n 1 "$tmp" | grep -q '^#!/usr/bin/env python3' || {
|
||||
echo "error: unexpected content from $url" >&2
|
||||
exit 1
|
||||
}
|
||||
chmod 755 "$tmp"
|
||||
mv "$tmp" "$dest/sbp"
|
||||
trap - EXIT
|
||||
curl -fsSL "$url" | tar -xz -C "$tmp"
|
||||
src="$(find "$tmp" -mindepth 1 -maxdepth 1 -type d | head -n 1)"
|
||||
[ -n "$src" ] && [ -f "$src/sbp" ] || { echo "error: unexpected archive layout from $url" >&2; exit 1; }
|
||||
[ -f "$src/src/sbp/cli.py" ] || { echo "error: package files missing from $url" >&2; exit 1; }
|
||||
|
||||
echo "installed $dest/sbp"
|
||||
mkdir -p "$(dirname "$app_dir")" "$bin_dir"
|
||||
rm -rf "$app_dir.new"
|
||||
mv "$src" "$app_dir.new"
|
||||
chmod 755 "$app_dir.new/sbp"
|
||||
if [ -e "$app_dir" ]; then
|
||||
[ -f "$app_dir/src/sbp/cli.py" ] || { echo "error: refusing to replace $app_dir" >&2; exit 1; }
|
||||
rm -rf "$app_dir"
|
||||
fi
|
||||
mv "$app_dir.new" "$app_dir"
|
||||
ln -sfn "$app_dir/sbp" "$bin_dir/sbp"
|
||||
|
||||
echo "installed $bin_dir/sbp"
|
||||
case ":$PATH:" in
|
||||
*":$dest:"*) ;;
|
||||
*) echo "note: add $dest to PATH, for example: export PATH=\"$dest:\$PATH\"" ;;
|
||||
*":$bin_dir:"*) ;;
|
||||
*) echo "note: add $bin_dir to PATH, for example: export PATH=\"$bin_dir:\$PATH\"" ;;
|
||||
esac
|
||||
|
||||
if [ "${1:-}" = "--run" ]; then
|
||||
exec "$dest/sbp" install "${@:2}"
|
||||
exec "$bin_dir/sbp" install "${@:2}"
|
||||
fi
|
||||
@@ -0,0 +1,20 @@
|
||||
[project]
|
||||
name = "sbp"
|
||||
version = "2.0.0"
|
||||
description = "Install and manage a user-level sing-box proxy server"
|
||||
requires-python = ">=3.9"
|
||||
license = { text = "MIT" }
|
||||
dependencies = []
|
||||
|
||||
[project.scripts]
|
||||
sbp = "sbp.cli:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/sbp"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
@@ -0,0 +1,3 @@
|
||||
"""sbp: install and manage a user-level sing-box proxy server."""
|
||||
|
||||
__version__ = "2.0.0"
|
||||
@@ -0,0 +1,4 @@
|
||||
from .cli import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Command line entry point."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
from . import commands, constants, paths as path_module
|
||||
from .errors import SbpError
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog=constants.APP,
|
||||
description="Install and manage a user-level sing-box proxy server.",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""examples:
|
||||
sbp install
|
||||
sbp install --protocols hysteria2,vless-reality --sni www.example.com
|
||||
sbp status
|
||||
sbp protocol enable trojan --port 24000
|
||||
sbp user add phone
|
||||
sbp user show phone
|
||||
sbp tui
|
||||
""",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-V", "--version", action="version", version=f"{constants.APP} {constants.VERSION}"
|
||||
)
|
||||
sub = parser.add_subparsers(dest="command")
|
||||
for module in commands.MODULES:
|
||||
module.register(sub)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
import os
|
||||
|
||||
os.umask(0o077)
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
paths = path_module.load()
|
||||
try:
|
||||
if not args.command:
|
||||
if sys.stdout.isatty():
|
||||
return commands.tui.run(args, paths)
|
||||
parser.print_help()
|
||||
return 0
|
||||
return commands.COMMANDS[args.command].run(args, paths)
|
||||
except SbpError as exc:
|
||||
print(f"error: {exc.message}", file=sys.stderr)
|
||||
return exc.code
|
||||
except KeyboardInterrupt:
|
||||
print("", file=sys.stderr)
|
||||
return 130
|
||||
@@ -0,0 +1,33 @@
|
||||
"""CLI command modules. Each module registers one command group."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import ModuleType
|
||||
|
||||
from . import cert, install, protocol, service, settings, status, tui, uninstall, upgrade, user
|
||||
|
||||
MODULES: tuple[ModuleType, ...] = (
|
||||
install,
|
||||
status,
|
||||
protocol,
|
||||
user,
|
||||
service,
|
||||
cert,
|
||||
settings,
|
||||
upgrade,
|
||||
uninstall,
|
||||
tui,
|
||||
)
|
||||
|
||||
COMMANDS: dict[str, ModuleType] = {
|
||||
"install": install,
|
||||
"status": status,
|
||||
"protocol": protocol,
|
||||
"user": user,
|
||||
"service": service,
|
||||
"cert": cert,
|
||||
"settings": settings,
|
||||
"upgrade": upgrade,
|
||||
"uninstall": uninstall,
|
||||
"tui": tui,
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Shared CLI helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
|
||||
def progress(message: str) -> None:
|
||||
print(f"==> {message}", flush=True)
|
||||
|
||||
|
||||
def warnings(messages: list[str]) -> None:
|
||||
for message in messages:
|
||||
print(f"warning: {message}", file=sys.stderr)
|
||||
@@ -0,0 +1,25 @@
|
||||
"""sbp cert"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
|
||||
from .. import paths as path_module
|
||||
from ..ops import settings as settings_op
|
||||
from ..ops import show as show_op
|
||||
|
||||
|
||||
def register(sub) -> None:
|
||||
parser = sub.add_parser("cert", help="show or renew the server certificate")
|
||||
actions = parser.add_subparsers(dest="action", required=True)
|
||||
actions.add_parser("show", help="print the certificate (PEM)")
|
||||
actions.add_parser("renew", help="regenerate the self-signed certificate")
|
||||
|
||||
|
||||
def run(args: argparse.Namespace, paths: path_module.Paths) -> int:
|
||||
if args.action == "show":
|
||||
print(show_op.server_certificate(paths), end="")
|
||||
elif args.action == "renew":
|
||||
settings_op.regenerate_cert(paths)
|
||||
print("certificate regenerated")
|
||||
return 0
|
||||
@@ -0,0 +1,54 @@
|
||||
"""sbp install"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
|
||||
from .. import constants, paths as path_module, protocols
|
||||
from ..ops import install as install_op
|
||||
from ..ops import show as show_op
|
||||
from ._common import progress, warnings
|
||||
|
||||
|
||||
def register(sub) -> None:
|
||||
parser = sub.add_parser("install", help="install sing-box, enable protocols, and start the service")
|
||||
parser.add_argument("--host", help="address printed for clients (default: detected public IP)")
|
||||
parser.add_argument("--sni", help=f"TLS server name (default: {constants.DEFAULT_SNI})")
|
||||
parser.add_argument("--cert", help="use an existing certificate (PEM)")
|
||||
parser.add_argument("--key", help="use an existing private key (PEM)")
|
||||
parser.add_argument("--protocols", help="comma separated protocol list (default: all)")
|
||||
parser.add_argument("--base-port", type=int, default=constants.BASE_PORT, help="first port to try")
|
||||
parser.add_argument("--no-obfs", action="store_true", help="disable Hysteria2 obfuscation")
|
||||
parser.add_argument("--sing-box-version", dest="sing_box_version", help="sing-box release tag")
|
||||
parser.add_argument("--force", action="store_true", help="reinstall over an existing setup")
|
||||
|
||||
|
||||
def run(args: argparse.Namespace, paths: path_module.Paths) -> int:
|
||||
selected = (
|
||||
[item.strip() for item in args.protocols.split(",") if item.strip()]
|
||||
if args.protocols
|
||||
else None
|
||||
)
|
||||
options = install_op.InstallOptions(
|
||||
host=args.host,
|
||||
sni=args.sni,
|
||||
cert=args.cert,
|
||||
key=args.key,
|
||||
protocols=selected,
|
||||
base_port=args.base_port,
|
||||
no_obfs=args.no_obfs,
|
||||
singbox_version=args.sing_box_version,
|
||||
force=args.force,
|
||||
)
|
||||
state = install_op.install(paths, options, progress=progress)
|
||||
warnings(options.warnings)
|
||||
print()
|
||||
print(f"installed in {paths.config_dir}")
|
||||
print(f"sing-box {state.singbox_version}")
|
||||
for key, protocol_state in state.enabled_protocols():
|
||||
print(f" {key:<16} {protocol_state.port}/{protocols.get(key).info.transport}")
|
||||
print()
|
||||
for user in state.users:
|
||||
for key, uri in show_op.user_uris(paths, user.name):
|
||||
print(f"{user.name} {key}: {uri}")
|
||||
return 0
|
||||
@@ -0,0 +1,49 @@
|
||||
"""sbp protocol"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
|
||||
from .. import paths as path_module, protocols
|
||||
from ..ops import protocols as protocols_op
|
||||
from ..ops import status as status_op
|
||||
|
||||
|
||||
def register(sub) -> None:
|
||||
parser = sub.add_parser("protocol", help="manage protocols")
|
||||
actions = parser.add_subparsers(dest="action", required=True)
|
||||
actions.add_parser("list", help="list protocols")
|
||||
enable = actions.add_parser("enable", help="enable a protocol")
|
||||
enable.add_argument("key", choices=protocols.keys())
|
||||
enable.add_argument("--port", type=int)
|
||||
disable = actions.add_parser("disable", help="disable a protocol")
|
||||
disable.add_argument("key", choices=protocols.keys())
|
||||
setter = actions.add_parser("set", help="set a protocol option")
|
||||
setter.add_argument("key", choices=protocols.keys())
|
||||
setter.add_argument("option")
|
||||
setter.add_argument("value")
|
||||
port = actions.add_parser("port", help="change a protocol port")
|
||||
port.add_argument("key", choices=protocols.keys())
|
||||
port.add_argument("port", type=int)
|
||||
|
||||
|
||||
def run(args: argparse.Namespace, paths: path_module.Paths) -> int:
|
||||
if args.action == "list":
|
||||
for row in status_op.status(paths).protocols:
|
||||
state = "enabled" if row.enabled else "off"
|
||||
details = ", ".join(f"{key}={value}" for key, value in row.details)
|
||||
suffix = f" {details}" if details else ""
|
||||
print(f"{row.key:<16} {state:<8} {row.port:<6} {row.transport}{suffix}")
|
||||
elif args.action == "enable":
|
||||
protocols_op.enable(paths, args.key, port=args.port)
|
||||
print(f"enabled {args.key}")
|
||||
elif args.action == "disable":
|
||||
protocols_op.disable(paths, args.key)
|
||||
print(f"disabled {args.key}")
|
||||
elif args.action == "set":
|
||||
value = protocols_op.set_option(paths, args.key, args.option, args.value)
|
||||
print(f"{args.key}.{args.option} = {value}")
|
||||
elif args.action == "port":
|
||||
protocols_op.set_port(paths, args.key, args.port)
|
||||
print(f"{args.key} port = {args.port}")
|
||||
return 0
|
||||
@@ -0,0 +1,33 @@
|
||||
"""sbp service"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
|
||||
from .. import paths as path_module
|
||||
from ..ops import service as service_op
|
||||
|
||||
|
||||
def register(sub) -> None:
|
||||
parser = sub.add_parser("service", help="control the service")
|
||||
actions = parser.add_subparsers(dest="action", required=True)
|
||||
for action in ("start", "stop", "restart"):
|
||||
actions.add_parser(action, help=action)
|
||||
logs = actions.add_parser("logs", help="show logs")
|
||||
logs.add_argument("-n", "--lines", type=int, default=100)
|
||||
|
||||
|
||||
def run(args: argparse.Namespace, paths: path_module.Paths) -> int:
|
||||
if args.action == "start":
|
||||
service_op.start(paths)
|
||||
print("service started")
|
||||
elif args.action == "stop":
|
||||
service_op.stop(paths)
|
||||
print("service stopped")
|
||||
elif args.action == "restart":
|
||||
service_op.restart(paths)
|
||||
print("service restarted")
|
||||
elif args.action == "logs":
|
||||
for line in service_op.logs(paths, args.lines):
|
||||
print(line)
|
||||
return 0
|
||||
@@ -0,0 +1,35 @@
|
||||
"""sbp settings"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
|
||||
from .. import paths as path_module
|
||||
from ..ops import settings as settings_op
|
||||
from ._common import warnings
|
||||
|
||||
|
||||
def register(sub) -> None:
|
||||
parser = sub.add_parser("settings", help="change server settings")
|
||||
actions = parser.add_subparsers(dest="action", required=True)
|
||||
host = actions.add_parser("host", help="set the client-facing host")
|
||||
host.add_argument("value")
|
||||
sni = actions.add_parser("sni", help="set the TLS server name")
|
||||
sni.add_argument("value")
|
||||
cert = actions.add_parser("cert", help="use an existing certificate")
|
||||
cert.add_argument("--cert", required=True)
|
||||
cert.add_argument("--key", required=True)
|
||||
cert.add_argument("--sni")
|
||||
|
||||
|
||||
def run(args: argparse.Namespace, paths: path_module.Paths) -> int:
|
||||
if args.action == "host":
|
||||
settings_op.set_host(paths, args.value)
|
||||
print(f"host = {args.value}")
|
||||
elif args.action == "sni":
|
||||
warnings(settings_op.set_sni(paths, args.value))
|
||||
print(f"sni = {args.value}")
|
||||
elif args.action == "cert":
|
||||
warnings(settings_op.set_cert(paths, args.cert, args.key, args.sni))
|
||||
print("certificate updated")
|
||||
return 0
|
||||
@@ -0,0 +1,67 @@
|
||||
"""sbp status"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
|
||||
from .. import paths as path_module
|
||||
from ..ops import status as status_op
|
||||
|
||||
|
||||
def register(sub) -> None:
|
||||
parser = sub.add_parser("status", help="show server status")
|
||||
parser.add_argument("--json", action="store_true", help="print JSON")
|
||||
|
||||
|
||||
def _print(status: status_op.Status) -> None:
|
||||
if not status.installed:
|
||||
print("sbp is not installed")
|
||||
return
|
||||
print(f"service {'active' if status.active else 'inactive'}")
|
||||
print(f"sing-box {status.singbox_version}")
|
||||
print(f"address {status.host}")
|
||||
print(f"sni {status.sni}")
|
||||
print(f"tls {status.tls_mode}")
|
||||
print(f"cert until {status.cert_until}")
|
||||
print(f"users {status.user_count}")
|
||||
print(f"created {status.created}")
|
||||
print()
|
||||
print(f"{'PROTOCOL':<16} {'STATE':<9} {'PORT':<7} TRANSPORT")
|
||||
for row in status.protocols:
|
||||
state = "enabled" if row.enabled else "off"
|
||||
print(f"{row.key:<16} {state:<9} {row.port:<7} {row.transport}")
|
||||
|
||||
|
||||
def _as_dict(status: status_op.Status) -> dict:
|
||||
return {
|
||||
"installed": status.installed,
|
||||
"active": status.active,
|
||||
"singbox_version": status.singbox_version,
|
||||
"host": status.host,
|
||||
"sni": status.sni,
|
||||
"tls": status.tls_mode,
|
||||
"cert_until": status.cert_until,
|
||||
"users": status.user_count,
|
||||
"created": status.created,
|
||||
"protocols": [
|
||||
{
|
||||
"key": row.key,
|
||||
"title": row.title,
|
||||
"enabled": row.enabled,
|
||||
"port": row.port,
|
||||
"transport": row.transport,
|
||||
"details": dict(row.details),
|
||||
}
|
||||
for row in status.protocols
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def run(args: argparse.Namespace, paths: path_module.Paths) -> int:
|
||||
status = status_op.status(paths)
|
||||
if args.json:
|
||||
print(json.dumps(_as_dict(status), indent=2))
|
||||
else:
|
||||
_print(status)
|
||||
return 0
|
||||
@@ -0,0 +1,17 @@
|
||||
"""sbp tui"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
|
||||
from .. import paths as path_module
|
||||
|
||||
|
||||
def register(sub) -> None:
|
||||
sub.add_parser("tui", help="open the full-screen manager")
|
||||
|
||||
|
||||
def run(args: argparse.Namespace, paths: path_module.Paths) -> int:
|
||||
from ..tui.app import run as run_tui
|
||||
|
||||
return run_tui(paths)
|
||||
@@ -0,0 +1,22 @@
|
||||
"""sbp uninstall"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
|
||||
from .. import paths as path_module
|
||||
from ..ops import uninstall as uninstall_op
|
||||
|
||||
|
||||
def register(sub) -> None:
|
||||
parser = sub.add_parser("uninstall", help="stop and remove the service and binary")
|
||||
parser.add_argument("--purge", action="store_true", help="also delete config and certificates")
|
||||
|
||||
|
||||
def run(args: argparse.Namespace, paths: path_module.Paths) -> int:
|
||||
uninstall_op.uninstall(paths, purge=args.purge)
|
||||
if args.purge:
|
||||
print("removed the service, binary, config, and certificates")
|
||||
else:
|
||||
print(f"removed the service and binary; kept {paths.config_dir} (use --purge to delete)")
|
||||
return 0
|
||||
@@ -0,0 +1,26 @@
|
||||
"""sbp upgrade"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
|
||||
from .. import paths as path_module
|
||||
from ..ops import upgrade as upgrade_op
|
||||
from ._common import progress
|
||||
|
||||
|
||||
def register(sub) -> None:
|
||||
parser = sub.add_parser("upgrade", help="upgrade sing-box")
|
||||
parser.add_argument("--sing-box-version", dest="sing_box_version")
|
||||
parser.add_argument("--force", action="store_true")
|
||||
|
||||
|
||||
def run(args: argparse.Namespace, paths: path_module.Paths) -> int:
|
||||
current, target = upgrade_op.upgrade(
|
||||
paths, version=args.sing_box_version, force=args.force, progress=progress
|
||||
)
|
||||
if current == target and not args.force:
|
||||
print(f"already on {target}")
|
||||
else:
|
||||
print(f"upgraded {current} -> {target}")
|
||||
return 0
|
||||
@@ -0,0 +1,60 @@
|
||||
"""sbp user"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
|
||||
from .. import paths as path_module, protocols
|
||||
from ..ops import show as show_op
|
||||
from ..ops import users as users_op
|
||||
|
||||
|
||||
def register(sub) -> None:
|
||||
parser = sub.add_parser("user", help="manage users")
|
||||
actions = parser.add_subparsers(dest="action", required=True)
|
||||
actions.add_parser("list", help="list users")
|
||||
add = actions.add_parser("add", help="add a user")
|
||||
add.add_argument("name")
|
||||
delete = actions.add_parser("del", help="delete a user")
|
||||
delete.add_argument("name")
|
||||
show = actions.add_parser("show", help="print share URIs or client config")
|
||||
show.add_argument("name")
|
||||
show.add_argument("--protocol", choices=protocols.keys())
|
||||
show.add_argument("--uri", action="store_true", help="print the share URI only")
|
||||
show.add_argument("--json", action="store_true", help="print the sing-box client config")
|
||||
|
||||
|
||||
def run(args: argparse.Namespace, paths: path_module.Paths) -> int:
|
||||
if args.action == "list":
|
||||
users = users_op.list_users(paths)
|
||||
if not users:
|
||||
print("no users")
|
||||
return 0
|
||||
width = max(len("NAME"), *(len(user.name) for user in users))
|
||||
print(f"{'NAME':<{width}} CREATED")
|
||||
for user in users:
|
||||
print(f"{user.name:<{width}} {user.created[:10]}")
|
||||
elif args.action == "add":
|
||||
user = users_op.add(paths, args.name)
|
||||
print(f"added user {user.name}")
|
||||
for key, uri in show_op.user_uris(paths, user.name):
|
||||
print(f" {key}: {uri}")
|
||||
elif args.action == "del":
|
||||
users_op.remove(paths, args.name)
|
||||
print(f"deleted user {args.name}")
|
||||
elif args.action == "show":
|
||||
if args.json:
|
||||
if not args.protocol:
|
||||
from ..errors import SbpError
|
||||
|
||||
raise SbpError("--json requires --protocol")
|
||||
print(show_op.client_config_json(paths, args.name, args.protocol))
|
||||
elif args.protocol:
|
||||
if args.uri:
|
||||
print(show_op.user_uri(paths, args.name, args.protocol))
|
||||
else:
|
||||
print(show_op.client_config_json(paths, args.name, args.protocol))
|
||||
else:
|
||||
for key, uri in show_op.user_uris(paths, args.name):
|
||||
print(f"{key}: {uri}")
|
||||
return 0
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Assemble sing-box server and client JSON from state."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from . import constants, protocols
|
||||
from .errors import SbpError
|
||||
from .protocols.base import ServerContext
|
||||
from .state import State, User
|
||||
|
||||
|
||||
def server_context(state: State) -> ServerContext:
|
||||
return ServerContext(
|
||||
host=state.host,
|
||||
sni=state.sni,
|
||||
cert=state.tls.cert,
|
||||
key=state.tls.key,
|
||||
cert_self_signed=state.tls.self_signed,
|
||||
cert_sha256=state.tls.cert_sha256,
|
||||
spki_pin=state.tls.spki_pin,
|
||||
reality_private=state.reality.private_key,
|
||||
reality_public=state.reality.public_key,
|
||||
)
|
||||
|
||||
|
||||
def build_server_config(state: State) -> dict[str, Any]:
|
||||
ctx = server_context(state)
|
||||
inbounds: list[dict[str, Any]] = []
|
||||
for key, protocol_state in state.enabled_protocols():
|
||||
protocol = protocols.get(key)
|
||||
settings = protocol.normalize(protocol_state.options, ctx)
|
||||
protocol.validate(settings, ctx)
|
||||
inbounds.extend(
|
||||
protocol.inbounds(ctx, settings, state.users, protocol_state.listen, protocol_state.port)
|
||||
)
|
||||
return {
|
||||
"log": {"level": "warn", "timestamp": True},
|
||||
"inbounds": inbounds,
|
||||
"outbounds": [{"type": "direct", "tag": "direct"}],
|
||||
"route": {"rules": [], "final": "direct"},
|
||||
}
|
||||
|
||||
|
||||
def build_client_config(state: State, protocol_key: str, user: User) -> dict[str, Any]:
|
||||
ctx = server_context(state)
|
||||
protocol_state = state.protocols.get(protocol_key)
|
||||
if protocol_state is None or not protocol_state.enabled:
|
||||
raise SbpError(f"protocol {protocol_key} is not enabled")
|
||||
protocol = protocols.get(protocol_key)
|
||||
settings = protocol.normalize(protocol_state.options, ctx)
|
||||
protocol.validate(settings, ctx)
|
||||
outbound = protocol.client_outbound(ctx, settings, user, protocol_state.port)
|
||||
return {
|
||||
"log": {"level": "warn"},
|
||||
"inbounds": [{
|
||||
"type": "mixed",
|
||||
"tag": "mixed-in",
|
||||
"listen": "127.0.0.1",
|
||||
"listen_port": constants.DEFAULT_CLIENT_PORT,
|
||||
}],
|
||||
"outbounds": [outbound],
|
||||
"route": {"rules": [], "final": outbound["tag"]},
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Fixed values shared by all modules."""
|
||||
|
||||
from . import __version__
|
||||
|
||||
APP = "sbp"
|
||||
VERSION = __version__
|
||||
SCHEMA = 2
|
||||
|
||||
DEFAULT_SNI = "www.bing.com"
|
||||
DEFAULT_CLIENT_PORT = 1080
|
||||
BASE_PORT = 20000
|
||||
PORT_RANGE = 40000
|
||||
|
||||
UNIT_NAME = "sbp"
|
||||
|
||||
RELEASE_API = "https://api.github.com/repos/SagerNet/sing-box/releases/latest"
|
||||
RELEASE_TAG_API = "https://api.github.com/repos/SagerNet/sing-box/releases/tags/{tag}"
|
||||
|
||||
STATE_FILE = "state.json"
|
||||
SERVER_CONFIG = "sing-box.json"
|
||||
CERT_DIR = "certs"
|
||||
CERT_FILE = "cert.pem"
|
||||
KEY_FILE = "key.pem"
|
||||
BINARY_PATH = "bin/sing-box"
|
||||
UNIT_TEMPLATE = "systemd/{unit}.service"
|
||||
|
||||
SENSITIVE_HOME_PATHS = (
|
||||
".ssh", ".gnupg", ".aws", ".azure", ".kube", ".docker",
|
||||
".netrc", ".git-credentials", ".password-store", ".mozilla",
|
||||
".config/gcloud", ".config/gh", ".config/hub", ".config/op",
|
||||
".config/google-chrome", ".config/chromium", ".config/BraveSoftware",
|
||||
".local/share/keyrings",
|
||||
)
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Credential generators."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
import secrets
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
def now_iso() -> str:
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def token() -> str:
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
def user_uuid() -> str:
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
def shadowsocks_key() -> str:
|
||||
return base64.b64encode(os.urandom(16)).decode()
|
||||
|
||||
|
||||
def reality_short_id() -> str:
|
||||
return secrets.token_hex(4)
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Error type used by every layer.
|
||||
|
||||
Business code raises SbpError. The CLI turns it into an exit code and the TUI
|
||||
shows it in a dialog. No layer calls sys.exit.
|
||||
"""
|
||||
|
||||
|
||||
class SbpError(Exception):
|
||||
"""A user-facing failure with a message that needs no traceback."""
|
||||
|
||||
def __init__(self, message: str, code: int = 1) -> None:
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.code = code
|
||||
@@ -0,0 +1,56 @@
|
||||
"""File helpers with atomic writes and strict permissions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .errors import SbpError
|
||||
|
||||
|
||||
def open_exclusive(path: Path, mode: int) -> int:
|
||||
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
||||
if hasattr(os, "O_NOFOLLOW"):
|
||||
flags |= os.O_NOFOLLOW
|
||||
return os.open(path, flags, mode)
|
||||
|
||||
|
||||
def write_file(path: Path, data: str, mode: int) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_name(f".{path.name}.{os.getpid()}.{secrets.token_hex(4)}.tmp")
|
||||
fd = open_exclusive(tmp, mode)
|
||||
try:
|
||||
with os.fdopen(fd, "w") as handle:
|
||||
os.fchmod(handle.fileno(), mode)
|
||||
handle.write(data)
|
||||
os.replace(tmp, path)
|
||||
except BaseException:
|
||||
tmp.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
|
||||
def write_json(path: Path, data: Any, mode: int = 0o600) -> None:
|
||||
write_file(path, json.dumps(data, indent=2) + "\n", mode)
|
||||
|
||||
|
||||
def read_json(path: Path) -> Any:
|
||||
try:
|
||||
return json.loads(path.read_text())
|
||||
except (json.JSONDecodeError, OSError) as exc:
|
||||
raise SbpError(f"cannot read {path}: {exc}") from exc
|
||||
|
||||
|
||||
def safe_rmtree(path: Path, marker: Path) -> None:
|
||||
resolved = path.resolve()
|
||||
home = Path.home().resolve()
|
||||
if resolved == Path("/") or resolved == home or home.is_relative_to(resolved):
|
||||
raise SbpError(f"refusing to delete {resolved}")
|
||||
if not marker.exists():
|
||||
raise SbpError(f"refusing to delete {resolved}: {marker} not found")
|
||||
if not marker.resolve().is_relative_to(resolved):
|
||||
raise SbpError(f"refusing to delete {resolved}: {marker} is outside it")
|
||||
shutil.rmtree(resolved)
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Host checks, address detection, and port helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import socket
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
from . import constants
|
||||
from .errors import SbpError
|
||||
from .shell import run
|
||||
|
||||
|
||||
def is_ip(value: str) -> bool:
|
||||
try:
|
||||
ipaddress.ip_address(value)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def is_private(value: str) -> bool:
|
||||
try:
|
||||
addr = ipaddress.ip_address(value)
|
||||
except ValueError:
|
||||
return False
|
||||
return addr.is_private or addr.is_loopback
|
||||
|
||||
|
||||
def valid_host(value: str) -> bool:
|
||||
if is_ip(value):
|
||||
return True
|
||||
if not value or len(value) > 253:
|
||||
return False
|
||||
return re.fullmatch(r"[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?", value) is not None
|
||||
|
||||
|
||||
def ipv6_available() -> bool:
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM)
|
||||
sock.bind(("::", 0))
|
||||
sock.close()
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def default_listen() -> str:
|
||||
return "::" if ipv6_available() else "0.0.0.0"
|
||||
|
||||
|
||||
def port_free(port: int, listen: str, kind: str = "udp", retries: int = 1) -> bool:
|
||||
family = socket.AF_INET6 if ":" in listen else socket.AF_INET
|
||||
sock_type = socket.SOCK_STREAM if kind == "tcp" else socket.SOCK_DGRAM
|
||||
for attempt in range(retries):
|
||||
sock = socket.socket(family, sock_type)
|
||||
try:
|
||||
sock.bind((listen, port))
|
||||
return True
|
||||
except OSError:
|
||||
if attempt + 1 < retries:
|
||||
time.sleep(0.2)
|
||||
finally:
|
||||
sock.close()
|
||||
return False
|
||||
|
||||
|
||||
def pick_port(
|
||||
listen: str, used: set[int], start: int = constants.BASE_PORT, kind: str = "udp"
|
||||
) -> int:
|
||||
for port in range(start, min(start + constants.PORT_RANGE, 65536)):
|
||||
if port in used:
|
||||
continue
|
||||
if port_free(port, listen, kind):
|
||||
return port
|
||||
raise SbpError(f"cannot find a free {kind.upper()} port from {start}")
|
||||
|
||||
|
||||
def linger_enabled() -> bool | None:
|
||||
if shutil.which("loginctl") is None:
|
||||
return None
|
||||
proc = run(["loginctl", "show-user", str(os.getuid()), "-p", "Linger", "--value"], check=False)
|
||||
if proc.returncode != 0:
|
||||
return None
|
||||
return proc.stdout.strip() == "yes"
|
||||
|
||||
|
||||
def detect_host() -> str:
|
||||
try:
|
||||
with urllib.request.urlopen("https://api.ipify.org", timeout=4) as resp:
|
||||
value = resp.read().decode().strip()
|
||||
if is_ip(value):
|
||||
return value
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.connect(("8.8.8.8", 53))
|
||||
value = sock.getsockname()[0]
|
||||
sock.close()
|
||||
return value
|
||||
except Exception:
|
||||
return "127.0.0.1"
|
||||
|
||||
|
||||
def check_platform() -> None:
|
||||
if os.uname().sysname != "Linux":
|
||||
raise SbpError("sbp supports Linux only")
|
||||
if os.geteuid() == 0:
|
||||
raise SbpError("run sbp as your normal user, not as root")
|
||||
for tool in ("openssl", "systemctl", "journalctl"):
|
||||
if shutil.which(tool) is None:
|
||||
raise SbpError(f"{tool} is required but not found in PATH")
|
||||
if "XDG_RUNTIME_DIR" not in os.environ:
|
||||
runtime = f"/run/user/{os.getuid()}"
|
||||
if os.path.isdir(runtime):
|
||||
os.environ["XDG_RUNTIME_DIR"] = runtime
|
||||
proc = run(["systemctl", "--user", "show-environment"], check=False)
|
||||
if proc.returncode != 0:
|
||||
detail = (proc.stderr or proc.stdout).strip()
|
||||
raise SbpError(
|
||||
f"systemd user manager is unavailable: {detail}\n"
|
||||
"run 'loginctl enable-linger $USER' as root, then log in again"
|
||||
)
|
||||
|
||||
|
||||
def warnings() -> list[str]:
|
||||
messages: list[str] = []
|
||||
if linger_enabled() is False:
|
||||
messages.append(
|
||||
f"lingering is disabled; the service stops when you log out\n"
|
||||
f"run: sudo loginctl enable-linger {os.environ.get('USER', os.getuid())}"
|
||||
)
|
||||
if os.environ.get("ALL_PROXY") and not (
|
||||
os.environ.get("HTTPS_PROXY") or os.environ.get("https_proxy")
|
||||
):
|
||||
messages.append("ALL_PROXY is not used by Python downloads; set HTTPS_PROXY instead")
|
||||
return messages
|
||||
@@ -0,0 +1,99 @@
|
||||
"""GitHub release lookup and verified downloads."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import platform
|
||||
import shutil
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from . import constants
|
||||
from .errors import SbpError
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Release:
|
||||
tag: str
|
||||
assets: list[dict]
|
||||
|
||||
|
||||
def http_json(url: str) -> dict:
|
||||
request = urllib.request.Request(
|
||||
url,
|
||||
headers={
|
||||
"User-Agent": f"{constants.APP}/{constants.VERSION}",
|
||||
"Accept": "application/vnd.github+json",
|
||||
},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=30) as resp:
|
||||
return json.load(resp)
|
||||
except urllib.error.HTTPError as exc:
|
||||
raise SbpError(f"GitHub API returned HTTP {exc.code} for {url}") from exc
|
||||
except Exception as exc:
|
||||
raise SbpError(f"cannot reach GitHub: {exc}") from exc
|
||||
|
||||
|
||||
def get_release(version: str | None) -> Release:
|
||||
if version in (None, "", "latest"):
|
||||
data = http_json(constants.RELEASE_API)
|
||||
else:
|
||||
tag = version if version.startswith("v") else f"v{version}"
|
||||
data = http_json(constants.RELEASE_TAG_API.format(tag=tag))
|
||||
return Release(tag=data["tag_name"], assets=data["assets"])
|
||||
|
||||
|
||||
def asset_for(assets: list[dict], tag: str) -> dict:
|
||||
machine = platform.machine()
|
||||
arch = {
|
||||
"x86_64": "amd64", "amd64": "amd64",
|
||||
"aarch64": "arm64", "arm64": "arm64",
|
||||
"armv7l": "armv7", "armv6l": "armv6",
|
||||
"i686": "386", "i386": "386",
|
||||
}.get(machine)
|
||||
if arch is None:
|
||||
raise SbpError(f"unsupported architecture: {machine}")
|
||||
# glibc builds carry no suffix; only musl builds have "-musl" in the name.
|
||||
musl = platform.libc_ver()[0] != "glibc"
|
||||
prefix = f"sing-box-{tag.lstrip('v')}-linux-{arch}"
|
||||
candidates = [
|
||||
a for a in assets if a["name"].startswith(prefix) and a["name"].endswith(".tar.gz")
|
||||
]
|
||||
if not candidates:
|
||||
raise SbpError(f"no release asset for linux-{arch}")
|
||||
preferred = [a for a in candidates if ("-musl" in a["name"]) == musl]
|
||||
return (preferred or candidates)[0]
|
||||
|
||||
|
||||
def download(url: str, dest: Path) -> None:
|
||||
request = urllib.request.Request(url, headers={"User-Agent": f"{constants.APP}/{constants.VERSION}"})
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=180) as resp, open(dest, "wb") as handle:
|
||||
shutil.copyfileobj(resp, handle, 1 << 20)
|
||||
except Exception as exc:
|
||||
raise SbpError(f"download failed: {exc}") from exc
|
||||
|
||||
|
||||
def file_sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with open(path, "rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1 << 20), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def verify_digest(path: Path, asset: dict) -> bool:
|
||||
"""Return True when a digest was present and matched, False when absent."""
|
||||
expected = (asset.get("digest") or "").strip().lower()
|
||||
if not expected.startswith("sha256:"):
|
||||
return False
|
||||
actual = file_sha256(path)
|
||||
if actual != expected.removeprefix("sha256:"):
|
||||
raise SbpError(
|
||||
f"checksum mismatch for {asset['name']}: expected {expected}, got sha256:{actual}"
|
||||
)
|
||||
return True
|
||||
@@ -0,0 +1 @@
|
||||
"""Operations shared by the CLI and the TUI."""
|
||||
@@ -0,0 +1,164 @@
|
||||
"""Shared operations: load state, render config, apply changes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
|
||||
from .. import singbox, tls
|
||||
from ..config import build_server_config
|
||||
from ..errors import SbpError
|
||||
from ..fsutil import write_file
|
||||
from ..hostinfo import pick_port
|
||||
from ..paths import Paths
|
||||
from ..protocols import get as get_protocol
|
||||
from ..service import render_unit, restart
|
||||
from ..state import ProtocolState, State, StateStore, TlsState
|
||||
|
||||
|
||||
def open_store(paths: Paths) -> StateStore:
|
||||
return StateStore(paths.config_dir)
|
||||
|
||||
|
||||
def load(paths: Paths) -> tuple[StateStore, State]:
|
||||
store = open_store(paths)
|
||||
return store, store.load()
|
||||
|
||||
|
||||
def render_server_config(paths: Paths, state: State) -> None:
|
||||
if not paths.binary.is_file():
|
||||
raise SbpError(f"sing-box binary missing: {paths.binary}; run 'sbp upgrade --force'")
|
||||
for path in (state.tls.cert, state.tls.key):
|
||||
if not path or not os.path.isabs(path) or not os.path.isfile(path):
|
||||
raise SbpError(f"TLS file is missing or not absolute: {path}")
|
||||
staged = paths.server_config.with_name(f".{paths.server_config.name}.{os.getpid()}.tmp")
|
||||
write_file(staged, json.dumps(build_server_config(state), indent=2) + "\n", 0o600)
|
||||
try:
|
||||
singbox.check_config(paths, staged)
|
||||
except SbpError:
|
||||
staged.unlink(missing_ok=True)
|
||||
raise
|
||||
os.replace(staged, paths.server_config)
|
||||
|
||||
|
||||
def apply(
|
||||
paths: Paths,
|
||||
store: StateStore,
|
||||
state: State,
|
||||
*,
|
||||
restart_service: bool = True,
|
||||
progress: Optional[Callable[[str], None]] = None,
|
||||
) -> None:
|
||||
render_server_config(paths, state)
|
||||
render_unit(paths)
|
||||
store.save(state)
|
||||
if restart_service:
|
||||
if progress:
|
||||
progress("restarting service")
|
||||
restart(paths)
|
||||
|
||||
|
||||
def ensure_reality(paths: Paths, state: State) -> None:
|
||||
needed = any(get_protocol(key).info.needs_reality for key, _ in state.enabled_protocols())
|
||||
if needed and not (state.reality.private_key and state.reality.public_key):
|
||||
if not paths.binary.is_file():
|
||||
raise SbpError("sing-box binary is missing; install it before enabling REALITY")
|
||||
state.reality = singbox.reality_keypair(paths)
|
||||
|
||||
|
||||
def setup_tls(
|
||||
paths: Paths,
|
||||
state: State,
|
||||
sni: str,
|
||||
*,
|
||||
cert: Optional[str] = None,
|
||||
key: Optional[str] = None,
|
||||
force_regen: bool = False,
|
||||
) -> list[str]:
|
||||
"""Update state.tls for the given sni. Return non-fatal warnings."""
|
||||
warnings: list[str] = []
|
||||
if cert or key:
|
||||
if not (cert and key):
|
||||
raise SbpError("--cert and --key must be used together")
|
||||
cert_path = Path(cert).expanduser().resolve()
|
||||
key_path = Path(key).expanduser().resolve()
|
||||
tls.verify_pair(cert_path, key_path)
|
||||
names = tls.names(cert_path)
|
||||
if sni and names and sni not in names:
|
||||
warnings.append(f"certificate names are {', '.join(names)}; {sni} is not listed")
|
||||
is_self_signed = tls.self_signed(cert_path)
|
||||
spki_pin = cert_sha256 = ""
|
||||
if is_self_signed:
|
||||
spki_pin, cert_sha256 = tls.pins(cert_path)
|
||||
state.tls = TlsState(
|
||||
mode="custom",
|
||||
cert=str(cert_path),
|
||||
key=str(key_path),
|
||||
sni=sni,
|
||||
self_signed=is_self_signed,
|
||||
spki_pin=spki_pin,
|
||||
cert_sha256=cert_sha256,
|
||||
)
|
||||
return warnings
|
||||
|
||||
stale = state.tls.sni != sni or not (paths.cert_file.is_file() and paths.key_file.is_file())
|
||||
if force_regen or stale:
|
||||
tls.generate(sni, paths.cert_file, paths.key_file)
|
||||
spki_pin, cert_sha256 = tls.pins(paths.cert_file)
|
||||
state.tls = TlsState(
|
||||
mode="self-signed",
|
||||
cert=str(paths.cert_file),
|
||||
key=str(paths.key_file),
|
||||
sni=sni,
|
||||
self_signed=True,
|
||||
spki_pin=spki_pin,
|
||||
cert_sha256=cert_sha256,
|
||||
)
|
||||
warning = tls.key_permissions_warning(paths.key_file)
|
||||
if warning:
|
||||
warnings.append(warning)
|
||||
return warnings
|
||||
|
||||
|
||||
def assign_ports(state: State, listen: str, base_port: int, selected: list[str]) -> None:
|
||||
# Reserve every port already recorded in state first: a new protocol must
|
||||
# never be given a port that a later one still owns.
|
||||
used: set[int] = {
|
||||
protocol_state.port
|
||||
for protocol_state in state.protocols.values()
|
||||
if protocol_state.enabled and protocol_state.port
|
||||
}
|
||||
for offset, key in enumerate(selected):
|
||||
protocol_state = state.protocols.get(key)
|
||||
if protocol_state is not None and protocol_state.port:
|
||||
# A port already in state belongs to this server; keep it even while the
|
||||
# running service still holds it.
|
||||
continue
|
||||
protocol = get_protocol(key)
|
||||
port = pick_port(
|
||||
listen, used, start=base_port + offset, kind=protocol.info.transport
|
||||
)
|
||||
used.add(port)
|
||||
if protocol_state is None:
|
||||
protocol_state = ProtocolState(enabled=True, port=port, listen=listen, options={})
|
||||
state.protocols[key] = protocol_state
|
||||
protocol_state.port = port
|
||||
protocol_state.listen = protocol_state.listen or listen
|
||||
|
||||
|
||||
def mutate(
|
||||
paths: Paths,
|
||||
fn: Callable[[State], object],
|
||||
*,
|
||||
restart_service: bool = True,
|
||||
progress: Optional[Callable[[str], None]] = None,
|
||||
):
|
||||
"""Load state under the lock, apply fn, then render and restart."""
|
||||
store = open_store(paths)
|
||||
with store.lock():
|
||||
state = store.load()
|
||||
result = fn(state)
|
||||
apply(paths, store, state, restart_service=restart_service, progress=progress)
|
||||
return result
|
||||
@@ -0,0 +1,147 @@
|
||||
"""Install and reinstall the server."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
|
||||
from .. import constants, hostinfo, net, protocols, service, singbox, tls
|
||||
from ..credentials import now_iso
|
||||
from ..errors import SbpError
|
||||
from ..paths import Paths
|
||||
from ..state import ProtocolState, RealityState, State, TlsState, User
|
||||
from .common import apply, assign_ports, ensure_reality, open_store, setup_tls
|
||||
|
||||
|
||||
@dataclass
|
||||
class InstallOptions:
|
||||
host: Optional[str] = None
|
||||
sni: Optional[str] = None
|
||||
cert: Optional[str] = None
|
||||
key: Optional[str] = None
|
||||
protocols: Optional[list[str]] = None
|
||||
base_port: int = constants.BASE_PORT
|
||||
no_obfs: bool = False
|
||||
singbox_version: Optional[str] = None
|
||||
force: bool = False
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def _new_state() -> State:
|
||||
return State(
|
||||
schema=constants.SCHEMA,
|
||||
created=now_iso(),
|
||||
host="",
|
||||
sni="",
|
||||
tls=TlsState(mode="", cert="", key="", sni="", self_signed=False, spki_pin="", cert_sha256=""),
|
||||
reality=RealityState(),
|
||||
users=[],
|
||||
protocols={},
|
||||
singbox_version="",
|
||||
)
|
||||
|
||||
|
||||
def install(
|
||||
paths: Paths,
|
||||
options: InstallOptions,
|
||||
progress: Optional[Callable[[str], None]] = None,
|
||||
) -> State:
|
||||
hostinfo.check_platform()
|
||||
options.warnings.extend(hostinfo.warnings())
|
||||
|
||||
if not 1024 <= options.base_port <= 65535:
|
||||
raise SbpError("base port must be between 1024 and 65535")
|
||||
if bool(options.cert) != bool(options.key):
|
||||
raise SbpError("--cert and --key must be used together")
|
||||
|
||||
store = open_store(paths)
|
||||
existing = store.try_load()
|
||||
if existing and not options.force:
|
||||
raise SbpError(f"already installed in {paths.config_dir}; use --force to reinstall")
|
||||
|
||||
if options.protocols is not None:
|
||||
selected = list(options.protocols)
|
||||
elif existing is not None:
|
||||
selected = [key for key, _ in existing.enabled_protocols()]
|
||||
else:
|
||||
selected = protocols.keys()
|
||||
for key in selected:
|
||||
if key not in protocols.REGISTRY:
|
||||
raise SbpError(f"unknown protocol: {key} (choose from {', '.join(protocols.keys())})")
|
||||
|
||||
release = net.get_release(options.singbox_version)
|
||||
|
||||
with store.lock():
|
||||
singbox.install_binary(
|
||||
paths,
|
||||
release,
|
||||
current_config=paths.server_config if paths.server_config.exists() else None,
|
||||
progress=progress,
|
||||
)
|
||||
state = existing or _new_state()
|
||||
listen = hostinfo.default_listen()
|
||||
|
||||
host = options.host or state.host or hostinfo.detect_host()
|
||||
if not hostinfo.valid_host(host):
|
||||
raise SbpError(f"invalid host value: {host}")
|
||||
sni = options.sni or state.sni
|
||||
if not sni and options.cert:
|
||||
names = tls.names(Path(options.cert).expanduser().resolve())
|
||||
sni = names[0] if names else ""
|
||||
sni = sni or (host if not hostinfo.is_ip(host) else constants.DEFAULT_SNI)
|
||||
if not hostinfo.valid_host(sni):
|
||||
raise SbpError(f"invalid sni value: {sni}")
|
||||
if listen == "::" and not hostinfo.ipv6_available():
|
||||
raise SbpError("IPv6 is unavailable on this host")
|
||||
state.host = host
|
||||
state.sni = sni
|
||||
|
||||
if options.cert:
|
||||
options.warnings.extend(setup_tls(paths, state, sni, cert=options.cert, key=options.key))
|
||||
elif (
|
||||
existing is not None
|
||||
and existing.tls.mode == "custom"
|
||||
and Path(existing.tls.cert).is_file()
|
||||
and Path(existing.tls.key).is_file()
|
||||
):
|
||||
state.tls.sni = sni
|
||||
if existing.sni != sni:
|
||||
options.warnings.append(
|
||||
f"kept the custom certificate; its names may not cover the new SNI {sni}"
|
||||
)
|
||||
else:
|
||||
options.warnings.extend(
|
||||
setup_tls(
|
||||
paths,
|
||||
state,
|
||||
sni,
|
||||
force_regen=existing is None or existing.sni != sni,
|
||||
)
|
||||
)
|
||||
|
||||
if options.protocols is not None:
|
||||
for key, protocol_state in state.protocols.items():
|
||||
protocol_state.enabled = key in selected
|
||||
for key in selected:
|
||||
protocol = protocols.get(key)
|
||||
protocol_state = state.protocols.get(key)
|
||||
if protocol_state is None:
|
||||
protocol_state = ProtocolState(enabled=True, port=0, listen=listen, options=protocol.defaults())
|
||||
state.protocols[key] = protocol_state
|
||||
else:
|
||||
protocol_state.enabled = True
|
||||
if not protocol_state.options:
|
||||
protocol_state.options = protocol.defaults()
|
||||
if key == "hysteria2" and options.no_obfs:
|
||||
protocol_state.options["obfs"] = False
|
||||
|
||||
if not state.users:
|
||||
state.users = [User.create("u1")]
|
||||
|
||||
assign_ports(state, listen, options.base_port, selected)
|
||||
ensure_reality(paths, state)
|
||||
state.singbox_version = release.tag
|
||||
apply(paths, store, state, restart_service=True, progress=progress)
|
||||
service.enable(paths)
|
||||
return state
|
||||
@@ -0,0 +1,150 @@
|
||||
"""Enable, disable, and configure protocols."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .. import hostinfo, protocols
|
||||
from ..config import server_context
|
||||
from ..errors import SbpError
|
||||
from ..paths import Paths
|
||||
from ..state import ProtocolState
|
||||
from .common import ensure_reality, load, mutate
|
||||
|
||||
|
||||
def _require(key: str):
|
||||
if key not in protocols.REGISTRY:
|
||||
raise SbpError(f"unknown protocol: {key} (choose from {', '.join(protocols.keys())})")
|
||||
return protocols.get(key)
|
||||
|
||||
|
||||
def _check_state_port(state, key: str, port: int, protocol) -> None:
|
||||
for other_key, other in state.protocols.items():
|
||||
if other_key == key or not other.enabled or other.port != port:
|
||||
continue
|
||||
if protocols.get(other_key).info.transport == protocol.info.transport:
|
||||
raise SbpError(f"port {port} is already used by {other_key}")
|
||||
|
||||
|
||||
def enable(paths: Paths, key: str, port: int | None = None) -> ProtocolState:
|
||||
protocol = _require(key)
|
||||
if port is not None and not 1024 <= port <= 65535:
|
||||
raise SbpError("port must be between 1024 and 65535")
|
||||
|
||||
def change(state):
|
||||
protocol_state = state.protocols.get(key)
|
||||
listen = hostinfo.default_listen()
|
||||
if protocol_state is None:
|
||||
protocol_state = ProtocolState(
|
||||
enabled=True, port=0, listen=listen, options=protocol.defaults()
|
||||
)
|
||||
state.protocols[key] = protocol_state
|
||||
protocol_state.enabled = True
|
||||
if not protocol_state.options:
|
||||
protocol_state.options = protocol.defaults()
|
||||
if port is not None:
|
||||
_check_state_port(state, key, port, protocol)
|
||||
if not hostinfo.port_free(port, protocol_state.listen, protocol.info.transport):
|
||||
raise SbpError(f"port {port} is already in use on {protocol_state.listen}")
|
||||
protocol_state.port = port
|
||||
if not protocol_state.port:
|
||||
used = {item.port for item in state.protocols.values()}
|
||||
protocol_state.port = hostinfo.pick_port(
|
||||
protocol_state.listen, used, kind=protocol.info.transport
|
||||
)
|
||||
ensure_reality(paths, state)
|
||||
return protocol_state
|
||||
|
||||
return mutate(paths, change)
|
||||
|
||||
|
||||
def disable(paths: Paths, key: str) -> None:
|
||||
_require(key)
|
||||
|
||||
def change(state):
|
||||
protocol_state = state.protocols.get(key)
|
||||
if protocol_state is None or not protocol_state.enabled:
|
||||
raise SbpError(f"protocol {key} is not enabled")
|
||||
protocol_state.enabled = False
|
||||
|
||||
mutate(paths, change)
|
||||
|
||||
|
||||
def options(paths: Paths, key: str) -> dict[str, Any]:
|
||||
"""Effective options after normalize, for prefilling editors."""
|
||||
protocol = _require(key)
|
||||
_, state = load(paths)
|
||||
protocol_state = state.protocols.get(key)
|
||||
if protocol_state is None:
|
||||
raise SbpError(f"protocol {key} is not installed")
|
||||
return protocol.normalize(protocol_state.options, server_context(state))
|
||||
|
||||
|
||||
def set_option(paths: Paths, key: str, option: str, raw: str) -> Any:
|
||||
protocol = _require(key)
|
||||
kinds = {name: kind for name, _, kind in protocol.editable()}
|
||||
if option not in kinds:
|
||||
raise SbpError(f"protocol {key} has no option {option}")
|
||||
value = _coerce(raw, kinds[option])
|
||||
|
||||
def change(state):
|
||||
protocol_state = state.protocols.get(key)
|
||||
if protocol_state is None:
|
||||
raise SbpError(f"protocol {key} is not installed")
|
||||
protocol_state.options[option] = value
|
||||
protocol_state.options = protocol.normalize(protocol_state.options, server_context(state))
|
||||
return value
|
||||
|
||||
return mutate(paths, change)
|
||||
|
||||
|
||||
def _coerce(raw: str, kind: str) -> Any:
|
||||
if kind == "bool":
|
||||
lowered = raw.strip().lower()
|
||||
if lowered in ("1", "true", "yes", "on"):
|
||||
return True
|
||||
if lowered in ("0", "false", "no", "off"):
|
||||
return False
|
||||
raise SbpError(f"expected a boolean, got {raw!r}")
|
||||
if kind == "int":
|
||||
try:
|
||||
return int(raw)
|
||||
except ValueError:
|
||||
raise SbpError(f"expected an integer, got {raw!r}") from None
|
||||
return raw
|
||||
|
||||
|
||||
def summary(paths: Paths) -> list[tuple[str, bool, int, list[tuple[str, str]]]]:
|
||||
_, state = load(paths)
|
||||
ctx = server_context(state)
|
||||
result = []
|
||||
for protocol in protocols.all_protocols():
|
||||
protocol_state = state.protocols.get(protocol.info.key)
|
||||
enabled = bool(protocol_state and protocol_state.enabled)
|
||||
port = protocol_state.port if protocol_state else 0
|
||||
details = (
|
||||
protocol.summary(protocol.normalize(protocol_state.options, ctx))
|
||||
if protocol_state
|
||||
else []
|
||||
)
|
||||
result.append((protocol.info.key, enabled, port, details))
|
||||
return result
|
||||
|
||||
|
||||
def set_port(paths: Paths, key: str, port: int) -> None:
|
||||
protocol = _require(key)
|
||||
if not 1024 <= port <= 65535:
|
||||
raise SbpError("port must be between 1024 and 65535")
|
||||
|
||||
def change(state):
|
||||
protocol_state = state.protocols.get(key)
|
||||
if protocol_state is None:
|
||||
raise SbpError(f"protocol {key} is not installed")
|
||||
if protocol_state.port == port:
|
||||
return
|
||||
_check_state_port(state, key, port, protocol)
|
||||
if not hostinfo.port_free(port, protocol_state.listen, protocol.info.transport):
|
||||
raise SbpError(f"port {port} is already in use on {protocol_state.listen}")
|
||||
protocol_state.port = port
|
||||
|
||||
mutate(paths, change)
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Service control."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .. import service
|
||||
from ..paths import Paths
|
||||
|
||||
|
||||
def start(paths: Paths) -> None:
|
||||
service.start(paths)
|
||||
|
||||
|
||||
def stop(paths: Paths) -> None:
|
||||
service.stop(paths)
|
||||
|
||||
|
||||
def restart(paths: Paths) -> None:
|
||||
service.restart(paths)
|
||||
|
||||
|
||||
def logs(paths: Paths, lines: int = 100) -> list[str]:
|
||||
return service.journal(paths, lines=lines)
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Change host, SNI, and certificates."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from .. import hostinfo
|
||||
from ..errors import SbpError
|
||||
from ..paths import Paths
|
||||
from ..state import State
|
||||
from .common import mutate, setup_tls
|
||||
|
||||
|
||||
def _validate_host(value: str) -> str:
|
||||
if not hostinfo.valid_host(value):
|
||||
raise SbpError(f"invalid host value: {value}")
|
||||
return value
|
||||
|
||||
|
||||
def set_host(paths: Paths, host: str) -> None:
|
||||
_validate_host(host)
|
||||
mutate(paths, lambda state: setattr(state, "host", host), restart_service=False)
|
||||
|
||||
|
||||
def set_sni(paths: Paths, sni: str) -> list[str]:
|
||||
_validate_host(sni)
|
||||
warnings: list[str] = []
|
||||
|
||||
def change(state: State):
|
||||
state.sni = sni
|
||||
if state.tls.mode == "custom":
|
||||
state.tls.sni = sni
|
||||
return
|
||||
warnings.extend(setup_tls(paths, state, sni, force_regen=True))
|
||||
|
||||
mutate(paths, change)
|
||||
return warnings
|
||||
|
||||
|
||||
def set_cert(paths: Paths, cert: str, key: str, sni: Optional[str] = None) -> list[str]:
|
||||
warnings: list[str] = []
|
||||
|
||||
def change(state: State):
|
||||
target = sni or state.sni
|
||||
_validate_host(target)
|
||||
state.sni = target
|
||||
warnings.extend(setup_tls(paths, state, target, cert=cert, key=key))
|
||||
|
||||
mutate(paths, change)
|
||||
return warnings
|
||||
|
||||
|
||||
def regenerate_cert(paths: Paths) -> None:
|
||||
def change(state: State):
|
||||
if state.tls.mode == "custom":
|
||||
raise SbpError("the server uses a custom certificate; pass a new --cert and --key")
|
||||
setup_tls(paths, state, state.sni, force_regen=True)
|
||||
|
||||
mutate(paths, change)
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Read helpers that assemble share text and client configs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from ..config import build_client_config
|
||||
from ..errors import SbpError
|
||||
from ..paths import Paths
|
||||
from ..share import all_uris, share_uri
|
||||
from .common import load
|
||||
|
||||
|
||||
def user_uris(paths: Paths, name: str) -> list[tuple[str, str]]:
|
||||
_, state = load(paths)
|
||||
return all_uris(state, state.user(name))
|
||||
|
||||
|
||||
def user_uri(paths: Paths, name: str, protocol_key: str) -> str:
|
||||
_, state = load(paths)
|
||||
return share_uri(state, protocol_key, state.user(name))
|
||||
|
||||
|
||||
def client_config(paths: Paths, name: str, protocol_key: str) -> dict:
|
||||
_, state = load(paths)
|
||||
return build_client_config(state, protocol_key, state.user(name))
|
||||
|
||||
|
||||
def client_config_json(paths: Paths, name: str, protocol_key: str) -> str:
|
||||
return json.dumps(client_config(paths, name, protocol_key), indent=2)
|
||||
|
||||
|
||||
def server_certificate(paths: Paths) -> str:
|
||||
_, state = load(paths)
|
||||
cert = Path(state.tls.cert)
|
||||
if not cert.is_file():
|
||||
raise SbpError(f"certificate not found: {cert}")
|
||||
return cert.read_text()
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Read-only status reporting."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from .. import protocols, service, singbox, tls
|
||||
from ..config import server_context
|
||||
from ..paths import Paths
|
||||
from .common import open_store
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProtocolStatus:
|
||||
key: str
|
||||
title: str
|
||||
transport: str
|
||||
enabled: bool
|
||||
port: int
|
||||
details: list[tuple[str, str]]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Status:
|
||||
installed: bool
|
||||
active: bool
|
||||
host: str
|
||||
sni: str
|
||||
tls_mode: str
|
||||
cert_until: str
|
||||
singbox_version: str
|
||||
user_count: int
|
||||
created: str
|
||||
config_path: Path
|
||||
protocols: list[ProtocolStatus]
|
||||
|
||||
|
||||
def status(paths: Paths) -> Status:
|
||||
store = open_store(paths)
|
||||
state = store.try_load()
|
||||
if state is None:
|
||||
return Status(
|
||||
installed=False,
|
||||
active=False,
|
||||
host="",
|
||||
sni="",
|
||||
tls_mode="",
|
||||
cert_until="",
|
||||
singbox_version="",
|
||||
user_count=0,
|
||||
created="",
|
||||
config_path=paths.server_config,
|
||||
protocols=[],
|
||||
)
|
||||
ctx = server_context(state)
|
||||
protocol_rows = []
|
||||
for protocol in protocols.all_protocols():
|
||||
protocol_state = state.protocols.get(protocol.info.key)
|
||||
enabled = bool(protocol_state and protocol_state.enabled)
|
||||
protocol_rows.append(
|
||||
ProtocolStatus(
|
||||
key=protocol.info.key,
|
||||
title=protocol.info.title,
|
||||
transport=protocol.info.transport,
|
||||
enabled=enabled,
|
||||
port=protocol_state.port if protocol_state else 0,
|
||||
details=(
|
||||
protocol.summary(protocol.normalize(protocol_state.options, ctx))
|
||||
if protocol_state
|
||||
else []
|
||||
),
|
||||
)
|
||||
)
|
||||
cert_until = ""
|
||||
if state.tls.cert and Path(state.tls.cert).is_file():
|
||||
cert_until = tls.expiry(Path(state.tls.cert))
|
||||
return Status(
|
||||
installed=True,
|
||||
active=service.active(paths),
|
||||
host=state.host,
|
||||
sni=state.sni,
|
||||
tls_mode="self-signed (pinned)" if state.tls.self_signed else "custom",
|
||||
cert_until=cert_until,
|
||||
singbox_version=singbox.version(paths) or state.singbox_version or "missing",
|
||||
user_count=len(state.users),
|
||||
created=state.created[:10],
|
||||
config_path=paths.server_config,
|
||||
protocols=protocol_rows,
|
||||
)
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Remove the service, binary, and optionally the configuration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .. import service
|
||||
from ..errors import SbpError
|
||||
from ..fsutil import safe_rmtree
|
||||
from ..paths import Paths
|
||||
from .common import open_store
|
||||
|
||||
|
||||
def _remove(paths: Paths, purge: bool) -> None:
|
||||
service.disable(paths)
|
||||
if paths.data_dir.exists():
|
||||
marker = paths.binary if paths.binary.exists() else paths.binary.parent
|
||||
safe_rmtree(paths.data_dir, marker)
|
||||
same_dir = paths.config_dir.resolve() == paths.data_dir.resolve()
|
||||
if purge and paths.config_dir.exists() and not same_dir:
|
||||
marker = paths.state_file if paths.state_file.exists() else paths.cert_dir
|
||||
safe_rmtree(paths.config_dir, marker)
|
||||
|
||||
|
||||
def uninstall(paths: Paths, purge: bool = False) -> None:
|
||||
store = open_store(paths)
|
||||
state = store.try_load()
|
||||
if state is None and not paths.unit_file.exists() and not paths.binary.exists():
|
||||
raise SbpError("sbp is not installed")
|
||||
|
||||
if store.config_dir.exists():
|
||||
with store.lock():
|
||||
_remove(paths, purge)
|
||||
else:
|
||||
_remove(paths, purge)
|
||||
@@ -0,0 +1,33 @@
|
||||
"""sing-box upgrade."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable, Optional
|
||||
|
||||
from .. import net, singbox
|
||||
from ..paths import Paths
|
||||
from .common import apply, load
|
||||
|
||||
|
||||
def upgrade(
|
||||
paths: Paths,
|
||||
version: Optional[str] = None,
|
||||
force: bool = False,
|
||||
progress: Optional[Callable[[str], None]] = None,
|
||||
) -> tuple[str, str]:
|
||||
store, state = load(paths)
|
||||
release = net.get_release(version)
|
||||
current = state.singbox_version or singbox.version(paths) or "unknown"
|
||||
if state.singbox_version == release.tag and paths.binary.is_file() and not force:
|
||||
return current, release.tag
|
||||
with store.lock():
|
||||
state = store.load()
|
||||
singbox.install_binary(
|
||||
paths,
|
||||
release,
|
||||
current_config=paths.server_config,
|
||||
progress=progress,
|
||||
)
|
||||
state.singbox_version = release.tag
|
||||
apply(paths, store, state, restart_service=True, progress=progress)
|
||||
return current, release.tag
|
||||
@@ -0,0 +1,50 @@
|
||||
"""User management."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from ..errors import SbpError
|
||||
from ..paths import Paths
|
||||
from ..state import User
|
||||
from .common import mutate
|
||||
|
||||
NAME_RE = re.compile(r"[A-Za-z0-9_-]{1,32}")
|
||||
|
||||
|
||||
def validate_name(name: str) -> str:
|
||||
if not NAME_RE.fullmatch(name):
|
||||
raise SbpError("user name must match [A-Za-z0-9_-]{1,32}")
|
||||
return name
|
||||
|
||||
|
||||
def add(paths: Paths, name: str) -> User:
|
||||
validate_name(name)
|
||||
|
||||
def change(state):
|
||||
if any(user.name == name for user in state.users):
|
||||
raise SbpError(f"user {name} already exists")
|
||||
user = User.create(name)
|
||||
state.users.append(user)
|
||||
return user
|
||||
|
||||
return mutate(paths, change)
|
||||
|
||||
|
||||
def remove(paths: Paths, name: str) -> None:
|
||||
def change(state):
|
||||
remaining = [user for user in state.users if user.name != name]
|
||||
if len(remaining) == len(state.users):
|
||||
raise SbpError(f"user {name} does not exist")
|
||||
if not remaining:
|
||||
raise SbpError("cannot remove the last user; add another user first")
|
||||
state.users = remaining
|
||||
|
||||
mutate(paths, change)
|
||||
|
||||
|
||||
def list_users(paths: Paths) -> list[User]:
|
||||
from .common import load
|
||||
|
||||
_, state = load(paths)
|
||||
return list(state.users)
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Runtime file locations.
|
||||
|
||||
All paths are derived at call time so tests and the TUI can override them
|
||||
through environment variables without import-order surprises.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from . import constants
|
||||
|
||||
|
||||
def _xdg(env: str, fallback: str) -> Path:
|
||||
return Path(os.environ.get(env) or fallback).expanduser()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Paths:
|
||||
config_dir: Path
|
||||
data_dir: Path
|
||||
unit_dir: Path
|
||||
unit_name: str
|
||||
|
||||
@property
|
||||
def state_file(self) -> Path:
|
||||
return self.config_dir / constants.STATE_FILE
|
||||
|
||||
@property
|
||||
def server_config(self) -> Path:
|
||||
return self.config_dir / constants.SERVER_CONFIG
|
||||
|
||||
@property
|
||||
def cert_dir(self) -> Path:
|
||||
return self.config_dir / constants.CERT_DIR
|
||||
|
||||
@property
|
||||
def cert_file(self) -> Path:
|
||||
return self.cert_dir / constants.CERT_FILE
|
||||
|
||||
@property
|
||||
def key_file(self) -> Path:
|
||||
return self.cert_dir / constants.KEY_FILE
|
||||
|
||||
@property
|
||||
def binary(self) -> Path:
|
||||
return self.data_dir / constants.BINARY_PATH
|
||||
|
||||
@property
|
||||
def unit_file(self) -> Path:
|
||||
return self.unit_dir / f"{self.unit_name}.service"
|
||||
|
||||
|
||||
def load() -> Paths:
|
||||
return Paths(
|
||||
config_dir=Path(os.environ.get("SBP_CONFIG_DIR") or _xdg("XDG_CONFIG_HOME", "~/.config") / "sbp"),
|
||||
data_dir=Path(os.environ.get("SBP_DATA_DIR") or _xdg("XDG_DATA_HOME", "~/.local/share") / "sbp"),
|
||||
unit_dir=Path(os.environ.get("SBP_UNIT_DIR") or _xdg("XDG_CONFIG_HOME", "~/.config") / "systemd" / "user"),
|
||||
unit_name=os.environ.get("SBP_UNIT_NAME", constants.UNIT_NAME),
|
||||
)
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Protocol registry. Add a protocol by importing its module here."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .anytls import AnyTls
|
||||
from .base import Protocol, ProtocolInfo, ServerContext
|
||||
from .hysteria2 import Hysteria2
|
||||
from .shadowsocks import Shadowsocks
|
||||
from .trojan import Trojan
|
||||
from .tuic import Tuic
|
||||
from .vless_reality import VlessReality
|
||||
|
||||
_INSTANCES: tuple[Protocol, ...] = (
|
||||
Hysteria2(),
|
||||
VlessReality(),
|
||||
Trojan(),
|
||||
Shadowsocks(),
|
||||
Tuic(),
|
||||
AnyTls(),
|
||||
)
|
||||
|
||||
REGISTRY: dict[str, Protocol] = {proto.info.key: proto for proto in _INSTANCES}
|
||||
|
||||
|
||||
def get(key: str) -> Protocol:
|
||||
try:
|
||||
return REGISTRY[key]
|
||||
except KeyError:
|
||||
raise ValueError(f"unknown protocol: {key}") from None
|
||||
|
||||
|
||||
def all_protocols() -> list[Protocol]:
|
||||
return list(_INSTANCES)
|
||||
|
||||
|
||||
def keys() -> list[str]:
|
||||
return [proto.info.key for proto in _INSTANCES]
|
||||
|
||||
|
||||
__all__ = ["Protocol", "ProtocolInfo", "ServerContext", "REGISTRY", "get", "all_protocols", "keys"]
|
||||
@@ -0,0 +1,47 @@
|
||||
"""AnyTLS: TLS on TCP with a padding scheme."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .base import (
|
||||
Protocol,
|
||||
ProtocolInfo,
|
||||
client_tls,
|
||||
insecure_flag,
|
||||
node_name,
|
||||
quote,
|
||||
server_tls,
|
||||
uri_host,
|
||||
)
|
||||
|
||||
|
||||
class AnyTls(Protocol):
|
||||
info = ProtocolInfo(key="anytls", title="AnyTLS", transport="tcp")
|
||||
|
||||
def inbounds(self, ctx, settings, users, listen, port):
|
||||
return [{
|
||||
"type": "anytls",
|
||||
"tag": "anytls-in",
|
||||
"listen": listen,
|
||||
"listen_port": port,
|
||||
"users": [{"name": u.name, "password": u.password} for u in users],
|
||||
"tls": server_tls(ctx),
|
||||
}]
|
||||
|
||||
def share_uri(self, ctx, settings, user, port):
|
||||
query = {"sni": ctx.sni, "insecure": insecure_flag(ctx)}
|
||||
from urllib.parse import urlencode
|
||||
|
||||
return (
|
||||
f"anytls://{quote(user.password)}@{uri_host(ctx.host)}:{port}/"
|
||||
f"?{urlencode(query)}#{node_name(self.info, user)}"
|
||||
)
|
||||
|
||||
def client_outbound(self, ctx, settings, user, port):
|
||||
return {
|
||||
"type": "anytls",
|
||||
"tag": "anytls-out",
|
||||
"server": ctx.host,
|
||||
"server_port": port,
|
||||
"password": user.password,
|
||||
"tls": client_tls(ctx),
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Protocol interface shared by every protocol module.
|
||||
|
||||
A protocol module owns three pure translations:
|
||||
|
||||
state settings -> sing-box inbound JSON
|
||||
state settings -> client share URI
|
||||
state settings -> sing-box client outbound JSON
|
||||
|
||||
It never touches files, services, or the network.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import urllib.parse
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from ..state import User
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProtocolInfo:
|
||||
key: str
|
||||
title: str
|
||||
transport: str
|
||||
needs_reality: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ServerContext:
|
||||
host: str
|
||||
sni: str
|
||||
cert: str
|
||||
key: str
|
||||
cert_self_signed: bool
|
||||
cert_sha256: str
|
||||
spki_pin: str
|
||||
reality_private: str = ""
|
||||
reality_public: str = ""
|
||||
|
||||
|
||||
def uri_host(host: str) -> str:
|
||||
if ":" in host and not host.startswith("["):
|
||||
return f"[{host}]"
|
||||
return host
|
||||
|
||||
|
||||
def quote(value: str) -> str:
|
||||
return urllib.parse.quote(value, safe="")
|
||||
|
||||
|
||||
def node_name(info: ProtocolInfo, user: User) -> str:
|
||||
return quote(f"{user.name}-{info.key}")
|
||||
|
||||
|
||||
def server_tls(ctx: ServerContext, server_name: str | None = None, alpn: list[str] | None = None) -> dict[str, Any]:
|
||||
tls: dict[str, Any] = {
|
||||
"enabled": True,
|
||||
"server_name": server_name or ctx.sni,
|
||||
"min_version": "1.3",
|
||||
"certificate_path": ctx.cert,
|
||||
"key_path": ctx.key,
|
||||
}
|
||||
if alpn:
|
||||
tls["alpn"] = alpn
|
||||
return tls
|
||||
|
||||
|
||||
def client_tls(ctx: ServerContext, server_name: str | None = None, alpn: list[str] | None = None) -> dict[str, Any]:
|
||||
tls: dict[str, Any] = {
|
||||
"enabled": True,
|
||||
"server_name": server_name or ctx.sni,
|
||||
"min_version": "1.3",
|
||||
}
|
||||
if alpn:
|
||||
tls["alpn"] = alpn
|
||||
if ctx.cert_self_signed and ctx.spki_pin:
|
||||
tls["certificate_public_key_sha256"] = [ctx.spki_pin]
|
||||
return tls
|
||||
|
||||
|
||||
def insecure_flag(ctx: ServerContext) -> str:
|
||||
return "1" if ctx.cert_self_signed else "0"
|
||||
|
||||
|
||||
class Protocol(ABC):
|
||||
info: ProtocolInfo
|
||||
|
||||
def defaults(self) -> dict[str, Any]:
|
||||
return {}
|
||||
|
||||
def editable(self) -> list[tuple[str, str, str]]:
|
||||
"""Options the UI may edit: (key, label, kind) with kind bool/int/str."""
|
||||
return []
|
||||
|
||||
def normalize(self, settings: dict[str, Any], ctx: ServerContext) -> dict[str, Any]:
|
||||
return settings
|
||||
|
||||
def validate(self, settings: dict[str, Any], ctx: ServerContext) -> None:
|
||||
return None
|
||||
|
||||
@abstractmethod
|
||||
def inbounds(
|
||||
self,
|
||||
ctx: ServerContext,
|
||||
settings: dict[str, Any],
|
||||
users: list[User],
|
||||
listen: str,
|
||||
port: int,
|
||||
) -> list[dict[str, Any]]:
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def share_uri(
|
||||
self, ctx: ServerContext, settings: dict[str, Any], user: User, port: int
|
||||
) -> str:
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def client_outbound(
|
||||
self, ctx: ServerContext, settings: dict[str, Any], user: User, port: int
|
||||
) -> dict[str, Any]:
|
||||
...
|
||||
|
||||
def summary(self, settings: dict[str, Any]) -> list[tuple[str, str]]:
|
||||
return []
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Hysteria2: QUIC transport with TLS 1.3 and optional salamander obfuscation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import urllib.parse
|
||||
from typing import Any
|
||||
|
||||
from ..credentials import token
|
||||
from ..hostinfo import is_ip
|
||||
from .base import (
|
||||
Protocol,
|
||||
ProtocolInfo,
|
||||
client_tls,
|
||||
node_name,
|
||||
server_tls,
|
||||
uri_host,
|
||||
)
|
||||
|
||||
|
||||
def default_masquerade(sni: str) -> str:
|
||||
return "https://www.bing.com" if is_ip(sni) else f"https://{sni}"
|
||||
|
||||
|
||||
class Hysteria2(Protocol):
|
||||
info = ProtocolInfo(key="hysteria2", title="Hysteria2", transport="udp")
|
||||
|
||||
def defaults(self) -> dict[str, Any]:
|
||||
return {"obfs": True, "obfs_password": token(), "masquerade": ""}
|
||||
|
||||
def editable(self):
|
||||
return [("obfs", "Obfuscation", "bool"), ("masquerade", "Masquerade URL", "str")]
|
||||
|
||||
def normalize(self, settings, ctx):
|
||||
"""Flatten legacy obfs shapes and drop obfs when it has no key.
|
||||
|
||||
The result must be deterministic: the server config and the share URI
|
||||
are built from it separately, so both must agree on the obfs key.
|
||||
"""
|
||||
result = dict(settings)
|
||||
obfs = result.get("obfs")
|
||||
if isinstance(obfs, dict):
|
||||
result["obfs"] = bool(obfs.get("enabled", obfs.get("password")))
|
||||
if obfs.get("password"):
|
||||
result["obfs_password"] = obfs["password"]
|
||||
result["obfs"] = bool(result.get("obfs"))
|
||||
if result["obfs"] and not result.get("obfs_password"):
|
||||
result["obfs"] = False
|
||||
return result
|
||||
|
||||
def inbounds(self, ctx, settings, users, listen, port):
|
||||
inbound: dict[str, Any] = {
|
||||
"type": "hysteria2",
|
||||
"tag": "hysteria2-in",
|
||||
"listen": listen,
|
||||
"listen_port": port,
|
||||
"users": [{"name": u.name, "password": u.password} for u in users],
|
||||
"masquerade": settings.get("masquerade") or default_masquerade(ctx.sni),
|
||||
"tls": server_tls(ctx, alpn=["h3"]),
|
||||
}
|
||||
if settings.get("obfs"):
|
||||
inbound["obfs"] = {
|
||||
"type": "salamander",
|
||||
"password": settings["obfs_password"],
|
||||
}
|
||||
return [inbound]
|
||||
|
||||
def share_uri(self, ctx, settings, user, port):
|
||||
query = {"sni": ctx.sni}
|
||||
if settings.get("obfs"):
|
||||
query["obfs"] = "salamander"
|
||||
query["obfs-password"] = settings["obfs_password"]
|
||||
if ctx.cert_self_signed and ctx.cert_sha256:
|
||||
query["pinSHA256"] = ctx.cert_sha256
|
||||
return (
|
||||
f"hysteria2://{urllib.parse.quote(user.password, safe='')}"
|
||||
f"@{uri_host(ctx.host)}:{port}/?{urllib.parse.urlencode(query)}"
|
||||
f"#{node_name(self.info, user)}"
|
||||
)
|
||||
|
||||
def client_outbound(self, ctx, settings, user, port):
|
||||
outbound: dict[str, Any] = {
|
||||
"type": "hysteria2",
|
||||
"tag": "hysteria2-out",
|
||||
"server": ctx.host,
|
||||
"server_port": port,
|
||||
"password": user.password,
|
||||
"tls": client_tls(ctx, alpn=["h3"]),
|
||||
}
|
||||
if settings.get("obfs"):
|
||||
outbound["obfs"] = {
|
||||
"type": "salamander",
|
||||
"password": settings["obfs_password"],
|
||||
}
|
||||
return outbound
|
||||
|
||||
def summary(self, settings):
|
||||
return [
|
||||
("obfs", "salamander" if settings.get("obfs") else "off"),
|
||||
("masquerade", settings.get("masquerade") or "default"),
|
||||
]
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Shadowsocks 2022 with a server key and one key per user."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import urllib.parse
|
||||
from typing import Any
|
||||
|
||||
from ..credentials import shadowsocks_key
|
||||
from .base import Protocol, ProtocolInfo, node_name, uri_host
|
||||
|
||||
METHOD = "2022-blake3-aes-128-gcm"
|
||||
|
||||
|
||||
class Shadowsocks(Protocol):
|
||||
info = ProtocolInfo(key="shadowsocks", title="Shadowsocks 2022", transport="tcp")
|
||||
|
||||
def defaults(self) -> dict[str, Any]:
|
||||
return {"method": METHOD, "server_key": shadowsocks_key()}
|
||||
|
||||
def normalize(self, settings, ctx):
|
||||
"""The method is fixed: the keys are sized for it, so a change breaks them."""
|
||||
result = dict(settings)
|
||||
result["method"] = METHOD
|
||||
result.setdefault("server_key", shadowsocks_key())
|
||||
return result
|
||||
|
||||
def inbounds(self, ctx, settings, users, listen, port):
|
||||
return [{
|
||||
"type": "shadowsocks",
|
||||
"tag": "shadowsocks-in",
|
||||
"listen": listen,
|
||||
"listen_port": port,
|
||||
"method": settings.get("method", METHOD),
|
||||
"password": settings["server_key"],
|
||||
"users": [{"name": u.name, "password": u.ss_key} for u in users],
|
||||
}]
|
||||
|
||||
def share_uri(self, ctx, settings, user, port):
|
||||
method = settings.get("method", METHOD)
|
||||
credentials = f"{method}:{settings['server_key']}:{user.ss_key}"
|
||||
return (
|
||||
f"ss://{urllib.parse.quote(credentials, safe=':')}"
|
||||
f"@{uri_host(ctx.host)}:{port}#{node_name(self.info, user)}"
|
||||
)
|
||||
|
||||
def client_outbound(self, ctx, settings, user, port):
|
||||
return {
|
||||
"type": "shadowsocks",
|
||||
"tag": "shadowsocks-out",
|
||||
"server": ctx.host,
|
||||
"server_port": port,
|
||||
"method": settings.get("method", METHOD),
|
||||
# Multi-user SS2022: the client sends the server identity PSK and its
|
||||
# own user PSK, separated by a colon.
|
||||
"password": f"{settings['server_key']}:{user.ss_key}",
|
||||
}
|
||||
|
||||
def summary(self, settings):
|
||||
return [("method", settings.get("method", METHOD))]
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Trojan: TLS on TCP, password authentication."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
from .base import (
|
||||
Protocol,
|
||||
ProtocolInfo,
|
||||
client_tls,
|
||||
insecure_flag,
|
||||
node_name,
|
||||
quote,
|
||||
server_tls,
|
||||
uri_host,
|
||||
)
|
||||
|
||||
|
||||
class Trojan(Protocol):
|
||||
info = ProtocolInfo(key="trojan", title="Trojan", transport="tcp")
|
||||
|
||||
def inbounds(self, ctx, settings, users, listen, port):
|
||||
return [{
|
||||
"type": "trojan",
|
||||
"tag": "trojan-in",
|
||||
"listen": listen,
|
||||
"listen_port": port,
|
||||
"users": [{"name": u.name, "password": u.password} for u in users],
|
||||
"tls": server_tls(ctx),
|
||||
}]
|
||||
|
||||
def share_uri(self, ctx, settings, user, port):
|
||||
query = {
|
||||
"security": "tls",
|
||||
"sni": ctx.sni,
|
||||
"type": "tcp",
|
||||
"allowInsecure": insecure_flag(ctx),
|
||||
}
|
||||
from urllib.parse import urlencode
|
||||
|
||||
return (
|
||||
f"trojan://{quote(user.password)}@{uri_host(ctx.host)}:{port}"
|
||||
f"?{urlencode(query)}#{node_name(self.info, user)}"
|
||||
)
|
||||
|
||||
def client_outbound(self, ctx, settings, user, port):
|
||||
return {
|
||||
"type": "trojan",
|
||||
"tag": "trojan-out",
|
||||
"server": ctx.host,
|
||||
"server_port": port,
|
||||
"password": user.password,
|
||||
"tls": client_tls(ctx),
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
"""TUIC v5: QUIC transport with TLS 1.3."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .base import (
|
||||
Protocol,
|
||||
ProtocolInfo,
|
||||
client_tls,
|
||||
insecure_flag,
|
||||
node_name,
|
||||
quote,
|
||||
server_tls,
|
||||
uri_host,
|
||||
)
|
||||
|
||||
|
||||
class Tuic(Protocol):
|
||||
info = ProtocolInfo(key="tuic", title="TUIC v5", transport="udp")
|
||||
|
||||
def defaults(self) -> dict[str, Any]:
|
||||
return {"congestion_control": "bbr"}
|
||||
|
||||
def editable(self):
|
||||
return [("congestion_control", "Congestion control", "str")]
|
||||
|
||||
def inbounds(self, ctx, settings, users, listen, port):
|
||||
return [{
|
||||
"type": "tuic",
|
||||
"tag": "tuic-in",
|
||||
"listen": listen,
|
||||
"listen_port": port,
|
||||
"users": [
|
||||
{"name": u.name, "uuid": u.uuid, "password": u.password}
|
||||
for u in users
|
||||
],
|
||||
"congestion_control": settings.get("congestion_control", "bbr"),
|
||||
"zero_rtt_handshake": False,
|
||||
"tls": server_tls(ctx, alpn=["h3"]),
|
||||
}]
|
||||
|
||||
def share_uri(self, ctx, settings, user, port):
|
||||
query = {
|
||||
"sni": ctx.sni,
|
||||
"congestion_control": settings.get("congestion_control", "bbr"),
|
||||
"udp_relay_mode": "native",
|
||||
"alpn": "h3",
|
||||
"allowInsecure": insecure_flag(ctx),
|
||||
}
|
||||
from urllib.parse import urlencode
|
||||
|
||||
return (
|
||||
f"tuic://{user.uuid}:{quote(user.password)}"
|
||||
f"@{uri_host(ctx.host)}:{port}?{urlencode(query)}#{node_name(self.info, user)}"
|
||||
)
|
||||
|
||||
def client_outbound(self, ctx, settings, user, port):
|
||||
return {
|
||||
"type": "tuic",
|
||||
"tag": "tuic-out",
|
||||
"server": ctx.host,
|
||||
"server_port": port,
|
||||
"uuid": user.uuid,
|
||||
"password": user.password,
|
||||
"congestion_control": settings.get("congestion_control", "bbr"),
|
||||
"udp_relay_mode": "native",
|
||||
"tls": client_tls(ctx, alpn=["h3"]),
|
||||
}
|
||||
|
||||
def summary(self, settings):
|
||||
return [("congestion_control", settings.get("congestion_control", "bbr"))]
|
||||
@@ -0,0 +1,119 @@
|
||||
"""VLESS with REALITY: steals a real site's TLS handshake, no certificate."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from ..credentials import reality_short_id
|
||||
from ..errors import SbpError
|
||||
from ..hostinfo import is_ip
|
||||
from .base import Protocol, ProtocolInfo, node_name, uri_host
|
||||
|
||||
|
||||
class VlessReality(Protocol):
|
||||
info = ProtocolInfo(
|
||||
key="vless-reality",
|
||||
title="VLESS + REALITY",
|
||||
transport="tcp",
|
||||
needs_reality=True,
|
||||
)
|
||||
|
||||
def defaults(self) -> dict[str, Any]:
|
||||
return {
|
||||
"server_name": "",
|
||||
"short_id": reality_short_id(),
|
||||
"flow": "xtls-rprx-vision",
|
||||
}
|
||||
|
||||
def editable(self):
|
||||
return [
|
||||
("server_name", "Handshake domain", "str"),
|
||||
("short_id", "Short ID (hex)", "str"),
|
||||
("flow", "Flow", "str"),
|
||||
]
|
||||
|
||||
def normalize(self, settings, ctx):
|
||||
result = dict(settings)
|
||||
if not result.get("server_name"):
|
||||
result["server_name"] = ctx.sni
|
||||
return result
|
||||
|
||||
def validate(self, settings, ctx):
|
||||
server_name = settings.get("server_name") or ctx.sni
|
||||
if is_ip(server_name):
|
||||
raise SbpError("REALITY needs a domain name as server_name, not an IP address")
|
||||
short_id = settings.get("short_id", "")
|
||||
if short_id and (
|
||||
len(short_id) > 16
|
||||
or len(short_id) % 2 != 0
|
||||
or any(c not in "0123456789abcdef" for c in short_id.lower())
|
||||
):
|
||||
raise SbpError("short_id must be 0 to 8 bytes of hexadecimal, an even number of digits")
|
||||
|
||||
def inbounds(self, ctx, settings, users, listen, port):
|
||||
server_name = settings.get("server_name") or ctx.sni
|
||||
flow = settings.get("flow", "xtls-rprx-vision")
|
||||
return [{
|
||||
"type": "vless",
|
||||
"tag": "vless-reality-in",
|
||||
"listen": listen,
|
||||
"listen_port": port,
|
||||
"users": [{"name": u.name, "uuid": u.uuid, "flow": flow} for u in users],
|
||||
"tls": {
|
||||
"enabled": True,
|
||||
"server_name": server_name,
|
||||
"reality": {
|
||||
"enabled": True,
|
||||
"handshake": {"server": server_name, "server_port": 443},
|
||||
"private_key": ctx.reality_private,
|
||||
"short_id": [settings.get("short_id", "")],
|
||||
},
|
||||
},
|
||||
}]
|
||||
|
||||
def share_uri(self, ctx, settings, user, port):
|
||||
server_name = settings.get("server_name") or ctx.sni
|
||||
query = {
|
||||
"encryption": "none",
|
||||
"flow": settings.get("flow", "xtls-rprx-vision"),
|
||||
"security": "reality",
|
||||
"sni": server_name,
|
||||
"fp": "chrome",
|
||||
"pbk": ctx.reality_public,
|
||||
"sid": settings.get("short_id", ""),
|
||||
"type": "tcp",
|
||||
}
|
||||
from urllib.parse import urlencode
|
||||
|
||||
return (
|
||||
f"vless://{user.uuid}@{uri_host(ctx.host)}:{port}?{urlencode(query)}"
|
||||
f"#{node_name(self.info, user)}"
|
||||
)
|
||||
|
||||
def client_outbound(self, ctx, settings, user, port):
|
||||
server_name = settings.get("server_name") or ctx.sni
|
||||
return {
|
||||
"type": "vless",
|
||||
"tag": "vless-reality-out",
|
||||
"server": ctx.host,
|
||||
"server_port": port,
|
||||
"uuid": user.uuid,
|
||||
"flow": settings.get("flow", "xtls-rprx-vision"),
|
||||
"tls": {
|
||||
"enabled": True,
|
||||
"server_name": server_name,
|
||||
"utls": {"enabled": True, "fingerprint": "chrome"},
|
||||
"reality": {
|
||||
"enabled": True,
|
||||
"public_key": ctx.reality_public,
|
||||
"short_id": settings.get("short_id", ""),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
def summary(self, settings):
|
||||
return [
|
||||
("server_name", settings.get("server_name") or "auto"),
|
||||
("flow", settings.get("flow", "xtls-rprx-vision")),
|
||||
("short_id", settings.get("short_id") or "(empty)"),
|
||||
]
|
||||
@@ -0,0 +1,147 @@
|
||||
"""systemd user unit rendering and control."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from . import constants
|
||||
from .errors import SbpError
|
||||
from .fsutil import write_file
|
||||
from .paths import Paths
|
||||
from .shell import run
|
||||
|
||||
|
||||
def _quote(path: Path) -> str:
|
||||
escaped = str(path).replace("%", "%%").replace('"', '\\"')
|
||||
return f'"{escaped}"'
|
||||
|
||||
|
||||
def _inaccessible_paths() -> list[Path]:
|
||||
home = Path.home()
|
||||
return [home / name for name in constants.SENSITIVE_HOME_PATHS if (home / name).exists()]
|
||||
|
||||
|
||||
def render_unit(paths: Paths) -> None:
|
||||
binary = _quote(paths.binary)
|
||||
config = _quote(paths.server_config)
|
||||
inaccessible = "\n".join(
|
||||
f"InaccessiblePaths={_quote(path)}" for path in _inaccessible_paths()
|
||||
)
|
||||
unit = f"""[Unit]
|
||||
Description=sbp sing-box server
|
||||
Documentation=https://sing-box.sagernet.org
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart={binary} run -c {config}
|
||||
Restart=on-failure
|
||||
RestartSec=3s
|
||||
LimitNOFILE=65535
|
||||
UMask=0077
|
||||
SystemCallArchitectures=native
|
||||
NoNewPrivileges=yes
|
||||
PrivateTmp=yes
|
||||
PrivateDevices=yes
|
||||
ProtectSystem=strict
|
||||
ProtectHome=read-only
|
||||
{inaccessible}
|
||||
ProtectProc=invisible
|
||||
ProcSubset=pid
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectKernelLogs=yes
|
||||
ProtectClock=yes
|
||||
ProtectHostname=yes
|
||||
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
|
||||
RestrictNamespaces=yes
|
||||
RestrictRealtime=yes
|
||||
RestrictSUIDSGID=yes
|
||||
LockPersonality=yes
|
||||
MemoryDenyWriteExecute=yes
|
||||
SystemCallFilter=@system-service
|
||||
SystemCallErrorNumber=EPERM
|
||||
CapabilityBoundingSet=
|
||||
AmbientCapabilities=
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
"""
|
||||
write_file(paths.unit_file, unit, 0o644)
|
||||
|
||||
|
||||
def systemctl(*args: str, check: bool = True):
|
||||
return run(["systemctl", "--user", *args], check=check)
|
||||
|
||||
|
||||
def active(paths: Paths) -> bool:
|
||||
proc = systemctl("is-active", paths.unit_name, check=False)
|
||||
return proc.stdout.strip() == "active"
|
||||
|
||||
|
||||
def wait_active(paths: Paths, timeout: float = 8.0) -> bool:
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if active(paths):
|
||||
time.sleep(0.5)
|
||||
return active(paths)
|
||||
time.sleep(0.2)
|
||||
return False
|
||||
|
||||
|
||||
def _fail_inactive(paths: Paths, action: str) -> None:
|
||||
detail = systemctl("status", paths.unit_name, "--no-pager", "-n", "15", check=False)
|
||||
message = (detail.stdout or "") + (detail.stderr or "")
|
||||
raise SbpError(
|
||||
f"{paths.unit_name} did not become active after {action}\n{message.strip()}\n"
|
||||
f"check 'journalctl --user -u {paths.unit_name}'"
|
||||
)
|
||||
|
||||
|
||||
def restart(paths: Paths) -> None:
|
||||
systemctl("daemon-reload", check=False)
|
||||
proc = systemctl("restart", paths.unit_name, check=False)
|
||||
if proc.returncode != 0:
|
||||
detail = (proc.stderr or proc.stdout).strip()
|
||||
raise SbpError(f"cannot restart {paths.unit_name}: {detail}")
|
||||
if not wait_active(paths):
|
||||
_fail_inactive(paths, "restart")
|
||||
|
||||
|
||||
def enable(paths: Paths) -> None:
|
||||
"""Enable the unit so it starts at boot, without restarting it."""
|
||||
systemctl("daemon-reload", check=False)
|
||||
proc = systemctl("enable", paths.unit_name, check=False)
|
||||
if proc.returncode != 0:
|
||||
detail = (proc.stderr or proc.stdout).strip()
|
||||
raise SbpError(f"cannot enable {paths.unit_name}: {detail}")
|
||||
|
||||
|
||||
def start(paths: Paths) -> None:
|
||||
systemctl("daemon-reload", check=False)
|
||||
proc = systemctl("enable", "--now", paths.unit_name, check=False)
|
||||
if proc.returncode != 0:
|
||||
detail = (proc.stderr or proc.stdout).strip()
|
||||
raise SbpError(f"cannot start {paths.unit_name}: {detail}")
|
||||
if not wait_active(paths):
|
||||
_fail_inactive(paths, "start")
|
||||
|
||||
|
||||
def stop(paths: Paths) -> None:
|
||||
systemctl("stop", paths.unit_name, check=False)
|
||||
|
||||
|
||||
def disable(paths: Paths) -> None:
|
||||
systemctl("disable", "--now", paths.unit_name, check=False)
|
||||
if paths.unit_file.exists():
|
||||
paths.unit_file.unlink()
|
||||
systemctl("daemon-reload", check=False)
|
||||
|
||||
|
||||
def journal(paths: Paths, lines: int = 100, follow: bool = False) -> list[str]:
|
||||
cmd = ["journalctl", "--user", "-u", paths.unit_name, "-n", str(lines), "--no-pager"]
|
||||
if follow:
|
||||
cmd.append("-f")
|
||||
proc = run(cmd, check=False)
|
||||
return (proc.stdout or "").splitlines()
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Share URI generation for enabled protocols."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from . import protocols
|
||||
from .config import server_context
|
||||
from .errors import SbpError
|
||||
from .state import State, User
|
||||
|
||||
|
||||
def share_uri(state: State, protocol_key: str, user: User) -> str:
|
||||
protocol_state = state.protocols.get(protocol_key)
|
||||
if protocol_state is None or not protocol_state.enabled:
|
||||
raise SbpError(f"protocol {protocol_key} is not enabled")
|
||||
protocol = protocols.get(protocol_key)
|
||||
ctx = server_context(state)
|
||||
settings = protocol.normalize(protocol_state.options, ctx)
|
||||
protocol.validate(settings, ctx)
|
||||
return protocol.share_uri(ctx, settings, user, protocol_state.port)
|
||||
|
||||
|
||||
def all_uris(state: State, user: User) -> list[tuple[str, str]]:
|
||||
return [
|
||||
(key, share_uri(state, key, user))
|
||||
for key, protocol_state in state.enabled_protocols()
|
||||
]
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Subprocess helpers that raise SbpError instead of exiting."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from typing import Optional
|
||||
|
||||
from .errors import SbpError
|
||||
|
||||
|
||||
def run(
|
||||
cmd: list[str],
|
||||
*,
|
||||
check: bool = True,
|
||||
input: Optional[str] = None,
|
||||
env: Optional[dict[str, str]] = None,
|
||||
) -> subprocess.CompletedProcess:
|
||||
try:
|
||||
proc = subprocess.run(cmd, check=False, input=input, capture_output=True, text=True, env=env)
|
||||
except OSError as exc:
|
||||
raise SbpError(f"{cmd[0]}: {exc}") from exc
|
||||
if check and proc.returncode != 0:
|
||||
detail = (proc.stderr or proc.stdout or "").strip()
|
||||
raise SbpError(f"{' '.join(cmd)}: {detail}")
|
||||
return proc
|
||||
|
||||
|
||||
def run_bytes(cmd: list[str], *, input: Optional[bytes] = None) -> bytes:
|
||||
try:
|
||||
proc = subprocess.run(cmd, check=False, input=input, capture_output=True)
|
||||
except OSError as exc:
|
||||
raise SbpError(f"{cmd[0]}: {exc}") from exc
|
||||
if proc.returncode != 0:
|
||||
detail = (proc.stderr or proc.stdout).decode(errors="replace").strip()
|
||||
raise SbpError(f"{' '.join(cmd)}: {detail}")
|
||||
return proc.stdout
|
||||
@@ -0,0 +1,115 @@
|
||||
"""sing-box binary lifecycle."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import secrets
|
||||
import shutil
|
||||
import tarfile
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
|
||||
from . import net
|
||||
from .errors import SbpError
|
||||
from .fsutil import open_exclusive
|
||||
from .paths import Paths
|
||||
from .shell import run
|
||||
from .state import RealityState
|
||||
|
||||
|
||||
def version(paths: Paths) -> str:
|
||||
if not paths.binary.is_file():
|
||||
return ""
|
||||
proc = run([str(paths.binary), "version"], check=False)
|
||||
if proc.returncode != 0:
|
||||
return ""
|
||||
first = proc.stdout.splitlines()[0] if proc.stdout else ""
|
||||
return first.replace("sing-box version ", "").strip()
|
||||
|
||||
|
||||
def check_config(paths: Paths, config_path: Path) -> None:
|
||||
check_config_with(paths.binary, config_path)
|
||||
|
||||
|
||||
def check_config_with(binary: Path, config_path: Path) -> None:
|
||||
proc = run([str(binary), "check", "-c", str(config_path)], check=False)
|
||||
if proc.returncode != 0:
|
||||
detail = (proc.stderr or proc.stdout).strip()
|
||||
raise SbpError(f"sing-box rejects the config: {detail}")
|
||||
|
||||
|
||||
def reality_keypair(paths: Paths) -> RealityState:
|
||||
proc = run([str(paths.binary), "generate", "reality-keypair"], check=False)
|
||||
if proc.returncode != 0:
|
||||
detail = (proc.stderr or proc.stdout).strip()
|
||||
raise SbpError(f"cannot generate reality keypair: {detail}")
|
||||
private = public = ""
|
||||
for line in proc.stdout.splitlines():
|
||||
key, _, value = line.partition(":")
|
||||
if key.strip() == "PrivateKey":
|
||||
private = value.strip()
|
||||
elif key.strip() == "PublicKey":
|
||||
public = value.strip()
|
||||
if not private or not public:
|
||||
raise SbpError("sing-box returned an unexpected reality keypair")
|
||||
return RealityState(private_key=private, public_key=public)
|
||||
|
||||
|
||||
def _stage_binary(tarball: Path, dest: Path) -> Path:
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
staged = dest.with_name(f"{dest.name}.{os.getpid()}.{secrets.token_hex(4)}.tmp")
|
||||
try:
|
||||
with tarfile.open(tarball, "r:gz") as archive:
|
||||
members = [m for m in archive.getmembers() if m.isfile() and m.name.endswith("/sing-box")]
|
||||
if not members:
|
||||
raise SbpError("sing-box binary not found in archive")
|
||||
source = archive.extractfile(members[0])
|
||||
if source is None:
|
||||
raise SbpError("cannot read sing-box binary from archive")
|
||||
fd = open_exclusive(staged, 0o600)
|
||||
with os.fdopen(fd, "wb") as handle:
|
||||
os.fchmod(handle.fileno(), 0o600)
|
||||
shutil.copyfileobj(source, handle)
|
||||
except tarfile.TarError as exc:
|
||||
staged.unlink(missing_ok=True)
|
||||
raise SbpError(f"invalid release archive: {exc}") from exc
|
||||
except BaseException:
|
||||
staged.unlink(missing_ok=True)
|
||||
raise
|
||||
os.chmod(staged, 0o755)
|
||||
return staged
|
||||
|
||||
|
||||
def install_binary(
|
||||
paths: Paths,
|
||||
release: net.Release,
|
||||
current_config: Optional[Path] = None,
|
||||
progress: Optional[Callable[[str], None]] = None,
|
||||
) -> None:
|
||||
asset = net.asset_for(release.assets, release.tag)
|
||||
if progress:
|
||||
progress(f"downloading sing-box {release.tag}")
|
||||
with tempfile.TemporaryDirectory(prefix="sbp-") as tmpdir:
|
||||
tarball = Path(tmpdir) / "sing-box.tar.gz"
|
||||
net.download(asset["browser_download_url"], tarball)
|
||||
verified = net.verify_digest(tarball, asset)
|
||||
if progress and verified:
|
||||
progress("release checksum verified")
|
||||
staged = _stage_binary(tarball, paths.binary)
|
||||
|
||||
try:
|
||||
probe = run([str(staged), "version"], check=False)
|
||||
if probe.returncode != 0:
|
||||
raise SbpError("downloaded sing-box binary does not run")
|
||||
if current_config is not None and current_config.exists():
|
||||
try:
|
||||
check_config_with(staged, current_config)
|
||||
except SbpError as exc:
|
||||
raise SbpError(
|
||||
f"new sing-box rejects the current config; not replacing the binary: {exc}"
|
||||
) from exc
|
||||
os.replace(staged, paths.binary)
|
||||
except BaseException:
|
||||
staged.unlink(missing_ok=True)
|
||||
raise
|
||||
@@ -0,0 +1,258 @@
|
||||
"""State model, JSON round-trip, migration, and the file lock.
|
||||
|
||||
The state file is the single source of truth. Every writer takes the lock
|
||||
through StateStore.lock().
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fcntl
|
||||
import os
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterator, Optional
|
||||
|
||||
from . import constants, credentials
|
||||
from .errors import SbpError
|
||||
from .fsutil import read_json, write_json
|
||||
|
||||
|
||||
@dataclass
|
||||
class User:
|
||||
name: str
|
||||
password: str
|
||||
uuid: str
|
||||
ss_key: str
|
||||
created: str
|
||||
|
||||
@classmethod
|
||||
def create(cls, name: str) -> "User":
|
||||
return cls(
|
||||
name=name,
|
||||
password=credentials.token(),
|
||||
uuid=credentials.user_uuid(),
|
||||
ss_key=credentials.shadowsocks_key(),
|
||||
created=credentials.now_iso(),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"name": self.name,
|
||||
"password": self.password,
|
||||
"uuid": self.uuid,
|
||||
"ss_key": self.ss_key,
|
||||
"created": self.created,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "User":
|
||||
return cls(
|
||||
name=data["name"],
|
||||
password=data.get("password") or credentials.token(),
|
||||
uuid=data.get("uuid") or credentials.user_uuid(),
|
||||
ss_key=data.get("ss_key") or credentials.shadowsocks_key(),
|
||||
created=data.get("created") or credentials.now_iso(),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TlsState:
|
||||
mode: str
|
||||
cert: str
|
||||
key: str
|
||||
sni: str
|
||||
self_signed: bool
|
||||
spki_pin: str
|
||||
cert_sha256: str
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"mode": self.mode,
|
||||
"cert": self.cert,
|
||||
"key": self.key,
|
||||
"sni": self.sni,
|
||||
"self_signed": self.self_signed,
|
||||
"spki_pin": self.spki_pin,
|
||||
"cert_sha256": self.cert_sha256,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "TlsState":
|
||||
return cls(
|
||||
mode=data.get("mode", "self-signed"),
|
||||
cert=data.get("cert", ""),
|
||||
key=data.get("key", ""),
|
||||
sni=data.get("sni", ""),
|
||||
self_signed=bool(data.get("self_signed")),
|
||||
spki_pin=data.get("spki_pin", ""),
|
||||
cert_sha256=data.get("cert_sha256", ""),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RealityState:
|
||||
private_key: str = ""
|
||||
public_key: str = ""
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {"private_key": self.private_key, "public_key": self.public_key}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "RealityState":
|
||||
return cls(private_key=data.get("private_key", ""), public_key=data.get("public_key", ""))
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProtocolState:
|
||||
enabled: bool
|
||||
port: int
|
||||
listen: str
|
||||
options: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"enabled": self.enabled,
|
||||
"port": self.port,
|
||||
"listen": self.listen,
|
||||
"options": self.options,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "ProtocolState":
|
||||
return cls(
|
||||
enabled=bool(data.get("enabled", True)),
|
||||
port=int(data.get("port", 0)),
|
||||
listen=data.get("listen", "::"),
|
||||
options=dict(data.get("options") or {}),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class State:
|
||||
schema: int
|
||||
created: str
|
||||
host: str
|
||||
sni: str
|
||||
tls: TlsState
|
||||
reality: RealityState
|
||||
users: list[User]
|
||||
protocols: dict[str, ProtocolState]
|
||||
singbox_version: str = ""
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"schema": self.schema,
|
||||
"created": self.created,
|
||||
"host": self.host,
|
||||
"sni": self.sni,
|
||||
"tls": self.tls.to_dict(),
|
||||
"reality": self.reality.to_dict(),
|
||||
"users": [user.to_dict() for user in self.users],
|
||||
"protocols": {key: proto.to_dict() for key, proto in self.protocols.items()},
|
||||
"singbox_version": self.singbox_version,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "State":
|
||||
return cls(
|
||||
schema=int(data.get("schema", constants.SCHEMA)),
|
||||
created=data.get("created", credentials.now_iso()),
|
||||
host=data.get("host", ""),
|
||||
sni=data.get("sni", constants.DEFAULT_SNI),
|
||||
tls=TlsState.from_dict(data.get("tls") or {}),
|
||||
reality=RealityState.from_dict(data.get("reality") or {}),
|
||||
users=[User.from_dict(item) for item in data.get("users") or []],
|
||||
protocols={
|
||||
key: ProtocolState.from_dict(value)
|
||||
for key, value in (data.get("protocols") or {}).items()
|
||||
},
|
||||
singbox_version=data.get("singbox_version", data.get("version", "")),
|
||||
)
|
||||
|
||||
def user(self, name: str) -> User:
|
||||
for user in self.users:
|
||||
if user.name == name:
|
||||
return user
|
||||
raise SbpError(f"user {name} does not exist")
|
||||
|
||||
def enabled_protocols(self) -> list[tuple[str, ProtocolState]]:
|
||||
return [(key, proto) for key, proto in self.protocols.items() if proto.enabled]
|
||||
|
||||
|
||||
def migrate(raw: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Convert a schema 1 state (Hysteria2 only) to schema 2."""
|
||||
if int(raw.get("schema", 1)) >= constants.SCHEMA:
|
||||
return raw
|
||||
|
||||
listen = raw.get("listen", "::")
|
||||
port = int(raw.get("port", 0))
|
||||
options: dict[str, Any] = {}
|
||||
obfs = raw.get("obfs")
|
||||
if isinstance(obfs, dict):
|
||||
options["obfs"] = bool(obfs.get("enabled", obfs.get("password")))
|
||||
if obfs.get("password"):
|
||||
options["obfs_password"] = obfs["password"]
|
||||
elif obfs is not None:
|
||||
options["obfs"] = bool(obfs)
|
||||
if raw.get("obfs_password"):
|
||||
options["obfs_password"] = raw["obfs_password"]
|
||||
if raw.get("masquerade"):
|
||||
options["masquerade"] = raw["masquerade"]
|
||||
|
||||
users = []
|
||||
for item in raw.get("users") or []:
|
||||
user = User.from_dict(item)
|
||||
users.append(user.to_dict())
|
||||
|
||||
return {
|
||||
"schema": constants.SCHEMA,
|
||||
"created": raw.get("created") or credentials.now_iso(),
|
||||
"host": raw.get("host", ""),
|
||||
"sni": raw.get("sni", constants.DEFAULT_SNI),
|
||||
"tls": raw.get("tls") or {},
|
||||
"reality": raw.get("reality") or {},
|
||||
"users": users,
|
||||
"protocols": {
|
||||
"hysteria2": {"enabled": True, "port": port, "listen": listen, "options": options}
|
||||
},
|
||||
"singbox_version": raw.get("version", ""),
|
||||
}
|
||||
|
||||
|
||||
class StateStore:
|
||||
def __init__(self, config_dir: Path) -> None:
|
||||
self.config_dir = config_dir
|
||||
self.path = config_dir / constants.STATE_FILE
|
||||
|
||||
def exists(self) -> bool:
|
||||
return self.path.exists()
|
||||
|
||||
def load(self) -> State:
|
||||
if not self.path.exists():
|
||||
raise SbpError("sbp is not installed; run 'sbp install' first")
|
||||
return State.from_dict(migrate(read_json(self.path)))
|
||||
|
||||
def try_load(self) -> Optional[State]:
|
||||
"""Return None when the state is absent or unreadable, so repair works."""
|
||||
if not self.path.exists():
|
||||
return None
|
||||
try:
|
||||
return State.from_dict(migrate(read_json(self.path)))
|
||||
except (SbpError, ValueError, KeyError, TypeError):
|
||||
return None
|
||||
|
||||
def save(self, state: State) -> None:
|
||||
write_json(self.path, state.to_dict(), 0o600)
|
||||
|
||||
@contextmanager
|
||||
def lock(self) -> Iterator[None]:
|
||||
self.config_dir.mkdir(parents=True, exist_ok=True)
|
||||
os.chmod(self.config_dir, 0o700)
|
||||
fd = os.open(self.config_dir / ".lock", os.O_WRONLY | os.O_CREAT, 0o600)
|
||||
try:
|
||||
fcntl.flock(fd, fcntl.LOCK_EX)
|
||||
yield
|
||||
finally:
|
||||
fcntl.flock(fd, fcntl.LOCK_UN)
|
||||
os.close(fd)
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Self-signed certificate generation, inspection, and pinning."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import os
|
||||
import stat
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from .errors import SbpError
|
||||
from .hostinfo import is_ip
|
||||
from .shell import run, run_bytes
|
||||
|
||||
|
||||
def generate(sni: str, cert: Path, key: Path) -> None:
|
||||
cert.parent.mkdir(parents=True, exist_ok=True)
|
||||
os.chmod(cert.parent, 0o700)
|
||||
san = f"IP:{sni}" if is_ip(sni) else f"DNS:{sni}"
|
||||
run([
|
||||
"openssl", "req", "-x509", "-newkey", "ec",
|
||||
"-pkeyopt", "ec_paramgen_curve:P-256", "-nodes",
|
||||
"-keyout", str(key), "-out", str(cert), "-days", "3650",
|
||||
"-subj", f"/CN={sni}",
|
||||
"-addext", f"subjectAltName={san}",
|
||||
"-addext", "basicConstraints=critical,CA:FALSE",
|
||||
"-addext", "keyUsage=critical,digitalSignature",
|
||||
"-addext", "extendedKeyUsage=serverAuth",
|
||||
])
|
||||
os.chmod(key, 0o600)
|
||||
os.chmod(cert, 0o644)
|
||||
|
||||
|
||||
def pins(cert: Path) -> tuple[str, str]:
|
||||
der = run_bytes(["openssl", "x509", "-in", str(cert), "-outform", "DER"])
|
||||
public_pem = run_bytes(["openssl", "x509", "-in", str(cert), "-pubkey", "-noout"])
|
||||
spki = run_bytes(["openssl", "pkey", "-pubin", "-outform", "DER"], input=public_pem)
|
||||
spki_pin = base64.b64encode(hashlib.sha256(spki).digest()).decode()
|
||||
cert_sha256 = hashlib.sha256(der).hexdigest()
|
||||
return spki_pin, cert_sha256
|
||||
|
||||
|
||||
def expiry(cert: Path) -> str:
|
||||
proc = run(["openssl", "x509", "-in", str(cert), "-noout", "-enddate"], check=False)
|
||||
if proc.returncode != 0:
|
||||
return "unknown"
|
||||
value = proc.stdout.strip().split("=", 1)[-1]
|
||||
try:
|
||||
return datetime.strptime(value, "%b %d %H:%M:%S %Y %Z").strftime("%Y-%m-%d")
|
||||
except ValueError:
|
||||
return value
|
||||
|
||||
|
||||
def names(cert: Path) -> list[str]:
|
||||
proc = run(["openssl", "x509", "-in", str(cert), "-noout", "-ext", "subjectAltName"], check=False)
|
||||
result: list[str] = []
|
||||
for part in proc.stdout.replace("\n", ",").split(","):
|
||||
part = part.strip()
|
||||
if part.startswith("DNS:"):
|
||||
result.append(part[4:])
|
||||
elif part.startswith("IP Address:"):
|
||||
result.append(part[len("IP Address:"):])
|
||||
return result
|
||||
|
||||
|
||||
def self_signed(cert: Path) -> bool:
|
||||
subject = run(["openssl", "x509", "-in", str(cert), "-noout", "-subject"], check=False).stdout
|
||||
issuer = run(["openssl", "x509", "-in", str(cert), "-noout", "-issuer"], check=False).stdout
|
||||
return subject.strip().split("=", 1)[-1] == issuer.strip().split("=", 1)[-1]
|
||||
|
||||
|
||||
def verify_pair(cert: Path, key: Path) -> None:
|
||||
if not cert.is_file():
|
||||
raise SbpError(f"certificate not found: {cert}")
|
||||
if not key.is_file():
|
||||
raise SbpError(f"private key not found: {key}")
|
||||
mode = key.stat().st_mode
|
||||
if not stat.S_ISREG(mode):
|
||||
raise SbpError(f"private key is not a regular file: {key}")
|
||||
cert_pub = run_bytes(["openssl", "x509", "-in", str(cert), "-pubkey", "-noout"])
|
||||
key_pub = run_bytes(["openssl", "pkey", "-in", str(key), "-pubout"])
|
||||
if cert_pub.strip() != key_pub.strip():
|
||||
raise SbpError("certificate and private key do not match")
|
||||
|
||||
|
||||
def key_permissions_warning(key: Path) -> str | None:
|
||||
if key.is_file() and key.stat().st_mode & 0o077:
|
||||
return f"{key} is readable by group or others; run: chmod 600 {key}"
|
||||
return None
|
||||
@@ -0,0 +1 @@
|
||||
"""Full-screen terminal interface."""
|
||||
@@ -0,0 +1,105 @@
|
||||
"""TUI application loop and shared helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import curses
|
||||
import sys
|
||||
|
||||
from .. import constants, protocols
|
||||
from ..errors import SbpError
|
||||
from ..ops import install as install_op
|
||||
from ..ops import status as status_op
|
||||
from ..paths import Paths
|
||||
from .term import Term
|
||||
from .screens import dashboard, logs, protocols as protocols_screen, settings, users
|
||||
|
||||
|
||||
class App:
|
||||
def __init__(self, paths: Paths) -> None:
|
||||
self.paths = paths
|
||||
self.term: Term
|
||||
|
||||
def status(self) -> status_op.Status:
|
||||
return status_op.status(self.paths)
|
||||
|
||||
def main(self, screen) -> None:
|
||||
self.term = Term(screen)
|
||||
while True:
|
||||
status = self.status()
|
||||
if not status.installed:
|
||||
choice = self.term.menu("sbp", ["Install", "Quit"], subtitle="not installed")
|
||||
if choice == 0:
|
||||
self.install_wizard()
|
||||
continue
|
||||
return
|
||||
subtitle = f"{status.host} {'active' if status.active else 'inactive'}"
|
||||
choice = self.term.menu(
|
||||
f"sbp {constants.VERSION}",
|
||||
["Dashboard", "Protocols", "Users", "Settings", "Logs", "Quit"],
|
||||
subtitle=subtitle,
|
||||
)
|
||||
if choice is None or choice == 5:
|
||||
return
|
||||
handler = (
|
||||
dashboard.show,
|
||||
protocols_screen.show,
|
||||
users.show,
|
||||
settings.show,
|
||||
logs.show,
|
||||
)[choice]
|
||||
try:
|
||||
handler(self)
|
||||
except SbpError as exc:
|
||||
self.term.message("Error", exc.message, "error")
|
||||
|
||||
def install_wizard(self) -> None:
|
||||
host = self.term.prompt("Install", "Client-facing host", "")
|
||||
sni = self.term.prompt("Install", "TLS server name", constants.DEFAULT_SNI)
|
||||
if sni is None:
|
||||
return
|
||||
keys = ", ".join(protocols.keys())
|
||||
selection = self.term.prompt("Install", f"Protocols ({keys})", "all")
|
||||
if selection is None:
|
||||
return
|
||||
port_text = self.term.prompt("Install", "First port", str(constants.BASE_PORT))
|
||||
if port_text is None:
|
||||
return
|
||||
try:
|
||||
base_port = int(port_text)
|
||||
except ValueError:
|
||||
self.term.message("Error", f"invalid port: {port_text}", "error")
|
||||
return
|
||||
selected = None if selection.strip().lower() in ("", "all") else [
|
||||
item.strip() for item in selection.split(",") if item.strip()
|
||||
]
|
||||
options = install_op.InstallOptions(
|
||||
host=host or None,
|
||||
sni=sni or None,
|
||||
protocols=selected,
|
||||
base_port=base_port,
|
||||
)
|
||||
|
||||
def action(progress):
|
||||
install_op.install(self.paths, options, progress=progress)
|
||||
for warning in options.warnings:
|
||||
print(f"warning: {warning}")
|
||||
|
||||
self.term.run_external("Install sbp", action)
|
||||
|
||||
|
||||
def run(paths: Paths) -> int:
|
||||
if not sys.stdout.isatty() or not sys.stdin.isatty():
|
||||
print("sbp tui needs an interactive terminal", file=sys.stderr)
|
||||
return 1
|
||||
app = App(paths)
|
||||
try:
|
||||
curses.wrapper(app.main)
|
||||
except curses.error as exc:
|
||||
print(f"error: cannot start the TUI: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
except SbpError as exc:
|
||||
print(f"error: {exc.message}", file=sys.stderr)
|
||||
return exc.code
|
||||
except KeyboardInterrupt:
|
||||
return 130
|
||||
return 0
|
||||
@@ -0,0 +1 @@
|
||||
"""TUI screens."""
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Dashboard: status summary and service control."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ...ops import service as service_op
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..app import App
|
||||
|
||||
|
||||
def _summary(app: "App") -> str:
|
||||
status = app.status()
|
||||
lines = [
|
||||
f"Service {'active' if status.active else 'inactive'}",
|
||||
f"sing-box {status.singbox_version}",
|
||||
f"Address {status.host}",
|
||||
f"SNI {status.sni}",
|
||||
f"TLS {status.tls_mode}",
|
||||
f"Cert until {status.cert_until}",
|
||||
f"Users {status.user_count}",
|
||||
"",
|
||||
"Protocols",
|
||||
]
|
||||
for row in status.protocols:
|
||||
mark = "on " if row.enabled else "off"
|
||||
details = ", ".join(f"{key}={value}" for key, value in row.details)
|
||||
lines.append(f" [{mark}] {row.title:<18} port {row.port:<6} {row.transport} {details}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def show(app: "App") -> None:
|
||||
while True:
|
||||
status = app.status()
|
||||
choice = app.term.menu(
|
||||
"Dashboard",
|
||||
["Show full status", "Start service", "Stop service", "Restart service", "Back"],
|
||||
subtitle=f"{status.host} {'active' if status.active else 'inactive'}",
|
||||
)
|
||||
if choice is None or choice == 4:
|
||||
return
|
||||
if choice == 0:
|
||||
app.term.scroll_text("Status", _summary(app))
|
||||
elif choice == 1:
|
||||
app.term.run_external("Start service", lambda progress: service_op.start(app.paths))
|
||||
elif choice == 2:
|
||||
app.term.run_external("Stop service", lambda progress: service_op.stop(app.paths))
|
||||
elif choice == 3:
|
||||
app.term.run_external("Restart service", lambda progress: service_op.restart(app.paths))
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Journal viewer."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ...ops import service as service_op
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..app import App
|
||||
|
||||
|
||||
def show(app: "App") -> None:
|
||||
while True:
|
||||
choice = app.term.menu("Logs", ["Recent 100 lines", "Recent 500 lines", "Back"])
|
||||
if choice is None or choice == 2:
|
||||
return
|
||||
lines = 100 if choice == 0 else 500
|
||||
app.term.scroll_text(
|
||||
"Logs",
|
||||
"\n".join(service_op.logs(app.paths, lines=lines)) or "no log lines",
|
||||
)
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Protocol list and per-protocol actions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ... import protocols
|
||||
from ...ops import protocols as protocols_op
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..app import App
|
||||
|
||||
|
||||
def _show(value) -> str:
|
||||
if isinstance(value, bool):
|
||||
return "true" if value else "false"
|
||||
return str(value)
|
||||
|
||||
|
||||
def _label(row, protocol) -> str:
|
||||
state = "enabled" if row[1] else "off"
|
||||
details = ", ".join(f"{key}={value}" for key, value in row[3])
|
||||
suffix = f" {details}" if details else ""
|
||||
return f"{protocol.info.title:<18} {state:<8} {row[2]:<6} {protocol.info.transport}{suffix}"
|
||||
|
||||
|
||||
def show(app: "App") -> None:
|
||||
while True:
|
||||
rows = protocols_op.summary(app.paths)
|
||||
labels = [_label(row, protocols.get(row[0])) for row in rows]
|
||||
labels.append("Back")
|
||||
choice = app.term.menu("Protocols", labels, subtitle="select a protocol to manage")
|
||||
if choice is None or choice == len(rows):
|
||||
return
|
||||
_detail(app, rows[choice][0])
|
||||
|
||||
|
||||
def _detail(app: "App", key: str) -> None:
|
||||
while True:
|
||||
row = next(item for item in protocols_op.summary(app.paths) if item[0] == key)
|
||||
protocol = protocols.get(key)
|
||||
enabled = row[1]
|
||||
actions = [
|
||||
"Disable" if enabled else "Enable",
|
||||
"Change port",
|
||||
"Edit options",
|
||||
"Show settings",
|
||||
"Back",
|
||||
]
|
||||
choice = app.term.menu(
|
||||
protocol.info.title,
|
||||
actions,
|
||||
subtitle=f"{'enabled' if enabled else 'off'} port {row[2]} {protocol.info.transport}",
|
||||
)
|
||||
if choice is None or choice == 4:
|
||||
return
|
||||
if choice == 0:
|
||||
if enabled:
|
||||
app.term.run_external("Disable", lambda progress: protocols_op.disable(app.paths, key))
|
||||
else:
|
||||
port_text = app.term.prompt("Enable", "Port (blank = auto)", "")
|
||||
port = int(port_text) if port_text and port_text.isdigit() else None
|
||||
app.term.run_external(
|
||||
"Enable", lambda progress: protocols_op.enable(app.paths, key, port=port)
|
||||
)
|
||||
elif choice == 1:
|
||||
current = str(row[2])
|
||||
value = app.term.prompt("Change port", "Port", current)
|
||||
if value and value.isdigit():
|
||||
app.term.run_external(
|
||||
"Change port", lambda progress: protocols_op.set_port(app.paths, key, int(value))
|
||||
)
|
||||
elif choice == 2:
|
||||
_edit_options(app, key)
|
||||
elif choice == 3:
|
||||
lines = [f"{name}: {value}" for name, value in row[3]] or ["no editable options"]
|
||||
app.term.scroll_text(protocol.info.title, "\n".join(lines))
|
||||
|
||||
|
||||
def _edit_options(app: "App", key: str) -> None:
|
||||
protocol = protocols.get(key)
|
||||
editable = protocol.editable()
|
||||
if not editable:
|
||||
app.term.message("Options", "this protocol has no editable options")
|
||||
return
|
||||
current = protocols_op.options(app.paths, key)
|
||||
labels = [f"{label}: {_show(current.get(name, ''))}" for name, label, _ in editable]
|
||||
labels.append("Back")
|
||||
choice = app.term.menu("Edit options", labels)
|
||||
if choice is None or choice == len(editable):
|
||||
return
|
||||
name, label, _kind = editable[choice]
|
||||
value = app.term.prompt("Edit option", label, _show(current.get(name, "")))
|
||||
if value is None:
|
||||
return
|
||||
app.term.run_external(
|
||||
"Apply option",
|
||||
lambda progress: protocols_op.set_option(app.paths, key, name, value),
|
||||
)
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Server settings, upgrade, and uninstall."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ...ops import install as install_op
|
||||
from ...ops import settings as settings_op
|
||||
from ...ops import uninstall as uninstall_op
|
||||
from ...ops import upgrade as upgrade_op
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..app import App
|
||||
|
||||
ACTIONS = (
|
||||
"Show settings",
|
||||
"Set host",
|
||||
"Set SNI",
|
||||
"Use custom certificate",
|
||||
"Renew certificate",
|
||||
"Upgrade sing-box",
|
||||
"Reinstall",
|
||||
"Uninstall",
|
||||
"Back",
|
||||
)
|
||||
|
||||
|
||||
def show(app: "App") -> None:
|
||||
while True:
|
||||
status = app.status()
|
||||
choice = app.term.menu(
|
||||
"Settings",
|
||||
list(ACTIONS),
|
||||
subtitle=f"{status.host} sni {status.sni}",
|
||||
)
|
||||
if choice is None or choice == 8:
|
||||
return
|
||||
if choice == 0:
|
||||
app.term.scroll_text(
|
||||
"Settings",
|
||||
"\n".join([
|
||||
f"Host {status.host}",
|
||||
f"SNI {status.sni}",
|
||||
f"TLS {status.tls_mode}",
|
||||
f"Cert until {status.cert_until}",
|
||||
f"Config {status.config_path}",
|
||||
f"Created {status.created}",
|
||||
]),
|
||||
)
|
||||
elif choice == 1:
|
||||
value = app.term.prompt("Set host", "Client-facing host", status.host)
|
||||
if value:
|
||||
app.term.run_external(
|
||||
"Set host", lambda progress: settings_op.set_host(app.paths, value)
|
||||
)
|
||||
elif choice == 2:
|
||||
value = app.term.prompt("Set SNI", "TLS server name", status.sni)
|
||||
if value:
|
||||
app.term.run_external(
|
||||
"Set SNI", lambda progress: settings_op.set_sni(app.paths, value)
|
||||
)
|
||||
elif choice == 3:
|
||||
cert = app.term.prompt("Custom certificate", "Certificate path (PEM)", "")
|
||||
if not cert:
|
||||
continue
|
||||
key = app.term.prompt("Custom certificate", "Private key path (PEM)", "")
|
||||
if not key:
|
||||
continue
|
||||
sni = app.term.prompt("Custom certificate", "SNI", status.sni)
|
||||
app.term.run_external(
|
||||
"Use custom certificate",
|
||||
lambda progress: settings_op.set_cert(app.paths, cert, key, sni or None),
|
||||
)
|
||||
elif choice == 4:
|
||||
app.term.run_external(
|
||||
"Renew certificate", lambda progress: settings_op.regenerate_cert(app.paths)
|
||||
)
|
||||
elif choice == 5:
|
||||
app.term.run_external(
|
||||
"Upgrade sing-box", lambda progress: upgrade_op.upgrade(app.paths, progress=progress)
|
||||
)
|
||||
elif choice == 6:
|
||||
if app.term.confirm("Reinstall", "Reinstall sing-box and regenerate the service?"):
|
||||
options = install_op.InstallOptions(force=True)
|
||||
app.term.run_external(
|
||||
"Reinstall",
|
||||
lambda progress: install_op.install(app.paths, options, progress=progress),
|
||||
)
|
||||
elif choice == 7:
|
||||
if app.term.confirm("Uninstall", "Stop and remove the service and binary?"):
|
||||
purge = app.term.confirm(
|
||||
"Uninstall",
|
||||
"Also delete configuration, users, and certificates?",
|
||||
)
|
||||
app.term.run_external(
|
||||
"Uninstall", lambda progress: uninstall_op.uninstall(app.paths, purge=purge)
|
||||
)
|
||||
return
|
||||
@@ -0,0 +1,60 @@
|
||||
"""User list, share links, and client configs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ... import protocols
|
||||
from ...ops import show as show_op
|
||||
from ...ops import users as users_op
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..app import App
|
||||
|
||||
|
||||
def show(app: "App") -> None:
|
||||
while True:
|
||||
users = users_op.list_users(app.paths)
|
||||
labels = [f"{user.name:<20} {user.created[:10]}" for user in users]
|
||||
labels.append("+ Add user")
|
||||
labels.append("Back")
|
||||
choice = app.term.menu("Users", labels, subtitle=f"{len(users)} users")
|
||||
if choice is None or choice == len(users) + 1:
|
||||
return
|
||||
if choice == len(users):
|
||||
name = app.term.prompt("Add user", "User name", "")
|
||||
if name:
|
||||
app.term.run_external("Add user", lambda progress: users_op.add(app.paths, name))
|
||||
continue
|
||||
_detail(app, users[choice].name)
|
||||
|
||||
|
||||
def _detail(app: "App", name: str) -> None:
|
||||
while True:
|
||||
choice = app.term.menu(
|
||||
f"User {name}",
|
||||
["Show share URIs", "Show client config", "Delete user", "Back"],
|
||||
)
|
||||
if choice is None or choice == 3:
|
||||
return
|
||||
if choice == 0:
|
||||
uris = show_op.user_uris(app.paths, name)
|
||||
text = "\n\n".join(f"{key}\n {uri}" for key, uri in uris) or "no enabled protocols"
|
||||
app.term.scroll_text(f"Share URIs: {name}", text)
|
||||
elif choice == 1:
|
||||
enabled = [row.key for row in app.status().protocols if row.enabled]
|
||||
if not enabled:
|
||||
app.term.message("Client config", "no enabled protocols")
|
||||
continue
|
||||
labels = [protocols.get(key).info.title for key in enabled] + ["Back"]
|
||||
picked = app.term.menu("Client config", labels)
|
||||
if picked is None or picked == len(enabled):
|
||||
continue
|
||||
app.term.scroll_text(
|
||||
f"Client config: {name} / {enabled[picked]}",
|
||||
show_op.client_config_json(app.paths, name, enabled[picked]),
|
||||
)
|
||||
elif choice == 2:
|
||||
if app.term.confirm("Delete user", f"Delete {name}? Clients using this user stop working."):
|
||||
app.term.run_external("Delete user", lambda progress: users_op.remove(app.paths, name))
|
||||
return
|
||||
@@ -0,0 +1,279 @@
|
||||
"""Curses widgets: menus, prompts, dialogs, and text paging.
|
||||
|
||||
Only this module touches curses. Screens call these helpers and never draw
|
||||
raw escape sequences themselves.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import curses
|
||||
import textwrap
|
||||
import unicodedata
|
||||
from typing import Callable, Optional
|
||||
|
||||
ACCENT = 1
|
||||
GOOD = 2
|
||||
WARN = 3
|
||||
BAD = 4
|
||||
DIM = 5
|
||||
|
||||
|
||||
def display_width(text: str) -> int:
|
||||
width = 0
|
||||
for char in text:
|
||||
if unicodedata.combining(char):
|
||||
continue
|
||||
width += 2 if unicodedata.east_asian_width(char) in ("W", "F") else 1
|
||||
return width
|
||||
|
||||
|
||||
def truncate(text: str, width: int) -> str:
|
||||
if display_width(text) <= width:
|
||||
return text
|
||||
result = ""
|
||||
used = 0
|
||||
for char in text:
|
||||
size = 2 if unicodedata.east_asian_width(char) in ("W", "F") else 1
|
||||
if used + size > width - 1:
|
||||
break
|
||||
result += char
|
||||
used += size
|
||||
return result + "…"
|
||||
|
||||
|
||||
def pad(text: str, width: int) -> str:
|
||||
return text + " " * max(0, width - display_width(text))
|
||||
|
||||
|
||||
def wrap_display(text: str, width: int) -> list[str]:
|
||||
"""Split text into lines that fit the display width, breaking anywhere."""
|
||||
if width <= 0:
|
||||
return [text]
|
||||
lines: list[str] = []
|
||||
current = ""
|
||||
used = 0
|
||||
for char in text:
|
||||
size = 2 if unicodedata.east_asian_width(char) in ("W", "F") else 1
|
||||
if used + size > width and current:
|
||||
lines.append(current)
|
||||
current = ""
|
||||
used = 0
|
||||
current += char
|
||||
used += size
|
||||
lines.append(current)
|
||||
return lines
|
||||
|
||||
|
||||
class Term:
|
||||
def __init__(self, screen) -> None:
|
||||
self.screen = screen
|
||||
self.has_color = curses.has_colors()
|
||||
if self.has_color:
|
||||
curses.start_color()
|
||||
try:
|
||||
curses.use_default_colors()
|
||||
except curses.error:
|
||||
pass
|
||||
curses.init_pair(ACCENT, curses.COLOR_CYAN, -1)
|
||||
curses.init_pair(GOOD, curses.COLOR_GREEN, -1)
|
||||
curses.init_pair(WARN, curses.COLOR_YELLOW, -1)
|
||||
curses.init_pair(BAD, curses.COLOR_RED, -1)
|
||||
curses.init_pair(DIM, curses.COLOR_WHITE, -1)
|
||||
self.screen.keypad(True)
|
||||
try:
|
||||
curses.curs_set(0)
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
def attr(self, pair: int, extra: int = 0) -> int:
|
||||
if not self.has_color:
|
||||
return extra
|
||||
return curses.color_pair(pair) | extra
|
||||
|
||||
def size(self) -> tuple[int, int]:
|
||||
height, width = self.screen.getmaxyx()
|
||||
return height, width
|
||||
|
||||
def put(self, y: int, x: int, text: str, attr: int = 0) -> None:
|
||||
height, width = self.size()
|
||||
if y < 0 or y >= height or x >= width:
|
||||
return
|
||||
try:
|
||||
self.screen.addstr(y, x, truncate(text, max(0, width - x - 1)), attr)
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
def clear(self) -> None:
|
||||
self.screen.erase()
|
||||
|
||||
def refresh(self) -> None:
|
||||
self.screen.refresh()
|
||||
|
||||
def header(self, title: str, subtitle: str = "") -> int:
|
||||
height, width = self.size()
|
||||
self.put(0, 1, title, self.attr(ACCENT, curses.A_BOLD))
|
||||
if subtitle:
|
||||
self.put(0, max(2, width - display_width(subtitle) - 2), subtitle, self.attr(DIM))
|
||||
self.put(1, 0, "─" * max(1, width - 1), self.attr(DIM))
|
||||
return 3
|
||||
|
||||
def footer(self, text: str) -> None:
|
||||
height, width = self.size()
|
||||
self.put(height - 2, 0, "─" * max(1, width - 1), self.attr(DIM))
|
||||
self.put(height - 1, 1, text, self.attr(DIM))
|
||||
|
||||
def message(self, title: str, text: str, kind: str = "info") -> None:
|
||||
pair = {"info": ACCENT, "good": GOOD, "warn": WARN, "error": BAD}.get(kind, ACCENT)
|
||||
self.clear()
|
||||
height, width = self.size()
|
||||
y = self.header(title)
|
||||
lines: list[str] = []
|
||||
for paragraph in text.splitlines() or [""]:
|
||||
lines.extend(textwrap.wrap(paragraph, max(20, width - 6)) or [""])
|
||||
for line in lines[: max(1, height - 7)]:
|
||||
self.put(y, 3, line, self.attr(pair))
|
||||
y += 1
|
||||
self.footer(" press any key ")
|
||||
self.refresh()
|
||||
self.screen.getch()
|
||||
|
||||
def confirm(self, title: str, text: str) -> bool:
|
||||
items = ["Yes", "No"]
|
||||
return self.menu(title, items, subtitle=text, start=1) == 0
|
||||
|
||||
def prompt(self, title: str, label: str, default: str = "", secret: bool = False) -> Optional[str]:
|
||||
self.clear()
|
||||
height, width = self.size()
|
||||
self.header(title)
|
||||
self.put(3, 3, label)
|
||||
if default and not secret:
|
||||
self.put(3, 3 + display_width(label) + 1, f"[{default}]", self.attr(DIM))
|
||||
self.put(height - 1, 1, "Enter accept Esc cancel", self.attr(DIM))
|
||||
self.refresh()
|
||||
try:
|
||||
curses.curs_set(1)
|
||||
except curses.error:
|
||||
pass
|
||||
if secret:
|
||||
curses.noecho()
|
||||
else:
|
||||
curses.echo()
|
||||
input_x = 3 + display_width(label) + (
|
||||
display_width(default) + 3 if default and not secret else 1
|
||||
)
|
||||
try:
|
||||
self.screen.move(
|
||||
min(3, max(0, height - 2)), min(max(0, input_x), max(0, width - 1))
|
||||
)
|
||||
except curses.error:
|
||||
pass
|
||||
try:
|
||||
raw = self.screen.getstr()
|
||||
except (curses.error, KeyboardInterrupt):
|
||||
raw = b""
|
||||
finally:
|
||||
curses.noecho()
|
||||
try:
|
||||
curses.curs_set(0)
|
||||
except curses.error:
|
||||
pass
|
||||
if raw == b"\x1b" or b"\x1b" in raw:
|
||||
return None
|
||||
value = raw.decode(errors="replace").strip()
|
||||
if value == "" and default:
|
||||
return default
|
||||
return value or None
|
||||
|
||||
def menu(
|
||||
self,
|
||||
title: str,
|
||||
items: list[str],
|
||||
subtitle: str = "",
|
||||
footer: str = "↑/↓ select Enter open q back",
|
||||
start: int = 0,
|
||||
) -> Optional[int]:
|
||||
index = min(max(start, 0), max(0, len(items) - 1))
|
||||
while True:
|
||||
self.clear()
|
||||
height, width = self.size()
|
||||
y = self.header(title, subtitle)
|
||||
visible = max(1, height - 6)
|
||||
offset = max(0, index - visible + 1)
|
||||
for row, item in enumerate(items[offset: offset + visible]):
|
||||
absolute = offset + row
|
||||
selected = absolute == index
|
||||
marker = "▸ " if selected else " "
|
||||
attr = self.attr(ACCENT, curses.A_BOLD) if selected else 0
|
||||
self.put(y + row, 2, marker + item, attr)
|
||||
self.footer(footer)
|
||||
self.refresh()
|
||||
key = self.screen.getch()
|
||||
if key == -1:
|
||||
return None
|
||||
if key in (curses.KEY_UP, ord("k")):
|
||||
index = (index - 1) % len(items)
|
||||
elif key in (curses.KEY_DOWN, ord("j")):
|
||||
index = (index + 1) % len(items)
|
||||
elif key in (10, 13, curses.KEY_ENTER, ord(" ")):
|
||||
return index
|
||||
elif key in (ord("q"), 27):
|
||||
return None
|
||||
elif key == curses.KEY_RESIZE:
|
||||
continue
|
||||
elif ord("1") <= key <= ord("9"):
|
||||
candidate = key - ord("1")
|
||||
if candidate < len(items):
|
||||
return candidate
|
||||
|
||||
def scroll_text(self, title: str, text: str, subtitle: str = "") -> None:
|
||||
height, width = self.size()
|
||||
available = max(1, width - 4)
|
||||
lines: list[str] = []
|
||||
for source in text.splitlines() or [""]:
|
||||
lines.extend(wrap_display(source, available))
|
||||
top = 0
|
||||
while True:
|
||||
self.clear()
|
||||
height, width = self.size()
|
||||
y = self.header(title, subtitle)
|
||||
body = max(1, height - 5)
|
||||
for row in range(body):
|
||||
position = top + row
|
||||
if position >= len(lines):
|
||||
break
|
||||
self.put(y + row, 2, lines[position])
|
||||
self.footer("↑/↓ scroll PgUp/PgDn q back")
|
||||
self.refresh()
|
||||
key = self.screen.getch()
|
||||
if key == -1 or key in (ord("q"), 27):
|
||||
return
|
||||
if key in (curses.KEY_UP, ord("k")):
|
||||
top = max(0, top - 1)
|
||||
elif key in (curses.KEY_DOWN, ord("j")):
|
||||
top = min(max(0, len(lines) - body), top + 1)
|
||||
elif key == curses.KEY_PPAGE:
|
||||
top = max(0, top - body)
|
||||
elif key == curses.KEY_NPAGE:
|
||||
top = min(max(0, len(lines) - body), top + body)
|
||||
|
||||
def run_external(self, title: str, action: Callable[[Callable[[str], None]], None]) -> bool:
|
||||
"""Leave curses, run action, wait for Enter, then restore curses."""
|
||||
try:
|
||||
curses.def_prog_mode()
|
||||
curses.endwin()
|
||||
print(f"\n== {title} ==\n")
|
||||
try:
|
||||
action(lambda message: print(f"==> {message}", flush=True))
|
||||
ok = True
|
||||
except Exception as exc: # noqa: BLE001 - shown to the user
|
||||
print(f"\nerror: {exc}")
|
||||
ok = False
|
||||
print("\npress Enter to return")
|
||||
try:
|
||||
input()
|
||||
except EOFError:
|
||||
pass
|
||||
finally:
|
||||
curses.reset_prog_mode()
|
||||
self.screen.refresh()
|
||||
return ok
|
||||
Whitespace-only changes.
@@ -0,0 +1,41 @@
|
||||
import unittest
|
||||
|
||||
from sbp import commands, protocols
|
||||
from sbp.cli import build_parser
|
||||
|
||||
|
||||
class ParserTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.parser = build_parser()
|
||||
|
||||
def test_every_command_has_a_module(self) -> None:
|
||||
sub = next(
|
||||
action for action in self.parser._actions if action.dest == "command"
|
||||
)
|
||||
registered = set(sub.choices)
|
||||
self.assertEqual(registered, set(commands.COMMANDS))
|
||||
|
||||
def test_protocol_choices_match_registry(self) -> None:
|
||||
args = self.parser.parse_args(["protocol", "enable", "hysteria2", "--port", "24000"])
|
||||
self.assertEqual(args.command, "protocol")
|
||||
self.assertEqual(args.action, "enable")
|
||||
self.assertEqual(args.port, 24000)
|
||||
self.assertEqual(set(protocols.keys()), set(protocols.REGISTRY))
|
||||
|
||||
def test_user_show_options(self) -> None:
|
||||
args = self.parser.parse_args(
|
||||
["user", "show", "phone", "--protocol", "trojan", "--json"]
|
||||
)
|
||||
self.assertEqual(args.name, "phone")
|
||||
self.assertEqual(args.protocol, "trojan")
|
||||
self.assertTrue(args.json)
|
||||
|
||||
def test_install_defaults(self) -> None:
|
||||
args = self.parser.parse_args(["install"])
|
||||
self.assertEqual(args.base_port, 20000)
|
||||
self.assertFalse(args.force)
|
||||
self.assertIsNone(args.protocols)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,138 @@
|
||||
import json
|
||||
import unittest
|
||||
|
||||
from sbp.config import build_client_config, build_server_config
|
||||
from sbp.errors import SbpError
|
||||
from sbp.share import all_uris, share_uri
|
||||
from tests.test_state import SERVER_SS_KEY, USER_SS_KEY, sample_state
|
||||
|
||||
|
||||
class ServerConfigTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.state = sample_state()
|
||||
self.config = build_server_config(self.state)
|
||||
|
||||
def test_all_protocols_present(self) -> None:
|
||||
tags = {inbound["tag"] for inbound in self.config["inbounds"]}
|
||||
self.assertEqual(
|
||||
tags,
|
||||
{"hysteria2-in", "vless-reality-in", "trojan-in", "shadowsocks-in", "tuic-in", "anytls-in"},
|
||||
)
|
||||
|
||||
def test_disabled_protocol_is_skipped(self) -> None:
|
||||
self.state.protocols["trojan"].enabled = False
|
||||
tags = {inbound["tag"] for inbound in build_server_config(self.state)["inbounds"]}
|
||||
self.assertNotIn("trojan-in", tags)
|
||||
|
||||
def test_hysteria2_tls_and_obfs(self) -> None:
|
||||
inbound = next(i for i in self.config["inbounds"] if i["tag"] == "hysteria2-in")
|
||||
self.assertEqual(inbound["tls"]["alpn"], ["h3"])
|
||||
self.assertEqual(inbound["obfs"]["type"], "salamander")
|
||||
self.assertEqual(inbound["users"][0]["password"], "PASSWORD")
|
||||
|
||||
def test_reality_uses_private_key(self) -> None:
|
||||
inbound = next(i for i in self.config["inbounds"] if i["tag"] == "vless-reality-in")
|
||||
self.assertEqual(inbound["tls"]["reality"]["private_key"], "PRIVATE")
|
||||
self.assertEqual(inbound["tls"]["reality"]["handshake"]["server"], "www.bing.com")
|
||||
|
||||
def test_shadowsocks_multi_user(self) -> None:
|
||||
inbound = next(i for i in self.config["inbounds"] if i["tag"] == "shadowsocks-in")
|
||||
self.assertEqual(inbound["password"], SERVER_SS_KEY)
|
||||
self.assertEqual(inbound["users"][0]["password"], USER_SS_KEY)
|
||||
|
||||
def test_shadowsocks_client_sends_identity_and_user_key(self) -> None:
|
||||
config = build_client_config(self.state, "shadowsocks", self.state.users[0])
|
||||
self.assertEqual(
|
||||
config["outbounds"][0]["password"], f"{SERVER_SS_KEY}:{USER_SS_KEY}"
|
||||
)
|
||||
|
||||
def test_config_is_json_serializable(self) -> None:
|
||||
json.dumps(self.config)
|
||||
|
||||
def test_reality_rejects_ip_server_name(self) -> None:
|
||||
self.state.protocols["vless-reality"].options["server_name"] = "203.0.113.10"
|
||||
with self.assertRaises(SbpError):
|
||||
build_server_config(self.state)
|
||||
|
||||
def test_reality_rejects_odd_length_short_id(self) -> None:
|
||||
self.state.protocols["vless-reality"].options["short_id"] = "abc"
|
||||
with self.assertRaises(SbpError):
|
||||
build_server_config(self.state)
|
||||
|
||||
def test_reality_accepts_even_short_id(self) -> None:
|
||||
self.state.protocols["vless-reality"].options["short_id"] = "ab12"
|
||||
build_server_config(self.state)
|
||||
|
||||
|
||||
class ClientConfigTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.state = sample_state()
|
||||
self.user = self.state.users[0]
|
||||
|
||||
def test_disabled_protocol_raises_sbp_error(self) -> None:
|
||||
self.state.protocols["tuic"].enabled = False
|
||||
with self.assertRaises(SbpError):
|
||||
build_client_config(self.state, "tuic", self.user)
|
||||
with self.assertRaises(SbpError):
|
||||
share_uri(self.state, "tuic", self.user)
|
||||
|
||||
def test_each_client_config_routes_to_outbound(self) -> None:
|
||||
for key in self.state.protocols:
|
||||
config = build_client_config(self.state, key, self.user)
|
||||
self.assertEqual(config["route"]["final"], config["outbounds"][0]["tag"])
|
||||
json.dumps(config)
|
||||
|
||||
def test_self_signed_pins_public_key(self) -> None:
|
||||
config = build_client_config(self.state, "trojan", self.user)
|
||||
self.assertEqual(
|
||||
config["outbounds"][0]["tls"]["certificate_public_key_sha256"], ["SPKI"]
|
||||
)
|
||||
|
||||
def test_reality_does_not_pin(self) -> None:
|
||||
config = build_client_config(self.state, "vless-reality", self.user)
|
||||
tls = config["outbounds"][0]["tls"]
|
||||
self.assertNotIn("certificate_public_key_sha256", tls)
|
||||
self.assertEqual(tls["reality"]["public_key"], "PUBLIC")
|
||||
|
||||
|
||||
class ShareUriTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.state = sample_state()
|
||||
self.user = self.state.users[0]
|
||||
|
||||
def test_uri_prefixes(self) -> None:
|
||||
prefixes = {
|
||||
"hysteria2": "hysteria2://",
|
||||
"vless-reality": "vless://",
|
||||
"trojan": "trojan://",
|
||||
"shadowsocks": "ss://",
|
||||
"tuic": "tuic://",
|
||||
"anytls": "anytls://",
|
||||
}
|
||||
for key, prefix in prefixes.items():
|
||||
self.assertTrue(share_uri(self.state, key, self.user).startswith(prefix), key)
|
||||
|
||||
def test_hysteria2_pin_and_obfs(self) -> None:
|
||||
uri = share_uri(self.state, "hysteria2", self.user)
|
||||
self.assertIn("pinSHA256=SHA256", uri)
|
||||
self.assertIn("obfs=salamander", uri)
|
||||
|
||||
def test_vless_reality_parameters(self) -> None:
|
||||
uri = share_uri(self.state, "vless-reality", self.user)
|
||||
self.assertIn("security=reality", uri)
|
||||
self.assertIn("pbk=PUBLIC", uri)
|
||||
self.assertIn("flow=xtls-rprx-vision", uri)
|
||||
|
||||
def test_shadowsocks_uses_plain_userinfo(self) -> None:
|
||||
from urllib.parse import unquote
|
||||
|
||||
uri = share_uri(self.state, "shadowsocks", self.user)
|
||||
userinfo = unquote(uri.removeprefix("ss://").split("@", 1)[0])
|
||||
self.assertEqual(userinfo, f"2022-blake3-aes-128-gcm:{SERVER_SS_KEY}:{USER_SS_KEY}")
|
||||
|
||||
def test_all_uris(self) -> None:
|
||||
self.assertEqual(len(all_uris(self.state, self.user)), 6)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,59 @@
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from sbp import hostinfo
|
||||
from sbp.errors import SbpError
|
||||
from sbp.fsutil import safe_rmtree
|
||||
from sbp.state import StateStore
|
||||
|
||||
|
||||
class PickPortTest(unittest.TestCase):
|
||||
def test_stops_at_65535(self) -> None:
|
||||
with mock.patch("sbp.hostinfo.port_free", return_value=False) as probe:
|
||||
with self.assertRaises(SbpError):
|
||||
hostinfo.pick_port("127.0.0.1", set(), start=65535)
|
||||
self.assertEqual(probe.call_count, 1)
|
||||
|
||||
def test_skips_used_ports(self) -> None:
|
||||
with mock.patch("sbp.hostinfo.port_free", return_value=True):
|
||||
self.assertEqual(hostinfo.pick_port("127.0.0.1", {20000, 20001}, start=20000), 20002)
|
||||
|
||||
|
||||
class SafeRmtreeTest(unittest.TestCase):
|
||||
def test_refuses_marker_outside_target(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
target = root / "data"
|
||||
target.mkdir()
|
||||
marker = root / "elsewhere"
|
||||
marker.write_text("x")
|
||||
with self.assertRaises(SbpError):
|
||||
safe_rmtree(target, marker)
|
||||
self.assertTrue(target.exists())
|
||||
|
||||
def test_removes_when_marker_is_inside(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
target = Path(tmp) / "data"
|
||||
(target / "bin").mkdir(parents=True)
|
||||
marker = target / "bin" / "sing-box"
|
||||
marker.write_text("x")
|
||||
safe_rmtree(target, marker)
|
||||
self.assertFalse(target.exists())
|
||||
|
||||
|
||||
class TryLoadTest(unittest.TestCase):
|
||||
def test_corrupt_state_returns_none(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
store = StateStore(Path(tmp))
|
||||
store.path.write_text("{not json")
|
||||
self.assertIsNone(store.try_load())
|
||||
|
||||
def test_missing_state_returns_none(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
self.assertIsNone(StateStore(Path(tmp)).try_load())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,158 @@
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from sbp import net, paths as path_module, tls
|
||||
from sbp.errors import SbpError
|
||||
from sbp.ops import install as install_op
|
||||
from sbp.ops import protocols as protocols_op
|
||||
from sbp.ops import upgrade as upgrade_op
|
||||
from sbp.ops import users as users_op
|
||||
from sbp.state import RealityState, StateStore
|
||||
|
||||
|
||||
class InstallTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.tmp = Path(tempfile.mkdtemp(prefix="sbp-test-"))
|
||||
self.cert = self.tmp / "cert.pem"
|
||||
self.key = self.tmp / "key.pem"
|
||||
tls.generate("www.bing.com", self.cert, self.key)
|
||||
self.env = mock.patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"SBP_CONFIG_DIR": str(self.tmp / "config"),
|
||||
"SBP_DATA_DIR": str(self.tmp / "data"),
|
||||
"SBP_UNIT_DIR": str(self.tmp / "units"),
|
||||
},
|
||||
)
|
||||
self.env.start()
|
||||
self.patchers = [
|
||||
mock.patch("sbp.ops.install.hostinfo.check_platform"),
|
||||
mock.patch("sbp.ops.install.hostinfo.warnings", return_value=[]),
|
||||
mock.patch("sbp.ops.install.hostinfo.detect_host", return_value="203.0.113.10"),
|
||||
mock.patch("sbp.ops.install.hostinfo.ipv6_available", return_value=True),
|
||||
mock.patch("sbp.ops.install.hostinfo.default_listen", return_value="::"),
|
||||
mock.patch("sbp.ops.install.net.get_release", return_value=net.Release("v1.14.4", [])),
|
||||
mock.patch("sbp.singbox.install_binary", side_effect=self._install_binary),
|
||||
mock.patch("sbp.singbox.check_config"),
|
||||
mock.patch(
|
||||
"sbp.singbox.reality_keypair",
|
||||
return_value=RealityState(private_key="PRIVATE", public_key="PUBLIC"),
|
||||
),
|
||||
mock.patch("sbp.ops.common.restart"),
|
||||
mock.patch("sbp.service.enable"),
|
||||
mock.patch("sbp.service.active", return_value=True),
|
||||
]
|
||||
for patcher in self.patchers:
|
||||
patcher.start()
|
||||
self.paths = path_module.load()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
for patcher in self.patchers:
|
||||
patcher.stop()
|
||||
self.env.stop()
|
||||
shutil.rmtree(self.tmp, ignore_errors=True)
|
||||
|
||||
def _install_binary(self, paths, release, current_config=None, progress=None):
|
||||
paths.binary.parent.mkdir(parents=True, exist_ok=True)
|
||||
paths.binary.write_text("#!/bin/sh\nexit 0\n")
|
||||
os.chmod(paths.binary, 0o755)
|
||||
|
||||
def test_install_creates_state_config_and_unit(self) -> None:
|
||||
state = install_op.install(
|
||||
self.paths,
|
||||
install_op.InstallOptions(host="203.0.113.10", cert=str(self.cert), key=str(self.key), sni="www.bing.com"),
|
||||
)
|
||||
self.assertTrue(self.paths.state_file.is_file())
|
||||
self.assertTrue(self.paths.server_config.is_file())
|
||||
self.assertTrue(self.paths.unit_file.is_file())
|
||||
self.assertEqual(len(state.users), 1)
|
||||
self.assertEqual(set(state.protocols), {"hysteria2", "vless-reality", "trojan", "shadowsocks", "tuic", "anytls"})
|
||||
ports = [proto.port for proto in state.protocols.values()]
|
||||
self.assertEqual(len(ports), len(set(ports)))
|
||||
self.assertTrue(all(port >= 20000 for port in ports))
|
||||
|
||||
def test_add_and_remove_user(self) -> None:
|
||||
install_op.install(self.paths, install_op.InstallOptions(host="203.0.113.10", cert=str(self.cert), key=str(self.key)))
|
||||
users_op.add(self.paths, "phone")
|
||||
store = path_module.load()
|
||||
state = StateStore(store.config_dir).load()
|
||||
self.assertEqual([user.name for user in state.users], ["u1", "phone"])
|
||||
users_op.remove(self.paths, "phone")
|
||||
state = StateStore(store.config_dir).load()
|
||||
self.assertEqual([user.name for user in state.users], ["u1"])
|
||||
|
||||
def test_cannot_remove_last_user(self) -> None:
|
||||
install_op.install(self.paths, install_op.InstallOptions(host="203.0.113.10", cert=str(self.cert), key=str(self.key)))
|
||||
with self.assertRaises(SbpError):
|
||||
users_op.remove(self.paths, "u1")
|
||||
|
||||
def test_disable_protocol_updates_config(self) -> None:
|
||||
import json
|
||||
|
||||
install_op.install(self.paths, install_op.InstallOptions(host="203.0.113.10", cert=str(self.cert), key=str(self.key)))
|
||||
protocols_op.disable(self.paths, "trojan")
|
||||
config = json.loads(self.paths.server_config.read_text())
|
||||
tags = {inbound["tag"] for inbound in config["inbounds"]}
|
||||
self.assertNotIn("trojan-in", tags)
|
||||
protocols_op.enable(self.paths, "trojan")
|
||||
config = json.loads(self.paths.server_config.read_text())
|
||||
tags = {inbound["tag"] for inbound in config["inbounds"]}
|
||||
self.assertIn("trojan-in", tags)
|
||||
|
||||
def test_set_port_rejects_port_of_another_protocol(self) -> None:
|
||||
state = install_op.install(
|
||||
self.paths, install_op.InstallOptions(host="203.0.113.10", cert=str(self.cert), key=str(self.key))
|
||||
)
|
||||
taken = state.protocols["trojan"].port
|
||||
with self.assertRaises(SbpError):
|
||||
protocols_op.set_port(self.paths, "vless-reality", taken)
|
||||
|
||||
def test_upgrade_restores_missing_binary(self) -> None:
|
||||
install_op.install(
|
||||
self.paths, install_op.InstallOptions(host="203.0.113.10", cert=str(self.cert), key=str(self.key))
|
||||
)
|
||||
self.paths.binary.unlink()
|
||||
current, target = upgrade_op.upgrade(self.paths)
|
||||
self.assertTrue(self.paths.binary.is_file())
|
||||
self.assertEqual(target, "v1.14.4")
|
||||
self.assertNotEqual(current, "missing")
|
||||
|
||||
def test_set_port_to_current_port_is_noop(self) -> None:
|
||||
state = install_op.install(
|
||||
self.paths, install_op.InstallOptions(host="203.0.113.10", cert=str(self.cert), key=str(self.key))
|
||||
)
|
||||
port = state.protocols["trojan"].port
|
||||
protocols_op.set_port(self.paths, "trojan", port)
|
||||
reloaded = StateStore(self.paths.config_dir).load()
|
||||
self.assertEqual(reloaded.protocols["trojan"].port, port)
|
||||
|
||||
def test_reinstall_rejects_without_force(self) -> None:
|
||||
install_op.install(self.paths, install_op.InstallOptions(host="203.0.113.10", cert=str(self.cert), key=str(self.key)))
|
||||
with self.assertRaises(Exception):
|
||||
install_op.install(self.paths, install_op.InstallOptions(host="203.0.113.10"))
|
||||
|
||||
def test_reinstall_keeps_custom_certificate(self) -> None:
|
||||
install_op.install(
|
||||
self.paths,
|
||||
install_op.InstallOptions(host="203.0.113.10", cert=str(self.cert), key=str(self.key), sni="www.bing.com"),
|
||||
)
|
||||
state = install_op.install(self.paths, install_op.InstallOptions(force=True))
|
||||
self.assertEqual(state.tls.mode, "custom")
|
||||
self.assertEqual(state.tls.cert, str(self.cert.resolve()))
|
||||
|
||||
def test_reinstall_keeps_ports(self) -> None:
|
||||
first = install_op.install(
|
||||
self.paths,
|
||||
install_op.InstallOptions(host="203.0.113.10", cert=str(self.cert), key=str(self.key)),
|
||||
)
|
||||
ports = {key: proto.port for key, proto in first.protocols.items()}
|
||||
second = install_op.install(self.paths, install_op.InstallOptions(force=True))
|
||||
self.assertEqual({key: proto.port for key, proto in second.protocols.items()}, ports)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,44 @@
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from sbp import net
|
||||
|
||||
ASSETS = [
|
||||
{"name": "sing-box-1.14.4-linux-amd64-musl.tar.gz"},
|
||||
{"name": "sing-box-1.14.4-linux-amd64.tar.gz"},
|
||||
{"name": "sing-box-1.14.4-linux-arm64.tar.gz"},
|
||||
{"name": "sing-box-1.14.4-linux-amd64.deb"},
|
||||
]
|
||||
|
||||
|
||||
def pick(machine: str, libc: tuple[str, str]) -> str:
|
||||
with mock.patch("platform.machine", return_value=machine), mock.patch(
|
||||
"platform.libc_ver", return_value=libc
|
||||
):
|
||||
return net.asset_for(ASSETS, "v1.14.4")["name"]
|
||||
|
||||
|
||||
class AssetTest(unittest.TestCase):
|
||||
def test_glibc_prefers_plain_build(self) -> None:
|
||||
self.assertEqual(pick("x86_64", ("glibc", "2.36")), "sing-box-1.14.4-linux-amd64.tar.gz")
|
||||
|
||||
def test_musl_prefers_musl_build(self) -> None:
|
||||
self.assertEqual(
|
||||
pick("x86_64", ("musl", "1.2")), "sing-box-1.14.4-linux-amd64-musl.tar.gz"
|
||||
)
|
||||
|
||||
def test_arm64_maps_to_arm64(self) -> None:
|
||||
self.assertEqual(pick("aarch64", ("glibc", "2.36")), "sing-box-1.14.4-linux-arm64.tar.gz")
|
||||
|
||||
def test_unknown_arch_fails(self) -> None:
|
||||
with self.assertRaises(Exception):
|
||||
pick("riscv64", ("glibc", "2.36"))
|
||||
|
||||
|
||||
class DigestTest(unittest.TestCase):
|
||||
def test_missing_digest_is_not_verified(self) -> None:
|
||||
self.assertFalse(net.verify_digest(__file__, {}))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,114 @@
|
||||
import base64
|
||||
import unittest
|
||||
|
||||
from sbp.state import ProtocolState, RealityState, State, TlsState, User, migrate
|
||||
|
||||
USER_UUID = "11111111-2222-3333-4444-555555555555"
|
||||
USER_SS_KEY = base64.b64encode(b"0123456789abcdef").decode()
|
||||
SERVER_SS_KEY = base64.b64encode(b"abcdef0123456789").decode()
|
||||
|
||||
|
||||
def sample_state() -> State:
|
||||
return State(
|
||||
schema=2,
|
||||
created="2026-01-01T00:00:00Z",
|
||||
host="203.0.113.10",
|
||||
sni="www.bing.com",
|
||||
tls=TlsState(
|
||||
mode="self-signed",
|
||||
cert="/tmp/cert.pem",
|
||||
key="/tmp/key.pem",
|
||||
sni="www.bing.com",
|
||||
self_signed=True,
|
||||
spki_pin="SPKI",
|
||||
cert_sha256="SHA256",
|
||||
),
|
||||
reality=RealityState(private_key="PRIVATE", public_key="PUBLIC"),
|
||||
users=[User("u1", "PASSWORD", USER_UUID, USER_SS_KEY, "2026-01-01T00:00:00Z")],
|
||||
protocols={
|
||||
"hysteria2": ProtocolState(
|
||||
True, 21001, "::", {"obfs": True, "obfs_password": "OBFS", "masquerade": ""}
|
||||
),
|
||||
"vless-reality": ProtocolState(
|
||||
True, 21002, "::", {"server_name": "www.bing.com", "short_id": "", "flow": "xtls-rprx-vision"}
|
||||
),
|
||||
"trojan": ProtocolState(True, 21003, "::", {}),
|
||||
"shadowsocks": ProtocolState(
|
||||
True, 21004, "::", {"method": "2022-blake3-aes-128-gcm", "server_key": SERVER_SS_KEY}
|
||||
),
|
||||
"tuic": ProtocolState(True, 21005, "::", {"congestion_control": "bbr"}),
|
||||
"anytls": ProtocolState(True, 21006, "::", {}),
|
||||
},
|
||||
singbox_version="v1.14.4",
|
||||
)
|
||||
|
||||
|
||||
class StateRoundTripTest(unittest.TestCase):
|
||||
def test_round_trip(self) -> None:
|
||||
state = sample_state()
|
||||
restored = State.from_dict(state.to_dict())
|
||||
self.assertEqual(restored.host, state.host)
|
||||
self.assertEqual(restored.sni, state.sni)
|
||||
self.assertEqual(restored.users[0].uuid, USER_UUID)
|
||||
self.assertEqual(restored.protocols["hysteria2"].port, 21001)
|
||||
self.assertEqual(restored.reality.public_key, "PUBLIC")
|
||||
|
||||
def test_migrate_schema_one(self) -> None:
|
||||
raw = {
|
||||
"schema": 1,
|
||||
"created": "2025-01-01T00:00:00Z",
|
||||
"host": "198.51.100.1",
|
||||
"sni": "www.example.com",
|
||||
"listen": "::",
|
||||
"port": 23456,
|
||||
"tls": {"mode": "self-signed", "self_signed": True, "cert": "/c", "key": "/k",
|
||||
"sni": "www.example.com", "spki_pin": "S", "cert_sha256": "C"},
|
||||
"obfs": {"enabled": True, "password": "O"},
|
||||
"users": [{"name": "u1", "password": "P", "created": "2025-01-01T00:00:00Z"}],
|
||||
"version": "v1.10.0",
|
||||
}
|
||||
migrated = migrate(raw)
|
||||
state = State.from_dict(migrated)
|
||||
self.assertEqual(state.schema, 2)
|
||||
self.assertEqual(state.protocols["hysteria2"].port, 23456)
|
||||
options = state.protocols["hysteria2"].options
|
||||
self.assertIs(options["obfs"], True)
|
||||
self.assertEqual(options["obfs_password"], "O")
|
||||
self.assertEqual(state.users[0].name, "u1")
|
||||
self.assertTrue(state.users[0].uuid)
|
||||
self.assertTrue(state.users[0].ss_key)
|
||||
self.assertEqual(state.singbox_version, "v1.10.0")
|
||||
|
||||
def test_migrated_state_builds_config_and_uri(self) -> None:
|
||||
from sbp.config import build_server_config
|
||||
from sbp.share import share_uri
|
||||
|
||||
raw = {
|
||||
"schema": 1,
|
||||
"host": "198.51.100.1",
|
||||
"sni": "www.example.com",
|
||||
"listen": "::",
|
||||
"port": 23456,
|
||||
"tls": {"mode": "self-signed", "self_signed": True, "cert": "/c", "key": "/k",
|
||||
"sni": "www.example.com", "spki_pin": "S", "cert_sha256": "C"},
|
||||
"obfs": {"enabled": True, "password": "O"},
|
||||
"users": [{"name": "u1", "password": "P"}],
|
||||
}
|
||||
state = State.from_dict(migrate(raw))
|
||||
inbound = build_server_config(state)["inbounds"][0]
|
||||
self.assertEqual(inbound["obfs"]["password"], "O")
|
||||
self.assertIn("obfs-password=O", share_uri(state, "hysteria2", state.users[0]))
|
||||
|
||||
def test_obfs_without_key_is_disabled(self) -> None:
|
||||
from sbp.config import build_server_config
|
||||
|
||||
state = sample_state()
|
||||
state.protocols["hysteria2"].options = {"obfs": True}
|
||||
inbound = next(
|
||||
i for i in build_server_config(state)["inbounds"] if i["tag"] == "hysteria2-in"
|
||||
)
|
||||
self.assertNotIn("obfs", inbound)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,36 @@
|
||||
import unittest
|
||||
|
||||
from sbp.tui.term import display_width, pad, truncate, wrap_display
|
||||
|
||||
|
||||
class WidthTest(unittest.TestCase):
|
||||
def test_ascii_width(self) -> None:
|
||||
self.assertEqual(display_width("hello"), 5)
|
||||
|
||||
def test_cjk_width(self) -> None:
|
||||
self.assertEqual(display_width("中文"), 4)
|
||||
self.assertEqual(display_width("a中"), 3)
|
||||
|
||||
def test_truncate_ascii(self) -> None:
|
||||
self.assertEqual(truncate("abcdef", 10), "abcdef")
|
||||
self.assertEqual(truncate("abcdef", 4), "abc…")
|
||||
|
||||
def test_truncate_cjk(self) -> None:
|
||||
self.assertEqual(truncate("中文字符", 5), "中文…")
|
||||
|
||||
def test_pad(self) -> None:
|
||||
self.assertEqual(display_width(pad("中", 5)), 5)
|
||||
|
||||
def test_wrap_display_keeps_long_uri_visible(self) -> None:
|
||||
uri = "vless://" + "a" * 200
|
||||
lines = wrap_display(uri, 40)
|
||||
self.assertTrue(all(display_width(line) <= 40 for line in lines))
|
||||
self.assertEqual("".join(lines), uri)
|
||||
|
||||
def test_wrap_display_cjk(self) -> None:
|
||||
lines = wrap_display("中文字符", 4)
|
||||
self.assertEqual(lines, ["中文", "字符"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user