[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
+12 -4
View File
@@ -32,7 +32,7 @@ export class FileConfigStore {
this.examplePath = examplePath
this.atomicWrite = atomicWrite
this.env = { ...env }
this.#snapshot = immutableSnapshot({ ...snapshot, configPath })
this.#snapshot = immutableSnapshot({ ...snapshot, revision: Number(snapshot.revision || 0), configPath })
}
static async open({ configPath, examplePath, atomicWrite, env } = {}) {
let text
@@ -52,7 +52,7 @@ export class FileConfigStore {
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 })
this.#snapshot = immutableSnapshot({ ...normalized, revision: this.#snapshot.revision + 1, configPath: this.configPath })
return result?.id ? this.#snapshot.instances.find(item => item.id === result.id) || result : result
})
this.#queue = operation.catch(() => {})
@@ -71,8 +71,16 @@ export class FileConfigStore {
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 action = payload.passwordAction
if (action !== undefined && !['preserve', 'set', 'clear'].includes(action)) throw new ConfigValidationError('passwordAction must be preserve, set or clear')
if (payload.auth && Object.hasOwn(payload.auth, 'password')) throw new ConfigValidationError("auth.password is ambiguous; use passwordAction='set' or 'clear'")
if (Object.hasOwn(payload, 'password') && action === undefined) throw new ConfigValidationError("password requires passwordAction='set'")
if (action === 'set' && !String(payload.password || '')) throw new ConfigValidationError("passwordAction='set' requires a non-empty password")
const auth = action === 'set' ? { mode: 'password', password: String(payload.password) }
: action === 'clear' ? { mode: 'none', password: '' }
: current.auth
const { passwordAction: _passwordAction, password: _password, auth: _payloadAuth, ...metadata } = payload
const merged = { ...current, ...metadata, id: payload.id ? String(payload.id).trim() : current.id, 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
+24 -1
View File
@@ -1,10 +1,26 @@
import { ConfigValidationError } from './errors.js'
import { isIP } from 'node:net'
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')
const hostname = url.hostname.replace(/^\[|\]$/g, '').toLowerCase()
const ipv4 = hostname.split('.').map(Number)
const isIpv4 = ipv4.length === 4 && ipv4.every(part => Number.isInteger(part) && part >= 0 && part <= 255)
const prohibitedIpv4 = isIpv4 && (
ipv4[0] === 127 || ipv4[0] === 0 ||
(ipv4[0] === 169 && ipv4[1] === 254) ||
(ipv4[0] >= 224)
)
const prohibitedIpv6 = hostname === '::' || hostname === '::1' || /^fe[89ab][0-9a-f]:/.test(hostname) || hostname.startsWith('ff') || hostname.startsWith('::ffff:') || hostname.startsWith('64:ff9b:')
const prohibitedMetadata = hostname === '100.100.100.200' || hostname === '168.63.129.16'
const prohibitedTransition = hostname.startsWith('2002:')
const isIpLiteral = isIP(hostname) !== 0
if (!isIpLiteral || prohibitedIpv4 || prohibitedIpv6 || prohibitedMetadata || prohibitedTransition) {
throw new ConfigValidationError('instance target must be a permitted IP literal; hostnames and local/metadata addresses are prohibited')
}
url.hash = ''
url.search = ''
return url.toString().replace(/\/$/, '')
@@ -38,8 +54,15 @@ export function normalizeConfig(raw = {}, env = process.env) {
}
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')
const host = String(env.HOST ?? raw.server?.host ?? '127.0.0.1').trim()
const bareHost = host.replace(/^\[|\]$/g, '').toLowerCase()
const octets = bareHost.split('.')
const ipv4Loopback = octets.length === 4 && octets[0] === '127' && octets.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
if (!(bareHost === 'localhost' || bareHost === '::1' || bareHost === '0:0:0:0:0:0:0:1' || ipv4Loopback)) {
throw new ConfigValidationError('server.host must be a loopback host (127/8, ::1, or localhost)')
}
return {
server: { host: raw.server?.host ?? env.HOST ?? '127.0.0.1', port },
server: { host, port },
instances,
raw: { ...raw, instances },
}