---
title: "線上更新 — 需求討論稿 (FR-167)"
brand: "Guidant AI · **FR-167** 線上更新"
eyebrow: "FR-167 · 需求討論稿 · 2026-10-29（v8 待審）"
h1: "需求討論稿：為什麼這樣決定"
subtitle: "有權限的管理員按下載、按更新，其餘全自動；每一步有時限，失敗、卡住、斷電都自己退回，資料一筆不掉，不必派人到現場"
lede: "現在每出一版，客戶要自己把 **2.9GB 的出貨包**搬上主機、照手冊敲指令升級；中途出事，要人照著畫面印出的還原指令一行行打，打不出來就得派人過去。客戶要是在國外，這條路走不通。本案分兩層解：**第一層是讓升級指令絕不出事**——在資料庫複本上升級、每步有時限、失敗或超時或斷電都自動退回，開放使用之前現在那份資料庫從頭到尾沒動過，所以退回不需要還原、不會掉資料。**第二層是把搬檔與敲指令拿掉**——系統每天向我們的**更新站**問一次有沒有新版，有就通知管理員；管理員確認後系統才下載（跟 iPhone 一樣），下載驗證完，有「系統更新」權限的人按「立即更新」或選排程時間，其餘全自動；連不到外網的客戶自己上傳更新包。客戶主機上**不多任何常駐程式、不開任何新的埠**：主系統只會寫一張更新單，主機上的 systemd 看到更新單才呼叫裝機時就在的 `guidantai upgrade`；真要人出手，走客戶本來就有的主機登入。"
chips: [
  {text: "已定：確認後才下載、離線自己上傳、按鈕觸發＋系統更新權限、複本升級、自動退回、停服務後不給取消鈕、不強制停工作、不做救援頁、不做常駐助手、Agent 先推一台、必經版本規則、更新站與授權中心分開、不分安全修補版與功能版、一律人按不自動裝、授權過期仍可升同主版號最新版、更新站放 Cloudflare Worker 先用免費方案、發布印章在決策者電腦一正一備、不做頻道、拒絕名單手動加、升級指令先換自己再跑、發布清單 90 天有效", kind: ok},
  {text: "待裁：沒有了。CRA 兩題決策者裁先不管", kind: ok},
  {text: "前作：FR-065 install.sh --upgrade、FR-066 Agent 安裝包、FR-062 授權中心、FR-064 簽章", kind: accent},
  {text: "雛形：prototype/index.html", kind: accent}
]
footer: "FR-167 · 線上更新 — 需求討論稿 · 2026-10-29 v8 待審 · 現況來源：scripts/installer/install.sh、scripts/installer/guidantai、docker/production/docker-compose.yml、evidence-agent core/task_executor.py、189 POC 唯讀實查"
---

## 這案要解決什麼 {#why nav="為什麼"}

::: statgrid
::: {.stat .crit}
[人工]{.v}[升級失敗時，現在要人照畫面印出的指令手動還原]{.k}
:::
::: {.stat .crit}
[會掉]{.v}[主系統停機那段時間，Agent 送不出的檢測結果現在會被丟掉]{.k}
:::
::: {.stat .ok}
[5 步]{.v}[`install.sh --upgrade` 已有的升級步驟，本案沿用]{.k}
:::
::: {.stat .ok}
[650MB]{.v}[POC 資料庫大小，複製一份只要幾十秒]{.k}
:::
:::

決策者的話：「要能在 user 按下 accept 後自動化更新，要顯示進度，而且不可以卡住，就算卡住也要能復原」「不能因為卡死了就沒辦法復原，這樣還是我們人要去一次，要是客戶在俄羅斯怎辦」「要可以在更新的時候讓 user 反悔或是中斷」「資料絕對不可以掉」「救援頁感覺像後門」「有沒有現成的工具可以用，不要這樣搞」「沒有輸入指令這件事，就是讓有權限的人按」。

需求拆成兩層，價值差很多：

| 層 | 內容 | 價值 | 怎麼做到 | 詳見 |
|---|---|---|---|---|
| **A 更新不會出事** | 複本升級、每步時限、失敗／超時／斷電自動退回、進度看得到、可重試、資料不掉 | 這是「不派人到俄羅斯」的全部 | 改現有的升級指令，幾百行 | [§5 更新流程](#flow)、[§6 資料不掉](#nodataloss)、[§7 卡死怎麼救](#rescue) |
| **B 不用搬檔、不用敲指令** | 系統自己發現新版、確認後自己下載；有權限的人按一下或選時間，到點自己跑 | 管理員不必登入主機 | 更新站＋更新中心頁；「按一下就跑」靠更新單＋systemd 觸發器（D26） | [§11 更新站](#updatesite) |

A 是本案第一期，先做；B 第二、三期。

## 分工概述（30 秒版） {#overview nav="分工概述"}

整案：**升級指令改成絕不出事；我們這邊多一個更新站；畫面多一頁更新中心負責通知、下載、按更新。客戶主機上不多任何常駐程式、不開任何新的埠。**

| 期 | 做什麼 | 產出 | 完成怎麼判定（決策者檢查法） |
|---|---|---|---|
| **一 升級變安全** | `guidantai upgrade` 改成：預檢 → 停接新工作、等做完 → 停服務 → 複製資料庫 → 在複本上升級 → 健康檢查 → 開放；每步有時限、寫進度檔；失敗或超時自動退回；斷電後開機自動退回；保留上一版映像；前門在停機期間顯示進度頁；必經版本由 manifest 自動算；CI 拿真實舊庫排練；Agent 送不出的結果先存著、恢復後補送 | IT 現在就敲的那道指令變安全，不必等畫面 | 在測試機裝 1.23.1、灌 POC 資料庫備份，一道指令直升新版成功；放一支會失敗的 migration，自動退回，資料筆數與升級前一致；升級跑到一半直接重開機，開機後自動退回；停機期間打開原網址看得到進度 |
| **二 更新站** | 我們這邊：獨立更新站（與授權中心分開）、發布 CLI（決策者電腦離線簽名）、CI 出包後自動登記草稿 | 我們發一版，客戶查得到 | 跑一次 tag pipeline，更新站後台出現草稿；在自己電腦跑發布指令變「已發布」；用 DEV 的授權問一次，回應裡有新版 |
| **三 更新中心** | 客戶這邊：每日檢查、通知、管理員確認後才下載、驗簽名、離線上傳、預檢、就緒後「立即更新」或選排程時間、寫更新單、主機 systemd 觸發器看到更新單呼叫 `guidantai upgrade`、進度、結果、更新紀錄、診斷包下載；新增「系統更新」權限 | 管理員不必搬檔、不必登入主機 | DEV 收到「有新版」通知，按「下載」，看到就緒；按「立即更新」，更新中心與維護頁都看得到進度跑到完成；再試一次選排程，到點自己跑；沒有「系統更新」權限的帳號看不到更新鈕；拔網路改走離線上傳，一樣就緒 |
| **四 Agent 跟進** | 主系統升完後，Agent 先推一台、正常再推其餘；心跳帶目標版本 | Agent 不必逐台手動升 | 三台 Agent 的測試環境：主系統升完，先一台變新版，其餘兩台接著升；故意讓第一台失敗，其餘兩台不會被推 |

依賴：一與二可以同時做；三要等一、二；四要等三。**決策者功課**：更新站網域出貨前再定（開發期用 workers.dev 子網域）；CRA 兩題先不管；在自己電腦產發布印章一正一備（見 D3）。

## 完整走一次：客戶 A 從 1.24.1 升到 1.27.0 {#usecase nav="走一次"}

**背景**：客戶 A 跑 1.24.1，接了 3 台 Agent。我們剛發 1.27.0，可以從 1.22.0 以上直升。

| 時間 | 誰 | 發生什麼 |
|---|---|---|
| 週三 | 我們 | CI 出 1.27.0，在更新站登記草稿；決策者在自己電腦跑 `guidant-release publish 1.27.0` 簽名發布 |
| 週四 03:00 | 系統 | 每日檢查：向更新站出示本機授權，問「我 1.24.1，有新的嗎」。拿回簽好的版本資訊（幾 KB）：有 1.27.0、可直升、2.9GB、更新說明。**這時還沒下載** |
| 週四 09:00 | 管理員 | 登入看到「有新版本 1.27.0」，點進更新中心看更新說明、「服務會中斷約 10 到 20 分鐘」、預檢結果（空間夠、版本可直升） |
| 週四 09:05 | 管理員 | 按「下載」。系統記下是誰按的 |
| 週四 09:05–09:20 | 系統 | **這時才開始下載**，服務照常。下載完驗簽名與雜湊，通過後更新中心顯示「已就緒」，並寄通知給管理員 |
| 週四 09:25 | 管理員 | 回更新中心，選「排程」週六 22:00，按「確認更新」。主系統在主機上寫一張更新單（版本、時間、誰按的） |
| 週六 21:30 | 系統 | 頁面上方提示所有人「30 分鐘後系統更新」 |
| 週六 22:00 | 主機 | systemd 觸發器到點呼叫 `guidantai upgrade`：預檢 → 停止接新工作，等跑到一半的證據分類做完 → 停服務 → 複製資料庫 → 在複本上套 migration → 啟動新版 → 健康檢查 |
| 週六 22:00–22:15 | 管理員 | 打開原網址看到**維護頁**：目前在第幾步、這步跑了多久、剩多少時限。這頁由前門直接顯示，主系統與資料庫停機時照樣看得到 |
| 週六 22:15 | 系統 | 健康檢查通過 → **開放使用** → 通知管理員「已更新到 1.27.0」 |
| 週六 22:20 | 系統 | 先讓 1 台 Agent 升級，正常後推給另外 2 台 |
| 週一 | 管理員 | 更新中心頁顯示：主系統 1.27.0 正常、Agent 3/3 正常、下載人王大明、按更新的人王大明（排程） |

**變化情境：**

| 情境 | 怎麼走 |
|---|---|
| 22:08 套 migration 卡住不動 | 超過時限沒有進度 → 自動退回 1.24.1 → 通知管理員「更新失敗，已退回，資料完整」，附原因與診斷包下載鈕。管理員把診斷包寄給我們，我們修好再發 |
| 22:05 機房斷電 | 來電開機 → 升級指令的開機檢查讀進度檔，發現上次沒做完 → 自動退回 1.24.1 |
| 22:03 管理員臨時反悔 | 服務還沒停（還在等工作做完）：更新中心按「取消」，立刻恢復接新工作，什麼都沒動。服務已經停了：**沒有取消鈕**（D25），這段每步有時限，最多跑滿時限就自動有結果——升完，或退回 |
| 22:00 還有一件檢測派送在跑 | 等它做完，**不強制停**。等到 23:30 維護時段結束還沒做完，這次就不更新，恢復接新工作，通知管理員改約時間 |
| docker 本身當掉，自動退回沒跑 | 唯一要人登入主機的情況：客戶 IT 用 SSH 執行 `sudo guidantai recover`，效果等同自動退回。這是客戶本來就有的門 |
| 客戶 A 停在 1.20.0 | 比必經版本 1.22.0 舊 → 更新中心頁顯示「需先升 1.22.0，再升 1.27.0」。按一次，兩個包都下載；更新分兩段跑；第二段失敗會停在 1.22.0，那是一個穩定、可用的版本 |
| 週四下載到一半網路斷了 | 恢復後從斷點續傳；沒下載完就不會顯示「已就緒」，更新鈕不會出現，升級指令也會拒絕用不完整的包開始 |
| 客戶 B 的主機不能連外網 | 走離線更新：我們把 1.27.0 更新包交給客戶，管理員在更新中心「離線更新」上傳。從驗簽名開始，後面完全一樣；只是沒有自動通知 |
| 升完兩天才發現功能有問題 | 不退回（退回會丟掉這兩天的資料），我們發修正版 |

## 已經定案的 {#decided nav="已定案"}

::: {.callout .decided}
**D22 先通知，管理員確認後才下載（跟 iPhone 一樣）**

系統每天只問「有沒有新版」，拿回的是幾 KB 的版本資訊，不下載。管理員按「下載」後才開始下載 2.9GB；下載時服務照常，下載完驗過簽名才算就緒。不先下載的理由：客戶不一定要升這一版，先下載會白佔頻寬與硬碟。
:::

::: {.callout .decided}
**D23 離線更新：管理員自己上傳**

連不到外網的客戶，我們把更新包交給客戶，管理員在更新中心「離線更新」上傳。上傳後從驗簽名開始，跟線上下載完全同一條路。
:::

::: {.callout .decided}
**D24 記錄是誰更新的**

每一次更新都記：誰按的下載、誰按的更新（主系統帳號與名稱、時間、立即或排程）、誰取消的；若是 IT 在主機上手動執行或用 `guidantai recover`，記主機登入帳號（由指令寫進進度檔）。主系統走既有稽核事件；指令這邊寫在進度檔，主系統恢復後同步回稽核紀錄。更新中心的更新紀錄表每一列都顯示操作人。
:::

::: {.callout .decided}
**D13 客戶機上由 `guidantai upgrade` 換版，主系統不碰 docker**

主系統跑在容器裡，換不掉自己。換版的是裝機時已經放在主機上的 `guidantai` 指令（bash，`/usr/local/bin/guidantai`，隨出貨包更新），用 root 跑。主系統容器**沒有 docker 權限**，只負責通知、下載、驗簽名、顯示、**寫一張更新單**；能停容器、換版本的權限等於整台主機的最高權限，不交給對外的網頁服務。
:::

::: {.callout .decided}
**D19 跑到一半的工作：等它做完，永遠不強制停**

更新開始先停止接新工作，等進行中的證據分類、檢測派送做完。超過 30 分鐘還沒完，通知管理員「還有 N 筆在跑」，繼續等。等到維護時段結束還沒完，**這次就不更新**：什麼都沒動，恢復接新工作，通知管理員改約時間。理由：等待發生在服務停掉之前，放棄更新等於什麼事都沒發生，最壞只是延期；而半夜排程沒人能回答「要不要強制停」，延期是系統唯一不會傷到東西的決定。
:::

::: {.callout .decided}
**D20 不做救援頁，不開第二道門**

一個能退回、能重啟容器的網頁，等於拿著主機最高權限，又開在另一個埠、用另一組裝機時產生的固定密碼——對外就是多一道門，而管理埠正是攻擊者最愛掃的目標。它原本要解的情況，幾乎都已經由「時限＋自動退回＋開機檢查」自動處理；剩下真的要人出手的，走客戶本來就有的主機登入（`guidantai recover`）。詳見 [§7](#rescue)。
:::

::: {.callout .decided}
**D21 不做常駐的更新助手；執行者就是 `guidantai upgrade`，由 systemd 觸發**

不新增容器、不新增獨立執行檔、不給任何容器 docker 權限。主系統寫更新單，主機上一個 systemd 檔案觸發器（兩個十行的設定檔）看到更新單幾秒內就呼叫 `guidantai upgrade`，排程的話到點才叫——Debian 的自動安全更新就是這個形狀。`install.sh` 本來就要求主機有 systemd、本來就在主機上裝 `guidantai`，所以這不是新成本。GitLab Runner、Ansible 這類現成工具能代勞的只有「等到時間、把腳本跑起來」這件小事，代價卻是客戶主機上多一條我們能下任意指令的通道，不划算。
:::

::: {.callout .decided}
**D25 服務停掉之後不給取消鈕，靠時限自動判失敗、自動退回**

服務停掉之前（下載中、等工作做完）隨時能取消，什麼都沒動。服務停掉之後（複製資料庫、升級、健康檢查）每一步各有時限（見 [§5](#flow)），任何一條觸發就自動退回，管理員不用按任何東西，最多等到時限就有結果。不給取消鈕的理由：那幾分鐘資料庫是停的，網頁沒辦法確認按的人是不是管理員，要做就得另發票或另開一道門；為了提早幾分鐘退回不值得。真要在這幾分鐘人工介入，IT 在主機敲 `guidantai recover`。
:::

::: {.callout .decided}
**D26 沒有「敲指令」這件事：有「系統更新」權限的人在畫面上按**

權限系統加一個「系統更新」能力，預設給系統管理員，可以另外授權。按「立即更新」或選排程時間後，主系統只做一件事：在主機上寫一張更新單（版本、時間、誰按的）。主機上的 systemd 檔案觸發器看到更新單就呼叫 `guidantai upgrade`，排程的到點才叫。主系統拿到的權限只有「寫一個檔」。指令本身仍在，IT 想手動跑也可以，當備援。
:::

::: {.callout .decided}
**D14 失敗處理：開放使用前一律退回；開放使用後不退回，發修正版**

開放使用那一刻是唯一的「不可退回點」。在那之前資料沒有變過，退回不掉資料；在那之後退回一定會丟掉這段時間的新資料。
:::

::: {.callout .decided}
**D15 下載要管理員確認；更新要有「系統更新」權限的人按，可立即也可排程；任何版本都不自動裝**

更新會停機、會改功能，時間由客戶挑；客戶不更新是客戶的事。D28 說明為什麼連安全修正也不自動裝。
:::

::: {.callout .decided}
**D16 Agent 先推 1 台，正常再推其餘**

Agent 失敗只影響那 1 台，主系統不受影響。Agent 版本永遠不比主系統新。
:::

::: {.callout .decided}
**D17 必經版本只有三種情況才設**

資料要分兩步搬、基線重整、升級流程本身改了。寫 migration 的人在 manifest 標記，封包時自動算出「從哪版可直升」寫進更新包；CI 用比必經版本更舊的庫試升必須被擋、用必經版本的庫試升必須成功。
:::

::: {.callout .decided}
**D18 資料不掉的做法：在複本上升級**

詳見 [§6](#nodataloss)。
:::

::: {.callout .decided}
**D2 更新站與授權中心分開**

授權中心可能不對客戶開放；更新來源與簽發站合在一起，攻破一個等於兩個都破。客戶出示本機簽好的授權檔，更新站用授權中心的公鑰驗，不連授權中心、不存客戶名單。
:::

::: {.callout .decided}
**D7 授權過期的客戶仍可升到同一個主版號的最新版；換主版號（2.0）要重新授權**

授權擋的是「能用什麼」，不是「能裝什麼」：產品現在就有這道門，授權過期系統進唯讀、模組照授權檔鎖。過期客戶升到最新版，拿到的是一套唯讀的最新版，新功能照樣鎖著，我們沒有少收錢；而過期客戶的系統還在跑、還連著網路，有洞不補對雙方都是風險。所以更新站不看授權到期日，只看兩件事：這是不是一台合法安裝的機器（本機授權檔由授權中心簽過）、有沒有在拒絕名單。**主版號**是唯一的範圍限制：授權檔簽的是「1.x」，出 2.0 時過期客戶就停在 1.x 的最後一版，這是買斷軟體「含同代升級、換代另購」的常見做法（JetBrains、Sublime、Beyond Compare）。現在只有 1.x，這條規則目前零成本，先寫下來免得出 2.0 再吵。詳見 [§10 CRA](#cra)。
:::

::: {.callout .decided}
**D27 只維護最新版；安全修正就是發下一版；五年支援靠「永遠可以免費升到最新版」達成；要找懂 CRA 的人確認兩題**

我們每一版都是修正加功能混著出，從沒出過「只修安全」的版，以後也不打算分：有洞就修、修完出下一個版號，release note 寫明是安全修正。舊版的洞靠「免費升到最新版、升級保證資料不掉」解決，不另外維護舊版本線。「五年支援」寫成「五年內永遠有一條免費、不掉資料的路徑升到有修正的版本」。要拿去問懂 CRA 的人的兩題：①**我們到底受不受 CRA 管**（客戶在台灣、產品沒賣進歐盟的話整節 §10 可以拆掉）；②若受管，「單一版本線、安全修正與功能一起出、新功能由授權鎖」這種寫法是否滿足「安全更新與功能更新分開提供」那條（CRA 原文有「技術上可行時」的但書）。**CRA 主要義務 2027 年 12 月適用，design 定案前先問。**
:::

::: {.callout .decided}
**D28 一律人按，不做自動安裝；安全修正也不例外**

決策者裁示：「絕對不要自動裝，客戶不更新是他的事。」理由站得住：落地企業軟體更新要停機、客戶有自己的維護時段、裝了出事是客戶的 IT 要收拾，GitLab、Jira、Sentry 這些自架產品也都不自動裝；而且我們沒有「只修安全、不動資料庫」的小版（D27），每一版都可能帶 migration，自動裝的風險跟功能版一樣大。CRA 那條原文是「適用時」才要預設自動裝，CRA 文件寫一段「本類產品不適用自動裝」的理由即可（併進 D27 第②題一起問）。代價：有洞的客戶拖著不升，我們只能在更新中心一直提醒，不能替他按。
:::

::: {.callout .decided}
**D29 每一版的更新包至少保留 10 年**

CRA 第 13 條第 9 項要求安全更新至少 10 年可下載；我們不分安全版，所以每一版都留。放 R2，一年十幾版也才幾十 GB，費用可以忽略。
:::

::: {.callout .decided}
**D30 升級指令開跑第一步先換成新版的自己，再跑剩下的步驟；畫面上不顯示指令版本**

主機上的 `guidantai` 是裝機時從出貨包複製到 `/usr/local/bin/` 的，客戶跑 1.24.1 時那支指令也是 1.24.1 版。新版的升級流程（複本升級、每步時限、自動退回）寫在新版指令裡，舊指令不會做。所以 `guidantai upgrade` 第一件事是從**已驗過簽名與雜湊**的新更新包取出新版指令、換掉 `/usr/local/bin/guidantai`、再以新版接著跑；之後每次升級跑的都是「目標版本的升級流程」，舊版指令只要會「驗包、換自己」就夠（GitLab omnibus、Docker installer 都這樣）。代價：第一期出去那版指令要把「換自己」這一步做對，之後才不會再卡；換自己失敗就當預檢不過，什麼都不動。這是指令內部的事，更新中心頁不顯示「指令版本」「相容」之類的欄位，客戶不需要知道有這支指令。
:::

::: {.callout .decided}
**D31 發布清單 90 天有效，過期重新蓋章；不做「回應 24 小時內」**

更新站手上沒有私鑰（D3），簽章是決策者發版時離線蓋的，更新站只是原樣轉交，沒辦法對每次回應重新簽時間。所以「回應超過 24 小時拒收」做不到。改成清單本身帶簽發時間與有效期 90 天，客戶端超過就拒收；一版發出去 90 天內沒下一版，決策者跑一次 `guidant-release publish` 重新蓋章延長。這是 TUF 的 timestamp 角色的簡化版：防的是有人把過期的舊清單一直餵給客戶讓他升不上來，90 天是「被卡的最長時間」。
:::

### 更新站那一組

::: {.callout .decided}
**D1 更新包放在 S3 相容的私有空間，用 Cloudflare R2**

程式只認 S3 介面，換供應商改一個網址。R2 對外下載流量不收費：客戶每次更新抓 2.9GB，放別家每出一版都要付一次流量費。
:::

::: {.callout .decided}
**D2a 更新站跑在 Cloudflare Worker，先用免費方案**

更新站是我們這邊唯一對客戶開放的服務：回答「有沒有新版、你能不能拿」（合法安裝、不在拒絕名單、同主版號）、產短效下載網址、收客戶回報的版號。對外服務最花的是守機器的人力，不是寫程式的時間；R2、弱點資料網域、文件站都已在 Cloudflare，多開一個功能不多一家帳務。先用免費方案（每天 10 萬次請求，我們一天不到 100 次），跑一段時間再看要不要升付費方案（每月 5 美元）或改成自己養一台。代價：驗章要改呼叫 Cloudflare 的加密函式，多一份驗章實作，用授權中心真簽出來的檔當守衛測試。排除：跟授權中心同一台（D2）、自己養一台對外主機（長期要守）、完全不寫程式只在 R2 放清單檔（做不到 D6、D9、D12）。
:::

::: {.callout .decided}
**D3 發版的最後一步在決策者電腦蓋章；發布印章一正一備，跟授權中心的印章分開**

更新包要帶一個只有我們蓋得出來的印章（Ed25519 簽章），客戶系統用程式裡的印樣（公鑰）對章，對不上整包丟掉。CI 在 188 出包、上傳 R2、在更新站登記成「草稿」，客戶看不到；決策者在自己電腦跑 `guidant-release publish <版>` 蓋章，才變「已發布」。印章（私鑰）只在決策者電腦，188 被攻破的人出得了包、發不了版——這是「上版要決策者明示放行」鐵律的程式化。

**印章怎麼保護、怎麼換**（業界標準做法，TUF、apt、Android 系統更新都這樣）：

- 一開始刻兩顆：主力那顆放決策者電腦，備用那顆產完就封存放別處，不在任何電腦上。產品出貨時兩顆的印樣都帶著。
- 每顆印章有編號，發布清單寫「這版是幾號印章蓋的」，客戶系統拿對應編號的印樣對章；沒那個編號直接判驗證失敗。主產品驗授權檔已經是這樣做（`PUBLIC_KEYS` 以 kid 認公鑰），照搬。
- 換印樣只能藏在一次「對章通過」的更新裡：用客戶現在認的印章蓋一版包，包裡印新印樣；客戶系統先拿手上的舊印樣對章，對得上才裝、裝完才收下新印樣。包裡的印樣在對章失敗那一刻跟整包一起被丟掉，進不了門。更新站、R2、管理員畫面都不能換印樣。
- 新舊印樣重疊一版：換印樣那版裝完客戶兩個都認，下一版才拿掉舊的，中間漏一版的客戶也認得。
- 主印章丟了或被偷：拆封備用印章，發一版宣告舊印章作廢、帶一顆新的備用印樣。已經裝好的舊版完全不受影響，對章只發生在下載新版那一刻。
- 兩顆都丟了，才需要人到客戶那邊重裝，所以備用那顆不跟主印章放同一台電腦。
- 很久沒更新的客戶，中間我們換過兩次印章，先裝換第一次印章那版再往上升，就是必經版本規則（D17），不另外做東西。
:::

::: {.callout .decided}
**D6 不做頻道；草稿可以指定給幾台內部機器先裝**

本意是 STG、POC 比客戶早拿到新版當白老鼠。頻道是給客戶看的概念，但客戶永遠只該看到正式版；改成草稿在後台指定「哪幾台機器看得到」，一個欄位解決，後台也少一欄。
:::

::: {.callout .decided}
**D8 不做差異更新；D9 客戶只回報授權編號、機器指紋、現行版號、最近一次升級結果；D10 每天檢查一次、管理員可關；D11 客戶出示本機授權檔、更新站用授權中心公鑰驗**

四項在流程表（[§5](#flow)）、安全原則（[§11](#updatesite)）與 D2 已寫定，這裡只登記。D10 補一句：每日檢查是唯一的開關；關了會在更新中心一直顯示提醒。
:::

::: {.callout .decided}
**D12 拒絕名單，第一版決策者在更新站後台手動加**

授權過期也能升同主版號最新版（D7）之後，拒絕名單只剩「我們主動要切斷某客戶」這一種情況，不常用，不值得自動化。
:::

客製化版本的客戶怎麼走更新，這輪先不談。

## 更新流程 {#flow nav="更新流程"}

每一步都有「失敗或卡住怎麼辦」和「能不能取消」。**技術選擇掛在步驟底下**，不單獨討論。

```{.mermaid cap="圖 1 — 更新流程：服務停掉之前取消等於什麼都沒發生；停掉之後任何一條失敗的線都自動回到「更新前」"}
%%{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 TB
  A0["① 發現新版<br/>每日檢查・只拿版本資訊"] --> B["② 管理員按下載<br/>記錄是誰"]
  U["離線：管理員上傳更新包"] --> V
  B --> V["③ 下載・驗簽名<br/>服務照常"]
  V --> T["④ 有權限的人按更新<br/>立即或排程・寫更新單<br/>systemd 呼叫 guidantai upgrade・預檢"]
  T --> D["⑤ 停止接新工作<br/>等進行中的做完・不強制停"]
  D --> E["⑥ 停服務・複製資料庫"]
  E --> F["⑦ 在複本上升級<br/>載入映像・套 migration"]
  F --> G["⑧ 啟動新版・健康檢查"]
  G --> H["⑨ 開放使用<br/>不可退回點"]
  H --> I["⑩ Agent 跟進<br/>先 1 台再其餘"]
  V -. 驗不過 .-> X0["停在更新前<br/>列出原因・什麼都沒動"]
  T -. 預檢不過 .-> X0
  D -. 取消・等不完 .-> X0
  E -. 失敗・斷電 .-> R
  F -. 失敗・超時・斷電 .-> R
  G -. 不過・斷電 .-> R
  R["自動退回<br/>改接現在那份資料庫<br/>啟動舊版映像"] --> X1["回到更新前<br/>資料完整・通知管理員"]
  style H fill:#F6EBD5,stroke:#9C6B12,stroke-width:2px
  style R fill:#FDECEC,stroke:#B42318
  style X1 fill:#EEF6EE,stroke:#3F7D45
```

| 步驟 | 做什麼 | 失敗或卡住時 | 能取消嗎 | 技術選擇 |
|---|---|---|---|---|
| **① 發現新版** | 每天向更新站出示本機授權問一次，拿回簽好的版本資訊（幾 KB），通知管理員。**不下載** | 連不到更新站就安靜略過，更新中心顯示「最近一次檢查失敗」 | — | 照弱點資料每日同步的形狀做；管理員也可按「立即檢查」 |
| **② 管理員按下載** | 看更新說明、停機時間、要不要先升必經版本；按「下載」。**記錄是誰按的**。任何版本都不自動下載 | — | 能 | 主系統的更新中心頁 |
| **③ 下載、驗簽名** | 確認後才下載 2.9GB，服務照常；斷線續傳；下載完驗簽名與雜湊，通過後顯示「已就緒」並通知管理員。離線客戶改成管理員上傳，從驗簽名開始同一條路 | 驗不過就丟掉重下；沒下載完就不顯示就緒，更新鈕不出現 | 能，取消下載 | 包就是現在出貨的 `guidant-ai-<版>.tar.gz`，不另開格式；更新站只給短效下載網址，檔案直接從私有倉庫拿 |
| **④ 按更新、預檢** | 有「系統更新」權限的人按「立即更新」或選排程時間，任何版本都不自動裝（D28）。主系統寫更新單；systemd 觸發器呼叫 `guidantai upgrade`（排程的到點才叫）；先提醒在線使用者，再預檢：空間夠不夠放複本與新映像、資料庫連得上、版本路徑合法；**先從已驗過簽名的新包裡取出新版 `guidantai` 換掉自己，再用新版跑後面每一步**（D30） | 列出不過的項目，**什麼都不動** | 能 | 就緒時更新中心先跑一次預檢，執行時再跑一次；空間不足時提示可清掉多少舊映像；指令仍可由 IT 手動執行當備援 |
| **⑤ 停止接新工作** | 不再派新的證據分類、檢測工作給 Agent；等進行中的做完。**不強制停**（D19） | 超過 30 分鐘通知管理員，繼續等；到維護時段結束還沒完就這次不更新，恢復接新工作 | 能：更新中心按「取消」，什麼都沒動 | 背景工作現有的租約機制 |
| **⑥ 停服務、複製資料庫** | 停 API、背景工作、資料庫；把整個資料庫資料夾複製一份 | 複製出錯或超過預估時間三倍：刪掉複本，啟動舊版 | 沒有取消鈕（D25） | 先停 API 是 POC 踩過的教訓（不停的話 Agent 心跳會讓 migration 鎖死）；複製時間跟資料庫大小成正比，預檢要把預估停機時間算給管理員看 |
| **⑦ 在複本上升級** | 載入新映像、驗雜湊、對複本套 migration，逐支回報進度 | 載入出錯或超過 5 分鐘、任一支 migration **超過 10 分鐘沒有新進度**：自動退回 | 沒有取消鈕 | 現有 `migrate.sh` 已經逐支套用，只要每支寫進度檔 |
| **⑧ 啟動新版、健康檢查** | 新版接複本啟動，跑健康檢查 | 180 秒內沒變健康、或登入與主要 API 打不通：自動退回 | 沒有取消鈕 | 現有 180 秒健康檢查沿用，加上登入與幾支主要 API 的實打 |
| **⑨ 開放使用** | 換上新版前門、解除維護頁、讓使用者進來 | — | **不能**，這是不可退回點 | 之後有問題發修正版 |
| **⑩ Agent 跟進** | 主系統正常後，Agent 目標版本改成新版；先 1 台，正常再推其餘 | 只影響那台 Agent | 個別 Agent 可暫停 | 見 [§9](#agent) |

**整段總上限 60 分鐘**：⑥～⑧ 加起來超過一律判失敗退回，不會永遠卡著。

**進度檔**：一個放在主機上的純文字檔（`upgrade-progress.json`），**只有 `guidantai upgrade` 寫**，記錄這次更新的編號、目前第幾步、每步開始時間、時限、結果、錯誤訊息、誰觸發的。更新中心頁、維護頁、開機檢查都讀這一份，所以三邊看到的永遠一樣。

### 更新期間畫面從哪來

更新中途主系統和資料庫是停的，畫面不能靠它。分三段：

| 階段 | 畫面誰給 | 資料從哪來 |
|---|---|---|
| 服務停掉之前（下載、預檢、等工作做完） | 主系統的更新中心，跟平常一樣 | 後端讀進度檔 |
| **服務停掉之後（複製資料庫、升級、健康檢查）** | **前門（nginx）直接給一張維護頁**，後端、資料庫都不在 | 維護頁每 3 秒讀一次進度檔 |
| 開放使用之後 | 主系統的更新中心 | 後端讀進度檔，顯示結果、寫進更新紀錄 |

- 維護頁是 nginx 的內建用法：後端連不上時改送一張靜態頁，很多網站的「系統維護中」就是這樣做的。這張頁是幾十行的 HTML，畫出步驟清單、進度條、這步跑了多久、還剩多少時限。**沒有任何按鈕**。進度檔從主機以唯讀方式掛進 nginx 容器；頁面與設定隨 FE 映像出貨。
- **前門最後才換**：nginx 自己也是要升級的容器之一，現在 `install.sh --upgrade` 是整組一起重建，那樣維護頁會跟著消失。改成舊版 nginx 從頭留到尾負責顯示，第 ⑨ 步才換成新版；使用者只在「開放使用」那一刻感覺到幾秒斷線。
- IT 若是手動執行指令，終端機上同時看到同樣的步驟逐行印出來，跟現在一樣。

## 資料絕對不掉怎麼做到 {#nodataloss nav="資料不掉"}

### 在複本上升級，現在那份不動

| | 現有做法 | 本案做法 |
|---|---|---|
| 升級對象 | 直接在現在那份資料庫上套 migration | 先複製一份，在**複本**上套 |
| 失敗時 | 從升級前的備份**還原** | 改接回現在那份，啟動舊版映像 |
| 退回要多久 | 還原一次，資料越多越久，而且是在最慌的時候做 | 幾秒 |
| 退回本身會不會失敗 | 會：還原是寫入動作 | 幾乎不會：沒有寫入，只是換接 |

**中間沒有新資料**：從第 ⑥ 步停服務到第 ⑨ 步開放使用，服務是關著的，沒有任何新資料寫進來。所以退回到現在那份等於一筆都沒掉。

**開放使用之後**：新資料寫進新的那份。舊的那份保留到下一次更新成功為止，標示為「升級前快照」，**只用於災難救援，不是回滾**——拿它還原會丟掉開放後的資料，要由我們判斷才用。

### 其他會掉資料的地方

| 地方 | 風險 | 處理 |
|---|---|---|
| **證據檔案（SeaweedFS，POC 約 10GB）** | 太大，每次複製太慢 | **不複製**。服務停著時沒有新檔案寫進來，檔案不會變。規則：升級過程**不准改寫既有檔案**；真有一版需要改檔案，就把那版標成特殊版，那次才連檔案一起複製 |
| **設定檔與憑證** | migration 或新版可能改設定 | 第 ⑥ 步一起複製一份，退回時一起換回 |
| **Agent 跑完但送不出的結果** | 現在 Agent 回報失敗只記 log，結果直接丟掉（`evidence-agent core/task_executor.py` 的 `_result`） | 第一期補：Agent 送不出的結果先存在本機，主系統恢復後補送。第 ⑤ 步停止派新工作也能減少這種情況 |
| **跑到一半的背景工作** | 停服務時被中斷 | 不會發生：第 ⑤ 步等它做完才停，不強制停（D19）；等不完就這次不更新 |

### 實查 POC 的數字

| 項目 | 實際值 | 對設計的意義 |
|---|---|---|
| 資料庫 | 533MB，資料夾約 650MB | 複製一份幾十秒，可行。客戶用兩年到 50GB 時可能要十幾分鐘，預檢要把預估停機時間算出來 |
| 證據檔案 | 約 10GB | 不複製的理由 |
| 硬碟剩餘 | 20GB，已用 90% | 預檢一定要先算空間 |
| 主機上的映像與 build 殘留 | 映像 35GB、build 快取 21GB | POC 自己的殘留，不是產品問題；但預檢要提示可清掉多少 |

## 卡死也救得回來，不必派人、不開新的門 {#rescue nav="卡死怎麼救"}

### 原則：救援靠自動，不靠人按

所有「回來」都由升級指令自己完成，不需要人在旁邊，也不需要任何新的對外入口：

- **每步有時限**：超時判定卡住，自動退回。
- **開機檢查**：`guidantai` 隨 docker 啟動時先讀進度檔，上次更新停在 ⑥～⑧ 沒做完，就自動退回。
- **退回不是還原**：只是改接回沒動過的那份資料庫、啟動保留的舊版映像，幾十秒，幾乎不會失敗。

真的要人出手的情況只剩「連自動退回都沒跑」（例如 docker 本身當掉），那時走客戶本來就有的主機登入：IT 用 SSH 進主機執行 `sudo guidantai recover`。我們沒有多開任何門，誰能進主機、怎麼管，是客戶自己的政策。

### 各種情況

| 情況 | 結果 | 要人嗎 |
|---|---|---|
| migration 卡住不動 | 超時自動退回 | 不用 |
| 新版起不來 | 健康檢查不過，自動退回 | 不用 |
| 更新中斷電或主機重開 | 開機檢查讀進度檔，自動退回 | 不用 |
| 管理員反悔（服務停掉之前） | 更新中心按「取消」，恢復接新工作，什麼都沒動 | 按一下 |
| 管理員反悔（服務停掉之後） | 沒有取消鈕；最多跑滿時限就有結果（D25） | 不用 |
| 退回後想再試 | 更新包還在主機上，不用重下載；更新中心按「重試」，從預檢重跑 | 按一下 |
| docker 本身當掉、自動退回沒跑 | 唯一要登入主機的情況：IT 執行 `sudo guidantai recover`，效果等同自動退回 | 敲一行 |
| 硬碟滿了 | 預檢就擋下，不會開始 | 不用 |
| 要我們遠端判斷原因 | 更新中心「下載診斷包」或 IT 執行 `sudo guidantai diag`：進度檔、各容器 log、主機狀態；自動遮掉密碼與金鑰。寄給我們就能判斷 | 寄個檔 |

**任何情況的終點只有兩個**：新版正常開放，或退回更新前、資料完整。不會停在中間。

**不做「從中斷的那一步接著跑」**：斷在哪一步沒人說得準，從複本重新升一次比接著跑穩，而且複本是整份重做，不會留半套的狀態。

## 跨版升級與必經版本 {#multiversion nav="跨版升級"}

**每版出一整包，可以一次跨好幾版。** 包裡帶著從第一支到最新一支的全部 migration，`migrate.sh` 拿客戶庫已套的清單比對，缺哪些就依序補哪些。所以 1.24.1 直升 1.27.0，就是把中間三版的 migration 一次套完。不做差異包：要維護每兩版之間的差異，是另一套要顧的東西。

**必經版本**（D17 已定）：只有三種情況才設——資料要分兩步搬（中間那版的程式負責搬資料）、基線重整（新包不再帶舊 migration）、升級流程本身改了（舊的指令看不懂新包）。由 manifest 標記自動算出「從哪版可直升」寫進更新包，現在寫死的 `1.0.0` 改成自動產生。

**跨版排練**：每次封正式包前，CI 拿真實的舊庫（不是乾淨新裝）試升，這是 1.21.0 抓到 `fr093-4d` 順序問題的做法。STG 與 POC 現在都是 1.26.0、跟主線同版，自然升級測不到跨版，要另外在測試機用 189 留著的 1.23.1 映像加 POC 資料庫備份排練。全程在測試機做，不碰 STG、POC。

## Agent 跟進 {#agent nav="Agent"}

| 項目 | 做法 |
|---|---|
| 誰告訴 Agent 要升級 | 主系統。主系統升完並開放後，把 Agent 目標版本改成新版；Agent 下次心跳看到就去主系統拿包 |
| Agent 拿包從哪來 | 主系統（主系統的更新包裡就帶 Agent 的包）。Agent 不連外網、不連更新站 |
| 誰在 Agent 主機上換版 | Agent 自己的升級指令，跟主系統同一套做法：複本、時限、自動退回 |
| 分批 | 先 1 台，正常再推其餘（D16）；任何一台失敗就暫停推送，通知管理員 |
| 版本約束 | Agent 永遠不比主系統新；主系統退回時，還沒升的 Agent 不會被推 |
| **只升 Agent，主系統不動** | 可以，同一套機制。兩種情況：①主系統已經升了、某幾台 Agent 落後（新加的、上次暫停的、當時失敗的）——更新中心 Agent 區選那幾台按「推送」，走一樣的先一台再其餘；②我們只修 Agent（例如 Agent 的檢測工具有洞）——更新站發 **Agent 單獨修正版**（只有 Agent 的包，幾十 MB），主系統照一樣的路下載、驗簽名、存起來，但不停服務、不升自己，直接推給 Agent。版本約束不變：Agent 修正版的主線版號必須等於主系統現行版 |
| 作業系統更新 | 不在本案（Flatcar 那條線另案） |

## 版本只有一種，授權只管同主版號（CRA） {#cra nav="CRA"}

版本不分「安全修補版」與「功能版」：我們每一版都是修正加功能混著出，安全修正就是發下一版（D27）。授權管的是**能用什麼**與**能升到哪一代**，不管能不能裝：

| 客戶狀態 | 能不能升 | 升上去拿到什麼 |
|---|---|---|
| 授權有效 | 同主版號內任何新版 | 完整功能，照授權檔的模組 |
| 授權過期 | 同主版號（1.x）內任何新版，含安全修正 | 唯讀的最新版；新功能照樣鎖（產品現有的授權門） |
| 換主版號（2.0） | 要重新授權 | — |
| 在拒絕名單 | 更新站不回應 | — |

更新站因此只判三件事：本機授權檔是不是授權中心簽的、有沒有在拒絕名單、目標版本跟授權檔簽的主版號一不一樣。不看授權到期日。

CRA 對更新的要求怎麼對：

| 條文 | 要求 | 本案怎麼落 |
|---|---|---|
| 第 13 條第 8 項 | 支援期至少 5 年，到期年月賣的時候寫明 | 「五年內永遠可以免費升到同主版號最新版」（D27）；要問顧問 |
| 附件一第二部分第 8 點 | 安全更新免費 | 過期客戶也能升到含修正的最新版（D7） |
| 附件一第二部分第 2 點 | 技術上可行時，安全更新與功能更新分開提供 | **不分**。寫「單一版本線、新功能由授權鎖、分開提供技術上不可行」的理由；要問顧問（D27 第②題） |
| 第 13 條第 9 項 | 每個安全更新至少 10 年可下載 | 每一版都留 10 年（D29） |
| 附件一第一部分第 2 點 c 款 | 適用時安全更新預設自動裝 | **不自動裝**，寫「本類產品不適用」理由（D28）；要問顧問 |
| 附件一第二部分第 8 點 | 安全更新附說明，告訴使用者該做什麼 | 含安全修正的版本，更新說明固定多「影響範圍」「建議動作」兩段 |

**例子**：客戶 C 跑 1.25.1，授權 2026-09 到期沒續，系統進唯讀。2026-11 我們修一個洞，出 1.27.0。客戶 C 的更新中心照樣收到 1.27.0，管理員自己決定要不要按；升上去之後還是唯讀、新功能鎖著，但洞補了。2028 我們出 2.0，客戶 C 看不到，要續約才有。

## 更新站：新版從哪裡來 {#updatesite nav="更新站"}

更新站回答流程第 ① 步的「有沒有新版」，並在第 ③ 步給短效下載網址。離線客戶不經過它，自己上傳，兩條路驗同一套簽名。

### 架構：誰在哪、誰能碰誰

::: diagram
[![架構圖：客戶機房、Cloudflare、我們內網、決策者電腦四區](diagrams/architecture.svg)](diagrams/architecture.svg)

[圖 2 — 架構：四個地方，對外服務上沒有任何私鑰（點圖看大圖）]{.diagram-caption}
:::

- **客戶只連 Cloudflare**，連不到授權中心、CI、188。
- **紅框是私鑰，只有兩個**：授權中心的簽發私鑰在內網，發布金鑰在決策者電腦。對外的更新站手上沒有任何私鑰，被攻破只拿到幾張表與公鑰。
- **授權中心與更新站之間只有一條虛線**：複製公鑰表。沒有 token、沒有 API、沒有連線。
- **2.9GB 的檔不經過更新站**：更新站只回一個短效網址，客戶直接向倉庫拿。

| 角色 | 管什麼 | 手上有什麼 | 被攻破會怎樣 |
|---|---|---|---|
| **授權中心**（現有，內網） | 簽發授權 | 簽發私鑰、客戶名單 | 能偽造授權，**不能**發更新 |
| **更新站**（新，對客戶開放） | 回答「有沒有新版、你能不能拿」（合法安裝、不在拒絕名單、同主版號）、產短效下載網址、記客戶在哪版 | 只有公鑰、倉庫讀權、幾張表 | 能「不給」，不能「給假的」：客戶端驗簽名與雜湊全擋 |
| **私有倉庫** | 存檔、傳檔 | 更新包 | 檔被換掉驗不過 |
| **發布金鑰** | 簽發布清單 | 離線，在決策者電腦 | 不在任何伺服器上 |

**發版流程**：CI 出包後上傳倉庫、在更新站登記為「草稿」，客戶看不到；決策者在自己電腦跑 `guidant-release publish <版>`，用離線發布金鑰簽名後才對外。這是「上版要決策者明示放行」鐵律的程式化。

### 安全原則（照 TUF，做輕量版）

| 原則 | 本案怎麼落 |
|---|---|
| 發布金鑰獨立、離線 | 跟授權簽發鑰是兩把；只在決策者電腦；CI 只能登記草稿；一正一備，備用封存，換鑰用舊鑰背書（D3） |
| 兩個系統互不持有對方憑證 | 攻破一邊，另一邊的能力拿不到 |
| 客戶端不信任更新站 | 只信兩把公鑰，更新站回的每份清單都要過發布公鑰驗章 |
| 不能被卡在舊版 | 發布清單帶簽發時間與 90 天有效期，過期的清單客戶端拒收；要延長就重新蓋章（D31） |
| 拒絕降版 | 只接受版號大於現行版的清單 |
| 只回報必要資訊 | 授權編號、機器指紋、現行版號、升級結果；不送使用資料、不送客戶名稱 |

### 更新站怎麼蓋（D2a：Cloudflare Worker，TypeScript，先用免費方案）

```{.mermaid cap="圖 3 — 選型：每一步問一個問題"}
%%{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
  Q1["對外更新站<br/>客戶每天打，最常被敲"] --> Q2["和授權中心同一台？"]
  Q2 -- 是 --> X1["✗ 入口與簽發私鑰同機<br/>破一個等於破兩個"]
  Q2 -- 否 --> Q3["自己養一台？"]
  Q3 -- 是 --> X2["✗ OS・備份・憑證・DDoS<br/>對外機器長期要守"]
  Q3 -- 否，無伺服器 --> Q4["哪一家？"]
  Q4 -- 新的一家 --> X3["✗ 多一家帳務與除錯"]
  Q4 -- Cloudflare --> Q5["R2・vulndb・文件站都在<br/>同一家多開一功能<br/>用什麼寫？"]
  Q5 -- Python --> X4["✗ Python Worker 仍是測試版<br/>cryptography 進不去"]
  Q5 -- TypeScript --> OK["✓ WebCrypto・D1・R2 原生<br/>真簽章當守衛測試"]
  OK --> R["TS Worker＋D1＋R2<br/>零機器・同一家・正路"]
```

選 Worker 的理由：**零機器**（對外服務最花人力的是守，不是寫）、**同一家**（R2、弱點資料網域、文件站都已在 Cloudflare）、**語言選 TypeScript**（決策者 2026-10-29 改判：Python Worker 仍是測試版，TS 是 Cloudflare 正路，WebCrypto／D1／R2 都原生；發布 CLI 同 repo 用 TS，Node 內建 Ed25519，一個 repo 一套工具鏈）。**費用**：Worker 免費方案每天 10 萬次請求，50 家客戶每天問一次是 50 次，先用免費方案；付費方案每月 5 美元含 1,000 萬次。R2 每 GB 每月 0.015 美元、前 10GB 免費、下載流量不收費，三條線各出修補留 10 年估 300GB 約 4.5 美元。自己養一台對外小機器最便宜也是每月 5 到 10 美元，錢差不多，多的是要守。代價：`cryptography` 套件目前進不去 Worker，驗章要改呼叫 WebCrypto，等於多一份驗章實作；用授權中心真簽出來的檔當測試資料，Worker 驗不過就擋部署，兩份實作被同一組真資料綁住。

## 現況盤點：沿用什麼、補什麼 {#inventory nav="現況盤點"}

| 零件 | 現況 | 本案動作 |
|---|---|---|
| 升級指令 | `install.sh --upgrade` 五步：檢查版本、備份（失敗即中止）、載入映像並驗雜湊、套 migration 並斷言待套 0 支、換版加 180 秒健康檢查；維運子命令已獨立成 `/usr/local/bin/guidantai`（bash、root、隨出貨包更新） | **extend**。`guidantai upgrade` 呼叫的就是它；補停接新工作、先停服務、複本升級、每步時限、進度檔、自動退回、開機檢查、保留上一版映像、前門最後才換。不另寫一套，否則手動與線上兩條路會各自演化 |
| migration 執行 | `scripts/init/migrate.sh` 比對已套清單、依序補套 | **extend**。逐支寫進度 |
| 出貨包 | `guidant-ai-<版>.tar.gz` ＋ `.sha256`，內含映像、VERSION、manifest、bundle-info；1.26.0 壓縮後 2.9GB，其中後端映像佔一半以上，第三方映像（資料庫、快取、檔案庫、分類器、錯誤追蹤）佔其餘 | **reuse**。bundle-info 的「適用起始版號」改成自動算 |
| 簽章 | 授權中心 Ed25519 簽章函式、主產品 `PUBLIC_KEYS` 以 kid 認公鑰 | **reuse 函式**。發布金鑰另一把 |
| 主機端 CLI | `guidantai` 子命令（status／logs／diag…） | **extend**。加 `upgrade`、`recover`；`diag` 的輸出併進診斷包 |
| 前門 | nginx 容器，有 healthcheck；後端掛掉時回 502 | **extend**。加維護頁：後端連不上時改送靜態頁，頁面讀唯讀掛進來的進度檔 |
| 主機 | `install.sh` 要求 systemd（取機器識別碼）；容器 `restart: unless-stopped` 隨 docker 開機起 | **reuse**。更新單觸發器（D26）就是兩個 systemd 設定檔，隨 `install.sh` 裝 |
| 權限 | `common/authz/` 的 capability 軸，主體域守門可用 route decorator | **extend**。加「系統更新」能力，更新中心的下載、更新、取消端點用它守門 |
| Agent 回報 | 送不出的結果只記 log、直接丟掉 | **extend**。先存本機、恢復後補送 |
| 背景工作 | 工作單在資料庫、有租約與寬限秒數 | **reuse**。第 ⑤ 步「停接新工作、等做完」用它 |
| 弱點資料每日同步 | 心跳 observer 每日拉、驗章、連不上靜默略過 | **reuse 形狀**（第 ① 步每日檢查照它做） |
| **缺** | 主系統沒有更新中心頁 | **new**：FE 一頁＋BE 的檢查、下載、驗簽名、讀進度檔端點 |
| **缺** | 對客戶開放的更新站 | **new**（第二期） |

## 待決策 {#decisions nav="待決策"}

沒有待裁項。CRA 兩題決策者 2026-10-29 裁先不管（有答案再回頭看 §15 反悔條件）——原題留著：①我們受不受 CRA 管；②若受管，「不分安全版、不自動裝、過期仍可免費升同主版號最新版」這種寫法過不過。這卡 design 定案、不卡討論稿。

## 業界怎麼做 {#industry nav="業界做法"}

| 誰 | 更新怎麼跑 | 本案借了什麼 |
|---|---|---|
| **GitLab、Sentry、Mattermost、Jira Data Center**（企業自架） | IT 在主機上跑一道指令；產品負責讓那道指令安全：先備份、套 migration、檢查、出事還原；GitLab 公開升級路徑表列出必經版本 | 第一期的形狀：一道絕不出事的指令；必經版本規則 |
| **Debian unattended-upgrades** | systemd 定時器到點自動裝安全更新；管理員可關、可延後 | 只借「排程到點由 systemd 跑」這個形狀（D26）；不借自動裝（D28） |
| **vCenter**（VMware 落地版） | 管理頁「暫存」與「安裝」分開；升級前先備份 | 下載與安裝分開 |
| **TrueNAS、Synology** | 升級前拍檔案系統快照，失敗退回快照 | 升級前留一份能退回的副本。我們管不了客戶主機的檔案系統，所以用複製資料庫代替快照 |
| **Android、ChromeOS** | 新版裝在另一個分割區，起不來自動退回舊分割區 | 在複本上升級、健康檢查不過自動退回 |
| **Nextcloud、WordPress** | 網頁按一下就升級；Nextcloud 官方對正式環境不建議用，WordPress 出事率高 | 反面教材：主系統不自己升級自己 |
| **Wazuh、Elastic Fleet** | 管理端推 Agent 升級包；Agent 版本不可比管理端新；分批、可暫停、失敗自動退回 | Agent 跟主系統走、先推一台 |
| **Replicated**（幫軟體廠商做落地版的平台） | 授權序號拿更新；線上離線同一份包 | 憑授權拿更新。不買：只支援 Kubernetes，我們是 docker compose；Business 方案每月 3,000 美元起再加每授權費 |
| **TUF**（CNCF 畢業的更新安全規範） | 簽章在發布端、驗章在客戶端；金鑰分開離線；清單帶時效；拒絕降版 | 更新站的安全原則 |

業界共識：**下載與安裝分開、升級前留副本、失敗自動退回、Agent 跟著主系統走**。企業市場多半由 IT 敲指令；本案把「敲指令」換成「寫更新單讓 systemd 叫」，主系統拿到的權限沒有變多。本案多做的一件是「在複本上升級」，讓退回不必還原。

## 排除的方案 {#rejected nav="排除方案"}

| 方案 | 為什麼不選 |
|---|---|
| **直接在現在那份資料庫上升級、失敗再還原** | 還原是寫入動作，本身會失敗、會卡；資料越大越久，而且發生在最慌的時候 |
| **升完之後提供回滾** | 回滾一定丟掉開放後的新資料，等於把風險交給客戶。改發修正版 |
| **自動安裝（任何版本）** | 會停機、可能帶 migration；客戶的維護時段與風險由客戶自己定，客戶不更新是客戶的事（D28） |
| **分「安全修補版」與「功能版」兩種** | 我們每版都是修正加功能混著出，分開等於多維護一套發版規則與舊版本線；過期客戶要拿修正改用「可升同主版號最新版」解（D7、D27）。若 CRA 顧問說非分不可再回頭，更新站只多一個「種類」欄 |
| **每年一個長期支援版、支援五年** | 每個資安修補要補五次，人力撐不住（D27） |
| **主系統容器直接操作 docker 換自己** | 容器要拿到 docker 的完整權限，被打穿等於整台主機淪陷；而且主系統壞了就沒人能救 |
| **救援頁（另一個埠＋專用密碼）** | 等於多開一道門，固定密碼不跟著客戶帳號政策走；它要解的情況幾乎都已由自動退回涵蓋（D20） |
| **常駐的更新助手（容器拿 docker 權限，或 Nuitka 獨立執行檔）** | 多一個要維護、要資安審查的常駐程式；它做的四件事（到點跑、寫進度、退回、開機檢查）用現有的 `guidantai` 加 systemd 觸發器就有（D21） |
| **服務停掉之後的取消鈕（短效票）** | 資料庫停了認不了人，要另發票或另開門；那幾分鐘靠時限自動退回就夠（D25） |
| **裝 GitLab Runner、Ansible agent 當執行者** | 能代勞的只有「等到時間、跑腳本」；代價是客戶主機上多一條我們能下任意指令的通道，資安審查過不了 |
| **中心推送進客戶機**（我們用 SSH 連進去裝） | 要客戶開入站或 VPN，資安合規客戶不會接受 |
| **從中斷的那一步接著跑** | 斷在哪沒人說得準；從複本整份重做比接著跑穩 |
| **差異更新包** | 要維護每兩版之間的差異；整包跨版靠 migration 補套就夠 |
| **只換有洞的那顆容器** | 技術上做得到（包裡只放改到的映像、清單寫以哪版為底、升級指令只改那一行），但多一種包的規則要維護與測試；企業自架產品（GitLab、Sentry、Mattermost）安全修補也是整包。客戶數上百或客戶抱怨流量時再回頭做，做法不變 |
| **把更新做進授權中心** | 授權中心可能不對客戶開放；合在一起攻破一個等於兩個（D2） |
| **Harbor 開給客戶 docker pull** | 多養一個對外關鍵服務、每客戶發帳號；BE 映像每版層全變，省不到流量 |
| **買 Replicated** | 只支援 Kubernetes，不適用 docker compose 部署；費用高 |

## 資料模型草案 {#data nav="資料模型"}

**主機上的檔**（都在資料目錄，主系統容器唯讀掛載）：

| 檔 | 誰寫 | 內容 |
|---|---|---|
| 進度檔 `upgrade-progress.json` | 只有 `guidantai upgrade` | 這次更新的編號、從哪版到哪版、路徑（含必經版本的分段）、目前步驟、每步開始時間、時限、結果、錯誤訊息、執行人（主機帳號）、現在那份資料庫與複本的位置、上一版映像標籤 |
| 更新單 `upgrade-order.json` | 主系統（有權限的人按更新時） | 版本、立即或排程時間、誰按的；systemd 觸發器看到就呼叫 `guidantai upgrade`（排程的到點才叫），跑完刪掉。主系統對這個目錄只有寫權 |
| 更新包 | 主系統下載或管理員上傳 | `guidant-ai-<版>.tar.gz` 與簽名；驗過才留 |

**主系統資料表**（主線 migration）：

| 表 | 欄位要點 | 用途 |
|---|---|---|
| `product_update_jobs` | 從版、到版、狀態（downloading／ready／scheduled／running／succeeded／rolled_back／skipped／cancelled）、下載人、按更新的人（帳號＋名稱＋時間、立即或排程）、取消人、來源（線上／離線上傳）、排程時間、開始與結束時間、失敗原因 | 更新中心頁的更新紀錄；結束後從進度檔同步結果 |
| `agent_update_rollouts` | Agent、目標版本、批次、狀態、時間、來源（跟著主系統／單獨推送／Agent 修正版） | 第四期 Agent 分批，含只升 Agent |

更新站的表：`product_releases`（版本、主版號、可直升起始版、雜湊、大小、狀態、**草稿先開給哪幾台內部機器**、是否含安全修正、簽好的清單與印章編號、保留到期）、`instance_version_reports`（授權編號、指紋、現行版本、最近回報、最近升級結果；**沒有客戶名稱**）、`release_download_log`、`license_denylist`、`signing_keys`（印章編號、印樣、狀態：主力／備用／作廢）。

## 不做什麼 {#out nav="不做什麼"}

- 不做救援頁、不開任何新的埠或密碼；不做常駐的更新助手。
- 不做作業系統更新（Flatcar 另案）。
- 不做差異更新包；不做只換單顆容器；每一版都是整包。
- 不做更新頻道（stable／beta）；內部先裝用草稿指定機器。
- 不做升完之後的回滾，改發修正版。
- 不做自動下載與自動安裝，任何版本都一樣：管理員確認後才下載；有權限的人按了才更新（D28）。
- 不分安全修補版與功能版；不維護舊版本線；安全修正就是發下一版（D27）。
- 不做服務停掉之後的取消鈕。
- 不做「從中斷那一步接著跑」。
- 不送使用資料給我們。
- 不談客製化版本客戶的更新路徑（這輪先不談）。
- 災難復原（機器整台壞了在新機重建）另開一案；本案的「升級前快照」可以被它用，但異地備份、定期演練不在本案。

## 先看哪裡 {#next nav="下一步"}

1. 點 `prototype/index.html`：有新版通知、更新中心（下載、離線上傳）、下載中、已就緒（立即或排程）、維護頁進度、自動退回、完成與 Agent、跨版兩段、例外、更新站後台（草稿先開給內部機器、印章）。
2. CRA 兩題先不管（決策者 2026-10-29）。
3. 拍板後落 design.md、拆卡。預估第一期跨 BE（`guidantai`、`install.sh`、`migrate.sh`、出包、nginx 維護頁）與 evidence-agent（結果暫存補送）；第二期新 repo `update-center`（已建、已登記工作坊）與 BE 的 CI；第三期 BE（更新中心端點、系統更新權限、更新單、systemd 觸發器）、FE；第四期 BE、evidence-agent（含只升 Agent 與 Agent 修正版）。
