- 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
311 lines
10 KiB
Bash
311 lines
10 KiB
Bash
#!/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-simadmin,Linux 为 ~/.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
|