# Internal API — api-server ↔ remote-proxy > **雛形(Phase 0)就要實作**(2026-04-22 Q1 裁決)。 > 雛形採雙 binary 部署:`api-server` 與 `remote-proxy` 各自為獨立 process,透過本文件定義的 HTTP 介面溝通。 > `api-server` 無狀態;`remote-proxy` 持有所有 tunnel session(in-memory,無 Redis,見 ADR-006)。 --- ## 背景 雛形(單一 remote-proxy instance): ``` [瀏覽器] ─HTTPS─► [api-server] [remote-proxy] ←WS+yamux─ [local agent] │ ▲ │ GET /internal/session/:token │ │ POST /internal/forward/http │ │ GET /internal/forward/ws │ └────────────────────────────────────┘ (internal HTTP,走內網 / localhost) ``` - `api-server` **不持有** `*yamux.Session`;要走 tunnel 的請求一律轉發給 `remote-proxy` - `remote-proxy` 持有 `map[token]*yamux.Session`,本機記憶體 Phase 1 當 `remote-proxy` 水平擴展到多節點時,api-server 還需要有「找到對的 proxy」的能力(見 `tunnel.md` §5.4),但此 HTTP 介面的形狀不變。 --- ## 端點 > **雙軌設計**(B3 Review Major 1 修復 / 2026-04-22): > > api-server ↔ remote-proxy 的 HTTP forward 提供兩條路徑: > > | Endpoint | 封裝 | 支援 streaming | 支援 WS upgrade | 適合場景 | > |----------|------|---------------|----------------|---------| > | `POST /internal/forward/http` | JSON + base64 body | ❌ | ❌ | 一次性 JSON request/response(GET /healthz、POST /api/devices 等) | > | `POST /internal/forward/raw` | Hijack 成 raw TCP | ✅(MJPEG / SSE / chunked) | ✅(只要 caller 按 HTTP 協定送 upgrade) | `session.ProxyClient.OpenStream(ctx) net.Conn` 的底層;B4 ProxyClient 預設走這條 | > > **兩者並存**是刻意為之 — JSON 版對簡單 API 好寫好測,raw 版是 OpenStream 語意的真實底層(B4 的 `session.RemoteHandle` 必須走這條)。 ### POST `/internal/forward/http`(JSON 版) **用途**:api-server 把一個一次性的 JSON request 轉發給 proxy 節點,由該節點透過 tunnel 打給 local agent。**不支援 streaming**;對 MJPEG / SSE / chunked body 應改走 `/internal/forward/raw`。 **Request**: ``` POST /internal/forward/http? Content-Type: application/json { "session_token": "vAc_...", "method": "GET", "path": "/api/devices", "headers": {"X-From-Api-Server": "value"}, "body": "" } ``` **Response**(200 OK 或 502 Bad Gateway): ```json { "status": 200, "headers": { "Content-Type": ["application/json"] }, "body": "", "error": null } ``` 失敗時: ```json { "error": { "code": "TUNNEL_DISCONNECTED", "message": "session not connected" } } ``` **實作要點**(proxy 端): - `store.Lookup(token)` 找 handle - `handle.OpenStream(ctx)` 開 yamux stream - 組 `http.Request` → `req.Write(stream)` - `http.ReadResponse(bufio.NewReader(stream), req)` → `io.ReadAll(body)` → base64 encode 回 JSON ### POST `/internal/forward/raw`(raw bytes + Hijack 版,B3 Review Major 1 修復) **用途**:把 api-server 的 HTTP 連線接管成 raw TCP,與 tunnel 裡的 yamux stream 雙向 pipe。支援任意 HTTP 流量(streaming body、長連線、WS upgrade),是 `session.ProxyClient.OpenStream(ctx) net.Conn` 語意的真實底層。 **Request**(api-server 送): ``` POST /internal/forward/raw?token= HTTP/1.1 Host: Content-Length: 0 ``` **Response 握手**(proxy 在 session 找到後寫回): ``` HTTP/1.1 200 Connected <空白行> ``` **此時 HTTP 協議結束**,proxy 呼叫 `http.Hijacker.Hijack()` 把底層連線交出,與 yamux stream 做雙向 `io.Copy`。api-server 此後把這條連線當成 `net.Conn` 直接用: ```go // api-server 端(B4 實作,示意) conn := dial(/internal/forward/raw?token=xxx) readHandshake(conn) // 讀 "HTTP/1.1 200 Connected" + 空白行 // 從這裡開始 conn 就是一條通到 local agent 的 raw TCP stream(透過 yamux) req.Write(conn) // 送完整 HTTP request resp, _ := http.ReadResponse(bufio.NewReader(conn), req) io.Copy(browserResponseWriter, resp.Body) // streaming friendly ``` **失敗處理**: - Session 不存在:在 hijack **之前**回 `502 JSON { error: TUNNEL_DISCONNECTED }`(一般 HTTP client 可讀) - Hijacking 不支援:回 `500 JSON { error: HIJACK_UNSUPPORTED }` - Hijack 後 `OpenStream` 失敗:在 hijacked 連線上寫回 `HTTP/1.1 502 Bad Gateway` + JSON body 再關閉(caller 拿到的仍是合法 HTTP response) **為何用 CONNECT-style 握手而非直接雙向 pipe?** - 需要一個明確的「session ready」訊號,避免 caller 不知道 session 是否存在就開始 write - 與既有 HTTP 基礎設施相容(middleware / LB 能正確路由與紀錄一次 request) - api-server 端 B4 實作時可以用標準 `http.Client` 發第一個 request,拿到 underlying conn 後再切換成 raw 模式 **Flusher 支援**:不需要 — raw 模式下 io.Copy 天然支援任意 chunk / streaming 語意,由 yamux 負責在 TCP 上傳輸。 --- ### GET `/internal/forward/ws`(WebSocket upgrade) **用途**:與 `/http` 同,但用於 WebSocket。api-server 收到瀏覽器 WS → 升級 → 在內部開一個到 proxy 的 HTTP Connection Upgrade。 **Request**: ``` GET /internal/forward/ws?token= Upgrade: websocket ... (原瀏覽器 WS upgrade request headers) ``` **Response**: ``` 101 Switching Protocols ... (yamux stream 裡 local agent 回來的 101 response) ``` proxy 拿到 upgrade request 後: 1. `session.Open()` 拿 stream 2. 把 upgrade request 寫進 stream(給 local agent) 3. 讀 stream 回來的 101(從 local agent) 4. Hijack caller 連線,與 stream 雙向 pipe 邏輯同 POC `proxyWebSocket`,只是「caller」換成 api-server(internal)。 --- ### GET `/internal/session/:token` **用途**:proxy 自我查詢某個 token 的 session 狀態,debug / health 用。 **Response**: ```json { "token": "pk_xxx", "connected": true, "connected_at": "...", "last_seen_at": "...", "stream_open_count": 3 } ``` --- ### POST `/internal/session/:token/close` **用途**:後台運維強制斷某條 tunnel(使用者 revoke token 後)。 --- ## 安全 - 內部 API 只監聽 **internal network interface**(VPC 私網、K8s ClusterIP) - 防禦: - 網路層面:security group / NetworkPolicy 只允許 api-server pod 連 - 應用層面(Phase 1+):mTLS 或 shared secret header `X-Internal-Auth: ` - **絕不對外暴露**(公網 LB 白名單) --- ## 觀測 Metrics: - `proxy_internal_forward_total{endpoint, result}` - `proxy_internal_forward_duration_seconds` - `proxy_stream_open_total` - `proxy_stream_error_total{reason}` Log: - `event=forward path=/api/devices token_prefix=pk_abcd duration_ms=45 status=200` --- ## 雛形行為(Phase 0 必做,Q1 裁決) **雛形就是雙 binary**,本文件的所有 endpoints 都是 Phase 0 必須實作: - `remote-proxy` 提供這些 internal HTTP endpoints - `api-server` 透過 `ProxyClientStore` 呼叫這些 endpoints 雛形 API handler 端的呼叫方式: ```go // api-server 端 handle, err := sessionStore.Lookup(ctx, token) // 實際走 GET /internal/session/:token if err != nil { return ErrTunnelDisconnected } stream, err := handle.OpenStream(ctx) // RemoteHandle 實作:POST /internal/forward/raw(B3 Major 1 修復後) // 寫 request、讀 response — 語義跟直接拿 yamux stream 一致 ``` **B4 實作提醒**: - `RemoteHandle.OpenStream` 必須走 `POST /internal/forward/raw`(hijack raw TCP),不能走 JSON 版的 `/internal/forward/http` - 對於簡單 JSON API 的同步呼叫(如 internal 健康檢查),仍可使用 JSON 版以簡化程式碼 Phase 1 多節點時: - 介面完全不變 - `ProxyClientStore` 底層增加「找到正確 proxy 節點」的邏輯(見 `tunnel.md` §5.4) --- **總結**: - ✅ 雛形:`api-server` ↔ `remote-proxy` 透過本文件定義的 HTTP 介面溝通(必做) - ✅ Phase 1:介面不變,底層 routing 策略升級 - ❌ 雛形 **不**走「api-server 與 remote-proxy 同進程共用 map」的捷徑(那條路會讓 api-server 變成有狀態,走不到 Phase 1)