iT邦幫忙

2026 iThome 鐵人賽

DAY 9
0
AI Engineering

從單一agent 到多agent 集群的開發流水帳以及應用系列 第 12

Day 9:把門關上之後,才發現鑰匙早就作廢了

  • 分享至 

  • xImage
  •  

Day 9:把門關上之後,才發現鑰匙早就作廢了

前情:Day 8 收工時,spectyn serve 上有 22 條路由對沒有憑證的遠端呼叫者直接回答。當天的處理是在綁定時加一道閘——只要這 22 條還開著,就拒絕綁任何非 loopback 位址,並把這件事列成一個需要操作者裁定的點。今天要做的是把那個裁定點消掉:關門,證明留開的無害,然後讓 serve 真的能綁 tailnet。

做到了。然後我請一批 agent 來拆自己當天的成果,拆出 17 條成立的問題,最重的一條是我親手裝上去的。


一、逐條量,不是逐條關

22 條路由,一條一個 agent 去量三件事:它回傳什麼、誰在呼叫、關掉會壞什麼。44 個 agent——每條一個量測者、一個獨立的駁斥者,駁斥者的預設答案是「不成立」。

結果:20 條關、2 條開、0 條「必須開」。

五天前擔心的方向是錯的。當時怕的是「有些路由被遠端 peer 依賴,關了 mesh 就斷」;量出來,20 條的呼叫者全部是本機 UI——而本機呼叫者本來就豁免。唯一一條有遠端呼叫者的是 /rpc/ping,它的兩個呼叫端根本沒帶憑證。所以關門之前先讓敲門的人帶鑰匙:那是獨立的一個 commit,在換鎖之前。

順便量到第 23 條:/node/capabilities,不在 /api 也不在 /rpc 底下,從來不在清點範圍內。這件事後面會變成整天的主題。

二、換鎖:一層 middleware,不是二十次呼叫

原本的閘是 per-handler:每個 handler 自己記得呼叫驗證。20 條開著,不是有人決定要開,是沒有人記得關,而且沒有東西列舉過結果。

所以修法不是再寫二十次,是把閘變成一層 middleware:閘在一個地方,例外變成資料。 加一條路由不再能靠「忘記」把它開著——它得被明列。

一個真實的風險先避掉:middleware 驗 HMAC 要讀 body,讀 body 會弄壞串流端點。避法是先判本機豁免(只需要 header 與來源 IP),本機呼叫者原封不動放行、串流照常;只有遠端才付緩衝的代價。

既有的 ratchet 是雙向的——多一條開著會紅,少一條也會紅。它紅了,列出的正是那 20 條。這是一個 ratchet 在正確方向上工作的樣子。

更新基線時發現那個 const 被兩個消費者用:ratchet 問「什麼是開的」,綁定閘問「開著會不會外洩」。那是兩個不同的問題,而一條路由可以「開著而且無害」。拆成兩份,綁定閘讀的那份現在是空的。

三、紅證抓到三件疊在一起的事

三條紅證:停用 middleware、拿掉本機豁免、把會外洩的路由加進開放清單。前兩條首次即中,第三條沒中——追下去是三個疊在一起的問題:

  1. 測試讀的是自己的副本。 我在測試裡寫死了那兩條開放路由。往 const 加一條會外洩的,測試不會發現。一個守衛讀自己那份副本,就不是在守它。
  2. middleware 有第二份清單。 我自己在 serve.rs 寫了一份,跟 commands::http 那份各自獨立。又是兩份清單一個問題——而我是在修這個問題的同時做了一次。
  3. fixture 是空的。 /api/todos<data>/todos.json,測試沒設沙箱,讀的是我自己的真實資料目錄。剛好是空的,規則就無條件通過。

三個都修完,紅證才真的紅。

留開路由的「不含操作者資料」檢查,第一版是關鍵字清單,在 "cluster_peers": 0 上誤報——那是計數,不是位址。我沒有刪關鍵字——那是把守衛變成橡皮圖章的第一步——改成規則:每個值只能是數字、布林、或版本樣的短字串。比清單更嚴,而且沒有「刪掉一個關鍵字」這個失效模式。

四、關門之後,才發現鑰匙早就作廢了

門關上了,去確認主要呼叫者——桌面 app——還開得了門。發現兩件事:

get_json 從來沒有簽過。post_rpc 簽的是 core 在 2026-08-08 就刪掉的方案——只簽 body。core 刪它的理由寫在程式碼裡:HMAC(secret, "") 是一個固定常數,任何空 body 的端點都吃它,等於一張永久通行證,所以連 migration 開關都沒留。app 原封不動地留著那個方案。

三週沒有人發現,因為 app 每一次呼叫都走 loopback,原生請求沒有 Origin,拿到本機豁免——簽章從頭到尾沒有被看過一眼。

那個模組不是沒測試,它有兩條天天綠的:

  • signature_matches_the_openssl_contract:釘死一組手抄進測試的向量,註解寫「任何一邊改了都會被抓到」。一個釘死的向量只是對單邊的檢查。 daemon 那邊整個方案被刪,沒抓到。
  • signing_covers_the_exact_body_bytes:斷言換 body 摘要會變。對——新舊兩個方案都滿足。 一條壞掉的實作也具備的性質,不是檢查。

修法不是補簽章——那只是把三個各自記得要簽的地方,還原成三個各自記得要簽的地方,而這次出事就是其中一個忘了。改成一扇門:模組裡只有 send_signed 碰 HTTP client,簽章直接呼叫 rpc_wire::sign_headers——那個函式的註解本來就寫著「全 repo 只有這一份簽章實作」,在 app 手刻一份的期間,那句話是假的;呼叫它,那句話才成立。

守衛一開始用 find("#[cfg(test)]") 切「非測試程式碼」——那個檔案有四個測試模組,它只掃了三分之一,漏掉三個呼叫點之一,而且報綠。抓到它的是我放在底部的下限斷言:「至少要看到三個呼叫點」。那條斷言唯一的作用,就是問「這條檢查到底看得到東西嗎」。

五、清點的是名字裡的那片,不是實際存在的那片

「22 條關上了」為真——對 /api/rpc 而言。serve 在這兩個前綴外面還掛了 32 條。清點它們的檔案,開頭寫著外部審查在 08-27 抓到的事:「我們把 API 關起來了」是一句只關於一個前綴的話。同一件事又發生了一次,只是往外一層。

逐條量:六條 handler 自己驗、/node/capabilities 真的開著(已關——它唯一的遠端呼叫者會簽)、三條 liveness 與整套 UI 靜態資源開著且應該開。靜態資源用規則歸類而不是 25 個名字,規則旁邊釘一條斷言:被歸類成資源的路由永遠不能回 JSON——一條開始回 JSON 的「資源」已經不是資源了。

順著這條抓到一個真的洩漏:/readyz 會把操作者的家目錄路徑寫進 detail,而它不能關(watchdog 沒憑證可帶)。改成遠端拿裁決、本機或已簽章才拿文字。

到這裡,Day 9 的 goal 清了。然後我開了一輪對抗式稽核。

六、我裝了一個會壞的閘

閘的另一半

驗章會燒掉 nonce——同一枚簽章驗過一次就不能再驗,這是防重放的核心。而 /api/rpc 底下,35 條路由的 handler 本來就自己驗,另外 18 條指令表路由掛著第二層 route_layer 的閘——那一層我不知道它存在。

middleware 一裝上去,同一個請求被驗兩次。第二次看到的是自己剛燒掉的 nonce,判定重放,回 401。

middleware 放行的簽章,handler 對它回 401。 53 條路由,每一個簽章正確的遠端呼叫者全部進不來。

三千多條測試沒有一條抓到,原因很乾淨:每一條都是「送一個沒有憑證的遠端請求,斷言 401」——而閘壞掉的時候,答案也是 401。 它們分不出「擋住壞人」和「擋住所有人」。一個只驗證「該拒的有被拒」的套件,對「該放的有沒有被放」是全盲的,而閘的價值有一半在後面那句。

修法不是刪 handler 的檢查——/rpc/admin/shell 用的是沒有本機豁免的嚴格版,交給 middleware 等於把 node shell 對任何本機行程打開。改成 middleware 站開:35 條由測試從原始碼反推比對,18 條直接從指令表推導,連清單都不用有。

而那條反推測試只掃 serve.rs,第二道閘在別的檔案裡,它看不到。我用來防止漏掃的檢查,自己漏掃。

七、同一個錯誤,四種穿法

四種穿法

/scan/hardware 回 hostname、OS、CPU、GPU、RAM、本機 Ollama 模型清單。/scan/credentials 回哪些 provider 有金鑰、來自哪裡。兩條完全沒有閘,而綁定閘當天剛被我解除。

它們躲過所有檢查,只因為 serve.rs 把路徑寫在 .route(下一行,而清點器只認「以 .route(" 開頭的行」。96 條註冊,讀到 93 條。

修完之後再往外一層:serve.rs 併進了另一個模組的三條 /api 路由,指令表又掛了 18 條、路徑是從指令名組出來的,原始碼裡沒有那個字串。讀 serve.rs 然後說「這是全部」,是同一個錯誤的第四種穿法。

現在清點器讀整張實際掛上去的表,並且釘兩條:解析出的路徑數必須等於 .route( 出現次數——讀不懂的註冊方式不是跳過,是紅;serve.rs 任何一行併進 router,都必須是讀過的來源。

八、五個死鑰匙送出端

死鑰匙

app 不是唯一一個。spectyn dispatchmsgsessionsgit syncself-update --peerdev_verify --remote——六個 CLI 指令用的都是那個作廢方案,其中兩處簽的是 HMAC(secret, "") 本身,也就是當初刪掉它的理由。

三週沒人發現,而原因不是沒人用:spectyn sessions 對每個 peer 的錯誤欄位,把「連不上」和「被拒絕」印成同一句話。一個把 401 折疊進「離線」的介面,等於把自己的認證錯誤靜音。 這比沒有錯誤處理更糟——沒有處理至少會爆,這個會安靜地把一個可修的問題顯示成不可修的。

瀏覽器端也一樣:兩頁用 crypto.subtle 簽舊方案;第三頁——手機的 /m——從來沒簽過。今天的目標是讓 serve 能綁 tailnet,而達成之後的副作用是手機主控台整頁 401。我那條「本機介面不受影響」的測試只送 127.0.0.1,失敗訊息裡卻寫著「/m PWA 也是本機呼叫者」。那句話是假的。

所以瀏覽器也要有一份 canonical v2。差點犯兩個錯:

  • 只用 crypto.subtle 它只在 secure context 存在,而這個檔案存在的理由正是手機用 plain HTTP 開頁面——剛好是它不能用的地方。那兩頁早就解掉了,各自帶著一份純 JS 的 SHA-256,banner 上還寫著原因。我正要把它解回去。救我的不是測試,是替換一段程式碼之前先讀它為什麼長這樣。
  • 換了 localStorage 的 key。 會編譯、會通過每一條測試,然後每一台存過密鑰的裝置靜靜失效。一個儲存 key 是資料格式的一部分,不是實作細節。

這次不只修,加一條全 repo 的規則:掛簽章 header 的檔案必須同時掛時間戳與 nonce——那正是新方案加的、舊方案沒有的兩樣東西。它會抓到 app、CLI、diagnostic 這三個各自被獨立發現、相隔數週的檔案,在它們被寫出來的那天。第一次跑就紅,指著 whatsapp.rs——那是 Meta 的 X-Hub-Signature-256比對名字,就會抓到所有取同一個名字的人。 修法不是例外清單,是改成看 header 本身。

九、稽核

稽核

兩輪 28 條主張,17 條撐過駁斥。除了上面那些,還有幾條各自值一句:

  • 一條測試不見了。 the_override_is_the_only_way_to_publish_an_ungated_api#[test] 被我稍早的編輯吃掉。它沒有變紅,它是不存在了,而全綠的報告不會說「你今天少了一條檢查」。
  • 洩漏藏在只有出錯時才出現的訊息裡。 /dist/scripts 的 404 內文回候選路徑陣列——也就是家目錄。跟 /readyz 同一個形狀:健康的節點什麼都不洩,洩漏在出事那一刻才出現,正是沒人在看的那一刻。
  • 一個輸出包含自己鑰匙的過濾器,不是過濾器。 spectyn coordinator/peers 不帶 secret_hash 就回全部,而回應裡包含每個節點的 secret_hash——正是過濾器檢查的那個值。查一次沒過濾的,就拿到做過濾查詢的鑰匙。
  • 探針把 400 當成「開著」。 /onboarding/config(回傳 cluster_secret 明文)被報成未認證可讀;實際是 extractor 缺參數先回 400,探針根本沒走到閘。現在任何回 400 的路由直接讓測試紅——把「探不到」當「沒問題」,正是今天一整天在講的事。

收尾:帶走的幾句話

  • 一個只驗證「該拒的有被拒」的套件,對「該放的有沒有被放」是全盲的。 每一種閘的測試都要成對寫。
  • 疊第二層閘之前,先問第一層有沒有副作用。 燒 nonce、消耗 token、寫 log——冪等的檢查才可以疊。
  • 清點器清點的是它名字裡的那片。 四次。每一次都以為這次是全部了。
  • 把 401 顯示成「離線」,等於把認證錯誤靜音。
  • 一條在第一次不方便時就被改掉的規則不是規則——「只能縮不能長」分不出「把路由打開」和「發現它本來就開著」,所以它問錯了。
  • 替換一段程式碼之前,先讀它為什麼長這樣。 那幾百行看起來像重造輪子的東西,是一段被證實過的知識。

明天:D9 裁定(手機主控台要每台存一次密鑰,還是開 trust_tailnet_peers),以及第三輪稽核的結果。


附:當日實錄(原文,稽核前)

Day 09:把 22 條無憑證的門關上

Day 8 收工時,四個裁定點全部回到操作者手上 —— 交付、R2 發佈、綁 tailnet、
復原服務。唯一還能由我推進的,是其中一項的第三個選項:
補上那 22 條路由的驗證。

開場:一個被刻意擱置的決定

spectyn serve 在有 Tailscale 的機器上綁不起來,因為一道 fail-closed 閘擋著:

refusing to bind ["100.x.x.x"]: 22 routes under /api and /rpc still answer a
remote caller with no credentials, so binding off loopback would publish them.

那道閘是對的。而它擋住的東西 —— /api/todos/api/sessions/api/cost
/api/governance/log —— 是操作者真正的工作內容。

五天前有人量過這件事,並且刻意沒有順手修掉。那份測試的檔頭寫著:

Fixing all twenty is an S1 decision, not a drive-by: a few of them
(/api/version, /api/status) may be load-bearing for peer discovery and
monitoring, and gating those could break a fleet in a way no test here would
notice.

於是它 ratchet 住現狀:二十條寫下來,多一條就讓建置失敗。

那是對的中間狀態。 它把「我們把 API 關好了」這種說法擋在門外,同時不假裝
自己知道哪一條能關。今天要做的是把那個決定真的做完 —— 而做完的前提是
逐條去量,不是逐條去猜。

Phase A:逐條量,22 條各一個 agent

44 個 agent(每條一個測量者加一個駁斥者),33 分鐘。決定性的問題我寫成這樣:

如果這條需要憑證,什麼會停止運作、對誰?

因為對大多數路由,誠實的答案是「沒有 —— 呼叫端都在本機,而本機是豁免的」。

裁決:

must_gate            20
safe_to_leave_open    2   /api/dashboard/status · /api/version
must_stay_open        0

五天前那個擔憂,方向是錯的

檔頭點名的是 /api/version/api/status。量出來:

  • /api/version 可以開 —— 而且理由不是「它無害」,是兩條已經開著的路由給得更多
    駁斥者找到 /node/capabilities(完全無閘,回傳 os/arch/service_model/能力清單)
    /rpc/ping(回傳版本、node 名、能力、uptime、以及 [agent.*] 的鍵名)。
  • /api/status 必須關,而關掉「幾乎不會壞任何東西,而且不會是操作者會注意到的」。

真正會弄壞叢集的是 /rpc/ping —— 而它從來沒被點名過。

第 23 條

/node/capabilities 完全無閘,而且不在那份 22 條清單裡
那份清單本身漏了一條 —— 這是駁斥者找到的,不是測量者。

Phase B(第一步):關門之前,先讓敲門的人帶鑰匙

/rpc/ping 的兩個呼叫端送出零憑證:

  • ClusterManager::ping_peer —— 心跳,post_with_retry(…, None)
  • /api/nodes 的 roster fan-out —— 一個裸的 client.get(url)

照現況把 /rpc/ping 關上,每個 peer 都會翻成離線spectyn peer list 顯示
整個叢集下線、roster 永遠讀 1/N —— 而且沒有任何一條測試會紅。

這正是五天前那句「breaks a fleet in a way no test here would notice」。
它是真的,只是瞄錯了路由。

修法很小:None 改成 Some((self, "/rpc/ping")),跟 /rpc/message
/rpc/task/assign 用的是同一個慣用法。而且 sign_request 在沒設
cluster_secret 時回 None,所以未設定的部署送出的位元組完全不變

這一步刻意單獨提交:先讓敲門的人帶上鑰匙,下一步才換鎖。
順序反過來的話,壞掉的方式正好是沒有人看得見的那種。

Phase B(第二步):換鎖 —— 一層 middleware,不是二十次呼叫

原本的閘是 per-handler:每個 handler 自己記得呼叫
require_cluster_auth_local_ui。二十條開著,不是因為有人決定要開,是因為
沒有人記得關,而且沒有任何東西列舉過結果。

所以修法不是再寫二十次呼叫,是把它變成一層 middleware:閘在一個地方,
例外變成資料。
加一條路由不再能靠「忘記」把它開著 —— 它得被明列出來。

一個真實的風險先避開了:middleware 要驗 HMAC 就得讀 body,而讀 body 會弄壞
串流端點。避法是先判本機豁免(只需要 header 與來源 IP,不需要 body):
本機呼叫者原封不動放行、串流照常;只有遠端呼叫者才付緩衝的代價 ——
而遠端呼叫串流端點,正是最該驗證的那種。

ratchet 因為「關上了」而紅

既有的 api_auth_inventory 是雙向 ratchet:多一條開著會紅,少一條也會紅,
訊息是「這些現在關上了,把它們從基線移除,否則基線就失去意義」。

它紅了,而且列出的正是那二十條 —— 兩條 open-by-design 不在其中。
這是一個 ratchet 在正確方向上工作的樣子。

一份清單,兩個問題

更新基線時發現那個 const 同時被兩個消費者用:ratchet 問「什麼是開的」,
綁定閘問「開著會不會外洩」。那是兩個不同的問題,而一條路由可以是
「開著而且無害」。拆成兩個 const,綁定閘問後者 —— 它現在是空的。

紅證抓到三件事,一件比一件深

三條紅證:停用 middleware、拿掉本機豁免、把會外洩的路由加進開放清單。
前兩條首次即中。第三條沒中,而追下去揭露了三個疊在一起的問題:

一、測試讀的是自己的副本。 我在測試裡寫死了 STILL_OPEN = ["/api/version", "/api/dashboard/status"]。有人把會外洩的路由加進那個 const,測試不會發現 ——
一個守衛讀自己那份副本,就不是在守它。 改成從 const 讀。

二、middleware 有第二份清單。 我自己在 serve.rs 寫了一個 OPEN_BY_DESIGN,
commands::http 的那份各自獨立。又是兩份清單一個問題 —— 這整個系列講的
就是這件事,而我在修它的同時做了一次。刪掉,共用同一份。

三、fixture 是空的,所以測試證明不了任何事。 改完之後紅證還是不中:
/api/todos<data>/todos.json,而測試沒設 SPECTYN_HOME ——
它讀的是操作者的真實資料目錄,結果不確定;而如果那裡剛好是空的,
路由回傳 [],規則就無條件通過。

補上沙箱資料根與可辨識的內容之後,紅證才真的會紅。

順帶把一根太粗的針換成一條規則

留開路由的「不含操作者資料」檢查,第一版是關鍵字清單,結果在
"cluster_peers": 0 上誤報 —— 那是計數,不是位址。

但我沒有把關鍵字刪掉:那是把守衛變成橡皮圖章的第一步。 改成斷言規則 ——
在一條全網路可讀的路由上,每個值只能是數字、布林、或版本樣的短字串;
任何帶斜線、冒號、@ 或空格的字串都是路徑、URL、位址或句子。
比清單更嚴,而且沒有「刪掉一個關鍵字」這個失效模式。

Phase C:關門之後,才發現鑰匙早就不能用了

Phase B 把二十條路由關上。下一步是確認還能開門的人真的開得了 ——
桌面 app 是最主要的呼叫者,它得帶得出憑證。

去看的時候發現兩件事,一件比一件糟:

一、get_json 從來沒有簽過。 app 的每一條 /api/* 讀取都是裸請求。
在 Phase B 之前那沒差 —— 那些路由本來就不驗。Phase B 之後,對一台不在本機的
daemon,它們全部 401。

二、post_rpc 簽的是一個 daemon 三週前刪掉的方案。 core 在 2026-08-08
移除了「只簽 body」的舊分支,理由寫在程式碼註解裡:HMAC(secret, "") 是一個
固定常數,任何空 body 的端點都吃它,等於一張永久通行證,所以連 migration
開關都沒留
。而 app 這邊的 sign(secret, body) 就是那個方案,原封不動地留著。

也就是說:app 帶著一把三週前就作廢的鑰匙,而它不可能知道。
因為 app 的每一次呼叫都走 loopback,而原生請求沒有 Origin,所以拿到本機豁免 ——
簽章從頭到尾沒有被看過一眼。

兩條綠燈,兩種假的

那個模組不是沒有測試。它有兩條,而且天天都綠:

signature_matches_the_openssl_contract —— 註解寫著「跟文件裡的
canonical example 同一組向量,所以任何一邊改了都會被抓到」。但那組向量是
用手抄進測試裡的常數。daemon 那邊改了(整個方案被刪),沒有任何東西被抓到。
一個釘死的向量只是對單邊的檢查,它把「兩邊一致」寫成了「這邊等於我抄的值」。

signing_covers_the_exact_body_bytes —— 斷言換 body digest 會變、換 key
digest 會變。兩條都對。而且新舊兩個方案都滿足。
一條壞掉的實作也具備的性質,不是檢查。

新的檢查只有一種寫法:用 daemon 自己的驗證器去驗
rpc_wire::build_canonical_stringverify_hmac 都是公開的,測試直接拿來重組,
簽章過不了就紅。沒有第二份 canonical string,沒有手抄的向量。

一扇門

真正的修法不是「補上 get_json 的簽章」。那只是把三個各自記得要簽的地方
變成三個各自記得要簽的地方 —— 而這次出事就是因為其中一個忘了。

改成一扇門:模組裡只有 send_signed 會碰 HTTP client,簽章在那裡發生。
簽章實作本身也不再自己寫,直接呼叫 rpc_wire::sign_headers —— 那個函式的
文件註解本來就寫著「全 repo 只有這一份簽章實作」,而在 app 自己手刻一份的
期間,那句話是假的。呼叫它,那句話才成立。

門也有一個可以不裝的把手:send_signed 只在收到 secret 時才簽,所以一個
call site 傳 None 就靜靜地裸奔,而其他每一條檢查照樣綠。所以守衛數兩件事 ——
call site 的數量,和傳了 secret 的數量,兩者必須相等。

那個守衛只掃了三分之一的檔案

一開始它是這樣切出「非測試程式碼」的:src[..src.find("#[cfg(test)]")]

這個檔案有四個 #[cfg(test)] 模組。所以它掃了第一個之前的部分就停了,
daemon_proxy 整個沒看到 —— 而 daemon_proxy 正是三個 call site 之一。
它報綠。

抓到它的不是紅證,是守衛底部那條下限斷言:「至少要看到三個 call site」。
那條斷言唯一的作用,就是問「這條檢查到底看得到東西嗎」。
沒有它,這個守衛會一直綠著,而它守的東西有三分之二在它視野之外。

Phase D:一個只有空常數當輸入的守衛,只會說好

Day 8 加的綁定閘,在 /api 還開著的時候拒絕任何非 loopback 綁定。
Phase B 之後那份清單空了,閘於是放行 —— 操作者終於可以跨機器跑。

但**「放行」本身不是證據**。一個唯一輸入是空常數的守衛,body 被整段刪掉也一樣
放行;它從來沒有被觀察到做過事。而且它從加進去到今天,沒有任何測試走過它

所以把決策拆出來、清單當參數注入,兩個方向都走一次:清單空 → LAN 位址可綁;
清單有一條會外洩的 → 同一個位址被拒,而且拒絕訊息必須指名位址、指出
--host 127.0.0.1 這條退路、指名那個 override 環境變數。
再加一條:無法解析的主機名不當成 loopback —— 往寬鬆方向猜,是守衛變成裝飾的
標準路徑。

Phase E:清點的是「叫這個名字的那片」,不是實際存在的那片

Phase B 收工時我寫的是「二十條關上了」。那句話為真 —— 對 /api/rpc 而言。

spectyn serve 掛的路由不只那兩個前綴。實際數過:另外三十二條在它們外面 ——
/node/capabilities/onboarding/config/onboarding/token/ws/ws/pty
/partner/message/partner/signal/mcp/healthz/readyz/version,
加上整套 web UI 的 HTML/CSS/JS/圖示。

而清點它們的那個檔案 api_auth_inventory.rs,開頭的註解是這樣寫的:

外部審查(codex,2026-08-27)指出,指令表的閘完全沒有碰 /rpc/*,而且從來沒有
東西列舉過它 —— 所以「我們把 API 關起來了」是一句只關於一個前綴的話,
另一個前綴就擺在旁邊,沒有被測量。

同一件事又發生了一次,只是往外一層。 那次修法是把前綴從一個加成兩個,
而不是問「到底有幾個前綴」。一個主題是「我們忘了列舉什麼」的檔案,
最不該寫死的東西就是前綴。

量完的結果:比想像的好,但有一條真的開著

逐條看 handler 有沒有自己呼叫驗證:

路由 結果
/onboarding/config/onboarding/token 已驗(handler 自己驗)
/ws/ws/pty 已驗(握手時驗,middleware 看不到的那一層)
/partner/message/partner/signal/mcp 已驗
/node/capabilities 開著
/version/healthz/readyz 開著,而且應該開
web UI 的靜態資源與外殼 開著,而且必須開

/node/capabilities 回的是 os、arch、service model 和整份能力清單。
它唯一的遠端呼叫者是 cluster_dispatch_wire::rpc_get,而那個函式會簽
(量過)。所以關上它不會弄壞任何東西 —— 這就是 Phase A 那套「先量誰在呼叫」
的整個用處。

靜態資源用規則,不用二十五個名字

widened 之後的 ratchet 需要判斷「這條開著是不是應該的」。
UI 的 HTML/CSS/JS/圖示必須開 —— 瀏覽器不會簽章,手機第一次載 PWA 時還沒有 secret。

但把二十五個路徑名寫進豁免清單,就是又一份會腐爛的清單。改成規則:
/static//dist//scripts/ 開頭,或副檔名是 .png/.js/.css/.webmanifest,
或是那幾個 HTML 外殼。

規則有規則的失效方式:有人在 /static/ 底下掛一條回 JSON 的資料路由,
它就免死了。所以規則旁邊釘一條斷言 ——
被歸類成靜態資源的路由,永遠不能用 application/json 回答。
一條開始回 JSON 的「資源」已經不是資源了,而豁免它的那個理由已經不成立。

/version/healthz/readyz 則直接寫進「開著而且是刻意的」那份清單 ——
它們不是今天才開的,是從來沒有被列舉過,因為那份清單的名字裡有 /api


上一篇
Day 08|打勾,和真的成立是兩件事:五個翻案,和一支三天沒送到的 binary
下一篇
Day 10:門關上了,呼叫端還連得到嗎?
系列文
從單一agent 到多agent 集群的開發流水帳以及應用18
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言