依 autoflow-agent workspace v2 設計把 PRD / 設計 / 架構 / 交付類 共享文件從個人層 .autoflow/(ignored)搬到 docs/autoflow/(進 git), 讓團隊可共享產品與架構文件,個人層只留 progress / review / testing 等 per-branch 筆記。 - 02-prd/ 21 個檔(PRD、features、market-analysis 等) - 03-design/ 18 個檔(design-spec、wireframes、flows 等) - 04-architecture/ 31 個檔(TDD、design-doc、ADR×14、API 規格等) - 07-delivery/ 3 個檔(project-summary、phase-0.6-handover、stage-deployment-setup) 合計 73 檔。原檔已從 .autoflow/ 移除(migration 工具執行 git mv, 但因 .autoflow/ 在 .gitignore 中、git 將此操作視為新增、無 rename history)。
233 lines
8.5 KiB
Markdown
233 lines
8.5 KiB
Markdown
# 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?<optional-token-in-query>
|
||
Content-Type: application/json
|
||
|
||
{
|
||
"session_token": "vAc_...",
|
||
"method": "GET",
|
||
"path": "/api/devices",
|
||
"headers": {"X-From-Api-Server": "value"},
|
||
"body": "<base64-encoded request body,可省略>"
|
||
}
|
||
```
|
||
|
||
**Response**(200 OK 或 502 Bad Gateway):
|
||
```json
|
||
{
|
||
"status": 200,
|
||
"headers": { "Content-Type": ["application/json"] },
|
||
"body": "<base64-encoded response 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=<session-token> HTTP/1.1
|
||
Host: <proxy-internal-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=<pairing-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: <bearer>`
|
||
- **絕不對外暴露**(公網 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)
|