Files
tg-vault/deploy/install.sh
T

365 lines
11 KiB
Bash
Executable File
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.
#!/usr/bin/env bash
set -euo pipefail
NON_INTERACTIVE=false
case "${1:-}" in
"") ;;
--non-interactive) NON_INTERACTIVE=true ;;
-h|--help)
cat <<'EOF'
用法:./deploy/install.sh [--non-interactive]
默认先检测服务器环境;缺少组件时由用户选择自动补全、查看提示或退出,随后交互询问 Web 前端 URL 和后端 API URL。
--non-interactive 只检测环境,不自动安装;从现有 .env 或同名环境变量读取配置,不等待输入。
EOF
exit 0
;;
*)
echo "未知参数:$1;使用 --help 查看用法。" >&2
exit 2
;;
esac
if [[ "$NON_INTERACTIVE" == false && ! -t 0 ]]; then
echo "当前没有交互式终端;请在终端中运行,或使用 --non-interactive。" >&2
exit 2
fi
detect_package_manager() {
if command -v apt-get >/dev/null 2>&1; then
printf 'apt'
elif command -v dnf >/dev/null 2>&1; then
printf 'dnf'
elif command -v yum >/dev/null 2>&1; then
printf 'yum'
else
printf 'unsupported'
fi
}
run_privileged() {
if [[ "${EUID:-$(id -u)}" -eq 0 ]]; then
"$@"
elif command -v sudo >/dev/null 2>&1; then
sudo "$@"
else
echo "需要管理员权限,但当前不是 root 且未找到 sudo。" >&2
return 1
fi
}
install_missing_packages() {
local manager="$1"
shift
case "$manager" in
apt)
run_privileged apt-get update
run_privileged apt-get install -y "$@"
;;
dnf) run_privileged dnf install -y "$@" ;;
yum) run_privileged yum install -y "$@" ;;
*) return 1 ;;
esac
}
check_environment() {
local missing=()
command -v docker >/dev/null 2>&1 || missing+=(docker)
if command -v docker >/dev/null 2>&1 && ! docker compose version >/dev/null 2>&1; then
missing+=(compose)
fi
command -v python3 >/dev/null 2>&1 || missing+=(python3)
command -v git >/dev/null 2>&1 || missing+=(git)
echo "服务器环境检测"
printf ' %-24s %s\n' "Docker Engine" "$([[ ! " ${missing[*]} " =~ " docker " ]] && echo '✓ 已安装' || echo '✗ 缺失')"
printf ' %-24s %s\n' "Docker Compose 插件" "$([[ ! " ${missing[*]} " =~ " compose " ]] && echo '✓ 已安装' || echo '✗ 缺失')"
printf ' %-24s %s\n' "Python 3" "$([[ ! " ${missing[*]} " =~ " python3 " ]] && echo '✓ 已安装' || echo '✗ 缺失')"
printf ' %-24s %s\n' "Git" "$([[ ! " ${missing[*]} " =~ " git " ]] && echo '✓ 已安装' || echo '✗ 缺失')"
if [[ ${#missing[@]} -eq 0 ]]; then
echo "环境检测通过。"
return 0
fi
echo
echo "缺少必需环境:${missing[*]}"
if [[ "$NON_INTERACTIVE" == true ]]; then
echo "非交互模式不会自动修改服务器环境;请先安装缺失项后重试。" >&2
exit 1
fi
local manager choice
manager="$(detect_package_manager)"
if [[ "$manager" == unsupported ]]; then
echo "未识别受支持的包管理器(apt/dnf/yum),请手动安装缺失项后重试。" >&2
exit 1
fi
echo "请选择处理方式:"
echo " 1) 自动安装缺失环境(推荐)"
echo " 2) 显示手动处理提示并退出"
echo " q) 退出"
while true; do
printf '> '
IFS= read -r choice
case "${choice,,}" in
1|"") break ;;
2)
echo "请安装 Docker Engine、Docker Compose 插件、Python 3 和 Git 中的缺失项后重新运行。"
exit 0
;;
q) echo "已退出,未修改服务器环境。"; exit 0 ;;
*) echo "请输入 1、2 或 q。" >&2 ;;
esac
done
local packages=()
for item in "${missing[@]}"; do
case "$manager:$item" in
apt:docker) packages+=(docker.io) ;;
apt:compose) packages+=(docker-compose-plugin) ;;
apt:python3) packages+=(python3) ;;
apt:git) packages+=(git) ;;
dnf:docker|yum:docker) packages+=(docker) ;;
dnf:compose|yum:compose) packages+=(docker-compose-plugin) ;;
dnf:python3|yum:python3) packages+=(python3) ;;
dnf:git|yum:git) packages+=(git) ;;
esac
done
install_missing_packages "$manager" "${packages[@]}"
if [[ "${INSTALL_TEST_SKIP_ENV_RECHECK:-false}" == true ]]; then
echo "测试模式:已记录缺失环境安装命令。"
exit 1
fi
local still_missing=()
command -v docker >/dev/null 2>&1 || still_missing+=(docker)
if command -v docker >/dev/null 2>&1 && ! docker compose version >/dev/null 2>&1; then
still_missing+=(compose)
fi
command -v python3 >/dev/null 2>&1 || still_missing+=(python3)
command -v git >/dev/null 2>&1 || still_missing+=(git)
if [[ ${#still_missing[@]} -gt 0 ]]; then
echo "自动安装后仍缺少:${still_missing[*]}。请按系统提示完成 Docker 官方安装后重试。" >&2
exit 1
fi
echo "缺失环境已补全。"
}
if [[ ! -f docker-compose.yml ]]; then
echo "请从包含 docker-compose.yml 的项目目录运行 deploy/install.sh。" >&2
exit 1
fi
check_environment
normalize_origin() {
python3 - "$1" <<'PY'
from urllib.parse import urlsplit
import sys
value = sys.argv[1].strip()
parsed = urlsplit(value)
if parsed.scheme not in ('http', 'https') or not parsed.netloc or parsed.path not in ('', '/') or parsed.query or parsed.fragment:
raise SystemExit(1)
print(f'{parsed.scheme}://{parsed.netloc}')
PY
}
prompt_origin() {
local label="$1"
local example="$2"
local current="$3"
local entered normalized
while true; do
echo >&2
echo "$label" >&2
if [[ -n "$current" && "$current" != *example.com* ]]; then
echo "当前值:$current" >&2
printf '直接按 Enter 保留当前值:' >&2
else
echo "示例:$example" >&2
printf '> ' >&2
fi
IFS= read -r entered
entered="${entered:-$current}"
if normalized="$(normalize_origin "$entered")"; then
printf '%s' "$normalized"
return 0
fi
echo "地址无效。请输入完整的 http(s) origin,不能包含路径、查询参数或片段。" >&2
done
}
confirm_install() {
local choice
while true; do
echo
echo "配置确认"
echo "Web 前端 URL:$CORS_ORIGIN_VALUE"
echo "后端 API URL:$VITE_API_URL_VALUE"
echo
printf '按 Enter 保存配置并开始安装,输入 e 重新编辑,输入 q 退出:'
IFS= read -r choice
case "${choice,,}" in
"") return 0 ;;
e) return 1 ;;
q) echo "已取消,未保存配置或启动服务。"; exit 0 ;;
*) echo "请输入 Enter、e 或 q。" >&2 ;;
esac
done
}
upsert_env() {
local key="$1"
local value="$2"
local temp
temp="$(mktemp)"
python3 - "$key" "$value" .env "$temp" <<'PY'
from pathlib import Path
import sys
key, value, source, target = sys.argv[1:]
path = Path(source)
lines = path.read_text().splitlines() if path.exists() else []
replacement = f'{key}={value}'
updated = []
found = False
for line in lines:
if line.startswith(f'{key}='):
if not found:
updated.append(replacement)
found = True
continue
updated.append(line)
if not found:
updated.append(replacement)
Path(target).write_text('\n'.join(updated) + '\n')
PY
chmod 600 "$temp"
mv "$temp" .env
}
read_env() {
local key="$1"
python3 - "$key" .env <<'PY'
from pathlib import Path
import sys
key = sys.argv[1]
for line in Path(sys.argv[2]).read_text().splitlines():
if line.startswith(f'{key}='):
print(line.split('=', 1)[1], end='')
break
PY
}
ensure_generated_secret() {
local key="$1"
if [[ -z "$(read_env "$key")" ]]; then
upsert_env "$key" "$(python3 - <<'PY'
import secrets
print(secrets.token_hex(32))
PY
)"
fi
}
remove_env_keys() {
local temp
temp="$(mktemp)"
python3 - .env "$temp" "$@" <<'PY'
from pathlib import Path
import sys
source, target, *keys = sys.argv[1:]
blocked = set(keys)
lines = Path(source).read_text().splitlines() if Path(source).exists() else []
kept = [line for line in lines if line.split('=', 1)[0] not in blocked]
Path(target).write_text('\n'.join(kept) + ('\n' if kept else ''))
PY
chmod 600 "$temp"
mv "$temp" .env
}
created_env=false
CURRENT_CORS_ORIGIN=""
CURRENT_VITE_API_URL=""
if [[ -f .env ]]; then
CURRENT_CORS_ORIGIN="$(read_env CORS_ORIGIN)"
CURRENT_VITE_API_URL="$(read_env VITE_API_URL)"
else
created_env=true
fi
CORS_ORIGIN_VALUE="${CORS_ORIGIN:-$CURRENT_CORS_ORIGIN}"
VITE_API_URL_VALUE="${VITE_API_URL:-$CURRENT_VITE_API_URL}"
if [[ "$NON_INTERACTIVE" == false ]]; then
echo "TG Vault 安装向导"
while true; do
CORS_ORIGIN_VALUE="$(prompt_origin '请输入 Web 前端 URL' 'https://cloud.example.com' "$CORS_ORIGIN_VALUE")"
VITE_API_URL_VALUE="$(prompt_origin '请输入后端 API URL' 'https://api.example.com' "$VITE_API_URL_VALUE")"
if [[ "$CORS_ORIGIN_VALUE" != https://* || "$VITE_API_URL_VALUE" != https://* ]]; then
echo "警告:当前配置包含 HTTP 地址,登录 Cookie 或接口流量可能无法获得生产级保护。" >&2
fi
if confirm_install; then
break
fi
done
else
if [[ -z "$CORS_ORIGIN_VALUE" || -z "$VITE_API_URL_VALUE" ]]; then
echo "非交互模式需要在 .env 或环境变量中提供 CORS_ORIGIN 和 VITE_API_URL。" >&2
exit 2
fi
if ! CORS_ORIGIN_VALUE="$(normalize_origin "$CORS_ORIGIN_VALUE")"; then
echo "CORS_ORIGIN 必须是完整的 http(s) origin,不能包含路径、查询参数或片段。" >&2
exit 2
fi
if ! VITE_API_URL_VALUE="$(normalize_origin "$VITE_API_URL_VALUE")"; then
echo "VITE_API_URL 必须是完整的 http(s) origin,不能包含路径、查询参数或片段。" >&2
exit 2
fi
fi
if [[ "$created_env" == true ]]; then
umask 077
touch .env
fi
upsert_env CORS_ORIGIN "$CORS_ORIGIN_VALUE"
upsert_env VITE_API_URL "$VITE_API_URL_VALUE"
if [[ "$created_env" == true ]]; then
upsert_env COOKIE_SECURE true
upsert_env COOKIE_SECURE_FORCE true
fi
chmod 600 .env
ensure_generated_secret DB_PASSWORD
if [[ "$created_env" == true ]]; then
ensure_generated_secret SESSION_SECRET
ensure_generated_secret STORAGE_CREDENTIALS_SECRET
fi
# v2.2.0 及更早版本曾把构建元数据写入 .env,升级时主动清理,避免旧值覆盖新版本。
remove_env_keys IMAGE_VERSION SOURCE_REVISION SOURCE_VERSION
RELEASE_REVISION="$(git rev-parse HEAD 2>/dev/null || printf unknown)"
RELEASE_VERSION="v$(python3 - <<'PY'
import json
from pathlib import Path
print(json.loads(Path('backend/package.json').read_text())['version'])
PY
)"
env IMAGE_VERSION="$RELEASE_VERSION" SOURCE_REVISION="$RELEASE_REVISION" SOURCE_VERSION="$RELEASE_VERSION" docker compose config --quiet
env IMAGE_VERSION="$RELEASE_VERSION" SOURCE_REVISION="$RELEASE_REVISION" SOURCE_VERSION="$RELEASE_VERSION" docker compose build backend frontend
env IMAGE_VERSION="$RELEASE_VERSION" SOURCE_REVISION="$RELEASE_REVISION" SOURCE_VERSION="$RELEASE_VERSION" docker compose up -d --no-build --no-deps backend frontend
docker compose ps
echo
echo "TG Vault 安装完成"
echo "Web:$CORS_ORIGIN_VALUE"
echo "API:$VITE_API_URL_VALUE"
echo
echo "请在宿主机 Nginx/面板中配置 HTTPS:"
echo " Web -> http://127.0.0.1:47832"
echo " API -> http://127.0.0.1:51947"
echo "验证:curl -fsS http://127.0.0.1:51947/readyz"