Legacy panel: - upstream body reads now carry their own deadline and a 10 MB byte budget; a stalled modem can no longer hang /api/status fan-out forever nor OOM the proxy (request headers alone had the timeout, bodies had none) - add X-Frame-Options DENY / CSP frame-ancestors none / nosniff; the panel (delete-instance and confirmed-write dialogs) is no longer clickjackable - only send a JSON content-type when the API console request has a body, so payload-less dangerous writes stop failing with 400 and burning the one-use confirmation token - register a form-urlencoded parser (the proxy branch was unreachable) and drop the multipart parser that buffered up to 60 MB before rejecting; bodyLimit drops to 2 MB; framework-level 415 keeps the stable error body - remove /api/sms/send from readable paths: GET bypassed the write confirmation for a send endpoint - /api/instances/:id/login maps upstream failures to a stable 502 instead of leaking raw error text - guard MULTI_SIMADMIN_TIMEOUT_MS parsing (NaN aborted every request); prune dead code (buildClients, cookie expando no-op) Control plane: - deleting a notification channel detaches it from rules instead of leaving dangling ids that made every referencing rule unreadable and silently dropped future notifications; rule reads tolerate unknown ids - startup sweep resets notification_queue rows stranded in 'sending' by a crash (mirrors the sms outbox sweep); terminal outbox rows are pruned on the retention timer - /api/v1/metrics no longer emits operator-assigned node names on the session-free scrape; login limiter map is bounded and pruned; Secure cookie honors the gateway-declared x-forwarded-proto - webhook delivery sets redirect: manual (signed payloads are not replayed) - SMTP envelope sender is validated against CR/LF smuggling - scheduled reboots with delaySeconds != 3 fail fast at the dispatcher with a clear reason instead of burning every retry; contract narrowed to the pinned baseline
134 lines
9.2 KiB
JavaScript
134 lines
9.2 KiB
JavaScript
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 { defaultProxyPolicy } from './proxy/policy.js'
|
|
import { ConfirmationStore } from './proxy/confirmations.js'
|
|
import { ConfigNotFoundError, ConfigValidationError } from './config/errors.js'
|
|
import { isIP } from 'node:net'
|
|
|
|
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, proxyPolicy = defaultProxyPolicy, confirmationOptions = {} } = {}) {
|
|
if (!configStore || !clientRegistry) throw new TypeError('configStore and clientRegistry are required')
|
|
const app = Fastify({ logger, bodyLimit: 2 * 1024 * 1024 })
|
|
const parseAuthority = raw => {
|
|
const value = String(raw || '').trim().toLowerCase()
|
|
if (!value || /[\s/@]/.test(value)) return null
|
|
let hostname, port = ''
|
|
if (value.startsWith('[')) { const match=value.match(/^\[([^\]]+)\](?::(\d{1,5}))?$/); if(!match)return null; [,hostname,port='']=match }
|
|
else { const match=value.match(/^([^:]+)(?::(\d{1,5}))?$/); if(!match)return null; [,hostname,port='']=match }
|
|
if(port && Number(port)>65535)return null
|
|
return { hostname, port }
|
|
}
|
|
const loopbackAuthority = raw => {
|
|
const authority=parseAuthority(raw); if(!authority)return null
|
|
const {hostname}=authority
|
|
if(hostname === 'localhost') return { ...authority, hostname: 'localhost' }
|
|
if(isIP(hostname) === 6 && (hostname === '::1' || hostname === '0:0:0:0:0:0:0:1')) return { ...authority, hostname: '::1' }
|
|
if(isIP(hostname)!==4)return null
|
|
const octets=hostname.split('.').map(Number)
|
|
return octets[0]===127 ? authority : null
|
|
}
|
|
const configuredRawHost = String(configStore.snapshot.server.host).replace(/^\[|\]$/g, '').toLowerCase()
|
|
const configuredHost = isIP(configuredRawHost) === 6 ? '::1' : configuredRawHost
|
|
const configuredPort = String(configStore.snapshot.server.port)
|
|
app.addHook('onRequest', async (_request, reply) => {
|
|
reply.header('x-frame-options', 'DENY')
|
|
reply.header('content-security-policy', "frame-ancestors 'none'")
|
|
reply.header('x-content-type-options', 'nosniff')
|
|
})
|
|
app.addHook('onRequest', async (request, reply) => {
|
|
const authority=loopbackAuthority(request.headers.host)
|
|
const injectDefault = request.headers.host === 'localhost:80' && request.raw.socket?.localPort == null
|
|
if (!authority || (!injectDefault && (authority.hostname !== configuredHost || authority.port !== configuredPort))) return reply.code(421).send({ error: 'configured loopback Host required' })
|
|
if (request.headers.origin) {
|
|
let origin
|
|
try { origin = new URL(request.headers.origin) } catch {}
|
|
const originAuthority = origin ? loopbackAuthority(origin.host) : null
|
|
if (!origin || origin.protocol !== 'http:' || !originAuthority || originAuthority.hostname !== authority.hostname || originAuthority.port !== authority.port) return reply.code(403).send({ error: 'cross-origin request rejected' })
|
|
}
|
|
})
|
|
app.addContentTypeParser('application/octet-stream', { parseAs: 'buffer' }, (_request, body, done) => done(null, body))
|
|
app.addContentTypeParser('application/x-www-form-urlencoded', { parseAs: 'string' }, (_request, body, done) => done(null, body))
|
|
// Unparsable content types (e.g. multipart) must fail with the same stable body
|
|
// the proxy policy would have produced, without buffering the payload first.
|
|
app.setErrorHandler((error, _request, reply) => {
|
|
if (error?.code === 'FST_ERR_CTP_INVALID_MEDIA_TYPE') return reply.code(415).send({ error: 'unsupported proxy content type' })
|
|
if (error?.code === 'FST_ERR_CTP_EMPTY_JSON_BODY') return reply.code(400).send({ error: 'empty JSON body' })
|
|
reply.send(error)
|
|
})
|
|
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')
|
|
try {
|
|
return { ...(await client.ensureAuthenticated(supplied ? { credential: request.body.password } : {})), hasSavedPassword: Boolean(configStore.snapshot.instances.find(item => item.id === request.params.id)?.auth?.password) }
|
|
} catch (error) {
|
|
return reply.code(502).send({ error: 'upstream request failed' })
|
|
}
|
|
})
|
|
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, policy: proxyPolicy, configStore, confirmationStore: new ConfirmationStore(confirmationOptions) })
|
|
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
|
|
}
|