FR-173 · 操作手冊 · 192.168.50.172

Vault 架設手冊

單節點、apt 安裝、內建 Raft 儲存、內網自簽 TLS、稽核 log、Transit Ed25519 簽章鑰不可匯出

從零架一台出版簽章 Vault 的逐步指令,以及重開機、升級、換憑證等日常維運。172 現在裝成什麼樣、還差什麼,看 Vault 現況。給決策者自己登入照抄執行。這份手冊不含任何祕密:解封鑰匙與 root token 只在初始化當下印在你的終端機,要你自己分開存,絕不進 repo、不貼進對話。每一段都附「預期結果」,跑完對一下再往下。GitLab 串接、祕密搬家、CI 簽章 job 不在這份,FR-173 開卡後由卡處理。

主機 192.168.50.172(Ubuntu 26.04) Vault 2.2.1 Community 祕密不進這份文件

0. 主機座標與範圍

項目 值
主機 192.168.50.172,主機名 hashicorp-vault
系統 Ubuntu 26.04.1 LTS(codename resolute),4 vCPU、15 GB、磁碟 48 GB
安裝方式 HashiCorp 官方 apt 套件,systemd 服務,不用 docker
服務位址 https://192.168.50.172:8200(API 與 UI);8201 是叢集埠,單節點只綁本機
誰可以連 8200 188(GitLab runner、build 機)、決策者 Mac;其餘擋掉
資料目錄 /opt/vault/data(Raft)
TLS 目錄 /opt/vault/tls
稽核 log /var/log/vault/audit.log
快照目錄 /opt/vault/snapshots(每天一份,再複製到別台)
簽章鑰 Transit 引擎 transit/,鑰名 guidant-release,Ed25519,不可匯出、不可刪

這份做完的狀態:Vault 跑起來、已初始化並解封、稽核 log 開著、Transit 鑰建好且能簽能驗、每天自動快照。還沒做:GitLab OIDC 角色、KV 祕密、CI 簽章 job、公鑰進 release-keys/(這些是 FR-173 的卡)。


1. 安裝 Vault

ssh jedi@192.168.50.172

wget -O - https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg

echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list

sudo apt update && sudo apt install -y vault jq
vault version

預期:Vault v2.2.1(或更新的 2.x)。套件會建好 vault 系統使用者、/etc/vault.d/、/opt/vault/、vault.service。


2. 內網自簽 TLS 憑證

自己當一張小 CA,簽 Vault 的伺服器憑證;188 與 Mac 之後信任這張 CA。

🔴 不要 cd /opt/vault/tls:安裝套件後這個目錄是 vault 使用者專屬(700),jedi 進不去,cd 會失敗但後面的 sudo openssl 照樣跑,檔案就全寫到你當下所在的目錄(2026-11-01 實際踩過,檔案跑到 /home/jedi/)。下面一律寫完整路徑。

T=/opt/vault/tls
sudo mkdir -p $T

# CA(10 年)
sudo openssl genrsa -out $T/vault-ca.key 4096
sudo openssl req -x509 -new -key $T/vault-ca.key -sha256 -days 3650 -subj "/CN=Guidant Release Vault CA" -out $T/vault-ca.pem

# 伺服器憑證(2 年),SAN 要含 IP 與主機名
sudo openssl genrsa -out $T/vault-key.pem 2048
sudo openssl req -new -key $T/vault-key.pem -subj "/CN=hashicorp-vault" -out $T/vault.csr
printf "subjectAltName=IP:192.168.50.172,IP:127.0.0.1,DNS:hashicorp-vault,DNS:localhost\nextendedKeyUsage=serverAuth\n" | sudo tee $T/vault.ext >/dev/null
sudo openssl x509 -req -in $T/vault.csr -CA $T/vault-ca.pem -CAkey $T/vault-ca.key -CAcreateserial -days 730 -sha256 -extfile $T/vault.ext -out $T/vault-cert.pem

sudo chown -R vault:vault $T
sudo chmod 600 $T/vault-ca.key $T/vault-key.pem
sudo chmod 644 $T/vault-ca.pem $T/vault-cert.pem

# CA 憑證是公開的,放一份 jedi 讀得到的位置給 CLI 用($T 本身 jedi 進不去)
sudo cp $T/vault-ca.pem /etc/vault.d/vault-ca.pem && sudo chmod 644 /etc/vault.d/vault-ca.pem
sudo ls -l $T
sudo openssl x509 -in $T/vault-cert.pem -noout -subject -ext subjectAltName -enddate

預期:SAN 列出 IP Address:192.168.50.172、hashicorp-vault;到期日約兩年後。

🔴 vault-ca.key 是這張 CA 的私鑰:之後換伺服器憑證才用得到。跑完把它複製一份到你的保管處(sudo cat /opt/vault/tls/vault-ca.key 貼到密碼管理器),然後從主機刪掉也可以(sudo shred -u /opt/vault/tls/vault-ca.key),兩年後換憑證時再拿回來。


3. 設定檔

sudo tee /etc/vault.d/vault.hcl >/dev/null <<'EOF'
# ── Guidant AI 出版簽章金庫(192.168.50.172)────────────────────────────
# 架法與每一段為什麼這樣設,見 repo docs/features/FR-173-2611-release-pipeline/vault-setup-runbook.md

# 開網頁管理介面:瀏覽器開 https://192.168.50.172:8200/ui 就能看狀態、登入、查鑰匙。
# 走同一個 8200 埠、同一張 TLS 憑證,不另開埠。
ui = true

# 不鎖定記憶體。Vault 預設會把記憶體鎖住、避免祕密被換頁寫到磁碟;
# 但內建儲存(Raft)用的是記憶體對映檔,兩者一起開會吃光記憶體,官方要求這種儲存一定要關。
# 這台不開 swap 就沒有換頁風險(Ubuntu 預設有小 swap,要的話 sudo swapoff -a 並註解掉 /etc/fstab 那行)。
disable_mlock = true

# 對外公告「找我請打這個網址」。GitLab runner、CLI、網頁介面重新導向都用它,
# 所以要填別台機器連得到的 IP,不能填 127.0.0.1。
api_addr = "https://192.168.50.172:8200"

# 叢集節點之間互相溝通用的位址。這台是單節點,沒有別的節點要連,綁本機就好。
# 日後要加第二台做備援時才改成對外 IP。
cluster_addr = "https://127.0.0.1:8201"

# ── 監聽:Vault 在哪個埠收請求 ───────────────────────────────────────────
listener "tcp" {
  # 收 API 與網頁請求的埠。0.0.0.0 表示所有網卡都聽,誰能連進來由第 4 段防火牆決定
  #(只放 188 與決策者 Mac)。
  address = "0.0.0.0:8200"

  # 叢集埠只聽本機,外面連不到(單節點用不到)。
  cluster_address = "127.0.0.1:8201"

  # 第 2 段簽出來的伺服器憑證與私鑰。所有連線都加密,不准走明文 http。
  tls_cert_file = "/opt/vault/tls/vault-cert.pem"
  tls_key_file  = "/opt/vault/tls/vault-key.pem"

  # 最低只接受 TLS 1.2,擋掉舊版不安全的協定。
  tls_min_version = "tls12"
}

# ── 儲存:鑰匙與設定存在哪 ───────────────────────────────────────────────
# 用 Vault 內建的 Raft 儲存,不必另外裝資料庫。磁碟上的內容全部加密,
# 沒有解封鑰匙打不開;簽章私鑰就存在這裡面。
storage "raft" {
  # 資料目錄。每天的快照(第 10 段)就是從這裡拍的。
  path = "/opt/vault/data"

  # 這個節點的名字,叢集裡辨識用。單節點隨便取但取了就不要改,改了要重建。
  node_id = "release-vault-1"
}
EOF
sudo chown vault:vault /etc/vault.d/vault.hcl && sudo chmod 640 /etc/vault.d/vault.hcl
sudo mkdir -p /opt/vault/data && sudo chown -R vault:vault /opt/vault/data
vault operator diagnose -config=/etc/vault.d/vault.hcl -skip=storage 2>&1 | tail -15

預期:diagnose 最後沒有紅色的 [ failure ];黃色 [ warning ] 若是說 mlock 關掉、或 swap 開著,可以接受(看上面那段說明決定要不要關 swap)。

設定檔的權限 640、擁有者 vault:只有 Vault 服務本身和 root 讀得到,一般使用者看不到檔案內容(雖然這份沒有祕密,但習慣上這樣設)。


4. 防火牆

sudo ufw allow OpenSSH
sudo ufw allow from 192.168.50.188 to any port 8200 proto tcp comment 'GitLab runner / build'
sudo ufw allow from <你的 Mac 的內網 IP> to any port 8200 proto tcp comment 'decision maker'
sudo ufw enable
sudo ufw status numbered

預期:只有 22(全開)與 8200(188、你的 Mac)兩條。Mac 換 IP 時要回來改這條。


5. 啟動

sudo systemctl enable --now vault
sudo systemctl status vault --no-pager | head -5

echo 'export VAULT_ADDR=https://127.0.0.1:8200' >> ~/.bashrc
echo 'export VAULT_CACERT=/etc/vault.d/vault-ca.pem' >> ~/.bashrc
source ~/.bashrc
vault status

預期:服務 active (running);vault status 顯示 Initialized false、Sealed true。

起不來時:sudo journalctl -u vault --no-pager -n 20 看真正的錯誤(status 只顯示最後一行)。連續失敗三次後 systemd 會拒絕再起,修好後要先 sudo systemctl reset-failed vault 再 sudo systemctl start vault。


6. 初始化(只做一次,🔴 只在你的終端機)

vault operator init -key-shares=5 -key-threshold=3

這一步會印出 5 把解封鑰匙(Unseal Key 1~5)與 1 個 Initial Root Token。只會印這一次,之後拿不回來。

🔴 保管規則(照做再往下):

東西 用途 存哪
解封鑰匙 5 把 Vault 每次重開機要用其中任 3 把解封 分開放:例如你的密碼管理器 2 把、公司保險箱或另一個管理員 2 把、離線紙本 1 把。任一處外洩都還差 1 把
Root token 最高權限,只在初次設定用 第 9 步做完就撤銷,不保留
  • 不貼進這個對話、不貼進 Notion、不存進任何 repo 或 188。
  • 清掉終端機捲動紀錄:clear && history -c,並確認沒被其他工具錄下來。

7. 解封與登入

vault operator unseal   # 貼第 1 把,Enter
vault operator unseal   # 貼第 2 把
vault operator unseal   # 貼第 3 把
vault status            # Sealed false

vault login             # 貼 root token

預期:Sealed false、HA Mode active(單節點也是 active)。

以後每次 Vault 主機重開機都要做這一步(拿 3 把解封)。重開後沒解封,CI 簽章 job 會失敗,退回 Mac 備用章。


8. 稽核 log、Transit 簽章鑰

# 稽核 log:每次簽章誰簽、何時簽都記下來
sudo mkdir -p /var/log/vault && sudo chown vault:vault /var/log/vault
vault audit enable file file_path=/var/log/vault/audit.log
vault audit list

# Transit 引擎+Ed25519 簽章鑰(不可匯出、不可刪)
vault secrets enable transit
vault write -f transit/keys/guidant-release type=ed25519
vault read transit/keys/guidant-release

預期:read 結果 type ed25519、exportable false、deletion_allowed false、allow_plaintext_backup false。這三個 false 一個都不能是 true:exportable 一旦打開就關不掉,鑰匙就變成拿得出來。

試簽一次、驗一次:

vault write transit/sign/guidant-release input=$(printf 'hello' | base64)
# 回 signature = vault:v1:<base64>
vault write transit/verify/guidant-release input=$(printf 'hello' | base64) signature=<上一步的 vault:v1:...>
# 回 valid = true

取公鑰(給 FR-173 V-1 算 kid、放進 release-keys/;公鑰不是祕密,可以貼回對話):

vault read -format=json transit/keys/guidant-release | jq -r '.data.keys["1"].public_key'

9. 管理者帳號,撤銷 root token

root token 不該留著。建一個管理者政策與一個 userpass 帳號給你日常用:

vault policy write release-admin - <<'EOF'
path "sys/*"              { capabilities = ["create","read","update","delete","list","sudo"] }
path "auth/*"             { capabilities = ["create","read","update","delete","list","sudo"] }
path "transit/keys/*"     { capabilities = ["read","list","update"] }
path "transit/sign/*"     { capabilities = ["update"] }
path "transit/verify/*"   { capabilities = ["update"] }
path "kv/*"               { capabilities = ["create","read","update","delete","list"] }
EOF

vault auth enable userpass

🔴 以下分段貼,不要整段一起貼。 password=- 是「從標準輸入讀」,不會跳提示、打字會顯示,整段貼時會把後面幾行指令當成密碼吃掉(2026-11-01 實際踩過)。用下面的寫法:輸入不顯示、不進 history。

read -rs -p '新密碼: ' P; echo
printf %s "$P" | vault write auth/userpass/users/jedi password=- policies=release-admin
unset P

看到 Success! Data written to: auth/userpass/users/jedi,把密碼存進 Apple「密碼」。這段重跑會覆寫密碼,忘記密碼時也用它重設(要先用別的管理者或 root 登入)。

🔴 先確認新帳號登得進去,才撤銷 root。 反過來做,root 撤掉後新帳號又登不進去,就只剩解封鑰匙救援(2026-11-01 實際踩過,靠 generate-root 救回)。

vault login -no-store -method=userpass username=jedi > /dev/null && echo 試登成功

看到 試登成功 再往下(-no-store 不會蓋掉目前的 root 登入):

vault token revoke -self
vault login -method=userpass username=jedi > /dev/null
vault token lookup | grep -E "^(policies|display_name) "

> /dev/null 讓 token 不印在畫面上;沒有錯誤訊息就是登入成功。輸出裡有 token、Unseal Key 的行,不要貼到任何地方。

萬一 root 已撤、新帳號又登不進去:

vault operator generate-root -init                       # 記下 OTP 與 Nonce(不要貼出去)
vault operator generate-root -nonce=<Nonce>              # 跑三次,每次一把解封鑰匙;第三次印 Encoded Token
vault operator generate-root -decode=<Encoded Token> -otp=<OTP>   # 印出新的 root token
vault login                                              # 貼它;接著回到本段開頭重做,最後一樣撤銷

預期:用 jedi 登入後 vault token lookup 的 policies 是 [default release-admin]。

帳號名 jedi 是決策者 2026-11-01 指定(與主機帳號同名)。稽核 log 記成 userpass-jedi;有第二個管理員時每人各開一個帳號,不要共用,否則稽核分不出是誰簽的。

root token 撤銷後,要再拿 root 只能用 3 把解封鑰匙跑 vault operator generate-root,這是設計上的保險。


10. 每天快照

Transit 鑰匙拿不出來,快照是唯一的備份。沒快照、主機壞了,這把章就沒了(還有 Mac 備用章,但要出一版換 kid)。

sudo mkdir -p /opt/vault/snapshots && sudo chown vault:vault /opt/vault/snapshots

# 快照用的最小權限 token(只能拿快照)
vault policy write snapshot - <<'EOF'
path "sys/storage/raft/snapshot" { capabilities = ["read"] }
EOF
vault token create -policy=snapshot -period=768h -display-name=daily-snapshot -field=token | sudo tee /etc/vault.d/snapshot.token >/dev/null
sudo chown root:root /etc/vault.d/snapshot.token && sudo chmod 600 /etc/vault.d/snapshot.token

sudo tee /etc/cron.daily/vault-snapshot >/dev/null <<'EOF'
#!/bin/sh
set -e
export VAULT_ADDR=https://127.0.0.1:8200 VAULT_CACERT=/opt/vault/tls/vault-ca.pem
export VAULT_TOKEN="$(cat /etc/vault.d/snapshot.token)"
vault token renew >/dev/null
f=/opt/vault/snapshots/vault-$(date +%Y%m%d).snap
vault operator raft snapshot save "$f"
chown vault:vault "$f"
find /opt/vault/snapshots -name 'vault-*.snap' -mtime +30 -delete
EOF
sudo chmod 755 /etc/cron.daily/vault-snapshot
sudo /etc/cron.daily/vault-snapshot && ls -la /opt/vault/snapshots/

預期:/opt/vault/snapshots/ 有今天的 .snap。本機留 30 天。

🔴 快照要另外複製到別台(主機整台壞掉時才救得回)。快照內容是加密的,沒有解封鑰匙打不開,可以放在一般備份位置。存到哪台、怎麼複製由你決定,決定後回寫這節。

還原(演練用,不要在正式那台做):新機裝好 Vault(第 1~5 步)→ 初始化一次(臨時的)→ vault operator raft snapshot restore -force <檔> → 用原本那 3 把解封鑰匙解封。


11. 188 與 Mac 信任這張 CA

# 在你的 Mac
scp jedi@192.168.50.172:/etc/vault.d/vault-ca.pem ~/.vault-ca.pem
curl --cacert ~/.vault-ca.pem https://192.168.50.172:8200/v1/sys/health
# 回 JSON,"sealed":false

# 在 188(從 Mac 轉一手過去)
scp ~/.vault-ca.pem jedi@192.168.50.188:/tmp/vault-ca.pem
ssh jedi@192.168.50.188 "sudo cp /tmp/vault-ca.pem /usr/local/share/ca-certificates/guidant-release-vault.crt && sudo update-ca-certificates && curl -s https://192.168.50.172:8200/v1/sys/health"

預期:兩邊都拿到 "initialized":true,"sealed":false。


11b. 網頁介面

設定檔的 ui = true 已開,不用另外裝。瀏覽器開 https://192.168.50.172:8200/ui:

  1. 第一次會跳憑證警告(自簽 CA),按「進階」→「繼續」;要永久消掉就照第 11 段把 CA 加進 Mac 鑰匙圈:sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain ~/.vault-ca.pem。
  2. 登入方式(Method)從預設的 Token 改選 Username,帳號 jedi、密碼是 Apple「密碼」裡那組。不用貼任何 token。
左側選單 用來做什麼
Secrets Engines → transit/ → guidant-release 簽章鑰匙。Key Actions 分頁可手動 Sign、Verify;Details 看設定(Exportable、Deletion allowed 應為 No);Versions 看每一版公鑰,懷疑外洩時在這裡 Rotate
Access 帳號與登入方式(userpass/ 底下是 jedi;日後多一個 GitLab 用的 jwt/)
Policies 權限規則,例如 release-admin

網頁上沒有任何按鈕能取出私鑰;稽核 log 網頁看不到,要在 172 上看 /var/log/vault/audit.log。主機重開機後網頁會顯示已封住,在那頁貼 3 把鑰匙也能解封。


12. 裝完之後

裝完把結果記到 Vault 現況:版本、簽章鑰三個 false、公鑰與 kid、快照存哪、還沒做的項目。那一頁是 172 狀態的唯一來源,本手冊只寫步驟。

接著照第 14 段做 FR-173 各批次開工前的設定。


13. 日常維運

情境 做什麼
主機重開 vault operator unseal 三次(3 把鑰匙)或在網頁解封;沒解封 CI 簽章會失敗
忘記 jedi 密碼 用 generate-root 拿臨時 root(第 9 段末),重設密碼後撤銷 root
升級 Vault sudo apt update && sudo apt install --only-upgrade vault && sudo systemctl restart vault,然後解封
伺服器憑證到期(兩年) 拿回 vault-ca.key,照第 2 步重簽 vault-cert.pem,sudo systemctl reload vault
查誰簽了什麼 sudo jq -c 'select(.request.path=="transit/sign/guidant-release") | {t:.time, who:.auth.display_name}' /var/log/vault/audit.log
懷疑鑰匙外洩 vault write -f transit/keys/guidant-release/rotate 產新版本 → 新公鑰進 release-keys/ 出一版 → 舊版本設 min_decryption_version 停用
稽核 log 太大 加 logrotate(之後 FR-173 D-1 補)

14. FR-173 開工前設定

這一段在做什麼:讓 CI 能自己蓋章、自己拿祕密,不用人在 Mac 上操作。Vault 裡要準備四樣東西:

東西 白話 哪一節
章 兩把簽章鑰匙:正式章(客戶機認得,第 8 段已建)、測試章(只有 190 認得) 14.1
祕密 CI 裝套件、推映像、傳檔案、送簽要用的帳密,集中放在 Vault,機器上不再放明文 14.5
權限(policy) 一張「能做哪些事」的清單,例如「能用測試章簽、能讀 Nexus 帳密」 14.2、14.6
身分規則(角色) 「誰來要,就給哪張權限清單」的判斷規則 14.2、14.4、14.6

誰會來要:

  • 人:只有兩個帳號。jedi 是管理員;release-dev 是給 V-1 開發用的,只能蓋測試章(14.2)。
  • CI:沒有帳號密碼。每次跑的時候,GitLab 發一張臨時身分證給它,上面寫著哪個專案、跑的是 tag 還是 branch、誰按下 Run pipeline。Vault 先向 gitlab.com 確認身分證是真的(14.4),再照角色規則決定給哪張權限清單(14.6),job 跑完就失效。

連線方向:job 在 188 上跑,透過內網連 172 的 Vault;Vault 主動向 gitlab.com 抓公鑰來驗證身分證。外面連不進 172,這一段也沒有開任何對外的口。

在 172 用 jedi 登入後一行一行貼(有 read -rs 的那行會等你打字,整段貼會把下一行當密碼吃掉)。先:

export VAULT_ADDR=https://127.0.0.1:8200 VAULT_CACERT=/etc/vault.d/vault-ca.pem
vault login -method=userpass username=jedi > /dev/null

14.1 測試章(派 V-1 前)

做什麼:多建一把只給測試機用的章。測試包用它簽,190 認得,客戶機不認得,所以測試包就算流出去也裝不上客戶機器。

vault write -f transit/keys/guidant-test type=ed25519
vault read transit/keys/guidant-test

預期:與正式章一樣三個 false。測試章只給 190 用,公鑰不進安裝包,也不放 188 STG、189 POC(任何 branch 都能簽測試章,信任它等於信任沒審過的程式碼)。

14.2 開發用「只能簽章」帳號(派 V-1 前)

做什麼:V-1 的小弟在 Mac 上開發蓋章工具時,要真的連 Vault 試簽。開一個權限很小的帳號給它用,不能拿管理員帳號。第一段是權限清單,後三行是建帳號(密碼不會顯示在畫面上)。

只能簽測試章、讀兩把公鑰。正式章只給 CI 的 release-ci 角色(受保護 tag):開發帳號密碼放在 Mac 的 .env,能簽正式章就等於拿到密碼的人簽得出客戶會信的東西。

vault policy write release-sign-dev - <<'EOF'
path "transit/sign/guidant-test"    { capabilities = ["update"] }
path "transit/keys/guidant-release" { capabilities = ["read"] }
path "transit/keys/guidant-test"    { capabilities = ["read"] }
EOF
read -rs P
printf '%s' "$P" | vault write auth/userpass/users/release-dev password=- token_policies=release-sign-dev token_ttl=1h
unset P

在 Mac:

mkdir -p ~/.config/guidant
scp jedi@192.168.50.172:/etc/vault.d/vault-ca.pem ~/.config/guidant/vault-ca.pem

update-center .env 加四行:VAULT_ADDR=https://192.168.50.172:8200、VAULT_CACERT=/Users/<你>/.config/guidant/vault-ca.pem、VAULT_DEV_USERNAME=release-dev、VAULT_DEV_PASSWORD=<剛設的>。

14.3 測試章公鑰只放 190(派批 2 前)

做什麼:機器只認自己 /etc/guidant/release-keys/ 資料夾裡有的公鑰。把測試章的公鑰放到 190,190 才收得下測試包。第一段把公鑰從 Vault 取出存到 Mac,第二段傳到 190。

在 Mac:

ssh jedi@192.168.50.172 'export VAULT_ADDR=https://127.0.0.1:8200 VAULT_CACERT=/etc/vault.d/vault-ca.pem; vault read -format=json transit/keys/guidant-test' \
  | python3 -c 'import sys,json,base64,hashlib;k=base64.b64decode(json.load(sys.stdin)["data"]["keys"]["1"]["public_key"]);kid=hashlib.sha256(k).hexdigest()[:16];der=bytes.fromhex("302a300506032b6570032100")+k;print(kid);open(f"/tmp/{kid}.pub","w").write("-----BEGIN PUBLIC KEY-----\n"+base64.b64encode(der).decode()+"\n-----END PUBLIC KEY-----\n")'

第一行印出測試章 kid(這行要在已登入的終端機跑,或先在 172 登入;公鑰不是祕密)。然後只放 190:

scp /tmp/<kid>.pub jedi@192.168.50.190:/tmp/ && ssh jedi@192.168.50.190 "sudo install -m 644 /tmp/<kid>.pub /etc/guidant/release-keys/<kid>.pub"

188、189 不放:測試章任何 branch 都簽得出來,放了等於讓沒審過的程式碼裝得進 STG、POC。

14.4 讓 Vault 認得 GitLab(派 C-3 前)

做什麼:開一種新的登入方式,叫 jwt,並告訴 Vault 這種身分證是 gitlab.com 發的。做完以後 Vault 才分得出身分證的真假,但還沒有任何權限,要等 14.6 建角色才會給。

vault auth enable jwt
vault write auth/jwt/config oidc_discovery_url=https://gitlab.com bound_issuer=https://gitlab.com

14.5 祕密進 KV(派 C-3 前放 Nexus,派批 2 前放其餘)

做什麼:開一個存祕密的地方,叫 kv,把 CI 要用的五組帳密放進去。CI 跑的時候才臨時讀出來,188 和 GitLab 變數裡就不必再放明文。

vault secrets enable -path=kv kv-v2

值到網頁 https://192.168.50.172:8200/ui → kv → Create secret 貼,不要打在指令列(會留在 shell 歷史):

路徑 欄位
ci/nexus npm_auth(.npmrc 裡 _auth= 那串)、pypi_user、pypi_password
ci/r2 access_key_id、secret_access_key、endpoint
ci/harbor user、password
ci/update-site url、ci_token(register-draft 那把)
ci/license-center api_token、url(Agent 打包送簽用;值是 188 /opt/guidant-ai-agent/.env 的 LICENSE_CENTER_API_TOKEN/LICENSE_ACTIVATION_SERVER_URL,放進 KV 後等 C-2 驗完再從 .env 刪)

14.6 GitLab 角色

做什麼:建兩張權限清單、兩條身分規則,決定 CI 能拿到什麼:

指令 白話
vault policy write ci-release 正式包權限清單:能用正式章簽、能讀全部五組帳密
vault policy write ci-test 測試包權限清單:只能用測試章簽、能讀 Nexus、Harbor、License Center
vault write auth/jwt/role/release-ci 規則:BE 或 Agent 專案、受保護的 v* tag、而且是決策者按下的 → 給正式包權限
vault write auth/jwt/role/test-ci 規則:BE 或 Agent 專案、任何 branch、而且是決策者按下的 → 給測試包權限

FE 的規則 fe-ci(FE 專案任何 job → 只能讀 Nexus)在 C-3 前已經建好。

只有決策者能發版、能跑測試包,靠 GitLab 帳號編號(user_id)擋:GitLab 免費版的受保護 tag/branch 只能選角色、不能指定某個人,而五位成員都是 Maintainer 以上,用角色擋不住。所以由 Vault 認 GitLab job 身分證裡的 user_id(按下 Run pipeline 的那個人)。決策者是 21126655(raymond.jedicotech)。之後要加人:查他的 user_id(glab api users?username=<帳號>),加進下面兩個角色的 user_id 陣列,整塊重貼一次即可,不用改程式、不用重新出版。

vault policy write ci-release - <<'EOF'
path "transit/sign/guidant-release" { capabilities = ["update"] }
path "transit/keys/guidant-release" { capabilities = ["read"] }
path "kv/data/ci/*" { capabilities = ["read"] }
EOF

vault policy write ci-test - <<'EOF'
path "transit/sign/guidant-test" { capabilities = ["update"] }
path "transit/keys/guidant-test" { capabilities = ["read"] }
path "kv/data/ci/nexus" { capabilities = ["read"] }
path "kv/data/ci/harbor" { capabilities = ["read"] }
path "kv/data/ci/license-center" { capabilities = ["read"] }
EOF
vault write auth/jwt/role/release-ci - <<'EOF'
{
  "role_type": "jwt",
  "user_claim": "user_login",
  "bound_audiences": ["https://192.168.50.172:8200"],
  "token_policies": ["ci-release"],
  "token_ttl": "30m",
  "bound_claims": {
    "project_path": ["auditmanager/compliance-manager-be", "auditmanager/evidence-agent"],
    "ref_type": "tag",
    "ref_protected": "true",
    "user_id": ["21126655"]
  }
}
EOF
vault write auth/jwt/role/test-ci - <<'EOF'
{
  "role_type": "jwt",
  "user_claim": "user_login",
  "bound_audiences": ["https://192.168.50.172:8200"],
  "token_policies": ["ci-test"],
  "token_ttl": "30m",
  "bound_claims": {
    "project_path": ["auditmanager/compliance-manager-be", "auditmanager/evidence-agent"],
    "ref_type": "branch",
    "user_id": ["21126655"]
  }
}
EOF

fe-ci(只讀 Nexus、誰推都能跑)已建,不用重貼。

預期:vault list auth/jwt/role 列出 fe-ci、release-ci、test-ci(14.6b 建完再多一個 classifier-ci)。

為什麼用 JSON 寫:bound_claims='{...}' 會被當字串送出,Vault 回 expected a map, got 'string'。

為什麼測試包也拿得到 License Center token:BE、Agent 打包都要把檔案清單送 License Center 簽(BE build_release.sh、Agent build_all.sh),沒簽打不出映像;這把鑰客戶端也認,所以測試包和正式包一樣只開給名單上的人。

殘餘風險:user_id 是「誰按下 Run pipeline」,不是「誰寫的程式」。在別人推的 branch 上按測試包,簽的就是那個 branch 的程式碼;按之前看清楚跑的是哪個 commit。

為什麼不再加 ref_protected:受保護 branch 在免費版也只能選角色,五位成員都是 Maintainer 以上,加了擋不到任何人;真正的關卡是 user_id。測試包要 License Center token 是因為沒簽的產物打不出映像,拿掉就出不了測試包。

同一把 token 目前還有更大的口子:188 上 /opt/guidant-ai-agent/.env、/opt/guidant-ai-be/.env 都明文放著 License Center token,而 188 runner 以 jedi 身分跑,任何在 188 跑的 job(包括 C-3 之後 FE 每次 push 的檢查)讀得到這兩個檔。所以第二批 CI 驗完後,刪 188 上這些明文、再到 License Center 換一把新 token 放進 Vault,Vault 這道限制才真的有效。在那之前,風險與現況相同。

14.6b jedi 套件的角色(分類器映像推 Harbor)

做什麼:jedi 的分類器映像由 BE 觸發,跑在 jedi 的 branch 上,release-ci(只認受保護 tag)登不進去;放寬 release-ci 又會讓任何 branch 蓋得出正式章。所以另建一個只給 jedi 用、只能讀 Harbor 帳密、不能蓋章的角色。

vault policy write ci-classifier - <<'EOF'
path "kv/data/ci/harbor" { capabilities = ["read"] }
EOF
vault write auth/jwt/role/classifier-ci - <<'EOF'
{
  "role_type": "jwt",
  "user_claim": "user_login",
  "bound_audiences": ["https://192.168.50.172:8200"],
  "token_policies": ["ci-classifier"],
  "token_ttl": "30m",
  "bound_claims": {
    "project_path": "commonservice3/jedi-python-package",
    "user_id": ["21126655"]
  }
}
EOF

原則:每個 repo 的 CI 一個自己的角色,權限只開它要的。角色綁專案路徑,祕密照用途分開讀;要蓋正式章的只有 release-ci,而且只認受保護 tag。新增 repo 要接 Vault 時,比照這段另建角色,不要把它塞進既有角色。

14.7 GitLab 專案設定

做什麼:在 GitLab 網頁上做兩件事。第一,把 v* tag 設成受保護,只有 Maintainer 能建,這樣 14.6 的 ref_protected 規則才有意義。第二,允許 Agent 的 CI 下載 FE 打包好的主控台前端。

  • BE、Agent 專案:Settings → Repository → Protected tags → 加 v*,Allowed to create 選 Maintainers(免費版只能選角色;「只有決策者」由 14.6 的 user_id 擋)。
  • FE 專案:Settings → CI/CD → Job token permissions → 加 auditmanager/evidence-agent(Agent CI 要下載 FE 的主控台前端產物)。
  • 188 照第 11 段信任 CA(2026-11-03 裝上;FE CI 第一次實跑前沒裝,curl 報 SSL certificate problem 即此原因)。