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:
chick
2026-09-07 02:20:41 +08:00
parent 29a0eee854
commit c1be714ba4
14 changed files with 268 additions and 3859 deletions
@@ -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) {