feat(api): integrate identity, notification scope and connection settings into control plane

- Wire identity, metrics and update services into the assembled API.

- Add connection authorization modes and scoped console authorization.

- Preserve notification group IDs for filtered delivery.

- Extend maintenance, backup, resource and module flows with runtime metadata and upstream safety.
This commit is contained in:
chick
2026-09-07 00:45:18 +08:00
parent 7168e9dba2
commit 635527dd26
22 changed files with 754 additions and 46 deletions
@@ -6,6 +6,10 @@ const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
const MIN_PASSWORD_LENGTH = 8;
const MAX_PASSWORD_BYTES = 1024;
const SCRYPT_KEY_LENGTH = 32;
const IDLE_TIMEOUT_SETTING_KEY = 'console-auth.idle-timeout-minutes';
const DEFAULT_IDLE_TIMEOUT_MINUTES = 30;
const MIN_IDLE_TIMEOUT_MINUTES = 5;
const MAX_IDLE_TIMEOUT_MINUTES = 1440;
interface AuthConfigRow {
readonly protection_enabled: 0 | 1;
@@ -18,6 +22,7 @@ export interface ConsoleAuthStatus {
readonly configured: boolean;
readonly protectionEnabled: boolean;
readonly authenticated: boolean;
readonly sessionIdleTimeoutMinutes: number;
}
export class ConsoleAuthError extends Error {
@@ -40,6 +45,7 @@ export interface ConsoleAuthServiceOptions {
export class ConsoleAuthService {
private readonly db: Database.Database;
private readonly now: () => Date;
#idleTimeoutMinutes: number = DEFAULT_IDLE_TIMEOUT_MINUTES;
constructor(options: ConsoleAuthServiceOptions) {
this.db = options.db;
@@ -49,17 +55,19 @@ export class ConsoleAuthService {
status(sessionToken?: string): ConsoleAuthStatus {
this.pruneExpired();
const config = this.config();
const sessionIdleTimeoutMinutes = this.idleTimeoutMinutes();
const configured = config?.password_hash !== null && config?.password_hash !== undefined;
const protectionEnabled = config?.protection_enabled === 1;
return {
configured,
protectionEnabled,
authenticated: !protectionEnabled || this.isAuthenticated(sessionToken, config),
};
const authenticated = !protectionEnabled || this.isAuthenticated(sessionToken, config);
return { configured, protectionEnabled, authenticated, sessionIdleTimeoutMinutes };
}
isProtectedPath(pathname: string): boolean {
if (!pathname.startsWith('/api/v1/')) return false;
// Monitoring scrapers cannot hold a browser session. Like the Hub's /health and /metrics,
// this one stays session-free and is meant for the trusted network the console already
// assumes; it carries no device addresses, only opaque node ids and counters.
if (pathname === '/api/v1/metrics') return false;
return !pathname.startsWith('/api/v1/auth/');
}
@@ -67,6 +75,7 @@ export class ConsoleAuthService {
const status = this.status(sessionToken);
if (status.protectionEnabled && !status.authenticated)
throw new ConsoleAuthError('AUTH_REQUIRED');
this.touchSession(sessionToken);
}
async login(password: string): Promise<string> {
@@ -86,11 +95,16 @@ export class ConsoleAuthService {
}
async updateSettings(
input: { readonly enabled: boolean; readonly newPassword?: string },
input: {
readonly enabled: boolean;
readonly newPassword?: string;
readonly idleTimeoutMinutes?: number;
},
sessionToken?: string,
): Promise<{ readonly status: ConsoleAuthStatus; readonly sessionToken?: string }> {
const current = this.config();
if (current?.protection_enabled === 1) this.requireSession(sessionToken);
if (input.idleTimeoutMinutes !== undefined) this.saveIdleTimeout(input.idleTimeoutMinutes);
if (input.enabled) {
if (input.newPassword !== undefined) {
@@ -170,6 +184,31 @@ export class ConsoleAuthService {
.get() as AuthConfigRow | undefined;
}
private idleTimeoutMinutes(): number {
const row = this.db
.prepare('SELECT value_json FROM app_settings WHERE key = ?')
.get(IDLE_TIMEOUT_SETTING_KEY) as { readonly value_json: unknown } | undefined;
const value = row?.value_json;
return typeof value === 'number' ? this.normalizeIdleTimeout(value) : this.#idleTimeoutMinutes;
}
private saveIdleTimeout(value: number): void {
const minutes = this.normalizeIdleTimeout(value);
const now = this.now().toISOString();
this.db
.prepare(
`INSERT INTO app_settings (key,value_json,created_at,updated_at)
VALUES (?,?,?,?)
ON CONFLICT(key) DO UPDATE SET value_json=excluded.value_json,updated_at=excluded.updated_at`,
)
.run(IDLE_TIMEOUT_SETTING_KEY, Math.round(minutes), now, now);
this.#idleTimeoutMinutes = minutes;
}
private normalizeIdleTimeout(value: number): number {
return Math.min(MAX_IDLE_TIMEOUT_MINUTES, Math.max(MIN_IDLE_TIMEOUT_MINUTES, value));
}
private validatePassword(password: string): void {
const bytes = Buffer.byteLength(password, 'utf8');
if (
@@ -204,7 +243,12 @@ export class ConsoleAuthService {
private createSession(passwordRevision: number): string {
const token = randomBytes(32).toString('base64url');
const now = this.now();
const expires = new Date(now.getTime() + SESSION_TTL_MS);
const expires = new Date(
Math.min(
now.getTime() + this.idleTimeoutMinutes() * 60 * 1000,
now.getTime() + SESSION_TTL_MS,
),
);
this.db
.prepare(
`INSERT INTO console_auth_sessions
@@ -230,4 +274,29 @@ export class ConsoleAuthService {
.prepare('DELETE FROM console_auth_sessions WHERE expires_at<=?')
.run(this.now().toISOString());
}
touchSession(token: string | undefined): void {
if (!token) return;
const config = this.config();
if (!config || config.password_revision <= 0) return;
const row = this.db
.prepare('SELECT created_at FROM console_auth_sessions WHERE session_hash=? AND expires_at>?')
.get(this.digest(token), this.now().toISOString()) as
| { readonly created_at: string }
| undefined;
if (!row) return;
const timeoutMs = this.idleTimeoutMinutes() * 60 * 1000;
const now = this.now();
this.db
.prepare(
'UPDATE console_auth_sessions SET expires_at=? WHERE session_hash=? AND expires_at>?',
)
.run(
new Date(
Math.min(now.getTime() + timeoutMs, new Date(row.created_at).getTime() + SESSION_TTL_MS),
).toISOString(),
this.digest(token),
this.now().toISOString(),
);
}
}
@@ -39,10 +39,12 @@ describe('validateConnectionSettings', () => {
expect(validateConnectionSettings({ heartbeatSeconds: 60 })).toEqual({
heartbeatSeconds: 60,
offlineSeconds: 120,
authorizationMode: 'auto',
});
expect(validateConnectionSettings({ offlineSeconds: 300 })).toEqual({
heartbeatSeconds: 30,
offlineSeconds: 300,
authorizationMode: 'auto',
});
});
@@ -67,6 +69,16 @@ describe('validateConnectionSettings', () => {
expect(validateConnectionSettings({ heartbeatSeconds: 30, offlineSeconds: 60 })).toEqual({
heartbeatSeconds: 30,
offlineSeconds: 60,
authorizationMode: 'auto',
});
});
it('requires a known authorization mode and keeps the mode omitted on update', () => {
expect(() => validateConnectionSettings({ authorizationMode: 'operator' })).toThrow(TypeError);
expect(validateConnectionSettings({ authorizationMode: 'manual' })).toEqual({
heartbeatSeconds: 30,
offlineSeconds: 90,
authorizationMode: 'manual',
});
});
});
@@ -74,7 +86,11 @@ describe('validateConnectionSettings', () => {
describe('ConnectionSettingsService', () => {
it('starts from the shipped defaults', () => {
const { settings } = service();
expect(settings.get()).toEqual({ heartbeatSeconds: 30, offlineSeconds: 90 });
expect(settings.get()).toEqual({
heartbeatSeconds: 30,
offlineSeconds: 90,
authorizationMode: 'auto',
});
expect(settings.heartbeatMs).toBe(30_000);
expect(settings.snapshotTtlMs).toBe(90_000);
});
@@ -84,9 +100,16 @@ describe('ConnectionSettingsService', () => {
expect(settings.update({ heartbeatSeconds: 45, offlineSeconds: 180 })).toEqual({
heartbeatSeconds: 45,
offlineSeconds: 180,
authorizationMode: 'auto',
});
expect(stored(db)).toBe(
'{"heartbeatSeconds":45,"offlineSeconds":180,"authorizationMode":"auto"}',
);
expect(settings.get()).toEqual({
heartbeatSeconds: 45,
offlineSeconds: 180,
authorizationMode: 'auto',
});
expect(stored(db)).toBe('{"heartbeatSeconds":45,"offlineSeconds":180}');
expect(settings.get()).toEqual({ heartbeatSeconds: 45, offlineSeconds: 180 });
expect(settings.heartbeatMs).toBe(45_000);
expect(settings.snapshotTtlMs).toBe(180_000);
});
@@ -94,12 +117,16 @@ describe('ConnectionSettingsService', () => {
it('rewrites the same row instead of inserting a second one', () => {
const { db, settings } = service();
settings.update({ heartbeatSeconds: 10, offlineSeconds: 20 });
settings.update({ heartbeatSeconds: 20, offlineSeconds: 40 });
settings.update({ heartbeatSeconds: 20, offlineSeconds: 40, authorizationMode: 'manual' });
const rows = db.prepare('SELECT COUNT(*) AS count FROM app_settings').get() as {
count: number;
};
expect(rows.count).toBe(1);
expect(settings.get()).toEqual({ heartbeatSeconds: 20, offlineSeconds: 40 });
expect(settings.get()).toEqual({
heartbeatSeconds: 20,
offlineSeconds: 40,
authorizationMode: 'manual',
});
});
it('falls back to the defaults when the stored row is corrupt', () => {
@@ -121,7 +148,13 @@ describe('ConnectionSettingsService', () => {
const { db, settings } = service();
settings.update({ heartbeatSeconds: 60, offlineSeconds: 120 });
expect(() => settings.update({ heartbeatSeconds: 60, offlineSeconds: 61 })).toThrow(RangeError);
expect(settings.get()).toEqual({ heartbeatSeconds: 60, offlineSeconds: 120 });
expect(stored(db)).toBe('{"heartbeatSeconds":60,"offlineSeconds":120}');
expect(settings.get()).toEqual({
heartbeatSeconds: 60,
offlineSeconds: 120,
authorizationMode: 'auto',
});
expect(stored(db)).toBe(
'{"heartbeatSeconds":60,"offlineSeconds":120,"authorizationMode":"auto"}',
);
});
});
@@ -8,6 +8,7 @@ import type Database from 'better-sqlite3';
export interface ConnectionSettings {
readonly heartbeatSeconds: number;
readonly offlineSeconds: number;
readonly authorizationMode: 'auto' | 'manual';
}
export const MIN_HEARTBEAT_SECONDS = 5;
@@ -17,11 +18,16 @@ export const MAX_OFFLINE_SECONDS = 1_800;
export const DEFAULT_CONNECTION_SETTINGS: Readonly<ConnectionSettings> = Object.freeze({
heartbeatSeconds: 30,
offlineSeconds: 90,
authorizationMode: 'auto',
});
const SETTING_KEY = 'connection.settings';
const FIELDS: readonly (keyof ConnectionSettings)[] = ['heartbeatSeconds', 'offlineSeconds'];
const FIELDS: readonly (keyof ConnectionSettings)[] = [
'heartbeatSeconds',
'offlineSeconds',
'authorizationMode',
];
function safeInteger(value: unknown, name: string): number {
if (!Number.isSafeInteger(value))
@@ -40,6 +46,13 @@ export function validateConnectionSettings(value: unknown): ConnectionSettings {
);
if (unknown.length > 0) throw new TypeError(`Unknown connection setting: ${unknown[0]}`);
const authorizationMode =
source.authorizationMode === undefined
? DEFAULT_CONNECTION_SETTINGS.authorizationMode
: source.authorizationMode;
if (authorizationMode !== 'auto' && authorizationMode !== 'manual')
throw new TypeError('authorizationMode must be auto or manual');
const heartbeatSeconds = safeInteger(
source.heartbeatSeconds ?? DEFAULT_CONNECTION_SETTINGS.heartbeatSeconds,
'heartbeatSeconds',
@@ -59,7 +72,7 @@ export function validateConnectionSettings(value: unknown): ConnectionSettings {
if (offlineSeconds > MAX_OFFLINE_SECONDS)
throw new RangeError(`offlineSeconds must not exceed ${MAX_OFFLINE_SECONDS}`);
return { heartbeatSeconds, offlineSeconds };
return { heartbeatSeconds, offlineSeconds, authorizationMode };
}
interface SettingsRow {
@@ -50,6 +50,7 @@ export const INSTANCE_MODULE_PROBES: Readonly<Record<InstanceModuleKey, readonly
{ key: 'data', path: '/data' },
{ key: 'cpu', path: '/stats/cpu' },
{ key: 'smsStats', path: '/sms/stats' },
{ key: 'networkSpeed', path: '/network/speed' },
],
sim: [
{ key: 'sim', path: '/sim' },
@@ -12,7 +12,16 @@ interface Reply {
readonly body?: unknown;
}
function fixture(probes: Readonly<Record<string, Reply>>) {
function fixture(
probes: Readonly<Record<string, Reply>>,
extra: {
readonly onIdentity?: (
instanceId: string,
evidence: Readonly<Record<string, unknown>>,
origin: string,
) => void;
} = {},
) {
const sessions = new InstanceSessionStore();
const calls: UpstreamRequest[] = [];
const instances = {
@@ -45,6 +54,7 @@ function fixture(probes: Readonly<Record<string, Reply>>) {
};
},
now: () => new Date('2026-09-04T00:00:00.000Z'),
...(extra.onIdentity ? { onIdentity: extra.onIdentity } : {}),
});
return { service, sessions, calls };
}
@@ -55,6 +65,10 @@ describe('InstanceModuleService', () => {
'/device': { status: 200, body: { model: 'LPAX', android_version: '13' } },
'/stats': { status: 200, body: { cpu_percent: 12 } },
'/connectivity': { status: 200, body: {} },
'/network/speed': {
status: 200,
body: { interfaces: [{ interface: 'wwan0', rx_bytes_per_sec: 1536 }] },
},
});
const snapshot = await service.read('node-a', 'overview');
expect(snapshot.observedAt).toBe('2026-09-04T00:00:00.000Z');
@@ -63,6 +77,10 @@ describe('InstanceModuleService', () => {
expect(byKey.device?.data).toMatchObject({ model: 'LPAX' });
expect(byKey.stats?.state).toBe('ok');
expect(byKey.connectivity?.state).toBe('empty');
expect(byKey.networkSpeed?.state).toBe('ok');
expect(byKey.networkSpeed?.data).toMatchObject({
interfaces: [{ interface: 'wwan0', rx_bytes_per_sec: 1536 }],
});
expect(byKey.data?.state).toBe('unsupported');
});
@@ -168,4 +186,47 @@ describe('InstanceModuleService', () => {
});
await expect(service.read('missing', 'overview')).rejects.toMatchObject({ code: 'NOT_FOUND' });
});
it('hands the hardware report to the identity guard while reading the overview', async () => {
const seen: [string, Record<string, unknown>, string][] = [];
const { service } = fixture(
{
'/device': { status: 200, body: { imei: '860000000000001', model: 'LPAX' } },
'/stats': { status: 200, body: {} },
'/connectivity': { status: 200, body: {} },
},
{
onIdentity: (instanceId, evidence, origin) => {
seen.push([instanceId, evidence as Record<string, unknown>, origin]);
},
},
);
await service.read('node-a', 'overview');
expect(seen).toEqual([
['node-a', { imei: '860000000000001', model: 'LPAX' }, 'http://node-a.local'],
]);
});
it('stays quiet when a module other than the overview is read', async () => {
const seen: string[] = [];
const { service } = fixture(
{ '/sim': { status: 200, body: { imei: '860000000000001' } } },
{ onIdentity: (instanceId) => void seen.push(instanceId) },
);
await service.read('node-a', 'sim');
expect(seen).toEqual([]);
});
it('never loses an overview the operator asked for because bookkeeping threw', async () => {
const { service } = fixture(
{ '/device': { status: 200, body: { model: 'LPAX' } } },
{
onIdentity: () => {
throw new Error('guard unavailable');
},
},
);
const snapshot = await service.read('node-a', 'overview');
expect(snapshot.sections.find((section) => section.key === 'device')?.state).toBe('ok');
});
});
@@ -171,6 +171,16 @@ export interface InstanceModuleServiceOptions {
readonly now?: () => Date;
/** Fallback for catalog entries without an explicit budget; keeps a dead device from stalling. */
readonly defaultTimeoutMs?: number;
/**
* Hears what the device just said about its own hardware. The overview module already reads
* /device, so identity is observed by the pages the operator opens rather than by a poller
* that costs the LAN extra traffic.
*/
readonly onIdentity?: (
instanceId: string,
evidence: Readonly<Record<string, unknown>>,
origin: string,
) => void;
}
/**
@@ -214,6 +224,7 @@ export class InstanceModuleService {
refreshAttempted: false,
};
const sections = await this.#probeAll(instanceId, instance.origin, probes, context);
this.#reportIdentity(module, instanceId, instance.origin, sections);
return {
instanceId,
module,
@@ -223,6 +234,23 @@ export class InstanceModuleService {
};
}
#reportIdentity(
module: InstanceModuleKey,
instanceId: string,
origin: string,
sections: readonly ModuleSection[],
): void {
const report = this.options.onIdentity;
if (!report || module !== 'overview') return;
const device = sections.find((section) => section.key === 'device' && section.state === 'ok');
if (!device || !isRecord(device.data)) return;
try {
report(instanceId, device.data, origin);
} catch {
// Bookkeeping for the identity guard never breaks a read the operator asked for.
}
}
async #probeAll(
instanceId: string,
origin: string,
@@ -133,6 +133,36 @@ describe('CentralNotificationService', () => {
expect(updated.scope).toEqual({ mode: 'devices', instanceIds: ['instance-2'] });
});
it('matches rules for any selected device group', async () => {
const { service } = fixture();
const channel = await service.createChannel(bark);
const rule = await service.createRule({
name: '外场转发',
eventType: 'system',
enabled: true,
condition: { field: 'status', mode: 'all' },
scope: { mode: 'groups', groups: ['group-field', 'group-lab'] },
channelIds: [channel.id],
templates: { title: '{{title}}', body: '{{content}}' },
});
expect(rule.scope).toEqual({ mode: 'groups', groups: ['group-field', 'group-lab'] });
expect(
service.matchRules('system', {
instanceId: 'instance-1',
instanceGroupIds: ['group-field'],
fields: { status: 'offline' },
}),
).toEqual([rule]);
expect(
service.matchRules('system', {
instanceId: 'instance-2',
instanceGroupIds: ['group-office'],
fields: { status: 'offline' },
}),
).toEqual([]);
});
it('tests an enabled channel, records delivery, and never logs the secret', async () => {
const { db, service, deliveries } = fixture();
const channel = await service.createChannel(bark);
@@ -70,7 +70,8 @@ export interface NotificationCondition {
}
export interface NotificationTargetScope {
readonly mode: 'all' | 'tags' | 'devices';
readonly mode: 'all' | 'tags' | 'devices' | 'groups';
readonly groups?: readonly string[];
readonly tags?: readonly string[];
readonly match?: 'all' | 'any';
readonly instanceIds?: readonly string[];
@@ -246,6 +247,7 @@ const LOG_CLEANUP_MAXIMUM_ENTRIES = 1_000_000;
export interface NotificationEventInput {
readonly instanceId?: string;
readonly instanceTags?: readonly string[];
readonly instanceGroupIds?: readonly string[];
readonly fields?: Readonly<Record<string, string>>;
}
@@ -682,6 +684,11 @@ export class CentralNotificationService {
: selected.some((tag) => tags.includes(tag));
if (!matched) return false;
}
if (
scope.mode === 'groups' &&
!(scope.groups ?? []).some((id) => (event.instanceGroupIds ?? []).includes(id))
)
return false;
if (scope.mode === 'devices' && !(scope.instanceIds ?? []).includes(event.instanceId ?? ''))
return false;
const field = rule.condition.field;
@@ -1473,8 +1480,17 @@ export class CentralNotificationService {
#scope(value: unknown): NotificationTargetScope {
const item = record(value);
const mode = text(item?.mode, 20);
if (!item || !['all', 'tags', 'devices'].includes(mode ?? ''))
if (!item || !['all', 'tags', 'devices', 'groups'].includes(mode ?? ''))
throw new CentralNotificationServiceError('VALIDATION_FAILED');
if (mode === 'groups') {
const groups = item.groups;
if (!Array.isArray(groups) || groups.length === 0 || groups.some((group) => !text(group, 80)))
throw new CentralNotificationServiceError('VALIDATION_FAILED');
return Object.freeze({
mode: 'groups',
groups: Object.freeze(groups.map((group) => text(group, 80) as string)),
});
}
if (mode === 'tags') {
const tags = item.tags;
if (!Array.isArray(tags) || tags.length === 0 || tags.some((tag) => !text(tag, 80)))
@@ -114,11 +114,28 @@ describe('instance resource allowlist parsing', () => {
}),
),
).toEqual({
carrier: 'China Mobile',
carrier: '中国移动',
cellularRegistration: 'registered_home',
accessTechnology: 'LTE',
cellularOnline: true,
});
// An unassigned or foreign PLMN keeps whatever the device reported.
expect(
parseNetwork(
response({
data: {
operator_name: 'Telekom.de',
registration_status: 'registered_roaming',
mcc: 262,
mnc: 1,
},
}),
),
).toEqual({
carrier: 'Telekom.de',
cellularRegistration: 'registered_roaming',
cellularOnline: true,
});
expect(parseSignalStrength(response({ data: { strength: 80 } }))).toEqual({
signalPercent: 80,
});
@@ -1,3 +1,5 @@
import { lookupOperator } from '@multi-simadmin/contracts';
import type { InstanceService } from '../instances/instance-service.js';
import type {
InstanceSessionStore,
@@ -139,7 +141,11 @@ export function parseDevice(response: UpstreamResponse): InstanceResources {
export function parseNetwork(response: UpstreamResponse): InstanceResources {
const value = data(response);
if (!value) return {};
const carrier = safeText(value.operator_name, 64);
const reported = safeText(value.operator_name, 64);
// Devices spell the same network every which way ("China Mobile", "CHN-CMCC", "46000"); the
// shared PLMN registry settles it, exactly like the Hub does.
const known = lookupOperator(value.mcc, value.mnc);
const carrier = known?.name ?? reported;
const registration = safeCode(value.registration_status);
const accessTechnology = safeText(value.technology_preference, 32);
const cellularOnline =
@@ -74,6 +74,42 @@ function seed(db: Database.Database, tag: string): void {
1,
'2026-01-01T00:00:00.000Z',
);
db.prepare(
`INSERT INTO jobs
(id,root_job_id,operation_id,risk_level,status,requested_by,request_id,parameters_digest,created_at,updated_at)
VALUES (?,?,?,?,'succeeded',?,?,?,?,?)`,
).run(
`job-${tag}`,
`job-${tag}`,
'probe',
'R0',
'operator',
`${tag}-req`,
'digest',
'2026-01-01T00:00:00.000Z',
'2026-01-01T00:00:00.000Z',
);
db.prepare(
`INSERT INTO job_items
(id,job_id,instance_id,attempt_number,status,created_at,updated_at)
VALUES (?,?,?,1,'succeeded',?,?)`,
).run(
`item-${tag}`,
`job-${tag}`,
`node-${tag}`,
'2026-01-01T00:00:00.000Z',
'2026-01-01T00:00:00.000Z',
);
db.prepare(
`INSERT INTO job_attempts (id,job_id,status,started_at,created_at)
VALUES (?,?,?,?,?)`,
).run(
`attempt-${tag}`,
`job-${tag}`,
'succeeded',
'2026-01-01T00:00:00.000Z',
'2026-01-01T00:00:00.000Z',
);
}
describe('ComponentBackupService', () => {
@@ -124,8 +160,14 @@ describe('ComponentBackupService', () => {
version: 'test',
backupDirectory,
});
const written = await restoreService.restore(created.filename, ['devices']);
expect(written).toEqual({ devices: 8 });
const result = await restoreService.restore(created.filename, ['devices']);
expect(result.written).toEqual({ devices: 8 });
// The state right before the merge is snapshotted first, so the restore stays reversible.
expect(result.safetyBackup).not.toBe(created.filename);
const safety = await restoreService.preview(result.safetyBackup);
expect(safety.note).toBe(`恢复前自动备份:${created.filename}`);
expect(safety.automatic).toBe(true);
expect(safety.components.map((component) => component.key)).toEqual(['devices']);
const instances = target.prepare('SELECT id FROM instances ORDER BY id').all() as {
id: string;
}[];
@@ -188,7 +230,7 @@ describe('ComponentBackupService', () => {
service.updateAutoSettings({
enabled: true,
components: ['devices'],
timeOfDay: '03:30',
timeOfDay: '02:00',
weekday: -1,
maximumCount: 2,
});
@@ -221,7 +263,7 @@ describe('ComponentBackupService', () => {
});
expect(service.autoSettings()).toMatchObject({
enabled: false,
timeOfDay: '03:30',
timeOfDay: '02:00',
components: ['devices', 'notifications', 'automation'],
});
});
@@ -14,6 +14,7 @@ export type BackupComponentKey =
| 'notificationRecords'
| 'automation'
| 'automationRecords'
| 'jobs'
| 'sms'
| 'settings'
| 'audit';
@@ -52,6 +53,11 @@ export const BACKUP_COMPONENTS: Readonly<Record<BackupComponentKey, ComponentSpe
description: '每次定时执行的状态与结果。',
tables: ['scheduled_runs'],
},
jobs: {
label: '命令历史',
description: '集中下发的设备操作命令与逐设备执行明细。',
tables: ['jobs', 'job_items', 'job_attempts'],
},
sms: {
label: '短信记录',
description: '集中保存的跨设备短信正文与会话。',
@@ -125,6 +131,20 @@ export interface AutoBackupSettings {
readonly lastRunAt: string | null;
}
export interface RestoreResult {
readonly written: Partial<Record<BackupComponentKey, number>>;
/** Filename of the snapshot taken just before the merge, so a restore is always reversible. */
readonly safetyBackup: string;
}
/** Download metadata without parsing an already validated archive again. */
export interface ComponentBackupFile {
readonly filename: string;
readonly path: string;
readonly sizeBytes: number;
readonly createdAt: string;
}
export class ComponentBackupError extends Error {
constructor(
readonly code:
@@ -292,11 +312,14 @@ export class ComponentBackupService {
async restore(
filename: string,
components: readonly BackupComponentKey[],
): Promise<Partial<Record<BackupComponentKey, number>>> {
): Promise<RestoreResult> {
const safe = this.#safeName(filename);
const keys = normalizeComponents(components);
const { document } = await this.#read(safe);
if (document.formatVersion > FORMAT_VERSION) throw new ComponentBackupError('INCOMPATIBLE');
// Snapshot the units that are about to be overwritten first: a restore must always be
// reversible, and the state right before the merge is the only thing worth returning to.
const safety = await this.create(keys, `${safe}`, true);
const written: Partial<Record<BackupComponentKey, number>> = {};
const restore = this.#db.transaction(() => {
for (const key of keys) {
@@ -314,7 +337,12 @@ export class ComponentBackupService {
}
});
restore();
return written;
return { written, safetyBackup: safety.filename };
}
/** Where the backup files live, shown so an operator can mirror the folder off-console. */
directory(): string {
return this.#directory;
}
async remove(filename: string): Promise<{ filename: string }> {
@@ -322,6 +350,23 @@ export class ComponentBackupService {
return { filename: this.#safeName(filename) };
}
/** Resolves a listed archive to a path that stays inside the backup directory. */
async backupFile(filename: string): Promise<ComponentBackupFile | undefined> {
const safe = this.#safeName(filename);
const path = join(this.#directory, safe);
const details = await stat(path).catch((error: NodeJS.ErrnoException) => {
if (error.code === 'ENOENT') return undefined;
throw error;
});
if (!details?.isFile()) return undefined;
return {
filename: safe,
path,
sizeBytes: details.size,
createdAt: details.mtime.toISOString(),
};
}
autoSettings(): AutoBackupSettings {
return this.#readAuto();
}
@@ -536,9 +581,9 @@ export class ComponentBackupService {
const fallback = {
enabled: false,
components: ['devices', 'notifications', 'automation'] as BackupComponentKey[],
timeOfDay: '03:30',
timeOfDay: '02:00',
weekday: -1,
maximumCount: 14,
maximumCount: 10,
};
const source: Record<string, unknown> = { ...fallback, ...(stored ?? {}) };
let components: BackupComponentKey[] = fallback.components;
@@ -30,6 +30,8 @@ export interface MaintenanceOverview {
};
readonly storage: {
readonly databaseBytes: number;
/** SQLite file path; empty for in-memory databases. */
readonly databasePath: string;
/** Write-ahead log file size; 0 when the database is not in WAL mode. */
readonly walBytes: number;
/** Total size of the retained backup files. */
@@ -195,6 +197,7 @@ export class SystemMaintenanceService {
},
storage: {
databaseBytes: pageCount * pageSize,
databasePath: databaseFile,
walBytes,
backupBytes: await this.#backupBytes(),
reclaimableBytes: freelist * pageSize,
+129
View File
@@ -397,6 +397,7 @@ describe('buildControlPlaneApp', () => {
checkedAt: null,
},
resources: {},
identity: { tracked: false, status: 'confirmed', reasons: [] },
},
],
});
@@ -1296,4 +1297,132 @@ describe('buildControlPlaneApp', () => {
expect(unbinds).toEqual([`http://192.168.1.20:8080/api/hub/unbind`]);
await app.close();
});
it('holds controls for two nodes that claim the same device identity', async () => {
const db = new Database(':memory:');
db.pragma('foreign_keys=ON');
migrateDatabase(db);
dbs.push(db);
const imei = '860000000000007';
const app = buildControlPlaneApp({
db,
store: new Store(),
upstream: {
get: async () => ({ status: 200, headers: {}, body: '' }),
request: async ({ url, method }) => {
if (url.endsWith('/api/device'))
return {
status: 200,
headers: {},
body: JSON.stringify({ imei, model: 'RM500Q', manufacturer: 'Quectel' }),
};
return {
status: method === 'POST' ? 200 : 404,
headers: {},
body: '{"status":"success"}',
};
},
postNetworkRegisterAuto: async () => ({ status: 200 }),
postServiceRestart: async () => ({ status: 200 }),
postBasebandRestart: async () => ({ status: 200 }),
postSystemReboot: async () => ({ status: 200 }),
},
});
const created: string[] = [];
for (const host of ['192.168.1.30', '192.168.1.31']) {
const response = await app.inject({
method: 'POST',
url: '/api/v1/instances',
payload: { name: `节点 ${host}`, origin: `http://${host}:8080` },
});
created.push(response.json().id as string);
}
const [first, second] = created;
// Reading the overview is enough: the device reports its own hardware and the guard notices
// that two records now answer with one IMEI.
for (const id of created) {
const snapshot = await app.inject(`/api/v1/instances/${id}/modules/overview`);
expect(snapshot.statusCode).toBe(200);
}
const listed = await app.inject('/api/v1/fleet/identities');
expect(listed.json()).toMatchObject({
summary: { tracked: 2, pending: 2 },
items: [
{ instanceId: first, reasons: ['imei_claimed'] },
{ instanceId: second, reasons: ['imei_claimed'] },
],
});
const blocked = await app.inject({
method: 'POST',
url: `/api/v1/instances/${first}/device-actions/call.waiting`,
payload: { confirm: true, params: { value: 'enabled' } },
});
expect(blocked.statusCode).toBe(409);
expect(blocked.json()).toMatchObject({ code: 'IDENTITY_UNCONFIRMED' });
const overview = await app.inject('/api/v1/fleet/overview');
expect(overview.json().items[0].identity).toEqual({
tracked: true,
status: 'pending',
reasons: ['imei_claimed'],
});
// The operator vouches for one node; the other keeps waiting.
const confirmed = await app.inject({
method: 'POST',
url: `/api/v1/fleet/identities/${first}/confirm`,
});
expect(confirmed.json()).toMatchObject({
identity: { instanceId: first, status: 'confirmed', reasons: [] },
});
const allowed = await app.inject({
method: 'POST',
url: `/api/v1/instances/${first}/device-actions/call.waiting`,
payload: { confirm: true, params: { value: 'enabled' } },
});
expect(allowed.statusCode).toBe(200);
await app.close();
});
it('scrapes the fused fleet through the metrics endpoint', async () => {
const db = new Database(':memory:');
db.pragma('foreign_keys=ON');
migrateDatabase(db);
dbs.push(db);
const app = buildControlPlaneApp({
db,
store: new Store(),
runtimeVersion: '9.9.9',
upstream: {
get: async () => ({ status: 200, headers: {}, body: '' }),
request: async ({ method }) => ({
status: method === 'POST' ? 200 : 404,
headers: {},
body: '{"status":"success"}',
}),
postNetworkRegisterAuto: async () => ({ status: 200 }),
postServiceRestart: async () => ({ status: 200 }),
postBasebandRestart: async () => ({ status: 200 }),
postSystemReboot: async () => ({ status: 200 }),
},
});
const created = await app.inject({
method: 'POST',
url: '/api/v1/instances',
payload: { name: '节点 A', origin: 'http://192.168.1.40:8080' },
});
expect(created.statusCode).toBe(201);
const response = await app.inject({ method: 'GET', url: '/api/v1/metrics' });
expect(response.statusCode).toBe(200);
expect(response.headers['content-type']).toContain('text/plain');
expect(response.body).toContain('multi_simadmin_build_info{version="9.9.9"} 1');
expect(response.body).toContain('multi_simadmin_nodes_total 1');
expect(response.body).toContain('multi_simadmin_notification_queue{status="pending"} 0');
await app.close();
});
});
+71 -1
View File
@@ -27,6 +27,7 @@ import {
type UpstreamSessionClientOptions,
} from './application/connections/upstream-session-client.js';
import { InstanceService } from './application/instances/instance-service.js';
import { DeviceIdentityService } from './application/identity/device-identity-service.js';
import { DeviceDiscoveryService } from './application/instances/device-discovery-service.js';
import {
type BindingRelease,
@@ -53,6 +54,13 @@ import { registerFleetRoutes } from './interface/http/fleet-routes.js';
import { SystemMaintenanceService } from './application/system/system-maintenance-service.js';
import { ComponentBackupService } from './application/system/component-backup-service.js';
import { registerSystemRoutes } from './interface/http/system-routes.js';
import {
ConsoleUpdateService,
createCommandRunner,
type RestartLauncher,
type UpdateCommandRunner,
} from './application/system/console-update-service.js';
import { registerUpdateRoutes } from './interface/http/update-routes.js';
import { InstanceNotificationService } from './application/notifications/instance-notification-service.js';
import { CentralNotificationService } from './application/notifications/central-notification-service.js';
import { registerCentralNotificationRoutes } from './interface/http/central-notification-routes.js';
@@ -65,6 +73,9 @@ import { InstanceModuleService } from './application/instances/instance-module-s
import { registerInstanceModuleRoutes } from './interface/http/instance-module-routes.js';
import { DeviceActionService } from './application/instances/device-action-service.js';
import { registerDeviceActionRoutes } from './interface/http/device-action-routes.js';
import { registerIdentityRoutes } from './interface/http/identity-routes.js';
import { MetricsService } from './application/observability/metrics-service.js';
import { registerMetricsRoutes } from './interface/http/metrics-routes.js';
import { registerDiscoveryRoutes } from './interface/http/discovery-routes.js';
import { ScheduledOperationDispatcher } from './application/automation/scheduled-operation-dispatcher.js';
import { SchedulerCoordinator } from './application/automation/scheduler-coordinator.js';
@@ -95,6 +106,15 @@ export interface ControlPlaneOptions {
/** Keep device online state fresh on its own; off in tests, on for the production gateway. */
readonly heartbeatEnabled?: boolean;
readonly app?: Omit<BuildAppOptions, 'registerRoutes'>;
/** Online update of the console itself; defaults to a git checkout in the current directory. */
readonly update?: {
readonly remote?: string;
readonly root?: string;
readonly runner?: UpdateCommandRunner;
readonly launcher?: RestartLauncher;
/** Overrides the safety snapshot taken before the checkout moves. */
readonly preInstallBackup?: (targetCommit: string) => Promise<string>;
};
}
const DEFAULT_SMS_SYNC_INTERVAL_MS = 300_000;
@@ -107,6 +127,7 @@ interface HubDeviceSummary {
readonly name: string;
readonly state: 'ready' | 'unavailable' | 'unknown';
readonly tags: readonly string[];
readonly groupId: string | null;
}
// Device reachability comes from the last ConnectionProbe result, never from a live probe:
@@ -136,6 +157,7 @@ function createHubDeviceReader(
name: instance.name,
state: reachable.has(instance.id) ? 'ready' : 'unknown',
tags: [...instance.tags],
groupId: instance.groupId,
});
}
if (current.items.length < 100) break;
@@ -254,6 +276,7 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane
await centralNotifications.enqueueEvent('system', {
instanceId: transition.instanceId,
instanceTags: instance?.tags ?? [],
instanceGroupIds: instance?.groupId ? [instance.groupId] : [],
fields: {
title: `${label} ${FLEET_STATE_EVENTS[transition.to]}`,
status: FLEET_STATE_CODES[transition.to],
@@ -274,6 +297,7 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane
await centralNotifications.enqueueEvent('sms', {
instanceId,
instanceTags: instance?.tags ?? [],
instanceGroupIds: instance?.groupId ? [instance.groupId] : [],
fields: {
sender: message.phoneNumber,
content: message.content,
@@ -393,6 +417,29 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane
backupDirectory: options.backupDirectory ?? './data/backups',
...(options.now ? { now: options.now } : {}),
});
// The console updates the same way the Hub does: a git remote is consulted, the candidate is
// fetched and verified, and only then does the supervisor get asked for a restart.
const updateRunner =
options.update?.runner ??
createCommandRunner({ cwd: options.update?.root ?? process.cwd(), timeoutMs: 120_000 });
const updates = new ConsoleUpdateService({
db: options.db,
version: options.runtimeVersion ?? '0.1.0',
runner: updateRunner,
launcher: options.update?.launcher ?? { supported: false, async restart() {} },
...(options.update?.remote ? { remote: options.update.remote } : {}),
...(options.now ? { now: options.now } : {}),
preInstallBackup:
options.update?.preInstallBackup ??
(async (targetCommit: string) => {
const created = await componentBackups.create(
['devices', 'notifications', 'automation', 'settings'],
`更新前自动备份:${targetCommit.slice(0, 7)}`,
true,
);
return created.filename;
}),
});
const scheduledDispatcher = new ScheduledOperationDispatcher({
db: options.db,
operations: secureExecution,
@@ -418,11 +465,21 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane
...(options.now ? { now: options.now } : {}),
});
const logCenter = new LogCenterService({ db: options.db, connections: connectionLogs });
// The guard learns identities from the device reads the console already makes, and in return it
// gets to stop control traffic when a node stops matching its own record.
const identities = new DeviceIdentityService({
db: options.db,
...(options.now ? { now: options.now } : {}),
authorizationMode: () => connectionSettings.get().authorizationMode,
});
const instanceModules = new InstanceModuleService({
instances,
sessions,
request: options.upstream.request,
ensureSession,
onIdentity: (instanceId, evidence, origin) => {
identities.observe(instanceId, { ...evidence, origin });
},
});
const deviceActions = new DeviceActionService({
instances,
@@ -430,6 +487,15 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane
request: options.upstream.request,
db: options.db,
ensureSession,
identities,
});
// Scraped by the operator's monitoring rather than by the console, and read-only by
// construction: it aggregates rows the control plane already writes instead of probing devices.
const metrics = new MetricsService({
db: options.db,
version: options.runtimeVersion ?? '0.1.0',
sources: { connections: connectionLogs, identities },
...(options.now ? { now: options.now } : {}),
});
const app = buildApp({
...options.app,
@@ -451,6 +517,7 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane
notifications,
connections,
outbox: smsOutbox,
identities,
});
registerCentralNotificationRoutes(app, {
notifications: centralNotifications,
@@ -476,10 +543,13 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane
connectionSettings,
heartbeat,
});
registerUpdateRoutes(app, { updates });
registerOrganizationRoutes(app, { organization });
registerLogCenterRoutes(app, { logs: logCenter, connections: connectionLogs });
registerInstanceModuleRoutes(app, { modules: instanceModules });
registerDeviceActionRoutes(app, { actions: deviceActions });
registerDeviceActionRoutes(app, { actions: deviceActions, identities });
registerIdentityRoutes(app, { identities, instances, modules: instanceModules });
registerMetricsRoutes(app, { metrics });
registerDiscoveryRoutes(app, { discovery });
},
});
@@ -65,6 +65,22 @@ describe('SafeUpstreamGateway', () => {
},
]);
});
it('allows the pinned aggregate network-speed read', async () => {
const get = vi.fn(async () => ({ status: 200, headers: {}, body: '{"data":{}}' }));
const gateway = new SafeUpstreamGateway({
transport: { get, post: async () => ({ status: 204, headers: {}, body: '' }) },
});
await expect(
gateway.request({
url: 'http://192.168.1.20:8080/api/network/speed',
method: 'GET',
headers: { accept: 'application/json' },
}),
).resolves.toMatchObject({ status: 200 });
expect(get).toHaveBeenCalledWith('http://192.168.1.20:8080/api/network/speed', {
accept: 'application/json',
});
});
it('rejects HTTP login and logout so credentials and cookies are never sent in cleartext', async () => {
const gateway = new SafeUpstreamGateway({
transport: {
@@ -70,6 +70,7 @@ const DEVICE_READ_PATHS: Readonly<Record<string, RegExp>> = {
'/api/voicemail/status': /^$/u,
'/api/ims/status': /^$/u,
'/api/network/operators': /^$/u,
'/api/network/speed': /^$/u,
'/api/network/signal-strength': /^$/u,
'/api/network/interfaces': /^$/u,
'/api/network/connection-addresses': /^$/u,
@@ -13,6 +13,7 @@ export interface CentralNotificationRoutesOptions {
readonly name: string;
readonly state: string;
readonly tags: readonly string[];
readonly groupId: string | null;
}[]
>;
}
@@ -1,5 +1,6 @@
import Database from 'better-sqlite3';
import { afterEach, describe, expect, it } from 'vitest';
import { Writable } from 'node:stream';
import { buildApp } from '../../app.js';
import { ConsoleAuthService } from '../../application/auth/console-auth-service.js';
@@ -15,18 +16,32 @@ function fixture() {
db.pragma('foreign_keys = ON');
migrateDatabase(db);
dbs.push(db);
let now = new Date('2026-07-19T00:00:00.000Z');
const auth = new ConsoleAuthService({ db, now: () => now });
const clock = { now: new Date('2026-07-19T00:00:00.000Z') };
const auth = new ConsoleAuthService({ db, now: () => clock.now });
let logs = '';
const app = buildApp({
logger: {
stream: new (class extends Writable {
override write(chunk: Buffer | string): boolean {
logs += chunk.toString();
return true;
}
})(),
},
registerRoutes: (fastify) => {
registerConsoleAuth(fastify, auth);
fastify.get('/api/v1/protected-probe', async () => ({ ok: true }));
},
});
const advance = (milliseconds: number) => {
clock.now = new Date(clock.now.getTime() + milliseconds);
};
return {
app,
db,
auth,
advance: (milliseconds: number) => (now = new Date(now.getTime() + milliseconds)),
advance,
logs: () => logs,
};
}
@@ -44,6 +59,7 @@ describe('aggregate-console password protection HTTP boundary', () => {
configured: false,
protectionEnabled: false,
authenticated: true,
sessionIdleTimeoutMinutes: 30,
});
expect(JSON.stringify(response.json())).not.toMatch(/hash|salt|password/i);
await app.close();
@@ -225,6 +241,50 @@ describe('aggregate-console password protection HTTP boundary', () => {
await app.close();
});
it('expires sessions after configurable browser-idle inactivity', async () => {
const { app, auth, advance, logs } = fixture();
const enable = await app.inject({
method: 'PUT',
url: '/api/v1/auth/settings',
payload: { enabled: true, newPassword: PASSWORD },
});
expect(enable.statusCode).toBe(200);
expect(enable.json()).toMatchObject({ sessionIdleTimeoutMinutes: 30 });
const login = await app.inject({
method: 'POST',
url: '/api/v1/auth/login',
payload: { password: PASSWORD },
});
const cookie = String(login.headers['set-cookie']).split(';', 1)[0] ?? '';
expect(
(await app.inject({ method: 'GET', url: '/api/v1/auth/status', headers: { cookie } })).json(),
).toMatchObject({ authenticated: true, sessionIdleTimeoutMinutes: 30 });
const updated = await app.inject({
method: 'PUT',
url: '/api/v1/auth/settings',
headers: { cookie },
payload: { enabled: true, idleTimeoutMinutes: 10 },
});
expect(updated.statusCode).toBe(200);
expect(updated.json()).toMatchObject({ sessionIdleTimeoutMinutes: 10 });
expect(auth.status().sessionIdleTimeoutMinutes).toBe(10);
advance(9 * 60 * 1000);
expect(
(await app.inject({ method: 'GET', url: '/api/v1/protected-probe', headers: { cookie } }))
.statusCode,
).toBe(200);
advance(11 * 60 * 1000);
expect(
(await app.inject({ method: 'GET', url: '/api/v1/auth/status', headers: { cookie } })).json(),
).toMatchObject({ authenticated: false });
expect(logs()).not.toContain('request failed');
await app.close();
});
it('rejects anonymous password replacement while a stored credential is disabled', async () => {
const { app } = fixture();
const enable = await app.inject({
@@ -73,6 +73,7 @@ const settingsSchema = {
properties: {
enabled: { type: 'boolean' },
newPassword: { type: 'string', minLength: 1, maxLength: 1024 },
idleTimeoutMinutes: { type: 'number', minimum: 5, maximum: 1440, multipleOf: 1 },
},
} as const;
const loginSchema = {
@@ -88,7 +89,9 @@ export function registerConsoleAuth(app: FastifyInstance, auth: ConsoleAuthServi
const pathname = request.url.split(/[?#]/u, 1)[0] ?? request.url;
if (!auth.isProtectedPath(pathname)) return;
try {
auth.requireSession(parseCookie(request));
const token = parseCookie(request);
auth.requireSession(token);
auth.touchSession(token);
} catch (error) {
return handle(request, reply, error);
}
@@ -133,7 +136,11 @@ export function registerConsoleAuth(app: FastifyInstance, auth: ConsoleAuthServi
app.put('/api/v1/auth/settings', { schema: { body: settingsSchema } }, async (request, reply) => {
try {
const body = request.body as { enabled: boolean; newPassword?: string };
const body = request.body as {
enabled: boolean;
newPassword?: string;
idleTimeoutMinutes?: number;
};
const result = await auth.updateSettings(body, parseCookie(request));
if (result.sessionToken)
reply.header('Set-Cookie', cookie(result.sessionToken, usesSecureCookie(request)));
@@ -169,6 +169,8 @@ describe('system maintenance routes', () => {
expect(catalog.json().items).toEqual(
expect.arrayContaining([expect.objectContaining({ key: 'devices', rows: 1 })]),
);
expect(typeof catalog.json().directory).toBe('string');
expect(catalog.json().directory.length).toBeGreaterThan(0);
const created = await app.inject({
method: 'POST',
@@ -188,6 +190,15 @@ describe('system maintenance routes', () => {
expect(preview.statusCode, preview.body).toBe(200);
expect(preview.json()).toMatchObject({ integrity: 'ok', note: '升级前' });
const download = await app.inject({
method: 'GET',
url: `/api/v1/system/component-backups/${encodeURIComponent(filename)}/download`,
});
expect(download.statusCode, download.body).toBe(200);
expect(download.headers['content-disposition']).toBe(`attachment; filename="${filename}"`);
expect(download.headers['content-type']).toBe('application/octet-stream');
expect(Number(download.headers['content-length'])).toBe(download.rawPayload.length);
db.prepare('DELETE FROM device_groups').run();
const restored = await app.inject({
method: 'POST',
@@ -195,16 +206,32 @@ describe('system maintenance routes', () => {
payload: { components: ['devices'] },
});
expect(restored.statusCode, restored.body).toBe(200);
expect(restored.json()).toMatchObject({ devices: 1 });
const restoredBody = restored.json() as {
written: Record<string, number>;
safetyBackup: string;
};
expect(restoredBody.written).toEqual({ devices: 1 });
expect(restoredBody.safetyBackup.length).toBeGreaterThan(0);
expect(
(db.prepare('SELECT COUNT(*) AS count FROM device_groups').get() as { count: number }).count,
).toBe(1);
const removed = await app.inject({
method: 'DELETE',
url: `/api/v1/system/component-backups/${encodeURIComponent(filename)}`,
// The pre-restore snapshot is a normal backup file, listed and downloadable like any other.
const afterRestore = await app.inject({
method: 'GET',
url: '/api/v1/system/component-backups',
});
expect(removed.statusCode).toBe(200);
const names = (afterRestore.json().items as { filename: string; note: string }[]).map(
(item) => item.note,
);
expect(names.some((note) => note.startsWith('恢复前自动备份:'))).toBe(true);
for (const name of [filename, restoredBody.safetyBackup]) {
const removed = await app.inject({
method: 'DELETE',
url: `/api/v1/system/component-backups/${encodeURIComponent(name)}`,
});
expect(removed.statusCode).toBe(200);
}
expect(
(await app.inject({ method: 'GET', url: '/api/v1/system/component-backups' })).json(),
).toEqual({
@@ -257,16 +284,28 @@ describe('connection settings routes', () => {
const { app, connectionSettings } = fixture();
const read = await app.inject({ method: 'GET', url: '/api/v1/system/connection' });
expect(read.statusCode, read.body).toBe(200);
expect(read.json()).toEqual({ heartbeatSeconds: 30, offlineSeconds: 90 });
expect(read.json()).toEqual({
heartbeatSeconds: 30,
offlineSeconds: 90,
authorizationMode: 'auto',
});
const updated = await app.inject({
method: 'PUT',
url: '/api/v1/system/connection',
payload: { heartbeatSeconds: 45, offlineSeconds: 120 },
payload: { heartbeatSeconds: 45, offlineSeconds: 120, authorizationMode: 'manual' },
});
expect(updated.statusCode, updated.body).toBe(200);
expect(updated.json()).toEqual({ heartbeatSeconds: 45, offlineSeconds: 120 });
expect(connectionSettings.get()).toEqual({ heartbeatSeconds: 45, offlineSeconds: 120 });
expect(updated.json()).toEqual({
heartbeatSeconds: 45,
offlineSeconds: 120,
authorizationMode: 'manual',
});
expect(connectionSettings.get()).toEqual({
heartbeatSeconds: 45,
offlineSeconds: 120,
authorizationMode: 'manual',
});
expect(connectionSettings.snapshotTtlMs).toBe(120_000);
const refresh = await app.inject({ method: 'POST', url: '/api/v1/system/connection/refresh' });
@@ -295,6 +334,7 @@ describe('connection settings routes', () => {
expect((await app.inject({ method: 'GET', url: '/api/v1/system/connection' })).json()).toEqual({
heartbeatSeconds: 30,
offlineSeconds: 90,
authorizationMode: 'auto',
});
await app.close();
});
+21 -1
View File
@@ -161,7 +161,7 @@ export function registerSystemRoutes(app: FastifyInstance, options: SystemRoutes
app.get(
'/api/v1/system/component-backups/catalog',
wrap(async () => ({ items: backups().catalog() })),
wrap(async () => ({ items: backups().catalog(), directory: backups().directory() })),
);
app.get(
'/api/v1/system/component-backups/auto/settings',
@@ -196,6 +196,26 @@ export function registerSystemRoutes(app: FastifyInstance, options: SystemRoutes
'/api/v1/system/component-backups/:filename/preview',
wrap(async (request) => backups().preview((request.params as { filename: string }).filename)),
);
app.get(
'/api/v1/system/component-backups/:filename/download',
wrap(async (request, reply) => {
const backup = await backups().backupFile((request.params as { filename: string }).filename);
if (!backup)
return reply.code(404).type('application/problem+json').send({
type: 'about:blank',
title: 'Not Found',
status: 404,
code: 'COMPONENT_BACKUP_NOT_FOUND',
detail: 'The requested component backup file no longer exists.',
requestId: request.id,
});
reply
.header('content-disposition', `attachment; filename="${backup.filename}"`)
.header('content-length', String(backup.sizeBytes))
.type('application/octet-stream');
return createReadStream(backup.path);
}),
);
app.post(
'/api/v1/system/component-backups/:filename/restore',
{ bodyLimit: 8_192 },