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) }