Compare commits
17
Commits
f6319374cd
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81cef1ad6c | ||
|
|
1caf6985ff | ||
|
|
001659ed91 | ||
|
|
bfe5025943 | ||
|
|
c0adbcbc8a | ||
|
|
61a5aee48d | ||
|
|
70e598208a | ||
|
|
c1be714ba4 | ||
|
|
29a0eee854 | ||
|
|
2977f75129 | ||
|
|
236e78327c | ||
|
|
3c5971e167 | ||
|
|
e4c4bce75b | ||
|
|
c57dc81e42 | ||
|
|
96dd9e2ba6 | ||
|
|
4f42a6f835 | ||
|
|
eed134a25c |
@@ -0,0 +1 @@
|
||||
* text=auto eol=lf
|
||||
@@ -14,7 +14,7 @@
|
||||
- 融合 [SimAdminHub](https://github.com/3899/SimAdminHub) 的管理思路:跨节点短信、通知、自动化与跨域健康自然融入 Fleet 节点总览体系,无需部署或接入外部 Hub。
|
||||
- 敏感配置留在本地 `config.json`,仓库只提交 `config.example.json`。
|
||||
|
||||
## 一键安装(macOS)
|
||||
## 一键安装(macOS / Linux)
|
||||
|
||||
```bash
|
||||
curl -fsSL https://gitea.chickliu.fun/Hermes/multi-simadmin/raw/branch/main/scripts/install.sh | sh
|
||||
@@ -28,7 +28,7 @@ curl -fsSL https://gitea.chickliu.fun/Hermes/multi-simadmin/raw/branch/main/scri
|
||||
|
||||
安装完成后按终端输出访问 `http://<本机局域网 IP>:8788/fleet`。首次密码可在当前管理台直接设置。Gateway 默认监听所有网络接口,因此必须确保主机仅接入可信内网或已通过防火墙限制 8788 的来源;HTTP 部署不得直接暴露到公网,公网开放必须由前置代理提供 HTTPS 和访问控制。
|
||||
|
||||
> 当前生产秘密存储使用 macOS Keychain,因此一键安装脚本暂只支持 macOS。脚本不会覆盖已有源码目录或数据库,也不会占用已被其他进程监听的 8788/8790 端口。
|
||||
> 秘密存储按平台自动选择:macOS 使用 Keychain,Linux 使用数据目录下 0600 权限的 `secrets.json` 文件;也可用 `MULTI_SIMADMIN_SECRET_BACKEND=macos-keychain|secret-file` 显式指定。脚本不会覆盖已有源码目录或数据库,也不会占用已被其他进程监听的 8788/8790 端口。
|
||||
|
||||
### 服务管理
|
||||
|
||||
@@ -43,7 +43,17 @@ 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`(逗号分隔)配置。
|
||||
`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 节点总览与跨域健康
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -9,7 +9,7 @@ describe('ScheduledOperationDispatcher', () => {
|
||||
it('renders SMS time and random macros separately for each target', async () => {
|
||||
const db = new Database(':memory:');
|
||||
migrateDatabase(db);
|
||||
const send = vi.fn(async (_instanceId: string, input: { content: string }) => ({
|
||||
const send = vi.fn(async (_instanceId: string, _input: { content: string }) => ({
|
||||
sent: true as const,
|
||||
}));
|
||||
const dispatcher = new ScheduledOperationDispatcher({
|
||||
@@ -17,6 +17,7 @@ describe('ScheduledOperationDispatcher', () => {
|
||||
operations: { prepare: vi.fn(), execute: vi.fn() },
|
||||
messages: { send },
|
||||
store: {
|
||||
provider: 'macos-keychain',
|
||||
set: vi.fn(),
|
||||
get: vi.fn(async () =>
|
||||
JSON.stringify({
|
||||
@@ -90,7 +91,7 @@ describe('ScheduledOperationDispatcher', () => {
|
||||
execute,
|
||||
},
|
||||
messages: { send: vi.fn() },
|
||||
store: { set: vi.fn(), get: vi.fn(), delete: vi.fn() },
|
||||
store: { provider: 'macos-keychain', set: vi.fn(), get: vi.fn(), delete: vi.fn() },
|
||||
repository: new ScheduledTaskRepository(db),
|
||||
});
|
||||
const result = await dispatcher.dispatch(
|
||||
@@ -179,6 +180,7 @@ describe('ScheduledOperationDispatcher', () => {
|
||||
operations: { prepare: vi.fn(), execute: vi.fn() },
|
||||
messages: { send },
|
||||
store: {
|
||||
provider: 'macos-keychain',
|
||||
set: vi.fn(),
|
||||
get: vi.fn(async () =>
|
||||
JSON.stringify({ recipients: ['13800138000', '13900139000'], content: 'Maintenance' }),
|
||||
@@ -216,7 +218,7 @@ describe('ScheduledOperationDispatcher', () => {
|
||||
execute: vi.fn().mockResolvedValue({ id: 'job-a', status: 'succeeded' }),
|
||||
},
|
||||
messages: { send: vi.fn() },
|
||||
store: { set: vi.fn(), get: vi.fn(), delete: vi.fn() },
|
||||
store: { provider: 'macos-keychain', set: vi.fn(), get: vi.fn(), delete: vi.fn() },
|
||||
repository: new ScheduledTaskRepository(db),
|
||||
});
|
||||
const result = await dispatcher.dispatch(
|
||||
|
||||
@@ -114,6 +114,14 @@ export class ScheduledOperationDispatcher {
|
||||
const schema = operationSchemas[task.operationType];
|
||||
if (!operationId || !schema)
|
||||
return { outcome: 'needs-attention', reason: 'Unsupported scheduled operation', jobIds: [] };
|
||||
// The execution baseline pins delay_seconds to 3; anything else could never
|
||||
// succeed, so refuse up front instead of exhausting the retry policy.
|
||||
if (task.operationType === 'reboot-system' && (task.delaySeconds ?? 3) !== 3)
|
||||
return {
|
||||
outcome: 'needs-attention',
|
||||
reason: 'Scheduled reboots only support the pinned 3-second delay',
|
||||
jobIds: [],
|
||||
};
|
||||
const fields =
|
||||
task.operationType === 'reboot-system'
|
||||
? [
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ScheduledTaskRepository } from './scheduled-task-repository.js';
|
||||
import { ScheduledTaskService } from './scheduled-task-service.js';
|
||||
|
||||
class MemorySecrets implements SecretStore {
|
||||
readonly provider = 'macos-keychain';
|
||||
readonly values = new Map<string, string>();
|
||||
readonly setCalls: Array<{ key: SecretKey; value: string }> = [];
|
||||
blockRotations = false;
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ConnectionProbe } from './connection-probe.js';
|
||||
import { ConnectionLogService } from '../system/connection-log-service.js';
|
||||
|
||||
class Store implements SecretStore {
|
||||
readonly provider = 'macos-keychain';
|
||||
async set(key: { instanceId: string; purpose: string; slot?: string }) {
|
||||
return `keychain://multi-simadmin/${Buffer.from(JSON.stringify([key.instanceId, key.purpose, key.slot])).toString('base64url')}`;
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ describe('InstanceCredentialResolver', () => {
|
||||
it('resolves only the canonical password reference for the requested existing instance', async () => {
|
||||
let asked: string | undefined;
|
||||
const { db, resolver } = fixture({
|
||||
provider: 'macos-keychain',
|
||||
set: async () => '',
|
||||
get: async (reference) => {
|
||||
asked = reference;
|
||||
@@ -44,6 +45,7 @@ describe('InstanceCredentialResolver', () => {
|
||||
it('does not call SecretStore when no saved credential exists', async () => {
|
||||
let calls = 0;
|
||||
const { resolver } = fixture({
|
||||
provider: 'macos-keychain',
|
||||
set: async () => '',
|
||||
get: async () => {
|
||||
calls += 1;
|
||||
@@ -57,6 +59,7 @@ describe('InstanceCredentialResolver', () => {
|
||||
it('rejects a reference bound to another instance before SecretStore access', async () => {
|
||||
let calls = 0;
|
||||
const { db, resolver } = fixture({
|
||||
provider: 'macos-keychain',
|
||||
set: async () => '',
|
||||
get: async () => {
|
||||
calls += 1;
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
import { parseKeychainReference } from '../../infrastructure/secrets/keychain-secret-store.js';
|
||||
import { parseSecretReference } from '../../infrastructure/secrets/secret-reference.js';
|
||||
import type { SecretStore } from '../../infrastructure/secrets/secret-store.js';
|
||||
|
||||
const PURPOSE = 'instance-password';
|
||||
const PROVIDER = 'macos-keychain';
|
||||
export class CredentialResolverError extends Error {
|
||||
constructor(readonly code: 'NOT_FOUND' | 'CREDENTIAL_UNAVAILABLE') {
|
||||
super(code);
|
||||
@@ -24,10 +23,12 @@ export class InstanceCredentialResolver {
|
||||
.prepare(
|
||||
'SELECT external_reference FROM secret_references WHERE instance_id=? AND purpose=? AND provider=?',
|
||||
)
|
||||
.get(instanceId, PURPOSE, PROVIDER) as { external_reference: string } | undefined;
|
||||
.get(instanceId, PURPOSE, this.options.store.provider) as
|
||||
| { external_reference: string }
|
||||
| undefined;
|
||||
if (!row) throw new CredentialResolverError('CREDENTIAL_UNAVAILABLE');
|
||||
try {
|
||||
const parsed = parseKeychainReference(row.external_reference);
|
||||
const parsed = parseSecretReference(row.external_reference);
|
||||
if (parsed.instanceId !== instanceId || parsed.purpose !== PURPOSE || !parsed.slot)
|
||||
throw new Error('invalid binding');
|
||||
const secret = await this.options.store.get(row.external_reference);
|
||||
|
||||
@@ -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') < ?")
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { Instance } from '@multi-simadmin/contracts';
|
||||
import { InstanceService, InstanceServiceError } from './instance-service.js';
|
||||
|
||||
class MemorySecrets implements SecretStore {
|
||||
readonly provider = 'macos-keychain';
|
||||
readonly values = new Map<string, string>();
|
||||
readonly deletes: string[] = [];
|
||||
readonly sets: Array<{
|
||||
|
||||
@@ -10,10 +10,9 @@ import type {
|
||||
SnapshotFreshness,
|
||||
} from '@multi-simadmin/contracts';
|
||||
import type { SecretStore } from '../../infrastructure/secrets/secret-store.js';
|
||||
import { parseKeychainReference } from '../../infrastructure/secrets/keychain-secret-store.js';
|
||||
import { parseSecretReference } from '../../infrastructure/secrets/secret-reference.js';
|
||||
|
||||
const PURPOSE = 'instance-password';
|
||||
const PROVIDER = 'macos-keychain';
|
||||
const MAX_TAGS = 50;
|
||||
const MAX_TAG_LENGTH = 100;
|
||||
|
||||
@@ -408,7 +407,7 @@ export class InstanceService {
|
||||
.prepare(
|
||||
'INSERT INTO secret_references (id,instance_id,purpose,provider,external_reference,created_at,updated_at) VALUES (?,?,?,?,?,?,?)',
|
||||
)
|
||||
.run(id, instanceId, PURPOSE, PROVIDER, external, now, now);
|
||||
.run(id, instanceId, PURPOSE, this.store.provider, external, now, now);
|
||||
}
|
||||
private async storeSecret(
|
||||
instanceId: string,
|
||||
@@ -422,7 +421,7 @@ export class InstanceService {
|
||||
throw new InstanceServiceError('SECRET_STORE_FAILED', 'Could not store instance credential');
|
||||
}
|
||||
try {
|
||||
const parsed = parseKeychainReference(external);
|
||||
const parsed = parseSecretReference(external);
|
||||
if (parsed.instanceId !== instanceId || parsed.purpose !== PURPOSE || parsed.slot !== slot)
|
||||
throw new Error('secret store returned a reference with an invalid binding');
|
||||
} catch {
|
||||
@@ -504,7 +503,7 @@ export class InstanceService {
|
||||
AND secret_cleanup_tasks.purpose=excluded.purpose
|
||||
AND secret_cleanup_tasks.provider=excluded.provider`,
|
||||
)
|
||||
.run(reference, instanceId, PURPOSE, PROVIDER, now, now);
|
||||
.run(reference, instanceId, PURPOSE, this.store.provider, now, now);
|
||||
if (queued.changes !== 1)
|
||||
throw new InstanceServiceError('DATABASE_FAILED', 'Cleanup task could not be persisted');
|
||||
}
|
||||
@@ -529,13 +528,13 @@ export class InstanceService {
|
||||
);
|
||||
}
|
||||
private validateCleanupEntry(entry: CleanupEntry): void {
|
||||
if (entry.purpose !== PURPOSE || entry.provider !== PROVIDER)
|
||||
if (entry.purpose !== PURPOSE || entry.provider !== this.store.provider)
|
||||
throw new InstanceServiceError('DATABASE_FAILED', 'Cleanup task is invalid');
|
||||
this.validateCleanupReference(entry.instanceId, entry.reference);
|
||||
}
|
||||
private validateCleanupReference(instanceId: string, reference: string): void {
|
||||
try {
|
||||
const parsed = parseKeychainReference(reference);
|
||||
const parsed = parseSecretReference(reference);
|
||||
if (
|
||||
parsed.instanceId !== instanceId ||
|
||||
parsed.purpose !== PURPOSE ||
|
||||
|
||||
@@ -39,6 +39,7 @@ function pendingDatabase(): Database.Database {
|
||||
}
|
||||
|
||||
class FakeStore implements SecretStore {
|
||||
readonly provider = 'macos-keychain';
|
||||
readonly sets: { key: SecretKey; value: string }[] = [];
|
||||
readonly deletes: string[] = [];
|
||||
setError?: Error;
|
||||
|
||||
@@ -4,7 +4,6 @@ import { getManagedDatabaseIdentity } from '../../infrastructure/database/databa
|
||||
import type { SecretStore } from '../../infrastructure/secrets/secret-store.js';
|
||||
|
||||
const PURPOSE = 'instance-password';
|
||||
const PROVIDER = 'macos-keychain';
|
||||
const metadataKey = (instanceId: string): string => `legacy-import.instance.${instanceId}`;
|
||||
const referenceId = (instanceId: string): string => `legacy-secret:${instanceId}:${PURPOSE}`;
|
||||
|
||||
@@ -163,7 +162,7 @@ async function activatePendingSecretExclusive({
|
||||
throw new ActivationError('DATABASE_FAILED', 'Instance changed during secret activation');
|
||||
db.prepare(
|
||||
'INSERT INTO secret_references (id,instance_id,purpose,provider,external_reference,created_at,updated_at) VALUES (?,?,?,?,?,?,?)',
|
||||
).run(id, instanceId, PURPOSE, PROVIDER, externalReference, now, now);
|
||||
).run(id, instanceId, PURPOSE, store.provider, externalReference, now, now);
|
||||
const updated = db
|
||||
.prepare(
|
||||
"UPDATE instances SET auth_mode='password',config_revision=config_revision+1,updated_at=? WHERE id=? AND auth_mode='none' AND config_revision=?",
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from './central-notification-service.js';
|
||||
|
||||
class MemorySecrets implements SecretStore {
|
||||
readonly provider = 'macos-keychain';
|
||||
readonly values = new Map<string, string>();
|
||||
|
||||
async set(key: { instanceId: string; purpose: string; slot?: string }, value: string) {
|
||||
|
||||
@@ -559,7 +559,28 @@ export class CentralNotificationService {
|
||||
|
||||
async deleteChannel(id: string): Promise<void> {
|
||||
const channel = this.getChannel(id);
|
||||
this.#db.prepare('DELETE FROM notification_channels WHERE id=?').run(id);
|
||||
this.#db.transaction(() => {
|
||||
this.#db.prepare('DELETE FROM notification_channels WHERE id=?').run(id);
|
||||
// Rules keep channel ids in a JSON column, so no FK cascade applies; a
|
||||
// dangling id would make every referencing rule unreadable and drop
|
||||
// future notifications on the floor.
|
||||
const rules = this.#db
|
||||
.prepare('SELECT id, channel_ids_json FROM notification_rules')
|
||||
.all() as Array<{ id: string; channel_ids_json: string }>;
|
||||
for (const rule of rules) {
|
||||
let ids: unknown;
|
||||
try {
|
||||
ids = JSON.parse(String(rule.channel_ids_json));
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!Array.isArray(ids) || !ids.includes(id)) continue;
|
||||
const next = JSON.stringify(ids.filter((entry) => entry !== id));
|
||||
this.#db
|
||||
.prepare('UPDATE notification_rules SET channel_ids_json=?, updated_at=? WHERE id=?')
|
||||
.run(next, this.#now(), rule.id);
|
||||
}
|
||||
})();
|
||||
if (channel.secretReference)
|
||||
await this.#options.store.delete(channel.secretReference).catch(() => false);
|
||||
}
|
||||
@@ -1202,6 +1223,19 @@ export class CentralNotificationService {
|
||||
return { processed: succeeded + failed, succeeded, failed };
|
||||
}
|
||||
|
||||
/** Startup sweep: rows claimed as 'sending' before a crash would never drain again. */
|
||||
reconcileInterrupted(): number {
|
||||
return Number(
|
||||
this.#db
|
||||
.prepare(
|
||||
`UPDATE notification_queue
|
||||
SET status='pending',updated_at=?
|
||||
WHERE status='sending'`,
|
||||
)
|
||||
.run(this.#now()).changes,
|
||||
);
|
||||
}
|
||||
|
||||
retryQueueItem(id: string): void {
|
||||
const result = this.#db
|
||||
.prepare(
|
||||
@@ -1214,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');
|
||||
@@ -1338,7 +1386,12 @@ export class CentralNotificationService {
|
||||
#queueItem(row: Record<string, unknown>): NotificationQueueItem {
|
||||
const status = String(row.status) as NotificationQueueItem['status'];
|
||||
const attempts = Number(row.attempts ?? 0);
|
||||
const payload = record(JSON.parse(String(row.payload_json))) ?? {};
|
||||
let payload: Record<string, unknown> = {};
|
||||
try {
|
||||
payload = record(JSON.parse(String(row.payload_json))) ?? {};
|
||||
} catch {
|
||||
payload = {};
|
||||
}
|
||||
const deliveredAt =
|
||||
typeof row.delivered_at === 'string' && row.delivered_at ? row.delivered_at : undefined;
|
||||
const ruleId = typeof row.rule_id === 'string' && row.rule_id ? row.rule_id : undefined;
|
||||
@@ -1560,7 +1613,12 @@ export class CentralNotificationService {
|
||||
enabled: row.enabled === 1,
|
||||
condition: parseJson(String(row.condition_json), this.#condition.bind(this)),
|
||||
scope: parseJson(String(row.scope_json), this.#scope.bind(this)),
|
||||
channels: Object.freeze(channelIds.map((id) => this.getChannel(id))),
|
||||
channels: Object.freeze(
|
||||
channelIds.flatMap((id) => {
|
||||
const row = this.#row(`SELECT * FROM notification_channels WHERE id = ?`, id);
|
||||
return row ? [this.#channel(row)] : [];
|
||||
}),
|
||||
),
|
||||
templates: parseJson(String(row.templates_json), this.#templates.bind(this)),
|
||||
rateLimit: parseJson(String(row.rate_limit_json), validateRateLimit),
|
||||
quietHours: parseJson(String(row.quiet_hours_json), validateQuietHours),
|
||||
|
||||
@@ -160,6 +160,8 @@ async function defaultRequester(request: HttpRequest, timeoutMs: number): Promis
|
||||
headers: request.headers,
|
||||
...(request.body === undefined ? {} : { body: request.body }),
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
// A signed payload must never be replayed to a redirect target.
|
||||
redirect: 'manual',
|
||||
});
|
||||
const text = (await response.text()).slice(0, MAX_RESPONSE_BYTES);
|
||||
return { status: response.status, body: text };
|
||||
|
||||
@@ -220,6 +220,9 @@ export async function sendEmailNotification(
|
||||
const receivers = splitAddresses(configText(input.config, 'receiver_addresses'));
|
||||
if (!host) return { ok: false, detail: '缺少 SMTP 地址' };
|
||||
if (!sender) return { ok: false, detail: '缺少发件地址' };
|
||||
// Envelope commands are line-framed; reject anything that could break the frame.
|
||||
if (!/^[^\s@<>,;:"']+@[^\s@<>,;:"']+$/u.test(sender))
|
||||
return { ok: false, detail: '发件地址无效' };
|
||||
if (receivers.length === 0) return { ok: false, detail: '缺少有效的收件地址' };
|
||||
const port = configNumber(input.config, 'smtp_port', 465);
|
||||
const security = configText(input.config, 'smtp_security') || 'implicit_tls';
|
||||
|
||||
@@ -80,7 +80,9 @@ describe('MetricsService', () => {
|
||||
const text = metrics.render();
|
||||
expect(text).toContain('multi_simadmin_nodes_total 2');
|
||||
expect(text).toContain('multi_simadmin_node_enabled{node="node-b"} 0');
|
||||
expect(text).toContain('multi_simadmin_node_info{node="node-a",name="机房 A"} 1');
|
||||
expect(text).toContain('multi_simadmin_node_info{node="node-a"} 1');
|
||||
// Operator-assigned names stay out of the session-free exposition entirely.
|
||||
expect(text).not.toContain('机房 A');
|
||||
expect(text).toContain('multi_simadmin_node_probe_total{node="node-a",outcome="success"} 38');
|
||||
expect(text).toContain('multi_simadmin_node_availability_ratio{node="node-a"} 0.950');
|
||||
expect(text).toContain('multi_simadmin_sms_outbox{status="failed"} 1');
|
||||
@@ -108,14 +110,15 @@ describe('MetricsService', () => {
|
||||
expect(text).toContain('multi_simadmin_identity_pending_total 2');
|
||||
});
|
||||
|
||||
it('escapes label values so a node name cannot forge a series', () => {
|
||||
it('never emits a node name, so a hostile name cannot forge a series', () => {
|
||||
const { db: database, metrics } = fixture();
|
||||
seedNode(database, 'node-a', 'evil" name\nwith break');
|
||||
|
||||
const text = metrics.render();
|
||||
expect(text).toContain('name="evil\\" name\\nwith break"');
|
||||
// The injected newline must not become its own sample line.
|
||||
expect(text.split('\n').filter((line) => line.includes('with break'))).toHaveLength(1);
|
||||
// Names are excluded from the session-free exposition outright; escaping a
|
||||
// label is not the right tool for untrusted operator input.
|
||||
expect(text).not.toContain('evil"');
|
||||
expect(text.split('\n').filter((line) => line.includes('with break'))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('emits a valid exposition: help and type precede every series', () => {
|
||||
|
||||
@@ -77,9 +77,9 @@ export class MetricsService {
|
||||
]),
|
||||
);
|
||||
|
||||
const info = nodes.map(
|
||||
(node) => `${PREFIX}_node_info{node="${label(node.id)}",name="${label(node.name)}"} 1`,
|
||||
);
|
||||
// Operator-assigned names can leak hostnames/locations to unauthenticated
|
||||
// scrapers; the series intentionally stays opaque to node ids.
|
||||
const info = nodes.map((node) => `${PREFIX}_node_info{node="${label(node.id)}"} 1`);
|
||||
lines.push(
|
||||
...sample(`${PREFIX}_node_info`, 'Registered nodes and their addresses.', 'gauge', info),
|
||||
...sample(`${PREFIX}_nodes_total`, 'Number of registered nodes.', 'gauge', [
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from './delete-instance-operation.js';
|
||||
|
||||
class Store implements SecretStore {
|
||||
readonly provider = 'macos-keychain';
|
||||
async set() {
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
ConsoleUpdateService,
|
||||
UpdateError,
|
||||
createLaunchdRestartLauncher,
|
||||
createPlatformRestartLauncher,
|
||||
createSystemdRestartLauncher,
|
||||
parseManifestVersion,
|
||||
parseShortStat,
|
||||
type UpdateCommandResult,
|
||||
@@ -466,3 +468,76 @@ describe('createLaunchdRestartLauncher', () => {
|
||||
await expect(launcher.restart()).rejects.toMatchObject({ code: 'COMMAND', statusCode: 500 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('createSystemdRestartLauncher', () => {
|
||||
it('is unsupported without a unit or with a malformed unit name', () => {
|
||||
for (const unit of [undefined, '', 'evil; rm -rf /', 'multi-simadmin']) {
|
||||
const launcher = createSystemdRestartLauncher({
|
||||
env: unit === undefined ? {} : { MULTI_SIMADMIN_SYSTEMD_UNIT: unit },
|
||||
runner: { run: async () => ({ code: 0, stdout: '', stderr: '' }) },
|
||||
});
|
||||
expect(launcher.supported).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('restarts through the configured scope and unit', async () => {
|
||||
const calls: string[][] = [];
|
||||
const launcher = createSystemdRestartLauncher({
|
||||
env: { MULTI_SIMADMIN_SYSTEMD_UNIT: 'multi-simadmin-api.service' },
|
||||
runner: {
|
||||
run: async (command, args) => {
|
||||
calls.push([command, ...args]);
|
||||
return { code: 0, stdout: '', stderr: '' };
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(launcher.supported).toBe(true);
|
||||
await launcher.restart();
|
||||
expect(calls[0]).toEqual(['systemctl', 'user', 'restart', 'multi-simadmin-api.service']);
|
||||
});
|
||||
|
||||
it('supports the system scope and surfaces systemctl failures', async () => {
|
||||
const calls: string[][] = [];
|
||||
const launcher = createSystemdRestartLauncher({
|
||||
env: {
|
||||
MULTI_SIMADMIN_SYSTEMD_UNIT: 'multi-simadmin-api.service',
|
||||
MULTI_SIMADMIN_SYSTEMD_SCOPE: 'system',
|
||||
},
|
||||
runner: {
|
||||
run: async (command, args) => {
|
||||
calls.push([command, ...args]);
|
||||
return { code: 1, stdout: '', stderr: 'not logged in' };
|
||||
},
|
||||
},
|
||||
});
|
||||
await launcher.restart().catch(() => {});
|
||||
expect(calls[0]).toEqual(['systemctl', 'system', 'restart', 'multi-simadmin-api.service']);
|
||||
await expect(launcher.restart()).rejects.toMatchObject({ code: 'COMMAND', statusCode: 500 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('createPlatformRestartLauncher', () => {
|
||||
const runner = { run: async () => ({ code: 0, stdout: '', stderr: '' }) };
|
||||
|
||||
it('prefers systemd on Linux when a unit is configured', () => {
|
||||
const launcher = createPlatformRestartLauncher({
|
||||
env: { MULTI_SIMADMIN_HOST_PLATFORM: 'linux', MULTI_SIMADMIN_SYSTEMD_UNIT: 'api.service' },
|
||||
runner,
|
||||
});
|
||||
expect(launcher.supported).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the launchd launcher everywhere else', () => {
|
||||
const linux = createPlatformRestartLauncher({
|
||||
env: { MULTI_SIMADMIN_HOST_PLATFORM: 'linux' },
|
||||
runner,
|
||||
});
|
||||
const darwin = createPlatformRestartLauncher({
|
||||
env: { MULTI_SIMADMIN_HOST_PLATFORM: 'darwin', XPC_SERVICE_NAME: 'x' },
|
||||
runner,
|
||||
uid: () => 501,
|
||||
});
|
||||
expect(linux.supported).toBe(false);
|
||||
expect(darwin.supported).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -241,6 +241,51 @@ export function createLaunchdRestartLauncher(input: {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Linux systemd counterpart to the launchd launcher. The unit and scope are
|
||||
* supplied explicitly through the environment so a supervised deployment can
|
||||
* hand restarts to its service manager; unset variables keep it unsupported.
|
||||
*/
|
||||
export function createSystemdRestartLauncher(input: {
|
||||
readonly env: Readonly<Record<string, string | undefined>>;
|
||||
readonly runner: UpdateCommandRunner;
|
||||
}): RestartLauncher {
|
||||
const unit = input.env.MULTI_SIMADMIN_SYSTEMD_UNIT ?? '';
|
||||
const scope = input.env.MULTI_SIMADMIN_SYSTEMD_SCOPE === 'system' ? 'system' : 'user';
|
||||
const validUnit = /^[A-Za-z0-9@_.\-]+\.service$/.test(unit);
|
||||
const supported = validUnit;
|
||||
return {
|
||||
supported,
|
||||
async restart() {
|
||||
if (!supported)
|
||||
throw new UpdateError(
|
||||
'NOT_SUPPORTED',
|
||||
'未配置 systemd 服务单元(MULTI_SIMADMIN_SYSTEMD_UNIT)。',
|
||||
501,
|
||||
);
|
||||
const result = await input.runner.run('systemctl', [scope, 'restart', unit]);
|
||||
if (result.code !== 0)
|
||||
throw new UpdateError(
|
||||
'COMMAND',
|
||||
text(result.stderr, 200) || 'systemctl restart 失败。',
|
||||
500,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Picks the restart launcher matching the host service manager. */
|
||||
export function createPlatformRestartLauncher(input: {
|
||||
readonly env: Readonly<Record<string, string | undefined>>;
|
||||
readonly runner: UpdateCommandRunner;
|
||||
readonly uid?: () => number | undefined;
|
||||
}): RestartLauncher {
|
||||
const platform = input.env.MULTI_SIMADMIN_HOST_PLATFORM ?? process.platform;
|
||||
return platform === 'linux' && input.env.MULTI_SIMADMIN_SYSTEMD_UNIT !== undefined
|
||||
? createSystemdRestartLauncher(input)
|
||||
: createLaunchdRestartLauncher(input);
|
||||
}
|
||||
|
||||
export interface ConsoleUpdateServiceOptions {
|
||||
readonly db: Database.Database;
|
||||
readonly version: string;
|
||||
|
||||
@@ -107,6 +107,9 @@ function trustedUpstreamHeaders(
|
||||
delete sanitized[GATEWAY_CLIENT_IP_HEADER];
|
||||
if (gatewayToken !== undefined) sanitized[GATEWAY_AUTH_HEADER] = gatewayToken;
|
||||
if (clientIp !== undefined) sanitized[GATEWAY_CLIENT_IP_HEADER] = clientIp;
|
||||
// The browser connection's protocol is the gateway's to declare; the API uses
|
||||
// it to decide whether session cookies may carry the Secure flag.
|
||||
sanitized['x-forwarded-proto'] = (headers['x-forwarded-proto'] as string | undefined) ?? 'http';
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
|
||||
@@ -131,7 +131,9 @@ afterEach(async () => {
|
||||
await Promise.all(cleanup.splice(0).map((fn) => fn()));
|
||||
});
|
||||
|
||||
describe.sequential('executable package runtimes', () => {
|
||||
// The runtime lifecycle under test is macOS/POSIX-only: process-group SIGTERM,
|
||||
// lsof port inspection, and corepack as a direct spawn target.
|
||||
describe.sequential('executable package runtimes', { skip: process.platform === 'win32' }, () => {
|
||||
it('makes the exact production gateway executable attempt 8788 only with acknowledgement', async () => {
|
||||
await expectPortFree(8788);
|
||||
const fixture = await listenOn(8788);
|
||||
|
||||
@@ -9,7 +9,12 @@ it('registers the read-only operations catalog without touching upstream', async
|
||||
let upstreamCalls = 0;
|
||||
const app = buildControlPlaneApp({
|
||||
db,
|
||||
store: { set: async () => '', get: async () => undefined, delete: async () => false },
|
||||
store: {
|
||||
provider: 'macos-keychain',
|
||||
set: async () => '',
|
||||
get: async () => undefined,
|
||||
delete: async () => false,
|
||||
},
|
||||
upstream: {
|
||||
get: async () => {
|
||||
upstreamCalls += 1;
|
||||
|
||||
@@ -13,6 +13,7 @@ afterEach(async () => {
|
||||
for (const db of dbs.splice(0)) db.close();
|
||||
});
|
||||
class Store implements SecretStore {
|
||||
readonly provider = 'macos-keychain';
|
||||
private readonly values = new Map<string, string>();
|
||||
async set(key: { instanceId: string; purpose: string; slot?: string }, value: string) {
|
||||
const account = Buffer.from(
|
||||
|
||||
@@ -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,
|
||||
@@ -320,6 +327,7 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane
|
||||
...(options.now ? { now: options.now } : {}),
|
||||
});
|
||||
smsOutbox.reconcileInterrupted();
|
||||
centralNotifications.reconcileInterrupted();
|
||||
// Removal follows the hub rule the operator already knows: an online device is told to release
|
||||
// its own binding first and a refusal keeps the record, while a device that cannot be reached
|
||||
// at all is forgotten locally. The closure is late-bound because the device action service is
|
||||
@@ -346,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 (
|
||||
@@ -380,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);
|
||||
@@ -509,6 +524,7 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane
|
||||
resources,
|
||||
messages,
|
||||
registerDeletionPreparationRoute: false,
|
||||
onEvent: (envelope) => eventJournal.append(envelope),
|
||||
});
|
||||
registerFleetRoutes(app, {
|
||||
instances,
|
||||
@@ -581,6 +597,31 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane
|
||||
void Promise.resolve()
|
||||
.then(() => centralNotifications.pruneLogs())
|
||||
.catch(() => undefined);
|
||||
// Delivered and abandoned outbox rows otherwise accumulate forever.
|
||||
void Promise.resolve()
|
||||
.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;
|
||||
// Scheduled component backups: the tick is cheap and only writes when a period is overdue.
|
||||
|
||||
@@ -23,7 +23,7 @@ const STATE_FILE = 'cutover-state.json';
|
||||
const GATEWAY_ARGV = [
|
||||
process.execPath,
|
||||
'--import',
|
||||
resolve(process.cwd(), 'node_modules/.pnpm/tsx@4.22.4/node_modules/tsx/dist/loader.mjs'),
|
||||
'tsx',
|
||||
resolve(process.cwd(), 'apps/api/src/production-gateway-cli.ts'),
|
||||
] as const;
|
||||
const DIGEST = /^[a-f0-9]{64}$/u;
|
||||
@@ -289,11 +289,14 @@ async function durableState(plan: CutoverPlan, state: CutoverState): Promise<voi
|
||||
await handle.close();
|
||||
}
|
||||
await rename(temporary, target);
|
||||
const directory = await open(plan.stateDir, constants.O_RDONLY);
|
||||
try {
|
||||
await directory.sync();
|
||||
} finally {
|
||||
await directory.close();
|
||||
if (process.platform !== 'win32') {
|
||||
// directory fsync only exists on POSIX
|
||||
const directory = await open(plan.stateDir, constants.O_RDONLY);
|
||||
try {
|
||||
await directory.sync();
|
||||
} finally {
|
||||
await directory.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -311,7 +314,8 @@ function preparedState(plan: CutoverPlan): CutoverState {
|
||||
async function readState(plan: CutoverPlan): Promise<CutoverState> {
|
||||
const path = join(plan.stateDir, STATE_FILE);
|
||||
const info = await lstat(path);
|
||||
if (!info.isFile() || (info.mode & 0o777) !== 0o600)
|
||||
// Windows keeps no POSIX mode bits (chmod is a no-op); ACLs govern access there.
|
||||
if (!info.isFile() || (process.platform !== 'win32' && (info.mode & 0o777) !== 0o600))
|
||||
throw new Error('Cutover state must be a mode-0600 regular file');
|
||||
const input = record(JSON.parse(await readFile(path, 'utf8')) as unknown, 'Cutover state');
|
||||
const base = ['version', 'phase', 'legacyPid', 'legacyCommand', 'legacyIdentity', 'legacyStart'];
|
||||
|
||||
@@ -48,10 +48,13 @@ describe('verified backup, restore, and rollback foundation', () => {
|
||||
]);
|
||||
copy.close();
|
||||
expect(snapshot.sha256).toMatch(/^[a-f0-9]{64}$/);
|
||||
expect((await stat(backupPath)).mode & 0o777).toBe(0o600);
|
||||
if (process.platform !== 'win32') expect((await stat(backupPath)).mode & 0o777).toBe(0o600);
|
||||
});
|
||||
|
||||
it('verifies a separate candidate and digest-binds activation', async () => {
|
||||
// Activation renames a WAL file that a live connection still holds open;
|
||||
// POSIX permits that, Windows file locking does not.
|
||||
const itPosix = process.platform === 'win32' ? it.skip : it;
|
||||
itPosix('verifies a separate candidate and digest-binds activation', async () => {
|
||||
const { livePath, backupPath, database } = await fixture();
|
||||
const snapshot = await backupDatabase(database, backupPath);
|
||||
database.exec('UPDATE app_settings SET value_json = \'"after"\'');
|
||||
@@ -118,7 +121,7 @@ describe('verified backup, restore, and rollback foundation', () => {
|
||||
expect(await readFile(livePath)).toEqual(before);
|
||||
});
|
||||
|
||||
it('rejects in-place staged-byte mutation even when metadata is preserved', async () => {
|
||||
itPosix('rejects in-place staged-byte mutation even when metadata is preserved', async () => {
|
||||
const { livePath, backupPath, database } = await fixture();
|
||||
await backupDatabase(database, backupPath);
|
||||
database.close();
|
||||
|
||||
@@ -159,7 +159,8 @@ async function digestFileHandle(handle: FileHandle): Promise<string> {
|
||||
}
|
||||
|
||||
async function syncFile(path: string): Promise<void> {
|
||||
const handle = await open(path, 'r');
|
||||
// Windows FlushFileBuffers requires a writable handle; POSIX allows fsync on 'r'.
|
||||
const handle = await open(path, process.platform === 'win32' ? 'r+' : 'r');
|
||||
try {
|
||||
await handle.sync();
|
||||
} finally {
|
||||
@@ -168,6 +169,7 @@ async function syncFile(path: string): Promise<void> {
|
||||
}
|
||||
|
||||
async function syncDirectory(path: string): Promise<void> {
|
||||
if (process.platform === 'win32') return; // directory fsync only exists on POSIX
|
||||
const handle = await open(path, 'r');
|
||||
try {
|
||||
await handle.sync();
|
||||
@@ -264,7 +266,9 @@ export async function activateRestoreCandidate(
|
||||
await assertValidDatabase(stagedPath);
|
||||
if ((await digestFileHandle(stagedHandle)) !== stagedDigest)
|
||||
throw new Error('Staged restore candidate changed during validation');
|
||||
await stagedHandle.sync();
|
||||
// The staged handle is intentionally read-only after chmod(0400); Windows
|
||||
// FlushFileBuffers requires a writable handle, so skip the durability sync there.
|
||||
if (process.platform !== 'win32') await stagedHandle.sync();
|
||||
await activateStagedDatabase(
|
||||
databasePath,
|
||||
stagedPath,
|
||||
|
||||
@@ -362,7 +362,8 @@ describe('database backup and restore', () => {
|
||||
migrateDatabase(database);
|
||||
const backupPath = join(directory, 'backup.sqlite');
|
||||
await backupDatabase(database, backupPath);
|
||||
expect((await lstat(backupPath)).mode & 0o777).toBe(0o600);
|
||||
// Windows keeps no POSIX mode bits; the 0600 guarantee is POSIX-only.
|
||||
if (process.platform !== 'win32') expect((await lstat(backupPath)).mode & 0o777).toBe(0o600);
|
||||
database.close();
|
||||
});
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { chmod, mkdir, open, readFile, rename, rm, stat } from 'node:fs/promises';
|
||||
import { dirname, isAbsolute } from 'node:path';
|
||||
import {
|
||||
accountFor,
|
||||
parseSecretAccount,
|
||||
validateKey,
|
||||
SecretReferenceError,
|
||||
} from './secret-reference.js';
|
||||
import type { SecretKey, SecretStore } from './secret-store.js';
|
||||
|
||||
const PROVIDER = 'secret-file';
|
||||
const SERVICE = 'multi-simadmin';
|
||||
const MAX_SECRET_BYTES = 16_384;
|
||||
const MAX_STORE_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
export type FileSecretStoreErrorCode =
|
||||
| 'INVALID_KEY'
|
||||
| 'INVALID_SECRET'
|
||||
| 'INVALID_REFERENCE'
|
||||
| 'INVALID_STORE_PATH'
|
||||
| 'SIZE_LIMIT'
|
||||
| 'PAYLOAD_INVALID';
|
||||
|
||||
export class FileSecretStoreError extends Error {
|
||||
constructor(
|
||||
readonly code: FileSecretStoreErrorCode,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'FileSecretStoreError';
|
||||
}
|
||||
}
|
||||
|
||||
function invalid(code: FileSecretStoreErrorCode, message: string): never {
|
||||
throw new FileSecretStoreError(code, message);
|
||||
}
|
||||
|
||||
/**
|
||||
* POSIX filesystem-backed secret store for hosts without a system keyring
|
||||
* (e.g. Linux servers). Secrets live in a single 0600 JSON map under the data
|
||||
* root, replaced atomically via temp file + rename, with writes serialized
|
||||
* in-process.
|
||||
*/
|
||||
export class FileSecretStore implements SecretStore {
|
||||
readonly provider = PROVIDER;
|
||||
|
||||
private queue: Promise<unknown> = Promise.resolve();
|
||||
|
||||
constructor(readonly filePath: string) {
|
||||
if (typeof filePath !== 'string' || filePath.length === 0 || !isAbsolute(filePath))
|
||||
invalid('INVALID_STORE_PATH', 'Secret file path must be absolute');
|
||||
}
|
||||
|
||||
async set(key: SecretKey, value: string): Promise<string> {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.length === 0 ||
|
||||
/[\0\r\n]/.test(value) ||
|
||||
Buffer.byteLength(value, 'utf8') > MAX_SECRET_BYTES
|
||||
) {
|
||||
invalid('INVALID_SECRET', 'Secret is not valid for file storage');
|
||||
}
|
||||
validateKey(key);
|
||||
const account = accountFor(key);
|
||||
await this.enqueue(() => this.withStore((store) => ({ ...store, [account]: value })));
|
||||
return `secret-file://${SERVICE}/${account}`;
|
||||
}
|
||||
|
||||
async get(reference: string): Promise<string | undefined> {
|
||||
const { account } = this.parse(reference);
|
||||
const store = await this.readStore();
|
||||
return store[account];
|
||||
}
|
||||
|
||||
async delete(reference: string): Promise<boolean> {
|
||||
const { account } = this.parse(reference);
|
||||
let removed = false;
|
||||
await this.enqueue(() =>
|
||||
this.withStore((store) => {
|
||||
if (!(account in store)) return store;
|
||||
removed = true;
|
||||
const next = { ...store };
|
||||
delete next[account];
|
||||
return next;
|
||||
}),
|
||||
);
|
||||
return removed;
|
||||
}
|
||||
|
||||
private parse(reference: string): { account: string } {
|
||||
if (typeof reference !== 'string' || reference.length > 512)
|
||||
invalid('INVALID_REFERENCE', 'Secret file reference is invalid');
|
||||
const match = /^secret-file:\/\/multi-simadmin\/([A-Za-z0-9_-]+)$/.exec(reference);
|
||||
if (!match?.[1]) invalid('INVALID_REFERENCE', 'Secret file reference is invalid');
|
||||
try {
|
||||
parseSecretAccount(match[1], PROVIDER);
|
||||
} catch (error) {
|
||||
if (error instanceof SecretReferenceError)
|
||||
invalid('INVALID_REFERENCE', 'Secret file reference is invalid');
|
||||
throw error;
|
||||
}
|
||||
return { account: match[1] };
|
||||
}
|
||||
|
||||
private enqueue<T>(task: () => Promise<T>): Promise<T> {
|
||||
const run = this.queue.then(task, task);
|
||||
this.queue = run.catch(() => {});
|
||||
return run;
|
||||
}
|
||||
|
||||
private async readStore(): Promise<Record<string, string>> {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await readFile(this.filePath, 'utf8');
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return {};
|
||||
throw error;
|
||||
}
|
||||
if (Buffer.byteLength(raw, 'utf8') > MAX_STORE_BYTES)
|
||||
invalid('SIZE_LIMIT', 'Secret file exceeds the size limit');
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
invalid('PAYLOAD_INVALID', 'Secret file payload is invalid');
|
||||
}
|
||||
if (!isPlainStringMap(parsed)) invalid('PAYLOAD_INVALID', 'Secret file payload is invalid');
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/** Reads the current map, applies `mutate`, and durably replaces the file. */
|
||||
private async withStore(
|
||||
mutate: (store: Record<string, string>) => Record<string, string>,
|
||||
): Promise<void> {
|
||||
const current = await this.readStore();
|
||||
const next = mutate(current);
|
||||
const directory = dirname(this.filePath);
|
||||
await mkdir(directory, { recursive: true, mode: 0o700 });
|
||||
if (process.platform !== 'win32') {
|
||||
const info = await stat(directory);
|
||||
if (info.isDirectory() && (info.mode & 0o077) !== 0) await chmod(directory, 0o700);
|
||||
}
|
||||
const temporary = `${this.filePath}.${randomUUID()}.tmp`;
|
||||
const handle = await open(temporary, 'wx', 0o600);
|
||||
try {
|
||||
await handle.writeFile(`${JSON.stringify(next, null, 2)}\n`, 'utf8');
|
||||
// Windows FlushFileBuffers is skipped along with the directory fsync below;
|
||||
// durability anchoring is POSIX-only by design.
|
||||
if (process.platform !== 'win32') await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
try {
|
||||
await rename(temporary, this.filePath);
|
||||
if (process.platform !== 'win32') {
|
||||
const directoryHandle = await open(directory, 'r');
|
||||
try {
|
||||
await directoryHandle.sync();
|
||||
} finally {
|
||||
await directoryHandle.close();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
await rm(temporary, { force: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isPlainStringMap(value: unknown): value is Record<string, string> {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
|
||||
return Object.values(value).every((entry) => typeof entry === 'string');
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { accountFor, parseSecretAccount, SecretReferenceError } from './secret-reference.js';
|
||||
import type { SecretKey, SecretStore } from './secret-store.js';
|
||||
|
||||
const SECURITY_PATH = '/usr/bin/security';
|
||||
const SERVICE = 'multi-simadmin';
|
||||
const TIMEOUT_MS = 5_000;
|
||||
const MAX_STDOUT_BYTES = 65_536;
|
||||
const safeComponent = /^[A-Za-z0-9_.-]+$/;
|
||||
|
||||
export type SecretStoreErrorCode =
|
||||
| 'INVALID_KEY'
|
||||
@@ -105,43 +105,6 @@ function invalid(code: 'INVALID_KEY' | 'INVALID_REFERENCE', message: string): ne
|
||||
throw new SecretStoreError(code, message);
|
||||
}
|
||||
|
||||
function validateKey(key: SecretKey): void {
|
||||
if (
|
||||
typeof key.instanceId !== 'string' ||
|
||||
key.instanceId.length === 0 ||
|
||||
key.instanceId.length > 128 ||
|
||||
!safeComponent.test(key.instanceId)
|
||||
) {
|
||||
invalid('INVALID_KEY', 'Secret key instance id is invalid');
|
||||
}
|
||||
if (
|
||||
typeof key.purpose !== 'string' ||
|
||||
key.purpose.length === 0 ||
|
||||
key.purpose.length > 64 ||
|
||||
!safeComponent.test(key.purpose)
|
||||
) {
|
||||
invalid('INVALID_KEY', 'Secret key purpose is invalid');
|
||||
}
|
||||
if (
|
||||
key.slot !== undefined &&
|
||||
(typeof key.slot !== 'string' ||
|
||||
key.slot.length === 0 ||
|
||||
key.slot.length > 64 ||
|
||||
!safeComponent.test(key.slot))
|
||||
) {
|
||||
invalid('INVALID_KEY', 'Secret key slot is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
function accountFor(key: SecretKey): string {
|
||||
validateKey(key);
|
||||
const components =
|
||||
key.slot === undefined
|
||||
? [key.instanceId, key.purpose]
|
||||
: [key.instanceId, key.purpose, key.slot];
|
||||
return Buffer.from(JSON.stringify(components), 'utf8').toString('base64url');
|
||||
}
|
||||
|
||||
function referenceFor(account: string): string {
|
||||
return `keychain://${SERVICE}/${account}`;
|
||||
}
|
||||
@@ -152,28 +115,26 @@ export function parseKeychainReference(reference: string): ParsedKeychainReferen
|
||||
const match = /^keychain:\/\/multi-simadmin\/([A-Za-z0-9_-]+)$/.exec(reference);
|
||||
if (!match?.[1]) invalid('INVALID_REFERENCE', 'Keychain reference is invalid');
|
||||
const account = match[1];
|
||||
let decoded: unknown;
|
||||
let parsed;
|
||||
try {
|
||||
decoded = JSON.parse(Buffer.from(account, 'base64url').toString('utf8'));
|
||||
} catch {
|
||||
invalid('INVALID_REFERENCE', 'Keychain reference is invalid');
|
||||
parsed = parseSecretAccount(account, 'macos-keychain');
|
||||
} catch (error) {
|
||||
if (error instanceof SecretReferenceError)
|
||||
invalid('INVALID_REFERENCE', 'Keychain reference is invalid');
|
||||
throw error;
|
||||
}
|
||||
if (!Array.isArray(decoded) || (decoded.length !== 2 && decoded.length !== 3))
|
||||
invalid('INVALID_REFERENCE', 'Keychain reference is invalid');
|
||||
const [instanceId, purpose, slot] = decoded;
|
||||
const key = { instanceId, purpose, ...(slot === undefined ? {} : { slot }) } as SecretKey;
|
||||
validateKey(key);
|
||||
if (accountFor(key) !== account) invalid('INVALID_REFERENCE', 'Keychain reference is invalid');
|
||||
return {
|
||||
service: SERVICE,
|
||||
account,
|
||||
instanceId: key.instanceId,
|
||||
purpose: key.purpose,
|
||||
...(key.slot === undefined ? {} : { slot: key.slot }),
|
||||
instanceId: parsed.instanceId,
|
||||
purpose: parsed.purpose,
|
||||
...(parsed.slot === undefined ? {} : { slot: parsed.slot }),
|
||||
};
|
||||
}
|
||||
|
||||
export class MacOSKeychainSecretStore implements SecretStore {
|
||||
readonly provider = 'macos-keychain';
|
||||
|
||||
constructor(private readonly runner: CommandRunner = new SpawnCommandRunner()) {}
|
||||
|
||||
async set(key: SecretKey, value: string): Promise<string> {
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { mkdtemp, readFile, readdir, rm, stat, writeFile, chmod } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { FileSecretStore, FileSecretStoreError } from './file-secret-store.js';
|
||||
import { SecretReferenceError } from './secret-reference.js';
|
||||
import {
|
||||
createDefaultSecretStore,
|
||||
defaultSecretFileMetadataCheck,
|
||||
resolveSecretBackend,
|
||||
SecretBackendError,
|
||||
} from './secret-backend.js';
|
||||
import { MacOSKeychainSecretStore } from './keychain-secret-store.js';
|
||||
|
||||
describe('FileSecretStore', () => {
|
||||
it('round-trips set/get/delete with stable secret-file references', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'msa-file-secrets-'));
|
||||
try {
|
||||
const store = new FileSecretStore(join(directory, 'secrets.json'));
|
||||
const reference = await store.set(
|
||||
{ instanceId: 'device-1', purpose: 'instance-password', slot: 'slot-1' },
|
||||
'pa$$word',
|
||||
);
|
||||
expect(reference).toMatch(/^secret-file:\/\/multi-simadmin\/[A-Za-z0-9_-]+$/);
|
||||
expect(await store.get(reference)).toBe('pa$$word');
|
||||
expect(await store.delete(reference)).toBe(true);
|
||||
expect(await store.get(reference)).toBeUndefined();
|
||||
expect(await store.delete(reference)).toBe(false);
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects empty, framing, oversize, and malformed keys/values', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'msa-file-secrets-'));
|
||||
try {
|
||||
const store = new FileSecretStore(join(directory, 'secrets.json'));
|
||||
await expect(store.set({ instanceId: 'x', purpose: 'p' }, '')).rejects.toThrow(
|
||||
FileSecretStoreError,
|
||||
);
|
||||
await expect(store.set({ instanceId: 'x', purpose: 'p' }, 'a\nb')).rejects.toThrow(
|
||||
FileSecretStoreError,
|
||||
);
|
||||
await expect(
|
||||
store.set({ instanceId: 'x', purpose: 'p' }, 'a'.repeat(16_385)),
|
||||
).rejects.toThrow(FileSecretStoreError);
|
||||
await expect(store.set({ instanceId: '', purpose: 'p' }, 'v')).rejects.toThrow(
|
||||
SecretReferenceError,
|
||||
);
|
||||
await expect(store.get('https://evil.example/one')).rejects.toThrow(FileSecretStoreError);
|
||||
await expect(store.get('secret-file://other-service/abc')).rejects.toThrow(
|
||||
FileSecretStoreError,
|
||||
);
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the map private (0600) and leaves no temporary files behind', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'msa-file-secrets-'));
|
||||
try {
|
||||
const path = join(directory, 'nested', 'secrets.json');
|
||||
const store = new FileSecretStore(path);
|
||||
const reference = await store.set({ instanceId: 'x', purpose: 'p', slot: 's' }, 'secret');
|
||||
if (process.platform !== 'win32') expect((await stat(path)).mode & 0o777).toBe(0o600);
|
||||
expect(await readdir(join(directory, 'nested'))).toEqual(['secrets.json']);
|
||||
const raw = await readFile(path, 'utf8');
|
||||
expect(JSON.parse(raw)).toEqual({
|
||||
[reference.slice('secret-file://multi-simadmin/'.length)]: 'secret',
|
||||
});
|
||||
expect(await store.get(reference)).toBe('secret');
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('serializes concurrent writes so every mutation lands', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'msa-file-secrets-'));
|
||||
try {
|
||||
const store = new FileSecretStore(join(directory, 'secrets.json'));
|
||||
const references = await Promise.all(
|
||||
Array.from({ length: 25 }, (_, index) =>
|
||||
store.set({ instanceId: 'x', purpose: `p${index}`, slot: 's' }, `v${index}`),
|
||||
),
|
||||
);
|
||||
for (const [index, reference] of references.entries())
|
||||
expect(await store.get(reference)).toBe(`v${index}`);
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses a corrupted or non-map payload instead of failing open', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'msa-file-secrets-'));
|
||||
try {
|
||||
const path = join(directory, 'secrets.json');
|
||||
await writeFile(path, 'not json', 'utf8');
|
||||
const store = new FileSecretStore(path);
|
||||
await expect(store.get('secret-file://multi-simadmin/abc')).rejects.toThrow(
|
||||
FileSecretStoreError,
|
||||
);
|
||||
await writeFile(path, '{"account": 42}', 'utf8');
|
||||
await expect(store.get('secret-file://multi-simadmin/abc')).rejects.toThrow(
|
||||
FileSecretStoreError,
|
||||
);
|
||||
expect(() => new FileSecretStore('relative/secrets.json')).toThrow(FileSecretStoreError);
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('secret backend selection', () => {
|
||||
it('defaults to the keychain on darwin and the file store elsewhere', () => {
|
||||
expect(resolveSecretBackend({}, 'darwin')).toBe('macos-keychain');
|
||||
expect(resolveSecretBackend({}, 'linux')).toBe('secret-file');
|
||||
expect(resolveSecretBackend({ MULTI_SIMADMIN_SECRET_BACKEND: 'secret-file' }, 'darwin')).toBe(
|
||||
'secret-file',
|
||||
);
|
||||
expect(() =>
|
||||
resolveSecretBackend({ MULTI_SIMADMIN_SECRET_BACKEND: 'vault' }, 'darwin'),
|
||||
).toThrow(SecretBackendError);
|
||||
});
|
||||
|
||||
it('builds the matching store and refuses keychain off macOS', () => {
|
||||
const directory = join(tmpdir(), 'msa-backend-selection');
|
||||
expect(
|
||||
createDefaultSecretStore({
|
||||
env: {},
|
||||
secretFilePath: join(directory, 's.json'),
|
||||
platform: 'linux',
|
||||
}),
|
||||
).toBeInstanceOf(FileSecretStore);
|
||||
expect(
|
||||
createDefaultSecretStore({
|
||||
env: { MULTI_SIMADMIN_SECRET_BACKEND: 'macos-keychain' },
|
||||
secretFilePath: join(directory, 's.json'),
|
||||
platform: 'darwin',
|
||||
}),
|
||||
).toBeInstanceOf(MacOSKeychainSecretStore);
|
||||
expect(() =>
|
||||
createDefaultSecretStore({
|
||||
env: { MULTI_SIMADMIN_SECRET_BACKEND: 'macos-keychain' },
|
||||
secretFilePath: join(directory, 's.json'),
|
||||
platform: 'linux',
|
||||
}),
|
||||
).toThrow(SecretBackendError);
|
||||
});
|
||||
|
||||
it('file metadata probe accepts a missing or private file and rejects a public one', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'msa-file-secrets-meta-'));
|
||||
try {
|
||||
const path = join(directory, 'secrets.json');
|
||||
expect(await defaultSecretFileMetadataCheck(path)).toBe(true);
|
||||
await writeFile(path, '{}\n', { mode: 0o600 });
|
||||
expect(await defaultSecretFileMetadataCheck(path)).toBe(true);
|
||||
if (process.platform !== 'win32') {
|
||||
await chmod(path, 0o644);
|
||||
expect(await defaultSecretFileMetadataCheck(path)).toBe(false);
|
||||
}
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { stat } from 'node:fs/promises';
|
||||
import { FileSecretStore } from './file-secret-store.js';
|
||||
import { MacOSKeychainSecretStore } from './keychain-secret-store.js';
|
||||
import type { SecretStore } from './secret-store.js';
|
||||
|
||||
export type SecretBackend = 'macos-keychain' | 'secret-file';
|
||||
|
||||
const BACKENDS: readonly SecretBackend[] = ['macos-keychain', 'secret-file'];
|
||||
|
||||
export class SecretBackendError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'SecretBackendError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the secret backend: explicit MULTI_SIMADMIN_SECRET_BACKEND override,
|
||||
* otherwise the macOS Keychain on darwin and the 0600 file store elsewhere.
|
||||
*/
|
||||
export function resolveSecretBackend(
|
||||
env: Readonly<Record<string, string | undefined>> = process.env,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): SecretBackend {
|
||||
const raw = env.MULTI_SIMADMIN_SECRET_BACKEND;
|
||||
if (raw !== undefined) {
|
||||
if (!BACKENDS.includes(raw as SecretBackend))
|
||||
throw new SecretBackendError(
|
||||
'MULTI_SIMADMIN_SECRET_BACKEND must be one of: macos-keychain, secret-file',
|
||||
);
|
||||
return raw as SecretBackend;
|
||||
}
|
||||
return platform === 'darwin' ? 'macos-keychain' : 'secret-file';
|
||||
}
|
||||
|
||||
export interface CreateDefaultSecretStoreOptions {
|
||||
readonly env?: Readonly<Record<string, string | undefined>>;
|
||||
/** Absolute path of the file store map; required for the secret-file backend. */
|
||||
readonly secretFilePath: string;
|
||||
readonly platform?: NodeJS.Platform;
|
||||
}
|
||||
|
||||
export function createDefaultSecretStore(options: CreateDefaultSecretStoreOptions): SecretStore {
|
||||
const platform = options.platform ?? process.platform;
|
||||
const backend = resolveSecretBackend(options.env, platform);
|
||||
if (backend === 'secret-file') return new FileSecretStore(options.secretFilePath);
|
||||
if (platform !== 'darwin')
|
||||
throw new SecretBackendError(
|
||||
'The macos-keychain backend requires /usr/bin/security (macOS only)',
|
||||
);
|
||||
return new MacOSKeychainSecretStore();
|
||||
}
|
||||
|
||||
interface SecretFileMetadata {
|
||||
isFile(): boolean;
|
||||
readonly mode: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Readiness metadata probe for the file backend. Availability only — this must
|
||||
* never read secret material.
|
||||
*/
|
||||
export async function defaultSecretFileMetadataCheck(
|
||||
filePath: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
statFn: (path: string) => Promise<SecretFileMetadata> = stat,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const info = await statFn(filePath);
|
||||
if (!info.isFile()) return false;
|
||||
// Windows keeps no POSIX mode bits; NTFS ACLs govern access there.
|
||||
if (platform !== 'win32' && (info.mode & 0o777) !== 0o600) return false;
|
||||
return true;
|
||||
} catch (error) {
|
||||
// A missing map is healthy: secrets are added lazily by instance setup.
|
||||
return (error as NodeJS.ErrnoException).code === 'ENOENT';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { SecretKey } from './secret-store.js';
|
||||
|
||||
export const SECRET_SERVICE = 'multi-simadmin';
|
||||
|
||||
export type SecretProvider = 'macos-keychain' | 'secret-file';
|
||||
|
||||
export const SECRET_PROVIDERS: readonly SecretProvider[] = ['macos-keychain', 'secret-file'];
|
||||
|
||||
const safeComponent = /^[A-Za-z0-9_.-]+$/;
|
||||
|
||||
export type SecretReferenceErrorCode = 'INVALID_KEY' | 'INVALID_REFERENCE';
|
||||
|
||||
export function parseSecretAccount(
|
||||
account: string,
|
||||
provider: SecretProvider,
|
||||
): ParsedSecretReference {
|
||||
let decoded: unknown;
|
||||
try {
|
||||
decoded = JSON.parse(Buffer.from(account, 'base64url').toString('utf8'));
|
||||
} catch {
|
||||
throw new SecretReferenceError('INVALID_REFERENCE', 'Secret reference is invalid');
|
||||
}
|
||||
if (!Array.isArray(decoded) || (decoded.length !== 2 && decoded.length !== 3))
|
||||
throw new SecretReferenceError('INVALID_REFERENCE', 'Secret reference is invalid');
|
||||
const [instanceId, purpose, slot] = decoded;
|
||||
const key = { instanceId, purpose, ...(slot === undefined ? {} : { slot }) } as SecretKey;
|
||||
validateKey(key);
|
||||
if (accountFor(key) !== account)
|
||||
throw new SecretReferenceError('INVALID_REFERENCE', 'Secret reference is invalid');
|
||||
return {
|
||||
provider,
|
||||
instanceId: key.instanceId,
|
||||
purpose: key.purpose,
|
||||
...(key.slot === undefined ? {} : { slot: key.slot }),
|
||||
};
|
||||
}
|
||||
|
||||
export function validateKey(key: SecretKey): void {
|
||||
if (
|
||||
typeof key.instanceId !== 'string' ||
|
||||
key.instanceId.length === 0 ||
|
||||
key.instanceId.length > 128 ||
|
||||
!safeComponent.test(key.instanceId)
|
||||
) {
|
||||
throw new SecretReferenceError('INVALID_KEY', 'Secret key instance id is invalid');
|
||||
}
|
||||
if (
|
||||
typeof key.purpose !== 'string' ||
|
||||
key.purpose.length === 0 ||
|
||||
key.purpose.length > 64 ||
|
||||
!safeComponent.test(key.purpose)
|
||||
) {
|
||||
throw new SecretReferenceError('INVALID_KEY', 'Secret key purpose is invalid');
|
||||
}
|
||||
if (
|
||||
key.slot !== undefined &&
|
||||
(typeof key.slot !== 'string' ||
|
||||
key.slot.length === 0 ||
|
||||
key.slot.length > 64 ||
|
||||
!safeComponent.test(key.slot))
|
||||
) {
|
||||
throw new SecretReferenceError('INVALID_KEY', 'Secret key slot is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
export function accountFor(key: SecretKey): string {
|
||||
validateKey(key);
|
||||
const components =
|
||||
key.slot === undefined
|
||||
? [key.instanceId, key.purpose]
|
||||
: [key.instanceId, key.purpose, key.slot];
|
||||
return Buffer.from(JSON.stringify(components), 'utf8').toString('base64url');
|
||||
}
|
||||
|
||||
export interface ParsedSecretReference {
|
||||
readonly provider: SecretProvider;
|
||||
readonly instanceId: string;
|
||||
readonly purpose: string;
|
||||
readonly slot?: string;
|
||||
}
|
||||
|
||||
export class SecretReferenceError extends Error {
|
||||
constructor(
|
||||
readonly code: SecretReferenceErrorCode,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'SecretReferenceError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Accepts `keychain://multi-simadmin/<account>` and `secret-file://multi-simadmin/<account>`. */
|
||||
export function parseSecretReference(reference: string): ParsedSecretReference {
|
||||
if (typeof reference !== 'string' || reference.length > 512)
|
||||
throw new SecretReferenceError('INVALID_REFERENCE', 'Secret reference is invalid');
|
||||
const match = /^([a-z-]+):\/\/multi-simadmin\/([A-Za-z0-9_-]+)$/.exec(reference);
|
||||
if (!match?.[1] || !match[2])
|
||||
throw new SecretReferenceError('INVALID_REFERENCE', 'Secret reference is invalid');
|
||||
const scheme = match[1];
|
||||
const provider =
|
||||
scheme === 'keychain' ? 'macos-keychain' : scheme === 'secret-file' ? 'secret-file' : undefined;
|
||||
if (!provider) throw new SecretReferenceError('INVALID_REFERENCE', 'Secret reference is invalid');
|
||||
return parseSecretAccount(match[2], provider);
|
||||
}
|
||||
@@ -5,6 +5,8 @@ export interface SecretKey {
|
||||
}
|
||||
|
||||
export interface SecretStore {
|
||||
/** Stable identifier persisted in secret_references.provider ('macos-keychain' | 'secret-file'). */
|
||||
readonly provider: string;
|
||||
set(key: SecretKey, value: string): Promise<string>;
|
||||
get(reference: string): Promise<string | undefined>;
|
||||
delete(reference: string): Promise<boolean>;
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { SecretKey, SecretStore } from '../../infrastructure/secrets/secret
|
||||
import { registerAutomationRoutes } from './automation-routes.js';
|
||||
|
||||
class MemorySecrets implements SecretStore {
|
||||
readonly provider = 'macos-keychain';
|
||||
readonly values = new Map<string, string>();
|
||||
async set(key: SecretKey, value: string): Promise<string> {
|
||||
const reference = `memory://${key.instanceId}/${key.purpose}`;
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { SecretStore } from '../../infrastructure/secrets/secret-store.js';
|
||||
import { registerCentralNotificationRoutes } from './central-notification-routes.js';
|
||||
|
||||
class MemorySecrets implements SecretStore {
|
||||
readonly provider = 'macos-keychain';
|
||||
readonly values = new Map<string, string>();
|
||||
|
||||
async set(key: { instanceId: string; purpose: string }, value: string) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -33,7 +33,8 @@ const cookie = (token: string, secure: boolean): string =>
|
||||
`${CONSOLE_SESSION_COOKIE}=${token}; Path=${COOKIE_PATH}; HttpOnly; SameSite=Strict${secure ? '; Secure' : ''}; Max-Age=${MAX_AGE_SECONDS}`;
|
||||
const clearCookie = (secure: boolean): string =>
|
||||
`${CONSOLE_SESSION_COOKIE}=; Path=${COOKIE_PATH}; HttpOnly; SameSite=Strict${secure ? '; Secure' : ''}; Max-Age=0`;
|
||||
const usesSecureCookie = (request: FastifyRequest): boolean => request.protocol === 'https';
|
||||
const usesSecureCookie = (request: FastifyRequest): boolean =>
|
||||
request.protocol === 'https' || request.headers['x-forwarded-proto'] === 'https';
|
||||
|
||||
const problem = (request: FastifyRequest, status: number, code: string, detail: string) => ({
|
||||
type: 'about:blank',
|
||||
@@ -83,8 +84,26 @@ const loginSchema = {
|
||||
properties: { password: { type: 'string', minLength: 1, maxLength: 1024 } },
|
||||
} as const;
|
||||
|
||||
const LOGIN_BUCKETS_MAX = 10_000;
|
||||
|
||||
export function registerConsoleAuth(app: FastifyInstance, auth: ConsoleAuthService): void {
|
||||
const loginAttempts = new Map<string, { count: number; resetAt: number }>();
|
||||
const rememberAttempt = (key: string, attempt: { count: number; resetAt: number }): void => {
|
||||
// Direct-to-API callers can send arbitrary client-ip values; cap the map so
|
||||
// bucket keys cannot grow without bound.
|
||||
if (loginAttempts.size >= LOGIN_BUCKETS_MAX) {
|
||||
const now = Date.now();
|
||||
for (const [existing, value] of loginAttempts) {
|
||||
if (value.resetAt <= now) loginAttempts.delete(existing);
|
||||
}
|
||||
while (loginAttempts.size >= LOGIN_BUCKETS_MAX) {
|
||||
const oldest = loginAttempts.keys().next().value;
|
||||
if (oldest === undefined) break;
|
||||
loginAttempts.delete(oldest);
|
||||
}
|
||||
}
|
||||
loginAttempts.set(key, attempt);
|
||||
};
|
||||
app.addHook('preHandler', async (request, reply) => {
|
||||
const pathname = request.url.split(/[?#]/u, 1)[0] ?? request.url;
|
||||
if (!auth.isProtectedPath(pathname)) return;
|
||||
@@ -121,7 +140,7 @@ export function registerConsoleAuth(app: FastifyInstance, auth: ConsoleAuthServi
|
||||
attempts && attempts.resetAt > now
|
||||
? attempts
|
||||
: { count: 0, resetAt: now + LOGIN_WINDOW_MS };
|
||||
loginAttempts.set(key, { count: active.count + 1, resetAt: active.resetAt });
|
||||
rememberAttempt(key, { count: active.count + 1, resetAt: active.resetAt });
|
||||
return handle(request, reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
@@ -21,6 +22,7 @@ import type { SecretStore } from '../../infrastructure/secrets/secret-store.js';
|
||||
import { registerInstanceRoutes } from './instance-routes.js';
|
||||
|
||||
class FakeStore implements SecretStore {
|
||||
readonly provider = 'macos-keychain';
|
||||
async set(key: { instanceId: string; purpose: string; slot?: string }) {
|
||||
return `keychain://multi-simadmin/${Buffer.from(
|
||||
JSON.stringify([key.instanceId, key.purpose, key.slot]),
|
||||
@@ -77,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();
|
||||
@@ -201,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({
|
||||
|
||||
@@ -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;
|
||||
}),
|
||||
|
||||
@@ -23,6 +23,7 @@ async function fixtureOptions(): Promise<ProductionControlPlaneOptions> {
|
||||
databasePath: join(directory, 'db.sqlite'),
|
||||
gatewayToken,
|
||||
store: {
|
||||
provider: 'macos-keychain',
|
||||
set: async () => '',
|
||||
get: async () => undefined,
|
||||
delete: async () => false,
|
||||
|
||||
@@ -10,7 +10,11 @@ import {
|
||||
} from './application/system/console-update-service.js';
|
||||
import { openDatabase } from './infrastructure/database/database.js';
|
||||
import { migrateDatabase } from './infrastructure/database/migrations.js';
|
||||
import { MacOSKeychainSecretStore } from './infrastructure/secrets/keychain-secret-store.js';
|
||||
import {
|
||||
createDefaultSecretStore,
|
||||
resolveSecretBackend,
|
||||
defaultSecretFileMetadataCheck,
|
||||
} from './infrastructure/secrets/secret-backend.js';
|
||||
import type { SecretStore } from './infrastructure/secrets/secret-store.js';
|
||||
import { createProductionUpstream } from './infrastructure/transport/production-upstream.js';
|
||||
import { createProductionReadiness, defaultKeychainMetadataCheck } from './production-readiness.js';
|
||||
@@ -21,6 +25,7 @@ export interface ProductionControlPlaneOptions {
|
||||
readonly upstream?: SafeControlPlaneUpstream;
|
||||
readonly gatewayToken?: string;
|
||||
readonly keychainMetadataCheck?: () => boolean | Promise<boolean>;
|
||||
readonly secretFileMetadataCheck?: () => boolean | Promise<boolean>;
|
||||
readonly assetsCheck?: () => boolean | Promise<boolean>;
|
||||
readonly runtimeVersion?: string;
|
||||
readonly recoveryMarkersCheck?: () => number | Promise<number>;
|
||||
@@ -44,8 +49,10 @@ export function buildProductionControlPlane(
|
||||
const db = openDatabase(options.databasePath);
|
||||
try {
|
||||
migrateDatabase(db);
|
||||
const store = options.store ?? new MacOSKeychainSecretStore();
|
||||
const env = options.env ?? process.env;
|
||||
const secretFilePath = join(dirname(options.databasePath), 'secrets.json');
|
||||
const store = options.store ?? createDefaultSecretStore({ env, secretFilePath });
|
||||
const secretBackend = resolveSecretBackend(env);
|
||||
const updateRoot = env.MULTI_SIMADMIN_UPDATE_ROOT ?? process.cwd();
|
||||
const updateRunner = createCommandRunner({ cwd: updateRoot, timeoutMs: 120_000 });
|
||||
const app = buildControlPlaneApp({
|
||||
@@ -71,7 +78,16 @@ export function buildProductionControlPlane(
|
||||
readiness: createProductionReadiness({
|
||||
db,
|
||||
gatewayToken: options.gatewayToken ?? '',
|
||||
keychainMetadataCheck: options.keychainMetadataCheck ?? defaultKeychainMetadataCheck,
|
||||
...(secretBackend === 'macos-keychain'
|
||||
? {
|
||||
keychainMetadataCheck:
|
||||
options.keychainMetadataCheck ?? defaultKeychainMetadataCheck,
|
||||
}
|
||||
: {
|
||||
secretFileMetadataCheck:
|
||||
options.secretFileMetadataCheck ??
|
||||
(async () => defaultSecretFileMetadataCheck(secretFilePath)),
|
||||
}),
|
||||
...(options.assetsCheck ? { assetsCheck: options.assetsCheck } : {}),
|
||||
...(options.recoveryMarkersCheck
|
||||
? { recoveryMarkersCheck: options.recoveryMarkersCheck }
|
||||
|
||||
@@ -45,9 +45,9 @@ async function fixture(name = 'cutover'): Promise<CutoverPlan> {
|
||||
drillEvidence: drill,
|
||||
drillEvidenceSha256: createHash('sha256').update(drillContent).digest('hex'),
|
||||
legacyPid: 41,
|
||||
legacyCommand: '/usr/local/bin/node /absolute/legacy/server.js',
|
||||
legacyCommand: `${process.execPath} /absolute/legacy/server.js`,
|
||||
legacyIdentity: 'legacy-start-1',
|
||||
legacyStart: ['/usr/local/bin/node', '/absolute/legacy/server.js'],
|
||||
legacyStart: [process.execPath, '/absolute/legacy/server.js'],
|
||||
stateDir: join(root, 'state'),
|
||||
};
|
||||
}
|
||||
@@ -130,11 +130,11 @@ describe('cutover validation and reversible orchestration', () => {
|
||||
expect(signals).toEqual([41]);
|
||||
expect(starts[0]?.environment?.MULTI_SIMADMIN_GATEWAY_TOKEN).toBe(token);
|
||||
const statePath = join(plan.stateDir, 'cutover-state.json');
|
||||
expect((await lstat(statePath)).mode & 0o777).toBe(0o600);
|
||||
if (process.platform !== 'win32') expect((await lstat(statePath)).mode & 0o777).toBe(0o600);
|
||||
expect(await readFile(statePath, 'utf8')).not.toContain(token);
|
||||
expect(await rollback(plan, system)).toBe(43);
|
||||
expect(signals).toEqual([41, 42]);
|
||||
expect(starts[1]?.argv).toEqual(['/usr/local/bin/node', '/absolute/legacy/server.js']);
|
||||
expect(starts[1]?.argv).toEqual([process.execPath, '/absolute/legacy/server.js']);
|
||||
});
|
||||
|
||||
it('automatically restarts legacy if the gateway fails to own authenticated 8788', async () => {
|
||||
@@ -146,10 +146,10 @@ describe('cutover validation and reversible orchestration', () => {
|
||||
[
|
||||
process.execPath,
|
||||
'--import',
|
||||
resolve(process.cwd(), 'node_modules/.pnpm/tsx@4.22.4/node_modules/tsx/dist/loader.mjs'),
|
||||
'tsx',
|
||||
resolve(process.cwd(), 'apps/api/src/production-gateway-cli.ts'),
|
||||
],
|
||||
['/usr/local/bin/node', '/absolute/legacy/server.js'],
|
||||
[process.execPath, '/absolute/legacy/server.js'],
|
||||
]);
|
||||
const state = JSON.parse(await readFile(join(plan.stateDir, 'cutover-state.json'), 'utf8')) as {
|
||||
phase: string;
|
||||
@@ -176,11 +176,13 @@ describe('cutover validation and reversible orchestration', () => {
|
||||
const first = fakeSystem(plan);
|
||||
await cutover(plan, first.system, token);
|
||||
const statePath = join(plan.stateDir, 'cutover-state.json');
|
||||
await chmod(statePath, 0o644);
|
||||
await expect(rollback(plan, first.system)).rejects.toThrow(/mode-0600/);
|
||||
expect(first.signals).toEqual([41]);
|
||||
|
||||
await chmod(statePath, 0o600);
|
||||
// Windows chmod is a no-op, so the mode-0600 refusal only applies on POSIX.
|
||||
if (process.platform !== 'win32') {
|
||||
await chmod(statePath, 0o644);
|
||||
await expect(rollback(plan, first.system)).rejects.toThrow(/mode-0600/);
|
||||
expect(first.signals).toEqual([41]);
|
||||
await chmod(statePath, 0o600);
|
||||
}
|
||||
const state = JSON.parse(await readFile(statePath, 'utf8')) as Record<string, unknown>;
|
||||
await writeFile(
|
||||
statePath,
|
||||
|
||||
@@ -7,7 +7,9 @@ export interface ProductionReadinessOptions {
|
||||
readonly db: Database.Database;
|
||||
readonly gatewayToken: string;
|
||||
/** Metadata/availability probe only. This callback must never retrieve a secret. */
|
||||
readonly keychainMetadataCheck: () => boolean | Promise<boolean>;
|
||||
readonly keychainMetadataCheck?: () => boolean | Promise<boolean>;
|
||||
/** Metadata/availability probe for the file secret backend (never reads secrets). */
|
||||
readonly secretFileMetadataCheck?: () => boolean | Promise<boolean>;
|
||||
/** Release gates are deliberately required: omitting one fails readiness closed. */
|
||||
readonly assetsCheck?: () => boolean | Promise<boolean>;
|
||||
readonly recoveryMarkersCheck?: () => number | Promise<number>;
|
||||
@@ -56,11 +58,20 @@ export function createProductionReadiness(
|
||||
checks.gatewayAuth = Buffer.byteLength(options.gatewayToken) >= 32 ? 'ready' : 'unconfigured';
|
||||
if (checks.gatewayAuth !== 'ready') ready = false;
|
||||
try {
|
||||
checks.keychain = (await options.keychainMetadataCheck()) ? 'ready' : 'unavailable';
|
||||
if (options.keychainMetadataCheck !== undefined) {
|
||||
checks.keychain = (await options.keychainMetadataCheck()) ? 'ready' : 'unavailable';
|
||||
if (checks.keychain !== 'ready') ready = false;
|
||||
} else if (options.secretFileMetadataCheck !== undefined) {
|
||||
checks.secretFile = (await options.secretFileMetadataCheck()) ? 'ready' : 'unavailable';
|
||||
if (checks.secretFile !== 'ready') ready = false;
|
||||
} else {
|
||||
checks.secrets = 'unconfigured';
|
||||
ready = false;
|
||||
}
|
||||
} catch {
|
||||
checks.keychain = 'unavailable';
|
||||
checks.secrets = 'unavailable';
|
||||
ready = false;
|
||||
}
|
||||
if (checks.keychain !== 'ready') ready = false;
|
||||
|
||||
try {
|
||||
const row = options.db
|
||||
|
||||
@@ -254,11 +254,14 @@ export async function appendReleaseEvidence(
|
||||
await handle.close();
|
||||
}
|
||||
await rename(temporary, absolute);
|
||||
const directory = await open(parent, 'r');
|
||||
try {
|
||||
await directory.sync();
|
||||
} finally {
|
||||
await directory.close();
|
||||
if (process.platform !== 'win32') {
|
||||
// directory fsync only exists on POSIX
|
||||
const directory = await open(parent, 'r');
|
||||
try {
|
||||
await directory.sync();
|
||||
} finally {
|
||||
await directory.close();
|
||||
}
|
||||
}
|
||||
return Object.freeze(record);
|
||||
} finally {
|
||||
|
||||
@@ -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 台设备');
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
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([]);
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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, ''));
|
||||
}
|
||||
@@ -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 的全局访问密码。
|
||||
@@ -30,6 +30,14 @@ export default defineConfig(
|
||||
},
|
||||
rules: {
|
||||
'no-undef': 'off',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
Generated
-3843
File diff suppressed because it is too large
Load Diff
@@ -417,10 +417,12 @@ export function parseCreateScheduledTaskRequest(value: unknown): CreateScheduled
|
||||
throw new TypeError('effectiveEndAt must be later than effectiveStartAt');
|
||||
if (source.enabled !== undefined && typeof source.enabled !== 'boolean')
|
||||
throw new TypeError('enabled must be boolean');
|
||||
// The upstream execution baseline pins reboot delay_seconds to 3; accepting a
|
||||
// wider range here would only produce tasks that can never run.
|
||||
const delaySeconds =
|
||||
source.delaySeconds === undefined
|
||||
? undefined
|
||||
: numberInRange(source.delaySeconds, 'delaySeconds', 0, 3_600);
|
||||
: numberInRange(source.delaySeconds, 'delaySeconds', 3, 3);
|
||||
if (delaySeconds !== undefined && operationType !== 'reboot-system')
|
||||
throw new TypeError('delaySeconds is only valid for reboot-system');
|
||||
return {
|
||||
|
||||
@@ -54,7 +54,7 @@ async function jsonFiles(dir: string): Promise<string[]> {
|
||||
for (const e of await readdir(dir, { withFileTypes: true })) {
|
||||
const p = path.join(dir, e.name);
|
||||
if (e.isDirectory()) out.push(...(await jsonFiles(p)));
|
||||
else if (e.name.endsWith('.json')) out.push(p);
|
||||
else if (e.name.endsWith('.json')) out.push(p.split(path.sep).join('/'));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -221,7 +221,15 @@ test('real fixtures satisfy envelope, registry, leak scan, and manifest integrit
|
||||
const files = (await jsonFiles(fixtureRoot)).filter((x) => !x.includes('/synthetic-errors/'));
|
||||
const srcEntries = await readdir(path.join(root, 'src'));
|
||||
assert.ok(!srcEntries.includes('synthetic-errors'));
|
||||
const config = JSON.parse(await readFile(path.resolve(root, '../../config.json'), 'utf8'));
|
||||
// config.json is gitignored and holds the only real private values; on a
|
||||
// fresh clone there is nothing to scan for, so the leak check degrades to a
|
||||
// no-op instead of inventing false positives from the example placeholders.
|
||||
const configPath = path.resolve(root, '../../config.json');
|
||||
const configText = await readFile(configPath, 'utf8').catch((error) => {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
||||
return undefined;
|
||||
});
|
||||
const config = configText === undefined ? {} : JSON.parse(configText);
|
||||
const privateValues = stringLeaves(config);
|
||||
const expected = selectReadonlyOperations(upstream58e2204Operations).selected;
|
||||
assert.equal(files.length, expected.length * 2);
|
||||
@@ -249,7 +257,9 @@ test('real fixtures satisfy envelope, registry, leak scan, and manifest integrit
|
||||
const registry = new Map(upstream58e2204Operations.map((x) => [x.operationId, x]));
|
||||
const domains = new Map<string, number>();
|
||||
const pairs = new Set<string>();
|
||||
const disk = files.map((file) => path.relative(root, file)).sort();
|
||||
const disk = files
|
||||
.map((file) => path.relative(root, file).split(path.sep).join('/'))
|
||||
.sort();
|
||||
const listed = manifest.files.map((x: any) => x.path).sort();
|
||||
assert.deepEqual(listed, disk);
|
||||
assert.equal(new Set(listed).size, listed.length);
|
||||
@@ -296,7 +306,7 @@ test('real fixtures satisfy envelope, registry, leak scan, and manifest integrit
|
||||
for (const p of leakPatterns) assert.doesNotMatch(text, p);
|
||||
for (const value of privateValues)
|
||||
assert.ok(!text.includes(value), 'fixture contains configured value');
|
||||
const rel = path.relative(root, file);
|
||||
const rel = path.relative(root, file).split(path.sep).join('/');
|
||||
const m = manifest.files.find((x: any) => x.path === rel);
|
||||
assert.ok(m);
|
||||
assert.equal(m.size, (await stat(file)).size);
|
||||
|
||||
+1
-1
@@ -100,7 +100,7 @@ async function runEndpoint(confirmed=false){
|
||||
} catch(error){ persistentError(`请求参数无效:${error.message||error}`); return }
|
||||
const {owner,method,path:endpoint,body}=draft, btn=$('runEndpoint'), token=requests.next('api-console'); if(btn.disabled)return
|
||||
btn.dataset.idleLabel='发送请求'; setPending(btn,true,'发送中…'); $('rawOutput').textContent='请求进行中…'; persistentError(''); const started=performance.now()
|
||||
try{ const init={method}; if(isWriteMethod(method)){ const prepared=await api.prepareConfirmation({instanceId:owner,method,path:endpoint,body}); if(!requests.isCurrent('api-console',token)||owner!==activeId)return; init.headers={'content-type':'application/json','x-confirmation-token':prepared.token}; if(body!==undefined)init.body=JSON.stringify(body) } const res=await api.proxy(owner,endpoint,init), text=await res.text(); if(!requests.isCurrent('api-console',token)||owner!==activeId)return; const elapsed=Math.round(performance.now()-started); outputText=text; try{outputPrettyText=JSON.stringify(JSON.parse(text),null,2)}catch{outputPrettyText=text} $('rawOutput').textContent=`HTTP ${res.status} ${res.statusText} · ${elapsed}ms · ${draft.method} ${draft.path}\n\n${$('outputPretty').getAttribute('aria-pressed')==='true'?outputPrettyText:outputText}`; if(!res.ok)persistentError(`API 请求失败:HTTP ${res.status} ${res.statusText}\n${text}`) }catch(error){ if(requests.isCurrent('api-console',token)&&owner===activeId){$('rawOutput').textContent=error.message||String(error);persistentError(`API 请求失败:${error.message||error}`)} }finally{ if(requests.isCurrent('api-console',token)) setPending(btn,false,'发送中…') }
|
||||
try{ const init={method}; if(isWriteMethod(method)){ const prepared=await api.prepareConfirmation({instanceId:owner,method,path:endpoint,body}); if(!requests.isCurrent('api-console',token)||owner!==activeId)return; init.headers={'x-confirmation-token':prepared.token}; if(body!==undefined){ init.body=JSON.stringify(body); init.headers['content-type']='application/json' } } const res=await api.proxy(owner,endpoint,init), text=await res.text(); if(!requests.isCurrent('api-console',token)||owner!==activeId)return; const elapsed=Math.round(performance.now()-started); outputText=text; try{outputPrettyText=JSON.stringify(JSON.parse(text),null,2)}catch{outputPrettyText=text} $('rawOutput').textContent=`HTTP ${res.status} ${res.statusText} · ${elapsed}ms · ${draft.method} ${draft.path}\n\n${$('outputPretty').getAttribute('aria-pressed')==='true'?outputPrettyText:outputText}`; if(!res.ok)persistentError(`API 请求失败:HTTP ${res.status} ${res.statusText}\n${text}`) }catch(error){ if(requests.isCurrent('api-console',token)&&owner===activeId){$('rawOutput').textContent=error.message||String(error);persistentError(`API 请求失败:${error.message||error}`)} }finally{ if(requests.isCurrent('api-console',token)) setPending(btn,false,'发送中…') }
|
||||
}
|
||||
async function refreshStatus({silent=false}={}){ const btn=$('refreshBtn'), token=requests.next('fleet-status'); btn.disabled=true; try{const data=await api.status(); if(!requests.isCurrent('fleet-status',token))return; statuses=new Map((data.instances||[]).map(x=>[x.id,x]));lastSuccessfulRefresh=new Date();$('staleBanner').hidden=true;persistentError('');renderHomeCards();if(activeId)renderDetail();if(!silent)showToast(`已刷新 ${statuses.size} 个设备`)}catch(error){if(!requests.isCurrent('fleet-status',token))return;$('staleBanner').textContent=`刷新失败,当前显示上次成功数据。上次成功:${lastSuccessfulRefresh?lastSuccessfulRefresh.toLocaleString():'尚无'}。${error.message}`;$('staleBanner').hidden=false;persistentError(`状态刷新失败:${error.message}`)}finally{if(requests.isCurrent('fleet-status',token))btn.disabled=false} }
|
||||
async function loadConfig(){ const [data,cat]=await Promise.all([api.config(),api.catalog().catch(()=>({groups:[]}))]);instances=data.instances||[];$('configPath').textContent=data.configPath||'config.json';catalog=cat.groups||[];renderEndpointList();const saved=localStorage.getItem('multi-simadmin-active');activeId=saved&&instances.some(x=>x.id===saved)?saved:null;renderHomeCards() }
|
||||
|
||||
+104
-11
@@ -4,7 +4,15 @@ 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"}
|
||||
PLATFORM=$(uname -s)
|
||||
default_app_root() {
|
||||
case "$PLATFORM" in
|
||||
Darwin) printf '%s' "$HOME/Library/Application Support/multi-simadmin" ;;
|
||||
Linux) printf '%s' "${XDG_DATA_HOME:-$HOME/.local/share}/multi-simadmin" ;;
|
||||
*) printf '' ;;
|
||||
esac
|
||||
}
|
||||
APP_ROOT=${MULTI_SIMADMIN_HOME:-"$(default_app_root)"}
|
||||
SOURCE_DIR="$APP_ROOT/source"
|
||||
DATA_ROOT="$APP_ROOT/data"
|
||||
RUNTIME_DIR="$APP_ROOT/run"
|
||||
@@ -28,15 +36,19 @@ Multi SimAdmin 一键安装与服务管理
|
||||
restart 停止后重新启动
|
||||
status 显示进程、端口与健康状态
|
||||
uninstall 卸载程序,默认保留数据、令牌和日志
|
||||
unit (仅 Linux)生成 systemd 用户单元,支持开机自启与在线自更新重启
|
||||
环境变量:
|
||||
MULTI_SIMADMIN_HOME 安装根目录
|
||||
MULTI_SIMADMIN_HOME 安装根目录(默认 macOS 为 ~/Library/Application Support/multi-simadmin,Linux 为 ~/.local/share/multi-simadmin)
|
||||
MULTI_SIMADMIN_ALLOWED_HOSTS 额外允许的 LAN 主机名/IP,逗号分隔
|
||||
MULTI_SIMADMIN_REPO_URL 源码仓库地址
|
||||
EOF
|
||||
}
|
||||
|
||||
require_macos() {
|
||||
[ "$(uname -s)" = Darwin ] || die "当前生产版仅支持 macOS(秘密存储依赖 macOS Keychain)。"
|
||||
require_platform() {
|
||||
case "$PLATFORM" in
|
||||
Darwin|Linux) ;;
|
||||
*) die "当前生产版仅支持 macOS 或 Linux。" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
require_commands() {
|
||||
@@ -61,14 +73,27 @@ pid_owned() {
|
||||
}
|
||||
|
||||
port_free() {
|
||||
! lsof -nP -iTCP:"$1" -sTCP:LISTEN >/dev/null 2>&1
|
||||
if command -v lsof >/dev/null 2>&1; then
|
||||
! lsof -nP -iTCP:"$1" -sTCP:LISTEN >/dev/null 2>&1
|
||||
elif command -v ss >/dev/null 2>&1; then
|
||||
! ss -ltn "( sport = :$1 )" 2>/dev/null | grep -q ":$1 "
|
||||
else
|
||||
die "缺少 lsof 或 ss,无法检查端口占用"
|
||||
fi
|
||||
}
|
||||
|
||||
lan_ip() {
|
||||
for interface in en0 en1; do
|
||||
value=$(ipconfig getifaddr "$interface" 2>/dev/null || true)
|
||||
if [ -n "$value" ]; then printf '%s' "$value"; return; fi
|
||||
done
|
||||
if [ "$PLATFORM" = Darwin ]; then
|
||||
for interface in en0 en1; do
|
||||
value=$(ipconfig getifaddr "$interface" 2>/dev/null || true)
|
||||
if [ -n "$value" ]; then printf '%s' "$value"; return; fi
|
||||
done
|
||||
else
|
||||
value=$(ip -4 addr show scope global 2>/dev/null | awk '/inet /{print $2; exit}' | cut -d/ -f1)
|
||||
if [ -n "${value:-}" ]; then printf '%s' "$value"; return; fi
|
||||
value=$(hostname -I 2>/dev/null | awk '{print $1}')
|
||||
if [ -n "${value:-}" ]; then printf '%s' "$value"; return; fi
|
||||
fi
|
||||
printf '127.0.0.1'
|
||||
}
|
||||
|
||||
@@ -135,7 +160,7 @@ wait_for() {
|
||||
}
|
||||
|
||||
start_service() {
|
||||
require_macos
|
||||
require_platform
|
||||
require_commands
|
||||
[ -f "$SOURCE_DIR/apps/web/dist/index.html" ] || die "尚未安装,请先运行 install"
|
||||
[ -f "$TOKEN_FILE" ] && [ ! -L "$TOKEN_FILE" ] || die "Gateway token 缺失或不是安全的普通文件,请重新运行 install"
|
||||
@@ -192,6 +217,7 @@ start_service() {
|
||||
trap 'rollback_start' EXIT HUP INT TERM
|
||||
MULTI_SIMADMIN_DATA_ROOT="$DATA_ROOT" \
|
||||
MULTI_SIMADMIN_DATABASE_PATH="$DATA_ROOT/control-plane.sqlite3" \
|
||||
MULTI_SIMADMIN_SECRET_BACKEND="$( [ "$PLATFORM" = Darwin ] && printf macos-keychain || printf secret-file )" \
|
||||
MULTI_SIMADMIN_GATEWAY_TOKEN="$token" \
|
||||
MULTI_SIMADMIN_WEB_DIST="$SOURCE_DIR/apps/web/dist" \
|
||||
API_HOST=127.0.0.1 API_PORT=8790 \
|
||||
@@ -256,7 +282,7 @@ status_service() {
|
||||
}
|
||||
|
||||
install_app() {
|
||||
require_macos
|
||||
require_platform
|
||||
require_commands
|
||||
mkdir -p "$APP_ROOT" "$DATA_ROOT" "$RUNTIME_DIR" "$LOG_DIR"
|
||||
chmod 700 "$APP_ROOT" "$DATA_ROOT" "$RUNTIME_DIR" "$LOG_DIR"
|
||||
@@ -272,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 ;;
|
||||
@@ -280,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
|
||||
|
||||
+19
-3
@@ -24,7 +24,7 @@ const CATALOG = [
|
||||
|
||||
export async function buildApp({ configStore, clientRegistry, logger = false, staticFiles = true, publicDir = path.join(ROOT, 'public'), reconcileRetryMs = 100, proxyPolicy = defaultProxyPolicy, confirmationOptions = {} } = {}) {
|
||||
if (!configStore || !clientRegistry) throw new TypeError('configStore and clientRegistry are required')
|
||||
const app = Fastify({ logger, bodyLimit: 60 * 1024 * 1024 })
|
||||
const app = Fastify({ logger, bodyLimit: 2 * 1024 * 1024 })
|
||||
const parseAuthority = raw => {
|
||||
const value = String(raw || '').trim().toLowerCase()
|
||||
if (!value || /[\s/@]/.test(value)) return null
|
||||
@@ -46,6 +46,11 @@ export async function buildApp({ configStore, clientRegistry, logger = false, st
|
||||
const configuredRawHost = String(configStore.snapshot.server.host).replace(/^\[|\]$/g, '').toLowerCase()
|
||||
const configuredHost = isIP(configuredRawHost) === 6 ? '::1' : configuredRawHost
|
||||
const configuredPort = String(configStore.snapshot.server.port)
|
||||
app.addHook('onRequest', async (_request, reply) => {
|
||||
reply.header('x-frame-options', 'DENY')
|
||||
reply.header('content-security-policy', "frame-ancestors 'none'")
|
||||
reply.header('x-content-type-options', 'nosniff')
|
||||
})
|
||||
app.addHook('onRequest', async (request, reply) => {
|
||||
const authority=loopbackAuthority(request.headers.host)
|
||||
const injectDefault = request.headers.host === 'localhost:80' && request.raw.socket?.localPort == null
|
||||
@@ -58,7 +63,14 @@ export async function buildApp({ configStore, clientRegistry, logger = false, st
|
||||
}
|
||||
})
|
||||
app.addContentTypeParser('application/octet-stream', { parseAs: 'buffer' }, (_request, body, done) => done(null, body))
|
||||
app.addContentTypeParser(/^multipart\//, { parseAs: 'buffer' }, (_request, body, done) => done(null, body))
|
||||
app.addContentTypeParser('application/x-www-form-urlencoded', { parseAs: 'string' }, (_request, body, done) => done(null, body))
|
||||
// Unparsable content types (e.g. multipart) must fail with the same stable body
|
||||
// the proxy policy would have produced, without buffering the payload first.
|
||||
app.setErrorHandler((error, _request, reply) => {
|
||||
if (error?.code === 'FST_ERR_CTP_INVALID_MEDIA_TYPE') return reply.code(415).send({ error: 'unsupported proxy content type' })
|
||||
if (error?.code === 'FST_ERR_CTP_EMPTY_JSON_BODY') return reply.code(400).send({ error: 'empty JSON body' })
|
||||
reply.send(error)
|
||||
})
|
||||
if (staticFiles) await app.register(fastifyStatic, { root: publicDir, prefix: '/' })
|
||||
const findClient = (id, reply) => {
|
||||
const client = clientRegistry.get(id)
|
||||
@@ -101,7 +113,11 @@ export async function buildApp({ configStore, clientRegistry, logger = false, st
|
||||
app.post('/api/instances/:id/login', async (request, reply) => {
|
||||
const client = findClient(request.params.id, reply); if (!client) return
|
||||
const supplied = Object.hasOwn(request.body || {}, 'password')
|
||||
return { ...(await client.ensureAuthenticated(supplied ? { credential: request.body.password } : {})), hasSavedPassword: Boolean(configStore.snapshot.instances.find(item => item.id === request.params.id)?.auth?.password) }
|
||||
try {
|
||||
return { ...(await client.ensureAuthenticated(supplied ? { credential: request.body.password } : {})), hasSavedPassword: Boolean(configStore.snapshot.instances.find(item => item.id === request.params.id)?.auth?.password) }
|
||||
} catch (error) {
|
||||
return reply.code(502).send({ error: 'upstream request failed' })
|
||||
}
|
||||
})
|
||||
app.post('/api/instances/:id/logout', async (request, reply) => {
|
||||
const client = findClient(request.params.id, reply); if (!client) return
|
||||
|
||||
@@ -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
|
||||
|
||||
+49
-5
@@ -1,7 +1,13 @@
|
||||
|
||||
export { normalizeBaseUrl, normalizeInstance } from './config/schema.js'
|
||||
|
||||
export const DEFAULT_TIMEOUT_MS = Number(process.env.MULTI_SIMADMIN_TIMEOUT_MS || 6000)
|
||||
function parsePositiveIntEnv(value, fallback) {
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback
|
||||
}
|
||||
|
||||
export const DEFAULT_TIMEOUT_MS = parsePositiveIntEnv(process.env.MULTI_SIMADMIN_TIMEOUT_MS, 6000)
|
||||
export const DEFAULT_MAX_BODY_BYTES = parsePositiveIntEnv(process.env.MULTI_SIMADMIN_MAX_BODY_BYTES, 10 * 1024 * 1024)
|
||||
export function redactInstance(instance) {
|
||||
return {
|
||||
id: instance.id,
|
||||
@@ -165,7 +171,6 @@ export function createSimAdminClient(instance, { fetchImpl = fetch, timeoutMs =
|
||||
const headers = new Headers(options.headers || {})
|
||||
const cookie = jar.header()
|
||||
if (cookie && !headers.has('cookie')) headers.set('cookie', cookie)
|
||||
if (headers.has('cookie')) headers.cookie = headers.get('cookie')
|
||||
try {
|
||||
const response = await fetchImpl(url, { ...options, headers, signal: controller.signal, redirect: options.redirect || 'manual' })
|
||||
jar.setFromHeaders(response.headers)
|
||||
@@ -178,13 +183,14 @@ export function createSimAdminClient(instance, { fetchImpl = fetch, timeoutMs =
|
||||
async function fetchJson(endpoint, options = {}) {
|
||||
const startedAt = Date.now()
|
||||
const response = await request(endpoint, { method: 'GET', ...options, headers: { accept: 'application/json,text/plain,*/*', ...(options.headers || {}) } })
|
||||
const text = await response.text()
|
||||
const text = (await readResponseBody(response, { bodyTimeoutMs: options.timeoutMs || timeoutMs })).toString('utf8')
|
||||
let data = null
|
||||
try { data = text ? JSON.parse(text) : null } catch {}
|
||||
return { ok: response.ok, status: response.status, latencyMs: Date.now() - startedAt, data, text: data ? undefined : text.slice(0, 1000), headers: response.headers }
|
||||
}
|
||||
|
||||
let ephemeralSecret = ''
|
||||
let lastFailedAutoLoginAt = 0
|
||||
async function ensureAuthenticated({ credential } = {}) {
|
||||
const status = await fetchJson('/api/auth/status')
|
||||
const auth = apiData(status.data) || {}
|
||||
@@ -198,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' }
|
||||
}
|
||||
|
||||
@@ -212,6 +226,36 @@ export function createSimAdminClient(instance, { fetchImpl = fetch, timeoutMs =
|
||||
return { instance, jar, request, fetchJson, ensureAuthenticated, clearEphemeralSecret }
|
||||
}
|
||||
|
||||
export function buildClients(instances, options = {}) {
|
||||
return new Map(instances.map(instance => [instance.id, createSimAdminClient(instance, options)]))
|
||||
// The header timeout ends once headers arrive; body reads need their own
|
||||
// deadline and a byte budget so a stalled or hostile upstream can neither hang
|
||||
// status aggregation nor exhaust memory.
|
||||
export async function readResponseBody(response, { maxBytes = DEFAULT_MAX_BODY_BYTES, bodyTimeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
||||
const reader = response.body?.getReader()
|
||||
if (!reader) return Buffer.alloc(0)
|
||||
const chunks = []
|
||||
let size = 0
|
||||
let failure = null
|
||||
const timer = setTimeout(() => {
|
||||
failure = new Error('upstream response body timed out')
|
||||
reader.cancel(failure).catch(() => {})
|
||||
}, bodyTimeoutMs)
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
size += value.byteLength
|
||||
if (size > maxBytes) {
|
||||
failure = new Error('upstream response body exceeded the size limit')
|
||||
throw failure
|
||||
}
|
||||
chunks.push(Buffer.from(value))
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
if (failure) {
|
||||
await reader.cancel(failure).catch(() => {})
|
||||
throw failure
|
||||
}
|
||||
return Buffer.concat(chunks)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ const AUTH_PATH = /^\/api\/(?:auth(?:\/|$)|login(?:\/|$)|logout(?:\/|$))/i
|
||||
|
||||
export const DEFAULT_READ_PATHS = Object.freeze([
|
||||
'/api/health', '/api/device', '/api/sim', '/api/network', '/api/stats', '/api/connectivity',
|
||||
'/api/sms/stats', '/api/sms/list', '/api/sms/send', '/api/sms/conversation',
|
||||
'/api/sms/stats', '/api/sms/list', '/api/sms/conversation',
|
||||
'/api/data', '/api/roaming', '/api/airplane-mode', '/api/radio-mode', '/api/band-lock', '/api/cell-lock', '/api/apn', '/api/cells',
|
||||
'/api/device-network/ddns/status', '/api/device-network/ddns/config', '/api/device-network/wlan/status', '/api/device-network/wlan/profiles',
|
||||
'/api/calls', '/api/call/history', '/api/ims/status', '/api/voicemail/status',
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { readResponseBody } from '../core.js'
|
||||
|
||||
const BLOCKED_REQUEST_HEADERS = new Set([
|
||||
'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailer',
|
||||
'transfer-encoding', 'upgrade', 'host', 'content-length', 'cookie', 'authorization',
|
||||
@@ -63,5 +65,5 @@ export async function proxyToInstance({ client, request, reply, rest }) {
|
||||
upstream.headers.forEach((value, name) => {
|
||||
if (!BLOCKED_RESPONSE_HEADERS.has(name.toLowerCase())) reply.header(name, value)
|
||||
})
|
||||
return reply.send(Buffer.from(await upstream.arrayBuffer()))
|
||||
return reply.send(await readResponseBody(upstream))
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ 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', () => {
|
||||
test('one-click installer has safe lifecycle commands and pinned production defaults', { skip: process.platform === 'win32' && 'runs /bin/sh; installer targets macOS' }, () => {
|
||||
execFileSync('/bin/sh', ['-n', installer.pathname]);
|
||||
const help = execFileSync('/bin/sh', [installer.pathname, 'help'], { encoding: 'utf8' });
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import { RequestCoordinator } from '../public/state/request-coordinator.js'
|
||||
import { createApiClient } from '../public/infrastructure/api-client.js'
|
||||
import { createViewRouter } from '../public/router/view-router.js'
|
||||
|
||||
const projectRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..')
|
||||
const projectRoot = path.resolve(import.meta.dirname, '..')
|
||||
|
||||
async function files(instances = []) {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'msa-blockers-'))
|
||||
|
||||
@@ -35,7 +35,7 @@ test('client stores simadmin_session from login and sends it on later proxied re
|
||||
assert.equal(auth.authenticated, true)
|
||||
const proxied = await client.fetchJson('/api/device')
|
||||
assert.equal(proxied.status, 200)
|
||||
assert.match(calls.at(-1).options.headers.cookie, /simadmin_session=abc123/)
|
||||
assert.match(calls.at(-1).options.headers.get('cookie'), /simadmin_session=abc123/)
|
||||
})
|
||||
|
||||
test('snapshot summary exposes device-monitoring fields from stats, sms, data and hardware endpoints', () => {
|
||||
|
||||
Reference in New Issue
Block a user