FR-171 · 需求討論稿 v1 · 2026-10-31(現況地圖+目標設計+D1~D12 待裁)
主系統要 Agent 做事就直接打過去;心跳只同步有變的設定;憑證到期自己換、金鑰可以輪;兩個方向同一套規則
主系統現在要 Agent 做事(派掃描、重啟、升級)都把指令塞進心跳回應,Agent 每 60 秒來拿一次,平均等 30 秒。心跳回應已經堆到 24 個鍵、每跳全量重送 8 KB。另一邊,Agent 的憑證與通行證有效 10 年,到期要人去主機打指令換,而客戶沒有 IT。本稿先把現在四條通訊路、每條怎麼證明身分、憑證從哪來到哪去畫成一張地圖,對照業界做法找出哪些是半套,再提出統一後的設計:一條指令通道、一條同步通道、一套簽章規則、憑證自動滾動,六個月後除錯只要看兩支檔。
整案一句話:主系統與 Agent 之間改成「指令直推、狀態帶版本號、憑證自動換」,而且兩個方向用同一套簽章規則,不再有四份長得像的 client 與三種半套的守門。
| 主題 | 做什麼 | 產出 | 完成怎麼判定(決策者檢查法) |
|---|---|---|---|
| A 指令通道 | 派工、重啟、升級、換證四種指令改成主系統直接打 Agent 8443;打不通退回心跳 | Agent 新端點、主系統統一的 client、契約 0.2.0 | 在主系統按「派工」,Agent 主控台 3 秒內看到任務開始(現在要等最多 60 秒);把 Agent 網路線拔掉再派,插回去 60 秒內照樣開始 |
| B 設定同步瘦身 | 心跳請求帶各類設定的版本號,主系統只回有變的 | 心跳契約加 config_versions |
看 BE log 一跳的回應大小從 3 KB 降到 300 B 以內;改一條授權清冊,下一跳 Agent 就拿到 |
| C 憑證自動換 | Agent 到期前 90 天自己換證、換通行證;主系統可按鈕叫它立刻換 | Agent 換證元件、主系統換證端點 | 把 DEV 的憑證效期設成 3 天,等兩天看 Agent 管理頁「憑證到期日」自己往後推,過程零人工 |
| D 信任錨可輪換 | 主系統的 CA 與簽章公鑰改成清單下發,新舊並存 | 信任錨清單走 B 通道下發 | 主系統按「加一把新鑰」,等所有 Agent 心跳確認收到,再按「切換」,所有直推照常 |
| E 心跳持有證明 | Agent 打主系統時除了通行證,再帶一張用自己私鑰簽的 60 秒簽章 | 主系統驗簽中介層、Agent 簽章元件 | 把 Agent 的通行證檔複製到另一台機器打心跳,主系統回 401 並記稽核 |
| F 守門與 client 收斂 | 主系統四份 client 收成一份;Agent 守門一條規則、全部前綴綁用途、加防重放 | jedi-remote-agent 的 AgentDataPlaneClient、Agent data_plane_auth 改版 |
全 repo grep jwt_util.mint 只剩一處;同一張 JWT 重送第二次 Agent 回 401 |
依賴一句:A 與 B 是通道,C、D、E 是通道上的第一批使用者;F 是把通道做乾淨的前提。拆卡時 F 先做(它是地基),A 與 B 同時開,C、D、E 接在後面。決策者功課:裁 D1~D12,其中 D2(範圍)決定本案是 3 張卡還是 15 張。
之前都是只能等心跳由 agent 發訊息給本體,後來才有了臨時 token 的模式讓本體可以主動跟 agent 溝通,所以現在應該算是可以雙向溝通?我想要把等待心跳的功能都改掉,除了原本的心跳功能,因為我覺得這太偷懶了而且越塞越多,不可能都只依靠心跳來處理事情。 — 決策者原話(2026-10-31)
反正都要重構了,我希望是好的設計,而不是像之前一樣都沒想過硬塞。mTLS 10 年過了要怎麼更新?你要想像我的客戶是沒有 IT 人員的一般人,叫他打指令根本是要他的命。目前看來很多機制沒錯,但都做一半或是沒有統一,這不對。要嘛就是機制統一幾個,不然兩個月後沒人記得,除錯要花兩三天。 — 決策者原話(2026-10-31)
兩段話對應兩個問題:
主系統要 Agent 做事,只能把指令放進心跳回應等它來拿。心跳回應現在有 24 個鍵、Agent 端 10 個 observer 依序讀;每跳請求 5 KB、回應 3 KB,沒變也送。FR-167 為了讓升級快一點,做了一支「叫 Agent 立刻來心跳」的端點,等於承認心跳不夠用卻又多疊一層。
Agent 憑證、主系統 client 憑證、控制面通行證全部 10 年。到期前 60 天主系統發通知,然後要人到 Agent 主機上跑 re-enroll。客戶沒有 IT;原廠要飛去現場。主系統自己的 CA 與簽章私鑰則連「換」的機制都沒有,外洩就是所有 Agent 重新註冊。
這一節只描述現在是什麼,不評價。全部是從程式碼讀出來的(主系統 jedi-remote-agent 1.11.0、jedi-agent-protocol 0.1.0、Agent 主線 3ab1253)。
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#EEF2F3','labelBoxBorderColor':'#C7D1D2','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF'}}}%%
flowchart LR
subgraph cloud["主系統(雲端或落地版)"]
direction TB
ck["持有:內部 CA 私鑰<br/>JWT 簽章私鑰(一把簽三種票)<br/>主系統 client 憑證<br/>各 Agent 的憑證與狀態"]
end
subgraph agent["Agent(客戶機器)"]
direction TB
p8443["8443 資料面<br/>mTLS,只給主系統"]
p8444["8444 主控台+直傳<br/>給人用,CIDR 白名單"]
ak["持有:自己的私鑰+憑證<br/>CA 憑證、JWT 公鑰<br/>控制面通行證"]
end
browser["使用者瀏覽器"]
ak -- "A 註冊(一次)<br/>一次性 token+CSR" --> ck
ak -- "B 心跳/回報/下載<br/>每 60 秒,bearer 通行證" --> ck
ck -- "C 直推<br/>mTLS+60 秒 JWT" --> p8443
ck -- "D 簽 60 秒一次性票" --> browser
browser -- "帶票連" --> p8444
| 路 | 方向 | 什麼時候 | 身分怎麼證明 | 現在走這條的事 |
|---|---|---|---|---|
| A 註冊 | Agent → 主系統 | 第一次啟動、被主系統刪掉後、通行證失效(401) | 一次性註冊 token + CSR;重註冊另帶舊私鑰簽章 | 只有註冊 |
| B 心跳 | Agent → 主系統 | 每 60 秒+有事就打 | 長效通行證(bearer JWT,10 年) | 心跳、派工 ack/result、outbox 補送、下載 profile/漏洞庫/升級包/任務檔、本機掃描回報、工具 schema |
| C 直推 | 主系統 → Agent | 使用者在主系統按了什麼 | mTLS + 每請求現簽的 60 秒 JWT | 取消掃描、測試連線、設定/清除工具帳密、取證據檔、叫醒心跳、拉升級進度 |
| D 瀏覽器票 | 瀏覽器 → Agent | 使用者開 Agent 主控台、直傳證據檔 | 主系統簽的 60 秒一次性票,換成 cookie session | 主控台登入、檔案直傳 |
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#EEF2F3','labelBoxBorderColor':'#C7D1D2','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF'}}}%%
sequenceDiagram
autonumber
participant A as Agent
participant M as 主系統
participant U as 管理員
Note over A: 算本機指紋 device_uuid(主機板序號+machine-id)
Note over A: 自己產金鑰對,私鑰落地、永不外送
Note over A: 產 CSR,名稱=device_uuid
A->>M: POST /agents/register{token, device_uuid, csr, base_url, 硬體資訊}<br/>https,Agent 用安裝時確認過的指紋釘住主系統憑證
Note over M: token 的 sha256 查 DB 找租戶(只認 token 查出的,body 說什麼都不信)
Note over M: 驗 CSR 名稱=device_uuid;指紋撞號就拒
Note over M: 重註冊帶舊私鑰簽章 → 驗過就接管原列、保留 uid
Note over M: 內部 CA 簽 Agent 憑證(10 年)
Note over M: 簽控制面通行證(10 年、typ=agent-control)
M-->>A: {agent_uid, 憑證, CA 憑證, JWT 公鑰, 通行證, 心跳間隔}
Note over A: 五樣落地 cert_dir;用自己憑證起 8443
U->>M: 在 Agent 管理頁按「核准」
Note over M: 核准前心跳回 403,什麼都不能做
要點:
prior_key_proof)。主系統驗這個簽章時不看舊證有沒有過期,只驗 CA 簽發、名稱、簽章。這是自動換證缺的那半塊,而且過期寬限在程式層已經成立。re-enroll 重釘。%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#EEF2F3','labelBoxBorderColor':'#C7D1D2','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF'}}}%%
sequenceDiagram
autonumber
participant A as Agent
participant M as 主系統
loop 每 60 秒,或被 /update/notify 叫醒
A->>M: POST /agents/heartbeat<br/>Authorization: Bearer 通行證<br/>body 44 欄約 5 KB:狀態、版本、能力、清冊版本、profile 狀態、升級結果、主控台事件…
Note over M: 驗通行證簽章+typ;查 remote_agents 狀態(撤銷→403)
Note over M: 更新 last_seen、自報狀態入庫
Note over M: 組回應 24 鍵約 3 KB:派工單、重啟旗標、升級指示、清冊、CIDR、操作者名單、設定…
M-->>A: 回應
Note over A: 10 個 observer 依序讀各自的鍵
Note over A: 有派工單 → 丟給 scan 模組執行
end
Note over A,M: 同一條通道還走:ack、result、outbox 補送、下載 profile/漏洞庫/升級包/任務檔
心跳回應 24 鍵分三類(FR-170 第 4 棒分析):
| 類 | 鍵 | 性質 | 現況問題 |
|---|---|---|---|
| 指令類 | pending_tasks、restart、update |
要 Agent 立刻做一件事 | 等 60 秒是純延遲 |
| 設定同步類 | allowed_targets、console_allow_cidr、offline_operators、tool_schemas_version、profiles、vulndb、ldap_config、error_tracking、file_protect、offline_login_days、allowed_target_names、agent_name |
要的是最終一致 | 每跳全量重送;只有 allowed_targets 與 tool_schemas 有版本比對、profiles 靠 sha256 逐筆比,其餘沒有 |
| 對帳類 | accepted/rejected、console_events_ack、server_time_utc |
回執 | 不動 |
取消掃描已經是直推(jedi_detection 的 agent_cancel_client 打 /detection/cancel,成功才標 cancelled),不在心跳裡。
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#EEF2F3','labelBoxBorderColor':'#C7D1D2','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF'}}}%%
sequenceDiagram
autonumber
participant M as 主系統
participant A as Agent 8443
Note over M: 從 DB 取 agent(base_url、uid、指紋)
Note over M: 用 JWT 私鑰簽 60 秒 token:aud=agent_uid、op="POST /detection/cancel"、bound_fp=指紋、tenant_id
M->>A: TLS 握手,出示 client 憑證(內部 CA 簽)
Note over A: ① TLS 層 CERT_REQUIRED:沒 CA 簽的憑證在 TCP 層就拒,碰不到 Flask
M->>A: HTTPS 請求 + Authorization: Bearer 60 秒 JWT
Note over A: ② 驗簽章(註冊時拿的公鑰)、iss、aud、exp
Note over A: ③ bound_fp ≠ 本機指紋 → 401(憑證被搬到別台)
Note over A: ④ /update 前綴才驗:op 要等於「方法 路徑」
A-->>M: 回應 + X-Agent-Fingerprint
Note over M: ⑤ 回應指紋≠DB 存的 → 記稽核、當失敗
目前只開 9 個端點:/blob*、/detection/probe、/detection/credentials、/detection/cancel、/scan-input、/update/notify、/update/progress、/health、/agent-info。受保護前綴只有一份清單 _PROTECTED_PREFIXES = ("/blob", "/detection", "/update"),新端點掛在底下自動受守。
使用者在主系統按「開 Agent 主控台」,主系統驗權限後簽一張 60 秒一次性票(iss=guidant-cloud、aud=agent:<uid>、sub=使用者、scope=哪些操作、jti),瀏覽器帶票連 Agent 8444。Agent 先過來源 IP 的 CIDR 白名單,再驗簽章、purpose、scope、jti 沒用過,換成 cookie session(只在記憶體,Agent 重啟全部失效)。直傳證據檔走同樣形狀,purpose 不同。這條已經有 jti 防重放(TicketReplayGuard),路 C 沒有。
| 材料 | 誰產生 | 誰持有 | 效期 | 撤銷/換新 |
|---|---|---|---|---|
| 內部 CA 私鑰 | installer 用 openssl | 主系統 | 10 年 | 無機制 |
| JWT 簽章私鑰(一把簽路 C、通行證、路 D 三種票) | installer | 主系統 | 無到期 | 無機制 |
| 主系統 client 憑證(打 8443 用) | installer 用同一個 CA 簽 | 主系統 | 10 年 | 無機制 |
| Agent 私鑰 | Agent 自己 | 只在 Agent | 隨憑證 | 重註冊時換新 |
| Agent 憑證 | 主系統 CA 簽 | Agent(8443 伺服器證) | 10 年 | 到期前 60 天通知;人工 re-enroll |
| 控制面通行證 | 主系統 JWT 私鑰 | Agent | 10 年 | 每請求查 DB 狀態 |
| 資料面 JWT(路 C) | 主系統 JWT 私鑰 | 每請求現簽 | 60 秒 | 不再簽即失效 |
| 瀏覽器票(路 D) | 主系統 JWT 私鑰 | 瀏覽器轉交 | 60 秒、一次性 | jti 燒掉 |
| 註冊 token | 管理員在畫面產 | DB 存 sha256 | 永久 | 畫面停用 |
| 主系統 https 憑證指紋 | 安裝 Agent 時人工確認 | Agent | 隨主系統憑證 | re-enroll 重釘 |
結論先講:形狀是「Kubernetes 節點模型」,對同內網的落地版是正確選擇,不用換模型。 要做的是把形狀內的半套補成整套。
| 我們的路 | 業界對應 | 判定 |
|---|---|---|
| A 註冊:bootstrap token → 節點自產金鑰送 CSR → 控制面簽 → 人工核准 | Kubernetes kubelet TLS bootstrapping、RFC 7030(EST)、AWS IoT fleet provisioning,骨架一模一樣 | 正規。差異只在 K8s 的 token 預設 24 小時用完即棄;我們永久共用靠人工核准補。對沒有 IT 的客戶,永久 token 加核准反而務實,但 token 應可設到期(D9) |
| B 心跳拉指令 | Chef/Puppet/Salt 的 agent 都定期 pull;Datadog remote config 也輪詢 | 常見但業界正在遠離。問題不在 pull,在「所有指令只能等下一輪」。主流已改成 pull 保底、另有即時通道 |
| B 身分用長效 bearer | RFC 6750 允許,是最低標 | 合格但弱。裝置身分主流是 mTLS(kubelet、AWS IoT、Azure IoT Hub X.509)或持有證明(RFC 9449 DPoP) |
| C 直推:mTLS+短效 JWT 綁 aud/op/指紋 | Kubernetes API server 打 kubelet 10250 就是 mTLS;綁 op 與指紋接近 RFC 9449 的 htm/htu 精神 | 正規,比多數產品嚴。缺 jti 防重放、op 綁定只套一個前綴 |
| D 瀏覽器一次性票 | S3 presigned URL、Teleport 一次性 session token | 正規 |
| 憑證 10 年、人工換 | 公用 TLS 上限 398 天;內部 PKI 無法定上限,NIST 800-57 的原則是「效期由輪換能力決定」;kubelet 預設一年且自動換 | 有自動換證就該縮短;沒有自動換證才被迫 10 年。10 年不是設計,是把問題推給十年後 |
| CA 10 年無輪換 | 私有根 CA 普遍 10~20 年;NIST 要求「為補救外洩而能換」,不是排程換 | 長效本身合規;缺的是「能換」 |
| 三種票共用一把私鑰、靠 typ/iss 區分 | RFC 8725 §3.11 明寫防跨用途混淆的正規做法就是明確 typ+驗 iss+驗 aud | 現況合規。分鑰是縱深防禦不是修 bug |
| 做法 | 代表產品 | 優點 | 代價 | 判斷 |
|---|---|---|---|---|
| 純出站長連線,主系統透過連線反推 | Tailscale、Teleport、Cloudflare Tunnel | 客戶不開任何入站埠 | 認證只在握手、要自建保活與重連、穿 proxy 常斷、BE 要多一個連線管理元件 | SaaS 版才需要;落地版同內網不值得。FR-170 §3.x 已裁,本稿不翻 |
| Message broker(MQTT) | AWS IoT Core、Azure IoT Hub | 直推與離線暫存天生就有 | 客戶機上多一個要維運的行程、身分體系要另外接 | 沒有 IT 的客戶多一個元件就是多一個會壞的東西,不建議 |
| 工作負載身分框架 | SPIFFE/SPIRE | 短效憑證自動輪換是核心功能 | 整套框架很重,有自己的 server 與 agent | 不引入,但「短效+自動換」的設計照它抄 |
| 內部 ACME | step-ca、smallstep | 換證協定標準化 | 又多一個 CA 服務 | 不引入,用現有註冊路徑做換證就夠 |
| gRPC 雙向串流 | 很多自家 agent | 一條連線雙向、契約強 | 與長連線同樣問題,且要換掉 Flask 那一面 | 不建議 |
每一條附規範依據,不是我覺得。
| # | 洞 | 現況 | 後果 | 依據 |
|---|---|---|---|---|
| G1 | 指令塞心跳 | 派工、重啟、升級走心跳回應 | 最慢等 60 秒;心跳回應越堆越多 | 業界主流 pull 保底+即時通道 |
| G2 | 憑證無自動換證 | 10 年、60 天前通知、人工 re-enroll |
客戶沒 IT 做不到;原廠要出差 | RFC 7030 re-enroll、kubelet 自動 rotate |
| G3 | CA 與簽章金鑰不能換 | Agent 只存一張 CA、一把公鑰 | 外洩就是所有 Agent 重新註冊 | NIST 800-57 要求有補救外洩的能力;RFC 7517 JWKS 多鑰並存 |
| G4 | 直推無防重放 | 路 C 的 JWT 沒 jti,Agent 不記已用過的 | 60 秒內同一張票重送,重啟會執行兩次。本案要做的正是指令,這條變必修 | RFC 9449 §11.1 要求 jti+伺服器端短期快取 |
| G5 | op 綁定只套一個前綴 | 只有 /update 驗 op |
一張簽給 GET /blob/x 的票 60 秒內能打 /detection/probe |
RFC 9449 htm/htu 是全部請求都綁 |
| G6 | 簽發前不查撤銷 | 四份 client 拿 DB 載入的 agent 就簽,沒看 status | 撤銷後 60 秒內主系統仍會對它下令 | 短效 token 的撤銷模型前提是「簽發點一定查狀態」 |
| G7 | 心跳只靠 bearer | 通行證被讀走就能冒充 10 年 | 備份外洩、技術人員拷走憑證目錄 | RFC 8705/RFC 9449 持有者約束 |
| G8 | 主系統有四份 client | agent_credential_client、agent_update_notify_client、jedi_detection 的 cancel 與 probe client、remote_agent_adapter 各自簽 JWT、各自建 mTLS context |
改一條規則要改四處;G6 就是這樣漏的 | 不是規範,是決策者說的「兩個月後沒人記得」 |
| G9 | 設定同步全量重送 | 12 類設定只有 2 類有版本比對 | 每跳 8 KB;設定類再加就再長 | 不是資安,是效率與可維護性 |
其中 G6 是真正的洞(現在就有風險);G4、G5、G7 是縱深不足(有別的門擋著但少一層);G2、G3 是沒想過(把問題推給未來);G1、G8、G9 是可維護性。
data_plane_auth,所有前綴同一條規則。主系統端一支 AgentDataPlaneClient,所有呼叫同一條路。%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#EEF2F3','labelBoxBorderColor':'#C7D1D2','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF'}}}%%
flowchart LR
subgraph cloud["主系統"]
direction TB
client["AgentDataPlaneClient(唯一出口)<br/>簽發前查狀態、mTLS、60 秒 JWT 含 jti 與 op"]
anchors["信任錨清單<br/>CA 與簽章公鑰多把並存,各帶 kid"]
verify["心跳驗簽中介層<br/>驗通行證+驗持有證明"]
end
subgraph agent["Agent"]
direction TB
guard["data_plane_auth(唯一守門)<br/>所有前綴:驗簽、aud、指紋、op、jti"]
cmd["指令端點<br/>/tasks /restart /update /credentials/renew /detection/cancel"]
renew["換證元件(BackgroundComponent)<br/>到期前 90 天自動換"]
sign["每請求簽章元件<br/>用自己私鑰簽 60 秒 JWT"]
end
client -- "C 指令直推<br/>打不通退回心跳" --> guard --> cmd
sign -- "B 心跳:通行證+持有證明<br/>帶 config_versions,只回有變的" --> verify
anchors -. "走 B 下發" .-> agent
renew -- "走 A 的重註冊路徑<br/>舊私鑰簽新 CSR" --> cloud
四種指令各一個端點,全部掛在受保護前綴下、op 綁定、冪等:
| 指令 | 端點 | body | Agent 端做什麼 | 冪等鍵 | 退回心跳怎麼接 |
|---|---|---|---|---|---|
| 派工 | POST /tasks |
一張派工單(同心跳 pending_tasks[] 的元素) |
丟給 scan.dispatch_tasks,與心跳路徑同一個入口;立刻 ack |
task uid;已領過的回 200 不重做 | 單子本來就在 DB 等心跳撿;直推成功 Agent ack(pending → dispatched),下一跳不會再拿到 |
| 重啟 | POST /restart |
{mode} |
同 restart_flag.observe |
60 秒內同 jti 拒;重啟本身冪等 | 主系統仍寫 restart_requested 旗標再直推;打通 Agent 當下處理並由心跳回報已清 |
| 升級 | POST /update |
同心跳 update 的內容 |
同 update_sync.observe |
target_version 相同不重下載 | 主系統仍走 update_extra;直推只是提前 |
| 換證 | POST /credentials/renew |
空 | 觸發主題 C 的換證流程 | 進行中就回 202 不重跑 | 不需要備援,Agent 自己會到期前換 |
| 取消 | POST /detection/cancel |
既有 | 既有 | 既有 | 既有(失敗 409) |
主系統端統一寫法:所有「要 Agent 做事」的呼叫都是「先把事實寫進 DB(單子、旗標),再呼叫 AgentDataPlaneClient.command(agent, op, body);回 True 記『直推成功』,回 False 記『退回心跳』」。直推失敗不重試、不排隊,交給心跳。這是「至少一次投遞+冪等消費者」的標準模式。
相容:舊 Agent 沒有新端點回 404,主系統當直推失敗,退回心跳;舊主系統不打新端點,新 Agent 照舊從心跳拿。兩邊不綁版。
/update/notify 退役:它的「叫 Agent 立刻來心跳」在有 POST /update 之後沒有存在理由。契約 0.2.0 標 deprecated,主系統改打 POST /update;Agent 端留一個版本(舊主系統還會打),下一個 minor 拔。
通則(取代七類各寫一套):
config_versions: {類別: 版本字串},Agent 回報手上每一類的版本。{版本} 或不帶鍵。config_versions(舊 Agent)→ 主系統全量回,行為同現在。allowed_targets 已經這樣做,本主題是把它的做法套到全部 12 類。信任錨清單(主題 D)是第 13 類,也是這個通道的第一個新用戶。
歸類:dispatch_gate 留在心跳的派工回應裡(它是主系統自己的升級閘,決定「現在要不要發單」,與 Agent 無關);upgrade_result 是對帳類不動;update 改走指令通道後,心跳的 update 鍵保留當備援。
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#EEF2F3','labelBoxBorderColor':'#C7D1D2','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF'}}}%%
sequenceDiagram
autonumber
participant A as Agent 換證元件
participant M as 主系統
Note over A: 每天看一次憑證到期日;剩 90 天開始,或收到 POST /credentials/renew
Note over A: 產新金鑰對+新 CSR(名稱=device_uuid)
Note over A: 用舊私鑰簽新 CSR(prior_key_proof)
A->>M: POST /agents/register{csr, 舊憑證, 舊私鑰簽章, agent_uid}<br/>Authorization: Bearer 通行證+持有證明
Note over M: 驗通行證(查狀態,撤銷不簽)
Note over M: 驗舊私鑰簽章(不看舊證是否過期,現況已如此)
Note over M: 簽新憑證(1 年)、簽新通行證(1 年);保留 uid
M-->>A: {新憑證, 新通行證, 信任錨清單}
Note over A: 新私鑰、新證、新通行證落地;8443 熱換不重啟
Note over A: 失敗明天再試,連試到過期後 90 天
A->>M: 下一跳心跳回報 cert_not_after 與換證結果
三條底線:
數字:憑證 1 年、剩 90 天開始換、過期後寬限 90 天仍可換。離線容忍度 9 個月加寬限。1 年效期的意義是憑證被偷最多活一年,現在是十年。剩下唯一要人碰的情況:整機壞掉或憑證目錄被清空,那本來就是重裝。
Agent 現在只存一張 CA、一把 JWT 公鑰。改成清單:
trust_anchors:
version: "a3f9…"
ca_certs: [{kid, pem, not_after}, …] ← 驗主系統 client 憑證
jwt_keys: [{kid, use: data_plane|ticket, pem, not_after}, …] ← 驗直推 JWT 與瀏覽器票
kid,Agent 用 kid 挑公鑰驗。沒帶 kid(舊主系統)就用清單第一把。仍需人的唯一情況:主系統私鑰被偷(不是到期)。那時舊鑰不能信,等於全部 Agent 重新註冊。任何 PKI 都逃不掉,只能靠保護好私鑰。
現在的通行證是 bearer,「誰拿著誰就能用」。改成:
DPoP),裡面寫方法、路徑、jti、時間。為什麼不是 mTLS:mTLS 強一層(TCP 層就擋),但主系統前門是給瀏覽器用的公開 HTTPS 埠,加 client 憑證驗證會讓瀏覽器跳憑證選擇框,實務要另開埠或 nginx 選擇性驗證,installer 與客戶防火牆都要動。對沒有 IT 的客戶,多開一個埠的成本遠高於應用層驗一次簽章。而且做完之後兩個方向是同一句話,守門邏輯可以是同一段程式。
誠實講的差距:DPoP 驗在應用層,沒私鑰的人還是能把請求送到 BE 門口才被擋;對內網落地版差距不大,SaaS 版前門要補速率限制。
相容:舊 Agent 不帶 DPoP header,主系統第一階段只記 log 不拒;等所有 Agent 升到帶 DPoP 的版本後,管理頁開「強制持有證明」開關。
| 邊 | 現況 | 目標 |
|---|---|---|
| 主系統 → Agent 的 client | 四份各自簽 JWT、建 mTLS、比指紋 | 一支 AgentDataPlaneClient 進 jedi-remote-agent;主系統、jedi_detection、notify、credential、upload adapter 全部 import 它。簽發前查狀態、JWT 帶 jti 與 kid、錯誤分類(離線/拒絕/指紋不符)寫一次 |
| Agent 守門 | _PROTECTED_PREFIXES 加 _OP_BOUND_PREFIXES 兩份清單 |
一份清單,所有受保護前綴驗 op 與 jti;TicketReplayGuard 從路 D 搬到 sync 共用。例外只有 /health、/agent-info |
| 主系統驗心跳 | agent_pass_required 只驗通行證 |
加驗 DPoP(第一階段記 log) |
| 契約 | jedi-agent-protocol 0.1.0 | 0.2.0:加 config_versions、trust_anchors、指令端點路徑常數、DPoP header 名;/update/notify 標 deprecated。只加不改名,minor bump |
守衛測試:Agent 端加一支「每個受保護前綴的 route 都在 op 綁定內」;主系統端加一支「全 repo jwt_util.mint 只出現在 AgentDataPlaneClient」。
主系統與 Agent 不綁版,四種組合都要能講話:
| 舊 Agent | 新 Agent | |
|---|---|---|
| 舊主系統 | 現況 | 心跳不帶 config_versions 回應?帶了,主系統忽略未知鍵→Agent 全量收。DPoP header 主系統忽略。指令端點沒人打,心跳照拿 |
| 新主系統 | 直推新端點 404→退回心跳。Agent 不帶 config_versions→全量回。無 DPoP→記 log 不拒。信任錨清單 Agent 不認得→忽略,用舊的單張 CA |
完整 |
唯一的硬切是「強制持有證明」開關,由管理員在確認所有 Agent 升級後手動開。
D1 — 範圍:六個主題全收,還是只做 A+B?
全收約 15 張卡、四個 repo;只做 A+B 約 5 張。
建議:全收。六個主題共用同兩條通道,A 與 B 做完後 C、D、E 都是「往通道上掛一個使用者」;分開做,下次就是在心跳旁邊再疊一層,正是決策者要避免的。
D2 — 指令端點路徑與形狀
POST /tasks、POST /restart、POST /update、POST /credentials/renew,取消沿用既有 /detection/cancel。全部掛在新前綴 /command/* 底下,還是各自前綴?
建議:各自前綴(/tasks、/restart、/update、/credentials),因為 /update 與 /detection 已存在、改前綴等於改契約。守門改成「除 /health、/agent-info 外全部受保護」就不用維護前綴清單。
D3 — 直推失敗的備援:不重試、交給心跳
直推打不通只記「退回心跳」,不重試、不排隊。
建議:照此。重試與排隊會在主系統長出第二份「待送指令」狀態,與 DB 的單子、旗標變成兩份真相。心跳本來就是重試機制。
D4 — 憑證效期與換證時機
憑證 1 年、剩 90 天開始換、過期後寬限 90 天。
建議:照此。1 年是有自動換證後的慣例;90 天是為了離線機器(備機、倉庫機)回來還換得到;寬限是因客戶沒 IT,過期就重裝等於「要他的命」。真正的保護在主系統簽發前查狀態,不在效期。
D5 — CA 不排程換,只做「能換」
建議:照此。NIST 的要求是「能補救外洩」不是「定期換」;每三年強迫換 CA 是自製事故。撤回先前提的「CA 縮到 3 年」。
D6 — 心跳持有證明用 DPoP 式簽章,不用 mTLS
建議:DPoP 式。不動主系統前門與 installer,兩個方向同一套規則。mTLS 留給 SaaS 版若有需要再議。
D7 — DPoP 分兩階段:先記 log,再強制
建議:照此。否則升級順序一錯(主系統先升、Agent 還沒升)所有 Agent 當場掉線。強制由管理員在確認全部升級後手動開。
D8 — /update/notify 退役
契約 0.2.0 標 deprecated、主系統改打 /update、Agent 留一版、下一 minor 拔。
建議:照此。
D9 — 註冊 token 加可選到期日
現況永久有效。加一個選填的到期日,預設仍永久(客戶場景)。
建議:加,成本一欄一判斷;不加也不影響本案主線。
D10 — 三種票分鑰
資料面 JWT、通行證、瀏覽器票各自獨立金鑰對,公鑰都進信任錨清單。
建議:做。現況靠 typ/iss 區分是合規的,分鑰是縱深;既然要做清單,順手做便宜,之後任一驗證端少檢查一個欄位也不會跨用。
D11 — 管理頁要不要顯示「直推成功/退回心跳」
建議:要,一格狀態加上次指令時間;不畫整頁雛形,Agent 管理頁現有的列多一欄即可。另外「憑證到期日、上次換證結果」已有欄位可顯示。要整頁雛形再說。
D12 — 拆卡順序與批
F(統一 client 與守門)先做當地基 → A+B 同批(各自 repo)→ C、D、E 接後 → 收尾釘版。契約卡與 jedi-remote-agent 都是釘版套件,批 1 前要先開環境卡(主 repo 改走本機路徑安裝)。
建議:照此。詳細拆卡表等 D1 裁完後在 design.md 用 cards.py 驗過再寫。
| 不做 | 理由 |
|---|---|
| WebSocket/長連線 | FR-170 §3.x 已裁;落地版同內網 8443 一定通;SaaS 版再議,本案設計讓 SaaS 只需加「出站連線」一層 |
| Message broker | 客戶機上多一個行程 |
| 心跳改 mTLS | 要動主系統前門與 installer;DPoP 式達到等價的持有證明 |
| CA 排程輪換 | 自製事故,見 D5 |
| 主系統幫 Agent 產私鑰推過去 | PKI 底線,私鑰不離開 Agent |
對帳類三鍵(accepted/rejected、console_events_ack、server_time_utc) |
沒問題,不動 |
| 風險 | 怎麼防 |
|---|---|
| 升級順序錯(主系統先升、Agent 還沒)讓 Agent 掉線 | 相容矩陣四格都要有測試;DPoP 分兩階段 |
| 換證時 8443 熱換失敗,Agent 一邊有新證一邊起不來 | 新證驗證能起 8443 才覆蓋舊證;失敗回滾用舊證,明天再試 |
| 直推與心跳同時送到同一張單 | 冪等鍵是 task uid,Agent 已領過的回 200 不重做;主系統 ack 後單子離開 pending |
| 信任錨清單下發一半,部分 Agent 只有舊鑰 | 「切換」前管理頁要顯示 N 台已收到;切換後舊鑰仍保留到到期 |
| jti 記憶放記憶體,主系統多 worker 各記各的 | 放 Redis,本專案已有 |
| 一張 JWT 一把私鑰簽三種用途,改分鑰時漏改一處 | 信任錨清單的 use 欄位,驗證端按用途挑鑰,拿錯鑰直接驗不過 |
登記型檔(契約 pyproject、event_code、_PROTECTED_PREFIXES、DI 接線)同批撞 |
拆卡時各自不同批,或給最早那張卡當 owner 一次掛完 |
docs/ARCHITECTURE.md、.claude/rules/security/sync-and-identity.md、endpoints.mdguidant_agent/sync/api/data_plane_auth.py、sync/app/enroll.py、sync/adapters/cert_files.py;主系統 jedi_remote_agent/common/agent_auth/jwt_util.py、possession.py、app/service/agent_enrollment_service.py;主系統 infra/remote_agent/agent_credential_client.py、agent_update_notify_client.py