Files
HomeAgent/third_party/hologramagentsdk/sdk/proxy.go
JianFeeeee 07d35297a6 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,那是运维决策,不在本次改动内。
2026-11-04 17:15:43 +08:00

431 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package sdk
import (
"net"
"strconv"
"strings"
)
// 反向代理声明:插件告诉 HologramAgent「我起了个 HTTP 服务,请把它反代出去」。
//
// 为什么需要:插件自带 Web UI / HTTP API 时,监听地址在插件自己的配置里
// (如 127.0.0.1:12100),外部无从得知;而 webui 的对外端口通常只有一个
// (默认 :8080,且常经 frp 单端口隧道穿透)。没有声明机制时,用户只能
// 「知道端口 + 自己配转发」,插件换端口就失效。
//
// 设计取舍——**声明式而非注册式**:声明写在 plugin.json 里,由 HologramAgent
// 在加载插件时读取聚合,而不是让插件在运行期调 API 注册。理由:
// 1. 静态可发现:未启动/已崩溃的插件,其服务声明依然可见(可给出准确报错
// 「插件 X 声明了 ui 但目标 127.0.0.1:12100 不可达」,而不是静默 404);
// 2. 可版本化:声明随插件包一起分发、可 diff、可审计;
// 3. 旧内核无害:manifest 解析忽略未知字段,未支持该能力的 HologramAgent 读旧
// 插件、或旧 HologramAgent 读新插件都不会报错。
//
// 与 ToolDef / ChannelDef / ConfigDef 同族:SDK 定义声明契约,内核实现行为。
//
// # 两种声明入口,以及各自**谁能用**(重要)
//
// - **plugin.json 的 proxies 字段**:这是**外部插件唯一的入口**。
// 由 HologramAgent 加载插件时读取 plugin.json 聚合,不经过插件进程的 SDK。
//
// - **RegisterProxy(name, def)**:**仅对内置插件有效**(编译进内核、
// 没有插件目录与 plugin.json 的那些,如 remotedevice)。
// 外部插件(独立 plugin.bin 子进程)调它**不会生效**,也不会报错。
//
// 为什么外部插件不能用运行期注册:插件跑在子进程里,声明要跨 RPC 协议回传
// 才可能落地,而 proc 协议目前**没有** proxy 注册方法(grep "proxy" in
// internal/plugin/proc/protocol.go 零命中),生成到插件工程的运行时模板也
// 不注入该注册回调(同 grep 零命中)。所以外部插件的 RegisterProxy 是
// 一个**空实现**。
//
// 历史提醒:本文件早期版本写的是「外部插件两种都支持」,那是错的,
// 造成了“声明了却没被反代、排查时只查端口”的真实困境。若将来要让运行期
// 声明对外部插件生效,必须先补协议,而不是只把这句话改回去。
//
// 安全性:**不声明 = 不被反代**。声明本身就是能力声明,因此不需要在
// capabilities 里另外开一个开关——最小权限默认生效。
//
// # 单一入口原则(强制要求)
//
// **一个声明 = 一个入口**。被反代的插件必须让它的全部资源与接口都能从
// 该入口的一个基准路径出发访问到,不得依赖「入口之外的根路径」。
//
// 为什么强制:反代有两种挂载形态,而它们对「根路径」的处理截然不同——
//
// Host 形态(host):插件独占 <标签>.<基域名>,根路径就是插件的根。
// 根绝对路径(fetch('/api/x'))**天然正确**。
// Path 形态(path):插件挂在门户自身 host 的某个前缀下,根路径属于**门户**。
// 此时插件里的 fetch('/api/x') 会打到门户自己的 /api/x
// —— 静默错路由,页面能开但功能全坏。
//
// 于是「同一个插件必须同时支持两种形态」这条要求,等价于:
//
// **插件内部一律使用相对路径**(或基于 <base>/location 推导的路径),
// 绝不硬编码以 / 开头的绝对路径。
//
// 这样同一份前端在两种形态下都正确,插件作者也不必知道自己被挂在哪。
// 反代层据此可以:外部子域可用时给 Host 形态,子域不可用(证书/放行限制)
// 时给 Path 形态,**无需插件配合改动**。
//
// 自检(插件作者在本地就该做):把页面挂到 <门户>/<任意前缀>/ 下访问,
// 所有请求都必须仍然打到插件自己。
//
// 本项目实测案例:某插件前端写死 fetch('/api/status'),配在
// /p/huawei/ 下会打到门户的 /api/status(404 或返回门户数据);
// 改成相对路径后两种形态同时可用。
// ProxyDef 是一个服务的**反代声明体**。
//
// 与 ToolDef 同构:Name 同时出现在字段与 RegisterProxy 的第一个参数里
// (ToolDef 也是这么做的 —— 字段供 plugin.json 序列化,参数供运行期调用)。
// Name 只用于展示、日志与冲突提示,**不参与路由**(路由键是 Host 与 Path)。
type ProxyDef struct {
// Name 是同一插件内多条声明的唯一标识(如 "ui"、"api")。
// 运行期由 RegisterProxy 的第一个参数填入;声明式由 plugin.json 的
// name 键填入。省略时由 HologramAgent 兜底为 "service"。
Name string `json:"name,omitempty"`
// Host 是**子域名标签**(不含基域名),如 "huawei" 对应 huawei.<基域名>。
//
// 约束:仅小写字母、数字与连字符,不以连字符开头/结尾,长度 ≤ 63
// (DNS label 规则)。省略时默认取插件名(下划线转连字符,因为下划线
// 不是合法 DNS label 字符)。
//
// 冲突处理:两个插件声明同一 Host 时,HologramAgent 不做「后者覆盖前者」——
// 那样会让先声明者静默消失。冲突条目被拒绝并在反代表里记录原因。
Host string `json:"host,omitempty"`
// Target 是上游地址,形如 "127.0.0.1:12100" 或 "http://127.0.0.1:12100"。
// 可带路径前缀(如 "127.0.0.1:3000/base"),HologramAgent 转发时保留该前缀。
//
// 端口由插件自己填它**实际监听**的地址,避免「声明与实际漂移」。
Target string `json:"target"`
// WebSocket 表示该服务需要 WebSocket 升级透传(默认 false)。
//
// 为什么必须显式声明而不是「有 Upgrade 头就转」:WS 是长连接,会占用
// 反代侧连接与 goroutine,且绕过普通请求的响应缓冲/超时逻辑。默认关闭
// 让普通 HTTP 服务的失败模式保持简单;未声明时的升级请求会被明确拒绝,
// 而不是静默降级成普通请求(后者表现为前端一直重连、排查困难)。
WebSocket bool `json:"websocket,omitempty"`
// Path 是可选的**路径挂载前缀**(如 "/api/v1/device")。
//
// 为什么 Host 子域之外还需要它:子域形态依赖 DNS 解析,而 *.localhost
// 只有浏览器内置该特例(RFC 6761)—— 普通进程(设备客户端、固件、
// CLI)走系统解析器,实测解析不到,会以「no such host」失败。
// 路径形态挂在门户自身 host 下,**无任何 DNS 依赖**,是给非浏览器
// 客户端用的。
//
// 语义:请求路径**原样保留**(不做前缀剥除)——声明者按上游真实路径填写,
// 例如上游注册 /api/v1/device/ws,就声明 Path="/api/v1/device"。
// 这样设备客户端可以直接使用它已硬编码的路径,不需要知道反代的存在。
//
// 与 Host 形态的关系(见包注释的「单一入口原则」):声明的服务应当
// **同时**能被两种形态访问。因此 Path 形态下插件内部必须用相对路径,
// 否则它的前端会把请求打到门户自己身上。
//
// 留空 = 只提供子域形态(插件自带 UI 的常见情形:UI 与它自己的 API
// 同源,走子域天然正确)。
Path string `json:"path,omitempty"`
// StripPath 决定转发前是否**剥掉** Path 前缀。默认 false(原样保留)。
//
// 两种挂载语义真实不同,必须由声明者选,不能靠猜:
//
// false(别名模式):Path 就是上游真实路径的一部分。
// 请求 /api/v1/device/ws + Path="/api/v1/device"
// → 上游收到 /api/v1/device/ws(一模一样)。
// 适用:客户端**已硬编码**路径的机器接口(设备网关就是如此,
// 它按 /api/v1/device/ws 连接,不可能知道反代的存在)。
//
// true(前缀模式):Path 只是门户上的挂载点,上游不知道它。
// 请求 /p/myapp/api/status + Path="/p/myapp"
// → 上游收到 /api/status。
// 适用:自带 UI 的服务(前端用相对路径,被挂到哪里都对)。
//
// 为什么不能自动判定:同一个声明「Path=/api/v1/device」在两种语义下
// 都说得通,代理无从分辨 —— 猜错的结果是全部请求 404,且看起来像
// 上游故障。所以由声明者显式写清楚。
StripPath bool `json:"strip_path,omitempty"`
// Auth 决定这条反代由谁保护,取值见 ProxyAuthNone / ProxyAuthHomeAgent。
// 空串等价于 ProxyAuthHomeAgent(默认安全)。
//
// 为什么做成可声明项:设备网关(remotedevice)这类服务的调用方是**设备**,
// 它们不可能持有浏览器会话 cookie,而服务自身已有接入令牌(如 ws_token)。
// 强制走 HologramAgent 门户鉴权会把这类链路挡死;反过来,插件自带的 UI 若
// 声明 none,就等于把管理界面裸露给任何能访问该端口的人。
// 因此必须由插件**逐条**声明,而不是全局一刀切。
Auth string `json:"auth,omitempty"`
}
// ProxyAuth 取值。空串按 ProxyAuthAgent 处理(安全的默认)。
const (
// ProxyAuthAgent 表示由内核统一保护:浏览器走门户会话
// (HologramAgent session cookie),非浏览器客户端走 X-API-Key。
// 两者都没有时返回 401,而不是把请求透传给上游。
ProxyAuthAgent = "agent"
// ProxyAuthNone 表示不经内核鉴权,直接把请求转发给上游。
//
// 适用场景:上游自己有鉴权且调用方不是浏览器(设备/嵌入式客户端),
// 或上游是刻意公开的服务。选用它意味着**信任上游自身的鉴权**,
// 且该服务在网络层可达范围内对所有人开放。
ProxyAuthNone = "none"
// ProxyAuthAgentLegacy 是旧值的字面量,**只用于兼容读取**,不要在新声明里写它。
//
// 为什么必须继续接受(2026-10-22):`Auth` 是写进 plugin.json 的**持久化
// 声明值**,存量插件的配置文件里已经写着旧值(本机实测 huawei_smarthome 的
// plugin.json:19 就是 "auth": "homeagent")。删掉这个分支 ⇒ 那些插件在加载
// 时被判为非法声明,反代直接不生效,且症状是"插件好好的但页面打不开"。
//
// 这正是 sdkModuleNames 处理 module 路径改名时的同一条教训:**协议值的
// 改名必须保留旧值的读取路径**,否则存量数据无声失效。
ProxyAuthAgentLegacy = "homeagent"
)
// ProxyAuthHomeAgent 是 ProxyAuthAgent 的旧名,仅为**存量源码**保留。
//
// Deprecated: 用 ProxyAuthAgent。存量插件的 plugin.json 里写的旧值仍被
// ValidProxyAuth/EffectiveProxyAuth 接受(见 ProxyAuthAgentLegacy)。
const ProxyAuthHomeAgent = ProxyAuthAgent
// ValidProxyAuth 校验 Auth 取值;空串合法(等价 ProxyAuthAgent)。
//
// 同时接受旧值 ProxyAuthAgentLegacy —— 存量 plugin.json 里写的仍是它。
func ValidProxyAuth(auth string) bool {
switch auth {
case "", ProxyAuthAgent, ProxyAuthNone, ProxyAuthAgentLegacy:
return true
}
return false
}
// EffectiveProxyAuth 返回生效的鉴权模式(空串与旧值都归一化为 ProxyAuthAgent)。
//
// 归一化旧值是关键:下游一律只可能拿到 ProxyAuthAgent,不必各自兼容旧值
// (否则每个判断点都要写 `== ProxyAuthAgent || == ProxyAuthAgentLegacy`,
// 漏一处就是鉴权绕过)。
func EffectiveProxyAuth(auth string) string {
if auth == "" || auth == ProxyAuthAgentLegacy {
return ProxyAuthAgent
}
return auth
}
// ValidProxyHostLabel 校验子域名标签是否合法(DNS label 规则)。
//
// 独立成导出函数:插件作者在写声明时、HologramAgent 在加载时、工具链在打包时
// 都要用同一套规则判定,避免三处各写一份而互相不一致。
func ValidProxyHostLabel(label string) bool {
if label == "" || len(label) > 63 {
return false
}
if label[0] == '-' || label[len(label)-1] == '-' {
return false
}
for i := 0; i < len(label); i++ {
c := label[i]
switch {
case c >= 'a' && c <= 'z', c >= '0' && c <= '9', c == '-':
default:
return false
}
}
return true
}
// NormalizeProxyHost 由插件名派生默认 Host 标签。
//
// 下划线转连字符:插件名允许下划线(huawei_smarthome),但 DNS label 不允许,
// 直接用会导致该子域名无法解析——这里统一转换,避免每个插件各自碰运气。
func NormalizeProxyHost(pluginName string) string {
s := strings.ToLower(strings.TrimSpace(pluginName))
s = strings.ReplaceAll(s, "_", "-")
// 去掉其它非法字符,保证结果是合法 label(宁可退化成保守值也不产出非法域名)
var b strings.Builder
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case c >= 'a' && c <= 'z', c >= '0' && c <= '9', c == '-':
b.WriteByte(c)
}
}
out := strings.Trim(b.String(), "-")
if out == "" {
return "plugin"
}
if len(out) > 63 {
out = strings.Trim(out[:63], "-")
}
return out
}
// ValidateProxyDef 校验一条反代声明,返回人类可读的错误说明(合法时为空)。
//
// 为什么要在 SDK 里做校验:HologramAgent 加载插件时必须能明确拒绝坏声明并说明
// 原因(而不是静默忽略导致用户以为配好了);插件作者也需要在本地就能查出
// 拼错的 Target/Host。同一套规则两端共用。
func ValidateProxyDef(d ProxyDef) string {
if strings.TrimSpace(d.Target) == "" {
return "target 为空:必须给出上游地址(如 127.0.0.1:12100 或 http://127.0.0.1:12100)"
}
if !ValidProxyAuth(d.Auth) {
return "auth 取值非法:" + d.Auth + "(只允许 \"\" / \"homeagent\" / \"none\")"
}
if d.Host != "" && !ValidProxyHostLabel(d.Host) {
return "host 不是合法的子域名标签(只允许小写字母/数字/连字符,且不以连字符开头结尾): " + d.Host
}
if p := strings.TrimSpace(d.Path); p != "" {
if !strings.HasPrefix(p, "/") {
return "path 必须以 / 开头: " + d.Path
}
if strings.HasSuffix(p, "/") {
return "path 不应以 / 结尾(它是前缀,不是目录): " + d.Path
}
if strings.Contains(p, "..") || strings.ContainsAny(p, " \t\r\n\x00?#") {
return "path 含非法字符: " + d.Path
}
}
// 前缀模式必须给出可剥的前缀。
// 注意 "/" 不需要单独判:它是前缀又同时以 "/" 结尾,已被上面的
// 「不应以 / 结尾」规则挡掉(挂到门户根会覆盖整站的意图因此无法达成)。
if d.StripPath && strings.TrimSpace(d.Path) == "" {
return "strip_path=true 时必须给出 path(否则没有可剥的前缀)"
}
// Target 的 host:port 部分必须可解析;路径前缀允许保留。
//
// 规则(刻意从严,因为地址写错是最常见的声明错误,而错误的反代会把
// 用户带到别处去):
// - 带 scheme 时(http://…)允许省略端口,由反代层按 scheme 补默认值;
// - 不带 scheme 时必须给出 host:port;
// - 端口必须是数字(SplitHostPort 本身不校验数字,"host:abc" 会通过)。
scheme := ""
raw := d.Target
if i := strings.Index(raw, "://"); i >= 0 {
scheme = strings.ToLower(raw[:i])
if scheme != "http" && scheme != "https" {
return "target scheme 只支持 http/https(WS 由 websocket 字段声明,不写 ws://): " + d.Target
}
raw = raw[i+3:]
}
if i := strings.IndexByte(raw, '/'); i >= 0 {
raw = raw[:i]
}
if raw == "" {
return "target 缺少主机部分: " + d.Target
}
host, port, err := net.SplitHostPort(raw)
if err != nil {
if scheme == "" {
return "target 必须给出 host:port(或带 http:// 前缀以便省略端口): " + d.Target
}
// 带 scheme 且解析失败:只剩主机名一种合法情形。
host, port = raw, ""
}
if host == "" {
return "target 缺少主机部分: " + d.Target
}
if port != "" {
n, err := strconv.Atoi(port)
if err != nil || n < 1 || n > 65535 {
return "target 端口非法(应为 1-65535 的数字): " + d.Target
}
}
return ""
}
// ProxyRegistrar 由内核注入(与 ToolRegistrar / InputChannelRegistrar 同族)。
// 插件不直接调它,用 RegisterProxy。
//
// 为什么需要运行期注册(明明有 plugin.json 自动发现):**内置插件**
// (编译进内核、没有独立插件目录与 plugin.json,如 remotedevice)扫不到;
// 而它们恰恰最需要被反代出去(设备网关就是内置的)。两种来源互补:
// - 外部插件 → plugin.json 的 proxies(静态,未启动也可见)
// - 内置插件 → RegisterProxy(运行期,随 Start 注册)
type ProxyRegistrar func(name string, def ProxyDef)
// ProxyRegistrarMissing 是「RegisterProxy 被调用、但没有注册回调」时的告警钩子。
//
// 存在理由:外部插件跑在子进程里,内核从未向它注入 ProxyRegistrar
// (proc 协议无 proxy 注册方法),于是它的 RegisterProxy 是空实现。
// 不告警的话,声明就**彻底静默消失** —— 用户只能看到“声明了却没被反代”。
//
// sdk 包不直接打日志(它被插件进程与工具链复用,不应绑定日志实现),
// 由内核侧通过 SetProxyRegistrarMissing 注入。未注入时保持完全安静
// (工具链、单元测试不需要噪声)。
type ProxyRegistrarMissing func(name string, def ProxyDef)
// SetProxyRegistrarMissing 由内核注入「声明无处落地」的告警实现。
// 插件不调用它。
func (s *PluginSDK) SetProxyRegistrarMissing(fn ProxyRegistrarMissing) {
if s == nil {
return
}
s.apiMu.Lock()
s.proxyWarn = fn
s.apiMu.Unlock()
}
// SetProxyRegistrar 由内核注入。插件不直接调它(与 SetInputChannelRegistrar 同族)。
func (s *PluginSDK) SetProxyRegistrar(r ProxyRegistrar) {
if s == nil {
return
}
s.apiMu.Lock()
s.proxyReg = r
s.apiMu.Unlock()
}
// RegisterProxy 声明一个需要 HologramAgent 反代出去的服务。
//
// 与 RegisterTool / RegisterInputChannel / RegisterOutputChannel 同一风格:
// 显式给名字 + 声明体。名字用于展示、日志与冲突提示(不参与路由 —— 路由键是
// def.Host / def.Path)。
//
// 用法(通常在 Start 里调用):
//
// s.RegisterProxy("ui", sdk.ProxyDef{
// Host: "myapp", Target: "127.0.0.1:12100",
// })
//
// ## ⚠ 仅对内置插件有效
//
// 插件跑在**子进程**里时(外部插件 / plugin.bin),本方法没有任何效果:
// 内核从未向插件进程注入 proxyReg 回调(proc 协议里没有 proxy 注册方法)。
// 调用会静默返回,不报错。
//
// **外部插件请写 plugin.json 的 proxies 字段** —— 那是唯一生效的入口。
// 详见包注释的「两种声明入口,以及各自谁能用」。
//
// 内置插件(编译进内核、无 plugin.json)用本方法声明,立即生效
// (反代表在下一次请求时重建)。**不做去重**:同一 Host/Path
// 被两条声明占用时由反代层判定冲突并明确报错,而不是在这里静默吞掉 ——
// 插件作者需要看见冲突。
func (s *PluginSDK) RegisterProxy(name string, def ProxyDef) {
if s == nil {
return
}
s.apiMu.RLock()
r := s.proxyReg
warn := s.proxyWarn
s.apiMu.RUnlock()
if r != nil {
r(name, def)
return
}
// ★ 没有注册回调时必须**留痕**,不能静默丢弃。
//
// 外部插件(独立 plugin.bin 子进程)就是这样:内核从未向插件进程注入
// proxyReg(proc 协议无 proxy 注册方法),所以本调用永远走不到上面
// 那个分支。历史实测代价:插件作者按旧文档写的声明完全没生效,
// 排查时只想到端口/防火墙/base_domain —— 因为**没有任何信号**说
// 「声明根本没发出去」。
//
// 这里只调可选钩子而不直接打日志:sdk 包不依赖 log(它被插件进程与
// 工具链复用),日志实现由内核侧注入。
if warn != nil {
warn(name, def)
}
}