#!/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 # ---------- 镜像加速配置 ---------- DEFAULT_MIRRORS=( "https://docker.1panel.live" "https://docker.1panel.dev" "https://docker.1ms.run" ) DAEMON_JSON="/etc/docker/daemon.json" MIRROR_CHECK_TIMEOUT=5 # ---------- 镜像加速状态变量 ---------- MIRROR_STATUS="" DEFAULT_STATUS="" WRITE_DAEMON=false RESTART_DOCKER=false REORDER=false INSERT_DEFAULT=false HAS_REGISTRY_MIRRORS_KEY=false AVAILABLE_MIRRORS=() USER_MIRRORS_AVAILABLE=() USER_MIRRORS_UNAVAILABLE=() USER_MIRRORS_ALL=() MIRROR_DETECT_OUTPUT="" # ---------- 其他状态变量 ---------- OS=""; OS_VERSION=""; ARCH="" HAS_GIT=false; HAS_CURL=false; HAS_WGET=false; HAS_DOCKER=false HAS_JQ=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 command -v jq &> /dev/null && HAS_JQ=true || HAS_JQ=false } 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 } # ---------- 镜像加速:连通性检测 ---------- check_mirror_available() { local url="$1" local http_code http_code=$(curl -s --max-time "$MIRROR_CHECK_TIMEOUT" -o /dev/null -w "%{http_code}" \ "${url}/v2/" 2>/dev/null) if [[ "$http_code" =~ ^2[0-9][0-9]$ ]] || [ "$http_code" = "401" ] || [ "$http_code" = "403" ]; then return 0 fi return 1 } # ---------- 镜像加速:安装 jq ---------- install_jq() { print_info "尝试安装 jq..." case $OS in opensuse*|suse*|opensuse-tumbleweed|opensuse-slowroll|opensuse-leap) zypper install -y jq 2>/dev/null || true ;; ubuntu|debian|linuxmint) apt update -qq 2>/dev/null && apt install -y jq 2>/dev/null || true ;; centos|rhel|fedora|rocky|almalinux) yum install -y jq 2>/dev/null || true ;; alpine) apk add jq 2>/dev/null || true ;; arch|manjaro|endeavouros) pacman -S --needed --noconfirm jq 2>/dev/null || true ;; *) return 1 ;; esac command -v jq &> /dev/null && HAS_JQ=true || HAS_JQ=false if [ "$HAS_JQ" = true ]; then print_success "jq 安装成功" return 0 else print_warn "jq 安装失败,将使用 sed 模式(功能受限)" return 1 fi } # ---------- 镜像加速:确保 daemon.json 存在且有效 ---------- ensure_daemon_json_valid() { if [ -f "$DAEMON_JSON" ] && [ -s "$DAEMON_JSON" ]; then if jq -e '.' "$DAEMON_JSON" >/dev/null 2>&1; then return 0 else local backup_path="${DAEMON_JSON}.bak.$(date +%Y%m%d-%H%M%S)" cp "$DAEMON_JSON" "$backup_path" print_warn "daemon.json 格式非法,已备份至: $backup_path" echo '{}' > "$DAEMON_JSON" print_info "已重建为有效的 JSON 文件" return 0 fi elif [ -f "$DAEMON_JSON" ] && [ ! -s "$DAEMON_JSON" ]; then local backup_path="${DAEMON_JSON}.bak.$(date +%Y%m%d-%H%M%S)" cp "$DAEMON_JSON" "$backup_path" 2>/dev/null || true print_warn "daemon.json 为空文件,已备份至: $backup_path" echo '{}' > "$DAEMON_JSON" print_info "已重建为有效的 JSON 文件" return 0 else print_info "daemon.json 不存在,将创建" echo '{}' > "$DAEMON_JSON" return 0 fi } # ---------- 镜像加速:检测阶段 ---------- check_docker_mirrors() { print_step "检测 Docker 镜像源配置..." # 1. 检测三个默认地址的连通性 print_info "检测默认镜像源连通性 (超时 ${MIRROR_CHECK_TIMEOUT}s)..." local default_available_count=0 for mirror in "${DEFAULT_MIRRORS[@]}"; do if check_mirror_available "$mirror"; then AVAILABLE_MIRRORS+=("$mirror") default_available_count=$((default_available_count + 1)) print_info " ✅ $mirror 可用" else print_info " ❌ $mirror 不可用" fi done if [ ${#AVAILABLE_MIRRORS[@]} -eq 0 ]; then DEFAULT_STATUS="default_unavailable" print_warn "所有默认镜像源均不可用,将跳过镜像加速配置" elif [ ${#AVAILABLE_MIRRORS[@]} -eq ${#DEFAULT_MIRRORS[@]} ]; then DEFAULT_STATUS="default_all_available" print_success "所有默认镜像源均可用" else DEFAULT_STATUS="default_partial" print_info "部分默认镜像源可用 (${#AVAILABLE_MIRRORS[@]}/${#DEFAULT_MIRRORS[@]})" fi # 2. 检查 daemon.json if [ ! -f "$DAEMON_JSON" ]; then MIRROR_STATUS="no_file" HAS_REGISTRY_MIRRORS_KEY=false print_info "daemon.json 不存在" generate_mirror_detect_output return 0 fi # 3. 检测 jq 是否可解析 if ! jq -e '.' "$DAEMON_JSON" >/dev/null 2>&1 2>/dev/null; then print_warn "daemon.json 格式不标准,将备份并重建" local backup_path="${DAEMON_JSON}.bak.$(date +%Y%m%d-%H%M%S)" cp "$DAEMON_JSON" "$backup_path" print_info "已备份至: $backup_path" echo '{}' > "$DAEMON_JSON" print_warn "已重建 daemon.json,请确认是否需要将其他自定义配置迁回" MIRROR_STATUS="no_file" HAS_REGISTRY_MIRRORS_KEY=false generate_mirror_detect_output return 0 fi # 4. 检查是否存在 registry-mirrors 字段 local has_key has_key=$(jq -e 'has("registry-mirrors")' "$DAEMON_JSON" 2>/dev/null) if [ "$has_key" != "true" ]; then HAS_REGISTRY_MIRRORS_KEY=false MIRROR_STATUS="no_key" print_info "daemon.json 中无 registry-mirrors 字段" generate_mirror_detect_output return 0 fi HAS_REGISTRY_MIRRORS_KEY=true # 5. 读取 registry-mirrors 数组 local mirror_list mirror_list=$(jq -r '."registry-mirrors" // [] | .[]' "$DAEMON_JSON" 2>/dev/null) if [ -z "$mirror_list" ]; then MIRROR_STATUS="empty_array" print_info "registry-mirrors 为空数组" generate_mirror_detect_output return 0 fi # 6. 读取用户配置所有地址(保持顺序) USER_MIRRORS_ALL=() while IFS= read -r mirror; do if [ -n "$mirror" ]; then USER_MIRRORS_ALL+=("$mirror") fi done <<< "$mirror_list" # 7. 检测用户配置中每个地址的连通性 print_info "检测用户配置的镜像源连通性..." USER_MIRRORS_AVAILABLE=() USER_MIRRORS_UNAVAILABLE=() for mirror in "${USER_MIRRORS_ALL[@]}"; do if check_mirror_available "$mirror"; then USER_MIRRORS_AVAILABLE+=("$mirror") print_info " ✅ $mirror (用户配置) 可用" else USER_MIRRORS_UNAVAILABLE+=("$mirror") print_info " ❌ $mirror (用户配置) 不可用" fi done # 8. 判断状态 if [ ${#USER_MIRRORS_AVAILABLE[@]} -gt 0 ]; then local need_reorder=false local seen_unavailable=false for mirror in "${USER_MIRRORS_ALL[@]}"; do local is_avail=false for a in "${USER_MIRRORS_AVAILABLE[@]}"; do if [ "$a" = "$mirror" ]; then is_avail=true break fi done if [ "$is_avail" = true ] && [ "$seen_unavailable" = true ]; then need_reorder=true break fi if [ "$is_avail" = false ]; then seen_unavailable=true fi done if [ "$need_reorder" = true ]; then MIRROR_STATUS="has_valid_reorder_needed" print_info "可用镜像源排在不可用镜像源之后,需要重排" else MIRROR_STATUS="has_valid_ordered" print_info "用户镜像源配置正常,无需调整" fi else MIRROR_STATUS="all_invalid" print_warn "用户配置全部不可用" fi generate_mirror_detect_output } # ---------- 镜像加速:生成检测输出 ---------- generate_mirror_detect_output() { MIRROR_DETECT_OUTPUT="" if [ "$DEFAULT_STATUS" = "default_unavailable" ]; then MIRROR_DETECT_OUTPUT="${MIRROR_DETECT_OUTPUT}${YELLOW}⚠ 所有默认镜像源均不可用,将跳过镜像加速配置${NC}\n" MIRROR_DETECT_OUTPUT="${MIRROR_DETECT_OUTPUT}如果 Docker 构建失败,请检查网络或手动配置镜像源。\n" return fi local avail_list="" for m in "${AVAILABLE_MIRRORS[@]}"; do avail_list="${avail_list} ✓ ${m}\n" done case "$MIRROR_STATUS" in no_file|no_key|empty_array) MIRROR_DETECT_OUTPUT="${MIRROR_DETECT_OUTPUT}${YELLOW}未检测到 registry-mirrors 配置。${NC}\n\n" if [ ${#AVAILABLE_MIRRORS[@]} -gt 0 ]; then MIRROR_DETECT_OUTPUT="${MIRROR_DETECT_OUTPUT}将写入以下可用镜像源:\n\n${avail_list}\n" MIRROR_DETECT_OUTPUT="${MIRROR_DETECT_OUTPUT}写入位置:${DAEMON_JSON}\n" MIRROR_DETECT_OUTPUT="${MIRROR_DETECT_OUTPUT}${YELLOW}Docker 将在配置完成后自动重启以应用配置。${NC}\n" WRITE_DAEMON=true RESTART_DOCKER=true REORDER=false INSERT_DEFAULT=true else MIRROR_DETECT_OUTPUT="${MIRROR_DETECT_OUTPUT}${RED}无可用的默认镜像源,将跳过配置。${NC}\n" WRITE_DAEMON=false RESTART_DOCKER=false REORDER=false INSERT_DEFAULT=false fi ;; has_valid_ordered) MIRROR_DETECT_OUTPUT="${MIRROR_DETECT_OUTPUT}${GREEN}检测完成。用户已配置可用的镜像源:${NC}\n\n" for m in "${USER_MIRRORS_AVAILABLE[@]}"; do MIRROR_DETECT_OUTPUT="${MIRROR_DETECT_OUTPUT} ✓ ${m}\n" done if [ ${#USER_MIRRORS_UNAVAILABLE[@]} -gt 0 ]; then MIRROR_DETECT_OUTPUT="${MIRROR_DETECT_OUTPUT}\n${YELLOW}不可用地址:${NC}\n" for m in "${USER_MIRRORS_UNAVAILABLE[@]}"; do MIRROR_DETECT_OUTPUT="${MIRROR_DETECT_OUTPUT} ✗ ${m}\n" done MIRROR_DETECT_OUTPUT="${MIRROR_DETECT_OUTPUT}\n可用地址已排在不可用地址之前,无需修改。\n" else MIRROR_DETECT_OUTPUT="${MIRROR_DETECT_OUTPUT}\n所有地址均可用,无需修改。\n" fi WRITE_DAEMON=false RESTART_DOCKER=false REORDER=false INSERT_DEFAULT=false ;; has_valid_reorder_needed) MIRROR_DETECT_OUTPUT="${MIRROR_DETECT_OUTPUT}${YELLOW}检测完成。用户配置中存在不可用或顺序不当的镜像源。${NC}\n\n" MIRROR_DETECT_OUTPUT="${MIRROR_DETECT_OUTPUT}当前配置顺序:\n" local idx=1 for m in "${USER_MIRRORS_ALL[@]}"; do local status="" local found=false for a in "${USER_MIRRORS_AVAILABLE[@]}"; do if [ "$a" = "$m" ]; then found=true break fi done if [ "$found" = true ]; then status="${GREEN}✅ 可用${NC}" else status="${RED}❌ 不可用${NC}" fi MIRROR_DETECT_OUTPUT="${MIRROR_DETECT_OUTPUT} ${idx}. ${m} ${status}\n" idx=$((idx + 1)) done MIRROR_DETECT_OUTPUT="${MIRROR_DETECT_OUTPUT}\n${YELLOW}将重新排序:可用地址前置,不可用地址后移。${NC}\n" MIRROR_DETECT_OUTPUT="${MIRROR_DETECT_OUTPUT}\n新顺序:\n" local new_idx=1 for m in "${USER_MIRRORS_AVAILABLE[@]}"; do MIRROR_DETECT_OUTPUT="${MIRROR_DETECT_OUTPUT} ${new_idx}. ${m} ${GREEN}✅${NC}\n" new_idx=$((new_idx + 1)) done for m in "${USER_MIRRORS_UNAVAILABLE[@]}"; do MIRROR_DETECT_OUTPUT="${MIRROR_DETECT_OUTPUT} ${new_idx}. ${m} ${RED}❌${NC}\n" new_idx=$((new_idx + 1)) done MIRROR_DETECT_OUTPUT="${MIRROR_DETECT_OUTPUT}\n${YELLOW}Docker 将在配置完成后自动重启以应用配置。${NC}\n" WRITE_DAEMON=true RESTART_DOCKER=true REORDER=true INSERT_DEFAULT=false ;; all_invalid) if [ ${#AVAILABLE_MIRRORS[@]} -gt 0 ]; then MIRROR_DETECT_OUTPUT="${MIRROR_DETECT_OUTPUT}${YELLOW}检测完成。用户配置的镜像源全部不可用:${NC}\n\n" for m in "${USER_MIRRORS_UNAVAILABLE[@]}"; do MIRROR_DETECT_OUTPUT="${MIRROR_DETECT_OUTPUT} ✗ ${m}\n" done MIRROR_DETECT_OUTPUT="${MIRROR_DETECT_OUTPUT}\n${GREEN}检测到可用镜像源:${NC}\n\n" for m in "${AVAILABLE_MIRRORS[@]}"; do MIRROR_DETECT_OUTPUT="${MIRROR_DETECT_OUTPUT} ✓ ${m}\n" done MIRROR_DETECT_OUTPUT="${MIRROR_DETECT_OUTPUT}\n${YELLOW}将把可用地址插入到用户配置最前面。${NC}\n" MIRROR_DETECT_OUTPUT="${MIRROR_DETECT_OUTPUT}${YELLOW}Docker 将在配置完成后自动重启以应用配置。${NC}\n" WRITE_DAEMON=true RESTART_DOCKER=true REORDER=false INSERT_DEFAULT=true else MIRROR_DETECT_OUTPUT="${MIRROR_DETECT_OUTPUT}${RED}检测完成。用户配置的镜像源全部不可用:${NC}\n\n" for m in "${USER_MIRRORS_UNAVAILABLE[@]}"; do MIRROR_DETECT_OUTPUT="${MIRROR_DETECT_OUTPUT} ✗ ${m}\n" done MIRROR_DETECT_OUTPUT="${MIRROR_DETECT_OUTPUT}\n${RED}默认镜像源也全部不可用。将跳过镜像加速配置。${NC}\n" MIRROR_DETECT_OUTPUT="${MIRROR_DETECT_OUTPUT}${YELLOW}如果 Docker 构建失败,请检查网络连接,或手动配置 /etc/docker/daemon.json 中的 registry-mirrors。${NC}\n" WRITE_DAEMON=false RESTART_DOCKER=false REORDER=false INSERT_DEFAULT=false fi ;; esac echo "" print_title print_subtitle "Docker 镜像源检测结果" echo "" echo -e "$MIRROR_DETECT_OUTPUT" print_title echo "" } # ---------- 镜像加速:配置执行阶段 ---------- configure_docker_mirrors() { if [ "$WRITE_DAEMON" != true ]; then print_info "无需修改 Docker 镜像源配置" return 0 fi print_step "配置 Docker 镜像源..." if [ "$HAS_JQ" = false ]; then print_warn "jq 未安装,尝试安装..." if ! install_jq; then print_error "jq 安装失败,无法配置镜像源" print_error "请手动配置 /etc/docker/daemon.json 中的 registry-mirrors" return 1 fi fi local new_mirrors=() if [ "$REORDER" = true ]; then new_mirrors=("${USER_MIRRORS_AVAILABLE[@]}" "${USER_MIRRORS_UNAVAILABLE[@]}") print_info "[反馈] 动作: 重排镜像源顺序" elif [ "$INSERT_DEFAULT" = true ]; then if [ "$MIRROR_STATUS" = "no_file" ] || [ "$MIRROR_STATUS" = "no_key" ] || [ "$MIRROR_STATUS" = "empty_array" ]; then new_mirrors=("${AVAILABLE_MIRRORS[@]}") print_info "[反馈] 动作: 写入可用默认镜像源 (无已有配置)" else new_mirrors=("${AVAILABLE_MIRRORS[@]}" "${USER_MIRRORS_UNAVAILABLE[@]}") print_info "[反馈] 动作: 插入可用默认镜像源到最前面 (用户配置全部不可用)" fi else print_info "无需修改镜像源配置" return 0 fi print_info "[反馈] 新镜像列表: ${new_mirrors[*]}" if ! ensure_daemon_json_valid; then print_error "无法创建或修复 daemon.json" return 1 fi local backup_path="${DAEMON_JSON}.bak.$(date +%Y%m%d-%H%M%S)" cp "$DAEMON_JSON" "$backup_path" print_info "[反馈] 已备份: $backup_path" local json_mirrors json_mirrors=$(printf '%s\n' "${new_mirrors[@]}" | jq -R . | jq -s . 2>/dev/null) if [ -z "$json_mirrors" ] || [ "$json_mirrors" = "[]" ]; then print_error "[反馈] 构建 JSON 镜像数组失败或为空" return 1 fi print_info "[反馈] json_mirrors: $json_mirrors" print_info "[反馈] 执行 jq 写入操作..." if ! jq --argjson mirrors "$json_mirrors" '."registry-mirrors" = $mirrors' \ "$DAEMON_JSON" > "${DAEMON_JSON}.tmp" 2> "${DAEMON_JSON}.error"; then print_error "[反馈] jq 执行失败" if [ -f "${DAEMON_JSON}.error" ]; then print_error "[反馈] 错误详情:" cat "${DAEMON_JSON}.error" | while read -r line; do print_error " $line" done rm -f "${DAEMON_JSON}.error" fi rm -f "${DAEMON_JSON}.tmp" return 1 fi if [ ! -f "${DAEMON_JSON}.tmp" ] || [ ! -s "${DAEMON_JSON}.tmp" ]; then print_error "[反馈] 临时文件为空或不存在" rm -f "${DAEMON_JSON}.tmp" return 1 fi if ! jq -e '.' "${DAEMON_JSON}.tmp" >/dev/null 2>&1; then print_error "[反馈] 生成的临时文件不是有效的 JSON" rm -f "${DAEMON_JSON}.tmp" return 1 fi mv "${DAEMON_JSON}.tmp" "$DAEMON_JSON" print_success "daemon.json 已更新" if [ "$RESTART_DOCKER" = true ]; then print_step "重启 Docker 服务..." if systemctl restart docker 2>/dev/null; then print_success "Docker 重启完成" sleep 2 else print_error "Docker 重启失败,请手动重启后重新运行脚本" exit 1 fi fi print_success "镜像源配置完成" return 0 } # ---------- 版本管理 ---------- 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}/data/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/data/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 print_info " 已备份: ${file}" fi done 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 "检查并清理旧容器..." if docker ps -a --format '{{.Names}}' 2>/dev/null | grep -q "^frpc-console$"; then print_info "检测到已存在的 frpc-console 容器" if docker ps --format '{{.Names}}' 2>/dev/null | grep -q "^frpc-console$"; then print_info "容器正在运行,停止中..." docker stop frpc-console 2>/dev/null || true fi print_info "删除旧容器..." docker rm frpc-console 2>/dev/null || true print_success "旧容器已清理" else print_info "未检测到旧容器" 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/data/frpc-console.db "SELECT COUNT(*) FROM users;" 2>/dev/null | grep -q "^[0-9]"; then print_success "数据库可用" else print_warn "数据库为空或不可用,请通过 WebUI 注册管理员账户" fi # ----- 验证版本 ----- print_step "验证版本..." sleep 1 CONTAINER_VERSION=$(docker exec frpc-console cat /app/data/version.ini 2>/dev/null | grep -v "^[;#]" | head -1 | tr -d '\n' || echo "unknown") echo -e " 容器版本: ${CONTAINER_VERSION}" echo -e " 目标版本: ${TARGET_VERSION}" if [ "$CONTAINER_VERSION" = "$TARGET_VERSION" ]; then print_success "版本写入成功" else print_warn "版本不匹配,请检查" 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}/data/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 check_docker_mirrors 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 "$@"