Compare commits
9
Commits
f6319374cd
...
29a0eee854
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29a0eee854 | ||
|
|
2977f75129 | ||
|
|
236e78327c | ||
|
|
3c5971e167 | ||
|
|
e4c4bce75b | ||
|
|
c57dc81e42 | ||
|
|
96dd9e2ba6 | ||
|
|
4f42a6f835 | ||
|
|
eed134a25c |
@@ -0,0 +1 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
- 融合 [SimAdminHub](https://github.com/3899/SimAdminHub) 的管理思路:跨节点短信、通知、自动化与跨域健康自然融入 Fleet 节点总览体系,无需部署或接入外部 Hub。
|
- 融合 [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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -30,6 +30,14 @@ export default defineConfig(
|
|||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
'no-undef': 'off',
|
'no-undef': 'off',
|
||||||
|
'@typescript-eslint/no-unused-vars': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
argsIgnorePattern: '^_',
|
||||||
|
varsIgnorePattern: '^_',
|
||||||
|
caughtErrorsIgnorePattern: '^_',
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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' });
|
||||||
|
|
||||||
|
|||||||
@@ -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-'))
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user