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:
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user