From ebed4c19696111d18a62e59ec02638007193a556 Mon Sep 17 00:00:00 2001 From: chick Date: Sun, 19 Jul 2026 21:10:16 +0800 Subject: [PATCH] feat(auth): allow trusted remote bootstrap and add installer --- .gitignore | 1 + README.md | 37 ++- .../http/console-auth-routes.test.ts | 27 +- .../src/interface/http/console-auth-routes.ts | 25 +- apps/web/src/auth/console-auth-settings.tsx | 2 +- apps/web/src/auth/console-auth.test.tsx | 2 + scripts/install.sh | 285 ++++++++++++++++++ test/installer.test.js | 47 +++ 8 files changed, 394 insertions(+), 32 deletions(-) create mode 100644 scripts/install.sh create mode 100644 test/installer.test.js diff --git a/.gitignore b/.gitignore index 716065e..eb2e638 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,5 @@ config.json .DS_Store npm-debug.log* coverage/ +apps/web/dist/ .hermes/ diff --git a/README.md b/README.md index 5ef863c..bd69c80 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,40 @@ - 保留原始 SimAdmin 页面 iframe 嵌入;若目标站禁止 iframe,可一键在原站打开。 - 敏感配置留在本地 `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 cp config.example.json config.json @@ -21,8 +54,6 @@ npm install npm start ``` -默认访问: - ## 配置 编辑 `config.json`: diff --git a/apps/api/src/interface/http/console-auth-routes.test.ts b/apps/api/src/interface/http/console-auth-routes.test.ts index 0878117..6505aa3 100644 --- a/apps/api/src/interface/http/console-auth-routes.test.ts +++ b/apps/api/src/interface/http/console-auth-routes.test.ts @@ -7,6 +7,7 @@ import { migrateDatabase } from '../../infrastructure/database/migrations.js'; import { registerConsoleAuth } from './console-auth-routes.js'; const PASSWORD = 'StrongPass!9'; +const GATEWAY_TOKEN = 'g'.repeat(32); const dbs: Database.Database[] = []; function fixture() { @@ -48,23 +49,41 @@ describe('aggregate-console password protection HTTP boundary', () => { 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:'); migrateDatabase(db); const auth = new ConsoleAuthService({ db }); const app = buildApp({ + gatewayToken: GATEWAY_TOKEN, registerRoutes: (fastify) => registerConsoleAuth(fastify, auth), }); - const response = await app.inject({ + const rejected = 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' }, payload: { enabled: true, newPassword: PASSWORD }, }); - expect(response.statusCode).toBe(403); - expect(response.json()).toMatchObject({ code: 'BOOTSTRAP_LOCAL_ONLY' }); + expect(rejected.statusCode).toBe(401); 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(); db.close(); }); diff --git a/apps/api/src/interface/http/console-auth-routes.ts b/apps/api/src/interface/http/console-auth-routes.ts index a7305f4..15dff13 100644 --- a/apps/api/src/interface/http/console-auth-routes.ts +++ b/apps/api/src/interface/http/console-auth-routes.ts @@ -82,15 +82,7 @@ const loginSchema = { properties: { password: { type: 'string', minLength: 1, maxLength: 1024 } }, } as const; -export interface ConsoleAuthRegistrationOptions { - readonly allowRemoteBootstrap?: boolean; -} - -export function registerConsoleAuth( - app: FastifyInstance, - auth: ConsoleAuthService, - options: ConsoleAuthRegistrationOptions = {}, -): void { +export function registerConsoleAuth(app: FastifyInstance, auth: ConsoleAuthService): void { const loginAttempts = new Map(); app.addHook('preHandler', async (request, reply) => { 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) => { try { 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)); if (result.sessionToken) reply.header('Set-Cookie', cookie(result.sessionToken, usesSecureCookie(request))); diff --git a/apps/web/src/auth/console-auth-settings.tsx b/apps/web/src/auth/console-auth-settings.tsx index cc222e8..4fcfb41 100644 --- a/apps/web/src/auth/console-auth-settings.tsx +++ b/apps/web/src/auth/console-auth-settings.tsx @@ -71,7 +71,7 @@ export function ConsoleAuthSettings({

SYSTEM SETTINGS

密码保护

-

首次密码仅允许从运行主机本机设置;远程访问请先在本机完成初始化。

+

可在当前管理台直接完成首次密码设置;请在可信网络中初始化并妥善保管密码。

当前部署为 HTTP,仅适用于可信内网;公网使用必须在前置代理启用 HTTPS。

参考单实例 SimAdmin 的访问方式,为整个聚合工作台增加统一登录保护。

diff --git a/apps/web/src/auth/console-auth.test.tsx b/apps/web/src/auth/console-auth.test.tsx index e443817..4869f83 100644 --- a/apps/web/src/auth/console-auth.test.tsx +++ b/apps/web/src/auth/console-auth.test.tsx @@ -79,6 +79,8 @@ describe('aggregate-console authentication UI', () => { render(); expect(await screen.findByRole('heading', { name: '密码保护' })).toBeTruthy(); + expect(screen.queryByText(/首次密码仅允许从运行主机本机设置/)).toBeNull(); + expect(screen.getByText(/可在当前管理台直接完成首次密码设置/)).toBeTruthy(); const toggle = screen.getByRole('checkbox', { name: /启用密码保护/ }); expect((toggle as HTMLInputElement).checked).toBe(false); await user.click(toggle); diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100644 index 0000000..64b152a --- /dev/null +++ b/scripts/install.sh @@ -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 diff --git a/test/installer.test.js b/test/installer.test.js new file mode 100644 index 0000000..a8c056a --- /dev/null +++ b/test/installer.test.js @@ -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/); +});