前言

當你管理的機器從 1 台變成 50 台之後,SSH 金鑰一定會讓你半夜驚醒:

  • 「有個工程師離職了,他的 public key 到底散在哪幾台機器的 authorized_keys 裡?我要一台一台去刪嗎?」
  • 「跳板機(bastion / control node)上放了一把萬能私鑰,只要那把鑰匙外洩,整個機房就全裸了。」
  • 「我只想讓 CI 在『部署的那 5 分鐘』內能連進去,但傳統金鑰一放上去就是永久有效,根本沒有『到期』這回事。」

這些痛點的共通根源是:傳統 SSH 是「一把鑰匙配一把鎖」的信任模型——你得把每一把公鑰(public key)事先塞進每一台目標主機。機器一多,這件事就變成災難。

這篇文章要介紹一個更聰明的解法:把 HashiCorp Vault 當成一個「SSH 憑證中心(CA, Certificate Authority)」

🔑 CA(Certificate Authority,憑證簽發中心)是什麼? 你可以把它想成「機場的證件核發櫃檯」。目標主機不再認得每一位旅客(每一把公鑰),它只認得「這張登機證是不是機場官方蓋章的」。只要 Vault 蓋過章,主機就放行——而且這張登機證還有登機時間限制(TTL),過期自動作廢。

讀完這篇,你會學到:用 Vault 簽發短命(例如 5 分鐘) 的 SSH 憑證,讓跳板機不必再存永久金鑰,還能針對不同權限需求發不同的憑證。這套架構和 [[在 k3s 上手把手打造 Prometheus + Grafana 監控系統|Kubernetes]] 環境也能無縫搭配(本文最後會談到)。

核心概念:從「認鑰匙」到「認簽章」

先建立心智模型。傳統 SSH 與 Vault SSH CA 的差別,一張圖就看懂:

flowchart LR
    subgraph 傳統做法["😵 傳統:每台主機都要放你的公鑰"]
        U1[你的 public key] -->|事先手動複製| H1[主機 A
authorized_keys] U1 -->|事先手動複製| H2[主機 B
authorized_keys] U1 -->|事先手動複製| H3[主機 C
authorized_keys] end subgraph CA做法["😎 Vault CA:主機只信任『一個簽章者』"] V[Vault CA] -.->|主機只放 Vault 的 public key 一次| T1[主機 A] V -.->|同一把| T2[主機 B] V -.->|同一把| T3[主機 C] end

關鍵差異:CA 做法下,目標主機只需要放一次 Vault 的 CA 公鑰(TrustedUserCAKeys),之後不管你發幾張憑證、給幾個人,主機都不用再改設定。人員異動?只要在 Vault 撤銷/停發即可,不必碰任何一台主機。

一次連線的完整旅程

再看「實際連線時」發生什麼事。假設「跳板機(control node)」想連到「目標主機」:

sequenceDiagram
    participant C as 跳板機
(control node) participant V as Vault
(CA) participant T as 目標主機
(target) Note over C: 我手上有一對 key pair
(private + public) C->>V: ① 帶著 public key 求簽
(附上身分證明) V-->>C: ② 回傳 signed cert
(TTL 只有 5 分鐘) C->>T: ③ 帶 private key + cert 連線 Note over T: 我信任 Vault CA,
檢查這張 cert 是它蓋的章嗎?
過期了沒? T-->>C: ④ 驗章通過 → 允許登入 ✅

用一句話總結整條流程:

你的 key pair(本地)→ 把 public key 送去 Vault 簽 → Vault 回傳 signed cert(TTL 5 分鐘) → SSH Server 看到 cert,確認是 Vault CA 簽的且沒過期 → 允許登入。

有三個名詞先在這裡解釋清楚,後面就不卡了:

名詞 白話解釋
key pair(金鑰對) 你用 ssh-keygen 產生的一對鑰匙:private key(私鑰,自己藏好)與 public key(公鑰,可以公開)。
signed cert(簽署憑證) Vault 拿你的 public key「加工」出來的一張證件,裡面寫著「誰可以登入、能登入誰、有效到幾點」,並蓋上 Vault 的簽章。
TTL(Time To Live) 憑證的壽命。設 5 分鐘,就代表這張證件 5 分鐘後自動失效,就算被偷走也沒用。

準備工作:Vault 與角色設計

本文假設你已經有一個跑起來的 Vault(dev 模式即可,vault server -dev,預設監聽 http://127.0.0.1:8200)。我們要打造的角色叫 deploy-service——一個負責自動部署的服務,它需要在每次部署前跟 Vault 要一張短命憑證去連目標機執行 SSH Command。

整體會用到兩個 Vault 元件,先有個印象:

flowchart LR
    A["SSH Secrets Engine
(負責簽 SSH cert)"] B["AppRole Auth Method
(負責驗證『你是誰』)"] B -->|換到 token| C[deploy-service 的 token] C -->|拿 token 去簽 cert| A A -->|回傳 signed cert| D[拿去連 SSH]
  • SSH Secrets Engine:Vault 裡真正「當 CA、簽 SSH 憑證」的模組。
  • AppRole Auth Method:讓「機器/服務」不用人工打帳密,改用 role_id + secret_id(像帳號+密碼)換一個 token 來跟 Vault 溝通。

💡 為什麼用 AppRole 而不是直接用 root token? 因為 root token 權力太大、又不會過期,放在 CI 或服務裡等於自殺。AppRole 讓你能發一個「只能簽 deploy-service 這條路徑」的最小權限身分,這才是 production 的正確姿勢。

逐步範例

我們分四大階段:① 啟用 SSH CA → ② 設定 AppRole 身分 → ③ 用 curl 手動跑通一次 → ④ 讓目標主機信任 CA。每一步都有可以直接貼上跑的指令。

📌 全文以 curl 直打 Vault API 示範(最透明,看得到每個環節)。UI 能做的地方我也會標出路徑,但 AppRole 的 Role 管理只能用 API/CLI(UI 不支援)。

階段一:啟用並設定 SSH Secrets Engine

Step 1:啟用 SSH Secrets Engine

我們把它掛在自訂路徑 ssh/ssh-client-signer 底下(路徑名稱可自取,之後所有 API 都會用到它):

1
2
3
4
5
6
# UI 路徑:Vault → Secrets → Enable new engine → SSH
# 這裡示範用 curl,把 SSH engine 掛在 ssh/ssh-client-signer
curl --header "X-Vault-Token: $VAULT_TOKEN" \
--request POST \
--data '{"type": "ssh"}' \
http://127.0.0.1:8200/v1/sys/mounts/ssh/ssh-client-signer

Step 2:產生這個 engine 的 CA 金鑰

CA 自己也需要一對金鑰(它就是用這把 private key 去「蓋章」)。讓 Vault 自動幫我們產生:

1
2
3
4
curl --header "X-Vault-Token: $VAULT_TOKEN" \
--request POST \
http://127.0.0.1:8200/v1/ssh/ssh-client-signer/config/ca
# 回傳裡的 public_key 就是等下要放到目標主機上的那把「CA 公鑰」

Step 3:建立 SSH Role「deploy-service」

Role 定義「這種憑證長什麼樣、能做什麼」。這是安全的核心,逐欄位看:

1
2
3
4
5
6
7
8
9
10
11
12
curl --header "X-Vault-Token: $VAULT_TOKEN" \
--request POST \
--data '{
"key_type": "ca",
"allow_user_certificates": true,
"allowed_users": "root,deploy",
"default_user": "deploy",
"ttl": "5m",
"max_ttl": "30m",
"default_extensions": { "permit-pty": "" }
}' \
http://127.0.0.1:8200/v1/ssh/ssh-client-signer/roles/deploy-service
欄位 說明
key_type: ca 這個 Role 是「CA 簽發模式」(另一種是幫你託管私鑰,本文不用)。
allow_user_certificates 允許簽「使用者憑證」(登入用),對應下面 cert_type: user
allowed_users 白名單:這張憑證只允許以 rootdeploy 這兩個 OS 使用者身分登入。這就是「針對權限需求發不同憑證」的關鍵——想給更小權限,就開一個 Role 只允許 deploy
default_user 沒指定時預設用哪個使用者。
ttl / max_ttl 憑證預設 5 分鐘、最長可要到 30 分鐘。短命是安全的靈魂
default_extensions 憑證的能力。permit-pty = 允許互動式 terminal;不給的話連 shell 都開不了(適合只想跑單一指令的場景)。

🎯 一個 Role = 一種權限等級。你可以再開 readonly-serviceallowed_users: "readonly"、不給 permit-pty),或 dba-service(只允許 postgres 使用者)。權限切分全靠多開幾個 Role,而目標主機完全不用改。

階段二:設定 AppRole,讓服務能自己拿身分

現在讓 deploy-service 這個服務,能用「帳號+密碼」自動跟 Vault 換 token。

Step 4:啟用 AppRole Auth Method

1
2
3
4
5
# UI 路徑:Vault → Access → Auth Methods → Enable new method → AppRole
curl --header "X-Vault-Token: $VAULT_TOKEN" \
--request POST \
--data '{"type": "approle"}' \
http://127.0.0.1:8200/v1/sys/auth/approle

Step 5:建立一個「最小權限」Policy

Policy(權限政策)決定「這個身分能碰 Vault 的哪些路徑」。我們只給它一件事——簽 deploy-service 的 SSH cert:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# Policy 內容(HCL 格式):只允許對「簽 deploy-service cert」的路徑做 create/update
# 路徑格式為 {mount_path}/sign/{role_name}
# mount_path = ssh/ssh-client-signer(Step 1 掛載的路徑)
# role_name = deploy-service(Step 3 建立的 Role)
cat > deploy-service-policy.hcl << 'EOF'
path "ssh/ssh-client-signer/sign/deploy-service" {
capabilities = ["create", "update"]
}
EOF

# 寫入 Vault
curl --header "X-Vault-Token: $VAULT_TOKEN" \
--request POST \
--data "{\"policy\": \"$(cat deploy-service-policy.hcl)\"}" \
http://127.0.0.1:8200/v1/sys/policies/acl/deploy-service-policy

⚠️ 常見踩雷:Policy 裡的路徑 ssh/ssh-client-signer/sign/deploy-service 必須跟你 Step 1 的 mount 路徑一字不差。如果你當初掛在別的路徑(例如只有 ssh/),這裡就要跟著改成 ssh/sign/deploy-service,否則簽 cert 時會被拒(permission denied)。

Step 6:建立 AppRole 並綁上 Policy

1
2
3
4
5
6
7
8
9
10
curl --header "X-Vault-Token: $VAULT_TOKEN" \
--request POST \
--data '{
"token_policies": ["deploy-service-policy"],
"token_ttl": "1h",
"token_max_ttl": "4h",
"secret_id_ttl": "0",
"secret_id_num_uses": 0
}' \
http://127.0.0.1:8200/v1/auth/approle/role/deploy-service
參數 說明
token_policies deploy-service-policy 換來的 token 綁上剛剛那個最小權限 policy。
token_ttl 1h AppRole 換來的 token 有效 1 小時。
token_max_ttl 4h token 續期(renew)最長能撐到 4 小時。
secret_id_ttl 0 secret_id 永不過期。這是 dev 方便設定;production 請務必設時限。
secret_id_num_uses 0 secret_id 可無限次使用;production 建議設成有限次數。

🧠 Token 類型小知識:AppRole 換來的 token 預設是 default-service 型——可以續期(renew)、有父子關係。這正是我們要的,因為 deploy-service 是長期運行的服務,token 需要能在有效期內延長,不然每小時就得用 role_id + secret_id 重換一次。另一種 batch 型輕量但不能 renew,適合高頻、短命的一次性請求,這裡不適用。

Step 7:取出 role_id 與 secret_id

把這兩個值存進服務的 .envrole_id 像「帳號」(固定不變),secret_id 像「密碼」(可撤銷、可輪替):

1
2
3
4
5
6
7
8
9
10
11
# 取得 role_id(固定的,像帳號)
curl --header "X-Vault-Token: $VAULT_TOKEN" \
http://127.0.0.1:8200/v1/auth/approle/role/deploy-service/role-id
# → data.role_id 例如 "46aee89a-6dba-3fbb-146b-4e7c947c6e3b"

# 產生 secret_id(像密碼,可產生多組)
curl --header "X-Vault-Token: $VAULT_TOKEN" \
--request POST \
http://127.0.0.1:8200/v1/auth/approle/role/deploy-service/secret-id
# → data.secret_id 例如 "edde11f3-fb10-d848-a03d-bc0f0ff78502"(登入用,存好!)
# → data.secret_id_accessor 例如 "68f53dc2-36b8-543f-a04c-020e705ebd9c"(撤銷用,也存好)

🔐 兩個值分工要記牢:登入時用 secret_id(不是 accessor)。secret_id_accessor 是「把手」,平常查詢、萬一 secret_id 外洩時可以拿它來撤銷該把密碼(而不會意外印出真正的祕密)。想知道某個 Role 目前有幾組 secret_id,就用 LIST 方法查——它只會回傳一串 accessor,不會洩漏真祕密。

階段三:用 curl 手動跑通一次

正式接進服務前,先手動把「換 token → 簽 cert → 看 cert」跑一遍,確認每個環節都對。

Step 8:用 role_id + secret_id 換 token

1
2
3
4
5
6
7
8
curl --request POST \
--data '{
"role_id": "'"$VAULT_ROLE_ID"'",
"secret_id": "'"$VAULT_ROLE_SECRET"'"
}' \
http://127.0.0.1:8200/v1/auth/approle/login
# 回傳的 auth.client_token 就是接下來簽 cert 要用的 token
# 例如 "hvs.CAESIKzNQeyTaR98Mz_ekDIFLJlw8Sq..."

🐚 上面用了 '"$VAR"' 這種寫法把 shell 變數插進單引號 JSON——因為單引號內變數不會展開。把 client_token 存成環境變數 VAULT_ROLE_TOKEN 給下一步用。

Step 9:產生一對測試金鑰

1
2
3
ssh-keygen -t ed25519 -f /tmp/test_key -N ""
# 產生 /tmp/test_key(私鑰)與 /tmp/test_key.pub(公鑰)
# -N "" 代表不設 passphrase,方便測試

🔎 為什麼選 Ed25519?它比傳統 RSA 更短、更快、更安全,是現代 SSH 的首選。

Step 10:用 token 向 Vault 簽 cert

把公鑰送去 sign/deploy-service,要一張使用者憑證(cert_type: user):

1
2
3
4
5
6
7
8
9
curl --header "X-Vault-Token: $VAULT_ROLE_TOKEN" \
--request POST \
--data "{
\"public_key\": \"$(cat /tmp/test_key.pub)\",
\"cert_type\": \"user\"
}" \
http://127.0.0.1:8200/v1/ssh/ssh-client-signer/sign/deploy-service
# 成功的話,回傳 data.signed_key 就是簽出來的 cert:
# "signed_key": "ssh-ed25519-cert-v01@openssh.com AAAAIHNzaC1lZDI1NTE5...==\n"

簽 cert 的 API 路徑要牢記這個公式:

1
2
3
/v1/{mount_path}/sign/{SSH Role Name}
↑ ↑
Step 1 掛載的路徑 Step 3 建立的 Role 名稱

Step 11:把 cert 存檔並檢查內容

永遠要驗一下 Vault 到底簽了什麼給你——尤其是 Valid(有效時間)、Principals(能登入哪些使用者)、Extensions(有沒有 permit-pty):

1
2
3
4
5
# 把 signed_key 內容存成 cert 檔(OpenSSH 慣例:<key 名>-cert.pub)
echo "<把上面回傳的 signed_key 內容貼這>" > /tmp/test_key-cert.pub

# 查看 cert 詳細內容
ssh-keygen -L -f /tmp/test_key-cert.pub

你會看到類似這樣的輸出,確認每一項都符合 Step 3 的設定:

1
2
3
4
5
6
Type: ssh-ed25519-cert-v01@openssh.com user certificate
Valid: from 2026-07-12T15:00:00 to 2026-07-12T15:05:00 ← TTL 5 分鐘 ✅
Principals:
deploy ← 對應 allowed_users ✅
Extensions:
permit-pty ← 能開 terminal ✅

階段四:讓目標主機信任 Vault CA

最後一哩路:目標主機得知道「Vault 這個 CA 值得信任」。這一步在每台目標主機上只需做一次

Step 12:取得 Vault CA 的公鑰

這是個公開端點,不需 token:

1
2
3
curl http://127.0.0.1:8200/v1/ssh/ssh-client-signer/public_key
# 輸出類似:ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA...
# 這就是要放到目標主機上的「CA 公鑰」

Step 13:在目標主機上信任這個 CA

1
2
3
4
5
6
7
8
9
# 1. 把 CA 公鑰放到主機上
echo "<上一步拿到的 CA 公鑰>" > /etc/ssh/vault_ca.pub

# 2. 告訴 sshd「凡是這個 CA 簽的憑證都信任」
echo "TrustedUserCAKeys /etc/ssh/vault_ca.pub" >> /etc/ssh/sshd_config

# 3. 重啟 sshd 讓設定生效
systemctl restart sshd
# 若是在 Docker 容器裡,通常用:service ssh restart

🚨 超級大坑:TrustedUserCAKeys 不能重複設定來「累加」多個檔案!

很多人以為可以這樣分開寫兩個 CA:

1
2
TrustedUserCAKeys /etc/ssh/ca1.pub
TrustedUserCAKeys /etc/ssh/ca2.pub

sshd 不會同時載入兩個檔案。 這個選項不是「可累加的清單」,重複出現時只有其中一個會生效,另一個被默默忽略——你會在「明明加了 CA 卻登不進去」的鬼打牆裡耗掉一整個下午。

正解:把多把 CA 公鑰塞進同一個檔案

1
2
3
4
5
6
7
8
9
# 一個檔案,一行一把 CA 公鑰,OpenSSH 會把每一把都當成可信任的 User CA
cat > /etc/ssh/trusted-user-ca-keys.pem << 'EOF'
ssh-ed25519 AAAAC3NzaC1... CA1
ssh-ed25519 AAAAC3NzaC1... CA2
ssh-rsa AAAAB3NzaC1... CA3
EOF

# sshd_config 永遠只指向這一個合併檔
# TrustedUserCAKeys /etc/ssh/trusted-user-ca-keys.pem

或者直接用 cat 合併現有的多個 .pub

1
cat ca1.pub ca2.pub ca3.pub > /etc/ssh/trusted-user-ca-keys.pem

Step 14:用 cert 登入!

見證奇蹟的時刻。私鑰和 cert 都要帶上,SSH client 會自動配對使用:

1
2
3
4
ssh -i /tmp/test_key \
-i /tmp/test_key-cert.pub \
-p 2222 deploy@localhost \
-o StrictHostKeyChecking=no

✅ 登入成功——而且你從沒把任何 public key 放進目標主機的 authorized_keys。目標主機只認得 Vault 的簽章,其餘一概不管。5 分鐘後這張 cert 自動失效,就算私鑰+cert 被人整包偷走,也活不過午休。

進階:在 Kubernetes 裡怎麼玩

如果你的目標主機(或 SSH 服務)跑在 [[在 k3s 上手把手打造 Prometheus + Grafana 監控系統|Kubernetes]] 上,「信任 CA」這一步會遇到一個現實問題:多個團隊各有自己的 CA 公鑰 ConfigMap,但 TrustedUserCAKeys 又只能吃一個檔案(見上面的大坑)。

慣用解法是:在 initContainer 或 entrypoint 裡,開機時把所有團隊的 CA 公鑰合併成一個檔案

1
2
# initContainer / entrypoint 內:把掛進來的所有 *.pub 合成一份
cat /ca/*.pub > /etc/ssh/trusted-user-ca-keys.pem

sshd_config 永遠只寫死一行,乾淨俐落:

1
TrustedUserCAKeys /etc/ssh/trusted-user-ca-keys.pem

這樣一來,新增一個團隊 = 多掛一個 ConfigMap 進 /ca/,重啟 Pod 即可,sshd_config 一個字都不用動。

完整範例:一個服務的部署流程長怎樣

把前面所有片段串起來,deploy-service 每次要 SSH 進目標機時,實際跑的就是這條 pipeline:

flowchart LR
    A[服務啟動] --> B["用 role_id + secret_id
換 token"] B --> C["每次連線前,用 token
簽一張 cert (TTL 5min)"] C --> D["帶 private key + cert
SSH 進目標主機"] D --> E["目標主機驗 Vault 簽章
→ 放行"] E -.->|5 分鐘後 cert 自動失效| F[歸零,下次重簽]

濃縮成一句話收尾:

deploy-service 啟動 → 用 role_id + secret_id 向 Vault 換 token → 每次 SSH 連線前,用 token 向 Vault 簽 cert(TTL 5 分鐘)→ 拿 cert + private key 連 SSH Node → 主機驗章放行 → cert 過期自動歸零。

結語

我們從「每台主機都要塞公鑰」的傳統煉獄,一路走到「主機只信任一個 CA、憑證 5 分鐘自動作廢」的現代架構。重點回顧:

  • 信任模型翻轉:目標主機從「認每一把鑰匙」變成「認一個簽章者(Vault CA)」,靠 TrustedUserCAKeys 設定,每台只需做一次。
  • 短命憑證(TTL):憑證幾分鐘就失效,大幅壓縮金鑰外洩的傷害窗口。
  • 權限即 Role:想切分權限,就多開幾個 SSH Role(不同 allowed_users、不同 extensions),目標主機完全不用改。
  • 服務用 AppRole:機器用 role_id + secret_id 換最小權限 token,杜絕 root token 亂放。
  • 記得那個大坑TrustedUserCAKeys 不能累加,多把 CA 一律合併成單一檔案。

下一步可以動手做的:把 deploy-service 的簽 cert 邏輯包進你的 CI/部署腳本、把 secret_id 改成有時限+有限次數(production 必做)、或替不同團隊各開一個最小權限 Role。跟散落各處、永不過期的 authorized_keys 說再見吧!