perf,feat(events),hardening: second review pass
Fleet overview N+1: - InstanceResourceService gains a 30s TTL cache with single-flight coalescing; the overview no longer fires six live upstream requests per device on every render (plus the re-login storm), while the per-device detail route probes live via force:true Event journal becomes live: - job terminal transitions (manual executions and the interrupted sweep) now append to the journal, so /api/v1/events SSE feeds the frontend's invalidation controller that was built but never received events - journal pruning moves off the append hot path (was an unindexable full-table json_extract scan per insert) onto the retention timer Console auth hardening: - scrypt upgraded from N=16384 to N=2^16 (OWASP interactive guidance); a new password_kdf column records the derivation per row and legacy hashes rehash transparently on the next successful login without invalidating sessions (migration 18) Legacy stack: - instance URL validation blocks IPv4-compatible IPv6 after WHATWG canonicalization (::a9fe:a9fe metadata, ::7f00:1 loopback slipped past) - status polls cool down auto-login for 60s after a failed attempt so a stale saved password cannot hammer the device into an account lockout Build hygiene: - web bundle splits app (410kB) from vendor (212kB) so framework code stays cacheable across releases; stale root package-lock.json removed (pnpm is the only lockfile)
This commit is contained in:
@@ -80,10 +80,12 @@ sh /tmp/multi-simadmin-install.sh uninstall
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp config.example.json config.json
|
cp config.example.json config.json
|
||||||
npm install
|
corepack pnpm install
|
||||||
npm start
|
corepack pnpm start
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> 仓库统一使用 pnpm(`pnpm-lock.yaml`)作为唯一 lockfile;陈旧的 `package-lock.json` 已移除。
|
||||||
|
|
||||||
## 配置
|
## 配置
|
||||||
|
|
||||||
编辑 `config.json`:
|
编辑 `config.json`:
|
||||||
|
|||||||
@@ -6,6 +6,25 @@ const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|||||||
const MIN_PASSWORD_LENGTH = 8;
|
const MIN_PASSWORD_LENGTH = 8;
|
||||||
const MAX_PASSWORD_BYTES = 1024;
|
const MAX_PASSWORD_BYTES = 1024;
|
||||||
const SCRYPT_KEY_LENGTH = 32;
|
const SCRYPT_KEY_LENGTH = 32;
|
||||||
|
// Legacy derivations used N=16384; OWASP guidance for interactive login sits at
|
||||||
|
// N=2^16/r=8. Rows keep their derivation tag so verification stays correct and
|
||||||
|
// the upgrade happens transparently on the next successful login.
|
||||||
|
interface ScryptParams {
|
||||||
|
readonly N: number;
|
||||||
|
readonly r: number;
|
||||||
|
readonly p: number;
|
||||||
|
}
|
||||||
|
const SCRYPT_LEGACY: ScryptParams = { N: 16_384, r: 8, p: 1 };
|
||||||
|
const SCRYPT_CURRENT: ScryptParams = { N: 65_536, r: 8, p: 1 };
|
||||||
|
const SCRYPT_MAXMEM = 256 * 1024 * 1024;
|
||||||
|
const scryptTag = (params: ScryptParams): string => `scrypt:${params.N}:${params.r}:${params.p}`;
|
||||||
|
const parseScryptTag = (value: string | null | undefined): ScryptParams => {
|
||||||
|
if (!value) return SCRYPT_LEGACY;
|
||||||
|
const match = /^scrypt:(\d+):(\d+):(\d+)$/.exec(value);
|
||||||
|
if (!match) return SCRYPT_LEGACY;
|
||||||
|
const [N, r, p] = match.slice(1).map(Number) as [number, number, number];
|
||||||
|
return { N, r, p };
|
||||||
|
};
|
||||||
const IDLE_TIMEOUT_SETTING_KEY = 'console-auth.idle-timeout-minutes';
|
const IDLE_TIMEOUT_SETTING_KEY = 'console-auth.idle-timeout-minutes';
|
||||||
const DEFAULT_IDLE_TIMEOUT_MINUTES = 30;
|
const DEFAULT_IDLE_TIMEOUT_MINUTES = 30;
|
||||||
const MIN_IDLE_TIMEOUT_MINUTES = 5;
|
const MIN_IDLE_TIMEOUT_MINUTES = 5;
|
||||||
@@ -16,6 +35,7 @@ interface AuthConfigRow {
|
|||||||
readonly password_salt: string | null;
|
readonly password_salt: string | null;
|
||||||
readonly password_hash: string | null;
|
readonly password_hash: string | null;
|
||||||
readonly password_revision: number;
|
readonly password_revision: number;
|
||||||
|
readonly password_kdf: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConsoleAuthStatus {
|
export interface ConsoleAuthStatus {
|
||||||
@@ -87,10 +107,23 @@ export class ConsoleAuthService {
|
|||||||
!config.password_hash
|
!config.password_hash
|
||||||
)
|
)
|
||||||
throw new ConsoleAuthError('INVALID_CREDENTIALS');
|
throw new ConsoleAuthError('INVALID_CREDENTIALS');
|
||||||
const actual = await this.derive(password, config.password_salt);
|
const params = parseScryptTag(config.password_kdf);
|
||||||
|
const actual = await this.derive(password, config.password_salt, params);
|
||||||
const expected = Buffer.from(config.password_hash, 'hex');
|
const expected = Buffer.from(config.password_hash, 'hex');
|
||||||
if (actual.length !== expected.length || !timingSafeEqual(actual, expected))
|
if (actual.length !== expected.length || !timingSafeEqual(actual, expected))
|
||||||
throw new ConsoleAuthError('INVALID_CREDENTIALS');
|
throw new ConsoleAuthError('INVALID_CREDENTIALS');
|
||||||
|
if (params !== SCRYPT_CURRENT) {
|
||||||
|
// Transparent upgrade: same salt, stronger derivation, same revision so
|
||||||
|
// live sessions survive.
|
||||||
|
const hash = (await this.derive(password, config.password_salt, SCRYPT_CURRENT)).toString(
|
||||||
|
'hex',
|
||||||
|
);
|
||||||
|
this.db
|
||||||
|
.prepare(
|
||||||
|
'UPDATE console_auth_config SET password_hash=?,password_kdf=?,updated_at=? WHERE singleton=1',
|
||||||
|
)
|
||||||
|
.run(hash, scryptTag(SCRYPT_CURRENT), this.now().toISOString());
|
||||||
|
}
|
||||||
return this.createSession(config.password_revision);
|
return this.createSession(config.password_revision);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,14 +154,14 @@ export class ConsoleAuthService {
|
|||||||
this.db
|
this.db
|
||||||
.prepare(
|
.prepare(
|
||||||
`INSERT INTO console_auth_config
|
`INSERT INTO console_auth_config
|
||||||
(singleton,protection_enabled,password_salt,password_hash,password_revision,updated_at)
|
(singleton,protection_enabled,password_salt,password_hash,password_revision,password_kdf,updated_at)
|
||||||
VALUES (1,1,?,?,?,?)
|
VALUES (1,1,?,?,?,?,?)
|
||||||
ON CONFLICT(singleton) DO UPDATE SET
|
ON CONFLICT(singleton) DO UPDATE SET
|
||||||
protection_enabled=1,password_salt=excluded.password_salt,
|
protection_enabled=1,password_salt=excluded.password_salt,
|
||||||
password_hash=excluded.password_hash,password_revision=excluded.password_revision,
|
password_hash=excluded.password_hash,password_revision=excluded.password_revision,
|
||||||
updated_at=excluded.updated_at`,
|
password_kdf=excluded.password_kdf,updated_at=excluded.updated_at`,
|
||||||
)
|
)
|
||||||
.run(salt, hash, revision, this.now().toISOString());
|
.run(salt, hash, revision, scryptTag(SCRYPT_CURRENT), this.now().toISOString());
|
||||||
this.db.prepare('DELETE FROM console_auth_sessions').run();
|
this.db.prepare('DELETE FROM console_auth_sessions').run();
|
||||||
return this.createSession(revision);
|
return this.createSession(revision);
|
||||||
})();
|
})();
|
||||||
@@ -178,7 +211,7 @@ export class ConsoleAuthService {
|
|||||||
private config(): AuthConfigRow | undefined {
|
private config(): AuthConfigRow | undefined {
|
||||||
return this.db
|
return this.db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT protection_enabled,password_salt,password_hash,password_revision
|
`SELECT protection_enabled,password_salt,password_hash,password_revision,password_kdf
|
||||||
FROM console_auth_config WHERE singleton=1`,
|
FROM console_auth_config WHERE singleton=1`,
|
||||||
)
|
)
|
||||||
.get() as AuthConfigRow | undefined;
|
.get() as AuthConfigRow | undefined;
|
||||||
@@ -221,13 +254,17 @@ export class ConsoleAuthService {
|
|||||||
throw new ConsoleAuthError('PASSWORD_POLICY_FAILED');
|
throw new ConsoleAuthError('PASSWORD_POLICY_FAILED');
|
||||||
}
|
}
|
||||||
|
|
||||||
private derive(password: string, salt: string): Promise<Buffer> {
|
private derive(
|
||||||
|
password: string,
|
||||||
|
salt: string,
|
||||||
|
params: ScryptParams = SCRYPT_CURRENT,
|
||||||
|
): Promise<Buffer> {
|
||||||
return new Promise((resolvePromise, reject) => {
|
return new Promise((resolvePromise, reject) => {
|
||||||
deriveScrypt(
|
deriveScrypt(
|
||||||
password,
|
password,
|
||||||
Buffer.from(salt, 'hex'),
|
Buffer.from(salt, 'hex'),
|
||||||
SCRYPT_KEY_LENGTH,
|
SCRYPT_KEY_LENGTH,
|
||||||
{ N: 16_384, r: 8, p: 1, maxmem: 64 * 1024 * 1024 },
|
{ ...params, maxmem: SCRYPT_MAXMEM },
|
||||||
(error, derivedKey) => {
|
(error, derivedKey) => {
|
||||||
if (error) reject(error);
|
if (error) reject(error);
|
||||||
else resolvePromise(derivedKey);
|
else resolvePromise(derivedKey);
|
||||||
|
|||||||
@@ -185,7 +185,6 @@ export class EventJournal {
|
|||||||
const sequence = Number(result.lastInsertRowid);
|
const sequence = Number(result.lastInsertRowid);
|
||||||
if (!Number.isSafeInteger(sequence))
|
if (!Number.isSafeInteger(sequence))
|
||||||
throw new Error('event journal sequence is not a safe integer');
|
throw new Error('event journal sequence is not a safe integer');
|
||||||
this.#prune();
|
|
||||||
const appended = { sequence, envelope };
|
const appended = { sequence, envelope };
|
||||||
for (const subscriber of [...this.#subscribers]) subscriber(appended);
|
for (const subscriber of [...this.#subscribers]) subscriber(appended);
|
||||||
return appended;
|
return appended;
|
||||||
@@ -236,7 +235,11 @@ export class EventJournal {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
#prune(): void {
|
/**
|
||||||
|
* Maintenance hook for a periodic timer. Pruning per append turned every
|
||||||
|
* insert into an unindexable full-table json_extract scan.
|
||||||
|
*/
|
||||||
|
prune(): void {
|
||||||
const cutoff = new Date(Date.now() - this.#retentionMs).toISOString();
|
const cutoff = new Date(Date.now() - this.#retentionMs).toISOString();
|
||||||
this.#database
|
this.#database
|
||||||
.prepare("DELETE FROM event_journal WHERE json_extract(envelope_json, '$.occurredAt') < ?")
|
.prepare("DELETE FROM event_journal WHERE json_extract(envelope_json, '$.occurredAt') < ?")
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type Database from 'better-sqlite3';
|
import type Database from 'better-sqlite3';
|
||||||
import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
|
import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
|
||||||
|
import type { EventEnvelope } from '../events/event-journal.js';
|
||||||
import type {
|
import type {
|
||||||
ExecuteOperationRequest,
|
ExecuteOperationRequest,
|
||||||
Job,
|
Job,
|
||||||
@@ -121,6 +122,8 @@ interface Options {
|
|||||||
readonly idFactory?: () => string;
|
readonly idFactory?: () => string;
|
||||||
readonly tokenFactory?: () => string;
|
readonly tokenFactory?: () => string;
|
||||||
readonly nonceFactory?: () => string;
|
readonly nonceFactory?: () => string;
|
||||||
|
/** Receives job terminal transitions so the SSE journal has a live producer. */
|
||||||
|
readonly emit?: (envelope: EventEnvelope) => void;
|
||||||
}
|
}
|
||||||
interface PreparationRow {
|
interface PreparationRow {
|
||||||
operation_id: string;
|
operation_id: string;
|
||||||
@@ -442,6 +445,7 @@ export class SecureOperationExecution {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.finish(ids, state, code);
|
this.finish(ids, state, code);
|
||||||
|
this.#emitTerminal(ids.job, state, requestId);
|
||||||
return this.job(ids.job);
|
return this.job(ids.job);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -453,7 +457,10 @@ export class SecureOperationExecution {
|
|||||||
"SELECT id FROM jobs WHERE operation_id IN ('postNetworkRegisterAuto','postServiceRestart','postBasebandRestart','postSystemReboot') AND risk_level IN ('R2','R3') AND status='running'",
|
"SELECT id FROM jobs WHERE operation_id IN ('postNetworkRegisterAuto','postServiceRestart','postBasebandRestart','postSystemReboot') AND risk_level IN ('R2','R3') AND status='running'",
|
||||||
)
|
)
|
||||||
.all() as Array<{ id: string }>;
|
.all() as Array<{ id: string }>;
|
||||||
for (const row of jobs) this.finishByJob(row.id, now, 'unknown-result', 'INTERRUPTED');
|
for (const row of jobs) {
|
||||||
|
this.finishByJob(row.id, now, 'unknown-result', 'INTERRUPTED');
|
||||||
|
this.#emitTerminal(row.id, 'unknown-result', now);
|
||||||
|
}
|
||||||
return jobs.length;
|
return jobs.length;
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
@@ -479,6 +486,21 @@ export class SecureOperationExecution {
|
|||||||
.run(state, now, now, ids.job);
|
.run(state, now, now, ids.job);
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
|
#emitTerminal(jobId: string, state: string, requestId: string): void {
|
||||||
|
if (!this.options.emit) return;
|
||||||
|
try {
|
||||||
|
this.options.emit({
|
||||||
|
kind: 'job',
|
||||||
|
id: this.id(),
|
||||||
|
occurredAt: this.clock().toISOString(),
|
||||||
|
requestId,
|
||||||
|
jobId,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// A journal failure must never fail the operation itself.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private finishByJob(jobId: string, now: string, state: string, code: string): void {
|
private finishByJob(jobId: string, now: string, state: string, code: string): void {
|
||||||
this.options.db
|
this.options.db
|
||||||
.prepare(
|
.prepare(
|
||||||
|
|||||||
@@ -155,6 +155,66 @@ describe('instance resource allowlist parsing', () => {
|
|||||||
).toEqual({ simPresent: true, phoneNumbers: ['+8613800000000'] });
|
).toEqual({ simPresent: true, phoneNumbers: ['+8613800000000'] });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('overview cache', () => {
|
||||||
|
const minimal = (cacheTtlMs?: number) => {
|
||||||
|
const requests: Array<{ url: string }> = [];
|
||||||
|
const service = new InstanceResourceService({
|
||||||
|
instances: { get: async () => ({ origin: 'http://192.168.3.55:3000' }) } as never,
|
||||||
|
sessions: { sessionFor: () => undefined } as never,
|
||||||
|
request: async (request: { url: string }) => {
|
||||||
|
requests.push(request);
|
||||||
|
return request.url.endsWith('/api/device')
|
||||||
|
? response({ data: { online: true } })
|
||||||
|
: { status: 503, headers: {}, body: '' };
|
||||||
|
},
|
||||||
|
...(cacheTtlMs === undefined ? {} : { cacheTtlMs }),
|
||||||
|
} as never);
|
||||||
|
return { service, requests };
|
||||||
|
};
|
||||||
|
|
||||||
|
it('serves repeat overview reads from the TTL cache and refreshes after expiry', async () => {
|
||||||
|
let clock = 1_000;
|
||||||
|
const { service, requests } = minimal();
|
||||||
|
(service as unknown as { options: { now?: () => number } }).options.now = () => clock;
|
||||||
|
|
||||||
|
await service.get('device-1');
|
||||||
|
const firstPass = requests.length;
|
||||||
|
await service.get('device-1');
|
||||||
|
expect(requests.length).toBe(firstPass);
|
||||||
|
clock += 31_000;
|
||||||
|
await service.get('device-1');
|
||||||
|
expect(requests.length).toBeGreaterThan(firstPass);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('coalesces concurrent overview reads into one upstream pass', async () => {
|
||||||
|
const { service, requests } = minimal();
|
||||||
|
await Promise.all([
|
||||||
|
service.get('device-1'),
|
||||||
|
service.get('device-1'),
|
||||||
|
service.get('device-1'),
|
||||||
|
]);
|
||||||
|
expect(requests).toHaveLength(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('probes live again when a detail view forces a refresh', async () => {
|
||||||
|
const clock = 5_000;
|
||||||
|
const { service, requests } = minimal();
|
||||||
|
(service as unknown as { options: { now?: () => number } }).options.now = () => clock;
|
||||||
|
|
||||||
|
await service.get('device-1');
|
||||||
|
const afterOverview = requests.length;
|
||||||
|
await service.get('device-1', { force: true });
|
||||||
|
expect(requests.length).toBe(afterOverview + 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps reads uncached when the TTL is disabled', async () => {
|
||||||
|
const { service, requests } = minimal(0);
|
||||||
|
await service.get('device-1');
|
||||||
|
await service.get('device-1');
|
||||||
|
expect(requests).toHaveLength(12);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('probes passwordless instances without manufacturing a cookie', async () => {
|
it('probes passwordless instances without manufacturing a cookie', async () => {
|
||||||
const requests: Array<{ url: string; headers: Readonly<Record<string, string>> }> = [];
|
const requests: Array<{ url: string; headers: Readonly<Record<string, string>> }> = [];
|
||||||
const service = new InstanceResourceService({
|
const service = new InstanceResourceService({
|
||||||
|
|||||||
@@ -163,7 +163,15 @@ export function parseSignalStrength(response: UpstreamResponse): InstanceResourc
|
|||||||
return signalPercent === undefined ? {} : { signalPercent };
|
return signalPercent === undefined ? {} : { signalPercent };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEFAULT_CACHE_TTL_MS = 30_000;
|
||||||
|
|
||||||
export class InstanceResourceService {
|
export class InstanceResourceService {
|
||||||
|
readonly #cache = new Map<
|
||||||
|
string,
|
||||||
|
{ readonly resources: InstanceResources; readonly fetchedAt: number }
|
||||||
|
>();
|
||||||
|
readonly #inflight = new Map<string, Promise<InstanceResources>>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly options: {
|
private readonly options: {
|
||||||
readonly instances: InstanceService;
|
readonly instances: InstanceService;
|
||||||
@@ -174,10 +182,46 @@ export class InstanceResourceService {
|
|||||||
origin: string,
|
origin: string,
|
||||||
force?: boolean,
|
force?: boolean,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
|
/** Fleet overview fan-out reads through this cache; 0 disables it. */
|
||||||
|
readonly cacheTtlMs?: number;
|
||||||
|
readonly now?: () => number;
|
||||||
},
|
},
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async get(instanceId: string): Promise<InstanceResources> {
|
/**
|
||||||
|
* Fleet overview fans out one read per registered node; without a shared TTL
|
||||||
|
* that is six live upstream requests per device on every page render (plus a
|
||||||
|
* re-login storm when sessions expired). Detail views pass `force` to probe
|
||||||
|
* the selected device immediately.
|
||||||
|
*/
|
||||||
|
async get(
|
||||||
|
instanceId: string,
|
||||||
|
{ force = false }: { readonly force?: boolean } = {},
|
||||||
|
): Promise<InstanceResources> {
|
||||||
|
const ttl = this.options.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS;
|
||||||
|
const now = this.options.now?.() ?? Date.now();
|
||||||
|
if (ttl > 0 && !force) {
|
||||||
|
const cached = this.#cache.get(instanceId);
|
||||||
|
if (cached && now - cached.fetchedAt < ttl) return cached.resources;
|
||||||
|
const inflight = this.#inflight.get(instanceId);
|
||||||
|
if (inflight) return inflight;
|
||||||
|
const pending = this.#fetch(instanceId)
|
||||||
|
.then((resources) => {
|
||||||
|
this.#cache.set(instanceId, { resources, fetchedAt: this.options.now?.() ?? now });
|
||||||
|
return resources;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.#inflight.delete(instanceId);
|
||||||
|
});
|
||||||
|
this.#inflight.set(instanceId, pending);
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
|
const resources = await this.#fetch(instanceId);
|
||||||
|
if (ttl > 0) this.#cache.set(instanceId, { resources, fetchedAt: now });
|
||||||
|
return resources;
|
||||||
|
}
|
||||||
|
|
||||||
|
async #fetch(instanceId: string): Promise<InstanceResources> {
|
||||||
const [instance, session] = await Promise.all([
|
const [instance, session] = await Promise.all([
|
||||||
this.options.instances.get(instanceId),
|
this.options.instances.get(instanceId),
|
||||||
Promise.resolve(this.options.sessions.sessionFor(instanceId)),
|
Promise.resolve(this.options.sessions.sessionFor(instanceId)),
|
||||||
|
|||||||
@@ -381,6 +381,7 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane
|
|||||||
const secureExecution = new SecureOperationExecution({
|
const secureExecution = new SecureOperationExecution({
|
||||||
db: options.db,
|
db: options.db,
|
||||||
registry: secureOperationRegistry,
|
registry: secureOperationRegistry,
|
||||||
|
emit: (envelope) => eventJournal.append(envelope),
|
||||||
transport: {
|
transport: {
|
||||||
request: async ({ origin, path, body, contentType, instanceId }) => {
|
request: async ({ origin, path, body, contentType, instanceId }) => {
|
||||||
const cookie = await resolveOperationCookie(instanceId, origin);
|
const cookie = await resolveOperationCookie(instanceId, origin);
|
||||||
@@ -586,6 +587,9 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane
|
|||||||
void Promise.resolve()
|
void Promise.resolve()
|
||||||
.then(() => smsOutbox.prune(30 * 24 * 60 * 60 * 1000))
|
.then(() => smsOutbox.prune(30 * 24 * 60 * 60 * 1000))
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
|
void Promise.resolve()
|
||||||
|
.then(() => eventJournal.prune())
|
||||||
|
.catch(() => undefined);
|
||||||
}, logPruneIntervalMs)
|
}, logPruneIntervalMs)
|
||||||
: undefined;
|
: undefined;
|
||||||
// Scheduled component backups: the tick is cheap and only writes when a period is overdue.
|
// Scheduled component backups: the tick is cheap and only writes when a period is overdue.
|
||||||
|
|||||||
@@ -670,6 +670,16 @@ export const MIGRATIONS: readonly Migration[] = [
|
|||||||
'CREATE INDEX idx_device_identities_imei ON device_identities(imei, instance_id)',
|
'CREATE INDEX idx_device_identities_imei ON device_identities(imei, instance_id)',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 18,
|
||||||
|
name: 'console-auth-scrypt-params',
|
||||||
|
statements: [
|
||||||
|
// Legacy hashes were derived with N=16384; new derivations use stronger
|
||||||
|
// parameters. The column records the derivation per row; NULL means legacy
|
||||||
|
// and is transparently upgraded after the next successful login.
|
||||||
|
'ALTER TABLE console_auth_config ADD COLUMN password_kdf TEXT',
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const createMigrationsTable = `CREATE TABLE schema_migrations (
|
const createMigrationsTable = `CREATE TABLE schema_migrations (
|
||||||
|
|||||||
@@ -49,6 +49,49 @@ afterEach(async () => {
|
|||||||
for (const db of dbs.splice(0)) if (db.open) db.close();
|
for (const db of dbs.splice(0)) if (db.open) db.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function seedLegacyHash(db: Database.Database, password: string): Promise<void> {
|
||||||
|
const { scrypt } = await import('node:crypto');
|
||||||
|
const salt = 'a'.repeat(32);
|
||||||
|
const hash = await new Promise<Buffer>((resolve, reject) =>
|
||||||
|
scrypt(password, Buffer.from(salt, 'hex'), 32, { N: 16_384, r: 8, p: 1 }, (error, key) =>
|
||||||
|
error ? reject(error) : resolve(key),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO console_auth_config
|
||||||
|
(singleton,protection_enabled,password_salt,password_hash,password_revision,password_kdf,updated_at)
|
||||||
|
VALUES (1,1,?,?,1,NULL,?)`,
|
||||||
|
).run(salt, hash.toString('hex'), new Date().toISOString());
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('console password derivation upgrade', () => {
|
||||||
|
it('verifies a legacy N=16384 hash and transparently rehashes with current params', async () => {
|
||||||
|
const { app, db, auth } = fixture();
|
||||||
|
await seedLegacyHash(db, PASSWORD);
|
||||||
|
|
||||||
|
const before = db
|
||||||
|
.prepare('SELECT password_hash, password_kdf FROM console_auth_config WHERE singleton=1')
|
||||||
|
.get() as { password_hash: string; password_kdf: string | null };
|
||||||
|
expect(before.password_kdf).toBeNull();
|
||||||
|
|
||||||
|
const login = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/auth/login',
|
||||||
|
payload: { password: PASSWORD },
|
||||||
|
});
|
||||||
|
expect(login.statusCode).toBe(204);
|
||||||
|
|
||||||
|
const after = db
|
||||||
|
.prepare('SELECT password_hash, password_kdf FROM console_auth_config WHERE singleton=1')
|
||||||
|
.get() as { password_hash: string; password_kdf: string | null };
|
||||||
|
expect(after.password_kdf).toBe('scrypt:65536:8:1');
|
||||||
|
expect(after.password_hash).not.toBe(before.password_hash);
|
||||||
|
|
||||||
|
// The upgraded hash still verifies with a plain login through the service.
|
||||||
|
await expect(auth.login(PASSWORD)).resolves.toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('aggregate-console password protection HTTP boundary', () => {
|
describe('aggregate-console password protection HTTP boundary', () => {
|
||||||
it('starts migration-safe as disabled and never returns password material', async () => {
|
it('starts migration-safe as disabled and never returns password material', async () => {
|
||||||
const { app } = fixture();
|
const { app } = fixture();
|
||||||
|
|||||||
@@ -370,7 +370,10 @@ export function registerInstanceRoutes(app: FastifyInstance, options: InstanceRo
|
|||||||
app.get(
|
app.get(
|
||||||
'/api/v1/instances/:instanceId/resources',
|
'/api/v1/instances/:instanceId/resources',
|
||||||
wrap(async (request) =>
|
wrap(async (request) =>
|
||||||
options.resources!.get((request.params as { instanceId: string }).instanceId),
|
// A single selected device is the one place a live probe is expected.
|
||||||
|
options.resources!.get((request.params as { instanceId: string }).instanceId, {
|
||||||
|
force: true,
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (options.messages) {
|
if (options.messages) {
|
||||||
|
|||||||
@@ -4,6 +4,18 @@ import { defineConfig } from 'vite';
|
|||||||
|
|
||||||
export default defineConfig(({ mode }) => ({
|
export default defineConfig(({ mode }) => ({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
build: {
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
// Framework code changes far less often than app code; splitting keeps
|
||||||
|
// the vendor chunk cacheable across releases instead of one big bundle.
|
||||||
|
manualChunks(id) {
|
||||||
|
if (id.includes('node_modules')) return 'vendor';
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias:
|
alias:
|
||||||
mode === 'test'
|
mode === 'test'
|
||||||
|
|||||||
Generated
-3843
File diff suppressed because it is too large
Load Diff
@@ -14,7 +14,10 @@ export function normalizeBaseUrl(rawUrl) {
|
|||||||
(ipv4[0] === 169 && ipv4[1] === 254) ||
|
(ipv4[0] === 169 && ipv4[1] === 254) ||
|
||||||
(ipv4[0] >= 224)
|
(ipv4[0] >= 224)
|
||||||
)
|
)
|
||||||
const prohibitedIpv6 = hostname === '::' || hostname === '::1' || /^fe[89ab][0-9a-f]:/.test(hostname) || hostname.startsWith('ff') || hostname.startsWith('::ffff:') || hostname.startsWith('64:ff9b:')
|
// WHATWG canonicalization turns ::127.0.0.1 into ::7f00:1 and ::169.254.169.254 into
|
||||||
|
// ::a9fe:a9fe; the IPv4-compatible form (:: <1-2 groups>) must be matched after
|
||||||
|
// canonicalization or it smuggles loopback/metadata targets past the list.
|
||||||
|
const prohibitedIpv6 = hostname === '::' || hostname === '::1' || /^::[0-9a-f]{1,4}(:[0-9a-f]{1,4})?$/.test(hostname) || /^fe[89ab][0-9a-f]:/.test(hostname) || hostname.startsWith('ff') || hostname.startsWith('::ffff:') || hostname.startsWith('64:ff9b:')
|
||||||
const prohibitedMetadata = hostname === '100.100.100.200' || hostname === '168.63.129.16'
|
const prohibitedMetadata = hostname === '100.100.100.200' || hostname === '168.63.129.16'
|
||||||
const prohibitedTransition = hostname.startsWith('2002:')
|
const prohibitedTransition = hostname.startsWith('2002:')
|
||||||
const isIpLiteral = isIP(hostname) !== 0
|
const isIpLiteral = isIP(hostname) !== 0
|
||||||
|
|||||||
@@ -190,6 +190,7 @@ export function createSimAdminClient(instance, { fetchImpl = fetch, timeoutMs =
|
|||||||
}
|
}
|
||||||
|
|
||||||
let ephemeralSecret = ''
|
let ephemeralSecret = ''
|
||||||
|
let lastFailedAutoLoginAt = 0
|
||||||
async function ensureAuthenticated({ credential } = {}) {
|
async function ensureAuthenticated({ credential } = {}) {
|
||||||
const status = await fetchJson('/api/auth/status')
|
const status = await fetchJson('/api/auth/status')
|
||||||
const auth = apiData(status.data) || {}
|
const auth = apiData(status.data) || {}
|
||||||
@@ -203,12 +204,20 @@ export function createSimAdminClient(instance, { fetchImpl = fetch, timeoutMs =
|
|||||||
return { configured: auth.configured ?? null, authenticated: false, loginAttempted: false, statusCode: status.status, reason: 'password_required' }
|
return { configured: auth.configured ?? null, authenticated: false, loginAttempted: false, statusCode: status.status, reason: 'password_required' }
|
||||||
}
|
}
|
||||||
if (credential !== undefined) ephemeralSecret = ''
|
if (credential !== undefined) ephemeralSecret = ''
|
||||||
|
// Status polls auto-login with the saved password; without a cooldown a
|
||||||
|
// stale password turns every refresh into a fresh login attempt, which
|
||||||
|
// some devices punish with account lockouts.
|
||||||
|
if (credential === undefined && !ephemeralSecret && Date.now() - lastFailedAutoLoginAt < 60_000) {
|
||||||
|
return { configured: auth.configured ?? null, authenticated: false, loginAttempted: false, statusCode: status.status, reason: 'login_cooldown' }
|
||||||
|
}
|
||||||
const login = await request('/api/auth/login', {
|
const login = await request('/api/auth/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
||||||
body: JSON.stringify({ password }),
|
body: JSON.stringify({ password }),
|
||||||
})
|
})
|
||||||
if (credential !== undefined && login.ok) ephemeralSecret = password
|
if (credential !== undefined && login.ok) ephemeralSecret = password
|
||||||
|
if (login.ok) lastFailedAutoLoginAt = 0
|
||||||
|
else if (credential === undefined) lastFailedAutoLoginAt = Date.now()
|
||||||
return { configured: auth.configured ?? null, authenticated: login.ok, loginAttempted: true, statusCode: login.status, reason: login.ok ? null : 'login_failed' }
|
return { configured: auth.configured ?? null, authenticated: login.ok, loginAttempted: true, statusCode: login.status, reason: login.ok ? null : 'login_failed' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user