Files
multi-simadmin/server/index.js
T
2026-07-07 23:04:32 +08:00

152 lines
5.9 KiB
JavaScript

import Fastify from 'fastify'
import fastifyStatic from '@fastify/static'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import {
addInstanceToConfig,
buildClients,
collectInstanceStatus,
createSimAdminClient,
deleteInstanceFromConfig,
loadConfig,
proxyToInstance,
redactInstance,
updateInstanceInConfig,
} 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
}
function sendConfigError(reply, error) {
const message = String(error?.message || error)
const status = /not found/.test(message) ? 404 : /duplicate|required|invalid|URL/.test(message) ? 400 : 500
return reply.code(status).send({ error: message })
}
function refreshClientFor(instance) {
clients.set(instance.id, createSimAdminClient(instance))
}
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.post('/api/instances', async (request, reply) => {
try {
const instance = await addInstanceToConfig(config, request.body || {})
refreshClientFor(instance)
return { instance: redactInstance(instance), configPath: config.configPath }
} catch (error) {
return sendConfigError(reply, error)
}
})
app.put('/api/instances/:id', async (request, reply) => {
try {
const oldId = request.params.id
const instance = await updateInstanceInConfig(config, oldId, request.body || {})
if (instance.id !== oldId) clients.delete(oldId)
refreshClientFor(instance)
return { instance: redactInstance(instance), configPath: config.configPath }
} catch (error) {
return sendConfigError(reply, error)
}
})
app.delete('/api/instances/:id', async (request, reply) => {
try {
const removed = await deleteInstanceFromConfig(config, request.params.id)
clients.delete(removed.id)
return { removed: redactInstance(removed), configPath: config.configPath }
} catch (error) {
return sendConfigError(reply, error)
}
})
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)
}