#!/usr/bin/env bash # --------------------------------------------------------------------------- # 班级小助手 · 运维命令(Linux) # # 安装位置:<安装目录>/bin/classhelper,并在 /usr/local/bin/classhelper 建软链, # 因此任何用户在任何目录都能直接敲 `classhelper`。 # # 设计原则: # 1. **敏感操作一律 root**:除 help / version / completion 外,全部先过 need_root。 # 2. **密码永不经过 argv / 环境变量 / 日志**:`password` 子命令从 /dev/tty(无 tty 时 stdin) # 读入,经管道交给 tools/admin-cli.mjs,只写进数据库;改配置时值也不进 argv(走临时文件)。 # 3. **配置只有一个真身**:/etc/classhelper/config.env(默认),安装目录里的 .env 是指向它的软链。 # —— Web 端「一键切换数据库」改写的是 <安装目录>/.env,靠软链落到同一份文件上; # 否则 systemd 注入的旧值会压住新值,切换看起来成功、重启后又连回旧库。详见 docs/linux-deploy.md。 # 4. **危险动作先备份**:改配置 / 改密 / 升级 / 回滚都先落一份到 /var/backups/classhelper/。 # 5. 关键操作写审计日志 /var/log/classhelper/audit.log(只记"谁对谁做了什么、结果如何")。 # 6. **替换程序文件用 mv 而不是 rm+cp**:升级时正在运行的正是本脚本(bin/classhelper), # rm 掉再写同名文件会让 bash 读到半个脚本;改名后新文件就位,运行中的进程继续读旧 inode。 # # 用法:classhelper help # --------------------------------------------------------------------------- set -euo pipefail # ---------------------------------------------------------------- 0. 常量 SERVICE_NAME="classhelper" DEFAULT_INSTALL_DIR="/opt/classhelper" DEFAULT_CONFIG_FILE="/etc/classhelper/config.env" DEFAULT_LOG_DIR="/var/log/classhelper" DEFAULT_BACKUP_DIR="/var/backups/classhelper" REPO_SLUG="LoveChengke/ClassHelper" GITHUB_API="https://api.github.com/repos/${REPO_SLUG}" GITHUB_RELEASES_PAGE="https://github.com/${REPO_SLUG}/releases" # GitHub 对没有 User-Agent 的请求直接 403 HTTP_UA="ClassHelper-Ops" CURL_OPTS=(-fsSL --max-time 20 -H "User-Agent: ${HTTP_UA}") CURL_OPTS_LONG=(-fsSL --max-time 600 -H "User-Agent: ${HTTP_UA}") # ---------------------------------------------------------------- 1. 输出 if [ -t 1 ] && [ "${TERM:-dumb}" != "dumb" ] && [ -z "${NO_COLOR:-}" ]; then C_RESET=$'\033[0m'; C_BOLD=$'\033[1m'; C_DIM=$'\033[2m' C_GREEN=$'\033[32m'; C_YELLOW=$'\033[33m'; C_RED=$'\033[31m'; C_CYAN=$'\033[36m' else C_RESET=''; C_BOLD=''; C_DIM=''; C_GREEN=''; C_YELLOW=''; C_RED=''; C_CYAN='' fi log() { printf '%s[classhelper]%s %s\n' "$C_GREEN" "$C_RESET" "$*"; } info() { printf '%s[classhelper]%s %s\n' "$C_CYAN" "$C_RESET" "$*"; } warn() { printf '%s[classhelper]%s %s\n' "$C_YELLOW" "$C_RESET" "$*" >&2; } die() { printf '%s[classhelper]%s %s\n' "$C_RED" "$C_RESET" "$*" >&2; exit 1; } # ---------------------------------------------------------------- 2. 定位自身与安装元数据 # 解析真实路径(软链 /usr/local/bin/classhelper -> <安装目录>/bin/classhelper) resolve_self() { local target="${BASH_SOURCE[0]}" resolved if command -v readlink >/dev/null 2>&1; then resolved="$(readlink -f "$target" 2>/dev/null || true)" [ -n "$resolved" ] && target="$resolved" fi ( cd "$(dirname "$target")" && printf '%s/%s' "$PWD" "$(basename "$target")" ) } SELF_PATH="$(resolve_self)" INSTALL_DIR="$(cd "$(dirname "$SELF_PATH")/.." && pwd)" META_FILE="${INSTALL_DIR}/.installed.json" # 元数据缺失(例如手工拷贝的 bin 目录):回落到默认值,尽量仍能给出状态 if [ ! -f "$META_FILE" ] && [ -n "${CLASSHELPER_HOME:-}" ]; then INSTALL_DIR="${CLASSHELPER_HOME}" META_FILE="${INSTALL_DIR}/.installed.json" fi # 只解析扁平、单层、值全为字符串的 JSON —— 格式由安装器与 upgrade 写出、受本脚本控制, # 因此用 sed 比引入 json 工具更可靠(运维工具要在 Node 坏掉时照样能用) meta_get() { local key="$1" [ -f "$META_FILE" ] || return 0 sed -n "s/^[[:space:]]*\"${key}\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*\$/\1/p" "$META_FILE" | head -n 1 } MODE="$(meta_get mode)"; MODE="${MODE:-native}" DB_KIND="$(meta_get database)"; DB_KIND="${DB_KIND:-sqlite}" SERVICE_USER="$(meta_get serviceUser)"; SERVICE_USER="${SERVICE_USER:-classhelper}" CONFIG_FILE="$(meta_get configFile)"; CONFIG_FILE="${CONFIG_FILE:-$DEFAULT_CONFIG_FILE}" LOG_DIR="$(meta_get logDir)"; LOG_DIR="${LOG_DIR:-$DEFAULT_LOG_DIR}" BACKUP_DIR="$(meta_get backupDir)"; BACKUP_DIR="${BACKUP_DIR:-$DEFAULT_BACKUP_DIR}" COMPOSE_FILE="$(meta_get composeFile)" CONFIG_DIR="$(dirname "$CONFIG_FILE")" DATA_DIR="${INSTALL_DIR}/data" TOOLS_DIR="${INSTALL_DIR}/tools" NODE_BIN="${INSTALL_DIR}/runtime/node/bin/node" [ -x "$NODE_BIN" ] || NODE_BIN="$(command -v node 2>/dev/null || true)" SHARED_LIB="${INSTALL_DIR}/node_modules/@classhelper/shared/dist/index.js" AUDIT_LOG="${LOG_DIR}/audit.log" # 审计:只记动作与目标,**绝不记密码 / 令牌 / 密钥**。 # 目录在这里按需创建,而不是脚本一启动就建 —— help / version / completion 不需要 root, # 也不该在别人的机器上顺手创建 /var/log 下的目录。 audit() { local action="$1" target="${2:--}" result="${3:-ok}" mkdir -p "$LOG_DIR" 2>/dev/null || true chmod 700 "$LOG_DIR" 2>/dev/null || true printf '%s operator=%s action=%s target=%s result=%s\n' \ "$(date '+%F %T%z')" "${SUDO_USER:-$(id -un)}" "$action" "$target" "$result" >>"$AUDIT_LOG" 2>/dev/null || true chmod 600 "$AUDIT_LOG" 2>/dev/null || true } need_root() { [ "$(id -u)" = "0" ] || die "该操作需要 root 权限:sudo classhelper $*" } require_install() { [ -d "$INSTALL_DIR" ] || die "找不到安装目录 ${INSTALL_DIR}(用 install.sh 安装,或设置 CLASSHELPER_HOME)" } # ---------------------------------------------------------------- 3. 配置读写 # 读配置项(去掉两侧引号,与 shell source / dotenv / systemd 三种读法口径一致) cfg_get() { local key="$1" [ -f "$CONFIG_FILE" ] || return 0 sed -n "s/^[[:space:]]*${key}=//p" "$CONFIG_FILE" | tail -n 1 | sed -e 's/^"//' -e 's/"$//' -e "s/^'//" -e "s/'$//" } is_secret_key() { case "$1" in *SECRET*|*PASSWORD*|*TOKEN*|DATABASE_URL) return 0 ;; *) return 1 ;; esac } # 打码显示:只露出头 2 位与末 1 位,短值全星 mask_value() { printf '%s' "${1:-}" | awk '{ if (length($0) <= 4) print "****"; else print substr($0,1,2) "****" substr($0,length($0)-1) }' } # 写 .env 时的取值引号规则:含空白或 # 的值必须加引号,否则三种读法都会解析错 quote_env_value() { local value="$1" case "$value" in *[[:space:]]*|*'#'*|*'"'*|*"'"*|'') printf '"%s"' "$(printf '%s' "$value" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g')" ;; *) printf '%s' "$value" ;; esac } current_port() { local port port="$(cfg_get PORT)" [ -n "$port" ] || port="$(meta_get port)" printf '%s' "${port:-4000}" } current_version() { local version="" if [ -f "${INSTALL_DIR}/VERSION" ]; then version="$(tr -d '[:space:]' <"${INSTALL_DIR}/VERSION")"; fi [ -n "$version" ] || version="$(meta_get version)" printf '%s' "${version:-未知}" } # ---------------------------------------------------------------- 4. 版本比较 # 与 @classhelper/shared 的 isNewerVersion 同口径 —— 优先用那份实现,不制造第二套规则。 # 只有在 Node / 共享包缺失(运维救急场景)时才退回纯 bash 比较(只比数字段,忽略预发布后缀)。 version_gt() { local current="$1" candidate="$2" if [ -n "$NODE_BIN" ] && [ -f "$SHARED_LIB" ]; then "$NODE_BIN" --input-type=module -e ' const [, lib, a, b] = process.argv; const shared = await import(lib); process.exit(shared.isNewerVersion(a, b) ? 0 : 1); ' "$SHARED_LIB" "$current" "$candidate" 2>/dev/null && return 0 return 1 fi local a="${current#v}" b="${candidate#v}" a="${a%%-*}"; b="${b%%-*}" local IFS='.' i x y local -a xs ys read -r -a xs <<<"$a" read -r -a ys <<<"$b" for i in 0 1 2; do x="${xs[$i]:-0}"; y="${ys[$i]:-0}" x="${x//[!0-9]/}"; y="${y//[!0-9]/}" [ -n "$x" ] || x=0 [ -n "$y" ] || y=0 if [ "$((10#$x))" -gt "$((10#$y))" ]; then return 0; fi if [ "$((10#$x))" -lt "$((10#$y))" ]; then return 1; fi done return 1 } # ---------------------------------------------------------------- 5. 服务控制 compose_cmd() { [ -f "$COMPOSE_FILE" ] || die "找不到 compose 文件:${COMPOSE_FILE}" if docker compose version >/dev/null 2>&1; then docker compose -f "$COMPOSE_FILE" --env-file "$CONFIG_FILE" "$@" elif command -v docker-compose >/dev/null 2>&1; then docker-compose -f "$COMPOSE_FILE" --env-file "$CONFIG_FILE" "$@" else die "未找到 docker compose(需要 docker compose 插件或 docker-compose)" fi } svc_active() { if [ "$MODE" = "docker" ]; then [ -n "$(compose_cmd ps -q 2>/dev/null || true)" ] return fi systemctl is-active --quiet "$SERVICE_NAME" } svc_enabled() { if [ "$MODE" = "docker" ]; then svc_active return fi systemctl is-enabled --quiet "$SERVICE_NAME" 2>/dev/null } # Docker 形态的镜像标签用**稳定名**(compose 文件里写死它),版本另打一个 tag 便于追溯。 # 为什么不用 classhelper-server:<版本> 直接写进 compose:升级替换完程序文件后, # compose 里那个版本号就过期了,`compose up -d` 会一直沿用旧镜像 —— 表现为"升级成功但行为没变"。 DOCKER_IMAGE="classhelper-server:local" DOCKER_IMAGE_MARKER="${INSTALL_DIR}/.docker-image-version" docker_build_image() { local ver; ver="$(current_version)" if [ -f "$DOCKER_IMAGE_MARKER" ] && [ "$(cat "$DOCKER_IMAGE_MARKER" 2>/dev/null)" = "$ver" ]; then return 0 fi if [ ! -f "${INSTALL_DIR}/Dockerfile" ]; then warn "安装目录里没有 Dockerfile,跳过镜像重建" return 1 fi info "构建镜像(版本 ${ver})..." if docker build -t "$DOCKER_IMAGE" -t "classhelper-server:${ver}" \ -f "${INSTALL_DIR}/Dockerfile" "$INSTALL_DIR" >>"${LOG_DIR}/docker-build.log" 2>&1; then printf '%s' "$ver" >"$DOCKER_IMAGE_MARKER" log "镜像已就绪:${DOCKER_IMAGE}(版本 ${ver})" return 0 fi warn "镜像构建失败,详见 ${LOG_DIR}/docker-build.log" return 1 } svc_start() { if [ "$MODE" = "docker" ]; then docker_build_image || true compose_cmd up -d else systemctl start "$SERVICE_NAME" fi } svc_stop() { if [ "$MODE" = "docker" ]; then compose_cmd down; else systemctl stop "$SERVICE_NAME"; fi; } svc_restart() { if [ "$MODE" = "docker" ]; then docker_build_image || warn "沿用现有镜像重启容器" compose_cmd up -d --force-recreate else systemctl restart "$SERVICE_NAME" fi } # 就绪探针:本地请求**必须**绕开系统代理(否则拿到的是代理返回的 404,见 AGENTS.md §7 第 1 条) probe_ready() { command -v curl >/dev/null 2>&1 || return 1 curl -fsS --noproxy '*' --max-time 3 -o /dev/null "http://127.0.0.1:$(current_port)/healthz" >/dev/null 2>&1 } wait_ready() { local timeout="${1:-60}" i=0 while [ "$i" -lt "$timeout" ]; do probe_ready && return 0 i=$((i + 1)) sleep 1 done return 1 } print_recent_logs() { printf '%s--- 最近日志 ---%s\n' "$C_DIM" "$C_RESET" >&2 if [ "$MODE" = "docker" ]; then compose_cmd logs --tail 50 2>&1 || true else journalctl -u "$SERVICE_NAME" -n 50 --no-pager 2>&1 || true fi } # ---------------------------------------------------------------- 6. 备份 ensure_backup_dir() { mkdir -p "$BACKUP_DIR" chmod 700 "$BACKUP_DIR" } # 程序文件快照(回滚用):data / logs / runtime / 配置软链都不进快照 snapshot_program_files() { local dest="$1" item ensure_backup_dir mkdir -p "$(dirname "$dest")" local items=() for item in server web node_modules tools bin systemd package.json VERSION README.txt Dockerfile .cross-built; do [ -e "${INSTALL_DIR}/${item}" ] && items+=("$item") done [ "${#items[@]}" -gt 0 ] || die "安装目录里没有任何可打包的程序文件" tar -czf "$dest" -C "$INSTALL_DIR" "${items[@]}" chmod 600 "$dest" } # 数据快照:SQLite 用 VACUUM INTO(服务运行中也一致);MySQL 用 mysqldump snapshot_database() { local dest_dir="$1" if [ "$DB_KIND" = "mysql" ]; then local dump_bin; dump_bin="$(command -v mysqldump 2>/dev/null || true)" if [ -z "$dump_bin" ]; then warn "未找到 mysqldump:本次备份只含配置,数据库请自行 mysqldump 备份" return 0 fi local url host port dbname user pass url="$(cfg_get DATABASE_URL)" user="$(printf '%s' "$url" | sed -n 's#^mysql://\([^:@]*\).*#\1#p')" pass="$(printf '%s' "$url" | sed -n 's#^mysql://[^:]*:\([^@]*\)@.*#\1#p')" host="$(printf '%s' "$url" | sed -n 's#^mysql://[^@]*@\([^:/]*\).*#\1#p')" port="$(printf '%s' "$url" | sed -n 's#^mysql://[^@]*@[^:]*:\([0-9]*\)/.*#\1#p')" dbname="$(printf '%s' "$url" | sed -n 's#^mysql://[^/]*/\([^?]*\).*#\1#p')" # 密码经环境变量交给子进程(不进 argv);MYSQL_PWD 是 mysqldump 官方支持的传参方式 MYSQL_PWD="$pass" "$dump_bin" -h "${host:-127.0.0.1}" -P "${port:-3306}" -u "${user:-root}" \ --single-transaction --routines "$dbname" >"${dest_dir}/database.sql" chmod 600 "${dest_dir}/database.sql" return 0 fi local db_file; db_file="$(cfg_get DATABASE_URL)" db_file="${db_file#file:}" if [ "$MODE" = "docker" ]; then # Docker 形态:数据在容器卷里,宿主机看不到那个文件 —— 在容器内做一致性快照再取出来 if compose_cmd exec -T -e LOG_LEVEL=error -e DOTENV_CONFIG_QUIET=true server \ node tools/admin-cli.mjs snapshot /tmp/classhelper-snapshot.db >/dev/null 2>&1; then local cid cid="$(compose_cmd ps -q server 2>/dev/null | head -n 1)" if [ -n "$cid" ] && docker cp "${cid}:/tmp/classhelper-snapshot.db" "${dest_dir}/classhelper.db" >/dev/null 2>&1; then compose_cmd exec -T server rm -f /tmp/classhelper-snapshot.db >/dev/null 2>&1 || true chmod 600 "${dest_dir}/classhelper.db" return 0 fi fi warn "容器内快照失败:本次备份只含配置(可进容器手工导出数据)" return 0 fi case "$db_file" in /*) ;; *) db_file="${INSTALL_DIR}/server/${db_file}" ;; esac if [ ! -f "$db_file" ]; then warn "找不到 SQLite 数据文件:${db_file}(本次备份只含配置)" return 0 fi if [ -x "$NODE_BIN" ] && [ -f "${TOOLS_DIR}/admin-cli.mjs" ]; then if CLASSHELPER_SNAPSHOT_DB="$db_file" LOG_LEVEL=error DOTENV_CONFIG_QUIET=true \ "$NODE_BIN" "${TOOLS_DIR}/admin-cli.mjs" snapshot "${dest_dir}/classhelper.db" >/dev/null 2>&1; then chmod 600 "${dest_dir}/classhelper.db" return 0 fi warn "VACUUM INTO 快照失败,退回整文件复制(服务运行中复制可能不一致,建议停机备份)" fi cp -p "$db_file" "${dest_dir}/classhelper.db" chmod 600 "${dest_dir}/classhelper.db" } cmd_backup() { local quiet=0 keep=0 note="" while [ $# -gt 0 ]; do case "$1" in --quiet) quiet=1; shift ;; --keep) keep="${2:-0}"; shift 2 ;; --note) note="${2:-}"; shift 2 ;; list) shift; cmd_backup_list; return 0 ;; restore) shift; cmd_backup_restore "$@"; return 0 ;; schedule) shift; cmd_backup_schedule "$@"; return 0 ;; *) die "backup: 未知参数 $1(classhelper help)" ;; esac done need_root "backup" require_install ensure_backup_dir local stamp; stamp="$(date '+%Y%m%d-%H%M%S')" local slug="" if [ -n "$note" ]; then slug="-$(printf '%s' "$note" | tr -c 'A-Za-z0-9_.-' '-')"; fi local dest="${BACKUP_DIR}/${stamp}${slug}" mkdir -p "$dest" chmod 700 "$dest" [ "$quiet" = "0" ] && info "备份到 ${dest} ..." if [ -f "$CONFIG_FILE" ]; then cp -p "$CONFIG_FILE" "${dest}/config.env"; chmod 600 "${dest}/config.env"; fi snapshot_database "$dest" # 元信息:只记版本与形态,**不记任何密钥** cat >"${dest}/meta.json" </dev/null; then local index=0 dir while read -r dir; do [ -n "$dir" ] || continue index=$((index + 1)) if [ "$index" -gt "$keep" ]; then rm -rf "${BACKUP_DIR:?}/${dir}" [ "$quiet" = "0" ] && info "已清理过期备份 ${dir}(保留最近 ${keep} 份)" fi done < <(ls -1 "$BACKUP_DIR" 2>/dev/null | grep -E '^[0-9]{8}-[0-9]{6}' | sort -r) fi [ "$quiet" = "0" ] && log "备份完成:${dest}" return 0 } cmd_backup_list() { ensure_backup_dir printf '%s备份目录:%s%s\n' "$C_BOLD" "$BACKUP_DIR" "$C_RESET" local found=0 dir size while read -r dir; do [ -n "$dir" ] || continue found=1 size="$(du -sh "${BACKUP_DIR}/${dir}" 2>/dev/null | awk '{print $1}')" printf ' %s %6s %s\n' "$dir" "${size:-?}" "$([ -f "${BACKUP_DIR}/${dir}/classhelper.db" ] && echo '含数据库' || echo '仅配置')" done < <(ls -1 "$BACKUP_DIR" 2>/dev/null | grep -E '^[0-9]{8}-[0-9]{6}' | sort -r) [ "$found" = "1" ] || printf ' %s(还没有备份,运行 classhelper backup)%s\n' "$C_DIM" "$C_RESET" printf '\n%s程序快照(回滚用,%s/rollback):%s\n' "$C_BOLD" "$BACKUP_DIR" "$C_RESET" found=0 while read -r dir; do [ -n "$dir" ] || continue found=1 size="$(du -h "${BACKUP_DIR}/rollback/${dir}" 2>/dev/null | awk '{print $1}')" printf ' %s %s\n' "$dir" "${size:-?}" done < <(ls -1t "${BACKUP_DIR}/rollback" 2>/dev/null) [ "$found" = "1" ] || printf ' %s(无)%s\n' "$C_DIM" "$C_RESET" } # 数据文件路径(SQLite) sqlite_file_path() { local db_file; db_file="$(cfg_get DATABASE_URL)" db_file="${db_file#file:}" case "$db_file" in /*) printf '%s' "$db_file" ;; *) printf '%s' "${INSTALL_DIR}/server/${db_file}" ;; esac } cmd_backup_restore() { need_root "backup restore" require_install local src="${1:-}" [ -n "$src" ] || die "用法:classhelper backup restore <备份目录名|路径>" [ -d "$src" ] || src="${BACKUP_DIR}/${src}" [ -d "$src" ] || die "找不到备份:${1}" printf '将用 %s 覆盖当前配置' "$src" [ -f "${src}/classhelper.db" ] && printf '与数据库' printf ',服务会先停止。\n' read -r -p "确认恢复?输入 yes 继续:" reply [ "$reply" = "yes" ] || { info "已取消"; return 0; } # 恢复前先备份现状,免得恢复错了无从退回 cmd_backup --quiet --note "restore-前" || true svc_stop || true if [ -f "${src}/config.env" ]; then cp -p "${src}/config.env" "$CONFIG_FILE" chmod 600 "$CONFIG_FILE" fi if [ -f "${src}/classhelper.db" ]; then if [ "$MODE" = "docker" ]; then # 数据在容器卷里,宿主机上的路径是容器内路径,直接 cp 会在宿主机建出一个假目录 warn "Docker 形态的数据库恢复要手工做(数据在容器卷里):" printf ' %s stop\n docker cp %s <容器>: /app/data/classhelper.db\n %s start\n' \ "$SERVICE_NAME" "${src}/classhelper.db" "$SERVICE_NAME" else local db_file; db_file="$(sqlite_file_path)" rm -f "${db_file}-wal" "${db_file}-shm" cp -p "${src}/classhelper.db" "$db_file" chown "${SERVICE_USER}:${SERVICE_USER}" "$db_file" 2>/dev/null || true fi fi svc_start if wait_ready 90; then audit restore "$src" ok log "恢复完成,服务已就绪 ✅" else audit restore "$src" failed print_recent_logs die "恢复后 90 秒内没通过探针,请检查上面的日志" fi } cmd_backup_schedule() { need_root "backup schedule" require_install local mode="${1:-}" case "$mode" in daily|weekly|off) ;; *) die "用法:classhelper backup schedule daily|weekly|off" ;; esac if [ "$MODE" = "docker" ]; then die "Docker 形态请用宿主机的 cron 调 'classhelper backup --quiet'(systemd timer 只装在直接安装形态)" fi if [ "$mode" = "off" ]; then systemctl disable --now classhelper-backup.timer >/dev/null 2>&1 || true rm -f /etc/systemd/system/classhelper-backup.timer systemctl daemon-reload audit backup-schedule off ok log "已关闭定时备份" return 0 fi local keep=7 oncalendar='*-*-* 03:30:00' if [ "$mode" = "weekly" ]; then keep=4 oncalendar='Mon *-*-* 03:30:00' fi local template="${INSTALL_DIR}/systemd/classhelper-backup" if [ -f "${template}.service" ] && [ -f "${template}.timer" ]; then sed -e "s#__BIN__#/usr/local/bin/classhelper#g" -e "s#__KEEP__#${keep}#g" \ "${template}.service" >/etc/systemd/system/classhelper-backup.service sed -e "s#__ONCALENDAR__#${oncalendar}#g" "${template}.timer" >/etc/systemd/system/classhelper-backup.timer else # 安装包里没带模板(老版本安装)时现场生成,保证功能可用 cat >/etc/systemd/system/classhelper-backup.service </etc/systemd/system/classhelper-backup.timer </dev/null audit backup-schedule "$mode" ok log "已开启定时备份(${mode},保留最近 ${keep} 份)" systemctl list-timers classhelper-backup.timer --no-pager 2>/dev/null | head -2 || true } # ---------------------------------------------------------------- 7. status / doctor human_size() { local bytes="${1:-0}" case "$bytes" in ''|*[!0-9]*) printf '0 B'; return 0 ;; esac if [ "$bytes" -ge 1073741824 ]; then printf '%s GB' "$((bytes / 1073741824))" elif [ "$bytes" -ge 1048576 ]; then printf '%s MB' "$((bytes / 1048576))" elif [ "$bytes" -ge 1024 ]; then printf '%s KB' "$((bytes / 1024))" else printf '%s B' "$bytes"; fi } first_ip() { local ip ip="$(hostname -I 2>/dev/null | awk '{print $1}')" printf '%s' "${ip:-127.0.0.1}" } listen_on_port() { local port="$1" if command -v ss >/dev/null 2>&1; then ss -ltn 2>/dev/null | awk -v p=":${port}\$" '$4 ~ p {print $4; exit}' elif command -v netstat >/dev/null 2>&1; then netstat -ltn 2>/dev/null | awk -v p=":${port}\$" '$4 ~ p {print $4; exit}' fi } cmd_status() { require_install local port; port="$(current_port)" local active="inactive" enabled="disabled" healthy="no" svc_active && active="active" || true svc_enabled && enabled="enabled" || true probe_ready && healthy="yes" || true local db_desc="${DB_KIND}" if [ "$DB_KIND" = "sqlite" ]; then if [ "$MODE" = "docker" ]; then # 数据在容器卷里,宿主机上看不到那个文件,别报成"缺失" db_desc="sqlite(Docker 卷 classhelper-data 内)" else local db_file; db_file="$(sqlite_file_path)" if [ -f "$db_file" ]; then db_desc="sqlite($(human_size "$(stat -c '%s' "$db_file" 2>/dev/null || echo 0)"))" else db_desc="sqlite(数据文件缺失:${db_file})" fi fi fi local listen; listen="$(listen_on_port "$port")" [ -n "$listen" ] || listen="未监听" if [ "${1:-}" = "--json" ]; then cat </dev/null || echo 未知)" doctor_check "配置文件权限为 600(实际 ${bits})" "$([ "$bits" = "600" ] && echo 0 || echo 1)" "chmod 600 ${CONFIG_FILE}" bits="$(stat -c '%a' "$CONFIG_DIR" 2>/dev/null || echo 未知)" doctor_check "配置目录权限为 700(实际 ${bits})" "$([ "$bits" = "700" ] && echo 0 || echo 1)" "chmod 700 ${CONFIG_DIR}" if [ "$MODE" = "native" ]; then local link_ok=1 link="${INSTALL_DIR}/.env" if [ -L "$link" ] && [ "$(readlink "$link")" = "$CONFIG_FILE" ]; then link_ok=0; fi doctor_check "安装目录 .env 是指向配置真身的软链" "$link_ok" "ln -sfn ${CONFIG_FILE} ${link}(Web 端「一键切换数据库」依赖它)" fi local node_ok=1 node_ver="未找到" if [ -x "$NODE_BIN" ]; then node_ver="$("$NODE_BIN" -v 2>/dev/null || echo '?')" local major minor major="$(printf '%s' "$node_ver" | sed 's/^v\([0-9]*\).*/\1/')" minor="$(printf '%s' "$node_ver" | sed 's/^v[0-9]*\.\([0-9]*\).*/\1/')" if [ "${major:-0}" -gt 20 ] || { [ "${major:-0}" -eq 20 ] && [ "${minor:-0}" -ge 19 ]; }; then node_ok=0; fi fi doctor_check "Node 运行时可用且 ≥20.19(${node_ver})" "$node_ok" "重装以重新下载内置 Node:bash install.sh" local data_ok=1 if [ -d "$DATA_DIR" ] && touch "${DATA_DIR}/.doctor-probe" 2>/dev/null; then rm -f "${DATA_DIR}/.doctor-probe"; data_ok=0 fi doctor_check "数据目录可写(${DATA_DIR})" "$data_ok" "chown -R ${SERVICE_USER}:${SERVICE_USER} ${DATA_DIR}" if [ "$MODE" = "native" ]; then doctor_check "systemd 单元已安装" "$([ -f "/etc/systemd/system/${SERVICE_NAME}.service" ] && echo 0 || echo 1)" "在安装目录执行 bash install.sh" doctor_check "服务正在运行" "$(svc_active && echo 0 || echo 1)" "classhelper start,然后 classhelper logs -n 80" doctor_check "服务开机自启" "$(svc_enabled && echo 0 || echo 1)" "systemctl enable ${SERVICE_NAME}" else doctor_check "compose 文件存在(${COMPOSE_FILE})" "$([ -f "$COMPOSE_FILE" ] && echo 0 || echo 1)" "重装 Docker 形态:bash install.sh" doctor_check "容器正在运行" "$(svc_active && echo 0 || echo 1)" "classhelper start" fi doctor_check "健康探针 /healthz" "$(probe_ready && echo 0 || echo 1)" "classhelper logs -n 80" local db_ok=1 if [ -f "${TOOLS_DIR}/admin-cli.mjs" ] && [ -x "$NODE_BIN" ]; then if "$NODE_BIN" "${TOOLS_DIR}/admin-cli.mjs" info >/dev/null 2>&1; then db_ok=0; fi fi doctor_check "数据库可连通" "$db_ok" "检查 config.env 的 DATABASE_URL 与数据库服务状态" local secret; secret="$(cfg_get JWT_SECRET)" doctor_check "JWT_SECRET 长度 ≥32" "$([ "${#secret}" -ge 32 ] && echo 0 || echo 1)" "classhelper key rotate" # 交叉构建的包(在 Windows 上打的):能装能跑,但 prisma 的原生 schema engine 是宿主平台的, # Web 端「数据库一键切换」那一步可能失败 —— 不做成静默,这里明确报出来。 if [ -f "${INSTALL_DIR}/.cross-built" ]; then doctor_check "安装包来自 Linux 上构建的正式发布包" 1 \ "这是交叉构建的包($(tr -d '\n' <"${INSTALL_DIR}/.cross-built" 2>/dev/null)):Web 端「数据库一键切换」可能失败,其余功能正常;正式部署请用 GitHub Actions 出的包" fi local avail_kb; avail_kb="$(df -Pk "$INSTALL_DIR" 2>/dev/null | awk 'NR==2 {print $4}')" case "$avail_kb" in ''|*[!0-9]*) avail_kb=0 ;; esac # df -Pk 给的是 1K 块,human_size 收的是字节 —— 不乘 1024 会把 25 GB 显示成 25 MB doctor_check "磁盘剩余 ≥1GB($(human_size "$((avail_kb * 1024))"))" "$([ "$avail_kb" -ge 1048576 ] && echo 0 || echo 1)" "清理旧备份:classhelper backup list" local newest="" backup_ok=1 newest="$(ls -1t "$BACKUP_DIR" 2>/dev/null | grep -E '^[0-9]{8}-[0-9]{6}' | head -1)" if [ -n "$newest" ]; then local age_days age_days=$(( ( $(date +%s) - $(stat -c '%Y' "${BACKUP_DIR}/${newest}" 2>/dev/null || echo 0) ) / 86400 )) [ "$age_days" -le 7 ] && backup_ok=0 fi doctor_check "最近 7 天内有备份(${newest:-无})" "$backup_ok" "classhelper backup;长期可用 classhelper backup schedule daily" printf '\n' if [ "$DOCTOR_FAIL" = "0" ]; then log "体检通过:${DOCTOR_PASS}/${DOCTOR_PASS} 项 ✅" else warn "体检结果:${DOCTOR_PASS} 项通过,${DOCTOR_FAIL} 项待处理" printf '\n%s建议依次处理:%s\n' "$C_BOLD" "$C_RESET" local tip for tip in "${DOCTOR_TIP[@]}"; do printf ' · %s\n' "$tip"; done fi return 0 } # ---------------------------------------------------------------- 8. password # 交互优先 /dev/tty(不受管道影响),没有 tty(自动化 / ssh 无 -t)时回退 stdin。 # 回退的意义:验证脚本可以 `printf '%s\n' "$PW" | classhelper password admin` —— 密码既不在 argv, # 也不进 shell history。 open_tty() { [ "${CLASSHELPER_NO_TTY:-0}" = "1" ] && return 1 # 必须用 `<>`(读写):read_secret 既要往 fd 3 写提示语、又要从它读输入, # 而只读打开的 fd 一写就 EBADF("write error: Bad file descriptor")—— # 在 set -e 下会让整个命令当场退出。install.sh 里踩过同一个坑(见那边的注释)。 exec 3<>/dev/tty 2>/dev/null } read_secret() { # $1=提示语 $2=目标变量名;读到 EOF 时把 READ_SECRET_EOF 置 1 local prompt="$1" var="$2" value='' READ_SECRET_EOF=0 if open_tty; then # 提示语写不进去(fd 3 不可写)时按"读不到输入"处理,交给调用方报错退出; # 千万别让这行失败把命令打死 —— set -e 下它会让 `classhelper password` 毫无提示地退出。 if printf '%s' "$prompt" >&3 2>/dev/null; then if ! IFS= read -r -s value <&3; then READ_SECRET_EOF=1; fi printf '\n' >&3 2>/dev/null || true else READ_SECRET_EOF=1 fi exec 3<&- 2>/dev/null || true else printf '%s' "$prompt" >&2 if ! IFS= read -r -s value; then READ_SECRET_EOF=1; fi printf '\n' >&2 fi printf -v "$var" '%s' "$value" return 0 } # 与服务端 tools/admin-cli.mjs 同一套强度规则(两处口径必须一致) password_problems() { # $1=密码 $2=用户名 local pw="$1" user="$2" kinds=0 local problems=() [ "${#pw}" -ge 8 ] || problems+=("长度至少 8 位") [ "${#pw}" -le 128 ] || problems+=("长度不要超过 128 位") [[ "$pw" =~ [a-z] ]] && kinds=$((kinds + 1)) [[ "$pw" =~ [A-Z] ]] && kinds=$((kinds + 1)) [[ "$pw" =~ [0-9] ]] && kinds=$((kinds + 1)) [[ "$pw" =~ [^A-Za-z0-9] ]] && kinds=$((kinds + 1)) [ "$kinds" -ge 3 ] || problems+=("需包含小写字母 / 大写字母 / 数字 / 符号 中至少 3 类") if [ -n "$user" ] && [ "$(printf '%s' "$pw" | tr 'A-Z' 'a-z')" = "$(printf '%s' "$user" | tr 'A-Z' 'a-z')" ]; then problems+=("不能与用户名相同") fi case "$pw" in 123456*|password*|admin*|classhelper*|qwerty*|111111*|abc123*) [ "${#pw}" -lt 12 ] && problems+=("过于常见,请换一个") ;; esac printf '%s' "$(IFS=';'; printf '%s' "${problems[*]:-}")" } admin_cli() { [ -f "${TOOLS_DIR}/admin-cli.mjs" ] || die "缺少 ${TOOLS_DIR}/admin-cli.mjs(重装可补齐)" # LOG_LEVEL=error + DOTENV_CONFIG_QUIET=true 是必须的:admin-cli 复用了服务端的 env/logger # 模块,那两个模块会往 **stdout** 打日志(dotenv 的 "◇ injected env..." 提示、Prisma 初始化一行), # 而本命令的 stdout 是要被当数据解析的 —— dump-hash 的 JSON 直接写进回滚用的备份文件, # 混进一行日志就不是合法 JSON 了(曾经会因此让"改密失败自动回滚"整条链路失效)。 # env.ts 用 dotenv 加载配置,而 dotenv 既不覆盖已存在的环境变量、也会读 DOTENV_CONFIG_QUIET。 if [ "$MODE" = "docker" ]; then # 镜像里也带了同一份工具(deploy/Dockerfile.linux-package 已 COPY deploy/tools) compose_cmd exec -T -e LOG_LEVEL=error -e DOTENV_CONFIG_QUIET=true server node tools/admin-cli.mjs "$@" else [ -x "$NODE_BIN" ] || die "缺少 Node 运行时(${NODE_BIN})" LOG_LEVEL=error DOTENV_CONFIG_QUIET=true "$NODE_BIN" "${TOOLS_DIR}/admin-cli.mjs" "$@" fi } cmd_password() { local target="" class_code="" do_list=0 while [ $# -gt 0 ]; do case "$1" in --list) do_list=1; shift ;; --class) class_code="${2:-}"; shift 2 ;; -h|--help) info "用法:classhelper password [用户名] [--list] [--class <班级码>]"; return 0 ;; -*) die "password: 未知参数 $1" ;; *) target="$1"; shift ;; esac done need_root "password" require_install if [ "$do_list" = "1" ]; then admin_cli list return 0 fi local key="${target:-admin}" label="${target:-admin}" if [ -n "$class_code" ]; then key="class:${class_code}" label="班级 ${class_code}" fi printf '%s现在修改 %s 的密码%s\n' "$C_BOLD" "$label" "$C_RESET" printf '%s提示:输入不回显;密码不会出现在命令行、进程列表或日志里。%s\n\n' "$C_DIM" "$C_RESET" # 有界循环:每次尝试都计数,且 stdin 读完(EOF)立刻退出 —— # 否则管道喂入不合规的密码时会一直重读空值,表现为"卡住不动"。 local pw1='' pw2='' problems='' tries=0 accepted=0 while [ "$tries" -lt 3 ] && [ "$accepted" = "0" ]; do tries=$((tries + 1)) read_secret "新密码:" pw1 if [ -z "$pw1" ]; then if [ "${READ_SECRET_EOF:-0}" = "1" ]; then die "读不到输入(标准输入已结束)。自动化请用管道:printf '%s\\n%s\\n' \"\$PW\" \"\$PW\" | classhelper password ${key}" fi warn "密码不能为空,请重试" continue fi problems="$(password_problems "$pw1" "${target:-admin}")" if [ -n "$problems" ]; then warn "密码不合规:${problems}" continue fi read_secret "再输入一次确认:" pw2 if [ "$pw1" != "$pw2" ]; then warn "两次输入不一致,请重来" continue fi accepted=1 done if [ "$accepted" != "1" ]; then audit password "$key" rejected die "连续 3 次输入不合规,已退出(密码未改动)" fi # 改前备份原哈希:失败或复核不过时按这份文件回滚(只含用户名 + bcrypt 哈希,权限 600)。 # 只取"以 { 开头的那一行" —— 即使将来日志噪声绕过 LOG_LEVEL,落盘的也仍是合法 JSON。 ensure_backup_dir local stamp; stamp="$(date '+%Y%m%d-%H%M%S')" local hash_backup="${BACKUP_DIR}/password-${stamp}.json" local had_hash=1 if admin_cli dump-hash "$key" 2>/dev/null | grep -E '^\{' | tail -n 1 >"$hash_backup"; then chmod 600 "$hash_backup" had_hash=0 info "原密码哈希已备份到 ${hash_backup}(权限 600)" else rm -f "$hash_backup" warn "未能读取原哈希(账号可能尚未设置密码),改后将无法自动回滚" fi local result=1 if printf '%s\n' "$pw1" | admin_cli set-password "$key"; then result=0; fi unset pw1 pw2 if [ "$result" != "0" ]; then warn "写入失败" if [ "$had_hash" = "0" ]; then info "正在用备份回滚原密码哈希 ..." if admin_cli restore-hash "$hash_backup" >/dev/null 2>&1; then log "已回滚到原密码" else audit password "$key" rollback-failed die "回滚失败!请用 classhelper backup restore 恢复数据(备份文件:${hash_backup})" fi fi audit password "$key" failed exit 1 fi audit password "$key" ok log "密码已更新:${label}" printf '%s已登录的会话不受影响(JWT 仍有效);需要让所有会话立即失效请执行 classhelper key rotate。%s\n' "$C_DIM" "$C_RESET" } # ---------------------------------------------------------------- 9. config / port / key # 把值写进配置项:值经临时文件传给 awk,**绝不进 argv**(argv 任何用户都能从 ps 看到) config_write_value() { local key="$1" value="$2" tmp_value tmp_file tmp_value="$(mktemp)"; tmp_file="$(mktemp)" chmod 600 "$tmp_value" "$tmp_file" printf '%s' "$value" >"$tmp_value" awk -v k="$key" -v vf="$tmp_value" ' BEGIN { while ((getline line < vf) > 0) v = (v == "" ? line : v "\n" line); close(vf) } $0 ~ "^[[:space:]]*" k "=" && !done { print k "=" v; done = 1; next } { print } END { if (!done) printf "\n%s=%s\n", k, v } ' "$CONFIG_FILE" >"$tmp_file" mv "$tmp_file" "$CONFIG_FILE" rm -f "$tmp_value" chmod 600 "$CONFIG_FILE" } restart_after_change() { if [ "${CLASSHELPER_ASSUME_YES:-0}" = "1" ]; then svc_restart || true if wait_ready 90; then log "服务已重启并就绪 ✅"; else warn "服务重启后未通过探针,请 classhelper logs -n 80"; fi return 0 fi local reply="" read -r -p "现在重启服务让配置生效?[y/N] " reply || true case "$reply" in y|Y|yes|YES) svc_restart || true if wait_ready 90; then log "服务已重启并就绪 ✅"; else warn "服务重启后未通过探针,请 classhelper logs -n 80"; fi ;; *) info "已跳过重启:配置在下次重启后生效(classhelper restart)" ;; esac } cmd_config() { local sub="${1:-show}"; shift || true case "$sub" in get) local key="${1:-}"; [ -n "$key" ] || die "用法:classhelper config get " local value; value="$(cfg_get "$key")" if is_secret_key "$key"; then mask_value "$value"; else printf '%s\n' "$value"; fi ;; show) require_install [ -f "$CONFIG_FILE" ] || die "配置文件不存在:${CONFIG_FILE}" local reveal=0 [ "${1:-}" = "--reveal" ] && reveal=1 printf '%s%s%s\n' "$C_BOLD" "$CONFIG_FILE" "$C_RESET" local line key value while IFS= read -r line; do case "$line" in ''|'#'*) printf '%s\n' "$line"; continue ;; esac key="${line%%=*}" value="${line#*=}" if [ "$reveal" = "0" ] && is_secret_key "$key"; then printf '%s=%s\n' "$key" "$(mask_value "$(printf '%s' "$value" | sed -e 's/^"//' -e 's/"$//')")" else printf '%s\n' "$line" fi done <"$CONFIG_FILE" [ "$reveal" = "0" ] && printf '\n%s敏感值已打码;要看原文用 config show --reveal(会在终端留痕)%s\n' "$C_DIM" "$C_RESET" ;; edit) need_root "config edit" require_install cmd_backup --quiet --note "config-edit-前" || true "${EDITOR:-vi}" "$CONFIG_FILE" chmod 600 "$CONFIG_FILE" audit config-edit "$CONFIG_FILE" ok log "已保存(权限已校正为 600)" restart_after_change ;; set) need_root "config set" require_install local key="${1:-}" value="${2:-}" [ -n "$key" ] || die "用法:classhelper config set (敏感项会自动改为交互输入)" [[ "$key" =~ ^[A-Z][A-Z0-9_]*$ ]] || die "KEY 只能是大写字母、数字与下划线" if is_secret_key "$key"; then # 敏感值不接受命令行传参:argv 会进 shell history,也会出现在 ps 里 local secret='' read_secret "输入 ${key} 的新值(不回显):" secret [ -n "$secret" ] || die "值不能为空" value="$secret" fi cmd_backup --quiet --note "config-set-前" || true config_write_value "$key" "$(quote_env_value "$value")" unset value secret audit config-set "$key" ok log "已更新 ${key}" restart_after_change ;; *) die "用法:classhelper config get|set|show|edit" ;; esac } cmd_port() { need_root "port" require_install local new="${1:-}" if [ -z "$new" ]; then printf '当前端口:%s\n' "$(current_port)" return 0 fi [[ "$new" =~ ^[0-9]+$ ]] || die "端口必须是数字" [ "$new" -ge 1 ] && [ "$new" -le 65535 ] || die "端口范围 1-65535" local old; old="$(current_port)" if [ "$old" = "$new" ]; then info "端口已经是 ${new},无需修改"; return 0; fi if [ -n "$(listen_on_port "$new")" ]; then die "端口 ${new} 已被占用(ss -ltnp 查看)" fi CLASSHELPER_ASSUME_YES=1 cmd_config set PORT "$new" audit port "${old}->${new}" ok if command -v firewall-cmd >/dev/null 2>&1; then warn "记得放行防火墙:firewall-cmd --add-port=${new}/tcp --permanent && firewall-cmd --reload" elif command -v ufw >/dev/null 2>&1; then warn "记得放行防火墙:ufw allow ${new}/tcp" fi } cmd_key() { local sub="${1:-}" [ "$sub" = "rotate" ] || die "用法:classhelper key rotate" need_root "key rotate" require_install printf '%s轮换 JWT_SECRET 会让所有已登录用户立即失效(需要重新登录)。%s\n' "$C_YELLOW" "$C_RESET" if [ "${CLASSHELPER_ASSUME_YES:-0}" != "1" ]; then local reply="" read -r -p "确认轮换?输入 yes 继续:" reply || true [ "$reply" = "yes" ] || { info "已取消"; return 0; } fi cmd_backup --quiet --note "key-rotate-前" || true local new_secret if [ -x "$NODE_BIN" ]; then new_secret="$("$NODE_BIN" -e 'process.stdout.write(require("node:crypto").randomBytes(48).toString("hex"))')" else new_secret="$(head -c 48 /dev/urandom | od -An -tx1 | tr -d ' \n')" fi # 直接写配置而不是走 `config set`:后者对敏感键强制交互输入(防 argv 泄漏), # 而这里的值是本命令自己生成的随机串,走 config_write_value 同样不进 argv。 config_write_value JWT_SECRET "$(quote_env_value "$new_secret")" unset new_secret audit key-rotate - ok log "已轮换 JWT_SECRET" restart_after_change } # ---------------------------------------------------------------- 10. upgrade / rollback asset_arch() { case "$(uname -m)" in x86_64|amd64) printf 'x64' ;; aarch64|arm64) printf 'arm64' ;; *) uname -m ;; esac } github_latest_tag() { command -v curl >/dev/null 2>&1 || return 1 local json json="$(curl "${CURL_OPTS[@]}" "${GITHUB_API}/releases/latest" 2>/dev/null)" || return 1 printf '%s' "$json" | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1 } resolve_download() { # $1=版本(可空) $2=url(可空);输出下载地址 local want="$1" url="$2" tag ver if [ -n "$url" ]; then printf '%s' "$url"; return 0; fi if [ -n "$want" ]; then tag="v${want#v}"; else tag="$(github_latest_tag)" || true; fi [ -n "$tag" ] || return 1 ver="${tag#v}" printf '%s/releases/download/%s/classhelper-server-linux-%s-%s.tar.gz' \ "$GITHUB_RELEASES_PAGE" "$tag" "$(asset_arch)" "$ver" } download_to() { # $1=url $2=目标文件 case "$1" in file://*|/*) cp -f "${1#file://}" "$2" ;; *) curl "${CURL_OPTS_LONG[@]}" -o "$2" "$1" ;; esac } verify_sha256() { # $1=文件 $2=期望值(可空) local file="$1" want="${2:-}" got if [ -z "$want" ]; then warn "未提供 sha256,跳过哈希校验(内网分发也建议带上 --sha256)" return 0 fi got="$(sha256sum "$file" | awk '{print $1}')" [ "$got" = "$want" ] || { warn "哈希不匹配:期望 ${want},实际 ${got}"; return 1; } return 0 } # 用解出来的新文件替换程序目录;data / logs / runtime / 配置一律不动。 # 用「改名 + 就位」而不是 rm + cp:升级中正在运行的就是本脚本(bin/classhelper), # rm 掉再创建同名文件会让 bash 读到半个脚本;改名后运行中的进程继续读旧 inode,安全。 replace_program_files() { # $1=解压出来的目录 local src="$1" item # 清理上次中途失败留下的残留 for item in "${INSTALL_DIR}"/.old-*; do [ -e "$item" ] && rm -rf "$item" done # .cross-built 是"这个包不在 Linux 上打的"标记:先删掉,新版包里没有它就不再提示 rm -f "${INSTALL_DIR}/.cross-built" for item in server web node_modules tools bin package.json VERSION README.txt Dockerfile .cross-built; do [ -e "${src}/${item}" ] || continue [ -e "${INSTALL_DIR}/${item}" ] && mv "${INSTALL_DIR}/${item}" "${INSTALL_DIR}/.old-${item}" mv "${src}/${item}" "${INSTALL_DIR}/${item}" rm -rf "${INSTALL_DIR:?}/.old-${item}" done chmod +x "${INSTALL_DIR}/bin/classhelper" 2>/dev/null || true # 安装目录里的 .env 必须是指向配置真身的软链(Web 端「一键切换数据库」依赖它) if [ -e "${INSTALL_DIR}/.env" ] && [ ! -L "${INSTALL_DIR}/.env" ]; then rm -f "${INSTALL_DIR}/.env"; fi [ -L "${INSTALL_DIR}/.env" ] || ln -sfn "$CONFIG_FILE" "${INSTALL_DIR}/.env" } extract_package() { # $1=tar.gz $2=目标目录 mkdir -p "$2" tar -xzf "$1" -C "$2" --strip-components=1 } # 与安装器写入的 .installed.json 保持同一份扁平格式(classhelper 自己用 sed 解析) write_meta() { # $1=版本 $2=来源 $3=sha256 cat >"$META_FILE" <] [--sha256 H] [--check] [--force]"; return 0 ;; *) die "upgrade: 未知参数 $1" ;; esac done need_root "upgrade" require_install local current; current="$(current_version)" local target="$want" if [ -z "$url" ] && [ -z "$target" ]; then target="$(github_latest_tag)" || true if [ -z "$target" ]; then warn "查不到 GitHub Release(本机可能没有外网,或匿名查询被限流)" info "可手工指定:classhelper upgrade --url <安装包地址> [--sha256 <哈希>]" return 0 fi target="${target#v}" fi printf '当前版本:%s\n' "${current:-未知}" [ -n "$target" ] && printf '目标版本:%s\n' "$target" if [ "$check_only" = "1" ]; then if [ -z "$url" ] && [ -n "$target" ] && [ -n "$current" ]; then if version_gt "$current" "$target"; then log "有新版本可用:${current} → ${target}" elif [ "$current" = "$target" ]; then log "已经是最新版本(${current})" else info "远端版本 ${target} 不高于本机 ${current}(本机可能是自建版本)" fi printf '发布页:%s\n' "$GITHUB_RELEASES_PAGE" fi [ -n "$url" ] && printf '安装包:%s\n' "$url" return 0 fi if [ -z "$url" ] && [ -n "$current" ] && [ -n "$target" ] && [ "$current" = "$target" ] && [ "$force" != "1" ]; then log "已经是最新版本(${current})。要强制重装同一版本:--force" return 0 fi local resolved; resolved="$(resolve_download "$target" "$url")" || die "无法确定下载地址" info "下载:${resolved}" local tmp; tmp="$(mktemp -d)" local pkg="${tmp}/classhelper-server.tar.gz" download_to "$resolved" "$pkg" || { rm -rf "$tmp"; die "下载失败:${resolved}"; } verify_sha256 "$pkg" "$sha" || { rm -rf "$tmp"; die "安装包校验失败,已中止(未改动任何文件)"; } info "sha256 校验通过" local stage="${tmp}/pkg" extract_package "$pkg" "$stage" || { rm -rf "$tmp"; die "解压失败:安装包格式不对"; } [ -f "${stage}/server/dist/index.js" ] || { rm -rf "$tmp"; die "安装包内容不对:缺少 server/dist/index.js"; } local new_ver; new_ver="$(tr -d '[:space:]' <"${stage}/VERSION" 2>/dev/null || echo "$target")" local pkg_sha; pkg_sha="$(sha256sum "$pkg" | awk '{print $1}')" info "备份数据与配置 ..." cmd_backup --quiet --note "upgrade-${new_ver}-前" || warn "备份未完成,继续升级" ensure_backup_dir local rollback_file="${BACKUP_DIR}/rollback/$(date '+%Y%m%d-%H%M%S')-${current:-unknown}.tar.gz" info "快照当前程序文件:${rollback_file}" snapshot_program_files "$rollback_file" info "停止服务 ..." svc_stop || warn "停止服务返回非 0(可能本来就没在运行)" replace_program_files "$stage" svc_start || true if ! wait_ready 90; then warn "新版本启动失败,正在自动回滚到 ${current:-上一版本} ..." print_recent_logs svc_stop || true if extract_package "$rollback_file" "${tmp}/rollback" && replace_program_files "${tmp}/rollback"; then svc_start || true if wait_ready 90; then audit upgrade "${current}->${new_ver}" rolled-back rm -rf "$tmp" die "升级失败,已回滚到 ${current}(服务已恢复)。失败原因见上面的日志" fi fi audit upgrade "${current}->${new_ver}" rollback-failed rm -rf "$tmp" die "升级失败且回滚后仍未就绪,请人工介入:classhelper logs -n 100" fi write_meta "$new_ver" "$resolved" "$pkg_sha" audit upgrade "${current}->${new_ver}" ok rm -rf "$tmp" log "升级完成:${current} → ${new_ver} ✅" printf '%s注意:升级不会回滚数据库结构。若新版执行过迁移,回滚程序前请确认兼容性(必要时 classhelper backup restore)。%s\n' "$C_DIM" "$C_RESET" } cmd_rollback() { need_root "rollback" require_install local to="" list=0 while [ $# -gt 0 ]; do case "$1" in --list) list=1; shift ;; --to) to="${2:-}"; shift 2 ;; *) die "rollback: 未知参数 $1" ;; esac done local dir="${BACKUP_DIR}/rollback" if [ "$list" = "1" ] || [ -z "$to" ]; then printf '%s可回滚的程序快照(%s):%s\n' "$C_BOLD" "$dir" "$C_RESET" ls -1t "$dir" 2>/dev/null | head -20 || true [ "$list" = "1" ] && return 0 to="$(ls -1t "$dir" 2>/dev/null | head -1)" [ -n "$to" ] || die "没有可用的程序快照(执行过一次 classhelper upgrade 才会产生)" info "使用最近一份:${to}" fi local file="$to" [ -f "$file" ] || file="${dir}/${to}" [ -f "$file" ] || die "找不到快照:${to}" printf '%s回滚只替换程序文件,不会回滚数据库结构。%s\n' "$C_YELLOW" "$C_RESET" local reply="" read -r -p "确认回滚到 $(basename "$file")?输入 yes 继续:" reply || true [ "$reply" = "yes" ] || { info "已取消"; return 0; } local tmp; tmp="$(mktemp -d)" extract_package "$file" "${tmp}/pkg" || { rm -rf "$tmp"; die "快照解压失败"; } svc_stop || true replace_program_files "${tmp}/pkg" svc_start || true if wait_ready 90; then local now; now="$(current_version)" write_meta "$now" "rollback:$(basename "$file")" "" audit rollback "$(basename "$file")" ok rm -rf "$tmp" log "已回滚到 ${now} ✅" else audit rollback "$(basename "$file")" failed print_recent_logs rm -rf "$tmp" die "回滚后服务未就绪,请人工排查" fi } # ---------------------------------------------------------------- 11. 其它 cmd_logs() { local follow=0 lines=100 since="" unit="$SERVICE_NAME" while [ $# -gt 0 ]; do case "$1" in -f|--follow) follow=1; shift ;; -n) lines="${2:-100}"; shift 2 ;; --since) since="${2:-}"; shift 2 ;; --unit) unit="${2:-}"; shift 2 ;; *) die "logs: 未知参数 $1" ;; esac done if [ "$MODE" = "docker" ]; then if [ "$follow" = "1" ]; then compose_cmd logs -f --tail "$lines" else compose_cmd logs --tail "$lines"; fi return 0 fi command -v journalctl >/dev/null 2>&1 || die "本机没有 journalctl(日志在 journald 里)" local args=(-u "$unit" -n "$lines" --no-pager) [ -n "$since" ] && args+=(--since "$since") [ "$follow" = "1" ] && args+=(-f) journalctl "${args[@]}" } # 删除目录前的护栏:安装元数据是文件里读来的,绝不能因为里面的值被改坏就把系统目录删掉 safe_remove_dir() { local dir="${1:-}" case "$dir" in ''|/|/etc|/usr|/var|/opt|/bin|/sbin|/lib|/lib64|/boot|/home|/root|/tmp|/srv|/data|/dev|/proc) warn "拒绝删除系统目录:${dir}(请手工处理)" return 1 ;; esac case "$dir" in /*/*) ;; *) warn "路径太浅,拒绝删除:${dir}"; return 1 ;; esac rm -rf "$dir" } cmd_uninstall() { need_root "uninstall" local purge=0 keep_data=1 keep_config=1 keep_backup=1 assume_yes="${CLASSHELPER_ASSUME_YES:-0}" while [ $# -gt 0 ]; do case "$1" in --purge) purge=1; shift ;; --yes|-y) assume_yes=1; shift ;; *) die "uninstall: 未知参数 $1" ;; esac done if [ "$purge" = "1" ]; then keep_data=0; keep_config=0; keep_backup=0; fi if [ "$assume_yes" != "1" ] && [ "$purge" != "1" ]; then printf '%s卸载会停止服务、删除程序文件;数据与配置默认会被搬到备份目录,不会丢。%s\n\n' "$C_BOLD" "$C_RESET" local a="" read -r -p "保留配置 config.env?[Y/n] " a || true; case "$a" in n|N) keep_config=0 ;; esac read -r -p "保留数据库与数据目录?[Y/n] " a || true; case "$a" in n|N) keep_data=0 ;; esac read -r -p "保留已有备份(${BACKUP_DIR})?[Y/n] " a || true; case "$a" in n|N) keep_backup=0 ;; esac printf '\n' read -r -p "确认卸载?输入 yes 继续:" a || true [ "$a" = "yes" ] || { info "已取消"; return 0; } fi local rescue="" if [ "$keep_config" = "1" ] || [ "$keep_data" = "1" ]; then rescue="${BACKUP_DIR}/uninstall-$(date '+%Y%m%d-%H%M%S')" mkdir -p "$rescue"; chmod 700 "$rescue" if [ "$keep_config" = "1" ] && [ -f "$CONFIG_FILE" ]; then cp -p "$CONFIG_FILE" "${rescue}/config.env"; fi if [ "$keep_data" = "1" ] && [ -d "$DATA_DIR" ]; then cp -a "$DATA_DIR" "${rescue}/data"; fi info "保留内容已搬到 ${rescue}" fi info "停止并移除服务 ..." if [ "$MODE" = "docker" ]; then compose_cmd down --remove-orphans >/dev/null 2>&1 || true else systemctl disable --now "$SERVICE_NAME" >/dev/null 2>&1 || true systemctl disable --now classhelper-backup.timer >/dev/null 2>&1 || true rm -f "/etc/systemd/system/${SERVICE_NAME}.service" \ /etc/systemd/system/classhelper-backup.service \ /etc/systemd/system/classhelper-backup.timer \ /etc/logrotate.d/classhelper systemctl daemon-reload fi rm -f /etc/profile.d/classhelper.sh /usr/local/bin/classhelper /usr/share/bash-completion/completions/classhelper if [ "$keep_backup" != "1" ] && [ -d "$BACKUP_DIR" ]; then safe_remove_dir "$BACKUP_DIR" && log "备份目录已删除:${BACKUP_DIR}" fi if [ -d "$INSTALL_DIR" ]; then if [ "$keep_data" = "1" ]; then rm -rf "${INSTALL_DIR:?}/server" "${INSTALL_DIR:?}/web" "${INSTALL_DIR:?}/node_modules" \ "${INSTALL_DIR:?}/tools" "${INSTALL_DIR:?}/bin" "${INSTALL_DIR:?}/runtime" \ "${INSTALL_DIR:?}/systemd" "${INSTALL_DIR:?}/package.json" \ "${INSTALL_DIR:?}/VERSION" "${INSTALL_DIR:?}/.installed.json" "${INSTALL_DIR:?}/.env" log "程序文件已删除,数据保留在 ${INSTALL_DIR}/data" else safe_remove_dir "$INSTALL_DIR" && log "安装目录已删除:${INSTALL_DIR}" fi fi if [ -d "$INSTALL_DIR" ] && [ -z "$(ls -A "$INSTALL_DIR" 2>/dev/null || true)" ]; then rmdir "$INSTALL_DIR" 2>/dev/null || true; fi # 配置目录只在明确不保留时删(--purge 或交互里回答了 n) if [ "$keep_config" != "1" ] && [ -d "$CONFIG_DIR" ]; then safe_remove_dir "$CONFIG_DIR" && log "配置目录已删除:${CONFIG_DIR}" fi audit uninstall "$INSTALL_DIR" ok log "卸载完成${rescue:+(保留内容:${rescue})}" [ "$keep_backup" = "1" ] && info "备份仍保留在:${BACKUP_DIR}" } cmd_completion() { case "${1:-bash}" in bash) cat <<'EOF' _classhelper_complete() { local cur prev commands sub cur="${COMP_WORDS[COMP_CWORD]}" prev="${COMP_WORDS[COMP_CWORD-1]}" commands="status doctor password backup upgrade rollback start stop restart reload logs config port key uninstall version help completion" if [ "$COMP_CWORD" -eq 1 ]; then COMPREPLY=( $(compgen -W "$commands" -- "$cur") ) return 0 fi case "${COMP_WORDS[1]}" in backup) COMPREPLY=( $(compgen -W "list restore schedule" -- "$cur") ) ;; config) COMPREPLY=( $(compgen -W "get set show edit" -- "$cur") ) ;; key) COMPREPLY=( $(compgen -W "rotate" -- "$cur") ) ;; completion) COMPREPLY=( $(compgen -W "bash zsh" -- "$cur") ) ;; logs) COMPREPLY=( $(compgen -W "-f -n --since --unit" -- "$cur") ) ;; upgrade) COMPREPLY=( $(compgen -W "--check --version --url --sha256 --force" -- "$cur") ) ;; esac } complete -F _classhelper_complete classhelper EOF ;; zsh) cat <<'EOF' #compdef classhelper _classhelper() { local -a commands commands=(status doctor password backup upgrade rollback start stop restart reload logs config port key uninstall version help completion) _arguments '1:command:($commands)' '*::arg:->args' } _classhelper "$@" EOF ;; *) die "用法:classhelper completion bash|zsh" ;; esac } cmd_help() { cat < [参数] ${C_BOLD}查看与诊断${C_RESET} status [--json] 服务状态、版本、端口、数据库、各类路径(--json 便于接监控) doctor 完整体检(权限 / Node / 服务单元 / 探针 / 数据库 / 磁盘 / 备份) logs [-f] [-n N] [--since T] 查看日志(-f 实时跟随;--unit classhelper-backup 看定时备份) ${C_BOLD}账号${C_RESET} password [用户名] 修改密码(默认 admin);交互输入、不回显、不进历史 password --list 列出可登录账号与班级账号(含是否已设密码) password --class <班级码> 修改班级密码(学生端用「班级码 + 班级密码」登录) ${C_BOLD}备份与恢复${C_RESET} backup [--note 说明] [--keep N] 备份配置 + 数据库到 ${BACKUP_DIR} backup list 列出备份与程序快照 backup restore <备份目录名> 用某个备份覆盖当前配置与数据(会先停服务) backup schedule daily|weekly|off 启用 / 关闭定时备份(systemd timer) ${C_BOLD}升级与回滚${C_RESET} upgrade --check 检查 GitHub Release 上有没有新版本 upgrade 升级到最新版(下载 → 校验 sha256 → 备份 → 替换 → 探针,失败自动回滚) upgrade --version 1.2.0 升级到指定版本 upgrade --url [--sha256 H] [--force] 内网离线分发 / 强制重装同版本 rollback [--list] [--to X] 回滚程序文件到某个快照 ${C_BOLD}服务与配置${C_RESET} start | stop | restart | reload 封装 systemctl / docker compose config show [--reveal] 查看配置(敏感值默认打码) config get 读取某项(敏感项自动打码) config set 修改某项(敏感项改为交互输入),改完询问是否重启 config edit 用 \$EDITOR 编辑配置(改前自动备份) port [新端口] 查看或修改服务端口 key rotate 轮换 JWT_SECRET(所有用户需重新登录) ${C_BOLD}其它${C_RESET} version 显示版本 help 显示本帮助 completion bash|zsh 生成补全脚本(chmod +x 后 source,或装到 completions 目录) ${C_DIM}敏感操作需要 root:sudo classhelper <子命令> 审计日志:${AUDIT_LOG}${C_RESET} EOF } # ---------------------------------------------------------------- 12. 入口 main() { local cmd="${1:-help}" shift || true case "$cmd" in # 不需要 root 的三个 help|-h|--help) cmd_help ;; version|-v|--version) printf 'classhelper %s(%s 形态)\n' "$(current_version)" "$MODE" ;; completion) cmd_completion "$@" ;; status) cmd_status "$@" ;; doctor) cmd_doctor "$@" ;; password) cmd_password "$@" ;; backup) cmd_backup "$@" ;; upgrade) cmd_upgrade "$@" ;; rollback) cmd_rollback "$@" ;; start|stop|restart|reload) need_root "$cmd" require_install case "$cmd" in start) svc_start ;; stop) svc_stop ;; restart) svc_restart ;; # 服务端没有 SIGHUP 处理,reload 与 restart 同义(保持命令面一致,并说明清楚) reload) info "服务端不支持热重载,按重启处理"; svc_restart ;; esac if [ "$cmd" = "stop" ]; then log "已停止" else if wait_ready 90; then log "服务已就绪 ✅(http://$(first_ip):$(current_port)/)" else print_recent_logs; die "服务在 90 秒内没有通过探针"; fi fi audit "$cmd" "$SERVICE_NAME" ok ;; logs) cmd_logs "$@" ;; config) cmd_config "$@" ;; port) cmd_port "$@" ;; key) cmd_key "$@" ;; uninstall) cmd_uninstall "$@" ;; *) warn "未知子命令:${cmd}"; printf '\n'; cmd_help; exit 2 ;; esac } main "$@"