Skip to Content
概念調度協定

調度協定

每個後端→Worker 的請求都是 <method> {dispatcher_origin}/dispatch{path},只多帶一個 header:x-teamsync-dispatch-assertion: <compact JWS>。dispatcher 驗證後,在自己綁定的 Worker 位置中解析 script(先看啟用或斷言的 serving_target claim,否則依固定順序 — 見架構),再以斷言攜帶的每次呼叫上限(後端預設:CPU 50 ms、500 個子請求)呼叫動態調度。地端由同一份 dispatcher 程式碼在套件的 relay 之後執行,且只有一個位置。

簽章斷言

EdDSA(Ed25519),header 恰為 {alg: "EdDSA", kid, typ: "JWT"},payload 16 個必要 claims,外加選填的 claims:limits、serving_target,以及另外兩個只有平台維運者的控制請求才可以攜帶的 claims — 不能有其他鍵,16 個必要 claims 一個都不能少:

iss, aud, sub (= user_id), company_id, department_id, script_name, release_generation, method, path, query, body_sha256, purpose, iat, nbf, exp, jti + optional: limits, serving_target (+ two operator-only claims)
  • limits = 恰為 {cpu_ms, subrequests},整數,範圍 1–30 000 與 1–1 000。後端一律附上,值來自 EDGE_WORKERS_LIMIT_CPU_MS/EDGE_WORKERS_LIMIT_SUBREQUESTS(預設 50/500);值不合規則時後端拒絕簽章,因此永遠不會送出 dispatcher 會拒絕的 claim。dispatcher 把這個 claim 套用到每一次位置查找(get(…, {limits}));沒有這個 claim(較舊的簽發端、平台部署 canary)時退回自己的 EDGE_WORKER_CPU_MS/EDGE_WORKER_SUBREQUESTS 變數。格式錯誤的 claim 與其他不合法 claim 一樣被拒絕。調整上限是後端設定變更加重啟,不需要重新部署 dispatcher。

  • serving_target = {dispatch_namespace, routing_epoch},對已被搬移的 Worker 另可加上選填的 script_name(在新位置服務它的 script):dispatch_namespace 是一個最多 64 字元、指名 dispatcher 所綁定位置之一的字串,routing_epoch 是一個非負的 safe integer。格式錯誤的 claim 與其他不合法 claim 一樣被拒絕。自後端 5.22.0 起,後端對已記錄服務位置的 Worker 發出簽章呼叫時會送出這個 claim(不一定每次呼叫都送);沒帶 claim 的呼叫,若該 script 有啟用就依該啟用路由(若在當下無法查找啟用,則依固定順序),否則依固定的查找順序。epoch 0 表示該 Worker 尚未被搬移:該 script 沒有啟用時,dispatcher 只問所指名的那個位置,不問其他位置(若它在當下無法查找啟用,epoch 0 的 claim 會退回固定的查找順序,正值 epoch 的 claim 則回 503 dispatcher_unavailable)。一旦該 script 有了啟用(dispatcher 啟用對照表中的項目,或後端搬移 Worker 時記錄的啟用),claim 就必須與它相同 — 同一位置、同一 epoch、同一 script — 而沒有啟用時帶正值 epoch 也同樣不相符;任何差異都會在使用者程式碼執行前回 409 serving_target_mismatch(Serving target changed)。在只有單一位置的 dispatcher(地端)上,這個 claim 會得到 503 dispatcher_unavailable;地端的後端從不送出它。

  • 存活時間:簽章端 exp = iat + 30、nbf = iat − 2;dispatcher 容忍 5 秒時鐘偏差並強制 exp − iat ≤ 30。

  • jti 是每請求隨機 UUID。刻意沒有重放狀態儲存 — 30 秒窗口加上精確的 method/path/query/body/script/release-generation 綁定即約束重放;耐久的一次性 jti 閘門是另一個威脅模型的決策。

  • body_sha256 覆蓋精確的有界 body 位元組(空 body → 零位元組的摘要;GET 帶 body 兩側都拒絕)。

  • query 是prepared 的線上 query(由 requests 準備;簽章簽的正是送出的位元組):≤1024 位元組、可列印 ASCII、無空白。太大 → 414;不合法 → 400,兩者都發生在簽章之前。

  • purpose ∈ invoke | control。control 只對保留路徑 /_teamsync/control 有效;invoke 只對其他路徑有效。

  • 整個 JWS 必須塞進 8192 位元組 — 這就是最終限制最壞情況 percent-encoded 路徑的東西。

dispatcher 驗證順序

先驗簽章與結構(精確的三段 JWS、精確的 header 鍵、公開 JWK 健全性 — JWK 必須是 verify-only、不得含 d),再比 kid、64 位元組簽章、WebCrypto Ed25519 verify,然後驗 claims:精確鍵集合(只有選填的 claims 可以缺席)、逐 claim 的上限與 regex(script_name ^dw-[0-9a-f]{48}$、UUID、64-hex 摘要)、issuer/audience 相等、時間窗、請求綁定(method、解碼路徑、原始 query、purpose↔path)。最後它用 company_id + department_id 重新推導 script 名稱並要求與 claim 相等 — 呼叫者選擇 script 名稱在結構上不可能。

每種拒絕都回同一個 401 invalid_dispatch_assertion。11 種明確拒絕代碼只出現在結構化日誌:malformed_compact_jws、invalid_protected_header、unknown_key_id、invalid_signature、invalid_claims、issuer_or_audience_mismatch、assertion_time_invalid、request_binding_mismatch、script_identity_mismatch、request_body_invalid、invalid_serving_target;非預期的驗證失敗會改記獨立的 fallback verification_failed。dispatcher 設定錯誤 — 包括不合法的啟用對照表,或格式錯誤的已記錄啟用 — 回 503 dispatcher_unavailable。

共用路徑圍欄

後端、dispatcher 與生成的 router 執行同一套規則,任何一個元件都不可能簽下另一個元件會做不同解讀的路徑:

  • percent-encoded 分隔符 %2f/%5c 在解碼之前就拒絕(後端檢查 ASGI raw_path — 唯一保留這資訊的標準欄位;raw_path 缺席時,動態路徑整個拒絕)。
  • 解碼後路徑:恰好一個開頭 /、無結尾 /、無 \ ? # % 或 C0/C1 控制字元、無空段/./.. 段。
  • 上限:2048 字元、2048 UTF-8 位元組、4096 percent-encoded 字元。
  • /_teamsync/* 保留;invoke 路由對它回 404,runtime 只在帶有精確 release generation 的已驗證 purpose=control 請求時服務它。

Header 清理

轉發前,dispatcher(與後端閘道各自獨立地)剝除 hop-by-hop header 加上所有 runtime 擁有的 header:精確名單 authorization、connection、content-length、cookie、host、keep-alive、proxy-authenticate、proxy-authorization、te、trailer、trailers、transfer-encoding、upgrade、x-api-key,以及所有 cf-*、x-forwarded-*、x-teamsync-*。正因 x-teamsync-* 從輸入剝除,dispatcher 之後加上的值才不可偽造:

Header簽章通道公開通道
x-teamsync-request-id新 UUID新 UUID
x-teamsync-release-generation驗證過的 claim 值永不
x-teamsync-dispatch-modeinternalpublic
x-teamsync-dispatch-purposeinvoke/control永不
x-teamsync-public-api-key—呼叫者的 X-TeamSync-Worker-Key(若符合 ^tsw_[A-Za-z0-9_-]{43,124}$)
x-teamsync-client-ip—cf-connecting-ip(經健全性檢查)

後端閘道還會在簽章通道上改寫一個 header:送往 dispatcher 時一律帶 User-Agent: teamsync-backend-edge-gateway(workers.dev 主機上的 Cloudflare 瀏覽器完整性檢查,會在 dispatcher 執行前以不透明的 403 error code: 1010 回應某些函式庫 agent),並把呼叫者的 agent 以 x-client-user-agent 交給 Worker — 最多 256 字元、空值時省略,呼叫者自帶的 x-client-user-agent 會被丟棄,所以在這條通道上這個 header 一定由後端寫入。兩個名稱都不屬於 runtime 擁有的 header,因此都會到達 handler 程式碼。公開通道轉發呼叫者自己的 header(扣除上面 runtime 擁有的那組),不做改寫。

生成的 router 在選路前驗證 mode/purpose/generation 三元組(漂移 → 503 release_generation_mismatch;context 形狀錯誤 → 401 dispatch_context_invalid),並在 handler 程式碼執行前再剝一次 TeamSync header。

回應方面,兩層都會丟掉 hop-by-hop header 加 set-cookie(部門 Worker 在兩條通道上都設不了 cookie),並串流 body — 後端逐 chunk 重驗 5 MiB 上限且不解壓(decode_content=False 保留 provider 的原始表示)。在雲端,dispatcher 還會在每個由 Worker 產生的回應上設定 x-teamsync-serving-namespace 與 x-teamsync-routing-epoch,覆寫 Worker 回傳的同名 header;它們是 Worker 程式碼無法設定的平台診斷資訊 — 不要解析它們。

Body 與逾時

dispatcher:有界串流讀取 ≤1 MiB,sha256 與 body_sha256 比對,然後轉發那些精確位元組。後端閘道:requests session 帶 trust_env=False(斷言永不流向 proxy)、timeout=(5, 35)、不跟隨轉址。控制通道:正規 JSON POST /_teamsync/control 在後端 ≤1 MiB(runtime 本身只接受 ≤256 KiB,超過回 422 control_request_invalid),回應上限 256 KiB;提交的程式碼永遠看不到這些請求。後端先解碼 JSON 回應 body,才看狀態碼。具型別的拒絕會變成帶有其狀態碼與代碼的 EdgeWorkerControlRejected:409 worker_wide_key_legacy_only、409 secret_limit_exceeded/secret_size_exceeded、409 service_account_limit_exceeded、413 secret_size_exceeded,以及 404 operation_not_found(回答的 release 沒有對應已簽章 generation 的控制路徑)。其他 409 → EdgeWorkerControlConflict;其他非 200 → 一般的控制拒絕。

Last updated on