mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-15 12:15:16 +00:00
## 背景 仓库早已迁移到 GitHub(`github.com/JianFeeeee/HomeAgent`,CI/CD 都在那边跑), gitcode 保留为**国内只读镜像**(`ci.yml` 的 sync-gitcode-main 快进推送)。 但 go.mod 的 module 路径一直是 `gitcode.com/JianFeeeee/...` —— 既与权威源 不符,`meta.SourceURL`(AGPL §13 的源码提供入口)也指向镜像而非实际运行的代码。 ## 关键区分(这是本次改动安全的前提) 两个 module 是**不同**的,影响面差一个数量级: | module | 谁依赖 | 改它的后果 | |---|---|---| | `.../HomeAgent`(主仓) | **只有主仓自己** | 内部重构,零外部影响 | | `.../homeagent-sdk`(SDK 仓) | 主仓 49 文件 + 所有外部插件 | 需重编插件 | ★ 实测确认:**插件不 import 主仓 module**(`strings qq/plugin.bin | grep HomeAgent` = 0), 只依赖 SDK 仓。而插件与内核的边界是 **RPC 方法名字符串** (`plugin.init` / `tool.invoke`…,见 proc/protocol.go),**与 Go module 名无关**。 ⇒ module 改名**不改变任何 wire 协议**,现网已装的插件照常运行。 SDK 的 GitHub 仓名是 `homeagentsdk`(无连字符),module 路径必须与之精确匹配, 否则 `go get` 会因路径不符失败,故取 `github.com/JianFeeeee/homeagentsdk`。 ## 改动 - `go.mod` / `go.sum`:两个 module 路径 + require/replace - 277 个 Go 文件 + Makefile + 打包脚本的 import 与 ldflags 路径 - SDK 仓 `meta.CoreModule`、`tools/hmapdev` 的 module 声明与工程模板 - 对外文档/链接的仓库地址(README、PLUGIN_DEV、OVERVIEW 等) ## 明确**保留** gitcode 的地方(镜像机制,不能动) - `GITCODE_TOKEN`、`GC_URL="…@gitcode.com"`(CI 同步 job) - `gitcode.com/api/v5/...`(release 附件上传) - git remote `gitcode`(镜像拉取/推送) - `docs/zh/ci-cd-runbook.md`(整篇讲镜像运维) ## 验证 - `go build ./...` / `go vet ./...` 绿 - `go test ./... -count=1` → 48 包 ok / 0 FAIL - hmapdev 工具链编译通过(`third_party/homeagent-sdk/tools/hmapdev`) - 全仓残留检查:module 形态的 `gitcode.com/JianFeeeee` = 0 - 镜像机制抽验:GC_URL / @gitcode / api/v5 / remote 均完好 ## 顺带修正 `hmapdev/templates.go` 的注释此前写「gitcode 的模块不在 proxy.golang.org 上」—— module 已迁 GitHub,该论断不再成立。改为如实说明**为何仍默认 replace 到本机 SDK**: 离线可构建、避免版本漂移、内网可用。理由与托管地无关,故 replace 保留。
266 lines
10 KiB
Go
266 lines
10 KiB
Go
package webui
|
||
|
||
import (
|
||
"fmt"
|
||
"net"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
internalConfig "github.com/JianFeeeee/HomeAgent/internal/config"
|
||
"github.com/JianFeeeee/HomeAgent/internal/sdk"
|
||
)
|
||
|
||
// ===== 旧反代路径:两处仍用 http.DefaultClient =====
|
||
//
|
||
// 新反代(proxy.go)早已修掉「跟随上游 3xx」与「不逐帧 flush」,
|
||
// 但**这两条旧路径**没跟上,各自复制了一份 http.DefaultClient 的实现:
|
||
//
|
||
// proxyToPluginmgr (handler_settings.go)
|
||
// handleDeviceGatewayProxy(handler_device.go)
|
||
//
|
||
// 后果是同一个 bug 修了两遍、还漏了两处。判据钉住它们的行为。
|
||
//
|
||
// ── Bug 1:跟随上游 3xx,把 302 变成 502 并泄露内网 URL ──
|
||
//
|
||
// http.DefaultClient 默认跟随最多 10 跳重定向。于是:
|
||
//
|
||
// 上游 302 → Location: http://127.0.0.1:12000/...
|
||
// DefaultClient 跟过去 → 本机另一个服务(或是探不到)
|
||
// 最终返回 500/502,且错误信息里带着内网地址
|
||
//
|
||
// 外部用户看到的是「Bad Gateway」加上一个他完全用不上的内网 URL。
|
||
// 既帮不上忙(那是内网地址,他访问不了),又泄露了内部拓扑。
|
||
|
||
// 上游 302 必须原样透传给客户端,**不得**跟随。
|
||
func TestLegacyProxyDoesNotFollowRedirects(t *testing.T) {
|
||
var followed string
|
||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
if r.URL.Path == "/redirected" {
|
||
followed = "被跟到 /redirected 了"
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
// 指向上游自己的另一个路径:若被跟随,测试就能发现
|
||
w.Header().Set("Location", "/redirected")
|
||
w.WriteHeader(http.StatusFound)
|
||
}))
|
||
defer upstream.Close()
|
||
|
||
h := newLegacyProxyTestHandler(t)
|
||
setDeviceGatewayTarget(t, upstream.Listener.Addr().String())
|
||
|
||
rec, _, _ := callDeviceProxy(t, h, "/api/v1/device/x")
|
||
if rec.Code != http.StatusFound {
|
||
t.Errorf("上游 302 应原样透传(302),实际 %d —— 跟随重定向后状态码就变了", rec.Code)
|
||
}
|
||
if followed != "" {
|
||
t.Errorf("反代跟随了上游重定向:%s", followed)
|
||
}
|
||
}
|
||
|
||
// 3xx 不得泄露内网 URL。
|
||
//
|
||
// 缺陷形态:DefaultClient 跟到内网地址后失败,把 err 写进响应体
|
||
// ("device gateway unreachable: Get \"http://127.0.0.1:9890/...\": ...")。
|
||
func TestLegacyProxyDoesNotLeakInternalURL(t *testing.T) {
|
||
// 上游指向一个**不可达**的地址,模拟「跟到内网后连不上」
|
||
h := newLegacyProxyTestHandler(t)
|
||
setDeviceGatewayTarget(t, "127.0.0.1:1") // 必然拒绝连接
|
||
|
||
rec, body, _ := callDeviceProxy(t, h, "/api/v1/device/x")
|
||
// 不可达时应 502(这是对的事实),但**不得**在响应里出现内网细节
|
||
if rec.Code != http.StatusBadGateway {
|
||
t.Errorf("上游不可达应 502,实际 %d", rec.Code)
|
||
}
|
||
for _, leak := range []string{"127.0.0.1:1", "connect: connection refused", "dial tcp"} {
|
||
if strings.Contains(body, leak) {
|
||
t.Errorf("错误响应泄露了内网细节 %q: %s", leak, body)
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Bug 2:不逐帧 flush,流式响应被缓冲到结束 ──
|
||
//
|
||
// ★ 这条判据的设计过程值得留在代码里(我为此试错了三轮):
|
||
//
|
||
// 第一版「首帧早于末帧」→ 假绿。Go 的 net/http 在响应结束后把缓冲一次性
|
||
// 吐出,首末帧之间仍有**微秒级**差,任何 `> 0` 都绿。
|
||
//
|
||
// 第二版改用 Go http.Client 量「首帧延迟 < 上游总时长 70%」→ 仍是假绿。
|
||
// 实测直连上游首帧 761ns、总时长 160ms:Go 的 HTTP **客户端**会合并读,
|
||
// 第一次 Read 往往把缓冲里已有的字节一次取走,量到的「首帧」其实是
|
||
// 客户端第一次拿到数据的时间,与服务端何时 flush 无关。
|
||
//
|
||
// 第三版(当前):**用裸 TCP 直连被测服务**。socket 读到几次、分别在
|
||
// 什么时刻,是唯一不受客户端读合并干扰的观测。对照组实测(裸 TCP 直连
|
||
// 一个逐帧 flush 的上游):3 次读,时刻为 0.24ms / 80ms / 161ms ——
|
||
// 正是上游的分帧节奏。
|
||
//
|
||
// 于是判据变成:**代理是否把上游的分帧节奏透传出来**(读到多次且
|
||
// 跨越上游的帧间隔),而不是「多久收到第一块」。
|
||
func TestLegacyProxyFlushesIncrementally(t *testing.T) {
|
||
const (
|
||
frames = 3
|
||
frameDelay = 80 * time.Millisecond
|
||
)
|
||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
body := "AAAABBBBCCCC"
|
||
w.Header().Set("Content-Type", "text/plain")
|
||
w.Header().Set("Content-Length", fmt.Sprint(len(body)))
|
||
w.WriteHeader(http.StatusOK)
|
||
for i := 0; i < frames; i++ {
|
||
_, _ = w.Write([]byte(body[i*4 : (i+1)*4]))
|
||
if f, ok := w.(http.Flusher); ok {
|
||
f.Flush()
|
||
}
|
||
time.Sleep(frameDelay)
|
||
}
|
||
}))
|
||
defer upstream.Close()
|
||
|
||
h := newLegacyProxyTestHandler(t)
|
||
setDeviceGatewayTarget(t, upstream.Listener.Addr().String())
|
||
srv := httptest.NewServer(h.Handler())
|
||
defer srv.Close()
|
||
|
||
// 先取代理的监听地址,用裸 TCP 观测(绕开客户端读合并)
|
||
proxyAddr := srv.Listener.Addr().String()
|
||
conn, err := net.Dial("tcp", proxyAddr)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer conn.Close()
|
||
fmt.Fprintf(conn,
|
||
"GET /api/v1/device/stream HTTP/1.1\r\nHost: x\r\nX-API-Key: %s\r\nConnection: close\r\n\r\n",
|
||
testAuthAPIKey)
|
||
|
||
start := time.Now()
|
||
var reads []time.Duration
|
||
buf := make([]byte, 512)
|
||
// 读到 EOF 为止:测试请求带了 Connection: close,上游写完 3 帧后
|
||
// 服务端会关闭连接,循环自然结束。
|
||
//
|
||
// ★ 第二个坑(记下来):**不能按「累计 12 字节正文」判结束**。
|
||
// 第一次 read 通常把「响应头 + 首帧正文」一起带来(实测 chunk0 =
|
||
// 头 + "AAAA"),按字节数判会在首读就认为读完,后面两帧的时序全丢 ——
|
||
// 于是判据又变成假的。
|
||
for {
|
||
_ = conn.SetReadDeadline(time.Now().Add(3 * time.Second))
|
||
n, rerr := conn.Read(buf)
|
||
if n > 0 {
|
||
reads = append(reads, time.Since(start))
|
||
}
|
||
if rerr != nil {
|
||
break
|
||
}
|
||
}
|
||
|
||
// 判据:读次数必须 ≥ 2 且跨越一个帧间隔 —— 说明代理没有把响应攒到最后。
|
||
// 阈值取半个帧间隔:Go 的写合并偶尔会把相邻两帧并成一次读,
|
||
// 但绝不可能把三帧跨越 160ms 全并成一次。
|
||
if len(reads) < 2 {
|
||
t.Errorf("只读到 %d 次(%v)—— 反代把流式响应缓冲到结束才一次性吐出。"+
|
||
"上游是每 %v 写一帧共 %d 帧的。", len(reads), reads, frameDelay, frames)
|
||
return
|
||
}
|
||
span := reads[len(reads)-1] - reads[0]
|
||
if span < frameDelay/2 {
|
||
t.Errorf("各次读取集中在 %v 内(%v)—— 没有透传上游的分帧节奏"+
|
||
"(帧间隔 %v),反代仍在缓冲。", span, reads, frameDelay)
|
||
}
|
||
}
|
||
|
||
// 旧反代必须设置 X-Forwarded-*(否则上游无法知道真实来源,
|
||
// 且与新反代 proxy.go 的行为不一致 —— 同一个系统两套规则)。
|
||
func TestLegacyProxySetsForwardedHeaders(t *testing.T) {
|
||
var gotFwdProto, gotFwdHost, gotXRealIP string
|
||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
gotFwdProto = r.Header.Get("X-Forwarded-Proto")
|
||
gotFwdHost = r.Header.Get("X-Forwarded-Host")
|
||
gotXRealIP = r.Header.Get("X-Real-IP")
|
||
w.WriteHeader(http.StatusOK)
|
||
}))
|
||
defer upstream.Close()
|
||
|
||
h := newLegacyProxyTestHandler(t)
|
||
setDeviceGatewayTarget(t, upstream.Listener.Addr().String())
|
||
|
||
req, _ := http.NewRequest(http.MethodGet, "/api/v1/device/x", nil)
|
||
req.Header.Set("X-API-Key", testAuthAPIKey)
|
||
req.Header.Set("X-Forwarded-Proto", "https")
|
||
req.Header.Set("X-Forwarded-Host", "homeagent.example.com")
|
||
req.Header.Set("X-Real-IP", "198.51.100.9")
|
||
req.RemoteAddr = "127.0.0.1:1234"
|
||
rec := httptest.NewRecorder()
|
||
h.Handler().ServeHTTP(rec, req)
|
||
|
||
if gotFwdHost == "" {
|
||
t.Error("旧反代未设置 X-Forwarded-Host(上游无法知道真实来源)")
|
||
}
|
||
if gotXRealIP == "" {
|
||
t.Error("旧反代未设置 X-Real-IP")
|
||
}
|
||
_ = gotFwdProto
|
||
}
|
||
|
||
// 上游回的头不得原样把 hop-by-hop 头透给客户端。
|
||
// Content-Length/Transfer-Encoding 等由本层连接决定,照抄上游会错乱。
|
||
func TestLegacyProxyStripsHopByHopHeaders(t *testing.T) {
|
||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
w.Header().Set("Content-Length", "2")
|
||
w.Header().Set("Keep-Alive", "timeout=5")
|
||
w.Header().Set("X-Custom", "keep-me")
|
||
w.WriteHeader(http.StatusOK)
|
||
_, _ = w.Write([]byte("ok"))
|
||
}))
|
||
defer upstream.Close()
|
||
|
||
h := newLegacyProxyTestHandler(t)
|
||
setDeviceGatewayTarget(t, upstream.Listener.Addr().String())
|
||
|
||
rec, _, _ := callDeviceProxy(t, h, "/api/v1/device/x")
|
||
if rec.Header().Get("Keep-Alive") != "" {
|
||
t.Errorf("Keep-Alive 不应透传给客户端(hop-by-hop 头)")
|
||
}
|
||
if rec.Header().Get("X-Custom") != "keep-me" {
|
||
t.Errorf("普通响应头应透传,实际 %q", rec.Header().Get("X-Custom"))
|
||
}
|
||
}
|
||
|
||
// ===== 测试脚手架(走真实 seam,不另造抽象层)=====
|
||
|
||
// newLegacyProxyTestHandler 造一个 handler,并把设备网关反代打开。
|
||
func newLegacyProxyTestHandler(t *testing.T) *Handler {
|
||
t.Helper()
|
||
cfgReg := internalConfig.NewConfigRegistry("")
|
||
seedWebUIConfig(cfgReg)
|
||
h := NewHandler(testSDK(sdk.SDKConfig{Settings: sdk.NewSettings("webui", cfgReg)}))
|
||
h.RegisterRoutes(http.NewServeMux())
|
||
|
||
prevEnabled, prevAddr, prevToken := deviceGatewayEnabled, deviceGatewayAddr, deviceGatewayToken
|
||
deviceGatewayEnabled = true
|
||
deviceGatewayToken = "gw-token"
|
||
t.Cleanup(func() {
|
||
deviceGatewayEnabled, deviceGatewayAddr, deviceGatewayToken = prevEnabled, prevAddr, prevToken
|
||
})
|
||
return h
|
||
}
|
||
|
||
func setDeviceGatewayTarget(t *testing.T, addr string) {
|
||
t.Helper()
|
||
deviceGatewayAddr = addr
|
||
}
|
||
|
||
// callDeviceProxy 经真实入口发一次请求,返回响应与文本体。
|
||
func callDeviceProxy(t *testing.T, h *Handler, path string) (*httptest.ResponseRecorder, string, *http.Response) {
|
||
t.Helper()
|
||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||
req.Header.Set("X-API-Key", testAuthAPIKey)
|
||
rec := httptest.NewRecorder()
|
||
h.Handler().ServeHTTP(rec, req)
|
||
return rec, rec.Body.String(), rec.Result()
|
||
}
|