mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-09 09:04:57 +00:00
fix(webui): 旧反代两条路径合并修复 —— 302变502/泄露内网URL/流式被缓冲
新反代(proxy.go)早已修掉这些,但**两条旧路径**没跟上,各自复制了一份
http.DefaultClient 实现:
proxyToPluginmgr (handler_settings.go)
handleDeviceGatewayProxy(handler_device.go)
同一个 bug 修了两遍还漏了两处。抽成共用的 reverseToUpstream,不再分叉。
## Bug 1:跟随上游 3xx → 302 变 502 + 泄露内网 URL
http.DefaultClient 默认跟最多 10 跳。上游回 302 时反代跟过去,目标可能是
内网另一个服务或不可达,于是把「上游的 302」变成「本层的 502」,
错误里还带着内网地址:
{"error":"device gateway unreachable: Get \"http://127.0.0.1:1/api/...\":
dial tcp 127.0.0.1:1: connect: connection refused"}
外部用户看到 Bad Gateway + 他访问不了的内网地址:既无用又泄露拓扑。
→ 反代**不应有重定向策略**(那是客户端的事),原样透传 3xx。
→ 上游错误细节只写日志,对外统一「上游服务不可达」。
## Bug 2:不逐帧 flush,流式响应被缓冲到结束
原实现 io.Copy(w, resp.Body),ResponseWriter 自带缓冲 → 上游按帧下发的
SSE/长轮询内容全堆到上游结束才吐。裸 TCP 实测:上游每 80ms 一帧共 3 帧,
缓冲版本只产生 **1 次**读(集中在 161ms),客户端表现为「卡住不动然后
一次性全出来」。改用 flushCopy(4KB 块 + 块间 Flush)。
## Bug 3:不过滤逐跳头
Content-Length / Transfer-Encoding 描述的是**上游那条连接**的分帧方式,
照抄到本层连接会导致客户端按错误长度读;Keep-Alive/Connection 同理。
按 RFC 7230 §6.1 剔除。
## 附带修正
- 补 X-Forwarded-For / X-Real-IP / X-Forwarded-Host / X-Forwarded-Proto,
且**只在尚未设置时补** —— 外层 nginx 已注入时覆盖会丢掉真实客户端 IP。
- 与 proxy.go 新建了带超时的 upstreamClient(DefaultClient 无超时,
上游卡住会拖住 goroutine)。
## 判据(5 条)+ ★ 判据设计的两个坑
★ 这条判据我试错了三轮,过程留在测试注释里:
1. 「首帧早于末帧」→ **假绿**。Go 在响应结束后把缓冲一次吐出,首末帧仍有
微秒级差,任何 `> 0` 都绿。
2. 用 http.Client 量「首帧延迟 < 上游总时长 70%」→ 仍是**假绿**。实测直连
上游首帧 761ns、总时长 160ms:Go 的 HTTP **客户端**合并读,量到的
「首帧」是客户端首次取到数据的时间,与服务端何时 flush 无关。
3. 当前(正确):**裸 TCP 直连被测服务**,看读到几次、分别在什么时刻。
对照组实测 3 次读 / 0.24ms / 80ms / 161ms —— 正是上游节奏。
第二个坑:不能按「累计 12 字节正文」判结束(首读含响应头 + 首帧正文,
会在首读就以为读完,后两帧时序全丢)。改为读到 EOF。
变异验证 4 条(均按预期打红后还原):退回 DefaultClient → 跟随重定向判红;
错误信息带 err.Error() → 泄露判红;退回 io.Copy → 只读到 1 次判红;
不过滤逐跳头 → Keep-Alive 判红。
全量:35 包全绿。
This commit is contained in:
@ -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 连接双向透传到设备网关。
|
||||
|
||||
Reference in New Issue
Block a user