@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/LICENSE +21 -0
- package/README.md +136 -0
- package/cordis.patch.yml +3 -0
- package/deploy/obmc-web-relay.service.example +22 -0
- package/deploy/relay.env.example +38 -0
- package/lib/client.js +497 -0
- package/lib/index.js +812 -0
- package/package.json +57 -0
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
|
+
}
|