Files
incudal/scripts/install-panel.sh
2026-06-20 14:26:29 +08:00

1156 lines
36 KiB
Bash
Raw Permalink 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
# ============================================================================
# Incudal 面板一键部署脚本(产物包模式)
#
# 功能:
# - 安装 Node.js 22、PostgreSQL 16、Redis 7
# - 从 GitHub Releases 下载预构建产物包
# - 自动配置数据库、环境变量、systemd 服务
# - 支持 Nginx+Certbot / Cloudflare Tunnel / 纯端口 三种外部访问方案
# - 支持升级已有安装
#
# 用法:
# 安装: sudo bash install-panel.sh
# 升级: sudo bash install-panel.sh --upgrade
# 卸载: sudo bash install-panel.sh --uninstall
#
# 项目地址: https://github.com/0xdabiaoge/incudal
# ============================================================================
set -euo pipefail
# ========================== 全局常量 ==========================
readonly SCRIPT_VERSION="3.0.0"
readonly GITHUB_REPO="0xdabiaoge/incudal"
readonly INSTALL_DIR="/opt/incudal"
readonly SERVICE_NAME="incudal"
readonly SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
readonly ENV_FILE="${INSTALL_DIR}/.env"
readonly RUN_USER="incudal"
readonly DEFAULT_PORT=3000
readonly NODE_MAJOR=22
readonly PG_VERSION=16
# ========================== 颜色定义 ==========================
readonly RED='\033[1;31m'
readonly GREEN='\033[1;32m'
readonly YELLOW='\033[1;33m'
readonly CYAN='\033[1;36m'
readonly BOLD='\033[1m'
readonly DIM='\033[2m'
readonly NC='\033[0m'
# ========================== 工具函数 ==========================
# 所有日志输出到 stderr,避免在 $() 子 shell 中被捕获
log() { echo -e "${GREEN}[✓]${NC} $1" >&2; }
info() { echo -e "${CYAN}[i]${NC} $1" >&2; }
warn() { echo -e "${YELLOW}[!]${NC} $1" >&2; }
error() { echo -e "${RED}[✗]${NC} $1" >&2; }
step() { echo -e "\n${CYAN}[▶]${NC} ${BOLD}$1${NC}" >&2; }
divider() {
echo -e "${DIM}────────────────────────────────────────────────────${NC}" >&2
}
# 生成随机密码
gen_password() {
openssl rand -hex 64 | cut -c "1-${1:-24}"
}
gen_secret() {
printf 'A1!%s' "$(openssl rand -hex 64)" | cut -c "1-${1:-48}"
}
get_env_value() {
local key="$1"
if [[ ! -f "$ENV_FILE" ]]; then
return 0
fi
grep -E "^${key}=" "$ENV_FILE" 2>/dev/null | tail -n 1 | cut -d'=' -f2- || true
}
set_env_if_missing() {
local key="$1"
local value="$2"
local label="$3"
local current
current="$(get_env_value "$key")"
if [[ -n "$current" ]]; then
return 0
fi
if grep -qE "^${key}=" "$ENV_FILE" 2>/dev/null; then
local tmp_file
tmp_file="$(mktemp)"
awk -v key="$key" -v value="$value" '
BEGIN { replaced = 0 }
$0 ~ "^" key "=" && replaced == 0 {
print key "=" value
replaced = 1
next
}
{ print }
' "$ENV_FILE" > "$tmp_file"
cat "$tmp_file" > "$ENV_FILE"
rm -f "$tmp_file"
else
printf '\n%s=%s\n' "$key" "$value" >> "$ENV_FILE"
fi
log "已自动补充 ${label}: ${key}"
}
ensure_env_keys() {
if [[ ! -f "$ENV_FILE" ]]; then
return 0
fi
set_env_if_missing "JWT_SECRET" "$(gen_secret 48)" "JWT 密钥"
set_env_if_missing "COOKIE_SECRET" "$(gen_secret 48)" "Cookie 密钥"
set_env_if_missing "ENCRYPTION_KEY" "$(openssl rand -base64 32)" "敏感数据加密密钥"
set_env_if_missing "ADMIN_PASSWORD" "$(gen_password 16)" "管理员初始密码"
chmod 600 "$ENV_FILE"
chown "${RUN_USER}:${RUN_USER}" "$ENV_FILE" 2>/dev/null || true
}
# ========================== 系统检查 ==========================
check_root() {
if [[ "$EUID" -ne 0 ]]; then
error "请以 root 权限运行此部署脚本!"
error "用法: sudo bash $0"
exit 1
fi
}
check_os() {
if [[ ! -f /etc/os-release ]]; then
error "无法检测操作系统(/etc/os-release 不存在)"
exit 1
fi
source /etc/os-release
OS_ID="${ID:-unknown}"
OS_VERSION="${VERSION_ID:-unknown}"
OS_CODENAME="${VERSION_CODENAME:-unknown}"
ARCH=$(dpkg --print-architecture 2>/dev/null || uname -m)
# 仅支持 Ubuntu 和 Debian
if [[ "$OS_ID" != "ubuntu" && "$OS_ID" != "debian" ]]; then
error "不支持的操作系统: ${OS_ID}"
error "本脚本仅支持 Ubuntu 和 Debian 系统"
exit 1
fi
# 版本检查
case "$OS_ID" in
ubuntu)
local major="${OS_VERSION%%.*}"
if [[ "$major" -lt 22 ]] 2>/dev/null; then
error "Ubuntu 版本过低 (${OS_VERSION}),最低要求 Ubuntu 22.04"
exit 1
fi
;;
debian)
local major="${OS_VERSION%%.*}"
if [[ "$major" -lt 12 ]] 2>/dev/null; then
error "Debian 版本过低 (${OS_VERSION}),最低要求 Debian 12 (Bookworm)"
exit 1
fi
;;
esac
# 架构检查
case "$ARCH" in
amd64|x86_64) ARCH="amd64" ;;
arm64|aarch64) ARCH="arm64" ;;
*)
error "不支持的系统架构: ${ARCH}"
error "仅支持 amd64 (x86_64) 和 arm64 (aarch64)"
exit 1
;;
esac
log "系统检测通过: ${OS_ID} ${OS_VERSION} (${ARCH})"
}
# ========================== 显示横幅 ==========================
show_banner() {
clear 2>/dev/null || true
echo ""
echo -e "${CYAN} ╔══════════════════════════════════════════════════╗${NC}"
echo -e "${CYAN} ║ ║${NC}"
echo -e "${CYAN}${BOLD}Incudal 面板一键部署脚本${NC}${CYAN}${NC}"
echo -e "${CYAN}${DIM}Pre-built Package Deploy${NC}${CYAN}${NC}"
echo -e "${CYAN} ║ ║${NC}"
echo -e "${CYAN} ╚══════════════════════════════════════════════════╝${NC}"
echo ""
echo -e " ${DIM}版本: ${SCRIPT_VERSION} | 仓库: ${GITHUB_REPO}${NC}"
echo ""
}
# ========================== 检查已有安装 ==========================
check_existing() {
if [[ -d "$INSTALL_DIR" && -f "${INSTALL_DIR}/server/dist/app.js" ]]; then
return 0 # 已安装
fi
return 1 # 未安装
}
# ========================== 安装 Node.js ==========================
install_nodejs() {
step "安装 Node.js ${NODE_MAJOR}..."
if command -v node &>/dev/null; then
local current_ver
current_ver=$(node -v 2>/dev/null | sed 's/v//' | cut -d. -f1)
if [[ "$current_ver" -ge "$NODE_MAJOR" ]] 2>/dev/null; then
log "Node.js $(node -v) 已安装,跳过"
return 0
fi
warn "当前 Node.js 版本较低 ($(node -v)),将升级到 v${NODE_MAJOR}"
fi
# 通过 NodeSource 安装
info "添加 NodeSource APT 源..."
curl -fsSL "https://deb.nodesource.com/setup_${NODE_MAJOR}.x" | bash - >/dev/null 2>&1
apt-get install -y -qq nodejs >/dev/null 2>&1
log "Node.js $(node -v) 安装完成"
}
# ========================== 安装 PostgreSQL ==========================
install_postgresql() {
step "安装 PostgreSQL ${PG_VERSION}..."
if command -v psql &>/dev/null; then
local pg_ver
pg_ver=$(psql --version 2>/dev/null | awk '{print $3}' | cut -d. -f1)
if [[ "$pg_ver" -ge "$PG_VERSION" ]] 2>/dev/null; then
log "PostgreSQL ${pg_ver} 已安装,跳过"
return 0
fi
fi
# 添加 PostgreSQL 官方 APT 源
info "添加 PostgreSQL 官方 APT 源..."
apt-get install -y -qq gnupg lsb-release >/dev/null 2>&1
curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc \
| gpg --yes --dearmor -o /etc/apt/keyrings/postgresql.gpg
echo "deb [signed-by=/etc/apt/keyrings/postgresql.gpg] https://apt.postgresql.org/pub/repos/apt ${OS_CODENAME}-pgdg main" \
> /etc/apt/sources.list.d/pgdg.list
apt-get update -qq >/dev/null 2>&1
apt-get install -y -qq "postgresql-${PG_VERSION}" >/dev/null 2>&1
# 确保服务启动
systemctl enable postgresql >/dev/null 2>&1
systemctl start postgresql
log "PostgreSQL ${PG_VERSION} 安装完成"
}
# ========================== 安装 Redis ==========================
install_redis() {
step "安装 Redis..."
if command -v redis-server &>/dev/null; then
log "Redis $(redis-server --version | awk '{print $3}' | sed 's/v=//') 已安装,跳过"
return 0
fi
apt-get install -y -qq redis-server >/dev/null 2>&1
# 确保服务启动
systemctl enable redis-server >/dev/null 2>&1
systemctl start redis-server
log "Redis 安装完成"
}
# ========================== 手动包目录 ==========================
readonly MANUAL_PKG_DIR="/tmp/incudal"
# ========================== 获取最新版本号(快速尝试) ==========================
get_latest_version() {
local api_url="https://api.github.com/repos/${GITHUB_REPO}/releases/latest"
local response=""
local version=""
# 5 秒超时速查,不阻塞
response=$(curl -sL --connect-timeout 5 --max-time 8 "$api_url" 2>/dev/null) || true
if [[ -n "$response" ]]; then
version=$(echo "$response" | grep '"tag_name"' | sed -E 's/.*"tag_name": *"([^"]+)".*/\1/' || true)
fi
echo "$version"
}
# ========================== 自动下载产物包 ==========================
download_release() {
local version="$1"
local filename="incudal-${version}-linux-${ARCH}.tar.gz"
local download_url="https://github.com/${GITHUB_REPO}/releases/download/${version}/${filename}"
local tmp_file="/tmp/${filename}"
info "下载地址: ${download_url}"
if curl -fSL --progress-bar --connect-timeout 15 --max-time 600 \
"$download_url" -o "$tmp_file" 2>/dev/null; then
local file_size
file_size=$(du -h "$tmp_file" | cut -f1 || true)
log "下载完成 (${file_size})"
echo "$tmp_file"
return 0
fi
rm -f "$tmp_file" 2>/dev/null || true
return 1
}
# ========================== 扫描手动放置的产物包 ==========================
scan_manual_package() {
local found=""
if [[ ! -d "$MANUAL_PKG_DIR" ]]; then
return 1
fi
# 优先匹配当前架构的包
found=$(find "$MANUAL_PKG_DIR" -maxdepth 1 -name "incudal-*-linux-${ARCH}.tar.gz" -type f 2>/dev/null | head -n1 || true)
# 退而匹配任意 incudal tar.gz
if [[ -z "$found" ]]; then
found=$(find "$MANUAL_PKG_DIR" -maxdepth 1 -name "incudal-*.tar.gz" -type f 2>/dev/null | head -n1 || true)
fi
if [[ -n "$found" ]]; then
echo "$found"
return 0
fi
return 1
}
# ========================== 等待用户放置产物包 ==========================
wait_for_manual_package() {
echo "" >&2
divider
echo -e " ${YELLOW}${BOLD}⚠ 自动获取产物包失败${NC}" >&2
echo -e " ${DIM}(仓库可能是私有的,或网络无法连接 GitHub)${NC}" >&2
divider
echo "" >&2
echo -e " ${BOLD}请手动下载产物包并放到以下目录:${NC}" >&2
echo "" >&2
echo -e " ${CYAN}${BOLD}${MANUAL_PKG_DIR}/${NC}" >&2
echo "" >&2
echo -e " ${BOLD}下载地址:${NC}" >&2
echo -e " ${CYAN}https://github.com/${GITHUB_REPO}/releases${NC}" >&2
echo "" >&2
echo -e " ${BOLD}所需文件名格式:${NC}" >&2
echo -e " ${GREEN}incudal-vX.Y.Z-linux-${ARCH}.tar.gz${NC}" >&2
echo "" >&2
echo -e " ${DIM}提示: 可使用 scp、wget、rz 等方式将文件传到服务器${NC}" >&2
echo -e " ${DIM}例如: scp incudal-v1.0.0-linux-${ARCH}.tar.gz root@服务器IP:${MANUAL_PKG_DIR}/${NC}" >&2
divider
echo "" >&2
# 创建目录
mkdir -p "$MANUAL_PKG_DIR"
# 轮询等待文件出现
local wait_count=0
local max_wait=600 # 最多等 10 分钟(含上传时间)
local spin_chars='⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
local MIN_SIZE_KB=10240 # 产物包至少 10MB
while true; do
# 尝试扫描
local pkg_file=""
pkg_file=$(scan_manual_package) || true
if [[ -n "$pkg_file" ]]; then
# 检测到文件,等待上传完成(文件大小稳定)
echo "" >&2
info "检测到文件: $(basename "$pkg_file")"
info "等待上传完成..."
local stable_count=0
local last_size=0
while true; do
local current_size
current_size=$(stat -c%s "$pkg_file" 2>/dev/null || echo "0")
local current_size_human
current_size_human=$(du -h "$pkg_file" 2>/dev/null | cut -f1 || echo "?")
if [[ "$current_size" -eq "$last_size" && "$current_size" -gt 0 ]]; then
stable_count=$((stable_count + 1))
else
stable_count=0
last_size="$current_size"
fi
# 连续 3 次(9 秒)大小不变,认为上传完成
if [[ $stable_count -ge 3 ]]; then
# 检查最小文件大小
local size_kb=$((current_size / 1024))
if [[ $size_kb -lt $MIN_SIZE_KB ]]; then
warn "文件过小 (${current_size_human}),产物包通常 >100MB,可能不完整"
warn "如确认无误,请将文件删除后重新放置"
# 继续等待
stable_count=0
last_size=0
sleep 3
continue
fi
log "上传完成!文件大小: ${current_size_human}"
echo "$pkg_file"
return 0
fi
printf "\r ${CYAN}${NC} 上传中... 当前大小: ${current_size_human} " >&2
sleep 3
wait_count=$((wait_count + 3))
if [[ $wait_count -ge $max_wait ]]; then
echo "" >&2
error "等待超时 (${max_wait}s),请重新运行脚本"
return 1
fi
done
fi
# 显示等待动画(输出到 stderr)
local spin_idx=$((wait_count % ${#spin_chars}))
local spin_char="${spin_chars:$spin_idx:1}"
printf "\r ${CYAN}${spin_char}${NC} 等待产物包... (已等待 %ds,输入 Ctrl+C 取消) " "$wait_count" >&2
sleep 2
wait_count=$((wait_count + 2))
if [[ $wait_count -ge $max_wait ]]; then
echo "" >&2
error "等待超时 (${max_wait}s),请重新运行脚本"
return 1
fi
done
}
# ========================== 统一入口:获取产物包 ==========================
obtain_release() {
step "获取 Incudal 产物包..."
# ---- 阶段 0:检查是否已有手动放置的包 ----
local existing_pkg=""
existing_pkg=$(scan_manual_package) || true
if [[ -n "$existing_pkg" ]]; then
log "检测到手动放置的产物包: $(basename "$existing_pkg")"
echo "$existing_pkg"
return 0
fi
# ---- 阶段 1:尝试 API 自动获取版本号 ----
info "正在查询最新版本(5 秒超时)..."
local version=""
version=$(get_latest_version)
if [[ -n "$version" ]]; then
info "最新版本: ${version}"
# ---- 阶段 2:尝试自动下载 ----
local tar_file=""
tar_file=$(download_release "$version") || true
if [[ -n "$tar_file" && -f "$tar_file" ]]; then
echo "$tar_file"
return 0
fi
warn "自动下载失败(文件可能不存在或网络受限)"
else
warn "自动获取版本号失败(仓库可能是私有的)"
fi
# ---- 阶段 3:引导用户手动放置包 ----
local manual_file=""
manual_file=$(wait_for_manual_package) || return 1
echo "$manual_file"
return 0
}
# ========================== 解压安装 ==========================
install_release() {
local tar_file="$1"
local is_upgrade="${2:-false}"
step "安装产物包..."
# 创建安装目录
mkdir -p "$INSTALL_DIR"
if [[ "$is_upgrade" == "true" ]]; then
# 升级模式:先备份当前版本
info "备份当前版本..."
local backup_dir="${INSTALL_DIR}.bak.$(date +%Y%m%d%H%M%S)"
cp -r "$INSTALL_DIR" "$backup_dir"
info "备份已保存到: ${backup_dir}"
# 保留 .env 文件和证书目录
local env_backup=""
local certs_backup=""
if [[ -f "${INSTALL_DIR}/.env" ]]; then
env_backup=$(mktemp)
cp "${INSTALL_DIR}/.env" "$env_backup"
fi
if [[ -d "${INSTALL_DIR}/server/certs" ]]; then
certs_backup=$(mktemp -d)
cp -r "${INSTALL_DIR}/server/certs" "$certs_backup/"
fi
# 清理旧文件(保留 .env 和 certs)
rm -rf "${INSTALL_DIR}/client" "${INSTALL_DIR}/server/dist" "${INSTALL_DIR}/server/node_modules"
# 解压新版本
tar -xzf "$tar_file" -C "$INSTALL_DIR" --strip-components=0
# 恢复 .env 和证书
if [[ -n "$env_backup" && -f "$env_backup" ]]; then
cp "$env_backup" "${INSTALL_DIR}/.env"
rm -f "$env_backup"
fi
if [[ -n "$certs_backup" && -d "$certs_backup/certs" ]]; then
mkdir -p "${INSTALL_DIR}/server/certs"
cp -r "$certs_backup/certs/"* "${INSTALL_DIR}/server/certs/" 2>/dev/null || true
rm -rf "$certs_backup"
fi
else
# 全新安装
tar -xzf "$tar_file" -C "$INSTALL_DIR" --strip-components=0
fi
# 清理下载的临时文件
rm -f "$tar_file"
# 创建证书目录
mkdir -p "${INSTALL_DIR}/server/certs"
log "产物包安装完成"
}
# ========================== 创建系统用户 ==========================
create_user() {
step "配置系统用户..."
if id "$RUN_USER" &>/dev/null; then
log "用户 ${RUN_USER} 已存在,跳过"
else
useradd --system --home-dir "${INSTALL_DIR}" --shell /usr/sbin/nologin "$RUN_USER"
log "系统用户 ${RUN_USER} 创建完成"
fi
# 创建 npm 缓存目录(npx/prisma 需要可写的 home 目录)
mkdir -p "${INSTALL_DIR}/.npm"
mkdir -p "${INSTALL_DIR}/.cache"
# 设置目录权限
chown -R "${RUN_USER}:${RUN_USER}" "$INSTALL_DIR"
chmod 750 "$INSTALL_DIR"
chmod 600 "${ENV_FILE}" 2>/dev/null || true
}
# ========================== 生成面板客户端证书 ==========================
generate_panel_cert() {
local cert_dir="${INSTALL_DIR}/server/certs"
local cert_file="${cert_dir}/client.crt"
local key_file="${cert_dir}/client.key"
step "配置面板客户端证书..."
# 幂等性:证书已存在则跳过
if [[ -f "$cert_file" && -f "$key_file" ]]; then
log "面板客户端证书已存在,跳过生成"
return 0
fi
mkdir -p "$cert_dir"
# 生成自签名客户端证书(用于面板与 Incus API 的 mTLS 通信)
info "生成面板客户端证书(RSA 4096 位,有效期 10 年)..."
openssl req -x509 -newkey rsa:4096 \
-keyout "$key_file" \
-out "$cert_file" \
-days 3650 -nodes \
-subj "/CN=incudal-panel/O=Incudal" \
2>/dev/null
# 设置权限:只有 incudal 用户可读
chmod 600 "$cert_file" "$key_file"
chown "${RUN_USER}:${RUN_USER}" "$cert_file" "$key_file"
log "面板客户端证书生成完成"
info "证书路径: ${cert_file}"
info "密钥路径: ${key_file}"
}
# ========================== 配置数据库 ==========================
setup_database() {
local db_password="$1"
step "配置 PostgreSQL 数据库..."
# 检查数据库和用户是否已存在
local db_exists
db_exists=$(sudo -u postgres psql -tAc "SELECT 1 FROM pg_database WHERE datname='incudal'" 2>/dev/null || echo "")
if [[ "$db_exists" == "1" ]]; then
info "数据库 'incudal' 已存在,跳过创建"
# 更新密码
sudo -u postgres psql -c "ALTER USER incudal WITH PASSWORD '${db_password}';" >/dev/null 2>&1 || true
else
# 创建用户和数据库
sudo -u postgres psql -c "CREATE USER incudal WITH PASSWORD '${db_password}';" >/dev/null 2>&1 || true
sudo -u postgres psql -c "CREATE DATABASE incudal OWNER incudal;" >/dev/null 2>&1
sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE incudal TO incudal;" >/dev/null 2>&1
log "数据库 'incudal' 创建完成"
fi
}
# ========================== 生成环境变量 ==========================
generate_env() {
local db_password="$1"
local redis_password="$2"
local admin_password="${3:-admin123}"
step "生成环境配置..."
if [[ -f "$ENV_FILE" ]]; then
info ".env 文件已存在,检查并补齐缺失的密钥配置"
ensure_env_keys
return 0
fi
local jwt_secret
jwt_secret=$(gen_secret 48)
local cookie_secret
cookie_secret=$(gen_secret 48)
local encryption_key
encryption_key=$(openssl rand -base64 32)
cat > "$ENV_FILE" << EOF
# ============================================================================
# Incudal 环境配置
# 由安装脚本自动生成于 $(date '+%Y-%m-%d %H:%M:%S')
# ============================================================================
# ============ 运行环境 ============
NODE_ENV=production
HOST=127.0.0.1
PORT=${DEFAULT_PORT}
# ============ 数据库配置 ============
DATABASE_URL=postgresql://incudal:${db_password}@127.0.0.1:5432/incudal
# ============ Redis 配置 ============
REDIS_URL=redis://:${redis_password}@127.0.0.1:6379
# ============ 安全配置(请勿泄露!)============
JWT_SECRET=${jwt_secret}
COOKIE_SECRET=${cookie_secret}
ENCRYPTION_KEY=${encryption_key}
# ============ 应用配置 ============
APP_PORT=${DEFAULT_PORT}
ADMIN_PASSWORD=${admin_password}
LOG_LEVEL=info
DISABLE_REQUEST_LOG=true
# ============ CORS 配置(必须修改为实际域名!)============
# 支付回调地址也会使用这个域名,必须是公网可访问的地址
FRONTEND_URL=
# ============ 监控告警(可选)============
# ALERT_WEBHOOK_URL=https://your-webhook-url
EOF
chmod 600 "$ENV_FILE"
chown "${RUN_USER}:${RUN_USER}" "$ENV_FILE"
log "环境配置文件生成完成: ${ENV_FILE}"
}
# ========================== 配置 Redis 密码 ==========================
setup_redis() {
local redis_password="$1"
step "配置 Redis..."
local redis_conf="/etc/redis/redis.conf"
if [[ -f "$redis_conf" ]]; then
# 设置密码
if grep -q "^requirepass" "$redis_conf" 2>/dev/null; then
sed -i "s/^requirepass.*/requirepass ${redis_password}/" "$redis_conf"
elif grep -q "^# requirepass" "$redis_conf" 2>/dev/null; then
sed -i "s/^# requirepass.*/requirepass ${redis_password}/" "$redis_conf"
else
echo "requirepass ${redis_password}" >> "$redis_conf"
fi
systemctl restart redis-server
log "Redis 密码配置完成"
else
warn "Redis 配置文件不存在,跳过密码配置"
fi
}
# ========================== 运行数据库迁移 ==========================
run_migrations() {
step "执行数据库迁移..."
cd "${INSTALL_DIR}/server"
# 以 incudal 用户身份加载 .env 并运行迁移
# HOME 需要指向有写权限的目录,否则 npm 缓存报 EACCES
sudo -u "$RUN_USER" HOME="${INSTALL_DIR}" bash -c "
set -a
source '${ENV_FILE}'
set +a
cd '${INSTALL_DIR}/server'
npx prisma migrate deploy
"
log "数据库迁移完成"
}
# ========================== 创建 systemd 服务 ==========================
create_service() {
step "创建 systemd 服务..."
cat > "$SERVICE_FILE" << EOF
[Unit]
Description=Incudal 容器虚拟化管理平台
Documentation=https://github.com/${GITHUB_REPO}
After=network.target postgresql.service redis-server.service
Requires=postgresql.service redis-server.service
[Service]
Type=simple
User=${RUN_USER}
Group=${RUN_USER}
WorkingDirectory=${INSTALL_DIR}
EnvironmentFile=${ENV_FILE}
# 确保 npm 缓存目录可写
Environment=HOME=${INSTALL_DIR}
Environment=NPM_CONFIG_CACHE=${INSTALL_DIR}/.npm
# 启动前自动执行数据库迁移
ExecStartPre=/usr/bin/bash -c 'cd ${INSTALL_DIR}/server && npx prisma migrate deploy'
# 启动主程序
ExecStart=/usr/bin/node ${INSTALL_DIR}/server/dist/app.js
# 优雅关闭
KillMode=mixed
KillSignal=SIGTERM
TimeoutStopSec=30
# 自动重启
Restart=on-failure
RestartSec=10
StartLimitBurst=5
StartLimitIntervalSec=300
# 安全加固
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=${INSTALL_DIR}/server/certs
PrivateTmp=true
# 资源限制
LimitNOFILE=65536
# 日志
StandardOutput=journal
StandardError=journal
SyslogIdentifier=${SERVICE_NAME}
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable "$SERVICE_NAME" >/dev/null 2>&1
log "systemd 服务创建完成"
}
# ========================== Nginx + Certbot ==========================
setup_nginx_certbot() {
info "准备配置 Nginx 反代及 Let's Encrypt SSL 自动证书"
echo -ne " ${BOLD}请输入你要绑定的域名 (例如 panel.yourdomain.com): ${NC}"
read -r DOMAIN
if [[ -z "$DOMAIN" ]]; then
error "域名不能为空!"
return 1
fi
echo -ne " ${BOLD}请输入你的邮箱 (用于证书过期通知,可留空): ${NC}"
read -r EMAIL
info "安装 Nginx 与 Certbot..."
apt-get install -y -qq nginx certbot python3-certbot-nginx >/dev/null 2>&1
# 更新 FRONTEND_URL
sed -i "s|^FRONTEND_URL=.*|FRONTEND_URL=https://${DOMAIN}|" "$ENV_FILE"
log "配置 Nginx 站点..."
cat > /etc/nginx/sites-available/incudal.conf <<NGINX
server {
listen 80;
listen [::]:80;
server_name ${DOMAIN};
# 安全头
add_header X-Content-Type-Options nosniff;
add_header X-Frame-Options DENY;
location / {
proxy_pass http://127.0.0.1:${DEFAULT_PORT};
proxy_http_version 1.1;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_cache_bypass \$http_upgrade;
# WebSocket 超时
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
}
NGINX
ln -sf /etc/nginx/sites-available/incudal.conf /etc/nginx/sites-enabled/
# 移除默认站点(避免冲突)
rm -f /etc/nginx/sites-enabled/default 2>/dev/null || true
nginx -t >/dev/null 2>&1
systemctl reload nginx
log "申请 Let's Encrypt SSL 证书..."
if [[ -n "$EMAIL" ]]; then
certbot --nginx -d "$DOMAIN" --non-interactive --agree-tos -m "$EMAIL" --redirect
else
certbot --nginx -d "$DOMAIN" --non-interactive --agree-tos --register-unsafely-without-email --redirect
fi
log "HTTPS 配置完成!访问地址: https://${DOMAIN}"
}
# ========================== Cloudflare Tunnel ==========================
setup_cf_tunnel() {
info "准备配置 Cloudflare Tunnel 内网穿透"
echo -e " ${DIM}请先在 Cloudflare Zero Trust 管理后台创建 Tunnel${NC}"
echo -e " ${DIM}并将 Public Hostname 的目标路由设置为 http://localhost:${DEFAULT_PORT}${NC}"
echo ""
echo -ne " ${BOLD}请输入 Cloudflare Tunnel Token: ${NC}"
read -r CF_TOKEN
if [[ -z "$CF_TOKEN" ]]; then
error "Token 不能为空!"
return 1
fi
echo -ne " ${BOLD}请输入绑定的域名 (例: panel.yourdomain.com): ${NC}"
read -r DOMAIN
if [[ -n "$DOMAIN" ]]; then
sed -i "s|^FRONTEND_URL=.*|FRONTEND_URL=https://${DOMAIN}|" "$ENV_FILE"
fi
# 安装 cloudflared
if ! command -v cloudflared &>/dev/null; then
info "安装 cloudflared..."
curl -fsSL https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-${ARCH} \
-o /usr/local/bin/cloudflared
chmod +x /usr/local/bin/cloudflared
fi
# 创建 cloudflared systemd 服务
cloudflared service install "$CF_TOKEN" 2>/dev/null || true
log "Cloudflare Tunnel 配置完成!"
if [[ -n "$DOMAIN" ]]; then
echo -e " 访问地址: ${GREEN}https://${DOMAIN}${NC}"
fi
}
# ========================== 启动服务 ==========================
start_service() {
step "启动 Incudal 服务..."
systemctl start "$SERVICE_NAME"
# 等待几秒检查状态
sleep 3
if systemctl is-active --quiet "$SERVICE_NAME"; then
log "Incudal 服务启动成功!"
else
error "服务启动失败,查看日志:"
journalctl -u "$SERVICE_NAME" --no-pager -n 20
exit 1
fi
}
# ========================== 显示安装结果 ==========================
show_result() {
echo ""
divider
echo -e " ${GREEN}${BOLD}✅ Incudal 面板部署成功!${NC}"
divider
echo ""
echo -e " ${BOLD}面板信息${NC}"
echo -e " 安装路径 : ${GREEN}${INSTALL_DIR}${NC}"
echo -e " 配置文件 : ${GREEN}${ENV_FILE}${NC}"
echo -e " 服务名称 : ${GREEN}${SERVICE_NAME}${NC}"
echo -e " 监听端口 : ${GREEN}${DEFAULT_PORT}${NC}"
echo ""
echo -e " ${BOLD}默认账号${NC}"
echo -e " 用户名 : ${GREEN}admin${NC}"
echo -e " 密码 : ${GREEN}admin123${NC} ${YELLOW}(请在首次登录后立即修改!)${NC}"
echo ""
echo -e " ${BOLD}常用命令${NC}"
echo -e " 启动服务 : ${CYAN}systemctl start ${SERVICE_NAME}${NC}"
echo -e " 停止服务 : ${CYAN}systemctl stop ${SERVICE_NAME}${NC}"
echo -e " 重启服务 : ${CYAN}systemctl restart ${SERVICE_NAME}${NC}"
echo -e " 查看状态 : ${CYAN}systemctl status ${SERVICE_NAME}${NC}"
echo -e " 查看日志 : ${CYAN}journalctl -u ${SERVICE_NAME} -f${NC}"
echo ""
divider
}
# ========================== 升级流程 ==========================
do_upgrade() {
show_banner
check_os
if ! check_existing; then
error "未检测到已安装的 Incudal,请先执行全新安装"
exit 1
fi
info "检测到已安装的 Incudal,准备升级..."
# 停止服务
info "停止当前服务..."
systemctl stop "$SERVICE_NAME" 2>/dev/null || true
# 获取产物包(自动下载或手动放置)
local tar_file
tar_file=$(obtain_release) || exit 1
if [[ -z "$tar_file" ]]; then
exit 1
fi
install_release "$tar_file" true
# 修复权限
chown -R "${RUN_USER}:${RUN_USER}" "$INSTALL_DIR"
# 运行迁移
run_migrations
# 重启服务
start_service
echo ""
divider
echo -e " ${GREEN}${BOLD}✅ Incudal 升级到 ${version} 完成!${NC}"
divider
}
# ========================== 卸载流程 ==========================
do_uninstall() {
show_banner
echo -e " ${RED}${BOLD}⚠️ 警告:卸载将执行以下操作:${NC}"
echo -e " ${RED} 1. 停止并删除 Incudal systemd 服务${NC}"
echo -e " ${RED} 2. 删除安装目录 ${INSTALL_DIR}${NC}"
echo -e " ${RED} 3. 删除系统用户 ${RUN_USER}${NC}"
echo -e " ${YELLOW} 注意:PostgreSQL/Redis 和数据库数据不会被删除${NC}"
echo ""
echo -ne " ${BOLD}确认卸载?${NC}[y/N]: "
read -r confirm
if [[ ! "$confirm" =~ ^[yY]$ ]]; then
info "已取消卸载"
exit 0
fi
# 停止服务
systemctl stop "$SERVICE_NAME" 2>/dev/null || true
systemctl disable "$SERVICE_NAME" 2>/dev/null || true
rm -f "$SERVICE_FILE"
systemctl daemon-reload
# 删除安装目录
rm -rf "$INSTALL_DIR"
# 删除用户
userdel "$RUN_USER" 2>/dev/null || true
# 清理 Nginx 配置
rm -f /etc/nginx/sites-enabled/incudal.conf 2>/dev/null || true
rm -f /etc/nginx/sites-available/incudal.conf 2>/dev/null || true
systemctl reload nginx 2>/dev/null || true
echo ""
log "Incudal 已完全卸载"
info "数据库数据保留在 PostgreSQL 中,如需删除请手动执行:"
echo -e " ${CYAN}sudo -u postgres psql -c \"DROP DATABASE incudal;\"${NC}"
echo -e " ${CYAN}sudo -u postgres psql -c \"DROP USER incudal;\"${NC}"
}
# ========================== 全新安装流程 ==========================
do_install() {
show_banner
check_os
if check_existing; then
warn "检测到已安装的 Incudal (${INSTALL_DIR})"
info "产物包已解压就绪,将跳过下载步骤,直接从配置阶段继续"
info "如需全新安装,请先运行: sudo bash $0 --uninstall"
echo "" >&2
fi
# 密码处理:如果 .env 已存在则复用旧密码,避免密码不一致
local db_password
local redis_password
if [[ -f "$ENV_FILE" ]]; then
info "检测到已有 .env 配置,复用现有密码"
# 从 DATABASE_URL 提取密码: postgresql://user:PASSWORD@host:port/db
db_password=$(grep -oP 'DATABASE_URL=postgresql://[^:]+:\K[^@]+' "$ENV_FILE" 2>/dev/null || echo "")
# 从 REDIS_URL 提取密码: redis://:PASSWORD@host:port
redis_password=$(grep -oP 'REDIS_URL=redis://:\K[^@]+' "$ENV_FILE" 2>/dev/null || echo "")
if [[ -z "$db_password" ]]; then
warn "无法从 .env 提取数据库密码,将生成新密码并重写 .env"
db_password=$(gen_password 24)
rm -f "$ENV_FILE"
fi
if [[ -z "$redis_password" ]]; then
redis_password=$(gen_password 24)
fi
else
db_password=$(gen_password 24)
redis_password=$(gen_password 24)
fi
# 安装依赖
step "更新系统包索引..."
apt-get update -qq >/dev/null 2>&1
install_nodejs
install_postgresql
install_redis
# 仅在全新安装时获取和解压产物包(已安装则跳过)
if ! check_existing; then
# 获取产物包(自动下载或手动放置)
local tar_file
tar_file=$(obtain_release) || exit 1
if [[ -z "$tar_file" ]]; then
exit 1
fi
# 解压安装
install_release "$tar_file" false
else
log "产物包已就绪,跳过下载和解压"
fi
# 创建用户
create_user
# 生成面板客户端证书(与 Incus API mTLS 通信所需)
generate_panel_cert
# 配置数据库
setup_database "$db_password"
# 配置 Redis
setup_redis "$redis_password"
# 生成 .env
generate_env "$db_password" "$redis_password"
# 创建 systemd 服务
create_service
# 运行数据库迁移
run_migrations
# 选择网络方案
echo ""
divider
echo -e " ${BOLD}请选择外部访问方案:${NC}"
divider
echo -e " ${CYAN}[1]${NC} Nginx + Certbot ${YELLOW}(推荐:自动 HTTPS,需要公网 IP 和域名)${NC}"
echo -e " ${CYAN}[2]${NC} Cloudflare Tunnel ${YELLOW}(适合无公网 IP 或隐藏源站 IP${NC}"
echo -e " ${CYAN}[3]${NC} 仅启动服务 ${DIM}(手动配置反代)${NC}"
echo ""
echo -ne " ${BOLD}请选择 [1-3]: ${NC}"
read -r net_opt
case "${net_opt:-3}" in
1) setup_nginx_certbot ;;
2) setup_cf_tunnel ;;
3) info "跳过网络配置,服务将监听 127.0.0.1:${DEFAULT_PORT}" ;;
*) info "无效选项,跳过网络配置" ;;
esac
# 启动服务
start_service
# 显示结果
show_result
}
# ========================== 主入口 ==========================
main() {
check_root
case "${1:-}" in
--upgrade|-u)
do_upgrade
;;
--uninstall|--remove)
do_uninstall
;;
--help|-h)
echo "Incudal 面板部署脚本 v${SCRIPT_VERSION}"
echo ""
echo "用法: sudo bash $0 [选项]"
echo ""
echo "选项:"
echo " (无参数) 全新安装"
echo " --upgrade 升级已有安装"
echo " --uninstall 卸载 Incudal"
echo " --help 显示帮助"
;;
*)
do_install
;;
esac
}
main "$@"