├── .ansible-lint ├── .github ├── CODEOWNERS ├── FUNDING.yml ├── ISSUE_TEMPLATE.md ├── ISSUE_TEMPLATE │ └── bug_report.md └── workflows │ ├── ansible-lint-sched.yml │ ├── ansible-lint.yml │ ├── issues.yml │ ├── py-linting.yml │ ├── scorecards.yml │ └── slsa.yml ├── .gitignore ├── LICENSE ├── README.md ├── SECURITY.md ├── Vagrantfile ├── hvault_inventory.py ├── playbook.yml ├── random_password.md ├── renovate.json ├── requirements-dev.txt ├── requirements.txt ├── ruff.toml ├── scripts ├── admin_server_installation.sh ├── rotate_linux_password.sh ├── vault_server_installation.sh └── vault_ssh_helper_installation.sh ├── ssh_certificates.md ├── ssh_otp.md └── vault_policies ├── ansible.hcl ├── linuxadmin.hcl ├── rotate-linux.hcl ├── ssh-certs.hcl └── ssh-certs.json /.ansible-lint: -------------------------------------------------------------------------------- 1 | --- 2 | profile: production 3 | exclude_paths: 4 | - .git/ 5 | - .github/ 6 | - tests 7 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @konstruktoid 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: konstruktoid 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Lint failure 3 | about: A lint failure issue 4 | title: "[ACTION] Linting failed" 5 | assignees: konstruktoid 6 | labels: bug 7 | --- 8 | {{ tools.context.actor }}: {{ tools.context.sha }} 9 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[BUG]" 5 | labels: '' 6 | assignees: konstruktoid 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior. 15 | 16 | **Expected behavior** 17 | A clear and concise description of what you expected to happen. 18 | 19 | **System (lsb_release -a or similar):** 20 | 21 | **Additional context** 22 | Add any other context about the problem here. 23 | -------------------------------------------------------------------------------- /.github/workflows/ansible-lint-sched.yml: -------------------------------------------------------------------------------- 1 | on: 2 | schedule: 3 | - cron: "0 1 * * */3" 4 | name: Ansible Lint - Scheduled 5 | 6 | permissions: 7 | contents: read 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 14 | - name: Lint Ansible Playbook 15 | uses: ansible/ansible-lint-action@eb92667e07cc18e1d115ff02e5f07126310cec11 # main 16 | -------------------------------------------------------------------------------- /.github/workflows/ansible-lint.yml: -------------------------------------------------------------------------------- 1 | on: [push, pull_request] 2 | name: Ansible Lint 3 | 4 | permissions: 5 | contents: read 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 12 | - name: Lint Ansible Playbook 13 | uses: ansible/ansible-lint-action@eb92667e07cc18e1d115ff02e5f07126310cec11 # main 14 | -------------------------------------------------------------------------------- /.github/workflows/issues.yml: -------------------------------------------------------------------------------- 1 | name: Issue assignment 2 | 3 | on: 4 | issues: 5 | types: [opened] 6 | 7 | permissions: 8 | contents: read 9 | 10 | jobs: 11 | auto-assign: 12 | permissions: 13 | issues: write 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: 'auto-assign issue' 17 | uses: pozil/auto-assign-issue@39c06395cbac76e79afc4ad4e5c5c6db6ecfdd2e # v2.2.0 18 | with: 19 | assignees: konstruktoid 20 | -------------------------------------------------------------------------------- /.github/workflows/py-linting.yml: -------------------------------------------------------------------------------- 1 | on: [push, pull_request] 2 | name: Konstruktoid Python linting 3 | jobs: 4 | konstruktoidPythonlinting: 5 | name: Python linting 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@master 9 | - name: Konstruktoid Python linting 10 | uses: konstruktoid/action-pylint@master 11 | -------------------------------------------------------------------------------- /.github/workflows/scorecards.yml: -------------------------------------------------------------------------------- 1 | name: Scorecards supply-chain security 2 | on: 3 | # Only the default branch is supported. 4 | branch_protection_rule: 5 | schedule: 6 | - cron: '23 9 * * 1' 7 | push: 8 | branches: [ "main" ] 9 | 10 | # Declare default permissions as read only. 11 | permissions: read-all 12 | 13 | jobs: 14 | analysis: 15 | name: Scorecards analysis 16 | runs-on: ubuntu-latest 17 | permissions: 18 | # Needed to upload the results to code-scanning dashboard. 19 | security-events: write 20 | # Used to receive a badge. 21 | id-token: write 22 | # Needs for private repositories. 23 | contents: read 24 | actions: read 25 | 26 | steps: 27 | - name: "Checkout code" 28 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 29 | with: 30 | persist-credentials: false 31 | 32 | - name: "Run analysis" 33 | uses: ossf/scorecard-action@05b42c624433fc40578a4040d5cf5e36ddca8cde # v2.4.2 34 | with: 35 | results_file: results.sarif 36 | results_format: sarif 37 | # (Optional) Read-only PAT token. Uncomment the `repo_token` line below if: 38 | # - you want to enable the Branch-Protection check on a *public* repository, or 39 | # - you are installing Scorecards on a *private* repository 40 | # To create the PAT, follow the steps in https://github.com/ossf/scorecard-action#authentication-with-pat. 41 | # repo_token: ${{ secrets.SCORECARD_READ_TOKEN }} 42 | 43 | # Publish the results for public repositories to enable scorecard badges. For more details, see 44 | # https://github.com/ossf/scorecard-action#publishing-results. 45 | # For private repositories, `publish_results` will automatically be set to `false`, regardless 46 | # of the value entered here. 47 | publish_results: true 48 | 49 | # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF 50 | # format to the repository Actions tab. 51 | - name: "Upload artifact" 52 | uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 53 | with: 54 | name: SARIF file 55 | path: results.sarif 56 | retention-days: 5 57 | 58 | # Upload the results to GitHub's code scanning dashboard. 59 | - name: "Upload to code-scanning" 60 | uses: github/codeql-action/upload-sarif@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18 61 | with: 62 | sarif_file: results.sarif 63 | -------------------------------------------------------------------------------- /.github/workflows/slsa.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: SLSA 3 | on: 4 | push: 5 | release: 6 | permissions: 7 | contents: write 8 | types: [published, released] 9 | 10 | permissions: 11 | contents: read 12 | 13 | jobs: 14 | build: 15 | outputs: 16 | hashes: ${{ steps.hash.outputs.hashes }} 17 | runs-on: ubuntu-latest 18 | steps: 19 | - run: echo "REPOSITORY_NAME=$(echo '${{ github.repository }}' | awk -F '/' '{print $2}')" >> $GITHUB_ENV 20 | shell: bash 21 | 22 | - name: Checkout repository 23 | uses: actions/checkout@v4.2.2 24 | 25 | - name: Build artifacts 26 | run: | 27 | find hvault_inventory.py playbook.yml scripts vault_policies -type f -exec sha256sum {} \; > ${{ env.REPOSITORY_NAME }}.sha256 28 | 29 | - name: Generate hashes 30 | shell: bash 31 | id: hash 32 | run: | 33 | echo "hashes=$(sha256sum ${{ env.REPOSITORY_NAME }}.sha256 | base64 -w0)" >> "$GITHUB_OUTPUT" 34 | 35 | - name: Upload ${{ env.REPOSITORY_NAME }}.sha256 36 | uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 37 | with: 38 | name: ${{ env.REPOSITORY_NAME }}.sha256 39 | path: ${{ env.REPOSITORY_NAME }}.sha256 40 | if-no-files-found: error 41 | retention-days: 5 42 | 43 | provenance: 44 | needs: [build] 45 | permissions: 46 | actions: read 47 | id-token: write 48 | contents: write 49 | uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v2.1.0 50 | with: 51 | base64-subjects: "${{ needs.build.outputs.hashes }}" 52 | upload-assets: ${{ startsWith(github.ref, 'refs/tags/') }} 53 | 54 | release: 55 | permissions: 56 | actions: read 57 | id-token: write 58 | contents: write 59 | needs: [build, provenance] 60 | runs-on: ubuntu-latest 61 | if: startsWith(github.ref, 'refs/tags/') 62 | steps: 63 | - run: echo "REPOSITORY_NAME=$(echo '${{ github.repository }}' | awk -F '/' '{print $2}')" >> $GITHUB_ENV 64 | shell: bash 65 | 66 | - name: Download ${{ env.REPOSITORY_NAME }}.sha256 67 | uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 68 | with: 69 | name: ${{ env.REPOSITORY_NAME }}.sha256 70 | 71 | - name: Upload asset 72 | uses: softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631 # v2.2.2 73 | with: 74 | files: | 75 | ${{ env.REPOSITORY_NAME }}.sha256 76 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vagrant 2 | *.swp 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright 2016 Thomas Sjögren 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Dynamic Ansible inventory using HashiCorp Vault 2 | 3 | `hvault_inventory.py` is a [Ansible](https://www.ansible.com/) [dynamic inventory](https://docs.ansible.com/ansible/latest/user_guide/intro_dynamic_inventory.html) 4 | script that supports a basic K/V setup (`hostname:ip`) but also supports 5 | [Vault One-Time SSH Password](https://learn.hashicorp.com/tutorials/vault/ssh-otp) 6 | functionality, local password rotation and [signed SSH Certificates](https://developer.hashicorp.com/vault/docs/secrets/ssh/signed-ssh-certificates). 7 | 8 | ## Documentation 9 | 10 | In [part one](./ssh_otp.md) HashiCorp Vault and the inventory script is used to 11 | set up OTP SSH authentication. 12 | 13 | In addition to SSH OTP, instructions on how to rotate local user passwords are 14 | available in [part two](./random_password.md). 15 | 16 | In [part three](./ssh_certificates.md) signed SSH Certificates are added to the inventory. 17 | 18 | ## Usage 19 | 20 | ```console 21 | Usage: 22 | ------ 23 | python hvault_inventory.py [-l] [-a ANSIBLE_HOSTS] [-c CERT_PATH] [-m MOUNT] [-u USER_KEYS] 24 | 25 | Options: 26 | -------- 27 | -l, --list Print the inventory. 28 | -a, --ansible-hosts K/V path to the Ansible hosts (default: ansible-hosts). 29 | -c, --cert-path Path to the SSH certificate file (default: ~/.ssh/ansible_{ANSIBLE_USER}_cert.pub). 30 | -m, --mount KV backend mount path (default: secret). 31 | -u, --user-keys K/V path to user public keys (default: user-keys). 32 | ``` 33 | 34 | ### Environment variables 35 | 36 | `USER` sets the `ansible_user` variable, if `ansible_user` is not set. 37 | 38 | `VAULT_MOUNT` which is the KV backend mount path with default "secret". 39 | 40 | `VAULT_ADDR` and `VAULT_TOKEN` are the Vault server address and Vault token. 41 | 42 | ### Examples 43 | 44 | #### K/V 45 | 46 | With default `secret/ansible-hosts`: 47 | 48 | ```sh 49 | $ ansible-inventory -i hvault_inventory.py --list --yaml 50 | all: 51 | children: 52 | vault_hosts: 53 | hosts: 54 | server01: 55 | ansible_host: 192.168.56.41 56 | ansible_user: vagrant 57 | server02: 58 | ansible_host: 192.168.56.42 59 | ansible_user: vagrant 60 | ``` 61 | 62 | Using environment variables: 63 | 64 | ```sh 65 | $ VAULT_MOUNT=secret VAULT_SECRET=ansible-hosts ansible-inventory -i hvault_inventory.py --list --yaml 66 | all: 67 | children: 68 | vault_hosts: 69 | hosts: 70 | server01: 71 | ansible_host: 192.168.56.41 72 | ansible_user: vagrant 73 | server02: 74 | ansible_host: 192.168.56.42 75 | ansible_user: vagrant 76 | ``` 77 | 78 | Note that `ansible_user` is set using the `USER` environment variable if 79 | present and `ansible_user` has not been configured manually. 80 | 81 | _A path with at least one `hostname:ip` K/V need to 82 | exist since the other options will use this to retrive host information and 83 | build upon it._ 84 | 85 | #### One-Time SSH Passwords 86 | 87 | ```sh 88 | $ ansible-inventory -i hvault_inventory.py --list --yaml 89 | all: 90 | children: 91 | ungrouped: {} 92 | vault_hosts: 93 | hosts: 94 | server01: 95 | ansible_host: 192.168.56.41 96 | ansible_password: 681ddbeb-823b-a10a-4b48-b3e0577ddcdb 97 | ansible_port: 22 98 | ansible_user: vagrant 99 | server02: 100 | ansible_host: 192.168.56.42 101 | ansible_password: 06fefcbc-941d-592f-f946-26da0e962d34 102 | ansible_port: 22 103 | ansible_user: vagrant 104 | ``` 105 | 106 | #### One-Time SSH Passwords and generated local passwords 107 | 108 | ```sh 109 | $ ansible-inventory -i hvault_inventory.py --list --yaml 110 | all: 111 | children: 112 | ungrouped: {} 113 | vault_hosts: 114 | hosts: 115 | server01: 116 | ansible_become_password: sprain-doorpost-stylus-decent-strangely 117 | ansible_host: 192.168.56.41 118 | ansible_password: 3e927f12-90db-d20f-36c9-33b64e8224d7 119 | ansible_port: 22 120 | ansible_user: vagrant 121 | server02: 122 | ansible_become_password: pastrami-bullpen-recast-shallot-tinsmith 123 | ansible_host: 192.168.56.42 124 | ansible_password: a3cc1375-cd26-51ff-21d2-de4ffff4c2e3 125 | ansible_port: 22 126 | ansible_user: vagrant 127 | ``` 128 | 129 | #### SSH certificates 130 | 131 | ```sh 132 | $ ansible-inventory -i hvault_inventory.py --list --yaml 133 | all: 134 | children: 135 | vault_hosts: 136 | hosts: 137 | server01: 138 | ansible_host: 192.168.56.41 139 | ansible_ssh_private_key_file: /home/vagrant/.ssh/ansible_vagrant_cert.pub 140 | ansible_user: vagrant 141 | server02: 142 | ansible_host: 192.168.56.42 143 | ansible_ssh_private_key_file: /home/vagrant/.ssh/ansible_vagrant_cert.pub 144 | ansible_user: vagrant 145 | ``` 146 | 147 | #### Multiple authentication methods 148 | 149 | In this examples we'll be using both the `keyboard-interactive` and `publickey` 150 | authentication methods, which will require the user to enter a password and then 151 | complete public key authentication. 152 | 153 | See [AuthenticationMethods](https://manpages.ubuntu.com/manpages/noble/man5/sshd_config.5.html) 154 | for the details. 155 | 156 | On `server01` and `server02` add the following line to 157 | `/etc/ssh/sshd_config.d/99-ssh-auth.conf` and restart the SSH server: 158 | 159 | ```sh 160 | ~$ echo "AuthenticationMethods keyboard-interactive,publickey" | \ 161 | sudo tee /etc/ssh/sshd_config.d/99-ssh-auth.conf 162 | ``` 163 | 164 | On the `admin` machine: 165 | 166 | ```sh 167 | ~$ ssh-add -l 168 | 256 SHA256:LLshRz4/FN4UbLjsW+DHXJ4wH6UuVuFrXS0pQ15PQJw vagrant (ED25519) 169 | $ ansible-inventory -i hvault_inventory.py --list --yaml 170 | all: 171 | children: 172 | vault_hosts: 173 | hosts: 174 | server01: 175 | ansible_become_password: d68f2d09-8327-4306-922d-522ebf4e53af 176 | ansible_host: 192.168.56.41 177 | ansible_password: 9cb98a45-0393-4dab-4fa3-769ad2a509c5 178 | ansible_port: 22 179 | ansible_ssh_private_key_file: /home/vagrant/.ssh/ansible_vagrant_cert.pub 180 | ansible_user: vagrant 181 | server02: 182 | ansible_become_password: e3620985-7abb-4c6e-bea6-8e471c1e6dfc 183 | ansible_host: 192.168.56.42 184 | ansible_password: 70bdcfe9-c88b-724a-2164-a8a698c4ba15 185 | ansible_port: 22 186 | ansible_ssh_private_key_file: /home/vagrant/.ssh/ansible_vagrant_cert.pub 187 | ansible_user: vagrant 188 | ~$ ssh -v -i /home/vagrant/.ssh/ansible_vagrant_cert.pub 192.168.56.41 189 | [...] 190 | debug1: SSH2_MSG_SERVICE_ACCEPT received 191 | debug1: Authentications that can continue: keyboard-interactive 192 | debug1: Next authentication method: keyboard-interactive 193 | (vagrant@192.168.56.41) Password: # df9a0219-7393-886a-4375-ae40f846b786 194 | Authenticated using "keyboard-interactive" with partial success. 195 | debug1: Authentications that can continue: publickey 196 | debug1: Next authentication method: publickey 197 | debug1: Offering public key: vagrant ED25519 SHA256:LLshRz4/FN4UbLjsW+DHXJ4w... 198 | debug1: Authentications that can continue: publickey 199 | debug1: Offering public key: /home/vagrant/.ssh/ansible_vagrant_cert.pub ED25519-CERT ... 200 | debug1: Server accepts key: /home/vagrant/.ssh/ansible_vagrant_cert.pub ED25519-CERT ... 201 | Authenticated to 192.168.56.41 ([192.168.56.41]:22) using "publickey". 202 | [...] 203 | vagrant@server01:~$ sudo -u root -i 204 | [sudo] password for vagrant: # scallion-paternal-stamp-produce-fiftieth 205 | root@server01:~# 206 | ``` 207 | 208 | Running the test playbook using multiple authentication methods: 209 | 210 | ```sh 211 | ~$ ansible-playbook -i hvault_inventory.py playbook.yml 212 | 213 | PLAY [Test Hashicorp Vault dynamic inventory] ********************************** 214 | 215 | TASK [Get ssh host keys from vault_hosts group] ******************************** 216 | # 192.168.56.41:22 SSH-2.0-OpenSSH_8.9p1 Ubuntu-3ubuntu0.6 217 | # 192.168.56.41:22 SSH-2.0-OpenSSH_8.9p1 Ubuntu-3ubuntu0.6 218 | ok: [server02 -> localhost] => (item=server01) 219 | ok: [server01 -> localhost] => (item=server01) 220 | # 192.168.56.42:22 SSH-2.0-OpenSSH_8.9p1 Ubuntu-3ubuntu0.6 221 | # 192.168.56.42:22 SSH-2.0-OpenSSH_8.9p1 Ubuntu-3ubuntu0.6 222 | ok: [server02 -> localhost] => (item=server02) 223 | ok: [server01 -> localhost] => (item=server02) 224 | 225 | TASK [Print ansible_password] ************************************************** 226 | ok: [server01] => { 227 | "msg": "79a1c335-c2f3-aa55-d13d-29e99d60aaa9" 228 | } 229 | ok: [server02] => { 230 | "msg": "f53bb2a7-51d6-f1cc-bf7d-73c6660b9071" 231 | } 232 | 233 | TASK [Print ansible_become_password] ******************************************* 234 | ok: [server01] => { 235 | "msg": "scallion-paternal-stamp-produce-fiftieth" 236 | } 237 | ok: [server02] => { 238 | "msg": "clavicle-rebate-wick-tall-trespass" 239 | } 240 | 241 | TASK [Print ansible_ssh_private_key_file] ************************************** 242 | ok: [server01] => { 243 | "msg": "/home/vagrant/.ssh/ansible_vagrant_cert.pub" 244 | } 245 | ok: [server02] => { 246 | "msg": "/home/vagrant/.ssh/ansible_vagrant_cert.pub" 247 | } 248 | 249 | TASK [Stat vault-ssh.log] ****************************************************** 250 | ok: [server02] 251 | ok: [server01] 252 | 253 | TASK [Grep authentication methods] ********************************************* 254 | ok: [server02] 255 | ok: [server01] 256 | 257 | TASK [Grep authentication string from /var/log/vault-ssh.log] ****************** 258 | ok: [server02] 259 | ok: [server01] 260 | 261 | TASK [Grep keyboard-interactive from /var/log/auth.log] ************************ 262 | ok: [server02] 263 | ok: [server01] 264 | 265 | TASK [Grep keyboard-interactive from /var/log/auth.log] ************************ 266 | ok: [server02] 267 | ok: [server01] 268 | 269 | TASK [Print authentication methods] ******************************************** 270 | ok: [server01] => { 271 | "msg": "authenticationmethods keyboard-interactive,publickey" 272 | } 273 | ok: [server02] => { 274 | "msg": "authenticationmethods publickey,keyboard-interactive" 275 | } 276 | 277 | TASK [Print authentication string] ********************************************* 278 | ok: [server01] => { 279 | "msg": "2024/03/01 16:07:46 [INFO] vagrant@192.168.56.41 authenticated!" 280 | } 281 | ok: [server02] => { 282 | "msg": "2024/03/01 16:07:46 [INFO] vagrant@192.168.56.42 authenticated!" 283 | } 284 | 285 | TASK [Print keyboard-interactive] *********************************************** 286 | ok: [server01] => { 287 | "msg": "Mar 1 14:11:58 ubuntu-jammy sshd[14636]: Accepted keyboard-interactive/pam ... 288 | } 289 | ok: [server02] => { 290 | "msg": "Mar 1 16:07:46 ubuntu-jammy sshd[16656]: Accepted keyboard-interactive/pam ... 291 | } 292 | 293 | TASK [Print cert serials] ****************************************************** 294 | ok: [server01] => { 295 | "msg": "Mar 01 16:07:46 server01 sshd[16712]: Accepted publickey for vagrant ... 296 | } 297 | ok: [server02] => { 298 | "msg": "Mar 01 15:40:11 server02 sshd[15620]: Accepted publickey for vagrant ... 299 | } 300 | ``` 301 | 302 | ## Scripts and policies 303 | 304 | Password rotation and SSH helper scripts are available in the [./scripts](./scripts/) 305 | directory. 306 | 307 | Vault policies are available in the [./vault_policies](./vault_policies/) 308 | directory. 309 | 310 | ### Links 311 | 312 | [Ansible dynamic inventory](https://docs.ansible.com/ansible/latest/user_guide/intro_dynamic_inventory.html) 313 | 314 | [KV Secrets Engine](https://www.vaultproject.io/docs/secrets/kv) 315 | 316 | [scarolan/painless-password-rotation](https://github.com/scarolan/painless-password-rotation) 317 | 318 | [Signed SSH Certificates](https://developer.hashicorp.com/vault/docs/secrets/ssh/signed-ssh-certificates) 319 | 320 | [SSH secrets engine: One-time SSH password](https://learn.hashicorp.com/tutorials/vault/ssh-otp) 321 | 322 | [Vault API client](https://github.com/hvac/hvac) 323 | 324 | [vault-ssh-helper](https://github.com/hashicorp/vault-ssh-helper) 325 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | The current [upstream](https://github.com/konstruktoid/ansible-hvault-inventory) 6 | and the [latest published version](https://github.com/konstruktoid/ansible-hvault-inventory/releases) are supported. 7 | 8 | ## Reporting a Bug or Vulnerability 9 | 10 | If you found a bug or vulnerability or just something odd, feel free to submit a issue or improve the code by creating a pull request. 11 | -------------------------------------------------------------------------------- /Vagrantfile: -------------------------------------------------------------------------------- 1 | Vagrant.configure("2") do |config| 2 | config.vm.provider "virtualbox" do |vb| 3 | vb.customize ["modifyvm", :id, "--uart1", "0x3F8", "4"] 4 | vb.customize ["modifyvm", :id, "--uartmode1", "file", File::NULL] 5 | end 6 | 7 | config.vm.define "admin" do |admin| 8 | admin.ssh.key_type = "ed25519" 9 | admin.vm.box = "bento/ubuntu-24.04" 10 | admin.vm.network "private_network", ip:"192.168.56.39" 11 | admin.vm.hostname = "admin" 12 | admin.vm.boot_timeout = 600 13 | admin.vm.provision "shell", 14 | path: "./scripts/admin_server_installation.sh" 15 | end 16 | 17 | config.vm.define "vault" do |vault| 18 | vault.ssh.key_type = "ed25519" 19 | vault.vm.box = "bento/ubuntu-24.04" 20 | vault.vm.network "private_network", ip:"192.168.56.40" 21 | vault.vm.network "forwarded_port", guest: 8200, host: 8200 22 | vault.vm.hostname = "vault" 23 | vault.vm.boot_timeout = 600 24 | vault.vm.provision "shell", 25 | path: "./scripts/vault_server_installation.sh" 26 | end 27 | 28 | (1..2).each do |i| 29 | config.vm.define "server0#{i}" do |server| 30 | server.ssh.key_type = "ed25519" 31 | server.vm.box = "bento/ubuntu-24.04" 32 | server.vm.network "private_network", ip:"192.168.56.4#{i}" 33 | server.vm.hostname = "server0#{i}" 34 | server.vm.boot_timeout = 600 35 | server.vm.provision "shell", 36 | path: "./scripts/vault_ssh_helper_installation.sh" 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /hvault_inventory.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # ruff: noqa: T201 3 | 4 | """HashiCorp Vault dynamic inventory for Ansible. 5 | 6 | This script provides a dynamic inventory for Ansible using HashiCorp Vault as 7 | the backend. 8 | 9 | Usage: 10 | ------ 11 | python hvault_inventory.py [-l] [-a ANSIBLE_HOSTS] [-c CERT_PATH] [-m MOUNT] [-u USER_KEYS] 12 | 13 | Options: 14 | -------- 15 | -l, --list Print the inventory. 16 | -a, --ansible-hosts K/V path to the Ansible hosts (default: ansible-hosts). 17 | -c, --cert-path Path to the SSH certificate file (default: ~/.ssh/ansible_{ANSIBLE_USER}_cert.pub). 18 | -m, --mount KV backend mount path (default: secret). 19 | -u, --user-keys K/V path to user public keys (default: user-keys). 20 | 21 | Example: 22 | ------- 23 | python3 hvault_inventory.py --list 24 | 25 | This will print the generated inventory JSON. 26 | 27 | Version: 0.1.0 28 | 29 | """ 30 | 31 | import argparse 32 | import base64 33 | import os 34 | import subprocess 35 | import sys 36 | from datetime import datetime, timedelta, timezone 37 | from io import BytesIO 38 | from pathlib import Path 39 | 40 | import hvac 41 | import pycurl 42 | 43 | try: 44 | import json 45 | except ImportError: 46 | import simplejson as json 47 | 48 | try: 49 | from urllib.parse import urlencode 50 | except ImportError: 51 | from urllib import urlencode 52 | 53 | __version__ = "0.1.0" 54 | 55 | 56 | inventory = {} 57 | inventory["vault_hosts"] = [] 58 | inventory["_meta"] = {} 59 | inventory["_meta"]["hostvars"] = {} 60 | 61 | parser = argparse.ArgumentParser( 62 | description="Dynamic HashiCorp Vault inventory.", 63 | epilog="version: " + __version__, 64 | ) 65 | 66 | parser.add_argument( 67 | "-l", 68 | "--list", 69 | help="print the inventory", 70 | action="store_true", 71 | ) 72 | 73 | parser.add_argument( 74 | "-a", 75 | "--ansible-hosts", 76 | help="K/V path to the Ansible hosts, default: ansible-hosts", 77 | ) 78 | 79 | parser.add_argument( 80 | "-c", 81 | "--cert-path", 82 | help="Path to the SSH certificate file, default: ~/.ssh/ansible_{ANSIBLE_USER}_cert.pub", 83 | ) 84 | 85 | parser.add_argument( 86 | "-m", 87 | "--mount", 88 | help="KV backend mount path, default: secret", 89 | ) 90 | 91 | parser.add_argument( 92 | "-u", 93 | "--user-keys", 94 | help="K/V path to user public keys, default: user-keys", 95 | ) 96 | 97 | args = parser.parse_args() 98 | 99 | try: 100 | client = hvac.Client( 101 | url=os.environ["VAULT_ADDR"], 102 | token=os.environ["VAULT_TOKEN"], 103 | ) 104 | 105 | except KeyError as error: 106 | print("Environment variable " + str(error) + " is missing.", file=sys.stderr) 107 | sys.exit(1) 108 | 109 | if not client.is_authenticated(): 110 | print("Client is not authenticated.") 111 | sys.exit(1) 112 | 113 | mount = args.mount if args.mount else os.environ.get("VAULT_MOUNT", "secret") 114 | ansible_hosts = args.ansible_hosts if args.ansible_hosts else "ansible-hosts" 115 | user_keys = args.user_keys if args.user_keys else "user-keys" 116 | 117 | 118 | def get_ssh_certificate_validity_dates(cert_path: str) -> bool: 119 | """Get the validity dates of an SSH certificate and check if it is still valid. 120 | 121 | Args: 122 | ---- 123 | cert_path (str): The path to the SSH certificate file. 124 | 125 | Returns: 126 | ------- 127 | bool: True if the certificate is still valid, False otherwise. 128 | 129 | """ 130 | result = subprocess.run( # noqa: S603 131 | ["/usr/bin/ssh-keygen", "-L", "-f", cert_path], 132 | capture_output=True, 133 | text=True, 134 | check=False, 135 | shell=False, 136 | ) 137 | 138 | is_valid = False 139 | for line in result.stdout.split("\n"): 140 | if "Valid:" in line: 141 | valid_to = line.split(" ")[-1] 142 | 143 | date_format = "%Y-%m-%dT%H:%M:%S" 144 | date = datetime.strptime(valid_to, date_format).replace(tzinfo=timezone.utc) 145 | decreased_date = date - timedelta(minutes=5) 146 | now = datetime.now(tz=timezone.utc) 147 | is_valid = now < decreased_date 148 | return is_valid 149 | 150 | 151 | try: 152 | hosts_read_response = client.secrets.kv.v2.read_secret_version( 153 | mount_point=mount, 154 | path=ansible_hosts, 155 | raise_on_deleted_version=True, 156 | ) 157 | except hvac.exceptions.InvalidPath as exception_string: 158 | print("InvalidPath Exception: ", str(exception_string), file=sys.stderr) 159 | sys.exit(1) 160 | 161 | for host in hosts_read_response["data"]["data"]: 162 | name = host 163 | ansible_host = hosts_read_response["data"]["data"][host] 164 | ANSIBLE_USER = None 165 | ANSIBLE_PASSWORD = None 166 | ANSIBLE_PORT = None 167 | ANSIBLE_BECOME_PASSWORD = None 168 | 169 | inventory["vault_hosts"].append(name) 170 | inventory["_meta"]["hostvars"][name] = {} 171 | 172 | post_data = {"ip": ansible_host} 173 | 174 | postfields = urlencode(post_data) 175 | buffer = BytesIO() 176 | 177 | otp = pycurl.Curl() 178 | otp.setopt(otp.URL, os.environ["VAULT_ADDR"] + "/v1/ssh/creds/otp_key_role") 179 | otp.setopt(otp.WRITEFUNCTION, buffer.write) 180 | otp.setopt(otp.POSTFIELDS, postfields) 181 | otp.setopt( 182 | otp.HTTPHEADER, 183 | ["X-Vault-Request: true", "X-Vault-Token:" + os.environ["VAULT_TOKEN"]], 184 | ) 185 | otp.perform() 186 | otp.close() 187 | 188 | ssh_creds_response = json.loads(buffer.getvalue().decode("utf-8")) 189 | 190 | try: 191 | if ssh_creds_response["data"]["username"]: 192 | ANSIBLE_USER = ssh_creds_response["data"]["username"] 193 | if ssh_creds_response["data"]["key"]: 194 | ANSIBLE_PASSWORD = ssh_creds_response["data"]["key"] 195 | if ssh_creds_response["data"]["port"]: 196 | ANSIBLE_PORT = ssh_creds_response["data"]["port"] 197 | except KeyError: 198 | pass 199 | 200 | try: 201 | if not ANSIBLE_USER: 202 | try: 203 | if os.environ["USER"]: 204 | ANSIBLE_USER = os.environ["USER"] 205 | except KeyError: 206 | pass 207 | 208 | user_password_read_response = client.secrets.kv.v2.read_secret_version( 209 | mount_point="systemcreds", 210 | path="linux/" + name + "/" + ANSIBLE_USER + "_creds", 211 | raise_on_deleted_version=True, 212 | ) 213 | 214 | for username in user_password_read_response["data"]["data"]: 215 | if username == ANSIBLE_USER: 216 | ANSIBLE_BECOME_PASSWORD = user_password_read_response["data"]["data"][ 217 | username 218 | ] 219 | except hvac.exceptions.InvalidPath: 220 | pass 221 | except TypeError: 222 | pass 223 | except hvac.exceptions.Forbidden: 224 | pass 225 | 226 | ssh_cert_path = ( 227 | args.cert_path 228 | if args.cert_path 229 | else Path.home() / ".ssh" / f"ansible_{ANSIBLE_USER}_cert.pub" 230 | ) 231 | vault_cert_path = True 232 | valid_ssh_cert = ( 233 | get_ssh_certificate_validity_dates(ssh_cert_path) 234 | if ssh_cert_path.exists() 235 | else False 236 | ) 237 | 238 | if not ssh_cert_path.exists() or not valid_ssh_cert: 239 | try: 240 | user_keys_read_response = client.secrets.kv.v2.read_secret_version( 241 | mount_point=mount, 242 | path=user_keys, 243 | raise_on_deleted_version=True, 244 | ) 245 | except hvac.exceptions.InvalidPath: 246 | vault_cert_path = False 247 | except TypeError: 248 | pass 249 | except hvac.exceptions.Forbidden: 250 | pass 251 | 252 | if vault_cert_path: 253 | for user in user_keys_read_response["data"]["data"]: 254 | if user == ANSIBLE_USER: 255 | public_key_base64 = user_keys_read_response["data"]["data"][user] 256 | public_key = base64.b64decode(public_key_base64).decode("utf-8") 257 | 258 | post_data = {"public_key": public_key} 259 | postfields = urlencode(post_data) 260 | buffer = BytesIO() 261 | 262 | ssh_signer = pycurl.Curl() 263 | ssh_signer.setopt( 264 | ssh_signer.URL, 265 | os.environ["VAULT_ADDR"] 266 | + "/v1/ssh-client-signer/sign/ssh-certs", 267 | ) 268 | ssh_signer.setopt(ssh_signer.WRITEFUNCTION, buffer.write) 269 | ssh_signer.setopt( 270 | ssh_signer.HTTPHEADER, 271 | [ 272 | "X-Vault-Request: true", 273 | "X-Vault-Token:" + os.environ["VAULT_TOKEN"], 274 | ], 275 | ) 276 | ssh_signer.setopt(ssh_signer.POSTFIELDS, postfields) 277 | ssh_signer.perform() 278 | ssh_signer.close() 279 | 280 | ssh_signer_response = json.loads(buffer.getvalue().decode("utf-8")) 281 | ssh_cert = ssh_signer_response["data"]["signed_key"] 282 | ssh_cert = ssh_cert.replace("\n", "") 283 | ssh_cert_type = public_key.split(" ")[0] 284 | 285 | with Path(ssh_cert_path).open("w") as f: 286 | f.write(ssh_cert) 287 | f.close() 288 | 289 | if ansible_host: 290 | inventory["_meta"]["hostvars"][name]["ansible_host"] = ansible_host 291 | if ANSIBLE_USER: 292 | inventory["_meta"]["hostvars"][name]["ansible_user"] = ANSIBLE_USER 293 | if ANSIBLE_PASSWORD: 294 | inventory["_meta"]["hostvars"][name]["ansible_password"] = ANSIBLE_PASSWORD 295 | if ANSIBLE_PORT: 296 | inventory["_meta"]["hostvars"][name]["ansible_port"] = ANSIBLE_PORT 297 | if ANSIBLE_BECOME_PASSWORD: 298 | inventory["_meta"]["hostvars"][name][ 299 | "ansible_become_password" 300 | ] = ANSIBLE_BECOME_PASSWORD 301 | if vault_cert_path and ssh_cert_path.exists() and valid_ssh_cert: 302 | inventory["_meta"]["hostvars"][name]["ansible_ssh_private_key_file"] = str( 303 | ssh_cert_path, 304 | ) 305 | 306 | if args.list: 307 | print(json.dumps(inventory, sort_keys=True, indent=2)) 308 | else: 309 | print(json.dumps(inventory, sort_keys=True)) 310 | -------------------------------------------------------------------------------- /playbook.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Test Hashicorp Vault dynamic inventory 3 | hosts: all 4 | gather_facts: false 5 | any_errors_fatal: true 6 | tasks: 7 | - name: Get ssh host keys from vault_hosts group 8 | delegate_to: localhost 9 | ansible.builtin.lineinfile: 10 | dest: ~/.ssh/known_hosts 11 | create: true 12 | state: present 13 | mode: "0600" 14 | line: "{{ lookup('pipe', 'ssh-keyscan -t ssh-ed25519' + ' ' + hostvars[item]['ansible_host']) }}" 15 | with_items: 16 | - "{{ groups['vault_hosts'] | list }}" 17 | 18 | - name: Print ansible_password 19 | ansible.builtin.debug: 20 | msg: "{{ ansible_password }}" 21 | when: 22 | - ansible_password is defined 23 | 24 | - name: Print ansible_become_password 25 | ansible.builtin.debug: 26 | msg: "{{ ansible_become_password }}" 27 | when: 28 | - ansible_become_password is defined 29 | 30 | - name: Print ansible_ssh_private_key_file 31 | ansible.builtin.debug: 32 | msg: "{{ ansible_ssh_private_key_file }}" 33 | when: 34 | - ansible_ssh_private_key_file is defined 35 | 36 | - name: Stat vault-ssh.log 37 | become: true 38 | ansible.builtin.stat: 39 | path: /var/log/vault-ssh.log 40 | changed_when: false 41 | register: vault_ssh_log 42 | 43 | - name: Grep authentication methods 44 | become: true 45 | ansible.builtin.shell: | 46 | set -o pipefail 47 | sshd -T | grep authenticationmethods 48 | args: 49 | executable: /bin/bash 50 | changed_when: false 51 | register: ssh_auth_methods 52 | 53 | - name: Grep authentication string from /var/log/vault-ssh.log 54 | become: true 55 | environment: 56 | PATH: /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin 57 | ansible.builtin.shell: | 58 | set -o pipefail 59 | grep 'authenticated!$' /var/log/vault-ssh.log | tail -n1 60 | args: 61 | executable: /bin/bash 62 | register: vault_ssh 63 | changed_when: vault_ssh.rc != 0 64 | when: 65 | - vault_ssh_log.stat.exists 66 | 67 | - name: Grep keyboard-interactive from /var/log/auth.log 68 | become: true 69 | environment: 70 | PATH: /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin 71 | ansible.builtin.shell: | 72 | set -o pipefail 73 | grep 'keyboard-interactive/pam for.*ssh2$' /var/log/auth.log | tail -n1 74 | args: 75 | executable: /bin/bash 76 | register: auth_log 77 | changed_when: auth_log.rc != 0 78 | when: 79 | - ansible_password is defined 80 | 81 | - name: Grep keyboard-interactive from /var/log/auth.log 82 | become: true 83 | environment: 84 | PATH: /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin 85 | ansible.builtin.shell: | 86 | set -o pipefail 87 | journalctl -u ssh | grep '(serial' | tail -n1 88 | args: 89 | executable: /bin/bash 90 | register: ssh_cert 91 | changed_when: ssh_cert.rc != 0 92 | when: 93 | - ansible_ssh_private_key_file is defined 94 | 95 | - name: Print authentication methods 96 | ansible.builtin.debug: 97 | msg: "{{ ssh_auth_methods.stdout }}" 98 | 99 | - name: Print authentication string 100 | ansible.builtin.debug: 101 | msg: "{{ vault_ssh.stdout }}" 102 | when: 103 | - vault_ssh_log.stat.exists 104 | 105 | - name: Print keyboard-interactive 106 | ansible.builtin.debug: 107 | msg: "{{ auth_log.stdout }}" 108 | when: 109 | - ansible_password is defined 110 | 111 | - name: Print cert serials 112 | ansible.builtin.debug: 113 | msg: "{{ ssh_cert.stdout }}" 114 | when: 115 | - ansible_ssh_private_key_file is defined 116 | -------------------------------------------------------------------------------- /random_password.md: -------------------------------------------------------------------------------- 1 | # Using HashiCorp Vault as a dynamic Ansible inventory and authentication service, part 2 2 | 3 | In [part one](./ssh_otp.md) HashiCorp Vault and the inventory script was used to 4 | set up OTP SSH authentication. 5 | 6 | In this part we'll expand that by adding password rotation to the `ANSIBLE_USER` 7 | account. 8 | 9 | ```console 10 | Do not use any of this without testing in a non-operational environment. 11 | ``` 12 | 13 | ## In summary 14 | 15 | - [SSH OTP authentication](./ssh_otp.md) 16 | - Configure `sudo` to require passwords 17 | - Rotate the [local user password](https://github.com/scarolan/painless-password-rotation), 18 | and use it as `ansible_become_password`. 19 | 20 | ## Vault and host configuration 21 | 22 | See [part one](./ssh_otp.md) for steps and details. 23 | 24 | ## Password rotation 25 | 26 | _This part is heavily inspired by [scarolan/painless-password-rotation](https://github.com/scarolan/painless-password-rotation)_ 27 | 28 | Enable [KV Secrets Engine](https://www.vaultproject.io/docs/secrets/kv) with the 29 | `systemcreds/` path on the Vault server: 30 | 31 | ```sh 32 | $ vault secrets enable -version=2 -path="systemcreds" kv 33 | Success! Enabled the kv secrets engine at: systemcreds/ 34 | ``` 35 | 36 | Upload the [rotate-linux.hcl](./vault_policies/rotate-linux.hcl) and 37 | [linuxadmin.hcl](vault_policies/linuxadmin.hcl) policies. 38 | 39 | ```sh 40 | $ vault policy write rotate-linux rotate-linux.hcl 41 | Success! Uploaded policy: rotate-linux 42 | $ vault policy write linuxadmin linuxadmin.hcl 43 | Success! Uploaded policy: linuxadmin 44 | ``` 45 | 46 | Create a authentication token for the `rotate-linux` policy with a 24 hour 47 | lifetime. 48 | 49 | ```sh 50 | $ vault token create -period 24h -policy rotate-linux 51 | Key Value 52 | --- ----- 53 | token hvs.CAESIA4OZQxuA8RSUeBIKrXe7Ui3... 54 | token_accessor 4I9EZYWOa7LaGh5K6uSpoxO6 55 | token_duration 24h 56 | token_renewable true 57 | token_policies ["default" "rotate-linux"] 58 | identity_policies [] 59 | policies ["default" "rotate-linux"] 60 | ``` 61 | 62 | The `token` value should be used as the `VAULT_TOKEN` on the managed servers, 63 | and both `VAULT_ADDR` and `VAULT_TOKEN` should be present in `/etc/environment` 64 | or equivalent. 65 | 66 | ### User policies 67 | 68 | The user `vagrant` with the password `HorsePassport` using the `ansible` and 69 | `linuxadmin` policies should be created or updated. 70 | 71 | On `vault`: 72 | 73 | ```sh 74 | $ vault write auth/userpass/users/vagrant password="HorsePassport" policies="ansible,linuxadmin" 75 | Success! Data written to: auth/userpass/users/vagrant 76 | ``` 77 | 78 | Copy [rotate_linux_password.sh](scripts/rotate_linux_password.sh) to the managed 79 | servers and generate a password for the user on each server. 80 | 81 | `bash ./rotate_linux_password.sh "$(id -un)"` 82 | 83 | Ensure that any `sudo` `NOPASSWD:` tags has been replaced with `PASSWD:` after 84 | a password has been generated and stored in Vault. 85 | 86 | On `admin`: 87 | 88 | ``` 89 | $ export VAULT_ADDR='http://192.168.56.40:8200' 90 | $ vault login -method=userpass username=vagrant password=HorsePassport 91 | Success! You are now authenticated. The token information displayed below 92 | is already stored in the token helper. You do NOT need to run "vault login" 93 | again. Future Vault requests will automatically use this token. 94 | 95 | Key Value 96 | --- ----- 97 | token hvs.CAESILn3hivHCO8UNJmAxGuQjf2RNsj... 98 | token_accessor jIdHDHIvHhptlM8druMRdDHN 99 | token_duration 768h 100 | token_renewable true 101 | token_policies ["ansible" "default" "linuxadmin"] 102 | identity_policies [] 103 | policies ["ansible" "default" "linuxadmin"] 104 | token_meta_username vagrant 105 | $ export VAULT_TOKEN='hvs.CAESILn3hivHCO8UNJmAxGuQjf2RNsj... 106 | $ ansible-inventory -i hvault_inventory.py --list --yaml 107 | all: 108 | children: 109 | vault_hosts: 110 | hosts: 111 | server01: 112 | ansible_become_password: uncurled-subtitle-unsocial-tightness-obstruct 113 | ansible_host: 192.168.56.41 114 | ansible_password: 7ff78fd7-3e40-6c53-f38d-5661b225f3a5 115 | ansible_port: 22 116 | ansible_user: vagrant 117 | server02: 118 | ansible_become_password: plethora-plod-jaybird-stopping-eternity 119 | ansible_host: 192.168.56.42 120 | ansible_password: 6b7e121d-01db-bea9-10e3-112fc4eb21b6 121 | ansible_port: 22 122 | ansible_user: vagrant 123 | $ ansible-playbook -i hvault_inventory.py playbook.yml 124 | 125 | PLAY [Test Hashicorp Vault dynamic inventory] ***************************************************** 126 | 127 | TASK [Get ssh host keys from vault_hosts group] *************************************************** 128 | ok: [server01 -> localhost] => (item=server01) 129 | ok: [server02 -> localhost] => (item=server01) 130 | ok: [server02 -> localhost] => (item=server02) 131 | ok: [server01 -> localhost] => (item=server02) 132 | 133 | TASK [Print ansible_password] ********************************************************************* 134 | ok: [server01] => { 135 | "msg": "74fef72d-5649-576b-b6bb-c5aa181ecae6" 136 | } 137 | ok: [server02] => { 138 | "msg": "6a6eaf29-51a6-82d2-cea5-541892c4c35b" 139 | } 140 | 141 | TASK [Print ansible_become_password] ************************************************************** 142 | ok: [server01] => { 143 | "msg": "d68f2d09-8327-4306-922d-522ebf4e53af" 144 | } 145 | ok: [server02] => { 146 | "msg": "e3620985-7abb-4c6e-bea6-8e471c1e6dfc" 147 | } 148 | 149 | TASK [Print ansible_ssh_private_key_file] ********************************************************* 150 | skipping: [server01] 151 | skipping: [server02] 152 | 153 | TASK [Stat vault-ssh.log] ************************************************************************* 154 | ok: [server02] 155 | ok: [server01] 156 | 157 | TASK [Grep authentication methods] **************************************************************** 158 | ok: [server02] 159 | ok: [server01] 160 | 161 | TASK [Grep authentication string from /var/log/vault-ssh.log] ************************************* 162 | ok: [server01] 163 | ok: [server02] 164 | 165 | TASK [Grep keyboard-interactive from /var/log/auth.log] ******************************************* 166 | ok: [server02] 167 | ok: [server01] 168 | 169 | TASK [Grep keyboard-interactive from /var/log/auth.log] ******************************************* 170 | skipping: [server01] 171 | skipping: [server02] 172 | 173 | TASK [Print authentication methods] *************************************************************** 174 | ok: [server01] => { 175 | "msg": "authenticationmethods any" 176 | } 177 | ok: [server02] => { 178 | "msg": "authenticationmethods any" 179 | } 180 | 181 | TASK [Print authentication string] ***************************************************************** 182 | ok: [server01] => { 183 | "msg": "2025/01/30 22:39:19 [INFO] vagrant@192.168.56.41 authenticated!" 184 | } 185 | ok: [server02] => { 186 | "msg": "2025/01/30 22:39:19 [INFO] vagrant@192.168.56.42 authenticated!" 187 | } 188 | 189 | TASK [Print keyboard-interactive] ******************************************************************* 190 | ok: [server01] => { 191 | "msg": "2025-01-30T22:39:19.913210+00:00 vagrant sshd[4079]: Accepted keyboard-interactive/pam... 192 | } 193 | ok: [server02] => { 194 | "msg": "2025-01-30T22:39:19.923243+00:00 vagrant sshd[3829]: Accepted keyboard-interactive/pam... 195 | } 196 | 197 | TASK [Print cert serials] *************************************************************************** 198 | skipping: [server01] 199 | skipping: [server02] 200 | ``` 201 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:base" 5 | ], 6 | "packageRules": [ 7 | { 8 | "matchUpdateTypes": ["minor", "patch", "pin", "digest"], 9 | "automerge": true, 10 | "automergeType": "branch" 11 | }, 12 | { 13 | "matchDepTypes": ["devDependencies"], 14 | "automerge": true 15 | } 16 | ], 17 | "platformAutomerge": true 18 | } 19 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | black==25.1.0 2 | codespell==2.4.1 3 | ruff==0.11.12 4 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | hvac==2.3.0 2 | pycurl==7.45.6 3 | simplejson==3.20.1 4 | -------------------------------------------------------------------------------- /ruff.toml: -------------------------------------------------------------------------------- 1 | line-length = 119 2 | lint.ignore = ["T201"] 3 | -------------------------------------------------------------------------------- /scripts/admin_server_installation.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -eux 3 | 4 | apt-get update 5 | apt-get --assume-yes install curl jq libcurl4-openssl-dev libssl-dev \ 6 | python3-pip sshpass unzip 7 | 8 | python3 -m pip install -U --break-system-packages ansible hvac pycurl 9 | 10 | curl -fsSL https://apt.releases.hashicorp.com/gpg |\ 11 | gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg 12 | 13 | echo "Types: deb 14 | URIs: https://apt.releases.hashicorp.com 15 | Suites: $(lsb_release -cs) 16 | Components: main 17 | Architectures: $(dpkg --print-architecture) 18 | Signed-by: /usr/share/keyrings/hashicorp-archive-keyring.gpg" | tee /etc/apt/sources.list.d/hashicorp.sources 19 | 20 | apt-get update 21 | apt-get --assume-yes install vault 22 | -------------------------------------------------------------------------------- /scripts/rotate_linux_password.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Source: https://github.com/scarolan/painless-password-rotation/blob/master/files/rotate_linux_password.sh 3 | # Script for rotating passwords on the local machine. 4 | # Make sure and store VAULT_TOKEN and VAULT_ADDR as environment variables. 5 | 6 | set -e -o pipefail 7 | 8 | for dep in curl jq logger; do 9 | if ! command -v "${dep}" 1>/dev/null; then 10 | echo "Missing dependency: ${dep}. Exiting." 11 | exit 1 12 | fi 13 | done 14 | 15 | if [ "${VAULT_TOKEN}" = "" ]; then 16 | echo "VAULT_TOKEN is missing. Exiting." 17 | exit 1 18 | fi 19 | 20 | if [ "${VAULT_ADDR}" = "" ]; then 21 | echo "VAULT_ADDR is missing. Exiting." 22 | exit 1 23 | fi 24 | 25 | # Check for usage 26 | if [[ $# -ne 1 ]]; then 27 | echo "Please provide a username." 28 | echo "Usage: $0 $(id -un)" 29 | exit 1 30 | fi 31 | 32 | USERNAME="$1" 33 | 34 | # Make sure the user exists on the local system 35 | if ! id "${USERNAME}" &>/dev/null; then 36 | echo "${USERNAME} does not exist. Exiting." 37 | exit 1 38 | fi 39 | 40 | # Renew our token before we do anything else 41 | if ! curl -sS --fail -X POST -H "X-Vault-Token: ${VAULT_TOKEN}" "${VAULT_ADDR}/v1/auth/token/renew-self" | grep -q 'request_id'; then 42 | echo "Error renewing Vault token lease. Exiting." 43 | exit 1 44 | fi 45 | 46 | NEWPASS="$(cat /proc/sys/kernel/random/uuid)" 47 | 48 | # Create the JSON payload to write to vault 49 | JSON="{ \"options\": { \"max_versions\": 12 }, \"data\": { \"${USERNAME}\": \"$NEWPASS\" } }" 50 | 51 | # First commit the new password to vault, then capture the exit status 52 | if curl -sS --fail -X POST -H "X-Vault-Token: ${VAULT_TOKEN}" --data "$JSON" "${VAULT_ADDR}/v1/systemcreds/data/linux/$(hostname -s)/${USERNAME}_creds"; then 53 | # After we save the password to vault, update it on the instance 54 | if echo "${USERNAME}:${NEWPASS}" | sudo chpasswd; then 55 | logger -p auth.info -t vault "Password for user ${USERNAME} was stored in Vault and updated locally." 56 | else 57 | logger --stderr -p auth.err -t vault "Password for ${USERNAME} was stored in Vault but NOT updated locally." 58 | fi 59 | else 60 | logger --stderr -p auth.err -t vault "Error saving new password to Vault. Local password will remain unchanged." 61 | exit 1 62 | fi 63 | -------------------------------------------------------------------------------- /scripts/vault_server_installation.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -eux 3 | 4 | VAULT_PLUGIN_DIRECTORY="/etc/vault.d/plugins" 5 | 6 | apt-get update 7 | apt-get --assume-yes install curl unzip 8 | 9 | curl -fsSL https://apt.releases.hashicorp.com/gpg |\ 10 | gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg 11 | 12 | echo "Types: deb 13 | URIs: https://apt.releases.hashicorp.com 14 | Suites: $(lsb_release -cs) 15 | Components: main 16 | Architectures: $(dpkg --print-architecture) 17 | Signed-by: /usr/share/keyrings/hashicorp-archive-keyring.gpg" | tee /etc/apt/sources.list.d/hashicorp.sources 18 | 19 | apt-get update 20 | apt-get --assume-yes install vault 21 | 22 | if ! grep -q 'plugin_directory' /etc/vault.d/vault.hcl; then 23 | echo "plugin_directory = ${VAULT_PLUGIN_DIRECTORY}" >> /etc/vault.d/vault.hcl 24 | else 25 | VAULT_PLUGIN_DIRECTORY="$(grep 'plugin_directory' /etc/vault.d/vault.hcl | awk '{print $NF}')" 26 | fi 27 | 28 | if ! [ -d "${VAULT_PLUGIN_DIRECTORY}" ]; then 29 | mkdir -p "${VAULT_PLUGIN_DIRECTORY}" 30 | fi 31 | 32 | echo "vault server -dev -dev-plugin-dir=\"${VAULT_PLUGIN_DIRECTORY}\" --dev-listen-address=$(hostname -I | awk '{print $2}') &> /tmp/vault.log &" 33 | -------------------------------------------------------------------------------- /scripts/vault_ssh_helper_installation.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -eux 3 | 4 | VAULT_SSH_VERSION="0.2.1" 5 | VAULT_SSH_CHECKSUM="fe26f62e5822bdf66ea4bf874d1a535ffca19af07a27ff3bcd7e344bc1af39fe" 6 | VAULT_SSH_FILENAME="vault-ssh-helper_${VAULT_SSH_VERSION}_linux_amd64.zip" 7 | 8 | VAULT_SERVER="192.168.56.40" 9 | 10 | apt-get update 11 | apt-get --assume-yes install jq unzip wget 12 | 13 | wget "https://releases.hashicorp.com/vault-ssh-helper/${VAULT_SSH_VERSION}/${VAULT_SSH_FILENAME}" 14 | 15 | if ! [ "$(sha256sum ${VAULT_SSH_FILENAME} | awk '{print $1}')" = "${VAULT_SSH_CHECKSUM}" ]; then 16 | echo "Checksum mismatch. Exiting." 17 | exit 1 18 | fi 19 | 20 | unzip -q "vault-ssh-helper_${VAULT_SSH_VERSION}_linux_amd64.zip" -d /usr/local/bin 21 | 22 | chmod 0755 /usr/local/bin/vault-ssh-helper 23 | chown root:root /usr/local/bin/vault-ssh-helper 24 | mkdir /etc/vault-ssh-helper.d/ 25 | 26 | tee /etc/vault-ssh-helper.d/config.hcl < ~/.ssh/id_ed25519.pub 87 | $ vault write -field=signed_key ssh-client-signer/sign/ssh-certs public_key=@$HOME/.ssh/id_ed25519.pub | ssh-keygen -Lf - 88 | (stdin):1: 89 | Type: ssh-ed25519-cert-v01@openssh.com user certificate 90 | Public key: ED25519-CERT SHA256:hfub1ct7tQKjjJI+O7IVr0FuzLfbsmcCyp/DnANW2jk 91 | Signing CA: RSA SHA256:nUXEUxYFJYu93Ch7xAglqpBsU3oiRvPzyuaMCajn2oI (using rsa-sha2-512) 92 | Key ID: "vault-userpass-vagrant-85fb9bd5cb7bb502a38c923e3bb215af416eccb7dbb26702ca9fc39c0356da39" 93 | Serial: 2071682612104208897 94 | Valid: from 2025-01-30T22:58:00 to 2025-01-30T23:28:30 95 | Principals: 96 | vagrant 97 | Critical Options: (none) 98 | Extensions: 99 | permit-pty 100 | $ vault write -field=signed_key ssh-client-signer/sign/ssh-certs public_key=@$HOME/.ssh/id_ed25519.pub > .ssh/id_ed25519-cert.pub 101 | $ ssh 192.168.56.41 'sudo journalctl -u ssh | grep ED25519-CERT' 102 | Jan 30 22:59:41 server01 sshd[4360]: Accepted publickey for vagrant from 192.168... 103 | $ ssh 192.168.56.42 'sudo journalctl -u ssh | grep ED25519-CERT' 104 | Jan 30 22:59:54 server02 sshd[4105]: Accepted publickey for vagrant from 192.168... 105 | ``` 106 | 107 | Verify that Ansible works as well: 108 | 109 | ```sh 110 | ansible-playbook --private-key ~/.ssh/id_ed25519-cert.pub -i hvault_inventory.py playbook.yml 111 | ``` 112 | 113 | ## Adding user public keys to K/V engine 114 | 115 | Convert user public key to a base64 string. 116 | 117 | ```sh 118 | $ cat ~/.ssh/id_ed25519.pub | base64 -w0 119 | c3NoLWVkMjU1MTkgQUFBQUMzTnphQzFsWkRJMU5URTVBQ... 120 | ``` 121 | 122 | Add it to the Vault K/V engine and verify: 123 | 124 | ``` 125 | $ vault kv put -mount=secret user-keys vagrant=c3NoLWVkMjU1MTkgQUFBQUMzTnphQ... 126 | $ vault kv get -field=vagrant -mount=secret user-keys | base64 -d 127 | ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINGUK3fVhpzejdnQafOhYIuUs/8tdMYajuQ3nryJm3i/ vagrant 128 | ``` 129 | 130 | Remove `~/.ssh/authorized_keys` on `server01` and `server02`. 131 | 132 | On `admin`, add the private key and run the test playbook: 133 | 134 | ``` 135 | $ ssh-add .ssh/id_ed25519 136 | Identity added: .ssh/id_ed25519 (vagrant) 137 | Certificate added: .ssh/id_ed25519-cert.pub (vault-userpass-vagrant-2cbb21473e...) 138 | $ ansible-playbook -i hvault_inventory.py playbook.yml 139 | 140 | PLAY [Test Hashicorp Vault dynamic inventory] ************************************ 141 | 142 | TASK [Get ssh host keys from vault_hosts group] ********************************** 143 | ok: [server01 -> localhost] => (item=server01) 144 | ok: [server02 -> localhost] => (item=server01) 145 | ok: [server01 -> localhost] => (item=server02) 146 | ok: [server02 -> localhost] => (item=server02) 147 | 148 | TASK [Print ansible_password] **************************************************** 149 | ok: [server01] => { 150 | "msg": "3539242a-526e-acb9-4264-0847c57ce03a" 151 | } 152 | ok: [server02] => { 153 | "msg": "47732c9c-4286-7a15-a6ee-4a3fdd94a966" 154 | } 155 | 156 | TASK [Print ansible_become_password] ********************************************* 157 | ok: [server01] => { 158 | "msg": "d68f2d09-8327-4306-922d-522ebf4e53af" 159 | } 160 | ok: [server02] => { 161 | "msg": "e3620985-7abb-4c6e-bea6-8e471c1e6dfc" 162 | } 163 | 164 | TASK [Print ansible_ssh_private_key_file] **************************************** 165 | ok: [server01] => { 166 | "msg": "/home/vagrant/.ssh/ansible_vagrant_cert.pub" 167 | } 168 | ok: [server02] => { 169 | "msg": "/home/vagrant/.ssh/ansible_vagrant_cert.pub" 170 | } 171 | 172 | TASK [Stat vault-ssh.log] ******************************************************** 173 | ok: [server01] 174 | ok: [server02] 175 | 176 | TASK [Grep authentication methods] *********************************************** 177 | ok: [server02] 178 | ok: [server01] 179 | 180 | TASK [Grep authentication string from /var/log/vault-ssh.log] ******************** 181 | ok: [server01] 182 | ok: [server02] 183 | 184 | TASK [Grep keyboard-interactive from /var/log/auth.log] ************************** 185 | ok: [server02] 186 | ok: [server01] 187 | 188 | TASK [Grep keyboard-interactive from /var/log/auth.log] ************************** 189 | ok: [server01] 190 | ok: [server02] 191 | 192 | TASK [Print authentication methods] ********************************************** 193 | ok: [server01] => { 194 | "msg": "authenticationmethods any" 195 | } 196 | ok: [server02] => { 197 | "msg": "authenticationmethods any" 198 | } 199 | 200 | TASK [Print authentication string] *********************************************** 201 | ok: [server01] => { 202 | "msg": "2025/01/30 23:01:27 [INFO] vagrant@192.168.56.41 authenticated!" 203 | } 204 | ok: [server02] => { 205 | "msg": "2025/01/30 23:01:27 [INFO] vagrant@192.168.56.42 authenticated!" 206 | } 207 | 208 | TASK [Print keyboard-interactive] ************************************************ 209 | ok: [server01] => { 210 | "msg": "2025-01-30T23:01:27.981233+00:00 vagrant sshd[4430]: Accepted keyboard-interactive/pam for vagrant from 192.168.56.39 port 43284 ssh2" 211 | } 212 | ok: [server02] => { 213 | "msg": "2025-01-30T23:01:27.937679+00:00 vagrant sshd[4175]: Accepted keyboard-interactive/pam for vagrant from 192.168.56.39 port 40956 ssh2" 214 | } 215 | 216 | TASK [Print cert serials] ******************************************************** 217 | ok: [server01] => { 218 | "msg": "Jan 30 23:07:16 server01 sshd[4847]: Accepted publickey for vagrant... 219 | } 220 | ok: [server02] => { 221 | "msg": "Jan 30 23:07:15 server02 sshd[4589]: Accepted publickey for vagrant... 222 | } 223 | ``` 224 | -------------------------------------------------------------------------------- /ssh_otp.md: -------------------------------------------------------------------------------- 1 | # Using HashiCorp Vault as a dynamic Ansible inventory and authentication service, part 1 2 | 3 | ## Introduction 4 | 5 | This is a example on how to use [HashiCorp Vault](https://www.hashicorp.com/products/vault) 6 | as a dynamic [Ansible](https://www.ansible.com/) inventory, and use the 7 | [One-Time SSH Password](https://learn.hashicorp.com/tutorials/vault/ssh-otp) 8 | functionality to create a one-time password every time Ansible makes a SSH 9 | connection into a managed host. 10 | 11 | In addition to SSH OTP, instructions on how to rotate local user passwords are 12 | available in [part two](./random_password.md). 13 | 14 | If you don't want to spend the time to 15 | [install Vault](https://learn.hashicorp.com/tutorials/vault/getting-started-install), 16 | the [vault-ssh-helper](https://github.com/hashicorp/vault-ssh-helper) 17 | you can use the available [Vagrantfile](https://www.vagrantup.com/) by running 18 | `vagrant up`. 19 | 20 | See [vault_server_installation.sh](./scripts/vault_server_installation.sh) and 21 | [vault_ssh_helper_installation.sh](./scripts/vault_ssh_helper_installation.sh) for 22 | the installation process. 23 | 24 | ```console 25 | Do not use any of this without testing in a non-operational environment. 26 | ``` 27 | 28 | ## The inventory script 29 | 30 | [hvault_inventory.py](./hvault_inventory.py) is a Python script that uses the 31 | [HashiCorp Vault API client](https://github.com/hvac/hvac) and [PycURL](http://pycurl.io/) 32 | libraries to communicate with the Vault server and generate a [dynamic inventory](https://docs.ansible.com/ansible/latest/inventory_guide/intro_dynamic_inventory.html) 33 | for use with Ansible. 34 | 35 | `hvault_inventory.py` reads a Vault path, `secret/ansible-hosts` by default, 36 | to get the list of managed hosts (`hostname:ip`), then uses the IP Addresses 37 | to write `/ssh/creds/otp_key_role` and retrive the created SSH OTP credentials. 38 | 39 | For password rotation the `"linux/" + name + "/" + ANSIBLE_USER + "_creds"` path 40 | is used, where `name` is the hostname and `ANSIBLE_USER` is the Ansible user, 41 | setting `ansible_become_password`. 42 | 43 | ## Vault and host onfiguration 44 | 45 | We will use Vagrant and the configured virtul machines, this will create four 46 | servers; `vault` which is the Vault server, `admin` which is server from where 47 | we'll run Ansible and two managed hosts named `server01` and `server02`. 48 | 49 | The admin server will have the IP address `192.168.56.39`, Vault server 50 | `192.168.56.40`, and the two hosts will use `192.168.56.41` and `192.168.56.42`. 51 | 52 | Make sure to update the addresses if you decide to use another environment. 53 | 54 | ### Configuration of the Vault server 55 | 56 | After the installation of the Vault server, we will use the ["Dev" Server Mode](https://developer.hashicorp.com/vault/docs/concepts/dev-server) 57 | just to get started quickly. 58 | 59 | On `vault`: 60 | 61 | ```sh 62 | $ vault server -dev -dev-plugin-dir="/etc/vault.d/plugins" --dev-listen-address=192.168.56.40:8200 &> /tmp/vault.log & 63 | $ grep -Eo '(VAULT_ADDR|Root Token).*' /tmp/vault.log 64 | $ export VAULT_ADDR='http://192.168.56.40:8200' 65 | $ export VAULT_TOKEN='hvs.vDkyJoiMWV3JuBn9sqd7g307' 66 | ``` 67 | 68 | #### KV Secrets Engine 69 | 70 | Using the [KV Secrets Engine](https://developer.hashicorp.com/vault/docs/secrets/kv) we'll 71 | add the names and IP addresses of the two hosts that will be managed by Ansible. 72 | 73 | On `vault`: 74 | 75 | ```sh 76 | $ vault kv put -mount=secret ansible-hosts server01=192.168.56.41 server02=192.168.56.42 77 | ====== Secret Path ====== 78 | secret/data/ansible-hosts 79 | 80 | ======= Metadata ======= 81 | Key Value 82 | --- ----- 83 | created_time 2025-01-30T21:39:07.645839328Z 84 | custom_metadata 85 | deletion_time n/a 86 | destroyed false 87 | version 1 88 | $ vault kv get -mount=secret ansible-hosts 89 | ====== Secret Path ====== 90 | secret/data/ansible-hosts 91 | 92 | ======= Metadata ======= 93 | Key Value 94 | --- ----- 95 | created_time 2025-01-30T21:39:07.645839328Z 96 | custom_metadata 97 | deletion_time n/a 98 | destroyed false 99 | version 1 100 | 101 | ====== Data ====== 102 | Key Value 103 | --- ----- 104 | server01 192.168.56.41 105 | server02 192.168.56.42 106 | ``` 107 | 108 | With `ansible-inventory -i hvault_inventory.py --list` we will verify that the 109 | script can read the Vault path and build a basic inventory. 110 | 111 | `ansible_user`, if not configured, is by default the `USER` environment 112 | variable. 113 | 114 | On `admin`: 115 | 116 | ```sh 117 | $ export VAULT_ADDR='http://192.168.56.40:8200' 118 | $ export VAULT_TOKEN='hvs.vDkyJoiMWV3JuBn9sqd7g307' 119 | $ python3 hvault_inventory.py --list 120 | { 121 | "_meta": { 122 | "hostvars": { 123 | "server01": { 124 | "ansible_host": "192.168.56.41", 125 | "ansible_user": "vagrant" 126 | }, 127 | "server02": { 128 | "ansible_host": "192.168.56.42", 129 | "ansible_user": "vagrant" 130 | } 131 | } 132 | }, 133 | "vault_hosts": [ 134 | "server01", 135 | "server02" 136 | ] 137 | } 138 | ``` 139 | 140 | Note that using the root token is [not in any way recommended](https://developer.hashicorp.com/vault/docs/concepts/tokens#root-tokens), 141 | and is used only for testing. 142 | 143 | #### SSH Secrets Engine 144 | 145 | In addition to using Vault as a basic inventory, we will use the 146 | [SSH Secrets Engine](https://learn.hashicorp.com/tutorials/vault/ssh-otp) to 147 | create one-time passwords for the SSH authentication. 148 | 149 | On the Vault server we first mount the secrets engine and then configure the 150 | role. 151 | 152 | On `vault`: 153 | 154 | ```sh 155 | $ vault secrets enable ssh 156 | Success! Enabled the ssh secrets engine at: ssh/ 157 | $ vault write ssh/roles/otp_key_role key_type=otp default_user=vagrant cidr_list=192.168.56.0/24 158 | Success! Data written to: ssh/roles/otp_key_role 159 | ``` 160 | 161 | Setting `default_user=vagrant` and `cidr_list=192.168.56.0/24` because we're 162 | using the Vagrant environment and the IP addresses configured. 163 | 164 | The [ansible.hcl](./vault_policies/ansible.hcl) policy grants a user the 165 | capabilites to read, create and update both the list of the Ansible managed 166 | hosts and the OTP role. 167 | 168 | ```sh 169 | $ tee ansible.hcl < WARNING: Dev mode is enabled! 225 | 2021/12/02 22:59:47 [INFO] using SSH mount point: ssh 226 | 2021/12/02 22:59:47 [INFO] using namespace: 227 | 2021/12/02 22:59:47 [INFO] vault-ssh-helper verification successful! 228 | ``` 229 | 230 | Ensure that `sshd` is configured with the following settings: 231 | 232 | ```sh 233 | echo "ChallengeResponseAuthentication yes 234 | UsePAM yes 235 | PasswordAuthentication no" | sudo tee /etc/ssh/sshd_config.d/99-ssh-otp.conf 236 | ``` 237 | 238 | Ensure that the `/etc/pam.d/sshd` file has the following settings: 239 | 240 | ```sh 241 | #@include common-auth 242 | auth requisite pam_exec.so quiet expose_authtok log=/var/log/vault-ssh.log /usr/local/bin/vault-ssh-helper -dev -config=/etc/vault-ssh-helper.d/config.hcl 243 | auth optional pam_unix.so use_first_pass nodelay 244 | ``` 245 | 246 | Note that with the `-dev` option set `vault-ssh-helper` communicates with Vault 247 | with TLS disabled. This is NOT recommended for production use. 248 | 249 | ## Usage 250 | 251 | The following is a step-by-step example on a host that is used as a Ansible 252 | management node. 253 | 254 | On `admin`: 255 | 256 | ```sh 257 | $ export VAULT_ADDR='http://192.168.56.40:8200' 258 | $ unset VAULT_TOKEN 259 | $ vault login -method=userpass username=vagrant password=HorsePassport 260 | Success! You are now authenticated. The token information displayed below 261 | is already stored in the token helper. You do NOT need to run "vault login" 262 | again. Future Vault requests will automatically use this token. 263 | 264 | Key Value 265 | --- ----- 266 | token hvs.CAESIAOrlcwOteUdSJRK49alyQmBFMGw_dgzn1CZM35gya... 267 | token_accessor gEcH7AMHezSPnhnpdi9F3sA0 268 | token_duration 768h 269 | token_renewable true 270 | token_policies ["ansible" "default"] 271 | identity_policies [] 272 | policies ["ansible" "default"] 273 | token_meta_username vagrant 274 | 275 | $ export VAULT_TOKEN='hvs.CAESIAOrlcwOteUdSJRK49alyQmBFMGw_dgzn1CZM35gya... 276 | $ ansible-inventory -i hvault_inventory.py --list 277 | { 278 | "_meta": { 279 | "hostvars": { 280 | "server01": { 281 | "ansible_host": "192.168.56.41", 282 | "ansible_password": "28c57a0d-5f74-1b34-285f-9305e707941b", 283 | "ansible_port": 22, 284 | "ansible_user": "vagrant" 285 | }, 286 | "server02": { 287 | "ansible_host": "192.168.56.42", 288 | "ansible_password": "22697bee-094c-dfd9-9fa5-f454571316fa", 289 | "ansible_port": 22, 290 | "ansible_user": "vagrant" 291 | } 292 | } 293 | }, 294 | "all": { 295 | "children": [ 296 | "ungrouped", 297 | "vault_hosts" 298 | ] 299 | }, 300 | "vault_hosts": { 301 | "hosts": [ 302 | "server01", 303 | "server02" 304 | ] 305 | } 306 | } 307 | $ for repeat in 1 2 3; do ansible-inventory -i hvault_inventory.py --host server01 | jq -r '.ansible_password'; done 308 | b368e271-7ccc-53ea-7eff-537a97a1f2f0 309 | 0e4259b4-76dc-f211-5893-3a0e3c54e1e9 310 | f47b81af-4fc5-d186-48e9-59cf2ce2e3d4 311 | ``` 312 | 313 | A sample [Ansible playbook](./playbook.yml) is used for additional verification 314 | and testing. 315 | 316 | ```sh 317 | $ ansible-playbook -i hvault_inventory.py playbook.yml 318 | 319 | PLAY [Test Hashicorp Vault dynamic inventory] ****************************************************** 320 | 321 | TASK [Get ssh host keys from vault_hosts group] **************************************************** 322 | ok: [server02 -> localhost] => (item=server01) 323 | ok: [server01 -> localhost] => (item=server01) 324 | ok: [server02 -> localhost] => (item=server02) 325 | ok: [server01 -> localhost] => (item=server02) 326 | 327 | TASK [Print ansible_password] ********************************************************************** 328 | ok: [server01] => { 329 | "msg": "31f64122-6b1d-9885-ec53-364ecd52616a" 330 | } 331 | ok: [server02] => { 332 | "msg": "16fa5ee6-337a-5c7d-9a95-49becdbc0248" 333 | } 334 | 335 | TASK [Print ansible_become_password] *************************************************************** 336 | skipping: [server01] 337 | skipping: [server02] 338 | 339 | TASK [Print ansible_ssh_private_key_file] ********************************************************** 340 | skipping: [server01] 341 | skipping: [server02] 342 | 343 | TASK [Stat vault-ssh.log] ************************************************************************** 344 | ok: [server02] 345 | ok: [server01] 346 | 347 | TASK [Grep authentication methods] ***************************************************************** 348 | ok: [server02] 349 | ok: [server01] 350 | 351 | TASK [Grep authentication string from /var/log/vault-ssh.log] ************************************** 352 | ok: [server01] 353 | ok: [server02] 354 | 355 | TASK [Grep keyboard-interactive from /var/log/auth.log] ******************************************** 356 | ok: [server02] 357 | ok: [server01] 358 | 359 | TASK [Grep keyboard-interactive from /var/log/auth.log] ******************************************** 360 | skipping: [server01] 361 | skipping: [server02] 362 | 363 | TASK [Print authentication methods] **************************************************************** 364 | ok: [server01] => { 365 | "msg": "authenticationmethods any" 366 | } 367 | ok: [server02] => { 368 | "msg": "authenticationmethods any" 369 | } 370 | 371 | TASK [Print authentication string] ****************************************************************** 372 | ok: [server01] => { 373 | "msg": "2025/01/30 22:17:54 [INFO] vagrant@192.168.56.41 authenticated!" 374 | } 375 | ok: [server02] => { 376 | "msg": "2025/01/30 22:17:54 [INFO] vagrant@192.168.56.42 authenticated!" 377 | } 378 | 379 | TASK [Print keyboard-interactive] ******************************************************************* 380 | ok: [server01] => { 381 | "msg": "2025-01-30T22:17:54.137334+00:00 vagrant sshd[3186]: Accepted keyboard-interactive/pam... 382 | } 383 | ok: [server02] => { 384 | "msg": "2025-01-30T22:17:54.083261+00:00 vagrant sshd[3124]: Accepted keyboard-interactive/pam... 385 | } 386 | 387 | TASK [Print cert serials] *************************************************************************** 388 | skipping: [server01] 389 | skipping: [server02] 390 | ``` 391 | -------------------------------------------------------------------------------- /vault_policies/ansible.hcl: -------------------------------------------------------------------------------- 1 | path "secret/data/ansible-hosts" { 2 | capabilities = ["read", "create", "update"] 3 | } 4 | 5 | path "ssh/*" { 6 | capabilities = [ "list" ] 7 | } 8 | 9 | path "ssh/creds/otp_key_role" { 10 | capabilities = ["create", "read", "update"] 11 | } 12 | -------------------------------------------------------------------------------- /vault_policies/linuxadmin.hcl: -------------------------------------------------------------------------------- 1 | # Source: https://github.com/scarolan/painless-password-rotation/blob/master/policies/linuxadmin.hcl 2 | # Allows admins to read passwords. 3 | path "systemcreds/*" { 4 | capabilities = ["list"] 5 | } 6 | path "systemcreds/data/linux/*" { 7 | capabilities = ["list", "read"] 8 | } 9 | -------------------------------------------------------------------------------- /vault_policies/rotate-linux.hcl: -------------------------------------------------------------------------------- 1 | # Source: https://github.com/scarolan/painless-password-rotation/blob/master/policies/rotate-linux.hcl 2 | # Allows hosts to write new passwords 3 | path "systemcreds/data/linux/*" { 4 | capabilities = ["create", "update"] 5 | } 6 | 7 | # Allow hosts to generate new passphrases 8 | path "gen/passphrase" { 9 | capabilities = ["update"] 10 | } 11 | 12 | # Allow hosts to generate new passwords 13 | path "gen/password" { 14 | capabilities = ["update"] 15 | } 16 | -------------------------------------------------------------------------------- /vault_policies/ssh-certs.hcl: -------------------------------------------------------------------------------- 1 | path "secret/data/user-keys" { 2 | capabilities = ["read"] 3 | } 4 | 5 | path "ssh-client-signer/roles/*" { 6 | capabilities = ["list"] 7 | } 8 | 9 | path "ssh-client-signer/sign/ssh-certs" { 10 | capabilities = ["create", "update"] 11 | } 12 | -------------------------------------------------------------------------------- /vault_policies/ssh-certs.json: -------------------------------------------------------------------------------- 1 | { 2 | "algorithm_signer": "rsa-sha2-256", 3 | "allow_user_certificates": true, 4 | "allowed_users": "*", 5 | "allowed_extensions": "permit-pty,permit-port-forwarding", 6 | "default_extensions": { 7 | "permit-pty": "" 8 | }, 9 | "key_type": "ca", 10 | "default_user": "vagrant", 11 | "ttl": "30m0s" 12 | } 13 | --------------------------------------------------------------------------------