Files
vps/install/23-2-Sub2API.sh
T
eddy d380f35db9 fix: 保留 Compose 环境变量中的特殊字符
- 引用并转义生成的 .env 值,防止 Compose 意外执行变量插值
- 为兼容旧版 Compose,拒绝管理员凭证中不受支持的单引号
- 从运行中的容器显示实际生效的管理员凭证
2026-07-18 04:10:48 +08:00

394 lines
16 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/bin/bash
# chmod +x 23-2-Sub2API.sh && ./23-2-Sub2API.sh
# curl -sS -O https://gitea.tohub.top/Share/vps/raw/branch/main/install/23-2-Sub2API.sh && chmod +x 23-2-Sub2API.sh && ./23-2-Sub2API.sh
set -euo pipefail
port80=8231
# 安装目录与更新脚本路径
install_dir=/root/data/docker_data/Sub2API-2
update_script="$install_dir/auto-update.sh"
env_file="$install_dir/.env"
update_log="$install_dir/auto-update.log"
# 检测 compose 命令,结果存入全局数组 compose_cmd;检测失败返回 1
detect_compose() {
if docker compose version >/dev/null 2>&1; then
compose_cmd=(docker compose)
elif command -v docker-compose >/dev/null 2>&1; then
compose_cmd=(docker-compose)
else
return 1
fi
return 0
}
# 卸载当前 Sub2API-2 实例。Docker 镜像可能被其他实例共用,因此不删除镜像。
uninstall_sub2api() {
echo "即将卸载 Sub2API-2"
echo " - 停止并删除容器及 Compose 网络"
echo " - 删除自动更新定时任务"
echo " - 可选删除 ufw 端口规则 $port80"
echo " - 永久删除 $install_dir(包括数据库、配置和日志)"
read -r -p "此操作不可恢复。如确需卸载,请输入 UNINSTALL 确认:" uninstall_confirm
if [ "$uninstall_confirm" != "UNINSTALL" ]; then
echo "未确认卸载,已退出。原有安装未修改。"
exit 0
fi
if ! command -v docker >/dev/null 2>&1; then
echo "未检测到 Docker,无法确认并停止现有容器,已取消卸载。" >&2
echo "请先恢复 Docker,或确认容器已不存在后手动清理 $install_dir。" >&2
exit 1
fi
if ! detect_compose; then
echo "未检测到 docker compose 或 docker-compose,无法安全停止现有容器,已取消卸载。" >&2
exit 1
fi
if [ -f "$install_dir/docker-compose.yml" ]; then
cd "$install_dir" || { echo "无法进入安装目录 $install_dir" >&2; exit 1; }
"${compose_cmd[@]}" down --remove-orphans
fi
if command -v crontab >/dev/null 2>&1; then
(crontab -l 2>/dev/null | grep -v -F -- "$update_script" || true) | crontab -
echo "已删除自动更新定时任务。"
fi
if command -v ufw >/dev/null 2>&1; then
read -r -p "是否同时删除 ufw 端口规则 $port80[y/N]" remove_ufw_rule
if [ "$remove_ufw_rule" = "y" ] || [ "$remove_ufw_rule" = "Y" ]; then
if ufw --force delete allow "$port80"; then
echo "已删除 ufw 端口规则 $port80。"
else
echo "未找到或无法删除 ufw 端口规则 $port80,请手动检查。" >&2
fi
else
echo "已保留 ufw 端口规则 $port80。"
fi
fi
cd / || { echo "无法切换到根目录,取消删除安装目录。" >&2; exit 1; }
rm -rf -- "$install_dir"
echo "Sub2API-2 已卸载,Docker 镜像已保留。"
exit 0
}
# 交互:已有安装时默认只更新,避免覆盖数据库账号、密码和连接配置
existing_install=0
if [ -f "$install_dir/docker-compose.yml" ]; then
existing_install=1
fi
echo "请选择操作:"
if [ "$existing_install" -eq 1 ]; then
echo " 1) 已安装,仅更新 Sub2API app 镜像(默认,安全,不覆盖配置/数据库)"
echo " 2) 强制重装 Sub2API(重写 docker-compose.yml,保留 .env 与数据目录)"
echo " 3) 卸载 Sub2API-2(删除容器、配置及全部数据)"
read -r -p "请输入选项 [1/2/3](默认 1):" action_choice
else
echo " 1) 安装 Sub2API(默认)"
echo " 2) 不安装,退出"
read -r -p "请输入选项 [1/2](默认 1):" action_choice
fi
action_choice="${action_choice:-1}"
# 严格校验输入,防止误输入落入重装或卸载流程
if [ "$existing_install" -eq 1 ]; then
if [ "$action_choice" != "1" ] && [ "$action_choice" != "2" ] && [ "$action_choice" != "3" ]; then
echo "无效选项:$action_choice,已退出。未做任何修改。" >&2
exit 1
fi
elif [ "$action_choice" != "1" ] && [ "$action_choice" != "2" ]; then
echo "无效选项:$action_choice,已退出。未做任何修改。" >&2
exit 1
fi
if [ "$existing_install" -eq 0 ] && [ "$action_choice" = "2" ]; then
echo "已选择不安装,退出。"
exit 0
fi
if [ "$existing_install" -eq 1 ] && [ "$action_choice" = "3" ]; then
uninstall_sub2api
fi
# 安装、更新和重装都要求 Docker 与 Compose 正常可用;卸载在函数内单独检查。
if ! command -v docker >/dev/null 2>&1; then
echo "未检测到 docker,请先安装 Docker 后再运行本脚本。" >&2
exit 1
fi
if ! detect_compose; then
echo "未检测到 docker compose 或 docker-compose,请先安装 Docker Compose。" >&2
exit 1
fi
if [ "$existing_install" -eq 1 ] && [ "$action_choice" = "1" ]; then
echo "已选择:立即执行 Docker 更新。"
if [ -x "$update_script" ]; then
# 优先用已生成的 auto-update.sh(同样只更新 app,不动 db / redis
"$update_script"
else
cd "$install_dir" || { echo "无法进入安装目录 $install_dir" >&2; exit 1; }
# 回退方案:直接用 compose 只更新 app 服务(Sub2API 镜像),不动 db / redis
"${compose_cmd[@]}" pull app
"${compose_cmd[@]}" up -d app
docker image prune -f
fi
echo "Docker 更新完成。"
exit 0
fi
if [ "$existing_install" -eq 1 ] && [ "$action_choice" = "2" ]; then
echo "检测到已有 Sub2API 安装:$install_dir/docker-compose.yml"
echo "强制重装会重写 docker-compose.yml.env 与 data/ 数据目录会保留,原管理员账号密码继续有效)。"
read -r -p "如确需重装,请输入 REINSTALL 确认:" reinstall_confirm
if [ "$reinstall_confirm" != "REINSTALL" ]; then
echo "未确认强制重装,已退出。原有配置未修改。"
exit 0
fi
fi
# 1、更新包(更新失败不阻断后续安装;非 Debian 系无 apt 则跳过)
if command -v apt >/dev/null 2>&1; then
export DEBIAN_FRONTEND=noninteractive
apt update -y || true
apt upgrade -y || true #更新一下包
else
echo "未检测到 apt,跳过系统包更新。"
fi
# 2、创建安装目录
mkdir -p "$install_dir"
cd "$install_dir" || { echo "无法进入安装目录 $install_dir" >&2; exit 1; }
# 3、生成 .env(仅首次生成,重装/更新不覆盖,避免密钥和数据库密码变化)
gen_secret() {
openssl rand -hex 32 2>/dev/null || head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n'
}
# Compose 对 .env 中未加引号和双引号的值执行变量插值。
# 不含单引号的值用单引号包裹,按字面处理($、#、空格、\ 均不被改写);
# 含单引号的值改用双引号包裹,并转义 \、" 和 $。
compose_env_literal() {
local value=$1
if [[ $value != *"'"* ]]; then
printf "'%s'" "$value"
return
fi
local escaped=${value//\\/\\\\}
escaped=${escaped//\"/\\\"}
escaped=${escaped//\$/\\\$}
printf '"%s"' "$escaped"
}
if [ ! -f "$env_file" ]; then
# 兼容旧版安装:尝试从旧 docker-compose.yml 中提取已在用的数据库密码,
# 避免生成新密码后与已有 postgres 数据目录不一致导致连不上库
# 注意:这里回读的是旧版明文 docker-compose.yml(历史格式),与本脚本新生成的
# 引号化 .env 表示法不同。切勿改成从 .env 回读——.env 里的值可能带引号/转义,
# 下面的 [^ #]* 会在引号或 # 处截断,取到错误密码。
old_pg_password=""
old_pg_user=""
if [ -f docker-compose.yml ]; then
# grep -v '\$' 排除新版 compose 中的 ${VAR} 占位符,只取旧版明文值
old_pg_password=$(sed -n 's/.*POSTGRES_PASSWORD=\([^ #]*\).*/\1/p' docker-compose.yml | grep -v '\$' | head -n1 || true)
old_pg_user=$(sed -n 's/.*POSTGRES_USER=\([^ #]*\).*/\1/p' docker-compose.yml | grep -v '\$' | head -n1 || true)
fi
pg_password="${old_pg_password:-$(gen_secret)}"
pg_user="${old_pg_user:-postgres}"
# 交互:设置管理员账号密码(仅数据库首次初始化时生效;
# 如果数据库已有数据,登录仍用数据库里已存在的账号密码)
default_admin_email="admin@sub2api-2.local"
default_admin_password=$(gen_secret | cut -c1-16)
echo "配置管理员账户(仅数据库首次初始化时创建):"
read -r -p "管理员邮箱(默认 $default_admin_email):" admin_email
admin_email="${admin_email:-$default_admin_email}"
read -r -p "管理员密码(回车使用随机密码 $default_admin_password):" admin_password
admin_password="${admin_password:-$default_admin_password}"
# 单引号值无法在旧版 docker-compose (v1) 的 .env 解析中可靠转义,直接拒绝
while [[ $admin_email == *"'"* ]] || [[ $admin_password == *"'"* ]]; do
echo "邮箱或密码不能包含单引号('),请重新输入。"
read -r -p "管理员邮箱(默认 $default_admin_email):" admin_email
admin_email="${admin_email:-$default_admin_email}"
read -r -p "管理员密码(回车使用随机密码 $default_admin_password):" admin_password
admin_password="${admin_password:-$default_admin_password}"
done
pg_user_env=$(compose_env_literal "$pg_user")
pg_password_env=$(compose_env_literal "$pg_password")
pg_db_env=$(compose_env_literal "sub2api")
jwt_secret_env=$(compose_env_literal "$(gen_secret)")
totp_key_env=$(compose_env_literal "$(gen_secret)")
admin_email_env=$(compose_env_literal "$admin_email")
admin_password_env=$(compose_env_literal "$admin_password")
tz_env=$(compose_env_literal "Asia/Shanghai")
cat <<EOF > "$env_file"
# Sub2API 环境配置(由安装脚本生成,请勿删除,否则需重新配置)
# 数据库
POSTGRES_USER=$pg_user_env
POSTGRES_PASSWORD=$pg_password_env
POSTGRES_DB=$pg_db_env
# 固定密钥:保证容器重建/更新后登录态和 2FA 不失效
JWT_SECRET=$jwt_secret_env
TOTP_ENCRYPTION_KEY=$totp_key_env
# 管理员账户(仅首次启动时创建,之后修改无效)
ADMIN_EMAIL=$admin_email_env
ADMIN_PASSWORD=$admin_password_env
TZ=$tz_env
EOF
chmod 600 "$env_file"
echo "已生成 $env_file(包含数据库密码、密钥和管理员初始密码,请妥善保管)。"
else
echo "检测到已有 $env_file,沿用原有管理员账号密码与数据库配置,不再询问。"
fi
# 4、填写docker-compose配置
# AUTO_SETUP=true + 环境变量传入数据库/Redis 配置,容器重建后无需再走网页安装向导
cat <<EOF > docker-compose.yml
services:
db:
image: postgres:15-alpine
container_name: sub2api-2-db
restart: unless-stopped
volumes:
- ./data/postgres:/var/lib/postgresql/data
environment:
- POSTGRES_USER=\${POSTGRES_USER:-postgres}
- POSTGRES_PASSWORD=\${POSTGRES_PASSWORD:?POSTGRES_PASSWORD 未配置,请检查 .env}
- POSTGRES_DB=\${POSTGRES_DB:-sub2api}
- TZ=\${TZ:-Asia/Shanghai}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U \${POSTGRES_USER:-postgres} -d \${POSTGRES_DB:-sub2api}"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
container_name: sub2api-2-redis
restart: unless-stopped
volumes:
- ./data/redis:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
app:
image: weishaw/sub2api:latest
container_name: sub2api-2-app
restart: unless-stopped
volumes:
- ./data/app:/app/data # 持久化 app 配置/数据
ports:
- "$port80:8080" # 左边的端口可以更换,右边不要动!
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
environment:
# 自动初始化:跳过网页安装向导,配置全部来自环境变量(.env)
- AUTO_SETUP=true
- SERVER_HOST=0.0.0.0
- SERVER_PORT=8080 # 必须与 ports 右边的容器端口一致
- SERVER_MODE=release
# 主机用容器服务名 db / redis,不要写宿主机 IP
- DATABASE_HOST=db
- DATABASE_PORT=5432
- DATABASE_USER=\${POSTGRES_USER:-postgres}
- DATABASE_PASSWORD=\${POSTGRES_PASSWORD}
- DATABASE_DBNAME=\${POSTGRES_DB:-sub2api}
- DATABASE_SSLMODE=disable
- REDIS_HOST=redis
- REDIS_PORT=6379
# 固定密钥(来自 .env):更新/重启后登录态和 2FA 不失效
- JWT_SECRET=\${JWT_SECRET}
- TOTP_ENCRYPTION_KEY=\${TOTP_ENCRYPTION_KEY}
# 管理员账户仅首次启动时创建
- ADMIN_EMAIL=\${ADMIN_EMAIL:-admin@sub2api-2.local}
- ADMIN_PASSWORD=\${ADMIN_PASSWORD:-}
- TZ=\${TZ:-Asia/Shanghai}
EOF
# 5、安装(docker 与 compose 已在脚本开头检查过)
"${compose_cmd[@]}" up -d
# 6、打开防火墙的端口
if command -v ufw >/dev/null 2>&1; then
ufw allow "$port80"
ufw status
else
echo "未检测到 ufw,跳过防火墙端口放行。"
fi
# 7、配置每日自动更新
cat <<'EOF' > "$update_script"
#!/bin/bash
set -euo pipefail
install_dir=/root/data/docker_data/Sub2API-2
cd "$install_dir" # 进入 docker-compose 所在的文件夹
if docker compose version >/dev/null 2>&1; then
compose_cmd=(docker compose)
elif command -v docker-compose >/dev/null 2>&1; then
compose_cmd=(docker-compose)
else
echo "未检测到 docker compose 或 docker-compose,无法自动更新。" >&2
exit 1
fi
# 只更新 app 服务(Sub2API 镜像),不动 db / redis
"${compose_cmd[@]}" pull app
"${compose_cmd[@]}" up -d app
# 清理旧镜像,释放磁盘空间
docker image prune -f
# 修剪日志避免无限增长(只保留最近 1000 行)
# 用 cat 覆盖写回而非 mv,保留原 inode,避免 cron 持有的 append 文件描述符失效
log_file="$install_dir/auto-update.log"
if [ -f "$log_file" ]; then
tail -n 1000 "$log_file" > "$log_file.tmp" && cat "$log_file.tmp" > "$log_file" && rm -f "$log_file.tmp"
fi
EOF
chmod +x "$update_script"
# 写入每日 04:10 自动更新任务
cron_line="10 4 * * * $update_script >> $update_log 2>&1"
if command -v crontab >/dev/null 2>&1; then
# 使用脚本路径去重,避免重复写入 Sub2API 自动更新任务
( crontab -l 2>/dev/null | grep -v -F -- "$update_script" || true ; echo "$cron_line" ) | crontab -
echo "已配置定时自动更新任务:每天 04:10 自动更新 Sub2API app 镜像(不更新 db / redis)。"
else
echo "未检测到 crontab,跳过定时更新任务配置。可手动执行 $update_script 更新。"
fi
# 打印访问链接
echo "------------------------"
echo "访问链接:"
echo "http://服务器IP:$port80"
echo "管理员账户(首次安装时自动创建,以下为容器实际收到的凭证):"
if ! docker inspect sub2api-2-app --format '{{range .Config.Env}}{{println .}}{{end}}' 2>/dev/null \
| grep -E '^(ADMIN_EMAIL|ADMIN_PASSWORD)='; then
echo "(容器尚未就绪,凭证可稍后执行 docker inspect sub2api-2-app 查看;如查看 $env_file,注意值外层的引号不属于密码本身)"
fi
echo "------------------------"
echo "已启用 AUTO_SETUP 自动初始化:更新/重建容器后无需重新走网页安装向导。"
echo "已开启定时自动更新:每天 04:10 仅拉取最新 app 镜像并重启(db / redis 不动)。"
echo "手动更新可执行:$update_script"
echo "------------------------"