diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 5547514..4a9d811 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -22,6 +22,9 @@ jobs: exclude: - python_version: "3.9" ansible_version: "2.18" + type: + - openbao + - vault steps: - name: Github Checkout 🛎 uses: actions/checkout@v4 @@ -42,4 +45,4 @@ jobs: - name: Run integration tests 🧪 run: | - ansible-playbook -i tests/inventory -v tests/*.yml -e ansible_python_interpreter=$(which python3) + ansible-playbook -i tests/inventory -v tests/test_${{ matrix.type }}.yml -e ansible_python_interpreter=$(which python3) diff --git a/galaxy.yml b/galaxy.yml index 21b48e0..5904ad8 100644 --- a/galaxy.yml +++ b/galaxy.yml @@ -2,7 +2,7 @@ namespace: stackhpc name: hashicorp description: > Hashicorp Vault/Consul deployment and configuration -version: "2.5.1" +version: "2.6.0" readme: "README.md" authors: - "Michał Nasiadka" @@ -11,6 +11,7 @@ authors: - "Pierre Riteau" - "Bartosz Bezak" - "Kyle Dean" + - "Jack Hodgkiss" dependencies: "community.docker": "*" license: @@ -21,4 +22,5 @@ tags: - infrastructure - security - vault + - openbao repository: "https://github.com/stackhpc/ansible-collection-hashicorp" diff --git a/roles/openbao/README.md b/roles/openbao/README.md new file mode 100644 index 0000000..01f07dc --- /dev/null +++ b/roles/openbao/README.md @@ -0,0 +1,129 @@ +This role deploys and initializes OpenBao with a Raft backend. + +Requirements +------------ + +`ansible-modules-hashivault` Python package installed on the Ansible control host +`hvac` Python package installed on the remote hosts + +Note that since version `4.6.4`, `ansible-modules-hashivault` requires +`ansible>4`. + +Role variables +-------------- + +* Common variables + * Optional + * `openbao_registry_url`: Address of the Docker registry used to authenticate (default: "") + * `openbao_registry_username`: Username used to authenticate with the Docker registry (default: "") + * `openbao_registry_password`: Password used to authenticate with the Docker registry (default: "") + +* OpenBao + * Mandatory + * `openbao_cluster_name`: OpenBao cluster name (e.g. "prod_cluster") + * `openbao_config_dir`: Directory into which to bind mount OpenBao configuration + * Optional + * `openbao_bind_address`: Which IP address should OpenBao bind to (default: "127.0.0.1") + * `openbao_api_addr`: OpenBao [API addr](https://openbao.org/docs/configuration/#high-availability-parameters) - Full URL including protocol and port (default: "http://127.0.0.1:8200") + * `openbao_init_addr`: OpenBao init addr (used only for initialisation purposes) - full URL including protocol and port (default: "http://127.0.0.1:8200") + * `openbao_docker_name`: Docker - under which name to run the OpenBao image (default: "bao") + * `openbao_docker_image`: Docker image for OpenBao (default: "openbao/openbao") + * `openbao_docker_tag`: Docker image tag for OpenBao (default: "latest") + * `openbao_extra_volumes`: List of `":"` + * `openbao_ca_cert`: Path to CA certificate used to verify OpenBao server TLS cert + * `openbao_tls_key`: Path to TLS key to use by OpenBao + * `openbao_tls_cert`: Path to TLS cert to use by OpenBao + * `openbao_log_keys`: Whether to log the root token and unseal keys in the Ansible output. Default `false` + * `openbao_set_keys_fact`: Whether to set a `openbao_keys` fact containing the root token and unseal keys. Default `false` + * `openbao_write_keys_file`: Whether to write the root token and unseal keys to a file. Default `false` + * `openbao_write_keys_file_host`: Host on which to write root token and unseal keys. Default `localhost` + * `openbao_write_keys_file_path`: Path of file to write root token and unseal keys. Default `bao-keys.json` + +Root and unseal keys +-------------------- + +After OpenBao has been initialised, a root token and a set of unseal keys are emitted. +It is very important to store these keys safely and securely. +This role provides several mechanisms for extracting the root token and unseal keys: + +1. Print to Ansible log output (`openbao_log_keys`) +1. Set a `openbao_keys` fact (`openbao_set_keys_fact`) +1. Write to a file (`openbao_write_keys_file`) + +In each case, the output will contain the following: + +```json +{ + "keys": [ + "...", + "..." + ], + "keys_base64": [ + "...", + "..." + ], + "root_token": "..." +} +``` + +Example playbook (used with OpenStack Kayobe) +--------------------------------------------- + +``` +--- +- name: Prepare for OpenBao role + any_errors_fatal: True + gather_facts: True + hosts: consul + tasks: + - name: Ensure /opt/kayobe/bao exists + file: + path: /opt/kayobe/bao + state: directory + + - name: Template out tls key and cert + vars: + tls_files: + - content: "{{ secrets_external_tls_cert }}" + dest: "tls.cert" + - content: "{{ secrets_external_tls_key }}" + dest: "tls.key" + copy: + content: "{{ item.content }}" + dest: "/opt/kayobe/bao/{{ item.dest }}" + owner: 100 + group: 1001 + mode: 0600 + loop: "{{ tls_files }}" + no_log: True + become: true + +- name: Run OpenBao role + any_errors_fatal: True + gather_facts: True + hosts: consul + roles: + - role: stackhpc.hashicorp.openbao + openbao_bind_address: "{{ external_net_ips[inventory_hostname] }}" + openbao_api_addr: "https://{{ external_net_fqdn }}:8200" + openbao_config_dir: "/opt/kayobe/bao" +``` + +Example post-config playbook to enable secrets engines: +``` +--- +- name: OpenBao post deployment config + any_errors_fatal: True + gather_facts: True + hosts: bao + tasks: + - name: Enable bao secrets engines + hashivault_secret_engine: + url: "https://vault.example.com:8200" + token: "{{ secrets_openbao_keys.root_token }}" + name: pki + backend: pki + run_once: True +``` + +NOTE: secrets_external_tls_cert/key are variables in Kayobe's secrets.yml diff --git a/roles/openbao/defaults/main.yml b/roles/openbao/defaults/main.yml new file mode 100644 index 0000000..e57351b --- /dev/null +++ b/roles/openbao/defaults/main.yml @@ -0,0 +1,86 @@ +--- +openbao_registry_url: "" +openbao_registry_username: "" +openbao_registry_password: "" + +openbao_docker_name: "bao" +openbao_docker_image: "openbao/openbao" +openbao_docker_tag: "latest" + +openbao_cluster_name: "" +openbao_protocol: "{{ 'https' if openbao_tls_key and openbao_tls_cert else 'http' }}" +# Allow openbao_vip_url and openbao_vip_address for backwards compatibility. +openbao_vip_address: "{{ openbao_vip_url | default(openbao_bind_address) }}" +openbao_api_addr: "{{ openbao_protocol ~ '://' ~ openbao_vip_address ~ ':8200' }}" +openbao_bind_address: "127.0.0.1" +openbao_init_addr: "http://127.0.0.1:8200" +openbao_tls_key: "" +openbao_tls_cert: "" + +openbao_config_dir: "" + +openbao_config: > + { + "cluster_name": "{{ openbao_cluster_name }}", + "ui": false, + "api_addr": "{{ openbao_api_addr }}", + "cluster_addr": "http://127.0.0.1:8201", + "listener": [{ + "tcp": { + "address": "{{ openbao_bind_address }}:8200", + {% if openbao_tls_key and openbao_tls_cert %} + "tls_min_version": "tls12", + "tls_key_file": "/bao/config/{{ openbao_tls_key }}", + "tls_cert_file": "/bao/config/{{ openbao_tls_cert }}" + {% else %} + "tls_disable": "true" + {% endif %} + }{% if openbao_bind_address != '127.0.0.1' %}, + }, + { + "tcp": { + "address": "127.0.0.1:8200", + "tls_disable": "true" + } + {% endif %} + }], + "storage": { + "raft": { + "node_id": "raft_{{ ansible_facts.nodename }}", + "path": "/openbao/file" + } + }, + "telemetry": { + "prometheus_retention_time": "30s", + "disable_hostname": true + } + } + +# Docker options +openbao_container: {} + +# Docker volumes +# Default volume mapping +_openbao_default_volumes: + - "{{ openbao_config_dir | default('openbao_config', true) }}:/openbao/config" + - "openbao_file:/openbao/file" + - "openbao_logs:/openbao/logs" + +# Exposed for playbooks to access later +openbao_extra_volumes: [] + +# Combined volume lists +_openbao_volumes: "{{ _openbao_default_volumes + openbao_extra_volumes }}" + +# Whether to log the root token and unseal keys in the Ansible output. +openbao_log_keys: false + +# Whether to set a openbao_keys fact containing the root token and unseal keys. +openbao_set_keys_fact: false + +# Whether to write the root token and unseal keys to a file. +openbao_write_keys_file: false +# Host on which to write root token and unseal keys. +openbao_write_keys_file_host: localhost +# Path of file to write root token and unseal keys. +openbao_write_keys_file_path: bao-keys.json diff --git a/roles/openbao/meta/main.yml b/roles/openbao/meta/main.yml new file mode 100644 index 0000000..d33405f --- /dev/null +++ b/roles/openbao/meta/main.yml @@ -0,0 +1,6 @@ +--- +# WORKAROUND: Without this, we see the following in some setups: +# ERROR! couldn't resolve module/action 'hashivault_unseal'. This often indicates a misspelling, missing collection, or incorrect module path. +# Seen using Kayobe on Ansible 2.10.17, running modules on a remote host. +collections: + - community.docker diff --git a/roles/openbao/tasks/main.yml b/roles/openbao/tasks/main.yml new file mode 100644 index 0000000..3d7935c --- /dev/null +++ b/roles/openbao/tasks/main.yml @@ -0,0 +1,6 @@ +--- +- name: "Vault Prerequisites" + import_tasks: prereqs.yml + +- name: "Deploy OpenBao" + import_tasks: openbao.yml diff --git a/roles/openbao/tasks/openbao.yml b/roles/openbao/tasks/openbao.yml new file mode 100644 index 0000000..c29dcd0 --- /dev/null +++ b/roles/openbao/tasks/openbao.yml @@ -0,0 +1,59 @@ +--- +- name: Ensure OpenBao container is running + docker_container: + name: "{{ openbao_docker_name }}" + image: "{{ openbao_docker_image }}:{{ openbao_docker_tag }}" + network_mode: host + etc_hosts: "{{ openbao_container.etc_hosts | default(omit) }}" + capabilities: IPC_LOCK + volumes: "{{ _openbao_volumes }}" + comparisons: + '*': strict + restart_policy: "always" + env: + BAO_LOCAL_CONFIG: "{{ openbao_config | to_json }}" + command: > + server + become: true + +- name: Check if OpenBao is initialized + uri: + url: "{{ openbao_init_addr }}/v1/sys/init" + register: openbao_init_status + retries: 50 + delay: 1 + run_once: true + until: openbao_init_status.status == 200 + +- name: "Initialize OpenBao" + run_once: true + when: + - not openbao_init_status.json.initialized + block: + - name: Initialize OpenBao + hashivault_init: + url: "{{ openbao_init_addr }}" + ca_cert: "{{ openbao_ca_cert | default(omit) }}" + no_log: true + register: openbao_keys_result + + - name: Print OpenBao keys + debug: + var: openbao_keys_result + when: + - openbao_log_keys | bool + + - name: Set openbao_keys fact + set_fact: + openbao_keys: "{{ openbao_keys_result }}" + when: + - openbao_set_keys_fact | bool + + - name: Write OpenBao keys to a file + copy: + content: "{{ openbao_keys_result | to_nice_json }}" + dest: "{{ openbao_write_keys_file_path }}" + mode: "0600" + delegate_to: "{{ openbao_write_keys_file_host }}" + when: + - openbao_write_keys_file | bool diff --git a/roles/openbao/tasks/prereqs.yml b/roles/openbao/tasks/prereqs.yml new file mode 100644 index 0000000..782ec40 --- /dev/null +++ b/roles/openbao/tasks/prereqs.yml @@ -0,0 +1,8 @@ +--- +- name: Log into Docker registry + docker_login: + registry: "{{ openbao_registry_url }}" + username: "{{ openbao_registry_username }}" + password: "{{ openbao_registry_password }}" + when: openbao_registry_username | length > 0 + become: true diff --git a/tests/inventory b/tests/inventory index 02318e3..15748a7 100644 --- a/tests/inventory +++ b/tests/inventory @@ -1,2 +1,5 @@ [consul] localhost ansible_connection=local + +[openbao] +localhost ansible_connection=local diff --git a/tests/test_openbao.yml b/tests/test_openbao.yml new file mode 100644 index 0000000..b92fb99 --- /dev/null +++ b/tests/test_openbao.yml @@ -0,0 +1,138 @@ +--- +- name: Prepare for openbao role + gather_facts: true + hosts: openbao + vars: + openbao_config_dir: "/etc/openbao" + openbao_log_keys: true + openbao_api_addr: "{{ 'http' ~ '://' ~ '127.0.0.1' ~ ':8200' }}" + openbao_set_keys_fact: true + openbao_write_keys_file: true + tasks: + - name: Debug + ansible.builtin.debug: + var: openbao_api_addr + + - name: Ensure /etc/openbao exists + ansible.builtin.file: + path: /etc/openbao + state: directory + mode: "0700" + become: true + + - name: Include openbao role + ansible.builtin.include_role: + name: openbao + + - name: Include openbao role (idemoptence test) + ansible.builtin.include_role: + name: openbao + + - name: Unseal vault + ansible.builtin.include_role: + name: vault_unseal + vars: + vault_api_addr: "{{ openbao_api_addr }}" + vault_unseal_keys: "{{ openbao_keys.keys_base64 }}" + + - name: Configure PKI - create root/intermediate and generate certificates + vars: + vault_pki_certificate_subject: + - role: 'ServerCert' + common_name: "OS-CERT-TEST" + extra_params: + ttl: "8760h" + ip_sans: "127.0.0.1" + alt_names: "example.com" + exclude_cn_from_sans: true + vault_pki_certificates_directory: "/tmp/" + vault_pki_generate_certificates: true + vault_pki_intermediate_ca_name: "OS-TLS-INT" + vault_pki_intermediate_create: true + vault_pki_intermediate_roles: + - name: "ServerCert" + config: + max_ttl: 8760h + ttl: 8760h + allow_any_name: true + allow_ip_sans: true + require_cn: false + server_flag: true + key_type: rsa + key_bits: 4096 + country: ["UK"] + locality: ["Bristol"] + organization: ["StackHPC"] + ou: ["HPC"] + vault_pki_root_ca_name: "OS-TLS-ROOT" + vault_pki_root_create: true + vault_pki_write_certificate_files: true + vault_pki_write_int_ca_to_file: true + vault_pki_write_pem_bundle: false + vault_pki_write_root_ca_to_file: true + vault_api_addr: "{{ openbao_api_addr }}" + vault_token: "{{ openbao_keys.root_token }}" + block: + - name: Configure PKI - create root/intermediate and generate certificates + ansible.builtin.include_role: + name: vault_pki + + - name: Configure PKI - create root/intermediate and generate certificates (idempotence test) + ansible.builtin.include_role: + name: vault_pki + + - name: Configure PKI - generate certificate pem bundle + vars: + vault_pki_certificate_subject: + - role: 'ServerCert' + common_name: "OS-CERT-TEST2" + extra_params: + ttl: "8760h" + ip_sans: "192.168.38.72" + exclude_cn_from_sans: true + vault_pki_certificates_directory: "/tmp/" + vault_pki_generate_certificates: true + vault_pki_intermediate_ca_name: "OS-TLS-INT" + vault_pki_intermediate_create: false + vault_pki_root_ca_name: "OS-TLS-ROOT" + vault_pki_root_create: false + vault_pki_write_certificate_files: true + vault_pki_write_pem_bundle: true + vault_api_addr: "{{ openbao_api_addr }}" + vault_token: "{{ openbao_keys.root_token }}" + block: + - name: Configure PKI - generate certificate pem bundle + ansible.builtin.include_role: + name: vault_pki + + - name: Configure PKI - generate certificate pem bundle (idempotence test) + ansible.builtin.include_role: + name: vault_pki + + - name: Validate if certificates exist + ansible.builtin.stat: + path: "/tmp/{{ item }}" + register: stat_result + failed_when: not stat_result.stat.exists + loop: + - OS-CERT-TEST.crt + - OS-CERT-TEST2.pem + + - name: Concatenate CAs + ansible.builtin.shell: | + cat /tmp/OS-TLS-ROOT.pem /tmp/OS-TLS-INT.crt > /tmp/CA-CHAIN.pem + args: + executable: /bin/bash + become: true + changed_when: true + + - name: Verify certificate chain + ansible.builtin.command: | + openssl verify -CAfile /tmp/CA-CHAIN.pem + /tmp/{{ item }} + register: verify_result + failed_when: verify_result.rc != 0 + loop: + - OS-CERT-TEST.crt + - OS-CERT-TEST2.pem + changed_when: false