diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fb63cb376..10c21d23c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -65,6 +65,10 @@ jobs: database: external box: centos/stream9 iop: disabled + - certificate_source: custom_server + security: none + database: internal + box: centos/stream9 runs-on: ubuntu-24.04 env: FOREMANCTL_BASE_BOX: ${{ matrix.box }} @@ -97,6 +101,10 @@ jobs: if: contains(matrix.certificate_source, 'installer') run: | ./forge installer-certs + - name: Create custom certificates + if: matrix.certificate_source == 'custom_server' + run: | + ./forge custom-certs - name: Setup security mode ${{ matrix.security }} if: matrix.security != 'none' run: | @@ -114,7 +122,7 @@ jobs: ./foremanctl pull-images - name: Run deployment run: | - ./foremanctl deploy --certificate-source=${{ matrix.certificate_source }} ${{ matrix.database == 'external' && '--database-mode=external --database-host=database.example.com --database-ssl-ca $(pwd)/.var/lib/foremanctl/db-ca.crt --database-ssl-mode verify-full' || '' }} --foreman-initial-admin-password=changeme --initial-organization "Foreman CI" --initial-location "Internet" --tuning development + ./foremanctl deploy --certificate-source=${{ matrix.certificate_source }} ${{ matrix.database == 'external' && '--database-mode=external --database-host=database.example.com --database-ssl-ca $(pwd)/.var/lib/foremanctl/db-ca.crt --database-ssl-mode verify-full' || '' }} ${{ matrix.certificate_source == 'custom_server' && '--certificate-server-certificate /root/custom-certificates/certs/quadlet.example.com.crt --certificate-server-key /root/custom-certificates/private/quadlet.example.com.key --certificate-server-ca-certificate /root/custom-certificates/certs/server-ca.crt' || '' }} --foreman-initial-admin-password=changeme --initial-organization "Foreman CI" --initial-location "Internet" --tuning development - name: Add optional feature - hammer run: | ./foremanctl deploy --add-feature hammer diff --git a/development/playbooks/custom-certs/custom-certs.yaml b/development/playbooks/custom-certs/custom-certs.yaml new file mode 100644 index 000000000..fc64b29ad --- /dev/null +++ b/development/playbooks/custom-certs/custom-certs.yaml @@ -0,0 +1,13 @@ +--- +- name: Generate custom certificates for testing + hosts: + - quadlet + become: true + vars: + certificates_ca_directory: /root/custom-certificates + certificates_ca_password: "CUSTOMCA" + certificates_ca_subject: 'Custom Test CA' + certificates_hostnames: + - "{{ ansible_facts['fqdn'] }}" + roles: + - role: certificates diff --git a/development/playbooks/deploy-dev/deploy-dev.yaml b/development/playbooks/deploy-dev/deploy-dev.yaml index e505f9b55..2832ff01c 100644 --- a/development/playbooks/deploy-dev/deploy-dev.yaml +++ b/development/playbooks/deploy-dev/deploy-dev.yaml @@ -5,7 +5,7 @@ vars_files: - "../../../src/vars/defaults.yml" - "../../../src/vars/flavors/{{ flavor }}.yml" - - "../../../src/vars/{{ certificate_source }}_certificates.yml" + - "../../../src/vars/{{ certificates_source }}_certificates.yml" - "../../../src/vars/images.yml" - "../../../src/vars/database.yml" - "../../../src/vars/foreman.yml" diff --git a/docs/user/certificates.md b/docs/user/certificates.md index ae5fb161a..310419375 100644 --- a/docs/user/certificates.md +++ b/docs/user/certificates.md @@ -6,13 +6,19 @@ This document describes how certificate generation and management works in forem ### Certificate Sources -foremanctl supports two certificate sources that determine how certificates are obtained: +foremanctl supports three certificate sources that determine how certificates are obtained: **Default Source (`certificate_source: default`)** - Automatically generates self-signed certificates during deployment - Creates a complete PKI infrastructure with CA, server, and client certificates - Recommended for development and testing environments +**Custom Server Source (`certificate_source: custom_server`)** +- Uses custom server certificates provided by the user (e.g., signed by your organization's CA) +- Automatically generates an internal CA for client certificates and localhost +- Server certificate, key, and CA bundle are copied to `/root/certificates/` +- Certificate source persists across deployments; original files only needed on first deploy or when updating certificates + **Installer Source (`certificate_source: installer`)** - Uses existing certificates from a previous `foreman-installer` deployment - Useful for migration scenarios where certificates already exist @@ -30,6 +36,29 @@ foremanctl deploy foremanctl deploy --certificate-source=default ``` +#### Using Custom Server Certificates + +```bash +# First deployment with custom certificates +foremanctl deploy \ + --certificate-source=custom_server \ + --certificate-server-certificate /path/to/server.crt \ + --certificate-server-key /path/to/server.key \ + --certificate-server-ca-certificate /path/to/ca-bundle.crt + +# Subsequent deployments (certificate paths no longer needed) +foremanctl deploy + +# Update certificates (provide new paths) +foremanctl deploy \ + --certificate-server-certificate /new/path/server.crt \ + --certificate-server-key /new/path/server.key \ + --certificate-server-ca-certificate /new/path/ca-bundle.crt + +# Switch back to auto-generated certificates +foremanctl deploy --certificate-source=default +``` + #### Using Existing Installer Certificates ```bash @@ -46,6 +75,12 @@ After deployment, certificates are available at: - Server Certificate: `/root/certificates/certs/.crt` - Client Certificate: `/root/certificates/certs/-client.crt` +**Custom Server Source:** +- CA Certificate: `/root/certificates/certs/ca.crt` (internal CA) +- Server Certificate: `/root/certificates/certs/.crt` (custom, user-provided) +- Server CA Certificate: `/root/certificates/certs/server-ca.crt` (custom CA that signed server cert) +- Client Certificate: `/root/certificates/certs/-client.crt` (generated by internal CA) + **Installer Source:** - CA Certificate: `/root/ssl-build/katello-default-ca.crt` - Server Certificate: `/root/ssl-build//-apache.crt` @@ -94,9 +129,10 @@ The `--certificate-renew` flag is **not persisted** in foremanctl’s answers fi ### Current Limitations -- Cannot provide custom certificate files during deployment - Uses the same lifetime for both client and server certificates - Limited certificate customization options +- Custom server certificates cannot be combined with `certificate_source: installer` +- CNAMEs are only applied to certificates generated by the internal CA ## Internal Design @@ -109,14 +145,17 @@ The certificate system uses a modular Ansible role-based approach with clear sep ``` src/roles/certificates/ ├── tasks/ -│ ├── main.yml # Entry point - orchestrates CA and certificate generation -│ ├── ca.yml # CA certificate generation -│ └── issue.yml # Host certificate issuance (server + client per hostname) -└── defaults/main.yml # Default configuration variables (validity, algorithm, paths) +│ ├── main.yml # Entry point for certificate management +│ ├── ca.yml # CA certificate generation +│ ├── issue.yml # Host certificate issuance +│ └── custom.yml # Applies user-provided custom server certs +└── defaults/main.yml # Default configuration variables ``` #### Certificate Generation Workflow +For `certificate_source: default`: + 1. **CA Generation** (when `certificates_ca: true`): - Install dependencies (`python3-cryptography`) and create directory layout under `certificates_ca_directory` - Generate RSA private key (size from `certificates_algorithm_size`, default 4096) @@ -129,6 +168,17 @@ src/roles/certificates/ Generation uses **`community.crypto`** (keys, CSRs, X.509) and **`python3-cryptography`**. +For `certificate_source: custom_server`: + +1. **CA Generation**: Generate self-signed internal CA certificate and key with 20-year validity +2. **Custom Server Certificates**: Copy the custom server cert, key, and CA bundle from user-provided paths to `/root/certificates/` (only when certificate paths are provided) +3. **Host Certificate Issuance**: Generate client certificate and localhost certificate signed by the internal CA (server cert for FQDN is skipped) + +For `certificate_source: installer`: + +- Uses existing certificates from `/root/ssl-build/` generated by foreman-installer +- No certificate generation performed; files must already exist + #### Variable System Certificate paths are defined in source-specific variable files: @@ -136,14 +186,22 @@ Certificate paths are defined in source-specific variable files: **Default Source (`src/vars/default_certificates.yml`):** ```yaml ca_certificate: "{{ certificates_ca_directory }}/certs/ca.crt" +ca_bundle: "{{ certificates_ca_directory }}/certs/ca-bundle.crt" server_certificate: "{{ certificates_ca_directory }}/certs/{{ ansible_facts['fqdn'] }}.crt" +server_ca_certificate: "{{ certificates_ca_directory }}/certs/server-ca.crt" client_certificate: "{{ certificates_ca_directory }}/certs/{{ ansible_facts['fqdn'] }}-client.crt" ``` +**Custom Server Source (`src/vars/custom_server_certificates.yml`):** +- Uses the same paths as default source +- The `server_ca_certificate` points to the custom CA that signed the server certificate +- The `ca_bundle` contains both the internal CA and custom server CA + **Installer Source (`src/vars/installer_certificates.yml`):** ```yaml ca_certificate: "/root/ssl-build/katello-default-ca.crt" server_certificate: "/root/ssl-build/{{ ansible_facts['fqdn'] }}/{{ ansible_facts['fqdn'] }}-apache.crt" +server_ca_certificate: "/root/ssl-build/katello-server-ca.crt" client_certificate: "/root/ssl-build/{{ ansible_facts['fqdn'] }}/{{ ansible_facts['fqdn'] }}-foreman-client.crt" ``` @@ -177,9 +235,9 @@ The `certificate_checks` role uses `foreman-certificate-check` binary to validat **Directory Structure:** ``` /root/certificates/ -├── certs/ # Public certificates -├── private/ # Private keys and passwords -└── requests/ # Certificate signing requests +├── certs/ # Public certificates (ca.crt, server-ca.crt, ca-bundle.crt, *.crt) +├── private/ # Private keys and passwords (ca.key, ca.pwd, *.key) +└── requests/ # Certificate signing requests (*.csr) ``` **SANs and CNAMEs:** diff --git a/src/playbooks/_certificate_source/metadata.obsah.yaml b/src/playbooks/_certificate_source/metadata.obsah.yaml index 0037a6ce4..03eb3a847 100644 --- a/src/playbooks/_certificate_source/metadata.obsah.yaml +++ b/src/playbooks/_certificate_source/metadata.obsah.yaml @@ -1,7 +1,9 @@ --- variables: - certificate_source: - help: Where certificates are coming from. Currently default Ansible role or the foreman-installer. + certificates_source: + help: Where certificates are coming from. Currently default Ansible role, the foreman-installer, or custom server certificates. + parameter: --certificate-source choices: - default - installer + - custom_server diff --git a/src/playbooks/deploy/deploy.yaml b/src/playbooks/deploy/deploy.yaml index 096c76519..837d36c98 100644 --- a/src/playbooks/deploy/deploy.yaml +++ b/src/playbooks/deploy/deploy.yaml @@ -6,7 +6,7 @@ vars_files: - "../../vars/defaults.yml" - "../../vars/flavors/{{ flavor }}.yml" - - "../../vars/{{ certificate_source }}_certificates.yml" + - "../../vars/{{ certificates_source }}_certificates.yml" - "../../vars/images.yml" - "../../vars/tuning/{{ tuning }}.yml" - "../../vars/database.yml" @@ -30,12 +30,12 @@ - role: pre_install - role: checks - role: certificates - when: "certificate_source == 'default'" + when: "certificates_source in ['default', 'custom_server']" - role: certificate_checks vars: certificate_checks_certificate: "{{ server_certificate }}" certificate_checks_key: "{{ server_key }}" - certificate_checks_ca: "{{ ca_certificate }}" + certificate_checks_ca: "{{ server_ca_certificate }}" - role: postgresql when: - database_mode == 'internal' diff --git a/src/playbooks/deploy/metadata.obsah.yaml b/src/playbooks/deploy/metadata.obsah.yaml index 5de33f81f..0f75a7922 100644 --- a/src/playbooks/deploy/metadata.obsah.yaml +++ b/src/playbooks/deploy/metadata.obsah.yaml @@ -29,6 +29,27 @@ variables: action: append_unique type: FQDN parameter: --certificate-cname + certificates_custom_server_certificate: + help: Path to a custom server certificate to use instead of the auto-generated one. + type: AbsolutePath + parameter: --certificate-server-certificate + persist: false + certificates_custom_server_key: + help: Path to the private key for the custom server certificate. + type: AbsolutePath + parameter: --certificate-server-key + persist: false + certificates_custom_server_ca_certificate: + help: Path to the CA certificate that signed the custom server certificate. + type: AbsolutePath + parameter: --certificate-server-ca-certificate + persist: false + +constraints: + required_together: + - [certificates_custom_server_certificate, certificates_custom_server_key, certificates_custom_server_ca_certificate] + forbidden_if: + - [certificates_source, installer, [certificates_custom_server_certificate, certificates_custom_server_key, certificates_custom_server_ca_certificate]] include: - _certificate_source diff --git a/src/requirements.txt b/src/requirements.txt index ecde11966..52dd0c3e9 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -1,3 +1,3 @@ -obsah >= 1.8.0 +obsah >= 1.8.1 requests requests-oauthlib diff --git a/src/roles/certificates/defaults/main.yml b/src/roles/certificates/defaults/main.yml index 37d6a2ec8..7258eaa8d 100644 --- a/src/roles/certificates/defaults/main.yml +++ b/src/roles/certificates/defaults/main.yml @@ -1,9 +1,11 @@ --- +certificates_source: default certificates_ca: true certificates_ca_directory: /root/certificates # Change this to /var/lib? certificates_ca_directory_keys: "{{ certificates_ca_directory }}/private" certificates_ca_directory_certs: "{{ certificates_ca_directory }}/certs" certificates_ca_directory_requests: "{{ certificates_ca_directory }}/requests" +certificates_ca_subject: 'Foreman Self-signed CA' certificates_cnames: [] certificates_algorithm_type: RSA certificates_algorithm_size: 4096 diff --git a/src/roles/certificates/tasks/ca.yml b/src/roles/certificates/tasks/ca.yml index 605f0661c..b098ad885 100644 --- a/src/roles/certificates/tasks/ca.yml +++ b/src/roles/certificates/tasks/ca.yml @@ -47,7 +47,7 @@ path: "{{ certificates_ca_directory_requests }}/ca.csr" privatekey_path: "{{ certificates_ca_directory_keys }}/ca.key" privatekey_passphrase: "{{ certificates_ca_password }}" - common_name: "Foreman Self-signed CA" + common_name: "{{ certificates_ca_subject }}" use_common_name_for_san: false basic_constraints: - 'CA:TRUE' @@ -67,3 +67,19 @@ privatekey_passphrase: "{{ certificates_ca_password }}" provider: selfsigned selfsigned_not_after: "+{{ certificates_ca_validity_days }}d" + +- name: 'Copy CA as server CA certificate' + ansible.builtin.copy: + src: "{{ certificates_ca_directory_certs }}/ca.crt" + dest: "{{ certificates_ca_directory_certs }}/server-ca.crt" + remote_src: true + force: false + mode: '0444' + +- name: 'Create CA bundle' + ansible.builtin.copy: + src: "{{ certificates_ca_directory_certs }}/ca.crt" + dest: "{{ certificates_ca_directory_certs }}/ca-bundle.crt" + remote_src: true + force: false + mode: '0444' diff --git a/src/roles/certificates/tasks/custom.yml b/src/roles/certificates/tasks/custom.yml new file mode 100644 index 000000000..52d53064e --- /dev/null +++ b/src/roles/certificates/tasks/custom.yml @@ -0,0 +1,28 @@ +--- +- name: Copy custom server certificate + ansible.builtin.copy: + src: "{{ certificates_custom_server_certificate }}" + dest: "{{ certificates_ca_directory_certs }}/{{ ansible_facts['fqdn'] }}.crt" + remote_src: true + mode: '0444' + +- name: Copy custom server key + ansible.builtin.copy: + src: "{{ certificates_custom_server_key }}" + dest: "{{ certificates_ca_directory_keys }}/{{ ansible_facts['fqdn'] }}.key" + remote_src: true + mode: '0440' + +- name: Copy custom server CA certificate + ansible.builtin.copy: + src: "{{ certificates_custom_server_ca_certificate }}" + dest: "{{ certificates_ca_directory_certs }}/server-ca.crt" + remote_src: true + mode: '0444' + +- name: Create CA bundle with internal CA and custom server CA + ansible.builtin.assemble: + src: "{{ certificates_ca_directory_certs }}" + dest: "{{ certificates_ca_directory_certs }}/ca-bundle.crt" + regexp: '(ca|server-ca)\.crt$' + mode: '0444' diff --git a/src/roles/certificates/tasks/issue.yml b/src/roles/certificates/tasks/issue.yml index cc57f1b8b..67b3c90ff 100644 --- a/src/roles/certificates/tasks/issue.yml +++ b/src/roles/certificates/tasks/issue.yml @@ -1,35 +1,39 @@ --- -- name: 'Create server private key' - community.crypto.openssl_privatekey: - path: "{{ certificates_ca_directory_keys }}/{{ certificates_hostname }}.key" - type: "{{ certificates_algorithm_type }}" - size: "{{ certificates_algorithm_size }}" - mode: '0600' +- name: Issue server certificate + when: + - (certificates_source != 'custom_server') or (certificates_hostname == 'localhost') + block: + - name: 'Create server private key' + community.crypto.openssl_privatekey: + path: "{{ certificates_ca_directory_keys }}/{{ certificates_hostname }}.key" + type: "{{ certificates_algorithm_type }}" + size: "{{ certificates_algorithm_size }}" + mode: '0600' -- name: 'Create server CSR' - community.crypto.openssl_csr: - path: "{{ certificates_ca_directory_requests }}/{{ certificates_hostname }}.csr" - privatekey_path: "{{ certificates_ca_directory_keys }}/{{ certificates_hostname }}.key" - common_name: "{{ certificates_hostname }}" - subject_alt_name: "{{ _certificates_desired_server_sans }}" - key_usage: - - digitalSignature - - keyEncipherment - extended_key_usage: - - serverAuth - vars: - _certificates_desired_server_sans: "{{ (([certificates_hostname] + certificates_cnames) | map('regex_replace', '^', 'DNS:') | list) }}" + - name: 'Create server CSR' + community.crypto.openssl_csr: + path: "{{ certificates_ca_directory_requests }}/{{ certificates_hostname }}.csr" + privatekey_path: "{{ certificates_ca_directory_keys }}/{{ certificates_hostname }}.key" + common_name: "{{ certificates_hostname }}" + subject_alt_name: "{{ _certificates_desired_server_sans }}" + key_usage: + - digitalSignature + - keyEncipherment + extended_key_usage: + - serverAuth + vars: + _certificates_desired_server_sans: "{{ (([certificates_hostname] + certificates_cnames) | map('regex_replace', '^', 'DNS:') | list) }}" -- name: 'Sign server certificate' - community.crypto.x509_certificate: - path: "{{ certificates_ca_directory_certs }}/{{ certificates_hostname }}.crt" - csr_path: "{{ certificates_ca_directory_requests }}/{{ certificates_hostname }}.csr" - provider: ownca - ownca_path: "{{ certificates_ca_directory_certs }}/ca.crt" - ownca_privatekey_path: "{{ certificates_ca_directory_keys }}/ca.key" - ownca_privatekey_passphrase: "{{ certificates_ca_password }}" - ownca_not_after: "+{{ certificates_validity_days }}d" - force: "{{ certificates_renew | bool }}" + - name: 'Sign server certificate' + community.crypto.x509_certificate: + path: "{{ certificates_ca_directory_certs }}/{{ certificates_hostname }}.crt" + csr_path: "{{ certificates_ca_directory_requests }}/{{ certificates_hostname }}.csr" + provider: ownca + ownca_path: "{{ certificates_ca_directory_certs }}/ca.crt" + ownca_privatekey_path: "{{ certificates_ca_directory_keys }}/ca.key" + ownca_privatekey_passphrase: "{{ certificates_ca_password }}" + ownca_not_after: "+{{ certificates_validity_days }}d" + force: "{{ certificates_renew | bool }}" - name: 'Create client private key' community.crypto.openssl_privatekey: diff --git a/src/roles/certificates/tasks/main.yml b/src/roles/certificates/tasks/main.yml index de400e696..754c97bba 100644 --- a/src/roles/certificates/tasks/main.yml +++ b/src/roles/certificates/tasks/main.yml @@ -3,7 +3,13 @@ ansible.builtin.include_tasks: ca.yml when: certificates_ca -- name: 'Issue other certificates' +- name: Apply custom server certificates + ansible.builtin.include_tasks: custom.yml + when: + - certificates_source == 'custom_server' + - certificates_custom_server_certificate is defined + +- name: Issue host certificates ansible.builtin.include_tasks: issue.yml when: certificates_hostnames is defined with_items: "{{ certificates_hostnames }}" diff --git a/src/roles/foreman_proxy/tasks/certs.yaml b/src/roles/foreman_proxy/tasks/certs.yaml index fbb504ecd..eff34e88a 100644 --- a/src/roles/foreman_proxy/tasks/certs.yaml +++ b/src/roles/foreman_proxy/tasks/certs.yaml @@ -2,7 +2,7 @@ - name: Create the podman secret for Foreman Proxy CA certificate containers.podman.podman_secret: name: foreman-proxy-ssl-ca - path: "{{ server_ca_certificate }}" + path: "{{ ca_certificate }}" state: present notify: - Restart Foreman Proxy diff --git a/src/vars/base.yaml b/src/vars/base.yaml index e48392e6b..8c54bc097 100644 --- a/src/vars/base.yaml +++ b/src/vars/base.yaml @@ -15,7 +15,7 @@ candlepin_tomcat_certificate: "{{ localhost_certificate }}" candlepin_client_key: "{{ client_key }}" candlepin_client_certificate: "{{ client_certificate }}" -foreman_ca_certificate: "{{ server_ca_certificate }}" +foreman_ca_certificate: "{{ ca_bundle }}" foreman_client_key: "{{ client_key }}" foreman_client_certificate: "{{ client_certificate }}" foreman_plugins: "{{ enabled_features | features_to_foreman_plugins }}" diff --git a/src/vars/custom_server_certificates.yml b/src/vars/custom_server_certificates.yml new file mode 100644 index 000000000..a70f33583 --- /dev/null +++ b/src/vars/custom_server_certificates.yml @@ -0,0 +1,14 @@ +--- +certificates_ca_directory: /root/certificates +ca_key_password: "{{ certificates_ca_directory }}/private/ca.pwd" +ca_certificate: "{{ certificates_ca_directory }}/certs/ca.crt" +ca_key: "{{ certificates_ca_directory }}/private/ca.key" +server_certificate: "{{ certificates_ca_directory }}/certs/{{ ansible_facts['fqdn'] }}.crt" +server_key: "{{ certificates_ca_directory }}/private/{{ ansible_facts['fqdn'] }}.key" +server_ca_certificate: "{{ certificates_ca_directory }}/certs/server-ca.crt" +ca_bundle: "{{ certificates_ca_directory }}/certs/ca-bundle.crt" +client_certificate: "{{ certificates_ca_directory }}/certs/{{ ansible_facts['fqdn'] }}-client.crt" +client_key: "{{ certificates_ca_directory }}/private/{{ ansible_facts['fqdn'] }}-client.key" +client_ca_certificate: "{{ certificates_ca_directory }}/certs/ca.crt" +localhost_key: "{{ certificates_ca_directory }}/private/localhost.key" +localhost_certificate: "{{ certificates_ca_directory }}/certs/localhost.crt" diff --git a/src/vars/default_certificates.yml b/src/vars/default_certificates.yml index cddb3fe58..3d8fa6fe2 100644 --- a/src/vars/default_certificates.yml +++ b/src/vars/default_certificates.yml @@ -5,7 +5,8 @@ ca_certificate: "{{ certificates_ca_directory }}/certs/ca.crt" ca_key: "{{ certificates_ca_directory }}/private/ca.key" server_certificate: "{{ certificates_ca_directory }}/certs/{{ ansible_facts['fqdn'] }}.crt" server_key: "{{ certificates_ca_directory }}/private/{{ ansible_facts['fqdn'] }}.key" -server_ca_certificate: "{{ certificates_ca_directory }}/certs/ca.crt" +server_ca_certificate: "{{ certificates_ca_directory }}/certs/server-ca.crt" +ca_bundle: "{{ certificates_ca_directory }}/certs/ca-bundle.crt" client_certificate: "{{ certificates_ca_directory }}/certs/{{ ansible_facts['fqdn'] }}-client.crt" client_key: "{{ certificates_ca_directory }}/private/{{ ansible_facts['fqdn'] }}-client.key" client_ca_certificate: "{{ certificates_ca_directory }}/certs/ca.crt" diff --git a/src/vars/defaults.yml b/src/vars/defaults.yml index 7fae5df26..ad6885288 100644 --- a/src/vars/defaults.yml +++ b/src/vars/defaults.yml @@ -1,5 +1,5 @@ --- -certificate_source: default +certificates_source: default database_mode: internal tuning: default flavor: katello diff --git a/src/vars/installer_certificates.yml b/src/vars/installer_certificates.yml index 7c6095027..6939e6310 100644 --- a/src/vars/installer_certificates.yml +++ b/src/vars/installer_certificates.yml @@ -5,6 +5,7 @@ ca_key: "/root/ssl-build/katello-default-ca.key" server_certificate: "/root/ssl-build/{{ ansible_facts['fqdn'] }}/{{ ansible_facts['fqdn'] }}-apache.crt" server_key: "/root/ssl-build/{{ ansible_facts['fqdn'] }}/{{ ansible_facts['fqdn'] }}-apache.key" server_ca_certificate: "/root/ssl-build/katello-server-ca.crt" +ca_bundle: "/root/ssl-build/ca-bundle.crt" client_certificate: "/root/ssl-build/{{ ansible_facts['fqdn'] }}/{{ ansible_facts['fqdn'] }}-foreman-client.crt" client_key: "/root/ssl-build/{{ ansible_facts['fqdn'] }}/{{ ansible_facts['fqdn'] }}-foreman-client.key" client_ca_certificate: "{{ ca_certificate }}" diff --git a/tests/certificates_test.py b/tests/certificates_test.py index 2933a861c..1eb67a1f3 100644 --- a/tests/certificates_test.py +++ b/tests/certificates_test.py @@ -6,9 +6,97 @@ def certificate_info(server, certificate): openssl_result = server.run(f"openssl x509 -in {certificate} -noout -enddate -dateopt iso_8601 -subject -issuer") return dict([x.split('=', 1) for x in openssl_result.stdout.splitlines()]) -@pytest.mark.parametrize("certificate_type", ['ca_certificate', 'server_certificate', 'client_certificate', 'localhost_certificate']) +@pytest.mark.parametrize("certificate_type", ['ca_certificate', 'server_ca_certificate', 'server_certificate', 'client_certificate', 'localhost_certificate']) def test_certificate_expiry(server, certificates, certificate_type): openssl_data = certificate_info(server, certificates[certificate_type]) not_after = dateutil.parser.parse(openssl_data['notAfter']) now = datetime.datetime.now(tz=not_after.tzinfo) assert not_after - now > datetime.timedelta(days=365*10) + +def test_default_server_ca_matches_internal_ca(server, certificates, default_certificates): + ca_info = certificate_info(server, certificates['ca_certificate']) + server_ca_info = certificate_info(server, certificates['server_ca_certificate']) + assert ca_info['subject'] == server_ca_info['subject'], \ + "Default/installer server CA should match the internal CA" + +def test_custom_server_ca_differs_from_internal_ca(server, certificates, custom_certificates): + ca_info = certificate_info(server, certificates['ca_certificate']) + server_ca_info = certificate_info(server, certificates['server_ca_certificate']) + assert ca_info['subject'] != server_ca_info['subject'], \ + "Custom server CA should have a different subject than the internal CA" + +def test_custom_server_certificate_issued_by_custom_ca(server, certificates, custom_certificates): + server_info = certificate_info(server, certificates['server_certificate']) + server_ca_info = certificate_info(server, certificates['server_ca_certificate']) + assert server_info['issuer'] == server_ca_info['subject'], \ + "Server certificate should be issued by the custom server CA" + +def test_client_certificate_issued_by_internal_ca(server, certificates, custom_certificates): + client_info = certificate_info(server, certificates['client_certificate']) + ca_info = certificate_info(server, certificates['ca_certificate']) + assert client_info['issuer'] == ca_info['subject'], \ + "Client certificate should still be issued by the internal CA" + +def test_ca_bundle_contains_both_cas(server, certificates, custom_certificates): + openssl_result = server.run(f"awk '/BEGIN CERTIFICATE/,/END CERTIFICATE/' {certificates['ca_bundle']} | openssl crl2pkcs7 -nocrl -certfile /dev/stdin | openssl pkcs7 -print_certs -noout -text | grep 'Subject:'") + subjects = [line.strip() for line in openssl_result.stdout.splitlines()] + + ca_info = certificate_info(server, certificates['ca_certificate']) + server_ca_info = certificate_info(server, certificates['server_ca_certificate']) + + assert len(subjects) == 2, f"CA bundle should contain exactly 2 certificates, found {len(subjects)}" + assert ca_info['subject'] in subjects[0] or ca_info['subject'] in subjects[1], \ + f"Internal CA not found in bundle. Expected: {ca_info['subject']}, Found: {subjects}" + assert server_ca_info['subject'] in subjects[0] or server_ca_info['subject'] in subjects[1], \ + f"Server CA not found in bundle. Expected: {server_ca_info['subject']}, Found: {subjects}" + +def test_server_certificate_chain_verifies(server, certificates): + cmd = server.run( + f"openssl verify -CAfile {certificates['ca_bundle']} " + f"{certificates['server_certificate']}" + ) + assert cmd.succeeded + assert "OK" in cmd.stdout + +def test_client_certificate_chain_verifies(server, certificates): + cmd = server.run( + f"openssl verify -CAfile {certificates['ca_certificate']} " + f"{certificates['client_certificate']}" + ) + assert cmd.succeeded + assert "OK" in cmd.stdout + +def test_localhost_certificate_issued_by_internal_ca(server, certificates, custom_certificates): + localhost_info = certificate_info(server, certificates['localhost_certificate']) + ca_info = certificate_info(server, certificates['ca_certificate']) + assert localhost_info['issuer'] == ca_info['subject'], \ + "Localhost certificate should be issued by the internal CA even with custom server certs" + +def test_ca_bundle_exists(server, certificates): + f = server.file(certificates['ca_bundle']) + assert f.exists + assert not f.is_directory + +def test_ca_bundle_verifies_server_certificate(server, certificates): + cmd = server.run( + f"openssl verify -CAfile {certificates['ca_bundle']} " + f"{certificates['server_certificate']}" + ) + assert cmd.succeeded + assert "OK" in cmd.stdout + +@pytest.mark.parametrize("cert_key,expected_mode", [ + ('server_certificate', 0o444), + ('server_ca_certificate', 0o444), + ('ca_bundle', 0o444), +]) +def test_custom_certificate_file_permissions(server, certificates, custom_certificates, cert_key, expected_mode): + f = server.file(certificates[cert_key]) + assert f.exists + assert f.mode == expected_mode + +def test_custom_server_key_permissions(server, certificates, custom_certificates): + f = server.file(certificates['server_key']) + assert f.exists + assert f.mode == 0o440 + diff --git a/tests/conftest.py b/tests/conftest.py index 1fae806c6..656632c90 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -15,7 +15,7 @@ def pytest_addoption(parser): - parser.addoption("--certificate-source", action="store", default="default", choices=('default', 'installer'), help="Where to obtain certificates from") + parser.addoption("--certificate-source", action="store", default="default", choices=('default', 'installer', 'custom_server'), help="Certificate source used during deployment") parser.addoption("--database-mode", action="store", default="internal", choices=('internal', 'external'), help="Whether the database is internal or external") @@ -45,19 +45,37 @@ def client_fqdn(client_hostname): @pytest.fixture(scope="module") -def certificates(pytestconfig, server_fqdn): - source = pytestconfig.getoption("certificate_source") +def certificates(certificate_source, server_fqdn): env = Environment(loader=FileSystemLoader("."), autoescape=select_autoescape()) - template = env.get_template(f"./src/vars/{source}_certificates.yml") - context = {'certificates_ca_directory': '/root/certificates', - 'ansible_facts': {'fqdn': server_fqdn}} + template = env.get_template(f"./src/vars/{certificate_source}_certificates.yml") + context = {'ansible_facts': {'fqdn': server_fqdn}} + # we have vars that refer to other vars, so load them once and then re-render the template + context.update(yaml.safe_load(template.render(context))) return yaml.safe_load(template.render(context)) +@pytest.fixture(scope="module") +def certificate_source(pytestconfig): + return pytestconfig.getoption("certificate_source") + + +@pytest.fixture(scope="module") +def custom_certificates(certificate_source): + if certificate_source != 'custom_server': + pytest.skip("Only applies to custom certificate deployments") + + +@pytest.fixture(scope="module") +def default_certificates(certificate_source): + if certificate_source == 'custom_server': + pytest.skip("Only applies to non-custom certificate deployments") + + @pytest.fixture(scope="module") def database_mode(pytestconfig): return pytestconfig.getoption("database_mode") + @pytest.fixture(scope="module") def server(server_hostname): yield testinfra.get_host(f'paramiko://{server_hostname}', sudo=True, ssh_config=SSH_CONFIG) diff --git a/tests/foreman_proxy_test.py b/tests/foreman_proxy_test.py index de7ffeacc..4cf886dcb 100644 --- a/tests/foreman_proxy_test.py +++ b/tests/foreman_proxy_test.py @@ -1,9 +1,11 @@ +import datetime import json +import pytest FOREMAN_PROXY_PORT = 8443 def test_foreman_proxy_features(server, certificates, server_fqdn): - cmd = server.run(f"curl --cacert {certificates['ca_certificate']} --silent https://{server_fqdn}:{FOREMAN_PROXY_PORT}/features") + cmd = server.run(f"curl --cacert {certificates['server_ca_certificate']} --silent https://{server_fqdn}:{FOREMAN_PROXY_PORT}/features") assert cmd.succeeded features = json.loads(cmd.stdout) assert "logs" in features @@ -17,3 +19,17 @@ def test_foreman_proxy_service(server): def test_foreman_proxy_port(server): foreman_proxy = server.addr('localhost') assert foreman_proxy.port(FOREMAN_PROXY_PORT).is_reachable + +@pytest.mark.xfail(reason='Fails until report feature is available') +def test_foreman_proxy_client_auth_to_foreman(server, certificates, server_fqdn): + test_report = {"config_report": {"host": "test.example.com", "reported_at": datetime.datetime.now(datetime.UTC).strftime('%Y-%m-%d %H:%M:%S UTC')}} + cmd = server.run( + f"curl --cacert {certificates['server_ca_certificate']} " + f"--cert {certificates['client_certificate']} " + f"--key {certificates['client_key']} " + f"--output /dev/null --write-out '%{{http_code}}' " + f"--data '{json.dumps(test_report)}' --header 'Content-Type: application/json' " + f"https://{server_fqdn}/api/v2/config_reports" + ) + assert cmd.succeeded + assert cmd.stdout == '201' diff --git a/tests/httpd_test.py b/tests/httpd_test.py index ca4aa0d64..e01ea7d6e 100644 --- a/tests/httpd_test.py +++ b/tests/httpd_test.py @@ -23,7 +23,7 @@ def test_http_foreman_ping(server, server_fqdn): assert cmd.stdout == f'https://{server_fqdn}/api/v2/ping' def test_https_foreman_ping(server, certificates, server_fqdn): - cmd = server.run(f"{CURL_CMD} --cacert {certificates['ca_certificate']} --write-out '%{{http_code}}' https://{server_fqdn}/api/v2/ping") + cmd = server.run(f"{CURL_CMD} --cacert {certificates['server_ca_certificate']} --write-out '%{{http_code}}' https://{server_fqdn}/api/v2/ping") assert cmd.succeeded assert cmd.stdout == '200' @@ -33,7 +33,7 @@ def test_http_pulp_api_status(server, server_fqdn): assert cmd.stdout == '404' def test_https_pulp_api_status(server, certificates, server_fqdn): - cmd = server.run(f"{CURL_CMD} --cacert {certificates['ca_certificate']} --write-out '%{{http_code}}' https://{server_fqdn}/pulp/api/v3/status/") + cmd = server.run(f"{CURL_CMD} --cacert {certificates['server_ca_certificate']} --write-out '%{{http_code}}' https://{server_fqdn}/pulp/api/v3/status/") assert cmd.succeeded assert cmd.stdout == '200' @@ -43,12 +43,12 @@ def test_http_pulp_content(server, server_fqdn): assert cmd.stderr == '200' def test_https_pulp_content(server, certificates, server_fqdn): - cmd = server.run(f"curl --silent --cacert {certificates['ca_certificate']} https://{server_fqdn}/pulp/content/") + cmd = server.run(f"curl --silent --cacert {certificates['server_ca_certificate']} https://{server_fqdn}/pulp/content/") assert cmd.succeeded assert "Index of /pulp/content/" in cmd.stdout def test_https_pulp_auth(server, certificates, server_fqdn): - cmd = server.run(f"{CURL_CMD} --cacert {certificates['ca_certificate']} --write-out '%{{http_code}}' --cert {certificates['client_certificate']} --key {certificates['client_key']} https://{server_fqdn}/pulp/api/v3/users/") + cmd = server.run(f"{CURL_CMD} --cacert {certificates['server_ca_certificate']} --write-out '%{{http_code}}' --cert {certificates['client_certificate']} --key {certificates['client_key']} https://{server_fqdn}/pulp/api/v3/users/") assert cmd.succeeded assert cmd.stdout == '200' @@ -64,17 +64,17 @@ def test_http_pub_directory_accessible(server, server_fqdn): assert cmd.stdout == '200' def test_https_pub_directory_accessible(server, certificates, server_fqdn): - cmd = server.run(f"{CURL_CMD} --cacert {certificates['ca_certificate']} --write-out '%{{http_code}}' https://{server_fqdn}/pub/") + cmd = server.run(f"{CURL_CMD} --cacert {certificates['server_ca_certificate']} --write-out '%{{http_code}}' https://{server_fqdn}/pub/") assert cmd.succeeded assert cmd.stdout == '200' -def test_http_pub_ca_certificate_downloadable(server, server_fqdn): +def test_http_pub_server_ca_certificate_downloadable(server, server_fqdn): cmd = server.run(f"{CURL_CMD} --write-out '%{{http_code}}' http://{server_fqdn}/pub/katello-server-ca.crt") assert cmd.succeeded assert cmd.stdout == '200' -def test_https_pub_ca_certificate_downloadable(server, certificates, server_fqdn): - cmd = server.run(f"{CURL_CMD} --cacert {certificates['ca_certificate']} --write-out '%{{http_code}}' https://{server_fqdn}/pub/katello-server-ca.crt") +def test_https_pub_server_ca_certificate_downloadable(server, certificates, server_fqdn): + cmd = server.run(f"{CURL_CMD} --cacert {certificates['server_ca_certificate']} --write-out '%{{http_code}}' https://{server_fqdn}/pub/katello-server-ca.crt") assert cmd.succeeded assert cmd.stdout == '200' @@ -84,6 +84,6 @@ def test_http_foreman_login(server, server_fqdn): assert cmd.stdout == '301' def test_https_foreman_login(server, certificates, server_fqdn): - cmd = server.run(f"{CURL_CMD} --cacert {certificates['ca_certificate']} --write-out '%{{http_code}}' https://{server_fqdn}/users/login") + cmd = server.run(f"{CURL_CMD} --cacert {certificates['server_ca_certificate']} --write-out '%{{http_code}}' https://{server_fqdn}/users/login") assert cmd.succeeded assert cmd.stdout == '200' diff --git a/tests/target_lifecycle_test.py b/tests/target_lifecycle_test.py index fb3f8df93..f834ffc97 100644 --- a/tests/target_lifecycle_test.py +++ b/tests/target_lifecycle_test.py @@ -9,7 +9,7 @@ def _wait_for_foreman(server, server_fqdn, certificates): """Poll the Foreman HTTPS frontend until available or timeout.""" for _ in range(FOREMAN_PING_RETRIES): cmd = server.run( - f"{CURL_CMD} --cacert {certificates['ca_certificate']}" + f"{CURL_CMD} --cacert {certificates['server_ca_certificate']}" f" --write-out '%{{http_code}}' https://{server_fqdn}/api/v2/ping" ) if cmd.stdout == '200':