[verified] refactor: establish service and frontend boundaries
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
import Fastify from 'fastify'
|
||||
import fastifyStatic from '@fastify/static'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { redactInstance } from './core.js'
|
||||
import { registerStatusRoutes } from './status/routes.js'
|
||||
import { registerProxyRoutes } from './proxy/routes.js'
|
||||
import { ConfigNotFoundError, ConfigValidationError } from './config/errors.js'
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const FEATURES = ['passwordless-status', 'password-session-login', 'read-write-api-proxy', 'device-sim-network-sms-data-ota-summary', 'iframe-fallback']
|
||||
const CATALOG = [
|
||||
{ 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'] },
|
||||
]
|
||||
|
||||
export async function buildApp({ configStore, clientRegistry, logger = false, staticFiles = true, publicDir = path.join(ROOT, 'public'), reconcileRetryMs = 100 } = {}) {
|
||||
if (!configStore || !clientRegistry) throw new TypeError('configStore and clientRegistry are required')
|
||||
const app = Fastify({ logger, bodyLimit: 60 * 1024 * 1024 })
|
||||
app.addContentTypeParser('application/octet-stream', { parseAs: 'buffer' }, (_request, body, done) => done(null, body))
|
||||
app.addContentTypeParser(/^multipart\//, { parseAs: 'buffer' }, (_request, body, done) => done(null, body))
|
||||
if (staticFiles) await app.register(fastifyStatic, { root: publicDir, prefix: '/' })
|
||||
const findClient = (id, reply) => {
|
||||
const client = clientRegistry.get(id)
|
||||
if (!client) reply.code(404).send({ error: 'instance not found' })
|
||||
return client
|
||||
}
|
||||
const configError = (reply, error) => reply.code(error instanceof ConfigNotFoundError ? 404 : error instanceof ConfigValidationError ? 400 : 500).send({ error: String(error.message || error) })
|
||||
let reconcileError = null
|
||||
let retryTimer = null
|
||||
const reconcile = () => {
|
||||
try { clientRegistry.reconcile(configStore.snapshot.instances); reconcileError = null; return true }
|
||||
catch (error) {
|
||||
reconcileError = error
|
||||
if (!retryTimer) retryTimer = setTimeout(() => { retryTimer = null; reconcile() }, reconcileRetryMs)
|
||||
return false
|
||||
}
|
||||
}
|
||||
const committed = async action => {
|
||||
const result = await action()
|
||||
return { result, reconciled: reconcile() }
|
||||
}
|
||||
|
||||
app.get('/api/health', async () => ({ ok: true }))
|
||||
app.get('/api/ready', async () => reconcileError ? ({ ready: false, degraded: true, reason: 'registry_reconcile_pending' }) : ({ ready: true }))
|
||||
app.get('/api/config', async () => ({ configPath: configStore.configPath, instances: configStore.snapshot.instances.map(redactInstance), features: FEATURES }))
|
||||
app.get('/api/instances', async () => ({ instances: configStore.snapshot.instances.map(redactInstance) }))
|
||||
app.post('/api/instances', async (request, reply) => {
|
||||
try { const { result, reconciled } = await committed(() => configStore.add(request.body || {})); return { instance: redactInstance(result), configPath: configStore.configPath, ...(reconciled ? {} : { reconcilePending: true }) } }
|
||||
catch (error) { return configError(reply, error) }
|
||||
})
|
||||
app.put('/api/instances/:id', async (request, reply) => {
|
||||
try { const { result, reconciled } = await committed(() => configStore.update(request.params.id, request.body || {})); return { instance: redactInstance(result), configPath: configStore.configPath, ...(reconciled ? {} : { reconcilePending: true }) } }
|
||||
catch (error) { return configError(reply, error) }
|
||||
})
|
||||
app.delete('/api/instances/:id', async (request, reply) => {
|
||||
try { const { result, reconciled } = await committed(() => configStore.delete(request.params.id)); return { removed: redactInstance(result), configPath: configStore.configPath, ...(reconciled ? {} : { reconcilePending: true }) } }
|
||||
catch (error) { return configError(reply, error) }
|
||||
})
|
||||
registerStatusRoutes(app, { registry: clientRegistry, findClient })
|
||||
app.post('/api/instances/:id/login', async (request, reply) => {
|
||||
const client = findClient(request.params.id, reply); if (!client) return
|
||||
const supplied = Object.hasOwn(request.body || {}, 'password')
|
||||
return { ...(await client.ensureAuthenticated(supplied ? { credential: request.body.password } : {})), hasSavedPassword: Boolean(configStore.snapshot.instances.find(item => item.id === request.params.id)?.auth?.password) }
|
||||
})
|
||||
app.post('/api/instances/:id/logout', async (request, reply) => {
|
||||
const client = findClient(request.params.id, reply); if (!client) return
|
||||
try { await client.fetchJson('/api/auth/logout', { method: 'POST' }) } catch {}
|
||||
client.jar.clear(); client.clearEphemeralSecret?.(); return { ok: true }
|
||||
})
|
||||
registerProxyRoutes(app, { findClient })
|
||||
app.get('/api/catalog', async () => ({ groups: CATALOG }))
|
||||
app.get('/api/reload-note', async () => ({ message: 'Configuration changes are applied immediately.' }))
|
||||
app.setNotFoundHandler(async (request, reply) => request.url.startsWith('/api/') ? reply.code(404).send({ error: 'not found' }) : staticFiles ? reply.sendFile('index.html') : reply.code(404).send({ error: 'not found' }))
|
||||
app.addHook('onClose', async () => { if (retryTimer) clearTimeout(retryTimer); clientRegistry.close() })
|
||||
return app
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { createSimAdminClient } from '../core.js'
|
||||
|
||||
function sameInstance(a, b) {
|
||||
return a && b && JSON.stringify(a) === JSON.stringify(b)
|
||||
}
|
||||
|
||||
export class ClientRegistry {
|
||||
constructor({ createClient = createSimAdminClient } = {}) {
|
||||
this.createClient = createClient
|
||||
this.clients = new Map()
|
||||
}
|
||||
reconcile(instances) {
|
||||
const previous = this.clients
|
||||
const nextClients = new Map()
|
||||
const staged = []
|
||||
try {
|
||||
for (const instance of instances) {
|
||||
const current = previous.get(instance.id)
|
||||
const next = current && sameInstance(current.instance, instance) ? current : this.createClient(instance)
|
||||
nextClients.set(instance.id, next)
|
||||
if (next !== current && ![...previous.values()].includes(next)) staged.push(next)
|
||||
}
|
||||
} catch (error) {
|
||||
for (const client of staged) { client.close?.(); client.jar?.clear?.() }
|
||||
throw error
|
||||
}
|
||||
this.clients = nextClients
|
||||
for (const [id, client] of previous) {
|
||||
if (nextClients.get(id) !== client) { client.close?.(); client.jar?.clear?.() }
|
||||
}
|
||||
return this
|
||||
}
|
||||
get(id) { return this.clients.get(id) }
|
||||
values() { return this.clients.values() }
|
||||
delete(id) {
|
||||
const client = this.clients.get(id)
|
||||
if (!client) return false
|
||||
client.close?.()
|
||||
client.jar?.clear?.()
|
||||
return this.clients.delete(id)
|
||||
}
|
||||
close() {
|
||||
for (const id of [...this.clients.keys()]) this.delete(id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export class ConfigValidationError extends Error {
|
||||
constructor(message) { super(message); this.name = 'ConfigValidationError' }
|
||||
}
|
||||
export class ConfigNotFoundError extends Error {
|
||||
constructor(message) { super(message); this.name = 'ConfigNotFoundError' }
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { readFile, open, rename, unlink } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { normalizeConfig, normalizeInstance, serializeConfig, immutableSnapshot } from './schema.js'
|
||||
import { ConfigNotFoundError, ConfigValidationError } from './errors.js'
|
||||
|
||||
export async function atomicWriteJson(destination, value) {
|
||||
const temp = path.join(path.dirname(destination), `.${path.basename(destination)}.${process.pid}.${crypto.randomUUID()}.tmp`)
|
||||
try {
|
||||
const handle = await open(temp, 'wx', 0o600)
|
||||
try {
|
||||
await handle.writeFile(`${JSON.stringify(value, null, 2)}\n`)
|
||||
await handle.sync()
|
||||
} finally { await handle.close() }
|
||||
await rename(temp, destination)
|
||||
try {
|
||||
const directory = await open(path.dirname(destination), 'r')
|
||||
try { await directory.sync() } finally { await directory.close() }
|
||||
} catch (error) {
|
||||
if (!['EINVAL', 'ENOTSUP', 'EISDIR', 'EPERM', 'EACCES'].includes(error.code)) throw error
|
||||
}
|
||||
} catch (error) {
|
||||
await unlink(temp).catch(() => {})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export class FileConfigStore {
|
||||
#snapshot
|
||||
#queue = Promise.resolve()
|
||||
constructor({ configPath, examplePath, snapshot, atomicWrite = atomicWriteJson, env = process.env }) {
|
||||
this.configPath = configPath
|
||||
this.examplePath = examplePath
|
||||
this.atomicWrite = atomicWrite
|
||||
this.env = { ...env }
|
||||
this.#snapshot = immutableSnapshot({ ...snapshot, configPath })
|
||||
}
|
||||
static async open({ configPath, examplePath, atomicWrite, env } = {}) {
|
||||
let text
|
||||
try { text = await readFile(configPath, 'utf8') }
|
||||
catch (error) {
|
||||
if (error.code !== 'ENOENT') throw error
|
||||
if (!examplePath) throw new ConfigNotFoundError(`config not found: ${configPath}`)
|
||||
text = await readFile(examplePath, 'utf8')
|
||||
}
|
||||
const effectiveEnv = env || process.env
|
||||
return new FileConfigStore({ configPath, examplePath, atomicWrite, env: effectiveEnv, snapshot: normalizeConfig(JSON.parse(text), effectiveEnv) })
|
||||
}
|
||||
get snapshot() { return this.#snapshot }
|
||||
transact(mutator) {
|
||||
const operation = this.#queue.then(async () => {
|
||||
const draft = structuredClone(this.#snapshot)
|
||||
const result = await mutator(draft)
|
||||
const normalized = normalizeConfig(serializeConfig(draft), this.env)
|
||||
await this.atomicWrite(this.configPath, serializeConfig(normalized))
|
||||
this.#snapshot = immutableSnapshot({ ...normalized, configPath: this.configPath })
|
||||
return result?.id ? this.#snapshot.instances.find(item => item.id === result.id) || result : result
|
||||
})
|
||||
this.#queue = operation.catch(() => {})
|
||||
return operation
|
||||
}
|
||||
add(payload) {
|
||||
return this.transact(draft => {
|
||||
const next = normalizeInstance(payload, draft.instances.length)
|
||||
if (draft.instances.some(item => item.id === next.id)) throw new ConfigValidationError(`duplicate instance id: ${next.id}`)
|
||||
draft.instances.push(next)
|
||||
return next
|
||||
})
|
||||
}
|
||||
update(id, payload) {
|
||||
return this.transact(draft => {
|
||||
const index = draft.instances.findIndex(item => item.id === id)
|
||||
if (index < 0) throw new ConfigNotFoundError(`instance not found: ${id}`)
|
||||
const current = draft.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 && draft.instances.some(item => item.id === next.id)) throw new ConfigValidationError(`duplicate instance id: ${next.id}`)
|
||||
draft.instances[index] = next
|
||||
return next
|
||||
})
|
||||
}
|
||||
delete(id) {
|
||||
return this.transact(draft => {
|
||||
const index = draft.instances.findIndex(item => item.id === id)
|
||||
if (index < 0) throw new ConfigNotFoundError(`instance not found: ${id}`)
|
||||
return draft.instances.splice(index, 1)[0]
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { ConfigValidationError } from './errors.js'
|
||||
|
||||
export function normalizeBaseUrl(rawUrl) {
|
||||
let url
|
||||
try { url = new URL(rawUrl) } catch { throw new ConfigValidationError('instance URL is invalid') }
|
||||
if (!['http:', 'https:'].includes(url.protocol)) throw new ConfigValidationError('instance URL must use http or https')
|
||||
if (url.username || url.password) throw new ConfigValidationError('instance URL must not contain credentials')
|
||||
url.hash = ''
|
||||
url.search = ''
|
||||
return url.toString().replace(/\/$/, '')
|
||||
}
|
||||
|
||||
export function normalizeInstance(instance, index = 0) {
|
||||
if (!instance || typeof instance !== 'object') throw new ConfigValidationError(`instances[${index}] must be an object`)
|
||||
const id = String(instance.id || '').trim()
|
||||
const name = String(instance.name || id || `SimAdmin ${index + 1}`).trim()
|
||||
const rawUrl = String(instance.url || '').trim()
|
||||
if (!id || !/^[a-zA-Z0-9_.-]+$/.test(id)) throw new ConfigValidationError(`instances[${index}].id is required and may only contain letters, numbers, _, -, .`)
|
||||
if (!rawUrl) throw new ConfigValidationError(`instances[${index}].url is required`)
|
||||
const password = instance.auth?.password ?? instance.password ?? ''
|
||||
const mode = password ? 'password' : String(instance.auth?.mode || 'none')
|
||||
if (!['none', 'password'].includes(mode)) throw new ConfigValidationError(`instances[${index}].auth.mode must be none or password`)
|
||||
return {
|
||||
id, name, url: normalizeBaseUrl(rawUrl),
|
||||
description: String(instance.description || '').trim(),
|
||||
tags: Array.isArray(instance.tags) ? instance.tags.map(String) : [],
|
||||
auth: { mode, password: password ? String(password) : '' },
|
||||
capabilities: Array.isArray(instance.capabilities) ? instance.capabilities.map(String) : [],
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeConfig(raw = {}, env = process.env) {
|
||||
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 ConfigValidationError(`duplicate instance id: ${item.id}`)
|
||||
ids.add(item.id)
|
||||
}
|
||||
const port = Number(env.PORT ?? raw.server?.port ?? 8788)
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new ConfigValidationError('server.port must be an integer from 1 to 65535')
|
||||
return {
|
||||
server: { host: raw.server?.host ?? env.HOST ?? '127.0.0.1', port },
|
||||
instances,
|
||||
raw: { ...raw, instances },
|
||||
}
|
||||
}
|
||||
|
||||
export function serializeConfig(snapshot) {
|
||||
return {
|
||||
...(snapshot.raw || {}),
|
||||
server: snapshot.raw?.server || snapshot.server,
|
||||
instances: snapshot.instances.map(instance => ({
|
||||
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 || [],
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export function immutableSnapshot(value) {
|
||||
const clone = structuredClone(value)
|
||||
const freeze = object => {
|
||||
if (object && typeof object === 'object' && !Object.isFrozen(object)) {
|
||||
Object.freeze(object)
|
||||
for (const item of Object.values(object)) freeze(item)
|
||||
}
|
||||
return object
|
||||
}
|
||||
return freeze(clone)
|
||||
}
|
||||
+16
-177
@@ -1,116 +1,7 @@
|
||||
|
||||
import { readFile, writeFile } from 'node:fs/promises'
|
||||
import { existsSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
export { normalizeBaseUrl, normalizeInstance } from './config/schema.js'
|
||||
|
||||
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,
|
||||
@@ -164,7 +55,7 @@ function pickNetwork(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,
|
||||
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,
|
||||
@@ -264,6 +155,7 @@ class CookieJar {
|
||||
}
|
||||
|
||||
export function createSimAdminClient(instance, { fetchImpl = fetch, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
||||
instance = structuredClone(instance)
|
||||
const jar = new CookieJar()
|
||||
|
||||
async function request(endpoint, options = {}) {
|
||||
@@ -292,87 +184,34 @@ export function createSimAdminClient(instance, { fetchImpl = fetch, timeoutMs =
|
||||
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() {
|
||||
let ephemeralSecret = ''
|
||||
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 }
|
||||
}
|
||||
if (!instance.auth?.password) return { configured: auth.configured ?? null, authenticated: false, loginAttempted: false, statusCode: status.status, reason: 'password_required' }
|
||||
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 = ''
|
||||
const login = await request('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
||||
body: JSON.stringify({ password: instance.auth.password }),
|
||||
body: JSON.stringify({ password }),
|
||||
})
|
||||
if (credential !== undefined && login.ok) ephemeralSecret = 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 }
|
||||
function clearEphemeralSecret() { ephemeralSecret = '' }
|
||||
|
||||
return { instance, jar, request, fetchJson, ensureAuthenticated, clearEphemeralSecret }
|
||||
}
|
||||
|
||||
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()))
|
||||
}
|
||||
|
||||
+22
-143
@@ -1,151 +1,30 @@
|
||||
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'
|
||||
import { buildApp } from './app.js'
|
||||
import { FileConfigStore } from './config/file-config-store.js'
|
||||
import { ClientRegistry } from './clients/client-registry.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 root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const configPath = process.env.MULTI_SIMADMIN_CONFIG || path.join(root, 'config.json')
|
||||
const examplePath = path.join(root, 'config.example.json')
|
||||
const configStore = await FileConfigStore.open({ configPath, examplePath })
|
||||
const clientRegistry = new ClientRegistry().reconcile(configStore.snapshot.instances)
|
||||
const app = await buildApp({ configStore, clientRegistry, logger: true })
|
||||
|
||||
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
|
||||
let closing = false
|
||||
async function shutdown(signal) {
|
||||
if (closing) return
|
||||
closing = true
|
||||
app.log.info({ signal }, 'shutting down')
|
||||
await app.close()
|
||||
}
|
||||
|
||||
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')
|
||||
})
|
||||
for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, () => shutdown(signal).catch(error => { app.log.error(error); process.exitCode = 1 }))
|
||||
|
||||
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)
|
||||
await app.listen({ ...configStore.snapshot.server })
|
||||
app.log.info(`Loaded ${configStore.snapshot.instances.length} instance(s) from ${configPath}`)
|
||||
} catch (error) {
|
||||
app.log.error(error)
|
||||
await app.close().catch(() => {})
|
||||
process.exitCode = 1
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { proxyToInstance } from './service.js'
|
||||
|
||||
export function registerProxyRoutes(app, { findClient, proxy = proxyToInstance }) {
|
||||
app.all('/api/proxy/:id/*', async (request, reply) => {
|
||||
const client = findClient(request.params.id, reply)
|
||||
if (!client) return
|
||||
try { return await proxy({ client, request, reply, rest: request.params['*'] }) }
|
||||
catch (error) {
|
||||
if (/invalid proxy path/.test(error.message)) return reply.code(400).send({ error: error.message })
|
||||
if (error.statusCode === 415) return reply.code(415).send({ error: error.message })
|
||||
throw error
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
const BLOCKED_REQUEST_HEADERS = new Set([
|
||||
'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailer',
|
||||
'transfer-encoding', 'upgrade', 'host', 'content-length', 'cookie', 'authorization',
|
||||
'forwarded', 'x-forwarded-for', 'x-forwarded-host', 'x-forwarded-proto', 'x-forwarded-port', 'accept-encoding',
|
||||
])
|
||||
const BLOCKED_RESPONSE_HEADERS = new Set([...BLOCKED_REQUEST_HEADERS, 'set-cookie', 'content-encoding'])
|
||||
|
||||
export function safeProxyPath(rest = '') {
|
||||
const raw = String(rest || '')
|
||||
if (!raw || raw.startsWith('/') || raw.includes('\\')) throw new Error('invalid proxy path')
|
||||
let value = raw
|
||||
const assertSafe = candidate => {
|
||||
if (/[\\?#]/.test(candidate) || candidate.startsWith('//') || candidate.split('/').some(segment => segment === '..' || segment === '.')) throw new Error('invalid proxy path')
|
||||
if (/%(?:2f|5c|3f|23|2e)/i.test(candidate)) throw new Error('invalid proxy path')
|
||||
}
|
||||
for (let depth = 0; depth < 16; depth += 1) {
|
||||
assertSafe(value)
|
||||
let decoded
|
||||
try { decoded = decodeURIComponent(value) } catch { throw new Error('invalid proxy path') }
|
||||
if (decoded === value) return `/${raw}`
|
||||
value = decoded
|
||||
}
|
||||
// Refuse inputs whose semantics still change after a bounded number of decodes.
|
||||
throw new Error('invalid proxy path')
|
||||
}
|
||||
|
||||
export function proxyHeaders(input = {}) {
|
||||
const headers = new Headers()
|
||||
for (const [name, value] of Object.entries(input)) {
|
||||
if (!BLOCKED_REQUEST_HEADERS.has(name.toLowerCase()) && value !== undefined) headers.set(name, Array.isArray(value) ? value.join(', ') : String(value))
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
export async function proxyToInstance({ client, request, reply, rest }) {
|
||||
const targetPath = safeProxyPath(rest)
|
||||
const query = request.url.includes('?') ? request.url.slice(request.url.indexOf('?')) : ''
|
||||
const target = new URL(`${targetPath}${query}`, new URL(client.instance.url).origin)
|
||||
const origin = new URL(client.instance.url).origin
|
||||
if (target.origin !== origin) throw new Error('invalid proxy path')
|
||||
const headers = proxyHeaders(request.headers)
|
||||
let body
|
||||
if (!['GET', 'HEAD'].includes(request.method)) {
|
||||
const type = (headers.get('content-type') || '').split(';')[0].trim().toLowerCase()
|
||||
if (request.body === undefined && !type) body = undefined
|
||||
else if (type.startsWith('multipart/')) { const error = new Error('unsupported proxy content type'); error.statusCode = 415; throw error }
|
||||
else if (type === 'application/json' || type.endsWith('+json')) body = request.body === undefined ? undefined : JSON.stringify(request.body)
|
||||
else if (type.startsWith('text/') || type === 'application/x-www-form-urlencoded') body = request.body
|
||||
else if (Buffer.isBuffer(request.body)) body = request.body
|
||||
else { const error = new Error('unsupported proxy content type'); error.statusCode = 415; throw error }
|
||||
}
|
||||
const upstream = await client.request(target.toString(), { method: request.method, headers, body, redirect: 'manual' })
|
||||
reply.code(upstream.status)
|
||||
upstream.headers.forEach((value, name) => {
|
||||
if (!BLOCKED_RESPONSE_HEADERS.has(name.toLowerCase())) reply.header(name, value)
|
||||
})
|
||||
return reply.send(Buffer.from(await upstream.arrayBuffer()))
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { collectInstanceStatus } from './service.js'
|
||||
|
||||
export function registerStatusRoutes(app, { registry, collect = collectInstanceStatus, findClient }) {
|
||||
app.get('/api/status', async () => ({ instances: await Promise.all([...registry.values()].map(collect)) }))
|
||||
app.get('/api/status/:id', async (request, reply) => {
|
||||
const client = findClient(request.params.id, reply)
|
||||
if (client) return collect(client)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { redactInstance, summarizeInstanceSnapshot } from '../core.js'
|
||||
|
||||
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.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.ok) { result.raw[key] = response.data; result.reachable = true }
|
||||
if (response.status === 401) result.authenticated = false
|
||||
} catch {}
|
||||
}))
|
||||
result.reachable ||= health.ok
|
||||
result.summary = summarizeInstanceSnapshot(result.raw)
|
||||
} catch (error) {
|
||||
result.error = error?.name === 'AbortError' ? 'timeout' : String(error?.message || error)
|
||||
}
|
||||
return result
|
||||
}
|
||||
Reference in New Issue
Block a user