從 edge-ai-platform POC 轉為正式產品的雲端後端,含以下整合階段:
- Phase 0:雛形骨架 — `cmd/api-server` (REST :3721) + `cmd/remote-proxy`
(tunnel :3800 / internal :3801) 雙 binary 共用 internal/,沿用 POC 的
WebSocket+yamux tunnel 協定但解耦 relay 與 API
- Phase 0.6:OIDC BFF 接 Innovedus Member Center
- internal/oidc package(coreos/go-oidc + PKCE S256 + state + nonce)
- internal/usersession package(HMAC-SHA256 cookie + RotateSessionID
防 session fixation, OWASP ASVS V3.2.1)
- 4 個 OIDC handler(/api/auth/login|callback|me|logout)+ AuthMiddleware
- 完全拔除 StaticAuthProvider,OIDC 是唯一認證路徑
- 9 個 ADR(含 ADR-010 BFF / ADR-011 取代 static auth /
ADR-012 pending session shared cookie / ADR-013 PKCE-only public client)
- Phase 0.7:A1 改造 + security audit 修復
- OIDC ClientSecret 變選填,支援 stage MC 的 public PKCE-only client
(AuthStyleInParams 強制 token endpoint 不送 client_secret)
- 預留 ServiceClient* 欄位給未來 client_credentials grant
- 移除 13+ 處 resolveUserID(uc, StaticUserID) fallback 改 strict mode
(Audit C1:multi-tenant 隔離破口)
- Pairing exchange MarkUsed 失敗 abort + revoke session token(Audit M3)
- 新增 all_endpoints_require_auth_test 整合測試(51 endpoint × 401)
驗證:go test -race -count=3 ./... 17 packages 全綠 / go vet 0 warning
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
144 lines
5.9 KiB
Go
144 lines
5.9 KiB
Go
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=<session_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<body>` 再關閉
|
||
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))
|
||
}
|