feat(auth): allow trusted remote bootstrap and add installer
This commit is contained in:
@@ -5,4 +5,5 @@ config.json
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
coverage/
|
coverage/
|
||||||
|
apps/web/dist/
|
||||||
.hermes/
|
.hermes/
|
||||||
|
|||||||
@@ -13,7 +13,40 @@
|
|||||||
- 保留原始 SimAdmin 页面 iframe 嵌入;若目标站禁止 iframe,可一键在原站打开。
|
- 保留原始 SimAdmin 页面 iframe 嵌入;若目标站禁止 iframe,可一键在原站打开。
|
||||||
- 敏感配置留在本地 `config.json`,仓库只提交 `config.example.json`。
|
- 敏感配置留在本地 `config.json`,仓库只提交 `config.example.json`。
|
||||||
|
|
||||||
## 快速开始
|
## 一键安装(macOS)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://gitea.chickliu.fun/Hermes/multi-simadmin/raw/branch/main/scripts/install.sh | sh
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本会从 Gitea 下载源码,固定使用 pnpm `11.13.0` 安装依赖并构建 Web,然后启动:
|
||||||
|
|
||||||
|
- LAN Gateway:`0.0.0.0:8788`
|
||||||
|
- Control-plane API:`127.0.0.1:8790`
|
||||||
|
- Canary Gateway:`8789` 保持关闭
|
||||||
|
|
||||||
|
安装完成后按终端输出访问 `http://<本机局域网 IP>:8788/fleet`。首次密码可在当前管理台直接设置。Gateway 默认监听所有网络接口,因此必须确保主机仅接入可信内网或已通过防火墙限制 8788 的来源;HTTP 部署不得直接暴露到公网,公网开放必须由前置代理提供 HTTPS 和访问控制。
|
||||||
|
|
||||||
|
> 当前生产秘密存储使用 macOS Keychain,因此一键安装脚本暂只支持 macOS。脚本不会覆盖已有源码目录或数据库,也不会占用已被其他进程监听的 8788/8790 端口。
|
||||||
|
|
||||||
|
### 服务管理
|
||||||
|
|
||||||
|
下载脚本后可执行完整生命周期命令:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://gitea.chickliu.fun/Hermes/multi-simadmin/raw/branch/main/scripts/install.sh -o /tmp/multi-simadmin-install.sh
|
||||||
|
sh /tmp/multi-simadmin-install.sh status
|
||||||
|
sh /tmp/multi-simadmin-install.sh restart
|
||||||
|
sh /tmp/multi-simadmin-install.sh stop
|
||||||
|
sh /tmp/multi-simadmin-install.sh start
|
||||||
|
sh /tmp/multi-simadmin-install.sh uninstall
|
||||||
|
```
|
||||||
|
|
||||||
|
`uninstall` 默认只移除程序源码,保留数据库、Gateway token 和日志。默认安装位置为 `~/Library/Application Support/multi-simadmin`,可通过 `MULTI_SIMADMIN_HOME` 覆盖;额外 LAN Host 可通过 `MULTI_SIMADMIN_ALLOWED_HOSTS`(逗号分隔)配置。
|
||||||
|
|
||||||
|
## 旧版开发入口
|
||||||
|
|
||||||
|
旧 `server/` 与 `public/` 仅用于历史兼容和开发验证,不是当前生产安装入口:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp config.example.json config.json
|
cp config.example.json config.json
|
||||||
@@ -21,8 +54,6 @@ npm install
|
|||||||
npm start
|
npm start
|
||||||
```
|
```
|
||||||
|
|
||||||
默认访问:<http://localhost:8788>
|
|
||||||
|
|
||||||
## 配置
|
## 配置
|
||||||
|
|
||||||
编辑 `config.json`:
|
编辑 `config.json`:
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { migrateDatabase } from '../../infrastructure/database/migrations.js';
|
|||||||
import { registerConsoleAuth } from './console-auth-routes.js';
|
import { registerConsoleAuth } from './console-auth-routes.js';
|
||||||
|
|
||||||
const PASSWORD = 'StrongPass!9';
|
const PASSWORD = 'StrongPass!9';
|
||||||
|
const GATEWAY_TOKEN = 'g'.repeat(32);
|
||||||
const dbs: Database.Database[] = [];
|
const dbs: Database.Database[] = [];
|
||||||
|
|
||||||
function fixture() {
|
function fixture() {
|
||||||
@@ -48,23 +49,41 @@ describe('aggregate-console password protection HTTP boundary', () => {
|
|||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects initial password takeover from a non-loopback client', async () => {
|
it('allows an administrator on the trusted gateway to initialize the first password remotely', async () => {
|
||||||
const db = new Database(':memory:');
|
const db = new Database(':memory:');
|
||||||
migrateDatabase(db);
|
migrateDatabase(db);
|
||||||
const auth = new ConsoleAuthService({ db });
|
const auth = new ConsoleAuthService({ db });
|
||||||
const app = buildApp({
|
const app = buildApp({
|
||||||
|
gatewayToken: GATEWAY_TOKEN,
|
||||||
registerRoutes: (fastify) => registerConsoleAuth(fastify, auth),
|
registerRoutes: (fastify) => registerConsoleAuth(fastify, auth),
|
||||||
});
|
});
|
||||||
const response = await app.inject({
|
const rejected = await app.inject({
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
url: '/api/v1/auth/settings',
|
url: '/api/v1/auth/settings',
|
||||||
remoteAddress: '127.0.0.1',
|
remoteAddress: '127.0.0.1',
|
||||||
headers: { 'x-multi-simadmin-client-ip': '192.168.3.8' },
|
headers: { 'x-multi-simadmin-client-ip': '192.168.3.8' },
|
||||||
payload: { enabled: true, newPassword: PASSWORD },
|
payload: { enabled: true, newPassword: PASSWORD },
|
||||||
});
|
});
|
||||||
expect(response.statusCode).toBe(403);
|
expect(rejected.statusCode).toBe(401);
|
||||||
expect(response.json()).toMatchObject({ code: 'BOOTSTRAP_LOCAL_ONLY' });
|
|
||||||
expect(auth.status()).toMatchObject({ configured: false, protectionEnabled: false });
|
expect(auth.status()).toMatchObject({ configured: false, protectionEnabled: false });
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: '/api/v1/auth/settings',
|
||||||
|
remoteAddress: '127.0.0.1',
|
||||||
|
headers: {
|
||||||
|
'x-multi-simadmin-client-ip': '192.168.3.8',
|
||||||
|
'x-multi-simadmin-gateway-token': GATEWAY_TOKEN,
|
||||||
|
},
|
||||||
|
payload: { enabled: true, newPassword: PASSWORD },
|
||||||
|
});
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json()).toMatchObject({
|
||||||
|
configured: true,
|
||||||
|
protectionEnabled: true,
|
||||||
|
authenticated: true,
|
||||||
|
});
|
||||||
|
expect(response.headers['set-cookie']).toContain('multi_simadmin_console_session=');
|
||||||
|
expect(auth.status()).toMatchObject({ configured: true, protectionEnabled: true });
|
||||||
await app.close();
|
await app.close();
|
||||||
db.close();
|
db.close();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -82,15 +82,7 @@ const loginSchema = {
|
|||||||
properties: { password: { type: 'string', minLength: 1, maxLength: 1024 } },
|
properties: { password: { type: 'string', minLength: 1, maxLength: 1024 } },
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export interface ConsoleAuthRegistrationOptions {
|
export function registerConsoleAuth(app: FastifyInstance, auth: ConsoleAuthService): void {
|
||||||
readonly allowRemoteBootstrap?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function registerConsoleAuth(
|
|
||||||
app: FastifyInstance,
|
|
||||||
auth: ConsoleAuthService,
|
|
||||||
options: ConsoleAuthRegistrationOptions = {},
|
|
||||||
): void {
|
|
||||||
const loginAttempts = new Map<string, { count: number; resetAt: number }>();
|
const loginAttempts = new Map<string, { count: number; resetAt: number }>();
|
||||||
app.addHook('preHandler', async (request, reply) => {
|
app.addHook('preHandler', async (request, reply) => {
|
||||||
const pathname = request.url.split(/[?#]/u, 1)[0] ?? request.url;
|
const pathname = request.url.split(/[?#]/u, 1)[0] ?? request.url;
|
||||||
@@ -142,21 +134,6 @@ export function registerConsoleAuth(
|
|||||||
app.put('/api/v1/auth/settings', { schema: { body: settingsSchema } }, async (request, reply) => {
|
app.put('/api/v1/auth/settings', { schema: { body: settingsSchema } }, async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
const body = request.body as { enabled: boolean; newPassword?: string };
|
const body = request.body as { enabled: boolean; newPassword?: string };
|
||||||
const status = auth.status(parseCookie(request));
|
|
||||||
if (
|
|
||||||
body.enabled &&
|
|
||||||
body.newPassword !== undefined &&
|
|
||||||
!status.configured &&
|
|
||||||
!options.allowRemoteBootstrap &&
|
|
||||||
clientIp(request) !== '127.0.0.1' &&
|
|
||||||
clientIp(request) !== '::1'
|
|
||||||
)
|
|
||||||
return reply
|
|
||||||
.code(403)
|
|
||||||
.type('application/problem+json')
|
|
||||||
.send(
|
|
||||||
problem(request, 403, 'BOOTSTRAP_LOCAL_ONLY', 'Initial password setup is local-only'),
|
|
||||||
);
|
|
||||||
const result = await auth.updateSettings(body, parseCookie(request));
|
const result = await auth.updateSettings(body, parseCookie(request));
|
||||||
if (result.sessionToken)
|
if (result.sessionToken)
|
||||||
reply.header('Set-Cookie', cookie(result.sessionToken, usesSecureCookie(request)));
|
reply.header('Set-Cookie', cookie(result.sessionToken, usesSecureCookie(request)));
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export function ConsoleAuthSettings({
|
|||||||
<header className="page-heading">
|
<header className="page-heading">
|
||||||
<p className="eyebrow">SYSTEM SETTINGS</p>
|
<p className="eyebrow">SYSTEM SETTINGS</p>
|
||||||
<h1 id="password-protection-title">密码保护</h1>
|
<h1 id="password-protection-title">密码保护</h1>
|
||||||
<p>首次密码仅允许从运行主机本机设置;远程访问请先在本机完成初始化。</p>
|
<p>可在当前管理台直接完成首次密码设置;请在可信网络中初始化并妥善保管密码。</p>
|
||||||
<p>当前部署为 HTTP,仅适用于可信内网;公网使用必须在前置代理启用 HTTPS。</p>
|
<p>当前部署为 HTTP,仅适用于可信内网;公网使用必须在前置代理启用 HTTPS。</p>
|
||||||
<p>参考单实例 SimAdmin 的访问方式,为整个聚合工作台增加统一登录保护。</p>
|
<p>参考单实例 SimAdmin 的访问方式,为整个聚合工作台增加统一登录保护。</p>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -79,6 +79,8 @@ describe('aggregate-console authentication UI', () => {
|
|||||||
render(<settings.ConsoleAuthSettings dataSource={dataSource} />);
|
render(<settings.ConsoleAuthSettings dataSource={dataSource} />);
|
||||||
|
|
||||||
expect(await screen.findByRole('heading', { name: '密码保护' })).toBeTruthy();
|
expect(await screen.findByRole('heading', { name: '密码保护' })).toBeTruthy();
|
||||||
|
expect(screen.queryByText(/首次密码仅允许从运行主机本机设置/)).toBeNull();
|
||||||
|
expect(screen.getByText(/可在当前管理台直接完成首次密码设置/)).toBeTruthy();
|
||||||
const toggle = screen.getByRole('checkbox', { name: /启用密码保护/ });
|
const toggle = screen.getByRole('checkbox', { name: /启用密码保护/ });
|
||||||
expect((toggle as HTMLInputElement).checked).toBe(false);
|
expect((toggle as HTMLInputElement).checked).toBe(false);
|
||||||
await user.click(toggle);
|
await user.click(toggle);
|
||||||
|
|||||||
@@ -0,0 +1,285 @@
|
|||||||
|
#!/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"
|
||||||
|
APP_ROOT=${MULTI_SIMADMIN_HOME:-"$HOME/Library/Application Support/multi-simadmin"}
|
||||||
|
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 安装根目录
|
||||||
|
MULTI_SIMADMIN_ALLOWED_HOSTS 额外允许的 LAN 主机名/IP,逗号分隔
|
||||||
|
MULTI_SIMADMIN_REPO_URL 源码仓库地址
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
require_macos() {
|
||||||
|
[ "$(uname -s)" = Darwin ] || die "当前生产版仅支持 macOS(秘密存储依赖 macOS Keychain)。"
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
||||||
|
! lsof -nP -iTCP:"$1" -sTCP:LISTEN >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
lan_ip() {
|
||||||
|
for interface in en0 en1; do
|
||||||
|
value=$(ipconfig getifaddr "$interface" 2>/dev/null || true)
|
||||||
|
if [ -n "$value" ]; then printf '%s' "$value"; return; fi
|
||||||
|
done
|
||||||
|
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_macos
|
||||||
|
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_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_macos
|
||||||
|
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
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
|
||||||
|
const installer = new URL('../scripts/install.sh', import.meta.url);
|
||||||
|
const source = readFileSync(installer, 'utf8');
|
||||||
|
|
||||||
|
test('one-click installer has safe lifecycle commands and pinned production defaults', () => {
|
||||||
|
execFileSync('/bin/sh', ['-n', installer.pathname]);
|
||||||
|
const help = execFileSync('/bin/sh', [installer.pathname, 'help'], { encoding: 'utf8' });
|
||||||
|
|
||||||
|
for (const command of ['install', 'start', 'stop', 'restart', 'status', 'uninstall']) {
|
||||||
|
assert.match(help, new RegExp(`\\b${command}\\b`));
|
||||||
|
}
|
||||||
|
assert.match(source, /PNPM_VERSION="11\.13\.0"/);
|
||||||
|
assert.match(source, /API_HOST=127\.0\.0\.1/);
|
||||||
|
assert.match(source, /API_PORT=8790/);
|
||||||
|
assert.match(source, /MULTI_SIMADMIN_GATEWAY_HOST=0\.0\.0\.0/);
|
||||||
|
assert.match(source, /CANARY_UPSTREAM_PORT=8790/);
|
||||||
|
assert.doesNotMatch(source, /CANARY_PORT=8789/);
|
||||||
|
assert.match(source, /umask 077/);
|
||||||
|
assert.match(source, /install -m 600/);
|
||||||
|
assert.match(source, /--frozen-lockfile/);
|
||||||
|
assert.match(source, /trap 'rollback_start' EXIT HUP INT TERM/);
|
||||||
|
assert.match(source, /保留 PID 文件/);
|
||||||
|
assert.match(source, /node --import tsx .*production-cli\.ts/);
|
||||||
|
assert.match(source, /node --import tsx .*production-gateway-cli\.ts/);
|
||||||
|
assert.doesNotMatch(source, /nohup corepack pnpm/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('installer refuses unsupported platforms and preserves data by default on uninstall', () => {
|
||||||
|
assert.match(source, /Darwin/);
|
||||||
|
assert.match(source, /当前生产版仅支持 macOS/);
|
||||||
|
assert.match(source, /默认保留数据/);
|
||||||
|
assert.doesNotMatch(source, /rm -rf "\$DATA_ROOT"/);
|
||||||
|
assert.doesNotMatch(source, /git reset --hard|git clean -fdx/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('README exposes the token-free Gitea one-click command', () => {
|
||||||
|
const readme = readFileSync(new URL('../README.md', import.meta.url), 'utf8');
|
||||||
|
assert.match(
|
||||||
|
readme,
|
||||||
|
/curl -fsSL https:\/\/gitea\.chickliu\.fun\/Hermes\/multi-simadmin\/raw\/branch\/main\/scripts\/install\.sh \| sh/,
|
||||||
|
);
|
||||||
|
assert.doesNotMatch(readme, /https:\/\/[^\s/]+:[^\s@]+@gitea\.chickliu\.fun/);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user