Files
multi-simadmin/scripts/install.sh
T
chick 3c5971e167 feat(installer): support Linux hosts with XDG data root and ss port probe
- platform gate accepts Darwin and Linux; other platforms are refused
- default install root follows each platform's convention
  (~/Library/Application Support vs ~/.local/share)
- LAN IP discovery uses ip -4/hostname -I on Linux, ipconfig on macOS
- port occupancy checks fall back from lsof to ss
- API process gets MULTI_SIMADMIN_SECRET_BACKEND pinned per platform
- README documents the per-platform secret storage
2026-09-07 01:36:04 +08:00

311 lines
10 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/bin/sh
set -eu
umask 077
REPO_URL=${MULTI_SIMADMIN_REPO_URL:-https://gitea.chickliu.fun/Hermes/multi-simadmin.git}
PNPM_VERSION="11.13.0"
PLATFORM=$(uname -s)
default_app_root() {
case "$PLATFORM" in
Darwin) printf '%s' "$HOME/Library/Application Support/multi-simadmin" ;;
Linux) printf '%s' "${XDG_DATA_HOME:-$HOME/.local/share}/multi-simadmin" ;;
*) printf '' ;;
esac
}
APP_ROOT=${MULTI_SIMADMIN_HOME:-"$(default_app_root)"}
SOURCE_DIR="$APP_ROOT/source"
DATA_ROOT="$APP_ROOT/data"
RUNTIME_DIR="$APP_ROOT/run"
LOG_DIR="$APP_ROOT/logs"
TOKEN_FILE="$APP_ROOT/gateway-token"
API_PID_FILE="$RUNTIME_DIR/api.pid"
GATEWAY_PID_FILE="$RUNTIME_DIR/gateway.pid"
API_LOG="$LOG_DIR/api.log"
GATEWAY_LOG="$LOG_DIR/gateway.log"
say() { printf '%s\n' "$*"; }
die() { printf '错误:%s\n' "$*" >&2; exit 1; }
usage() {
cat <<'EOF'
Multi SimAdmin 一键安装与服务管理
用法:install.sh [install|start|stop|restart|status|uninstall|help]
install 下载源码、安装依赖、构建并启动(默认)
start 启动 API(127.0.0.1:8790) 与 LAN Gateway(*:8788)
stop 安全停止服务
restart 停止后重新启动
status 显示进程、端口与健康状态
uninstall 卸载程序,默认保留数据、令牌和日志
环境变量:
MULTI_SIMADMIN_HOME 安装根目录(默认 macOS 为 ~/Library/Application Support/multi-simadminLinux 为 ~/.local/share/multi-simadmin
MULTI_SIMADMIN_ALLOWED_HOSTS 额外允许的 LAN 主机名/IP,逗号分隔
MULTI_SIMADMIN_REPO_URL 源码仓库地址
EOF
}
require_platform() {
case "$PLATFORM" in
Darwin|Linux) ;;
*) die "当前生产版仅支持 macOS 或 Linux。" ;;
esac
}
require_commands() {
for command in git node corepack curl lsof install; do
command -v "$command" >/dev/null 2>&1 || die "缺少命令:$command"
done
}
pid_alive() { [ -f "$1" ] && pid=$(cat "$1" 2>/dev/null) && [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; }
pid_owned() {
file=$1
kind=$2
pid_alive "$file" || return 1
pid=$(cat "$file")
command_line=$(ps -p "$pid" -o command= 2>/dev/null || true)
case "$kind:$command_line" in
api:*"$SOURCE_DIR/apps/api/src/production-cli.ts"*) return 0 ;;
gateway:*"$SOURCE_DIR/apps/api/src/production-gateway-cli.ts"*) return 0 ;;
*) return 1 ;;
esac
}
port_free() {
if command -v lsof >/dev/null 2>&1; then
! lsof -nP -iTCP:"$1" -sTCP:LISTEN >/dev/null 2>&1
elif command -v ss >/dev/null 2>&1; then
! ss -ltn "( sport = :$1 )" 2>/dev/null | grep -q ":$1 "
else
die "缺少 lsof 或 ss,无法检查端口占用"
fi
}
lan_ip() {
if [ "$PLATFORM" = Darwin ]; then
for interface in en0 en1; do
value=$(ipconfig getifaddr "$interface" 2>/dev/null || true)
if [ -n "$value" ]; then printf '%s' "$value"; return; fi
done
else
value=$(ip -4 addr show scope global 2>/dev/null | awk '/inet /{print $2; exit}' | cut -d/ -f1)
if [ -n "${value:-}" ]; then printf '%s' "$value"; return; fi
value=$(hostname -I 2>/dev/null | awk '{print $1}')
if [ -n "${value:-}" ]; then printf '%s' "$value"; return; fi
fi
printf '127.0.0.1'
}
allowed_hosts() {
host=$(hostname 2>/dev/null || true)
ip=$(lan_ip)
extra=${MULTI_SIMADMIN_ALLOWED_HOSTS:-}
value="127.0.0.1,localhost,$ip"
[ -n "$host" ] && value="$value,$host"
[ -n "$extra" ] && value="$value,$extra"
printf '%s' "$value"
}
create_token() {
if [ -e "$TOKEN_FILE" ] || [ -L "$TOKEN_FILE" ]; then
[ -f "$TOKEN_FILE" ] && [ ! -L "$TOKEN_FILE" ] || die "Gateway token 必须是普通文件且不能是符号链接"
chmod 600 "$TOKEN_FILE"
return
fi
tmp="$TOKEN_FILE.tmp.$$"
trap 'rm -f "$tmp"' EXIT HUP INT TERM
node -e "process.stdout.write(require('node:crypto').randomBytes(32).toString('hex'))" >"$tmp"
install -m 600 "$tmp" "$TOKEN_FILE"
rm -f "$tmp"
trap - EXIT HUP INT TERM
}
build_source() {
if [ -e "$SOURCE_DIR" ]; then
[ -d "$SOURCE_DIR/.git" ] || die "安装目录已存在但不是有效源码仓库:$SOURCE_DIR"
say "检测到已有源码;为避免覆盖本地修改,不自动 pull。"
else
mkdir -p "$APP_ROOT"
git clone --depth 1 "$REPO_URL" "$SOURCE_DIR"
fi
cd "$SOURCE_DIR"
corepack pnpm --version | grep -qx "$PNPM_VERSION" || corepack prepare "pnpm@$PNPM_VERSION" --activate
corepack pnpm install --frozen-lockfile
corepack pnpm --filter @multi-simadmin/web build
[ -f "$SOURCE_DIR/apps/web/dist/index.html" ] || die "Web 构建产物缺失"
node -e "require('better-sqlite3');" || die "better-sqlite3 无法加载"
}
api_probe() {
TOKEN_FILE="$TOKEN_FILE" node -e '
const fs=require("node:fs");
const token=fs.readFileSync(process.env.TOKEN_FILE,"utf8").trim();
fetch("http://127.0.0.1:8790/healthz",{headers:{"x-multi-simadmin-gateway-token":token},signal:AbortSignal.timeout(2000)})
.then(r=>{if(!r.ok)process.exit(1);return r.json()}).then(v=>process.exit(v.status==="ok"?0:1)).catch(()=>process.exit(1));
' >/dev/null 2>&1
}
gateway_probe() { curl -fsS --max-time 2 http://127.0.0.1:8788/healthz >/dev/null 2>&1; }
wait_for() {
label=$1
shift
count=0
until "$@"; do
count=$((count + 1))
[ "$count" -lt 40 ] || die "$label 启动后健康检查失败,请查看 $LOG_DIR"
sleep 1
done
}
start_service() {
require_platform
require_commands
[ -f "$SOURCE_DIR/apps/web/dist/index.html" ] || die "尚未安装,请先运行 install"
[ -f "$TOKEN_FILE" ] && [ ! -L "$TOKEN_FILE" ] || die "Gateway token 缺失或不是安全的普通文件,请重新运行 install"
chmod 600 "$TOKEN_FILE"
mkdir -p "$DATA_ROOT" "$RUNTIME_DIR" "$LOG_DIR"
chmod 700 "$APP_ROOT" "$DATA_ROOT" "$RUNTIME_DIR" "$LOG_DIR"
api_running=false
gateway_running=false
pid_owned "$API_PID_FILE" api && api_running=true
pid_owned "$GATEWAY_PID_FILE" gateway && gateway_running=true
if [ "$api_running" = true ] && [ "$gateway_running" = true ]; then
say "Multi SimAdmin 已在运行。"
return
fi
if [ "$api_running" = true ] || [ "$gateway_running" = true ]; then
die "检测到部分服务仍在运行;为避免丢失进程所有权,请先运行 stop 后再 start。"
fi
rm -f "$API_PID_FILE" "$GATEWAY_PID_FILE"
port_free 8790 || die "端口 8790 已被其他进程占用,不会覆盖。"
port_free 8788 || die "端口 8788 已被其他进程占用,不会覆盖。"
token=$(cat "$TOKEN_FILE")
[ "${#token}" -ge 32 ] || die "Gateway token 无效"
cd "$SOURCE_DIR"
started_api=false
started_gateway=false
rollback_failed=false
rollback_process() {
file=$1
kind=$2
started=$3
[ "$started" = true ] || return
pid_owned "$file" "$kind" || return
pid=$(cat "$file")
kill -TERM "$pid" 2>/dev/null || true
count=0
while kill -0 "$pid" 2>/dev/null; do
count=$((count + 1))
if [ "$count" -ge 15 ]; then
rollback_failed=true
say "警告:$kind 启动回滚未完成,保留 PID 文件 $file 以便人工停止。"
return
fi
sleep 1
done
rm -f "$file"
}
rollback_start() {
rollback_process "$GATEWAY_PID_FILE" gateway "$started_gateway"
rollback_process "$API_PID_FILE" api "$started_api"
[ "$rollback_failed" = false ] || say "启动失败后仍有进程可能存活;请运行 status 并检查日志。"
}
trap 'rollback_start' EXIT HUP INT TERM
MULTI_SIMADMIN_DATA_ROOT="$DATA_ROOT" \
MULTI_SIMADMIN_DATABASE_PATH="$DATA_ROOT/control-plane.sqlite3" \
MULTI_SIMADMIN_SECRET_BACKEND="$( [ "$PLATFORM" = Darwin ] && printf macos-keychain || printf secret-file )" \
MULTI_SIMADMIN_GATEWAY_TOKEN="$token" \
MULTI_SIMADMIN_WEB_DIST="$SOURCE_DIR/apps/web/dist" \
API_HOST=127.0.0.1 API_PORT=8790 \
nohup node --import tsx "$SOURCE_DIR/apps/api/src/production-cli.ts" >>"$API_LOG" 2>&1 &
printf '%s\n' "$!" >"$API_PID_FILE"
started_api=true
wait_for API api_probe
MULTI_SIMADMIN_GATEWAY_TOKEN="$token" \
MULTI_SIMADMIN_CUTOVER_ACK='I ACKNOWLEDGE MULTI-SIMADMIN OWNS PORT 8788' \
MULTI_SIMADMIN_GATEWAY_HOST=0.0.0.0 \
MULTI_SIMADMIN_GATEWAY_ALLOWED_HOSTS="$(allowed_hosts)" \
CANARY_DIST_DIR="$SOURCE_DIR/apps/web/dist" CANARY_UPSTREAM_PORT=8790 \
nohup node --import tsx "$SOURCE_DIR/apps/api/src/production-gateway-cli.ts" >>"$GATEWAY_LOG" 2>&1 &
printf '%s\n' "$!" >"$GATEWAY_PID_FILE"
started_gateway=true
wait_for Gateway gateway_probe
trap - EXIT HUP INT TERM
say "已启动:http://$(lan_ip):8788/fleet"
}
stop_one() {
file=$1
kind=$2
if [ ! -f "$file" ]; then return; fi
if ! pid_owned "$file" "$kind"; then
rm -f "$file"
die "$kind PID 记录与进程身份不符,已拒绝发送信号。"
fi
pid=$(cat "$file")
kill -TERM "$pid"
count=0
while kill -0 "$pid" 2>/dev/null; do
count=$((count + 1))
[ "$count" -lt 15 ] || die "$kind 未在 15 秒内停止,请人工检查 PID $pid。"
sleep 1
done
rm -f "$file"
}
stop_service() {
stop_one "$GATEWAY_PID_FILE" gateway
stop_one "$API_PID_FILE" api
say "服务已停止。"
}
status_service() {
if pid_owned "$API_PID_FILE" api; then say "API: running"; else say "API: stopped"; fi
if pid_owned "$GATEWAY_PID_FILE" gateway; then
say "Gateway: running"
if gateway_probe; then say "Health: ok"; else say "Health: unavailable"; fi
else
say "Gateway: stopped"
say "Health: unavailable"
fi
if command -v lsof >/dev/null 2>&1; then
if port_free 8789; then say "Canary 8789: closed"; else say "Canary 8789: occupied (非本安装器启动)"; fi
else
say "Canary 8789: unknown (缺少 lsof)"
fi
[ -f "$DATA_ROOT/control-plane.sqlite3" ] && say "Database: preserved at $DATA_ROOT/control-plane.sqlite3" || say "Database: not created"
}
install_app() {
require_platform
require_commands
mkdir -p "$APP_ROOT" "$DATA_ROOT" "$RUNTIME_DIR" "$LOG_DIR"
chmod 700 "$APP_ROOT" "$DATA_ROOT" "$RUNTIME_DIR" "$LOG_DIR"
build_source
create_token
start_service
}
uninstall_app() {
stop_service
if [ -d "$SOURCE_DIR" ]; then rm -rf "$SOURCE_DIR"; fi
rm -f "$API_PID_FILE" "$GATEWAY_PID_FILE"
say "程序已卸载;默认保留数据、Gateway token 和日志:$APP_ROOT"
}
command=${1:-install}
case "$command" in
install) install_app ;;
start) start_service ;;
stop) stop_service ;;
restart) stop_service; start_service ;;
status) status_service ;;
uninstall) uninstall_app ;;
help|-h|--help) usage ;;
*) usage >&2; exit 2 ;;
esac