Ansible Vault 加密 Git 中的密碼與 API token
了解如何用 Ansible Vault 加密 vars 檔案或單一值,區分 staging 與 production,並安全地重新加密與輪替 Git 中的 secrets。
Ansible Vault 保護的內容,以及未涵蓋的範圍
Ansible Vault 會加密 playbook repository 中的 secret,因此 git 儲存的是 ciphertext,而不是純文字密碼。ansible-vault command 可使用你選擇的密碼衍生出的對稱式金鑰,加密整個檔案或檔案中的單一值。執行 play 時,Ansible 會在記憶體中解密該內容,因此該變數的行為與其他變數相同。
這個模型有明確的界線。Vault 只會保護 repository 中靜態儲存的 secret,不會提供其他保護。工作執行後,除非你另外阻止,該值會以純文字存在於記憶體、產生的範本、module 引數及執行輸出中。所有能執行 playbook 的人都持有 vault password,因此 vault 能防止團隊外部的人員取得 secret,但無法提供團隊內部的個人層級存取控制。
如果你尚未撰寫 playbook,請先參閱 第一個針對 VPS 的 Ansible playbook,等該 playbook 需要密碼時再回到這裡。
加密整個檔案,還是單一字串?
ansible-vault encrypt 會以密文取代檔案。檔案會變成一整段 base64 文字,且位於以 $ANSIBLE_VAULT 開頭的標頭列下方。當檔案只包含 secret 時,請使用此方式。
ansible-vault encrypt_string 會加密單一值,並輸出一段 YAML 片段,供您貼入一般的 vars 檔案。變數名稱維持可讀,只有值會是密文。當 secret 與純文字設定並存時,請使用此方式。
日常工作中最重要的差異在於 diff。每次儲存 vault 檔案時,都會使用新的隨機 salt 重新加密,因此密文的每個位元組都會變更。此時 git diff 只會顯示一整段無法讀取的內容被另一段無法讀取的內容取代,審查者無法判斷您是輪替一個密碼,還是重寫了整個檔案。使用 encrypt_string 時,每個 secret 都是純文字檔案中的獨立區塊,因此 diff 會精確顯示變更的變數,檔案其餘部分則保持不變。
inline 形式有一項代價,會在輪替時出現:ansible-vault rekey 不會處理 inline 區塊。當 secret 清單很長且很少變更時,請選擇檔案形式。當檔案同時包含 secret 與一般變數,且您希望 code review 能提供實質資訊時,請選擇 inline 形式。
group_vars 配置清楚標示受保護的內容
Ansible 會載入 group_vars/<group>.yml,也會載入 group_vars/<group>/ 目錄中的每個檔案。目錄形式更適合此用途,因為同一個群組可以並列放置明文檔案與加密檔案。
inventory/
hosts.ini
group_vars/
all/
vars.yml
vault.yml
web/
vars.yml
vault.yml
host_vars/
db01/
vars.yml
vault.yml
playbooks/
site.yml每個 vault.yml 都經過加密。每個 vars.yml 都是明文。讀者無須開啟檔案,就能看出哪些值受到保護,因為檔名已清楚標示。
這個模式的另一半是間接引用。在加密檔案中,為每個變數加上 vault_ 前綴。
vault_db_password: "a real password"
vault_grafana_admin_token: "a real token"接著從旁邊的明文檔案引用這些名稱。
db_password: "{{ vault_db_password }}"
grafana_admin_token: "{{ vault_grafana_admin_token }}"角色與範本使用 db_password,不必知道值的來源,因此能維持 playbook 與角色之間的分離。明文 vars.yml 也可作為可搜尋的索引:grep -r vault_ group_vars/ 會列出儲存庫預期的所有 secret,無須解密任何內容。代價是每個 secret 都多一個名稱,而 vault_ 名稱中的拼字錯誤會在執行時以未定義變數呈現,不會在語法檢查時被偵測。
使用 encrypt_string 加密單一變數
ansible-vault encrypt_string --vault-id prod@~/.ansible/vault-prod.txt \
--stdin-name 'vault_db_password'輸入 secret,然後按下 Ctrl-D。--stdin-name 會從標準輸入讀取值,因此不會將值寫入 shell 歷程記錄檔。另一種形式會將值放在命令列上,shell 會記錄該值:
ansible-vault encrypt_string --vault-id prod@~/.ansible/vault-prod.txt \
'a real password' --name 'vault_db_password'無論使用哪種形式,命令都會輸出 YAML 區塊。請完全依照輸出內容將其貼入 vars 檔案,因為 !vault 標籤下的縮排屬於值的一部分。
vault_db_password: !vault |
$ANSIBLE_VAULT;1.2;AES256;prod
6638643965323633646262656665306333616466396630323136393465356136396436383331
3131303163306665326539353837343663313762616561306534373963383531613664393332!vault 標籤會告知 YAML 載入器,這個 scalar 是密文,而非文字。標頭包含格式版本、cipher,以及用於加密的 vault ID 標籤。未指定 vault ID 加密的值會帶有沒有標籤的 1.1 標頭;這種格式仍可正常使用,只是無法得知密碼的來源。
Vault 密碼存放在哪裡?
存放在 repository 外部。這是唯一沒有例外的規則。
--ask-vault-pass 每次執行只提示一次,且不儲存任何內容。這適合在筆記型電腦上使用,但不適合 cron job 或 CI runner。
密碼檔案是純文字檔,第一行存放密碼。先以嚴格權限建立空檔案,再使用編輯器填入密碼,避免密碼進入 shell 歷程:
mkdir -p ~/.ansible
install -m 600 /dev/null ~/.ansible/vault-prod.txt
$EDITOR ~/.ansible/vault-prod.txt使用 --vault-password-file 讓任何命令讀取該檔案:
ansible-playbook -i inventory/hosts.ini playbooks/site.yml \
--vault-password-file ~/.ansible/vault-prod.txt每個命令都重複指定此旗標很容易忘記,因此請在 repository 根目錄的 ansible.cfg 中設定一次。
[defaults]
inventory = inventory/hosts.ini
vault_password_file = ~/.ansible/vault-prod.txt相同設定也會讀取環境變數 ANSIBLE_VAULT_PASSWORD_FILE,這通常是 CI job 提供密碼的方式。該 job 會從自己的 credential store 讀取密碼,將密碼寫入暫存目錄中的檔案、匯出此變數,並在執行結束時刪除檔案。同時也應將檔名模式加入 .gitignore,因為 ansible.cfg 中的路徑會被提交,而且遲早有人會在 checkout 目錄中建立實際的檔案。
如果密碼檔案具有可執行權限,Ansible 會執行該檔案,並從其標準輸出讀取密碼,而不是將檔案當作文字讀取。如此即可從系統 keyring 或雲端 secret manager 取得 vault 密碼,完全不必將密碼寫入磁碟。透過 --vault-id 使用的 script 另有要求:檔名必須以 -client 結尾,或以 -client 加上副檔名結尾;檔案必須具有可執行權限、必須接受 --vault-id 選項,並且必須將密碼輸出至標準輸出。
兩個 vault ID:staging 與 production
vault ID 是附加在 vault 密碼上的標籤,寫法為 label@source。來源可以是 prompt、密碼檔案的路徑,或用戶端指令碼的路徑。標籤可讓同一個 repository 使用多個密碼儲存 secret,因此 staging 密碼無法開啟 production 檔案。
ansible-vault encrypt --vault-id staging@~/.ansible/vault-staging.txt \
group_vars/staging/vault.yml
ansible-vault encrypt --vault-id prod@~/.ansible/vault-prod.txt \
group_vars/prod/vault.yml傳入此次執行可能需要的每個 ID:
ansible-playbook playbooks/site.yml \
--vault-id staging@~/.ansible/vault-staging.txt \
--vault-id prod@~/.ansible/vault-prod.txt或在 ansible.cfg 中列出一次:
[defaults]
vault_identity_list = staging@~/.ansible/vault-staging.txt, prod@~/.ansible/vault-prod.txt有一項行為常讓人意外。根據預設,標籤是提示,不是鎖定條件。Ansible 會使用目前持有的每個 secret 嘗試解密檔案,直到其中一個成功,因此標記為 staging 的檔案,在 production 密碼碰巧是正確金鑰時仍然可以開啟。在 [defaults] 下設定 vault_id_match = True,或設定環境變數 ANSIBLE_VAULT_ID_MATCH,Ansible 就只會使用標籤與檔案標頭相符的 secret。這項檢查需要 1.2 標頭,因此只適用於原本就使用 vault ID 加密的內容。
載入多個 ID 後,ansible-vault encrypt 無法再判斷要使用哪個密碼進行加密。使用 --encrypt-vault-id prod 指定,或在 ansible.cfg 中設定 vault_encrypt_identity,讓 repository 具備預設值。
這樣做的效益在於限制部署範圍。負責部署 staging 的 CI 工作只會取得 staging 密碼,因此遭入侵的 runner 無法讀取 production 憑證。當你從一台控制機器對 一群 Linux 伺服器執行 play 時,這種隔離可能決定事件規模是有限,還是大幅擴大。
人員離開時重新設定 vault 金鑰
重新設定金鑰會變更 vault 密碼,並使用新密碼重新加密內容。這不會撤銷任何既有存取權。曾持有舊密碼的人,仍可解密他們保留的任何 repository 副本,包括該副本中的所有舊 commit。因此,持有人離開的當下,就應視 vault 密碼已洩露,並依照以下順序輪替。
- 先在伺服器與第三方服務中變更實際的認證資訊。只有這個步驟會真正撤銷存取權。
- 使用
ansible-vault edit將新值寫入 vault 檔案。 - 將每個加密檔案重新設定為使用新的 vault 密碼。
- 透過不經過 repository 的通道,將新的 vault 密碼提供給仍需使用的人員。
ansible-vault rekey --vault-id prod@~/.ansible/vault-prod-old.txt \
--new-vault-id prod@prompt \
group_vars/prod/vault.yml host_vars/db01/vault.ymlrekey 可在單一指令中接受多個檔案,--new-vault-id prod@prompt 則會要求輸入一次新密碼,而不是從磁碟讀取。除非有理由變更標籤,否則請沿用相同標籤,因為該標籤會寫入指令重寫之每個檔案的標頭。
這正是 inline 格式的成本所在。ansible-vault rekey 只會處理完整加密的檔案,因此,位於明文 vars 檔案中的 !vault 區塊會保持不變。請先找出這些區塊,再使用新的密碼搭配 encrypt_string 重新產生每個區塊:
grep -rl '!vault' group_vars/ host_vars/這就是完整的取捨。Inline 區塊可提供容易閱讀的差異,但每次輪替時都需要手動處理。完整加密的檔案可透過單一指令完成輪替,但在檢視時不會提供有用的內容。
為什麼輸出中仍會出現 secret
Vault 在值解密的瞬間就完成工作。Ansible 會回報 task 的結果,而會回顯引數的 module 會將 credential 帶入該回報內容。詳細模式執行、在 template task 上使用 --diff、失敗的 task 傾印其引數,或將輸出寫入檔案的 callback plugin,都可能保留明文。檔案加密無法防止這些情況。
no_log: true 是控制開關。凡是接收 credential 的 task,都應設定此選項。
- name: Write the application environment file
ansible.builtin.template:
src: app.env.j2
dest: /etc/myapp/app.env
owner: myapp
group: myapp
mode: "0600"
no_log: trueAnsible 會隱藏該 task 的結果,因此 log 只會記錄 task 已執行,不會記錄它處理的內容。這對 loop 尤其重要,因為 loop 會針對每個項目回報一個結果,而遍歷 credential 清單的 loop 會回報整份清單。
解密後的 secret 還可能從另外 4 個地方洩漏,且 no_log 無法涵蓋這些情況:
- 從 template 產生的檔案會繼承你指定的
mode和owner。凡是存放 credential 的檔案,都應設定mode: "0600"和明確的 owner,否則 secret 可能會在 target host 上供所有使用者讀取。 - 傳給
ansible.builtin.command或ansible.builtin.shell的 secret 會在 command 執行期間出現在 target host 的 process list 中,任何本機使用者都能讀取。請改用檔案或 environment variable 傳遞。 - Fact caching 會將收集到的 facts 寫入 control machine 的磁碟,因此存放 secret 的 registered variable 可能會進入沒有人視為敏感資料的 cache file。
- 同一個 secret 通常也會存在第二個位置,例如容器讀取的 environment file。該處適用不同規則,避免將 credential 放入 Compose env file 說明了這一部分。
no_log 會讓除錯更加困難,而這正是它的用途。task 發生異常時,可暫時在測試主機上移除它;變更進入 production 前,務必加回。
讀取與編輯加密檔案,且不留下明文
ansible-vault view group_vars/prod/vault.yml 會將檔案解密後傳送至分頁器,不會寫入磁碟。ansible-vault edit 會將檔案解密至暫存檔、開啟你的 $EDITOR,並在關閉檔案時重新加密。這兩者都優先於 ansible-vault decrypt,因為後者會在工作樹中留下明文檔案。意外加入暫存區的解密 vault 檔案,是實際憑證進入公開 repository 最常見的方式。
Git 可以在解密檔案的同時,為完整加密的檔案產生可讀的差異:
git config --local diff.ansible-vault.textconv "ansible-vault view --vault-password-file ~/.ansible/vault-prod.txt"
printf '%s\n' 'group_vars/**/vault.yml diff=ansible-vault' >> .gitattributes啟用前,請先了解其作用。git diff 現在會將 production secret 直接輸出到終端機,這些內容會出現在終端機回捲內容及任何螢幕分享中。這是供單一使用者在單一機器上使用的本機便利功能,因此請將 git config 保持在本機;除非其他使用者也完成相同設定,否則他們的 checkout 行為可能不同。
Vault 不再適用的情況
Vault 是一種每個標籤使用一組密碼的檔案格式,這種設計也決定了它的適用界線。符合以下任一情況時,請改用真正的 secret store。
- 需要依人員設定存取權限。所有執行 playbook 的人都持有相同密碼,而 vault ID 只能依環境區分存取權限,無法依人員區分。
- 需要稽核軌跡。Vault 不會記錄誰在何時解密了哪些內容。
- 需要依排程輪替憑證。Vault 沒有到期時間與版本管理,因此無法得知某組憑證是否已經兩年未變更。
- 應用程式本身需要在執行期間取得 secret。服務在啟動時讀取資料庫密碼時,不應從部署 repository 讀取該密碼。
此時應反轉原本的模式。Ansible 不再儲存 secret,而是透過 lookup plugin,在執行期間從 HashiCorp Vault(另一個名稱相近、容易混淆的產品)、雲端供應商的 secret manager,或 control machine 上的 keyring 取得 secret。repository 儲存路徑,store 儲存值,而 store 保留存取日誌。對小型團隊而言,具備 API 的自架密碼管理器,例如 Vaultwarden 伺服器,也能以較小的規模完成相同工作。
有一組憑證不屬於上述範圍。control machine 用來連線至伺服器的 SSH key 不是 Vault 的問題,因為 Ansible 在執行任何 play 之前就需要它。請使用 agent 搭配 passphrase 管理,方式可參考 SSH key 管理基礎。
FAQ
應該加密整個 vars 檔案,還是只加密 secret 字串?
如果檔案只包含 secret,請加密整個檔案。這樣只需一個命令即可輪替所有 secret,檔案結構也較簡單。如果 secret 與一般變數並存,請使用 ansible-vault encrypt_string。這樣差異只會顯示加密後的值變更,審查者也能看出修改的是哪個變數。取捨在於輪替方式。ansible-vault rekey 會處理整個檔案,但保留內嵌的 !vault 區塊不變。因此,變更密碼後必須手動重新產生這些區塊。
Ansible Vault 密碼檔案應存放在哪裡?
請存放在 repository 外部,並將權限設為 0600,例如存放於 ~/.ansible/vault-prod.txt。使用 --vault-password-file 指定檔案,或在 ansible.cfg 的 [defaults] 下設定 vault_password_file,也可以在環境中設定 ANSIBLE_VAULT_PASSWORD_FILE。在 CI 中,讓工作從自身的 credential store 將密碼寫入暫存檔、匯出該變數,並在工作結束時刪除檔案。如果該檔案具有執行權限,Ansible 會執行它,並從 standard output 讀取密碼。如此即可從 keyring 取得密碼,而不必將密碼儲存在磁碟上。
如何在 staging 與 production 使用不同的 vault 密碼?
使用 --vault-id staging@/path/to/file 和 --vault-id prod@/path/to/file 為每個密碼指定 label,並使用各自的 label 加密每個環境的檔案。在執行時傳入兩個 ID,或將它們列在 [defaults] 下的 vault_identity_list 中。根據預設,Ansible 會依序嘗試所有已載入的 secret,直到其中一個能解密檔案。如果只希望它嘗試檔案標頭中 label 對應的 secret,請設定 vault_id_match = True。載入多個 ID 時,請使用 --encrypt-vault-id 選擇加密所用的 ID。
Ansible Vault 能防止密碼出現在執行輸出中嗎?
不能。Vault 只會保護 repository 中靜態儲存的 secret。任務執行後,該值會成為 plaintext;詳細模式執行或失敗的任務都可能將它寫入 log。請在每個處理 credential 的任務加入 no_log: true,並對範本產生的檔案設定嚴格的 mode 與 owner。此外,請避免將 secret 作為命令引數傳遞,因為命令執行期間,這些引數會顯示在 target host 的 process list 中。