diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..460e321 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +__pycache__/ +*.pyc +.venv/ +.pytest_cache/ +.ruff_cache/ diff --git a/README.md b/README.md index 1a47efb..bc3bc8c 100644 --- a/README.md +++ b/README.md @@ -1,112 +1,207 @@ # sbp -`sbp` 在用户级别搭建和管理 sing-box Hysteria2 服务. 不需要 root. 服务由 systemd user 管理. +`sbp` 在用户级别安装和管理 sing-box 代理服务. 不需要 root. 服务由 systemd user 管理. +它提供六个协议, 一个命令行接口, 和一个全屏 TUI. -## 部署到 VPS +## 安装 -一条命令下载并安装 `sbp`: +一条命令安装 `sbp` 命令: ```bash curl -fsSL https://raw.githubusercontent.com/justypist/sbp/main/install.sh | bash -sbp install +sbp ``` -下载后直接开始搭建: +`install.sh` 把程序放到 `~/.local/share/sbp/app`, 并建立 `~/.local/bin/sbp` 符号链接. +环境变量 `SBP_BIN` 改变命令目录. `SBP_APP_DIR` 改变程序目录. + +安装后运行 `sbp install` 搭建服务. 也可以让安装脚本直接搭建: ```bash curl -fsSL https://raw.githubusercontent.com/justypist/sbp/main/install.sh | bash -s -- --run ``` -`install.sh` 只安装 `sbp` 命令. 环境变量 `SBP_BIN` 可以改变安装目录 (默认 `~/.local/bin`). +依赖: Linux, systemd user, openssl, python3 3.9 或更高版本, curl. 网络必须能访问 GitHub. -也可以手动复制: +## 快速开始 ```bash -install -m 755 sbp ~/.local/bin/sbp -sbp --version +sbp install # 安装全部六个协议, 自动分配端口, 生成用户 u1 +sbp # 打开 TUI +sbp status # 查看状态 +sbp user show u1 # 打印 u1 的全部分享链接 ``` -依赖: Linux, systemd user, openssl, python3 (标准库). 网络可访问 GitHub. +## 协议 -## 用法 +| 协议 | 传输 | 证书 | 说明 | +| --- | --- | --- | --- | +| Hysteria2 | UDP | 需要 | 默认开启 salamander 混淆 | +| VLESS + REALITY | TCP | 不需要 | 借用真实网站的 TLS 握手 | +| Trojan | TCP | 需要 | 经典 TLS 代理 | +| Shadowsocks 2022 | TCP | 不需要 | 每个用户独立密钥 | +| TUIC v5 | UDP | 需要 | QUIC 传输 | +| AnyTLS | TCP | 需要 | 使用填充方案 | + +默认安装全部协议. 用 `--protocols` 选择子集: ```bash -sbp install # 下载最新 sing-box, 生成证书和用户, 启动服务, 打印配置 -sbp install --port 8443 --sni www.example.com --host 203.0.113.10 +sbp install --protocols hysteria2,vless-reality +``` + +每个协议使用独立端口. 默认从 `20000` 开始分配空闲端口. 用 `--base-port` 改变起点. + +REALITY 需要一个域名作为握手目标. 默认使用 `--sni` 的值. 如果 `--sni` 是 IP, +REALITY 会拒绝启动. 请显式指定域名: + +```bash +sbp install --sni www.example.com +``` + +## 命令行 + +### 安装与升级 + +```bash +sbp install --host 203.0.113.10 --sni www.example.com sbp install --cert fullchain.pem --key privkey.pem --sni example.com -sbp new phone # 新增用户并打印配置 -sbp list # 列出用户和分享链接 -sbp show phone # 打印用户的完整配置 -sbp show phone --uri # 只打印分享链接 -sbp show phone --json # 只打印 sing-box 客户端配置 -sbp show --cert # 打印服务端证书 -sbp del phone # 删除用户 -sbp status # 服务状态 -sbp log -f # 查看日志 +sbp install --protocols hysteria2,trojan --base-port 30000 sbp upgrade # 升级 sing-box -sbp uninstall # 停止并删除服务和程序, 保留配置 +sbp uninstall # 删除服务和程序, 保留配置 sbp uninstall --purge # 同时删除配置和证书 ``` -`install` 的选项: +### 协议 -| 选项 | 默认值 | 说明 | -| --- | --- | --- | -| `--port` | 随机空闲端口 | UDP 监听端口, 必须 >= 1024 | -| `--listen` | `::` | 监听地址, 默认同时监听 IPv4 和 IPv6 | -| `--sni` | `--host` 是域名时用它, 否则 `www.bing.com` | TLS server name 和证书名 | -| `--host` | 自动探测公网 IP | 打印给客户端使用的地址 | -| `--cert` / `--key` | 自签名证书 | 使用已有证书, 证书和私钥必须匹配 | -| `--masquerade` | `https://` | 伪装地址 | -| `--no-obfs` | 关闭 | 关闭 salamander 混淆 (不推荐) | -| `--sing-box-version` | 最新版 | 指定 sing-box 版本 | -| `--force` | 关闭 | 重装并保留已有用户 | +```bash +sbp protocol list +sbp protocol enable tuic --port 24000 +sbp protocol disable tuic +sbp protocol port tuic 24001 +sbp protocol set hysteria2 obfs false +sbp protocol set vless-reality short_id 0123456789abcdef +``` -## 安全设计 +### 用户 -- 密码和混淆密码由 `secrets.token_urlsafe(32)` 生成, 每个用户独立. -- 默认自签名 ECDSA P-256 证书, 有效期 10 年, 只含一个 SAN. -- TLS 最低版本 1.3, ALPN 只用 `h3`. -- 客户端配置使用 `certificate_public_key_sha256` 固定证书公钥. 不开启 `insecure`. -- 服务以非特权用户运行, 只监听 >= 1024 的端口. -- 下载的 sing-box 压缩包与 GitHub 发布的 `sha256` 摘要比对. -- systemd 加固: `NoNewPrivileges`, `ProtectSystem=strict`, `ProtectHome=read-only`, - `PrivateTmp`, `PrivateDevices`, `ProtectProc=invisible`, `ProcSubset=pid`, - `ProtectKernelTunables`, `ProtectKernelModules`, `ProtectKernelLogs`, - `RestrictAddressFamilies` (含 `AF_NETLINK`), `RestrictNamespaces`, - `MemoryDenyWriteExecute`, `SystemCallFilter=@system-service`, 空 capability 集等. -- `InaccessiblePaths` 屏蔽家目录中的常见凭据目录 (`~/.ssh`, `~/.gnupg`, `~/.aws`, - `~/.kube`, `~/.docker`, 浏览器配置等). 只屏蔽已存在的路径. -- 配置和状态文件权限 `0600`, 证书目录权限 `0700`. -- 日志级别 `warn`, 不记录访问日志. +```bash +sbp user list +sbp user add phone +sbp user del phone +sbp user show phone +sbp user show phone --protocol hysteria2 --uri +sbp user show phone --protocol hysteria2 --json +``` -## 分享链接与 pinSHA256 +`--json` 输出完整的 sing-box 客户端配置. 客户端保存为 `client.json` 后运行: -分享链接中的 `pinSHA256` 是证书 DER 的 SHA-256, 十六进制. 这与 Hysteria 2 URI 规范一致. +```bash +sing-box run -c client.json +``` -部分客户端不支持 `pinSHA256`. 这些客户端会拒绝自签名证书. 请改用 `sbp show --json` -输出的 sing-box 客户端配置. 不要为了兼容而关闭证书校验. +客户端配置包含 `127.0.0.1:1080` 的 mixed 入站. 它同时提供 SOCKS5 和 HTTP 代理. -客户端配置使用 `certificate_public_key_sha256`. 该字段要求 sing-box 1.13 或更高版本. -旧客户端无法解析. +### 服务与设置 + +```bash +sbp service start|stop|restart +sbp service logs -n 200 +sbp settings host 203.0.113.10 +sbp settings sni www.example.com +sbp settings cert --cert fullchain.pem --key privkey.pem --sni example.com +sbp cert show +sbp cert renew +``` + +### TUI + +```bash +sbp tui +``` + +TUI 提供这些页面: + +- Dashboard: 状态总览, 启动, 停止, 重启. +- Protocols: 启用, 停用, 改端口, 改选项. +- Users: 增加, 删除, 查看分享链接和客户端配置. +- Settings: 修改 host 和 SNI, 更换证书, 升级, 重装, 卸载. +- Logs: 查看 journal. + +操作键: `↑`/`↓` 或 `j`/`k` 移动, `Enter` 确认, `q` 返回. + +## 证书 + +默认生成自签名 ECDSA P-256 证书, 有效期 10 年. 客户端配置使用 +`certificate_public_key_sha256` 固定证书公钥. 分享链接使用 `pinSHA256` 或 +`allowInsecure=1`. + +自签名证书适合个人使用. 如果需要兼容更多客户端, 使用已有证书: + +```bash +sbp settings cert --cert fullchain.pem --key privkey.pem --sni example.com +``` ## 文件位置 | 路径 | 内容 | | --- | --- | -| `~/.config/sbp/state.json` | 用户, 端口, 密码, 证书指纹 | +| `~/.config/sbp/state.json` | 用户, 协议, 端口, 密钥 | | `~/.config/sbp/sing-box.json` | 生成的 sing-box 服务端配置 | | `~/.config/sbp/certs/` | 证书和私钥 | | `~/.local/share/sbp/bin/sing-box` | sing-box 程序 | +| `~/.local/share/sbp/app/` | sbp 程序文件 | | `~/.config/systemd/user/sbp.service` | systemd user 单元 | +路径遵循 `XDG_CONFIG_HOME` 和 `XDG_DATA_HOME`. + +## 安全设计 + +- 密码, UUID, Shadowsocks 密钥由 `secrets` 或 `os.urandom` 生成, 每个用户独立. +- 默认自签名 ECDSA P-256 证书, 只含一个 SAN. +- TLS 最低版本 1.3, Hysteria2 和 TUIC 只用 `h3` ALPN. +- 客户端配置固定证书公钥. 不开启 `insecure`. +- 服务以非特权用户运行, 只监听 >= 1024 的端口. +- 下载的 sing-box 压缩包与 GitHub 发布的 `sha256` 摘要比对. +- 新版本 sing-box 必须通过当前配置的 `check`, 才会替换旧程序. +- systemd 加固: `NoNewPrivileges`, `ProtectSystem=strict`, `ProtectHome=read-only`, + `PrivateTmp`, `PrivateDevices`, `ProtectProc=invisible`, `ProtectKernelTunables`, + `MemoryDenyWriteExecute`, `SystemCallFilter=@system-service`, 空 capability 集. +- `InaccessiblePaths` 屏蔽家目录中的常见凭据目录. +- 配置和状态文件权限 `0600`, 证书目录权限 `0700`. +- 日志级别 `warn`, 不记录访问日志. + +## 代码结构 + +``` +src/sbp/ + cli.py 命令行入口和参数解析 + commands/ 每个命令组一个模块 + state.py 状态模型, JSON 读写, 迁移 + config.py 生成 sing-box 服务端和客户端配置 + share.py 生成分享链接 + protocols/ 每个协议一个模块 + ops/ 业务操作, CLI 和 TUI 共用 + tui/ curses 界面 +``` + +协议模块只做三件事: 生成 inbound, 生成分享链接, 生成客户端 outbound. +它不读写文件, 不操作服务, 不访问网络. 新增协议只需要在 `protocols/` 增加一个模块, +并在 `protocols/__init__.py` 注册. + +## 测试 + +```bash +PYTHONPATH=src:. python3 -m unittest discover -s tests +``` + +测试覆盖状态迁移, 配置生成, 分享链接, 端口分配, 以及安装流程. +测试不访问网络, 也不依赖已安装的 sing-box. + ## 说明 -- 主机需要公网可达的 UDP 端口. 路由器 NAT 需要端口转发. -- 防火墙需要放行 UDP 端口. `install` 会打印示例命令. +- 主机需要公网可达的端口. 路由器 NAT 需要端口转发. +- 防火墙需要放行协议端口. UDP 协议放行 UDP, TCP 协议放行 TCP. - 用户级服务依赖 lingering. 确认 `loginctl show-user $USER -p Linger` 输出 `Linger=yes`. - `sbp install` 在未开启 lingering 时会给出警告. -- 主机没有 IPv6 时, 默认监听 `0.0.0.0`, 不会监听 `::`. -- 首次安装后, `sbp` 会等待服务进入 `active`. 如果失败, 用 `sbp log` 查看原因. + `sbp install` 在未开启 lingering 时给出警告. +- 主机没有 IPv6 时, 默认监听 `0.0.0.0`. - Python 的 `urllib` 不读取 `ALL_PROXY`. 使用 SOCKS 代理时请设置 `HTTPS_PROXY`. -- 文件位置遵循 `XDG_CONFIG_HOME` 和 `XDG_DATA_HOME`. 上表按默认值列出. diff --git a/install.sh b/install.sh index 79dc881..876ca20 100755 --- a/install.sh +++ b/install.sh @@ -3,31 +3,39 @@ set -euo pipefail repo="${SBP_REPO:-justypist/sbp}" branch="${SBP_BRANCH:-main}" -dest="${SBP_BIN:-$HOME/.local/bin}" -url="https://raw.githubusercontent.com/${repo}/${branch}/sbp" +app_dir="${SBP_APP_DIR:-$HOME/.local/share/sbp/app}" +bin_dir="${SBP_BIN:-$HOME/.local/bin}" +url="https://github.com/${repo}/archive/refs/heads/${branch}.tar.gz" command -v curl >/dev/null 2>&1 || { echo "error: curl is required" >&2; exit 1; } +command -v tar >/dev/null 2>&1 || { echo "error: tar is required" >&2; exit 1; } command -v python3 >/dev/null 2>&1 || { echo "error: python3 is required" >&2; exit 1; } -mkdir -p "$dest" -tmp="$(mktemp)" -trap 'rm -f "$tmp"' EXIT +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT -curl -fsSL "$url" -o "$tmp" -head -n 1 "$tmp" | grep -q '^#!/usr/bin/env python3' || { - echo "error: unexpected content from $url" >&2 - exit 1 -} -chmod 755 "$tmp" -mv "$tmp" "$dest/sbp" -trap - EXIT +curl -fsSL "$url" | tar -xz -C "$tmp" +src="$(find "$tmp" -mindepth 1 -maxdepth 1 -type d | head -n 1)" +[ -n "$src" ] && [ -f "$src/sbp" ] || { echo "error: unexpected archive layout from $url" >&2; exit 1; } +[ -f "$src/src/sbp/cli.py" ] || { echo "error: package files missing from $url" >&2; exit 1; } -echo "installed $dest/sbp" +mkdir -p "$(dirname "$app_dir")" "$bin_dir" +rm -rf "$app_dir.new" +mv "$src" "$app_dir.new" +chmod 755 "$app_dir.new/sbp" +if [ -e "$app_dir" ]; then + [ -f "$app_dir/src/sbp/cli.py" ] || { echo "error: refusing to replace $app_dir" >&2; exit 1; } + rm -rf "$app_dir" +fi +mv "$app_dir.new" "$app_dir" +ln -sfn "$app_dir/sbp" "$bin_dir/sbp" + +echo "installed $bin_dir/sbp" case ":$PATH:" in - *":$dest:"*) ;; - *) echo "note: add $dest to PATH, for example: export PATH=\"$dest:\$PATH\"" ;; + *":$bin_dir:"*) ;; + *) echo "note: add $bin_dir to PATH, for example: export PATH=\"$bin_dir:\$PATH\"" ;; esac if [ "${1:-}" = "--run" ]; then - exec "$dest/sbp" install "${@:2}" + exec "$bin_dir/sbp" install "${@:2}" fi diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..ad75cef --- /dev/null +++ b/pyproject.toml @@ -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"] diff --git a/sbp b/sbp index 08033f5..1795ffc 100755 --- a/sbp +++ b/sbp @@ -1,1098 +1,12 @@ #!/usr/bin/env python3 -"""sbp - manage a user-level sing-box Hysteria2 server.""" +"""Launcher used when sbp runs from a source checkout.""" -from __future__ import annotations - -import argparse -import base64 -import fcntl -import hashlib -import ipaddress -import json import os -import platform -import re -import secrets -import shutil -import socket -import stat -import subprocess import sys -import tarfile -import tempfile -import urllib.error -import urllib.parse -import urllib.request -from contextlib import contextmanager -from datetime import datetime, timezone -from pathlib import Path -APP = "sbp" -VERSION = "1.0.0" -DEFAULT_SNI = "www.bing.com" -DEFAULT_LISTEN = "::" -DEFAULT_CLIENT_PORT = 1080 -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}" - -def xdg(env: str, fallback: str) -> Path: - return Path(os.environ.get(env) or fallback).expanduser() - - -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", "sbp") - -STATE_FILE = CONFIG_DIR / "state.json" -SERVER_CONFIG = CONFIG_DIR / "sing-box.json" -CERT_FILE = CONFIG_DIR / "certs" / "cert.pem" -KEY_FILE = CONFIG_DIR / "certs" / "key.pem" -BINARY = DATA_DIR / "bin" / "sing-box" -UNIT_FILE = UNIT_DIR / f"{UNIT_NAME}.service" - -TTY = sys.stdout.isatty() and not os.environ.get("NO_COLOR") - - -def paint(code: str, text: str) -> str: - return f"\x1b[{code}m{text}\x1b[0m" if TTY else text - - -def info(msg: str) -> None: - print(f"{paint('36', '==>')} {msg}") - - -def ok(msg: str) -> None: - print(f"{paint('32', '==>')} {msg}") - - -def warn(msg: str) -> None: - print(f"{paint('33', 'warning:')} {msg}", file=sys.stderr) - - -def die(msg: str, code: int = 1) -> None: - print(f"{paint('31', 'error:')} {msg}", file=sys.stderr) - sys.exit(code) - - -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 run(cmd: list[str], check: bool = True, input: str | None = None) -> subprocess.CompletedProcess: - try: - proc = subprocess.run(cmd, check=False, input=input, capture_output=True, text=True) - except OSError as exc: - die(f"{cmd[0]}: {exc}") - if check and proc.returncode != 0: - detail = (proc.stderr or proc.stdout or "").strip() - die(f"{' '.join(cmd)}: {detail}") - return proc - - -def run_bytes(cmd: list[str], input: bytes | None = None) -> bytes: - try: - proc = subprocess.run(cmd, check=False, input=input, capture_output=True) - except OSError as exc: - die(f"{cmd[0]}: {exc}") - if proc.returncode != 0: - detail = (proc.stderr or proc.stdout).decode(errors="replace").strip() - die(f"{' '.join(cmd)}: {detail}") - return proc.stdout - - -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) - with os.fdopen(fd, "w") as handle: - os.fchmod(handle.fileno(), mode) - handle.write(data) - os.replace(tmp, path) - - -def load_state(strict: bool = True) -> dict | None: - if not STATE_FILE.exists(): - return None - try: - return json.loads(STATE_FILE.read_text()) - except (json.JSONDecodeError, OSError) as exc: - if strict: - die(f"cannot read state file {STATE_FILE}: {exc}") - warn(f"ignoring unreadable state file {STATE_FILE}: {exc}") - return None - - -def save_state(state: dict) -> None: - write_file(STATE_FILE, json.dumps(state, indent=2) + "\n", 0o600) - - -def require_state() -> dict: - state = load_state() - if state is None: - die("sbp is not installed; run 'sbp install' first") - return state - - -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 validate_name(name: str) -> str: - if not re.fullmatch(r"[A-Za-z0-9_-]{1,32}", name): - die("user name must match [A-Za-z0-9_-]{1,32}") - return name - - -def port_free(port: int, listen: str, retries: int = 1) -> bool: - import time - family = socket.AF_INET6 if ":" in listen else socket.AF_INET - for attempt in range(retries): - sock = socket.socket(family, socket.SOCK_DGRAM) - 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) -> int: - for _ in range(128): - port = 20000 + secrets.randbelow(40000) - if port_free(port, listen): - return port - die("cannot find a free UDP port") - - -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 format_address(host: str, port: int) -> str: - if ":" in host and not host.startswith("["): - host = f"[{host}]" - return f"{host}:{port}" - - -def proxy_env() -> bool: - keys = ("HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy") - return any(os.environ.get(key) for key in keys) - - -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" - - -@contextmanager -def state_lock(): - CONFIG_DIR.mkdir(parents=True, exist_ok=True) - os.chmod(CONFIG_DIR, 0o700) - fd = os.open(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) - - -def locked(func): - def wrapper(args: argparse.Namespace) -> None: - with state_lock(): - func(args) - return wrapper - - -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 detect_host() -> str: - if proxy_env(): - warn("proxy environment detected; the detected address may be the proxy exit, use --host to override") - 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 sys.platform != "linux": - die("sbp supports Linux only") - if os.geteuid() == 0: - die("run sbp as your normal user, not as root") - if shutil.which("openssl") is None: - die("openssl is required but not found in PATH") - if shutil.which("systemctl") is None: - die("systemctl is required but not found in PATH") - if "XDG_RUNTIME_DIR" not in os.environ: - runtime = Path(f"/run/user/{os.getuid()}") - if runtime.is_dir(): - os.environ["XDG_RUNTIME_DIR"] = str(runtime) - proc = run(["systemctl", "--user", "show-environment"], check=False) - if proc.returncode != 0: - detail = (proc.stderr or proc.stdout).strip() - die(f"systemd user manager is unavailable: {detail}\n" - "run 'loginctl enable-linger $USER' as root, then log in again") - if linger_enabled() is False: - warn(f"lingering is disabled; {UNIT_NAME} 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")): - warn("ALL_PROXY is not used by Python downloads; set HTTPS_PROXY if you need a proxy") - - -def systemctl(*args: str, check: bool = True) -> subprocess.CompletedProcess: - return run(["systemctl", "--user", *args], check=check) - - -def service_active() -> bool: - proc = systemctl("is-active", UNIT_NAME, check=False) - return proc.stdout.strip() == "active" - - -def http_json(url: str) -> dict: - request = urllib.request.Request(url, headers={ - "User-Agent": f"{APP}/{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: - die(f"GitHub API returned HTTP {exc.code} for {url}") - except Exception as exc: - die(f"cannot reach GitHub: {exc}") - raise AssertionError - - -def get_release(version: str | None) -> tuple[str, list[dict]]: - if version in (None, "", "latest"): - data = http_json(RELEASE_API) - else: - tag = version if version.startswith("v") else f"v{version}" - data = http_json(RELEASE_TAG_API.format(tag=tag)) - return data["tag_name"], 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: - die(f"unsupported architecture: {machine}") - libc = "glibc" if platform.libc_ver()[0] == "glibc" else "musl" - 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: - die(f"no release asset for linux-{arch}") - preferred = [a for a in candidates if libc in a["name"]] - return (preferred or candidates)[0] - - -def download(url: str, dest: Path) -> None: - request = urllib.request.Request(url, headers={"User-Agent": f"{APP}/{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: - die(f"download failed: {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) -> None: - expected = (asset.get("digest") or "").strip().lower() - if not expected.startswith("sha256:"): - warn(f"{asset['name']} has no sha256 digest; skipping integrity check") - return - actual = file_sha256(path) - if actual != expected.removeprefix("sha256:"): - die(f"checksum mismatch for {asset['name']}: expected {expected}, got sha256:{actual}") - ok("release checksum verified") - - -def install_binary(tag: str, assets: list[dict]) -> None: - asset = asset_for(assets, tag) - size = asset.get("size", 0) / 1048576 - info(f"downloading sing-box {tag} ({size:.1f} MiB)") - with tempfile.TemporaryDirectory(prefix="sbp-") as tmpdir: - tarball = Path(tmpdir) / "sing-box.tar.gz" - download(asset["browser_download_url"], tarball) - verify_digest(tarball, asset) - 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: - die("sing-box binary not found in archive") - source = archive.extractfile(members[0]) - if source is None: - die("cannot read sing-box binary from archive") - BINARY.parent.mkdir(parents=True, exist_ok=True) - staged = BINARY.with_name(f"sing-box.{os.getpid()}.{secrets.token_hex(4)}.tmp") - 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: - die(f"invalid release archive: {exc}") - os.chmod(staged, 0o755) - proc = run([str(staged), "version"], check=False) - if proc.returncode != 0: - staged.unlink(missing_ok=True) - die("downloaded sing-box binary does not run") - if SERVER_CONFIG.exists(): - check = run([str(staged), "check", "-c", str(SERVER_CONFIG)], check=False) - if check.returncode != 0: - staged.unlink(missing_ok=True) - detail = (check.stderr or check.stdout).strip() - die(f"new sing-box rejects the current config; not replacing the binary: {detail}") - os.replace(staged, BINARY) - ok(f"installed sing-box {tag} at {BINARY}") - - -def gen_cert(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 cert_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 cert_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 cert_names(cert: Path) -> list[str]: - proc = run(["openssl", "x509", "-in", str(cert), "-noout", "-ext", "subjectAltName"], check=False) - names = [] - for part in proc.stdout.replace("\n", ",").split(","): - part = part.strip() - if part.startswith("DNS:"): - names.append(part[4:]) - elif part.startswith("IP Address:"): - names.append(part[len("IP Address:"):]) - return names - - -def cert_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_cert_pair(cert: Path, key: Path) -> None: - if not cert.is_file(): - die(f"certificate not found: {cert}") - if not key.is_file(): - die(f"private key not found: {key}") - mode = key.stat().st_mode - if not stat.S_ISREG(mode): - die(f"private key is not a regular file: {key}") - if mode & 0o077: - warn(f"{key} is readable by group or others; run: chmod 600 {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(): - die("certificate and private key do not match") - - -def setup_tls(state: dict, args: argparse.Namespace, force_regen: bool) -> None: - if args.cert or args.key: - if not (args.cert and args.key): - die("--cert and --key must be used together") - cert, key = Path(args.cert).expanduser().resolve(), Path(args.key).expanduser().resolve() - verify_cert_pair(cert, key) - names = cert_names(cert) - if args.sni and names and args.sni not in names: - warn(f"certificate names are {', '.join(names)}; {args.sni} is not listed") - state["sni"] = args.sni or (names[0] if names else DEFAULT_SNI) - entry: dict = {"mode": "custom", "cert": str(cert), "key": str(key)} - if cert_self_signed(cert): - spki_pin, cert_sha256 = cert_pins(cert) - entry.update({"self_signed": True, "spki_pin": spki_pin, "cert_sha256": cert_sha256}) - state["tls"] = entry - ok("using the provided certificate") - return - - sni = state["sni"] - stale = state.get("tls", {}).get("sni") != sni - if force_regen or stale or not (CERT_FILE.is_file() and KEY_FILE.is_file()): - info(f"generating a self-signed certificate for {sni}") - gen_cert(sni, CERT_FILE, KEY_FILE) - spki_pin, cert_sha256 = cert_pins(CERT_FILE) - state["tls"] = { - "mode": "self-signed", - "self_signed": True, - "cert": str(CERT_FILE), - "key": str(KEY_FILE), - "sni": sni, - "spki_pin": spki_pin, - "cert_sha256": cert_sha256, - } - - -def tls_pinned(state: dict) -> bool: - return bool(state["tls"].get("self_signed")) - - -def masquerade_for(sni: str) -> str: - return "https://www.bing.com" if is_ip(sni) else f"https://{sni}" - - -def server_config(state: dict) -> dict: - tls = { - "enabled": True, - "server_name": state["sni"], - "alpn": ["h3"], - "min_version": "1.3", - "certificate_path": state["tls"]["cert"], - "key_path": state["tls"]["key"], - } - inbound: dict = { - "type": "hysteria2", - "tag": "hy2-in", - "listen": state["listen"], - "listen_port": state["port"], - "users": [{"name": user["name"], "password": user["password"]} for user in state["users"]], - "masquerade": state.get("masquerade") or masquerade_for(state["sni"]), - "tls": tls, - } - if state["obfs"]["enabled"]: - inbound["obfs"] = {"type": "salamander", "password": state["obfs"]["password"]} - return { - "log": {"level": "warn", "timestamp": True}, - "inbounds": [inbound], - "outbounds": [{"type": "direct", "tag": "direct"}], - "route": {"rules": [], "final": "direct"}, - } - - -def client_config(state: dict, user: dict) -> dict: - tls: dict = { - "enabled": True, - "server_name": state["sni"], - "alpn": ["h3"], - "min_version": "1.3", - } - if tls_pinned(state): - tls["certificate_public_key_sha256"] = [state["tls"]["spki_pin"]] - outbound: dict = { - "type": "hysteria2", - "tag": "hy2-out", - "server": state["host"], - "server_port": state["port"], - "password": user["password"], - "tls": tls, - } - if state["obfs"]["enabled"]: - outbound["obfs"] = {"type": "salamander", "password": state["obfs"]["password"]} - return { - "log": {"level": "warn"}, - "inbounds": [{ - "type": "mixed", - "tag": "mixed-in", - "listen": "127.0.0.1", - "listen_port": DEFAULT_CLIENT_PORT, - }], - "outbounds": [outbound], - "route": {"rules": [], "final": "hy2-out"}, - } - - -def share_uri(state: dict, user: dict) -> str: - host = state["host"] - if ":" in host and not host.startswith("["): - host = f"[{host}]" - query: dict[str, str] = {"sni": state["sni"]} - if state["obfs"]["enabled"]: - query["obfs"] = "salamander" - query["obfs-password"] = state["obfs"]["password"] - if tls_pinned(state): - query["pinSHA256"] = state["tls"]["cert_sha256"] - auth = urllib.parse.quote(user["password"], safe="") - name = urllib.parse.quote(user["name"], safe="") - return f"hysteria2://{auth}@{host}:{state['port']}/?{urllib.parse.urlencode(query)}#{name}" - - -def render_server(state: dict) -> None: - if not BINARY.is_file(): - die(f"sing-box binary missing: {BINARY}; run 'sbp install --force'") - for path in (state["tls"]["cert"], state["tls"]["key"]): - if not Path(path).is_absolute() or not Path(path).is_file(): - die(f"TLS file is missing or not absolute: {path}") - staged = SERVER_CONFIG.with_name(f".sing-box.{os.getpid()}.{secrets.token_hex(4)}.json") - write_file(staged, json.dumps(server_config(state), indent=2) + "\n", 0o600) - proc = run([str(BINARY), "check", "-c", str(staged)], check=False) - if proc.returncode != 0: - staged.unlink(missing_ok=True) - detail = (proc.stderr or proc.stdout).strip() - die(f"generated server config is invalid: {detail}") - os.replace(staged, SERVER_CONFIG) - - -def unit_quote(path: Path) -> str: - escaped = str(path).replace("%", "%%").replace('"', '\\"') - return f'"{escaped}"' - - -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", -] - - -def inaccessible_paths() -> list[Path]: - home = Path.home() - return [home / name for name in SENSITIVE_HOME_PATHS if (home / name).exists()] - - -def render_unit(state: dict) -> None: - binary = unit_quote(BINARY) - config = unit_quote(SERVER_CONFIG) - inaccessible = "\n".join( - f"InaccessiblePaths={unit_quote(path)}" for path in inaccessible_paths() - ) - unit = f"""[Unit] -Description=sbp sing-box Hysteria2 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(UNIT_FILE, unit, 0o644) - - -def wait_active(timeout: float = 8.0) -> bool: - import time - deadline = time.monotonic() + timeout - while time.monotonic() < deadline: - if service_active(): - time.sleep(0.5) - return service_active() - time.sleep(0.2) - return False - - -def fail_inactive(action: str) -> None: - detail = systemctl("status", UNIT_NAME, "--no-pager", "-n", "15", check=False) - print(detail.stdout + detail.stderr, file=sys.stderr) - die(f"{UNIT_NAME} did not become active after {action}\n" - f"check 'journalctl --user -u {UNIT_NAME}'") - - -def restart_service() -> None: - systemctl("daemon-reload", check=False) - proc = systemctl("restart", UNIT_NAME, check=False) - if proc.returncode != 0: - detail = (proc.stderr or proc.stdout).strip() - die(f"cannot restart {UNIT_NAME}: {detail}\ncheck 'journalctl --user -u {UNIT_NAME}'") - if not wait_active(): - fail_inactive("restart") - - -def enable_service() -> None: - systemctl("daemon-reload", check=False) - proc = systemctl("enable", "--now", UNIT_NAME, check=False) - if proc.returncode != 0: - detail = (proc.stderr or proc.stdout).strip() - die(f"cannot start {UNIT_NAME}: {detail}\ncheck 'journalctl --user -u {UNIT_NAME}'") - if not wait_active(): - fail_inactive("start") - - -def print_user(state: dict, user: dict) -> None: - config = client_config(state, user) - uri = share_uri(state, user) - print() - print(paint("1", f"User {user['name']}")) - print(f" password {user['password']}") - print() - print(paint("1", "Share URI")) - print(f" {uri}") - if tls_pinned(state): - print(" note: pinSHA256 pins the certificate. Clients that cannot pin must") - print(" import the certificate or use the JSON config below.") - print() - print(paint("1", "sing-box client config")) - for line in json.dumps(config, indent=2).splitlines(): - print(f" {line}") - print() - print(paint("1", "Client")) - print(" save the JSON above as client.json, then run:") - print(" sing-box run -c client.json") - print(f" SOCKS5 and HTTP proxy: 127.0.0.1:{DEFAULT_CLIENT_PORT}") - - -def print_server(state: dict) -> None: - print() - print(paint("1", "Server")) - print(f" address {format_address(state['host'], state['port'])}/udp") - print(f" sni {state['sni']}") - print(f" tls {'self-signed (pinned)' if tls_pinned(state) else 'custom'}") - print(f" obfs {'salamander' if state['obfs']['enabled'] else 'disabled'}") - print(f" masquerade {state.get('masquerade') or masquerade_for(state['sni'])}") - print(f" users {len(state['users'])}") - print(f" config {SERVER_CONFIG}") - if is_private(state["host"]): - warn(f"advertised address {state['host']} is private; pass --host if clients connect elsewhere") - - -def firewall_hint(state: dict) -> None: - port = state["port"] - print() - print(paint("1", "Firewall")) - print(f" allow UDP {port} if a firewall is active, for example:") - print(f" sudo ufw allow {port}/udp") - print(f" sudo firewall-cmd --permanent --add-port={port}/udp && sudo firewall-cmd --reload") - - -def new_user(name: str) -> dict: - return {"name": name, "password": token(), "created": now_iso()} - - -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): - die(f"refusing to delete {resolved}") - if not marker.exists(): - die(f"refusing to delete {resolved}: {marker} not found") - shutil.rmtree(resolved) - - -def build_state(args: argparse.Namespace, existing: dict | None) -> dict: - state = existing or {"schema": 1, "created": now_iso(), "users": []} - if args.listen: - listen = args.listen - elif state.get("listen"): - listen = state["listen"] - else: - listen = DEFAULT_LISTEN if ipv6_available() else "0.0.0.0" - if listen == "::" and not ipv6_available(): - die("IPv6 is unavailable on this host; use --listen 0.0.0.0") - port = args.port or state.get("port") or pick_port(listen) - own_port = (existing is not None and existing.get("port") == port - and existing.get("listen") == listen and service_active()) - if not own_port and not port_free(port, listen, retries=10): - die(f"UDP port {port} is already in use on {listen}") - state["listen"] = listen - state["port"] = port - state["host"] = args.host or state.get("host") or detect_host() - if not valid_host(state["host"]): - die(f"invalid --host value: {state['host']}") - if args.sni: - if not valid_host(args.sni): - die(f"invalid --sni value: {args.sni}") - state["sni"] = args.sni - elif not state.get("sni"): - state["sni"] = state["host"] if not is_ip(state["host"]) else DEFAULT_SNI - if "obfs" not in state: - state["obfs"] = {"enabled": not args.no_obfs, "password": token()} - if args.no_obfs: - state["obfs"]["enabled"] = False - if args.masquerade: - state["masquerade"] = args.masquerade - if not state["users"]: - state["users"] = [new_user("u1")] - return state - - -@locked -def cmd_install(args: argparse.Namespace) -> None: - check_platform() - existing = load_state() - if existing and not args.force: - die(f"already installed in {CONFIG_DIR}; use --force to reinstall") - if bool(args.cert) != bool(args.key): - die("--cert and --key must be used together") - - tag, assets = get_release(args.singbox_version) - install_binary(tag, assets) - - state = build_state(args, existing) - state["version"] = tag - sni_changed = existing is not None and existing.get("sni") != state["sni"] - setup_tls(state, args, force_regen=sni_changed or existing is None) - render_server(state) - render_unit(state) - save_state(state) - if existing: - systemctl("stop", UNIT_NAME, check=False) - enable_service() - ok(f"{UNIT_NAME} is active") - - print_server(state) - for user in state["users"]: - print_user(state, user) - firewall_hint(state) - - -@locked -def cmd_new(args: argparse.Namespace) -> None: - state = require_state() - name = args.name or f"u{secrets.token_hex(3)}" - validate_name(name) - if any(user["name"] == name for user in state["users"]): - die(f"user {name} already exists") - user = new_user(name) - state["users"].append(user) - render_server(state) - save_state(state) - restart_service() - ok(f"added user {name}") - print_user(state, user) - - -@locked -def cmd_del(args: argparse.Namespace) -> None: - state = require_state() - validate_name(args.name) - remaining = [user for user in state["users"] if user["name"] != args.name] - if len(remaining) == len(state["users"]): - die(f"user {args.name} does not exist") - state["users"] = remaining - render_server(state) - save_state(state) - restart_service() - ok(f"deleted user {args.name}") - if not remaining: - warn("no users left; all clients are rejected") - - -def cmd_list(args: argparse.Namespace) -> None: - state = require_state() - if args.json: - payload = { - "installed": True, - "service": "active" if service_active() else "inactive", - "address": format_address(state["host"], state["port"]), - "sni": state["sni"], - "users": [ - {"name": u["name"], "created": u["created"], "uri": share_uri(state, u)} - for u in state["users"] - ], - } - print(json.dumps(payload, indent=2)) - return - if not state["users"]: - print("no users") - return - rows = [(u["name"], u["created"][:10], share_uri(state, u)) for u in state["users"]] - name_width = max(len("NAME"), *(len(r[0]) for r in rows)) - date_width = max(len("CREATED"), *(len(r[1]) for r in rows)) - print(f"{'NAME':<{name_width}} {'CREATED':<{date_width}} URI") - for name, created, uri in rows: - print(f"{name:<{name_width}} {created:<{date_width}} {uri}") - - -def cmd_show(args: argparse.Namespace) -> None: - state = require_state() - if args.cert: - cert = Path(state["tls"]["cert"]) - if not cert.is_file(): - die(f"certificate not found: {cert}") - print(cert.read_text(), end="") - return - if args.uri: - if not args.name: - die("--uri requires a user name") - user = find_user(state, args.name) - print(share_uri(state, user)) - return - if args.json: - if args.name: - print(json.dumps(client_config(state, find_user(state, args.name)), indent=2)) - else: - if not state["users"]: - die("no users; run 'sbp new' first") - print(json.dumps(client_config(state, state["users"][0]), indent=2)) - return - if args.name: - print_user(state, find_user(state, args.name)) - else: - print_server(state) - for user in state["users"]: - print_user(state, user) - - -def find_user(state: dict, name: str) -> dict: - for user in state["users"]: - if user["name"] == name: - return user - die(f"user {name} does not exist") - raise AssertionError - - -def cmd_status(args: argparse.Namespace) -> None: - state = require_state() - active = service_active() - if BINARY.is_file(): - version = run([str(BINARY), "version"], check=False) - version_text = version.stdout.splitlines()[0].replace("sing-box version ", "").strip() \ - if version.returncode == 0 else "unknown" - else: - version_text = "missing (run 'sbp install --force')" - print(f"service {'active' if active else 'inactive'}") - print(f"sing-box {version_text}") - listen = f"[{state['listen']}]" if ":" in state["listen"] else state["listen"] - print(f"listen {listen}:{state['port']}/udp") - print(f"sni {state['sni']}") - print(f"tls {'self-signed (pinned)' if tls_pinned(state) else 'custom'}") - print(f"users {len(state['users'])}") - print(f"created {state['created'][:10]}") - print(f"cert until {cert_expiry(Path(state['tls']['cert']))}") - if not active: - print(f"hint journalctl --user -u {UNIT_NAME} -n 50") - - -def cmd_log(args: argparse.Namespace) -> None: - require_state() - cmd = ["journalctl", "--user", "-u", UNIT_NAME, "-n", str(args.lines), "--no-pager"] - if args.follow: - cmd.append("-f") - os.execvp("journalctl", cmd) - - -@locked -def cmd_upgrade(args: argparse.Namespace) -> None: - state = require_state() - tag, assets = get_release(args.singbox_version) - current = state.get("version") - if current == tag and not args.force: - ok(f"already on {tag}") - return - install_binary(tag, assets) - state["version"] = tag - render_server(state) - save_state(state) - restart_service() - ok(f"upgraded {current or 'unknown'} -> {tag}") - - -@locked -def cmd_uninstall(args: argparse.Namespace) -> None: - state = load_state(strict=False) - if state is None and not UNIT_FILE.exists() and not BINARY.exists(): - die("sbp is not installed") - systemctl("disable", "--now", UNIT_NAME, check=False) - if UNIT_FILE.exists(): - UNIT_FILE.unlink() - systemctl("daemon-reload", check=False) - same_dir = CONFIG_DIR.resolve() == DATA_DIR.resolve() - if DATA_DIR.exists(): - marker = BINARY if BINARY.exists() else STATE_FILE - safe_rmtree(DATA_DIR, marker) - if args.purge: - if CONFIG_DIR.exists() and not same_dir: - safe_rmtree(CONFIG_DIR, STATE_FILE) - ok("removed the service, binary, config, and certificates") - else: - ok(f"removed the service and binary; kept {CONFIG_DIR} (use --purge to delete)") - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser( - prog=APP, - description="Manage a user-level sing-box Hysteria2 server.", - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog="""examples: - sbp install - sbp install --port 23456 --sni www.example.com --host 203.0.113.10 - sbp install --cert /path/fullchain.pem --key /path/privkey.pem --sni example.com - sbp new phone - sbp list - sbp show phone --uri - sbp del phone - sbp uninstall --purge -""", - ) - parser.add_argument("-V", "--version", action="version", version=f"{APP} {VERSION}") - sub = parser.add_subparsers(dest="command", required=True) - - install = sub.add_parser("install", help="download sing-box, create a server, and start it") - install.add_argument("--port", type=int, help="UDP listen port (default: random free port)") - install.add_argument("--listen", help="listen address (default: ::)") - install.add_argument("--sni", help=f"TLS server name and certificate CN (default: {DEFAULT_SNI})") - install.add_argument("--host", help="address printed for clients (default: detected public IP)") - install.add_argument("--cert", help="use an existing certificate (PEM)") - install.add_argument("--key", help="use an existing private key (PEM)") - install.add_argument("--masquerade", help="masquerade URL (default: https://)") - install.add_argument("--no-obfs", action="store_true", help="disable salamander obfuscation") - install.add_argument("--sing-box-version", dest="singbox_version", help="sing-box release tag (default: latest)") - install.add_argument("--force", action="store_true", help="reinstall over an existing setup") - install.set_defaults(func=cmd_install) - - new = sub.add_parser("new", help="add a user and print its config") - new.add_argument("name", nargs="?", help="user name (default: random)") - new.set_defaults(func=cmd_new) - - delete = sub.add_parser("del", help="delete a user") - delete.add_argument("name") - delete.set_defaults(func=cmd_del) - - listing = sub.add_parser("list", help="list users") - listing.add_argument("--json", action="store_true", help="print JSON") - listing.set_defaults(func=cmd_list) - - show = sub.add_parser("show", help="print server or user config") - show.add_argument("name", nargs="?", help="user name") - 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 only") - show.add_argument("--cert", action="store_true", help="print the server certificate (PEM)") - show.set_defaults(func=cmd_show) - - status = sub.add_parser("status", help="show service status") - status.set_defaults(func=cmd_status) - - log = sub.add_parser("log", help="show service logs") - log.add_argument("-n", "--lines", type=int, default=100, help="number of lines") - log.add_argument("-f", "--follow", action="store_true", help="follow the log") - log.set_defaults(func=cmd_log) - - upgrade = sub.add_parser("upgrade", help="upgrade sing-box to the latest release") - upgrade.add_argument("--sing-box-version", dest="singbox_version", help="release tag (default: latest)") - upgrade.add_argument("--force", action="store_true", help="reinstall the same version") - upgrade.set_defaults(func=cmd_upgrade) - - uninstall = sub.add_parser("uninstall", help="stop and remove the service and binary") - uninstall.add_argument("--purge", action="store_true", help="also delete config and certificates") - uninstall.set_defaults(func=cmd_uninstall) - - return parser - - -def main() -> None: - os.umask(0o077) - args = build_parser().parse_args() - if args.command == "install" and args.port is not None and not 1024 <= args.port <= 65535: - die("--port must be between 1024 and 65535") - args.func(args) +sys.path.insert(0, os.path.join(os.path.dirname(os.path.realpath(__file__)), "src")) +from sbp.cli import main # noqa: E402 if __name__ == "__main__": - main() + raise SystemExit(main()) diff --git a/src/sbp/__init__.py b/src/sbp/__init__.py new file mode 100644 index 0000000..c48b57d --- /dev/null +++ b/src/sbp/__init__.py @@ -0,0 +1,3 @@ +"""sbp: install and manage a user-level sing-box proxy server.""" + +__version__ = "2.0.0" diff --git a/src/sbp/__main__.py b/src/sbp/__main__.py new file mode 100644 index 0000000..6a12592 --- /dev/null +++ b/src/sbp/__main__.py @@ -0,0 +1,4 @@ +from .cli import main + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/sbp/cli.py b/src/sbp/cli.py new file mode 100644 index 0000000..3bd800e --- /dev/null +++ b/src/sbp/cli.py @@ -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 diff --git a/src/sbp/commands/__init__.py b/src/sbp/commands/__init__.py new file mode 100644 index 0000000..a71a80b --- /dev/null +++ b/src/sbp/commands/__init__.py @@ -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, +} diff --git a/src/sbp/commands/_common.py b/src/sbp/commands/_common.py new file mode 100644 index 0000000..6256577 --- /dev/null +++ b/src/sbp/commands/_common.py @@ -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) diff --git a/src/sbp/commands/cert.py b/src/sbp/commands/cert.py new file mode 100644 index 0000000..a852d60 --- /dev/null +++ b/src/sbp/commands/cert.py @@ -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 diff --git a/src/sbp/commands/install.py b/src/sbp/commands/install.py new file mode 100644 index 0000000..17c0ff8 --- /dev/null +++ b/src/sbp/commands/install.py @@ -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 diff --git a/src/sbp/commands/protocol.py b/src/sbp/commands/protocol.py new file mode 100644 index 0000000..2507279 --- /dev/null +++ b/src/sbp/commands/protocol.py @@ -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 diff --git a/src/sbp/commands/service.py b/src/sbp/commands/service.py new file mode 100644 index 0000000..e91941f --- /dev/null +++ b/src/sbp/commands/service.py @@ -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 diff --git a/src/sbp/commands/settings.py b/src/sbp/commands/settings.py new file mode 100644 index 0000000..4d99c81 --- /dev/null +++ b/src/sbp/commands/settings.py @@ -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 diff --git a/src/sbp/commands/status.py b/src/sbp/commands/status.py new file mode 100644 index 0000000..4deb3fe --- /dev/null +++ b/src/sbp/commands/status.py @@ -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 diff --git a/src/sbp/commands/tui.py b/src/sbp/commands/tui.py new file mode 100644 index 0000000..9beafcf --- /dev/null +++ b/src/sbp/commands/tui.py @@ -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) diff --git a/src/sbp/commands/uninstall.py b/src/sbp/commands/uninstall.py new file mode 100644 index 0000000..3380d12 --- /dev/null +++ b/src/sbp/commands/uninstall.py @@ -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 diff --git a/src/sbp/commands/upgrade.py b/src/sbp/commands/upgrade.py new file mode 100644 index 0000000..16caf29 --- /dev/null +++ b/src/sbp/commands/upgrade.py @@ -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 diff --git a/src/sbp/commands/user.py b/src/sbp/commands/user.py new file mode 100644 index 0000000..17d9239 --- /dev/null +++ b/src/sbp/commands/user.py @@ -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 diff --git a/src/sbp/config.py b/src/sbp/config.py new file mode 100644 index 0000000..d4f00ad --- /dev/null +++ b/src/sbp/config.py @@ -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"]}, + } diff --git a/src/sbp/constants.py b/src/sbp/constants.py new file mode 100644 index 0000000..a9f30cf --- /dev/null +++ b/src/sbp/constants.py @@ -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", +) diff --git a/src/sbp/credentials.py b/src/sbp/credentials.py new file mode 100644 index 0000000..8571045 --- /dev/null +++ b/src/sbp/credentials.py @@ -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) diff --git a/src/sbp/errors.py b/src/sbp/errors.py new file mode 100644 index 0000000..24ea901 --- /dev/null +++ b/src/sbp/errors.py @@ -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 diff --git a/src/sbp/fsutil.py b/src/sbp/fsutil.py new file mode 100644 index 0000000..fca22eb --- /dev/null +++ b/src/sbp/fsutil.py @@ -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) diff --git a/src/sbp/hostinfo.py b/src/sbp/hostinfo.py new file mode 100644 index 0000000..3aa57f8 --- /dev/null +++ b/src/sbp/hostinfo.py @@ -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 diff --git a/src/sbp/net.py b/src/sbp/net.py new file mode 100644 index 0000000..7fe3731 --- /dev/null +++ b/src/sbp/net.py @@ -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 diff --git a/src/sbp/ops/__init__.py b/src/sbp/ops/__init__.py new file mode 100644 index 0000000..aa4229d --- /dev/null +++ b/src/sbp/ops/__init__.py @@ -0,0 +1 @@ +"""Operations shared by the CLI and the TUI.""" diff --git a/src/sbp/ops/common.py b/src/sbp/ops/common.py new file mode 100644 index 0000000..b222786 --- /dev/null +++ b/src/sbp/ops/common.py @@ -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 diff --git a/src/sbp/ops/install.py b/src/sbp/ops/install.py new file mode 100644 index 0000000..f270f19 --- /dev/null +++ b/src/sbp/ops/install.py @@ -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 diff --git a/src/sbp/ops/protocols.py b/src/sbp/ops/protocols.py new file mode 100644 index 0000000..2f6e31e --- /dev/null +++ b/src/sbp/ops/protocols.py @@ -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) diff --git a/src/sbp/ops/service.py b/src/sbp/ops/service.py new file mode 100644 index 0000000..46514aa --- /dev/null +++ b/src/sbp/ops/service.py @@ -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) diff --git a/src/sbp/ops/settings.py b/src/sbp/ops/settings.py new file mode 100644 index 0000000..462034e --- /dev/null +++ b/src/sbp/ops/settings.py @@ -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) diff --git a/src/sbp/ops/show.py b/src/sbp/ops/show.py new file mode 100644 index 0000000..0a70206 --- /dev/null +++ b/src/sbp/ops/show.py @@ -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() diff --git a/src/sbp/ops/status.py b/src/sbp/ops/status.py new file mode 100644 index 0000000..ea95fd0 --- /dev/null +++ b/src/sbp/ops/status.py @@ -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, + ) diff --git a/src/sbp/ops/uninstall.py b/src/sbp/ops/uninstall.py new file mode 100644 index 0000000..d823044 --- /dev/null +++ b/src/sbp/ops/uninstall.py @@ -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) diff --git a/src/sbp/ops/upgrade.py b/src/sbp/ops/upgrade.py new file mode 100644 index 0000000..f216c7e --- /dev/null +++ b/src/sbp/ops/upgrade.py @@ -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 diff --git a/src/sbp/ops/users.py b/src/sbp/ops/users.py new file mode 100644 index 0000000..190c019 --- /dev/null +++ b/src/sbp/ops/users.py @@ -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) diff --git a/src/sbp/paths.py b/src/sbp/paths.py new file mode 100644 index 0000000..29aca90 --- /dev/null +++ b/src/sbp/paths.py @@ -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), + ) diff --git a/src/sbp/protocols/__init__.py b/src/sbp/protocols/__init__.py new file mode 100644 index 0000000..5783fd3 --- /dev/null +++ b/src/sbp/protocols/__init__.py @@ -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"] diff --git a/src/sbp/protocols/anytls.py b/src/sbp/protocols/anytls.py new file mode 100644 index 0000000..1ed7286 --- /dev/null +++ b/src/sbp/protocols/anytls.py @@ -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), + } diff --git a/src/sbp/protocols/base.py b/src/sbp/protocols/base.py new file mode 100644 index 0000000..d9c08bd --- /dev/null +++ b/src/sbp/protocols/base.py @@ -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 [] diff --git a/src/sbp/protocols/hysteria2.py b/src/sbp/protocols/hysteria2.py new file mode 100644 index 0000000..f9af0c3 --- /dev/null +++ b/src/sbp/protocols/hysteria2.py @@ -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"), + ] diff --git a/src/sbp/protocols/shadowsocks.py b/src/sbp/protocols/shadowsocks.py new file mode 100644 index 0000000..634037a --- /dev/null +++ b/src/sbp/protocols/shadowsocks.py @@ -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))] diff --git a/src/sbp/protocols/trojan.py b/src/sbp/protocols/trojan.py new file mode 100644 index 0000000..3799be6 --- /dev/null +++ b/src/sbp/protocols/trojan.py @@ -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), + } diff --git a/src/sbp/protocols/tuic.py b/src/sbp/protocols/tuic.py new file mode 100644 index 0000000..a172009 --- /dev/null +++ b/src/sbp/protocols/tuic.py @@ -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"))] diff --git a/src/sbp/protocols/vless_reality.py b/src/sbp/protocols/vless_reality.py new file mode 100644 index 0000000..b8c1288 --- /dev/null +++ b/src/sbp/protocols/vless_reality.py @@ -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)"), + ] diff --git a/src/sbp/service.py b/src/sbp/service.py new file mode 100644 index 0000000..caed6e7 --- /dev/null +++ b/src/sbp/service.py @@ -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() diff --git a/src/sbp/share.py b/src/sbp/share.py new file mode 100644 index 0000000..b8eb2fb --- /dev/null +++ b/src/sbp/share.py @@ -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() + ] diff --git a/src/sbp/shell.py b/src/sbp/shell.py new file mode 100644 index 0000000..260b745 --- /dev/null +++ b/src/sbp/shell.py @@ -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 diff --git a/src/sbp/singbox.py b/src/sbp/singbox.py new file mode 100644 index 0000000..db25682 --- /dev/null +++ b/src/sbp/singbox.py @@ -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 diff --git a/src/sbp/state.py b/src/sbp/state.py new file mode 100644 index 0000000..53d438a --- /dev/null +++ b/src/sbp/state.py @@ -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) diff --git a/src/sbp/tls.py b/src/sbp/tls.py new file mode 100644 index 0000000..6eb0613 --- /dev/null +++ b/src/sbp/tls.py @@ -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 diff --git a/src/sbp/tui/__init__.py b/src/sbp/tui/__init__.py new file mode 100644 index 0000000..f72c8f6 --- /dev/null +++ b/src/sbp/tui/__init__.py @@ -0,0 +1 @@ +"""Full-screen terminal interface.""" diff --git a/src/sbp/tui/app.py b/src/sbp/tui/app.py new file mode 100644 index 0000000..ec76814 --- /dev/null +++ b/src/sbp/tui/app.py @@ -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 diff --git a/src/sbp/tui/screens/__init__.py b/src/sbp/tui/screens/__init__.py new file mode 100644 index 0000000..09ea513 --- /dev/null +++ b/src/sbp/tui/screens/__init__.py @@ -0,0 +1 @@ +"""TUI screens.""" diff --git a/src/sbp/tui/screens/dashboard.py b/src/sbp/tui/screens/dashboard.py new file mode 100644 index 0000000..c5bc597 --- /dev/null +++ b/src/sbp/tui/screens/dashboard.py @@ -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)) diff --git a/src/sbp/tui/screens/logs.py b/src/sbp/tui/screens/logs.py new file mode 100644 index 0000000..2516170 --- /dev/null +++ b/src/sbp/tui/screens/logs.py @@ -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", + ) diff --git a/src/sbp/tui/screens/protocols.py b/src/sbp/tui/screens/protocols.py new file mode 100644 index 0000000..4a01fd5 --- /dev/null +++ b/src/sbp/tui/screens/protocols.py @@ -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), + ) diff --git a/src/sbp/tui/screens/settings.py b/src/sbp/tui/screens/settings.py new file mode 100644 index 0000000..471422d --- /dev/null +++ b/src/sbp/tui/screens/settings.py @@ -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 diff --git a/src/sbp/tui/screens/users.py b/src/sbp/tui/screens/users.py new file mode 100644 index 0000000..c4c5074 --- /dev/null +++ b/src/sbp/tui/screens/users.py @@ -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 diff --git a/src/sbp/tui/term.py b/src/sbp/tui/term.py new file mode 100644 index 0000000..5d13d44 --- /dev/null +++ b/src/sbp/tui/term.py @@ -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 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..175d408 --- /dev/null +++ b/tests/test_cli.py @@ -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() diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..43b92cd --- /dev/null +++ b/tests/test_config.py @@ -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() diff --git a/tests/test_guards.py b/tests/test_guards.py new file mode 100644 index 0000000..0b10b17 --- /dev/null +++ b/tests/test_guards.py @@ -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() diff --git a/tests/test_install.py b/tests/test_install.py new file mode 100644 index 0000000..f855b8c --- /dev/null +++ b/tests/test_install.py @@ -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() diff --git a/tests/test_net.py b/tests/test_net.py new file mode 100644 index 0000000..01f2b8f --- /dev/null +++ b/tests/test_net.py @@ -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() diff --git a/tests/test_state.py b/tests/test_state.py new file mode 100644 index 0000000..01f3c08 --- /dev/null +++ b/tests/test_state.py @@ -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() diff --git a/tests/test_term.py b/tests/test_term.py new file mode 100644 index 0000000..5a2de9f --- /dev/null +++ b/tests/test_term.py @@ -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() diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..fce3336 --- /dev/null +++ b/uv.lock @@ -0,0 +1,8 @@ +version = 1 +revision = 3 +requires-python = ">=3.9" + +[[package]] +name = "sbp" +version = "2.0.0" +source = { editable = "." }