#!/usr/bin/env bash # Terry 工具箱:启动检测依赖;确认后才安装,不自动升级系统。 set -u TOOLBOX_DIR='' # No external modules in the standalone bundle. TOOLBOX_VERSION='0.2.0-dev' # 非 root 时自动提权,便于一条命令 `bash <(curl -fsSL sh.uw.cx)` 直接可用。 if (( EUID != 0 )) && [[ -z ${TOOLBOX_ELEVATED:-} ]]; then command -v sudo >/dev/null 2>&1 || { printf '需要 root 权限:请用 root 运行,或先安装 sudo。\n' >&2 exit 1 } export TOOLBOX_ELEVATED=1 case ${BASH_SOURCE[0]:-} in /dev/fd/*|/dev/stdin|/proc/self/fd/*|bash|'') # 流式执行时脚本本身在已关闭/被读空的 fd 上,重新取一份完整副本再提权。 command -v curl >/dev/null 2>&1 || { printf '需要 curl 才能自动提权。\n' >&2; exit 1; } _tb_tmp=$(mktemp "${TMPDIR:-/tmp}/toolbox.XXXXXXXX") || exit 1 if ! curl -fsSL --proto '=https,http' sh.uw.cx >"$_tb_tmp"; then printf '下载脚本失败(sh.uw.cx)。\n' >&2 exit 1 fi # 管道执行时 stdin 不是终端,菜单会读不到;能拿到终端就切回去。 if [[ -t 0 ]]; then exec sudo -E bash "$_tb_tmp" "$@" elif { : /dev/null; then exec sudo -E bash "$_tb_tmp" "$@" /dev/null 2>&1; then columns=$(tput cols 2>/dev/null) || columns=80; fi [[ $columns =~ ^[0-9]+$ ]] || columns=80 (( columns > 78 )) && columns=78 (( columns < 32 )) && columns=32 TB_WIDTH=$((columns - 2)) TB_OS='Linux' TB_OS_ID='unknown' local line val release=${TOOLBOX_OS_RELEASE:-/etc/os-release} if [[ -r $release ]]; then while IFS= read -r line; do case "$line" in PRETTY_NAME=*|ID=*) val=${line#*=}; val=${val#\"}; val=${val%\"}; val=${val#\'}; val=${val%\'} # OS metadata is parsed as data; strip anything that could move the cursor. val=${val//[^a-zA-Z0-9 ._()\/+-]/} case "$line" in PRETTY_NAME=*) TB_OS=$val;; ID=*) TB_OS_ID=$val;; esac ;; esac done < "$release" fi TB_ARCH=$(uname -m 2>/dev/null) || TB_ARCH='unknown' TB_TERRY_ART=( '██████ █████ █████ █████ ██ ██' ' ██ ██ ██ ██ ██ ██ ██ ██' ' ██ ████ █████ █████ ███ ' ' ██ ██ ██ ██ ██ ██ ██ ' ' ██ █████ ██ ██ ██ ██ ██ ' ) } _tb_display_width() { local LC_ALL=C local text=${1//$'\xef\xb8\x8f'/} i char n=0 # Byte-based so alignment holds under LC_ALL=C or a C-locale SSH login. # Box drawing (U+2500 block, 3 bytes) is one column; only CJK and emoji are two. for ((i=0; i<${#text}; i++)); do char=${text:i:1} case "$char" in [$'\x80'-$'\xbf']) ;; # continuation byte [$'\xe3'-$'\xe9']) ((n+=2));; # CJK ideograph / CJK punctuation [$'\xe2']) case ${text:i+1:1} in [$'\x98'-$'\x9f']) ((n+=2));; *) ((n+=1));; esac ;; [$'\xef']) ((n+=2));; # fullwidth forms [$'\xf0'-$'\xf3']) ((n+=2));; # emoji [$'\xc2'-$'\xdf']) ((n+=1));; # Latin-1 punctuation (·, …) *) ((n+=1));; esac done TB_TEXT_WIDTH=$n } _tb_repeat() { local n=$1 ch=$2 i; for ((i=0;i= 46 )); then _tb_center ' ' for art in "${TB_TERRY_ART[@]}"; do _tb_center "$art" "$TB_BOLD$TB_BLUE"; done _tb_center ' ' _tb_center 'T E R R Y T O O L B O X' "$TB_BOLD$TB_CYAN" else _tb_center ' ' _tb_center 'TERRY TOOLBOX' "$TB_BOLD$TB_CYAN" fi _tb_center '服务器工具箱 · 启动先检测依赖' "$TB_DIM" _tb_sep if (( TB_WIDTH >= 60 )); then _tb_center "$TB_OS $TB_ARCH $TOOLBOX_VERSION" "$TB_DIM" else _tb_center "$TB_ARCH $TOOLBOX_VERSION" "$TB_DIM" fi _tb_bot printf '\n' } _tb_main_screen() { _tb_banner _tb_head '请选择要执行的操作' _tb_pair '🌐 1. 网络服务' '💽 4. 重装系统' _tb_pair '⚡ 2. 安装 BBR' '🛠️ 5. 常用工具' _tb_pair '📦 3. 安装基础依赖' '🔄 99. 重新启动' _tb_pair '🩺 DIAG 只读体检' '🚪 0. 退出脚本' _tb_bot if (( TB_WIDTH < 64 )); then printf '\n %s· 输入编号回车;A1 / B9 快捷可用%s\n' "$TB_DIM" "$TB_RESET" printf ' %s· 修改前会提示影响并确认%s\n\n' "$TB_DIM" "$TB_RESET" else printf '\n %s· 输入编号回车;完整编号 A1 / B9 也可直接输入%s\n' "$TB_DIM" "$TB_RESET" printf ' %s· 安装、修改和重启前会显示影响并二次确认%s\n\n' "$TB_DIM" "$TB_RESET" fi } _tb_network_screen() { _tb_head '🌐 网络服务' _tb_row '📡 1. Alpine 版 HY2' _tb_row '📡 2. Alpine 版 Xray' _tb_row '📡 3. TUIC 服务' _tb_row '📡 4. HY2 服务' _tb_row '📡 5. Xray 服务' _tb_sep _tb_row '↩️ 0. 返回主菜单' _tb_bot; printf '\n' printf ' %sAlpine 两项仅在 Alpine + OpenRC 可用。%s\n\n' "$TB_DIM" "$TB_RESET" } _tb_apps_screen() { _tb_head '🛠️ 常用工具' _tb_pair '🐳 1. Docker' '🧰 9. IT-Tools' _tb_pair '📊 2. NodeQuality' '🔐 10. WebSSH' _tb_pair '📁 3. Alist' '🧱 11. 1Panel' _tb_pair '🐱 4. 哪吒面板' '⚙️ 12. TCP Brutal' _tb_pair '🪪 5. X-UI' '🌍 13. MyIP' _tb_pair '💾 6. 虚拟内存' '🛰️ 14. 回程测试' _tb_pair '📈 7. 流量限制' '📨 15. TG 通知' _tb_pair '🔑 8. 修改密码' '🚀 16. 网络优化' _tb_sep _tb_pair '🧭 17. Sun-panel' '🏔️ 19. Alpine 工具' _tb_pair '📝 20. Memos' '🔀 21. NPM' _tb_pair '✍️ 22. Halo' '🌐 23. DNS 切换' _tb_pair '🧩 24. NginxWebUI' '🖥️ 25. Nexterm' _tb_pair '📺 26. LibreTV' '📄 27. Stirling PDF' _tb_pair '✅ 28. 网页验证' '🧬 29. DDNS-GO' _tb_pair '🔒 30. 密码管理器' '🔁 31. Relay 转发' _tb_sep _tb_pair '🕳️ 18. 预留' '↩️ 0. 返回主菜单' _tb_bot; printf '\n' } _tb_read_choice() { printf ' %s🎯%s 请输入数字选择操作:' "$TB_CYAN" "$TB_RESET" IFS= read -r TB_CHOICE || { printf '\n'; return 1; } TB_CHOICE=${TB_CHOICE^^} } _tb_pause() { if [[ -t 0 && -t 1 ]]; then local answer printf '\n %s↩ 操作结束,按回车返回菜单…%s' "$TB_DIM" "$TB_RESET" IFS= read -r answer || return 1 fi } _tb_bye() { printf '\n' _tb_top '╔' '╗' _tb_center '👋 感谢使用 Terry Toolbox' "$TB_BOLD$TB_CYAN" _tb_center ' ' _tb_center '期待下次见,留好备用登录方式。' "$TB_DIM" _tb_center ' ' _tb_bot printf '\n' } _tb_bootstrap_has() { command -v "$1" >/dev/null 2>&1; } _tb_bootstrap_exec() { "$@"; } _tb_bootstrap() { # Interactive startup only. --help, --list and piped runs never install anything. [[ -t 0 && -t 1 ]] || return 0 local command answer missing=() manager='' for command in curl wget socat sudo nano openssl tar; do _tb_bootstrap_has "$command" || missing+=("$command") done if (( ${#missing[@]} == 0 )); then if (( TB_WIDTH < 60 )); then printf ' %s✓%s 基础依赖已齐全\n' "$TB_GREEN" "$TB_RESET" else printf ' %s✓%s 基础依赖已齐全 · %s · %s\n' "$TB_GREEN" "$TB_RESET" "$TB_OS" "$TB_ARCH" fi return 0 fi case "$TB_OS_ID" in alpine) manager=apk;; debian|ubuntu) manager=apt-get;; centos|rhel|rocky|almalinux|fedora) if _tb_bootstrap_has dnf; then manager=dnf; else manager=yum; fi;; esac printf ' %s⚠%s 系统 %s · %s\n' "$TB_YELLOW" "$TB_RESET" "$TB_OS" "$TB_ARCH" printf ' %s缺少%s:%s\n' "$TB_YELLOW" "$TB_RESET" "${missing[*]}" if [[ -z $manager ]] || ! _tb_bootstrap_has "$manager"; then printf ' 未找到支持的包管理器,跳过安装;各功能会自行检查依赖。\n' return 0 fi printf ' 将用 %s 只补缺失项(不升级系统)。按 Y 安装,其他键跳过:' "$manager" IFS= read -r answer || return 0 [[ ${answer^^} == Y ]] || { printf ' %s已跳过%s,稍后可从 [3] 安装。\n' "$TB_DIM" "$TB_RESET"; return 0; } (( EUID == 0 )) || { printf ' %s安装依赖需要 root%s;未执行。\n' "$TB_RED" "$TB_RESET"; return 1; } case "$manager" in apt-get) _tb_bootstrap_exec apt-get update && _tb_bootstrap_exec apt-get install -y --no-upgrade -- "${missing[@]}";; apk) _tb_bootstrap_exec apk add --no-cache -- "${missing[@]}";; dnf|yum) _tb_bootstrap_exec "$manager" install -y -- "${missing[@]}";; esac || { printf ' %s装依赖失败%s,可检查软件源后从 [3] 重试。\n' "$TB_RED" "$TB_RESET" >&2; return 1; } missing=() for command in curl wget socat sudo nano openssl tar; do _tb_bootstrap_has "$command" || missing+=("$command"); done (( ${#missing[@]} == 0 )) || { printf ' %s仍缺少%s:%s\n' "$TB_RED" "$TB_RESET" "${missing[*]}" >&2; return 1; } printf ' %s✓%s 依赖检查通过。\n' "$TB_GREEN" "$TB_RESET" } # END bundled module: entry show_list() { printf '%s\n' 'A1 A2 A3 A4 A5' 'B1 B2 B3 B4 B5 B6 B7 B8 B9 B10 B11 B12 B13 B14 B15 B16 B17 B18 B19 B20 B21 B22 B23 B24 B25 B26 B27 B28 B29 B30 B31' } show_help() { printf 'Terry 工具箱 %s\n用法: bash toolbox.sh [--help|--list]\n--list 列出菜单编号;不带参数进入交互菜单。\n' "$TOOLBOX_VERSION" } # Bundled modules; load_modules is intentionally a no-op. TOOLBOX_BUNDLED=1 load_modules() { :; } # BEGIN bundled module: apps #!/usr/bin/env bash # Source this file; nothing runs until toolbox_app NUMBER is called. # Docker CLI and a working daemon must already be installed. All host ports are # loopback-only on Docker Engine >= 28. Each app gets its own bridge network; # this is not a firewall against host users, privileged containers, custom routing, # direct routing or other containers deliberately connected to that network. # No host networking or Docker socket mounts. Existing data requires explicit # reuse confirmation and may be modified by the app; nothing is deleted. # Only the default local Unix socket is accepted. A socket proxy could still point # to a remote daemon: the operator must ensure it really is the local Engine. # Multi-platform manifest digests resolved from official registries on 2026-10-30; # SHA-256 verified against manifest bytes and registry digest header. B28 is single-arch (amd64). # Pinning is not # signature verification, security review or a guarantee of application readiness. # Pins require deliberate security updates and compatibility testing before reuse. # Stirling PDF remains mutable: its registry returned HTTP 403 during validation. # References: upstream READMEs at github.com/{AlistGo/alist,CorentinTh/it-tools, # jason5ng32/MyIP,hslr-s/sun-panel,usememos/memos,NginxProxyManager/nginx-proxy-manager, # halo-dev/halo,cym1102/nginxWebUI,gnmyt/Nexterm,LibreSpark/LibreTV, # Stirling-Tools/Stirling-PDF,jeessy2/ddns-go,dani-garcia/vaultwarden}; # https://docs.nexterm.dev/installation # https://nezha.wiki/en_US/guide/dashboard.html # https://github.com/MHSanaei/3x-ui/wiki/Installation # # B28 来源证据: https://github.com/doubleDimple/mfa-start 的 Dockerfile(eclipse-temurin:8-jdk-jammy, # WORKDIR /mfa-start, EXPOSE 9999, H2 数据写 ./data)与该仓库自带 deploy.yaml 引用的 # lovele/mfa-start 完全一致;Docker Hub 账号 lovele 逐个 release 发布同名标签(2.2.4–2.2.52, # 最新标签与 release 同一分钟推送),但注册表元数据没有源码仓库标注,属高可信推断而非官方背书。 # 上游官方部署方式是下载未校验的 JAR + 脚本,故这里改用镜像 digest 固定。 # 应用只有表单登录,release 配置内置默认 admin/admin1234,可用 SPRING_SECURITY_USER_* 覆盖。 # 镜像仅提供 linux/amd64 单架构(非 manifest list)。 # B10 WebSSH 仍留在 modules/webssh.sh:上游只有 Dockerfile 与 compose 自建,没有官方镜像。 # A subshell prevents secrets, umask and local environment changes leaking to the caller. toolbox_app() ( local id=${1-} name image default_port target_port data_target='' note='' port answer local root data names secret extra_ports='' environment='' context endpoint version major network_id reuse=0 canonical subdir local -a args=() if [[ $# != 1 ]]; then printf '用法: toolbox_app <数字>\n' >&2; return 2; fi case "$id" in 3) name=alist; image=xhofe/alist@sha256:afa909f81817770520df07515287eb3df3d4e3c6631ca28b02cbae8499149b39; default_port=5244; target_port=5244; data_target=/opt/alist/data; note='首次启动的初始管理员密码因版本而异:可用 docker exec -it terry-alist ./alist admin 查看,忘记则须按上游文档随机重置,登录后立即更换。' ;; 4) name=nezha; image=ghcr.io/nezhahq/nezha@sha256:c3de06b940fc2da673e7d29be3c742270440cb881675df84b35e474ab9799bf1; default_port=8008; target_port=8008; data_target=/dashboard/data; note='上游首次启动默认 admin/admin;立即从 /dashboard 登录并修改密码,公网开放前必须配置 HTTPS 和认证。Agent 通信和网页共用端口;本部署仅本机访问,远程 Agent 须另行安全配置支持 gRPC 的 TLS 代理及 Agent 地址。复用数据如已更改监听端口,需自行调整配置。' ;; 5) name=x-ui; image=ghcr.io/mhsanaei/3x-ui@sha256:1628adbd9bed680d25d6f714bef4a20f132105f4612ea294952f6665dad32e5b; default_port=2053; target_port=2053; data_target=/etc/x-ui; environment=xui; note='3X-UI 上游首次启动默认 admin/admin;立即修改凭据,建议开启 2FA、设置独立路径,公网访问前配置 HTTPS。仅映射管理端口,代理入站端口未映射;须另行审查并配置端口、协议及防火墙。未授予 NET_ADMIN/NET_RAW,Fail2ban 已禁用,IP 限制封禁不会生效。复用数据如已更改监听端口或路径需自行调整。' ;; 9) name=it-tools; image=corentinth/it-tools@sha256:8b8128748339583ca951af03dfe02a9a4d7363f61a216226fc28030731a5a61f; default_port=7090; target_port=80; note='无内置认证:任何能连到该端口的人都能直接使用;公网开放前须自备认证与 HTTPS。' ;; 13) name=myip; image=jason5ng32/myip@sha256:ba54d22576f1cd86dcfb9c453fcf805bd6817ca9bed234a6fd529f5f0f19fb31; default_port=18966; target_port=18966; note='无内置登录认证,访问者都能查询(可选限流与安全配置见上游文档);MaxMind 功能需要另行配置凭据;域名访问需配置 ALLOWED_DOMAINS。' ;; 17) name=sun-panel; image=hslr/sun-panel@sha256:dc8fea427bf5ee825b81ebbc5bd6f39e61123ecbf8173601a57b1ec00637c444; default_port=3002; target_port=3002; data_target=/app/conf; note='首次登录的默认账号为 admin@sun.cc、密码 12345678(由上游初始化代码写入),登录后立即修改。上游说明最新版本可能闭源。' ;; 18) printf 'B18 为预留项。\n' >&2; return 2 ;; 20) name=memos; image=neosmemo/memos@sha256:24c2707ddd8fbd2ceaa7a61ecab86a53cdcde640b24ce572b73da14b00b5785f; default_port=5230; target_port=5230; data_target=/var/opt/memos; note='首次打开页面时创建第一个账号,该账号即管理员;创建后关闭公开注册。' ;; 21) name=nginx-proxy-manager; image=jc21/nginx-proxy-manager@sha256:4393e642e233e5efd5adeb7f10918d4aaea71c9ed875b94190273fc8e87f9594; default_port=81; target_port=81; data_target=/data; extra_ports=npm; note='首次访问进入初始化向导创建管理员(当前版本无固定默认凭据;更早版本为 admin@example.com / changeme,亦可用 INITIAL_ADMIN_EMAIL/PASSWORD 预先指定)。代理端口 80/443 仅绑定本机。容器内 localhost 指向 NPM 自身,无法访问宿主机 127.0.0.1 或其他应用的 loopback 端口;默认独立网络也无法访问其他应用。请另行设计并审查安全的上游连接及 TLS,勿假定 localhost 可用。' ;; 22) name=halo; image=halohub/halo@sha256:cc28f8bab142b79bf1bc5bdb07c2e0746821af5c814705e72b5beda6e9cabbc9; default_port=8090; target_port=8090; data_target=/root/.halo2; note='无默认凭据;请立即通过本机页面 /console 完成管理员初始化。' ;; 24) name=nginxwebui; image=cym1102/nginxwebui@sha256:3e5ffa13dfc23702e3cd8d60320466617649a783b39ae5087ebee0a1ac704c67; default_port=8080; target_port=8080; data_target=/home/nginxWebUI; note='无默认凭据:首次打开页面时初始化管理员账号,须设为复杂密码(本容器以 root 执行 nginx 及系统命令)。仅映射管理端口;容器内 Nginx 的代理端口需自行审查并映射。' ;; 25) name=nexterm; image=nexterm/aio@sha256:def6f6a50b48c842d3fbe796ff8e7ce6b8fa54e2d92116a62742859c64f47d7e; default_port=6989; target_port=6989; data_target=/app/data; environment=nexterm; note='首次访问自行创建管理员账号;账号体系由应用自身数据库维护。Bridge 网络不支持所有 host 网络功能;数据与加密密钥须一并备份。' ;; 26) name=libretv; image=ghcr.io/librespark/libretv@sha256:f19c897f84c303e887d1f03313897dc294dfbeebd4ac9d2aa42070146d18b7e9; default_port=8080; target_port=8080; environment=libretv; note='上游要求设置访问密码:该密码即唯一访问口令,没有用户名;仅使用合法视频源。' ;; 27) name=stirling-pdf; image=docker.stirlingpdf.com/stirlingtools/stirling-pdf:latest; default_port=8080; target_port=8080; data_target=/configs; note='默认不启用登录(SECURITY_ENABLELOGIN=false):任何可达者都能使用,公网访问前须开启登录并配置 TLS。' ;; 28) name=mfa-start; image=lovele/mfa-start@sha256:d328fb3d8046192356b7e693bb4afdea160a897ac6d227a8260aa23f89077572; default_port=9999; target_port=9999; data_target=/mfa-start/data; environment=mfa-start; note='release 配置自带默认 admin/admin1234;本工具改用固定用户名 mfa-start-user 和自设密码,经 SPRING_SECURITY_USER_* 环境变量传入(不进入命令行)。只有表单登录,无 HTTPS、无 2FA:公网前须自行配置反向代理与 TLS。数据是未加密的 H2 单文件(/mfa-start/data),容器以 root 运行且文件属主为 root,其中保存的 TOTP 种子泄露即可生成验证码,须定期备份并限制读取。镜像只有 linux/amd64 架构。' ;; 29) name=ddns-go; image=jeessy/ddns-go@sha256:0336e6eddcb4052e978c90f0aa530f77205b1dd08fe28057869dbc77a60c5671; default_port=9876; target_port=9876; data_target=/root; note='无默认凭据:首次访问须自行设置管理员账号和复杂密码;bridge 模式可能无法读取宿主机网卡/IPv6。' ;; 30) name=vaultwarden; image=vaultwarden/server@sha256:efb3cde962015fcc036b2ea625242248611943b27212ebf4392841fb30fad055; default_port=3003; target_port=80; data_target=/data; note='默认允许注册(SIGNUPS_ALLOWED=true):首个注册账号即管理员,初始化后立即关闭注册。正式使用需可信域名、HTTPS、备份及上游安全配置。' ;; *) printf '未知应用编号: %s\n' "$id" >&2; return 2 ;; esac if ! command -v docker >/dev/null 2>&1; then printf '未找到 Docker;请先自行安装并启动 Docker。\n' >&2; return 1 fi # Do not infer locality from daemon info: bind paths are resolved by the daemon. if [[ -v DOCKER_HOST || -v DOCKER_CONTEXT || -v DOCKER_TLS_VERIFY || -v DOCKER_CERT_PATH || -v DOCKER_API_VERSION ]]; then printf 'Docker 连接环境变量会造成目标歧义;请清除 DOCKER_HOST/DOCKER_CONTEXT/TLS/API 覆盖后重试。\n' >&2; return 1 fi if ! context=$(docker context show) || [[ $context != default ]] || ! endpoint=$(docker context inspect default --format '{{.Endpoints.docker.Host}}') || [[ $endpoint != unix:///var/run/docker.sock ]]; then printf '仅支持 default context 的 unix:///var/run/docker.sock 本机 daemon;远程或未知连接已拒绝。确认该 socket 并非远程代理。\n' >&2; return 1 fi if ! docker info >/dev/null 2>&1; then printf 'Docker 不可用或无权限访问 daemon;未作任何部署。\n' >&2; return 1 fi if ! version=$(docker version --format '{{.Server.Version}}') || [[ ! $version =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)(\+[a-zA-Z0-9.+_-]+)?$ ]]; then printf '无法确定稳定版 Docker Engine 版本;中止。\n' >&2; return 1 fi major=${BASH_REMATCH[1]} if (( ${#major} > 3 )) || (( 10#$major < 28 )); then printf '需要 Docker Engine 28 或更高版本,以避免旧版 loopback 端口向同网段泄露。\n' >&2; return 1 fi if ! names=$(docker container ls -a --format '{{.Names}}'); then printf '无法检查容器名称;中止。\n' >&2; return 1 fi if [[ $'\n'"$names"$'\n' == *$'\n'"terry-$name"$'\n'* ]]; then printf '容器 terry-%s 已存在;不会覆盖或删除。\n' "$name" >&2; return 1 fi root=${HOME:?HOME 必须设置}/.local/share/terry-toolbox/apps data=$root/$name if [[ $data == *','* || $data == *$'\n'* || $data != /* ]]; then printf 'HOME 必须是绝对路径且不能包含逗号或换行,避免 Docker 挂载参数歧义。\n' >&2; return 1 fi if ! canonical=$(realpath -m -- "$data") || [[ $canonical != "$data" ]]; then printf '数据路径包含符号链接或非规范路径;拒绝挂载。\n' >&2; return 1 fi if [[ -n $data_target && ( -e $data || -L $data ) ]]; then if [[ ! -d $data || -L $data || ! -O $data ]]; then printf '已有数据路径不是当前用户所有的真实目录;拒绝复用。\n' >&2; return 1 fi reuse=1 printf '已有数据: %s。可能是失败部署或用户数据;复用会允许容器修改、迁移或损坏其中数据。请先检查并备份;本工具不会删除任何内容。\n输入 REUSE 明确确认复用,其他输入取消: ' "$data" >&2 if ! IFS= read -r answer || [[ $answer != REUSE ]]; then printf '未确认复用,已取消。\n' >&2; return 1 fi fi if [[ $extra_ports == npm && ( -e $data/letsencrypt || -L $data/letsencrypt ) ]]; then if [[ ! -d $data/letsencrypt || -L $data/letsencrypt || ! -O $data/letsencrypt ]]; then printf '证书路径不安全;拒绝使用。\n' >&2; return 1 fi fi if [[ $id == 5 ]]; then for subdir in cert acme; do if [[ -e $data/$subdir || -L $data/$subdir ]]; then if [[ ! -d $data/$subdir || -L $data/$subdir || ! -O $data/$subdir ]]; then printf 'X-UI 证书路径不安全;拒绝使用。\n' >&2; return 1 fi fi done fi printf 'B%s %s\n镜像: %s\n' "$id" "$name" "$image" if [[ $image == *@sha256:* ]]; then printf '镜像已固定 digest;仍需审查来源、安全更新与兼容性,应用未经过真实部署验证。\n' else printf '警告:镜像标签可变,尚未能校验并固定 digest;部署前应自行审查。\n' >&2 fi if [[ -n $data_target ]]; then if (( reuse )); then printf '将复用持久数据目录: %s\n' "$data"; else printf '新建持久数据目录: %s\n' "$data"; fi fi printf '安全边界: 仅接受默认 Unix socket;请确认它不是转发到远程 daemon 的代理。独立 bridge 不阻止宿主机用户、特权容器、被连接到该网络的容器或自定义直接路由访问;127.0.0.1 仅限制宿主机端口发布。\n' [[ -z $note ]] || printf '注意: %s\n' "$note" printf '本机端口 [%s]: ' "$default_port" if ! IFS= read -r port; then printf '\n输入结束,已取消。\n' >&2; return 1; fi port=${port:-$default_port} if [[ ! $port =~ ^[1-9][0-9]{0,4}$ ]] || (( 10#$port > 65535 )); then printf '无效端口,须为 1–65535 的十进制数。\n' >&2; return 1 fi if [[ $extra_ports == npm && ( $port == 80 || $port == 443 ) ]]; then printf '管理端口不能与代理端口 80 或 443 相同。\n' >&2; return 1 fi printf '将仅绑定 127.0.0.1:%s -> %s' "$port" "$target_port" [[ $extra_ports != npm ]] || printf ',以及 127.0.0.1:80、443' printf '。确认创建容器? [y/N] ' if ! IFS= read -r answer || [[ $answer != y && $answer != Y ]]; then printf '已取消。\n' >&2; return 1 fi case "$environment" in nexterm) printf '输入自行生成并妥善备份的 64 位十六进制加密密钥(输入不回显): ' >&2 if ! IFS= read -r -s secret; then printf '\n输入结束。\n' >&2; return 1; fi printf '\n' >&2 if [[ ! $secret =~ ^[0-9a-fA-F]{64}$ ]]; then printf '无效密钥。\n' >&2; return 1; fi export ENCRYPTION_KEY=$secret; unset secret args+=(--env ENCRYPTION_KEY) ;; libretv) printf '设置 LibreTV 访问密码(至少 12 字符,输入不回显): ' >&2 if ! IFS= read -r -s secret; then printf '\n输入结束。\n' >&2; return 1; fi printf '\n' >&2 if (( ${#secret} < 12 )); then printf '密码过短。\n' >&2; return 1; fi export PASSWORD=$secret; unset secret args+=(--env PASSWORD) ;; mfa-start) # 覆盖 release 配置内置的 admin/admin1234;值只经环境变量进入 Docker。 printf '设置 mfa-start 登录密码(至少 12 字符,输入不回显): ' >&2 if ! IFS= read -r -s secret; then printf '\n输入结束。\n' >&2; return 1; fi printf '\n' >&2 if (( ${#secret} < 12 )); then printf '密码过短。\n' >&2; return 1; fi export SPRING_SECURITY_USER_PASSWORD=$secret; unset secret args+=(--env SPRING_SECURITY_USER_PASSWORD) ;; esac if [[ $environment == xui ]]; then args+=(--env 'XUI_ENABLE_FAIL2BAN=false' --env 'XRAY_VMESS_AEAD_FORCED=false') fi if [[ $id == 28 ]]; then args+=(--env 'SPRING_SECURITY_USER_NAME=mfa-start-user'); fi # Docker itself arbitrates ports and container names atomically. Never remove # anything after failure: a newly created directory may contain valuable data. if [[ -n $data_target ]]; then umask 077 if ! mkdir -p -- "$root" || { (( ! reuse )) && ! mkdir -- "$data"; }; then printf '无法安全创建数据目录: %s\n' "$data" >&2; return 1 fi if [[ -L $data || ! -d $data || ! -O $data ]] || ! canonical=$(realpath -m -- "$data") || [[ $canonical != "$data" ]]; then printf '数据目录不安全;中止。\n' >&2; return 1 fi args+=(--mount "type=bind,src=$data,dst=$data_target") if [[ $extra_ports == npm ]]; then if [[ ! -e $data/letsencrypt && ! -L $data/letsencrypt ]]; then if ! mkdir -- "$data/letsencrypt"; then printf '无法创建证书目录。\n' >&2; return 1; fi fi if [[ ! -d $data/letsencrypt || -L $data/letsencrypt || ! -O $data/letsencrypt ]]; then printf '证书目录不安全。\n' >&2; return 1 fi args+=(--mount "type=bind,src=$data/letsencrypt,dst=/etc/letsencrypt") fi if [[ $id == 5 ]]; then for subdir in cert acme; do if [[ ! -e $data/$subdir && ! -L $data/$subdir ]]; then if ! mkdir -- "$data/$subdir"; then printf '无法创建 X-UI 证书目录。\n' >&2; return 1; fi fi if [[ ! -d $data/$subdir || -L $data/$subdir || ! -O $data/$subdir ]] || ! canonical=$(realpath -m -- "$data/$subdir") || [[ $canonical != "$data/$subdir" ]]; then printf 'X-UI 证书目录不安全。\n' >&2; return 1 fi done args+=(--mount "type=bind,src=$data/cert,dst=/root/cert" --mount "type=bind,src=$data/acme,dst=/root/.acme.sh") fi fi if [[ $extra_ports == npm ]]; then args+=(-p 127.0.0.1:80:80 -p 127.0.0.1:443:443) fi if [[ $id == 24 ]]; then args+=(--env 'BOOT_OPTIONS=--server.port=8080'); fi # Unique, fresh bridge each attempt: never reuse an existing, possibly shared network. # Network creation can leave an orphan on failure; do not delete it automatically. if ! network_id=$(docker network create --driver bridge "terry-$name-$(date +%s)-$BASHPID-$RANDOM"); then printf '无法创建独立网络;数据保留。检查 Docker 网络及数据后再重试。\n' >&2; return 1 fi if [[ ! $network_id =~ ^[a-fA-F0-9]{12,64}$ ]]; then printf 'Docker 返回的网络 ID 无效;不运行容器,网络和数据均保留以供检查。\n' >&2; return 1 fi if ! docker run -d --name "terry-$name" --network "$network_id" --restart unless-stopped \ -p "127.0.0.1:$port:$target_port" "${args[@]}" "$image"; then printf '容器创建失败(可能是端口冲突或镜像拉取失败);数据和网络均保留。检查是否存在同名容器,检查并备份数据后可输入 REUSE 重试;不会自动删除任何资源。\n' >&2; return 1 fi printf 'Docker 已接受创建请求。地址: http://127.0.0.1:%s;应用就绪状态尚未验证。\n' "$port" printf '远程访问请使用 SSH 隧道;如需公网访问,先完成认证及 HTTPS 安全配置。\n' ) # END bundled module: apps # BEGIN bundled module: network #!/usr/bin/env bash # Network operations for the toolbox. Sourcing this file only defines functions. # Network installers refuse unsafe or unsupported environments. # # A1/A2 只走「Alpine + OpenRC + 固定官方发布产物 + SHA-256 白名单」这一条路径: # 不执行远程脚本、不改 apk 仓库、不碰防火墙;写盘前拒绝不确定状态,写盘后失败 # 一律回滚。端口归属只认本机事实(pidfile、/proc//exe、内核 comm)。 # A5 是同一套 Xray 产物的 Debian + systemd 路径:回落目标必须显式给出并当场验证 # TLS 1.3/h2/证书覆盖,回退流量双向限速,写盘按清单回滚,安装后跑双向认证探针。 # B23 只碰 systemd-resolved,且要求 /etc/resolv.conf 确由它以 stub 接管, # 其他解析器一律 fail closed;验证失败回滚并列出泄漏面。 # Print alpine/debian/ubuntu for supported os-release data; never source it as # shell. An optional path permits isolated tests and offline inspection. toolbox_network_detect_os() { if (( $# > 1 )); then printf '用法: toolbox_network_detect_os [os-release 文件]\n' >&2 return 2 fi local file=${1:-/etc/os-release} line key value id='' id_like='' word [[ -r $file && -f $file ]] || { printf '无法读取系统信息: %s\n' "$file" >&2 return 1 } while IFS= read -r line || [[ -n $line ]]; do [[ $line =~ ^[[:space:]]*(ID|ID_LIKE)=(.*)$ ]] || continue key=${BASH_REMATCH[1]} value=${BASH_REMATCH[2]} if [[ $value == \"*\" || $value == \'*\' ]]; then value=${value:1:${#value}-2} fi # Restrict values to simple identifiers and spaces; no expansions, # command substitution, escaped characters, or shell interpretation. [[ $value =~ ^[a-zA-Z0-9_.\ -]+$ ]] || continue if [[ $key == ID ]]; then id=${value,,}; else id_like=${value,,}; fi done < "$file" case $id in alpine|debian|ubuntu) printf '%s\n' "$id"; return 0 ;; esac # Avoid word splitting/globbing against the working directory. for word in alpine debian ubuntu; do if [[ " $id_like " == *" $word "* ]]; then printf '%s\n' "$word" return 0 fi done printf '不支持或无法识别的 Linux 发行版\n' >&2 return 1 } # A4 的安装逻辑在 modules/hy2.sh(独立模块,菜单直接调用)。这里转发,让 # `toolbox_network A4 …` 与菜单等价;模块没加载时仍 fail closed,不假装可用。 toolbox_network_hy2() { if declare -F toolbox_hy2 >/dev/null; then toolbox_hy2 "$@" return fi printf 'A4 暂时不可用:modules/hy2.sh 未加载(缺模块);未修改系统。\n' >&2 return 1 } # A5 已启用:回退限速、目标 TLS 1.3/h2 能力校验、flock 串行化、清单化回滚、 # 本机 xray 客户端双向认证探针都在安装路径内。别名让直接调用也可用(分发在 # toolbox_network 里,避免自递归)。 toolbox_network_xray() { toolbox_network A5 "$@" ; } # ================================================================ 公共设施 # # B12/A3/B31 共用约定: # - sourcing 只定义函数;所有外部命令都在调用时执行。 # - 只认本机事实(ID=debian、systemd、ss 监听表、MainPID),不猜「看起来像 # 我们的服务」。写盘前拒绝一切不确定状态,写盘后失败一律回滚。 # - 安装要两次确认(YES + 关键词),卸载一次。 # - 只删自己写入的绝对路径;同名但内容不符的文件视为他人所有,不覆盖。 net_err() { printf '%s\n' "$*" >&2; } net_die() { net_err "$*"; return 1; } # 常量声明成「已定义就不覆盖」:同一 shell 里重复 source 时,函数会被重新定义 # (调用方靠这个刷新实现),而 readonly 变量二次赋值会报错并中止加载。 net_const() { # name value [[ -n ${!1+set} ]] && return 0 printf -v "$1" '%s' "$2" readonly "$1" } net_require_root() { [[ ${TOOLBOX_NET_UID:-${EUID:-$(id -u)}} -eq 0 ]] \ || net_die '需要 root 权限运行。' } # 只接受 ID=debian:衍生版的内核头文件与 DKMS 行为不同,A4 的约定保持一致。 net_require_debian() { local file=${TOOLBOX_NET_OS_RELEASE:-/etc/os-release} id='' [[ -r $file ]] || net_die "无法读取 $file,不能确认发行版。" toolbox_network_detect_os "$file" >/dev/null 2>&1 \ || net_die "不支持的发行版($file 无法识别为 debian/ubuntu/alpine)。" id=$(sed -n 's/^ID=//p' "$file" 2>/dev/null | head -n1) id=${id//\"/}; id=${id//\'/} [[ ${id,,} == debian ]] \ || net_die "仅支持 Debian(当前 ID=${id:-未知});不支持 Alpine/Ubuntu/衍生版。" } net_require_systemd() { local run_dir=${TOOLBOX_NET_SYSTEMD_RUN:-/run/systemd/system} command -v systemctl >/dev/null 2>&1 || net_die '未找到 systemctl,本机不是 systemd 系统。' [[ -d $run_dir ]] || net_die 'systemd 未作为 init 运行(缺少 /run/systemd/system)。' } # 容器内 modprobe/sysctl 写的是宿主内核,且通常没有匹配的内核头文件。 net_require_not_container() { local out='' f if command -v systemd-detect-virt >/dev/null 2>&1; then out=$(systemd-detect-virt --container 2>/dev/null) || out='' if [[ -n $out && $out != none ]]; then net_die "检测到容器环境($out):内核模块需在宿主机安装。" return 1 fi return 0 fi for f in /.dockerenv /run/.containerenv; do if [[ -e $f ]]; then net_die '检测到容器环境:内核模块需在宿主机安装。' return 1 fi done } net_require_cmds() { local missing=() c for c in "$@"; do command -v "$c" >/dev/null 2>&1 || missing+=("$c") done (( ${#missing[@]} == 0 )) || net_die "缺少必需命令:${missing[*]}" } net_valid_port() { [[ $1 =~ ^[1-9][0-9]{0,4}$ ]] || return 1 (( $1 <= 65535 )) } net_valid_hostname() { [[ $1 =~ ^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$ ]] } # 目标主机:IPv4 或域名。IPv6 需要 socat 的方括号写法,暂不承诺。 net_valid_target_host() { local h=$1 [[ $h =~ ^[A-Za-z0-9]([A-Za-z0-9.-]{0,252})$ ]] || return 1 [[ $h != *..* && $h != .* && $h != *. ]] || return 1 } net_valid_uuid() { [[ $1 =~ ^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$ ]] } # 两次确认:先 YES,再输入关键词。任一步不符都在写盘前退出。 net_confirm_twice() { local word=$1 first second printf '确认执行?输入 YES 继续:' IFS= read -r first || return 1 [[ ${first^^} == YES ]] || { net_err '已取消,未修改系统。'; return 1; } printf '再次确认(输入 %s):' "$word" if ! IFS= read -r second; then net_err '没有收到二次确认,已取消;未修改系统。' return 1 fi [[ ${second^^} == "$word" ]] \ || { net_err "二次确认不匹配(需要 $word),已取消;未修改系统。"; return 1; } } net_confirm_once() { local answer printf '确认执行?输入 YES:' IFS= read -r answer || return 1 [[ ${answer^^} == YES ]] || { net_err '已取消。'; return 1; } } net_lock() { local file="${TOOLBOX_NET_LOCK_DIR:-/run}/terry-toolbox-$1.lock" exec 9>"$file" || { net_die "无法创建锁文件 $file"; return 1; } flock -n 9 || { net_die '另一个安装/卸载正在进行,稍后再试。'; return 1; } } # 子菜单:没有参数时(菜单入口)读一个编号。 net_submenu() { local title=$1 c printf '%s\n 1 安装\n 2 卸载\n 3 状态\n请输入编号:' "$title" >&2 IFS= read -r c || return 1 case $c in 1) printf 'install\n' ;; 2) printf 'uninstall\n' ;; 3) printf 'status\n' ;; *) net_err '无效选项。'; return 1 ;; esac } # ---------------------------------------------------------------- 监听事实 # 打印本机在该协议/端口上的监听行(来自 ss,不看「谁应该监听」)。 net_port_lines() { local proto=$1 port=$2 line field local flags=(-lunH) [[ $proto == tcp ]] && flags=(-ltnH) while IFS= read -r line; do [[ $line =~ ^(LISTEN|UNCONN|ESTAB) ]] || continue read -r -a _net_f <<< "$line" field=${_net_f[3]:-} [[ ${field##*:} == "$port" ]] || continue printf '%s\n' "$line" done < <(ss "${flags[@]}" 2>/dev/null) } net_port_busy() { [[ -n $(net_port_lines "$1" "$2") ]]; } # 端口是否由指定单元的进程持有:MainPID 相等,或 /proc//exe 就是我们的 # 可执行文件。两者都不成立就算「不是我们的」。 net_port_owned_by() { local proto=$1 port=$2 bin=$3 unit=${4:-} line pid exe want main='' want=$(readlink -f -- "$bin" 2>/dev/null) || want=$bin [[ -n $unit ]] && main=$(systemctl show -p MainPID --value "$unit" 2>/dev/null) [[ $main =~ ^[0-9]+$ ]] || main='' while IFS= read -r line; do while IFS= read -r pid; do [[ -n $pid ]] || continue [[ -n $main && $pid == "$main" ]] && return 0 exe=$(readlink -f "/proc/$pid/exe" 2>/dev/null) || exe='' [[ -n $exe && $exe == "$want" ]] && return 0 done < <(grep -o 'pid=[0-9]*' <<< "$line" | cut -d= -f2) done < <(ss -lunpH 2>/dev/null; ss -ltnpH 2>/dev/null) return 1 } # 单元归属:absent / ours / foreign。 net_unit_state() { local unit=$1 bin=$2 [[ -e $unit ]] || { printf 'absent\n'; return 0; } if grep -qF "ExecStart=$bin" "$unit" 2>/dev/null; then printf 'ours\n' else printf 'foreign\n' fi } net_wait_active() { local unit=$1 i for ((i = 0; i < 20; i++)); do systemctl is-active --quiet "$unit" 2>/dev/null && return 0 sleep 0.5 done return 1 } net_wait_owned() { local proto=$1 port=$2 bin=$3 unit=$4 i for ((i = 0; i < 20; i++)); do net_port_owned_by "$proto" "$port" "$bin" "$unit" && return 0 sleep 0.5 done return 1 } # ---------------------------------------------------------------- 证书校验 # 与 A4 同一套判据:可读、非空、PEM 可解析、公私钥配对、未过期未生效、 # 证书包含目标主机名。服务启动时的错误远少于这里的主动检查。 net_check_cert() { local cert=$1 key=$2 host=$3 now start start_epoch cpub kpub [[ -f $cert && -r $cert ]] || { net_die "证书不存在或不可读:$cert"; return 1; } [[ -f $key && -r $key ]] || { net_die "私钥不存在或不可读:$key"; return 1; } (( $(stat -c %s -- "$cert") > 0 )) || { net_die "证书文件为空:$cert"; return 1; } (( $(stat -c %s -- "$key") > 0 )) || { net_die "私钥文件为空:$key"; return 1; } openssl x509 -in "$cert" -noout 2>/dev/null || { net_die "不是有效的 PEM 证书:$cert"; return 1; } openssl pkey -in "$key" -noout 2>/dev/null || { net_die "不是有效的 PEM 私钥:$key"; return 1; } cpub=$(openssl x509 -in "$cert" -noout -pubkey 2>/dev/null) || { net_die '证书公钥解析失败'; return 1; } kpub=$(openssl pkey -in "$key" -pubout 2>/dev/null) || { net_die '私钥公钥解析失败'; return 1; } [[ $cpub == "$kpub" ]] || { net_die '证书与私钥不配对。'; return 1; } now=$(date +%s) openssl x509 -in "$cert" -noout -checkend 0 >/dev/null 2>&1 \ || { net_die '证书已过期(notAfter 已过)。'; return 1; } start=$(openssl x509 -in "$cert" -noout -startdate 2>/dev/null | cut -d= -f2) if [[ -n $start ]]; then start_epoch=$(date -d "$start" +%s 2>/dev/null) || start_epoch=0 (( start_epoch <= now )) || { net_die "证书尚未生效(notBefore=$start)。"; return 1; } fi if ! openssl x509 -in "$cert" -noout -checkend $((7 * 86400)) >/dev/null 2>&1; then net_err '注意:证书将在 7 天内过期,安装后请及时续期。' fi openssl x509 -in "$cert" -noout -checkhost "$host" 2>/dev/null | grep -q 'does match' \ || { net_die "证书不包含主机名 $host(SAN/CN 不匹配)。"; return 1; } } # 证书链能否被本机信任库验证,决定自签证书要不要在客户端链接里带 insecure。 # 判定只看本机 CA 文件;读不到信任库时按「不信任」处理(宁多带一个 insecure)。 # TOOLBOX_NET_CAFILE / TOOLBOX_NET_CAPATH 供隔离测试注入。 net_tls_trusted() { local cert=$1 cafile=${TOOLBOX_NET_CAFILE:-/etc/ssl/certs/ca-certificates.crt} local capath=${TOOLBOX_NET_CAPATH:-/etc/ssl/certs} if [[ -n $cafile && -r $cafile ]]; then openssl verify -CAfile "$cafile" "$cert" >/dev/null 2>&1 return fi [[ -n $capath && -d $capath ]] || return 1 openssl verify -CApath "$capath" "$cert" >/dev/null 2>&1 } # ================================================================ B12 TCP Brutal # # 固定上游 HyNetworks/tcp-brutal v2.0.1 的官方 DKMS 源码包(SHA-256 白名单), # 自己 dkms 编译、安装、载入,并写 modules-load.d 与 sysctl.d 持久化。 # 不用官方一键脚本,也不从 latest 解析版本。 # # v2 起 brutal 仍是内核拥塞控制算法(tcp_congestion_ops.name = "brutal"), # 全局默认值改成本算法会影响本机所有新 TCP 连接,因此安装必须两次确认。 net_const BRUTAL_VERSION '2.0.1' net_const BRUTAL_DKMS_NAME 'tcp-brutal' net_const BRUTAL_MODULE 'brutal' net_const BRUTAL_SHA256 '64a2778f308f7684cb5a137d578bc071e373960a3b9b79f9de015b84b29a7299' net_const BRUTAL_MIN_MAJOR 5 net_const BRUTAL_MIN_MINOR 10 net_brutal_etc() { printf '%s/terry-toolbox-brutal\n' "${TOOLBOX_NET_ETC:-/etc}"; } net_brutal_modload() { printf '%s/%s.conf\n' "${TOOLBOX_NET_MODLOAD_DIR:-/etc/modules-load.d}" "$BRUTAL_MODULE"; } net_brutal_sysctl() { printf '%s/terry-toolbox-brutal.conf\n' "${TOOLBOX_NET_SYSCTL_DIR:-/etc/sysctl.d}"; } net_brutal_bin() { printf '%s/brutalctl\n' "${TOOLBOX_NET_BIN_DIR:-/usr/local/bin}"; } net_brutal_dkmsdir() { printf '%s/%s\n' "${TOOLBOX_NET_DKMS_DIR:-/var/lib/dkms}" "$BRUTAL_DKMS_NAME"; } net_brutal_srcdir() { printf '%s/%s-%s\n' "${TOOLBOX_NET_SRC_DIR:-/usr/src}" "$BRUTAL_DKMS_NAME" "$BRUTAL_VERSION"; } net_brutal_sysctl_body() { printf 'net.ipv4.tcp_congestion_control = %s\n' "$BRUTAL_MODULE" } net_kernel_release() { printf '%s\n' "${TOOLBOX_NET_KERNEL_RELEASE:-$(uname -r)}"; } net_brutal_require_kernel() { local rel major minor rel=$(net_kernel_release) [[ $rel =~ ^([0-9]+)\.([0-9]+) ]] || { net_die "无法解析内核版本:$rel"; return 1; } major=${BASH_REMATCH[1]}; minor=${BASH_REMATCH[2]} if (( major < BRUTAL_MIN_MAJOR || (major == BRUTAL_MIN_MAJOR && minor < BRUTAL_MIN_MINOR) )); then net_die "内核 $rel 过旧:TCP Brutal v2 需要 5.10 以上。" return 1 fi local build="${TOOLBOX_NET_MODDIR:-/lib/modules/$rel/build}" [[ -d $build ]] || net_die "缺少内核头文件($build 不存在),无法编译模块。" } net_brutal_require_toolchain() { net_require_cmds dkms make cc sha256sum curl flock mktemp modprobe sysctl lsmod tar depmod sed || return 1 net_require_cmds ip || true } net_current_cc() { sysctl -n net.ipv4.tcp_congestion_control 2>/dev/null | tr -d '[:space:]' } net_available_cc() { sysctl -n net.ipv4.tcp_available_congestion_control 2>/dev/null } net_cc_supported() { local cc=$1 word for word in $(net_available_cc); do [[ $word == "$cc" ]] && return 0 done return 1 } net_brutal_module_loaded() { lsmod 2>/dev/null | awk '{print $1}' | grep -qx "$BRUTAL_MODULE" } # 写盘前的冲突判定。任何一项为真都停下来,宁可让用户先卸载再装。 net_brutal_conflicts() { local etc modload sysctl_file dkms_dir bin rc=0 want etc=$(net_brutal_etc); modload=$(net_brutal_modload) sysctl_file=$(net_brutal_sysctl); dkms_dir=$(net_brutal_dkmsdir); bin=$(net_brutal_bin) want=$(net_brutal_sysctl_body) if [[ -d $etc ]]; then net_err "已存在 $etc:看起来是上次留下的本模块安装,请先卸载(B12 → 2)。" rc=1 fi if [[ -e $modload ]]; then if [[ "$(tr -d '[:space:]' < "$modload" 2>/dev/null)" == "$BRUTAL_MODULE" ]]; then net_err "已存在 $modload(本模块的残留),请先卸载。" else net_err "已存在 $modload 且内容不同(他人安装),不覆盖;请人工确认。" fi rc=1 fi if [[ -e $sysctl_file ]]; then if [[ "$(cat "$sysctl_file" 2>/dev/null)" == "$want" ]]; then net_err "已存在 $sysctl_file(本模块的残留),请先卸载。" else net_err "已存在 $sysctl_file 且内容不同(他人安装),不覆盖;请人工确认。" fi rc=1 fi if [[ -d $dkms_dir ]]; then net_err "DKMS 中已有 $BRUTAL_DKMS_NAME($dkms_dir):请先 dkms remove 或人工确认归属。" rc=1 fi if [[ -e $bin ]]; then net_err "已存在 $bin:不是本模块安装的,不覆盖。" rc=1 fi if (( rc == 0 )) && net_brutal_module_loaded; then # 卸载后模块可能因仍有连接而被内核留到重启;文件已清干净时重装无害, # modprobe 对已载入模块是空操作,所以这里只提示不阻断。 net_err "注意:内核里已载入 $BRUTAL_MODULE 模块但没有任何本模块文件(可能是上次卸载的残留)。" net_err '将继续安装;若该模块其实是别人装的,请先人工确认。' fi return $rc } net_brutal_fetch() { local tmp=$1 tarball="$1/tcp-brutal.dkms.tar.gz" url actual url="https://github.com/HyNetworks/tcp-brutal/releases/download/v${BRUTAL_VERSION}/tcp-brutal.dkms.tar.gz" url=${TOOLBOX_NET_BRUTAL_URL:-$url} if [[ -n ${TOOLBOX_NET_BRUTAL_TGZ:-} ]]; then cp -- "$TOOLBOX_NET_BRUTAL_TGZ" "$tarball" || { net_die '复制本地源码包失败'; return 1; } else net_err "下载官方 DKMS 源码包:$url" curl -fsSL --proto '=https' --tlsv1.2 --connect-timeout 15 --max-time 300 \ -o "$tarball" "$url" || { net_die '下载失败(网络或 GitHub 不可达)。'; return 1; } fi actual=$(sha256sum -- "$tarball" | cut -d' ' -f1) [[ $actual == "${TOOLBOX_NET_BRUTAL_SHA256:-$BRUTAL_SHA256}" ]] \ || { net_die "SHA-256 不匹配:期望 ${TOOLBOX_NET_BRUTAL_SHA256:-$BRUTAL_SHA256},实际 $actual。"; return 1; } tar -C "$tmp" -xzf "$tarball" || { net_die '解压失败。'; return 1; } [[ -f $tmp/dkms_source_tree/dkms.conf && -f $tmp/dkms_source_tree/Makefile ]] \ || { net_die '源码包结构不符(缺 dkms.conf 或 Makefile)。'; return 1; } grep -qx "PACKAGE_NAME=\"$BRUTAL_DKMS_NAME\"" "$tmp/dkms_source_tree/dkms.conf" \ || { net_die 'dkms.conf 的包名与预期不符。'; return 1; } grep -qx "PACKAGE_VERSION=\"$BRUTAL_VERSION\"" "$tmp/dkms_source_tree/dkms.conf" \ || { net_die 'dkms.conf 的版本与预期不符。'; return 1; } } net_brutal_rollback() { local prev=${1:-} etc modload sysctl_file src dkms_dir bin etc=$(net_brutal_etc); modload=$(net_brutal_modload); sysctl_file=$(net_brutal_sysctl) src=$(net_brutal_srcdir); dkms_dir=$(net_brutal_dkmsdir); bin=$(net_brutal_bin) net_err '--- 开始回滚 ---' rmmod "$BRUTAL_MODULE" >/dev/null 2>&1 || true dkms remove "$BRUTAL_DKMS_NAME/$BRUTAL_VERSION" --all >/dev/null 2>&1 || true rm -rf -- "$src" "$dkms_dir" rm -f -- "$bin" "$modload" "$sysctl_file" if [[ -f $etc/meta ]]; then rm -rf -- "$etc"; else [[ -d $etc ]] && rm -rf -- "$etc"; fi if [[ -n $prev ]] && net_cc_supported "$prev"; then sysctl -w "net.ipv4.tcp_congestion_control=$prev" >/dev/null 2>&1 || true fi depmod -a "$(net_kernel_release)" >/dev/null 2>&1 || true net_err '已撤销本次写入(模块、DKMS 记录、drop-in、brutalctl、sysctl 默认值)。' } _net_brutal_install() { local tmp prev rel bin etc net_require_root || return 1 net_require_debian || return 1 net_require_systemd || return 1 net_require_not_container || return 1 net_brutal_require_kernel || return 1 net_brutal_require_toolchain || return 1 net_brutal_conflicts || return 1 rel=$(net_kernel_release) prev=$(net_current_cc) [[ -n $prev ]] || prev='cubic' printf '即将安装 TCP Brutal 内核模块(全局拥塞控制算法):\n' printf ' 版本 : v%s(官方 DKMS 源码包,安装前校验 SHA-256)\n' "$BRUTAL_VERSION" printf ' 内核 : %s(模块只为当前内核编译)\n' "$rel" printf ' 默认算法: %s → %s(对本机所有新 TCP 连接生效)\n' "$prev" "$BRUTAL_MODULE" printf ' 持久化 : %s、%s\n' "$(net_brutal_modload)" "$(net_brutal_sysctl)" printf ' 工具 : %s(brutalctl,v2 的按目标限速规则)\n' "$(net_brutal_bin)" printf ' 附带 : 会先确认 dkms/make/cc/内核头文件是否齐备\n' printf ' 回滚 : 任一步失败即 rmmod、dkms remove 并恢复 %s\n' "$prev" printf '注意:升级内核后需要重新 dkms 编译(dkms 会自动尝试)。\n' net_confirm_twice BRUTAL || return 1 net_lock brutal || return 1 tmp=$(mktemp -d) || { net_die '无法创建临时目录'; return 1; } net_err '--- 开始写入 ---' net_brutal_fetch "$tmp" || { rm -rf -- "$tmp"; net_brutal_rollback "$prev"; return 1; } etc=$(net_brutal_etc); bin=$(net_brutal_bin) # 先编译再落盘:编译失败时系统里还没有任何改动。 make -C "$tmp/dkms_source_tree/tools" >/dev/null 2>&1 \ || { rm -rf -- "$tmp"; net_brutal_rollback "$prev"; net_die 'brutalctl 编译失败。'; return 1; } [[ -x $tmp/dkms_source_tree/tools/brutalctl ]] \ || { rm -rf -- "$tmp"; net_brutal_rollback "$prev"; net_die 'brutalctl 未生成。'; return 1; } install -d -m 0755 "$(net_brutal_srcdir)" \ && cp -a "$tmp/dkms_source_tree/." "$(net_brutal_srcdir)/" \ || { rm -rf -- "$tmp"; net_brutal_rollback "$prev"; return 1; } dkms add "$(net_brutal_srcdir)" >/dev/null 2>&1 \ || { rm -rf -- "$tmp"; net_brutal_rollback "$prev"; net_die 'dkms add 失败。'; return 1; } dkms build "$BRUTAL_DKMS_NAME/$BRUTAL_VERSION" -k "$rel" >/dev/null 2>&1 \ || { rm -rf -- "$tmp"; net_brutal_rollback "$prev"; net_die '模块编译失败(检查内核头文件与 dkms 日志)。'; return 1; } dkms install "$BRUTAL_DKMS_NAME/$BRUTAL_VERSION" -k "$rel" --force >/dev/null 2>&1 \ || { rm -rf -- "$tmp"; net_brutal_rollback "$prev"; net_die 'dkms install 失败。'; return 1; } install -d -m 0755 "$etc" && install -d -m 0755 "$(dirname -- "$bin")" \ && install -m 0755 "$tmp/dkms_source_tree/tools/brutalctl" "$bin" \ || { rm -rf -- "$tmp"; net_brutal_rollback "$prev"; return 1; } install -d -m 0755 "$(dirname -- "$(net_brutal_modload)")" \ && printf '%s\n' "$BRUTAL_MODULE" > "$(net_brutal_modload)" \ || { rm -rf -- "$tmp"; net_brutal_rollback "$prev"; return 1; } install -d -m 0755 "$(dirname -- "$(net_brutal_sysctl)")" \ && net_brutal_sysctl_body > "$(net_brutal_sysctl)" && chmod 0644 "$(net_brutal_sysctl)" \ || { rm -rf -- "$tmp"; net_brutal_rollback "$prev"; return 1; } if ! modprobe "$BRUTAL_MODULE"; then rm -rf -- "$tmp"; net_brutal_rollback "$prev"; net_die 'modprobe 失败。'; return 1 fi if ! net_brutal_module_loaded; then rm -rf -- "$tmp"; net_brutal_rollback "$prev"; net_die '模块未出现在内核中。'; return 1 fi if ! net_cc_supported "$BRUTAL_MODULE"; then rm -rf -- "$tmp"; net_brutal_rollback "$prev" net_die '内核未注册 brutal 拥塞控制算法(tcp_available_congestion_control 无此项)。' return 1 fi if ! sysctl -w "net.ipv4.tcp_congestion_control=$BRUTAL_MODULE" >/dev/null 2>&1; then rm -rf -- "$tmp"; net_brutal_rollback "$prev"; net_die '写 sysctl 默认拥塞控制失败。'; return 1 fi # 回读:sysctl 真的生效,不是只写了文件。 if [[ $(net_current_cc) != "$BRUTAL_MODULE" ]]; then rm -rf -- "$tmp"; net_brutal_rollback "$prev" net_die "回读失败:当前默认算法仍是 $(net_current_cc)。" return 1 fi printf '%s|%s|%s\n' "$prev" "$BRUTAL_VERSION" "$rel" > "$etc/meta" chmod 0600 "$etc/meta" rm -rf -- "$tmp" printf '\n安装完成。\n' printf '[已验证] 模块已载入,tcp_available_congestion_control 含 %s,默认算法为 %s\n' \ "$BRUTAL_MODULE" "$(net_current_cc)" printf '[已验证] 开机自动载入:%s\n' "$(net_brutal_modload)" printf '\n仍需人工处理:\n' printf ' - 对具体目标限速(v2 用规则,不改全局):%s add 203.0.113.5/32 100\n' "$(net_brutal_bin)" printf ' - 规则不随重启恢复,需要时自行写进开机脚本;brutalctl list 可查看\n' printf ' - 升级内核后确认 dkms 已为新内核编译;卸载:B12 → 2\n' } _net_brutal_uninstall() { local etc modload sysctl_file src dkms_dir bin prev='' version='' rel='' net_require_root || return 1 net_require_systemd || return 1 etc=$(net_brutal_etc); modload=$(net_brutal_modload); sysctl_file=$(net_brutal_sysctl) src=$(net_brutal_srcdir); dkms_dir=$(net_brutal_dkmsdir); bin=$(net_brutal_bin) if [[ ! -d $etc && ! -e $modload && ! -e $bin && ! -d $dkms_dir ]]; then net_die '没有找到本模块的安装。' return 1 fi if [[ ! -d $etc ]]; then net_die "缺少 $etc:不像是本模块的安装,未删除;请人工确认。" return 1 fi if [[ -f $etc/meta ]]; then IFS='|' read -r prev version rel < "$etc/meta" || true fi [[ -n $prev ]] || prev='cubic' printf '将停止并删除:\n %s\n %s\n %s\n %s\n %s\n %s\n' \ "$modload" "$sysctl_file" "$bin" "$dkms_dir" "$src" "$etc" printf '并把默认拥塞控制恢复为 %s。\n' "$prev" printf '注意:会先 brutalctl flush,清掉本工具加的规则和路由(含手工 add 的)。\n' net_confirm_once || return 1 net_lock brutal || return 1 # 规则和 brutalctl 路由不在文件里,先清掉再卸模块,避免留下孤儿路由。 if [[ -x $bin ]]; then "$bin" flush >/dev/null 2>&1 || net_err '提示:brutalctl flush 未成功,请检查是否有残留路由(ip route show proto 233)。' fi if net_cc_supported "$prev" && [[ $(net_current_cc) != "$prev" ]]; then sysctl -w "net.ipv4.tcp_congestion_control=$prev" >/dev/null 2>&1 \ || net_err "警告:恢复 $prev 失败,请手工 sysctl。" fi local i for ((i = 0; i < 5; i++)); do rmmod "$BRUTAL_MODULE" 2>/dev/null && break sleep 1 done if net_brutal_module_loaded; then net_err '注意:rmmod 失败(仍有连接在用),模块会保留到重启;开机载入已移除。' fi rm -f -- "$modload" "$sysctl_file" "$bin" dkms remove "$BRUTAL_DKMS_NAME/$BRUTAL_VERSION" --all >/dev/null 2>&1 || true [[ -n $version ]] && dkms remove "$BRUTAL_DKMS_NAME/$version" --all >/dev/null 2>&1 || true rm -rf -- "$src" "$dkms_dir" "$etc" depmod -a "$(net_kernel_release)" >/dev/null 2>&1 || true printf '已卸载。\n' } net_brutal_status() { local etc modload sysctl_file bin prev='' version='' rel='' etc=$(net_brutal_etc); modload=$(net_brutal_modload); sysctl_file=$(net_brutal_sysctl) bin=$(net_brutal_bin) if [[ ! -d $etc && ! -e $modload && ! -e $bin ]]; then printf '未安装。\n' if net_brutal_module_loaded; then printf '注意:brutal 模块仍在内存里(通常是卸载后仍有连接在用),重启后消失;默认算法 %s。\n' "$(net_current_cc)" fi return 1 fi if [[ -f $etc/meta ]]; then IFS='|' read -r prev version rel < "$etc/meta" || true fi printf '版本 : %s(当初安装的内核 %s,当前内核 %s)\n' \ "${version:-未知}" "${rel:-未知}" "$(net_kernel_release)" printf '模块 : %s\n' "$(net_brutal_module_loaded && printf '已载入' || printf '未载入')" printf '默认算法: %s(安装前为 %s)\n' "$(net_current_cc)" "${prev:-未知}" printf '可用算法: %s\n' "$(net_available_cc)" printf '开机载入: %s\n' "$( [[ -e $modload ]] && printf '已配置 %s' "$modload" || printf '无')" printf 'brutalctl: %s\n' "$( [[ -x $bin ]] && printf '%s' "$bin" || printf '缺失')" } # ================================================================ A3 TUIC # # 固定上游 tuic-protocol/tuic 的 tuic-server 1.0.0 官方静态二进制(musl, # 自带运行时),SHA-256 取自同版本官方 .sha256sum 并写死。 # 上游自 2023-06 起无新版本,1.0.0 是目前唯一可用的官方服务端。 # # 认证靠 UUID + 密码,配置里没有主机名字段;主机名只用于证书 SAN 校验和 # 客户端链接。本模块没有 TUIC 客户端可当探针,因此「服务起来、UDP 端口被本 # 服务持有」是全部自动验证,认证是否通过需要客户端实测(报告里写明)。 net_const TUIC_VERSION '1.0.0' net_const TUIC_BIN_NAME 'terry-toolbox-tuic' net_const TUIC_UNIT_NAME 'terry-toolbox-tuic.service' net_const TUIC_DEFAULT_PORT 443 net_tuic_etc() { printf '%s/terry-toolbox-tuic\n' "${TOOLBOX_NET_ETC:-/etc}"; } net_tuic_bin() { printf '%s/%s\n' "${TOOLBOX_NET_BIN_DIR:-/usr/local/bin}" "$TUIC_BIN_NAME"; } net_tuic_unit() { printf '%s/%s\n' "${TOOLBOX_NET_UNIT_DIR:-/etc/systemd/system}" "$TUIC_UNIT_NAME"; } net_tuic_arch() { case $(uname -m) in x86_64|amd64) printf 'x86_64\n' ;; aarch64|arm64) printf 'aarch64\n' ;; *) net_die "不支持的 CPU 架构:$(uname -m)" ;; esac } net_tuic_sha256() { case $1 in x86_64) printf '%s\n' "${TOOLBOX_NET_TUIC_SHA256:-adb808944ca829857bf408d2ee438a156e8171543dbbed3e94964b66b4135393}" ;; aarch64) printf '%s\n' "${TOOLBOX_NET_TUIC_SHA256_ARM64:-6ac43186b5197afaf0bc707c1241f2b3931d61356fc8354807884b68861a4892}" ;; *) return 1 ;; esac } net_tuic_fetch() { local arch=$1 dest=$2 expected url actual expected=$(net_tuic_sha256 "$arch") || { net_die "没有 $arch 的固定校验值。"; return 1; } url="https://github.com/tuic-protocol/tuic/releases/download/tuic-server-${TUIC_VERSION}/tuic-server-${TUIC_VERSION}-${arch}-unknown-linux-musl" url=${TOOLBOX_NET_TUIC_URL:-$url} if [[ -n ${TOOLBOX_NET_TUIC_BIN_SRC:-} ]]; then cp -- "$TOOLBOX_NET_TUIC_BIN_SRC" "$dest" || { net_die '复制本地二进制失败'; return 1; } else net_err "下载官方二进制:$url" curl -fsSL --proto '=https' --tlsv1.2 --connect-timeout 15 --max-time 300 \ -o "$dest" "$url" || { net_die '下载失败(网络或 GitHub 不可达)。'; return 1; } fi actual=$(sha256sum -- "$dest" | cut -d' ' -f1) [[ $actual == "$expected" ]] \ || { net_die "SHA-256 不匹配:期望 $expected,实际 $actual。"; return 1; } chmod 0755 -- "$dest" || { net_die '无法设置可执行权限。'; return 1; } } # 离线自检:起前台进程,等日志里的监听行,然后杀掉;顺带确认它真占了端口。 net_tuic_smoke() { local bin=$1 conf=$2 port=$3 out pid i rc=1 out=$(mktemp) || return 1 # 9>&-:冒烟子进程不留住调用方的 flock,否则紧接着的卸载会撞锁。 "$bin" -c "$conf" >"$out" 2>&1 9>&- & pid=$! for ((i = 0; i < 60; i++)); do grep -q "server started, listening on" "$out" 2>/dev/null && { rc=0; break; } kill -0 "$pid" 2>/dev/null || break sleep 0.2 done grep -q "server started, listening on" "$out" 2>/dev/null && rc=0 if (( rc == 0 )); then net_port_busy udp "$port" || { net_err "进程报告已监听,但 UDP $port 没有监听项。"; rc=1; } fi kill "$pid" 2>/dev/null wait "$pid" 2>/dev/null || true if (( rc == 0 )); then rm -f -- "$out"; return 0; fi net_err "冒烟启动失败(UDP $port),服务输出:" sed -n '1,10p' "$out" >&2 rm -f -- "$out" return 1 } net_tuic_rollback() { local unit bin etc unit=$(net_tuic_unit); bin=$(net_tuic_bin); etc=$(net_tuic_etc) net_err '--- 开始回滚 ---' if [[ -e $unit ]]; then systemctl disable --now "$TUIC_UNIT_NAME" >/dev/null 2>&1 || true rm -f -- "$unit" systemctl daemon-reload >/dev/null 2>&1 || true systemctl reset-failed "$TUIC_UNIT_NAME" >/dev/null 2>&1 || true fi rm -f -- "$bin" [[ -d $etc ]] && rm -rf -- "$etc" net_err '已恢复到安装前状态。' } _net_tuic_install() { local cert=${1:-} key=${2:-} host=${3:-} port=${4:-} answer local arch tmp etc bin unit pass uuid state net_require_root || return 1 net_require_debian || return 1 net_require_systemd || return 1 net_require_cmds openssl sha256sum curl flock ss stat sed mktemp || return 1 arch=$(net_tuic_arch) || return 1 [[ -n $cert ]] || { net_die '缺少证书路径。'; return 1; } [[ -n $key ]] || { net_die '缺少私钥路径。'; return 1; } [[ -n $host ]] || { net_die '缺少主机名(证书对应的域名)。'; return 1; } host=${host,,} net_valid_hostname "$host" || { net_die "主机名格式不正确:$host"; return 1; } if [[ -n $port ]]; then net_valid_port "$port" || { net_die "端口不正确:$port(1-65535,无前导零)"; return 1; } else port=$TUIC_DEFAULT_PORT fi net_check_cert "$cert" "$key" "$host" || return 1 etc=$(net_tuic_etc); bin=$(net_tuic_bin); unit=$(net_tuic_unit) state=$(net_unit_state "$unit" "$bin") case $state in foreign) net_die "$unit 已存在但内容不是本模块生成的,未覆盖;请人工确认归属。" return 1 ;; ours) if systemctl is-active --quiet "$TUIC_UNIT_NAME" 2>/dev/null; then net_die "$TUIC_UNIT_NAME 已在运行(我们的服务)。重装请先卸载。" else net_die "$TUIC_UNIT_NAME 已存在但未运行(我们的残留)。请先卸载。" fi return 1 ;; esac if [[ -d $etc || -e $bin ]]; then net_die "已存在 $(net_tuic_etc) 或 $bin(不是本模块写入的),未覆盖。" return 1 fi if net_port_busy udp "$port"; then net_err "UDP 端口 $port 已被占用:" net_port_lines udp "$port" | cut -c1-160 >&2 net_die '换端口,或先确认并停止占用方。' return 1 fi printf '即将安装 TUIC 服务端:\n' printf ' 版本 : v%s(官方 musl 静态二进制,安装前校验 SHA-256)\n' "$TUIC_VERSION" printf ' 主机名 : %s\n' "$host" printf ' 监听 : [::]:%s/udp(双栈)\n' "$port" printf ' 证书 : %s\n' "$cert" printf ' 私钥 : %s(复制到 %s,权限 600)\n' "$key" "$etc" printf ' 单元 : %s\n' "$unit" printf ' 认证 : 随机 UUID + 随机密码,只显示一次\n' printf ' 防火墙 : 不改动;需自行放行 UDP %s\n' "$port" net_confirm_twice TUIC || return 1 net_lock tuic || return 1 tmp=$(mktemp -d) || { net_die '无法创建临时目录'; return 1; } uuid=${TOOLBOX_NET_UUID:-$(cat /proc/sys/kernel/random/uuid 2>/dev/null)} net_valid_uuid "$uuid" || uuid=$(openssl rand -hex 16 | sed 's/^\(.\{8\}\)\(.\{4\}\)\(.\{4\}\)\(.\{4\}\)\(.\{12\}\)$/\1-\2-\3-\4-\5/') net_valid_uuid "$uuid" || { rm -rf -- "$tmp"; net_die 'UUID 生成失败'; return 1; } pass=$(openssl rand -base64 48 | tr -dc 'A-Za-z0-9' | cut -c1-32) [[ ${#pass} -eq 32 ]] || { rm -rf -- "$tmp"; net_die '密码生成失败'; return 1; } net_err '--- 开始写入 ---' net_tuic_fetch "$arch" "$tmp/tuic-server" || { rm -rf -- "$tmp"; net_tuic_rollback; return 1; } install -d -m 0755 "$etc" && install -d -m 0755 "$(dirname -- "$bin")" \ && install -m 0755 "$tmp/tuic-server" "$bin" \ || { rm -rf -- "$tmp"; net_tuic_rollback; return 1; } install -m 0600 "$cert" "$etc/cert.pem" && install -m 0600 "$key" "$etc/key.pem" \ || { rm -rf -- "$tmp"; net_tuic_rollback; return 1; } cat > "$etc/config.json" < "$unit" </dev/null 2>&1; then net_err 'systemctl enable --now 失败。' rm -rf -- "$tmp"; net_tuic_rollback; return 1 fi if ! net_wait_active "$TUIC_UNIT_NAME"; then net_err '服务未进入 active 状态。' systemctl --no-pager -l status "$TUIC_UNIT_NAME" >&2 2>&1 || true rm -rf -- "$tmp"; net_tuic_rollback; return 1 fi if ! net_wait_owned udp "$port" "$bin" "$TUIC_UNIT_NAME"; then net_err "单元显示运行中,但 UDP $port 的监听进程不是 $TUIC_BIN_NAME。" rm -rf -- "$tmp"; net_tuic_rollback; return 1 fi printf '%s|%s|%s\n' "$uuid" "$pass" "$port" > "$etc/meta" chmod 0600 "$etc/meta" rm -rf -- "$tmp" printf '\n安装完成,服务 %s 已设为开机自启。\n' "$TUIC_UNIT_NAME" printf '客户端连接信息(只显示这一次,也写入 %s/meta):\n' "$etc" printf ' tuic://%s:%s@%s:%s?sni=%s&congestion_control=bbr\n' "$uuid" "$pass" "$host" "$port" "$host" printf '\n验证情况:\n' printf ' [已验证] 单元 active、UDP %s 由 %s 监听、配置可被本二进制解析并绑定\n' "$port" "$TUIC_BIN_NAME" printf ' [未验证] 认证与数据面:本机没有 TUIC 客户端可当探针,需用客户端实际连一次\n' printf '\n仍需人工处理:\n' printf ' - 防火墙/安全组放行 UDP %s(本模块不改防火墙)\n' "$port" printf ' - 证书续期:覆盖 %s/cert.pem、key.pem 后重启服务\n' "$etc" printf ' - 卸载:A3 → 2\n' } _net_tuic_uninstall() { local unit etc bin state net_require_root || return 1 net_require_systemd || return 1 unit=$(net_tuic_unit); etc=$(net_tuic_etc); bin=$(net_tuic_bin) state=$(net_unit_state "$unit" "$bin") if [[ $state == absent && ! -e $bin && ! -d $etc ]]; then net_die '没有找到本模块的安装。' return 1 fi if [[ $state == foreign ]]; then net_die "$unit 不是本模块生成的,未删除;请人工确认。" return 1 fi printf '将停止并删除:\n %s\n %s\n %s\n' "$unit" "$bin" "$etc" net_confirm_once || return 1 net_lock tuic || return 1 net_tuic_rollback printf '已卸载。\n' } net_tuic_status() { local unit etc uuid pass port unit=$(net_tuic_unit); etc=$(net_tuic_etc) if [[ ! -e $unit ]]; then printf '未安装。\n' return 1 fi printf '单元: %s\n' "$unit" systemctl --no-pager -l status "$TUIC_UNIT_NAME" 2>&1 | sed -n '1,6p' if [[ -f $etc/meta ]]; then IFS='|' read -r uuid pass port < "$etc/meta" || true printf '端口 %s 监听: ' "${port:-?}" if [[ -n ${port:-} ]] && net_port_owned_by udp "$port" "$(net_tuic_bin)" "$TUIC_UNIT_NAME"; then printf '由本服务持有\n' else printf '不是本服务\n' fi printf '认证: UUID=%s 密码=%s\n' "${uuid:-?}" "${pass:-?}" else printf '缺少 meta(凭据未知)。\n' fi } # ================================================================ B31 Relay 转发 # # 用 socat + 每个转发一个独立 systemd 单元。选它而不是 nginx stream: # - 不依赖宿主机是否装有 nginx、也不 reload 别人的共享守护进程; # - 不写 nginx conf.d 里的公共目录,冲突面只有一个可以自己命名的单元; # - 参数就是一个 ExecStart,回滚 = 停掉并删掉它,没有残留配置。 # 代价是 socat 每连接 fork 一个进程、UDP 语义比 nginx stream 弱,报告里写明。 # # 单元名带协议和本地端口,因此可以并存多个转发、逐个卸载。 net_const RELAY_PREFIX 'terry-toolbox-relay' net_relay_unit_dir() { printf '%s\n' "${TOOLBOX_NET_UNIT_DIR:-/etc/systemd/system}"; } net_relay_unit_name() { printf '%s-%s-%s.service\n' "$RELAY_PREFIX" "$1" "$2"; } net_relay_unit() { printf '%s/%s\n' "$(net_relay_unit_dir)" "$(net_relay_unit_name "$1" "$2")"; } net_relay_socat() { local p=${TOOLBOX_NET_SOCAT:-} if [[ -n $p ]]; then printf '%s\n' "$p"; return 0; fi command -v socat 2>/dev/null } net_relay_valid_proto() { [[ $1 == tcp || $1 == udp ]]; } # 单元里用到的两个地址串;UDP 必须 reuseaddr 才不会在重启时撞端口。 net_relay_listen_arg() { if [[ $1 == tcp ]]; then printf 'TCP-LISTEN:%s,fork,reuseaddr\n' "$2" else printf 'UDP-LISTEN:%s,reuseaddr,fork\n' "$2" fi } net_relay_target_arg() { if [[ $1 == tcp ]]; then printf 'TCP:%s:%s\n' "$2" "$3" else printf 'UDP:%s:%s\n' "$2" "$3" fi } net_relay_exec_line() { local proto=$1 lport=$2 host=$3 tport=$4 socat socat=$(net_relay_socat) || return 1 [[ -n $socat ]] || return 1 printf '%s -d -d %s %s\n' "$socat" \ "$(net_relay_listen_arg "$proto" "$lport")" \ "$(net_relay_target_arg "$proto" "$host" "$tport")" } net_relay_write_unit() { local proto=$1 lport=$2 host=$3 tport=$4 unit exec unit=$(net_relay_unit "$proto" "$lport") exec=$(net_relay_exec_line "$proto" "$lport" "$host" "$tport") || return 1 cat > "$unit" < $host:$tport After=network-online.target Wants=network-online.target [Service] Type=simple ExecStart=$exec Restart=on-failure RestartSec=3 LimitNOFILE=65536 NoNewPrivileges=yes ProtectSystem=strict ProtectHome=yes PrivateTmp=yes ProtectKernelTunables=yes ProtectKernelModules=yes ProtectControlGroups=yes RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX [Install] WantedBy=multi-user.target EOF chmod 0644 "$unit" && [[ -s $unit ]] } net_relay_rollback() { local proto=$1 lport=$2 unit unit=$(net_relay_unit "$proto" "$lport") net_err '--- 开始回滚 ---' systemctl disable --now "$(net_relay_unit_name "$proto" "$lport")" >/dev/null 2>&1 || true rm -f -- "$unit" systemctl daemon-reload >/dev/null 2>&1 || true systemctl reset-failed "$(net_relay_unit_name "$proto" "$lport")" >/dev/null 2>&1 || true net_err '已删除本次写入的单元。' } _net_relay_install() { local proto=${1:-} lport=${2:-} host=${3:-} tport=${4:-} local unit name socat state net_require_root || return 1 net_require_debian || return 1 net_require_systemd || return 1 net_require_cmds flock ss sed mktemp || return 1 net_relay_valid_proto "$proto" || { net_die "协议只能是 tcp 或 udp:${proto:-空}"; return 1; } [[ -n $lport ]] || { net_die '缺少本地端口。'; return 1; } net_valid_port "$lport" || { net_die "本地端口不正确:$lport"; return 1; } [[ -n $host ]] || { net_die '缺少目标主机。'; return 1; } net_valid_target_host "$host" || { net_die "目标主机格式不正确:$host(仅 IPv4/域名)"; return 1; } [[ -n $tport ]] || { net_die '缺少目标端口。'; return 1; } net_valid_port "$tport" || { net_die "目标端口不正确:$tport"; return 1; } socat=$(net_relay_socat) || socat='' [[ -n $socat && -x $socat ]] || { net_die '未找到 socat,请先安装(apt-get install socat)。'; return 1; } unit=$(net_relay_unit "$proto" "$lport") name=$(net_relay_unit_name "$proto" "$lport") state=$(net_unit_state "$unit" "$socat") case $state in foreign) net_die "$unit 已存在但内容不是本模块生成的,未覆盖;请人工确认。" return 1 ;; ours) net_die "$name 已存在(同一端口)。重装请先卸载该转发。" return 1 ;; esac if net_port_busy "$proto" "$lport"; then net_err "$proto 端口 $lport 已被占用:" net_port_lines "$proto" "$lport" | cut -c1-160 >&2 net_die '换端口,或先确认并停止占用方。' return 1 fi printf '即将创建转发:\n' printf ' 协议 : %s\n' "$proto" printf ' 监听 : 0.0.0.0:%s/%s(所有 IPv4 地址)\n' "$lport" "$proto" printf ' 目标 : %s:%s\n' "$host" "$tport" printf ' 程序 : %s\n' "$socat" printf ' 单元 : %s\n' "$unit" printf ' 防火墙 : 不改动;需自行放行 %s/%s\n' "$lport" "$proto" net_confirm_twice RELAY || return 1 net_lock relay || return 1 net_relay_write_unit "$proto" "$lport" "$host" "$tport" \ || { net_relay_rollback "$proto" "$lport"; net_die '单元写入失败。'; return 1; } if ! systemctl daemon-reload; then net_relay_rollback "$proto" "$lport"; return 1 fi if ! systemctl enable --now "$name" >/dev/null 2>&1; then net_err 'systemctl enable --now 失败。' net_relay_rollback "$proto" "$lport"; return 1 fi if ! net_wait_active "$name"; then net_err '转发未进入 active 状态。' systemctl --no-pager -l status "$name" >&2 2>&1 || true net_relay_rollback "$proto" "$lport"; return 1 fi if ! net_wait_owned "$proto" "$lport" "$socat" "$name"; then net_err "单元显示运行中,但 $proto $lport 的监听进程不是 socat。" net_relay_rollback "$proto" "$lport"; return 1 fi printf '\n转发已建立并设为开机自启:%s\n' "$name" printf '[已验证] 单元 active、%s/%s 由 socat 监听,目标 %s:%s\n' "$proto" "$lport" "$host" "$tport" printf '[未验证] 端到端连通:需要客户端实际经 %s 传输一次\n' "$lport" printf '\n仍需人工处理:\n' printf ' - 防火墙/安全组放行 %s/%s\n' "$lport" "$proto" printf ' - 目标端口若换址,需卸载后重建\n' printf ' - 卸载:B31 → 2,或 systemctl disable --now %s\n' "$name" } # 列出本模块创建的转发单元(只看自己的前缀)。 net_relay_units() { local dir f dir=$(net_relay_unit_dir) for f in "$dir/$RELAY_PREFIX"-*.service; do [[ -e $f ]] || continue printf '%s\n' "$f" done } # 从单元名反解 proto 和端口:terry-toolbox-relay--.service net_relay_parse_unit() { local base=${1##*/} rest [[ $base == "$RELAY_PREFIX"-*.service ]] || return 1 rest=${base#"$RELAY_PREFIX"-} rest=${rest%.service} [[ $rest =~ ^(tcp|udp)-([0-9]+)$ ]] || return 1 printf '%s %s\n' "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" } _net_relay_uninstall() { local units=() unit proto lport socat name state net_require_root || return 1 net_require_systemd || return 1 socat=$(net_relay_socat) || socat='' mapfile -t units < <(net_relay_units) (( ${#units[@]} > 0 )) || { net_die '没有找到本模块的转发单元。'; return 1; } if (( $# == 0 )) || [[ ${1:-} == all ]]; then : elif (( $# == 2 )); then proto=$1; lport=$2 net_relay_valid_proto "$proto" || { net_die "协议只能是 tcp 或 udp:$proto"; return 1; } net_valid_port "$lport" || { net_die "本地端口不正确:$lport"; return 1; } unit=$(net_relay_unit "$proto" "$lport") [[ -e $unit ]] || { net_die "没有找到 $unit。"; return 1; } units=("$unit") else net_die '用法: uninstall [tcp|udp 本地端口 | all]' return 1 fi printf '将停止并删除:\n' for unit in "${units[@]}"; do state=$(net_unit_state "$unit" "${socat:-socat}") printf ' %s%s\n' "$unit" "$( [[ $state == foreign ]] && printf '(他人单元,将保留)')" done net_confirm_once || return 1 net_lock relay || return 1 for unit in "${units[@]}"; do if [[ $(net_unit_state "$unit" "${socat:-socat}") == foreign ]]; then continue fi name=${unit##*/} systemctl disable --now "$name" >/dev/null 2>&1 || true rm -f -- "$unit" systemctl reset-failed "$name" >/dev/null 2>&1 || true printf '已删除 %s\n' "$name" done systemctl daemon-reload >/dev/null 2>&1 || true } net_relay_status() { local units=() unit proto lport name mapfile -t units < <(net_relay_units) if (( ${#units[@]} == 0 )); then printf '没有任何本模块的转发。\n' return 1 fi for unit in "${units[@]}"; do name=${unit##*/} read -r proto lport < <(net_relay_parse_unit "$name") || continue printf '%s (%s %s)\n' "$name" "$proto" "$lport" printf ' ExecStart: %s\n' "$(sed -n 's/^ExecStart=//p' "$unit")" printf ' 状态 : %s\n' "$(systemctl is-active "$name" 2>/dev/null || true)" printf ' 监听 : %s\n' "$( [[ -n $(net_port_lines "$proto" "$lport") ]] && printf '有' || printf '无')" done } # ================================================================ A1/A2 Alpine 公共 # # A1/A2 只支持「全新 Alpine + OpenRC + 固定官方发布二进制 + SHA-256 白名单」。 # 不执行远程脚本、不改 apk 仓库、不碰防火墙。写盘前拒绝一切不确定状态, # 写盘后失败一律回滚。端口是否被自己持有只认本机事实(pidfile、/proc//exe、 # 内核 comm),不靠「看起来像我们的服务」。 net_const NET_ALPINE_HY2_VERSION '2.13.0' net_const NET_ALPINE_XRAY_VERSION '26.10.29' net_const NET_ALPINE_HY2_NAME 'terry-toolbox-hy2' net_const NET_ALPINE_XRAY_NAME 'terry-toolbox-xray' net_const NET_ALPINE_DEFAULT_PORT 443 net_const NET_ALPINE_HY2_MASQUERADE 'Terry Toolbox Hysteria 2' net_initdir() { printf '%s\n' "${TOOLBOX_NET_INITD:-/etc/init.d}"; } net_rundir() { printf '%s\n' "${TOOLBOX_NET_RUN:-/run}"; } net_alpine_require_os() { local os os=$(toolbox_network_detect_os "${TOOLBOX_NET_OS_RELEASE:-/etc/os-release}" 2>/dev/null) \ || { net_die '无法确认发行版:A1/A2 只支持 Alpine(Debian/Ubuntu 请用 A4/A5 路径)。'; return 1; } [[ $os == alpine ]] || { net_die "A1/A2 只支持 Alpine(检测到 $os)。"; return 1; } } # 容器里装 Alpine 很常见,但没有 OpenRC 时装了也起不来,必须提前拒绝。 net_alpine_require_openrc() { local run=${TOOLBOX_NET_OPENRC_RUN:-/run/openrc} if ! command -v rc-service >/dev/null 2>&1 || ! command -v rc-update >/dev/null 2>&1; then net_die '未找到 rc-service/rc-update,本机不是 OpenRC 系统。' return 1 fi [[ -d $run ]] || { net_die "OpenRC 未作为 init 运行(缺少 $run)。"; return 1; } } net_alpine_arch() { case $(uname -m) in x86_64|amd64) printf 'amd64\n' ;; aarch64|arm64) printf 'arm64\n' ;; *) net_die "不支持的 CPU 架构:$(uname -m)"; return 1 ;; esac } # HyNetworks/hysteria app/v2.13.0 官方发布二进制的 SHA-256 白名单。 net_alpine_hy2_sha256() { case $1 in amd64) printf '%s\n' "${TOOLBOX_A1_SHA256:-907ba8c9693edb104b20582681fb7dc15639d5b64a9cbb616a7b539190a86691}" ;; arm64) printf '%s\n' "${TOOLBOX_A1_SHA256_ARM64:-a68a61a84452ca250ce0368202521965ca9cc9d801a404f1dc9008ac6cf677a7}" ;; *) return 1 ;; esac } # XTLS/Xray-core v26.10.29 官方 zip 的 SHA-256 白名单(取自上游 .dgst 并本地复核)。 net_alpine_xray_sha256() { case $1 in amd64) printf '%s\n' "${TOOLBOX_A2_SHA256:-8a5f3e6a1057e4bd0f8a7c57c9d91204b4be0b74222a6e210d070603101163c2}" ;; arm64) printf '%s\n' "${TOOLBOX_A2_SHA256_ARM64:-afe01f19045b05237420328028ee1ee2e1edd2100eb41a8cbbf50e683b0a6e58}" ;; *) return 1 ;; esac } # Xray 官方产物的架构后缀与 uname 名不同:arm64 是 arm64-v8a。 net_xray_asset_arch() { case $1 in amd64) printf '64\n' ;; arm64) printf 'arm64-v8a\n' ;; *) return 1 ;; esac } net_alpine_xray_url() { # arch local asset asset=$(net_xray_asset_arch "$1") || return 1 printf 'https://github.com/XTLS/Xray-core/releases/download/v%s/Xray-linux-%s.zip\n' \ "$NET_ALPINE_XRAY_VERSION" "$asset" } net_alpine_port_owned_by() { # proto port bin pidfile local proto=$1 port=$2 bin=$3 pidfile=$4 line pid exe want svcpid='' comm want=$(readlink -f -- "$bin" 2>/dev/null) || want=$bin comm=${bin##*/}; comm=${comm:0:15} [[ -r $pidfile ]] && svcpid=$(<"$pidfile") [[ $svcpid =~ ^[0-9]+$ ]] || svcpid='' while IFS= read -r line; do while IFS= read -r pid; do [[ -n $pid ]] || continue [[ -n $svcpid && $pid == "$svcpid" ]] && return 0 exe=$(readlink -f "/proc/$pid/exe" 2>/dev/null) || exe='' [[ -n $exe && $exe == "$want" ]] && return 0 done < <(grep -o 'pid=[0-9]*' <<< "$line" | cut -d= -f2) # 内核 comm 被截断到 15 字符,只能按前缀兜底。 [[ $line == *"$comm"* ]] && return 0 done < <(if [[ $proto == tcp ]]; then ss -ltnpH 2>/dev/null; else ss -lunpH 2>/dev/null; fi) return 1 } net_alpine_wait_owned() { local i for ((i = 0; i < 20; i++)); do net_alpine_port_owned_by "$1" "$2" "$3" "$4" && return 0 sleep 0.5 done return 1 } # absent / ours / foreign:同名 init 脚本不代表是我们的。 net_alpine_svc_state() { # init-script bin [[ -e $1 ]] || { printf 'absent\n'; return 0; } if grep -qF "command=\"$2\"" "$1" 2>/dev/null; then printf 'ours\n' else printf 'foreign\n' fi } net_alpine_svc_active() { rc-service "$1" status >/dev/null 2>&1; } net_alpine_wait_active() { local i for ((i = 0; i < 20; i++)); do net_alpine_svc_active "$1" && return 0 sleep 0.5 done return 1 } net_alpine_rollback() { # name init bin etc pidfile clientdir local name=$1 init=$2 bin=$3 etc=$4 pid=$5 client=$6 net_err '--- 开始回滚 ---' if [[ -e $init ]]; then rc-service "$name" stop >/dev/null 2>&1 || true rc-update del "$name" default >/dev/null 2>&1 || true rm -f -- "$init" fi rm -f -- "$bin" "$pid" [[ -d $etc ]] && rm -rf -- "$etc" [[ -n $client && -d $client ]] && rm -rf -- "$client" if net_alpine_svc_active "$name" 2>/dev/null; then net_err "警告:回滚后 $name 仍在运行,请人工确认。" fi net_err '已恢复到安装前状态(新增的 init 脚本、二进制、配置目录已删除)。' } # 下载后立刻校验;测试用 *_SRC 注入本地文件,同样会走校验和。 net_alpine_fetch() { # url dest expected [local-src] local url=$1 dest=$2 expected=$3 src=${4:-} actual if [[ -n $src ]]; then cp -- "$src" "$dest" || { net_die "复制本地文件失败:$src"; return 1; } else net_err "下载官方发布文件:$url" curl -fsSL --proto '=https' --tlsv1.2 --connect-timeout 15 --max-time 300 \ -o "$dest" "$url" || { net_die '下载失败(网络或上游不可达)。'; return 1; } fi actual=$(sha256sum -- "$dest" | cut -d' ' -f1) [[ $actual == "$expected" ]] \ || { net_die "SHA-256 不匹配:期望 $expected,实际 $actual(下载被篡改或版本漂移)。"; return 1; } } net_alpine_free_port() { local p=$1 while net_port_busy udp "$p" || net_port_busy tcp "$p"; do ((p++)); done printf '%s\n' "$p" } net_curl_socks() { # socks-port url local out i for ((i = 0; i < 3; i++)); do out=$(timeout 25 curl -fsS --max-time 20 --socks5-hostname "127.0.0.1:$1" \ -o /dev/null -w '%{http_code}' "$2" 2>/dev/null) \ && [[ $out == 2* ]] && return 0 sleep 1 done return 1 } # 写盘后、交给 OpenRC 之前的离线自检:配置能解析、证书能加载、端口能绑定。 net_hysteria_smoke() { local bin=$1 conf=$2 out pid i out=$(mktemp) || return 1 "$bin" server -c "$conf" --disable-update-check >"$out" 2>&1 & pid=$! for ((i = 0; i < 60; i++)); do grep -q 'server up and running' "$out" 2>/dev/null && break grep -q 'FATAL' "$out" 2>/dev/null && break kill -0 "$pid" 2>/dev/null || break sleep 0.2 done kill "$pid" 2>/dev/null wait "$pid" 2>/dev/null || true if grep -q 'server up and running' "$out" 2>/dev/null; then rm -f -- "$out" return 0 fi net_err '冒烟启动失败,服务输出:' sed -n '1,12p' "$out" >&2 rm -f -- "$out" return 1 } # 认证探测:用同一份二进制当客户端;negative=1 时只在连通时返回 0。 net_hysteria_probe() { # bin host port pass insecure label negative local bin=$1 host=$2 port=$3 pass=$4 insecure=$5 label=$6 negative=${7:-0} local dir sock out i cpid result=1 dir=$(net_rundir)/terry-toolbox-hy2-verify mkdir -p "$dir" || return 1 sock=$(net_alpine_free_port $(( 39000 + RANDOM % 2000 ))) out=$dir/$label.log { printf 'server: 127.0.0.1:%s\n' "$port" printf 'auth: %s\n' "$pass" printf 'tls:\n sni: %s\n' "$host" [[ $insecure == 1 ]] && printf ' insecure: true\n' printf 'socks5:\n listen: 127.0.0.1:%s\n' "$sock" } > "$dir/$label.yaml" || return 1 "$bin" client -c "$dir/$label.yaml" --disable-update-check >"$out" 2>&1 & cpid=$! for ((i = 0; i < 75; i++)); do grep -q 'connected to server' "$out" 2>/dev/null && { result=0; break; } grep -q 'FATAL' "$out" 2>/dev/null && break kill -0 "$cpid" 2>/dev/null || break sleep 0.2 done (( result == 1 )) && grep -q 'connected to server' "$out" 2>/dev/null && result=0 if (( result == 0 && negative == 0 )); then net_curl_socks "$sock" 'https://www.cloudflare.com/cdn-cgi/trace' \ || net_err "提示:$label 隧道已通,但数据面出站未成功(可能主机无外网出口)。" fi kill "$cpid" 2>/dev/null wait "$cpid" 2>/dev/null || true return $result } # xray 客户端:等它真正开始监听再打数据面;判据是 HTTP 200,不是「进程还活着」。 net_xray_probe() { # bin host port uuid pub shortid label negative local bin=$1 host=$2 port=$3 uuid=$4 pub=$5 shortid=$6 label=$7 negative=${8:-0} local dir sock out i cpid result=1 dir=$(net_rundir)/terry-toolbox-xray-verify mkdir -p "$dir" || return 1 sock=$(net_alpine_free_port $(( 41000 + RANDOM % 2000 ))) out=$dir/$label.log cat > "$dir/$label.json" <"$out" 2>&1 & cpid=$! for ((i = 0; i < 75; i++)); do grep -q 'started' "$out" 2>/dev/null && { result=0; break; } grep -q 'Failed to start' "$out" 2>/dev/null && break kill -0 "$cpid" 2>/dev/null || break sleep 0.2 done (( result == 1 )) && grep -q 'started' "$out" 2>/dev/null && result=0 if (( result == 0 && negative == 0 )); then net_curl_socks "$sock" 'https://www.cloudflare.com/cdn-cgi/trace' \ || net_err "提示:$label 隧道已通,但数据面出站未成功(可能主机无外网出口)。" fi kill "$cpid" 2>/dev/null wait "$cpid" 2>/dev/null || true return $result } # ---------------------------------------------------------------- A1 Alpine HY2 net_a1_etc() { printf '%s\n' "${TOOLBOX_A1_ETC:-/etc/terry-toolbox-hy2}"; } net_a1_bin() { printf '%s/%s\n' "${TOOLBOX_NET_BIN_DIR:-/usr/local/bin}" "$NET_ALPINE_HY2_NAME"; } net_a1_init() { printf '%s/%s\n' "$(net_initdir)" "$NET_ALPINE_HY2_NAME"; } net_a1_pid() { printf '%s/%s.pid\n' "$(net_rundir)" "$NET_ALPINE_HY2_NAME"; } net_a1_client() { printf '%s/terry-toolbox-hy2-verify\n' "$(net_rundir)"; } net_a1_rollback() { net_alpine_rollback "$NET_ALPINE_HY2_NAME" "$(net_a1_init)" "$(net_a1_bin)" \ "$(net_a1_etc)" "$(net_a1_pid)" "$(net_a1_client)" } net_a1_conflict() { # 写盘前的冲突判定 local state state=$(net_alpine_svc_state "$(net_a1_init)" "$(net_a1_bin)") case $state in foreign) net_die "$(net_a1_init) 已存在但不是本模块生成的,未覆盖;请人工确认归属。" return 1 ;; ours) if net_alpine_svc_active "$NET_ALPINE_HY2_NAME"; then net_die "$NET_ALPINE_HY2_NAME 已在运行(我们的服务)。重装请先执行卸载。" else net_die "$NET_ALPINE_HY2_NAME 已存在但未运行(我们的残留)。请先执行卸载清理。" fi return 1 ;; absent) # 没有 init 脚本却有同名二进制/配置目录:可能是别人的安装,不静默覆盖。 if [[ -e $(net_a1_bin) || -d $(net_a1_etc) ]]; then net_die "已存在 $(net_a1_bin) 或 $(net_a1_etc),却没有本模块的 init 脚本;请人工确认后清理。" return 1 fi ;; esac } _net_a1_install() { local cert=${1:-} key=${2:-} host=${3:-} port=${4:-} trusted=1 insecure=0 local arch tmp etc bin init pid pass state net_require_root || return 1 net_alpine_require_os || return 1 net_alpine_require_openrc || return 1 net_require_cmds curl sha256sum install mktemp flock ss awk openssl stat timeout arch=$(net_alpine_arch) || return 1 [[ -n $cert ]] || { net_die '缺少证书路径。'; return 1; } [[ -n $key ]] || { net_die '缺少私钥路径。'; return 1; } [[ -n $host ]] || { net_die '缺少主机名(证书对应的域名)。'; return 1; } host=${host,,} net_valid_hostname "$host" || { net_die "主机名格式不正确:$host"; return 1; } if [[ -n $port ]]; then net_valid_port "$port" || { net_die "端口不正确:$port(1-65535,无前导零)"; return 1; } else port=$NET_ALPINE_DEFAULT_PORT fi net_check_cert "$cert" "$key" "$host" || return 1 net_a1_conflict || return 1 etc=$(net_a1_etc); bin=$(net_a1_bin); init=$(net_a1_init); pid=$(net_a1_pid) if net_port_busy udp "$port"; then net_err "UDP 端口 $port 已被占用,监听它的进程:" ss -lunpH 2>/dev/null | awk -v p=":$port" '$4 ~ p "$" {print " " $4 " " $0}' | cut -c1-160 >&2 net_die '换成其他端口,或先确认并停止占用方。' return 1 fi printf '即将在 Alpine 上安装 Hysteria 2 服务端(OpenRC):\n' printf ' 版本 : v%s(官方发布,安装前校验 SHA-256)\n' "$NET_ALPINE_HY2_VERSION" printf ' 主机名 : %s\n' "$host" printf ' 监听 : :%s/udp(所有地址,IPv4+IPv6)\n' "$port" printf ' 证书 : %s\n' "$cert" printf ' 私钥 : %s(复制到 %s,权限 600)\n' "$key" "$etc" printf ' init : %s(rc-update add default)\n' "$init" printf ' 认证 : 随机密码,安装完成后只显示一次\n' printf ' 防火墙 : 不改动;需自行放行 UDP %s\n' "$port" net_confirm_once || return 1 net_lock a1 || return 1 tmp=$(mktemp -d) || { net_die '无法创建临时目录'; return 1; } pass=$(openssl rand -base64 32 | tr -dc 'A-Za-z0-9' | cut -c1-32) [[ ${#pass} -eq 32 ]] || { rm -rf -- "$tmp"; net_die '密码生成失败'; return 1; } net_err '--- 开始写入 ---' net_alpine_fetch \ "https://github.com/HyNetworks/hysteria/releases/download/app/v${NET_ALPINE_HY2_VERSION}/hysteria-linux-${arch}" \ "$tmp/hysteria" "$(net_alpine_hy2_sha256 "$arch")" "${TOOLBOX_A1_BIN_SRC:-}" \ || { rm -rf -- "$tmp"; net_a1_rollback; return 1; } install -d -m 0755 "$etc" \ && install -m 0755 "$tmp/hysteria" "$bin" \ && install -m 0600 "$cert" "$etc/cert.pem" \ && install -m 0600 "$key" "$etc/key.pem" \ || { rm -rf -- "$tmp"; net_a1_rollback; return 1; } cat > "$etc/config.yaml" < "$init" </dev/null 2>&1; then net_err 'rc-update add 失败。' rm -rf -- "$tmp"; net_a1_rollback; return 1 fi if ! rc-service "$NET_ALPINE_HY2_NAME" start >/dev/null 2>&1; then net_err 'rc-service start 失败。' rc-service "$NET_ALPINE_HY2_NAME" status >&2 2>&1 || true rm -rf -- "$tmp"; net_a1_rollback; return 1 fi if ! net_alpine_wait_active "$NET_ALPINE_HY2_NAME"; then net_err '服务未进入 started 状态。' rm -rf -- "$tmp"; net_a1_rollback; return 1 fi if ! net_alpine_wait_owned udp "$port" "$bin" "$pid"; then net_err "服务显示已启动,但 UDP $port 的监听进程不是 $NET_ALPINE_HY2_NAME。" rm -rf -- "$tmp"; net_a1_rollback; return 1 fi net_tls_trusted "$etc/cert.pem" || trusted=0 [[ $trusted == 0 ]] && insecure=1 if ! net_hysteria_probe "$bin" "$host" "$port" "$pass" "$insecure" good; then net_err '认证探测失败(正确密码未连通)。' rm -rf -- "$tmp"; net_a1_rollback; return 1 fi if net_hysteria_probe "$bin" "$host" "$port" "wrong-$pass" "$insecure" bad 1; then net_err '错误密码也被接受,认证未生效。' rm -rf -- "$tmp"; net_a1_rollback; return 1 fi printf ' 负向探测:错误密码被拒绝 ✓\n' printf '%s|%s|%s|%s\n' "$host" "$port" "$insecure" "$NET_ALPINE_HY2_VERSION" > "$etc/meta" chmod 0600 "$etc/meta" rm -rf -- "$tmp" net_a1_report "$host" "$port" "$pass" "$insecure" } net_a1_report() { local host=$1 port=$2 pass=$3 insecure=$4 qs='' [[ $insecure == 1 ]] && qs='&insecure=1' printf '\n安装完成,服务 %s 已加入 default 运行级。\n' "$NET_ALPINE_HY2_NAME" printf '客户端连接信息(密码只显示这一次,也已写入 %s/config.yaml):\n' "$(net_a1_etc)" printf ' hysteria2://%s@%s:%s/?sni=%s%s\n' "$pass" "$host" "$port" "$host" "$qs" if [[ $insecure == 1 ]]; then printf ' 证书链未被本机信任库验证通过:链接带 insecure=1,客户端需同样开启。\n' else printf ' 证书链验证通过:客户端可保持严格校验(去掉 insecure=1)。\n' fi printf '\n验证情况:\n' printf ' [已验证] 服务 started、UDP %s 由 %s 监听\n' "$port" "$NET_ALPINE_HY2_NAME" printf ' [已验证] 正确密码经 TLS 认证连通;错误密码被拒绝\n' printf '\n仍需人工处理:\n' printf ' - 防火墙/安全组放行 UDP %s(本模块不改防火墙)\n' "$port" printf ' - 证书续期后覆盖配置目录里的 cert.pem、key.pem,再 rc-service %s restart\n' "$NET_ALPINE_HY2_NAME" printf ' - 卸载:A1 → 2\n' } # ---------------------------------------------------------------- A2 Alpine Xray net_a2_etc() { printf '%s\n' "${TOOLBOX_A2_ETC:-/etc/terry-toolbox-xray}"; } net_a2_bin() { printf '%s/%s\n' "${TOOLBOX_NET_BIN_DIR:-/usr/local/bin}" "$NET_ALPINE_XRAY_NAME"; } net_a2_init() { printf '%s/%s\n' "$(net_initdir)" "$NET_ALPINE_XRAY_NAME"; } net_a2_pid() { printf '%s/%s.pid\n' "$(net_rundir)" "$NET_ALPINE_XRAY_NAME"; } net_a2_client() { printf '%s/terry-toolbox-xray-verify\n' "$(net_rundir)"; } net_a2_rollback() { net_alpine_rollback "$NET_ALPINE_XRAY_NAME" "$(net_a2_init)" "$(net_a2_bin)" \ "$(net_a2_etc)" "$(net_a2_pid)" "$(net_a2_client)" } net_a2_conflict() { local state state=$(net_alpine_svc_state "$(net_a2_init)" "$(net_a2_bin)") case $state in foreign) net_die "$(net_a2_init) 已存在但不是本模块生成的,未覆盖;请人工确认归属。" return 1 ;; ours) if net_alpine_svc_active "$NET_ALPINE_XRAY_NAME"; then net_die "$NET_ALPINE_XRAY_NAME 已在运行(我们的服务)。重装请先执行卸载。" else net_die "$NET_ALPINE_XRAY_NAME 已存在但未运行(我们的残留)。请先执行卸载清理。" fi return 1 ;; absent) if [[ -e $(net_a2_bin) || -d $(net_a2_etc) ]]; then net_die "已存在 $(net_a2_bin) 或 $(net_a2_etc),却没有本模块的 init 脚本;请人工确认后清理。" return 1 fi ;; esac } # 回落目标:主机名,或 主机名:端口。IPv6 目标暂不支持。 net_valid_dest() { local dest=${1%:*} dport='' [[ $1 == *:* ]] && dport=${1##*:} net_valid_hostname "$dest" || return 1 [[ -z $dport ]] || net_valid_port "$dport" } _net_a2_install() { local port=${1:-} dest=${2:-} dhost='' dport='' local arch tmp etc bin init pid uuid priv pub shortid cfg state net_require_root || return 1 net_alpine_require_os || return 1 net_alpine_require_openrc || return 1 net_require_cmds curl sha256sum install mktemp flock ss awk openssl stat timeout unzip arch=$(net_alpine_arch) || return 1 if [[ -n $port ]]; then net_valid_port "$port" || { net_die "端口不正确:$port(1-65535,无前导零)"; return 1; } else port=$NET_ALPINE_DEFAULT_PORT fi [[ -n $dest ]] || dest='www.cloudflare.com:443' net_valid_dest "$dest" || { net_die "回落目标格式不正确:$dest(应为 主机名 或 主机名:端口)"; return 1; } dhost=${dest%:*}; dport=${dest##*:} [[ $dest == *:* ]] || dport=443 net_a2_conflict || return 1 etc=$(net_a2_etc); bin=$(net_a2_bin); init=$(net_a2_init); pid=$(net_a2_pid) if net_port_busy tcp "$port"; then net_err "TCP 端口 $port 已被占用,监听它的进程:" ss -ltnpH 2>/dev/null | awk -v p=":$port" '$4 ~ p "$" {print " " $4 " " $0}' | cut -c1-160 >&2 net_die '换成其他端口,或先确认并停止占用方。' return 1 fi printf '即将在 Alpine 上安装 Xray 服务端(VLESS + TCP + REALITY,OpenRC):\n' printf ' 版本 : v%s(官方发布,安装前校验 SHA-256)\n' "$NET_ALPINE_XRAY_VERSION" printf ' 监听 : :%s/tcp(所有地址,IPv4+IPv6)\n' "$port" printf ' 回落 : %s(未认证流量原样转发,需要该目标可直连)\n' "$dest" printf ' 配置 : %s(600;含私钥,凭据只在安装完成后显示一次)\n' "$etc" printf ' init : %s(rc-update add default)\n' "$init" printf ' 防火墙 : 不改动;需自行放行 TCP %s\n' "$port" printf ' 局限 : REALITY 不限制回落流量;端口不是 443 时被封锁概率更高\n' net_confirm_once || return 1 net_lock a2 || return 1 tmp=$(mktemp -d) || { net_die '无法创建临时目录'; return 1; } net_err '--- 开始写入 ---' net_alpine_fetch \ "$(net_alpine_xray_url "$arch")" \ "$tmp/xray.zip" "$(net_alpine_xray_sha256 "$arch")" "${TOOLBOX_A2_ZIP_SRC:-}" \ || { rm -rf -- "$tmp"; net_a2_rollback; return 1; } mkdir -p "$tmp/x" && unzip -oq "$tmp/xray.zip" xray -d "$tmp/x" \ || { rm -rf -- "$tmp"; net_a2_rollback; return 1; } [[ -s $tmp/x/xray ]] || { rm -rf -- "$tmp"; net_die '压缩包内没有 xray 二进制。'; return 1; } install -d -m 0755 "$etc" && install -m 0755 "$tmp/x/xray" "$bin" \ || { rm -rf -- "$tmp"; net_a2_rollback; return 1; } # 用刚校验过的二进制自己生成凭据,避免与 xray 的编码差异。 uuid=$("$bin" uuid 2>/dev/null) || { rm -rf -- "$tmp"; net_die 'uuid 生成失败。'; return 1; } # 一次调用同时取公私钥:分两次调用会拿到两对不匹配的密钥。 local kpair kpair=$("$bin" x25519 2>/dev/null) || kpair='' priv=$(sed -n 's/^PrivateKey: //p' <<< "$kpair") pub=$(sed -n 's/^Password (PublicKey): //p' <<< "$kpair") net_valid_uuid "$uuid" || { rm -rf -- "$tmp"; net_die 'uuid 输出异常。'; return 1; } [[ -n $priv && -n $pub ]] || { rm -rf -- "$tmp"; net_die 'x25519 密钥生成失败。'; return 1; } shortid=$(openssl rand -hex 4) cfg=$etc/config.json cat > "$cfg" <"$tmp/t.log" 2>&1; then net_err '配置自检失败,xray 输出:' sed -n '1,12p' "$tmp/t.log" >&2 rm -rf -- "$tmp"; net_a2_rollback; return 1 fi cat > "$init" </dev/null 2>&1; then net_err 'rc-update add 失败。' rm -rf -- "$tmp"; net_a2_rollback; return 1 fi if ! rc-service "$NET_ALPINE_XRAY_NAME" start >/dev/null 2>&1; then net_err 'rc-service start 失败。' rc-service "$NET_ALPINE_XRAY_NAME" status >&2 2>&1 || true rm -rf -- "$tmp"; net_a2_rollback; return 1 fi if ! net_alpine_wait_active "$NET_ALPINE_XRAY_NAME"; then net_err '服务未进入 started 状态。' rm -rf -- "$tmp"; net_a2_rollback; return 1 fi if ! net_alpine_wait_owned tcp "$port" "$bin" "$pid"; then net_err "服务显示已启动,但 TCP $port 的监听进程不是 $NET_ALPINE_XRAY_NAME。" rm -rf -- "$tmp"; net_a2_rollback; return 1 fi if ! net_xray_probe "$bin" "$dhost" "$port" "$uuid" "$pub" "$shortid" good; then net_err '认证探测失败(正确 UUID 未连通)。' rm -rf -- "$tmp"; net_a2_rollback; return 1 fi if net_xray_probe "$bin" "$dhost" "$port" '11111111-1111-4111-8111-111111111111' \ "$pub" "$shortid" bad 1; then net_err '错误 UUID 也被接受,认证未生效。' rm -rf -- "$tmp"; net_a2_rollback; return 1 fi printf ' 负向探测:错误 UUID 被拒绝 ✓\n' printf '%s|%s|%s|%s\n' "$dhost" "$port" "$dest" "$NET_ALPINE_XRAY_VERSION" > "$etc/meta" chmod 0600 "$etc/meta" rm -rf -- "$tmp" net_a2_report "$dhost" "$port" "$uuid" "$pub" "$shortid" "$dest" } net_a2_report() { local host=$1 port=$2 uuid=$3 pub=$4 sid=$5 dest=$6 printf '\n安装完成,服务 %s 已加入 default 运行级。\n' "$NET_ALPINE_XRAY_NAME" printf '客户端连接信息(私钥只在 %s/config.json):\n' "$(net_a2_etc)" printf ' vless://%s@%s:%s?type=tcp&security=reality&sni=%s&fp=chrome&pbk=%s&sid=%s&flow=xtls-rprx-vision#terry\n' \ "$uuid" "$host" "$port" "${dest%:*}" "$pub" "$sid" printf '\n验证情况:\n' printf ' [已验证] 服务 started、TCP %s 由 %s 监听\n' "$port" "$NET_ALPINE_XRAY_NAME" printf ' [已验证] 正确 UUID 经 REALITY 握手连通;错误 UUID 被拒绝\n' printf '\n仍需人工处理:\n' printf ' - 防火墙/安全组放行 TCP %s(本模块不改防火墙)\n' "$port" printf ' - REALITY 不限制回落流量;未认证流量会转发到 %s\n' "$dest" printf ' - 卸载:A2 → 2\n' } # ---------------------------------------------------------------- A1/A2 卸载与状态 net_alpine_uninstall() { # name init bin etc pid client label lockkey local name=$1 init=$2 bin=$3 etc=$4 pid=$5 client=$6 label=$7 lockkey=$8 state net_require_root || return 1 net_alpine_require_openrc || return 1 state=$(net_alpine_svc_state "$init" "$bin") if [[ $state == absent && ! -e $bin && ! -d $etc ]]; then net_die "$label:没有找到本模块的安装(init 脚本、二进制、配置都不存在)。" return 1 fi if [[ $state == foreign ]]; then net_die "$init 不是本模块生成的,未删除;请人工确认。" return 1 fi printf '将停止并删除:%s\n %s\n %s\n %s\n' "$init" "$bin" "$etc" '(服务已从 default 运行级移除)' net_confirm_once || return 1 net_lock "$lockkey" || return 1 rc-service "$name" stop >/dev/null 2>&1 || true rc-update del "$name" default >/dev/null 2>&1 || true rm -f -- "$init" "$bin" "$pid" [[ -d $etc ]] && rm -rf -- "$etc" [[ -n $client && -d $client ]] && rm -rf -- "$client" printf '已卸载。\n' } net_alpine_status() { # name init bin etc proto local name=$1 init=$2 bin=$3 etc=$4 proto=$5 host='' port='' _extra='' version='' pid if [[ ! -e $init ]]; then printf '%s 未安装。\n' "$name" return 1 fi pid=$(net_rundir)/$name.pid IFS='|' read -r host port _extra version < "$etc/meta" 2>/dev/null || true printf 'init: %s\n' "$init" if net_alpine_svc_active "$name"; then printf '状态: started\n'; else printf '状态: 未运行\n'; fi rc-service "$name" status 2>&1 | sed -n '1,6p' if [[ -n ${port:-} ]]; then printf '端口 %s 监听: ' "$port" if net_alpine_port_owned_by "$proto" "$port" "$bin" "$pid"; then printf '由本服务持有\n' else printf '不是本服务\n' fi fi return 0 } # ================================================================ A5 Xray (Debian) # # 与 A2 同一套上游产物(XTLS/Xray-core 固定版本 + SHA-256 白名单),但这里是 # Debian + systemd 路径。A2 的四个已知问题在此逐条处理: # # 1) REALITY 回退滥用:未认证流量会被原样转给回落目标,扫描到就能白嫖转发。 # 按 XTLS 文档给 realitySettings 加 limitFallbackUpload/limitFallbackDownload # 令牌桶(afterBytes/bytesPerSec/burstBytesPerSec),双向都限,且参数按文档 # 建议随机化(固定值本身是特征)。已用本版本二进制实测:受限后未认证拉 1 MiB # 从 0.07 s 变为 28 s,而认证连接不受影响。 # 注:评审提到的 dest 对象形式 maxTime/maxBytes 在 v26.10.29 已不被 REALITY # 的 target 接受(实测 target 只收字符串),现行机制就是上面两个 limit 对象。 # 2) 部分写盘回滚:所有落盘文件登记到 manifest(路径 + 写后 SHA-256),回退只删 # 「登记过且内容未被改动」的路径;单元只在 ExecStart 指向我们的二进制时才停/ # 删,外来同名单元不动。 # 3) 并发:flock 在冲突检查之前拿到,检查与写盘在同一把锁内完成。 # 4) 目标与认证:回落目标必须显式给出,SNI 必须显式给出;写盘前用 openssl 确认 # 目标支持 TLS 1.3 + ALPN h2 且证书覆盖该 SNI(REALITY 要求,缺一即暴露特征)。 # 安装后先用离线冒烟 + 本机 xray 客户端完成「正确 UUID 连通、错误 UUID 被拒」 # 双向认证验证(服务端日志判据),再交给 systemd,最后在真实服务上再验一次。 # # 没有证书:REALITY 借用回落目标的证书,本模块不碰证书文件。 net_const NET_A5_VERSION '26.10.29' net_const NET_A5_BIN_NAME 'terry-toolbox-xray' net_const NET_A5_UNIT_NAME 'terry-toolbox-xray.service' net_const NET_A5_DEFAULT_PORT 443 net_const NET_A5_TAG 'vless-in' net_a5_etc() { printf '%s/terry-toolbox-xray\n' "${TOOLBOX_NET_ETC:-/etc}"; } net_a5_bin() { printf '%s/%s\n' "${TOOLBOX_NET_BIN_DIR:-/usr/local/bin}" "$NET_A5_BIN_NAME"; } net_a5_unit() { printf '%s/%s\n' "${TOOLBOX_NET_UNIT_DIR:-/etc/systemd/system}" "$NET_A5_UNIT_NAME"; } net_a5_client() { printf '%s/terry-toolbox-xray-verify\n' "$(net_rundir)"; } net_a5_cfg() { printf '%s/config.json\n' "$(net_a5_etc)"; } net_a5_meta() { printf '%s/meta\n' "$(net_a5_etc)"; } # 校验值只维护一份(A2/A5 同一版本同一产物),测试可用 A5 专用变量覆盖。 net_a5_sha256() { local arch=$1 case $arch in amd64) [[ -n ${TOOLBOX_A5_SHA256:-} ]] && { printf '%s\n' "$TOOLBOX_A5_SHA256"; return 0; } ;; arm64) [[ -n ${TOOLBOX_A5_SHA256_ARM64:-} ]] && { printf '%s\n' "$TOOLBOX_A5_SHA256_ARM64"; return 0; } ;; esac net_alpine_xray_sha256 "$arch" } net_a5_zip_url() { local asset asset=$(net_xray_asset_arch "$1") || return 1 printf 'https://github.com/XTLS/Xray-core/releases/download/v%s/Xray-linux-%s.zip\n' \ "$NET_A5_VERSION" "$asset" } # ---------------------------------------------------------------- 回落目标校验 # REALITY 把未认证流量转给目标,目标本身必须「像正常网站」:TLS 1.3、ALPN h2, # 且证书覆盖我们声明的 SNI。三者缺一,握手特征就会和目标不一致。 net_a5_check_target() { # host port sni local host=$1 port=$2 sni=$3 raw='' cert='' chain='' rc=0 n=0 line infile=0 raw=$(mktemp) || { net_die '无法创建临时文件。'; return 1; } cert=$(mktemp) || { rm -f -- "$raw"; net_die '无法创建临时文件。'; return 1; } chain=$(mktemp) || { rm -f -- "$raw" "$cert"; net_die '无法创建临时文件。'; return 1; } : > "$cert"; : > "$chain" timeout 20 openssl s_client -connect "$host:$port" -servername "$sni" \ -tls1_3 -alpn h2 -showcerts "$raw" 2>&1 || rc=1 if (( rc != 0 )) && ! grep -q 'BEGIN CERTIFICATE' "$raw"; then net_err "回落目标 $host:$port 握手失败(DNS 解析、网络或端口不通)。" sed -n '1,6p' "$raw" >&2 rm -f -- "$raw" "$cert" "$chain" return 1 fi # 第一张是叶子证书,其余按链保存(仅用于本机信任度提示)。 while IFS= read -r line; do case $line in *"BEGIN CERTIFICATE"*) n=$(( n + 1 )); infile=1 ;; esac if (( infile == 1 )); then if (( n == 1 )); then printf '%s\n' "$line" >> "$cert"; else printf '%s\n' "$line" >> "$chain"; fi fi case $line in *"END CERTIFICATE"*) infile=0 ;; esac done < "$raw" if [[ ! -s $cert ]]; then net_err "回落目标 $host:$port 没有返回证书。" rm -f -- "$raw" "$cert" "$chain" return 1 fi if ! grep -qE '^[[:space:]]*(New, )?Protocol[[:space:]]*:[[:space:]]*TLSv1\.3' "$raw"; then net_err "回落目标 $host:$port 不支持 TLS 1.3(REALITY 必需;中间盒降级也会落到这里)。" rm -f -- "$raw" "$cert" "$chain" return 1 fi if ! grep -qiE '^ALPN protocol: *h2$' "$raw"; then net_err "回落目标 $host:$port 协商不到 ALPN h2(REALITY 要求目标支持 HTTP/2)。" rm -f -- "$raw" "$cert" "$chain" return 1 fi if ! openssl x509 -in "$cert" -noout -checkhost "$sni" 2>/dev/null | grep -q 'does match'; then net_err "回落目标的证书不覆盖 SNI $sni(选目标证书里真实存在的名字)。" rm -f -- "$raw" "$cert" "$chain" return 1 fi if [[ -r /etc/ssl/certs/ca-certificates.crt ]]; then local vrc=0 if [[ -s $chain ]]; then openssl verify -CAfile /etc/ssl/certs/ca-certificates.crt -untrusted "$chain" "$cert" >/dev/null 2>&1 || vrc=1 else openssl verify -CAfile /etc/ssl/certs/ca-certificates.crt "$cert" >/dev/null 2>&1 || vrc=1 fi (( vrc == 0 )) || net_err '注意:回落目标的证书链未被本机信任库验证通过(客户端看到的仍是同一张证书)。' fi rm -f -- "$raw" "$cert" "$chain" return 0 } # 回退限速参数:文档建议随机化(固定值本身是特征)。范围取「人类浏览体感」的 # 量级,认证连接不受影响。测试可整体覆盖。 net_a5_limits() { local after base if [[ -n ${TOOLBOX_A5_LIMITS:-} ]]; then # after:downBps:downBurst:upBps:upBurst(全部为字节/秒,测试用) IFS=':' read -r A5_AFTER A5_DOWN_BPS A5_DOWN_BURST A5_UP_BPS A5_UP_BURST <<< "$TOOLBOX_A5_LIMITS" local f for f in "$A5_AFTER" "$A5_DOWN_BPS" "$A5_DOWN_BURST" "$A5_UP_BPS" "$A5_UP_BURST"; do [[ $f =~ ^[1-9][0-9]*$ ]] || { net_die "TOOLBOX_A5_LIMITS 格式不对:$TOOLBOX_A5_LIMITS"; return 1; } done return 0 fi after=$(( (4 + RANDOM % 13) * 1048576 )) base=$(( (512 + RANDOM % 513) * 1024 )) A5_AFTER=$after; A5_DOWN_BPS=$base; A5_DOWN_BURST=$(( base * (2 + RANDOM % 3) )) base=$(( (256 + RANDOM % 257) * 1024 )) A5_UP_BPS=$base; A5_UP_BURST=$(( base * (2 + RANDOM % 3) )) } # ---------------------------------------------------------------- 归属与回滚 net_a5_conflict() { local state state=$(net_unit_state "$(net_a5_unit)" "$(net_a5_bin)") case $state in foreign) net_die "$(net_a5_unit) 已存在但不是本模块生成的,未覆盖;请人工确认归属。" return 1 ;; ours) if systemctl is-active --quiet "$NET_A5_UNIT_NAME" 2>/dev/null; then net_die "$NET_A5_UNIT_NAME 已在运行(我们的服务)。重装请先卸载。" else net_die "$NET_A5_UNIT_NAME 已存在但未运行(我们的残留)。请先卸载。" fi return 1 ;; absent) if [[ -e $(net_a5_bin) || -d $(net_a5_etc) ]]; then net_die "已存在 $(net_a5_bin) 或 $(net_a5_etc),却没有本模块的单元;请人工确认后清理。" return 1 fi ;; esac } # 写盘清单:路径 | 写后 SHA-256。回滚只认清单,不凭猜测删路径。 net_a5_manifest=() net_a5_etc_created=0 net_a5_record() { local sum sum=$(sha256sum -- "$1" 2>/dev/null | cut -d' ' -f1) || sum='' net_a5_manifest+=("$1|$sum") } # 先写到临时路径、校验通过再 mv 到位(mv 同目录内是原子的,不会留半截文件); # 成功后登记清单。 net_a5_place() { # mode src dest local mode=$1 src=$2 dest=$3 [[ -s $src ]] || { net_die "临时文件为空:$src"; return 1; } install -m "$mode" -- "$src" "$dest.a5tmp" || { net_die "写入失败:$dest"; return 1; } if ! mv -f -- "$dest.a5tmp" "$dest"; then rm -f -- "$dest.a5tmp" net_die "落位失败:$dest" return 1 fi net_a5_record "$dest" } net_a5_rollback() { local unit entry path want now i unit=$(net_a5_unit) net_err '--- 开始回滚 ---' # 单元只在确属本模块时才停:看 ExecStart 是否指向我们的二进制。 if [[ -e $unit ]] && grep -qF "ExecStart=$(net_a5_bin)" "$unit" 2>/dev/null; then systemctl disable --now "$NET_A5_UNIT_NAME" >/dev/null 2>&1 || true rm -f -- "$unit" systemctl daemon-reload >/dev/null 2>&1 || true systemctl reset-failed "$NET_A5_UNIT_NAME" >/dev/null 2>&1 || true fi for (( i = ${#net_a5_manifest[@]} - 1; i >= 0; i-- )); do entry=${net_a5_manifest[i]} path=${entry%%|*}; want=${entry##*|} [[ -e $path ]] || continue if [[ -n $want ]]; then now=$(sha256sum -- "$path" 2>/dev/null | cut -d' ' -f1) || now='' if [[ $now != "$want" ]]; then net_err "保留 $path:安装期间内容被改动,不当作我们的文件删除。" continue fi fi rm -f -- "$path" done if (( net_a5_etc_created == 1 )) && [[ -d $(net_a5_etc) ]]; then rmdir -- "$(net_a5_etc)" 2>/dev/null || true fi rm -rf -- "$(net_a5_client)" net_a5_manifest=() if systemctl is-active --quiet "$NET_A5_UNIT_NAME" 2>/dev/null; then net_err "警告:回滚后 $NET_A5_UNIT_NAME 仍在运行,请人工确认。" fi net_err '已按清单恢复到安装前状态。' } # ---------------------------------------------------------------- 探针 # 本机 xray 客户端走一遍 REALITY:判据是数据面真拿到 HTTP 响应,不是「进程还在」。 net_a5_probe() { # bin port uuid pub shortid sni label local bin=$1 port=$2 uuid=$3 pub=$4 shortid=$5 sni=$6 label=$7 local dir sock out i cpid result=1 dir=$(net_a5_client); mkdir -p "$dir" || return 1 sock=$(net_alpine_free_port $(( 41000 + RANDOM % 2000 ))) out=$dir/$label.log cat > "$dir/$label.json" <&-:别把调用方的锁句柄带进客户端进程。 "$bin" run -c "$dir/$label.json" >"$out" 2>&1 9>&- & cpid=$! for ((i = 0; i < 75; i++)); do grep -q 'started' "$out" 2>/dev/null && { result=0; break; } grep -q 'Failed to start' "$out" 2>/dev/null && break kill -0 "$cpid" 2>/dev/null || break sleep 0.2 done (( result == 1 )) && grep -q 'started' "$out" 2>/dev/null && result=0 if (( result == 0 )); then # -k:目标证书已由 net_a5_check_target 验过,这里只关心隧道是否通。 local code='' for ((i = 0; i < 3; i++)); do code=$(timeout 25 curl -sSk --max-time 20 --socks5-hostname "127.0.0.1:$sock" \ -o /dev/null -w '%{http_code}' "https://$sni/" 2>/dev/null) || code='' [[ $code == 2* || $code == 3* ]] && break sleep 1 done [[ $code == 2* || $code == 3* ]] || { net_err "探针 $label:隧道无数据面响应(HTTP $code)。"; result=1; } else sed -n '1,8p' "$out" >&2 fi kill "$cpid" 2>/dev/null wait "$cpid" 2>/dev/null || true return $result } net_a5_wait_listen() { # logfile local log=$1 i for ((i = 0; i < 60; i++)); do grep -q 'listening tcp' "$log" 2>/dev/null && return 0 sleep 0.2 done return 1 } # ---------------------------------------------------------------- 安装 _net_a5_install() { local target=${1:-} sni=${2:-} port=${3:-} answer local thost='' tport='' arch tmp etc bin unit uuid priv pub shortid local cfg log smcfg accepted rejected after_accepted after_rejected local A5_AFTER='' A5_DOWN_BPS='' A5_DOWN_BURST='' A5_UP_BPS='' A5_UP_BURST='' local spid='' net_require_root || return 1 net_require_debian || return 1 net_require_systemd || return 1 net_require_cmds openssl sha256sum curl install flock ss cut unzip mktemp timeout sed || return 1 arch=$(net_alpine_arch) || return 1 [[ -n $target ]] || { net_die '缺少回落目标(显式给出 主机名[:端口],默认端口 443)。'; return 1; } [[ -n $sni ]] || { net_die '缺少 SNI 主机名(必须是回落目标证书覆盖的名字,需显式给出)。'; return 1; } sni=${sni,,} thost=${target%%:*}; tport='' [[ $target == *:* ]] && tport=${target##*:} [[ -n $tport ]] || tport=$NET_A5_DEFAULT_PORT net_valid_target_host "$thost" || { net_die "回落目标格式不正确:$target"; return 1; } net_valid_port "$tport" || { net_die "回落目标端口不正确:$tport"; return 1; } net_valid_hostname "$sni" || { net_die "SNI 格式不正确:$sni"; return 1; } if [[ -n $port ]]; then net_valid_port "$port" || { net_die "监听端口不正确:$port(1-65535,无前导零)"; return 1; } else port=$NET_A5_DEFAULT_PORT fi etc=$(net_a5_etc); bin=$(net_a5_bin); unit=$(net_a5_unit) cfg=$(net_a5_cfg) # 目标能力:写盘前确认,失败即拒绝(不会留半套东西)。 net_err "检查回落目标 $thost:$tport(TLS 1.3 + h2 + 证书覆盖 $sni)…" net_a5_check_target "$thost" "$tport" "$sni" || return 1 net_a5_limits || return 1 # 检查与写盘之间不允许别人插进来。 net_lock a5 || return 1 net_a5_conflict || return 1 if net_port_busy tcp "$port"; then net_err "TCP $port 已被占用:" net_port_lines tcp "$port" | cut -c1-160 >&2 net_die '换端口,或先确认并停止占用方。' return 1 fi printf '即将安装 Xray 服务端(VLESS + TCP + REALITY,systemd):\n' printf ' 版本 : v%s(官方发布,安装前校验 SHA-256)\n' "$NET_A5_VERSION" printf ' 监听 : :%s/tcp(所有地址,IPv4+IPv6)\n' "$port" printf ' 回落 : %s:%s(已验 TLS 1.3 / h2 / 证书覆盖 %s)\n' "$thost" "$tport" "$sni" printf ' 回退限速: 未认证流量 %s MiB 后限 %s KiB/s(下行)/ %s KiB/s(上行),认证连接不限\n' \ "$(( A5_AFTER / 1048576 ))" "$(( A5_DOWN_BPS / 1024 ))" "$(( A5_UP_BPS / 1024 ))" printf ' 单元 : %s\n' "$unit" printf ' 凭据 : 随机 UUID,只显示一次;私钥只写 %s\n' "$cfg" printf ' 防火墙 : 不改动;需自行放行 TCP %s\n' "$port" net_confirm_twice XRAY || return 1 tmp=$(mktemp -d) || { net_die '无法创建临时目录'; return 1; } net_err '--- 开始写入 ---' if [[ -n ${TOOLBOX_A5_ZIP_SRC:-} ]]; then cp -- "$TOOLBOX_A5_ZIP_SRC" "$tmp/xray.zip" || { rm -rf -- "$tmp"; net_die '复制本地压缩包失败'; return 1; } else net_err "下载官方发布文件:$(net_a5_zip_url "$arch")" curl -fsSL --proto '=https' --tlsv1.2 --connect-timeout 15 --max-time 300 \ -o "$tmp/xray.zip" "$(net_a5_zip_url "$arch")" \ || { rm -rf -- "$tmp"; net_die '下载失败(网络或上游不可达)。'; return 1; } fi local actual; actual=$(sha256sum -- "$tmp/xray.zip" | cut -d' ' -f1) [[ $actual == "$(net_a5_sha256 "$arch")" ]] \ || { rm -rf -- "$tmp"; net_die "SHA-256 不匹配:期望 $(net_a5_sha256 "$arch"),实际 $actual。"; return 1; } mkdir -p "$tmp/x" && unzip -oq "$tmp/xray.zip" xray -d "$tmp/x" \ || { rm -rf -- "$tmp"; net_die '解压失败'; return 1; } [[ -s $tmp/x/xray ]] || { rm -rf -- "$tmp"; net_die '压缩包内没有 xray 二进制。'; return 1; } # 凭据用刚校验过的二进制自己生成,避免编码差异。 uuid=$("$tmp/x/xray" uuid 2>/dev/null) || { rm -rf -- "$tmp"; net_die 'uuid 生成失败'; return 1; } net_valid_uuid "$uuid" || { rm -rf -- "$tmp"; net_die 'uuid 输出异常'; return 1; } # 一次调用同时取公私钥:分两次调用会拿到两对不匹配的密钥。 local kpair kpair=$("$tmp/x/xray" x25519 2>/dev/null) || kpair='' priv=$(sed -n 's/^PrivateKey: //p' <<< "$kpair") pub=$(sed -n 's/^Password (PublicKey): //p' <<< "$kpair") [[ -n $priv && -n $pub ]] || { rm -rf -- "$tmp"; net_die 'x25519 密钥生成失败'; return 1; } shortid=$(openssl rand -hex 8) if [[ -d $etc ]]; then net_a5_etc_created=0; else install -d -m 0755 -- "$etc" || { rm -rf -- "$tmp"; net_die '无法创建配置目录'; return 1; } net_a5_etc_created=1 fi install -d -m 0755 -- "$(dirname -- "$bin")" || { rm -rf -- "$tmp"; net_die '无法创建二进制目录'; return 1; } # 配置先落在临时目录,自检通过后才到位:partial 写入不会污染 etc。 cat > "$tmp/config.json" <"$tmp/t.log" 2>&1; then net_err '配置自检失败,xray 输出:' sed -n '1,12p' "$tmp/t.log" >&2 rm -rf -- "$tmp"; net_a5_rollback; return 1 fi cat > "$tmp/unit" < "$smcfg" : > "$log" "$bin" run -c "$smcfg" >"$log" 2>&1 9>&- & spid=$! if ! net_a5_wait_listen "$log"; then net_err '冒烟启动失败(未监听),服务输出:' sed -n '1,12p' "$log" >&2 kill "$spid" 2>/dev/null; wait "$spid" 2>/dev/null || true rm -rf -- "$tmp"; net_a5_rollback; return 1 fi accepted=$(grep -c "accepted tcp:" "$log" 2>/dev/null || true) rejected=$(grep -c "invalid request user id" "$log" 2>/dev/null || true) if ! net_a5_probe "$bin" "$port" "$uuid" "$pub" "$shortid" "$sni" good; then net_err '认证探测失败(正确 UUID 未连通或无数据面)。' sed -n '1,12p' "$log" >&2 kill "$spid" 2>/dev/null; wait "$spid" 2>/dev/null || true rm -rf -- "$tmp"; net_a5_rollback; return 1 fi after_accepted=$(grep -c "accepted tcp:" "$log" 2>/dev/null || true) if (( after_accepted <= accepted )); then net_err '隧道有数据,但服务端日志没有认证通过记录(可能只是回退转发,认证未生效)。' sed -n '1,12p' "$log" >&2 kill "$spid" 2>/dev/null; wait "$spid" 2>/dev/null || true rm -rf -- "$tmp"; net_a5_rollback; return 1 fi # 负向:错误 UUID 必须被服务端明确拒绝。 net_a5_probe "$bin" "$port" '11111111-1111-4111-8111-111111111111' \ "$pub" "$shortid" "$sni" bad || true after_rejected=$(grep -c "invalid request user id" "$log" 2>/dev/null || true) if (( after_rejected <= rejected )); then net_err '错误 UUID 没有被拒绝,认证未生效。' sed -n '1,12p' "$log" >&2 kill "$spid" 2>/dev/null; wait "$spid" 2>/dev/null || true rm -rf -- "$tmp"; net_a5_rollback; return 1 fi printf ' 负向探测:错误 UUID 被拒绝 ✓\n' kill "$spid" 2>/dev/null; wait "$spid" 2>/dev/null || true if ! systemctl daemon-reload; then rm -rf -- "$tmp"; net_a5_rollback; return 1; fi if ! systemctl enable --now "$NET_A5_UNIT_NAME" >/dev/null 2>&1; then net_err 'systemctl enable --now 失败。' rm -rf -- "$tmp"; net_a5_rollback; return 1 fi if ! net_wait_active "$NET_A5_UNIT_NAME"; then net_err '服务未进入 active 状态。' systemctl --no-pager -l status "$NET_A5_UNIT_NAME" >&2 2>&1 || true rm -rf -- "$tmp"; net_a5_rollback; return 1 fi if ! net_wait_owned tcp "$port" "$bin" "$NET_A5_UNIT_NAME"; then net_err "单元显示运行中,但 TCP $port 的监听进程不是 $NET_A5_BIN_NAME。" rm -rf -- "$tmp"; net_a5_rollback; return 1 fi # 真实服务上再验一次(冒烟进程已退出,这里是 systemd 起的那个)。 if ! net_a5_probe "$bin" "$port" "$uuid" "$pub" "$shortid" "$sni" live; then net_err '对已启用的服务再探测失败。' rm -rf -- "$tmp"; net_a5_rollback; return 1 fi printf '%s|%s|%s|%s|%s|%s|%s|%s\n' "$thost" "$tport" "$sni" "$port" "$uuid" "$pub" \ "$shortid" "$NET_A5_VERSION" > "$tmp/meta" || { rm -rf -- "$tmp"; net_die 'meta 写入失败'; return 1; } net_a5_place 0600 "$tmp/meta" "$(net_a5_meta)" || { rm -rf -- "$tmp"; net_a5_rollback; return 1; } rm -rf -- "$tmp" net_a5_report "$thost" "$tport" "$sni" "$port" "$uuid" "$pub" "$shortid" \ "$A5_AFTER" "$A5_DOWN_BPS" "$A5_UP_BPS" } net_a5_report() { local thost=$1 tport=$2 sni=$3 port=$4 uuid=$5 pub=$6 sid=$7 after=$8 dbps=$9 ubps=${10} printf '\n安装完成,服务 %s 已设为开机自启。\n' "$NET_A5_UNIT_NAME" printf '客户端连接信息(只显示这一次,也写入 %s):\n' "$(net_a5_meta)" printf ' vless://%s@%s:%s?type=tcp&security=reality&sni=%s&fp=chrome&pbk=%s&sid=%s&flow=xtls-rprx-vision#terry\n' \ "$uuid" "$sni" "$port" "$sni" "$pub" "$sid" printf '\n验证情况:\n' printf ' [已验证] 回落目标 %s:%s 支持 TLS 1.3、ALPN h2,证书覆盖 %s\n' "$thost" "$tport" "$sni" printf ' [已验证] 单元 active、TCP %s 由 %s 监听\n' "$port" "$NET_A5_BIN_NAME" printf ' [已验证] 正确 UUID 经 REALITY 建立隧道并有数据面响应;错误 UUID 被服务端拒绝\n' printf ' [已限制] 未认证回退流量:%s MiB 后限 %s KiB/s 下行、%s KiB/s 上行\n' \ "$(( after / 1048576 ))" "$(( dbps / 1024 ))" "$(( ubps / 1024 ))" printf '\n仍需人工处理:\n' printf ' - 防火墙/安全组放行 TCP %s(本模块不改防火墙)\n' "$port" printf ' - 目标证书更新无需改动;目标站点关停/换证书后需重装换目标\n' printf ' - 卸载:A5 → 2\n' } # ---------------------------------------------------------------- 卸载与状态 _net_a5_uninstall() { local unit etc bin state answer net_require_root || return 1 net_require_systemd || return 1 unit=$(net_a5_unit); etc=$(net_a5_etc); bin=$(net_a5_bin) state=$(net_unit_state "$unit" "$bin") if [[ $state == absent && ! -e $bin && ! -d $etc ]]; then net_die '没有找到本模块的安装(单元、二进制、配置都不存在)。' return 1 fi if [[ $state == foreign ]]; then net_die "$unit 不是本模块生成的,未删除;请人工确认。" return 1 fi printf '将停止并删除:\n %s\n %s\n %s\n' "$unit" "$bin" "$etc" net_confirm_once || return 1 net_lock a5 || return 1 # 只停/删我们的单元(ExecStart 指向我们的二进制)。 if [[ -e $unit ]] && grep -qF "ExecStart=$bin" "$unit" 2>/dev/null; then systemctl disable --now "$NET_A5_UNIT_NAME" >/dev/null 2>&1 || true rm -f -- "$unit" fi systemctl daemon-reload >/dev/null 2>&1 || true systemctl reset-failed "$NET_A5_UNIT_NAME" >/dev/null 2>&1 || true rm -f -- "$bin" [[ -d $etc ]] && rm -rf -- "$etc" rm -rf -- "$(net_a5_client)" printf '已卸载。\n' } net_a5_status() { local unit meta thost tport sni port uuid pub sid version bin unit=$(net_a5_unit); meta=$(net_a5_meta); bin=$(net_a5_bin) if [[ ! -e $unit ]]; then printf '未安装。\n' return 1 fi printf '单元: %s(%s)\n' "$unit" "$(net_unit_state "$unit" "$bin")" systemctl --no-pager -l status "$NET_A5_UNIT_NAME" 2>&1 | sed -n '1,6p' if [[ -f $meta ]]; then IFS='|' read -r thost tport sni port uuid pub sid version < "$meta" || true printf '回落目标: %s:%s(SNI %s)\n' "${thost:-?}" "${tport:-?}" "${sni:-?}" printf '版本: %s\n' "${version:-?}" if [[ -n ${port:-} ]]; then printf 'TCP %s 监听: ' "$port" if net_port_owned_by tcp "$port" "$bin" "$NET_A5_UNIT_NAME"; then printf '由本服务持有\n' else printf '不是本服务\n' fi fi else printf '缺少 meta(目标/凭据未知)。\n' fi } # ================================================================ B23 DNS 切换 # # 只在 systemd-resolved 真正接管解析时动手:resolvectl 存在、服务 active、 # stub-resolv.conf 存在,且 /etc/resolv.conf 确实指向 stub。其他解析器 # (NetworkManager、openresolv、手写文件)一律 fail closed。 # 持久化用 resolved.conf.d drop-in + 重启 systemd-resolved;同时按默认路由链路 # 写运行期设置,覆盖 DHCP 下发。成功判据是 resolvectl status 与 query 都能过。 net_dns_dropin() { printf '%s/%s\n' "${TOOLBOX_DNS_DROPIN_DIR:-/etc/systemd/resolved.conf.d}" '99-terry-toolbox-dns.conf'; } net_dns_resolv() { printf '%s\n' "${TOOLBOX_DNS_RESOLV_CONF:-/etc/resolv.conf}"; } net_dns_run() { printf '%s\n' "${TOOLBOX_DNS_RESOLVE_RUN:-/run/systemd/resolve}"; } net_dns_require_resolved() { command -v resolvectl >/dev/null 2>&1 || { net_die '未找到 resolvectl,本机没有 systemd-resolved。'; return 1; } systemctl is-active --quiet systemd-resolved 2>/dev/null \ || { net_die 'systemd-resolved 未运行(容器或被禁用)。'; return 1; } [[ -e $(net_dns_run)/stub-resolv.conf ]] \ || { net_die "缺少 $(net_dns_run)/stub-resolv.conf,无法确认由 systemd-resolved 接管。"; return 1; } } net_dns_check_resolv() { local conf target conf=$(net_dns_resolv) [[ -e $conf || -L $conf ]] || { net_die "$conf 不存在。"; return 1; } [[ -L $conf ]] || { net_die "$conf 是普通文件(不是 systemd-resolved 的 stub 链接),解析权不在 resolved。"; return 1; } target=$(readlink -- "$conf") case $target in *systemd/resolve/stub-resolv.conf|*systemd/resolve/resolv.conf) return 0 ;; *) net_die "$conf 指向 $target,不是 systemd-resolved 的 stub,被其他程序接管。" ;; esac } # 默认路由链路。新版 systemd 直接给 "Default Route: yes" 字段,用它最准确; # 旧版只能看 Protocols 里的 +DefaultRoute,此时再排除 Current Scopes: none # (tailscale 之类会挂 +DefaultRoute 但没有 DNS)。多个或没有就返回空。 net_dns_default_link() { local out line name='' default='' count=0 scopes_none=0 has_field=0 dns_links out=$(resolvectl status 2>/dev/null) || return 0 # 该字段在 Protocol 之后才出现,先单独收集有 DNS 的链路,避免顺序依赖。 dns_links=" $(net_dns_links_with_dns | tr '\n' ' ') " grep -qE '^[[:space:]]*Default Route:' <<< "$out" && has_field=1 while IFS= read -r line; do if [[ $line =~ ^[[:space:]]*Link\ ([0-9]+)\ \((.+)\)$ ]]; then name=${BASH_REMATCH[2]}; name=${name%%@*} scopes_none=0 continue fi [[ -z $name ]] && continue # 有默认路由但没有 DNS 的链路(tailscale、docker 网桥)不算可用的出口链路。 [[ $dns_links == *" $name "* ]] || continue if [[ $line == *'Current Scopes:'* && $line == *none* ]]; then scopes_none=1 continue fi if (( has_field )); then if [[ $line =~ ^[[:space:]]*Default\ Route:[[:space:]]*yes ]]; then default=$name; ((count += 1)) fi elif [[ $line == *'+DefaultRoute'* && $scopes_none == 0 ]]; then default=$name; ((count += 1)) fi done <<< "$out" (( count == 1 )) && printf '%s\n' "$default" return 0 } # 某条链路上当前的 DNS 服务器(空格分隔,可能为空)。 net_dns_link_servers() { local line name='' out='' while IFS= read -r line; do if [[ $line =~ ^[[:space:]]*Link\ ([0-9]+)\ \((.+)\)$ ]]; then name=${BASH_REMATCH[2]}; name=${name%%@*} continue fi if [[ $name == "$1" && $line =~ ^[[:space:]]*DNS\ Servers:[[:space:]]*(.*)$ ]]; then out=${BASH_REMATCH[1]} fi done < <(resolvectl status 2>/dev/null) printf '%s' "$out" } # 部分 systemd 版本的 `resolvectl revert` 不接受空参数(会报 Interface argument # required),所以要逐条链路退回,但只碰「DNS 里含我们写入的服务器」的链路。 net_dns_revert_ours() { # servers... local our=" $* " link srv s hit while IFS= read -r link; do [[ -n $link ]] || continue srv=$(net_dns_link_servers "$link") hit=0 for s in $srv; do [[ $our == *" $s "* ]] && { hit=1; break; } done (( hit )) && resolvectl revert "$link" >/dev/null 2>&1 || true done < <(net_dns_links_with_dns) } # 配置了 DNS 服务器的链路名(一行一个),只用于判断「这条链路能不能当出口」。 net_dns_links_with_dns() { local line name='' while IFS= read -r line; do if [[ $line =~ ^[[:space:]]*Link\ ([0-9]+)\ \((.+)\)$ ]]; then name=${BASH_REMATCH[2]}; name=${name%%@*} continue fi if [[ -n $name && $line =~ ^[[:space:]]*(Current\ DNS\ Server|DNS\ Servers): ]]; then printf '%s\n' "$name" fi done < <(resolvectl status 2>/dev/null) } # 其他链路自带的 DNS:Domains=~. 下它们只在本域生效,但仍值得点名。 net_dns_leak_links() { # [跳过本链路] local line name='' found='' servers while IFS= read -r line; do if [[ $line =~ ^[[:space:]]*Link\ ([0-9]+)\ \((.+)\)$ ]]; then name=${BASH_REMATCH[2]}; name=${name%%@*} continue fi if [[ $line =~ ^[[:space:]]*(Current\ DNS\ Server|DNS\ Servers):[[:space:]]*(.*)$ ]]; then servers=${BASH_REMATCH[2]} [[ -n $name && -n $servers ]] || continue [[ -n ${1:-} && $name == "$1" ]] && continue case " $found " in *" $name "*) ;; *) found+="$name "; printf '%s(%s) ' "$name" "$servers" ;; esac fi done < <(resolvectl status 2>/dev/null) } net_dns_preset() { case $1 in 1) printf '1.1.1.1 1.0.0.1' ;; 2) printf '8.8.8.8 8.8.4.4' ;; 3) printf '9.9.9.9 149.112.112.112' ;; 4) printf '223.5.5.5 223.6.6.6' ;; 5) printf '119.29.29.29 182.254.116.116' ;; *) return 1 ;; esac } net_valid_ip() { net_valid_ipv4 "$1" || net_valid_ipv6 "$1"; } net_valid_ipv4() { local o [[ $1 =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ ]] || return 1 local IFS=. for o in $1; do (( o <= 255 )) || return 1; done } net_valid_ipv6() { [[ $1 == *:* && $1 =~ ^[0-9a-fA-F:.]+$ ]] || return 1 [[ $1 == *::* || $(grep -o ':' <<< "$1" | wc -l) -eq 7 ]] } net_dns_write() { # servers... local file tmp file=$(net_dns_dropin) install -d -m 0755 "$(dirname -- "$file")" || { net_die "无法创建 $(dirname -- "$file")"; return 1; } # 只保留第一次的原始备份:多次切换后回滚仍回到本工具接管前的状态。 if [[ -e $file && ! -e $file.pre-toolbox ]]; then cp -p -- "$file" "$file.pre-toolbox" || { net_die '备份现有配置失败。'; return 1; } fi tmp=$file.new.$$ { printf '# 由 Terry Toolbox B23 生成;恢复请走 B23 → 7,或删除本文件后重启 systemd-resolved。\n' printf '[Resolve]\nDNS=%s\nDomains=~.\n' "$*" } > "$tmp" || { net_die '写入临时配置失败。'; return 1; } chmod 0644 "$tmp" && mv -f -- "$tmp" "$file" \ || { rm -f -- "$tmp"; net_die '替换配置失败。'; return 1; } } net_dns_restart_resolved() { local i systemctl restart systemd-resolved >/dev/null 2>&1 || { net_die '重启 systemd-resolved 失败。'; return 1; } for ((i = 0; i < 20; i++)); do systemctl is-active --quiet systemd-resolved 2>/dev/null && return 0 sleep 0.3 done net_die 'systemd-resolved 重启后未回到 active。' } net_dns_rollback() { local file link=${1:-} file=$(net_dns_dropin) net_err '--- 开始回滚 DNS ---' if [[ -e $file.pre-toolbox ]]; then mv -f -- "$file.pre-toolbox" "$file" || net_err "警告:还原 $file 失败,请人工检查。" else rm -f -- "$file" fi [[ -n $link ]] && resolvectl revert "$link" >/dev/null 2>&1 || true systemctl restart systemd-resolved >/dev/null 2>&1 \ || net_err '警告:重启 systemd-resolved 失败,请人工确认。' net_err '已恢复原有 DNS 配置。' } # 成功判据:status 里必须能看到每个服务器,query 也必须真的能解析。 net_dns_verify() { local out s i out=$(resolvectl status 2>/dev/null) || { net_die '读取 resolvectl status 失败。'; return 1; } for s in "$@"; do grep -qF -- "$s" <<< "$out" || { net_die "resolvectl status 里看不到 $s,配置未生效。"; return 1; } done for ((i = 0; i < 3; i++)); do resolvectl query www.cloudflare.com >/dev/null 2>&1 && return 0 sleep 1 done net_die '解析查询仍然失败(服务器不可达或被拦截)。' } _net_dns_apply() { local servers=("$@") link='' s other had_backup=0 net_require_root || return 1 net_dns_require_resolved || return 1 net_dns_check_resolv || return 1 (( ${#servers[@]} > 0 )) || { net_die '没有要设置的 DNS 服务器。'; return 1; } (( ${#servers[@]} <= 2 )) || { net_die '最多两个 DNS 服务器。'; return 1; } for s in "${servers[@]}"; do net_valid_ip "$s" || { net_die "不是合法的 IPv4/IPv6 地址:$s"; return 1; } done link=$(net_dns_default_link) printf '即将修改本机 DNS:\n' for s in "${servers[@]}"; do printf ' DNS 服务器: %s\n' "$s"; done printf ' 配置文件 : %s(drop-in,重启后仍生效)\n' "$(net_dns_dropin)" if [[ -n $link ]]; then printf ' 作用链路 : %s(默认路由;全局与链路同时写,覆盖 DHCP 下发)\n' "$link" else printf ' 作用链路 : 未能唯一定位默认路由,只写全局(链路自带 DNS 可能优先)\n' fi printf ' 影响 : 会重启 systemd-resolved,DNS 可能短暂中断;明文 DNS(非 DoT/DoH)\n' net_confirm_once || return 1 net_lock dns || return 1 [[ -e $(net_dns_dropin).pre-toolbox ]] && had_backup=1 if ! net_dns_write "${servers[@]}"; then # 写失败时只在本次新建了备份的情况下清掉,避免丢掉更早的原始备份。 (( had_backup )) || rm -f -- "$(net_dns_dropin).pre-toolbox" return 1 fi if ! net_dns_restart_resolved; then net_dns_rollback "$link" return 1 fi if [[ -n $link ]]; then resolvectl dns "$link" "${servers[@]}" >/dev/null 2>&1 \ || { net_err "resolvectl dns $link 失败。"; net_dns_rollback "$link"; return 1; } resolvectl domain "$link" '~.' >/dev/null 2>&1 \ || { net_err "resolvectl domain $link 失败。"; net_dns_rollback "$link"; return 1; } fi if ! net_dns_verify "${servers[@]}"; then net_dns_rollback "$link" return 1 fi printf '\nDNS 已切换并验证通过:\n' for s in "${servers[@]}"; do printf ' [已验证] %s 出现在 resolvectl status\n' "$s"; done printf ' [已验证] resolvectl query 可以正常解析\n' printf '\n剩余泄漏面(本模块不处理,需自行评估):\n' other=$(net_dns_leak_links "$link") if [[ -n $other ]]; then printf ' - 其他链路仍有自己的 DNS,命中其域名的查询可能走它们:%s\n' "$other" else printf ' - 当前没发现其他链路自带 DNS;以后插网卡/VPN 仍可能新增\n' fi printf ' - 明文 DNS,可被中间人看到;需要加密请另配 DNS over TLS\n' printf ' - 容器、自带解析器的应用(docker、nginx 缓存、部分 VPN 客户端)不受本设置约束\n' printf ' - 恢复原状:B23 → 7(删除 %s 并重启 systemd-resolved)\n' "$(net_dns_dropin)" } net_dns_status() { local file other file=$(net_dns_dropin) printf 'systemd-resolved: ' if systemctl is-active --quiet systemd-resolved 2>/dev/null; then printf '运行中\n'; else printf '未运行\n'; fi printf '%s -> %s\n' "$(net_dns_resolv)" \ "$(readlink -- "$(net_dns_resolv)" 2>/dev/null || printf '(普通文件)')" if [[ -e $file ]]; then printf '本工具配置: %s\n' "$file" sed -n '1,10p' "$file" else printf '本工具配置: 无(当前 DNS 不是本工具设置的)\n' fi printf '\n当前解析状态:\n' resolvectl status 2>/dev/null | sed -n '1,25p' other=$(net_dns_leak_links) [[ -n $other ]] && printf '\n其他链路 DNS(潜在泄漏):%s\n' "$other" return 0 } _net_dns_restore() { local file ours='' net_require_root || return 1 net_dns_require_resolved || return 1 file=$(net_dns_dropin) if [[ ! -e $file && ! -e $file.pre-toolbox ]]; then net_die '没有本工具写入的 DNS 配置,无需恢复。' return 1 fi # 恢复过备份后文件还在,但已经不含本工具的标记;再恢复一次就会误删别人的配置。 if [[ -e $file && ! -e $file.pre-toolbox ]] \ && ! grep -qF '由 Terry Toolbox B23 生成' "$file" 2>/dev/null; then net_die "$file 已不是本工具生成的配置,未删除;请人工确认。" return 1 fi printf '将删除 %s 并重启 systemd-resolved(DNS 可能短暂中断)。\n' "$file" net_confirm_once || return 1 ours=$(sed -n 's/^DNS=//p' "$file" 2>/dev/null) || ours='' if [[ -e $file.pre-toolbox ]]; then mv -f -- "$file.pre-toolbox" "$file" || { net_die '还原备份失败。'; return 1; } else rm -f -- "$file" fi # shellcheck disable=SC2086 net_dns_revert_ours $ours net_dns_restart_resolved || return 1 printf '已恢复。当前解析状态:\n' resolvectl status 2>/dev/null | sed -n '1,15p' } # B23 入口:直接调用时接受 install/restore/status,不带动作时进预置菜单。 toolbox_network_dns() { local c='' input s local -a servers=() if (( $# > 0 )); then case $1 in install) shift (( $# > 0 )) || { net_die '用法: toolbox_network B23 install [DNS2]'; return 1; } ( set -e; _net_dns_apply "$@" ) ;; restore) ( set -e; _net_dns_restore ) ;; status) net_dns_status ;; *) net_die '用法: toolbox_network B23 [install [DNS2] | restore | status]' ;; esac return $? fi printf 'B23 DNS 切换(仅 systemd-resolved)\n' >&2 printf ' 1 Cloudflare 1.1.1.1/1.0.0.1\n 2 Google 8.8.8.8/8.8.4.4\n' >&2 printf ' 3 Quad9 9.9.9.9/149.112.112.112\n 4 阿里 223.5.5.5/223.6.6.6\n' >&2 printf ' 5 腾讯 119.29.29.29/182.254.116.116\n 6 自定义\n 7 恢复原状\n 8 查看状态\n请输入编号:' >&2 IFS= read -r c || return 1 case $c in 1|2|3|4|5) for s in $(net_dns_preset "$c"); do servers+=("$s"); done ( set -e; _net_dns_apply "${servers[@]}" ) ;; 6) printf '输入 1-2 个 IPv4/IPv6 地址(空格或逗号分隔):' IFS= read -r input || return 1 input=${input//,/ } for s in $input; do servers+=("$s"); done ( set -e; _net_dns_apply "${servers[@]}" ) ;; 7) ( set -e; _net_dns_restore ) ;; 8) net_dns_status ;; *) net_die '无效选项。' ;; esac } # ================================================================ 入口 toolbox_network() { if (( $# < 1 || $# > 6 )); then printf '用法: toolbox_network \n' >&2 printf ' toolbox_network A1 [install <证书> <私钥> <主机名> [端口] | uninstall | status]\n' >&2 printf ' toolbox_network A2 [install [端口] [回落目标] | uninstall | status]\n' >&2 printf ' toolbox_network A3 [install|uninstall|status] [证书] [私钥] [主机名] [端口]\n' >&2 printf ' toolbox_network A5 [install <回落目标> [端口] | uninstall | status]\n' >&2 printf ' toolbox_network B12 [install|uninstall|status]\n' >&2 printf ' toolbox_network B23 [install | restore | status]\n' >&2 printf ' toolbox_network B31 [install|uninstall|status] <本地端口> [目标主机] [目标端口]\n' >&2 return 2 fi local key=$1 action description shift || true case $key in A1) action=${1:-} if [[ -z $action ]]; then action=$(net_submenu 'A1 Alpine Hysteria 2') || return 1 set -- fi shift || true case $action in install) ( set -e; _net_a1_install "$@" ) ;; uninstall) ( set -e; net_alpine_uninstall "$NET_ALPINE_HY2_NAME" "$(net_a1_init)" \ "$(net_a1_bin)" "$(net_a1_etc)" "$(net_a1_pid)" "$(net_a1_client)" 'A1' a1 ) ;; status) net_alpine_status "$NET_ALPINE_HY2_NAME" "$(net_a1_init)" \ "$(net_a1_bin)" "$(net_a1_etc)" udp ;; *) net_die '用法: toolbox_network A1 [install <证书> <私钥> <主机名> [端口] | uninstall | status]' ;; esac return $? ;; A2) action=${1:-} if [[ -z $action ]]; then action=$(net_submenu 'A2 Alpine Xray') || return 1 set -- fi shift || true case $action in install) ( set -e; _net_a2_install "$@" ) ;; uninstall) ( set -e; net_alpine_uninstall "$NET_ALPINE_XRAY_NAME" "$(net_a2_init)" \ "$(net_a2_bin)" "$(net_a2_etc)" "$(net_a2_pid)" "$(net_a2_client)" 'A2' a2 ) ;; status) net_alpine_status "$NET_ALPINE_XRAY_NAME" "$(net_a2_init)" \ "$(net_a2_bin)" "$(net_a2_etc)" tcp ;; *) net_die '用法: toolbox_network A2 [install [端口] [回落目标] | uninstall | status]' ;; esac return $? ;; A3) action=${1:-} if [[ -z $action ]]; then action=$(net_submenu 'A3 TUIC') || return 1 set -- fi shift || true case $action in install) ( set -e; _net_tuic_install "$@" ) ;; uninstall) ( set -e; _net_tuic_uninstall ) ;; status) net_tuic_status ;; *) net_die "用法: toolbox_network A3 [install <证书> <私钥> <主机名> [端口] | uninstall | status]" ;; esac return $? ;; A4) toolbox_network_hy2 "$@"; return $? ;; A5) action=${1:-} if [[ -z $action ]]; then action=$(net_submenu 'A5 Xray (Debian)') || return 1 set -- fi shift || true case $action in install) ( set -e; _net_a5_install "$@" ) ;; uninstall) ( set -e; _net_a5_uninstall ) ;; status) net_a5_status ;; *) net_die '用法: toolbox_network A5 [install <回落目标> [端口] | uninstall | status]' ;; esac return $? ;; B12) action=${1:-} if [[ -z $action ]]; then action=$(net_submenu 'B12 TCP Brutal') || return 1 set -- fi shift || true case $action in install) ( set -e; _net_brutal_install ) ;; uninstall) ( set -e; _net_brutal_uninstall ) ;; status) net_brutal_status ;; *) net_die '用法: toolbox_network B12 [install | uninstall | status]' ;; esac return $? ;; B23) toolbox_network_dns "$@"; return $? ;; B31) action=${1:-} if [[ -z $action ]]; then action=$(net_submenu 'B31 Relay 转发') || return 1 set -- fi shift || true case $action in install) ( set -e; _net_relay_install "$@" ) ;; uninstall) ( set -e; _net_relay_uninstall "$@" ) ;; status) net_relay_status ;; *) net_die '用法: toolbox_network B31 [install <本地端口> <目标> <目标端口> | uninstall [tcp|udp 端口 | all] | status]' ;; esac return $? ;; *) printf '未知网络操作: %s\n' "$key" >&2; return 2 ;; esac printf '%s (%s): 未实现,未执行任何安装或系统修改。\n' "$key" "$description" >&2 printf '缺少可验证且不会覆盖已有服务的安全安装路径;请勿使用来源脚本直接执行。\n' >&2 return 1 } # END bundled module: network # BEGIN bundled module: system #!/usr/bin/env bash # Source-only system management module. No work is performed until toolbox_system. _toolbox_system_error() { printf '%s\n' "$*" >&2; } _toolbox_system_unimplemented() { _toolbox_system_error "$1:尚未实现;未执行任何更改。" return 1 } _toolbox_system_root() { if [[ $(id -u) != 0 ]]; then _toolbox_system_error '此操作需要 root。'; return 1; fi } _toolbox_system_confirm() { local answer printf '%s\n' "$1" >&2 printf '输入 YES 确认: ' >&2 IFS= read -r answer && [[ $answer == YES ]] || { _toolbox_system_error '已取消。'; return 1; } printf '再次输入 YES 确认: ' >&2 IFS= read -r answer && [[ $answer == YES ]] || { _toolbox_system_error '已取消。'; return 1; } } _toolbox_system_os() { # Parse OS metadata as data rather than executing shell from /etc/os-release. local detected detected=$(toolbox_network_detect_os) || return case "$detected" in alpine) printf 'alpine\n';; debian|ubuntu) printf 'debian\n';; *) _toolbox_system_error '不支持的系统'; return 1;; esac } # Package-manager and service-manager boundaries are indirected so tests can # replace them without touching the host. _toolbox_system_has() { command -v "$1" >/dev/null 2>&1; } _toolbox_system_need_tools() { local c missing=() for c in "$@"; do _toolbox_system_has "$c" || missing+=("$c"); done (( ${#missing[@]} == 0 )) || { _toolbox_system_error "缺少必需命令:${missing[*]}"; return 1; } } _toolbox_system_systemd() { _toolbox_system_has systemctl || { _toolbox_system_error '未找到 systemctl,本机不是 systemd 系统。'; return 1; } [[ -d ${TOOLBOX_SYSTEMD_RUN:-/run/systemd/system} ]] \ || { _toolbox_system_error 'systemd 未作为 init 运行(缺少 /run/systemd/system)。'; return 1; } } # Paths are overridable so isolated tests never write outside a temp root. _toolbox_system_etc() { printf '%s\n' "${TOOLBOX_SYSTEM_ETC:-/etc/terry-toolbox}"; } _toolbox_system_state() { printf '%s\n' "${TOOLBOX_SYSTEM_STATE:-/var/lib/terry-toolbox}"; } _toolbox_system_unit_dir() { printf '%s\n' "${TOOLBOX_SYSTEM_UNIT_DIR:-/etc/systemd/system}"; } _toolbox_system_bin_dir() { printf '%s\n' "${TOOLBOX_SYSTEM_BIN_DIR:-/usr/local/bin}"; } _toolbox_system_sysctl_dir(){ printf '%s\n' "${TOOLBOX_SYSTEM_SYSCTL_DIR:-/etc/sysctl.d}"; } # Ownership markers: a file without the marker is treated as foreign and is # never overwritten, only reported. readonly _TOOLBOX_SYS_MARK_B7='# terry-toolbox B7 traffic config' readonly _TOOLBOX_SYS_MARK_B15='# terry-toolbox B15 telegram config' readonly _TOOLBOX_SYS_MARK_B16='# Managed by terry-toolbox (B16)' readonly _TOOLBOX_SYS_MARK_B19='# terry-toolbox B19 port-hop' _toolbox_system_ours() { local file=$1 marker=$2 [[ -f $file ]] && grep -qxF -- "$marker" "$file" 2>/dev/null } # Print a file atomically next to its target (same filesystem), mode 0600/0644. _toolbox_system_put() { local target=$1 mode=$2 tmp tmp="${target}.tmp.$$" cat > "$tmp" || { rm -f -- "$tmp"; return 1; } chmod "$mode" -- "$tmp" || { rm -f -- "$tmp"; return 1; } mv -f -- "$tmp" "$target" } # Hidden input: value never appears in the terminal echo or in logs. _toolbox_system_read_secret() { local __value printf '%s: ' "$2" >&2 IFS= read -rs __value || { printf '\n' >&2; _toolbox_system_error "$2 未输入。"; return 1; } printf '\n' >&2 [[ -n $__value ]] || { _toolbox_system_error "$2 不能为空。"; return 1; } printf -v "$1" '%s' "$__value" } _toolbox_system_read_line() { local __value printf '%s: ' "$2" >&2 IFS= read -r __value || { _toolbox_system_error "$2 未输入。"; return 1; } [[ -n $__value ]] || { _toolbox_system_error "$2 不能为空。"; return 1; } printf -v "$1" '%s' "$__value" } _toolbox_system_positive_int() { [[ $1 =~ ^[1-9][0-9]{0,6}$ ]]; } _toolbox_system_deps() { local os _toolbox_system_root || return os=$(_toolbox_system_os) || return _toolbox_system_confirm "将通过 $os 系统包管理器安装 curl socat wget sudo nano iptables,可能触发依赖升级。" || return case "$os" in alpine) apk add --no-cache curl socat wget sudo nano iptables;; debian) apt-get install -y curl socat wget sudo nano iptables;; *) _toolbox_system_error '不支持的包管理器'; return 1;; esac } _toolbox_system_bbr() { local available current _toolbox_system_root || return available=$(sysctl -n net.ipv4.tcp_available_congestion_control) || return case " $available " in *' bbr '*) ;; *) _toolbox_system_error '内核当前未提供 BBR;未改动。'; return 1;; esac current=$(sysctl -n net.ipv4.tcp_congestion_control) || return if [[ $current == bbr ]]; then printf 'BBR 已启用。\n'; return 0; fi _toolbox_system_confirm "将当前 TCP 拥塞控制从 $current 切换到 bbr(仅当前运行期,不写持久配置);回滚: sysctl -w net.ipv4.tcp_congestion_control=$current" || return sysctl -w net.ipv4.tcp_congestion_control=bbr || return current=$(sysctl -n net.ipv4.tcp_congestion_control) || return [[ $current == bbr ]] || { _toolbox_system_error '设置后验证失败;请检查系统状态。'; return 1; } printf 'BBR 已启用(重启后可能失效)。\n' } _toolbox_system_docker() { local os _toolbox_system_root || return if _toolbox_system_has docker; then _toolbox_system_error 'Docker 已存在;为保护现有服务和数据不进行安装或重配置。'; return 1 fi os=$(_toolbox_system_os) || return _toolbox_system_confirm '将使用系统仓库安装 Docker;包安装可能启动服务或改变防火墙。不会删除容器和数据。' || return case "$os" in debian) apt-get install -y docker.io;; alpine) apk add --no-cache docker docker-cli-compose;; *) return 1;; esac || return _toolbox_system_has docker || { _toolbox_system_error '安装命令已完成但 docker 不可用;请检查包状态。'; return 1; } printf 'Docker 包已安装;服务状态及网络尚需单独检查。\n' } _toolbox_system_password() { _toolbox_system_root || return _toolbox_system_confirm '将交由 passwd 交互式修改 root 密码。请确保保留独立登录方式。' || return passwd root } _toolbox_system_reboot() { _toolbox_system_root || return _toolbox_system_confirm '将立即重启本机,中断所有服务和连接。' || return reboot } # ---------------------------------------------------------------- B2 NodeQuality # 上游没有版本号,只按 main 分支发布;脚本本体因此固定到提交号,摘要写死在代码里。 # raw.githubusercontent 对完整提交号是不可变地址:上游更新后摘要不变,但需要人改这里 # 的两行常量才会跟随(不自动更新)。摘要只证明下载内容与该提交一致,不是安全审计。 readonly _TOOLBOX_NODEQUALITY_COMMIT='a92fca6c0067df29ddd03fdc2fee6f3000f64545' readonly _TOOLBOX_NODEQUALITY_URL=\ "https://raw.githubusercontent.com/LloydAsp/NodeQuality/$_TOOLBOX_NODEQUALITY_COMMIT/NodeQuality.sh" readonly _TOOLBOX_NODEQUALITY_SHA256='4e1b25894cadf908ef61fb0d9ce874a75524c6dafc2ea26f0477107288e0c018' # 地址与摘要均可被环境变量覆盖:离线测试用,也可在上游更新后临时对照实际摘要。 _toolbox_system_nodequality_url() { printf '%s\n' "${TOOLBOX_NODEQUALITY_URL:-$_TOOLBOX_NODEQUALITY_URL}"; } _toolbox_system_nodequality_sha() { printf '%s\n' "${TOOLBOX_NODEQUALITY_SHA256:-$_TOOLBOX_NODEQUALITY_SHA256}"; } # 上游 post_cleanup 固定以 exit 1 结束,成功与否要看输出是否走到上传完成。 _toolbox_system_nodequality() { local tmp script actual expected rc=0 left failed=0 _toolbox_system_root || return _toolbox_system_need_tools bash curl sha256sum tar mount chroot || return printf 'NodeQuality:官方综合测试(不安装软件,只在临时系统内跑一遍)\n' printf ' 脚本地址 : %s\n' "$(_toolbox_system_nodequality_url)" printf ' 固定提交 : %s\n' "$_TOOLBOX_NODEQUALITY_COMMIT" printf ' 校验 : 下载后比对固定 SHA-256 %s…(不匹配即中止,不执行)\n' \ "$(_toolbox_system_nodequality_sha | cut -c1-12)" printf ' 依据 : 摘要只证明文件与该提交一致,不等于安全审计;上游更新需改代码里的常量\n' printf ' 运行方式 : 在临时目录放脚本,chroot 挂载 BenchOs,测完自动卸载并删除该目录\n' printf ' 仍在变动 : BenchOS(release v0.0.2)与 main 分支的 part/*.sh、各检查脚本每次运行现下载\n' printf ' 上传 : 结果 zip 会上传到 api.nodequality.com(第三方接口,可能失败)\n' printf ' 退出码 : 上游结尾固定 exit 1,返回 1 属正常;以输出是否走到上传完成为准\n' printf ' 影响面 : 需要 root;运行期短暂挂载 proc/sys/dev,结束后卸载;不改系统配置\n' _toolbox_system_confirm '确认下载固定版本脚本,校验通过后在临时目录执行?' || return tmp=$(mktemp -d) || return 1 script="$tmp/NodeQuality.sh" if ! curl -fsSL --proto '=https' --tlsv1.2 --connect-timeout 15 --max-time 60 \ -o "$script" "$(_toolbox_system_nodequality_url)"; then _toolbox_system_error '下载脚本失败;未执行。' rm -rf -- "$tmp"; return 1 fi actual=$(sha256sum -- "$script" | cut -d' ' -f1) expected=$(_toolbox_system_nodequality_sha) if [[ $actual != "$expected" ]]; then _toolbox_system_error "脚本 SHA-256 不匹配:期望 $expected,实际 $actual。未执行。" _toolbox_system_error "上游可能已更新;核对无误后可临时用 TOOLBOX_NODEQUALITY_SHA256=$actual 复跑。" _toolbox_system_error "已保留下载文件供人工审阅:$script" return 1 fi printf '校验通过(%s…)。开始执行,以下输出直接来自上游脚本,中途会提问。\n\n' "${actual:0:12}" # 上游以当前目录为基准建 .nodequality<时间戳> 工作目录,故必须先进临时目录。 ( cd "$tmp" && bash ./NodeQuality.sh ) || rc=$? if (( rc != 0 && rc != 1 )); then _toolbox_system_error "上游脚本异常退出($rc);可能只跑了一部分。" failed=1 fi # 正常收尾会删掉工作目录;残留说明卸载或清理没成功,保留临时目录供检查。 left=$(find "$tmp" -maxdepth 1 -name '.nodequality*' -print -quit 2>/dev/null) if [[ -n $left ]]; then _toolbox_system_error "检测到未清理的工作目录 $left:可能挂载残留,重启后确认再删除。" _toolbox_system_error "临时目录保留在 $tmp(含 error.log,若有)。" return 1 fi rm -rf -- "$tmp" if (( failed )); then return 1; fi printf '\nNodeQuality 已结束:结果已尝试上传,临时目录已删除,本机配置未改动。\n' printf '若上传失败,输出里会显示上传接口报错,需重跑;屏幕上的报告不会自动保存。\n' } _toolbox_system_trace() { local destination label failed=0 _toolbox_system_has traceroute || { _toolbox_system_error '缺少 traceroute;请先手动安装。'; return 1; } # Published example targets, not an assurance of current carrier ownership: # https://github.com/zhucaidan/mtr_trace (README, Beijing destinations). _toolbox_system_confirm '将发送 UDP traceroute 探测到北京电信 219.141.136.12、联通 202.106.50.1、移动 221.179.155.161;目标可能变更或不响应。输出是本机至目标的 IPv4 路由,不自动判断线路。' || return while IFS=' ' read -r label destination; do printf '\n=== %s (%s) ===\n' "$label" "$destination" if ! traceroute -n -m 20 -w 2 -q 1 "$destination"; then _toolbox_system_error "$label 探测命令失败。" failed=1 fi done <<'TARGETS' 北京电信 219.141.136.12 北京联通 202.106.50.1 北京移动 221.179.155.161 TARGETS printf '\n提示:命令成功不代表目标可达;星号可能是丢包或设备不响应。\n' (( failed == 0 )) } # ---------------------------------------------------------------- B7 流量统计 # vnstat 只读取内核计数,不进入转发路径,也不修改防火墙;因此这里只做统计和 # 阈值告警,不在超额时阻断网络。阈值告警由 systemd timer 每小时调用一次, # 同一自然日最多告警一次,避免刷屏。 _TOOLBOX_TRAFFIC_CHECK='terry-toolbox-traffic-check' _TOOLBOX_TRAFFIC_SERVICE='terry-toolbox-traffic.service' _TOOLBOX_TRAFFIC_TIMER='terry-toolbox-traffic.timer' _toolbox_system_vnstat_json_ok() { local out out=$(vnstat --json 2>/dev/null) || return 1 # vnstat exits 0 before the database exists but emits no JSON object. [[ $out == *'"interfaces"'* ]] } # 可选参数是旧配置的备份:失败时恢复旧配置,而不是让重设变成“没有配置”。 _toolbox_system_traffic_cleanup() { local unit_dir bin etc unit_dir=$(_toolbox_system_unit_dir); bin=$(_toolbox_system_bin_dir); etc=$(_toolbox_system_etc) systemctl disable --now "$_TOOLBOX_TRAFFIC_TIMER" >/dev/null 2>&1 || true rm -f -- "$unit_dir/$_TOOLBOX_TRAFFIC_TIMER" "$unit_dir/$_TOOLBOX_TRAFFIC_SERVICE" rm -f -- "$bin/$_TOOLBOX_TRAFFIC_CHECK" if [[ -n ${1-} && -f $1 ]]; then mv -f -- "$1" "$etc/traffic.conf" \ && printf '已恢复此前的阈值配置,但定时器已停用;请重跑 B7 重新启用。\n' >&2 else rm -f -- "$etc/traffic.conf" fi systemctl daemon-reload >/dev/null 2>&1 || true } _toolbox_system_write_traffic_check() { local target=$1 etc state etc=$(_toolbox_system_etc); state=$(_toolbox_system_state) cat > "$target" </dev/null || printf '%s\n' "\$*" >&2; } [[ -r \$CONF ]] || exit 0 limit='' while IFS='=' read -r key value; do [[ \$key == LIMIT_GIB ]] && limit=\$value done < "\$CONF" case \${limit:-} in ''|*[!0-9]*) exit 0;; esac (( limit > 0 )) || exit 0 command -v vnstat >/dev/null 2>&1 || { log 'vnstat 不存在,跳过检查'; exit 0; } command -v jq >/dev/null 2>&1 || { log '缺少 jq,跳过检查'; exit 0; } json=\$(vnstat --json 2>/dev/null) || { log 'vnstat 读取失败'; exit 0; } bytes=\$(printf '%s' "\$json" | jq -r --arg y "\$(date +%Y)" --argjson m "\$((10#\$(date +%m)))" \\ '[ .interfaces[]? | select(.name != "lo") | .traffic.month[]? | select((.date.year|tostring) == \$y and .date.month == \$m) | ((.rx // 0) + (.tx // 0)) ] | add // 0' 2>/dev/null) || exit 0 case \${bytes:-} in ''|*[!0-9]*) exit 0;; esac used=\$(awk -v b="\$bytes" 'BEGIN { printf "%.2f", b / 1073741824 }') over=\$(awk -v u="\$used" -v l="\$limit" 'BEGIN { print (u >= l) ? 1 : 0 }') (( over == 1 )) || exit 0 mkdir -p "\$STATE" 2>/dev/null || exit 0 marker="\$STATE/traffic-\$(date +%Y%m).state" today=\$(date +%F) if [[ -r \$marker && \$(<"\$marker") == "\$today" ]]; then exit 0; fi printf '%s\n' "\$today" > "\$marker" || exit 0 msg="\$(hostname) 本月流量已用 \${used} GiB,达到阈值 \${limit} GiB(仅告警,未阻断)。" log "\$msg" [[ -r \$TG_CONF ]] || exit 0 token=''; chat='' while IFS='=' read -r key value; do case \$key in TOKEN) token=\$value;; CHAT_ID) chat=\$value;; esac done < "\$TG_CONF" [[ -n \$token && -n \$chat ]] || exit 0 curl -fsS --proto '=https' --tlsv1.2 --connect-timeout 10 --max-time 20 \\ --data-urlencode "chat_id=\$chat" --data-urlencode "text=\$msg" \\ -o /dev/null "https://api.telegram.org/bot\$token/sendMessage" \\ || log 'Telegram 发送失败' exit 0 EOF chmod 0755 -- "$target" } _toolbox_system_traffic() { local os etc state bin unit_dir conf limit installed=0 ready=0 i backup='' _toolbox_system_root || return os=$(_toolbox_system_os) || return [[ $os == debian ]] || { _toolbox_system_error '流量统计目前只支持 Debian/Ubuntu 的 vnstat 包;Alpine 未验证。'; return 1; } _toolbox_system_systemd || return _toolbox_system_need_tools curl || return etc=$(_toolbox_system_etc); state=$(_toolbox_system_state) bin=$(_toolbox_system_bin_dir); unit_dir=$(_toolbox_system_unit_dir) conf="$etc/traffic.conf" if [[ -e $conf ]] && ! _toolbox_system_ours "$conf" "$_TOOLBOX_SYS_MARK_B7"; then _toolbox_system_error "$conf 已存在但不是本工具生成的,未覆盖;请人工确认归属。" return 1 fi _toolbox_system_has vnstat && installed=1 _toolbox_system_read_line limit '每月告警阈值(GiB,整数;0 表示只统计不告警)' || return case $limit in 0) ;; *) _toolbox_system_positive_int "$limit" || { _toolbox_system_error "阈值不正确:$limit"; return 1; };; esac if (( installed == 1 )); then printf 'vnstat 已安装:只更新阈值和定时器,不重装包。\n' else printf 'vnstat 未安装:将从系统仓库安装 vnstat 和 jq(jq 用于解析统计 JSON)。\n' fi printf ' 阈值 : %s\n' "$( (( limit == 0 )) && printf '只统计' || printf '%s GiB/月' "$limit" )" printf ' 单位 : GiB(1 GiB = 1024^3 字节)\n' printf ' 检查 : 每小时一次,超额时同日最多告警一次\n' printf ' 通知 : 写入系统日志;若已配置 B15 则同时发 Telegram\n' printf ' 阻断 : 不阻断流量,超额后网络照常\n' _toolbox_system_confirm 'vnstat 会持续写入本机统计库;统计值来自内核计数,只统计不改变网络配置。确认继续?' || return # 目录和旧配置备份要在安装/就绪检查之前建好:任何后续失败都能原样恢复。 install -d -m 0755 "$etc" "$state" || return 1 if [[ -f $conf ]]; then backup="$conf.bak.$$"; cp -p -- "$conf" "$backup" || backup=''; fi if (( installed == 0 )); then apt-get install -y vnstat jq || { _toolbox_system_error 'vnstat/jq 安装失败;未写入配置。'; rm -f -- "$backup"; return 1 } _toolbox_system_has jq || { _toolbox_system_error 'jq 仍不可用;未写入配置。'; rm -f -- "$backup"; return 1 } systemctl enable --now vnstat >/dev/null 2>&1 || { _toolbox_system_error 'vnstat 服务启用失败;未写入配置。'; rm -f -- "$backup"; return 1 } else _toolbox_system_has jq \ || { _toolbox_system_error '已装 vnstat 但缺少 jq(检查脚本用它解析 JSON)。'; rm -f -- "$backup"; return 1; } systemctl enable --now vnstat >/dev/null 2>&1 || _toolbox_system_error '提示:vnstat 服务启用失败,统计可能不更新。' fi for ((i = 0; i < 30; i++)); do if _toolbox_system_vnstat_json_ok; then ready=1; break; fi sleep 1 done if (( ready == 0 )); then _toolbox_system_error 'vnstat 在 30 秒内未产生可用统计(数据库未就绪);包已安装,配置未写入。' _toolbox_system_traffic_cleanup "$backup" return 1 fi { printf '%s\n' "$_TOOLBOX_SYS_MARK_B7" printf '# 由 Terry Toolbox B7 管理;值来自本工具询问。\n' printf 'LIMIT_GIB=%s\n' "$limit" } | _toolbox_system_put "$conf" 0600 || { _toolbox_system_error '写入阈值配置失败。'; [[ -n $backup ]] && mv -f -- "$backup" "$conf"; return 1; } if ! _toolbox_system_write_traffic_check "$bin/$_TOOLBOX_TRAFFIC_CHECK"; then _toolbox_system_error '写入检查脚本失败。' _toolbox_system_traffic_cleanup "$backup" return 1 fi cat > "$unit_dir/$_TOOLBOX_TRAFFIC_SERVICE" < "$unit_dir/$_TOOLBOX_TRAFFIC_TIMER" </dev/null 2>&1; then _toolbox_system_error '定时器启用失败,已撤销本次写入。' _toolbox_system_traffic_cleanup "$backup" return 1 fi if ! systemctl is-active --quiet "$_TOOLBOX_TRAFFIC_TIMER"; then _toolbox_system_error '定时器未进入 active 状态,已撤销本次写入。' _toolbox_system_traffic_cleanup "$backup" return 1 fi rm -f -- "$backup" if (( limit == 0 )); then printf '\n流量统计已就绪:只记录,不告警(阈值为 0)。\n' else printf '\n流量统计已就绪:vnstat 记录读写字节,每月阈值 %s GiB,超额告警。\n' "$limit" fi printf '查看:vnstat -m;手动检查:%s;日志:journalctl -t terry-toolbox-traffic\n' "$bin/$_TOOLBOX_TRAFFIC_CHECK" printf '注意:统计数据来自内核计数,重装系统或清空 vnstat 数据库会丢失历史;只告警不阻断。\n' printf '重设阈值:再次执行 B7。告警渠道:执行 B15 配置 Telegram。\n' } # ---------------------------------------------------------------- B15 TG 通知 _TOOLBOX_TG_REPORT='terry-toolbox-tg-report' _TOOLBOX_TG_SERVICE='terry-toolbox-tg.service' _TOOLBOX_TG_TIMER='terry-toolbox-tg.timer' _toolbox_system_tg_valid_token() { [[ $1 =~ ^[0-9]{5,}:[A-Za-z0-9_-]{30,}$ ]]; } _toolbox_system_tg_valid_chat() { [[ $1 =~ ^(-?[0-9]{4,}|@[A-Za-z0-9_]{4,32})$ ]]; } _toolbox_system_write_tg_report() { local target=$1 etc etc=$(_toolbox_system_etc) cat > "$target" </dev/null || printf '%s\n' "\$*" >&2; } [[ -r \$CONF ]] || { log "缺少 \$CONF"; exit 1; } token=''; chat='' while IFS='=' read -r key value; do case \$key in TOKEN) token=\$value;; CHAT_ID) chat=\$value;; esac done < "\$CONF" [[ -n \$token && -n \$chat ]] || { log '配置缺少 TOKEN 或 CHAT_ID'; exit 1; } if [[ \${1-} == --test ]]; then text="\$(hostname) 的 Telegram 流量通知已配置成功(B15)。日报将在每天 09:17 左右发送。" else command -v vnstat >/dev/null 2>&1 || { log 'vnstat 不存在'; exit 1; } command -v jq >/dev/null 2>&1 || { log '缺少 jq'; exit 1; } json=\$(vnstat --json 2>/dev/null) || { log 'vnstat 读取失败'; exit 1; } day=\$(printf '%s' "\$json" | jq -r --arg y "\$(date +%Y)" --argjson m "\$((10#\$(date +%m)))" --argjson d "\$((10#\$(date +%d)))" \\ '[ .interfaces[]? | select(.name != "lo") | .traffic.day[]? | select((.date.year|tostring) == \$y and .date.month == \$m and .date.day == \$d) | ((.rx // 0) + (.tx // 0)) ] | add // 0' 2>/dev/null) || { log '统计解析失败'; exit 1; } month=\$(printf '%s' "\$json" | jq -r --arg y "\$(date +%Y)" --argjson m "\$((10#\$(date +%m)))" \\ '[ .interfaces[]? | select(.name != "lo") | .traffic.month[]? | select((.date.year|tostring) == \$y and .date.month == \$m) | ((.rx // 0) + (.tx // 0)) ] | add // 0' 2>/dev/null) || { log '统计解析失败'; exit 1; } gi() { awk -v b="\$1" 'BEGIN { printf "%.2f", b / 1073741824 }'; } text="\$(hostname) 流量日报 \$(date +%F):今日下行+上行 \$(gi "\${day:-0}") GiB,本月累计 \$(gi "\${month:-0}") GiB。vnstat 口径,仅供估算。" fi curl -fsS --proto '=https' --tlsv1.2 --connect-timeout 10 --max-time 20 \\ --data-urlencode "chat_id=\$chat" --data-urlencode "text=\$text" \\ -o /dev/null "https://api.telegram.org/bot\$token/sendMessage" EOF chmod 0755 -- "$target" } # 可选参数是旧凭据备份:失败时恢复旧配置。 _toolbox_system_tg_cleanup() { local unit_dir bin etc unit_dir=$(_toolbox_system_unit_dir); bin=$(_toolbox_system_bin_dir); etc=$(_toolbox_system_etc) systemctl disable --now "$_TOOLBOX_TG_TIMER" >/dev/null 2>&1 || true rm -f -- "$unit_dir/$_TOOLBOX_TG_TIMER" "$unit_dir/$_TOOLBOX_TG_SERVICE" rm -f -- "$bin/$_TOOLBOX_TG_REPORT" if [[ -n ${1-} && -f $1 ]]; then mv -f -- "$1" "$etc/telegram.conf" \ && printf '已恢复此前的凭据配置,但定时器已停用;请重跑 B15 重新启用。\n' >&2 else rm -f -- "$etc/telegram.conf" fi systemctl daemon-reload >/dev/null 2>&1 || true } _toolbox_system_tg() { local etc bin unit_dir conf token chat out backup='' _toolbox_system_root || return _toolbox_system_os >/dev/null || return _toolbox_system_systemd || return _toolbox_system_need_tools curl || return _toolbox_system_has vnstat || { _toolbox_system_error '未找到 vnstat;请先执行 B7 流量统计。'; return 1; } _toolbox_system_has jq || { _toolbox_system_error '缺少 jq;请先执行 B7(会一并安装)。'; return 1; } etc=$(_toolbox_system_etc); bin=$(_toolbox_system_bin_dir); unit_dir=$(_toolbox_system_unit_dir) conf="$etc/telegram.conf" if [[ -e $conf ]] && ! _toolbox_system_ours "$conf" "$_TOOLBOX_SYS_MARK_B15"; then _toolbox_system_error "$conf 已存在但不是本工具生成的,未覆盖;请人工确认归属。" return 1 fi printf 'Token 和 Chat ID 从 @BotFather / 与机器人的会话获取;输入时不回显。\n' >&2 _toolbox_system_read_secret token 'Bot Token(形如 123456789:AA...)' || return _toolbox_system_tg_valid_token "$token" || { _toolbox_system_error 'Token 格式不正确(应为 <数字>:<35+ 位字母数字_-)。'; return 1; } _toolbox_system_read_line chat 'Chat ID(数字,或 @channelusername)' || return _toolbox_system_tg_valid_chat "$chat" || { _toolbox_system_error "Chat ID 格式不正确:$chat"; return 1; } printf '将写入 %s(权限 600),并创建每天 09:17 的 systemd 定时器。\n' "$conf" printf '安装结束前会真实发送一条测试消息;标签为 %s,发送失败则回滚本次写入。\n' "$(hostname)" printf 'Token 会出现在 systemd 以外的 curl 命令行参数中;不写入日志。\n' _toolbox_system_confirm '确认保存凭据并启用定时器?' || return install -d -m 0755 "$etc" || return 1 if [[ -f $conf ]]; then backup="$conf.bak.$$"; cp -p -- "$conf" "$backup" || backup=''; fi { printf '%s\n' "$_TOOLBOX_SYS_MARK_B15" printf 'TOKEN=%s\n' "$token" printf 'CHAT_ID=%s\n' "$chat" } | _toolbox_system_put "$conf" 0600 || { _toolbox_system_error '写入凭据失败。'; [[ -n $backup ]] && mv -f -- "$backup" "$conf"; return 1; } _toolbox_system_write_tg_report "$bin/$_TOOLBOX_TG_REPORT" \ || { _toolbox_system_error '写入发送脚本失败。'; _toolbox_system_tg_cleanup "$backup"; return 1; } cat > "$unit_dir/$_TOOLBOX_TG_SERVICE" < "$unit_dir/$_TOOLBOX_TG_TIMER" <&1); then _toolbox_system_error "测试消息发送失败,响应:${out:0:300}" _toolbox_system_error '已回滚本次写入(凭据、脚本、定时器)。请核对 token、chat_id 及到 api.telegram.org 的连通性。' _toolbox_system_tg_cleanup "$backup" return 1 fi if ! systemctl daemon-reload || ! systemctl enable --now "$_TOOLBOX_TG_TIMER" >/dev/null 2>&1; then _toolbox_system_error '定时器启用失败,已回滚本次写入。' _toolbox_system_tg_cleanup "$backup" return 1 fi rm -f -- "$backup" printf '\nTelegram 流量通知已启用:每天 09:17 左右发送前一自然日数量与本自然月累计。\n' printf '立即测试:%s(不带参数即发日报);停用见帮助文档。\n' "$bin/$_TOOLBOX_TG_REPORT" printf '凭据以明文存放在 %s(600):可被 root 读取,注意备份与轮换。\n' "$conf" } # ---------------------------------------------------------------- B16 网络优化 # 只写一个独立的 sysctl drop-in,并在写入前逐个确认内核确实接受这些键; # 已有同名文件但不是本工具写的则拒绝覆盖。回滚按保存的旧值恢复。 _TOOLBOX_NETOPT_KEYS=( net.core.default_qdisc net.ipv4.tcp_congestion_control net.core.somaxconn net.ipv4.tcp_max_syn_backlog net.ipv4.tcp_fastopen net.ipv4.tcp_slow_start_after_idle net.ipv4.tcp_mtu_probing net.ipv4.ip_local_port_range net.core.rmem_max net.core.wmem_max ) _TOOLBOX_NETOPT_VALUES=( fq bbr 4096 8192 3 0 1 '10240 65000' 16777216 16777216 ) _toolbox_system_netopt_file() { printf '%s/99-terry-toolbox.conf\n' "$(_toolbox_system_sysctl_dir)"; } _toolbox_system_netopt_state() { printf '%s/net-opt.previous\n' "$(_toolbox_system_state)"; } _toolbox_system_netopt_apply() { local file state applied=0 i key local keys=() values=() previous=() file=$(_toolbox_system_netopt_file); state=$(_toolbox_system_netopt_state) _toolbox_system_root || return [[ $(_toolbox_system_os) == debian ]] || { _toolbox_system_error 'B16 只面向 Debian/Ubuntu 的 sysctl.d。'; return 1; } local available available=$(sysctl -n net.ipv4.tcp_available_congestion_control) || return case " $available " in *' bbr '*) ;; *) _toolbox_system_error '内核未提供 BBR,本轮不改动;先确认内核版本与模块。'; return 1;; esac if [[ -e $file ]] && ! _toolbox_system_ours "$file" "$_TOOLBOX_SYS_MARK_B16"; then _toolbox_system_error "$file 已存在但不是本工具生成的,未覆盖;请人工合并或改名。" return 1 fi # 只保留内核接受的键;读不到的值不能用来回滚,直接跳过。 for ((i = 0; i < ${#_TOOLBOX_NETOPT_KEYS[@]}; i++)); do key=${_TOOLBOX_NETOPT_KEYS[i]} local old old=$(sysctl -n "$key" 2>/dev/null) || { printf '跳过(内核不支持):%s\n' "$key"; continue; } keys+=("$key"); values+=("${_TOOLBOX_NETOPT_VALUES[i]}"); previous+=("$old") done (( ${#keys[@]} > 0 )) || { _toolbox_system_error '没有任何键被内核接受;未改动。'; return 1; } local want existing='' want=$(printf '%s\n' "$_TOOLBOX_SYS_MARK_B16" \ '# 由 Terry Toolbox B16 管理;回滚请用菜单。不要手工混入其他键。' for ((i = 0; i < ${#keys[@]}; i++)); do printf '%s = %s\n' "${keys[i]}" "${values[i]}"; done) if [[ -r $file ]]; then existing=$(<"$file") if [[ $existing == "$want" ]]; then printf '优化项已全部应用且内容一致;未修改任何配置。\n' return 0 fi printf '同名文件已存在(本工具生成)但内容不同:将覆盖为当前内核可接受的键。\n' fi printf '将写入 %s:\n' "$file" for ((i = 0; i < ${#keys[@]}; i++)); do printf ' %-40s %s -> %s\n' "${keys[i]}" "${previous[i]}" "${values[i]}" done printf ' 队列与拥塞控制 : fq + bbr(BBR 需要内核支持,已确认)\n' printf ' 不改动 : 其他 sysctl 文件、防火墙、网卡参数\n' printf ' 回滚 : 菜单 B16 → 2,按保存的旧值恢复\n' _toolbox_system_confirm '这些参数会立即生效并写入持久配置(重启后仍生效)。确认应用?' || return install -d -m 0755 "$(_toolbox_system_sysctl_dir)" "$(_toolbox_system_state)" || return 1 { printf '%s\n' "$_TOOLBOX_SYS_MARK_B16" for ((i = 0; i < ${#keys[@]}; i++)); do printf '%s=%s\n' "${keys[i]}" "${previous[i]}"; done } | _toolbox_system_put "$state" 0600 || { _toolbox_system_error '保存回滚值失败;未改动。'; return 1; } printf '%s\n' "$want" | _toolbox_system_put "$file" 0644 \ || { _toolbox_system_error '写入 drop-in 失败;未改动运行期参数。'; rm -f -- "$state"; return 1; } if ! applied=$(sysctl -p "$file" 2>&1); then _toolbox_system_error 'sysctl 应用失败,正在恢复运行期旧值并删除 drop-in。原因:' printf '%s\n' "$applied" | head -n 3 >&2 local restore_failed=0 rm -f -- "$file" for ((i = 0; i < ${#keys[@]}; i++)); do sysctl -w "${keys[i]}=${previous[i]}" >/dev/null 2>&1 \ || { _toolbox_system_error " 恢复失败:${keys[i]}=${previous[i]}"; restore_failed=1; } done if (( restore_failed == 1 )); then _toolbox_system_error "部分运行期值未能恢复,回滚记录保留在 $state。" else rm -f -- "$state" fi return 1 fi # 回读:写进去不等于生效(例如被后加载的文件覆盖)。 for ((i = 0; i < ${#keys[@]}; i++)); do local now now=$(sysctl -n "${keys[i]}" 2>/dev/null) || now='' if [[ $now != "${values[i]}" ]]; then _toolbox_system_error "回读不一致:${keys[i]} = '$now'(期望 '${values[i]}')。可能有其他 sysctl 文件后加载并覆盖。" applied=1 fi done if (( applied == 1 )); then _toolbox_system_error 'drop-in 已写入但未全部生效;可先用 B16 → 2 回滚,再检查冲突的 sysctl 文件。' return 1 fi printf '\n网络参数已应用并写入 %s;重启后保持。\n' "$file" printf '查看:sysctl -a | grep -E "congestion|qdisc|somaxconn"\n' } _toolbox_system_netopt_rollback() { local file state key value file=$(_toolbox_system_netopt_file); state=$(_toolbox_system_netopt_state) _toolbox_system_root || return if [[ ! -r $state ]]; then _toolbox_system_error "没有回滚记录($state);可能从未应用,或记录已被删除。" return 1 fi if [[ -e $file ]] && ! _toolbox_system_ours "$file" "$_TOOLBOX_SYS_MARK_B16"; then _toolbox_system_error "$file 已不是本工具生成的,未删除;请人工处理后再回滚。" return 1 fi _toolbox_system_confirm "将删除 $file 并按保存值恢复运行期参数,然后删除回滚记录。确认?" || return rm -f -- "$file" || return 1 local failed=0 while IFS='=' read -r key value; do [[ $key == '#'* || -z $key ]] && continue sysctl -w "$key=$value" >/dev/null 2>&1 || { _toolbox_system_error "恢复失败:$key=$value"; failed=1; } done < "$state" if (( failed == 1 )); then _toolbox_system_error "部分值恢复失败,回滚记录保留在 $state;可修正后重试或按记录手工 sysctl -w。" return 1 fi rm -f -- "$state" printf '已删除 drop-in 并恢复保存的旧值;重启后回到这些值。\n' printf '提示:运行期值已恢复,但仍请确认其他 sysctl 文件没有同时改动同一键。\n' } _toolbox_system_netopt() { local choice printf 'B16 网络优化\n 1 应用优化参数\n 2 回滚到应用前\n 0 返回\n请输入编号: ' >&2 IFS= read -r choice || return 1 case $choice in 1) _toolbox_system_netopt_apply;; 2) _toolbox_system_netopt_rollback;; 0) printf '未修改。\n';; *) _toolbox_system_error "无效选项:$choice"; return 2;; esac } # ---------------------------------------------------------------- B11 1Panel # 上游 quick_start.sh 只负责引导,真正的版本号和安装包在运行时才解析;因此这里 # 只固定引导脚本本体,并明确告知版本不固定、包摘要来自同一台官方服务器。 readonly _TOOLBOX_1PANEL_URL='https://resource.fit2cloud.com/1panel/package/v2/quick_start.sh' readonly _TOOLBOX_1PANEL_SHA256='619716796c54a4e3f82c21dff4d7d3e9eb44b7dd1f3fceb90e8e1f584f674d9c' readonly _TOOLBOX_1PANEL_BASE='/opt/1panel' # 固定的摘要/地址可被环境变量替换,便于离线测试和上游更新后的对照。 _toolbox_system_1panel_sha() { printf '%s\n' "${TOOLBOX_1PANEL_SHA256:-$_TOOLBOX_1PANEL_SHA256}"; } _toolbox_system_1panel_present() { _toolbox_system_has 1pctl || _toolbox_system_has 1panel || [[ -d $_TOOLBOX_1PANEL_BASE ]] } _toolbox_system_panel() { local os tmp script actual _toolbox_system_root || return os=$(_toolbox_system_os) || return [[ $os == debian ]] || { _toolbox_system_error '1Panel 官方脚本面向 Debian/Ubuntu;Alpine 未验证。'; return 1; } _toolbox_system_need_tools curl sha256sum tar bash || return if _toolbox_system_1panel_present; then _toolbox_system_error '检测到 1Panel(1pctl / 1panel / /opt/1panel);为保护面板数据不做重复安装。' _toolbox_system_error '升级请用面板自带的 1pctl update;卸载请用官方脚本后人工确认数据目录。' return 1 fi printf '即将安装 1Panel 面板:\n' printf ' 引导脚本 : %s\n' "${TOOLBOX_1PANEL_URL:-$_TOOLBOX_1PANEL_URL}" printf ' 校验 : 下载后比对固定 SHA-256 %s…(不匹配即中止)\n' "$(_toolbox_system_1panel_sha | cut -c1-12)" printf ' 版本 : 不固定,由官方服务器在安装时返回 stable 的最新版本\n' printf ' 包校验 : 官方脚本用同一服务器上的 checksums.txt 校验安装包\n' printf ' 注意 : 官方脚本用 curl -k 下载安装包(不校验证书链),并管道执行安装统计脚本\n' printf ' 数据目录 : %s(脚本默认,未改动)\n' "$_TOOLBOX_1PANEL_BASE" printf ' 端口 : 脚本默认 9999;安装完成会回显访问地址与初始账号\n' printf ' 防火墙 : 不改动\n' _toolbox_system_confirm '确认下载并执行固定版本的官方引导脚本?' || return tmp=$(mktemp -d) || return 1 script="$tmp/quick_start.sh" if ! curl -fsSL --proto '=https' --tlsv1.2 --connect-timeout 15 --max-time 120 \ -o "$script" "${TOOLBOX_1PANEL_URL:-$_TOOLBOX_1PANEL_URL}"; then _toolbox_system_error '下载引导脚本失败;未执行。' rm -rf -- "$tmp"; return 1 fi actual=$(sha256sum -- "$script" | cut -d' ' -f1) local expected expected=$(_toolbox_system_1panel_sha) if [[ $actual != "$expected" ]]; then _toolbox_system_error "引导脚本 SHA-256 不匹配:期望 $expected,实际 $actual。" _toolbox_system_error "上游可能已更新;确认无误后可临时用 TOOLBOX_1PANEL_SHA256=$actual 重跑对照。已保留 $script 供人工审阅。" return 1 fi printf '引导脚本校验通过(%s)。开始执行,输出直接来自上游脚本。\n\n' "${actual:0:12}…" local rc=0 ( cd "$tmp" && INSTALL_MODE=stable PANEL_BASE_DIR=$_TOOLBOX_1PANEL_BASE bash ./quick_start.sh ) || rc=$? if (( rc != 0 )); then _toolbox_system_error "上游脚本返回 $rc;可能只完成一部分。" _toolbox_system_error "请先检查 1pctl、/opt/1panel 和容器状态再决定是否重试,避免半装状态下重复安装。临时目录:$tmp" return 1 fi if ! _toolbox_system_1panel_present; then _toolbox_system_error '脚本返回 0 但未发现 1pctl 或 /opt/1panel;请人工检查面板是否真正装上。' return 1 fi rm -rf -- "$tmp" printf '\n1Panel 已安装。\n' printf '下一步:用输出里的地址和初始账号登录,并立刻改密码;开放端口前先确认防火墙。\n' printf '当前面板自带 Docker 依赖:如脚本顺带安装了 Docker,请勿再用 B1 重复安装。\n' printf '升级:1pctl update;备份:/opt/1panel 与面板内备份功能,二者都要做。\n' } # ---------------------------------------------------------------- B19 Alpine _TOOLBOX_WEBTOP_NAME='terry-toolbox-webtop' _TOOLBOX_WEBTOP_IMAGE='lscr.io/linuxserver/webtop:latest' _TOOLBOX_HOP_CONF_NAME='port-hop.conf' _toolbox_system_alpine_require() { local os _toolbox_system_root || return os=$(_toolbox_system_os) || return [[ $os == alpine ]] || { _toolbox_system_error 'B19 只面向 Alpine;Debian 用对应功能即可。'; return 1; } } _toolbox_system_alpine_webtop() { local dir _toolbox_system_alpine_require || return if ! _toolbox_system_has docker; then _toolbox_system_error '未找到 docker;B19 不负责装 Docker(先走 B1)。'; return 1 fi docker info >/dev/null 2>&1 || { _toolbox_system_error 'Docker 守护进程不可用;先启动并确认权限。'; return 1; } if [[ -n $(docker ps -a --filter "name=^/${_TOOLBOX_WEBTOP_NAME}\$" --format '{{.Names}}') ]]; then _toolbox_system_error "容器 $_TOOLBOX_WEBTOP_NAME 已存在;不删除、不重建。查看:docker logs $_TOOLBOX_WEBTOP_NAME" return 1 fi dir=/opt/terry-toolbox-webtop printf '将运行 Webtop 桌面容器:\n' printf ' 镜像 : %s(latest,不固定摘要)\n' "$_TOOLBOX_WEBTOP_IMAGE" printf ' 端口 : 127.0.0.1:3000 -> 容器 3000(仅本机;对外需反代或改绑定)\n' printf ' 数据 : %s(挂载到 /config,重建容器不丢)\n' "$dir" printf ' shm : 1g;重启策略 unless-stopped\n' _toolbox_system_confirm '将拉取镜像并启动容器,可能占用数百 MB 磁盘。确认?' || return install -d -m 0755 "$dir" || return 1 if ! docker pull "$_TOOLBOX_WEBTOP_IMAGE"; then _toolbox_system_error '镜像拉取失败;未创建容器。' return 1 fi if ! docker run -d --name "$_TOOLBOX_WEBTOP_NAME" --restart unless-stopped \ -e PUID=0 -e PGID=0 -e TZ=Asia/Shanghai \ -p 127.0.0.1:3000:3000 -v "$dir:/config" --shm-size=1g "$_TOOLBOX_WEBTOP_IMAGE"; then _toolbox_system_error '容器启动命令失败;若已创建同名容器,请先 docker rm -f 后重试。' return 1 fi if [[ $(docker inspect -f '{{.State.Running}}' "$_TOOLBOX_WEBTOP_NAME" 2>/dev/null) != true ]]; then _toolbox_system_error "容器未运行;日志:docker logs $_TOOLBOX_WEBTOP_NAME" _toolbox_system_error "已保留容器便于排查;确认后可 docker rm -f $_TOOLBOX_WEBTOP_NAME" return 1 fi printf '\n容器已运行。首次启动约 1-2 分钟。\n' printf '本机访问:http://127.0.0.1:3000(远程用 SSH 隧道:ssh -L 3000:127.0.0.1:3000 用户@主机)\n' printf '数据目录:%s;日志:docker logs -f %s\n' "$dir" "$_TOOLBOX_WEBTOP_NAME" printf '注意:容器内 root 映射到主机 root 的 /config;无内置认证,公网暴露前请加反代和密码。\n' } _toolbox_system_alpine_hop_apply() { local etc conf start end target port local -a spec=() _toolbox_system_alpine_require || return _toolbox_system_need_tools iptables ss || { _toolbox_system_error '缺少 iptables 或 ss(iproute2);Alpine 可 apk add iptables iproute2。'; return 1; } etc=$(_toolbox_system_etc); conf="$etc/$_TOOLBOX_HOP_CONF_NAME" if [[ -e $conf ]] && ! _toolbox_system_ours "$conf" "$_TOOLBOX_SYS_MARK_B19"; then _toolbox_system_error "$conf 已存在但不是本工具生成的,未覆盖。" return 1 fi if [[ -e $conf ]]; then # 配置已存在:沿用其中的端口,不重新询问;只补回缺失的规则。 local key value while IFS='=' read -r key value; do case $key in START) start=$value;; END) end=$value;; TARGET) target=$value;; esac done < "$conf" for port in "$start" "$end" "$target"; do _toolbox_system_positive_int "$port" && (( port <= 65535 )) \ || { _toolbox_system_error "配置里的端口不正确:$port;先移除再重设。"; return 1; } done spec=(-p udp --dport "$start:$end" -j REDIRECT --to-ports "$target") if iptables -t nat -C PREROUTING "${spec[@]}" 2>/dev/null; then printf '端口跳跃已应用(%s-%s -> %s),规则在位;未修改。\n' "$start" "$end" "$target" printf '要改端口请先执行「移除端口跳跃」。\n' return 0 fi printf '配置已存在(%s-%s -> %s)但规则不在位,将补回。\n' "$start" "$end" "$target" else _toolbox_system_read_line start '跳跃起始端口' || return _toolbox_system_read_line end '跳跃结束端口' || return _toolbox_system_read_line target '实际监听端口(Hysteria2 的 UDP 端口,如 443)' || return for port in "$start" "$end" "$target"; do _toolbox_system_positive_int "$port" && (( port <= 65535 )) \ || { _toolbox_system_error "端口不正确:$port"; return 1; } done (( start <= end )) || { _toolbox_system_error "起始端口 $start 大于结束端口 $end。"; return 1; } (( end - start + 1 <= 1000 )) || { _toolbox_system_error '范围超过 1000 个端口,配合 qdisc 可能过重;请缩短。'; return 1; } spec=(-p udp --dport "$start:$end" -j REDIRECT --to-ports "$target") fi if (( target >= start && target <= end )); then printf '提示:监听端口 %s 落在跳跃范围内,DNAT 会再指向自己,通常仍可用但建议把监听端口放在范围外。\n' "$target" fi if iptables -t nat -C PREROUTING "${spec[@]}" 2>/dev/null; then printf '规则已存在,未重复添加。\n' else if ! ss -lunH 2>/dev/null | awk '{print $4}' | grep -q ":$target\$"; then printf '提示:本机当前没有 UDP %s 监听;先装好服务再验证,否则流量会落到空端口。\n' "$target" fi printf '将添加:iptables -t nat -A PREROUTING %s\n' "${spec[*]}" printf '作用 : 客户端可在 %s-%s 间切换端口,全部转到 %s/udp\n' "$start" "$end" "$target" printf '不改动 : 已有 iptables 规则;持久化依赖本机 iptables 服务,若保存失败会明确提示\n' _toolbox_system_confirm '确认添加 DNAT 规则?' || return if ! iptables -t nat -A PREROUTING "${spec[@]}"; then _toolbox_system_error '添加规则失败;未写入配置。' return 1 fi if ! iptables -t nat -C PREROUTING "${spec[@]}" 2>/dev/null; then _toolbox_system_error '添加后回读规则不存在,尝试撤销。' iptables -t nat -D PREROUTING "${spec[@]}" >/dev/null 2>&1 || true return 1 fi fi install -d -m 0755 "$etc" || return 1 { printf '%s\n' "$_TOOLBOX_SYS_MARK_B19" printf 'START=%s\nEND=%s\nTARGET=%s\n' "$start" "$end" "$target" } | _toolbox_system_put "$conf" 0644 || { _toolbox_system_error '写入配置失败;规则已生效但无法回滚。'; return 1; } if _toolbox_system_has rc-service && rc-service iptables save >/dev/null 2>&1; then printf '规则已保存到本机 iptables 服务配置(重启后保留)。\n' else printf '提示:未能保存持久化(无 rc-service 或 iptables 服务未启用)。规则当前生效,重启后会丢失。\n' fi printf '验证:iptables -t nat -L PREROUTING -n --line-numbers\n' printf '客户端在 %s-%s 间轮换即可;防火墙需放行整个范围(本工具不改防火墙)。\n' "$start" "$end" } _toolbox_system_alpine_hop_remove() { local etc conf start end target _toolbox_system_alpine_require || return _toolbox_system_need_tools iptables || return etc=$(_toolbox_system_etc); conf="$etc/$_TOOLBOX_HOP_CONF_NAME" if [[ ! -r $conf ]]; then _toolbox_system_error "没有端口跳跃配置($conf);未改动规则。" return 1 fi _toolbox_system_ours "$conf" "$_TOOLBOX_SYS_MARK_B19" \ || { _toolbox_system_error "$conf 不是本工具生成的,未处理。"; return 1; } start=''; end=''; target='' while IFS='=' read -r key value; do case $key in START) start=$value;; END) end=$value;; TARGET) target=$value;; esac done < "$conf" [[ -n $start && -n $end && -n $target ]] || { _toolbox_system_error '配置不完整,未改动。'; return 1; } _toolbox_system_confirm "将删除 UDP $start:$end -> $target 的 DNAT 规则并删除配置;其他规则不动。确认?" || return if iptables -t nat -D PREROUTING -p udp --dport "$start:$end" -j REDIRECT --to-ports "$target"; then : else _toolbox_system_error '删除规则失败(可能已被手工删除);配置保留以便重试。' return 1 fi rm -f -- "$conf" printf '已删除规则与配置。' if _toolbox_system_has rc-service; then rc-service iptables save >/dev/null 2>&1 && printf '已同步保存。\n' || printf '提示:持久化保存失败,重启后规则本来也会消失。\n' else printf '\n' fi } _toolbox_system_alpine_swap() { _toolbox_system_error 'Alpine swap 暂未启用:OpenRC 下没有等价的事务与回滚方案,尚未验证;未改动。' _toolbox_system_error 'Alpine 可先用 apk add zram-init 或 setup-swap 手工处理;Debian/Ubuntu 请用 B6。' return 1 } _toolbox_system_alpine() { local choice printf 'B19 Alpine 专用工具\n 1 Webtop 桌面容器\n 2 端口跳跃(HY2 等 UDP 服务)\n 3 移除端口跳跃\n 4 swap(暂未启用)\n 0 返回\n请输入编号: ' >&2 IFS= read -r choice || return 1 case $choice in 1) _toolbox_system_alpine_webtop;; 2) _toolbox_system_alpine_hop_apply;; 3) _toolbox_system_alpine_hop_remove;; 4) _toolbox_system_alpine_swap;; 0) printf '未修改。\n';; *) _toolbox_system_error "无效选项:$choice"; return 2;; esac } # ---------------------------------------------------------------- B6 swap # 事务式 swap。写入顺序固定为:独占锁 → 只读预检 → 确认 → 锁内重新校验 → # 落盘(日志 → 文件 → mkswap → 单元 → daemon-reload → 启用)→ 双重验证。 # 持久化只靠 systemd 单元,不写 fstab,避免两套机制互相覆盖。 # # 无法判断的现状一律在写入前失败:随机别人的文件、别人的单元、drop-in、 # 已加载但不在本工具路径的单元、乃至 swapon 读不出来,都不猜。 # 只有本工具自己写下的东西才会被撤销,用记录在事务日志里的 dev:inode 认回, # 单元要等 swapoff 确认后才删除。 _TOOLBOX_SWAP_FILE='swapfile_toolbox' _TOOLBOX_SWAP_UNIT='swapfile_toolbox.swap' readonly _TOOLBOX_SYS_MARK_B6='# terry-toolbox B6 swap unit' _TOOLBOX_SWAP_TXN_FILE='' _TOOLBOX_SWAP_TXN_UNIT='' _TOOLBOX_SWAP_TXN_INODE='' _TOOLBOX_SWAP_MODE='' _TOOLBOX_SWAP_INODE='' _toolbox_system_swap_root() { printf '%s\n' "${TOOLBOX_SWAP_ROOT:-/}"; } _toolbox_system_swap_file() { local root root=$(_toolbox_system_swap_root) printf '%s/%s\n' "${root%/}" "$_TOOLBOX_SWAP_FILE" } _toolbox_system_swap_unit() { printf '%s/%s\n' "$(_toolbox_system_unit_dir)" "$_TOOLBOX_SWAP_UNIT"; } _toolbox_system_swap_lock() { printf '%s/swap.lock\n' "$(_toolbox_system_state)"; } _toolbox_system_swap_journal() { printf '%s/swap.txn\n' "$(_toolbox_system_state)"; } _toolbox_system_swap_supported() { _toolbox_system_root || return [[ $(_toolbox_system_os) == debian ]] || { _toolbox_system_error 'swap 事务只在 Debian/Ubuntu 上验证过;未改动。'; return 1; } _toolbox_system_systemd || return _toolbox_system_need_tools flock dd mkswap swapon swapoff findmnt df stat || return } # 活动 swap 精确匹配:逐个路径串比较,不做前缀/设备号推断。 _toolbox_system_swap_snapshot() { local out line out=$(swapon --show=NAME --noheadings 2>/dev/null) || return 1 while IFS= read -r line; do [[ -n ${line//[[:space:]]/} ]] && printf '%s\n' "$line" done <<< "$out" return 0 } _toolbox_system_swap_listed() { local list=$1 want=$2 line while IFS= read -r line; do [[ $line == "$want" ]] && return 0; done <<< "$list" return 1 } _toolbox_system_swap_member() { local list list=$(_toolbox_system_swap_snapshot) || return 1 _toolbox_system_swap_listed "$list" "$1" } _toolbox_system_swap_lock_acquire() { local lock lock=$(_toolbox_system_swap_lock) install -d -m 0755 -- "$(_toolbox_system_state)" || { _toolbox_system_error "无法创建状态目录 $(_toolbox_system_state);未改动。"; return 1; } # 不等待、不自动抢占:并发时报错,由人决定,避免两个进程各自继续。 exec 9> "$lock" || { _toolbox_system_error "无法打开锁文件 $lock;未改动。"; return 1; } if ! flock -n 9; then exec 9>&- _toolbox_system_error "另一个进程正在操作 swap(锁被占用:$lock);未改动。" return 1 fi } _toolbox_system_swap_lock_release() { flock -u 9 2>/dev/null || true; exec 9>&- 2>/dev/null || true; } # 事务日志:SIGKILL/断电后,下一次运行据此认回自己的残留,别人的不动。 _toolbox_system_swap_journal_write() { printf 'PHASE=%s\nPATH=%s\nUNIT=%s\nINODE=%s\n' "$1" "$2" "$3" "${4-}" \ | _toolbox_system_put "$(_toolbox_system_swap_journal)" 0600 } _toolbox_system_swap_check_space() { local size=$1 root avail_kb want_kb root=$(_toolbox_system_swap_root) avail_kb=$(df -Pk -- "$root" 2>/dev/null | while read -r _ _ _ a _; do printf '%s\n' "$a"; done | tail -n 1) case ${avail_kb:-} in ''|*[!0-9]*) _toolbox_system_error "无法读取 $root 的可用空间(df);未改动。"; return 1;; esac want_kb=$(( size * 1024 + 65536 )) if (( avail_kb < want_kb )); then _toolbox_system_error "可用空间不足:需要约 $(( want_kb / 1024 )) MiB(含 64 MiB 余量),实际 $(( avail_kb / 1024 )) MiB。" return 1 fi } # 只读预检。成功时只在全局变量里留下判定:create / resume / idempotent。 _toolbox_system_swap_preflight() { local file unit dir fstype drops list frag existing file=$(_toolbox_system_swap_file); unit=$(_toolbox_system_swap_unit); dir=$(printf '%s.d' "$unit") _TOOLBOX_SWAP_MODE=''; _TOOLBOX_SWAP_INODE='' _toolbox_system_swap_supported || return 1 _toolbox_system_swap_check_space 1 || return 1 fstype=$(findmnt -n -o FSTYPE --target "$(_toolbox_system_swap_root)" 2>/dev/null) || { _toolbox_system_error '无法确定根文件系统类型(findmnt);未改动。'; return 1; } case $fstype in ext2|ext3|ext4|xfs|btrfs) ;; *) _toolbox_system_error "根文件系统是 $fstype,swapfile 的用法未验证;未改动。"; return 1;; esac # systemd 实际生效的单元可能来自 /usr/lib、/run 或生成器,不止单元目录。 frag=$(systemctl show -p FragmentPath --value "$_TOOLBOX_SWAP_UNIT" 2>/dev/null) || frag='' if [[ -n $frag && $frag != "$unit" ]]; then _toolbox_system_error "systemd 中已有同名单元来自 $frag(不是 $unit);未改动。" return 1 fi drops=$(systemctl show -p DropInPaths --value "$_TOOLBOX_SWAP_UNIT" 2>/dev/null) || drops='' if [[ -n ${drops// /} ]]; then _toolbox_system_error "同名单元已带 drop-in($drops),可能覆盖 What=;未改动。" return 1 fi if [[ -e $unit ]] && ! _toolbox_system_ours "$unit" "$_TOOLBOX_SYS_MARK_B6"; then _toolbox_system_error "$unit 已存在但不是本工具生成的;未改动。" return 1 fi if compgen -G "$dir/*.conf" >/dev/null; then _toolbox_system_error "$dir 里已有 drop-in,可能覆盖 What=;未改动。" return 1 fi list=$(_toolbox_system_swap_snapshot) || { _toolbox_system_error 'swapon 读取失败,无法确认活动 swap;未改动。'; return 1; } if [[ -L $file ]]; then _toolbox_system_error "$file 是符号链接,归属不明确;未改动。"; return 1 fi if [[ -e $unit ]]; then [[ -f $file ]] || { _toolbox_system_error "单元存在但文件缺失($file);未改动。"; return 1; } grep -qxF -- "What=$file" "$unit" 2>/dev/null || { _toolbox_system_error "单元里的 What= 不是 $file;未改动。"; return 1; } if _toolbox_system_swap_listed "$list" "$file"; then _TOOLBOX_SWAP_MODE='idempotent'; return 0 fi existing=$(stat -c '%d:%i' -- "$file" 2>/dev/null) || { _toolbox_system_error "无法读取 $file 的 inode;未改动。"; return 1; } _TOOLBOX_SWAP_MODE='resume'; _TOOLBOX_SWAP_INODE=$existing; return 0 fi if [[ -e $file ]]; then if _toolbox_system_swap_listed "$list" "$file"; then _toolbox_system_error "$file 是活动 swap 但没有本工具的单元,无法断言归属;未改动。" else _toolbox_system_error "$file 已存在且未启用,也没有本工具的单元,无法断言归属;未改动。" fi return 1 fi _TOOLBOX_SWAP_MODE='create' } # 回滚:先让 systemd 停用并确认 swapoff,再删单元,最后按 inode 删文件。 # 顺序不可交换:单元先删会留下“文件仍在 swap 里但没有单元”的状态。 # 外层用子 shell 隔离 errexit,回滚里任何一步失败都不中断剩下的清理。 _toolbox_system_swap_rollback() { ( set +e; _toolbox_system_swap_rollback_body "$1" "$2" "${3-}" ) } _toolbox_system_swap_rollback_body() { local file=$1 unit=$2 ino=${3-} i now='' if _toolbox_system_swap_member "$file"; then systemctl disable --now "$_TOOLBOX_SWAP_UNIT" >/dev/null 2>&1 for ((i = 0; i < 20; i++)); do _toolbox_system_swap_member "$file" || break sleep 0.5 done if _toolbox_system_swap_member "$file"; then swapoff -- "$file" >/dev/null 2>&1 fi if _toolbox_system_swap_member "$file"; then _toolbox_system_error "swapoff 未完成($file 仍是活动 swap),单元保留在 $unit;请手工处理后重试。" return 1 fi fi if [[ -e $unit ]]; then rm -f -- "$unit" && systemctl daemon-reload >/dev/null 2>&1 fi if [[ -e $file || -L $file ]]; then now=$(stat -c '%d:%i' -- "$file" 2>/dev/null) || now='' if [[ -n $ino && $now != "$ino" ]]; then _toolbox_system_error "文件已不是本次创建的那个(inode ${now:-读不到} ≠ $ino),保留:$file" elif _toolbox_system_swap_member "$file"; then _toolbox_system_error "文件仍是活动 swap,保留:$file" else rm -f -- "$file" && _toolbox_system_error "已删除本次创建的 swap 文件:$file" fi fi rm -f -- "$(_toolbox_system_swap_journal)" return 0 } _toolbox_system_swap_abort() { local sig=$1 rc=$? msg=${2-} case $sig in INT) rc=130;; TERM) rc=143;; esac (( rc != 0 )) || rc=1 trap - INT TERM EXIT printf '\n' >&2 [[ -n $msg ]] && _toolbox_system_error "$msg" _toolbox_system_error "开始回滚本次写入($sig)。" _toolbox_system_swap_rollback "$_TOOLBOX_SWAP_TXN_FILE" "$_TOOLBOX_SWAP_TXN_UNIT" "$_TOOLBOX_SWAP_TXN_INODE" \ || _toolbox_system_error '回滚未完成,见上面的残留说明。' _toolbox_system_swap_lock_release _toolbox_system_error '已回滚;本次未启用 swap。' exit "$rc" } # 上一次运行没走完(SIGKILL、断电、机器被杀):先按日志认回自己的残留。 _toolbox_system_swap_recover() { local journal path='' unit='' ino='' phase='' key value journal=$(_toolbox_system_swap_journal) while IFS='=' read -r key value; do case $key in PHASE) phase=$value;; PATH) path=$value;; UNIT) unit=$value;; INODE) ino=$value;; esac done < "$journal" if [[ -z $path || -z $unit ]]; then _toolbox_system_error "事务日志 $journal 不完整(缺少 PATH/UNIT);未改动。" return 1 fi _toolbox_system_error "发现上次未完成的事务(阶段 ${phase:-未知}),先回滚残留。" _toolbox_system_swap_rollback "$path" "$unit" "$ino" || return 1 printf '上次残留已处理,继续本次检查。\n' >&2 } _toolbox_system_swap_size() { local __value=${1-} value=${TOOLBOX_SWAP_SIZE_MB:-} if [[ -n $value ]]; then _toolbox_system_positive_int "$value" || { _toolbox_system_error "大小不正确:$value"; return 1; } else printf 'swap 文件大小(MiB,128-8192,默认 1024): ' >&2 IFS= read -r value || { _toolbox_system_error '大小未输入。'; return 1; } value=${value:-1024} _toolbox_system_positive_int "$value" || { _toolbox_system_error "大小不正确:$value"; return 1; } fi (( value >= 128 && value <= 8192 )) || { _toolbox_system_error "大小超范围:$value MiB(128-8192)。"; return 1; } printf -v "$__value" '%s' "$value" } _toolbox_system_swap() { local file unit fstab size=1024 mode_before=0 _toolbox_system_root || return file=$(_toolbox_system_swap_file); unit=$(_toolbox_system_swap_unit); fstab="$(_toolbox_system_swap_root)/etc/fstab" _toolbox_system_swap_lock_acquire || return if [[ -e $(_toolbox_system_swap_journal) ]]; then _toolbox_system_swap_recover || { _toolbox_system_swap_lock_release; return 1; } fi if ! _toolbox_system_swap_preflight; then _toolbox_system_swap_lock_release return 1 fi mode_before=$_TOOLBOX_SWAP_MODE if [[ $mode_before == idempotent ]]; then # 已启用:只补一次 daemon-reload 并复核,不动文件、不动单元。 systemctl daemon-reload >/dev/null 2>&1 || true _toolbox_system_swap_member "$file" && systemctl is-active --quiet "$_TOOLBOX_SWAP_UNIT" \ || { _toolbox_system_error '状态复核不一致(活动 swap 或单元);未改动,请人工检查。' _toolbox_system_swap_lock_release; return 1; } _toolbox_system_swap_lock_release printf 'swap 已启用且单元在位(%s,开机自启);未修改任何配置。\n' "$file" return 0 fi if [[ $mode_before == resume ]]; then size=$(( $(stat -c '%s' -- "$file") / 1048576 )) printf '发现上次未启用的残留(单元已在,文件 %s MiB);将重新启用。\n' "$size" else _toolbox_system_swap_size size || { _toolbox_system_swap_lock_release; return 1; } _toolbox_system_swap_check_space "$size" || { _toolbox_system_swap_lock_release; return 1; } fi printf '\nB6 swap(systemd 单元,不写 fstab)\n' printf ' 文件 : %s(%s MiB,权限 0600)\n' "$file" "$size" printf ' 单元 : %s\n' "$unit" printf ' 开机 : systemd 单元 enable,重启后自动启用\n' printf ' fstab : 不修改(两套机制并存会互相覆盖)\n' printf ' 验证 : 启用后核对 swapon 活动项与单元 active 状态\n' printf ' 回滚 : 任一步失败、Ctrl-C 或 SIGTERM 自动撤销本次写入\n' if grep -qE "^[[:space:]]*${file//\//\\/}[[:space:]]" "$fstab" 2>/dev/null; then printf ' 注意 : %s 里已有该路径的条目;本工具不改它,两份配置指向同一文件。\n' "$fstab" fi _toolbox_system_confirm "将在 $file 创建 swap 文件并启用 systemd 单元,确认?" || { _toolbox_system_swap_lock_release; return 1; } # 确认期间外部可能改动过;锁只能挡住本工具,所以再校验一遍并比对判定。 if ! _toolbox_system_swap_preflight; then _toolbox_system_error '确认期间状态已变化,判定不再一致;未写入。' _toolbox_system_swap_lock_release return 1 fi if [[ $_TOOLBOX_SWAP_MODE != "$mode_before" ]]; then _toolbox_system_error "确认期间状态已变化($mode_before → $_TOOLBOX_SWAP_MODE);未写入,请重跑。" _toolbox_system_swap_lock_release return 1 fi _TOOLBOX_SWAP_TXN_FILE=$file _TOOLBOX_SWAP_TXN_UNIT=$unit _TOOLBOX_SWAP_TXN_INODE=$_TOOLBOX_SWAP_INODE # 先记下调用者原有的 trap,成功时原样恢复,不静默丢掉别人的清理。 local old_int old_term old_exit old_int=$(trap -p INT); old_term=$(trap -p TERM); old_exit=$(trap -p EXIT) trap '_toolbox_system_swap_abort INT' INT trap '_toolbox_system_swap_abort TERM' TERM trap '_toolbox_system_swap_abort EXIT' EXIT if [[ $mode_before == create ]]; then _toolbox_system_swap_journal_write create "$file" "$unit" '' \ || _toolbox_system_swap_abort FAIL '写事务日志失败;未创建文件。' dd if=/dev/zero of="$file" bs=1M count="$size" conv=fsync status=none \ || _toolbox_system_swap_abort FAIL '创建 swap 文件失败。' # 写完再认一次:路径可能在 dd 期间被换成链接(如 /var 挂载变化)。 [[ -f $file && ! -L $file ]] \ || _toolbox_system_swap_abort FAIL "$file 创建后不是普通文件(可能被同时替换)。" ino=$(stat -c '%d:%i' -- "$file" 2>/dev/null) \ || _toolbox_system_swap_abort FAIL "无法读取 $file 的 inode。" _TOOLBOX_SWAP_TXN_INODE=$ino _toolbox_system_swap_journal_write file "$file" "$unit" "$ino" \ || _toolbox_system_swap_abort FAIL '更新事务日志失败。' [[ $(stat -c '%s' -- "$file") == "$(( size * 1048576 ))" ]] \ || _toolbox_system_swap_abort FAIL "文件大小不是 ${size} MiB。" chmod 0600 -- "$file" || _toolbox_system_swap_abort FAIL 'chmod 0600 失败。' mkswap -- "$file" >/dev/null || _toolbox_system_swap_abort FAIL 'mkswap 失败。' { printf '%s\n' "$_TOOLBOX_SYS_MARK_B6" printf '# 由 Terry Toolbox B6 管理;删除请用菜单,不要手工混改。\n' printf '[Unit]\nDescription=Terry Toolbox swap file\n\n' printf '[Swap]\nWhat=%s\n\n' "$file" printf '[Install]\nWantedBy=swap.target\n' } | _toolbox_system_put "$unit" 0644 || _toolbox_system_swap_abort FAIL '写入 systemd 单元失败。' _toolbox_system_swap_journal_write unit "$file" "$unit" "$ino" \ || _toolbox_system_swap_abort FAIL '更新事务日志失败。' fi systemctl daemon-reload || _toolbox_system_swap_abort FAIL 'systemctl daemon-reload 失败。' systemctl enable --now "$_TOOLBOX_SWAP_UNIT" >/dev/null 2>&1 \ || _toolbox_system_swap_abort FAIL 'systemctl 启用单元失败。' systemctl is-active --quiet "$_TOOLBOX_SWAP_UNIT" \ || _toolbox_system_swap_abort FAIL '单元未进入 active 状态。' _toolbox_system_swap_member "$file" \ || _toolbox_system_swap_abort FAIL '单元为 active,但 swapon 活动列表里没有该文件。' rm -f -- "$(_toolbox_system_swap_journal)" eval "${old_int:-trap - INT}" eval "${old_term:-trap - TERM}" eval "${old_exit:-trap - EXIT}" _toolbox_system_swap_lock_release printf '\nswap 已启用:%s(%s MiB),单元 %s 已 enable 并 active。\n' "$file" "$size" "$unit" printf '查看:swapon --show;systemctl status %s;free -h\n' "$_TOOLBOX_SWAP_UNIT" printf '注意:fstab 未改,持久化只依赖 systemd 单元;请勿再手工给同一路径加 fstab 条目。\n' } # MAIN_* distinguish primary menu actions from B* toolbox actions. # B and bare both select toolbox-menu entries. toolbox_system() { case "${1-}" in MAIN_BBR) _toolbox_system_bbr;; MAIN_DEPS) _toolbox_system_deps;; MAIN_REINSTALL) _toolbox_system_unimplemented '系统重装(缺少安全可验证的恢复方案)';; MAIN_REBOOT) _toolbox_system_reboot;; B1|1) _toolbox_system_docker;; B2|2) _toolbox_system_nodequality;; B4|4) _toolbox_system_unimplemented '哪吒安装';; B5|5) _toolbox_system_unimplemented 'XUI 安装';; B6|6) _toolbox_system_swap;; B7|7) _toolbox_system_traffic;; B8|8) _toolbox_system_password;; B10|10) _toolbox_system_unimplemented 'WebSSH 安装(缺少安全认证和部署方案)';; B11|11) _toolbox_system_panel;; B14|14) _toolbox_system_trace;; B15|15) _toolbox_system_tg;; B16|16) _toolbox_system_netopt;; B19|19) _toolbox_system_alpine;; *) _toolbox_system_error "未知系统工具编号: ${1-}"; return 2;; esac } # END bundled module: system # BEGIN bundled module: diagnostics #!/usr/bin/env bash # Read-only diagnostics; missing utilities are reported rather than installed. toolbox_diagnostics() { local cmd printf '\n=== 系统 ===\n' uname -a || true if [[ -r /etc/os-release ]]; then while IFS= read -r line; do case "$line" in PRETTY_NAME=*) printf '%s\n' "$line";; esac; done < /etc/os-release fi printf '\n=== 运行时间 ===\n' if command -v uptime >/dev/null 2>&1; then uptime || true; else printf 'uptime 不可用\n'; fi printf '\n=== 内存 ===\n' if command -v free >/dev/null 2>&1; then free -h || free || true elif [[ -r /proc/meminfo ]]; then while IFS= read -r line; do case "$line" in MemTotal:*|MemAvailable:*|SwapTotal:*|SwapFree:*) printf '%s\n' "$line";; esac; done < /proc/meminfo else printf '不可用\n'; fi printf '\n=== 磁盘 ===\n' df -h || true printf '\n=== 网络 ===\n' if command -v ip >/dev/null 2>&1; then ip -brief address || ip address || true else printf 'ip 命令不可用\n'; fi printf '\n=== 监听端口 ===\n' if command -v ss >/dev/null 2>&1; then ss -lntup || true elif command -v netstat >/dev/null 2>&1; then netstat -lntup || true else printf 'ss / netstat 不可用\n'; fi } # END bundled module: diagnostics # BEGIN bundled module: hy2 #!/usr/bin/env bash # A4 Hysteria 2 服务端安全安装模块。 # # 只支持「全新的 Debian + systemd + 自带证书」这一条路径;sourcing 本文件只定义 # 函数,不写系统、不探测网络。任何一项前置条件不满足都在写盘前失败;写盘后失败 # 则回滚到安装前的状态。所有判定只认本机事实(OS、systemd、ss 监听、systemd 单元 # 归属),不猜测「看起来像我们的服务」。 # # 固定上游:HyNetworks/hysteria app/v2.13.0 官方发布二进制 + SHA-256 白名单。 # 不使用官方一键脚本,也不从「latest」解析版本号。 hy2_const() { # name value [[ -n ${!1+set} ]] && return 0 printf -v "$1" '%s' "$2" readonly "$1" } hy2_const HY2_VERSION '2.13.0' hy2_const HY2_BIN_NAME 'terry-toolbox-hy2' hy2_const HY2_UNIT_NAME 'terry-toolbox-hy2.service' hy2_const HY2_DEFAULT_PORT 443 hy2_const HY2_MIN_VALID_DAYS 7 hy2_const HY2_MASQUERADE_TEXT 'Terry Toolbox Hysteria 2' # 测试可覆盖的路径与外部事实;默认值即真实主机路径。 hy2_etc() { printf '%s\n' "${TOOLBOX_HY2_ETC:-/etc/terry-toolbox-hy2}"; } hy2_unit_dir() { printf '%s\n' "${TOOLBOX_HY2_UNIT_DIR:-/etc/systemd/system}"; } hy2_bin_dir() { printf '%s\n' "${TOOLBOX_HY2_BIN_DIR:-/usr/local/bin}"; } hy2_lock() { printf '%s\n' "${TOOLBOX_HY2_LOCK:-/run/terry-toolbox-hy2.lock}"; } hy2_client_dir() { printf '%s\n' "${TOOLBOX_HY2_CLIENT_DIR:-/run/terry-toolbox-hy2-verify}"; } hy2_bin() { printf '%s/%s\n' "$(hy2_bin_dir)" "$HY2_BIN_NAME"; } hy2_unit() { printf '%s/%s\n' "$(hy2_unit_dir)" "$HY2_UNIT_NAME"; } hy2_err() { printf '%s\n' "$*" >&2; } hy2_die() { hy2_err "$*"; return 1; } # ---------------------------------------------------------------- 前置条件 hy2_require_root() { [[ ${TOOLBOX_HY2_UID:-${EUID:-$(id -u)}} -eq 0 ]] \ || hy2_die '需要 root 权限运行(安装会写入 /etc、/usr/local/bin 和 systemd 单元)。' } # 只接受 ID=debian。衍生版(如 kali、raspbian)的包和 systemd 行为有差异,不冒险。 hy2_require_debian() { local file=${TOOLBOX_HY2_OS_RELEASE:-/etc/os-release} id='' [[ -r $file ]] || hy2_die "无法读取 $file,不能确认发行版。" id=$(sed -n 's/^ID=//p' "$file" | head -n1) id=${id%\"}; id=${id#\"}; id=${id%\'}; id=${id#\'} [[ ${id,,} == debian ]] || hy2_die "仅支持 Debian(当前 ID=${id:-未知});不支持 Alpine/Ubuntu/衍生版。" } hy2_require_systemd() { local run_dir=${TOOLBOX_HY2_SYSTEMD_RUN:-/run/systemd/system} command -v systemctl >/dev/null 2>&1 || hy2_die '未找到 systemctl,本机不是 systemd 系统。' [[ -d $run_dir ]] || hy2_die 'systemd 未作为 init 运行(缺少 /run/systemd/system)。' } hy2_require_tools() { local missing=() c for c in openssl sha256sum curl flock ss awk sed mktemp; do command -v "$c" >/dev/null 2>&1 || missing+=("$c") done (( ${#missing[@]} == 0 )) || hy2_die "缺少必需命令:${missing[*]}" } hy2_arch() { case $(uname -m) in x86_64|amd64) printf 'amd64\n' ;; aarch64|arm64) printf 'arm64\n' ;; *) hy2_die "不支持的 CPU 架构:$(uname -m)" ;; esac } # 官方二进制白名单;测试可用 TOOLBOX_HY2_SHA256 覆盖以验证校验逻辑。 hy2_official_sha256() { case $1 in amd64) printf '%s\n' "${TOOLBOX_HY2_SHA256:-907ba8c9693edb104b20582681fb7dc15639d5b64a9cbb616a7b539190a86691}" ;; arm64) printf '%s\n' "${TOOLBOX_HY2_SHA256_ARM64:-a68a61a84452ca250ce0368202521965ca9cc9d801a404f1dc9008ac6cf677a7}" ;; *) return 1 ;; esac } hy2_valid_port() { [[ $1 =~ ^[1-9][0-9]{0,4}$ ]] || return 1 (( $1 <= 65535 )) } # 证书是用户输入,进 YAML 前先确认是可用的主机名。 hy2_valid_hostname() { [[ $1 =~ ^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$ ]] } # ---------------------------------------------------------------- 本机事实 # 端口集合来自 ss 的实际监听表,不靠「谁应该监听」推测。 hy2_udp_port_busy() { local line local_addr while IFS= read -r line; do [[ $line =~ ^(UNCONN|ESTAB|State) ]] || continue read -r -a _hy2_f <<< "$line" local_addr=${_hy2_f[3]:-} if [[ ${local_addr##*:} == "$1" ]]; then return 0; fi done < <(ss -lunH 2>/dev/null) return 1 } # 单元归属:absent(没有)/ ours(有且指向我们的目标)/ foreign(有但内容不符)。 hy2_unit_state() { local unit_file unit_file=$(hy2_unit) [[ -e $unit_file ]] || { printf 'absent\n'; return 0; } if grep -qF "ExecStart=$(hy2_bin)" "$unit_file" 2>/dev/null; then printf 'ours\n' else printf 'foreign\n' fi } # 端口是否由「本单元」持有。三层证据,任一成立即可: # 1) /proc//exe 就是我们的二进制(脚本安装时 exe 是解释器,会落到下一层); # 2) 该 pid 等于本单元 systemd 的 MainPID(systemd 亲自背书); # 3) 进程名匹配(内核 comm 截断到 15 字符,按前缀比)。 hy2_port_owned_by() { local port=$1 bin=$2 unit=${3:-$HY2_UNIT_NAME} line pid exe want main='' want=$(readlink -f -- "$bin" 2>/dev/null) || want=$bin main=$(systemctl show -p MainPID --value "$unit" 2>/dev/null) || main='' [[ $main =~ ^[0-9]+$ ]] || main='' while IFS= read -r line; do read -r -a _hy2_f <<< "$line" [[ ${_hy2_f[3]:-} == *":$port" ]] || continue while IFS= read -r pid; do [[ -n $pid ]] || continue if [[ -n $main && $pid == "$main" ]]; then return 0; fi exe=$(readlink -f "/proc/$pid/exe" 2>/dev/null) || exe='' if [[ -n $exe && $exe == "$want" ]]; then return 0; fi done < <(grep -o 'pid=[0-9]*' <<< "$line" | cut -d= -f2) if [[ $line == *"${HY2_BIN_NAME:0:15}"* ]]; then return 0; fi done < <(ss -lunpH 2>/dev/null) return 1 } # ---------------------------------------------------------------- 证书 hy2_check_cert() { local cert=$1 key=$2 host=$3 now [[ -f $cert && -r $cert ]] || { hy2_die "证书不存在或不可读:$cert"; return 1; } [[ -f $key && -r $key ]] || { hy2_die "私钥不存在或不可读:$key"; return 1; } (( $(stat -c %s -- "$cert") > 0 )) || { hy2_die "证书文件为空:$cert"; return 1; } (( $(stat -c %s -- "$key") > 0 )) || { hy2_die "私钥文件为空:$key"; return 1; } openssl x509 -in "$cert" -noout 2>/dev/null || { hy2_die "不是有效的 PEM 证书:$cert"; return 1; } openssl pkey -in "$key" -noout 2>/dev/null || { hy2_die "不是有效的 PEM 私钥:$key"; return 1; } # 公私钥配对:比对两者的公开模数/公钥,配对失败的服务启动即 FATAL。 local cpub kpub cpub=$(openssl x509 -in "$cert" -noout -pubkey 2>/dev/null) || { hy2_die '证书公钥解析失败'; return 1; } kpub=$(openssl pkey -in "$key" -pubout 2>/dev/null) || { hy2_die '私钥公钥解析失败'; return 1; } [[ $cpub == "$kpub" ]] || { hy2_die '证书与私钥不配对。'; return 1; } now=$(date +%s) openssl x509 -in "$cert" -noout -checkend 0 >/dev/null 2>&1 \ || { hy2_die '证书已过期(notAfter 已过)。'; return 1; } # notBefore 在未来时 openssl 只把它算作「尚未生效」,单独判定。 local start start=$(openssl x509 -in "$cert" -noout -startdate 2>/dev/null | cut -d= -f2) if [[ -n $start ]]; then local start_epoch start_epoch=$(date -d "$start" +%s 2>/dev/null) || start_epoch=0 (( start_epoch <= now )) || { hy2_die "证书尚未生效(notBefore=$start)。"; return 1; } fi if openssl x509 -in "$cert" -noout -checkend $((HY2_MIN_VALID_DAYS * 86400)) >/dev/null 2>&1; then : else hy2_err "注意:证书将在 $HY2_MIN_VALID_DAYS 天内过期,安装后请及时续期。" fi # 主机名必须出现在证书里,否则客户端校验不过、sniGuard 也会拒绝。 if openssl x509 -in "$cert" -noout -checkhost "$host" 2>/dev/null | grep -q 'does match'; then return 0 fi hy2_die "证书不包含主机名 $host(SAN/CN 不匹配)。" return 1 } # 证书链是否能被本机信任库验证(决定验证客户端要不要加 insecure)。 hy2_tls_trusted() { local cert=$1 if [[ -r /etc/ssl/certs/ca-certificates.crt ]]; then openssl verify -CAfile /etc/ssl/certs/ca-certificates.crt "$cert" >/dev/null 2>&1 elif [[ -d /etc/ssl/certs ]]; then openssl verify -CApath /etc/ssl/certs "$cert" >/dev/null 2>&1 else return 1 fi } # ---------------------------------------------------------------- 下载 hy2_fetch() { local arch=$1 dest=$2 expected url expected=$(hy2_official_sha256 "$arch") || { hy2_die "没有 $arch 的固定校验值。"; return 1; } url="https://github.com/HyNetworks/hysteria/releases/download/app/v${HY2_VERSION}/hysteria-linux-${arch}" url=${TOOLBOX_HY2_URL:-$url} if [[ -n ${TOOLBOX_HY2_BIN_SRC:-} ]]; then # 测试注入:直接当成本地文件,仍走校验和判断。 cp -- "$TOOLBOX_HY2_BIN_SRC" "$dest" || { hy2_die '复制本地二进制失败'; return 1; } else hy2_err "下载官方二进制:$url" curl -fsSL --proto '=https' --tlsv1.2 --connect-timeout 15 --max-time 300 \ -o "$dest" "$url" || { hy2_die '下载失败(网络或 GitHub 不可达)。'; return 1; } fi local actual actual=$(sha256sum -- "$dest" | cut -d' ' -f1) [[ $actual == "$expected" ]] \ || { hy2_die "SHA-256 不匹配:期望 $expected,实际 $actual(怀疑下载被篡改或版本漂移)。"; return 1; } chmod 0755 -- "$dest" || { hy2_die '无法设置二进制可执行权限。'; return 1; } } # ---------------------------------------------------------------- 状态写入与回滚 hy2_rollback() { local unit bin etc unit=$(hy2_unit); bin=$(hy2_bin); etc=$(hy2_etc) hy2_err '--- 开始回滚 ---' if [[ -e $unit ]]; then systemctl disable --now "$HY2_UNIT_NAME" >/dev/null 2>&1 || true rm -f -- "$unit" systemctl daemon-reload >/dev/null 2>&1 || true systemctl reset-failed "$HY2_UNIT_NAME" >/dev/null 2>&1 || true fi rm -f -- "$bin" if [[ -d $etc ]]; then rm -rf -- "$etc"; fi rm -rf -- "$(hy2_client_dir)" # 只认我们自己的单元名,不碰同名以外的东西。 if systemctl is-active --quiet "$HY2_UNIT_NAME" 2>/dev/null; then hy2_err "警告:回滚后 $HY2_UNIT_NAME 仍在运行,请人工确认。" fi hy2_err '已恢复到安装前状态(新增的单元、二进制、/etc 目录已删除)。' } # ---------------------------------------------------------------- 启动与验证 # 写盘后、交给 systemd 之前的离线自检:配置能否被解析、证书能否加载、端口能否绑定。 # 起后台进程看日志,成功就杀掉并等它退出,避免和随后的 systemd 启动抢端口。 hy2_smoke() { local bin=$1 conf=$2 port=$3 out pid i out=$(mktemp) || return 1 "$bin" server -c "$conf" --disable-update-check >"$out" 2>&1 & pid=$! for ((i = 0; i < 60; i++)); do if grep -q 'server up and running' "$out" 2>/dev/null; then break; fi if grep -q 'FATAL' "$out" 2>/dev/null; then break; fi kill -0 "$pid" 2>/dev/null || break command sleep 0.2 done kill "$pid" 2>/dev/null wait "$pid" 2>/dev/null || true # 进程可能比日志先结束,最终以日志内容为准 if grep -q 'server up and running' "$out" 2>/dev/null; then rm -f -- "$out" return 0 fi hy2_err "冒烟启动失败(端口 $port),服务输出:" sed -n '1,12p' "$out" >&2 rm -f -- "$out" return 1 } # 用本模块自己的二进制当客户端,经 SOCKS5 走一遍 TLS + 认证。 # 成功判据是客户端日志出现 connected;密码错误/SNI 错误必须分别失败,否则视为 # 「认证没被证明」而不是默认通过。 # quiet=1 时只在失败时输出(用于预期失败的负向探测)。 hy2_probe() { local bin=$1 host=$2 port=$3 pass=$4 insecure=$5 label=$6 quiet=${7:-0} local dir sock out out_dc i dir=$(hy2_client_dir); mkdir -p "$dir" || return 1 sock=$(( 39000 + RANDOM % 2000 )) while hy2_udp_port_busy "$sock" || ss -ltnH 2>/dev/null | grep -q ":$sock "; do (( sock++ )); done out="$dir/$label.log" { printf 'server: 127.0.0.1:%s\n' "$port" printf 'auth: %s\n' "$pass" printf 'tls:\n sni: %s\n' "$host" if [[ $insecure == 1 ]]; then printf ' insecure: true\n'; fi printf 'socks5:\n listen: 127.0.0.1:%s\n' "$sock" } > "$dir/$label.yaml" || return 1 # 客户端要一直活着才能做数据面测试,所以放后台,靠日志判断结果。 "$bin" client -c "$dir/$label.yaml" --disable-update-check >"$out" 2>&1 & local cpid=$! result=1 for ((i = 0; i < 75; i++)); do if grep -q 'connected to server' "$out" 2>/dev/null; then result=0; break; fi if grep -q 'FATAL' "$out" 2>/dev/null; then break; fi kill -0 "$cpid" 2>/dev/null || break command sleep 0.2 done # 进程可能比日志先结束,最终以日志内容为准 if (( result == 1 )) && grep -q 'connected to server' "$out" 2>/dev/null; then result=0; fi if (( result == 0 )) && (( quiet == 0 )) && command -v curl >/dev/null 2>&1; then # 顺带走一次真实数据面;目标不可达时不推翻已证明的认证结果。 out_dc='' for ((i = 0; i < 3; i++)); do out_dc=$(timeout 25 curl -fsS --max-time 20 --socks5-hostname "127.0.0.1:$sock" \ -o /dev/null -w '%{http_code}' https://www.cloudflare.com/cdn-cgi/trace 2>/dev/null) && break out_dc='' command sleep 1 done [[ -n $out_dc ]] || hy2_err "提示:$label 隧道已通,但数据面出站未成功(可能主机无外网出口)。" elif (( result == 1 )) && (( quiet == 0 )); then sed -n '1,8p' "$out" >&2 fi kill "$cpid" 2>/dev/null wait "$cpid" 2>/dev/null || true return $result } # 端口从 enable 到真正 bind 有间隔,轮询等它出现再判定。 hy2_wait_owned() { local port=$1 bin=$2 i for ((i = 0; i < 20; i++)); do if hy2_port_owned_by "$port" "$bin"; then return 0; fi sleep 0.5 done return 1 } hy2_wait_active() { local i for ((i = 0; i < 20; i++)); do if systemctl is-active --quiet "$HY2_UNIT_NAME" 2>/dev/null; then return 0; fi sleep 0.5 done return 1 } # ---------------------------------------------------------------- 安装 _hy2_install() { local cert=${1:-} key=${2:-} host=${3:-} port=${4:-} answer local arch tmp etc bin unit pass trusted=1 hy2_require_root || return 1 hy2_require_debian || return 1 hy2_require_systemd || return 1 hy2_require_tools || return 1 arch=$(hy2_arch) || return 1 etc=$(hy2_etc); bin=$(hy2_bin); unit=$(hy2_unit) # 菜单只传动作不传参数:缺的项逐一问,问到一半就 EOF 也算取消。 if [[ -z $cert ]]; then printf '证书文件路径(PEM,含完整链):' IFS= read -r cert || { hy2_err '没有收到证书路径,已取消。'; return 1; } fi if [[ -z $key ]]; then printf '私钥文件路径:' IFS= read -r key || { hy2_err '没有收到私钥路径,已取消。'; return 1; } fi if [[ -z $host ]]; then printf '证书对应的域名:' IFS= read -r host || { hy2_err '没有收到域名,已取消。'; return 1; } fi host=${host,,} hy2_valid_hostname "$host" || { hy2_die "主机名格式不正确:$host"; return 1; } if [[ -n $port ]]; then hy2_valid_port "$port" || { hy2_die "端口不正确:$port(1-65535,无前导零)"; return 1; } else port=$HY2_DEFAULT_PORT fi hy2_check_cert "$cert" "$key" "$host" || return 1 # 冲突判定必须在写盘之前,并区分「我们的残留」和「别人的服务」。 local state state=$(hy2_unit_state) case $state in foreign) hy2_die "$(hy2_unit) 已存在但内容不是本模块生成的,未覆盖;请人工确认归属。" return 1 ;; ours) if systemctl is-active --quiet "$HY2_UNIT_NAME" 2>/dev/null; then hy2_die "$HY2_UNIT_NAME 已在运行(我们的服务)。重装请先执行 toolbox_hy2 uninstall。" else hy2_die "$HY2_UNIT_NAME 已存在但未运行(我们的残留)。请先执行 toolbox_hy2 uninstall 清理后再安装。" fi return 1 ;; esac # 没有单元文件却已有同名二进制/配置目录:可能是别人的安装,不静默覆盖 # (否则回滚会把别人的文件一起删掉)。 if [[ -e $bin || -e $etc ]]; then hy2_die "已存在 $bin 或 $etc,却没有 $unit;未覆盖,请人工确认归属。" return 1 fi if hy2_udp_port_busy "$port"; then hy2_err "UDP 端口 $port 已被占用,监听它的进程:" ss -lunpH 2>/dev/null | awk -v p=":$port" '$4 ~ p "$" {print " " $4 " " $0}' | cut -c1-160 >&2 hy2_die "换成其他端口,或先确认并停止占用方。" return 1 fi printf '即将安装 Hysteria 2 服务端:\n' printf ' 版本 : v%s(官方发布,安装前校验 SHA-256)\n' "$HY2_VERSION" printf ' 主机名 : %s\n' "$host" printf ' 监听 : :%s/udp(所有地址,IPv4+IPv6)\n' "$port" printf ' 证书 : %s\n' "$cert" printf ' 私钥 : %s(复制到 %s,权限 600)\n' "$key" "$etc" printf ' 单元 : %s\n' "$unit" printf ' 认证 : 随机密码,安装完成后只显示一次\n' printf ' 防火墙 : 不改动;需自行放行 UDP %s\n' "$port" printf '确认安装?输入 YES 继续:' IFS= read -r answer || true [[ ${answer^^} == YES ]] || { hy2_err '已取消,未修改系统。'; return 1; } # 互斥:同一时刻只允许一个安装/卸载。 exec 9>"$(hy2_lock)" || { hy2_die "无法创建锁文件 $(hy2_lock)"; return 1; } flock -n 9 || { hy2_die '另一个安装/卸载正在进行,稍后再试。'; return 1; } tmp=$(mktemp -d) || { hy2_die '无法创建临时目录'; return 1; } pass=$(openssl rand -base64 32 | tr -dc 'A-Za-z0-9' | cut -c1-32) [[ ${#pass} -eq 32 ]] || { rm -rf -- "$tmp"; hy2_die '密码生成失败'; return 1; } # 从这里开始有任何失败都回滚。 hy2_err '--- 开始写入 ---' hy2_fetch "$arch" "$tmp/hysteria" || { rm -rf -- "$tmp"; hy2_rollback; return 1; } install -d -m 0755 "$etc" || { rm -rf -- "$tmp"; hy2_rollback; return 1; } install -m 0755 "$tmp/hysteria" "$bin" || { rm -rf -- "$tmp"; hy2_rollback; return 1; } install -m 0600 "$cert" "$etc/cert.pem" || { rm -rf -- "$tmp"; hy2_rollback; return 1; } install -m 0600 "$key" "$etc/key.pem" || { rm -rf -- "$tmp"; hy2_rollback; return 1; } cat > "$etc/config.yaml" < "$unit" </dev/null 2>&1; then hy2_err 'systemctl enable --now 失败。' rm -rf -- "$tmp"; hy2_rollback; return 1 fi if ! hy2_wait_active; then hy2_err '服务未进入 active 状态。' systemctl --no-pager -l status "$HY2_UNIT_NAME" >&2 2>&1 || true rm -rf -- "$tmp"; hy2_rollback; return 1 fi # 回读:端口确实由本服务持有(而不是别的东西碰巧占着)。 if ! hy2_wait_owned "$port" "$bin"; then hy2_err "单元显示运行中,但 UDP $port 的监听进程不是 $HY2_BIN_NAME。" rm -rf -- "$tmp"; hy2_rollback; return 1 fi # 证书链不被本机信任时(自签/缺中间证书)用 insecure 探测,并明确标注。 hy2_tls_trusted "$etc/cert.pem" || trusted=0 local insecure=0 if [[ $trusted == 0 ]]; then insecure=1; fi if ! hy2_probe "$bin" "$host" "$port" "$pass" "$insecure" good; then hy2_err '认证探测失败(正确密码未连通)。' rm -rf -- "$tmp"; hy2_rollback; return 1 fi # 负向探测:错误密码必须被拒,否则等于没验证认证。 if hy2_probe "$bin" "$host" "$port" "wrong-$pass" "$insecure" bad 1; then hy2_err '错误密码也被接受,认证未生效。' rm -rf -- "$tmp"; hy2_rollback; return 1 fi printf ' 负向探测:错误密码被拒绝 ✓\n' printf '%s|%s|%s|%s\n' "$host" "$port" "$insecure" "$HY2_VERSION" > "$etc/meta" chmod 0600 "$etc/meta" rm -rf -- "$tmp" hy2_report "$host" "$port" "$pass" "$insecure" } hy2_report() { local host=$1 port=$2 pass=$3 insecure=$4 qs='' if [[ $insecure == 1 ]]; then qs='&insecure=1'; fi printf '\n安装完成,服务 %s 已设为开机自启。\n' "$HY2_UNIT_NAME" printf '客户端连接信息(密码只显示这一次,也已写入 %s/config.yaml):\n' "$(hy2_etc)" printf ' hysteria2://%s@%s:%s/?sni=%s%s\n' "$pass" "$host" "$port" "$host" "$qs" if [[ $insecure == 1 ]]; then printf ' 证书链未被本机信任库验证通过:上面链接带 insecure=1,客户端需同样开启。\n' else printf ' 证书链验证通过:客户端可保持严格校验(去掉 insecure=1)。\n' fi printf '\n验证情况:\n' printf ' [已验证] 单元 active、UDP %s 由 %s 监听\n' "$port" "$HY2_BIN_NAME" printf ' [已验证] 正确密码经 TLS 认证连通;错误密码被拒绝\n' printf '\n仍需人工处理:\n' printf ' - 防火墙/安全组放行 UDP %s(本模块不改防火墙)\n' "$port" printf ' - 证书续期:更新后覆盖 %s/cert.pem、key.pem,重载服务\n' "$(hy2_etc)" printf ' - 卸载:toolbox_hy2 uninstall\n' } # ---------------------------------------------------------------- 卸载 _hy2_uninstall() { local unit etc bin state answer hy2_require_root || return 1 hy2_require_systemd || return 1 unit=$(hy2_unit); etc=$(hy2_etc); bin=$(hy2_bin) state=$(hy2_unit_state) if [[ $state == absent && ! -e $bin && ! -d $etc ]]; then hy2_die '没有找到本模块的安装(单元、二进制、配置都不存在)。' return 1 fi if [[ $state == foreign ]]; then hy2_die "$unit 不是本模块生成的,未删除;请人工确认。" return 1 fi printf '将停止并删除:%s\n %s\n %s\n %s\n' "$unit" "$bin" "$etc" "(证书副本,原始证书不动)" printf '确认卸载?输入 YES:' IFS= read -r answer || true [[ ${answer^^} == YES ]] || { hy2_err '已取消。'; return 1; } exec 9>"$(hy2_lock)" || { hy2_die "无法创建锁文件"; return 1; } flock -n 9 || { hy2_die '另一个安装/卸载正在进行。'; return 1; } systemctl disable --now "$HY2_UNIT_NAME" >/dev/null 2>&1 || true rm -f -- "$unit" systemctl daemon-reload >/dev/null 2>&1 || true systemctl reset-failed "$HY2_UNIT_NAME" >/dev/null 2>&1 || true rm -f -- "$bin" rm -rf -- "$etc" rm -rf -- "$(hy2_client_dir)" printf '已卸载。\n' } # 安装/卸载整体放进子 shell:锁用 exec 9 持有,子 shell 结束后自动释放, # 不会把锁句柄留到调用方 shell 里。子 shell 内显式 set -e,这样即使调用方把 # 本函数放进 `||` / `$( )` 之类会关闭 errexit 的上下文,中途失败也会立即停下。 hy2_install() { ( set -e; _hy2_install "$@" ) ; } hy2_uninstall() { ( set -e; _hy2_uninstall "$@" ) ; } hy2_status() { local host port insecure version bin bin=$(hy2_bin) if [[ ! -e $(hy2_unit) ]]; then printf '未安装。\n' return 1 fi IFS='|' read -r host port insecure version < "$(hy2_etc)/meta" 2>/dev/null || true printf '单元: %s\n' "$(hy2_unit)" systemctl --no-pager -l status "$HY2_UNIT_NAME" 2>&1 | sed -n '1,6p' if [[ -n ${port:-} ]]; then printf '端口 %s 监听: ' "$port" if hy2_port_owned_by "$port" "$bin"; then printf '由本服务持有\n'; else printf '不是本服务\n'; fi fi } # ---------------------------------------------------------------- 入口 toolbox_hy2() { local action=${1:-install} shift || true case $action in install) hy2_install "$@" ;; uninstall) hy2_uninstall ;; status) hy2_status ;; *) hy2_die "用法: toolbox_hy2 [install <证书> <私钥> <主机名> [端口] | uninstall | status]" ;; esac } # 直接执行(bash modules/hy2.sh)也能用,但 sourcing 不产生任何副作用。 # 打进单文件包时由构建脚本设置 TOOLBOX_BUNDLED=1,避免误当成独立执行。 if [[ ${BASH_SOURCE[0]:-} == "$0" && ${TOOLBOX_BUNDLED:-0} != 1 ]]; then toolbox_hy2 "$@" fi # END bundled module: hy2 # BEGIN bundled module: webssh #!/usr/bin/env bash # B10 WebSSH:Debian + systemd 上的「浏览器 SSH」安全部署。 # # 上游 huashengdun/webssh 只发布 PyPI 源码包,没有官方镜像。因此本模块: # * 从 PyPI 固定 sdist(版本 + SHA-256 白名单),装进独立 venv,不用 latest; # * WebSSH 本身没有页面级认证,所以前面加一个独立 nginx 实例做 HTTP Basic 认证; # * WebSSH 和 nginx 都只监听 127.0.0.1(nginx 用可配置的回环端口), # nginx 只接受来自回环的 upstream,并拒绝非回环的上游地址(tdstream); # * 不碰系统 nginx:使用独立配置、独立单元、独立端口,卸载时只删自己的东西; # * 任一项前置条件不满足都在写盘前失败;写盘后失败则整体回滚。 # # 已知边界:Basic 认证走明文 HTTP,远程访问需自行加 TLS(反代/隧道),本模块不改防火墙。 # 固定的是 webssh 版本与 sdist 哈希;paramiko/tornado 等依赖仍按上游声明由 pip 解析(未做全量锁)。 readonly WEBSSH_VERSION='1.6.3' # PyPI sdist 白名单(https://pypi.org/project/webssh/1.6.3/)。版本漂移必须先人工复核。 readonly WEBSSH_SDIST_SHA256='2bce5bbaf206ad345910c7e4dc8024b3c418e681c9f5ff098f1802beff76e100' readonly WEBSSH_SDIST_URL='https://files.pythonhosted.org/packages/fe/8d/aba7feaaaaa15a54decff18c5df79df27f17c833e6f9fd274a217ec7077b/webssh-1.6.3.tar.gz' readonly WEBSSH_UNIT_NAME='terry-toolbox-webssh.service' readonly WEBSSH_NGINX_UNIT_NAME='terry-toolbox-webssh-nginx.service' readonly WEBSSH_DEFAULT_PORT=8080 readonly WEBSSH_DEFAULT_USER='admin' readonly WEBSSH_MIN_PASS=12 readonly WEBSSH_REALM='Terry Toolbox WebSSH' readonly WEBSSH_WEB_USER='www-data' # 测试可覆盖的路径;默认值即真实主机路径。 webssh_etc() { printf '%s\n' "${TOOLBOX_WEBSSH_ETC:-/etc/terry-toolbox-webssh}"; } webssh_opt() { printf '%s\n' "${TOOLBOX_WEBSSH_OPT:-/opt/terry-toolbox-webssh}"; } webssh_lib() { printf '%s\n' "${TOOLBOX_WEBSSH_LIB:-/var/lib/terry-toolbox-webssh}"; } webssh_log() { printf '%s\n' "${TOOLBOX_WEBSSH_LOG:-/var/log/terry-toolbox-webssh}"; } webssh_run() { printf '%s\n' "${TOOLBOX_WEBSSH_RUN:-/run/terry-toolbox-webssh}"; } webssh_unit_dir() { printf '%s\n' "${TOOLBOX_WEBSSH_UNIT_DIR:-/etc/systemd/system}"; } webssh_lock() { printf '%s\n' "${TOOLBOX_WEBSSH_LOCK:-/run/terry-toolbox-webssh.lock}"; } webssh_unit() { printf '%s/%s\n' "$(webssh_unit_dir)" "$WEBSSH_UNIT_NAME"; } webssh_nginx_unit(){ printf '%s/%s\n' "$(webssh_unit_dir)" "$WEBSSH_NGINX_UNIT_NAME"; } webssh_wssh() { printf '%s/venv/bin/wssh\n' "$(webssh_opt)"; } # 解析 nginx 实际路径:Debian 是 /usr/sbin/nginx,但仍以命令解析结果为准。 webssh_nginx() { [[ -n ${TOOLBOX_WEBSSH_NGINX:-} ]] && { printf '%s\n' "$TOOLBOX_WEBSSH_NGINX"; return 0; } command -v nginx 2>/dev/null || printf '/usr/sbin/nginx\n' } webssh_err() { printf '%s\n' "$*" >&2; } webssh_die() { webssh_err "$*"; return 1; } # ---------------------------------------------------------------- 前置条件 webssh_require_root() { [[ ${TOOLBOX_WEBSSH_UID:-${EUID:-$(id -u)}} -eq 0 ]] \ || webssh_die '需要 root 权限运行(会写 /etc、/opt、/var/lib 和 systemd 单元)。' } webssh_require_debian() { local file=${TOOLBOX_WEBSSH_OS_RELEASE:-/etc/os-release} id='' [[ -r $file ]] || webssh_die "无法读取 $file,不能确认发行版。" id=$(sed -n 's/^ID=//p' "$file" | head -n1) id=${id%\"}; id=${id#\"}; id=${id%\'}; id=${id#\'} [[ ${id,,} == debian ]] || webssh_die "仅支持 Debian(当前 ID=${id:-未知});Alpine/Ubuntu 未验证。" } webssh_require_systemd() { local run_dir=${TOOLBOX_WEBSSH_SYSTEMD_RUN:-/run/systemd/system} command -v systemctl >/dev/null 2>&1 || webssh_die '未找到 systemctl,本机不是 systemd 系统。' [[ -d $run_dir ]] || webssh_die 'systemd 未作为 init 运行(缺少 /run/systemd/system)。' } webssh_require_tools() { local missing=() c for c in openssl sha256sum curl flock ss awk sed mktemp install python3 getent; do command -v "$c" >/dev/null 2>&1 || missing+=("$c") done (( ${#missing[@]} == 0 )) || webssh_die "缺少必需命令:${missing[*]}" # venv 模块可能缺(python3-venv 未装),交给 ensure_packages 补装后再复查。 getent passwd "$WEBSSH_WEB_USER" >/dev/null 2>&1 \ || webssh_die "缺少系统用户 $WEBSSH_WEB_USER(nginx 工作进程用它降权)。" } # 只补装缺的包;已装的不动。滚动升级/清理交给系统包管理器。 webssh_ensure_packages() { local pkgs=() command -v nginx >/dev/null 2>&1 || pkgs+=(nginx) python3 -c 'import venv, ensurepip' >/dev/null 2>&1 || pkgs+=(python3-venv) (( ${#pkgs[@]} )) || return 0 webssh_err "安装系统包:${pkgs[*]}(apt-get)" DEBIAN_FRONTEND=noninteractive apt-get update -qq || { webssh_die 'apt-get update 失败。'; return 1; } DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends "${pkgs[@]}" \ || { webssh_die "apt-get install 失败:${pkgs[*]}"; return 1; } python3 -c 'import venv, ensurepip' >/dev/null 2>&1 \ || { webssh_die 'python3-venv 安装后仍不可用。'; return 1; } } # ---------------------------------------------------------------- 输入校验 webssh_valid_port() { [[ $1 =~ ^[1-9][0-9]{0,4}$ ]] || return 1 (( $1 <= 65535 )) } webssh_valid_user() { [[ $1 =~ ^[A-Za-z0-9][A-Za-z0-9._-]{1,31}$ ]]; } # 只允许可打印、无空白字符:能安全写进 meta,也能经 printf/openssl 传递。 webssh_valid_pass() { (( ${#1} >= WEBSSH_MIN_PASS && ${#1} <= 64 )) || return 1 # '|' 是 meta 的分隔符,[[::graph:]] 之外单独排除,避免 status 解析错位。 [[ $1 =~ ^[[:graph:]]+$ && $1 != *'|'* ]] } # 空密码由本模块生成;有密码时要求二次输入,避免打错后只能看哈希。 webssh_read_credentials() { local user=${1:-} pass=${2:-} again if [[ -z $user ]]; then printf '登录用户名 [默认 %s]([A-Za-z0-9._-],2-32 位): ' "$WEBSSH_DEFAULT_USER" IFS= read -r user || true user=${user:-$WEBSSH_DEFAULT_USER} fi webssh_valid_user "$user" || { webssh_die "用户名格式不正确:$user"; return 1; } if [[ -z $pass ]]; then while :; do printf '登录密码(至少 %s 位,输入不回显,留空自动生成): ' "$WEBSSH_MIN_PASS" IFS= read -rs pass || true printf '\n' if [[ -z $pass ]]; then pass=$(openssl rand -base64 48 | tr -dc 'A-Za-z0-9' | cut -c1-24) [[ ${#pass} -eq 24 ]] || { webssh_die '密码生成失败。'; return 1; } WEBSSH_PASS_GENERATED=1 break fi webssh_valid_pass "$pass" || { webssh_err "密码需 $WEBSSH_MIN_PASS-64 位且不含空白字符,请重试。" continue } printf '再输入一次: ' IFS= read -rs again || true printf '\n' [[ $again == "$pass" ]] && break webssh_err '两次输入不一致,请重试。' done else webssh_valid_pass "$pass" || { webssh_die '密码需 12-64 位且不含空白字符。'; return 1; } fi WEBSSH_USER_INPUT=$user WEBSSH_PASS_INPUT=$pass } # ---------------------------------------------------------------- 本机事实 webssh_tcp_port_busy() { ss -ltnH 2>/dev/null | awk -v p=":$1" '$4 ~ p "$" { found=1 } END { exit !found }' } webssh_port_owner() { ss -ltnpH 2>/dev/null | awk -v p=":$1" '$4 ~ p "$" { print; exit }' | cut -c1-200 } # 端口是否由指定单元的 MainPID 持有(不猜「看起来像」)。 webssh_port_owned_by() { local port=$1 unit=$2 line main='' main=$(systemctl show -p MainPID --value "$unit" 2>/dev/null) || main='' [[ $main =~ ^[0-9]+$ ]] || return 1 while IFS= read -r line; do if [[ $line == *"pid=$main,"* || $line == *"pid=$main)"* ]]; then return 0; fi done < <(ss -ltnpH 2>/dev/null | awk -v p="127.0.0.1:$port" '$4 == p') return 1 } webssh_wait_active() { local unit=$1 i tries=${TOOLBOX_WEBSSH_WAIT_TRIES:-20} for ((i = 0; i < tries; i++)); do systemctl is-active --quiet "$unit" 2>/dev/null && return 0 sleep 0.5 done return 1 } webssh_wait_owned() { local port=$1 unit=$2 i tries=${TOOLBOX_WEBSSH_WAIT_TRIES:-20} for ((i = 0; i < tries; i++)); do webssh_port_owned_by "$port" "$unit" && return 0 sleep 0.5 done return 1 } # 单元的三种归属:absent / ours(ExecStart 指纹匹配)/ foreign。 webssh_unit_state() { local file=$1 needle=$2 [[ -e $file ]] || { printf 'absent\n'; return 0; } if grep -qF -- "$needle" "$file" 2>/dev/null; then printf 'ours\n'; else printf 'foreign\n'; fi } # 内部端口:随机高端口,跳过已占用和与前端端口相同的值(同一回环地址不能共用)。 webssh_pick_internal_port() { local avoid=${1:-} p i for ((i = 0; i < 200; i++)); do p=$(( 21000 + RANDOM % 19000 )) [[ $p == "$avoid" ]] && continue webssh_tcp_port_busy "$p" || { printf '%s\n' "$p"; return 0; } done return 1 } # ---------------------------------------------------------------- 写入与回滚 webssh_rollback() { local etc opt lib log run etc=$(webssh_etc); opt=$(webssh_opt); lib=$(webssh_lib); log=$(webssh_log); run=$(webssh_run) webssh_err '--- 开始回滚 ---' # 先停 nginx 再停 webssh,避免回滚期间反向代理打到已停的后端。 systemctl disable --now "$WEBSSH_NGINX_UNIT_NAME" >/dev/null 2>&1 || true systemctl disable --now "$WEBSSH_UNIT_NAME" >/dev/null 2>&1 || true rm -f -- "$(webssh_nginx_unit)" "$(webssh_unit)" systemctl daemon-reload >/dev/null 2>&1 || true systemctl reset-failed "$WEBSSH_NGINX_UNIT_NAME" >/dev/null 2>&1 || true systemctl reset-failed "$WEBSSH_UNIT_NAME" >/dev/null 2>&1 || true rm -rf -- "$etc" "$opt" "$lib" "$log" "$run" if systemctl is-active --quiet "$WEBSSH_UNIT_NAME" 2>/dev/null \ || systemctl is-active --quiet "$WEBSSH_NGINX_UNIT_NAME" 2>/dev/null; then webssh_err '警告:回滚后仍有单元处于 active,请人工确认。' fi webssh_err '已恢复到安装前状态(新增的单元、目录和 venv 已删除;apt 补装的包保留)。' } webssh_fetch() { local dest=$1 expected=${TOOLBOX_WEBSSH_SDIST_SHA256:-$WEBSSH_SDIST_SHA256} actual url url=${TOOLBOX_WEBSSH_SDIST_URL:-$WEBSSH_SDIST_URL} if [[ -n ${TOOLBOX_WEBSSH_SDIST_SRC:-} ]]; then cp -- "$TOOLBOX_WEBSSH_SDIST_SRC" "$dest" || { webssh_die '复制本地源码包失败。'; return 1; } else webssh_err "下载 webssh $WEBSSH_VERSION 源码包(PyPI)…" curl -fsSL --proto '=https' --tlsv1.2 --connect-timeout 15 --max-time 300 \ -o "$dest" "$url" || { webssh_die '下载失败(网络或 PyPI 不可达)。'; return 1; } fi actual=$(sha256sum -- "$dest" | cut -d' ' -f1) [[ $actual == "$expected" ]] \ || { webssh_die "SHA-256 不匹配:期望 $expected,实际 $actual(版本漂移或下载被篡改)。"; return 1; } } webssh_install_venv() { local opt tmp=$1 opt=$(webssh_opt) python3 -m venv "$opt/venv" || { webssh_die '创建 venv 失败。'; return 1; } "$opt/venv/bin/pip" install --disable-pip-version-check --no-cache-dir --no-input \ "$tmp/webssh.tar.gz" || { webssh_die 'pip 安装 webssh 失败。'; return 1; } # 装完回读版本:索引上的 name/version 与源码包内版本不一致时要立刻发现。 local got got=$("$opt/venv/bin/pip" show webssh 2>/dev/null | sed -n 's/^Version: //p') [[ $got == "$WEBSSH_VERSION" ]] \ || { webssh_die "venv 中 webssh 版本为 ${got:-未知},期望 $WEBSSH_VERSION。"; return 1; } "$opt/venv/bin/python" -c 'import webssh' >/dev/null 2>&1 \ || { webssh_die 'webssh 无法导入,安装不完整。'; return 1; } } webssh_write_htpasswd() { local file=$1 user=$2 pass=$3 hash # apr1(= htpasswd -m)是 nginx 支持的格式;-stdin 让密码里的前导 '-' 也安全。 hash=$(printf '%s\n' "$pass" | openssl passwd -apr1 -salt "$(openssl rand -hex 4)" -stdin) \ || { webssh_die '生成 htpasswd 哈希失败。'; return 1; } [[ $hash == \$apr1\$* ]] || { webssh_die 'htpasswd 哈希格式异常。'; return 1; } printf '%s:%s\n' "$user" "$hash" > "$file" || { webssh_die "写入 $file 失败。"; return 1; } chown "root:$WEBSSH_WEB_USER" "$file" && chmod 0640 "$file" } webssh_write_nginx_conf() { local file=$1 port=$2 internal=$3 etc etc=$(webssh_etc) cat > "$file" < "$(webssh_unit)" < "$(webssh_nginx_unit)" </dev/null else curl -s -o /dev/null -w '%{http_code}' --max-time 5 "$url" 2>/dev/null fi } # Origin 必须带上端口:webssh 的默认 origin 策略是「同源」,Host 带端口而 Origin 不带会 403。 webssh_ws_code() { local url=$1 creds=${2:-} origin=$3 curl -s -o /dev/null -w '%{http_code}' --max-time 5 \ -H "Origin: $origin" -H 'Connection: Upgrade' -H 'Upgrade: websocket' \ -H 'Sec-WebSocket-Version: 13' -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \ ${creds:+-u "$creds"} "$url" 2>/dev/null } # 认证必须「有则通、无则挡」:只用正确密码通过不算证明。 webssh_verify_http() { local port=$1 internal=$2 user=$3 pass=$4 body code local base="http://127.0.0.1:$port" code=$(webssh_http_code "$base/") [[ $code == 401 ]] || { webssh_die "未认证访问首页返回 $code,期望 401。"; return 1; } code=$(webssh_http_code "$base/" "$user:wrong-$pass") [[ $code == 401 ]] || { webssh_die "错误密码返回 $code,期望 401(认证可能未生效)。"; return 1; } body=$(mktemp) || return 1 code=$(curl -s -o "$body" -w '%{http_code}' --max-time 5 -u "$user:$pass" "$base/" 2>/dev/null) if [[ $code != 200 ]] || ! grep -qi 'webssh' "$body"; then rm -f -- "$body" webssh_die "认证后首页返回 $code,或页面不是 WebSSH(内部端口 $internal)。" return 1 fi rm -f -- "$body" code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 -u "$user:$pass" "$base/static/js/main.js" 2>/dev/null) [[ $code == 200 ]] || { webssh_die "静态资源返回 $code,期望 200。"; return 1; } # 终端依赖 websocket:升级到 101 才说明 nginx 的 Upgrade 转发可用; # 认证缺失时必须先被 401 挡住,而不是把 websocket 直接放行。 code=$(webssh_ws_code "$base/ws?id=probe" '' "$base") [[ $code == 401 ]] || { webssh_die "未认证 websocket 返回 $code,期望 401。"; return 1; } code=$(webssh_ws_code "$base/ws?id=probe" "$user:$pass" "$base") [[ $code == 101 ]] || { webssh_die "websocket 升级返回 $code,期望 101(终端会连不上)。"; return 1; } } # ---------------------------------------------------------------- 安装 _webssh_install() { local port=${1:-} user='' pass='' internal='' answer local etc opt lib log run state tmp webssh_require_root || return 1 webssh_require_debian || return 1 webssh_require_systemd || return 1 webssh_require_tools || return 1 if [[ -n $port ]]; then webssh_valid_port "$port" || { webssh_die "端口不正确:$port(1-65535,无前导零)"; return 1; } else port=$WEBSSH_DEFAULT_PORT fi # 冲突判定全部在写盘前,并区分「我们的残留」和「别人的文件」。 state=$(webssh_unit_state "$(webssh_unit)" "ExecStart=$(webssh_wssh)") case $state in foreign) webssh_die "$(webssh_unit) 已存在且不是本模块生成,未覆盖;请人工确认。"; return 1 ;; ours) if systemctl is-active --quiet "$WEBSSH_UNIT_NAME" 2>/dev/null; then webssh_die "WebSSH 已在运行($(webssh_unit))。重装请先执行 toolbox_webssh uninstall。" else webssh_die "发现上次残留($(webssh_unit) 未运行)。请先 uninstall 清理。" fi return 1 ;; esac state=$(webssh_unit_state "$(webssh_nginx_unit)" "ExecStart=$(webssh_nginx) -c $(webssh_etc)/nginx.conf") case $state in foreign) webssh_die "$(webssh_nginx_unit) 已存在且不是本模块生成,未覆盖。"; return 1 ;; ours) webssh_die "发现残留的 nginx 单元,请先 uninstall。"; return 1 ;; esac if [[ -d $(webssh_etc) || -d $(webssh_opt) ]]; then webssh_die "$(webssh_etc) 或 $(webssh_opt) 已存在但没有对应单元,请人工确认后再装。" return 1 fi if webssh_tcp_port_busy "$port"; then webssh_err "TCP 端口 $port 已被占用:" webssh_port_owner "$port" >&2 || true webssh_die '换成其他端口,或先停止占用方。' return 1 fi internal=$(webssh_pick_internal_port "$port") || { webssh_die '找不到可用的内部端口。'; return 1; } webssh_read_credentials "$user" "$pass" || return 1 user=$WEBSSH_USER_INPUT pass=$WEBSSH_PASS_INPUT etc=$(webssh_etc); opt=$(webssh_opt); lib=$(webssh_lib); log=$(webssh_log); run=$(webssh_run) printf '\n即将安装 WebSSH(浏览器 SSH):\n' printf ' 版本 : %s(PyPI 源码包,安装前校验 SHA-256)\n' "$WEBSSH_VERSION" printf ' 访问 : http://127.0.0.1:%s/(仅回环)\n' "$port" printf ' 后端 : 127.0.0.1:%s(仅回环,WebSSH 自身无页面认证)\n' "$internal" printf ' 认证 : nginx HTTP Basic,用户名 %s,密码%s\n' "$user" \ "$([[ ${WEBSSH_PASS_GENERATED:-0} == 1 ]] && printf '随机生成、安装后只显示一次' || printf '按输入设置')" printf ' 目录 : %s(配置)、%s(venv)、%s、%s\n' "$etc" "$opt" "$lib" "$log" printf ' 不涉及 : 系统 nginx 配置、防火墙、TLS\n' printf '确认安装?输入 YES 继续:' IFS= read -r answer || true [[ ${answer^^} == YES ]] || { webssh_die '已取消,未修改系统。'; return 1; } exec 9>"$(webssh_lock)" || { webssh_die "无法创建锁文件 $(webssh_lock)"; return 1; } flock -n 9 || { webssh_die '另一个安装/卸载正在进行,稍后再试。'; return 1; } # 补包不属于回滚范围:失败时本模块还没写任何文件。 webssh_ensure_packages || return 1 tmp=$(mktemp -d) || { webssh_die '无法创建临时目录'; return 1; } webssh_err '--- 开始写入 ---' install -d -m 0755 "$etc" "$opt" || { rm -rf -- "$tmp"; webssh_rollback; return 1; } install -d -m 0750 -o root -g "$WEBSSH_WEB_USER" "$lib" "$log" || { rm -rf -- "$tmp"; webssh_rollback; return 1; } install -d -m 0770 -o "$WEBSSH_WEB_USER" -g "$WEBSSH_WEB_USER" \ "$lib/tmp" "$lib/tmp/client" "$lib/tmp/proxy" "$lib/tmp/fastcgi" "$lib/tmp/uwsgi" "$lib/tmp/scgi" \ || { rm -rf -- "$tmp"; webssh_rollback; return 1; } install -d -m 0755 "$run" || { rm -rf -- "$tmp"; webssh_rollback; return 1; } if ! : > "$etc/known_hosts" || ! chmod 0600 "$etc/known_hosts"; then rm -rf -- "$tmp"; webssh_rollback; return 1 fi webssh_fetch "$tmp/webssh.tar.gz" || { rm -rf -- "$tmp"; webssh_rollback; return 1; } webssh_install_venv "$tmp" || { rm -rf -- "$tmp"; webssh_rollback; return 1; } webssh_write_htpasswd "$etc/htpasswd" "$user" "$pass" || { rm -rf -- "$tmp"; webssh_rollback; return 1; } webssh_write_nginx_conf "$etc/nginx.conf" "$port" "$internal" || { rm -rf -- "$tmp"; webssh_rollback; return 1; } webssh_write_units || { rm -rf -- "$tmp"; webssh_rollback; return 1; } sed -i "s/__INTERNAL__/$internal/" "$(webssh_unit)" || { rm -rf -- "$tmp"; webssh_rollback; return 1; } # 语法检查必须在启用之前;nginx -t 会同时证明配置与 mime.types 可用。 if ! "$(webssh_nginx)" -t -c "$etc/nginx.conf" >/dev/null 2>&1; then webssh_err 'nginx 配置自检失败:' "$(webssh_nginx)" -t -c "$etc/nginx.conf" >&2 2>&1 || true rm -rf -- "$tmp"; webssh_rollback; return 1 fi if ! systemctl daemon-reload; then rm -rf -- "$tmp"; webssh_rollback; return 1 fi if ! systemctl enable --now "$WEBSSH_UNIT_NAME" >/dev/null 2>&1; then webssh_err 'webssh 单元启动失败:' systemctl --no-pager -l status "$WEBSSH_UNIT_NAME" >&2 2>&1 || true rm -rf -- "$tmp"; webssh_rollback; return 1 fi if ! webssh_wait_active "$WEBSSH_UNIT_NAME" || ! webssh_wait_owned "$internal" "$WEBSSH_UNIT_NAME"; then webssh_err "webssh 未在 127.0.0.1:$internal 上运行:" systemctl --no-pager -l status "$WEBSSH_UNIT_NAME" >&2 2>&1 || true rm -rf -- "$tmp"; webssh_rollback; return 1 fi if ! systemctl enable --now "$WEBSSH_NGINX_UNIT_NAME" >/dev/null 2>&1; then webssh_err 'nginx 单元启动失败:' systemctl --no-pager -l status "$WEBSSH_NGINX_UNIT_NAME" >&2 2>&1 || true rm -rf -- "$tmp"; webssh_rollback; return 1 fi if ! webssh_wait_active "$WEBSSH_NGINX_UNIT_NAME" || ! webssh_wait_owned "$port" "$WEBSSH_NGINX_UNIT_NAME"; then webssh_err "nginx 未在 127.0.0.1:$port 上运行:" systemctl --no-pager -l status "$WEBSSH_NGINX_UNIT_NAME" >&2 2>&1 || true rm -rf -- "$tmp"; webssh_rollback; return 1 fi if ! webssh_verify_http "$port" "$internal" "$user" "$pass"; then webssh_err 'HTTP/认证验证失败。' rm -rf -- "$tmp"; webssh_rollback; return 1 fi printf '%s|%s|%s|%s|%s|%s\n' "$user" "$pass" "$port" "$internal" "$WEBSSH_VERSION" \ "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$etc/meta" if [[ ! -s $etc/meta ]] || ! chmod 0600 "$etc/meta"; then rm -rf -- "$tmp"; webssh_rollback; return 1 fi rm -rf -- "$tmp" webssh_report "$user" "$pass" "$port" } webssh_report() { local user=$1 pass=$2 port=$3 printf '\n安装完成,%s 与 %s 已设为开机自启。\n' "$WEBSSH_UNIT_NAME" "$WEBSSH_NGINX_UNIT_NAME" printf '访问地址(本机):http://127.0.0.1:%s/\n' "$port" printf '登录:%s / %s\n' "$user" "$pass" if [[ ${WEBSSH_PASS_GENERATED:-0} == 1 ]]; then printf '密码为本次随机生成,只显示这一次;之后可在 %s/meta 查看(root 可读)。\n' "$(webssh_etc)" fi printf '\n已验证:\n' printf ' [已验证] 两个单元 active,且仅监听 127.0.0.1(%s / 内部端口)\n' "$port" printf ' [已验证] 未认证 401、错误密码 401、正确密码 200,websocket 升级 101\n' printf '\n仍需人工处理:\n' printf ' - 远程访问:自行用 TLS 反代或隧道指向 127.0.0.1:%s(Basic 认证是明文 HTTP)\n' "$port" printf ' 反代必须保留原 Host 头(WebSSH 默认同源校验,改 Host 会 403)\n' printf ' - 防火墙/安全组:本模块不改;放行的是你的 TLS 入口,不是这个回环端口\n' printf ' - 首次登录后自行在页面里填 SSH 目标、用户名和密码\n' printf ' - 卸载:toolbox_webssh uninstall\n' } # ---------------------------------------------------------------- 卸载 / 状态 _webssh_uninstall() { local etc opt lib log run state answer webssh_require_root || return 1 webssh_require_systemd || return 1 etc=$(webssh_etc); opt=$(webssh_opt); lib=$(webssh_lib); log=$(webssh_log); run=$(webssh_run) state=$(webssh_unit_state "$(webssh_unit)" "ExecStart=$(webssh_wssh)") if [[ $state == absent && ! -e $(webssh_nginx_unit) && ! -d $etc && ! -d $opt ]]; then webssh_die '没有找到本模块的安装。' return 1 fi if [[ $state == foreign ]]; then webssh_die "$(webssh_unit) 不是本模块生成的,未删除;请人工确认。" return 1 fi if [[ -e $(webssh_nginx_unit) ]] \ && ! grep -qF -- "ExecStart=$(webssh_nginx) -c $(webssh_etc)/nginx.conf" "$(webssh_nginx_unit)"; then webssh_die "$(webssh_nginx_unit) 不是本模块生成的,未删除;请人工确认。" return 1 fi printf '将停止并删除:\n %s\n %s\n %s\n %s\n %s\n %s\n' \ "$(webssh_nginx_unit)" "$(webssh_unit)" "$etc" "$opt" "$lib" "$log" printf '确认卸载?输入 YES:' IFS= read -r answer || true [[ ${answer^^} == YES ]] || { webssh_die '已取消。'; return 1; } exec 9>"$(webssh_lock)" || { webssh_die '无法创建锁文件'; return 1; } flock -n 9 || { webssh_die '另一个安装/卸载正在进行。'; return 1; } systemctl disable --now "$WEBSSH_NGINX_UNIT_NAME" >/dev/null 2>&1 || true systemctl disable --now "$WEBSSH_UNIT_NAME" >/dev/null 2>&1 || true rm -f -- "$(webssh_nginx_unit)" "$(webssh_unit)" systemctl daemon-reload >/dev/null 2>&1 || true systemctl reset-failed "$WEBSSH_NGINX_UNIT_NAME" >/dev/null 2>&1 || true systemctl reset-failed "$WEBSSH_UNIT_NAME" >/dev/null 2>&1 || true rm -rf -- "$etc" "$opt" "$lib" "$log" "$run" printf '已卸载(apt 补装的 nginx/python3-venv 保留)。\n' } webssh_status() { local user pass port internal version installed if [[ ! -e $(webssh_unit) ]]; then printf '未安装。\n' return 1 fi IFS='|' read -r user pass port internal version installed < "$(webssh_etc)/meta" 2>/dev/null || true printf '单元: %s\n' "$(webssh_unit)" systemctl --no-pager -l status "$WEBSSH_UNIT_NAME" 2>&1 | sed -n '1,5p' printf '前端: %s\n' "$(webssh_nginx_unit)" systemctl --no-pager -l status "$WEBSSH_NGINX_UNIT_NAME" 2>&1 | sed -n '1,5p' if [[ -n ${port:-} ]]; then printf '监听 127.0.0.1:%s:' "$port" webssh_port_owned_by "$port" "$WEBSSH_NGINX_UNIT_NAME" && printf '由本模块 nginx 持有\n' \ || printf '不是本模块 nginx\n' printf '访问:http://127.0.0.1:%s/ 登录:%s / %s\n' "$port" "${user:-?}" "${pass:-?}" fi [[ -n ${version:-} ]] && printf '版本 %s,安装于 %s\n' "$version" "${installed:-未知}" } # 安装/卸载放子 shell:锁随子 shell 释放,且调用方关闭 errexit 时中途失败也会停。 webssh_install() { ( set -e; _webssh_install "$@" ) ; } webssh_uninstall() { ( set -e; _webssh_uninstall "$@" ) ; } # ---------------------------------------------------------------- 入口 toolbox_webssh() { local action=${1:-install} if (( $# > 0 )); then shift; fi case $action in install) webssh_install "$@" ;; uninstall) webssh_uninstall "$@" ;; status) webssh_status ;; *) webssh_die '用法: toolbox_webssh [install [回环端口] | uninstall | status]' ;; esac } # 直接执行也可用;sourcing 不产生副作用。 if [[ ${BASH_SOURCE[0]:-} == "$0" && ${TOOLBOX_BUNDLED:-0} != 1 ]]; then toolbox_webssh "$@" fi # END bundled module: webssh run_action() { local key=$1 case "$key" in DIAG) toolbox_diagnostics ;; A4) a4_menu ;; A1|A2|A3|A5|B12|B23|B31) toolbox_network "$key" ;; B3|B4|B5|B9|B13|B17|B20|B21|B22|B24|B25|B26|B27|B28|B29|B30) toolbox_app "${key#B}" ;; MAIN_BBR|MAIN_DEPS|MAIN_REINSTALL|MAIN_REBOOT|B1|B2|B6|B7|B8|B11|B14|B15|B16|B19) toolbox_system "$key" ;; B10) toolbox_webssh ;; B18) printf 'B18:预留,暂未实现。\n' ; return 1 ;; *) printf '无效选项:%s\n' "$key" ; return 1 ;; esac } a4_menu() { local c printf '\n'; _tb_heading 'A4 Hysteria 2' _tb_line '[1] 安装'; _tb_line '[2] 卸载'; _tb_line '[3] 状态'; _tb_line '[0] 返回'; _tb_finish printf '\n请输入编号:' IFS= read -r c || return 1 case "$c" in 0) return 0 ;; 1) toolbox_hy2 install ;; 2) toolbox_hy2 uninstall ;; 3) toolbox_hy2 status ;; *) printf '无效选项。\n' ; return 1 ;; esac } menu() { local choice screen=main key _tb_ui_init _tb_banner _tb_bootstrap || true while :; do TB_SKIP_BANNER=1 case "$screen" in main) _tb_main_screen;; network) _tb_network_screen;; apps) _tb_apps_screen;; esac _tb_read_choice || return 0 choice=$TB_CHOICE case "$choice" in 0|Q) if [[ $screen != main ]]; then screen=main; continue; fi _tb_bye; return 0 ;; esac key=$choice if [[ $screen == main ]]; then case "$choice" in 1) screen=network; continue;; 5) screen=apps; continue;; 2|M1) key=MAIN_BBR;; 3|M2) key=MAIN_DEPS;; 4|DD|M3) key=MAIN_REINSTALL;; 99|M4) key=MAIN_REBOOT;; esac elif [[ $screen == network && $choice =~ ^[1-5]$ ]]; then key=A$choice elif [[ $screen == apps && $choice =~ ^([1-9]|[12][0-9]|3[01])$ ]]; then key=B$choice fi case "$key" in DIAG|MAIN_BBR|MAIN_DEPS|MAIN_REINSTALL|MAIN_REBOOT|A[1-5]|B[1-9]|B[12][0-9]|B3[01]) ;; *) printf '\n %s无效的选择,请按菜单编号输入。%s\n' "$TB_YELLOW" "$TB_RESET"; continue;; esac if [[ "$key" != B18 ]]; then if ! load_modules; then continue; fi fi printf '\n' run_action "$key" || printf '\n %s操作未完成或已取消。%s\n' "$TB_YELLOW" "$TB_RESET" _tb_pause || return 0 TB_SKIP_BANNER=0 done } case "${1-}" in --help|-h) show_help ;; --list) show_list ;; '') menu ;; *) printf '未知参数:%s\n' "$1" >&2; show_help >&2; exit 2 ;; esac