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
+4 -2
View File
@@ -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)),
+4
View File
@@ -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) {
+12
View File
@@ -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'
-3843
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -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
+9
View File
@@ -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' }
} }