Compare commits

..
8 Commits
Author SHA1 Message Date
chick 81cef1ad6c fix(web): 修复无正文请求被 JSON 解析器拒绝导致"删除节点"失效
控制台所有请求共用的 request() 无条件带上 Content-Type: application/json,
包括无正文的 DELETE /api/v1/instances/:id 与 POST test-connection。Fastify
的 JSON 解析器见到"声明了 JSON 却是空正文"会直接回 400 HTTP_400,请求在
进入路由之前就被拒——所以"删除节点"看起来点了没反应,日志里三次真实尝试
全部是 400,而路由本身完好。

复现(对 8788 实测):
- DELETE + Content-Type: json + 空正文 → 400 HTTP_400(旧行为,坏)
- DELETE 不带 Content-Type          → 401 CONSOLE_AUTH_REQUIRED(到达路由)
- DELETE + Content-Type: json + {}  → 401(能解析,故非路由问题)
- GET    + Content-Type: json + 空   → 401(Fastify 对 GET 不解析正文)

修复:Content-Type 只在确实有正文时才发送。删除的意图本来就在头部
(If-Match / X-Preparation-Id / X-Confirmation-Token)里,无正文属正常形状。

同源的 POST test-connection 也一并修好。iOS 客户端本就用 route.body != nil
判断,与修复后的契约一致——Web 侧是唯一的例外。

回归测试:
- web:断言 DELETE 只发 Accept、不发 Content-Type
- api:断言真实形状到达路由并完成删除(202 且节点转 404),
  同时断言"声称 JSON 却空正文"仍须显式 400、节点必须留存

验证:147 文件 / 1371 测试全绿;lint 干净;typecheck 干净;dist 已重建且
8788 正在服务的 bundle 与磁盘一致(md5 相同)。
2026-09-29 18:55:54 +08:00
chick 1caf6985ff docs: add iOS native IPA design 2026-09-08 20:57:54 +08:00
chick 001659ed91 style(web): unify panel, group and state controls
Standardize panel actions, group filters, segmented modes, workbench tabs, message filters, identity controls and privacy toggles on the shared button scale. Add cascade-aware tests so stale compact sizes and low-contrast states cannot return.
2026-09-08 19:58:10 +08:00
chick bfe5025943 style(web): close control-system gaps found in the deep sweep 2026-09-08 08:02:01 +08:00
chick c0adbcbc8a style(web): unify accessible control system 2026-09-08 01:00:22 +08:00
chick 61a5aee48d fix(events): close review gaps found in the follow-up sweep
- deletion flow emitted job/instance envelopes before the transaction that
  closed the job rows; subscribers refetching on the notification saw
  status 'running' with no further event. Emits now happen after commit,
  including the failure path
- interrupted-deletion sweep now emits job envelopes with the job's real
  request_id (it previously stayed silent while the secure-execution sweep
  already emitted)
- verified during the sweep: backup restore validation re-derives the
  expected schema via migrateDatabase, so migration 18 is automatically
  covered; no tables reference audit_events or operation_preparations, so
  the retention deletes cannot trip FK constraints
2026-09-07 05:57:35 +08:00
chick 70e598208a perf,feat(events),ops: third optimization pass
Overview cold start:
- InstanceResourceService persists every live fetch into status_snapshots
  (category 'resources') and serves a snapshot younger than 2 minutes
  before calling upstream, so the first fleet overview after a restart
  costs zero device requests; instance deletion cleans up via FK cascade

Bounded retention for the four fastest-growing tables (15-minute sweep):
- connection_logs: one row per probe per beat had no automatic cleanup
- audit_events: new pruneBefore (90d)
- operation_preparations: every prepare/retry attempt inserted a row,
  terminal rows now expire after 7 days
- notification_queue: terminal rows pruned after 30 days

SSE events now cover instance lifecycle:
- create/update emit instance envelopes from the routes; the two-phase
  delete emits a job envelope on every terminal transition plus an
  instance envelope when the node actually disappears, so fleet and
  instance views invalidate in real time

Linux ops:
- 'install.sh unit' writes systemd user units for API and gateway with
  Restart=on-failure, 0600 secret injection, and
  MULTI_SIMADMIN_SYSTEMD_UNIT wired so console self-update restarts via
  systemctl -- closing the self-update loop on Linux
2026-09-07 02:43:11 +08:00
chick c1be714ba4 perf,feat(events),hardening: second review pass
Fleet overview N+1:
- InstanceResourceService gains a 30s TTL cache with single-flight
  coalescing; the overview no longer fires six live upstream requests per
  device on every render (plus the re-login storm), while the per-device
  detail route probes live via force:true

Event journal becomes live:
- job terminal transitions (manual executions and the interrupted sweep)
  now append to the journal, so /api/v1/events SSE feeds the frontend's
  invalidation controller that was built but never received events
- journal pruning moves off the append hot path (was an unindexable
  full-table json_extract scan per insert) onto the retention timer

Console auth hardening:
- scrypt upgraded from N=16384 to N=2^16 (OWASP interactive guidance);
  a new password_kdf column records the derivation per row and legacy
  hashes rehash transparently on the next successful login without
  invalidating sessions (migration 18)

Legacy stack:
- instance URL validation blocks IPv4-compatible IPv6 after WHATWG
  canonicalization (::a9fe:a9fe metadata, ::7f00:1 loopback slipped past)
- status polls cool down auto-login for 60s after a failed attempt so a
  stale saved password cannot hammer the device into an account lockout

Build hygiene:
- web bundle splits app (410kB) from vendor (212kB) so framework code
  stays cacheable across releases; stale root package-lock.json removed
  (pnpm is the only lockfile)
2026-09-07 02:20:41 +08:00
35 changed files with 2390 additions and 4229 deletions
+14 -2
View File
@@ -45,6 +45,16 @@ sh /tmp/multi-simadmin-install.sh uninstall
`uninstall` 默认只移除程序源码,保留数据库、Gateway token 和日志。默认安装位置 macOS 为 `~/Library/Application Support/multi-simadmin`,Linux 为 `~/.local/share/multi-simadmin`(遵循 XDG),均可通过 `MULTI_SIMADMIN_HOME` 覆盖;额外 LAN Host 可通过 `MULTI_SIMADMIN_ALLOWED_HOSTS`(逗号分隔)配置。
#### Linux systemd(可选,但推荐)
`sh install.sh unit` 会生成 `~/.config/systemd/user/` 下的 API 与 Gateway 用户单元(`Restart=on-failure`、开机自启、密钥经 0600 的 `service-env` 注入)。随后:
```bash
systemctl --user enable --now multi-simadmin-api.service multi-simadmin-gateway.service
```
单元内声明了 `MULTI_SIMADMIN_SYSTEMD_UNIT`,因此 Web 控制台的“在线更新”在 Linux 上会把重启交给 systemd 完成,形成自更新闭环。由 systemd 托管后请用 `systemctl` 管理进程,不要再用本脚本 `start/stop`,避免双进程争抢端口。
### Fleet 节点总览与跨域健康
`/fleet` 读取本机 Control Plane 中的节点状态、资源快照与设备号码,形成节点总览。侧栏在实例总数 / 在线 / 需处理之外,还聚合跨域健康指标:
@@ -80,10 +90,12 @@ sh /tmp/multi-simadmin-install.sh uninstall
```bash
cp config.example.json config.json
npm install
npm start
corepack pnpm install
corepack pnpm start
```
> 仓库统一使用 pnpm(`pnpm-lock.yaml`)作为唯一 lockfile;陈旧的 `package-lock.json` 已移除。
## 配置
编辑 `config.json`:
@@ -139,6 +139,17 @@ function parameterSummary(value: unknown): readonly RedactedParameterSummaryItem
export class AuditQueryService {
constructor(private readonly db: Database.Database) {}
/** Retention hook: drops audit records older than the cutoff. */
pruneBefore(cutoffIso: string): number {
if (typeof cutoffIso !== 'string' || !Number.isFinite(Date.parse(cutoffIso)))
throw new RangeError('cutoffIso must be a valid date-time');
return Number(
this.db
.prepare('DELETE FROM audit_events WHERE created_at < ?')
.run(new Date(cutoffIso).toISOString()).changes,
);
}
summary(limit = 5): ControlPlaneAuditSummary {
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 20) validation('limit is invalid');
const totalRow = this.db.prepare('SELECT COUNT(*) AS total FROM audit_events').get() as
@@ -6,6 +6,25 @@ const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
const MIN_PASSWORD_LENGTH = 8;
const MAX_PASSWORD_BYTES = 1024;
const SCRYPT_KEY_LENGTH = 32;
// Legacy derivations used N=16384; OWASP guidance for interactive login sits at
// N=2^16/r=8. Rows keep their derivation tag so verification stays correct and
// the upgrade happens transparently on the next successful login.
interface ScryptParams {
readonly N: number;
readonly r: number;
readonly p: number;
}
const SCRYPT_LEGACY: ScryptParams = { N: 16_384, r: 8, p: 1 };
const SCRYPT_CURRENT: ScryptParams = { N: 65_536, r: 8, p: 1 };
const SCRYPT_MAXMEM = 256 * 1024 * 1024;
const scryptTag = (params: ScryptParams): string => `scrypt:${params.N}:${params.r}:${params.p}`;
const parseScryptTag = (value: string | null | undefined): ScryptParams => {
if (!value) return SCRYPT_LEGACY;
const match = /^scrypt:(\d+):(\d+):(\d+)$/.exec(value);
if (!match) return SCRYPT_LEGACY;
const [N, r, p] = match.slice(1).map(Number) as [number, number, number];
return { N, r, p };
};
const IDLE_TIMEOUT_SETTING_KEY = 'console-auth.idle-timeout-minutes';
const DEFAULT_IDLE_TIMEOUT_MINUTES = 30;
const MIN_IDLE_TIMEOUT_MINUTES = 5;
@@ -16,6 +35,7 @@ interface AuthConfigRow {
readonly password_salt: string | null;
readonly password_hash: string | null;
readonly password_revision: number;
readonly password_kdf: string | null;
}
export interface ConsoleAuthStatus {
@@ -87,10 +107,23 @@ export class ConsoleAuthService {
!config.password_hash
)
throw new ConsoleAuthError('INVALID_CREDENTIALS');
const actual = await this.derive(password, config.password_salt);
const params = parseScryptTag(config.password_kdf);
const actual = await this.derive(password, config.password_salt, params);
const expected = Buffer.from(config.password_hash, 'hex');
if (actual.length !== expected.length || !timingSafeEqual(actual, expected))
throw new ConsoleAuthError('INVALID_CREDENTIALS');
if (params !== SCRYPT_CURRENT) {
// Transparent upgrade: same salt, stronger derivation, same revision so
// live sessions survive.
const hash = (await this.derive(password, config.password_salt, SCRYPT_CURRENT)).toString(
'hex',
);
this.db
.prepare(
'UPDATE console_auth_config SET password_hash=?,password_kdf=?,updated_at=? WHERE singleton=1',
)
.run(hash, scryptTag(SCRYPT_CURRENT), this.now().toISOString());
}
return this.createSession(config.password_revision);
}
@@ -121,14 +154,14 @@ export class ConsoleAuthService {
this.db
.prepare(
`INSERT INTO console_auth_config
(singleton,protection_enabled,password_salt,password_hash,password_revision,updated_at)
VALUES (1,1,?,?,?,?)
(singleton,protection_enabled,password_salt,password_hash,password_revision,password_kdf,updated_at)
VALUES (1,1,?,?,?,?,?)
ON CONFLICT(singleton) DO UPDATE SET
protection_enabled=1,password_salt=excluded.password_salt,
password_hash=excluded.password_hash,password_revision=excluded.password_revision,
updated_at=excluded.updated_at`,
password_kdf=excluded.password_kdf,updated_at=excluded.updated_at`,
)
.run(salt, hash, revision, this.now().toISOString());
.run(salt, hash, revision, scryptTag(SCRYPT_CURRENT), this.now().toISOString());
this.db.prepare('DELETE FROM console_auth_sessions').run();
return this.createSession(revision);
})();
@@ -178,7 +211,7 @@ export class ConsoleAuthService {
private config(): AuthConfigRow | undefined {
return this.db
.prepare(
`SELECT protection_enabled,password_salt,password_hash,password_revision
`SELECT protection_enabled,password_salt,password_hash,password_revision,password_kdf
FROM console_auth_config WHERE singleton=1`,
)
.get() as AuthConfigRow | undefined;
@@ -221,13 +254,17 @@ export class ConsoleAuthService {
throw new ConsoleAuthError('PASSWORD_POLICY_FAILED');
}
private derive(password: string, salt: string): Promise<Buffer> {
private derive(
password: string,
salt: string,
params: ScryptParams = SCRYPT_CURRENT,
): Promise<Buffer> {
return new Promise((resolvePromise, reject) => {
deriveScrypt(
password,
Buffer.from(salt, 'hex'),
SCRYPT_KEY_LENGTH,
{ N: 16_384, r: 8, p: 1, maxmem: 64 * 1024 * 1024 },
{ ...params, maxmem: SCRYPT_MAXMEM },
(error, derivedKey) => {
if (error) reject(error);
else resolvePromise(derivedKey);
@@ -185,7 +185,6 @@ export class EventJournal {
const sequence = Number(result.lastInsertRowid);
if (!Number.isSafeInteger(sequence))
throw new Error('event journal sequence is not a safe integer');
this.#prune();
const appended = { sequence, envelope };
for (const subscriber of [...this.#subscribers]) subscriber(appended);
return appended;
@@ -236,7 +235,11 @@ export class EventJournal {
};
}
#prune(): void {
/**
* Maintenance hook for a periodic timer. Pruning per append turned every
* insert into an unindexable full-table json_extract scan.
*/
prune(): void {
const cutoff = new Date(Date.now() - this.#retentionMs).toISOString();
this.#database
.prepare("DELETE FROM event_journal WHERE json_extract(envelope_json, '$.occurredAt') < ?")
@@ -1248,6 +1248,20 @@ export class CentralNotificationService {
if (result.changes !== 1) throw new CentralNotificationServiceError('QUEUE_NOT_FOUND');
}
/** Retention for terminal queue rows; pending/sending backlog is never touched. */
pruneFinishedQueue(olderThanMs: number): number {
if (!Number.isSafeInteger(olderThanMs) || olderThanMs < 0)
throw new RangeError('olderThanMs must be a non-negative integer');
const cutoff = new Date(Date.parse(this.#now()) - olderThanMs).toISOString();
return Number(
this.#db
.prepare(
"DELETE FROM notification_queue WHERE status IN ('succeeded','failed','cancelled') AND updated_at <= ?",
)
.run(cutoff).changes,
);
}
deleteQueueItem(id: string): void {
if (this.#db.prepare('DELETE FROM notification_queue WHERE id=?').run(id).changes !== 1)
throw new CentralNotificationServiceError('QUEUE_NOT_FOUND');
@@ -1,4 +1,5 @@
import type Database from 'better-sqlite3';
import type { EventEnvelope } from '../events/event-journal.js';
import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
import type { Job, Preparation, PrepareOperationRequest } from '@multi-simadmin/contracts';
import { InstanceService, InstanceServiceError } from '../instances/instance-service.js';
@@ -75,6 +76,8 @@ interface Options {
readonly now?: () => Date;
readonly idFactory?: () => string;
readonly tokenFactory?: () => string;
/** Receives job/instance envelopes so the SSE journal can invalidate fleet views. */
readonly emit?: (envelope: EventEnvelope) => void;
}
const digest = (value: string): string => createHash('sha256').update(value, 'utf8').digest('hex');
@@ -93,6 +96,7 @@ export class DeleteInstanceOperation {
private readonly clock: () => Date;
private readonly id: () => string;
private readonly token: () => string;
private readonly emit: Options['emit'];
constructor(options: Options) {
this.db = options.db;
@@ -101,6 +105,7 @@ export class DeleteInstanceOperation {
this.clock = options.now ?? (() => new Date());
this.id = options.idFactory ?? randomUUID;
this.token = options.tokenFactory ?? (() => randomBytes(32).toString('base64url'));
this.emit = options.emit;
}
async prepare(input: PrepareOperationRequest, requestId = this.id()): Promise<Preparation> {
@@ -318,6 +323,7 @@ export class DeleteInstanceOperation {
const release = await this.releaseBindingPort(input.instanceId, input.requestId);
if (release?.status === 'failed') {
this.closeAsFailed(ids, input.requestId, BINDING_REFUSAL_CODE);
this.#emitJob(ids.job, input.requestId);
return this.job(ids.job);
}
try {
@@ -342,6 +348,16 @@ export class DeleteInstanceOperation {
)
.run(finished, finished, ids.job);
})();
// Events only after the terminal rows are committed, so a subscriber that
// refetches on the notification sees the finished job, not 'running'.
this.#emit({
kind: 'instance',
id: this.id(),
occurredAt: this.clock().toISOString(),
requestId: input.requestId,
instanceId: input.instanceId,
});
this.#emitJob(ids.job, input.requestId);
} catch (error) {
const finished = this.clock().toISOString();
const state = error instanceof InstanceServiceError ? 'failed' : 'unknown-result';
@@ -361,6 +377,7 @@ export class DeleteInstanceOperation {
.prepare('UPDATE jobs SET status=?,finished_at=?,updated_at=? WHERE id=?')
.run(state, finished, finished, ids.job);
})();
this.#emitJob(ids.job, input.requestId);
}
return this.job(ids.job);
}
@@ -379,6 +396,25 @@ export class DeleteInstanceOperation {
}
/** Closes the job without touching the instance row, so the device stays listed and retryable. */
#emit(envelope: EventEnvelope): void {
if (!this.emit) return;
try {
this.emit(envelope);
} catch {
// A journal failure must never fail the operation itself.
}
}
#emitJob(jobId: string, requestId: string): void {
this.#emit({
kind: 'job',
id: this.id(),
occurredAt: this.clock().toISOString(),
requestId,
jobId,
});
}
private closeAsFailed(
ids: { job: string; item: string; attempt: string },
requestId: string,
@@ -416,9 +452,10 @@ export class DeleteInstanceOperation {
const finished = this.clock().toISOString();
return this.db.transaction(() => {
const jobs = this.db
.prepare("SELECT id FROM jobs WHERE operation_id=? AND status='running'")
.all(OPERATION_ID) as Array<{ id: string }>;
for (const { id } of jobs) {
.prepare("SELECT id, request_id FROM jobs WHERE operation_id=? AND status='running'")
.all(OPERATION_ID) as Array<{ id: string; request_id: string }>;
for (const { id, request_id: requestId } of jobs) {
this.#emitJob(id, requestId);
this.db
.prepare(
"UPDATE job_items SET status='unknown-result',result_code='INTERRUPTED',finished_at=?,updated_at=? WHERE job_id=? AND status='running'",
@@ -1,5 +1,6 @@
import type Database from 'better-sqlite3';
import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
import type { EventEnvelope } from '../events/event-journal.js';
import type {
ExecuteOperationRequest,
Job,
@@ -121,6 +122,8 @@ interface Options {
readonly idFactory?: () => string;
readonly tokenFactory?: () => string;
readonly nonceFactory?: () => string;
/** Receives job terminal transitions so the SSE journal has a live producer. */
readonly emit?: (envelope: EventEnvelope) => void;
}
interface PreparationRow {
operation_id: string;
@@ -442,9 +445,28 @@ export class SecureOperationExecution {
}
}
this.finish(ids, state, code);
this.#emitTerminal(ids.job, state, requestId);
return this.job(ids.job);
}
/**
* Retention for consumed/expired/invalidated preparations. Every prepare
* (manual or a scheduled retry attempt) inserts a row, so without this the
* table grows one row per attempt forever.
*/
pruneTerminalPreparations(olderThanMs: number): number {
if (!Number.isSafeInteger(olderThanMs) || olderThanMs < 0)
throw new RangeError('olderThanMs must be a non-negative integer');
const cutoff = new Date(this.clock().getTime() - olderThanMs).toISOString();
return Number(
this.options.db
.prepare(
"DELETE FROM operation_preparations WHERE status IN ('consumed','expired','invalidated') AND updated_at <= ?",
)
.run(cutoff).changes,
);
}
reconcileInterruptedJobs(): number {
const now = this.clock().toISOString();
return this.options.db.transaction(() => {
@@ -453,7 +475,10 @@ export class SecureOperationExecution {
"SELECT id FROM jobs WHERE operation_id IN ('postNetworkRegisterAuto','postServiceRestart','postBasebandRestart','postSystemReboot') AND risk_level IN ('R2','R3') AND status='running'",
)
.all() as Array<{ id: string }>;
for (const row of jobs) this.finishByJob(row.id, now, 'unknown-result', 'INTERRUPTED');
for (const row of jobs) {
this.finishByJob(row.id, now, 'unknown-result', 'INTERRUPTED');
this.#emitTerminal(row.id, 'unknown-result', now);
}
return jobs.length;
})();
}
@@ -479,6 +504,21 @@ export class SecureOperationExecution {
.run(state, now, now, ids.job);
})();
}
#emitTerminal(jobId: string, state: string, requestId: string): void {
if (!this.options.emit) return;
try {
this.options.emit({
kind: 'job',
id: this.id(),
occurredAt: this.clock().toISOString(),
requestId,
jobId,
});
} catch {
// A journal failure must never fail the operation itself.
}
}
private finishByJob(jobId: string, now: string, state: string, code: string): void {
this.options.db
.prepare(
@@ -1,4 +1,6 @@
import Database from 'better-sqlite3';
import { describe, expect, it } from 'vitest';
import { migrateDatabase } from '../../infrastructure/database/migrations.js';
import {
InstanceResourceService,
@@ -155,6 +157,112 @@ describe('instance resource allowlist parsing', () => {
).toEqual({ simPresent: true, phoneNumbers: ['+8613800000000'] });
});
describe('persisted resource snapshots', () => {
it('serves the first overview after a restart from status_snapshots with zero upstream calls', async () => {
const db = new Database(':memory:');
migrateDatabase(db);
// status_snapshots carries an FK to instances; seed the node it belongs to.
db.prepare(
"INSERT INTO instances (id,name,base_url,created_at,updated_at) VALUES ('device-1','device-1','http://192.168.3.55:3000','2026-01-01T00:00:00.000Z','2026-01-01T00:00:00.000Z')",
).run();
let clock = 1_000;
let livePasses = 0;
const build = () => {
const requests: Array<{ url: string }> = [];
const service = new InstanceResourceService({
instances: { get: async () => ({ origin: 'http://192.168.3.55:3000' }) } as never,
sessions: { sessionFor: () => undefined } as never,
request: async (request: { url: string }) => {
requests.push(request);
return request.url.endsWith('/api/device')
? response({ data: { online: true } })
: { status: 503, headers: {}, body: '' };
},
db,
now: () => clock,
} as never);
return { service, requests };
};
const first = build();
await first.service.get('device-1');
livePasses = first.requests.length;
expect(livePasses).toBe(6);
// A brand-new service (process restart) reads the persisted snapshot.
const second = build();
const resources = await second.service.get('device-1');
expect(second.requests).toHaveLength(0);
expect(resources).toEqual({ controlOnline: true });
// Past the freshness window the probe goes live again and repersists.
clock += 121_000;
await second.service.get('device-1');
expect(second.requests.length).toBe(livePasses);
db.close();
});
});
describe('overview cache', () => {
const minimal = (cacheTtlMs?: number) => {
const requests: Array<{ url: string }> = [];
const service = new InstanceResourceService({
instances: { get: async () => ({ origin: 'http://192.168.3.55:3000' }) } as never,
sessions: { sessionFor: () => undefined } as never,
request: async (request: { url: string }) => {
requests.push(request);
return request.url.endsWith('/api/device')
? response({ data: { online: true } })
: { status: 503, headers: {}, body: '' };
},
...(cacheTtlMs === undefined ? {} : { cacheTtlMs }),
} as never);
return { service, requests };
};
it('serves repeat overview reads from the TTL cache and refreshes after expiry', async () => {
let clock = 1_000;
const { service, requests } = minimal();
(service as unknown as { options: { now?: () => number } }).options.now = () => clock;
await service.get('device-1');
const firstPass = requests.length;
await service.get('device-1');
expect(requests.length).toBe(firstPass);
clock += 31_000;
await service.get('device-1');
expect(requests.length).toBeGreaterThan(firstPass);
});
it('coalesces concurrent overview reads into one upstream pass', async () => {
const { service, requests } = minimal();
await Promise.all([
service.get('device-1'),
service.get('device-1'),
service.get('device-1'),
]);
expect(requests).toHaveLength(6);
});
it('probes live again when a detail view forces a refresh', async () => {
const clock = 5_000;
const { service, requests } = minimal();
(service as unknown as { options: { now?: () => number } }).options.now = () => clock;
await service.get('device-1');
const afterOverview = requests.length;
await service.get('device-1', { force: true });
expect(requests.length).toBe(afterOverview + 6);
});
it('keeps reads uncached when the TTL is disabled', async () => {
const { service, requests } = minimal(0);
await service.get('device-1');
await service.get('device-1');
expect(requests).toHaveLength(12);
});
});
it('probes passwordless instances without manufacturing a cookie', async () => {
const requests: Array<{ url: string; headers: Readonly<Record<string, string>> }> = [];
const service = new InstanceResourceService({
@@ -1,4 +1,6 @@
import { lookupOperator } from '@multi-simadmin/contracts';
import { randomUUID } from 'node:crypto';
import type Database from 'better-sqlite3';
import type { InstanceService } from '../instances/instance-service.js';
import type {
@@ -163,7 +165,17 @@ export function parseSignalStrength(response: UpstreamResponse): InstanceResourc
return signalPercent === undefined ? {} : { signalPercent };
}
const DEFAULT_CACHE_TTL_MS = 30_000;
const DEFAULT_SNAPSHOT_FRESH_MS = 120_000;
const RESOURCE_SNAPSHOT_CATEGORY = 'resources';
export class InstanceResourceService {
readonly #cache = new Map<
string,
{ readonly resources: InstanceResources; readonly fetchedAt: number }
>();
readonly #inflight = new Map<string, Promise<InstanceResources>>();
constructor(
private readonly options: {
readonly instances: InstanceService;
@@ -174,10 +186,115 @@ export class InstanceResourceService {
origin: string,
force?: boolean,
) => Promise<void>;
/** Fleet overview fan-out reads through this cache; 0 disables it. */
readonly cacheTtlMs?: number;
/**
* When set, every live fetch is persisted into status_snapshots
* (category 'resources') and a snapshot younger than snapshotFreshMs is
* served before any upstream call, so the first overview after a restart
* costs zero device requests.
*/
readonly db?: Database.Database;
readonly snapshotFreshMs?: number;
readonly now?: () => number;
},
) {}
async get(instanceId: string): Promise<InstanceResources> {
/**
* Fleet overview fans out one read per registered node; without a shared TTL
* that is six live upstream requests per device on every page render (plus a
* re-login storm when sessions expired). Detail views pass `force` to probe
* the selected device immediately.
*/
async get(
instanceId: string,
{ force = false }: { readonly force?: boolean } = {},
): Promise<InstanceResources> {
const ttl = this.options.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS;
const now = this.options.now?.() ?? Date.now();
if (ttl > 0 && !force) {
const cached = this.#cache.get(instanceId);
if (cached && now - cached.fetchedAt < ttl) return cached.resources;
const persisted = this.#readSnapshot(instanceId, now);
if (persisted) {
this.#cache.set(instanceId, { resources: persisted, fetchedAt: now });
return persisted;
}
const inflight = this.#inflight.get(instanceId);
if (inflight) return inflight;
const pending = this.#fetch(instanceId)
.then((resources) => {
const fetchedAt = this.options.now?.() ?? now;
this.#cache.set(instanceId, { resources, fetchedAt });
this.#writeSnapshot(instanceId, resources, fetchedAt);
return resources;
})
.finally(() => {
this.#inflight.delete(instanceId);
});
this.#inflight.set(instanceId, pending);
return pending;
}
const resources = await this.#fetch(instanceId);
if (ttl > 0) {
this.#cache.set(instanceId, { resources, fetchedAt: now });
this.#writeSnapshot(instanceId, resources, now);
}
return resources;
}
#readSnapshot(instanceId: string, now: number): InstanceResources | undefined {
const db = this.options.db;
if (!db) return undefined;
const freshMs = this.options.snapshotFreshMs ?? DEFAULT_SNAPSHOT_FRESH_MS;
try {
const row = db
.prepare(
'SELECT payload_json, observed_at FROM status_snapshots WHERE instance_id=? AND category=?',
)
.get(instanceId, RESOURCE_SNAPSHOT_CATEGORY) as
| { payload_json: string; observed_at: string }
| undefined;
if (!row) return undefined;
const age = now - Date.parse(row.observed_at);
if (!Number.isFinite(age) || age < 0 || age >= freshMs) return undefined;
const payload: unknown = JSON.parse(row.payload_json);
if (typeof payload !== 'object' || payload === null || Array.isArray(payload))
return undefined;
return payload as InstanceResources;
} catch {
return undefined;
}
}
#writeSnapshot(instanceId: string, resources: InstanceResources, observedAt: number): void {
const db = this.options.db;
if (!db) return;
const freshMs = this.options.snapshotFreshMs ?? DEFAULT_SNAPSHOT_FRESH_MS;
const observed = new Date(observedAt).toISOString();
const expiresAt = new Date(observedAt + freshMs).toISOString();
try {
db.prepare(
`INSERT INTO status_snapshots (id, instance_id, category, state, payload_json, observed_at, expires_at, created_at)
VALUES (?, ?, ?, 'fresh', ?, ?, ?, ?)
ON CONFLICT(instance_id, category) DO UPDATE SET
id=excluded.id, state=excluded.state, payload_json=excluded.payload_json,
observed_at=excluded.observed_at, expires_at=excluded.expires_at, created_at=excluded.created_at`,
).run(
randomUUID(),
instanceId,
RESOURCE_SNAPSHOT_CATEGORY,
JSON.stringify(resources),
observed,
expiresAt,
observed,
);
} catch {
// Persistence is an optimization; a failed write must not fail the read.
}
}
async #fetch(instanceId: string): Promise<InstanceResources> {
const [instance, session] = await Promise.all([
this.options.instances.get(instanceId),
Promise.resolve(this.options.sessions.sessionFor(instanceId)),
+38 -2
View File
@@ -167,6 +167,12 @@ function createHubDeviceReader(
};
}
// Retention windows for operator-facing history. Preparations are short-lived
// protocol state and cycle faster; audit records are the longest-lived.
const RETENTION_HISTORY_MS = 30 * 24 * 60 * 60 * 1000;
const RETENTION_AUDIT_MS = 90 * 24 * 60 * 60 * 1000;
const RETENTION_PREPARATION_MS = 7 * 24 * 60 * 60 * 1000;
export interface ControlPlaneApp extends FastifyInstance {
retryPendingSecretCleanup(): Promise<unknown>;
}
@@ -245,6 +251,7 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane
sessions,
request: options.upstream.request,
ensureSession,
db: options.db,
});
const messages = new InstanceMessageService({
instances,
@@ -347,8 +354,14 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane
instances,
releaseBinding,
now: options.now,
emit: (envelope) => eventJournal.append(envelope),
})
: new DeleteInstanceOperation({ db: options.db, instances, releaseBinding });
: new DeleteInstanceOperation({
db: options.db,
instances,
releaseBinding,
emit: (envelope) => eventJournal.append(envelope),
});
deletion.reconcileInterruptedJobs();
const listHubDevices = createHubDeviceReader(options.db, instances);
const resolveOperationCookie = async (
@@ -381,6 +394,7 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane
const secureExecution = new SecureOperationExecution({
db: options.db,
registry: secureOperationRegistry,
emit: (envelope) => eventJournal.append(envelope),
transport: {
request: async ({ origin, path, body, contentType, instanceId }) => {
const cookie = await resolveOperationCookie(instanceId, origin);
@@ -510,6 +524,7 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane
resources,
messages,
registerDeletionPreparationRoute: false,
onEvent: (envelope) => eventJournal.append(envelope),
});
registerFleetRoutes(app, {
instances,
@@ -584,7 +599,28 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane
.catch(() => undefined);
// Delivered and abandoned outbox rows otherwise accumulate forever.
void Promise.resolve()
.then(() => smsOutbox.prune(30 * 24 * 60 * 60 * 1000))
.then(() => smsOutbox.prune(RETENTION_HISTORY_MS))
.catch(() => undefined);
void Promise.resolve()
.then(() => eventJournal.prune())
.catch(() => undefined);
// Connection probes write a log row per beat per node; without a sweep
// that alone is thousands of rows a day.
void Promise.resolve()
.then(() =>
connectionLogs.prune({
before: new Date(Date.now() - RETENTION_HISTORY_MS).toISOString(),
}),
)
.catch(() => undefined);
void Promise.resolve()
.then(() => audit.pruneBefore(new Date(Date.now() - RETENTION_AUDIT_MS).toISOString()))
.catch(() => undefined);
void Promise.resolve()
.then(() => secureExecution.pruneTerminalPreparations(RETENTION_PREPARATION_MS))
.catch(() => undefined);
void Promise.resolve()
.then(() => centralNotifications.pruneFinishedQueue(RETENTION_HISTORY_MS))
.catch(() => undefined);
}, logPruneIntervalMs)
: undefined;
@@ -670,6 +670,16 @@ export const MIGRATIONS: readonly Migration[] = [
'CREATE INDEX idx_device_identities_imei ON device_identities(imei, instance_id)',
],
},
{
id: 18,
name: 'console-auth-scrypt-params',
statements: [
// Legacy hashes were derived with N=16384; new derivations use stronger
// parameters. The column records the derivation per row; NULL means legacy
// and is transparently upgraded after the next successful login.
'ALTER TABLE console_auth_config ADD COLUMN password_kdf TEXT',
],
},
];
const createMigrationsTable = `CREATE TABLE schema_migrations (
@@ -49,6 +49,49 @@ afterEach(async () => {
for (const db of dbs.splice(0)) if (db.open) db.close();
});
async function seedLegacyHash(db: Database.Database, password: string): Promise<void> {
const { scrypt } = await import('node:crypto');
const salt = 'a'.repeat(32);
const hash = await new Promise<Buffer>((resolve, reject) =>
scrypt(password, Buffer.from(salt, 'hex'), 32, { N: 16_384, r: 8, p: 1 }, (error, key) =>
error ? reject(error) : resolve(key),
),
);
db.prepare(
`INSERT INTO console_auth_config
(singleton,protection_enabled,password_salt,password_hash,password_revision,password_kdf,updated_at)
VALUES (1,1,?,?,1,NULL,?)`,
).run(salt, hash.toString('hex'), new Date().toISOString());
}
describe('console password derivation upgrade', () => {
it('verifies a legacy N=16384 hash and transparently rehashes with current params', async () => {
const { app, db, auth } = fixture();
await seedLegacyHash(db, PASSWORD);
const before = db
.prepare('SELECT password_hash, password_kdf FROM console_auth_config WHERE singleton=1')
.get() as { password_hash: string; password_kdf: string | null };
expect(before.password_kdf).toBeNull();
const login = await app.inject({
method: 'POST',
url: '/api/v1/auth/login',
payload: { password: PASSWORD },
});
expect(login.statusCode).toBe(204);
const after = db
.prepare('SELECT password_hash, password_kdf FROM console_auth_config WHERE singleton=1')
.get() as { password_hash: string; password_kdf: string | null };
expect(after.password_kdf).toBe('scrypt:65536:8:1');
expect(after.password_hash).not.toBe(before.password_hash);
// The upgraded hash still verifies with a plain login through the service.
await expect(auth.login(PASSWORD)).resolves.toBeTruthy();
});
});
describe('aggregate-console password protection HTTP boundary', () => {
it('starts migration-safe as disabled and never returns password material', async () => {
const { app } = fixture();
@@ -1,5 +1,6 @@
import Database from 'better-sqlite3';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { EventEnvelope } from '../../application/events/event-journal.js';
import { buildApp } from '../../app.js';
import {
InstanceSessionStore,
@@ -78,13 +79,36 @@ const fixture = () => {
idFactory: () => `operation-${++id}`,
tokenFactory: () => Buffer.alloc(32, 11).toString('base64url'),
});
const events: Array<Record<string, unknown>> = [];
const onEvent = (envelope: EventEnvelope): void => {
events.push(envelope as unknown as Record<string, unknown>);
};
const app = buildApp({
registerRoutes: (scope) =>
registerInstanceRoutes(scope, { instances, connections, login, deletion }),
registerInstanceRoutes(scope, { instances, connections, login, deletion, onEvent }),
});
return { app, instances };
return { app, instances, events };
};
describe('instance route SSE events', () => {
it('emits an instance envelope when a node is created', async () => {
const { app, events } = fixture();
const created = await app.inject({
method: 'POST',
url: '/api/v1/instances',
payload: {
name: 'Alpha',
origin: 'http://192.168.1.10:8080',
password: { action: 'set', password: 'never-return-this' },
},
});
expect(created.statusCode).toBe(201);
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({ kind: 'instance', requestId: expect.any(String) });
expect(String(events[0]?.instanceId)).toBeTruthy();
});
});
describe('instance HTTP routes', () => {
it('creates, lists, gets, updates and deletes redacted instance resources', async () => {
const { app } = fixture();
@@ -202,6 +226,62 @@ describe('instance HTTP routes', () => {
await app.close();
});
it('accepts the bodyless confirmation DELETE the console sends, and rejects a JSON claim', async () => {
// Regression: the console used to send Content-Type: application/json on this bodyless
// request. Fastify then rejected the empty body with 400 HTTP_400 before the route ran, so
// "delete node" silently did nothing. Deletion carries its intent in headers, so a request
// that merely *claims* a JSON body must fail loudly while the real shape must reach the route.
const { app } = fixture();
const created = await app.inject({
method: 'POST',
url: '/api/v1/instances',
payload: { name: 'Alpha', origin: 'http://192.168.1.10:8080' },
});
const id = (created.json() as { id: string }).id;
const preparation = await app.inject({
method: 'POST',
url: '/api/v1/operations/prepare',
payload: {
operationId: 'deleteInstance',
targets: [{ instanceId: id, revision: 1 }],
parameters: { parameterSchemaId: DELETE_INSTANCE_PARAMETER_SCHEMA_ID, fields: [] },
},
});
const prepared = preparation.json() as { id: string; confirmationToken: string };
const confirmationHeaders = {
'if-match': '"rev-1"',
'x-preparation-id': prepared.id,
'x-confirmation-token': prepared.confirmationToken,
};
// The old broken shape: an empty body that still declares itself as JSON.
const claimedJsonBody = await app.inject({
method: 'DELETE',
url: `/api/v1/instances/${id}`,
headers: { ...confirmationHeaders, 'content-type': 'application/json' },
});
expect(claimedJsonBody.statusCode).toBe(400);
expect(claimedJsonBody.json()).toMatchObject({ code: 'HTTP_400' });
// The node must survive the rejected attempt.
expect((await app.inject({ method: 'GET', url: `/api/v1/instances/${id}` })).statusCode).toBe(
200,
);
// The real console shape: headers only, no Content-Type, no body.
const accepted = await app.inject({
method: 'DELETE',
url: `/api/v1/instances/${id}`,
headers: confirmationHeaders,
});
expect(accepted.statusCode).toBe(202);
expect(accepted.json()).toMatchObject({ operationId: 'deleteInstance', status: 'succeeded' });
expect((await app.inject({ method: 'GET', url: `/api/v1/instances/${id}` })).statusCode).toBe(
404,
);
await app.close();
});
it('rejects invalid input and maps domain errors to stable RFC problem details', async () => {
const { app } = fixture();
const invalid = await app.inject({
+29 -1
View File
@@ -20,6 +20,8 @@ import {
DeleteInstanceOperationError,
} from '../../application/operations/delete-instance-operation.js';
import type { InstanceResourceService } from '../../application/resources/instance-resource-service.js';
import { randomUUID } from 'node:crypto';
import type { EventEnvelope } from '../../application/events/event-journal.js';
import {
MessageServiceError,
type InstanceMessageService,
@@ -33,6 +35,8 @@ export interface InstanceRoutesOptions {
readonly resources?: InstanceResourceService;
readonly messages?: InstanceMessageService;
readonly registerDeletionPreparationRoute?: boolean;
/** Receives instance lifecycle envelopes so the SSE journal can invalidate fleet views. */
readonly onEvent?: (envelope: EventEnvelope) => void;
}
const problem = (
request: FastifyRequest,
@@ -56,6 +60,25 @@ const problem = (
detail,
requestId: request.id,
});
const emitInstanceEvent = (
options: { readonly onEvent?: (envelope: EventEnvelope) => void },
instanceId: string,
requestId: string,
): void => {
if (!options.onEvent) return;
try {
options.onEvent({
kind: 'instance',
id: randomUUID(),
occurredAt: new Date().toISOString(),
requestId,
instanceId,
});
} catch {
// A journal failure must never fail the mutation itself.
}
};
const domainProblem = (request: FastifyRequest, error: InstanceServiceError) => {
const status = instanceErrorStatus[error.code];
return problem(
@@ -344,6 +367,7 @@ export function registerInstanceRoutes(app: FastifyInstance, options: InstanceRo
async (request, reply) => {
try {
const created = await options.instances.create(bodyInput(request.body));
emitInstanceEvent(options, created.id, request.id);
return reply.header('ETag', etag(created)).code(201).send(created);
} catch (error) {
if (error instanceof InstanceServiceError)
@@ -370,7 +394,10 @@ export function registerInstanceRoutes(app: FastifyInstance, options: InstanceRo
app.get(
'/api/v1/instances/:instanceId/resources',
wrap(async (request) =>
options.resources!.get((request.params as { instanceId: string }).instanceId),
// A single selected device is the one place a live probe is expected.
options.resources!.get((request.params as { instanceId: string }).instanceId, {
force: true,
}),
),
);
if (options.messages) {
@@ -431,6 +458,7 @@ export function registerInstanceRoutes(app: FastifyInstance, options: InstanceRo
revision(request),
bodyPatch(request.body),
);
emitInstanceEvent(options, (request.params as { instanceId: string }).instanceId, request.id);
reply.header('ETag', etag(result));
return result;
}),
+1 -1
View File
@@ -288,7 +288,7 @@ export function AuditPage({ dataSource, refreshSignal = 0 }: AuditPageProps) {
<h1 id="audit-title">审计</h1>
<p>只读运维审计事件。</p>
</div>
<Button htmlType="button" size="small" onClick={() => setAttempt((value) => value + 1)}>
<Button htmlType="button" onClick={() => setAttempt((value) => value + 1)}>
刷新审计事件
</Button>
</div>
@@ -59,6 +59,7 @@ describe('FleetIdentityPanel', () => {
render(<FleetIdentityPanel dataSource={source()} onClose={() => {}} onChanged={() => {}} />);
const dialog = await screen.findByRole('dialog', { name: '设备身份核对' });
await screen.findByText('已记录 2 台 · 待确认 1 台');
expect(dialog.textContent).toContain('已记录 2 台 · 待确认 1 台');
expect(screen.getByText('机房 A')).toBeTruthy();
expect(screen.getByText('同一 IMEI 出现在多台设备记录上')).toBeTruthy();
@@ -40,6 +40,7 @@ describe('FleetOrganizationPanel', () => {
);
expect(await screen.findByRole('dialog', { name: '分组与标签' })).toBeTruthy();
await screen.findByRole('button', { name: '编辑分组 核心' });
const core = screen.getByRole('button', { name: '编辑分组 核心' });
const row = core.closest('li');
expect(row?.textContent).toContain('2 台设备');
+11
View File
@@ -420,6 +420,17 @@ describe('FleetPage search and filter toolbar', () => {
});
describe('FleetPage batch and restart actions', () => {
it('keeps top-level create and batch actions on the same control scale', () => {
render(<FleetPage initialData={snapshot} />);
const create = screen.getByRole('link', { name: '添加实例' });
const batch = screen.getByRole('button', { name: '批量选择' });
expect(create.classList.contains('primary-action')).toBe(true);
expect(batch.classList.contains('animal-btn-small')).toBe(false);
expect(batch.className).toMatch(/animal-btn-middle-[A-Za-z0-9]+/);
});
it('keeps selection controls hidden until batch-selection mode is entered', () => {
render(<FleetPage initialData={snapshot} />);
expect(screen.queryByRole('checkbox', { name: /选择 Alpha modem/ })).toBeNull();
-1
View File
@@ -984,7 +984,6 @@ export function FleetPage({
) : null}
<Button
htmlType="button"
size="small"
onClick={() => {
if (selectionMode) setSelectedIds(new Set());
setSelectionMode((value) => !value);
@@ -148,7 +148,8 @@ describe('Phase 6.3 Device Network read module', () => {
within(screen.getByRole('region', { name: '接口与地址' })).queryByRole('button'),
).toBeNull();
// Actions come from the pinned catalog; without a reachable API the card stays honest.
const actions = await screen.findByRole('region', { name: '设备操作' });
await screen.findByText('无法读取该模块的可用操作,请稍后重试。');
const actions = screen.getByRole('region', { name: '设备操作' });
expect(actions.textContent).toContain('无法读取该模块的可用操作');
});
@@ -109,12 +109,14 @@ describe('instance API data source', () => {
method: 'DELETE',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'If-Match': '"rev-3"',
'X-Preparation-Id': 'prep-1',
'X-Confirmation-Token': token,
},
});
// A bodyless request must not claim a JSON body: the server's parser rejects the empty body
// with 400 before the confirmation headers are ever read, so deletion never happens.
expect(fetch.mock.calls[2]?.[1]?.headers).not.toHaveProperty('Content-Type');
expect(fetch.mock.calls[2]?.[1]?.body).toBeUndefined();
// The confirmation is a bearer secret: it belongs in a header, never in a URL or a body.
expect(fetch.mock.calls.every(([url]) => !String(url).includes(token))).toBe(true);
@@ -89,9 +89,17 @@ interface JsonResponse {
readonly response: Response;
}
async function request(url: string, init?: RequestInit): Promise<JsonResponse> {
// Content-Type describes the body. Sending it on a bodyless request makes the server's JSON
// parser reject the empty body with 400 before the route runs — which silently killed every
// DELETE (deletion) and POST (test-connection) that carries its intent in headers alone.
const hasBody = init?.body !== undefined && init.body !== null;
const response = await fetch(url, {
...init,
headers: { Accept: 'application/json', 'Content-Type': 'application/json', ...init?.headers },
headers: {
Accept: 'application/json',
...(hasBody ? { 'Content-Type': 'application/json' } : {}),
...init?.headers,
},
});
if (!response.ok) throw await safeError(response);
return {
+1 -2
View File
@@ -418,14 +418,13 @@ export function JobsPage({ dataSource, refreshSignal = 0 }: JobsPageProps) {
</label>
<Button
htmlType="button"
size="small"
aria-label="刷新任务"
onClick={() => setManualRefresh((value) => value + 1)}
>
刷新
</Button>
{canReconcile ? (
<Button htmlType="button" size="small" aria-label="对账中断任务" onClick={runReconcile}>
<Button htmlType="button" aria-label="对账中断任务" onClick={runReconcile}>
{reconciling ? '对账中…' : '对账中断任务'}
</Button>
) : null}
@@ -1,5 +1,5 @@
// @vitest-environment jsdom
import { cleanup, render, screen, within } from '@testing-library/react';
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, it, vi } from 'vitest';
@@ -28,6 +28,11 @@ async function mount(overrides: Partial<SystemMaintenanceDataSource> = {}) {
const data = source(overrides);
const view = render(<ConnectionSettingsPanel dataSource={data} />);
const region = await screen.findByRole('region', { name: '连接设置' });
if (!overrides.connection) {
await waitFor(() => {
expect(within(region).getByLabelText('心跳间隔(秒)').value).toBe('30');
});
}
return { ...view, data, region };
}
@@ -204,7 +204,9 @@ export function InstanceSettingsPage({
<Title color="app-green">实例</Title>
</h1>
</div>
<a href="/instances/new">添加实例</a>
<a className="primary-action" href="/instances/new">
添加实例
</a>
</header>
{unavailable ? (
+925 -354
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,100 @@
// @vitest-environment jsdom
import { readFile } from 'node:fs/promises';
import { cleanup, render } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { AppShell } from '../app-shell.js';
import { flatten } from './test-css.js';
function channel(value: number): number {
const ratio = value / 255;
return ratio <= 0.04045 ? ratio / 12.92 : ((ratio + 0.055) / 1.055) ** 2.4;
}
function luminance(rgb: readonly [number, number, number]): number {
return 0.2126 * channel(rgb[0]) + 0.7152 * channel(rgb[1]) + 0.0722 * channel(rgb[2]);
}
function parseColor(input: string): readonly [number, number, number] | null {
const m = input.match(/rgba?\(([^)]+)\)/);
if (!m) return null;
const parts = m[1].split(',').map((p) => Number.parseFloat(p.trim()));
return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0];
}
function contrast(
fore: readonly [number, number, number],
back: readonly [number, number, number],
) {
const l1 = luminance(fore);
const l2 = luminance(back);
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
}
function effectiveBackground(element: Element): readonly [number, number, number] {
let node: Element | null = element;
while (node) {
const value = parseColor(window.getComputedStyle(node).backgroundColor);
const isTransparent =
!value ||
(value[0] === 0 &&
value[1] === 0 &&
value[2] === 0 &&
getComputedStyle(node).backgroundColor === 'rgba(0, 0, 0, 0)');
if (!isTransparent) return value ?? [255, 255, 255];
node = node.parentElement;
}
return [233, 244, 223];
}
const ROUTES = [
'/fleet',
'/fleet/messages',
'/fleet/notifications',
'/fleet/logs',
'/automation',
'/jobs',
'/audit',
'/settings/system',
] as const;
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe('painted control contrast', () => {
it('keeps every rendered control at WCAG AA', { timeout: 60_000 }, async () => {
const raw = await readFile(
new URL('file:///Users/chick/.local/opt/multi-simadmin/apps/web/src/styles.css'),
'utf8',
);
const style = document.createElement('style');
style.textContent = flatten(raw);
document.head.append(style);
const failures: string[] = [];
for (const pathname of ROUTES) {
const { unmount } = render(<AppShell pathname={pathname} version="0.1.0" />);
await new Promise((done) => window.setTimeout(done, 40));
for (const element of document.querySelectorAll<HTMLElement>('button, a, [role="button"]')) {
const cs = window.getComputedStyle(element);
const fore = parseColor(cs.color);
if (!fore) continue;
const back = effectiveBackground(element);
const ratio = contrast(fore, back);
if (ratio >= 4.5) continue;
const label = (element.textContent ?? '').trim().slice(0, 30) || element.className;
failures.push(
`${pathname} ${ratio.toFixed(2)}:1 fg=${cs.color} bg=rgb(${back.join(',')}) <${element.tagName.toLowerCase()} class="${element.className}"> "${label}"`,
);
}
unmount();
}
for (const line of failures) console.log(line);
expect(failures).toEqual([]);
});
});
+403
View File
@@ -0,0 +1,403 @@
import { readFile } from 'node:fs/promises';
import { describe, expect, it } from 'vitest';
type Rule = { selector: string; declarations: Record<string, string> };
/**
* The stylesheet is layered by years of incremental patches, so asserting on raw substrings
* proves nothing about what the browser finally paints. These helpers resolve the cascade.
*/
function parseDeclarations(body: string): Record<string, string> {
const declarations: Record<string, string> = {};
for (const part of body.split(';')) {
const separator = part.indexOf(':');
if (separator === -1) continue;
const property = part.slice(0, separator).trim();
const value = part.slice(separator + 1).trim();
if (property && value) declarations[property] = value;
}
return declarations;
}
function collectRules(css: string, rules: Rule[] = []): Rule[] {
let index = 0;
let preludeStart = 0;
while (index < css.length) {
if (css[index] !== '{') {
index += 1;
continue;
}
const prelude = css.slice(preludeStart, index).trim();
let cursor = index + 1;
let depth = 1;
while (cursor < css.length && depth > 0) {
if (css[cursor] === '{') depth += 1;
else if (css[cursor] === '}') depth -= 1;
cursor += 1;
}
const body = css.slice(index + 1, cursor - 1);
if (prelude.startsWith('@')) {
collectRules(body, rules);
} else if (prelude) {
for (const selector of prelude.split(',')) {
const trimmed = selector.replace(/\s+/g, ' ').trim();
if (trimmed) rules.push({ selector: trimmed, declarations: parseDeclarations(body) });
}
}
preludeStart = cursor;
index = cursor;
}
return rules;
}
async function loadStylesheet() {
const css = await readFile(new URL('../styles.css', import.meta.url), 'utf8');
const rules = collectRules(css.replace(/\/\*[\s\S]*?\*\//g, ''), []);
const tokens: Record<string, string> = {};
for (const rule of rules) {
if (rule.selector !== ':root') {
continue;
}
for (const [property, value] of Object.entries(rule.declarations)) {
if (property.startsWith('--')) tokens[property] = value;
}
}
const wins = (target: string, property: string) => {
let winner: string | undefined;
for (const rule of rules) {
if (!rule.selector.split(',').some((entry) => entry.replace(/\s+/g, ' ').trim() === target))
continue;
const value = rule.declarations[property];
if (value !== undefined) winner = value;
}
return winner;
};
const resolve = (value: string | undefined): string | undefined => {
if (value === undefined) return undefined;
return value.replace(/var\((--[a-z0-9-]+)\)/gi, (_, name: string) => tokens[name] ?? '');
};
const exact = (selector: string, property: string) =>
rules.find((rule) => rule.selector === selector && rule.declarations[property] !== undefined)
?.declarations[property];
return { rules, tokens, wins, resolve, exact };
}
function channel(value: number): number {
const ratio = value / 255;
return ratio <= 0.04045 ? ratio / 12.92 : ((ratio + 0.055) / 1.055) ** 2.4;
}
function contrastRatio(foreground: string, background: string): number {
const parse = (input: string) => {
const hex = input.trim().match(/^#([0-9a-f]{6})$/i);
if (!hex) throw new Error(`expected a 6 digit hex colour, got "${input}"`);
const int = Number.parseInt(hex[1], 16);
return [int >> 16, (int >> 8) & 0xff, int & 0xff] as const;
};
const luminance = (input: string) => {
const [red, green, blue] = parse(input);
return 0.2126 * channel(red) + 0.7152 * channel(green) + 0.0722 * channel(blue);
};
const lighter = Math.max(luminance(foreground), luminance(background));
const darker = Math.min(luminance(foreground), luminance(background));
return (lighter + 0.05) / (darker + 0.05);
}
describe('button system', () => {
it('exposes one compact height and radius scale', async () => {
const { tokens } = await loadStylesheet();
expect(tokens['--button-height']).toBe('2.375rem');
expect(tokens['--button-height-compact']).toBe('2.125rem');
expect(tokens['--button-height-large']).toBe('2.625rem');
expect(tokens['--icon-button-size']).toBe('2.5rem');
expect(tokens['--button-radius']).toBe('10px');
});
it('paints the primary action with the accessible token pair, not the old teal pill', async () => {
const { rules, wins, resolve } = await loadStylesheet();
// A bare `.primary-action` selector has specificity (0,1,0) and loses to the generic
// `button:not([class*='animal-'])` surface rule at (0,1,1). That is what turned the label
// white on cream, so the winning declarations must live on an element-qualified selector.
const bare = rules.filter(
(rule) =>
rule.declarations.background !== undefined &&
rule.selector.split(',').some((entry) => entry.trim() === '.primary-action'),
);
expect(bare.map((rule) => rule.selector)).toEqual([]);
expect(wins('button.primary-action', 'border-radius')).toBe('var(--button-radius)');
const background = resolve(wins('button.primary-action', 'background'));
const color = resolve(wins('button.primary-action', 'color'));
expect(background).toMatch(/^#[0-9a-f]{6}$/i);
expect(color).toMatch(/^#[0-9a-f]{6}$/i);
expect(contrastRatio(color as string, background as string)).toBeGreaterThanOrEqual(4.5);
});
it('keeps disabled actions legible instead of fading them out', async () => {
const { wins, resolve, tokens } = await loadStylesheet();
expect(wins('button.primary-action:disabled', 'opacity')).toBe('1');
expect(wins('button:disabled', 'opacity')).toBe('1');
expect(
contrastRatio(
resolve(tokens['--button-disabled-text']) as string,
resolve(tokens['--button-disabled-bg']) as string,
),
).toBeGreaterThanOrEqual(3);
});
it('uses one accessible field scale shared by inputs, selects and textareas', async () => {
const { tokens, exact } = await loadStylesheet();
expect(tokens['--field-height']).toBe('2.375rem');
expect(tokens['--field-radius']).toBe('10px');
expect(tokens['--field-border']).toBe('var(--line-strong)');
expect(tokens['--field-focus-border']).toBe('var(--button-focus-ring)');
for (const selector of ['button', 'input', 'select', 'textarea']) {
expect(exact(selector, 'border')).toBe('1px solid var(--field-border)');
expect(exact(selector, 'background')).toBe('var(--field-bg)');
}
for (const selector of ['input:focus', 'select:focus', 'textarea:focus']) {
expect(exact(selector, 'border-color')).toBe('var(--field-focus-border)');
}
});
it('normalises semantic surfaces in notification, log and message centres', async () => {
const { tokens, rules } = await loadStylesheet();
const semanticNames = [
'--workbench-mint-border',
'--workbench-warning-border',
'--workbench-warning-text',
'--workbench-warning-soft',
'--workbench-danger-border',
];
expect(tokens['--workbench-warning-text']).toBe('#825f0c');
expect(semanticNames.every((name) => /^#[0-9a-f]{6}$/i.test(tokens[name] ?? ''))).toBe(true);
expect(
contrastRatio(tokens['--workbench-warning-text'], tokens['--workbench-warning-soft']),
).toBeGreaterThanOrEqual(4.5);
expect(
rules
.filter((rule) =>
[
'.fleet-notifications-retry-all',
'.fleet-messages-delete',
'.fleet-log-filters select',
].some((selector) => rule.selector.split(',').some((entry) => entry.trim() === selector)),
)
.every((rule) => !Object.values(rule.declarations).join(' ').includes('rgba(')),
).toBe(true);
const css = (await readFile(new URL('../styles.css', import.meta.url), 'utf8')).replace(
/\/\*[\s\S]*?\*\//g,
'',
);
expect(css).not.toMatch(
/#8a6722|#fff8e8|#fffafa|rgba\(17,\s*119,\s*110|rgba\(170,\s*120,\s*35|rgba\(182,\s*77,\s*72/,
);
});
it('keeps disabled and unavailable states legible without fading controls', async () => {
const { rules } = await loadStylesheet();
const stateSelectors = [
'.risk-confirm:has(input:disabled)',
'.fleet-messages-device-item[data-unavailable]',
'.fleet-log-retention-value:disabled',
'.fleet-notification-entry-button:disabled',
'[class*=animal-btn-]:disabled',
];
const faded = rules
.filter(
(rule) =>
stateSelectors.some((selector) => rule.selector.includes(selector)) &&
rule.declarations.opacity !== undefined &&
rule.declarations.opacity !== '1',
)
.map((rule) => rule.selector);
expect(faded).toEqual([]);
});
it('does not leave stale compact command-control sizes in later overrides', async () => {
const { rules } = await loadStylesheet();
const compactControls = [
'.global-navigation a',
'.fleet-sidebar-toggle',
'.fleet-cross-health-item',
'.fleet-select-all button',
'.batch-entry-actions button',
'.fleet-card-context-trigger',
'.fleet-group-tabs button',
'.fleet-subnavigation a',
'.automation-workspace .row-actions button',
'.fleet-log-retention-actions .primary-action',
];
const stale = rules
.filter((rule) =>
compactControls.some((selector) => rule.selector.replace(/\s+/g, ' ').includes(selector)),
)
.filter((rule) => {
const heights = [rule.declarations['min-height'], rule.declarations.height].filter(
(value): value is string => value !== undefined,
);
return heights.some((value) =>
['1.9rem', '2rem', '2.15rem', '2.2rem', '2.25rem'].includes(value),
);
})
.map((rule) => rule.selector);
expect(stale).toEqual([]);
});
it('keeps primitive actions aligned with the standard text-button scale', async () => {
const { rules, wins } = await loadStylesheet();
const standardControls = [
'.instance-context nav a',
'.dense-table th button',
'.organization-add',
'.fleet-card-admin-actions button',
'.fleet-messages-device-item',
'.maintenance-backup-actions a',
];
const stale = rules
.filter((rule) =>
standardControls.some((selector) => rule.selector.replace(/\s+/g, ' ').includes(selector)),
)
.filter((rule) => {
const declarations = Object.values(rule.declarations).join(' ');
const sizes = [rule.declarations['min-height'], rule.declarations.height].filter(
(value): value is string => value !== undefined,
);
return (
sizes.some((value) =>
['1.8rem', '2.1rem', '2.3rem', '2.35rem', '2.4rem'].includes(value),
) || declarations.includes('border-radius: 999px')
);
})
.map((rule) => rule.selector);
expect(stale).toEqual([]);
for (const selector of [
'button.primary-action',
'a.primary-action',
"[class*='animal-btn-middle']",
]) {
expect(wins(selector, 'min-height')).toBe('var(--button-height)');
expect(wins(selector, 'padding')).toBe('0.45rem 0.9rem');
expect(wins(selector, 'font-size')).toBe('0.82rem');
}
for (const selector of ['.organization-add', '.maintenance-backup-actions a']) {
expect(wins(selector, 'min-height')).toBe('var(--button-height-compact)');
expect(wins(selector, 'padding')).toBe('0.3rem 0.7rem');
expect(wins(selector, 'font-size')).toBe('0.76rem');
expect(wins(selector, 'border-radius')).toBe('var(--button-radius-compact)');
}
});
it('keeps grouped, segmented and workbench controls on the shared scale', async () => {
const { exact, wins } = await loadStylesheet();
const compactControls = [
'.fleet-group-tabs button',
'.segmented-control button',
'.identity-actions button',
'.fleet-notifications-retry-all',
'.fleet-messages-filter-chip',
'.fleet-messages-delete',
'.fleet-switch',
];
for (const selector of compactControls) {
expect(exact(selector, 'min-height')).toBe('var(--button-height-compact)');
expect(exact(selector, 'padding')).toBe('0.3rem 0.7rem');
expect(exact(selector, 'font-size')).toBe('0.76rem');
}
// Small touch surfaces deliberately promote panel controls to the standard height.
expect(wins('.identity-actions button', 'min-height')).toBe('var(--button-height)');
expect(wins('.fleet-group-tabs button', 'border-radius')).toBe('var(--button-radius-compact)');
expect(wins('.workbench-tabs button', 'min-height')).toBe('var(--button-height-large)');
});
it('keeps privacy toggles on the shared icon-button target', async () => {
const { wins } = await loadStylesheet();
expect(wins('.fleet-phone-privacy-toggle', 'width')).toBe('var(--icon-button-size)');
expect(wins('.fleet-phone-privacy-toggle', 'height')).toBe('var(--icon-button-size)');
expect(wins('.fleet-phone-privacy-toggle', 'border-radius')).toBe('var(--button-radius)');
});
it('keeps workbench text and focus indicators on the accessible brand scale', async () => {
const { tokens, resolve } = await loadStylesheet();
expect(tokens['--workbench-mint']).toBe('#0f6e66');
expect(tokens['--workbench-muted']).toBe('#6f6350');
expect(tokens['--button-focus-ring']).toBe('#0f6e66');
expect(tokens['--animal-primary-color-hover']).toBe('#0f6e66');
expect(tokens['--animal-text-color-muted']).toBe('#6f6350');
expect(
contrastRatio(
resolve(tokens['--workbench-muted']) as string,
resolve(tokens['--animal-canvas-fallback'] ?? '#e9f4df') as string,
),
).toBeGreaterThanOrEqual(4.5);
});
it('does not resurrect low-contrast legacy accents', async () => {
const css = (await readFile(new URL('../styles.css', import.meta.url), 'utf8')).replace(
/\/\*[\s\S]*?\*\//g,
'',
);
expect(css).not.toMatch(/#19b8aa|#178f84|#109d92|rgba\(25,\s*184,\s*170/i);
expect(css).not.toMatch(/#d95757|rgba\(217,\s*87,\s*87/i);
expect(css).not.toMatch(/#8f806b|#81745f|#8d816e|#766a58|#7f725f/);
});
it('normalises Animal UI buttons through attribute selectors, not hashed classes', async () => {
const { rules, wins } = await loadStylesheet();
const selectors = rules.map((rule) => rule.selector);
expect(selectors.some((selector) => selector.includes("[class*='animal-btn-']"))).toBe(true);
expect(selectors.some((s) => s.includes("[class*='animal-btn-small']"))).toBe(true);
// Hashed module classes such as animal-btn-QoKe1 are regenerated on every library upgrade
// and silently stop matching, so only the semantic suffixes may appear here.
const semantic =
'small|middle|large|icon|block|primary|default|dashed|text|link|ghost|danger|loading';
expect(selectors.join(' ')).not.toMatch(
new RegExp(`animal-btn-(?!${semantic}\\b)[A-Za-z0-9]{4,}`),
);
expect(wins("[class*='animal-btn-']", 'border-radius')).toBe('var(--button-radius)');
});
it('gives icon buttons a square target that matches the text scale', async () => {
const { wins } = await loadStylesheet();
expect(wins('button.icon-button', 'width')).toBe('var(--icon-button-size)');
expect(wins('button.icon-button', 'border-radius')).toBe('var(--button-radius)');
expect(wins('button.fleet-card-menu-trigger', 'border-radius')).toBe('var(--button-radius)');
expect(wins('button.row-action-trigger', 'min-height')).toBe('var(--icon-button-size)');
});
it('drops the capsule radius from every primary action variant', async () => {
const { rules } = await loadStylesheet();
const offenders = rules
.filter((rule) =>
rule.selector
.split(',')
.some((entry) => /(^|[\s>+~])\.primary-action(?![-\w])/.test(entry.trim())),
)
.filter((rule) => /999px|50%/.test(Object.values(rule.declarations).join(' ')));
expect(offenders.map((rule) => rule.selector)).toEqual([]);
});
});
+40
View File
@@ -0,0 +1,40 @@
// jsdom can read custom properties, but computed values that inherit through multiple
// layers are easier to audit when the stylesheet is flattened to literals first.
export function flatten(css: string): string {
const tokens: Record<string, string> = {};
const root = /:root\s*\{([^}]*)\}/g;
let match: RegExpExecArray | null;
while ((match = root.exec(css))) {
for (const declaration of match[1]!.split(';')) {
const separator = declaration.indexOf(':');
if (separator === -1) continue;
const property = declaration.slice(0, separator).trim();
const value = declaration.slice(separator + 1).trim();
if (property.startsWith('--')) tokens[property] = value;
}
}
const resolved: Record<string, string> = {};
function resolve(name: string, seen: ReadonlySet<string> = new Set()): string {
const cacheKey = `${name}\u0000${[...seen].join('\u0001')}`;
if (resolved[cacheKey] !== undefined) return resolved[cacheKey]!;
if (seen.has(name)) return '';
const raw = tokens[name];
if (raw === undefined) return '';
return substitute(raw, new Set(seen).add(name));
}
function substitute(text: string, seen: ReadonlySet<string> = new Set()): string {
let output = text;
for (let guard = 0; guard < 12 && /var\(/.test(output); guard += 1) {
output = output.replace(
/var\(\s*(--[a-z0-9-]+)\s*(?:,\s*([^()]*))?\)/gi,
(_, name: string, fallback?: string) =>
resolve(name, seen) || (fallback ? substitute(fallback, seen) : ''),
);
}
return output;
}
return substitute(css.replace(/^\s*--[a-z0-9-]+\s*:[^;]*;\s*$/gm, ''));
}
+12
View File
@@ -4,6 +4,18 @@ import { defineConfig } from 'vite';
export default defineConfig(({ mode }) => ({
plugins: [react()],
build: {
rollupOptions: {
output: {
// Framework code changes far less often than app code; splitting keeps
// the vendor chunk cacheable across releases instead of one big bundle.
manualChunks(id) {
if (id.includes('node_modules')) return 'vendor';
return undefined;
},
},
},
},
resolve: {
alias:
mode === 'test'
@@ -0,0 +1,193 @@
# iOS 原生 IPA 改写设计
日期:2026-09-08
## 目标
把 multi-simadmin 的管理能力改写为独立 iPhone 应用,而不是为现有 Web 服务做一个远程客户端或 WebView 壳。应用面向 iOS 16+ 和 TrollStore 安装环境,构建流程产出未签名 IPA;Apple 证书、开发者账号、TestFlight 和上架签名不在本项目范围内。
原生应用直接连接用户配置的各 SimAdmin 实例,本地承担实例注册、连接状态、消息归档、通知队列、任务历史、审计、日志和维护能力。不要求用户先运行 8788 控制台服务。现有 Web 前端与 Node API 保持不变,直到 iOS 版本验证后再另行决定发布关系。
## 已确认决策
- 形态:SwiftUI 原生应用。
- 最低系统:iOS 16.0。
- 构建产物:`MultiSimAdmin.ipa`,默认不签名,后续由用户用 TrollStore 或自有证书处理。
- 功能范围:优先保持现有控制台与 Hub 能力,但移除不适用于 iPhone 前台的定时/常驻能力。
- 全局访问密码:移除;实例自身的登录凭据仍保留并安全存储。
- 现有 UI 风格:移植为 iOS 原生组件,不照搬浏览器布局。
## 架构
新增 `apps/ios`,包含独立 Swift 应用。结构分为五层:
1. **App 层**:SwiftUI 应用入口、导航、生命周期和全局错误提示。
2. **Presentation 层**:节点、消息、通知、日志、操作、设置等 SwiftUI 视图与 view model。
3. **Domain 层**:实例、连接、消息、通知、操作、审计、维护等领域服务和值类型。
4. **Upstream 层**:`URLSession` 访问 SimAdmin 实例,管理实例会话 Cookie、超时、响应解析和安全路径白名单。
5. **Persistence 层**:SQLite 持久化本地控制面数据;实例密码等敏感值使用 iOS Keychain。
数据库采用 GRDB 管理迁移与查询,避免手写 SQLite C API。凭据使用 Keychain 的应用私有可迁移项,不进入数据库明文、日志、备份预览或导出文件。
应用不内嵌 Node.js。iOS 版本的消息、通知、任务和审计语义按现有控制面模型重新实现,保留核心字段和用户可见行为。
## 导航与界面
iPhone 主导航使用底部 Tab:
- **节点**:总览、筛选、批量选择、添加/编辑实例、实例详情和设备模块。
- **消息**:跨实例短信归档、会话、发送、删除、手动同步和发送队列。
- **通知**:通道、规则、队列、投递日志和手动投递/重试。
- **日志**:运行日志、连接日志、诊断和审计。
- **设置**:实例管理入口、分组/标签、操作目录、维护、备份、应用信息。
界面移植现有设计语言:
- 画布使用浅灰 `#f3f5f8`,主要内容使用白色卡片和柔和描边。
- 顶部使用深色品牌栏,内容区保持浅色高对比。
- 主操作按钮使用现有青绿色系统,危险操作使用现有红色系统。
- 统一普通、紧凑、大号控件高度和圆角;同屏并排的主要操作必须尺寸一致。
- 使用 SF Symbols 表达刷新、筛选、添加、删除、发送、密钥和状态,不使用文字包住所有图标。
- 卡片、表格、表单和确认弹窗按 iPhone 动态字体、安全区、横竖屏和 Assistive Access 语义适配。
## 功能映射
### 实例与节点
- 添加、编辑、删除实例,支持名称、地址、标签、分组和实例密码。
- 连接测试、登录、登出、在线/离线/需登录/降级状态和快照新旧程度。
- 批量选择、批量测试、批量删除、分组筛选、标签筛选、状态筛选和搜索。
- 节点资源摘要、手机号、温度、版本、新鲜度和最新短信预览。
- 删除实例前先尝试释放 Hub 绑定;无法访问时按现有规则区分“跳过释放”与“失败”。
### 设备模块
实例详情保留上游设备能力模块:总览、蜂窝、SIM、通话、eSIM、WiFi Calling、设备网络、OTA、消息、通知、自动化历史、配置和备份。每个模块按现有上游端点读写,使用统一的加载、空态、无权限、超时和确认流程。
高风险动作沿用操作目录中的风险等级和确认文案;系统重启、基带重启、服务重启等动作必须明确显示目标实例。
### 消息
- 直接拉取实例短信列表、会话、统计和详情。
- 发送短信、批量删除、清空、按方向筛选和分页。
- 本地归档跨实例消息,保留方向、号码、内容、时间、状态、来源实例和同步时间。
- 发送队列保留离线重试记录,但只在前台刷新、进入消息页或用户点击“发送”时处理。
### 通知
- 管理通知通道、规则、队列和投递日志。
- 支持基于连接状态、短信和操作事件的手动评估。
- “立即投递”“重试”“重试全部”“清空”只在应用前台执行。
- 投递成功、失败和重试状态进入本地队列/日志表。
不使用 iOS 远程推送、后台任务调度或常驻网络轮询。
### 操作、任务与审计
- 移植操作目录、准备/执行两段式协议、确认令牌、版本检查和风险等级。
- 操作准备写入本地数据库;应用退出时把未完成任务标记为终止或未知结果。
- 保留任务列表、任务详情、条目结果、尝试记录、取消、重试和手动 reconcile。
- 审计事件记录操作者、请求、目标、结果、参数摘要和异常原因。
定时任务创建与自动触发移除。已有调度模型只保留为历史只读视图;新的“手动执行”入口不创建周期任务。
### 日志与系统
- 连接日志、运行日志和诊断汇总进入日志中心。
- 支持筛选、清空、修剪、导出和详情展开。
- 系统维护显示数据库、组件数量、可回收空间、应用版本和平台信息。
- 手动清理、`VACUUM`、校验、备份、恢复、导入、导出和删除备份。
- 自动备份、自动日志修剪和自动数据保留调度移除;保留策略仅作为手动清理的默认参数。
### 分组、标签与身份
- 设备分组和标签完整本地管理,并用于节点筛选、通知规则和批量操作。
- 设备身份记录用于识别异常换绑和重复身份,前台刷新时更新。
- 授权模式支持自动与手动;手动模式在检测到身份变化时要求确认。
## 定时能力的移除边界
以下行为不实现:
- 应用启动后的周期网络轮询。
- iOS 后台任务、静默推送唤醒、常驻 Socket 或后台同步。
- Cron 表达式解析与周期执行。
- 自动备份、自动队列投递、自动日志清理。
- 控制台自更新。
以下行为保留:
- 进入前台、下拉刷新、页面切换或用户点击时执行一次同步。
- 用户主动触发队列投递、备份、清理和 reconcile。
- 前台期间页面可见时的按需状态刷新。
## 上游访问与安全
- 上游请求只允许显式注册的方法和路径,不提供任意 URL 代理。
- 响应大小、超时、JSON 结构和敏感字段按现有上游网关边界限制。
- 实例密码存入 Keychain;会话 Cookie 只保存在内存,不写入 SQLite 或日志。
- 登录会话按实例隔离;修改实例地址后立即清空旧会话。
- 日志不输出 Cookie、Authorization、密码、确认令牌、完整短信正文或敏感通知配置。
- 短信和通知正文在列表中支持脱敏显示,详情页按现有显隐交互显示原文。
iOS 允许访问用户明确输入的 HTTP SimAdmin 实例地址。设置页必须显示“HTTP 明文传输”警告;HTTPS 和受信任内网地址不做额外降级提示。
## 数据模型与迁移
SQLite 保留以下核心表,字段可按 Swift 领域模型调整,但不得丢失现有用户语义:
- 实例:`instances`、`instance_tags`、`tag_registry`、`device_groups`。
- 状态:`status_snapshots`、`capabilities`、`connection_logs`、`device_identities`。
- 消息:`sms_messages`、`sms_outbox`。
- 通知:`notification_channels`、`notification_rules`、`notification_deliveries`、`notification_queue`。
- 操作:`jobs`、`job_items`、`job_attempts`、`operation_preparations`、`audit_events`。
- 设置:`app_settings`、维护保留策略。
首次启动创建 iOS 版本 1 迁移。不导入现有 Node 服务数据库作为第一版要求;后续可在设置页提供数据库导入兼容层。导入时必须拒绝无法识别的 schema 版本,并在写入前创建安全备份。
## 构建 IPA
新增 Swift Package `apps/ios` 和构建脚本 `scripts/build-ios-ipa.sh`。Swift Package 是 iOS 源码与依赖的单一事实来源,不引入 XcodeGen 或检入的 Xcode 工程文件。
1. 使用 `swift build --configuration release --triple arm64-apple-ios16.0` 和 iPhoneOS SDK 编译 `MultiSimAdmin` 可执行文件。
2. 组装 `Payload/MultiSimAdmin.app`,包含可执行文件、`Info.plist`、资源包和应用图标。
3. `Info.plist` 声明 iOS 16 最低版本、iPhone 方向、深浅色外观、ATS 例外和启动屏,但不声明后台模式。
4. 压缩为 `build/ipa/MultiSimAdmin.ipa`。
5. 输出 IPA 路径、大小和 SHA-256。
构建脚本不得要求证书或描述文件。如果目标环境需要伪签名,由用户使用 TrollStore 或自有工具处理;仓库脚本不默认修改二进制签名。
## 错误处理
- 网络失败显示实例名、可读原因和重试操作,不中断其他实例刷新。
- 单个实例失败只影响该实例卡片或详情,不使节点总览整页失败。
- 上游 401 显示“需要登录”,403 显示权限受限,404 显示不支持,超时明确区分网络不可达。
- 删除、恢复、重启、清空和高风险操作使用统一确认弹窗,显示目标数量与不可恢复提示。
- 数据库迁移失败时保持只读启动屏,提示备份文件位置,不自动删除旧数据。
## 测试与验收
XCTest 覆盖:
- 实例创建、地址校验、版本冲突、密码保存、登录会话隔离和删除补偿。
- 上游路径白名单、超时、错误状态、Cookie 注入和响应解析。
- 短信归档、去重、发送、删除、队列前台 flush 和失败重试。
- 通知规则评估、队列处理、通道投递和日志写入。
- 操作准备/执行、确认令牌、任务结果、审计和中断任务恢复。
- 数据库迁移、备份导出、恢复预览和敏感字段排除。
- UI 层验证核心页面可达、空态/错误态存在、按钮尺寸一致、脱敏状态正确。
构建验收:
- iOS 16 Simulator 可安装并启动。
- 390、768 和 1024 点宽度下无横向溢出、文字截断或控件重叠。
- `scripts/build-ios-ipa.sh` 在干净构建目录产出未签名 IPA,并输出 SHA-256。
- 应用不请求定位、相机、麦克风、通讯录或后台刷新权限。
## 非目标
- 不实现 App Store 发布、TestFlight、推送证书或开发者签名。
- 不在 iPhone 上运行 Node.js、Fastify 或现有 SQLite 服务端。
- 不实现后台常驻、Cron 自动化、自动备份或控制台自更新。
- 不支持多用户、远程 Web 登录或浏览器访问 iPhone 内的控制面。
- 不移除实例自身密码能力;只移除 multi-simadmin 的全局访问密码。
-3843
View File
File diff suppressed because it is too large Load Diff
+68
View File
@@ -36,6 +36,7 @@ Multi SimAdmin 一键安装与服务管理
restart 停止后重新启动
status 显示进程、端口与健康状态
uninstall 卸载程序,默认保留数据、令牌和日志
unit (仅 Linux)生成 systemd 用户单元,支持开机自启与在线自更新重启
环境变量:
MULTI_SIMADMIN_HOME 安装根目录(默认 macOS 为 ~/Library/Application Support/multi-simadmin,Linux 为 ~/.local/share/multi-simadmin)
MULTI_SIMADMIN_ALLOWED_HOSTS 额外允许的 LAN 主机名/IP,逗号分隔
@@ -297,6 +298,72 @@ uninstall_app() {
say "程序已卸载;默认保留数据、Gateway token 和日志:$APP_ROOT"
}
write_systemd_units() {
require_platform
[ "$PLATFORM" = Linux ] || die "systemd 单元仅适用于 Linux"
command -v systemctl >/dev/null 2>&1 || die "缺少 systemctl"
[ -f "$SOURCE_DIR/apps/web/dist/index.html" ] || die "尚未安装,请先运行 install"
[ -f "$TOKEN_FILE" ] || die "缺少 Gateway token,请先运行 install"
node_bin=$(command -v node)
unit_dir=${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user
mkdir -p "$unit_dir"
chmod 700 "$unit_dir"
env_file="$APP_ROOT/service-env"
{ printf 'MULTI_SIMADMIN_GATEWAY_TOKEN='; cat "$TOKEN_FILE"; } >"$env_file"
chmod 600 "$env_file"
cat >"$unit_dir/multi-simadmin-api.service" <<EOF
[Unit]
Description=Multi SimAdmin control-plane API
After=network.target
[Service]
WorkingDirectory=$SOURCE_DIR
ExecStart=$node_bin --import tsx $SOURCE_DIR/apps/api/src/production-cli.ts
Restart=on-failure
RestartSec=3
Environment=MULTI_SIMADMIN_DATA_ROOT=$DATA_ROOT
Environment=MULTI_SIMADMIN_DATABASE_PATH=$DATA_ROOT/control-plane.sqlite3
Environment=MULTI_SIMADMIN_SECRET_BACKEND=secret-file
Environment=MULTI_SIMADMIN_SYSTEMD_UNIT=multi-simadmin-api.service
EnvironmentFile=$env_file
UMask=0077
[Install]
WantedBy=default.target
EOF
cat >"$unit_dir/multi-simadmin-gateway.service" <<EOF
[Unit]
Description=Multi SimAdmin LAN gateway
After=network.target multi-simadmin-api.service
[Service]
WorkingDirectory=$SOURCE_DIR
ExecStart=$node_bin --import tsx $SOURCE_DIR/apps/api/src/production-gateway-cli.ts
Restart=on-failure
RestartSec=3
Environment=MULTI_SIMADMIN_CUTOVER_ACK=I ACKNOWLEDGE MULTI-SIMADMIN OWNS PORT 8788
Environment=MULTI_SIMADMIN_GATEWAY_HOST=0.0.0.0
Environment=MULTI_SIMADMIN_GATEWAY_ALLOWED_HOSTS=$(allowed_hosts)
Environment=CANARY_DIST_DIR=$SOURCE_DIR/apps/web/dist
Environment=CANARY_UPSTREAM_PORT=8790
Environment=MULTI_SIMADMIN_SYSTEMD_UNIT=multi-simadmin-gateway.service
EnvironmentFile=$env_file
UMask=0077
[Install]
WantedBy=default.target
EOF
chmod 600 "$unit_dir/multi-simadmin-api.service" "$unit_dir/multi-simadmin-gateway.service"
systemctl --user daemon-reload
say "已写入 systemd 用户单元。启用自启动并启动:"
say " systemctl --user enable --now multi-simadmin-api.service multi-simadmin-gateway.service"
say "注意:由 systemd 托管后请用 systemctl 管理,不要再用本脚本 start/stop,避免双进程争抢端口。"
}
command=${1:-install}
case "$command" in
install) install_app ;;
@@ -305,6 +372,7 @@ case "$command" in
restart) stop_service; start_service ;;
status) status_service ;;
uninstall) uninstall_app ;;
unit) write_systemd_units ;;
help|-h|--help) usage ;;
*) usage >&2; exit 2 ;;
esac
+4 -1
View File
@@ -14,7 +14,10 @@ export function normalizeBaseUrl(rawUrl) {
(ipv4[0] === 169 && ipv4[1] === 254) ||
(ipv4[0] >= 224)
)
const prohibitedIpv6 = hostname === '::' || hostname === '::1' || /^fe[89ab][0-9a-f]:/.test(hostname) || hostname.startsWith('ff') || hostname.startsWith('::ffff:') || hostname.startsWith('64:ff9b:')
// WHATWG canonicalization turns ::127.0.0.1 into ::7f00:1 and ::169.254.169.254 into
// ::a9fe:a9fe; the IPv4-compatible form (:: <1-2 groups>) must be matched after
// canonicalization or it smuggles loopback/metadata targets past the list.
const prohibitedIpv6 = hostname === '::' || hostname === '::1' || /^::[0-9a-f]{1,4}(:[0-9a-f]{1,4})?$/.test(hostname) || /^fe[89ab][0-9a-f]:/.test(hostname) || hostname.startsWith('ff') || hostname.startsWith('::ffff:') || hostname.startsWith('64:ff9b:')
const prohibitedMetadata = hostname === '100.100.100.200' || hostname === '168.63.129.16'
const prohibitedTransition = hostname.startsWith('2002:')
const isIpLiteral = isIP(hostname) !== 0
+9
View File
@@ -190,6 +190,7 @@ export function createSimAdminClient(instance, { fetchImpl = fetch, timeoutMs =
}
let ephemeralSecret = ''
let lastFailedAutoLoginAt = 0
async function ensureAuthenticated({ credential } = {}) {
const status = await fetchJson('/api/auth/status')
const auth = apiData(status.data) || {}
@@ -203,12 +204,20 @@ export function createSimAdminClient(instance, { fetchImpl = fetch, timeoutMs =
return { configured: auth.configured ?? null, authenticated: false, loginAttempted: false, statusCode: status.status, reason: 'password_required' }
}
if (credential !== undefined) ephemeralSecret = ''
// Status polls auto-login with the saved password; without a cooldown a
// stale password turns every refresh into a fresh login attempt, which
// some devices punish with account lockouts.
if (credential === undefined && !ephemeralSecret && Date.now() - lastFailedAutoLoginAt < 60_000) {
return { configured: auth.configured ?? null, authenticated: false, loginAttempted: false, statusCode: status.status, reason: 'login_cooldown' }
}
const login = await request('/api/auth/login', {
method: 'POST',
headers: { 'content-type': 'application/json', accept: 'application/json' },
body: JSON.stringify({ password }),
})
if (credential !== undefined && login.ok) ephemeralSecret = password
if (login.ok) lastFailedAutoLoginAt = 0
else if (credential === undefined) lastFailedAutoLoginAt = Date.now()
return { configured: auth.configured ?? null, authenticated: login.ok, loginAttempted: true, statusCode: login.status, reason: login.ok ? null : 'login_failed' }
}