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

210 lines
6.8 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.

// proxy_client.go — HTTPProxyClient 實作 ProxyClient interface。
//
// HTTPProxyClient 是 api-server 端透過 internal HTTP API 存取 remote-proxy 的客戶端。
// 對齊 `.autoflow/04-architecture/api/api-internal.md` 的端點規格:
//
// - GET /internal/session/:token → GetSession
// - GET /internal/sessions → ListSessions
// - POST /internal/session/:token/close → CloseSession
//
// 實際的「打開 stream 並轉發 HTTP request」走 raw forward 路徑(見 forwarder.go
// 不在此 client 範圍內 — 這個 client 只負責純 metadata 操作。
package session
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"strings"
"time"
)
// defaultProxyClientTimeout 是 internal HTTP 呼叫的預設 timeout。
//
// 30s 對 internal 網路(同機 / 同 VPC已綽綽有餘
// 真正的 streaming 走 forward/raw 不走此 client所以不需要無限長。
const defaultProxyClientTimeout = 30 * time.Second
// HTTPProxyClient 是 ProxyClient 的 HTTP 實作。
//
// 並發安全:依賴 http.Client本身為 statelessbaseURL / logger / timeout 在建構時固定)。
type HTTPProxyClient struct {
baseURL string // remote-proxy internal URLhttp://localhost:3801
http *http.Client // 共用一個 http.Client 以重用 keep-alive 連線
logger *slog.Logger
}
// NewHTTPProxyClient 建立一個新的 HTTPProxyClient。
//
// baseURL 必須為合法 URL否則 caller 在第一次呼叫時才會發現錯誤;
// 為避免「沉默失敗」,這裡會在建構時 trim 尾端 "/"。
//
// logger 為 nil 時使用 slog.Default。
func NewHTTPProxyClient(baseURL string, logger *slog.Logger) *HTTPProxyClient {
if logger == nil {
logger = slog.Default()
}
return &HTTPProxyClient{
baseURL: strings.TrimRight(baseURL, "/"),
http: &http.Client{
Timeout: defaultProxyClientTimeout,
},
logger: logger,
}
}
// BaseURL 回傳建構時設定的 remote-proxy internal URL給 forwarder 共用)。
func (c *HTTPProxyClient) BaseURL() string {
return c.baseURL
}
// GetSession 對應 GET /internal/session/:token。
//
// 對 remote-proxy 的回應格式(由 internal_forward.go.getSession 寫入):
//
// {
// "token": "vAc_...",
// "connected": true,
// "connected_at": "RFC3339",
// "last_heartbeat": "RFC3339",
// "remote_addr": "1.2.3.4:5678",
// "user_id": "demo-user",
// "device_id": ""
// }
//
// 回傳 *Summarysession 不存在時回 ErrSessionNotFoundHTTP 404
func (c *HTTPProxyClient) GetSession(ctx context.Context, token string) (*Summary, error) {
if token == "" {
return nil, errors.New("session: GetSession requires non-empty token")
}
endpoint := c.baseURL + "/internal/session/" + url.PathEscape(token)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, fmt.Errorf("session: build GetSession request: %w", err)
}
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("session: GetSession call remote-proxy failed: %w", err)
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusOK:
// 解析 remote-proxy 的 JSON
var raw struct {
Token string `json:"token"`
Connected bool `json:"connected"`
ConnectedAt time.Time `json:"connected_at"`
LastHeartbeat time.Time `json:"last_heartbeat"`
RemoteAddr string `json:"remote_addr"`
UserID string `json:"user_id"`
DeviceID string `json:"device_id"`
}
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
return nil, fmt.Errorf("session: GetSession decode response: %w", err)
}
// connected=false 視為 NotFound已斷線或正在清理
if !raw.Connected {
return nil, ErrSessionNotFound
}
return &Summary{
Token: raw.Token,
UserID: raw.UserID,
DeviceID: raw.DeviceID,
ConnectedAt: raw.ConnectedAt,
LastHeartbeat: raw.LastHeartbeat,
RemoteAddr: raw.RemoteAddr,
}, nil
case http.StatusNotFound:
return nil, ErrSessionNotFound
default:
// 不是已知 status — 帶上 body 讓使用端 debug
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return nil, fmt.Errorf("session: GetSession unexpected status %d: %s", resp.StatusCode, string(body))
}
}
// ListSessions 對應 GET /internal/sessions。
//
// remote-proxy 回應格式internal_forward.go.HandleListSessions
//
// { "sessions": [ Summary, ... ], "total": N }
func (c *HTTPProxyClient) ListSessions(ctx context.Context) ([]*Summary, error) {
endpoint := c.baseURL + "/internal/sessions"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, fmt.Errorf("session: build ListSessions request: %w", err)
}
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("session: ListSessions call remote-proxy failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return nil, fmt.Errorf("session: ListSessions unexpected status %d: %s", resp.StatusCode, string(body))
}
var raw struct {
Sessions []*Summary `json:"sessions"`
Total int `json:"total"`
}
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
return nil, fmt.Errorf("session: ListSessions decode response: %w", err)
}
if raw.Sessions == nil {
// 空 list 統一用 non-nil empty slice呼叫方好處理
return []*Summary{}, nil
}
return raw.Sessions, nil
}
// CloseSession 對應 POST /internal/session/:token/close。
//
// 用於管理動作(使用者 revoke token、後台運維強制斷線
// session 不存在回 ErrSessionNotFound其他錯誤直接 wrap。
func (c *HTTPProxyClient) CloseSession(ctx context.Context, token string) error {
if token == "" {
return errors.New("session: CloseSession requires non-empty token")
}
endpoint := c.baseURL + "/internal/session/" + url.PathEscape(token) + "/close"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, nil)
if err != nil {
return fmt.Errorf("session: build CloseSession request: %w", err)
}
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("session: CloseSession call remote-proxy failed: %w", err)
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusOK:
// 消化 body 讓底層連線可以被 keep-alive 重用
_, _ = io.Copy(io.Discard, resp.Body)
return nil
case http.StatusNotFound:
return ErrSessionNotFound
default:
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return fmt.Errorf("session: CloseSession unexpected status %d: %s", resp.StatusCode, string(body))
}
}
// 編譯時檢查:確保 HTTPProxyClient 實作 ProxyClient。
var _ ProxyClient = (*HTTPProxyClient)(nil)