Initial Multi SimAdmin dashboard
This commit is contained in:
+293
@@ -0,0 +1,293 @@
|
||||
|
||||
import { readFile } 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,
|
||||
server: {
|
||||
host: raw.server?.host || process.env.HOST || '0.0.0.0',
|
||||
port: Number(process.env.PORT || raw.server?.port || 8788),
|
||||
},
|
||||
instances,
|
||||
}
|
||||
}
|
||||
|
||||
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()))
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
import Fastify from 'fastify'
|
||||
import fastifyStatic from '@fastify/static'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import {
|
||||
buildClients,
|
||||
collectInstanceStatus,
|
||||
loadConfig,
|
||||
proxyToInstance,
|
||||
redactInstance,
|
||||
} from './core.js'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
const ROOT = path.resolve(__dirname, '..')
|
||||
const PUBLIC_DIR = path.join(ROOT, 'public')
|
||||
const CONFIG_PATH = process.env.MULTI_SIMADMIN_CONFIG || path.join(ROOT, 'config.json')
|
||||
const DEFAULT_CONFIG_PATH = path.join(ROOT, 'config.example.json')
|
||||
|
||||
const config = await loadConfig({ configPath: CONFIG_PATH, defaultConfigPath: DEFAULT_CONFIG_PATH })
|
||||
const clients = buildClients(config.instances)
|
||||
const app = Fastify({ logger: true, bodyLimit: 60 * 1024 * 1024 })
|
||||
|
||||
await app.register(fastifyStatic, { root: PUBLIC_DIR, prefix: '/' })
|
||||
|
||||
function clientById(id, reply) {
|
||||
const client = clients.get(id)
|
||||
if (!client) reply.code(404).send({ error: 'instance not found' })
|
||||
return client
|
||||
}
|
||||
|
||||
app.get('/api/config', async () => ({
|
||||
configPath: config.configPath,
|
||||
instances: config.instances.map(redactInstance),
|
||||
features: [
|
||||
'passwordless-status',
|
||||
'password-session-login',
|
||||
'read-write-api-proxy',
|
||||
'device-sim-network-sms-data-ota-summary',
|
||||
'iframe-fallback',
|
||||
],
|
||||
}))
|
||||
|
||||
app.get('/api/instances', async () => ({ instances: config.instances.map(redactInstance) }))
|
||||
|
||||
app.get('/api/status', async () => ({
|
||||
instances: await Promise.all([...clients.values()].map(collectInstanceStatus)),
|
||||
}))
|
||||
|
||||
app.get('/api/status/:id', async (request, reply) => {
|
||||
const client = clientById(request.params.id, reply)
|
||||
if (!client) return
|
||||
return collectInstanceStatus(client)
|
||||
})
|
||||
|
||||
app.post('/api/instances/:id/login', async (request, reply) => {
|
||||
const client = clientById(request.params.id, reply)
|
||||
if (!client) return
|
||||
const password = request.body?.password || client.instance.auth?.password || ''
|
||||
if (password) client.instance.auth.password = String(password)
|
||||
const result = await client.ensureAuthenticated()
|
||||
return { ...result, hasSavedPassword: Boolean(client.instance.auth?.password) }
|
||||
})
|
||||
|
||||
app.post('/api/instances/:id/logout', async (request, reply) => {
|
||||
const client = clientById(request.params.id, reply)
|
||||
if (!client) return
|
||||
try { await client.fetchJson('/api/auth/logout', { method: 'POST' }) } catch {}
|
||||
client.jar.clear()
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
app.all('/api/proxy/:id/*', async (request, reply) => {
|
||||
const client = clientById(request.params.id, reply)
|
||||
if (!client) return
|
||||
return proxyToInstance({ client, request, reply, rest: request.params['*'] })
|
||||
})
|
||||
|
||||
app.get('/api/catalog', async () => ({
|
||||
groups: [
|
||||
{ id: 'overview', name: '总览', endpoints: ['/api/device', '/api/sim', '/api/network', '/api/stats', '/api/connectivity'] },
|
||||
{ id: 'sms', name: '短信', endpoints: ['/api/sms/stats', '/api/sms/list', '/api/sms/send', '/api/sms/conversation'] },
|
||||
{ id: 'network', name: '网络/小区', endpoints: ['/api/data', '/api/roaming', '/api/airplane-mode', '/api/radio-mode', '/api/band-lock', '/api/cell-lock', '/api/apn', '/api/cells'] },
|
||||
{ id: 'device-network', name: '设备网络', endpoints: ['/api/device-network/ddns/status', '/api/device-network/ddns/config', '/api/device-network/wlan/status', '/api/device-network/wlan/profiles'] },
|
||||
{ id: 'calls', name: '电话', endpoints: ['/api/calls', '/api/call/history', '/api/ims/status', '/api/voicemail/status'] },
|
||||
{ id: 'esim', name: 'eSIM', endpoints: ['/api/work-mode', '/api/esim/config', '/api/esim/lpac/status', '/api/esim/euicc', '/api/esim/profiles'] },
|
||||
{ id: 'notify-auto-ota', name: '通知/自动化/升级', endpoints: ['/api/notifications/config', '/api/notifications/logs', '/api/automation/config', '/api/automation/logs', '/api/ota/status'] },
|
||||
],
|
||||
}))
|
||||
|
||||
app.get('/api/reload-note', async () => ({ message: 'Edit config.json and restart this process to apply changes.' }))
|
||||
|
||||
app.setNotFoundHandler(async (request, reply) => {
|
||||
if (request.url.startsWith('/api/')) return reply.code(404).send({ error: 'not found' })
|
||||
return reply.sendFile('index.html')
|
||||
})
|
||||
|
||||
try {
|
||||
await app.listen({ host: config.server.host, port: config.server.port })
|
||||
app.log.info(`Multi SimAdmin listening on http://${config.server.host}:${config.server.port}`)
|
||||
app.log.info(`Loaded ${config.instances.length} instance(s) from ${config.configPath}`)
|
||||
} catch (err) {
|
||||
app.log.error(err)
|
||||
process.exit(1)
|
||||
}
|
||||
Reference in New Issue
Block a user