import { readFile, writeFile } from 'node:fs/promises' import { existsSync } from 'node:fs' import path from 'node:path' export const DEFAULT_TIMEOUT_MS = Number(process.env.MULTI_SIMADMIN_TIMEOUT_MS || 6000) const HOP_BY_HOP_HEADERS = new Set(['connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailer', 'transfer-encoding', 'upgrade', 'host', 'content-length']) export function normalizeBaseUrl(rawUrl) { const url = new URL(rawUrl) url.hash = '' url.search = '' return url.toString().replace(/\/$/, '') } export function normalizeInstance(instance, index = 0) { if (!instance || typeof instance !== 'object') throw new Error(`instances[${index}] must be an object`) const id = String(instance.id || '').trim() const name = String(instance.name || id || `SimAdmin ${index + 1}`).trim() const url = String(instance.url || '').trim() if (!id || !/^[a-zA-Z0-9_.-]+$/.test(id)) throw new Error(`instances[${index}].id is required and may only contain letters, numbers, _, -, .`) if (!url) throw new Error(`instances[${index}].url is required`) const password = instance.auth?.password ?? instance.password ?? '' return { id, name, url: normalizeBaseUrl(url), description: String(instance.description || '').trim(), tags: Array.isArray(instance.tags) ? instance.tags.map(String) : [], auth: { mode: password ? 'password' : String(instance.auth?.mode || 'none'), password: password ? String(password) : '', }, capabilities: Array.isArray(instance.capabilities) ? instance.capabilities.map(String) : [], } } export async function loadConfig({ configPath, defaultConfigPath }) { const resolvedPath = existsSync(configPath) ? configPath : defaultConfigPath const text = await readFile(resolvedPath, 'utf8') const raw = JSON.parse(text) const instances = Array.isArray(raw.instances) ? raw.instances.map(normalizeInstance) : [] const ids = new Set() for (const item of instances) { if (ids.has(item.id)) throw new Error(`duplicate instance id: ${item.id}`) ids.add(item.id) } return { configPath: resolvedPath, raw: { ...raw, instances }, server: { host: raw.server?.host || process.env.HOST || '0.0.0.0', port: Number(process.env.PORT || raw.server?.port || 8788), }, instances, } } function serializableInstance(instance) { return { id: instance.id, name: instance.name, url: instance.url, description: instance.description || '', tags: instance.tags || [], auth: instance.auth?.password ? { mode: 'password', password: instance.auth.password } : { mode: 'none' }, capabilities: instance.capabilities || [], } } async function persistConfig(config) { const raw = { ...(config.raw || {}), server: config.raw?.server || config.server, instances: config.instances.map(serializableInstance), } await writeFile(config.configPath, `${JSON.stringify(raw, null, 2)}\n`) config.raw = raw } export async function addInstanceToConfig(config, payload) { const next = normalizeInstance(payload, config.instances.length) if (config.instances.some(item => item.id === next.id)) throw new Error(`duplicate instance id: ${next.id}`) config.instances.push(next) await persistConfig(config) return next } export async function updateInstanceInConfig(config, id, payload) { const index = config.instances.findIndex(item => item.id === id) if (index < 0) throw new Error(`instance not found: ${id}`) const current = config.instances[index] const merged = { ...current, ...payload, id: payload.id ? String(payload.id).trim() : current.id, auth: payload.auth !== undefined || payload.password !== undefined ? payload.auth || { password: payload.password || '' } : current.auth, } const next = normalizeInstance(merged, index) if (next.id !== id && config.instances.some(item => item.id === next.id)) throw new Error(`duplicate instance id: ${next.id}`) config.instances[index] = next await persistConfig(config) return next } export async function deleteInstanceFromConfig(config, id) { const index = config.instances.findIndex(item => item.id === id) if (index < 0) throw new Error(`instance not found: ${id}`) const [removed] = config.instances.splice(index, 1) await persistConfig(config) return removed } 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 || 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 || null, operator: data.operator || data.operator_name || null, signal: data.signal || data.signal_strength || 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.status || null, accessTechnology: data.access_technology || data.accessTechnology || data.rat || data.mode || null, signal: data.signal || data.signal_strength || data.rssi || 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, } } 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, } } 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, } } 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), 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 } = {}) { 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) if (headers.has('cookie')) headers.cookie = headers.get('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 response.text() 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 } } async function ensureAuthenticated() { 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 } } if (!instance.auth?.password) return { configured: auth.configured ?? null, authenticated: false, loginAttempted: false, statusCode: status.status, reason: 'password_required' } const login = await request('/api/auth/login', { method: 'POST', headers: { 'content-type': 'application/json', accept: 'application/json' }, body: JSON.stringify({ password: instance.auth.password }), }) return { configured: auth.configured ?? null, authenticated: login.ok, loginAttempted: true, statusCode: login.status, reason: login.ok ? null : 'login_failed' } } return { instance, jar, request, fetchJson, ensureAuthenticated } } export function buildClients(instances, options = {}) { return new Map(instances.map(instance => [instance.id, createSimAdminClient(instance, options)])) } export async function collectInstanceStatus(client) { const result = { ...redactInstance(client.instance), reachable: false, authenticated: null, latencyMs: null, statusCode: null, error: null, authStatus: null, summary: summarizeInstanceSnapshot({}), raw: {}, checkedAt: new Date().toISOString(), } try { const health = await client.fetchJson('/api/health') result.reachable = health.ok result.latencyMs = health.latencyMs result.statusCode = health.status result.raw.health = health.data || health.text || null const auth = await client.ensureAuthenticated() result.authenticated = auth.authenticated result.authStatus = auth const endpoints = { device: '/api/device', sim: '/api/sim', network: '/api/network', smsStats: '/api/sms/stats', data: '/api/data', ota: '/api/ota/status', stats: '/api/stats', calls: '/api/calls' } await Promise.all(Object.entries(endpoints).map(async ([key, endpoint]) => { try { const response = await client.fetchJson(endpoint) if (response.status === 401) result.authenticated = false if (response.ok) result.raw[key] = response.data } catch {} })) result.summary = summarizeInstanceSnapshot(result.raw) } catch (error) { result.error = error?.name === 'AbortError' ? 'timeout' : String(error?.message || error) } return result } export function safeProxyPath(rest = '') { const clean = String(rest || '').replace(/^\/+/, '') if (!clean || clean === '..' || clean.includes('..')) throw new Error('invalid proxy path') return `/${clean}` } export async function proxyToInstance({ client, request, reply, rest }) { const targetPath = safeProxyPath(rest) const queryIndex = request.url.indexOf('?') const query = queryIndex >= 0 ? request.url.slice(queryIndex) : '' const headers = new Headers() for (const [name, value] of Object.entries(request.headers || {})) { if (!HOP_BY_HOP_HEADERS.has(name.toLowerCase()) && value !== undefined) headers.set(name, Array.isArray(value) ? value.join(', ') : String(value)) } const body = ['GET', 'HEAD'].includes(request.method) ? undefined : request.body const upstream = await client.request(`${targetPath}${query}`, { method: request.method, headers, body, redirect: 'manual' }) reply.code(upstream.status) upstream.headers.forEach((value, name) => { if (!HOP_BY_HOP_HEADERS.has(name.toLowerCase()) && name.toLowerCase() !== 'set-cookie') reply.header(name, value) }) return reply.send(Buffer.from(await upstream.arrayBuffer())) }