jim800121chen 22f0837ba8 feat(visionA-backend): Phase 0 → 0.7 雲端後端(雙 binary + OIDC BFF + stage 部署)
從 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>
2026-05-01 11:21:20 +08:00

333 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// forwarder.go — api-server → remote-proxy 的 raw forward client。
//
// 這是雛形雙 binary 架構下「api-server 把前端 HTTP 請求轉發到 local agent」
// 的核心元件。
//
// 整條路徑:
//
// browser ─HTTP─► api-server handler
// │
// │ Forwarder.ForwardHTTP / OpenStream
// ▼
// raw TCP dial remote-proxy: POST /internal/forward/raw?token=...
// │ (B3 Major-1 修復後新增的 hijack endpoint)
// ▼
// remote-proxy hijack 自己的連線 → yamux.OpenStream → 雙向 io.Copy
// │
// ▼
// local agent (yamux client) 把 stream 上的 HTTP request
// ▼
// 轉到本地 127.0.0.1:3721local-tool回 response
//
// 對齊 `.autoflow/04-architecture/api/api-internal.md` §POST /internal/forward/raw
// 與 `.autoflow/04-architecture/tunnel.md` §3.3。
package session
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"net/url"
"strings"
"time"
)
// defaultDialTimeout 是 raw TCP dial remote-proxy 的最大等待時間。
const defaultDialTimeout = 10 * time.Second
// defaultHandshakeTimeout 是讀取「HTTP/1.1 200 Connected」握手的最大等待時間。
const defaultHandshakeTimeout = 10 * time.Second
// Forwarder 把 api-server 的 HTTP 請求 forward 到 remote-proxy。
//
// 並發安全:本 struct 的方法不共享可變狀態,每個 OpenStream 走獨立 net.Conn
// 多個 goroutine 可同時呼叫。
type Forwarder struct {
// proxyHost 是從 baseURL 解析出來的 host:port供 net.Dial 用。
proxyHost string
// dialer 用於 raw TCP dial。獨立成欄位以利測試 / 未來換成 TLS dial。
dialer net.Dialer
logger *slog.Logger
}
// NewForwarder 從 baseURLhttp://localhost:3801建立 Forwarder。
//
// baseURL 必須是 http:// 或 https:// 開頭;其他 scheme 視為錯誤但延遲到
// 第一次呼叫時才回(保持建構簽章簡單)。
//
// **注意**:雛形 internal port 是純 HTTPnetwork policy 阻擋外部存取,見
// api-internal.md §安全。Phase 1 加 mTLS 時,本 Forwarder 需擴充支援 TLS。
func NewForwarder(baseURL string, logger *slog.Logger) *Forwarder {
if logger == nil {
logger = slog.Default()
}
host := parseHostFromBaseURL(baseURL)
return &Forwarder{
proxyHost: host,
dialer: net.Dialer{Timeout: defaultDialTimeout},
logger: logger,
}
}
// parseHostFromBaseURL 從 baseURL 取出 host:port失敗時回傳空字串
// (後續 OpenStream 會拒絕並回明確錯誤)。
func parseHostFromBaseURL(baseURL string) string {
if baseURL == "" {
return ""
}
u, err := url.Parse(baseURL)
if err != nil {
return ""
}
return u.Host
}
// OpenStream 對 remote-proxy 開一條 raw TCP 連線,完成 hijack 握手,並回傳
// 一條可以直接用 net.Conn 語意操作的連線(底層是 yamux stream
//
// 用法(典型 api-server handler
//
// conn, err := forwarder.OpenStream(ctx, sessionToken)
// if err != nil { ... }
// defer conn.Close()
//
// httpReq.Write(conn) // 送 HTTP request
// resp, _ := http.ReadResponse(bufio.NewReader(conn), httpReq)
// io.Copy(browserResponseWriter, resp.Body) // streaming friendly
//
// 失敗回傳的 error
// - ErrSessionNotFoundremote-proxy 在 hijack 前回 502 JSON
// - 其他 wrapped errordial / 握手 / 解析錯誤
//
// 注意caller 拿到 conn 後**必須自己負責 Close**;本函式內部不會 set deadline
// 因為 streaming 場景MJPEG / SSE需要無限長的存活時間。
func (f *Forwarder) OpenStream(ctx context.Context, sessionToken string) (net.Conn, error) {
if f.proxyHost == "" {
return nil, errors.New("session: forwarder has no proxy host (check VISIONA_PROXY_INTERNAL_URL)")
}
if sessionToken == "" {
return nil, errors.New("session: forwarder.OpenStream requires non-empty sessionToken")
}
// 1. raw TCP dial
conn, err := f.dialer.DialContext(ctx, "tcp", f.proxyHost)
if err != nil {
return nil, fmt.Errorf("session: dial remote-proxy %s: %w", f.proxyHost, err)
}
// 2. 寫 POST /internal/forward/raw?token=...
// 仿 dialRawForward 測試 helper 的格式(見 internal/relay/integration_raw_test.go
reqLine := fmt.Sprintf(
"POST /internal/forward/raw?token=%s HTTP/1.1\r\n"+
"Host: %s\r\n"+
"Content-Length: 0\r\n"+
"\r\n",
url.QueryEscape(sessionToken), f.proxyHost,
)
// 設一個短的握手 deadline避免 remote-proxy 假死時 hang 住。
if err := conn.SetWriteDeadline(time.Now().Add(defaultHandshakeTimeout)); err != nil {
_ = conn.Close()
return nil, fmt.Errorf("session: set write deadline: %w", err)
}
if _, err := conn.Write([]byte(reqLine)); err != nil {
_ = conn.Close()
return nil, fmt.Errorf("session: write forward request: %w", err)
}
// 3. 讀握手 — 預期 "HTTP/1.1 200 Connected\r\n\r\n"
if err := conn.SetReadDeadline(time.Now().Add(defaultHandshakeTimeout)); err != nil {
_ = conn.Close()
return nil, fmt.Errorf("session: set read deadline: %w", err)
}
reader := bufio.NewReader(conn)
statusLine, err := reader.ReadString('\n')
if err != nil {
_ = conn.Close()
return nil, fmt.Errorf("session: read handshake status: %w", err)
}
statusLine = strings.TrimRight(statusLine, "\r\n")
// 解析 status code
// 格式HTTP/1.1 200 Connected 或 HTTP/1.1 502 Bad Gateway
if !strings.HasPrefix(statusLine, "HTTP/1.1 200") {
// 非 200 → 把 body 讀出來幫 debug常見502 = TUNNEL_DISCONNECTED
bodyHint := drainAndPeek(reader)
_ = conn.Close()
// session 不存在的明確錯誤對應 ErrSessionNotFound
if strings.Contains(statusLine, "502") {
return nil, fmt.Errorf("%w: remote-proxy responded %q (body hint: %s)",
ErrSessionNotFound, statusLine, bodyHint)
}
return nil, fmt.Errorf("session: forward handshake failed: %q (body hint: %s)",
statusLine, bodyHint)
}
// 4. 把握手後的 header 讀完(一直讀到空行)
for {
line, err := reader.ReadString('\n')
if err != nil {
_ = conn.Close()
return nil, fmt.Errorf("session: read handshake headers: %w", err)
}
if line == "\r\n" || line == "\n" {
break
}
}
// 5. 清掉 deadline因為後續 streaming 場景不該再 timeout
if err := conn.SetDeadline(time.Time{}); err != nil {
_ = conn.Close()
return nil, fmt.Errorf("session: clear deadline: %w", err)
}
// 6. 如果 reader 裡還有預讀資料bufio.NewReader 可能讀超過一行),
// 回傳一個包裝 conn 把預讀的 byte 接回 stream。
// 這個情境在 raw forward 上理論上不會發生remote-proxy 在發出
// "200 Connected\r\n\r\n" 之後不會主動寫資料 — 它要等 caller 寫
// request 才會從 yamux stream 收 response但保險起見處理。
if buffered := reader.Buffered(); buffered > 0 {
peek, _ := reader.Peek(buffered)
f.logger.Warn("forwarder: unexpected bytes after handshake; wrapping conn",
"bytes", buffered)
return newPrefixConn(conn, append([]byte(nil), peek...)), nil
}
return conn, nil
}
// ForwardHTTP 是「給定 http.Request回傳 *http.Response」的高階 helper。
//
// 內部實作:
// 1. OpenStream 拿 raw TCP已 hijack連線
// 2. req.Write(conn) 把完整 HTTP request 寫進去
// 3. http.ReadResponse 讀出 response不消耗 body
//
// 重要response.Body **包住 conn 本身**(所以 caller 必須在用完後 Close
// response.Body這允許 streaming bodyMJPEG / SSE / chunked原樣轉發。
//
// req 的 URL.Host / Scheme 會被覆寫成 "127.0.0.1" / "http",因為 local agent
// 收到的是「打到自己 localhost」的請求caller 設定的 Host header 會被保留。
func (f *Forwarder) ForwardHTTP(ctx context.Context, sessionToken string, req *http.Request) (*http.Response, error) {
if req == nil {
return nil, errors.New("session: ForwardHTTP requires non-nil req")
}
conn, err := f.OpenStream(ctx, sessionToken)
if err != nil {
return nil, err
}
// 改寫 req 為「打給 local agent」格式
// - URL.Scheme = httpURL.Host = 127.0.0.1 → req.Write 才不會報錯
// - RequestURI 必須清空client 端不能設)
// - 不覆寫 req.Hostcaller 自行決定要不要保留 browser 的 Host
//
// 注意req 本身可能已被外部使用,這裡複製 URL 避免副作用。
outReq := req.Clone(ctx)
if outReq.URL == nil {
outReq.URL = &url.URL{}
}
outReq.URL.Scheme = "http"
outReq.URL.Host = "127.0.0.1"
outReq.RequestURI = ""
if outReq.Host == "" {
outReq.Host = "127.0.0.1"
}
// 把 request 寫到 conn
if err := outReq.Write(conn); err != nil {
_ = conn.Close()
return nil, fmt.Errorf("session: write request to forwarded conn: %w", err)
}
// 讀 response — 不可以 close conn因為 response.Body 還會用到
resp, err := http.ReadResponse(bufio.NewReader(conn), outReq)
if err != nil {
_ = conn.Close()
return nil, fmt.Errorf("session: read response from forwarded conn: %w", err)
}
// 把 conn 包進 response.Body 的 close chaincaller close body 時連 conn 一起關
resp.Body = &bodyWithConn{ReadCloser: resp.Body, conn: conn}
return resp, nil
}
// ForwardWebSocket 預留 — B5 接前端 WS 時實作。
//
// 預期實作(草稿):
// - OpenStream 拿到 raw conn
// - 把 WS upgrade request 透過 conn 寫過去
// - 等 101 response 回來
// - Hijack browser 端連線,與 conn 雙向 pipe
//
// 雛形先回 ErrNotImplemented避免被誤用。
func (f *Forwarder) ForwardWebSocket(ctx context.Context, sessionToken string, req *http.Request) (net.Conn, error) {
return nil, errors.New("session: ForwardWebSocket not implemented yet (TODO B5)")
}
// ----------------------------------------------------------------------
// Helpers
// ----------------------------------------------------------------------
// drainAndPeek 嘗試讀少量 byte 給 error message 加上 context
// 不阻塞太久,最多 256 byte。
//
// 呼叫前提caller 必須已經對 underlying conn 設過 ReadDeadline這個函式只
// 在 OpenStream 握手失敗的 error path 被呼叫,該路徑已經 SetReadDeadline
// 到 defaultHandshakeTimeout所以 Read 不會 hang 住;若 deadline 已過,
// Read 會立刻回 0 + deadline error行為仍然是「不阻塞」。
func drainAndPeek(reader *bufio.Reader) string {
buf := make([]byte, 256)
n, _ := reader.Read(buf)
return strings.TrimSpace(string(buf[:n]))
}
// bodyWithConn 把 ReadCloser 與底層 net.Conn 綁在一起,
// caller close body 時順便關 conn避免 leak
type bodyWithConn struct {
io.ReadCloser
conn net.Conn
}
// Close 同時關閉 body 與底層 conn以最後一個非 nil 的 error 回傳。
func (b *bodyWithConn) Close() error {
bodyErr := b.ReadCloser.Close()
connErr := b.conn.Close()
if bodyErr != nil {
return bodyErr
}
return connErr
}
// prefixConn 把預讀的 byte 接回 net.Conn 開頭,供 caller 透明使用。
//
// 並發說明net.Conn 本身對單一 goroutine 讀 + 單一 goroutine 寫是安全的。
// prefixConn 只包裝 Readprefix 的讀取不會跨 goroutine 共享Read 慣例上
// 只由 reader goroutine 呼叫),所以這裡不需要額外的 mutex。
type prefixConn struct {
net.Conn
prefix []byte
}
func newPrefixConn(c net.Conn, prefix []byte) *prefixConn {
return &prefixConn{Conn: c, prefix: prefix}
}
func (p *prefixConn) Read(b []byte) (int, error) {
if len(p.prefix) > 0 {
n := copy(b, p.prefix)
p.prefix = p.prefix[n:]
return n, nil
}
return p.Conn.Read(b)
}