@iasiv5/dsh-obmc-web 0.1.1 → 0.1.3
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/README.md +27 -4
- package/lib/index.js +174 -12
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# @iasiv5/dsh-obmc-web
|
|
2
2
|
|
|
3
|
-
在 DSH GUI 里使用 **BMC Web UI**(OpenBMC / bmcweb,AST2700 EVB
|
|
3
|
+
在 DSH GUI 里使用 **BMC Web UI**(OpenBMC / bmcweb,AST2700 EVB 实测;
|
|
4
|
+
插件本体于 DSH Web `0.1.7-rc.2` 实测)。
|
|
4
5
|
host 半身在 DSH web 服务器的 GUI origin 上注册一组同源代理路由,经
|
|
5
6
|
`obmc-web-relay.service`(SSH 本地转发,仅绑 `127.0.0.1`,端口默认
|
|
6
7
|
18443、被占自动顺延)抵达 BMC;client 半身仿照 dsh-surf,在「设置」
|
|
@@ -104,7 +105,8 @@ BMC 地址常由跳板侧 DHCP 动态分配,可能变化。当隧道目标失
|
|
|
104
105
|
报错,或 `/bmc-status?probe=1` 实测失败)时,插件自动 SSH 到跳板机定位
|
|
105
106
|
bmcweb 实际地址:先查 dnsmasq 租约表 + ARP 表(单纯换址时亚秒级返回),
|
|
106
107
|
不通再对候选网段做 TCP-443 扫描;候选地址以 `/redfish/v1` 应答做指纹
|
|
107
|
-
校验(200
|
|
108
|
+
校验(200 需带 `"RedfishVersion"` 服务签名;401 保留原判;当选后的
|
|
109
|
+
TLS 证书另经 TOFU 钉扎,见「安全说明」)。探测网段的确定顺序:配置的
|
|
108
110
|
`OBMC_SUBNET` → `BMC_TARGET` IP 的 /24 → 跳板机自身枚举的直连 IPv4
|
|
109
111
|
网段。找到后写回 env 文件的 `BMC_TARGET` 行、经 user manager 重启
|
|
110
112
|
relay,并通过隧道验证后才报告成功;若 relay unit 尚未安装(首次
|
|
@@ -121,8 +123,29 @@ bootstrap),地址仍会写入配置并提示下一步去点「安装并启
|
|
|
121
123
|
- 上游仅 `127.0.0.1`(SSH 隧道,端口默认 18443),不新增任何公网监听。
|
|
122
124
|
- 剥掉的响应头:`X-Frame-Options`、`Strict-Transport-Security`、
|
|
123
125
|
`WWW-Authenticate`(防止 bmcweb 的 401 Basic challenge 弹浏览器原生
|
|
124
|
-
凭据框;BMC SPA 走 POST /login JSON + cookie
|
|
125
|
-
`
|
|
126
|
+
凭据框;BMC SPA 走 POST /login JSON + cookie)、`Clear-Site-Data`
|
|
127
|
+
(bmcweb 登出会发 `Clear-Site-Data: "cache","cookies","storage"`,语义
|
|
128
|
+
是清空整个源的 Cookie 与存储——在同源代理下会连带清掉 GUI 自身的会话
|
|
129
|
+
Cookie,把 DSH 一起登出;BMC 侧登出不受影响:显式的 `SESSION=` 过期
|
|
130
|
+
Set-Cookie 照常透传,服务端会话照常销毁),CSP 仅移除
|
|
131
|
+
`frame-ancestors` 指令;其余响应头原样透传。
|
|
132
|
+
- 上行 Cookie 白名单(0.1.3 起):GUI 源的 Cookie 不再整串透传给
|
|
133
|
+
BMC——只有 bmcweb 自己的 `SESSION` / `XSRF-TOKEN` 允许跨隧道,其余
|
|
134
|
+
(尤其 DSH 会话 Cookie `__Host-dsh_auth_session`)在上行前剥离,
|
|
135
|
+
BMC 这个信任域永远收不到 DSH 会话。
|
|
136
|
+
- BMC TLS 证书 TOFU 钉扎(0.1.3 起):隧道对端的自签证书按 `BMC_TARGET`
|
|
137
|
+
记录 SHA-256 指纹(`~/.config/systemd/user/obmc-relay-trust.json`,
|
|
138
|
+
`OBMC_WEB_TRUST` 可改址),首次见到即钉扎;此后指纹不符的交换会被
|
|
139
|
+
502 拒绝、旧指纹作废、下一次请求重新钉扎——静默中间人最多拿到一次
|
|
140
|
+
被拒的交换,且拿不到 Cookie。BMC 侧重生成证书同样自动重钉扎;如需
|
|
141
|
+
彻底重置删除上述文件即可。WS(KVM/控制台)路径同受保护——顺带修复
|
|
142
|
+
了该路径调用不存在的 `https.connect` 导致首个升级请求即崩的问题。
|
|
143
|
+
- 非 `/login` 的上游 401 统一降级为 403:BMC SPA 的 axios 拦截器对其他
|
|
144
|
+
401 会执行 `window.location = "/login"`——根绝对路径的顶层导航,在
|
|
145
|
+
同源代理下会逃出 `/bmc` 前缀,落到 GUI 自己的 `/login` 并被认证门
|
|
146
|
+
重定向进 DSH 应用,把用户从 BMC 控制台拽走(首次打开最常见)。403
|
|
147
|
+
走 SPA 的原地「未授权」提示,不发生导航;`/login` 的真实 401 保留
|
|
148
|
+
(登录表单错误处理与 CSRF 探测依赖它)。
|
|
126
149
|
- 不在任何地方存储 BMC 凭据;登录在 BMC 自身页面完成。
|
|
127
150
|
- 信任围栏仿照 dsh-better-sidebar 的 trust-fence(Host 必须是 loopback
|
|
128
151
|
或部署信任的 authority、`Sec-Fetch-Site` 不得为 cross-site、Origin 若有
|
package/lib/index.js
CHANGED
|
@@ -10,11 +10,16 @@
|
|
|
10
10
|
* after its own exact table, so the GUI's own longer routes always win and
|
|
11
11
|
* a duplicate registration is skipped (never throws the plugin).
|
|
12
12
|
*
|
|
13
|
-
* Embed-hostile response headers are removed
|
|
14
|
-
* CSP frame-ancestors
|
|
15
|
-
* challenges can never pop the browser's native credential
|
|
16
|
-
* SPA logs in via POST /login JSON + cookie
|
|
17
|
-
*
|
|
13
|
+
* Embed-hostile and origin-hostile response headers are removed
|
|
14
|
+
* (X-Frame-Options, HSTS, CSP frame-ancestors, WWW-Authenticate so bmcweb's
|
|
15
|
+
* 401 Basic challenges can never pop the browser's native credential
|
|
16
|
+
* dialog — the BMC SPA logs in via POST /login JSON + cookie — and
|
|
17
|
+
* Clear-Site-Data, whose origin-wide wipe on BMC logout would otherwise
|
|
18
|
+
* destroy the GUI's session cookie). Non-/login 401s are degraded
|
|
19
|
+
* to 403: the BMC SPA redirects the whole tab to root-absolute /login on
|
|
20
|
+
* any other 401, which on this origin lands in the GUI's auth gate and
|
|
21
|
+
* bounces the browser into the DSH app. Everything else passes through
|
|
22
|
+
* untouched; no credentials are stored anywhere.
|
|
18
23
|
*
|
|
19
24
|
* BMC address discovery: the BMC's address is often DHCP-assigned by the
|
|
20
25
|
* jump host that links it (a small LAN of its own) and may change. When
|
|
@@ -47,6 +52,7 @@
|
|
|
47
52
|
*/
|
|
48
53
|
import https from 'node:https'
|
|
49
54
|
import net from 'node:net'
|
|
55
|
+
import tls from 'node:tls'
|
|
50
56
|
import { execFile, spawn } from 'node:child_process'
|
|
51
57
|
import fs from 'node:fs'
|
|
52
58
|
import os from 'node:os'
|
|
@@ -86,7 +92,18 @@ const HOP = new Set([
|
|
|
86
92
|
// www-authenticate is dropped so bmcweb's 401 Basic challenges (e.g. on
|
|
87
93
|
// /login and unknown paths) can never pop the browser's native credential
|
|
88
94
|
// dialog — the BMC SPA logs in via POST /login JSON + cookie, not Basic.
|
|
89
|
-
|
|
95
|
+
// clear-site-data is dropped because bmcweb's logout answers
|
|
96
|
+
// Clear-Site-Data: "cache","cookies","storage" — an instruction to wipe
|
|
97
|
+
// EVERY cookie/cache/storage entry for the ORIGIN. Through this same-origin
|
|
98
|
+
// proxy that origin is the GUI's: one BMC logoff would otherwise destroy
|
|
99
|
+
// the DSH session cookie (and DSH's localStorage) too. BMC-side logout
|
|
100
|
+
// still works without it: bmcweb expires its SESSION cookie via an
|
|
101
|
+
// explicit Set-Cookie (passes through) and destroys the session
|
|
102
|
+
// server-side; only the origin-wide nuke is suppressed.
|
|
103
|
+
const DROP_RESPONSE = new Set([
|
|
104
|
+
...HOP, 'x-frame-options', 'strict-transport-security', 'www-authenticate',
|
|
105
|
+
'clear-site-data',
|
|
106
|
+
])
|
|
90
107
|
|
|
91
108
|
const agent = new https.Agent({ keepAlive: true, maxSockets: 8, rejectUnauthorized: false })
|
|
92
109
|
|
|
@@ -323,7 +340,14 @@ for f in $lease_files; do
|
|
|
323
340
|
done
|
|
324
341
|
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
342
|
cands=$(printf '%s\\n' $cands | sort -u)
|
|
326
|
-
check() {
|
|
343
|
+
check() {
|
|
344
|
+
resp=$(curl -k -m 3 -s -w '\\n%{http_code}' "https://$1/redfish/v1" 2>/dev/null)
|
|
345
|
+
code=\${resp##*$'\\n'}
|
|
346
|
+
body=\${resp%$'\\n'*}
|
|
347
|
+
[ "\$code" = 200 ] && printf '%s' "\$body" | grep -q '"RedfishVersion"' && return 0
|
|
348
|
+
[ "\$code" = 401 ] && return 0
|
|
349
|
+
return 1
|
|
350
|
+
}
|
|
327
351
|
for c in $cands; do
|
|
328
352
|
if check "$c"; then echo "BMC_IP=$c"; exit 0; fi
|
|
329
353
|
done
|
|
@@ -489,17 +513,124 @@ function cspWithoutFrameAncestors(value) {
|
|
|
489
513
|
.join('; ')
|
|
490
514
|
}
|
|
491
515
|
|
|
516
|
+
// ── upstream trust domain hardening (audit 2026-09-28) ───────────────────
|
|
517
|
+
// The relay tunnel crosses into the BMC's trust domain. Two exposures are
|
|
518
|
+
// closed here:
|
|
519
|
+
// 1. Cookie bleed: the GUI origin's cookies (notably the DSH session cookie
|
|
520
|
+
// __Host-dsh_auth_session) used to ride every proxied request to the
|
|
521
|
+
// BMC. Only bmcweb's own cookie names may cross now.
|
|
522
|
+
// 2. TLS pinning (TOFU): the tunnel TLS terminates at the BMC's self-signed
|
|
523
|
+
// certificate. The first certificate seen for a BMC target is pinned to
|
|
524
|
+
// a trust file; a later mismatch is refused once, the stale pin dropped,
|
|
525
|
+
// and the next request re-pins — a silent MITM gets one refused exchange,
|
|
526
|
+
// never the session. Regenerated BMC certs re-enroll the same way.
|
|
527
|
+
|
|
528
|
+
const TRUST_FILE = process.env.OBMC_WEB_TRUST
|
|
529
|
+
?? path.join(os.homedir(), '.config/systemd/user/obmc-relay-trust.json')
|
|
530
|
+
const UPSTREAM_COOKIE_ALLOW = new Set(['session', 'xsrf-token'])
|
|
531
|
+
|
|
532
|
+
/** Keep only bmcweb's own cookie names; everything else never crosses. */
|
|
533
|
+
export function filterUpstreamCookies(cookieHeader) {
|
|
534
|
+
if (cookieHeader === undefined || cookieHeader === null) return undefined
|
|
535
|
+
const kept = String(cookieHeader)
|
|
536
|
+
.split(';')
|
|
537
|
+
.map(part => part.trim())
|
|
538
|
+
.filter(part => {
|
|
539
|
+
if (part === '') return false
|
|
540
|
+
const eq = part.indexOf('=')
|
|
541
|
+
const name = (eq === -1 ? part : part.slice(0, eq)).trim().toLowerCase()
|
|
542
|
+
return UPSTREAM_COOKIE_ALLOW.has(name)
|
|
543
|
+
})
|
|
544
|
+
return kept.length === 0 ? undefined : kept.join('; ')
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function loadTrustPins(file = TRUST_FILE) {
|
|
548
|
+
try {
|
|
549
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
550
|
+
if (parsed && typeof parsed === 'object' && parsed.pins && typeof parsed.pins === 'object') {
|
|
551
|
+
return parsed
|
|
552
|
+
}
|
|
553
|
+
} catch { /* missing/corrupt → treat as empty (first use re-pins) */ }
|
|
554
|
+
return { pins: {} }
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function writeTrustStore(store, file) {
|
|
558
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
559
|
+
const tmp = `${file}.tmp-${process.pid}`
|
|
560
|
+
fs.writeFileSync(tmp, JSON.stringify(store, null, 2) + '\n')
|
|
561
|
+
fs.renameSync(tmp, file)
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
export function saveTrustPin(target, fingerprint256, file = TRUST_FILE) {
|
|
565
|
+
const store = loadTrustPins(file)
|
|
566
|
+
store.pins[target] = fingerprint256
|
|
567
|
+
store.updatedAt = new Date().toISOString()
|
|
568
|
+
writeTrustStore(store, file)
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function clearTrustPin(target, file = TRUST_FILE) {
|
|
572
|
+
const store = loadTrustPins(file)
|
|
573
|
+
if (store.pins[target] === undefined) return
|
|
574
|
+
delete store.pins[target]
|
|
575
|
+
store.updatedAt = new Date().toISOString()
|
|
576
|
+
writeTrustStore(store, file)
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Build a checkServerIdentity-style validator for the tunnel TLS. TOFU:
|
|
581
|
+
* the first certificate seen for the configured BMC target is pinned; a
|
|
582
|
+
* later mismatch is refused once and the stale pin dropped (the next
|
|
583
|
+
* request re-enrolls). target=null (no BMC_TARGET in env) disables pinning.
|
|
584
|
+
*/
|
|
585
|
+
export function makeTlsPinChecker(target, file = TRUST_FILE) {
|
|
586
|
+
if (!target) return undefined
|
|
587
|
+
return (_host, cert) => {
|
|
588
|
+
const fp = cert?.fingerprint256
|
|
589
|
+
if (typeof fp !== 'string' || fp === '') return // nothing to pin on; do not fail the exchange
|
|
590
|
+
const pins = loadTrustPins(file).pins
|
|
591
|
+
const known = pins[target]
|
|
592
|
+
if (known === undefined) {
|
|
593
|
+
saveTrustPin(target, fp, file)
|
|
594
|
+
return
|
|
595
|
+
}
|
|
596
|
+
if (known !== fp) {
|
|
597
|
+
clearTrustPin(target, file)
|
|
598
|
+
throw new Error(`BMC TLS certificate for ${target} changed (TOFU pin mismatch) — pin dropped; next request re-enrolls`)
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
492
603
|
/** Forward one plain HTTP exchange to the BMC tunnel. */
|
|
493
604
|
function proxyRequest(req, res, targetPath, log) {
|
|
494
605
|
const headers = {}
|
|
495
606
|
for (const [key, value] of Object.entries(req.headers)) {
|
|
496
|
-
|
|
607
|
+
const lower = key.toLowerCase()
|
|
608
|
+
if (HOP.has(lower) || lower === 'host' || lower === 'cookie') continue
|
|
497
609
|
headers[key] = value
|
|
498
610
|
}
|
|
499
611
|
headers.host = `${UP_HOST}:${envConfig.relayPort}`
|
|
612
|
+
const cookie = filterUpstreamCookies(req.headers.cookie)
|
|
613
|
+
if (cookie !== undefined) headers.cookie = cookie
|
|
500
614
|
const upstream = https.request(
|
|
501
|
-
{
|
|
615
|
+
{
|
|
616
|
+
host: UP_HOST, port: envConfig.relayPort, method: req.method, path: targetPath, headers, agent,
|
|
617
|
+
},
|
|
502
618
|
(up) => {
|
|
619
|
+
// TOFU pin enforcement: with rejectUnauthorized:false Node skips
|
|
620
|
+
// checkServerIdentity entirely, so the pin is verified manually per
|
|
621
|
+
// exchange against the TLS socket's peer certificate (audit 2026-09-28).
|
|
622
|
+
const fp = up.socket?.getPeerCertificate?.(true)?.fingerprint256
|
|
623
|
+
try {
|
|
624
|
+
makeTlsPinChecker(envConfig.bmcTarget)?.(null, { fingerprint256: fp })
|
|
625
|
+
} catch (err) {
|
|
626
|
+
log?.warn?.(`[dsh-obmc-web] ${err.message}`)
|
|
627
|
+
up.destroy()
|
|
628
|
+
try {
|
|
629
|
+
res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' })
|
|
630
|
+
res.end('dsh-obmc-web: BMC TLS certificate changed (TOFU pin mismatch) — pin dropped; the next request re-enrolls')
|
|
631
|
+
} catch { /* socket already gone */ }
|
|
632
|
+
return
|
|
633
|
+
}
|
|
503
634
|
const raw = []
|
|
504
635
|
for (let i = 0; i < up.rawHeaders.length; i += 2) {
|
|
505
636
|
const key = up.rawHeaders[i]
|
|
@@ -514,7 +645,20 @@ function proxyRequest(req, res, targetPath, log) {
|
|
|
514
645
|
}
|
|
515
646
|
raw.push(key, value)
|
|
516
647
|
}
|
|
517
|
-
|
|
648
|
+
// The BMC SPA's axios interceptor treats ANY 401 outside /login as
|
|
649
|
+
// "session lost" and answers with `window.location = "/login"` — a
|
|
650
|
+
// ROOT-ABSOLUTE navigation that escapes the /bmc prefix. On this
|
|
651
|
+
// origin /login belongs to the GUI's auth gate, which 307s such
|
|
652
|
+
// navigations into the DSH app, yanking the browser out of the BMC
|
|
653
|
+
// console entirely (seen on the first console open after a DSH
|
|
654
|
+
// restart: the SPA boots with stale localStorage login hints, probes
|
|
655
|
+
// /redfish/... while logged out, and the 401 bounce follows). Degrade
|
|
656
|
+
// every non-/login 401 to 403: the SPA's 403 path (setUnauthorized)
|
|
657
|
+
// is an in-place toast, never a navigation. /login keeps its true
|
|
658
|
+
// status so the login form's error handling and the CSRF probe keep
|
|
659
|
+
// working.
|
|
660
|
+
const status = up.statusCode === 401 && !/^\/login(\?|$)/.test(targetPath) ? 403 : up.statusCode
|
|
661
|
+
res.writeHead(status, raw)
|
|
518
662
|
up.pipe(res)
|
|
519
663
|
},
|
|
520
664
|
)
|
|
@@ -535,11 +679,29 @@ function proxyRequest(req, res, targetPath, log) {
|
|
|
535
679
|
function proxyUpgrade(req, socket, head, log) {
|
|
536
680
|
const headers = {}
|
|
537
681
|
for (const [key, value] of Object.entries(req.headers)) {
|
|
538
|
-
|
|
682
|
+
const lower = key.toLowerCase()
|
|
683
|
+
if (lower === 'host' || lower === 'cookie') continue
|
|
539
684
|
headers[key] = value
|
|
540
685
|
}
|
|
541
686
|
headers.host = `${UP_HOST}:${envConfig.relayPort}`
|
|
542
|
-
const
|
|
687
|
+
const cookie = filterUpstreamCookies(req.headers.cookie)
|
|
688
|
+
if (cookie !== undefined) headers.cookie = cookie
|
|
689
|
+
// NOTE: https.connect does not exist in Node — the WS path used to crash
|
|
690
|
+
// with a TypeError on the first upgrade request. The tunnel is a plain
|
|
691
|
+
// TLS socket to the relay; tls.connect is the correct primitive, and the
|
|
692
|
+
// secureConnect callback is where the TOFU pin now runs (first byte).
|
|
693
|
+
const upstream = tls.connect({ host: UP_HOST, port: envConfig.relayPort, rejectUnauthorized: false }, () => {
|
|
694
|
+
const fp = typeof upstream.getPeerCertificate === 'function'
|
|
695
|
+
? upstream.getPeerCertificate(true)?.fingerprint256
|
|
696
|
+
: undefined
|
|
697
|
+
try {
|
|
698
|
+
makeTlsPinChecker(envConfig.bmcTarget)?.(null, { fingerprint256: fp })
|
|
699
|
+
} catch (err) {
|
|
700
|
+
log?.warn?.(`[dsh-obmc-web] ${err.message}`)
|
|
701
|
+
socket.destroy()
|
|
702
|
+
upstream.destroy()
|
|
703
|
+
return
|
|
704
|
+
}
|
|
543
705
|
const lines = [`${req.method} ${req.url} HTTP/1.1`]
|
|
544
706
|
for (const [key, value] of Object.entries(headers)) lines.push(`${key}: ${value}`)
|
|
545
707
|
upstream.write(lines.join('\r\n') + '\r\n\r\n')
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@iasiv5/dsh-obmc-web",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "OpenBMC 控制台入口:设置一级菜单「BMC 控制台」面板一键在顶层标签页打开 BMC Web UI,同源代理经 SSH 本地转发 relay(obmc-web-relay)抵达 BMC,BMC IP 变化自动探测并重定向隧道;需 SSH 跳板机可达 BMC。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|