diff --git a/apps/api/src/application/auth/console-auth-service.ts b/apps/api/src/application/auth/console-auth-service.ts index e0043e6..3256b16 100644 --- a/apps/api/src/application/auth/console-auth-service.ts +++ b/apps/api/src/application/auth/console-auth-service.ts @@ -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 { @@ -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(), + ); + } } diff --git a/apps/api/src/application/connections/connection-settings-service.test.ts b/apps/api/src/application/connections/connection-settings-service.test.ts index e26627e..f8b6b65 100644 --- a/apps/api/src/application/connections/connection-settings-service.test.ts +++ b/apps/api/src/application/connections/connection-settings-service.test.ts @@ -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"}', + ); }); }); diff --git a/apps/api/src/application/connections/connection-settings-service.ts b/apps/api/src/application/connections/connection-settings-service.ts index b196902..f5964ef 100644 --- a/apps/api/src/application/connections/connection-settings-service.ts +++ b/apps/api/src/application/connections/connection-settings-service.ts @@ -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 = 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 { diff --git a/apps/api/src/application/instances/instance-module-catalog.ts b/apps/api/src/application/instances/instance-module-catalog.ts index e08989a..cbf4be8 100644 --- a/apps/api/src/application/instances/instance-module-catalog.ts +++ b/apps/api/src/application/instances/instance-module-catalog.ts @@ -50,6 +50,7 @@ export const INSTANCE_MODULE_PROBES: Readonly>) { +function fixture( + probes: Readonly>, + extra: { + readonly onIdentity?: ( + instanceId: string, + evidence: Readonly>, + origin: string, + ) => void; + } = {}, +) { const sessions = new InstanceSessionStore(); const calls: UpstreamRequest[] = []; const instances = { @@ -45,6 +54,7 @@ function fixture(probes: Readonly>) { }; }, 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][] = []; + 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, 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'); + }); }); diff --git a/apps/api/src/application/instances/instance-module-service.ts b/apps/api/src/application/instances/instance-module-service.ts index 11630c2..a7ecf6b 100644 --- a/apps/api/src/application/instances/instance-module-service.ts +++ b/apps/api/src/application/instances/instance-module-service.ts @@ -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>, + 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, diff --git a/apps/api/src/application/notifications/central-notification-service.test.ts b/apps/api/src/application/notifications/central-notification-service.test.ts index 4c1cef1..41dce99 100644 --- a/apps/api/src/application/notifications/central-notification-service.test.ts +++ b/apps/api/src/application/notifications/central-notification-service.test.ts @@ -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); diff --git a/apps/api/src/application/notifications/central-notification-service.ts b/apps/api/src/application/notifications/central-notification-service.ts index a5bec2e..bc67164 100644 --- a/apps/api/src/application/notifications/central-notification-service.ts +++ b/apps/api/src/application/notifications/central-notification-service.ts @@ -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>; } @@ -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))) diff --git a/apps/api/src/application/resources/instance-resource-service.test.ts b/apps/api/src/application/resources/instance-resource-service.test.ts index 2b151e3..6f5801f 100644 --- a/apps/api/src/application/resources/instance-resource-service.test.ts +++ b/apps/api/src/application/resources/instance-resource-service.test.ts @@ -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, }); diff --git a/apps/api/src/application/resources/instance-resource-service.ts b/apps/api/src/application/resources/instance-resource-service.ts index 74dc45a..5958f19 100644 --- a/apps/api/src/application/resources/instance-resource-service.ts +++ b/apps/api/src/application/resources/instance-resource-service.ts @@ -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 = diff --git a/apps/api/src/application/system/component-backup-service.test.ts b/apps/api/src/application/system/component-backup-service.test.ts index 4e0814f..00ac82c 100644 --- a/apps/api/src/application/system/component-backup-service.test.ts +++ b/apps/api/src/application/system/component-backup-service.test.ts @@ -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'], }); }); diff --git a/apps/api/src/application/system/component-backup-service.ts b/apps/api/src/application/system/component-backup-service.ts index 4ceeb0c..3290ebf 100644 --- a/apps/api/src/application/system/component-backup-service.ts +++ b/apps/api/src/application/system/component-backup-service.ts @@ -14,6 +14,7 @@ export type BackupComponentKey = | 'notificationRecords' | 'automation' | 'automationRecords' + | 'jobs' | 'sms' | 'settings' | 'audit'; @@ -52,6 +53,11 @@ export const BACKUP_COMPONENTS: Readonly>; + /** 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>> { + ): Promise { 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> = {}; 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 { + 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 = { ...fallback, ...(stored ?? {}) }; let components: BackupComponentKey[] = fallback.components; diff --git a/apps/api/src/application/system/system-maintenance-service.ts b/apps/api/src/application/system/system-maintenance-service.ts index 71e0c2c..47c1051 100644 --- a/apps/api/src/application/system/system-maintenance-service.ts +++ b/apps/api/src/application/system/system-maintenance-service.ts @@ -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, diff --git a/apps/api/src/control-plane.test.ts b/apps/api/src/control-plane.test.ts index 6efa2ba..b469b7b 100644 --- a/apps/api/src/control-plane.test.ts +++ b/apps/api/src/control-plane.test.ts @@ -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(); + }); }); diff --git a/apps/api/src/control-plane.ts b/apps/api/src/control-plane.ts index 3aef71f..98dc2b0 100644 --- a/apps/api/src/control-plane.ts +++ b/apps/api/src/control-plane.ts @@ -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; + /** 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; + }; } 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 }); }, }); diff --git a/apps/api/src/infrastructure/transport/safe-upstream-gateway.test.ts b/apps/api/src/infrastructure/transport/safe-upstream-gateway.test.ts index 57683d7..c802751 100644 --- a/apps/api/src/infrastructure/transport/safe-upstream-gateway.test.ts +++ b/apps/api/src/infrastructure/transport/safe-upstream-gateway.test.ts @@ -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: { diff --git a/apps/api/src/infrastructure/transport/safe-upstream-gateway.ts b/apps/api/src/infrastructure/transport/safe-upstream-gateway.ts index 557a4db..539acfc 100644 --- a/apps/api/src/infrastructure/transport/safe-upstream-gateway.ts +++ b/apps/api/src/infrastructure/transport/safe-upstream-gateway.ts @@ -70,6 +70,7 @@ const DEVICE_READ_PATHS: Readonly> = { '/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, diff --git a/apps/api/src/interface/http/central-notification-routes.ts b/apps/api/src/interface/http/central-notification-routes.ts index 4615d3e..eb23482 100644 --- a/apps/api/src/interface/http/central-notification-routes.ts +++ b/apps/api/src/interface/http/central-notification-routes.ts @@ -13,6 +13,7 @@ export interface CentralNotificationRoutesOptions { readonly name: string; readonly state: string; readonly tags: readonly string[]; + readonly groupId: string | null; }[] >; } diff --git a/apps/api/src/interface/http/console-auth-routes.test.ts b/apps/api/src/interface/http/console-auth-routes.test.ts index 6505aa3..bc7f8d3 100644 --- a/apps/api/src/interface/http/console-auth-routes.test.ts +++ b/apps/api/src/interface/http/console-auth-routes.test.ts @@ -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({ diff --git a/apps/api/src/interface/http/console-auth-routes.ts b/apps/api/src/interface/http/console-auth-routes.ts index 15dff13..f9c00fc 100644 --- a/apps/api/src/interface/http/console-auth-routes.ts +++ b/apps/api/src/interface/http/console-auth-routes.ts @@ -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))); diff --git a/apps/api/src/interface/http/system-routes.test.ts b/apps/api/src/interface/http/system-routes.test.ts index 70fe535..bae6c40 100644 --- a/apps/api/src/interface/http/system-routes.test.ts +++ b/apps/api/src/interface/http/system-routes.test.ts @@ -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; + 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(); }); diff --git a/apps/api/src/interface/http/system-routes.ts b/apps/api/src/interface/http/system-routes.ts index 335de3d..f985255 100644 --- a/apps/api/src/interface/http/system-routes.ts +++ b/apps/api/src/interface/http/system-routes.ts @@ -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 },