package relay import ( "io" "net/http" "sync" ) // HandleForwardRaw 實作 POST /internal/forward/raw — 對齊 api-internal.md §POST /internal/forward/http // 所描述的「raw HTTP bytes」行為。 // // 與 `HandleForwardHTTP`(JSON + base64 封裝)的差別: // // ┌──────────────────────┬──────────────┬──────────────────────┐ // │ │ /forward/http │ /forward/raw │ // ├──────────────────────┼──────────────┼──────────────────────┤ // │ request 封裝 │ JSON + base64 │ hijack 成 raw TCP │ // │ 支援 streaming body │ ❌ │ ✅ │ // │ 支援 MJPEG / SSE │ ❌ │ ✅ │ // │ 支援 WebSocket-like │ ❌ │ ✅(只要走 HTTP bytes)│ // │ 適合場景 │ 簡單 JSON API │ ProxyClient.OpenStream│ // └──────────────────────┴──────────────┴──────────────────────┘ // // 兩個 endpoint 同時存在是**刻意為之**: // - JSON 版對於 api-server 一次性 JSON request/response(例如 GET /healthz)較好寫、好測 // - Raw 版是 `session.ProxyClient.OpenStream(ctx) net.Conn` 語意的真實底層 // (api-server 端會拿這條 hijacked 連線當 net.Conn 直接 `r.Write(conn)` + `http.ReadResponse(conn)`) // // 協議(API server 端怎麼用): // 1. POST /internal/forward/raw?token= // (可不帶 body;hijack 在收到 request 後立刻做) // 2. remote-proxy 找到 session → 寫回 `HTTP/1.1 200 Connected\r\n\r\n` 代表「session ready」 // → Hijack 自己的連線 → 從 yamux 開一個 stream → 雙向 io.Copy // 3. API server 端拿到連線後,依照 HTTP 協定把完整 request 丟進去,local agent 回的 response // bytes 會原封不動從同條連線讀回來;保留 chunked / streaming / WS upgrade 語意 // // 雛形範例(api-server 端,B4 會實作): // // dial raw to /internal/forward/raw?token=xxx // → 讀一行 "HTTP/1.1 200 Connected" + 空行 // → 拿下面那條 net.Conn: // - r.Write(conn) // 送出 HTTP request // - resp, _ := http.ReadResponse(bufio.NewReader(conn), r) // - io.Copy(clientResponseWriter, resp.Body) // streaming 友善 // // 失敗處理: // - session 不存在 → 502 JSON(在 hijack 之前回 statusline + body) // - hijack 不支援 → 500 JSON // - OpenStream 失敗 → hijack 後寫回 `HTTP/1.1 502 Bad Gateway\r\n\r\n` 再關閉 func (s *InternalServer) HandleForwardRaw(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeJSONError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "POST required") return } token := r.URL.Query().Get("token") if token == "" { writeJSONError(w, http.StatusBadRequest, "MISSING_SESSION_TOKEN", "token query param required") return } // 1. 先查 session — 若不存在,直接用一般 JSON error 回,尚未 hijack handle, err := s.store.Lookup(r.Context(), token) if err != nil || handle == nil || handle.IsClosed() { s.logger.Warn("raw forward: session not found or closed", "token_prefix", tokenPrefix(token), "error", err) writeJSONError(w, http.StatusBadGateway, "TUNNEL_DISCONNECTED", "session not connected") return } // 2. Hijack — 把連線從 http.Server 接管成 raw TCP hijacker, ok := w.(http.Hijacker) if !ok { writeJSONError(w, http.StatusInternalServerError, "HIJACK_UNSUPPORTED", "hijacking not supported") return } clientConn, _, err := hijacker.Hijack() if err != nil { s.logger.Error("raw forward: hijack failed", "error", err, "token_prefix", tokenPrefix(token)) return } defer clientConn.Close() // 3. 通知 caller「session 已 ready」— 用最小 HTTP/1.1 回應行 // 這是 Connect-style 的慣例(類似 HTTP CONNECT tunneling) // 必須在 hijack 之後自己寫,因為 http.ResponseWriter 已失效 if _, werr := clientConn.Write([]byte("HTTP/1.1 200 Connected\r\n\r\n")); werr != nil { s.logger.Warn("raw forward: write connected line failed", "error", werr, "token_prefix", tokenPrefix(token)) return } // 4. 從 session 開 yamux stream stream, err := handle.OpenStream(r.Context()) if err != nil { s.logger.Warn("raw forward: open stream failed", "error", err, "token_prefix", tokenPrefix(token)) // Hijack 後還能寫原 bytes — 回一個 HTTP 502 幫助 caller debug _, _ = clientConn.Write([]byte( "HTTP/1.1 502 Bad Gateway\r\n" + "Content-Type: application/json\r\n" + "Connection: close\r\n\r\n" + `{"error":{"code":"TUNNEL_ERROR","message":"open stream failed"}}`, )) return } defer stream.Close() s.logger.Info("raw forward: stream opened", "token_prefix", tokenPrefix(token), "remote_addr", r.RemoteAddr) // 5. 雙向 pipe — 把接管的連線和 yamux stream 連起來 // clientConn <---> stream (raw bytes,不做任何 HTTP 解析) // 任一方向 EOF / error 就關閉另一邊,確保兩個 goroutine 都會退出 var wg sync.WaitGroup wg.Add(2) go func() { defer wg.Done() // 從 caller(api-server)讀 → 寫到 tunnel stream _, _ = io.Copy(stream, clientConn) // 關 stream 的寫入端讓另一邊的 Copy 收到 EOF;yamux stream 沒有 // CloseWrite,直接 Close 整條 stream 讓另一側也 EOF _ = stream.Close() }() go func() { defer wg.Done() // 從 tunnel stream 讀 → 寫回 caller _, _ = io.Copy(clientConn, stream) _ = clientConn.Close() }() wg.Wait() s.logger.Info("raw forward: stream closed", "token_prefix", tokenPrefix(token)) }