[verified] refactor: harden operations and redesign device console

This commit is contained in:
chick
2026-07-15 16:33:16 +08:00
parent e04a16e817
commit 4977afdc20
24 changed files with 769 additions and 779 deletions
+37 -2
View File
@@ -5,7 +5,10 @@ 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']
@@ -19,9 +22,41 @@ const CATALOG = [
{ 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 } = {}) {
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: 60 * 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) => {
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(/^multipart\//, { parseAs: 'buffer' }, (_request, body, done) => done(null, body))
if (staticFiles) await app.register(fastifyStatic, { root: publicDir, prefix: '/' })
@@ -73,7 +108,7 @@ export async function buildApp({ configStore, clientRegistry, logger = false, st
try { await client.fetchJson('/api/auth/logout', { method: 'POST' }) } catch {}
client.jar.clear(); client.clearEphemeralSecret?.(); return { ok: true }
})
registerProxyRoutes(app, { findClient })
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' }))