// 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)