[verified] refactor: establish service and frontend boundaries

This commit is contained in:
chick
2026-07-15 02:43:41 +08:00
parent 0ac6faa6a3
commit e04a16e817
29 changed files with 917 additions and 598 deletions
+16 -177
View File
@@ -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()))
}