SSD Nodes Learn 🎉 VPS từ $5.50/tháng
Hướng dẫn Matt ConnorBởi Matt Connor

Ansible Vault: Mã hóa secret trong Git an toàn

Học cách dùng Ansible Vault để mã hóa file vars hoặc một chuỗi inline, tách staging và production, đồng thời rekey secret sạch sẽ trong Git.

Ansible Vault bảo vệ gì và không bảo vệ gì

Ansible Vault mã hóa secret trong repository của playbook, vì vậy git lưu ciphertext thay vì password dạng plaintext. Lệnh ansible-vault có thể mã hóa toàn bộ file hoặc một giá trị riêng trong file, bằng symmetric key được tạo từ password bạn chọn. Ansible giải mã nội dung đó trong memory khi play chạy, nên variable hoạt động như mọi variable khác.

Mô hình này có một giới hạn rõ ràng. Vault chỉ bảo vệ secret khi đang được lưu trong repository và không bảo vệ thêm gì khác. Khi task chạy, giá trị ở dạng plaintext trong memory, trong template đã render, trong module arguments và trong output của lần chạy, trừ khi bạn ngăn việc hiển thị này. Mọi người có thể chạy playbook đều phải có vault password, nên vault giúp giữ bí mật với người bên ngoài team, chứ không cung cấp access control theo từng người trong team.

Nếu bạn chưa viết playbook, hãy bắt đầu với playbook Ansible đầu tiên để quản lý một VPS, rồi quay lại khi playbook đó cần password.

Mã hóa cả file hay một chuỗi đơn?

ansible-vault encrypt thay thế một file bằng ciphertext. File trở thành một khối văn bản base64 duy nhất bên dưới một dòng header bắt đầu bằng $ANSIBLE_VAULT. Dùng cách này khi file chỉ chứa secret.

ansible-vault encrypt_string mã hóa một giá trị và in ra một đoạn YAML để bạn dán vào file vars thông thường. Tên biến vẫn đọc được, chỉ giá trị là ciphertext. Dùng cách này khi secret nằm cùng các thiết lập dạng plaintext.

Điểm khác biệt quan trọng trong công việc hằng ngày là diff. Mỗi lần lưu, vault file được mã hóa lại bằng salt ngẫu nhiên mới, nên mọi byte của ciphertext đều thay đổi. Khi đó, git diff chỉ hiển thị một khối không thể đọc bị thay bằng một khối không thể đọc khác. Reviewer không thể biết bạn chỉ xoay vòng một password hay đã ghi lại toàn bộ file. Với encrypt_string, mỗi secret là một khối riêng trong file plaintext. Vì vậy, diff hiển thị chính xác biến nào đã thay đổi và giữ nguyên phần còn lại của file.

Dạng inline có một điểm bất tiện, xuất hiện khi xoay vòng secret: ansible-vault rekey không tác động đến các khối inline. Chọn dạng file khi danh sách secret dài và hiếm khi thay đổi. Chọn dạng inline khi file chứa cả secret lẫn các biến thông thường và bạn muốn code review có ý nghĩa.

Bố cục group_vars cho biết giá trị nào được bảo vệ

Ansible tải group_vars/<group>.yml và cũng tải mọi file bên trong thư mục group_vars/<group>/. Dạng thư mục là lựa chọn phù hợp, vì nó cho phép một group chứa đồng thời file plaintext và file đã mã hóa.

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

Mọi vault.yml đều được mã hóa. Mọi vars.yml đều là plaintext. Người đọc có thể biết giá trị nào được bảo vệ mà không cần mở file, vì tên file đã thể hiện điều đó.

Nửa còn lại của mẫu này là cơ chế tham chiếu gián tiếp. Bên trong file đã mã hóa, thêm tiền tố vault_ vào mọi biến.

vault_db_password: "a real password"
vault_grafana_admin_token: "a real token"

Sau đó tham chiếu các tên đó từ file plaintext bên cạnh.

db_password: "{{ vault_db_password }}"
grafana_admin_token: "{{ vault_grafana_admin_token }}"

Roles và templates sử dụng db_password và không cần biết giá trị đến từ đâu. Cách này giữ cho ranh giới giữa playbook và role rõ ràng. File plaintext vars.yml cũng đóng vai trò như một chỉ mục có thể tìm kiếm: grep -r vault_ group_vars/ liệt kê mọi secret mà repository yêu cầu mà không cần giải mã. Đổi lại, bạn phải dùng thêm một tên cho mỗi secret, và lỗi chính tả trong tên vault_ chỉ xuất hiện khi chạy dưới dạng biến chưa được định nghĩa thay vì báo lỗi cú pháp.

Mã hóa một biến bằng encrypt_string

ansible-vault encrypt_string --vault-id prod@~/.ansible/vault-prod.txt \
  --stdin-name 'vault_db_password'

Nhập secret, rồi nhấn Ctrl-D. --stdin-name đọc giá trị từ standard input, nên giá trị này không xuất hiện trong file lịch sử của shell. Dạng lệnh còn lại đặt giá trị trực tiếp trên command line, nơi shell sẽ ghi lại giá trị đó:

ansible-vault encrypt_string --vault-id prod@~/.ansible/vault-prod.txt \
  'a real password' --name 'vault_db_password'

Cả hai cách đều in ra một block YAML. Dán nguyên block này vào file vars đúng như kết quả được in ra, vì phần thụt lề bên dưới tag !vault là một phần của giá trị.

vault_db_password: !vault |
          $ANSIBLE_VAULT;1.2;AES256;prod
          6638643965323633646262656665306333616466396630323136393465356136396436383331
          3131303163306665326539353837343663313762616561306534373963383531613664393332

Tag !vault cho YAML loader biết scalar này là ciphertext, không phải văn bản. Header chứa phiên bản format, cipher và nhãn vault ID đã dùng để mã hóa. Giá trị được mã hóa mà không có vault ID sẽ có header 1.1 không kèm nhãn. Dạng này vẫn hoạt động, nhưng chỉ cho biết ít hơn về nguồn gốc của password.

Mật khẩu vault nằm ở đâu?

Bên ngoài repository. Đây là quy tắc duy nhất không có ngoại lệ.

--ask-vault-pass hỏi một lần trong mỗi lần chạy và không lưu gì. Cách này phù hợp với laptop, nhưng không phù hợp với cron job hoặc CI runner.

Password file là một file văn bản thuần túy, trong đó dòng đầu tiên là mật khẩu. Tạo file rỗng với quyền truy cập chặt, sau đó nhập mật khẩu bằng editor để mật khẩu không bao giờ xuất hiện trong shell history:

mkdir -p ~/.ansible
install -m 600 /dev/null ~/.ansible/vault-prod.txt
$EDITOR ~/.ansible/vault-prod.txt

Chỉ định file này cho mọi command bằng --vault-password-file:

ansible-playbook -i inventory/hosts.ini playbooks/site.yml \
  --vault-password-file ~/.ansible/vault-prod.txt

Việc phải lặp lại flag đó trong mọi command rất dễ bị quên, vì vậy hãy đặt một lần trong ansible.cfg tại thư mục gốc của repository.

[defaults]
inventory = inventory/hosts.ini
vault_password_file = ~/.ansible/vault-prod.txt

Cấu hình này cũng đọc từ biến môi trường ANSIBLE_VAULT_PASSWORD_FILE, đây là cách CI job thường cung cấp mật khẩu. Job ghi mật khẩu từ credential store của chính nó vào một file trong thư mục tạm, export biến này, rồi xóa file khi lần chạy kết thúc. Đồng thời thêm pattern tên file vào .gitignore, vì đường dẫn trong ansible.cfg đã được commit và sớm muộn cũng sẽ có người tạo file thật bên trong checkout.

Nếu password file có quyền executable, Ansible sẽ chạy file đó và đọc mật khẩu từ standard output thay vì đọc nội dung file dưới dạng văn bản. Đây là cách lấy mật khẩu vault từ system keyring hoặc cloud secret manager mà không cần ghi mật khẩu xuống disk. Script được dùng thông qua --vault-id có thêm các yêu cầu: tên phải kết thúc bằng -client hoặc bằng -client kèm extension, phải có quyền executable, phải chấp nhận option --vault-id và phải in mật khẩu ra standard output.

Hai vault ID: staging và production

Vault ID là nhãn gắn với mật khẩu vault, được khai báo bằng label@source. Giá trị nguồn là prompt, tức đường dẫn đến file mật khẩu hoặc đường dẫn đến client script. Nhãn cho phép một repository lưu secret bằng nhiều mật khẩu, nên mật khẩu staging không thể mở file 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

Truyền mọi ID mà một lần chạy có thể cần:

ansible-playbook playbooks/site.yml \
  --vault-id staging@~/.ansible/vault-staging.txt \
  --vault-id prod@~/.ansible/vault-prod.txt

Hoặc khai báo một lần trong ansible.cfg:

[defaults]
vault_identity_list = staging@~/.ansible/vault-staging.txt, prod@~/.ansible/vault-prod.txt

Một hành vi thường khiến người dùng bất ngờ. Theo mặc định, nhãn chỉ là gợi ý, không phải cơ chế khóa. Ansible thử từng secret hiện có với file cho đến khi một secret giải mã được file đó. Vì vậy, file có nhãn staging vẫn mở được nếu mật khẩu production tình cờ là key đúng. Đặt vault_id_match = True trong [defaults] hoặc đặt biến môi trường ANSIBLE_VAULT_ID_MATCH để Ansible chỉ dùng secret có nhãn khớp với header của file. Kiểm tra này cần header 1.2, nên chỉ áp dụng cho nội dung ngay từ đầu đã được mã hóa bằng vault ID.

Khi đã nạp nhiều ID, ansible-vault encrypt không còn biết phải dùng mật khẩu nào để mã hóa. Chỉ rõ ID bằng --encrypt-vault-id prod hoặc đặt vault_encrypt_identity trong ansible.cfg để repository có giá trị mặc định.

Lợi ích nằm ở phạm vi triển khai. CI job triển khai staging chỉ được cấp mật khẩu staging, nên runner bị breach không thể đọc credential production. Khi bạn chạy play trên một cụm máy chủ Linux từ một control machine, việc tách biệt này quyết định sự cố chỉ ở mức nhỏ hay trở nên rất nghiêm trọng.

Đổi mật khẩu vault khi có người rời nhóm

Đổi mật khẩu vault sẽ thay đổi mật khẩu và mã hóa lại nội dung bằng mật khẩu mới. Thao tác này không thu hồi được bất kỳ quyền truy cập nào đã có. Bất kỳ ai từng giữ mật khẩu cũ vẫn có thể giải mã mọi bản sao repository mà họ đã lưu, bao gồm toàn bộ commit cũ trong bản sao đó. Vì vậy, hãy coi mật khẩu vault là đã bị lộ ngay khi người đó rời nhóm, rồi thực hiện xoay vòng theo thứ tự sau.

  1. Thay đổi credential thực tế trên các server và trong các dịch vụ bên thứ ba. Đây là bước thực sự thu hồi quyền truy cập.
  2. Ghi các giá trị mới vào các file vault bằng ansible-vault edit.
  3. Đổi mật khẩu cho mọi file đã mã hóa sang một mật khẩu vault mới.
  4. Gửi mật khẩu vault mới cho những người vẫn cần dùng, qua một kênh không phải repository.
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.yml

rekey chấp nhận nhiều file trong một lệnh, còn --new-vault-id prod@prompt yêu cầu nhập mật khẩu mới một lần thay vì đọc mật khẩu từ disk. Giữ nguyên label nếu không có lý do phải thay đổi, vì label được ghi vào header của mọi file được lệnh này ghi lại.

Đây là điểm inline form gây thêm chi phí. ansible-vault rekey xử lý các file đã mã hóa hoàn toàn, nên một block !vault nằm trong file vars plaintext sẽ không bị thay đổi. Trước tiên hãy tìm các block đó, sau đó tạo lại từng block bằng encrypt_string với mật khẩu mới:

grep -rl '!vault' group_vars/ host_vars/

Đó là toàn bộ đánh đổi. Inline block cho diff dễ đọc, nhưng khi xoay vòng bạn phải kiểm tra thủ công. File được mã hóa hoàn toàn có thể xoay vòng bằng một lệnh, nhưng không cung cấp thông tin hữu ích khi review.

Vì sao secret vẫn xuất hiện trong output của bạn

Vault hoàn tất ngay khi giá trị được giải mã. Ansible báo cáo kết quả của task, còn module echo các argument của nó sẽ đưa credential vào báo cáo đó. Một lần chạy verbose, một --diff trên task template, một task bị lỗi và dump các argument, hoặc một callback plugin ghi output vào file đều có thể giữ plaintext. Việc mã hóa file không ngăn được bất kỳ trường hợp nào trong số này.

no_log: true là tùy chọn dùng để kiểm soát việc đó. Hãy đặt nó trên mọi task nhận credential.

- 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: true

Sau đó, Ansible không đưa kết quả của task đó vào output. Log chỉ ghi nhận task đã chạy mà không ghi lại dữ liệu task đã xử lý. Đặc biệt hãy đặt tùy chọn này trên loop, vì một loop báo cáo một kết quả cho mỗi item, còn loop duyệt qua danh sách credential sẽ báo cáo toàn bộ danh sách.

Có 4 vị trí khác mà secret đã giải mã có thể bị lộ. no_log không kiểm soát được các vị trí này:

  • File được render từ template kế thừa modeowner mà bạn đã cấp cho nó. Hãy đặt mode: "0600" và owner cụ thể cho mọi file chứa credential. Nếu không, secret có thể được đọc bởi mọi người dùng trên target host.
  • Secret truyền cho ansible.builtin.command hoặc ansible.builtin.shell sẽ xuất hiện trong process list trên target host trong lúc command chạy. Mọi local user đều có thể đọc được giá trị đó. Thay vào đó, hãy truyền secret qua file hoặc environment variable.
  • Fact caching ghi các fact đã thu thập vào disk trên control machine. Vì vậy, một registered variable chứa secret có thể nằm trong cache file mà không ai nghĩ là dữ liệu nhạy cảm.
  • Cùng một secret thường còn tồn tại ở vị trí thứ hai, chẳng hạn environment file được container đọc. Các quy tắc ở đó là độc lập. không đưa credential vào Compose env file giải thích phần này.

no_log khiến việc debug khó hơn. Đó chính là mục đích của nó. Khi task gặp lỗi, hãy tạm thời gỡ tùy chọn này trên test host, rồi bật lại trước khi thay đổi được đưa lên production.

Đọc và chỉnh sửa file đã mã hóa mà không để lại bản rõ

ansible-vault view group_vars/prod/vault.yml giải mã vào pager và không ghi gì xuống disk. ansible-vault edit giải mã vào file tạm, mở $EDITOR của bạn rồi mã hóa lại khi bạn đóng file. Ưu tiên dùng cả hai cách này thay cho ansible-vault decrypt, vì ansible-vault decrypt để lại một file bản rõ trong working tree. Một file vault đã giải mã bị stage nhầm là cách phổ biến nhất khiến credential thật lọt vào repository công khai.

Git có thể hiển thị diff dễ đọc cho các file được mã hóa hoàn toàn bằng cách giải mã chúng trong quá trình xử lý:

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

Hãy hiểu rõ lệnh này thực hiện gì trước khi bật. git diff sẽ in secret production vào terminal, khiến chúng xuất hiện trong scrollback và mọi phiên chia sẻ màn hình. Đây chỉ là tiện ích cục bộ cho một người trên một máy, vì vậy hãy giữ git config ở phạm vi cục bộ và lưu ý rằng checkout của người khác sẽ hoạt động khác, trừ khi họ cũng cấu hình như vậy.

Khi vault không còn là công cụ phù hợp

Vault là một định dạng file, trong đó mỗi label có một password. Cấu trúc này quyết định giới hạn sử dụng của nó. Hãy chuyển sang một secret store thực thụ khi có bất kỳ điều kiện nào sau đây.

  • Bạn cần quyền truy cập theo từng người. Mọi người chạy playbook đều giữ cùng một password, còn vault ID chỉ tách quyền theo môi trường, không tách theo từng người.
  • Bạn cần audit trail. Vault không ghi lại ai đã giải mã secret nào hoặc giải mã lúc nào.
  • Bạn cần rotation theo lịch. Vault không có expiry và versioning, nên không có gì cho bạn biết một credential đã không thay đổi trong 2 năm.
  • Bản thân ứng dụng cần secret tại thời gian chạy. Một service đọc database password khi boot không nên đọc password đó từ repository triển khai.

Khi đó, mô hình sẽ đảo ngược. Ansible ngừng lưu secret và bắt đầu lấy secret tại thời gian chạy thông qua lookup plugin, từ HashiCorp Vault (một product khác có tên dễ gây nhầm lẫn), secret manager của cloud provider hoặc keyring trên control machine. Repository chỉ giữ path, store giữ value và store ghi lại access log. Với một team nhỏ, password manager tự host có API, chẳng hạn như một server Vaultwarden, có thể đảm nhiệm công việc tương tự ở quy mô nhỏ hơn.

Một credential vẫn nằm ngoài toàn bộ mô hình này. SSH key mà control machine dùng để truy cập các server không phải vấn đề của vault, vì Ansible cần key đó trước khi chạy bất kỳ play nào. Hãy quản lý key bằng agent và passphrase, theo hướng dẫn kiến thức cơ bản về quản lý SSH key.

FAQ

Tôi nên mã hóa toàn bộ file vars hay chỉ chuỗi secret?

Hãy mã hóa toàn bộ file nếu file chỉ chứa secret, vì một command sẽ rotate toàn bộ và giữ cho cấu trúc đơn giản. Dùng ansible-vault encrypt_string khi secret nằm cùng các biến thông thường, vì khi đó diff chỉ thay đổi giá trị đã mã hóa và reviewer có thể thấy biến nào đã được chỉnh sửa. Điểm đánh đổi là việc rotate. ansible-vault rekey xử lý toàn bộ file và giữ nguyên các block !vault inline, vì vậy bạn phải tạo lại chúng thủ công bằng password mới.

Nên lưu file password của Ansible Vault ở đâu?

Lưu bên ngoài repository, với mode 0600, tại một path như ~/.ansible/vault-prod.txt. Trỏ đến file bằng --vault-password-file, hoặc đặt vault_password_file trong [defaults] thuộc ansible.cfg, hoặc đặt ANSIBLE_VAULT_PASSWORD_FILE trong environment. Trong CI, để job ghi password từ credential store riêng vào một file tạm, export biến này, rồi xóa file khi job kết thúc. Nếu file có quyền executable, Ansible sẽ chạy file đó và đọc password từ standard output. Nhờ vậy, bạn có thể lấy password từ keyring thay vì lưu trên disk.

Làm thế nào để dùng password Vault khác nhau cho staging và production?

Gán label cho từng password bằng --vault-id staging@/path/to/file--vault-id prod@/path/to/file, rồi mã hóa file của từng environment theo label riêng. Truyền cả 2 ID khi chạy, hoặc liệt kê chúng trong vault_identity_list thuộc [defaults]. Theo mặc định, Ansible thử từng secret mà nó có cho đến khi một secret giải mã được file. Vì vậy, hãy đặt vault_id_match = True nếu bạn muốn Ansible chỉ thử secret có label khớp với header của file. Khi đã load nhiều ID, chọn ID dùng để mã hóa bằng --encrypt-vault-id.

Ansible Vault có ngăn password xuất hiện trong output của lần chạy không?

Không. Vault chỉ bảo vệ secret khi secret đang được lưu trong repository. Khi task chạy, giá trị ở dạng plaintext, và một lần chạy verbose hoặc task bị lỗi có thể đưa giá trị đó vào log. Thêm no_log: true vào mọi task xử lý credential, đặt modeowner ở mức hạn chế trên mọi file được template tạo ra, đồng thời tránh truyền secret dưới dạng command argument, vì các argument này hiển thị trong process list trên target host khi command đang chạy.