#!/bin/bash # ============================================================ # frpc-console 一键部署脚本 (Docker 优先) # 用法: # run-deploy.sh 引导后自动执行 # ============================================================ set -e # ---------- 颜色检测 ---------- if [ -t 1 ]; then RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m' BLUE='\033[0;34m'; CYAN='\033[0;36m'; MAGENTA='\033[0;35m'; NC='\033[0m' else RED=''; GREEN=''; YELLOW=''; BLUE=''; CYAN=''; MAGENTA=''; NC='' fi # ---------- 配置 ---------- REPO_URL="https://git.whitetop.xyz/lxh2875931338/frpc-console.git" WORK_DIR="/tmp/frpc-console-build" DEFAULT_PORT=9300 DEFAULT_DEPLOY_DIR="/opt/frpc-console" IMAGE_NAME="frpc-console" KEEP_IMAGES=3 # ---------- 状态变量 ---------- OS=""; OS_VERSION=""; ARCH="" HAS_GIT=false; HAS_CURL=false; HAS_WGET=false; HAS_DOCKER=false NEED_INSTALL_GIT=false; NEED_INSTALL_CURL=false; NEED_INSTALL_WGET=false PORT=${DEFAULT_PORT} DEPLOY_DIR=${DEFAULT_DEPLOY_DIR} DATA_DIR="${DEPLOY_DIR}/data" CONTAINER_EXISTS=false; CONTAINER_RUNNING=false SKIP_CONFIRM=false; CHECK_ONLY=false; DRY_RUN=false CHANNEL=""; BRANCH=""; TARGET_VERSION=""; IMAGE_TAG="" CURRENT_VERSION="" print_info() { echo -e "${BLUE}[INFO]${NC} $1"; } print_success() { echo -e "${GREEN}[✓]${NC} $1"; } print_warn() { echo -e "${YELLOW}[⚠]${NC} $1"; } print_error() { echo -e "${RED}[✗]${NC} $1"; } print_step() { echo -e "\n${CYAN}▶${NC} $1"; } print_title() { echo -e "\n${MAGENTA}════════════════════════════════════════════════════════${NC}"; } print_subtitle() { echo -e "${MAGENTA} $1${NC}"; } # ---------- 解析命令行参数 ---------- parse_args() { for arg in "$@"; do case $arg in --yes|-y) SKIP_CONFIRM=true ;; --check) CHECK_ONLY=true ;; --dry-run) DRY_RUN=true ;; --help|-h) echo "用法: ./deploy.sh [选项]" echo "选项:" echo " --yes, -y 跳过所有确认提示" echo " --check 只检测环境,不执行部署" echo " --dry-run 显示将执行的操作,不实际执行" echo " --help, -h 显示帮助信息" exit 0 ;; *) print_error "未知选项: $arg"; exit 1 ;; esac done } check_root() { if [ "$EUID" -ne 0 ]; then print_error "请使用 root 权限运行此脚本" echo " sudo bash deploy.sh" exit 1 fi } detect_os() { if [ -f /etc/os-release ]; then . /etc/os-release OS=$ID; OS_VERSION=$VERSION_ID else print_error "无法识别操作系统" exit 1 fi } detect_arch() { ARCH=$(uname -m) case $ARCH in x86_64|amd64|aarch64|arm64|armv7l|armhf) print_success "CPU 架构: $ARCH" ;; *) print_error "不支持的 CPU 架构: $ARCH" exit 1 ;; esac } check_tools() { command -v git &> /dev/null && HAS_GIT=true || NEED_INSTALL_GIT=true command -v curl &> /dev/null && HAS_CURL=true || NEED_INSTALL_CURL=true command -v wget &> /dev/null && HAS_WGET=true || NEED_INSTALL_WGET=true command -v docker &> /dev/null && HAS_DOCKER=true } check_container() { if docker ps -a --format '{{.Names}}' 2>/dev/null | grep -q "^frpc-console$"; then CONTAINER_EXISTS=true if docker ps --format '{{.Names}}' 2>/dev/null | grep -q "^frpc-console$"; then CONTAINER_RUNNING=true fi fi } # ---------- 版本管理 ---------- read_version_from_remote() { local branch="$1" local version_url="https://git.whitetop.xyz/lxh2875931338/frpc-console/raw/${branch}/version.ini" local content content=$(curl -sSL "$version_url" 2>/dev/null) if [ -z "$content" ]; then print_error "无法从远程读取 version.ini: $version_url" return 1 fi SEMVER=$(echo "$content" | grep -v "^[;#]" | head -1 | awk '{print $1}') echo "$SEMVER" } generate_target_version() { local channel="$1" local branch="${2:-$BRANCH}" SEMVER=$(read_version_from_remote "$branch") if [ -z "$SEMVER" ]; then print_error "读取语义版本失败" exit 1 fi local minor="${SEMVER#*.}" if [ "$minor" = "0" ] || [ "$minor" = "5" ]; then CHANNEL_TYPE="lts" else CHANNEL_TYPE="preview" fi if [ "$channel" = "lts" ] || [ "$CHANNEL_TYPE" = "lts" ]; then TARGET_VERSION="${SEMVER}-lts-$(date +%Y%m%d)" IMAGE_TAG="${SEMVER}-lts-$(date +%Y%m%d)" else TIMESTAMP=$(date +%Y%m%d-%H%M%S) TARGET_VERSION="${SEMVER}-preview-${TIMESTAMP}" IMAGE_TAG="preview-${TIMESTAMP}" fi } get_current_version() { local version_file="${DEPLOY_DIR}/version.ini" if [ -f "$version_file" ]; then grep -v "^[;#]" "$version_file" | head -1 | tr -d '\n' else if [ "$CONTAINER_EXISTS" = true ]; then docker exec frpc-console cat /app/version.ini 2>/dev/null | grep -v "^[;#]" | head -1 | tr -d '\n' || echo "" else echo "" fi fi } # ---------- 通道选择 ---------- select_channel() { if [ -n "$DEPLOY_CHANNEL" ] && [ -n "$DEPLOY_BRANCH" ]; then CHANNEL="$DEPLOY_CHANNEL" BRANCH="$DEPLOY_BRANCH" print_info "使用环境变量: ${CHANNEL} 通道 (${BRANCH} 分支)" return fi if [ -n "$CHANNEL" ]; then if [ "$CHANNEL" != "lts" ] && [ "$CHANNEL" != "preview" ]; then print_error "无效通道: $CHANNEL" exit 1 fi BRANCH="$([ "$CHANNEL" = "preview" ] && echo "test" || echo "main")" return fi echo "" echo -e "${CYAN}请选择部署通道:${NC}" echo " 1. LTS (稳定版) [默认] → main 分支" echo " 2. Preview (技术预览版) → test 分支" echo "" read -p "请选择 [1]: " CHANNEL_INPUT /dev/null; then print_warn "检测到降级操作: $CURRENT_VERSION → $TARGET_VERSION" echo "" echo -e "${YELLOW}⚠ 降级可能导致数据不兼容${NC}" TIMESTAMP=$(date +%Y%m%d_%H%M%S) BACKUP_DIR="/opt/frpc-console-backups/${TIMESTAMP}_${CHANNEL}" echo -e "${CYAN}备份目录: ${BACKUP_DIR}${NC}" read -p "确认执行降级? [y/N]: " CONFIRM_DOWNGRADE /dev/null || true fi docker rm frpc-console 2>/dev/null || true print_success "旧容器已清理" fi # ----- 准备部署目录 ----- print_step "准备部署目录..." mkdir -p "$DEPLOY_DIR" mkdir -p "$DATA_DIR" # ----- 检测并迁移旧版本文件(从根目录迁移到 data/)----- print_step "检测旧版本文件..." OLD_DB="${DEPLOY_DIR}/frpc-console.db" OLD_TOML="${DEPLOY_DIR}/frpc.toml" OLD_LOG="${DEPLOY_DIR}/frpc.log" OLD_PID="${DEPLOY_DIR}/frpc.pid" OLD_VERSION="${DEPLOY_DIR}/version.ini" NEW_DB="${DATA_DIR}/frpc-console.db" # 如果根目录有旧数据库文件,但 data/ 目录没有,执行迁移 if [ -f "$OLD_DB" ] && [ ! -f "$NEW_DB" ]; then print_warn "检测到旧版本数据库文件在根目录,将迁移到 data/ 子目录" # 备份旧文件 BACKUP_OLD_DIR="${DEPLOY_DIR}/.old_backup_$(date +%Y%m%d_%H%M%S)" mkdir -p "$BACKUP_OLD_DIR" for file in frpc-console.db frpc.toml frpc.log frpc.pid version.ini; do if [ -f "${DEPLOY_DIR}/${file}" ]; then cp "${DEPLOY_DIR}/${file}" "${BACKUP_OLD_DIR}/" 2>/dev/null || true print_info " 已备份: ${file}" fi done # 复制文件到 data/ 目录 for file in frpc-console.db frpc.toml frpc.log frpc.pid version.ini; do if [ -f "${DEPLOY_DIR}/${file}" ]; then cp "${DEPLOY_DIR}/${file}" "${DATA_DIR}/" 2>/dev/null || true print_info " 已迁移: ${file} → data/" fi done print_success "旧文件已迁移到 data/ 目录" print_info "旧文件备份位置: ${BACKUP_OLD_DIR}" print_info "确认服务正常后,可手动删除备份目录" else print_info "未检测到需要迁移的旧文件" fi # ----- 构建 Docker 镜像 ----- print_step "构建 Docker 镜像..." docker build --no-cache \ -t "${IMAGE_NAME}:${IMAGE_TAG}" \ . if [ $? -ne 0 ]; then print_error "Docker 构建失败" exit 1 fi print_success "Docker 镜像构建完成: ${IMAGE_NAME}:${IMAGE_TAG}" # 验证镜像内容 print_step "验证镜像内容..." if docker run --rm --entrypoint ls "${IMAGE_NAME}:${IMAGE_TAG}" -l /app/frpc-console 2>/dev/null; then print_success "二进制文件存在于镜像中" else print_error "镜像中缺少 /app/frpc-console" exit 1 fi # 标记通道标签 if [ "$CHANNEL" = "lts" ]; then docker tag "${IMAGE_NAME}:${IMAGE_TAG}" "${IMAGE_NAME}:lts" print_info "已标记 ${IMAGE_NAME}:lts" else docker tag "${IMAGE_NAME}:${IMAGE_TAG}" "${IMAGE_NAME}:preview" print_info "已标记 ${IMAGE_NAME}:preview" fi # ----- 启动新容器 ----- print_step "启动 frpc-console 容器..." # 启动容器(只挂载数据目录) docker run -d \ --name frpc-console \ --restart=always \ --network host \ -v ${DEPLOY_DIR}/data:/app/data \ -e PORT=${PORT} \ -e TZ=Asia/Shanghai \ ${IMAGE_NAME}:${IMAGE_TAG} if docker ps | grep -q frpc-console; then print_success "容器启动成功!" else print_error "容器启动失败,请检查日志: docker logs frpc-console" exit 1 fi # ----- 写入版本文件 ----- print_step "写入版本文件..." echo "$TARGET_VERSION" > "${DEPLOY_DIR}/data/version.ini" print_success "版本写入: ${TARGET_VERSION}" # ----- 检查 frpc 子进程(基于 PID 文件)----- print_step "检查 frpc 状态..." sleep 3 PID_FILE="${DEPLOY_DIR}/data/frpc.pid" if [ -f "$PID_FILE" ]; then PID=$(cat "$PID_FILE" 2>/dev/null | tr -d ' \n') if [ -n "$PID" ] && kill -0 "$PID" 2>/dev/null; then print_success "frpc 进程运行正常 (PID: $PID)" else print_warn "frpc 进程未运行(PID 文件存在但进程已退出,可能配置为空)" fi else print_warn "frpc 进程未运行(PID 文件不存在,请导入 TOML 后重试)" fi # ----- 验证数据库 ----- print_step "验证数据库状态..." if docker exec frpc-console sqlite3 /app/frpc-console.db "SELECT COUNT(*) FROM users;" 2>/dev/null | grep -q "^[0-9]"; then print_success "数据库可用" else print_warn "数据库为空或不可用,请通过 WebUI 注册管理员账户" fi # ----- 验证版本 ----- CONTAINER_VERSION=$(docker exec frpc-console cat /app/version.ini 2>/dev/null | tr -d '\n' || echo "") if [ "$CONTAINER_VERSION" = "$TARGET_VERSION" ]; then print_success "容器版本验证通过: ${CONTAINER_VERSION}" else print_warn "容器版本: ${CONTAINER_VERSION}, 目标版本: ${TARGET_VERSION}" fi # ----- 清理旧镜像 ----- print_step "清理旧镜像..." CURRENT_IMAGE_ID=$(docker images -q "${IMAGE_NAME}:${IMAGE_TAG}" 2>/dev/null) if [ -n "$CURRENT_IMAGE_ID" ]; then ALL_IMAGES=$(docker images "${IMAGE_NAME}:*" --format "{{.ID}} {{.CreatedAt}}" 2>/dev/null | sort -k2 -r | awk '{print $1}') COUNTER=0 TO_DELETE="" for IMG_ID in $ALL_IMAGES; do if [ "$IMG_ID" = "$CURRENT_IMAGE_ID" ]; then continue fi COUNTER=$((COUNTER + 1)) if [ $COUNTER -le $KEEP_IMAGES ]; then continue fi TO_DELETE="$TO_DELETE $IMG_ID" done if [ -n "$TO_DELETE" ]; then docker rmi -f $TO_DELETE 2>/dev/null || true print_info "已清理旧镜像" else print_info "无需清理,镜像数量未超过保留策略" fi fi # ----- 备份目录提示 ----- if [ -n "$BACKUP_DIR" ]; then echo "" print_warn "降级前数据已备份到: $BACKUP_DIR" echo " 如需回退: cp -r $BACKUP_DIR/frpc-console/* $DEPLOY_DIR/" fi # ----- 清理临时文件 ----- print_step "清理临时文件..." rm -rf "$WORK_DIR" print_success "临时文件已清理" # ----- 输出 ----- print_title print_success "frpc-console 部署完成!" print_title echo "" echo -e " ${CYAN}📍 访问地址:${NC} http://$(hostname -I | awk '{print $1}'):${PORT}" echo -e " ${CYAN}📂 数据目录:${NC} ${DEPLOY_DIR}" echo -e " ${CYAN}🐳 容器名称:${NC} frpc-console" echo -e " ${CYAN}🏷️ 版本:${NC} ${TARGET_VERSION}" echo -e " ${CYAN}🌿 分支:${NC} ${BRANCH}" echo "" echo -e " ${CYAN}常用命令:${NC}" echo " docker logs frpc-console # 查看日志" echo " docker restart frpc-console # 重启服务" echo " cat ${DEPLOY_DIR}/version.ini # 查看当前版本" echo "" echo -e " ${YELLOW}首次访问需要注册管理员账户${NC}" echo "" print_title } # ---------- 主流程 ---------- main() { clear 2>/dev/null || true parse_args "$@" check_root print_step "检测环境..." detect_os detect_arch check_tools check_container select_channel generate_target_version "$CHANNEL" "$BRANCH" CURRENT_VERSION="" if [ "$CONTAINER_EXISTS" = true ]; then CURRENT_VERSION=$(get_current_version) fi print_environment_summary if [ "$CHECK_ONLY" = true ]; then print_info "环境检测完成(--check 模式)" exit 0 fi if [ "$HAS_DOCKER" = false ]; then print_error "Docker 未安装" exit 1 fi generate_plan print_deployment_plan if ! confirm_deploy; then custom_config generate_plan print_deployment_plan if ! confirm_deploy; then print_info "已取消部署" exit 0 fi fi if [ "$DRY_RUN" = true ]; then print_info "演练模式(--dry-run)" exit 0 fi do_deploy } main "$@"