從 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>
210 lines
6.8 KiB
Go
210 lines
6.8 KiB
Go
// 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,本身為 stateless(baseURL / logger / timeout 在建構時固定)。
|
||
type HTTPProxyClient struct {
|
||
baseURL string // remote-proxy internal URL,例:http://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": ""
|
||
// }
|
||
//
|
||
// 回傳 *Summary;session 不存在時回 ErrSessionNotFound(HTTP 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)
|