mirror of
https://gitcode.com/JianFeeeee/homeagent-sdk.git
synced 2026-10-03 23:54:12 +00:00
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:
@ -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)
|
||||
}
|
||||
|
||||
@ -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:"-"`
|
||||
|
||||
22
tools/hmapdev/proxy_config.go
Normal file
22
tools/hmapdev/proxy_config.go
Normal 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"}
|
||||
136
tools/hmapdev/proxy_config_test.go
Normal file
136
tools/hmapdev/proxy_config_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user