fix(webui): 反代三处缺陷 —— 入口不显示、子域链接失效、前缀重定向逃逸

全部来自本机 systemd 实例的实测,不是推测。

1. 插件页「服务入口」永远显示"暂无"
   TABS.plugins.fetch 漏了 proxyServices,而 renderPlugins 第一行就调
   renderProxyServicesCard()(读 state.proxyServices)。于是该页签
   永远不请求 /api/v1/proxy/services —— journalctl 实测:用户正常使用
   期间零请求,只有我手工 curl 的那一条。
   判据 TestProxyServicesReachesPluginsTab 钉住三环(A 调用 B、B 读
   state、C fetch 含块名),已做变异验证(删掉该项立刻红)。
   不用静态调用图做通用依赖检查:实测它在 dashboard.js 上不可靠
   (中文注释引号破坏配平、分发器展开导致大量误报),已在注释里
   写明原因,避免后人重复踩。

2. 面板生成的子域链接 100% 进不了反代
   生成链接走 base_url 的主机名,路由匹配只认 base_domain。
   生产实例 base_url=homeagent.jianfgit.xyz、base_domain=localhost,
   于是链接落到门户 mux → 302 /login,而手拼 *.localhost 反而能进。
   新增 proxyBaseDomains 返回全部候选基域(配置值 → base_url 主机名
   → localhost),serveProxyHost 用它匹配。两者都认,不是二选一:
   *.localhost 是 RFC 6761 的零配置基座,不能丢。

3. 前缀模式下上游根绝对路径重定向逃出挂载点
   实测 NapCat:上游 GET / → 301 Location: /webui,浏览器跟随到
   https://<门户>/webui(已跑出 /napcat),门户 mux 接住 → 302 /login。
   httputil.ReverseProxy 不改写 Location,在 ModifyResponse 里补:
   只改以 / 开头且未带前缀的,避开 //host 与已经带前缀的(否则会出现
   /napcat/napcat/webui)。

顺带纠正:base_domain 保持原配置不动,改由代码兼容两种基域 ——
改配置会抢走别的 *.jianfgit.xyz 子域,且 *.jianfgit.xyz 是单层通配,
huawei-smarthome.homeagent.jianfgit.xyz 本来就证书不匹配(实测
verify error 62)。要外网可用子域形态,应把 base_domain 配成
jianfgit.xyz,那是运维决策,不在本次改动内。
This commit is contained in:
JianFeeeee
2026-11-04 17:15:43 +08:00
parent 0c959e3ff3
commit 07d35297a6
6 changed files with 489 additions and 7 deletions

View File

@ -0,0 +1,302 @@
package webui
// 判据:「插件」页签的「服务入口」必须真的被请求到数据。
//
// ## 要判的是什么(2026-11-04 生产实测)
//
// 「插件」页签的「服务入口」卡片永远显示「暂无被反代的插件服务」,
// 而 /api/v1/proxy/services **一次都没被请求过**(journalctl 实测:
// 该端点只有我手工 curl 的那一条 401,用户正常使用期间零请求)。
//
// 根因:`renderPlugins()` 第一行就调 `renderProxyServicesCard()`,
// 而后者读 `state.proxyServices`;但 TABS 表里
//
// plugins: { fetch: ["status","kernel","plugins","runtime"], ... } ← 没有 proxyServices
//
// 于是 `state.proxyServices` 永远是 undefined ⇒ `svcs = []` ⇒ 空卡片。
// 只有先点进「对话」页(那里 fetch 了 proxyServices)再回「插件」才能看到。
//
// ## 为什么钉「三条事实」而不是做静态调用图
//
// 我第一版写了个递归调用图提取器(render 根 → 展开同文件调用 → 收集
// `state.X` 读点 → 比对 TABS.fetch)。它在实测中**完全失效**:
//
// 1. 中文注释里的引号(「」与 ')会破坏括号/字符串配平扫描,
// `escHtml` 的函数体被误判成覆盖到文件末尾 ⇒ renderAdapters 的
// "调用链"里出现 renderKernel / renderOneSettings / renderAll……
// 2. 分发器 `starmapFetchBlock` 的 switch 覆盖全部块名,展开它会让
// 任何调过它的 render 都被判成「缺十几块数据」。
// 3. 按需加载的 state(messages / chatOffset / starmapInit)不属于
// TABS.fetch,误报成缺口。
//
// 结论:**JS 的静态依赖分析在这个文件上不可靠**。可靠的是钉住本缺陷
// 的**结构性链条**——三件必须同时成立的事,每件都是一次廉价字符串检查:
//
// A. renderPlugins 确实调用了 renderProxyServicesCard
// B. renderProxyServicesCard 确实读 state.proxyServices
// C. TABS.plugins.fetch 确实含 "proxyServices"
//
// 任何一环断了都不会再有"空卡片"这种静默故障:A 断 ⇒ 卡片不再渲染
// (显而易见的 UI 变化,评审会看见);B 断 ⇒ 读不到数据(同样明显)。
// C 是本次故障的那一环,也是唯一纯粹"看不见"的一环。
//
// 更通用的依赖覆盖检查需要真正的 JS 语义分析(或在 harness 里记录
// fetch 调用序列并与页签切换做配对断言)。那属于测试补强,另行安排;
// 这里刻意只保留一条不会误报、不会假绿的判据。
import (
"strings"
"testing"
)
// funcBody 取顶层 `function name(` 的花括号体(配平扫描)。
//
// 只在**函数体内部**做配平,遇到不平衡就返回空串让调用方判失败——
// 宁可判「找不到」也不要基于错误范围给出假结论。
func funcBody(js, name string) string {
marker := "function " + name + "("
idx := strings.Index(js, marker)
if idx == -1 {
return ""
}
// 从开括号起配平参数表。marker 已包含那个 '(',所以深度从 1 起。
i := idx + len(marker)
depth := 1
for ; i < len(js); i++ {
switch js[i] {
case '(':
depth++
case ')':
depth--
}
if depth == 0 {
break
}
}
if i >= len(js) {
return ""
}
// 跳过返回类型/空白,定位函数体 '{'。
for ; i < len(js) && js[i] != '{'; i++ {
if js[i] == ';' || js[i] == '=' {
return "" // 不是声明,是别的形态
}
}
if i >= len(js) {
return ""
}
start := i
d := 0
inStr := byte(0)
for ; i < len(js); i++ {
c := js[i]
if inStr != 0 {
if c == '\\' {
i++
continue
}
if c == inStr {
inStr = 0
}
continue
}
switch c {
case '"', '\'', '`':
inStr = c
case '{':
d++
case '}':
d--
if d == 0 {
return js[start : i+1]
}
}
}
return ""
}
// TestProxyServicesReachesPluginsTab 是本次实测缺陷的回归判据。
//
// 变异验证:把 TABS.plugins.fetch 里的 "proxyServices" 删掉 ⇒ 立刻红。
func TestProxyServicesReachesPluginsTab(t *testing.T) {
js := dashboardJSSource(t)
// —— A. renderPlugins 调用 renderProxyServicesCard ——
plug := funcBody(js, "renderPlugins")
if plug == "" {
t.Fatal("找不到 renderPlugins 的函数体 —— 解析器与源码形态不同步,本判据此刻是假绿")
}
if !strings.Contains(plug, "renderProxyServicesCard(") {
t.Fatalf("renderPlugins 不再调用 renderProxyServicesCard()。\n" +
"若这是刻意移除,请同时删掉本判据;若是重构漏了,服务入口卡片就消失了。")
}
// —— B. renderProxyServicesCard 读取 state.proxyServices ——
card := funcBody(js, "renderProxyServicesCard")
if card == "" {
t.Fatal("找不到 renderProxyServicesCard 的函数体 —— 同上,假绿风险")
}
if !strings.Contains(card, "state.proxyServices") {
t.Fatalf("renderProxyServicesCard 不再读 state.proxyServices —— " +
"要么它换了数据源(本判据需同步),要么卡片会永远为空")
}
// —— C. TABS.plugins.fetch 含 proxyServices ——
tabs := parseTabs(t, js)
for _, tab := range tabs {
if tab.name != "plugins" {
continue
}
for _, f := range tab.fetch {
if f == "proxyServices" {
return // 三环皆成立
}
}
t.Fatalf("TABS.plugins.fetch 缺少 \"proxyServices\"。\n"+
"症状(2026-11-04 生产实测):「服务入口」卡片永远显示"+
"「暂无被反代的插件服务」,即使插件确实声明了 proxies ——"+
"因为 /api/v1/proxy/services 根本没被请求。\n"+
"renderPlugins 第一行调用 renderProxyServicesCard(),后者读 state.proxyServices;"+
"renderPlugins 是 TABS.plugins.render,所以 proxyServices 必须在该页签的 fetch 里。\n"+
"当前 fetch = %v", tab.fetch)
}
t.Fatal("TABS 里找不到 plugins 页签 —— 解析器与源码形态不同步,本判据此刻是假绿")
}
// tabsSpec 是从 dashboard.js 源码解析出的 TABS 条目。
type tabsSpec struct {
name string
fetch []string
renders []string
}
// parseTabs 用**花括号配平扫描**解析 TABS 对象。
//
// 为什么不用正则切条目(实测踩过):单行条目
// `starmap: { fetch: [], render: [...] },` 的右花括号在同一行,
// 非贪婪正则于是吞到**下一个**多行条目末尾 —— `starmap` 把紧随其后的
// `plugins` 整块吞进自己体内,症状是 plugins 页签"不存在"。
func parseTabs(t *testing.T, js string) []tabsSpec {
t.Helper()
start := strings.Index(js, "var TABS = {")
if start == -1 {
t.Fatal("找不到 var TABS")
}
openBrace := start + strings.Index(js[start:], "{")
// 配平 TABS 主体。
d := 0
end := -1
for i := openBrace; i < len(js); i++ {
switch js[i] {
case '{':
d++
case '}':
d--
if d == 0 {
end = i
}
}
if end >= 0 {
break
}
}
if end < 0 {
t.Fatal("TABS 花括号配平失败")
}
obj := js[openBrace+1 : end]
var out []tabsSpec
i := 0
for i < len(obj) {
// 跳过空白与逗号/换行,停在下一个标识符。
for i < len(obj) && (obj[i] == ' ' || obj[i] == '\t' || obj[i] == '\n' || obj[i] == '\r' || obj[i] == ',') {
i++
}
k := i
for k < len(obj) && (obj[k] == '_' || obj[k] >= 'a' && obj[k] <= 'z' ||
obj[k] >= 'A' && obj[k] <= 'Z' || obj[k] >= '0' && obj[k] <= '9') {
k++
}
if k == i {
i++
continue
}
name := obj[i:k]
r := k
for r < len(obj) && (obj[r] == ' ' || obj[r] == '\t' || obj[r] == '\n' || obj[r] == '\r') {
r++
}
if r >= len(obj) || obj[r] != ':' {
i = k
continue
}
r++
for r < len(obj) && (obj[r] == ' ' || obj[r] == '\t' || obj[r] == '\n' || obj[r] == '\r') {
r++
}
if r >= len(obj) || obj[r] != '{' {
i = k
continue
}
dd := 0
close_ := -1
for j := r; j < len(obj); j++ {
switch obj[j] {
case '{':
dd++
case '}':
dd--
if dd == 0 {
close_ = j
}
}
if close_ >= 0 {
break
}
}
if close_ < 0 {
t.Fatalf("条目 %q 花括号配平失败", name)
}
body := obj[r : close_+1]
spec := tabsSpec{name: name}
if f := quotedList(body, "fetch:"); f != nil {
spec.fetch = f
}
if rr := quotedList(body, "render:"); rr != nil {
spec.renders = rr
}
out = append(out, spec)
i = close_ + 1
}
if len(out) == 0 {
t.Fatal("TABS 解析出 0 个页签 —— 解析器与源码形态不同步")
}
return out
}
// quotedList 取 `key: ["a", "b"]` 里的字符串列表。
func quotedList(body, key string) []string {
idx := strings.Index(body, key)
if idx == -1 {
return nil
}
lb := strings.Index(body[idx:], "[")
if lb == -1 {
return nil
}
lb += idx
rb := strings.Index(body[lb:], "]")
if rb == -1 {
return nil
}
inner := body[lb+1 : lb+rb]
var out []string
for _, p := range strings.Split(inner, ",") {
p = strings.Trim(strings.TrimSpace(p), `"`)
if p != "" {
out = append(out, p)
}
}
return out
}