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)
262 lines
10 KiB
JavaScript
262 lines
10 KiB
JavaScript
|
|
export { normalizeBaseUrl, normalizeInstance } from './config/schema.js'
|
|
|
|
function parsePositiveIntEnv(value, fallback) {
|
|
const parsed = Number(value)
|
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback
|
|
}
|
|
|
|
export const DEFAULT_TIMEOUT_MS = parsePositiveIntEnv(process.env.MULTI_SIMADMIN_TIMEOUT_MS, 6000)
|
|
export const DEFAULT_MAX_BODY_BYTES = parsePositiveIntEnv(process.env.MULTI_SIMADMIN_MAX_BODY_BYTES, 10 * 1024 * 1024)
|
|
export function redactInstance(instance) {
|
|
return {
|
|
id: instance.id,
|
|
name: instance.name,
|
|
url: instance.url,
|
|
description: instance.description,
|
|
tags: instance.tags || [],
|
|
auth: {
|
|
mode: instance.auth?.mode || 'none',
|
|
hasPassword: Boolean(instance.auth?.password),
|
|
},
|
|
capabilities: instance.capabilities || [],
|
|
}
|
|
}
|
|
|
|
function apiData(payload) {
|
|
return payload?.data ?? payload
|
|
}
|
|
|
|
function pickDevice(data) {
|
|
data = apiData(data)
|
|
if (!data || typeof data !== 'object') return null
|
|
return {
|
|
imei: data.imei || data.IMEI || null,
|
|
manufacturer: data.manufacturer || data.vendor || null,
|
|
model: data.model || data.device_model || null,
|
|
firmware: data.firmware || data.firmware_version || data.version || data.revision || null,
|
|
revision: data.revision || null,
|
|
powered: data.powered ?? data.power ?? null,
|
|
online: data.online ?? data.is_online ?? null,
|
|
}
|
|
}
|
|
|
|
function pickSim(data) {
|
|
data = apiData(data)
|
|
if (!data || typeof data !== 'object') return null
|
|
return {
|
|
iccid: data.iccid || data.ICCID || null,
|
|
imsi: data.imsi || data.IMSI || null,
|
|
phoneNumber: data.phone_number || data.phoneNumber || data.msisdn || (Array.isArray(data.phone_numbers) ? data.phone_numbers[0] : null) || null,
|
|
phoneNumbers: data.phone_numbers || data.phoneNumbers || null,
|
|
operator: data.operator || data.operator_name || null,
|
|
signal: data.signal || data.signal_strength || null,
|
|
present: data.present ?? data.inserted ?? null,
|
|
smsCenter: data.sms_center || data.smsCenter || null,
|
|
}
|
|
}
|
|
|
|
function pickNetwork(data) {
|
|
data = apiData(data)
|
|
if (!data || typeof data !== 'object') return null
|
|
return {
|
|
operator: data.operator || data.operator_name || data.provider || null,
|
|
registration: data.registration || data.registration_state || data.registration_status || data.status || null,
|
|
accessTechnology: data.access_technology || data.accessTechnology || data.rat || data.mode || data.technology_preference || null,
|
|
signal: data.signal || data.signal_strength || data.rssi || null,
|
|
mcc: data.mcc || null,
|
|
mnc: data.mnc || null,
|
|
}
|
|
}
|
|
|
|
function pickSms(data) {
|
|
data = apiData(data)
|
|
if (!data || typeof data !== 'object') return null
|
|
return {
|
|
total: data.total ?? data.total_count ?? data.count ?? null,
|
|
unread: data.unread ?? data.unread_count ?? null,
|
|
conversations: data.conversations ?? data.conversation_count ?? null,
|
|
incoming: data.incoming ?? null,
|
|
outgoing: data.outgoing ?? null,
|
|
pushed: data.pushed ?? null,
|
|
pushAttempted: data.push_attempted ?? data.pushAttempted ?? null,
|
|
}
|
|
}
|
|
|
|
function pickDataStatus(data) {
|
|
data = apiData(data)
|
|
if (!data || typeof data !== 'object') return null
|
|
return {
|
|
enabled: data.enabled ?? data.data_enabled ?? null,
|
|
connected: data.connected ?? data.is_connected ?? null,
|
|
roaming: data.roaming ?? data.roaming_allowed ?? null,
|
|
active: data.active ?? null,
|
|
}
|
|
}
|
|
|
|
function pickOta(data) {
|
|
data = apiData(data)
|
|
if (!data || typeof data !== 'object') return null
|
|
return {
|
|
currentVersion: data.current_version || data.currentVersion || data.version || null,
|
|
latestVersion: data.latest_version || data.latestVersion || null,
|
|
updateAvailable: data.update_available ?? data.updateAvailable ?? null,
|
|
pendingUpdate: data.pending_update ?? data.pendingUpdate ?? null,
|
|
currentCommit: data.current_commit || data.currentCommit || null,
|
|
}
|
|
}
|
|
|
|
function pickSystem(data) {
|
|
data = apiData(data)
|
|
if (!data || typeof data !== 'object') return null
|
|
return {
|
|
cpuLoad: data.cpu_load ?? data.cpuLoad ?? null,
|
|
memory: data.memory || null,
|
|
disk: data.disk || null,
|
|
networkSpeed: data.network_speed || data.networkSpeed || null,
|
|
info: data.system_info || data.systemInfo || null,
|
|
temperature: data.temperature || null,
|
|
uptime: data.uptime ?? null,
|
|
}
|
|
}
|
|
|
|
export function summarizeInstanceSnapshot(raw) {
|
|
return {
|
|
device: pickDevice(raw.device),
|
|
sim: pickSim(raw.sim),
|
|
network: pickNetwork(raw.network),
|
|
sms: pickSms(raw.smsStats),
|
|
data: pickDataStatus(raw.data),
|
|
ota: pickOta(raw.ota),
|
|
system: pickSystem(raw.stats),
|
|
stats: apiData(raw.stats) || null,
|
|
calls: apiData(raw.calls) || null,
|
|
}
|
|
}
|
|
|
|
function parseSetCookie(value) {
|
|
if (!value) return []
|
|
if (Array.isArray(value)) return value
|
|
return [String(value)]
|
|
}
|
|
|
|
class CookieJar {
|
|
constructor() { this.map = new Map() }
|
|
setFromHeaders(headers) {
|
|
const raw = headers.getSetCookie ? headers.getSetCookie() : parseSetCookie(headers.get('set-cookie'))
|
|
for (const line of raw) {
|
|
const [pair] = String(line).split(';')
|
|
const eq = pair.indexOf('=')
|
|
if (eq <= 0) continue
|
|
const name = pair.slice(0, eq).trim()
|
|
const value = pair.slice(eq + 1).trim()
|
|
if (value) this.map.set(name, value)
|
|
else this.map.delete(name)
|
|
}
|
|
}
|
|
header() {
|
|
return [...this.map.entries()].map(([k, v]) => `${k}=${v}`).join('; ')
|
|
}
|
|
clear() { this.map.clear() }
|
|
}
|
|
|
|
export function createSimAdminClient(instance, { fetchImpl = fetch, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
instance = structuredClone(instance)
|
|
const jar = new CookieJar()
|
|
|
|
async function request(endpoint, options = {}) {
|
|
const url = new URL(endpoint, instance.url)
|
|
const controller = new AbortController()
|
|
const timer = setTimeout(() => controller.abort(), options.timeoutMs || timeoutMs)
|
|
const headers = new Headers(options.headers || {})
|
|
const cookie = jar.header()
|
|
if (cookie && !headers.has('cookie')) headers.set('cookie', cookie)
|
|
try {
|
|
const response = await fetchImpl(url, { ...options, headers, signal: controller.signal, redirect: options.redirect || 'manual' })
|
|
jar.setFromHeaders(response.headers)
|
|
return response
|
|
} finally {
|
|
clearTimeout(timer)
|
|
}
|
|
}
|
|
|
|
async function fetchJson(endpoint, options = {}) {
|
|
const startedAt = Date.now()
|
|
const response = await request(endpoint, { method: 'GET', ...options, headers: { accept: 'application/json,text/plain,*/*', ...(options.headers || {}) } })
|
|
const text = (await readResponseBody(response, { bodyTimeoutMs: options.timeoutMs || timeoutMs })).toString('utf8')
|
|
let data = null
|
|
try { data = text ? JSON.parse(text) : null } catch {}
|
|
return { ok: response.ok, status: response.status, latencyMs: Date.now() - startedAt, data, text: data ? undefined : text.slice(0, 1000), headers: response.headers }
|
|
}
|
|
|
|
let ephemeralSecret = ''
|
|
let lastFailedAutoLoginAt = 0
|
|
async function ensureAuthenticated({ credential } = {}) {
|
|
const status = await fetchJson('/api/auth/status')
|
|
const auth = apiData(status.data) || {}
|
|
const protectionEnabled = auth.settings?.password_protection_enabled ?? auth.settings?.passwordProtectionEnabled
|
|
if (auth.authenticated || protectionEnabled === false || auth.configured === false) {
|
|
return { configured: auth.configured ?? null, authenticated: Boolean(auth.authenticated || protectionEnabled === false), loginAttempted: false, statusCode: status.status }
|
|
}
|
|
const password = credential === undefined ? (ephemeralSecret || instance.auth?.password) : String(credential || '')
|
|
if (!password) {
|
|
if (status.status === 404 && instance.auth?.mode === 'none') return { configured: null, authenticated: null, loginAttempted: false, statusCode: status.status, reason: 'auth_status_unsupported' }
|
|
return { configured: auth.configured ?? null, authenticated: false, loginAttempted: false, statusCode: status.status, reason: 'password_required' }
|
|
}
|
|
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', {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
body: JSON.stringify({ 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' }
|
|
}
|
|
|
|
function clearEphemeralSecret() { ephemeralSecret = '' }
|
|
|
|
return { instance, jar, request, fetchJson, ensureAuthenticated, clearEphemeralSecret }
|
|
}
|
|
|
|
// The header timeout ends once headers arrive; body reads need their own
|
|
// deadline and a byte budget so a stalled or hostile upstream can neither hang
|
|
// status aggregation nor exhaust memory.
|
|
export async function readResponseBody(response, { maxBytes = DEFAULT_MAX_BODY_BYTES, bodyTimeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
const reader = response.body?.getReader()
|
|
if (!reader) return Buffer.alloc(0)
|
|
const chunks = []
|
|
let size = 0
|
|
let failure = null
|
|
const timer = setTimeout(() => {
|
|
failure = new Error('upstream response body timed out')
|
|
reader.cancel(failure).catch(() => {})
|
|
}, bodyTimeoutMs)
|
|
try {
|
|
while (true) {
|
|
const { done, value } = await reader.read()
|
|
if (done) break
|
|
size += value.byteLength
|
|
if (size > maxBytes) {
|
|
failure = new Error('upstream response body exceeded the size limit')
|
|
throw failure
|
|
}
|
|
chunks.push(Buffer.from(value))
|
|
}
|
|
} finally {
|
|
clearTimeout(timer)
|
|
}
|
|
if (failure) {
|
|
await reader.cancel(failure).catch(() => {})
|
|
throw failure
|
|
}
|
|
return Buffer.concat(chunks)
|
|
}
|