diff --git a/internal/plugins/webui/handler_device.go b/internal/plugins/webui/handler_device.go index d0264c9..67516f0 100644 --- a/internal/plugins/webui/handler_device.go +++ b/internal/plugins/webui/handler_device.go @@ -3,6 +3,7 @@ package webui import ( "bufio" "io" + "log" "net" "strings" "time" @@ -55,26 +56,164 @@ func (h *Handler) handleDeviceGatewayProxy(w http.ResponseWriter, r *http.Reques return } + h.reverseToUpstream(w, r, url, deviceGatewayToken) +} + +// upstreamClient 是旧反代路径共用的 HTTP 客户端。 +// +// ★ 为什么必须**自定义**而不是用 http.DefaultClient(两者都有实际故障): +// +// 1. **禁止跟随重定向**。默认客户端最多跟 10 跳,于是上游回 302 时: +// 反代跟过去 → 目标可能是内网另一个服务或不可达 → 最终把 +// 「上游的 302」变成「本层的 502」,并且错误里带着内网 URL。 +// 外部用户看到 Bad Gateway + 他访问不了的内网地址:既无用又泄露拓扑。 +// 反代**不应有重定向策略** —— 那是客户端的事,原样透传才对。 +// +// 2. **超时必须存在**。默认客户端不设超时,上游卡住会拖住本层 goroutine +// 直到客户端放弃;并发下会把连接与内存占满。 +// +// 3. **不用 Transport 的自动解压**(默认行为):反代应当字节级透传, +// 由客户端自己决定是否解压。默认 Transport 会解开 gzip 并丢掉 +// Content-Encoding,导致「上游说 gzip、客户端收到明文」的不一致。 +var upstreamClient = &http.Client{ + Timeout: 60 * time.Second, + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse // 原样返回 3xx,不跟随 + }, + Transport: &http.Transport{ + Proxy: http.ProxyFromEnvironment, + MaxIdleConns: 64, + MaxIdleConnsPerHost: 16, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + DisableCompression: true, // 字节级透传,见上 + }, +} + +// hopByHopHeaders 是逐跳头,按 RFC 7230 §6.1 **不得**由代理转发。 +// +// 照抄上游这些头会出真问题:Content-Length 与 Transfer-Encoding 描述的是 +// **上游那条连接**的分帧方式,本层到客户端是另一条连接,直接抄会导致 +// 分帧错乱(客户端按错误的长度读);Keep-Alive/Connection 同理。 +var hopByHopHeaders = []string{ + "Connection", "Keep-Alive", "Proxy-Authenticate", "Proxy-Authorization", + "Te", "Trailer", "Transfer-Encoding", "Upgrade", +} + +// reverseToUpstream 是两条旧反代路径(设备网关 / pluginmgr)共用的转发实现。 +// +// 抽出来是因为原来两处各抄了一份,于是同一个 bug 修了两遍还漏了两处 +// (跟随 3xx、不 flush、不透传 X-Forwarded)。共用一份就不会再分叉。 +// +// authToken 非空时以 X-API-Key 注入(上游是受令牌保护的内网服务)。 +func (h *Handler) reverseToUpstream(w http.ResponseWriter, r *http.Request, url, authToken string) { req, err := http.NewRequestWithContext(r.Context(), r.Method, url, r.Body) if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "反代请求构造失败"}) return } req.Header = r.Header.Clone() - if deviceGatewayToken != "" { - req.Header.Set("X-API-Key", deviceGatewayToken) + if authToken != "" { + req.Header.Set("X-API-Key", authToken) } - resp, err := http.DefaultClient.Do(req) + h.setForwardedHeaders(req, r) + + resp, err := upstreamClient.Do(req) if err != nil { - writeJSON(w, http.StatusBadGateway, map[string]string{"error": "device gateway unreachable: " + err.Error()}) + // ★ 错误细节**不得**回给客户端:原始 err 里含上游地址与栈 + // (实测泄露 "http://127.0.0.1:9890/...: connect: connection refused")。 + // 那对用户无用(内网地址他访问不了),却把内部拓扑说给了外人。 + // 完整原因写日志,对外只给一句话。 + log.Printf("[webui] 反代上游不可达 url=%s err=%v", url, err) + writeJSON(w, http.StatusBadGateway, map[string]string{ + "error": "上游服务不可达", + }) return } defer resp.Body.Close() - for k, v := range resp.Header { - w.Header()[k] = v + + // 复制响应头(剔除逐跳头) + for k, vs := range resp.Header { + if isHopByHop(k) { + continue + } + w.Header()[k] = vs } w.WriteHeader(resp.StatusCode) - io.Copy(w, resp.Body) + + // ★ 逐帧 flush:这是「流式透传」的关键。 + // + // 原实现用 io.Copy(w, resp.Body):ResponseWriter 自带缓冲, + // 上游按帧下发的 SSE/长轮询内容会全部堆到上游响应结束才吐出。 + // 实测(裸 TCP 观测)上游每 80ms 一帧共 3 帧,缓冲版本只产生 + // **1 次**读(集中在 161ms),客户端表现为「卡住不动然后一次性全出来」。 + // + // 为什么不用 httputil.ReverseProxy:本函数要保留「注入 X-API-Key」 + // 这类旧行为,改写量比重写还大;且新反代 proxy.go 已用 ReverseProxy, + // 这里保持手写但补上 flush 即可。 + flushCopy(w, resp.Body) +} + +// flushCopy 逐块拷贝并在块间显式 Flush。 +// +// 用 io.CopyBuffer + 小块(4KB):块越大,单次 flush 的延迟越高。 +// 4KB 是 SSE 与插件 UI 增量响应的合理粒度 —— 更大没有吞吐收益 +// (这些响应本来就不是吞吐型的),却会让首帧更晚可见。 +func flushCopy(w http.ResponseWriter, src io.Reader) { + flusher, canFlush := w.(http.Flusher) + buf := make([]byte, 4096) + for { + n, err := src.Read(buf) + if n > 0 { + if _, werr := w.Write(buf[:n]); werr != nil { + return + } + if canFlush { + flusher.Flush() + } + } + if err != nil { + return + } + } +} + +// isHopByHop 判定逐跳头(大小写不敏感)。 +func isHopByHop(k string) bool { + for _, h := range hopByHopHeaders { + if strings.EqualFold(k, h) { + return true + } + } + return false +} + +// setForwardedHeaders 注入 X-Forwarded-*,让上游能知道真实来源。 +// +// 只在**尚未设置**时补:若请求本身已带这些头(例如外层 nginx 已注入), +// 覆盖会丢掉真正的客户端 IP —— 那正是限流与审计最需要的。 +func (h *Handler) setForwardedHeaders(dst *http.Request, src *http.Request) { + if dst.Header.Get("X-Forwarded-For") == "" { + if ip, _, err := net.SplitHostPort(src.RemoteAddr); err == nil { + dst.Header.Set("X-Forwarded-For", ip) + } else if src.RemoteAddr != "" { + dst.Header.Set("X-Forwarded-For", src.RemoteAddr) + } + } + if dst.Header.Get("X-Real-IP") == "" { + dst.Header.Set("X-Real-IP", clientIP(src)) + } + if dst.Header.Get("X-Forwarded-Host") == "" && src.Host != "" { + dst.Header.Set("X-Forwarded-Host", src.Host) + } + if dst.Header.Get("X-Forwarded-Proto") == "" { + proto := "http" + if src.TLS != nil { + proto = "https" + } + dst.Header.Set("X-Forwarded-Proto", proto) + } } // proxyWebSocket 用 TCP 直连 + hijack 将客户端 WS 连接双向透传到设备网关。 diff --git a/internal/plugins/webui/handler_settings.go b/internal/plugins/webui/handler_settings.go index 861fcc5..ee78a21 100644 --- a/internal/plugins/webui/handler_settings.go +++ b/internal/plugins/webui/handler_settings.go @@ -1,7 +1,6 @@ package webui import ( - "io" "log" "math" "sort" @@ -212,25 +211,9 @@ func (h *Handler) pluginmgrAddr() string { func (h *Handler) proxyToPluginmgr(w http.ResponseWriter, r *http.Request, path string) { addr := h.pluginmgrAddr() url := "http://" + addr + path - req, err := http.NewRequestWithContext(r.Context(), r.Method, url, r.Body) - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - req.Header = r.Header.Clone() - - resp, err := http.DefaultClient.Do(req) - if err != nil { - writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) - return - } - defer resp.Body.Close() - - for k, v := range resp.Header { - w.Header()[k] = v - } - w.WriteHeader(resp.StatusCode) - io.Copy(w, resp.Body) + // 与设备网关反代共用同一实现:原来两处各抄一份,于是同一个 bug + //(跟随 3xx / 不逐帧 flush / 不透传 X-Forwarded)修了两遍还漏了两处。 + h.reverseToUpstream(w, r, url, "") } func (h *Handler) handlePlugins(w http.ResponseWriter, r *http.Request) { diff --git a/internal/plugins/webui/legacy_proxy_test.go b/internal/plugins/webui/legacy_proxy_test.go new file mode 100644 index 0000000..e2fb3e7 --- /dev/null +++ b/internal/plugins/webui/legacy_proxy_test.go @@ -0,0 +1,265 @@ +package webui + +import ( + "fmt" + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config" + "gitcode.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() +}