mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-26 20:33:15 +00:00
新反代(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 包全绿。
331 lines
11 KiB
Go
331 lines
11 KiB
Go
package webui
|
||
|
||
import (
|
||
"bufio"
|
||
"io"
|
||
"log"
|
||
"net"
|
||
"strings"
|
||
"time"
|
||
|
||
"net/http"
|
||
)
|
||
|
||
// 设备网关反代:/api/v1/device/* → remotedevice(HTTP + WS 升级透传)。
|
||
|
||
// ======== Remote Device Gateway (proxied to remotedevice, opt-in) ========
|
||
|
||
// deviceGatewayEnabled / deviceGatewayAddr 由 webui 插件启动时从设置读取并注入。
|
||
// 默认禁用:用户显式配置 device_gateway_enabled=true 后,/api/v1/device/* 才会反代到
|
||
// remotedevice 插件(self-contained),避免与 remotedevice 耦合。
|
||
var (
|
||
deviceGatewayEnabled bool
|
||
deviceGatewayAddr string
|
||
deviceGatewayToken string
|
||
)
|
||
|
||
// handleDeviceGatewayProxy 将 /api/v1/device/* 反代到 remotedevice 内部 HTTP 服务。
|
||
// 鉴权:本端走 requireAPI(webui API key),转发时带 remotedevice 的 token(X-API-Key)。
|
||
// WS 升级请求(Upgrade: websocket)走 hijack 双向字节透传(标准库 http.Client 不支持 101 升级)。
|
||
func (h *Handler) handleDeviceGatewayProxy(w http.ResponseWriter, r *http.Request) {
|
||
if !deviceGatewayEnabled {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
// 客户端鉴权模式:服务端不再提供授权接口(授权由设备端本地控制)。
|
||
// 拒绝旧的 /device/auth 调用,避免误导。
|
||
if strings.HasSuffix(r.URL.Path, "/device/auth") {
|
||
writeJSON(w, http.StatusGone, map[string]string{
|
||
"error": "device authorization moved to client-side; the server no longer stores authorization state",
|
||
})
|
||
return
|
||
}
|
||
addr := deviceGatewayAddr
|
||
if addr == "" {
|
||
addr = "127.0.0.1:9890"
|
||
}
|
||
path := r.URL.Path // 保留 /api/v1/device/... 全路径
|
||
url := "http://" + addr + path
|
||
if r.URL.RawQuery != "" {
|
||
url += "?" + r.URL.RawQuery
|
||
}
|
||
|
||
// WebSocket 升级:hijack 双向透传(支持 WS over 远程 homed)
|
||
if strings.EqualFold(r.Header.Get("Upgrade"), "websocket") {
|
||
h.proxyWebSocket(w, r, addr, path)
|
||
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": "反代请求构造失败"})
|
||
return
|
||
}
|
||
req.Header = r.Header.Clone()
|
||
if authToken != "" {
|
||
req.Header.Set("X-API-Key", authToken)
|
||
}
|
||
h.setForwardedHeaders(req, r)
|
||
|
||
resp, err := upstreamClient.Do(req)
|
||
if err != nil {
|
||
// ★ 错误细节**不得**回给客户端:原始 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, vs := range resp.Header {
|
||
if isHopByHop(k) {
|
||
continue
|
||
}
|
||
w.Header()[k] = vs
|
||
}
|
||
w.WriteHeader(resp.StatusCode)
|
||
|
||
// ★ 逐帧 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 连接双向透传到设备网关。
|
||
func (h *Handler) proxyWebSocket(w http.ResponseWriter, r *http.Request, addr, path string) {
|
||
// 设备网关默认仅监听 127.0.0.1(remotedevice),反代目标即内网 homed 本机或指定 addr。
|
||
// 用 net.Dial 直连网关并手动发起 WS 升级握手(net/http 客户端不支持 ws:// 升级)。
|
||
host, port := addr, "9890"
|
||
if h2, p2, ok := splitHostPort(addr); ok {
|
||
host, port = h2, p2
|
||
}
|
||
target := net.JoinHostPort(host, port)
|
||
upConn, err := net.DialTimeout("tcp", target, 15*time.Second)
|
||
if err != nil {
|
||
http.Error(w, "ws upstream dial: "+err.Error(), http.StatusBadGateway)
|
||
return
|
||
}
|
||
defer upConn.Close()
|
||
|
||
// 手动构造 WS 升级请求(保留客户端头 + 注入网关 token)
|
||
key := r.Header.Get("Sec-WebSocket-Key")
|
||
if key == "" {
|
||
key = "homeagent-proxy-random-key"
|
||
}
|
||
reqPath := path
|
||
if r.URL.RawQuery != "" {
|
||
reqPath += "?" + r.URL.RawQuery
|
||
}
|
||
var b strings.Builder
|
||
b.WriteString("GET " + reqPath + " HTTP/1.1\r\n")
|
||
b.WriteString("Host: " + addr + "\r\n")
|
||
b.WriteString("Upgrade: websocket\r\n")
|
||
b.WriteString("Connection: Upgrade\r\n")
|
||
b.WriteString("Sec-WebSocket-Key: " + key + "\r\n")
|
||
b.WriteString("Sec-WebSocket-Version: 13\r\n")
|
||
if deviceGatewayToken != "" {
|
||
b.WriteString("X-API-Key: " + deviceGatewayToken + "\r\n")
|
||
}
|
||
for k, vv := range r.Header {
|
||
kl := strings.ToLower(k)
|
||
if kl == "upgrade" || kl == "connection" || kl == "sec-websocket-key" || kl == "sec-websocket-version" || kl == "host" || kl == "x-api-key" || kl == "authorization" {
|
||
continue
|
||
}
|
||
for _, v := range vv {
|
||
b.WriteString(k + ": " + v + "\r\n")
|
||
}
|
||
}
|
||
b.WriteString("\r\n")
|
||
if _, err := upConn.Write([]byte(b.String())); err != nil {
|
||
http.Error(w, "ws upstream write: "+err.Error(), http.StatusBadGateway)
|
||
return
|
||
}
|
||
|
||
// 读上游 101 响应
|
||
br := bufio.NewReader(upConn)
|
||
resp, err := http.ReadResponse(br, nil)
|
||
if err != nil {
|
||
http.Error(w, "ws upstream response: "+err.Error(), http.StatusBadGateway)
|
||
return
|
||
}
|
||
if resp.StatusCode != http.StatusSwitchingProtocols {
|
||
http.Error(w, "ws upstream status: "+resp.Status, http.StatusBadGateway)
|
||
return
|
||
}
|
||
|
||
// 客户端 hijack:把 101 响应头写给客户端并接管双向连接
|
||
hj, ok := w.(http.Hijacker)
|
||
if !ok {
|
||
http.Error(w, "hijack not supported", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
clientConn, brw, err := hj.Hijack()
|
||
if err != nil {
|
||
return
|
||
}
|
||
defer clientConn.Close()
|
||
|
||
// 向上游 101 响应头转发给客户端
|
||
if err := resp.Write(brw); err != nil {
|
||
return
|
||
}
|
||
if err := brw.Flush(); err != nil {
|
||
return
|
||
}
|
||
|
||
// 双向透传(WS 帧字节不动):
|
||
// 客户端 -> 上游
|
||
errCh := make(chan struct{}, 2)
|
||
go func() {
|
||
io.Copy(upConn, brw)
|
||
if tc, ok := upConn.(interface{ CloseWrite() error }); ok {
|
||
tc.CloseWrite()
|
||
}
|
||
errCh <- struct{}{}
|
||
}()
|
||
// 上游 -> 客户端
|
||
go func() {
|
||
wb := bufio.NewWriter(clientConn)
|
||
io.Copy(wb, br)
|
||
wb.Flush()
|
||
errCh <- struct{}{}
|
||
}()
|
||
<-errCh
|
||
}
|
||
|
||
// splitHostPort 拆分 addr 为 host/port;无端口时返回 ok=false。
|
||
func splitHostPort(addr string) (string, string, bool) {
|
||
if strings.Contains(addr, ":") {
|
||
h, p, err := net.SplitHostPort(addr)
|
||
if err == nil {
|
||
return h, p, true
|
||
}
|
||
}
|
||
return addr, "", false
|
||
}
|