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:
@@ -49,6 +49,49 @@ afterEach(async () => {
|
||||
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', () => {
|
||||
it('starts migration-safe as disabled and never returns password material', async () => {
|
||||
const { app } = fixture();
|
||||
|
||||
@@ -370,7 +370,10 @@ export function registerInstanceRoutes(app: FastifyInstance, options: InstanceRo
|
||||
app.get(
|
||||
'/api/v1/instances/:instanceId/resources',
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user