Compare commits

...
9 Commits
Author SHA1 Message Date
chick 29a0eee854 style: apply prettier to the repo's declared format surface 2026-09-07 01:59:24 +08:00
chick 2977f75129 fix: apply security and correctness review findings across both stacks
Legacy panel:
- upstream body reads now carry their own deadline and a 10 MB byte budget;
  a stalled modem can no longer hang /api/status fan-out forever nor OOM
  the proxy (request headers alone had the timeout, bodies had none)
- add X-Frame-Options DENY / CSP frame-ancestors none / nosniff; the panel
  (delete-instance and confirmed-write dialogs) is no longer clickjackable
- only send a JSON content-type when the API console request has a body, so
  payload-less dangerous writes stop failing with 400 and burning the
  one-use confirmation token
- register a form-urlencoded parser (the proxy branch was unreachable) and
  drop the multipart parser that buffered up to 60 MB before rejecting;
  bodyLimit drops to 2 MB; framework-level 415 keeps the stable error body
- remove /api/sms/send from readable paths: GET bypassed the write
  confirmation for a send endpoint
- /api/instances/:id/login maps upstream failures to a stable 502 instead
  of leaking raw error text
- guard MULTI_SIMADMIN_TIMEOUT_MS parsing (NaN aborted every request);
  prune dead code (buildClients, cookie expando no-op)

Control plane:
- deleting a notification channel detaches it from rules instead of leaving
  dangling ids that made every referencing rule unreadable and silently
  dropped future notifications; rule reads tolerate unknown ids
- startup sweep resets notification_queue rows stranded in 'sending' by a
  crash (mirrors the sms outbox sweep); terminal outbox rows are pruned on
  the retention timer
- /api/v1/metrics no longer emits operator-assigned node names on the
  session-free scrape; login limiter map is bounded and pruned; Secure
  cookie honors the gateway-declared x-forwarded-proto
- webhook delivery sets redirect: manual (signed payloads are not replayed)
- SMTP envelope sender is validated against CR/LF smuggling
- scheduled reboots with delaySeconds != 3 fail fast at the dispatcher with
  a clear reason instead of burning every retry; contract narrowed to the
  pinned baseline
2026-09-07 01:57:47 +08:00
chick 236e78327c feat(console-update): systemd restart launcher for Linux supervision
Linux deployments under a systemd unit can now use the online update
panel: MULTI_SIMADMIN_SYSTEMD_UNIT (+ optional _SCOPE=user|system) hands
the restart to systemctl; createPlatformRestartLauncher picks launchd on
macOS and systemd on Linux, degrading to 'unsupported' otherwise.
2026-09-07 01:38:23 +08:00
chick 3c5971e167 feat(installer): support Linux hosts with XDG data root and ss port probe
- platform gate accepts Darwin and Linux; other platforms are refused
- default install root follows each platform's convention
  (~/Library/Application Support vs ~/.local/share)
- LAN IP discovery uses ip -4/hostname -I on Linux, ipconfig on macOS
- port occupancy checks fall back from lsof to ss
- API process gets MULTI_SIMADMIN_SECRET_BACKEND pinned per platform
- README documents the per-platform secret storage
2026-09-07 01:36:04 +08:00
chick e4c4bce75b feat(secrets): add a 0600 file secret backend so production runs on Linux
The production composition hardwired the macOS Keychain (/usr/bin/security),
which made Linux deployment impossible. Now:

- SecretStore gains a stable provider identity persisted in
  secret_references.provider; services stop hardcoding 'macos-keychain'
- shared reference codec (secret-reference.ts) understands both
  keychain:// and secret-file:// references
- FileSecretStore: single 0600 JSON map under the data root, atomic
  temp+rename writes, serialized in-process, same validation envelope
- production composition picks the backend via
  MULTI_SIMADMIN_SECRET_BACKEND (default: Keychain on darwin, file store
  elsewhere) and readiness probes the matching backend
2026-09-07 01:32:07 +08:00
chick c57dc81e42 fix: finish cross-platform test parity while keeping POSIX deployment semantics
- cutover readState: enforce 0600 mode bits only on POSIX (Windows ACLs
  govern access; chmod is a no-op there)
- backup activation: skip the read-only-handle fsync on win32; the staged
  rename-over-open-WAL tests keep running on the POSIX deployment targets
- test-fixtures: normalize fixture paths to POSIX separators before
  comparing with manifest entries
2026-09-07 01:18:44 +08:00
chick 96dd9e2ba6 chore: pin LF line endings via .gitattributes
Windows checkouts previously materialized CRLF, breaking every pinned
sha256 snapshot check in operation-registry and test-fixtures.
2026-09-07 01:10:19 +08:00
chick 4f42a6f835 fix: make cutover restart argv upgrade-safe and tests platform-honest
- cutover-orchestrator: restart via 'node --import tsx' (matching install.sh)
  instead of a hardcoded .pnpm/tsx@4.22.4 loader path that breaks on tsx bumps
- production-gateway: use process.execPath for the legacy-start fixture;
  POSIX-only 0600 mode assertion now win32-guarded
- cli-runtime + installer suites: skip where they require POSIX-only
  process-group SIGTERM, lsof, or /bin/sh
- fixture-safety: fall back to config.example.json when config.json is absent
  on a fresh clone
2026-09-07 01:09:49 +08:00
chick eed134a25c fix: honor underscore-unused convention and make durability fsyncs cross-platform
- eslint: respect the repo's existing _-prefix convention for unused vars
- phase-one-blockers: derive projectRoot via import.meta.dirname so Windows
  checkouts stop producing C:\C:\... paths
- backup/release-evidence/cutover-orchestrator: skip POSIX-only directory
  fsync on win32 and fsync read-only handles through a writable handle
2026-09-07 01:07:16 +08:00
57 changed files with 1007 additions and 151 deletions
+1
View File
@@ -0,0 +1 @@
* text=auto eol=lf
+3 -3
View File
@@ -14,7 +14,7 @@
- 融合 [SimAdminHub](https://github.com/3899/SimAdminHub) 的管理思路:跨节点短信、通知、自动化与跨域健康自然融入 Fleet 节点总览体系,无需部署或接入外部 Hub。 - 融合 [SimAdminHub](https://github.com/3899/SimAdminHub) 的管理思路:跨节点短信、通知、自动化与跨域健康自然融入 Fleet 节点总览体系,无需部署或接入外部 Hub。
- 敏感配置留在本地 `config.json`,仓库只提交 `config.example.json`。 - 敏感配置留在本地 `config.json`,仓库只提交 `config.example.json`。
## 一键安装(macOS) ## 一键安装(macOS / Linux)
```bash ```bash
curl -fsSL https://gitea.chickliu.fun/Hermes/multi-simadmin/raw/branch/main/scripts/install.sh | sh 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 和访问控制。 安装完成后按终端输出访问 `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,7 @@ sh /tmp/multi-simadmin-install.sh start
sh /tmp/multi-simadmin-install.sh uninstall 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`(逗号分隔)配置。
### Fleet 节点总览与跨域健康 ### Fleet 节点总览与跨域健康
@@ -9,7 +9,7 @@ describe('ScheduledOperationDispatcher', () => {
it('renders SMS time and random macros separately for each target', async () => { it('renders SMS time and random macros separately for each target', async () => {
const db = new Database(':memory:'); const db = new Database(':memory:');
migrateDatabase(db); 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, sent: true as const,
})); }));
const dispatcher = new ScheduledOperationDispatcher({ const dispatcher = new ScheduledOperationDispatcher({
@@ -17,6 +17,7 @@ describe('ScheduledOperationDispatcher', () => {
operations: { prepare: vi.fn(), execute: vi.fn() }, operations: { prepare: vi.fn(), execute: vi.fn() },
messages: { send }, messages: { send },
store: { store: {
provider: 'macos-keychain',
set: vi.fn(), set: vi.fn(),
get: vi.fn(async () => get: vi.fn(async () =>
JSON.stringify({ JSON.stringify({
@@ -90,7 +91,7 @@ describe('ScheduledOperationDispatcher', () => {
execute, execute,
}, },
messages: { send: vi.fn() }, 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), repository: new ScheduledTaskRepository(db),
}); });
const result = await dispatcher.dispatch( const result = await dispatcher.dispatch(
@@ -179,6 +180,7 @@ describe('ScheduledOperationDispatcher', () => {
operations: { prepare: vi.fn(), execute: vi.fn() }, operations: { prepare: vi.fn(), execute: vi.fn() },
messages: { send }, messages: { send },
store: { store: {
provider: 'macos-keychain',
set: vi.fn(), set: vi.fn(),
get: vi.fn(async () => get: vi.fn(async () =>
JSON.stringify({ recipients: ['13800138000', '13900139000'], content: 'Maintenance' }), JSON.stringify({ recipients: ['13800138000', '13900139000'], content: 'Maintenance' }),
@@ -216,7 +218,7 @@ describe('ScheduledOperationDispatcher', () => {
execute: vi.fn().mockResolvedValue({ id: 'job-a', status: 'succeeded' }), execute: vi.fn().mockResolvedValue({ id: 'job-a', status: 'succeeded' }),
}, },
messages: { send: vi.fn() }, 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), repository: new ScheduledTaskRepository(db),
}); });
const result = await dispatcher.dispatch( const result = await dispatcher.dispatch(
@@ -114,6 +114,14 @@ export class ScheduledOperationDispatcher {
const schema = operationSchemas[task.operationType]; const schema = operationSchemas[task.operationType];
if (!operationId || !schema) if (!operationId || !schema)
return { outcome: 'needs-attention', reason: 'Unsupported scheduled operation', jobIds: [] }; 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 = const fields =
task.operationType === 'reboot-system' task.operationType === 'reboot-system'
? [ ? [
@@ -7,6 +7,7 @@ import { ScheduledTaskRepository } from './scheduled-task-repository.js';
import { ScheduledTaskService } from './scheduled-task-service.js'; import { ScheduledTaskService } from './scheduled-task-service.js';
class MemorySecrets implements SecretStore { class MemorySecrets implements SecretStore {
readonly provider = 'macos-keychain';
readonly values = new Map<string, string>(); readonly values = new Map<string, string>();
readonly setCalls: Array<{ key: SecretKey; value: string }> = []; readonly setCalls: Array<{ key: SecretKey; value: string }> = [];
blockRotations = false; blockRotations = false;
@@ -8,6 +8,7 @@ import { ConnectionProbe } from './connection-probe.js';
import { ConnectionLogService } from '../system/connection-log-service.js'; import { ConnectionLogService } from '../system/connection-log-service.js';
class Store implements SecretStore { class Store implements SecretStore {
readonly provider = 'macos-keychain';
async set(key: { instanceId: string; purpose: string; slot?: string }) { 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')}`; 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 () => { it('resolves only the canonical password reference for the requested existing instance', async () => {
let asked: string | undefined; let asked: string | undefined;
const { db, resolver } = fixture({ const { db, resolver } = fixture({
provider: 'macos-keychain',
set: async () => '', set: async () => '',
get: async (reference) => { get: async (reference) => {
asked = reference; asked = reference;
@@ -44,6 +45,7 @@ describe('InstanceCredentialResolver', () => {
it('does not call SecretStore when no saved credential exists', async () => { it('does not call SecretStore when no saved credential exists', async () => {
let calls = 0; let calls = 0;
const { resolver } = fixture({ const { resolver } = fixture({
provider: 'macos-keychain',
set: async () => '', set: async () => '',
get: async () => { get: async () => {
calls += 1; calls += 1;
@@ -57,6 +59,7 @@ describe('InstanceCredentialResolver', () => {
it('rejects a reference bound to another instance before SecretStore access', async () => { it('rejects a reference bound to another instance before SecretStore access', async () => {
let calls = 0; let calls = 0;
const { db, resolver } = fixture({ const { db, resolver } = fixture({
provider: 'macos-keychain',
set: async () => '', set: async () => '',
get: async () => { get: async () => {
calls += 1; calls += 1;
@@ -1,9 +1,8 @@
import type Database from 'better-sqlite3'; 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'; import type { SecretStore } from '../../infrastructure/secrets/secret-store.js';
const PURPOSE = 'instance-password'; const PURPOSE = 'instance-password';
const PROVIDER = 'macos-keychain';
export class CredentialResolverError extends Error { export class CredentialResolverError extends Error {
constructor(readonly code: 'NOT_FOUND' | 'CREDENTIAL_UNAVAILABLE') { constructor(readonly code: 'NOT_FOUND' | 'CREDENTIAL_UNAVAILABLE') {
super(code); super(code);
@@ -24,10 +23,12 @@ export class InstanceCredentialResolver {
.prepare( .prepare(
'SELECT external_reference FROM secret_references WHERE instance_id=? AND purpose=? AND provider=?', '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'); if (!row) throw new CredentialResolverError('CREDENTIAL_UNAVAILABLE');
try { try {
const parsed = parseKeychainReference(row.external_reference); const parsed = parseSecretReference(row.external_reference);
if (parsed.instanceId !== instanceId || parsed.purpose !== PURPOSE || !parsed.slot) if (parsed.instanceId !== instanceId || parsed.purpose !== PURPOSE || !parsed.slot)
throw new Error('invalid binding'); throw new Error('invalid binding');
const secret = await this.options.store.get(row.external_reference); const secret = await this.options.store.get(row.external_reference);
@@ -10,6 +10,7 @@ import type { Instance } from '@multi-simadmin/contracts';
import { InstanceService, InstanceServiceError } from './instance-service.js'; import { InstanceService, InstanceServiceError } from './instance-service.js';
class MemorySecrets implements SecretStore { class MemorySecrets implements SecretStore {
readonly provider = 'macos-keychain';
readonly values = new Map<string, string>(); readonly values = new Map<string, string>();
readonly deletes: string[] = []; readonly deletes: string[] = [];
readonly sets: Array<{ readonly sets: Array<{
@@ -10,10 +10,9 @@ import type {
SnapshotFreshness, SnapshotFreshness,
} from '@multi-simadmin/contracts'; } from '@multi-simadmin/contracts';
import type { SecretStore } from '../../infrastructure/secrets/secret-store.js'; 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 PURPOSE = 'instance-password';
const PROVIDER = 'macos-keychain';
const MAX_TAGS = 50; const MAX_TAGS = 50;
const MAX_TAG_LENGTH = 100; const MAX_TAG_LENGTH = 100;
@@ -408,7 +407,7 @@ export class InstanceService {
.prepare( .prepare(
'INSERT INTO secret_references (id,instance_id,purpose,provider,external_reference,created_at,updated_at) VALUES (?,?,?,?,?,?,?)', '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( private async storeSecret(
instanceId: string, instanceId: string,
@@ -422,7 +421,7 @@ export class InstanceService {
throw new InstanceServiceError('SECRET_STORE_FAILED', 'Could not store instance credential'); throw new InstanceServiceError('SECRET_STORE_FAILED', 'Could not store instance credential');
} }
try { try {
const parsed = parseKeychainReference(external); const parsed = parseSecretReference(external);
if (parsed.instanceId !== instanceId || parsed.purpose !== PURPOSE || parsed.slot !== slot) if (parsed.instanceId !== instanceId || parsed.purpose !== PURPOSE || parsed.slot !== slot)
throw new Error('secret store returned a reference with an invalid binding'); throw new Error('secret store returned a reference with an invalid binding');
} catch { } catch {
@@ -504,7 +503,7 @@ export class InstanceService {
AND secret_cleanup_tasks.purpose=excluded.purpose AND secret_cleanup_tasks.purpose=excluded.purpose
AND secret_cleanup_tasks.provider=excluded.provider`, 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) if (queued.changes !== 1)
throw new InstanceServiceError('DATABASE_FAILED', 'Cleanup task could not be persisted'); throw new InstanceServiceError('DATABASE_FAILED', 'Cleanup task could not be persisted');
} }
@@ -529,13 +528,13 @@ export class InstanceService {
); );
} }
private validateCleanupEntry(entry: CleanupEntry): void { 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'); throw new InstanceServiceError('DATABASE_FAILED', 'Cleanup task is invalid');
this.validateCleanupReference(entry.instanceId, entry.reference); this.validateCleanupReference(entry.instanceId, entry.reference);
} }
private validateCleanupReference(instanceId: string, reference: string): void { private validateCleanupReference(instanceId: string, reference: string): void {
try { try {
const parsed = parseKeychainReference(reference); const parsed = parseSecretReference(reference);
if ( if (
parsed.instanceId !== instanceId || parsed.instanceId !== instanceId ||
parsed.purpose !== PURPOSE || parsed.purpose !== PURPOSE ||
@@ -39,6 +39,7 @@ function pendingDatabase(): Database.Database {
} }
class FakeStore implements SecretStore { class FakeStore implements SecretStore {
readonly provider = 'macos-keychain';
readonly sets: { key: SecretKey; value: string }[] = []; readonly sets: { key: SecretKey; value: string }[] = [];
readonly deletes: string[] = []; readonly deletes: string[] = [];
setError?: Error; setError?: Error;
@@ -4,7 +4,6 @@ import { getManagedDatabaseIdentity } from '../../infrastructure/database/databa
import type { SecretStore } from '../../infrastructure/secrets/secret-store.js'; import type { SecretStore } from '../../infrastructure/secrets/secret-store.js';
const PURPOSE = 'instance-password'; const PURPOSE = 'instance-password';
const PROVIDER = 'macos-keychain';
const metadataKey = (instanceId: string): string => `legacy-import.instance.${instanceId}`; const metadataKey = (instanceId: string): string => `legacy-import.instance.${instanceId}`;
const referenceId = (instanceId: string): string => `legacy-secret:${instanceId}:${PURPOSE}`; 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'); throw new ActivationError('DATABASE_FAILED', 'Instance changed during secret activation');
db.prepare( db.prepare(
'INSERT INTO secret_references (id,instance_id,purpose,provider,external_reference,created_at,updated_at) VALUES (?,?,?,?,?,?,?)', '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 const updated = db
.prepare( .prepare(
"UPDATE instances SET auth_mode='password',config_revision=config_revision+1,updated_at=? WHERE id=? AND auth_mode='none' AND config_revision=?", "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'; } from './central-notification-service.js';
class MemorySecrets implements SecretStore { class MemorySecrets implements SecretStore {
readonly provider = 'macos-keychain';
readonly values = new Map<string, string>(); readonly values = new Map<string, string>();
async set(key: { instanceId: string; purpose: string; slot?: string }, value: 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> { async deleteChannel(id: string): Promise<void> {
const channel = this.getChannel(id); const channel = this.getChannel(id);
this.#db.transaction(() => {
this.#db.prepare('DELETE FROM notification_channels WHERE id=?').run(id); 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) if (channel.secretReference)
await this.#options.store.delete(channel.secretReference).catch(() => false); await this.#options.store.delete(channel.secretReference).catch(() => false);
} }
@@ -1202,6 +1223,19 @@ export class CentralNotificationService {
return { processed: succeeded + failed, succeeded, failed }; 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 { retryQueueItem(id: string): void {
const result = this.#db const result = this.#db
.prepare( .prepare(
@@ -1338,7 +1372,12 @@ export class CentralNotificationService {
#queueItem(row: Record<string, unknown>): NotificationQueueItem { #queueItem(row: Record<string, unknown>): NotificationQueueItem {
const status = String(row.status) as NotificationQueueItem['status']; const status = String(row.status) as NotificationQueueItem['status'];
const attempts = Number(row.attempts ?? 0); 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 = const deliveredAt =
typeof row.delivered_at === 'string' && row.delivered_at ? row.delivered_at : undefined; 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; const ruleId = typeof row.rule_id === 'string' && row.rule_id ? row.rule_id : undefined;
@@ -1560,7 +1599,12 @@ export class CentralNotificationService {
enabled: row.enabled === 1, enabled: row.enabled === 1,
condition: parseJson(String(row.condition_json), this.#condition.bind(this)), condition: parseJson(String(row.condition_json), this.#condition.bind(this)),
scope: parseJson(String(row.scope_json), this.#scope.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)), templates: parseJson(String(row.templates_json), this.#templates.bind(this)),
rateLimit: parseJson(String(row.rate_limit_json), validateRateLimit), rateLimit: parseJson(String(row.rate_limit_json), validateRateLimit),
quietHours: parseJson(String(row.quiet_hours_json), validateQuietHours), quietHours: parseJson(String(row.quiet_hours_json), validateQuietHours),
@@ -160,6 +160,8 @@ async function defaultRequester(request: HttpRequest, timeoutMs: number): Promis
headers: request.headers, headers: request.headers,
...(request.body === undefined ? {} : { body: request.body }), ...(request.body === undefined ? {} : { body: request.body }),
signal: AbortSignal.timeout(timeoutMs), 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); const text = (await response.text()).slice(0, MAX_RESPONSE_BYTES);
return { status: response.status, body: text }; return { status: response.status, body: text };
@@ -220,6 +220,9 @@ export async function sendEmailNotification(
const receivers = splitAddresses(configText(input.config, 'receiver_addresses')); const receivers = splitAddresses(configText(input.config, 'receiver_addresses'));
if (!host) return { ok: false, detail: '缺少 SMTP 地址' }; if (!host) return { ok: false, detail: '缺少 SMTP 地址' };
if (!sender) return { ok: false, detail: '缺少发件地址' }; 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: '缺少有效的收件地址' }; if (receivers.length === 0) return { ok: false, detail: '缺少有效的收件地址' };
const port = configNumber(input.config, 'smtp_port', 465); const port = configNumber(input.config, 'smtp_port', 465);
const security = configText(input.config, 'smtp_security') || 'implicit_tls'; const security = configText(input.config, 'smtp_security') || 'implicit_tls';
@@ -80,7 +80,9 @@ describe('MetricsService', () => {
const text = metrics.render(); const text = metrics.render();
expect(text).toContain('multi_simadmin_nodes_total 2'); 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_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_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_node_availability_ratio{node="node-a"} 0.950');
expect(text).toContain('multi_simadmin_sms_outbox{status="failed"} 1'); 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'); 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(); const { db: database, metrics } = fixture();
seedNode(database, 'node-a', 'evil" name\nwith break'); seedNode(database, 'node-a', 'evil" name\nwith break');
const text = metrics.render(); const text = metrics.render();
expect(text).toContain('name="evil\\" name\\nwith break"'); // Names are excluded from the session-free exposition outright; escaping a
// The injected newline must not become its own sample line. // label is not the right tool for untrusted operator input.
expect(text.split('\n').filter((line) => line.includes('with break'))).toHaveLength(1); 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', () => { it('emits a valid exposition: help and type precede every series', () => {
@@ -77,9 +77,9 @@ export class MetricsService {
]), ]),
); );
const info = nodes.map( // Operator-assigned names can leak hostnames/locations to unauthenticated
(node) => `${PREFIX}_node_info{node="${label(node.id)}",name="${label(node.name)}"} 1`, // scrapers; the series intentionally stays opaque to node ids.
); const info = nodes.map((node) => `${PREFIX}_node_info{node="${label(node.id)}"} 1`);
lines.push( lines.push(
...sample(`${PREFIX}_node_info`, 'Registered nodes and their addresses.', 'gauge', info), ...sample(`${PREFIX}_node_info`, 'Registered nodes and their addresses.', 'gauge', info),
...sample(`${PREFIX}_nodes_total`, 'Number of registered nodes.', 'gauge', [ ...sample(`${PREFIX}_nodes_total`, 'Number of registered nodes.', 'gauge', [
@@ -12,6 +12,7 @@ import {
} from './delete-instance-operation.js'; } from './delete-instance-operation.js';
class Store implements SecretStore { class Store implements SecretStore {
readonly provider = 'macos-keychain';
async set() { async set() {
return ''; return '';
} }
@@ -6,6 +6,8 @@ import {
ConsoleUpdateService, ConsoleUpdateService,
UpdateError, UpdateError,
createLaunchdRestartLauncher, createLaunchdRestartLauncher,
createPlatformRestartLauncher,
createSystemdRestartLauncher,
parseManifestVersion, parseManifestVersion,
parseShortStat, parseShortStat,
type UpdateCommandResult, type UpdateCommandResult,
@@ -466,3 +468,76 @@ describe('createLaunchdRestartLauncher', () => {
await expect(launcher.restart()).rejects.toMatchObject({ code: 'COMMAND', statusCode: 500 }); 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 { export interface ConsoleUpdateServiceOptions {
readonly db: Database.Database; readonly db: Database.Database;
readonly version: string; readonly version: string;
+3
View File
@@ -107,6 +107,9 @@ function trustedUpstreamHeaders(
delete sanitized[GATEWAY_CLIENT_IP_HEADER]; delete sanitized[GATEWAY_CLIENT_IP_HEADER];
if (gatewayToken !== undefined) sanitized[GATEWAY_AUTH_HEADER] = gatewayToken; if (gatewayToken !== undefined) sanitized[GATEWAY_AUTH_HEADER] = gatewayToken;
if (clientIp !== undefined) sanitized[GATEWAY_CLIENT_IP_HEADER] = clientIp; 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; return sanitized;
} }
+3 -1
View File
@@ -131,7 +131,9 @@ afterEach(async () => {
await Promise.all(cleanup.splice(0).map((fn) => fn())); 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 () => { it('makes the exact production gateway executable attempt 8788 only with acknowledgement', async () => {
await expectPortFree(8788); await expectPortFree(8788);
const fixture = await listenOn(8788); const fixture = await listenOn(8788);
@@ -9,7 +9,12 @@ it('registers the read-only operations catalog without touching upstream', async
let upstreamCalls = 0; let upstreamCalls = 0;
const app = buildControlPlaneApp({ const app = buildControlPlaneApp({
db, db,
store: { set: async () => '', get: async () => undefined, delete: async () => false }, store: {
provider: 'macos-keychain',
set: async () => '',
get: async () => undefined,
delete: async () => false,
},
upstream: { upstream: {
get: async () => { get: async () => {
upstreamCalls += 1; upstreamCalls += 1;
+1
View File
@@ -13,6 +13,7 @@ afterEach(async () => {
for (const db of dbs.splice(0)) db.close(); for (const db of dbs.splice(0)) db.close();
}); });
class Store implements SecretStore { class Store implements SecretStore {
readonly provider = 'macos-keychain';
private readonly values = new Map<string, string>(); private readonly values = new Map<string, string>();
async set(key: { instanceId: string; purpose: string; slot?: string }, value: string) { async set(key: { instanceId: string; purpose: string; slot?: string }, value: string) {
const account = Buffer.from( const account = Buffer.from(
+5
View File
@@ -320,6 +320,7 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane
...(options.now ? { now: options.now } : {}), ...(options.now ? { now: options.now } : {}),
}); });
smsOutbox.reconcileInterrupted(); smsOutbox.reconcileInterrupted();
centralNotifications.reconcileInterrupted();
// Removal follows the hub rule the operator already knows: an online device is told to release // 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 // 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 // at all is forgotten locally. The closure is late-bound because the device action service is
@@ -581,6 +582,10 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane
void Promise.resolve() void Promise.resolve()
.then(() => centralNotifications.pruneLogs()) .then(() => centralNotifications.pruneLogs())
.catch(() => undefined); .catch(() => undefined);
// Delivered and abandoned outbox rows otherwise accumulate forever.
void Promise.resolve()
.then(() => smsOutbox.prune(30 * 24 * 60 * 60 * 1000))
.catch(() => undefined);
}, logPruneIntervalMs) }, logPruneIntervalMs)
: undefined; : undefined;
// Scheduled component backups: the tick is cheap and only writes when a period is overdue. // Scheduled component backups: the tick is cheap and only writes when a period is overdue.
+6 -2
View File
@@ -23,7 +23,7 @@ const STATE_FILE = 'cutover-state.json';
const GATEWAY_ARGV = [ const GATEWAY_ARGV = [
process.execPath, process.execPath,
'--import', '--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'), resolve(process.cwd(), 'apps/api/src/production-gateway-cli.ts'),
] as const; ] as const;
const DIGEST = /^[a-f0-9]{64}$/u; const DIGEST = /^[a-f0-9]{64}$/u;
@@ -289,12 +289,15 @@ async function durableState(plan: CutoverPlan, state: CutoverState): Promise<voi
await handle.close(); await handle.close();
} }
await rename(temporary, target); await rename(temporary, target);
if (process.platform !== 'win32') {
// directory fsync only exists on POSIX
const directory = await open(plan.stateDir, constants.O_RDONLY); const directory = await open(plan.stateDir, constants.O_RDONLY);
try { try {
await directory.sync(); await directory.sync();
} finally { } finally {
await directory.close(); await directory.close();
} }
}
} }
function preparedState(plan: CutoverPlan): CutoverState { function preparedState(plan: CutoverPlan): CutoverState {
@@ -311,7 +314,8 @@ function preparedState(plan: CutoverPlan): CutoverState {
async function readState(plan: CutoverPlan): Promise<CutoverState> { async function readState(plan: CutoverPlan): Promise<CutoverState> {
const path = join(plan.stateDir, STATE_FILE); const path = join(plan.stateDir, STATE_FILE);
const info = await lstat(path); 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'); 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 input = record(JSON.parse(await readFile(path, 'utf8')) as unknown, 'Cutover state');
const base = ['version', 'phase', 'legacyPid', 'legacyCommand', 'legacyIdentity', 'legacyStart']; const base = ['version', 'phase', 'legacyPid', 'legacyCommand', 'legacyIdentity', 'legacyStart'];
@@ -48,10 +48,13 @@ describe('verified backup, restore, and rollback foundation', () => {
]); ]);
copy.close(); copy.close();
expect(snapshot.sha256).toMatch(/^[a-f0-9]{64}$/); 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 { livePath, backupPath, database } = await fixture();
const snapshot = await backupDatabase(database, backupPath); const snapshot = await backupDatabase(database, backupPath);
database.exec('UPDATE app_settings SET value_json = \'"after"\''); 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); 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(); const { livePath, backupPath, database } = await fixture();
await backupDatabase(database, backupPath); await backupDatabase(database, backupPath);
database.close(); database.close();
@@ -159,7 +159,8 @@ async function digestFileHandle(handle: FileHandle): Promise<string> {
} }
async function syncFile(path: string): Promise<void> { 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 { try {
await handle.sync(); await handle.sync();
} finally { } finally {
@@ -168,6 +169,7 @@ async function syncFile(path: string): Promise<void> {
} }
async function syncDirectory(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'); const handle = await open(path, 'r');
try { try {
await handle.sync(); await handle.sync();
@@ -264,7 +266,9 @@ export async function activateRestoreCandidate(
await assertValidDatabase(stagedPath); await assertValidDatabase(stagedPath);
if ((await digestFileHandle(stagedHandle)) !== stagedDigest) if ((await digestFileHandle(stagedHandle)) !== stagedDigest)
throw new Error('Staged restore candidate changed during validation'); 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( await activateStagedDatabase(
databasePath, databasePath,
stagedPath, stagedPath,
@@ -362,7 +362,8 @@ describe('database backup and restore', () => {
migrateDatabase(database); migrateDatabase(database);
const backupPath = join(directory, 'backup.sqlite'); const backupPath = join(directory, 'backup.sqlite');
await backupDatabase(database, backupPath); 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(); database.close();
}); });
@@ -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 { spawn } from 'node:child_process';
import { accountFor, parseSecretAccount, SecretReferenceError } from './secret-reference.js';
import type { SecretKey, SecretStore } from './secret-store.js'; import type { SecretKey, SecretStore } from './secret-store.js';
const SECURITY_PATH = '/usr/bin/security'; const SECURITY_PATH = '/usr/bin/security';
const SERVICE = 'multi-simadmin'; const SERVICE = 'multi-simadmin';
const TIMEOUT_MS = 5_000; const TIMEOUT_MS = 5_000;
const MAX_STDOUT_BYTES = 65_536; const MAX_STDOUT_BYTES = 65_536;
const safeComponent = /^[A-Za-z0-9_.-]+$/;
export type SecretStoreErrorCode = export type SecretStoreErrorCode =
| 'INVALID_KEY' | 'INVALID_KEY'
@@ -105,43 +105,6 @@ function invalid(code: 'INVALID_KEY' | 'INVALID_REFERENCE', message: string): ne
throw new SecretStoreError(code, message); 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 { function referenceFor(account: string): string {
return `keychain://${SERVICE}/${account}`; 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); const match = /^keychain:\/\/multi-simadmin\/([A-Za-z0-9_-]+)$/.exec(reference);
if (!match?.[1]) invalid('INVALID_REFERENCE', 'Keychain reference is invalid'); if (!match?.[1]) invalid('INVALID_REFERENCE', 'Keychain reference is invalid');
const account = match[1]; const account = match[1];
let decoded: unknown; let parsed;
try { try {
decoded = JSON.parse(Buffer.from(account, 'base64url').toString('utf8')); parsed = parseSecretAccount(account, 'macos-keychain');
} catch { } catch (error) {
if (error instanceof SecretReferenceError)
invalid('INVALID_REFERENCE', 'Keychain reference is invalid'); 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 { return {
service: SERVICE, service: SERVICE,
account, account,
instanceId: key.instanceId, instanceId: parsed.instanceId,
purpose: key.purpose, purpose: parsed.purpose,
...(key.slot === undefined ? {} : { slot: key.slot }), ...(parsed.slot === undefined ? {} : { slot: parsed.slot }),
}; };
} }
export class MacOSKeychainSecretStore implements SecretStore { export class MacOSKeychainSecretStore implements SecretStore {
readonly provider = 'macos-keychain';
constructor(private readonly runner: CommandRunner = new SpawnCommandRunner()) {} constructor(private readonly runner: CommandRunner = new SpawnCommandRunner()) {}
async set(key: SecretKey, value: string): Promise<string> { 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 { export interface SecretStore {
/** Stable identifier persisted in secret_references.provider ('macos-keychain' | 'secret-file'). */
readonly provider: string;
set(key: SecretKey, value: string): Promise<string>; set(key: SecretKey, value: string): Promise<string>;
get(reference: string): Promise<string | undefined>; get(reference: string): Promise<string | undefined>;
delete(reference: string): Promise<boolean>; delete(reference: string): Promise<boolean>;
@@ -9,6 +9,7 @@ import type { SecretKey, SecretStore } from '../../infrastructure/secrets/secret
import { registerAutomationRoutes } from './automation-routes.js'; import { registerAutomationRoutes } from './automation-routes.js';
class MemorySecrets implements SecretStore { class MemorySecrets implements SecretStore {
readonly provider = 'macos-keychain';
readonly values = new Map<string, string>(); readonly values = new Map<string, string>();
async set(key: SecretKey, value: string): Promise<string> { async set(key: SecretKey, value: string): Promise<string> {
const reference = `memory://${key.instanceId}/${key.purpose}`; 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'; import { registerCentralNotificationRoutes } from './central-notification-routes.js';
class MemorySecrets implements SecretStore { class MemorySecrets implements SecretStore {
readonly provider = 'macos-keychain';
readonly values = new Map<string, string>(); readonly values = new Map<string, string>();
async set(key: { instanceId: string; purpose: string }, value: string) { async set(key: { instanceId: string; purpose: string }, value: string) {
@@ -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}`; `${CONSOLE_SESSION_COOKIE}=${token}; Path=${COOKIE_PATH}; HttpOnly; SameSite=Strict${secure ? '; Secure' : ''}; Max-Age=${MAX_AGE_SECONDS}`;
const clearCookie = (secure: boolean): string => const clearCookie = (secure: boolean): string =>
`${CONSOLE_SESSION_COOKIE}=; Path=${COOKIE_PATH}; HttpOnly; SameSite=Strict${secure ? '; Secure' : ''}; Max-Age=0`; `${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) => ({ const problem = (request: FastifyRequest, status: number, code: string, detail: string) => ({
type: 'about:blank', type: 'about:blank',
@@ -83,8 +84,26 @@ const loginSchema = {
properties: { password: { type: 'string', minLength: 1, maxLength: 1024 } }, properties: { password: { type: 'string', minLength: 1, maxLength: 1024 } },
} as const; } as const;
const LOGIN_BUCKETS_MAX = 10_000;
export function registerConsoleAuth(app: FastifyInstance, auth: ConsoleAuthService): void { export function registerConsoleAuth(app: FastifyInstance, auth: ConsoleAuthService): void {
const loginAttempts = new Map<string, { count: number; resetAt: number }>(); 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) => { app.addHook('preHandler', async (request, reply) => {
const pathname = request.url.split(/[?#]/u, 1)[0] ?? request.url; const pathname = request.url.split(/[?#]/u, 1)[0] ?? request.url;
if (!auth.isProtectedPath(pathname)) return; if (!auth.isProtectedPath(pathname)) return;
@@ -121,7 +140,7 @@ export function registerConsoleAuth(app: FastifyInstance, auth: ConsoleAuthServi
attempts && attempts.resetAt > now attempts && attempts.resetAt > now
? attempts ? attempts
: { count: 0, resetAt: now + LOGIN_WINDOW_MS }; : { 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); return handle(request, reply, error);
} }
}); });
@@ -21,6 +21,7 @@ import type { SecretStore } from '../../infrastructure/secrets/secret-store.js';
import { registerInstanceRoutes } from './instance-routes.js'; import { registerInstanceRoutes } from './instance-routes.js';
class FakeStore implements SecretStore { class FakeStore implements SecretStore {
readonly provider = 'macos-keychain';
async set(key: { instanceId: string; purpose: string; slot?: string }) { async set(key: { instanceId: string; purpose: string; slot?: string }) {
return `keychain://multi-simadmin/${Buffer.from( return `keychain://multi-simadmin/${Buffer.from(
JSON.stringify([key.instanceId, key.purpose, key.slot]), JSON.stringify([key.instanceId, key.purpose, key.slot]),
@@ -23,6 +23,7 @@ async function fixtureOptions(): Promise<ProductionControlPlaneOptions> {
databasePath: join(directory, 'db.sqlite'), databasePath: join(directory, 'db.sqlite'),
gatewayToken, gatewayToken,
store: { store: {
provider: 'macos-keychain',
set: async () => '', set: async () => '',
get: async () => undefined, get: async () => undefined,
delete: async () => false, delete: async () => false,
+19 -3
View File
@@ -10,7 +10,11 @@ import {
} from './application/system/console-update-service.js'; } from './application/system/console-update-service.js';
import { openDatabase } from './infrastructure/database/database.js'; import { openDatabase } from './infrastructure/database/database.js';
import { migrateDatabase } from './infrastructure/database/migrations.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 type { SecretStore } from './infrastructure/secrets/secret-store.js';
import { createProductionUpstream } from './infrastructure/transport/production-upstream.js'; import { createProductionUpstream } from './infrastructure/transport/production-upstream.js';
import { createProductionReadiness, defaultKeychainMetadataCheck } from './production-readiness.js'; import { createProductionReadiness, defaultKeychainMetadataCheck } from './production-readiness.js';
@@ -21,6 +25,7 @@ export interface ProductionControlPlaneOptions {
readonly upstream?: SafeControlPlaneUpstream; readonly upstream?: SafeControlPlaneUpstream;
readonly gatewayToken?: string; readonly gatewayToken?: string;
readonly keychainMetadataCheck?: () => boolean | Promise<boolean>; readonly keychainMetadataCheck?: () => boolean | Promise<boolean>;
readonly secretFileMetadataCheck?: () => boolean | Promise<boolean>;
readonly assetsCheck?: () => boolean | Promise<boolean>; readonly assetsCheck?: () => boolean | Promise<boolean>;
readonly runtimeVersion?: string; readonly runtimeVersion?: string;
readonly recoveryMarkersCheck?: () => number | Promise<number>; readonly recoveryMarkersCheck?: () => number | Promise<number>;
@@ -44,8 +49,10 @@ export function buildProductionControlPlane(
const db = openDatabase(options.databasePath); const db = openDatabase(options.databasePath);
try { try {
migrateDatabase(db); migrateDatabase(db);
const store = options.store ?? new MacOSKeychainSecretStore();
const env = options.env ?? process.env; 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 updateRoot = env.MULTI_SIMADMIN_UPDATE_ROOT ?? process.cwd();
const updateRunner = createCommandRunner({ cwd: updateRoot, timeoutMs: 120_000 }); const updateRunner = createCommandRunner({ cwd: updateRoot, timeoutMs: 120_000 });
const app = buildControlPlaneApp({ const app = buildControlPlaneApp({
@@ -71,7 +78,16 @@ export function buildProductionControlPlane(
readiness: createProductionReadiness({ readiness: createProductionReadiness({
db, db,
gatewayToken: options.gatewayToken ?? '', 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.assetsCheck ? { assetsCheck: options.assetsCheck } : {}),
...(options.recoveryMarkersCheck ...(options.recoveryMarkersCheck
? { recoveryMarkersCheck: options.recoveryMarkersCheck } ? { recoveryMarkersCheck: options.recoveryMarkersCheck }
+9 -7
View File
@@ -45,9 +45,9 @@ async function fixture(name = 'cutover'): Promise<CutoverPlan> {
drillEvidence: drill, drillEvidence: drill,
drillEvidenceSha256: createHash('sha256').update(drillContent).digest('hex'), drillEvidenceSha256: createHash('sha256').update(drillContent).digest('hex'),
legacyPid: 41, legacyPid: 41,
legacyCommand: '/usr/local/bin/node /absolute/legacy/server.js', legacyCommand: `${process.execPath} /absolute/legacy/server.js`,
legacyIdentity: 'legacy-start-1', legacyIdentity: 'legacy-start-1',
legacyStart: ['/usr/local/bin/node', '/absolute/legacy/server.js'], legacyStart: [process.execPath, '/absolute/legacy/server.js'],
stateDir: join(root, 'state'), stateDir: join(root, 'state'),
}; };
} }
@@ -130,11 +130,11 @@ describe('cutover validation and reversible orchestration', () => {
expect(signals).toEqual([41]); expect(signals).toEqual([41]);
expect(starts[0]?.environment?.MULTI_SIMADMIN_GATEWAY_TOKEN).toBe(token); expect(starts[0]?.environment?.MULTI_SIMADMIN_GATEWAY_TOKEN).toBe(token);
const statePath = join(plan.stateDir, 'cutover-state.json'); 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 readFile(statePath, 'utf8')).not.toContain(token);
expect(await rollback(plan, system)).toBe(43); expect(await rollback(plan, system)).toBe(43);
expect(signals).toEqual([41, 42]); 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 () => { 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, process.execPath,
'--import', '--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'), 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 { const state = JSON.parse(await readFile(join(plan.stateDir, 'cutover-state.json'), 'utf8')) as {
phase: string; phase: string;
@@ -176,11 +176,13 @@ describe('cutover validation and reversible orchestration', () => {
const first = fakeSystem(plan); const first = fakeSystem(plan);
await cutover(plan, first.system, token); await cutover(plan, first.system, token);
const statePath = join(plan.stateDir, 'cutover-state.json'); const statePath = join(plan.stateDir, 'cutover-state.json');
// Windows chmod is a no-op, so the mode-0600 refusal only applies on POSIX.
if (process.platform !== 'win32') {
await chmod(statePath, 0o644); await chmod(statePath, 0o644);
await expect(rollback(plan, first.system)).rejects.toThrow(/mode-0600/); await expect(rollback(plan, first.system)).rejects.toThrow(/mode-0600/);
expect(first.signals).toEqual([41]); expect(first.signals).toEqual([41]);
await chmod(statePath, 0o600); await chmod(statePath, 0o600);
}
const state = JSON.parse(await readFile(statePath, 'utf8')) as Record<string, unknown>; const state = JSON.parse(await readFile(statePath, 'utf8')) as Record<string, unknown>;
await writeFile( await writeFile(
statePath, statePath,
+15 -4
View File
@@ -7,7 +7,9 @@ export interface ProductionReadinessOptions {
readonly db: Database.Database; readonly db: Database.Database;
readonly gatewayToken: string; readonly gatewayToken: string;
/** Metadata/availability probe only. This callback must never retrieve a secret. */ /** 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. */ /** Release gates are deliberately required: omitting one fails readiness closed. */
readonly assetsCheck?: () => boolean | Promise<boolean>; readonly assetsCheck?: () => boolean | Promise<boolean>;
readonly recoveryMarkersCheck?: () => number | Promise<number>; readonly recoveryMarkersCheck?: () => number | Promise<number>;
@@ -56,11 +58,20 @@ export function createProductionReadiness(
checks.gatewayAuth = Buffer.byteLength(options.gatewayToken) >= 32 ? 'ready' : 'unconfigured'; checks.gatewayAuth = Buffer.byteLength(options.gatewayToken) >= 32 ? 'ready' : 'unconfigured';
if (checks.gatewayAuth !== 'ready') ready = false; if (checks.gatewayAuth !== 'ready') ready = false;
try { try {
if (options.keychainMetadataCheck !== undefined) {
checks.keychain = (await options.keychainMetadataCheck()) ? 'ready' : 'unavailable'; checks.keychain = (await options.keychainMetadataCheck()) ? 'ready' : 'unavailable';
} catch {
checks.keychain = 'unavailable';
}
if (checks.keychain !== 'ready') ready = false; 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.secrets = 'unavailable';
ready = false;
}
try { try {
const row = options.db const row = options.db
+3
View File
@@ -254,12 +254,15 @@ export async function appendReleaseEvidence(
await handle.close(); await handle.close();
} }
await rename(temporary, absolute); await rename(temporary, absolute);
if (process.platform !== 'win32') {
// directory fsync only exists on POSIX
const directory = await open(parent, 'r'); const directory = await open(parent, 'r');
try { try {
await directory.sync(); await directory.sync();
} finally { } finally {
await directory.close(); await directory.close();
} }
}
return Object.freeze(record); return Object.freeze(record);
} finally { } finally {
await lock.close(); await lock.close();
+8
View File
@@ -30,6 +30,14 @@ export default defineConfig(
}, },
rules: { rules: {
'no-undef': 'off', 'no-undef': 'off',
'@typescript-eslint/no-unused-vars': [
'error',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
},
],
}, },
}, },
); );
+3 -1
View File
@@ -417,10 +417,12 @@ export function parseCreateScheduledTaskRequest(value: unknown): CreateScheduled
throw new TypeError('effectiveEndAt must be later than effectiveStartAt'); throw new TypeError('effectiveEndAt must be later than effectiveStartAt');
if (source.enabled !== undefined && typeof source.enabled !== 'boolean') if (source.enabled !== undefined && typeof source.enabled !== 'boolean')
throw new TypeError('enabled must be 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 = const delaySeconds =
source.delaySeconds === undefined source.delaySeconds === undefined
? undefined ? undefined
: numberInRange(source.delaySeconds, 'delaySeconds', 0, 3_600); : numberInRange(source.delaySeconds, 'delaySeconds', 3, 3);
if (delaySeconds !== undefined && operationType !== 'reboot-system') if (delaySeconds !== undefined && operationType !== 'reboot-system')
throw new TypeError('delaySeconds is only valid for reboot-system'); throw new TypeError('delaySeconds is only valid for reboot-system');
return { return {
@@ -54,7 +54,7 @@ async function jsonFiles(dir: string): Promise<string[]> {
for (const e of await readdir(dir, { withFileTypes: true })) { for (const e of await readdir(dir, { withFileTypes: true })) {
const p = path.join(dir, e.name); const p = path.join(dir, e.name);
if (e.isDirectory()) out.push(...(await jsonFiles(p))); 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; 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 files = (await jsonFiles(fixtureRoot)).filter((x) => !x.includes('/synthetic-errors/'));
const srcEntries = await readdir(path.join(root, 'src')); const srcEntries = await readdir(path.join(root, 'src'));
assert.ok(!srcEntries.includes('synthetic-errors')); 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 privateValues = stringLeaves(config);
const expected = selectReadonlyOperations(upstream58e2204Operations).selected; const expected = selectReadonlyOperations(upstream58e2204Operations).selected;
assert.equal(files.length, expected.length * 2); 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 registry = new Map(upstream58e2204Operations.map((x) => [x.operationId, x]));
const domains = new Map<string, number>(); const domains = new Map<string, number>();
const pairs = new Set<string>(); 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(); const listed = manifest.files.map((x: any) => x.path).sort();
assert.deepEqual(listed, disk); assert.deepEqual(listed, disk);
assert.equal(new Set(listed).size, listed.length); 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 p of leakPatterns) assert.doesNotMatch(text, p);
for (const value of privateValues) for (const value of privateValues)
assert.ok(!text.includes(value), 'fixture contains configured value'); 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); const m = manifest.files.find((x: any) => x.path === rel);
assert.ok(m); assert.ok(m);
assert.equal(m.size, (await stat(file)).size); assert.equal(m.size, (await stat(file)).size);
+1 -1
View File
@@ -100,7 +100,7 @@ async function runEndpoint(confirmed=false){
} catch(error){ persistentError(`请求参数无效:${error.message||error}`); return } } 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 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() 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 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() } 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() }
+31 -6
View File
@@ -4,7 +4,15 @@ umask 077
REPO_URL=${MULTI_SIMADMIN_REPO_URL:-https://gitea.chickliu.fun/Hermes/multi-simadmin.git} REPO_URL=${MULTI_SIMADMIN_REPO_URL:-https://gitea.chickliu.fun/Hermes/multi-simadmin.git}
PNPM_VERSION="11.13.0" 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" SOURCE_DIR="$APP_ROOT/source"
DATA_ROOT="$APP_ROOT/data" DATA_ROOT="$APP_ROOT/data"
RUNTIME_DIR="$APP_ROOT/run" RUNTIME_DIR="$APP_ROOT/run"
@@ -29,14 +37,17 @@ Multi SimAdmin 一键安装与服务管理
status 显示进程、端口与健康状态 status 显示进程、端口与健康状态
uninstall 卸载程序,默认保留数据、令牌和日志 uninstall 卸载程序,默认保留数据、令牌和日志
环境变量: 环境变量:
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_ALLOWED_HOSTS 额外允许的 LAN 主机名/IP,逗号分隔
MULTI_SIMADMIN_REPO_URL 源码仓库地址 MULTI_SIMADMIN_REPO_URL 源码仓库地址
EOF EOF
} }
require_macos() { require_platform() {
[ "$(uname -s)" = Darwin ] || die "当前生产版仅支持 macOS(秘密存储依赖 macOS Keychain)。" case "$PLATFORM" in
Darwin|Linux) ;;
*) die "当前生产版仅支持 macOS 或 Linux。" ;;
esac
} }
require_commands() { require_commands() {
@@ -61,14 +72,27 @@ pid_owned() {
} }
port_free() { port_free() {
if command -v lsof >/dev/null 2>&1; then
! lsof -nP -iTCP:"$1" -sTCP:LISTEN >/dev/null 2>&1 ! 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() { lan_ip() {
if [ "$PLATFORM" = Darwin ]; then
for interface in en0 en1; do for interface in en0 en1; do
value=$(ipconfig getifaddr "$interface" 2>/dev/null || true) value=$(ipconfig getifaddr "$interface" 2>/dev/null || true)
if [ -n "$value" ]; then printf '%s' "$value"; return; fi if [ -n "$value" ]; then printf '%s' "$value"; return; fi
done 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' printf '127.0.0.1'
} }
@@ -135,7 +159,7 @@ wait_for() {
} }
start_service() { start_service() {
require_macos require_platform
require_commands require_commands
[ -f "$SOURCE_DIR/apps/web/dist/index.html" ] || die "尚未安装,请先运行 install" [ -f "$SOURCE_DIR/apps/web/dist/index.html" ] || die "尚未安装,请先运行 install"
[ -f "$TOKEN_FILE" ] && [ ! -L "$TOKEN_FILE" ] || die "Gateway token 缺失或不是安全的普通文件,请重新运行 install" [ -f "$TOKEN_FILE" ] && [ ! -L "$TOKEN_FILE" ] || die "Gateway token 缺失或不是安全的普通文件,请重新运行 install"
@@ -192,6 +216,7 @@ start_service() {
trap 'rollback_start' EXIT HUP INT TERM trap 'rollback_start' EXIT HUP INT TERM
MULTI_SIMADMIN_DATA_ROOT="$DATA_ROOT" \ MULTI_SIMADMIN_DATA_ROOT="$DATA_ROOT" \
MULTI_SIMADMIN_DATABASE_PATH="$DATA_ROOT/control-plane.sqlite3" \ 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_GATEWAY_TOKEN="$token" \
MULTI_SIMADMIN_WEB_DIST="$SOURCE_DIR/apps/web/dist" \ MULTI_SIMADMIN_WEB_DIST="$SOURCE_DIR/apps/web/dist" \
API_HOST=127.0.0.1 API_PORT=8790 \ API_HOST=127.0.0.1 API_PORT=8790 \
@@ -256,7 +281,7 @@ status_service() {
} }
install_app() { install_app() {
require_macos require_platform
require_commands require_commands
mkdir -p "$APP_ROOT" "$DATA_ROOT" "$RUNTIME_DIR" "$LOG_DIR" mkdir -p "$APP_ROOT" "$DATA_ROOT" "$RUNTIME_DIR" "$LOG_DIR"
chmod 700 "$APP_ROOT" "$DATA_ROOT" "$RUNTIME_DIR" "$LOG_DIR" chmod 700 "$APP_ROOT" "$DATA_ROOT" "$RUNTIME_DIR" "$LOG_DIR"
+18 -2
View File
@@ -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 = {} } = {}) { 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') 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 parseAuthority = raw => {
const value = String(raw || '').trim().toLowerCase() const value = String(raw || '').trim().toLowerCase()
if (!value || /[\s/@]/.test(value)) return null 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 configuredRawHost = String(configStore.snapshot.server.host).replace(/^\[|\]$/g, '').toLowerCase()
const configuredHost = isIP(configuredRawHost) === 6 ? '::1' : configuredRawHost const configuredHost = isIP(configuredRawHost) === 6 ? '::1' : configuredRawHost
const configuredPort = String(configStore.snapshot.server.port) 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) => { app.addHook('onRequest', async (request, reply) => {
const authority=loopbackAuthority(request.headers.host) const authority=loopbackAuthority(request.headers.host)
const injectDefault = request.headers.host === 'localhost:80' && request.raw.socket?.localPort == null 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('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: '/' }) if (staticFiles) await app.register(fastifyStatic, { root: publicDir, prefix: '/' })
const findClient = (id, reply) => { const findClient = (id, reply) => {
const client = clientRegistry.get(id) 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) => { app.post('/api/instances/:id/login', async (request, reply) => {
const client = findClient(request.params.id, reply); if (!client) return const client = findClient(request.params.id, reply); if (!client) return
const supplied = Object.hasOwn(request.body || {}, 'password') const supplied = Object.hasOwn(request.body || {}, '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) } 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) => { app.post('/api/instances/:id/logout', async (request, reply) => {
const client = findClient(request.params.id, reply); if (!client) return const client = findClient(request.params.id, reply); if (!client) return
+40 -5
View File
@@ -1,7 +1,13 @@
export { normalizeBaseUrl, normalizeInstance } from './config/schema.js' 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) { export function redactInstance(instance) {
return { return {
id: instance.id, id: instance.id,
@@ -165,7 +171,6 @@ export function createSimAdminClient(instance, { fetchImpl = fetch, timeoutMs =
const headers = new Headers(options.headers || {}) const headers = new Headers(options.headers || {})
const cookie = jar.header() const cookie = jar.header()
if (cookie && !headers.has('cookie')) headers.set('cookie', cookie) if (cookie && !headers.has('cookie')) headers.set('cookie', cookie)
if (headers.has('cookie')) headers.cookie = headers.get('cookie')
try { try {
const response = await fetchImpl(url, { ...options, headers, signal: controller.signal, redirect: options.redirect || 'manual' }) const response = await fetchImpl(url, { ...options, headers, signal: controller.signal, redirect: options.redirect || 'manual' })
jar.setFromHeaders(response.headers) jar.setFromHeaders(response.headers)
@@ -178,7 +183,7 @@ export function createSimAdminClient(instance, { fetchImpl = fetch, timeoutMs =
async function fetchJson(endpoint, options = {}) { async function fetchJson(endpoint, options = {}) {
const startedAt = Date.now() const startedAt = Date.now()
const response = await request(endpoint, { method: 'GET', ...options, headers: { accept: 'application/json,text/plain,*/*', ...(options.headers || {}) } }) 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 let data = null
try { data = text ? JSON.parse(text) : null } catch {} 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 } return { ok: response.ok, status: response.status, latencyMs: Date.now() - startedAt, data, text: data ? undefined : text.slice(0, 1000), headers: response.headers }
@@ -212,6 +217,36 @@ export function createSimAdminClient(instance, { fetchImpl = fetch, timeoutMs =
return { instance, jar, request, fetchJson, ensureAuthenticated, clearEphemeralSecret } return { instance, jar, request, fetchJson, ensureAuthenticated, clearEphemeralSecret }
} }
export function buildClients(instances, options = {}) { // The header timeout ends once headers arrive; body reads need their own
return new Map(instances.map(instance => [instance.id, createSimAdminClient(instance, options)])) // 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)
} }
+1 -1
View File
@@ -2,7 +2,7 @@ const AUTH_PATH = /^\/api\/(?:auth(?:\/|$)|login(?:\/|$)|logout(?:\/|$))/i
export const DEFAULT_READ_PATHS = Object.freeze([ export const DEFAULT_READ_PATHS = Object.freeze([
'/api/health', '/api/device', '/api/sim', '/api/network', '/api/stats', '/api/connectivity', '/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/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/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', '/api/calls', '/api/call/history', '/api/ims/status', '/api/voicemail/status',
+3 -1
View File
@@ -1,3 +1,5 @@
import { readResponseBody } from '../core.js'
const BLOCKED_REQUEST_HEADERS = new Set([ const BLOCKED_REQUEST_HEADERS = new Set([
'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailer', 'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailer',
'transfer-encoding', 'upgrade', 'host', 'content-length', 'cookie', 'authorization', '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) => { upstream.headers.forEach((value, name) => {
if (!BLOCKED_RESPONSE_HEADERS.has(name.toLowerCase())) reply.header(name, value) 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))
} }
+1 -1
View File
@@ -6,7 +6,7 @@ import { test } from 'node:test';
const installer = new URL('../scripts/install.sh', import.meta.url); const installer = new URL('../scripts/install.sh', import.meta.url);
const source = readFileSync(installer, 'utf8'); 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]); execFileSync('/bin/sh', ['-n', installer.pathname]);
const help = execFileSync('/bin/sh', [installer.pathname, 'help'], { encoding: 'utf8' }); const help = execFileSync('/bin/sh', [installer.pathname, 'help'], { encoding: 'utf8' });
+1 -1
View File
@@ -14,7 +14,7 @@ import { RequestCoordinator } from '../public/state/request-coordinator.js'
import { createApiClient } from '../public/infrastructure/api-client.js' import { createApiClient } from '../public/infrastructure/api-client.js'
import { createViewRouter } from '../public/router/view-router.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 = []) { async function files(instances = []) {
const dir = await mkdtemp(path.join(tmpdir(), 'msa-blockers-')) const dir = await mkdtemp(path.join(tmpdir(), 'msa-blockers-'))
+1 -1
View File
@@ -35,7 +35,7 @@ test('client stores simadmin_session from login and sends it on later proxied re
assert.equal(auth.authenticated, true) assert.equal(auth.authenticated, true)
const proxied = await client.fetchJson('/api/device') const proxied = await client.fetchJson('/api/device')
assert.equal(proxied.status, 200) 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', () => { test('snapshot summary exposes device-monitoring fields from stats, sms, data and hardware endpoints', () => {