@iasiv5/dsh-obmc-web 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js ADDED
@@ -0,0 +1,812 @@
1
+ /**
2
+ * dsh-obmc-web — host half.
3
+ *
4
+ * Registers same-origin proxy routes on the DSH web server that forward to
5
+ * the AST2700 EVB BMC through the obmc-web-relay.service SSH tunnel
6
+ * (https://127.0.0.1:18443, loopback only). The BMC's web UI (bmcweb) is a
7
+ * hash-router SPA whose index references ROOT-ABSOLUTE assets and APIs
8
+ * (/js/…, /css/…, /redfish/…, /login, /logout), so the proxy owns those
9
+ * exact root path families; the web server dispatches longest-prefix-wins
10
+ * after its own exact table, so the GUI's own longer routes always win and
11
+ * a duplicate registration is skipped (never throws the plugin).
12
+ *
13
+ * Embed-hostile response headers are removed (X-Frame-Options, HSTS,
14
+ * CSP frame-ancestors — and WWW-Authenticate, so bmcweb's 401 Basic
15
+ * challenges can never pop the browser's native credential dialog; the BMC
16
+ * SPA logs in via POST /login JSON + cookie). Everything else passes
17
+ * through untouched; no credentials are stored anywhere.
18
+ *
19
+ * BMC address discovery: the BMC's address is often DHCP-assigned by the
20
+ * jump host that links it (a small LAN of its own) and may change. When
21
+ * the tunnel target stops answering (proxy upstream error, or a live probe
22
+ * of /bmc-status), the host SSHes to the configured jump host
23
+ * (OBMC_SSH_TARGET in the relay env file, BatchMode key assumed present)
24
+ * and locates the bmcweb speaker: dnsmasq lease file + ARP table first,
25
+ * full TCP-443 sweep as fallback, each candidate fingerprinted by its
26
+ * /redfish/v1 response. The winner is written back to the env file
27
+ * (BMC_TARGET, consumed by the relay unit's EnvironmentFile) and the relay
28
+ * is restarted through the user manager; the switch is verified through
29
+ * the tunnel before being reported. Manual trigger: POST /bmc-status.
30
+ * Discovery is entirely optional: with no OBMC_SSH_TARGET the proxy still
31
+ * works, it just cannot retarget itself.
32
+ *
33
+ * All site-specific values (BMC address, SSH jump host, link subnet, relay
34
+ * port) live in the env file — nothing about your network is hardcoded
35
+ * here. On first start a commented template is generated; the Settings
36
+ * panel's deployment form reads/edits the same keys through
37
+ * GET/POST /bmc-config and can install/restart the relay unit
38
+ * (POST /bmc-relay-install), so a fresh machine is usable without touching
39
+ * a shell.
40
+ *
41
+ * The trust fence mirrors dsh-better-sidebar's trust-fence.ts: Host must
42
+ * be loopback or a deployment-trusted authority, Sec-Fetch-Site must not
43
+ * be cross-site, and a present Origin must name the Host's hostname. It is
44
+ * a DNS-rebinding / cross-site defense, not authentication — the routes
45
+ * sit behind whatever gate the deployment already puts in front of the
46
+ * GUI (dsh-auth-caddy etc.) and the server itself binds loopback only.
47
+ */
48
+ import https from 'node:https'
49
+ import net from 'node:net'
50
+ import { execFile, spawn } from 'node:child_process'
51
+ import fs from 'node:fs'
52
+ import os from 'node:os'
53
+ import path from 'node:path'
54
+
55
+ export const name = '@iasiv5/dsh-obmc-web'
56
+ export const inject = ['webServer', 'webRuntime']
57
+
58
+ const UP_HOST = '127.0.0.1'
59
+ const DEFAULT_RELAY_PORT = 18443
60
+ // Deployment config: a KEY=VALUE env file shared with the relay unit (its
61
+ // EnvironmentFile). Override the location with the OBMC_WEB_ENV env var.
62
+ // Auto-generated as a commented template on first start; editable by hand
63
+ // or from the Settings form (GET/POST /bmc-config).
64
+ const ENV_FILE = process.env.OBMC_WEB_ENV
65
+ ?? path.join(os.homedir(), '.config/systemd/user/obmc-relay.env')
66
+ const UNIT_NAME = 'obmc-web-relay.service'
67
+ // Test-isolation knob mirroring OBMC_WEB_ENV: point it elsewhere in tests
68
+ // so they can never touch the real user unit.
69
+ const UNIT_DIR = process.env.OBMC_UNIT_DIR ?? path.join(os.homedir(), '.config/systemd/user')
70
+ const unitPath = () => path.join(UNIT_DIR, UNIT_NAME)
71
+ const DISCOVERY_COOLDOWN_MS = 60_000
72
+
73
+ // Validated at load time; unknown/invalid keys are ignored.
74
+ const envConfig = {
75
+ bmcTarget: null, // 'ip:port' | null — informational + discovery baseline
76
+ sshTarget: null, // 'user@host' | null — discovery disabled when null
77
+ sshPort: 22,
78
+ subnet: null, // 'a.b.c' — defaults to the bmcTarget IP's /24
79
+ relayPort: DEFAULT_RELAY_PORT,
80
+ }
81
+
82
+ const HOP = new Set([
83
+ 'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization',
84
+ 'proxy-connection', 'te', 'trailer', 'transfer-encoding', 'upgrade',
85
+ ])
86
+ // www-authenticate is dropped so bmcweb's 401 Basic challenges (e.g. on
87
+ // /login and unknown paths) can never pop the browser's native credential
88
+ // dialog — the BMC SPA logs in via POST /login JSON + cookie, not Basic.
89
+ const DROP_RESPONSE = new Set([...HOP, 'x-frame-options', 'strict-transport-security', 'www-authenticate'])
90
+
91
+ const agent = new https.Agent({ keepAlive: true, maxSockets: 8, rejectUnauthorized: false })
92
+
93
+ // ── BMC target state + discovery ─────────────────────────────────────────
94
+ const bmcState = {
95
+ target: null, // 'ip:port' | null — from the relay env file
96
+ phase: 'idle', // idle | discovering | ok | failed
97
+ lastError: null,
98
+ lastErrorAt: null, // epoch ms — lets the UI tell fresh errors apart
99
+ lastUpstreamError: null, // epoch ms of the most recent proxy upstream error
100
+ lastDiscovery: null, // { at, found, previous, switched, verified }
101
+ roundStartedAt: null, // epoch ms — discovery round bookkeeping
102
+ roundEndedAt: null, // epoch ms — UI polls until this passes its start
103
+ startedAt: Date.now(),
104
+ }
105
+ let discovering = false
106
+ let lastDiscoveryAt = 0
107
+
108
+ function isValidBmcTarget(value) {
109
+ const match = /^(\d{1,3}(?:\.\d{1,3}){3})(:\d{1,5})?$/.exec(value)
110
+ if (!match) return false
111
+ return match[1].split('.').every(octet => Number(octet) <= 255)
112
+ }
113
+
114
+ function isValidSshTarget(value) {
115
+ return /^[A-Za-z0-9._-]+@[A-Za-z0-9.\[\]-]+$/.test(value)
116
+ }
117
+
118
+ function isValidSubnet(value) {
119
+ const match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(value)
120
+ return match !== null && [match[1], match[2], match[3]].every(octet => Number(octet) <= 255)
121
+ }
122
+
123
+ function isValidPort(value) {
124
+ return /^\d{1,5}$/.test(value) && Number(value) >= 1 && Number(value) <= 65_535
125
+ }
126
+
127
+ /** Accept 'ip' or 'ip:port'; normalize to 'ip:port' or null. */
128
+ function normalizeBmcTarget(value) {
129
+ const withPort = value.includes(':') ? value : `${value}:443`
130
+ return isValidBmcTarget(withPort) ? withPort : null
131
+ }
132
+
133
+ /** Parse the relay env file; invalid values are skipped, never trusted. */
134
+ function readEnvConfig() {
135
+ const config = { bmcTarget: null, sshTarget: null, sshPort: 22, subnet: null, relayPort: DEFAULT_RELAY_PORT }
136
+ try {
137
+ const text = fs.readFileSync(ENV_FILE, 'utf8')
138
+ for (const line of text.split('\n')) {
139
+ const match = /^([A-Z0-9_]+)=(.*)$/.exec(line.trim())
140
+ if (!match) continue
141
+ const key = match[1]
142
+ const value = match[2].trim()
143
+ if (key === 'BMC_TARGET' && isValidBmcTarget(value)) config.bmcTarget = value
144
+ else if (key === 'OBMC_SSH_TARGET' && isValidSshTarget(value)) config.sshTarget = value
145
+ else if (key === 'OBMC_SSH_PORT' && isValidPort(value)) config.sshPort = Number(value)
146
+ else if (key === 'OBMC_SUBNET' && isValidSubnet(value)) config.subnet = value
147
+ else if (key === 'OBMC_RELAY_PORT' && isValidPort(value)) config.relayPort = Number(value)
148
+ }
149
+ } catch {
150
+ // No config file: the proxy still works through the tunnel as-is,
151
+ // discovery just stays off until OBMC_SSH_TARGET appears.
152
+ }
153
+ if (config.subnet === null && config.bmcTarget !== null) {
154
+ config.subnet = config.bmcTarget.split(':')[0].split('.').slice(0, 3).join('.')
155
+ }
156
+ return config
157
+ }
158
+
159
+ /**
160
+ * Update the given KEY=VALUE lines in place, preserving comments, ordering
161
+ * and every unknown key. Keys not yet present are appended at the end.
162
+ */
163
+ function updateEnvFile(updates) {
164
+ fs.mkdirSync(path.dirname(ENV_FILE), { recursive: true })
165
+ let text = ''
166
+ try { text = fs.readFileSync(ENV_FILE, 'utf8') } catch { /* first write */ }
167
+ for (const [key, value] of Object.entries(updates)) {
168
+ const line = `${key}=${value}`
169
+ const pattern = new RegExp(`^${key}=.+$`, 'm')
170
+ if (pattern.test(text)) text = text.replace(pattern, line)
171
+ else text = (text === '' || text.endsWith('\n') ? text : text + '\n') + line + '\n'
172
+ }
173
+ fs.writeFileSync(ENV_FILE, text, { mode: 0o600 })
174
+ }
175
+
176
+ function writeTargetEnvFile(ip) {
177
+ updateEnvFile({ BMC_TARGET: `${ip}:443` })
178
+ }
179
+
180
+ /** First-start bootstrap: a fully commented template, nothing enabled. */
181
+ function ensureEnvTemplate() {
182
+ if (fs.existsSync(ENV_FILE)) return false
183
+ fs.mkdirSync(path.dirname(ENV_FILE), { recursive: true })
184
+ fs.writeFileSync(ENV_FILE, `# @iasiv5/dsh-obmc-web 部署配置(首次启动自动生成,全部为注释态)
185
+ # 推荐在 DSH「设置 → BMC 控制台」的部署配置表单里填写,保存后自动写入本文件;
186
+ # 也可以手动编辑(改完重启 dsh web 生效)。格式 KEY=VALUE,未知键会被忽略。
187
+ #
188
+ # BMC 地址(ip 或 ip:port,端口缺省 443;下面是文档专用示例地址,请改成你的)
189
+ #BMC_TARGET=192.0.2.10:443
190
+ #
191
+ # —— 以下为 SSH 自动探测(可选):BMC 的 IP 被 DHCP 改变后自动找回 ——
192
+ # 跳板机:与 BMC 链路网段相通、已配好免密登录(BatchMode)的机器
193
+ #OBMC_SSH_TARGET=user@jump.example.net
194
+ #OBMC_SSH_PORT=22
195
+ # 链路网段(缺省取 BMC IP 的 /24)
196
+ #OBMC_SUBNET=192.0.2
197
+ #
198
+ # relay 本地监听端口(需与 relay unit 的 -L 参数一致,默认 18443)
199
+ #OBMC_RELAY_PORT=18443
200
+ `, { mode: 0o600 })
201
+ return true
202
+ }
203
+
204
+ /** True when something already listens on 127.0.0.1:port. */
205
+ function portInUse(port) {
206
+ return new Promise(resolve => {
207
+ const server = net.createServer()
208
+ server.once('error', () => resolve(true))
209
+ server.listen(port, '127.0.0.1', () => server.close(() => resolve(false)))
210
+ })
211
+ }
212
+
213
+ /** One HTTPS request through the tunnel; resolves { ok, status }. */
214
+ function tunnelRequest(requestPath, timeoutMs = 8000) {
215
+ return new Promise(resolve => {
216
+ const request = https.request(
217
+ { host: UP_HOST, port: envConfig.relayPort, method: 'GET', path: requestPath, agent, timeout: timeoutMs },
218
+ response => {
219
+ response.resume()
220
+ resolve({ ok: response.statusCode === 200 || response.statusCode === 401, status: response.statusCode })
221
+ },
222
+ )
223
+ request.on('timeout', () => { request.destroy(); resolve({ ok: false, status: 0 }) })
224
+ request.on('error', () => resolve({ ok: false, status: 0 }))
225
+ request.end()
226
+ })
227
+ }
228
+
229
+ function userEnv() {
230
+ const runtimeDir = `/run/user/${process.getuid ? process.getuid() : 1000}`
231
+ return {
232
+ ...process.env,
233
+ XDG_RUNTIME_DIR: runtimeDir,
234
+ DBUS_SESSION_BUS_ADDRESS: `unix:path=${runtimeDir}/bus`,
235
+ }
236
+ }
237
+
238
+ function systemdManagerCall(args) {
239
+ return new Promise((resolve, reject) => {
240
+ execFile('busctl', ['--user', 'call', 'org.freedesktop.systemd1', '/org/freedesktop/systemd1',
241
+ 'org.freedesktop.systemd1.Manager', ...args], { env: userEnv(), timeout: 20_000 }, (error, stdout) => {
242
+ if (error) reject(error)
243
+ else resolve(stdout)
244
+ })
245
+ })
246
+ }
247
+
248
+ /** systemd unit state: 'active' | 'inactive' | 'failed' | … | 'unknown'. */
249
+ function relayActiveState() {
250
+ return new Promise(resolve => {
251
+ execFile('systemctl', ['--user', 'is-active', UNIT_NAME], { env: userEnv(), timeout: 5_000 },
252
+ (error, stdout) => resolve(String(stdout).trim() || 'unknown'))
253
+ })
254
+ }
255
+
256
+ /** The user-level unit file, generated from the current validated config. */
257
+ function generateUnit(config) {
258
+ return `[Unit]
259
+ Description=SSH local-forward relay to the BMC Web UI (managed by @iasiv5/dsh-obmc-web)
260
+ # Local port ${config.relayPort} -> (over ssh to the jump host) -> BMC $BMC_TARGET
261
+ # Binds loopback only. BMC_TARGET lives in the shared env file and is
262
+ # rewritten in place by the plugin's SSH discovery.
263
+ After=network-online.target
264
+ Wants=network-online.target
265
+
266
+ [Service]
267
+ Type=simple
268
+ Environment=BMC_TARGET=${config.bmcTarget}
269
+ EnvironmentFile=-${ENV_FILE}
270
+ ExecStart=/usr/bin/ssh -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -o BatchMode=yes -N -L 127.0.0.1:${config.relayPort}:\${BMC_TARGET} -p ${config.sshPort} ${config.sshTarget}
271
+ Restart=always
272
+ RestartSec=5
273
+
274
+ [Install]
275
+ WantedBy=default.target
276
+ `
277
+ }
278
+
279
+ /** Small JSON request body reader (8 KB cap). */
280
+ function readJsonBody(req) {
281
+ return new Promise((resolve, reject) => {
282
+ const chunks = []
283
+ let size = 0
284
+ req.on('data', chunk => {
285
+ size += chunk.length
286
+ if (size > 8192) {
287
+ reject(new Error('request body too large'))
288
+ req.destroy()
289
+ return
290
+ }
291
+ chunks.push(chunk)
292
+ })
293
+ req.on('end', () => {
294
+ try {
295
+ resolve(chunks.length === 0 ? {} : JSON.parse(Buffer.concat(chunks).toString('utf8')))
296
+ } catch {
297
+ reject(new Error('invalid JSON body'))
298
+ }
299
+ })
300
+ req.on('error', reject)
301
+ })
302
+ }
303
+
304
+ /**
305
+ * Locate the bmcweb speaker from the jump host: dnsmasq leases + ARP table
306
+ * first (sub-second when the BMC is merely renumbered), full TCP-443 sweeps
307
+ * of the candidate /24s as fallback. Every candidate is fingerprinted by
308
+ * its /redfish/v1 answer, so generous candidate lists are safe. With a
309
+ * validated subnet the sweep is scoped to it; with null the jump host
310
+ * enumerates its own directly-connected IPv4 /24s (bootstrap mode — no
311
+ * site data is interpolated either way, the body stays fixed).
312
+ */
313
+ function buildDiscoveryScript(subnet) {
314
+ const subnetsSource = subnet
315
+ ? `"${subnet}"`
316
+ : '"$(ip -4 -o addr show scope global 2>/dev/null | awk \'{split($4,a,"/"); print a[1]}\' | awk -F. \'{OFS="."} NF==4 {print $1,$2,$3}\' | sort -u)"'
317
+ return `# dsh-obmc-web BMC discovery on the jump host
318
+ lease_files="/var/lib/misc/dnsmasq.leases /var/lib/dnsmasq/dnsmasq.leases"
319
+ SUBNETS=${subnetsSource}
320
+ cands=""
321
+ for f in $lease_files; do
322
+ [ -r "$f" ] && cands="$cands $(awk '{print $3}' "$f" 2>/dev/null)"
323
+ done
324
+ cands="$cands $(ip neigh show 2>/dev/null | awk '{print $1}' | grep -E '^(10\\.|172\\.(1[6-9]|2[0-9]|3[01])\\.|192\\.168\\.)' | head -100)"
325
+ cands=$(printf '%s\\n' $cands | sort -u)
326
+ check() { code=$(curl -k -m 3 -s -o /dev/null -w '%{http_code}' "https://$1/redfish/v1" 2>/dev/null); [ "$code" = 200 ] || [ "$code" = 401 ]; }
327
+ for c in $cands; do
328
+ if check "$c"; then echo "BMC_IP=$c"; exit 0; fi
329
+ done
330
+ for SUBNET in $SUBNETS; do
331
+ live=$(seq 2 254 | xargs -P 32 -I{} sh -c "nc -z -w1 $SUBNET.{} 443 2>/dev/null && echo $SUBNET.{}" 2>/dev/null)
332
+ for c in $(printf '%s\\n' $live | sort -u); do
333
+ if check "$c"; then echo "BMC_IP=$c"; exit 0; fi
334
+ done
335
+ done
336
+ echo "BMC_IP="
337
+ `
338
+ }
339
+
340
+ function runDiscoveryScript() {
341
+ const sshArgs = ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8', '-p', String(envConfig.sshPort), envConfig.sshTarget]
342
+ return new Promise(resolve => {
343
+ const child = spawn('ssh', [...sshArgs, 'sh -s'], { stdio: ['pipe', 'pipe', 'pipe'] })
344
+ let stdout = ''
345
+ let stderr = ''
346
+ const timer = setTimeout(() => child.kill('SIGKILL'), 60_000)
347
+ child.stdout.on('data', chunk => { stdout += chunk })
348
+ child.stderr.on('data', chunk => { stderr += chunk })
349
+ child.on('error', error => { clearTimeout(timer); resolve({ error: error.message }) })
350
+ child.on('close', code => {
351
+ clearTimeout(timer)
352
+ const match = /BMC_IP=(\d{1,3}(?:\.\d{1,3}){3})/.exec(stdout)
353
+ const found = match ? match[1] : null
354
+ if (found !== null && !isValidBmcTarget(`${found}:443`)) resolve({ error: 'implausible candidate ignored', found: null })
355
+ else resolve({ found, code, stderr: stderr.slice(0, 400) })
356
+ })
357
+ child.stdin.end(buildDiscoveryScript(envConfig.subnet))
358
+ })
359
+ }
360
+
361
+ async function relayRestart() {
362
+ await systemdManagerCall(['Reload'])
363
+ await systemdManagerCall(['RestartUnit', 'ss', UNIT_NAME, 'replace'])
364
+ // Give ssh a moment to establish and the port to re-listen.
365
+ for (let waited = 0; waited < 12_000; waited += 1_000) {
366
+ await new Promise(resolve => setTimeout(resolve, 1_000))
367
+ if ((await tunnelRequest('/redfish/v1', 4_000)).ok) return true
368
+ }
369
+ return false
370
+ }
371
+
372
+ async function switchTunnelTarget(ip) {
373
+ writeTargetEnvFile(ip)
374
+ return relayRestart()
375
+ }
376
+
377
+ async function discoverBmc(manual) {
378
+ if (envConfig.sshTarget === null) {
379
+ return { started: false, reason: 'discovery not configured: fill SSH 跳板机 (OBMC_SSH_TARGET) in the config form first' }
380
+ }
381
+ if (discovering) return { started: false, reason: 'already running' }
382
+ if (!manual && Date.now() - lastDiscoveryAt < DISCOVERY_COOLDOWN_MS) {
383
+ return { started: false, reason: 'cooldown' }
384
+ }
385
+ discovering = true
386
+ lastDiscoveryAt = Date.now()
387
+ bmcState.phase = 'discovering'
388
+ bmcState.roundStartedAt = Date.now()
389
+ bmcState.roundEndedAt = null
390
+ const previous = bmcState.target
391
+ // Fire and forget: the UI polls /bmc-status for phase transitions.
392
+ ;(async () => {
393
+ try {
394
+ const result = await runDiscoveryScript()
395
+ if (!result.found) {
396
+ bmcState.phase = 'failed'
397
+ // Surface the REAL cause: an SSH-level failure (wrong port/host/key)
398
+ // must not masquerade as "no bmcweb speaker found".
399
+ const stderrTail = (result.stderr || '').split('\n').map(l => l.trim()).filter(Boolean).pop() || ''
400
+ if (result.error) bmcState.lastError = result.error
401
+ else if (result.code !== 0 || stderrTail) bmcState.lastError = `SSH 探测未成功(检查跳板机地址/端口/免密登录):${stderrTail || `exit code ${result.code}`}`
402
+ else bmcState.lastError = `跳板机上未发现 bmcweb(网段:${envConfig.subnet || '自动枚举直连网段'})`
403
+ bmcState.lastErrorAt = Date.now()
404
+ return
405
+ }
406
+ const switched = `${result.found}:443` !== previous
407
+ let verified = false
408
+ let switchNote = null
409
+ if (switched) {
410
+ bmcState.target = `${result.found}:443`
411
+ try {
412
+ verified = await switchTunnelTarget(result.found)
413
+ } catch (error) {
414
+ // Bootstrap case: the relay unit is not installed yet — the
415
+ // address was still found and persisted; tell the user the next
416
+ // step instead of reporting a cryptic systemd error.
417
+ if (/not loaded|not found/i.test(error.message || '')) switchNote = 'relay 服务未安装'
418
+ else throw error
419
+ }
420
+ } else {
421
+ verified = (await tunnelRequest('/redfish/v1')).ok
422
+ if (!verified) verified = await switchTunnelTarget(result.found)
423
+ }
424
+ // The env file may have gained BMC_TARGET/OBMC_SUBNET — refresh the
425
+ // in-memory config so GET /bmc-config reflects it without a restart.
426
+ Object.assign(envConfig, readEnvConfig())
427
+ bmcState.lastDiscovery = {
428
+ at: Date.now(), found: result.found, previous,
429
+ switched: switched && verified, verified,
430
+ }
431
+ bmcState.phase = verified ? 'ok' : 'failed'
432
+ if (!verified) {
433
+ bmcState.lastError = switchNote
434
+ ? `已发现 BMC ${result.found} 并写入配置;${switchNote},点「安装并启动 relay 服务」即可打通隧道`
435
+ : `找到 ${result.found} 但隧道切换未验证`
436
+ bmcState.lastErrorAt = Date.now()
437
+ }
438
+ } catch (error) {
439
+ bmcState.phase = 'failed'
440
+ bmcState.lastError = error.message
441
+ bmcState.lastErrorAt = Date.now()
442
+ } finally {
443
+ bmcState.roundEndedAt = Date.now()
444
+ discovering = false
445
+ }
446
+ })()
447
+ return { started: true }
448
+ }
449
+
450
+ // ── trust fence (mirror of dsh-better-sidebar's trust-fence.ts) ──────────
451
+ function isLoopbackHostname(hostname) {
452
+ if (hostname === 'localhost' || hostname === '[::1]' || hostname === '::1') return true
453
+ const parts = hostname.split('.')
454
+ return parts.length === 4
455
+ && parts[0] === '127'
456
+ && parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
457
+ }
458
+
459
+ function trustedRequest(req, trustedHosts) {
460
+ const host = req.headers.host
461
+ if (typeof host !== 'string' || host === '') return false
462
+ let hostUrl
463
+ try { hostUrl = new URL(`http://${host}`) } catch { return false }
464
+ const trusted = trustedHosts.some(entry => {
465
+ try {
466
+ const entryUrl = new URL(`http://${entry}`)
467
+ const withPort = entryUrl.port !== ''
468
+ return withPort
469
+ ? `${entryUrl.hostname}:${entryUrl.port}` === hostUrl.host
470
+ : entryUrl.hostname === hostUrl.hostname
471
+ } catch { return false }
472
+ })
473
+ if (!isLoopbackHostname(hostUrl.hostname) && !trusted) return false
474
+ if (req.headers['sec-fetch-site'] === 'cross-site') return false
475
+ const origin = req.headers.origin
476
+ if (origin === undefined) return true
477
+ try {
478
+ return new URL(origin).hostname === hostUrl.hostname
479
+ } catch {
480
+ return false
481
+ }
482
+ }
483
+
484
+ /** Rewrite one CSP value: keep every directive except frame-ancestors. */
485
+ function cspWithoutFrameAncestors(value) {
486
+ return value.split(';')
487
+ .map(directive => directive.trim())
488
+ .filter(directive => directive !== '' && !directive.toLowerCase().startsWith('frame-ancestors'))
489
+ .join('; ')
490
+ }
491
+
492
+ /** Forward one plain HTTP exchange to the BMC tunnel. */
493
+ function proxyRequest(req, res, targetPath, log) {
494
+ const headers = {}
495
+ for (const [key, value] of Object.entries(req.headers)) {
496
+ if (HOP.has(key.toLowerCase()) || key.toLowerCase() === 'host') continue
497
+ headers[key] = value
498
+ }
499
+ headers.host = `${UP_HOST}:${envConfig.relayPort}`
500
+ const upstream = https.request(
501
+ { host: UP_HOST, port: envConfig.relayPort, method: req.method, path: targetPath, headers, agent },
502
+ (up) => {
503
+ const raw = []
504
+ for (let i = 0; i < up.rawHeaders.length; i += 2) {
505
+ const key = up.rawHeaders[i]
506
+ const value = up.rawHeaders[i + 1]
507
+ const lower = key.toLowerCase()
508
+ if (DROP_RESPONSE.has(lower)) continue
509
+ if (lower === 'content-security-policy') {
510
+ const rewritten = cspWithoutFrameAncestors(value)
511
+ if (rewritten === '') continue
512
+ raw.push(key, rewritten)
513
+ continue
514
+ }
515
+ raw.push(key, value)
516
+ }
517
+ res.writeHead(up.statusCode, raw)
518
+ up.pipe(res)
519
+ },
520
+ )
521
+ upstream.on('error', error => {
522
+ bmcState.lastUpstreamError = Date.now()
523
+ log?.warn?.(`[dsh-obmc-web] upstream error for ${targetPath}: ${error.message}`)
524
+ try {
525
+ res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' })
526
+ res.end('dsh-obmc-web: BMC relay tunnel unavailable (is obmc-web-relay.service running?)')
527
+ } catch { /* socket already gone */ }
528
+ // The tunnel target stopped answering: locate the BMC's current address.
529
+ discoverBmc(false).catch(() => {})
530
+ })
531
+ req.pipe(upstream)
532
+ }
533
+
534
+ /** Tunnel one WebSocket upgrade straight through to the BMC (kvm/console). */
535
+ function proxyUpgrade(req, socket, head, log) {
536
+ const headers = {}
537
+ for (const [key, value] of Object.entries(req.headers)) {
538
+ if (key.toLowerCase() === 'host') continue
539
+ headers[key] = value
540
+ }
541
+ headers.host = `${UP_HOST}:${envConfig.relayPort}`
542
+ const upstream = https.connect({ host: UP_HOST, port: envConfig.relayPort, rejectUnauthorized: false }, () => {
543
+ const lines = [`${req.method} ${req.url} HTTP/1.1`]
544
+ for (const [key, value] of Object.entries(headers)) lines.push(`${key}: ${value}`)
545
+ upstream.write(lines.join('\r\n') + '\r\n\r\n')
546
+ if (head !== undefined && head.length > 0) upstream.write(head)
547
+ upstream.on('close', () => socket.destroy())
548
+ upstream.on('error', () => socket.destroy())
549
+ socket.on('close', () => upstream.destroy())
550
+ socket.on('error', () => upstream.destroy())
551
+ upstream.pipe(socket)
552
+ socket.pipe(upstream)
553
+ })
554
+ upstream.on('error', error => {
555
+ log?.warn?.(`[dsh-obmc-web] upgrade tunnel error: ${error.message}`)
556
+ socket.write('HTTP/1.1 502 Bad Gateway\r\n\r\n')
557
+ socket.destroy()
558
+ })
559
+ }
560
+
561
+ function writeJson(res, status, payload) {
562
+ const body = JSON.stringify(payload)
563
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
564
+ res.end(body)
565
+ }
566
+
567
+ export function apply(ctx) {
568
+ const createdTemplate = ensureEnvTemplate()
569
+ if (createdTemplate) ctx.logger?.info?.(`[dsh-obmc-web] first start: wrote a commented config template to ${ENV_FILE} — configure it in Settings → BMC 控制台`)
570
+ Object.assign(envConfig, readEnvConfig())
571
+ bmcState.target = envConfig.bmcTarget
572
+ const trustedHostsOf = () => Array.isArray(ctx.webRuntime?.trustedHosts) ? ctx.webRuntime.trustedHosts : []
573
+ const fence = req => trustedRequest(req, trustedHostsOf())
574
+ const guarded = handler => (req, res) => {
575
+ if (!fence(req)) {
576
+ res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' })
577
+ res.end('dsh-obmc-web: forbidden')
578
+ return
579
+ }
580
+ handler(req, res)
581
+ }
582
+
583
+ const register = (route, label) => {
584
+ try {
585
+ ctx.effect(() => ctx.webServer.register(route), label)
586
+ } catch (error) {
587
+ ctx.logger?.warn?.(`[dsh-obmc-web] skipped ${label}: ${error.message}`)
588
+ }
589
+ }
590
+
591
+ // Status + manual discovery trigger: GET returns the live tunnel verdict
592
+ // and the current target; POST starts an SSH discovery round.
593
+ register({
594
+ kind: 'exact',
595
+ path: '/bmc-status',
596
+ handler: guarded((req, res) => {
597
+ const url = new URL(req.url ?? '/', 'http://dsh.internal')
598
+ if (req.method === 'POST') {
599
+ discoverBmc(true).then(result => writeJson(res, 202, result))
600
+ .catch(() => writeJson(res, 500, { started: false }))
601
+ return
602
+ }
603
+ if (req.method !== 'GET') {
604
+ writeJson(res, 405, { ok: false })
605
+ return
606
+ }
607
+ const live = url.searchParams.get('probe') === '1'
608
+ ? awaitTunnelProbe()
609
+ : Promise.resolve(null)
610
+ live.then(probe => writeJson(res, 200, {
611
+ target: bmcState.target,
612
+ phase: bmcState.phase,
613
+ discoveryConfigured: envConfig.sshTarget !== null,
614
+ ok: probe ? probe.ok : null,
615
+ probeStatus: probe ? probe.status : null,
616
+ lastError: bmcState.lastError,
617
+ lastErrorAt: bmcState.lastErrorAt,
618
+ lastUpstreamError: bmcState.lastUpstreamError,
619
+ lastDiscovery: bmcState.lastDiscovery,
620
+ roundStartedAt: bmcState.roundStartedAt,
621
+ roundEndedAt: bmcState.roundEndedAt,
622
+ discovering,
623
+ uptimeMs: Date.now() - bmcState.startedAt,
624
+ }))
625
+ }),
626
+ }, 'dsh-obmc-web: /bmc-status')
627
+
628
+ // Deployment config: read current values + relay unit state; save the
629
+ // Settings form's values into the shared env file (validated, in-place,
630
+ // comments preserved). Tunnel-affecting changes restart a running relay.
631
+ const publicConfig = () => ({
632
+ bmcTarget: envConfig.bmcTarget,
633
+ sshTarget: envConfig.sshTarget,
634
+ sshPort: envConfig.sshPort,
635
+ subnet: envConfig.subnet,
636
+ relayPort: envConfig.relayPort,
637
+ })
638
+ const relayInfo = () => relayActiveState().then(active => ({ installed: fs.existsSync(unitPath()), active }))
639
+ register({
640
+ kind: 'exact',
641
+ path: '/bmc-config',
642
+ handler: guarded((req, res) => {
643
+ if (req.method === 'GET') {
644
+ relayInfo().then(relay => writeJson(res, 200, { envFile: ENV_FILE, config: publicConfig(), relay }))
645
+ return
646
+ }
647
+ if (req.method !== 'POST') {
648
+ writeJson(res, 405, { ok: false })
649
+ return
650
+ }
651
+ readJsonBody(req).then(body => {
652
+ const updates = {}
653
+ const errors = []
654
+ if (body.bmcTarget !== undefined && body.bmcTarget !== '') {
655
+ const normalized = normalizeBmcTarget(String(body.bmcTarget).trim())
656
+ if (normalized === null) errors.push('BMC 地址格式无效(期望 ip 或 ip:port)')
657
+ else updates.BMC_TARGET = normalized
658
+ }
659
+ if (body.sshTarget !== undefined && body.sshTarget !== '') {
660
+ const value = String(body.sshTarget).trim()
661
+ if (!isValidSshTarget(value)) errors.push('SSH 跳板机格式无效(期望 user@host)')
662
+ else updates.OBMC_SSH_TARGET = value
663
+ }
664
+ for (const [field, key, label] of [
665
+ ['sshPort', 'OBMC_SSH_PORT', 'SSH 端口'],
666
+ ['relayPort', 'OBMC_RELAY_PORT', 'relay 端口'],
667
+ ]) {
668
+ if (body[field] !== undefined && body[field] !== '') {
669
+ const value = String(body[field]).trim()
670
+ if (!isValidPort(value)) errors.push(`${label}无效(1-65535)`)
671
+ else updates[key] = value
672
+ }
673
+ }
674
+ if (body.subnet !== undefined && body.subnet !== '') {
675
+ const value = String(body.subnet).trim()
676
+ if (!isValidSubnet(value)) errors.push('网段格式无效(期望 a.b.c)')
677
+ else updates.OBMC_SUBNET = value
678
+ }
679
+ if (errors.length > 0) {
680
+ writeJson(res, 400, { errors })
681
+ return
682
+ }
683
+ const before = { bmcTarget: envConfig.bmcTarget, relayPort: envConfig.relayPort }
684
+ if (Object.keys(updates).length > 0) updateEnvFile(updates)
685
+ Object.assign(envConfig, readEnvConfig())
686
+ bmcState.target = envConfig.bmcTarget
687
+ // Only a change to a tunnel-affecting key warrants bouncing a
688
+ // running relay; first-save or discovery-only edits do not.
689
+ const tunnelChanged = before.bmcTarget !== envConfig.bmcTarget || before.relayPort !== envConfig.relayPort
690
+ return relayActiveState().then(state => {
691
+ const verified = tunnelChanged && state === 'active' ? relayRestart() : Promise.resolve(null)
692
+ return verified.then(result => relayInfo().then(relay => writeJson(res, 200, {
693
+ saved: true,
694
+ config: publicConfig(),
695
+ relay,
696
+ verified: result,
697
+ })))
698
+ })
699
+ }).catch(error => writeJson(res, 400, { errors: [error.message] }))
700
+ }),
701
+ }, 'dsh-obmc-web: /bmc-config')
702
+
703
+ // One-click relay unit management: generate the unit from the current
704
+ // validated config, daemon-reload, (re)start it and verify the tunnel.
705
+ // If the configured relay port is taken by something else, shift to the
706
+ // next free port and report the adjustment.
707
+ register({
708
+ kind: 'exact',
709
+ path: '/bmc-relay-install',
710
+ handler: guarded((req, res) => {
711
+ if (req.method !== 'POST') {
712
+ writeJson(res, 405, { ok: false })
713
+ return
714
+ }
715
+ ;(async () => {
716
+ if (envConfig.bmcTarget === null || envConfig.sshTarget === null) {
717
+ writeJson(res, 400, { errors: ['请先在部署配置里填写 BMC 地址与 SSH 跳板机并保存'] })
718
+ return
719
+ }
720
+ let portAdjusted = null
721
+ if (await portInUse(envConfig.relayPort) && await relayActiveState() !== 'active') {
722
+ let found = null
723
+ for (let candidate = envConfig.relayPort + 1; candidate <= envConfig.relayPort + 50; candidate += 1) {
724
+ if (!(await portInUse(candidate))) { found = candidate; break }
725
+ }
726
+ if (found === null) {
727
+ writeJson(res, 500, { errors: [`端口 ${envConfig.relayPort} 被占用,且其后 50 个端口均无空闲`] })
728
+ return
729
+ }
730
+ updateEnvFile({ OBMC_RELAY_PORT: String(found) })
731
+ Object.assign(envConfig, readEnvConfig())
732
+ bmcState.target = envConfig.bmcTarget
733
+ portAdjusted = found
734
+ }
735
+ try {
736
+ fs.mkdirSync(path.dirname(unitPath()), { recursive: true })
737
+ fs.writeFileSync(unitPath(), generateUnit(envConfig), { mode: 0o644 })
738
+ } catch (error) {
739
+ writeJson(res, 500, { errors: [`写入 unit 文件失败:${error.message}`] })
740
+ return
741
+ }
742
+ try {
743
+ await systemdManagerCall(['Reload'])
744
+ const verified = await relayRestart()
745
+ const relay = await relayInfo()
746
+ writeJson(res, 200, { installed: true, relay, verified, portAdjusted })
747
+ } catch (error) {
748
+ writeJson(res, 500, { errors: [`systemd 操作失败:${error.message}`] })
749
+ }
750
+ })()
751
+ }),
752
+ }, 'dsh-obmc-web: /bmc-relay-install')
753
+
754
+ // The iframe entry: GUI origin '/bmc…' → BMC '/…' ('' maps to '/').
755
+ register({
756
+ kind: 'prefix',
757
+ path: '/bmc',
758
+ handler: guarded((req, res) => {
759
+ const url = new URL(req.url ?? '/', 'http://dsh.internal')
760
+ const rest = url.pathname.slice('/bmc'.length)
761
+ const target = (rest === '' ? '/' : rest) + url.search
762
+ proxyRequest(req, res, target, ctx.logger)
763
+ }),
764
+ }, 'dsh-obmc-web: /bmc entry prefix')
765
+
766
+ // Root-absolute asset/API families the bmcweb SPA hardcodes.
767
+ for (const path of ['/js', '/css', '/redfish']) {
768
+ register({
769
+ kind: 'prefix',
770
+ path,
771
+ handler: guarded((req, res) => proxyRequest(req, res, req.url ?? '/', ctx.logger)),
772
+ }, `dsh-obmc-web: ${path} prefix`)
773
+ }
774
+ for (const path of ['/login', '/logout']) {
775
+ register({
776
+ kind: 'exact',
777
+ path,
778
+ handler: guarded((req, res) => proxyRequest(req, res, req.url ?? '/', ctx.logger)),
779
+ }, `dsh-obmc-web: ${path} exact`)
780
+ }
781
+
782
+ // WebSocket endpoints (KVM / console) — exact-path upgrade tunnels.
783
+ for (const path of ['/kvm', '/console']) {
784
+ try {
785
+ ctx.effect(() => ctx.webServer.registerUpgrade({
786
+ path,
787
+ handler: (req, socket, head) => {
788
+ if (!fence(req)) {
789
+ socket.write('HTTP/1.1 403 Forbidden\r\n\r\n')
790
+ socket.destroy()
791
+ return
792
+ }
793
+ proxyUpgrade(req, socket, head, ctx.logger)
794
+ },
795
+ }), `dsh-obmc-web: ${path} upgrade`)
796
+ } catch (error) {
797
+ ctx.logger?.warn?.(`[dsh-obmc-web] skipped ${path} upgrade: ${error.message}`)
798
+ }
799
+ }
800
+ }
801
+
802
+ /** Serialized live tunnel probe (dedupes concurrent client polls). */
803
+ let probeInFlight = null
804
+ let probeAt = 0
805
+ function awaitTunnelProbe() {
806
+ if (probeInFlight !== null && Date.now() - probeAt < 4_000) return probeInFlight
807
+ probeAt = Date.now()
808
+ probeInFlight = tunnelRequest('/redfish/v1', 6_000).finally(() => {
809
+ setTimeout(() => { probeInFlight = null }, 4_000)
810
+ })
811
+ return probeInFlight
812
+ }