重写为多协议管理器: 6 协议, CLI + curses TUI, 用户级 systemd

This commit is contained in:
typist committed 2026-11-01 06:38:56 +08:00
1 parent c43ab4a16e
commit 237b958e41
71 files changed
+4599 -1171

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
__pycache__/
*.pyc
.venv/
.pytest_cache/
.ruff_cache/
+159 -64
View File
@@ -1,112 +1,207 @@
# sbp # sbp
`sbp` 在用户级别搭建和管理 sing-box Hysteria2 服务. 不需要 root. 服务由 systemd user 管理. `sbp` 在用户级别安装和管理 sing-box 代理服务. 不需要 root. 服务由 systemd user 管理.
它提供六个协议, 一个命令行接口, 和一个全屏 TUI.
## 部署到 VPS ## 安装
一条命令下载并安装 `sbp`: 一条命令安装 `sbp` 命令:
```bash ```bash
curl -fsSL https://raw.githubusercontent.com/justypist/sbp/main/install.sh | 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 ```bash
curl -fsSL https://raw.githubusercontent.com/justypist/sbp/main/install.sh | bash -s -- --run 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 ```bash
install -m 755 sbp ~/.local/bin/sbp sbp install # 安装全部六个协议, 自动分配端口, 生成用户 u1
sbp --version 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 ```bash
sbp install # 下载最新 sing-box, 生成证书和用户, 启动服务, 打印配置 sbp install --protocols hysteria2,vless-reality
sbp install --port 8443 --sni www.example.com --host 203.0.113.10 ```
每个协议使用独立端口. 默认从 `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 install --cert fullchain.pem --key privkey.pem --sni example.com
sbp new phone # 新增用户并打印配置 sbp install --protocols hysteria2,trojan --base-port 30000
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 upgrade # 升级 sing-box sbp upgrade # 升级 sing-box
sbp uninstall # 停止并删除服务和程序, 保留配置 sbp uninstall # 删除服务和程序, 保留配置
sbp uninstall --purge # 同时删除配置和证书 sbp uninstall --purge # 同时删除配置和证书
``` ```
`install` 的选项: ### 协议
| 选项 | 默认值 | 说明 | ```bash
| --- | --- | --- | sbp protocol list
| `--port` | 随机空闲端口 | UDP 监听端口, 必须 >= 1024 | sbp protocol enable tuic --port 24000
| `--listen` | `::` | 监听地址, 默认同时监听 IPv4 和 IPv6 | sbp protocol disable tuic
| `--sni` | `--host` 是域名时用它, 否则 `www.bing.com` | TLS server name 和证书名 | sbp protocol port tuic 24001
| `--host` | 自动探测公网 IP | 打印给客户端使用的地址 | sbp protocol set hysteria2 obfs false
| `--cert` / `--key` | 自签名证书 | 使用已有证书, 证书和私钥必须匹配 | sbp protocol set vless-reality short_id 0123456789abcdef
| `--masquerade` | `https://<sni>` | 伪装地址 | ```
| `--no-obfs` | 关闭 | 关闭 salamander 混淆 (不推荐) |
| `--sing-box-version` | 最新版 | 指定 sing-box 版本 |
| `--force` | 关闭 | 重装并保留已有用户 |
## 安全设计 ### 用户
- 密码和混淆密码由 `secrets.token_urlsafe(32)` 生成, 每个用户独立. ```bash
- 默认自签名 ECDSA P-256 证书, 有效期 10 年, 只含一个 SAN. sbp user list
- TLS 最低版本 1.3, ALPN 只用 `h3`. sbp user add phone
- 客户端配置使用 `certificate_public_key_sha256` 固定证书公钥. 不开启 `insecure`. sbp user del phone
- 服务以非特权用户运行, 只监听 >= 1024 的端口. sbp user show phone
- 下载的 sing-box 压缩包与 GitHub 发布的 `sha256` 摘要比对. sbp user show phone --protocol hysteria2 --uri
- systemd 加固: `NoNewPrivileges`, `ProtectSystem=strict`, `ProtectHome=read-only`, sbp user show phone --protocol hysteria2 --json
`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`, 不记录访问日志.
## 分享链接与 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` 客户端配置包含 `127.0.0.1:1080` 的 mixed 入站. 它同时提供 SOCKS5 和 HTTP 代理.
输出的 sing-box 客户端配置. 不要为了兼容而关闭证书校验.
客户端配置使用 `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/sing-box.json` | 生成的 sing-box 服务端配置 |
| `~/.config/sbp/certs/` | 证书和私钥 | | `~/.config/sbp/certs/` | 证书和私钥 |
| `~/.local/share/sbp/bin/sing-box` | sing-box 程序 | | `~/.local/share/sbp/bin/sing-box` | sing-box 程序 |
| `~/.local/share/sbp/app/` | sbp 程序文件 |
| `~/.config/systemd/user/sbp.service` | systemd user 单元 | | `~/.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 需要端口转发. - 主机需要公网可达的端口. 路由器 NAT 需要端口转发.
- 防火墙需要放行 UDP 端口. `install` 会打印示例命令. - 防火墙需要放行协议端口. UDP 协议放行 UDP, TCP 协议放行 TCP.
- 用户级服务依赖 lingering. 确认 `loginctl show-user $USER -p Linger` 输出 `Linger=yes`. - 用户级服务依赖 lingering. 确认 `loginctl show-user $USER -p Linger` 输出 `Linger=yes`.
`sbp install` 在未开启 lingering 时会给出警告. `sbp install` 在未开启 lingering 时给出警告.
- 主机没有 IPv6 时, 默认监听 `0.0.0.0`, 不会监听 `::`. - 主机没有 IPv6 时, 默认监听 `0.0.0.0`.
- 首次安装后, `sbp` 会等待服务进入 `active`. 如果失败, 用 `sbp log` 查看原因.
- Python 的 `urllib` 不读取 `ALL_PROXY`. 使用 SOCKS 代理时请设置 `HTTPS_PROXY`. - Python 的 `urllib` 不读取 `ALL_PROXY`. 使用 SOCKS 代理时请设置 `HTTPS_PROXY`.
- 文件位置遵循 `XDG_CONFIG_HOME` 和 `XDG_DATA_HOME`. 上表按默认值列出.
+25 -17
View File
@@ -3,31 +3,39 @@ set -euo pipefail
repo="${SBP_REPO:-justypist/sbp}" repo="${SBP_REPO:-justypist/sbp}"
branch="${SBP_BRANCH:-main}" branch="${SBP_BRANCH:-main}"
dest="${SBP_BIN:-$HOME/.local/bin}" app_dir="${SBP_APP_DIR:-$HOME/.local/share/sbp/app}"
url="https://raw.githubusercontent.com/${repo}/${branch}/sbp" 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 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; } command -v python3 >/dev/null 2>&1 || { echo "error: python3 is required" >&2; exit 1; }
mkdir -p "$dest" tmp="$(mktemp -d)"
tmp="$(mktemp)" trap 'rm -rf "$tmp"' EXIT
trap 'rm -f "$tmp"' EXIT
curl -fsSL "$url" -o "$tmp" curl -fsSL "$url" | tar -xz -C "$tmp"
head -n 1 "$tmp" | grep -q '^#!/usr/bin/env python3' || { src="$(find "$tmp" -mindepth 1 -maxdepth 1 -type d | head -n 1)"
echo "error: unexpected content from $url" >&2 [ -n "$src" ] && [ -f "$src/sbp" ] || { echo "error: unexpected archive layout from $url" >&2; exit 1; }
exit 1 [ -f "$src/src/sbp/cli.py" ] || { echo "error: package files missing from $url" >&2; exit 1; }
}
chmod 755 "$tmp"
mv "$tmp" "$dest/sbp"
trap - EXIT
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 case ":$PATH:" in
*":$dest:"*) ;; *":$bin_dir:"*) ;;
*) echo "note: add $dest to PATH, for example: export PATH=\"$dest:\$PATH\"" ;; *) echo "note: add $bin_dir to PATH, for example: export PATH=\"$bin_dir:\$PATH\"" ;;
esac esac
if [ "${1:-}" = "--run" ]; then if [ "${1:-}" = "--run" ]; then
exec "$dest/sbp" install "${@:2}" exec "$bin_dir/sbp" install "${@:2}"
fi fi
+20
View File
@@ -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"]
+4 -1090
View File
File diff suppressed because it is too large. Load diff
+3
View File
@@ -0,0 +1,3 @@
"""sbp: install and manage a user-level sing-box proxy server."""
__version__ = "2.0.0"
+4
View File
@@ -0,0 +1,4 @@
from .cli import main
if __name__ == "__main__":
raise SystemExit(main())
+55
View File
@@ -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
+33
View File
@@ -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,
}
+14
View File
@@ -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)
+25
View File
@@ -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
+54
View File
@@ -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
+49
View File
@@ -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
+33
View File
@@ -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
+35
View File
@@ -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
+67
View File
@@ -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
+17
View File
@@ -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)
+22
View File
@@ -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
+26
View File
@@ -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
+60
View File
@@ -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
+64
View File
@@ -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"]},
}
+33
View File
@@ -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",
)
+29
View File
@@ -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)
+14
View File
@@ -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
+56
View File
@@ -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)
+142
View File
@@ -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
+99
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
"""Operations shared by the CLI and the TUI."""
+164
View File
@@ -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
+147
View File
@@ -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
+150
View File
@@ -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)
+22
View File
@@ -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)
+59
View File
@@ -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)
+39
View File
@@ -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()
+90
View File
@@ -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,
)
+33
View File
@@ -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)
+33
View File
@@ -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
+50
View File
@@ -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)
+62
View File
@@ -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),
)
+40
View File
@@ -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"]
+47
View File
@@ -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),
}
+127
View File
@@ -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 []
+100
View File
@@ -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"),
]
+59
View File
@@ -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))]
+53
View File
@@ -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),
}
+72
View File
@@ -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"))]
+119
View File
@@ -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)"),
]
+147
View File
@@ -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()
+26
View File
@@ -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()
]
+36
View File
@@ -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
+115
View File
@@ -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
+258
View File
@@ -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)
+90
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
"""Full-screen terminal interface."""
+105
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
"""TUI screens."""
+50
View File
@@ -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))
+22
View File
@@ -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",
)
+99
View File
@@ -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),
)
+98
View File
@@ -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
+60
View File
@@ -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
+279
View File
@@ -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
View File
Whitespace-only changes.
+41
View File
@@ -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()
+138
View File
@@ -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()
+59
View File
@@ -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()
+158
View File
@@ -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()
+44
View File
@@ -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()
+114
View File
@@ -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()
+36
View File
@@ -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()
Generated
+8
View File
@@ -0,0 +1,8 @@
version = 1
revision = 3
requires-python = ">=3.9"
[[package]]
name = "sbp"
version = "2.0.0"
source = { editable = "." }