FR-171 · 需求討論稿 v1 · 2026-10-31(現況地圖+目標設計+D1~D12 待裁)

指令不再等心跳,身分不再靠人換

主系統要 Agent 做事就直接打過去;心跳只同步有變的設定;憑證到期自己換、金鑰可以輪;兩個方向同一套規則

主系統現在要 Agent 做事(派掃描、重啟、升級)都把指令塞進心跳回應,Agent 每 60 秒來拿一次,平均等 30 秒。心跳回應已經堆到 24 個鍵、每跳全量重送 8 KB。另一邊,Agent 的憑證與通行證有效 10 年,到期要人去主機打指令換,而客戶沒有 IT。本稿先把現在四條通訊路、每條怎麼證明身分、憑證從哪來到哪去畫成一張地圖,對照業界做法找出哪些是半套,再提出統一後的設計:一條指令通道、一條同步通道、一套簽章規則、憑證自動滾動,六個月後除錯只要看兩支檔。

決策者方向:等心跳太偷懶,要改雙向 D1~D12 待裁 現況骨架正確,五處半套 WebSocket 不做(FR-170 已裁,本稿不翻) 跨 4 repo:BE/jedi-python-package/evidence-agent/FE 陷阱:私鑰永遠不離開 Agent

0. 分工概述(30 秒版)

整案一句話:主系統與 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 張。


1. 為什麼要做

之前都是只能等心跳由 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 重新註冊。


2. 現況地圖:四條路、三個入口

這一節只描述現在是什麼,不評價。全部是從程式碼讀出來的(主系統 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
圖 1 — 主系統與 Agent 之間的四條路。A 只發生一次,是其他三條的信任起點。
路 方向 什麼時候 身分怎麼證明 現在走這條的事
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 主控台登入、檔案直傳

2.1 路 A:註冊,信任的起點

%%{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,什麼都不能做
圖 2 — 註冊流程。第 2 步的私鑰從此不離開 Agent;第 12 步的人工核准是擋「拿到 token 就能報到」的那道門。

要點:

  • 註冊 token 一租戶共用、永久有效(撤銷才失效),靠第 12 步人工核准擋住「拿到 token 就能報到」。
  • 重註冊靠舊私鑰簽新 CSR 證明「我是原本那台」(prior_key_proof)。主系統驗這個簽章時不看舊證有沒有過期,只驗 CA 簽發、名稱、簽章。這是自動換證缺的那半塊,而且過期寬限在程式層已經成立。
  • Agent 對主系統 https 憑證的信任是安裝時釘指紋,不靠系統 CA。換主系統主機要跑 re-enroll 重釘。

2.2 路 B:Agent → 主系統(心跳與所有回報)

%%{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/漏洞庫/升級包/任務檔
圖 3 — 心跳。身分只靠通行證;撤銷靠每次請求查 DB,所以通行證長效是刻意的。

心跳回應 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),不在心跳裡。

2.3 路 C:主系統 → Agent(直推 8443)

%%{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 存的 → 記稽核、當失敗
圖 4 — 直推。四道門由外到內;第 ④ 道目前只對 /update 前綴強制。

目前只開 9 個端點:/blob*、/detection/probe、/detection/credentials、/detection/cancel、/scan-input、/update/notify、/update/progress、/health、/agent-info。受保護前綴只有一份清單 _PROTECTED_PREFIXES = ("/blob", "/detection", "/update"),新端點掛在底下自動受守。

2.4 路 D:瀏覽器 → Agent 8444(給人用)

使用者在主系統按「開 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 沒有。

2.5 憑證與金鑰清單

材料 誰產生 誰持有 效期 撤銷/換新
內部 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 重釘

3. 業界對照:這套形狀對不對

結論先講:形狀是「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

3.1 其他做法,以及為什麼不換

做法 代表產品 優點 代價 判斷
純出站長連線,主系統透過連線反推 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 那一面 不建議

4. 現況的洞:哪些是半套

每一條附規範依據,不是我覺得。

# 洞 現況 後果 依據
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 是可維護性。


5. 目標設計:統一後的全貌

5.1 一句話版

  • 一個信任根:註冊那一次。之後所有材料都從它自動滾動。
  • 一條指令通道(路 C 擴大):主系統 → Agent 8443。派工、重啟、升級、換證、取消全走它。打不通退回心跳,單一真相在主系統 DB。
  • 一條同步通道(路 B 瘦身):心跳帶版本號,有變才送。清冊、信任錨、操作者名單、所有設定走它。
  • 一套簽章規則:兩個方向都是「每個請求帶一張 60 秒、綁方法與路徑、綁 jti、用自己私鑰簽的 JWT,對方用註冊時拿到的公鑰驗」。只是簽的人換邊。
  • 一份守門:Agent 端一支 data_plane_auth,所有前綴同一條規則。主系統端一支 AgentDataPlaneClient,所有呼叫同一條路。
  • 一個換鑰規則:Agent 憑證一年自動換;CA 長效但可換;全程 Agent 零人工。
%%{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
圖 5 — 目標狀態。路 A 不變;路 B 加持有證明並瘦身;路 C 擴成指令通道;路 D 不變。

5.2 主題 A:指令通道

四種指令各一個端點,全部掛在受保護前綴下、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 拔。

5.3 主題 B:設定同步帶版本號

通則(取代七類各寫一套):

  • 心跳請求加一個鍵 config_versions: {類別: 版本字串},Agent 回報手上每一類的版本。
  • 主系統對每一類算一個版本(內容的 sha256 前 16 碼,或資料表的 updated_at),只有不同時才回該類內容,相同時回 {版本} 或不帶鍵。
  • Agent 沒帶 config_versions(舊 Agent)→ 主系統全量回,行為同現在。
  • 主系統沒回某類(舊主系統)→ Agent 沿用快取,行為同現在。

allowed_targets 已經這樣做,本主題是把它的做法套到全部 12 類。信任錨清單(主題 D)是第 13 類,也是這個通道的第一個新用戶。

歸類:dispatch_gate 留在心跳的派工回應裡(它是主系統自己的升級閘,決定「現在要不要發單」,與 Agent 無關);upgrade_result 是對帳類不動;update 改走指令通道後,心跳的 update 鍵保留當備援。

5.4 主題 C:憑證自動換

%%{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 與換證結果
圖 6 — 自動換證。走現有註冊路徑的重註冊分支,只差「到期前自己觸發」。

三條底線:

  1. 私鑰永遠在 Agent 上產生、永遠不經過網路。 主系統「發動換證」只能是叫 Agent 自己去換,不可以幫它產一對鑰推過去。
  2. 換證要同時過兩個因子:通行證(我是誰)+舊私鑰簽章(我真的持有鑰)。少一個就退化成「拿到通行證就能換到新證」。
  3. 主系統簽發前查狀態:撤銷、停用的 Agent 不簽。這是整條換證線的真正保護,比「過期後能不能換」重要。

數字:憑證 1 年、剩 90 天開始換、過期後寬限 90 天仍可換。離線容忍度 9 個月加寬限。1 年效期的意義是憑證被偷最多活一年,現在是十年。剩下唯一要人碰的情況:整機壞掉或憑證目錄被清空,那本來就是重裝。

5.5 主題 D:信任錨清單

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 與瀏覽器票
  • 走主題 B 的版本號通道下發;Agent 收到就更新本機。
  • 每張 JWT 帶 kid,Agent 用 kid 挑公鑰驗。沒帶 kid(舊主系統)就用清單第一把。
  • 換鑰流程:主系統加新鑰 → 清單下發(舊鑰仍有效)→ 管理頁顯示「N 台已收到」→ 全部收到後按「切換」→ 新簽發改用新鑰 → Agent 下次換證拿到新 CA 簽的證 → 舊鑰到期停用。全程主系統按兩個鈕,Agent 零碰。
  • CA 本身不排程換。換 CA 是整套系統最危險的操作,每三年強迫做一次是製造事故。長效維持,只做「能換」,在外洩或演算法淘汰時用。
  • 順手把三種票分鑰(資料面、通行證、瀏覽器票各一把),三把公鑰都進清單。這是縱深不是修 bug,但既然要做清單,分鑰便宜。

仍需人的唯一情況:主系統私鑰被偷(不是到期)。那時舊鑰不能信,等於全部 Agent 重新註冊。任何 PKI 都逃不掉,只能靠保護好私鑰。

5.6 主題 E:心跳持有證明(DPoP 式)

現在的通行證是 bearer,「誰拿著誰就能用」。改成:

  • Agent 每次打主系統,除了通行證,再帶一張用自己憑證私鑰簽的 60 秒 JWT(header DPoP),裡面寫方法、路徑、jti、時間。
  • 主系統註冊時就有這台的憑證(公鑰在裡面),驗簽章零成本;60 秒內同 jti 拒(存 Redis,多 worker 共用)。
  • 通行證被複製走沒用:偷不到私鑰,簽不出這張。

為什麼不是 mTLS:mTLS 強一層(TCP 層就擋),但主系統前門是給瀏覽器用的公開 HTTPS 埠,加 client 憑證驗證會讓瀏覽器跳憑證選擇框,實務要另開埠或 nginx 選擇性驗證,installer 與客戶防火牆都要動。對沒有 IT 的客戶,多開一個埠的成本遠高於應用層驗一次簽章。而且做完之後兩個方向是同一句話,守門邏輯可以是同一段程式。

誠實講的差距:DPoP 驗在應用層,沒私鑰的人還是能把請求送到 BE 門口才被擋;對內網落地版差距不大,SaaS 版前門要補速率限制。

相容:舊 Agent 不帶 DPoP header,主系統第一階段只記 log 不拒;等所有 Agent 升到帶 DPoP 的版本後,管理頁開「強制持有證明」開關。

5.7 主題 F:守門與 client 收斂

邊 現況 目標
主系統 → 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」。


6. 相容矩陣

主系統與 Agent 不綁版,四種組合都要能講話:

舊 Agent 新 Agent
舊主系統 現況 心跳不帶 config_versions 回應?帶了,主系統忽略未知鍵→Agent 全量收。DPoP header 主系統忽略。指令端點沒人打,心跳照拿
新主系統 直推新端點 404→退回心跳。Agent 不帶 config_versions→全量回。無 DPoP→記 log 不拒。信任錨清單 Agent 不認得→忽略,用舊的單張 CA 完整

唯一的硬切是「強制持有證明」開關,由管理員在確認所有 Agent 升級後手動開。


7. 待決策

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 驗過再寫。


8. 不做什麼

不做 理由
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) 沒問題,不動

9. 風險與陷阱

風險 怎麼防
升級順序錯(主系統先升、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 一次掛完

10. 參考

  • FR-170 設計 §3.x 溝通模式、LOG 第 4 棒「P4 分析」(本稿起點)
  • Agent repo docs/ARCHITECTURE.md、.claude/rules/security/sync-and-identity.md、endpoints.md
  • 現況程式:Agent guidant_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
  • 規範:RFC 7030(EST 換證)、RFC 8725(JWT 最佳實務)、RFC 9449(DPoP)、RFC 8705(mTLS 綁定 token)、RFC 7517(JWKS)、NIST SP 800-57(金鑰管理)、Kubernetes kubelet TLS bootstrapping 與 certificate rotation