feat(sdk): 反代声明(ProxyDef / RegisterProxy)—— 插件声明服务,HomeAgent 反代出去

配套核心仓「webui 通用反向代理」。SDK 1.4.0 尚未发布,接口未冻结,
本次按开发期自由变更处理(正式发版时并入版本号推进)。

## 声明契约

plugin.json 的 proxies 字段(声明式,静态可发现)或 RegisterProxy
(运行期,供没有 plugin.json 的内置插件用):

    {"name":"ui","host":"myapp","path":"/p/myapp","strip_path":true,
     "target":"127.0.0.1:12100","auth":"homeagent"}

命名与既有能力对齐(ToolDef / ChannelDef / ConfigDef / RegisterTool /
ToolRegistrar)——第一版写成 ProxyDecl / DeclareProxy / ProxyDeclarer
被评审指出「跟 SDK 其他接口不是一个风格」,已全面改名。

## strip_path:Path 的两种语义

Path 不能一刀切成「原样保留」,真实需求有两种且**不能自动判定**
(同一个 path 在两种语义下都说得通,猜错即全部 404 且像上游故障):

  strip_path 缺省/false(别名模式)—— path 是上游真实路径的一部分
    /api/v1/device/ws + path=/api/v1/device → 上游收到原样
    适用:客户端**已硬编码**路径的机器接口(设备网关即如此)

  strip_path=true(前缀模式)—— path 只是门户上的挂载点
    /p/myapp/api/status + path=/p/myapp → 上游收到 /api/status
    适用:自带 UI 的服务(前端用相对路径)

非法组合(strip_path 而无 path)被 ValidateProxyDef 挡住。

## 单一入口原则(契约级要求)

一个声明 = 一个入口。两种挂载形态对「根路径」处理截然不同:
Host 形态下根路径是插件的根(fetch('/api/x') 天然正确);
Path 形态下根路径**属于门户**,同样代码会打到门户自己身上
(静默错路由:页面能开、功能全坏)。

故被反代的插件必须**一律使用相对路径**,绝不硬编码以 / 开头的绝对路径。
这样同一份前端在两种形态下都正确,插件不必知道自己被挂在哪,
反代层也能按外部条件(子域是否有证书/放行)自由选择形态。

## 判据

sdk/proxy_test.go:两种语义的映射、非法组合、单一入口原则的契约存在性。
hmapdev proxy_config_test.go:schema 漂移保护(新增字段忘了同步就判红)、
非法声明在**打包时**就被拒(不必装到 HomeAgent 才看到)。

两模块 go test 全绿;文档站已重新生成(ProxyDef 与单一入口原则进入
docs/api/misc.md 与 llms-full.txt)。

## 顺带修回的一处(此前随工作树丢失)

writePluginJSON 漏写 proxies 键 —— 漏写的话插件装得上、启动正常、
就是不出现,没有任何报错。该 bug 曾在核心仓侧出现过(判据抓到过),
这次移植时由 TestWritePluginJSONPreservesProxies 再次判红并修复。
This commit is contained in:
JianFeeeee
2026-09-26 14:08:30 +08:00
parent 255d6479ad
commit a176cc3e20
20 changed files with 1280 additions and 198 deletions

View File

@ -337,6 +337,12 @@ func writePluginJSON(plg *PlgConfig, platforms []string, entry string) {
if plg.ResolvedSDK != "" {
m["sdk"] = plg.ResolvedSDK
}
// 反代声明必须写进产物:内核靠读 plugin.json 的 proxies 才知道该把
// 哪个服务反向代理出去。漏写的话插件**装得上、启动正常、就是不出现** ——
// 没有任何报错,只有「访问不到」。这正是当初新增该字段时踩过的坑。
if len(plg.Proxies) > 0 {
m["proxies"] = plg.Proxies
}
data, _ := json.MarshalIndent(m, "", " ")
os.WriteFile("plugin.json", data, 0644)
}

View File

@ -2,9 +2,11 @@ package main
import (
"fmt"
"net"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"text/template"
)
@ -18,6 +20,67 @@ func (p *PlgConfig) ReplacesToSlice() []string {
return s
}
// validateProxies 在打包前校验反代声明,让插件作者**本地**就发现写错,
// 而不是装到 HomeAgent 上才看到「声明被拒」。
//
// 校验规则与 SDK 的 sdk.ValidateProxyDef 保持一致(同一套 DNS label / auth /
// target 规则);工具链不 import SDK 是为了保持"打包机只需工具链"的独立性,
// 两侧一致性由 SDK 仓与主仓的同名测试分别钉住。
func validateProxies(list []ProxyConfig) error {
seen := map[string]bool{}
for i, p := range list {
if strings.TrimSpace(p.Target) == "" {
return fmt.Errorf("proxies[%d] (%s): target 不能为空", i, p.Name)
}
switch p.Auth {
case "", "homeagent", "none":
default:
return fmt.Errorf("proxies[%d] (%s): auth 只允许 \"\"/\"homeagent\"/\"none\",得到 %q", i, p.Name, p.Auth)
}
if p.Host != "" {
if !validHostLabel(p.Host) {
return fmt.Errorf("proxies[%d] (%s): host %q 不是合法子域名标签(小写字母/数字/连字符,不以连字符开头结尾,≤63)", i, p.Name, p.Host)
}
if seen[p.Host] {
return fmt.Errorf("proxies[%d]: host %q 在同一声明里重复", i, p.Host)
}
seen[p.Host] = true
}
// 端口必须是数字:SplitHostPort 不校验数字,"host:abc" 会溜过去
raw := p.Target
if j := strings.Index(raw, "://"); j >= 0 {
raw = raw[j+3:]
}
if j := strings.IndexByte(raw, '/'); j >= 0 {
raw = raw[:j]
}
if h, port, err := net.SplitHostPort(raw); err == nil {
if h == "" {
return fmt.Errorf("proxies[%d] (%s): target %q 缺少主机", i, p.Name, p.Target)
}
if n, err := strconv.Atoi(port); err != nil || n < 1 || n > 65535 {
return fmt.Errorf("proxies[%d] (%s): target 端口非法(应为 1-65535): %q", i, p.Name, p.Target)
}
}
}
return nil
}
func validHostLabel(s string) bool {
if s == "" || len(s) > 63 || s[0] == '-' || s[len(s)-1] == '-' {
return false
}
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case c >= 'a' && c <= 'z', c >= '0' && c <= '9', c == '-':
default:
return false
}
}
return true
}
type PlgConfig struct {
Name string `json:"name"`
NameZh string `json:"name_zh"`
@ -43,6 +106,13 @@ type PlgConfig struct {
// 补丁由工具链挑最新」(patch 只含工具链/打包修复,接口不变,见 README 版本语义)。
SDK string `json:"sdk,omitempty"`
// Proxies 声明本插件需要 HomeAgent 反代出去的服务(自带 Web UI / HTTP API)。
//
// 为什么声明在 plugin.json 而不是运行期注册:静态可发现(插件没起来时
// 也能报「声明了 ui 但目标不可达」,而不是静默 404)、可版本化、旧内核无害。
// 字段语义见 SDK 的 sdk.ProxyDef(工具链与内核共用同一套校验规则)。
Proxies []ProxyConfig `json:"proxies,omitempty"`
// ResolvedSDK 是本次构建实际选中的 SDK 版本(build 按 SDK 声明解析后回填),
// 只写进产物里的 plugin.json,便于事后追溯「这个 .hmap 是哪版 SDK 编的」。
ResolvedSDK string `json:"-"`

View File

@ -0,0 +1,22 @@
package main
// ProxyConfig 是 plugin.json 里的反代声明项(工具链侧镜像,对应 sdk.ProxyDef)。
//
// 为什么不直接 import SDK 的 sdk.ProxyDef:工具链是**独立可执行**,
// 打包机可能只装了工具链而没有 SDK 源码树(旧版本常见)。镜像一份最小结构
// 能让 hmapdev 单机可用;两者的字段名必须保持一致,由
// tools/hmapdev/proxy_config_test.go 的对照测试钉住,防止单边漂移。
type ProxyConfig struct {
Name string `json:"name,omitempty"`
Host string `json:"host,omitempty"`
Path string `json:"path,omitempty"`
StripPath bool `json:"strip_path,omitempty"`
Target string `json:"target"`
WebSocket bool `json:"websocket,omitempty"`
Auth string `json:"auth,omitempty"`
}
// proxySchemaKeys 是 Proxies 序列化后允许出现的 JSON 键集合。
// writePluginJSON 用的是 map 重建,这里列清楚是为了让
// 「新增字段忘了同步」这件事在测试里立刻暴露。
var proxySchemaKeys = []string{"name", "host", "path", "strip_path", "target", "websocket", "auth"}

View File

@ -0,0 +1,136 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
// writePluginJSON 是「白名单 map 重建」,新增字段若忘了同步进 map 会被静默丢弃。
// 本测试钉住这件事:声明了 proxies 的插件,产物 plugin.json 里必须还有 proxies。
func TestWritePluginJSONPreservesProxies(t *testing.T) {
dir := t.TempDir()
old, _ := os.Getwd()
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
defer os.Chdir(old)
plg := &PlgConfig{
Name: "demo",
NameZh: "示例",
NameEn: "Demo",
Version: "0.1.0",
Author: "test",
Entry: "plugin.bin",
Tags: []string{"demo"},
Proxies: []ProxyConfig{
{Name: "ui", Host: "demo", Target: "127.0.0.1:12100"},
{Name: "gw", Host: "demo-gw", Target: "127.0.0.1:9890", WebSocket: true, Auth: "none"},
},
}
writePluginJSON(plg, []string{"linux"}, "plugin.bin")
raw, err := os.ReadFile(filepath.Join(dir, "plugin.json"))
if err != nil {
t.Fatal(err)
}
var got map[string]interface{}
if err := json.Unmarshal(raw, &got); err != nil {
t.Fatal(err)
}
proxies, ok := got["proxies"].([]interface{})
if !ok {
t.Fatalf("产物 plugin.json 丢了 proxies 字段,内容:%s", raw)
}
if len(proxies) != 2 {
t.Fatalf("proxies 条数 = %d,期望 2", len(proxies))
}
first, _ := proxies[0].(map[string]interface{})
if first["target"] != "127.0.0.1:12100" || first["host"] != "demo" {
t.Errorf("第 1 条声明内容不对: %v", first)
}
second, _ := proxies[1].(map[string]interface{})
if second["websocket"] != true || second["auth"] != "none" {
t.Errorf("第 2 条声明丢了 websocket/auth: %v", second)
}
}
// 没声明 proxies 时不应凭空冒出该字段(保持旧产物的字段集合不变)。
func TestWritePluginJSONOmitsEmptyProxies(t *testing.T) {
dir := t.TempDir()
old, _ := os.Getwd()
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
defer os.Chdir(old)
writePluginJSON(&PlgConfig{Name: "nop", Entry: "plugin.bin"}, nil, "plugin.bin")
raw, _ := os.ReadFile(filepath.Join(dir, "plugin.json"))
if strings.Contains(string(raw), "proxies") {
t.Errorf("未声明 proxies 却出现在产物里:%s", raw)
}
}
// 字段漂移守卫:ProxyConfig 的 JSON 键必须与 proxySchemaKeys 完全一致。
// 加字段时若只改结构体不改这个清单,测试会红,提醒同步内核侧解析与文档。
func TestProxyConfigSchemaKeys(t *testing.T) {
raw, err := json.Marshal(ProxyConfig{})
if err != nil {
t.Fatal(err)
}
var m map[string]interface{}
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
// 空结构体 + omitempty 会全部省略,所以改用非零值探测。
// 每个字段都必须出现在探测体里,否则「新增字段忘了同步」检测不出来。
raw, _ = json.Marshal(ProxyConfig{
Name: "n", Host: "h", Path: "/p", StripPath: true,
Target: "t", WebSocket: true, Auth: "a",
})
m = nil
_ = json.Unmarshal(raw, &m)
if len(m) != len(proxySchemaKeys) {
t.Fatalf("ProxyConfig 有 %d 个 JSON 键,proxySchemaKeys 列了 %d 个;请同步",
len(m), len(proxySchemaKeys))
}
for _, k := range proxySchemaKeys {
if _, ok := m[k]; !ok {
t.Errorf("proxySchemaKeys 列了 %q,但 ProxyConfig 序列化后没有该键", k)
}
}
}
// validateProxies 必须让插件作者在**打包时**就发现写错的声明。
func TestValidateProxies(t *testing.T) {
ok := [][]ProxyConfig{
{{Target: "127.0.0.1:12100"}},
{{Target: "http://127.0.0.1:12100", Host: "aaa"}},
{{Target: "127.0.0.1:9890", Host: "devices", WebSocket: true, Auth: "none"}},
{{Target: "https://example.com"}}, // 远程允许
}
for i, list := range ok {
if err := validateProxies(list); err != nil {
t.Errorf("合法集合 #%d 被拒: %v", i, err)
}
}
bad := [][]ProxyConfig{
{{Target: ""}},
{{Target: " "}},
{{Target: "127.0.0.1:1", Auth: "nope"}},
{{Target: "127.0.0.1:1", Host: "a_b"}},
{{Target: "127.0.0.1:1", Host: "-x"}},
{{Target: "127.0.0.1:1", Host: "X"}},
{{Target: "127.0.0.1:notaport"}},
{{Target: "127.0.0.1:99999"}},
{{Target: "127.0.0.1:1", Host: "dup"}, {Target: "127.0.0.1:2", Host: "dup"}}, // 同包内重复
}
for i, list := range bad {
if err := validateProxies(list); err == nil {
t.Errorf("非法集合 #%d 应被拒: %+v", i, list)
}
}
}