From 36e42cb89935cc098c9931369b4707ed1c9e17ac Mon Sep 17 00:00:00 2001 From: "Eric D. Helms" Date: Tue, 24 Mar 2026 12:17:39 -0400 Subject: [PATCH 1/2] Auto-detect and normalize installer certificates Replace the --certificate-source flag with automatic detection of foreman-installer certificates at /root/ssl-build/. When found, certificates are normalized into the canonical /root/certificates/ structure and the original directory is backed up. This allows foremanctl to transparently manage the full certificate lifecycle for users upgrading from foreman-installer. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/test.yml | 4 +- .../playbooks/deploy-dev/deploy-dev.yaml | 2 +- docs/certificates.md | 128 ++++++++---------- .../_certificate_source/metadata.obsah.yaml | 7 - src/playbooks/deploy/deploy.yaml | 3 +- src/playbooks/deploy/metadata.obsah.yaml | 1 - src/roles/certificates/tasks/ca.yml | 58 ++------ src/roles/certificates/tasks/main.yml | 17 ++- src/roles/certificates/tasks/normalize.yml | 95 +++++++++++++ src/roles/certificates/tasks/setup.yml | 48 +++++++ src/vars/default_certificates.yml | 2 +- src/vars/defaults.yml | 1 - src/vars/installer_certificates.yml | 12 -- tests/certificates_test.py | 2 +- tests/conftest.py | 6 +- 15 files changed, 232 insertions(+), 154 deletions(-) delete mode 100644 src/playbooks/_certificate_source/metadata.obsah.yaml create mode 100644 src/roles/certificates/tasks/normalize.yml create mode 100644 src/roles/certificates/tasks/setup.yml delete mode 100644 src/vars/installer_certificates.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index dfd7a5d97..9140e71ac 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -110,7 +110,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 --tuning development + ./foremanctl deploy ${{ 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 --tuning development - name: Add optional feature - hammer run: | ./foremanctl deploy --add-feature hammer @@ -122,7 +122,7 @@ jobs: ./foremanctl deploy --add-feature azure-rm --add-feature google --add-feature remote-execution - name: Run tests run: | - ./forge test --pytest-args="--certificate-source=${{ matrix.certificate_source }} --database-mode=${{ matrix.database }}" + ./forge test --pytest-args="--database-mode=${{ matrix.database }}" - name: Run smoker run: | ./forge smoker diff --git a/development/playbooks/deploy-dev/deploy-dev.yaml b/development/playbooks/deploy-dev/deploy-dev.yaml index 738381cd4..1f73abf45 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/default_certificates.yml" - "../../../src/vars/images.yml" - "../../../src/vars/database.yml" - "../../../src/vars/foreman.yml" diff --git a/docs/certificates.md b/docs/certificates.md index cbcca750f..beeffc552 100644 --- a/docs/certificates.md +++ b/docs/certificates.md @@ -4,52 +4,34 @@ This document describes how certificate generation and management works in forem ## User Guide -### Certificate Sources +### How Certificates Work -foremanctl supports two certificate sources that determine how certificates are obtained: +foremanctl automatically manages certificates for all deployed services. On first deploy, it detects existing certificates or generates new ones: -**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 +1. Fresh install — If no installer certificates exist, a self-signed CA and certificates are generated automatically. +2. Existing foreman-installer certificates — If certificates from a previous `foreman-installer` deployment exist at `/root/ssl-build/`, they are automatically adopted and normalized into `/root/certificates/`. All certificates are copied and the original directory is backed up to `/root/ssl-build.bak/` so foremanctl can manage their full lifecycle going forward. -**Installer Source (`certificate_source: installer`)** -- Uses existing certificates from a previous `foreman-installer` deployment -- Useful for migration scenarios where certificates already exist -- Certificate files must be present at expected foreman-installer paths +On subsequent deploys, existing certificates at `/root/certificates/` are reused and any new host certificates are issued as needed. ### Usage -#### Using Auto-Generated Certificates (Default) +#### Auto-Generated Certificates (Default) ```bash # Deploy with auto-generated certificates foremanctl deploy - -# Explicitly specify default certificate source -foremanctl deploy --certificate-source=default ``` -#### Using Existing Installer Certificates +No flags are needed — certificates are generated automatically on first deploy. + +#### Upgrading from foreman-installer ```bash -# Use certificates from previous foreman-installer -foremanctl deploy --certificate-source=installer +# Just deploy — installer certificates at /root/ssl-build/ are auto-detected +foremanctl deploy ``` -### Certificate Locations - -After deployment, certificates are available at: - -**Default Source:** -- CA Certificate: `/root/certificates/certs/ca.crt` -- Server Certificate: `/root/certificates/certs/.crt` -- Client Certificate: `/root/certificates/certs/-client.crt` - -**Installer Source:** -- CA Certificate: `/root/ssl-build/katello-default-ca.crt` -- Server Certificate: `/root/ssl-build//-apache.crt` -- Client Certificate: `/root/ssl-build//-foreman-client.crt` +foremanctl detects the existing certificates, normalizes them into its canonical structure, and manages them going forward. The original CA is preserved so existing client trust is maintained. The original `/root/ssl-build/` directory is backed up to `/root/ssl-build.bak/`. ### CNAME Support @@ -65,71 +47,81 @@ foremanctl deploy \ When CNAMEs are specified, certificates will include all names in the Subject Alternative Name field, allowing the same certificate to be valid for multiple hostnames. -### Current Limitations +### Certificate Locations -- Cannot provide custom certificate files during deployment -- Fixed 20-year certificate validity period -- Limited certificate customization options +After deployment, certificates are at: + +``` +/root/certificates/ +├── certs/ +│ ├── ca.crt # CA certificate +│ ├── server-ca.crt # Server CA certificate +│ ├── .crt # Server certificate +│ ├── -client.crt # Client certificate +│ └── localhost.crt # Localhost certificate (for Candlepin) +├── private/ +│ ├── ca.key # CA private key +│ ├── ca.pwd # CA key password +│ ├── .key # Server private key +│ ├── -client.key # Client private key +│ └── localhost.key # Localhost private key +└── requests/ # Certificate signing requests +``` ## Internal Design ### Architecture -The certificate system uses a modular Ansible role-based approach with clear separation between generation, validation, and usage phases. +The certificate system uses a modular Ansible role-based approach with auto-detection, normalization, and clear separation between generation, validation, and usage phases. #### Certificate Role Structure ``` src/roles/certificates/ ├── tasks/ -│ ├── main.yml # Entry point - orchestrates CA and certificate generation -│ ├── ca.yml # CA certificate generation -│ └── issue.yml # Host certificate issuance -├── defaults/main.yml # Default configuration variables +│ ├── main.yml # Entry point — auto-detects source and dispatches +│ ├── setup.yml # Shared directory/config setup +│ ├── ca.yml # CA certificate generation (fresh installs) +│ ├── issue.yml # Host certificate issuance +│ └── normalize.yml # Normalizes foreman-installer certs +├── defaults/main.yml # Default configuration variables └── templates/ - ├── openssl.cnf.j2 # OpenSSL configuration template - └── serial.j2 # Serial number template + ├── openssl.cnf.j2 # OpenSSL configuration template + └── serial.j2 # Serial number template ``` -#### Certificate Generation Workflow +#### Auto-Detection Workflow + +1. **Check installer path**: If `/root/ssl-build/katello-default-ca.crt` exists, normalize installer certificates into the canonical structure. +2. **Fresh install**: If no installer certificates found, generate a new self-signed CA and certificates. +3. **Issue certificates**: For each hostname in `certificates_hostnames`, issue server and client certificates if they don't already exist. -1. **CA Generation** (when `certificates_ca: true`): - - Install OpenSSL and create directory structure - - Generate 4096-bit RSA private key - - Create self-signed CA certificate (CN: "Foreman Self-signed CA", 20-year validity) +#### Normalization -2. **Host Certificate Issuance** (for each hostname in `certificates_hostnames`): - - Generate 4096-bit RSA private key - - Create certificate signing request (CSR) with Subject Alternative Names - - Include primary hostname and any additional CNAMEs from `certificate_cname` - - Sign certificate with CA (includes serverAuth/clientAuth extensions) - - Generate both server and client certificates per hostname +Installer certificates are copied from `/root/ssl-build/` into the canonical `/root/certificates/` structure. The original directory is backed up to `/root/ssl-build.bak/`. This means: +- Only one variable file (`src/vars/default_certificates.yml`) is needed +- All downstream roles (httpd, foreman, candlepin, etc.) use the same paths +- The `certificates` role always runs during deployment +- The CA key is preserved, enabling foremanctl to issue new certificates using the original CA #### Variable System -Certificate paths are defined in source-specific variable files: +Certificate paths are defined in `src/vars/default_certificates.yml`: -**Default Source (`src/vars/default_certificates.yml`):** ```yaml ca_certificate: "{{ certificates_ca_directory }}/certs/ca.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" ``` -**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" -client_certificate: "/root/ssl-build/{{ ansible_facts['fqdn'] }}/{{ ansible_facts['fqdn'] }}-foreman-client.crt" -``` - #### Integration with Deployment In `src/playbooks/deploy/deploy.yaml`: -1. **Variable Loading**: Loads certificate variables based on `certificate_source` -2. **Certificate Generation**: Runs `certificates` role when `certificate_source == 'default'` -3. **Certificate Validation**: Runs `certificate_checks` role for all sources +1. **Variable Loading**: Loads `default_certificates.yml` (always the same paths) +2. **Certificate Management**: Runs `certificates` role (auto-detects, normalizes, and generates as needed) +3. **Certificate Validation**: Runs `certificate_checks` role 4. **Service Configuration**: Passes certificate paths to dependent roles #### Validation System @@ -148,14 +140,6 @@ The `certificate_checks` role uses `foreman-certificate-check` binary to validat - Validity Period: 7300 days (20 years) - Extensions: serverAuth, clientAuth, nsSGC, msSGC -**Directory Structure:** -``` -/root/certificates/ -├── certs/ # Public certificates -├── private/ # Private keys and passwords -└── requests/ # Certificate signing requests -``` - **OpenSSL Configuration:** - Custom configuration template supports SAN extensions - Multiple DNS entries supported: `subjectAltName = DNS:{{ certificates_hostname }}{% for cname in certificate_cname %},DNS:{{ cname }}{% endfor %}` diff --git a/src/playbooks/_certificate_source/metadata.obsah.yaml b/src/playbooks/_certificate_source/metadata.obsah.yaml deleted file mode 100644 index 0037a6ce4..000000000 --- a/src/playbooks/_certificate_source/metadata.obsah.yaml +++ /dev/null @@ -1,7 +0,0 @@ ---- -variables: - certificate_source: - help: Where certificates are coming from. Currently default Ansible role or the foreman-installer. - choices: - - default - - installer diff --git a/src/playbooks/deploy/deploy.yaml b/src/playbooks/deploy/deploy.yaml index 89180f1bc..cddb008c6 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/default_certificates.yml" - "../../vars/images.yml" - "../../vars/tuning/{{ tuning }}.yml" - "../../vars/database.yml" @@ -16,7 +16,6 @@ - role: pre_install - role: checks - role: certificates - when: "certificate_source == 'default'" - role: certificate_checks vars: certificate_checks_certificate: "{{ server_certificate }}" diff --git a/src/playbooks/deploy/metadata.obsah.yaml b/src/playbooks/deploy/metadata.obsah.yaml index e9aef2092..85d00e2fe 100644 --- a/src/playbooks/deploy/metadata.obsah.yaml +++ b/src/playbooks/deploy/metadata.obsah.yaml @@ -25,7 +25,6 @@ variables: parameter: --certificate-cname include: - - _certificate_source - _database_mode - _database_connection - _tuning diff --git a/src/roles/certificates/tasks/ca.yml b/src/roles/certificates/tasks/ca.yml index f426dd735..228c8d386 100644 --- a/src/roles/certificates/tasks/ca.yml +++ b/src/roles/certificates/tasks/ca.yml @@ -1,56 +1,12 @@ --- -- name: 'Install openssl' - ansible.builtin.package: - name: openssl - state: present - -- name: 'Create certs directory' - ansible.builtin.file: - path: "{{ certificates_ca_directory_certs }}" - state: directory - mode: '0755' - -- name: 'Create keys directory' - ansible.builtin.file: - path: "{{ certificates_ca_directory_keys }}" - state: directory - mode: '0755' - -- name: 'Create requests directory' - ansible.builtin.file: - path: "{{ certificates_ca_directory_requests }}" - state: directory - mode: '0755' - -- name: 'Deploy configuration file' - ansible.builtin.template: - src: openssl.cnf.j2 - dest: "{{ certificates_ca_directory }}/openssl.cnf" - owner: root - group: root - mode: '0644' - -- name: 'Create index file' - ansible.builtin.file: - path: "{{ certificates_ca_directory }}/index.txt" - state: touch - owner: root - group: root - mode: '0644' - -- name: 'Ensure serial starting number' - ansible.builtin.template: - src: serial.j2 - dest: "{{ certificates_ca_directory }}/serial" - force: false - owner: root - group: root - mode: '0644' +- name: 'Setup certificate directory' + ansible.builtin.include_tasks: setup.yml - name: 'Create CA key password file' ansible.builtin.copy: content: "{{ certificates_ca_password }}" dest: "{{ certificates_ca_directory_keys }}/ca.pwd" + force: false owner: root group: root mode: '0600' @@ -70,3 +26,11 @@ -passout "file:{{ certificates_ca_directory_keys }}/ca.pwd" args: creates: "{{ certificates_ca_directory_certs }}/ca.crt" + +- 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' diff --git a/src/roles/certificates/tasks/main.yml b/src/roles/certificates/tasks/main.yml index de400e696..75ee71164 100644 --- a/src/roles/certificates/tasks/main.yml +++ b/src/roles/certificates/tasks/main.yml @@ -1,9 +1,20 @@ --- -- name: 'Generate CA certificate' +- name: Check if installer certificates exist + ansible.builtin.stat: + path: /root/ssl-build/katello-default-ca.crt + register: certificates_installer_ca + +- name: Normalize installer certificates + ansible.builtin.include_tasks: normalize.yml + when: certificates_installer_ca.stat.exists + +- name: Generate CA certificate ansible.builtin.include_tasks: ca.yml - when: certificates_ca + when: + - certificates_ca + - not certificates_installer_ca.stat.exists -- name: 'Issue other certificates' +- name: Issue host certificates ansible.builtin.include_tasks: issue.yml when: certificates_hostnames is defined with_items: "{{ certificates_hostnames }}" diff --git a/src/roles/certificates/tasks/normalize.yml b/src/roles/certificates/tasks/normalize.yml new file mode 100644 index 000000000..ffe2aadc9 --- /dev/null +++ b/src/roles/certificates/tasks/normalize.yml @@ -0,0 +1,95 @@ +--- +- name: 'Setup certificate directory' + ansible.builtin.include_tasks: setup.yml + +- name: Copy CA certificate from installer + ansible.builtin.copy: + src: /root/ssl-build/katello-default-ca.crt + dest: "{{ certificates_ca_directory_certs }}/ca.crt" + remote_src: true + force: false + mode: '0444' + +- name: Copy server CA certificate from installer + ansible.builtin.copy: + src: /root/ssl-build/katello-server-ca.crt + dest: "{{ certificates_ca_directory_certs }}/server-ca.crt" + remote_src: true + force: false + mode: '0444' + +- name: Copy CA key from installer + ansible.builtin.copy: + src: /root/ssl-build/katello-default-ca.key + dest: "{{ certificates_ca_directory_keys }}/ca.key" + remote_src: true + force: false + mode: '0440' + +- name: Copy CA password from installer + ansible.builtin.copy: + src: /root/ssl-build/katello-default-ca.pwd + dest: "{{ certificates_ca_directory_keys }}/ca.pwd" + remote_src: true + force: false + mode: '0440' + +- name: Copy server certificate from installer + ansible.builtin.copy: + src: "/root/ssl-build/{{ ansible_facts['fqdn'] }}/{{ ansible_facts['fqdn'] }}-apache.crt" + dest: "{{ certificates_ca_directory_certs }}/{{ ansible_facts['fqdn'] }}.crt" + remote_src: true + force: false + mode: '0444' + +- name: Copy server key from installer + ansible.builtin.copy: + src: "/root/ssl-build/{{ ansible_facts['fqdn'] }}/{{ ansible_facts['fqdn'] }}-apache.key" + dest: "{{ certificates_ca_directory_keys }}/{{ ansible_facts['fqdn'] }}.key" + remote_src: true + force: false + mode: '0440' + +- name: Copy client certificate from installer + ansible.builtin.copy: + src: "/root/ssl-build/{{ ansible_facts['fqdn'] }}/{{ ansible_facts['fqdn'] }}-foreman-client.crt" + dest: "{{ certificates_ca_directory_certs }}/{{ ansible_facts['fqdn'] }}-client.crt" + remote_src: true + force: false + mode: '0444' + +- name: Copy client key from installer + ansible.builtin.copy: + src: "/root/ssl-build/{{ ansible_facts['fqdn'] }}/{{ ansible_facts['fqdn'] }}-foreman-client.key" + dest: "{{ certificates_ca_directory_keys }}/{{ ansible_facts['fqdn'] }}-client.key" + remote_src: true + force: false + mode: '0440' + +- name: Copy localhost certificate from installer + ansible.builtin.copy: + src: /root/ssl-build/localhost/localhost-tomcat.crt + dest: "{{ certificates_ca_directory_certs }}/localhost.crt" + remote_src: true + force: false + mode: '0444' + +- name: Copy localhost key from installer + ansible.builtin.copy: + src: /root/ssl-build/localhost/localhost-tomcat.key + dest: "{{ certificates_ca_directory_keys }}/localhost.key" + remote_src: true + force: false + mode: '0440' + +- name: Backup installer certificate directory + ansible.builtin.copy: + src: /root/ssl-build/ + dest: /root/ssl-build.bak/ + remote_src: true + mode: preserve + +- name: Remove original installer certificate directory + ansible.builtin.file: + path: /root/ssl-build + state: absent diff --git a/src/roles/certificates/tasks/setup.yml b/src/roles/certificates/tasks/setup.yml new file mode 100644 index 000000000..4d87b214f --- /dev/null +++ b/src/roles/certificates/tasks/setup.yml @@ -0,0 +1,48 @@ +--- +- name: 'Install openssl' + ansible.builtin.package: + name: openssl + state: present + +- name: 'Create certs directory' + ansible.builtin.file: + path: "{{ certificates_ca_directory_certs }}" + state: directory + mode: '0755' + +- name: 'Create keys directory' + ansible.builtin.file: + path: "{{ certificates_ca_directory_keys }}" + state: directory + mode: '0755' + +- name: 'Create requests directory' + ansible.builtin.file: + path: "{{ certificates_ca_directory_requests }}" + state: directory + mode: '0755' + +- name: 'Deploy configuration file' + ansible.builtin.template: + src: openssl.cnf.j2 + dest: "{{ certificates_ca_directory }}/openssl.cnf" + owner: root + group: root + mode: '0644' + +- name: 'Create index file' + ansible.builtin.file: + path: "{{ certificates_ca_directory }}/index.txt" + state: touch + owner: root + group: root + mode: '0644' + +- name: 'Ensure serial starting number' + ansible.builtin.template: + src: serial.j2 + dest: "{{ certificates_ca_directory }}/serial" + force: false + owner: root + group: root + mode: '0644' diff --git a/src/vars/default_certificates.yml b/src/vars/default_certificates.yml index 09f47c5c9..0ea2b5df8 100644 --- a/src/vars/default_certificates.yml +++ b/src/vars/default_certificates.yml @@ -5,7 +5,7 @@ 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" 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..2686b5f77 100644 --- a/src/vars/defaults.yml +++ b/src/vars/defaults.yml @@ -1,5 +1,4 @@ --- -certificate_source: default database_mode: internal tuning: default flavor: katello diff --git a/src/vars/installer_certificates.yml b/src/vars/installer_certificates.yml deleted file mode 100644 index c6ab83af3..000000000 --- a/src/vars/installer_certificates.yml +++ /dev/null @@ -1,12 +0,0 @@ ---- -ca_key_password: "/root/ssl-build/katello-default-ca.pwd" -ca_certificate: "/root/ssl-build/katello-default-ca.crt" -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" -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 }}" -localhost_key: "/root/ssl-build/localhost/localhost-tomcat.key" -localhost_certificate: "/root/ssl-build/localhost/localhost-tomcat.crt" diff --git a/tests/certificates_test.py b/tests/certificates_test.py index 2933a861c..444dc8124 100644 --- a/tests/certificates_test.py +++ b/tests/certificates_test.py @@ -6,7 +6,7 @@ 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']) diff --git a/tests/conftest.py b/tests/conftest.py index d9ed914cc..dea0f0060 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,7 +14,6 @@ def pytest_addoption(parser): - parser.addoption("--certificate-source", action="store", default="default", choices=('default', 'installer'), help="Where to obtain certificates from") parser.addoption("--database-mode", action="store", default="internal", choices=('internal', 'external'), help="Whether the database is internal or external") @@ -44,10 +43,9 @@ def client_fqdn(client_hostname): @pytest.fixture(scope="module") -def certificates(pytestconfig, server_fqdn): - source = pytestconfig.getoption("certificate_source") +def certificates(server_fqdn): env = Environment(loader=FileSystemLoader("."), autoescape=select_autoescape()) - template = env.get_template(f"./src/vars/{source}_certificates.yml") + template = env.get_template("./src/vars/default_certificates.yml") context = {'certificates_ca_directory': '/root/certificates', 'ansible_facts': {'fqdn': server_fqdn}} return yaml.safe_load(template.render(context)) From 28bad696029a584b1485d5b6ff0ec57000c6fba2 Mon Sep 17 00:00:00 2001 From: "Eric D. Helms" Date: Wed, 25 Mar 2026 09:49:42 -0400 Subject: [PATCH 2/2] Add custom server certificate support Allow users to provide their own server certificates via --server-certificate, --server-key, and --server-ca-certificate flags on foremanctl deploy. Custom certificates are copied into the canonical /root/certificates/ structure, while client certificates and localhost certificates continue to be managed by the internal CA. Includes CI matrix entry for custom certificate testing and integration tests that verify the custom server CA differs from the internal CA. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/test.yml | 12 ++++++-- .../playbooks/custom-certs/custom-certs.yaml | 13 +++++++++ docs/certificates.md | 21 ++++++++++++-- src/playbooks/deploy/deploy.yaml | 2 +- src/playbooks/deploy/metadata.obsah.yaml | 16 +++++++++++ src/roles/certificates/defaults/main.yml | 1 + src/roles/certificates/tasks/ca.yml | 10 ++++++- src/roles/certificates/tasks/custom.yml | 28 +++++++++++++++++++ src/roles/certificates/tasks/main.yml | 4 +++ src/roles/certificates/tasks/normalize.yml | 7 +++++ src/roles/foreman_proxy/tasks/certs.yaml | 2 +- src/vars/base.yaml | 2 +- src/vars/default_certificates.yml | 1 + tests/certificates_test.py | 18 ++++++++++++ tests/conftest.py | 10 +++++++ 15 files changed, 139 insertions(+), 8 deletions(-) create mode 100644 development/playbooks/custom-certs/custom-certs.yaml create mode 100644 src/roles/certificates/tasks/custom.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9140e71ac..5d8dcd404 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -61,6 +61,10 @@ jobs: security: none database: external box: centos/stream9 + - certificate_source: custom + security: none + database: internal + box: centos/stream9 runs-on: ubuntu-24.04 env: FOREMANCTL_BASE_BOX: ${{ matrix.box }} @@ -93,6 +97,10 @@ jobs: if: contains(matrix.certificate_source, 'installer') run: | ./forge installer-certs + - name: Create custom certificates + if: matrix.certificate_source == 'custom' + run: | + ./forge custom-certs - name: Setup security mode ${{ matrix.security }} if: matrix.security != 'none' run: | @@ -110,7 +118,7 @@ jobs: ./foremanctl pull-images - name: Run deployment run: | - ./foremanctl deploy ${{ 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 --tuning development + ./foremanctl deploy ${{ 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 /root/custom-certificates/certs/quadlet.example.com.crt --server-key /root/custom-certificates/private/quadlet.example.com.key --server-ca-certificate /root/custom-certificates/certs/server-ca.crt' || '' }} --foreman-initial-admin-password=changeme --tuning development - name: Add optional feature - hammer run: | ./foremanctl deploy --add-feature hammer @@ -122,7 +130,7 @@ jobs: ./foremanctl deploy --add-feature azure-rm --add-feature google --add-feature remote-execution - name: Run tests run: | - ./forge test --pytest-args="--database-mode=${{ matrix.database }}" + ./forge test --pytest-args="--database-mode=${{ matrix.database }} --certificate-source=${{ matrix.certificate_source }}" - name: Run smoker run: | ./forge smoker diff --git a/development/playbooks/custom-certs/custom-certs.yaml b/development/playbooks/custom-certs/custom-certs.yaml new file mode 100644 index 000000000..a3f98bcc4 --- /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: '/CN=Custom Test CA' + certificates_hostnames: + - "{{ ansible_facts['fqdn'] }}" + roles: + - role: certificates diff --git a/docs/certificates.md b/docs/certificates.md index beeffc552..90df2351f 100644 --- a/docs/certificates.md +++ b/docs/certificates.md @@ -33,6 +33,21 @@ foremanctl deploy foremanctl detects the existing certificates, normalizes them into its canonical structure, and manages them going forward. The original CA is preserved so existing client trust is maintained. The original `/root/ssl-build/` directory is backed up to `/root/ssl-build.bak/`. +#### Custom Server Certificates + +To use certificates signed by your own CA instead of foremanctl's self-signed certificates: + +```bash +foremanctl deploy \ + --server-certificate /path/to/server.crt \ + --server-key /path/to/server.key \ + --server-ca-certificate /path/to/ca-bundle.crt +``` + +All three flags must be provided together. The custom server certificate, key, and CA are copied into `/root/certificates/` and used for all server-facing TLS. An internal CA is still generated (or preserved from a previous deploy) to manage client certificates and the localhost certificate. + +On subsequent deploys, the custom certificates persist — you only need to pass the flags again if you want to update them (e.g., for certificate rotation). + ### CNAME Support foremanctl supports Subject Alternative Names (SANs) for multi-domain certificates: @@ -83,7 +98,8 @@ src/roles/certificates/ │ ├── setup.yml # Shared directory/config setup │ ├── ca.yml # CA certificate generation (fresh installs) │ ├── issue.yml # Host certificate issuance -│ └── normalize.yml # Normalizes foreman-installer certs +│ ├── normalize.yml # Normalizes foreman-installer certs +│ └── custom.yml # Applies user-provided custom server certs ├── defaults/main.yml # Default configuration variables └── templates/ ├── openssl.cnf.j2 # OpenSSL configuration template @@ -94,7 +110,8 @@ src/roles/certificates/ 1. **Check installer path**: If `/root/ssl-build/katello-default-ca.crt` exists, normalize installer certificates into the canonical structure. 2. **Fresh install**: If no installer certificates found, generate a new self-signed CA and certificates. -3. **Issue certificates**: For each hostname in `certificates_hostnames`, issue server and client certificates if they don't already exist. +3. **Custom server certificates**: If `--server-certificate` flags are provided, copy the custom server cert, key, and CA into the canonical structure, overwriting any existing server certificate. +4. **Issue certificates**: For each hostname in `certificates_hostnames`, issue server and client certificates if they don't already exist. Server certificate issuance is skipped if a custom certificate was already placed in step 3. #### Normalization diff --git a/src/playbooks/deploy/deploy.yaml b/src/playbooks/deploy/deploy.yaml index cddb008c6..b07912ee5 100644 --- a/src/playbooks/deploy/deploy.yaml +++ b/src/playbooks/deploy/deploy.yaml @@ -20,7 +20,7 @@ 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 85d00e2fe..3c2398a5b 100644 --- a/src/playbooks/deploy/metadata.obsah.yaml +++ b/src/playbooks/deploy/metadata.obsah.yaml @@ -23,6 +23,22 @@ 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: --server-certificate + certificates_custom_server_key: + help: Path to the private key for the custom server certificate. + type: AbsolutePath + parameter: --server-key + certificates_custom_server_ca_certificate: + help: Path to the CA certificate that signed the custom server certificate. + type: AbsolutePath + parameter: --server-ca-certificate + +constraints: + required_together: + - [certificates_custom_server_certificate, certificates_custom_server_key, certificates_custom_server_ca_certificate] include: - _database_mode diff --git a/src/roles/certificates/defaults/main.yml b/src/roles/certificates/defaults/main.yml index 2c7c67b2d..5b0416910 100644 --- a/src/roles/certificates/defaults/main.yml +++ b/src/roles/certificates/defaults/main.yml @@ -4,4 +4,5 @@ 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: '/CN=Foreman Self-signed CA' certificates_cnames: [] diff --git a/src/roles/certificates/tasks/ca.yml b/src/roles/certificates/tasks/ca.yml index 228c8d386..b394a7d8c 100644 --- a/src/roles/certificates/tasks/ca.yml +++ b/src/roles/certificates/tasks/ca.yml @@ -20,7 +20,7 @@ -extensions v3_ca -days 7300 -config "{{ certificates_ca_directory }}/openssl.cnf" - -subj "/CN=Foreman Self-signed CA" + -subj "{{ certificates_ca_subject }}" -keyout "{{ certificates_ca_directory_keys }}/ca.key" -out "{{ certificates_ca_directory_certs }}/ca.crt" -passout "file:{{ certificates_ca_directory_keys }}/ca.pwd" @@ -34,3 +34,11 @@ 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/main.yml b/src/roles/certificates/tasks/main.yml index 75ee71164..ba910e94d 100644 --- a/src/roles/certificates/tasks/main.yml +++ b/src/roles/certificates/tasks/main.yml @@ -14,6 +14,10 @@ - certificates_ca - not certificates_installer_ca.stat.exists +- name: Apply custom server certificates + ansible.builtin.include_tasks: custom.yml + when: certificates_custom_server_certificate is defined + - name: Issue host certificates ansible.builtin.include_tasks: issue.yml when: certificates_hostnames is defined diff --git a/src/roles/certificates/tasks/normalize.yml b/src/roles/certificates/tasks/normalize.yml index ffe2aadc9..480425c6a 100644 --- a/src/roles/certificates/tasks/normalize.yml +++ b/src/roles/certificates/tasks/normalize.yml @@ -18,6 +18,13 @@ force: false mode: '0444' +- name: Create CA bundle from installer certificates + ansible.builtin.assemble: + src: "{{ certificates_ca_directory_certs }}" + dest: "{{ certificates_ca_directory_certs }}/ca-bundle.crt" + regexp: '(ca|server-ca)\.crt$' + mode: '0444' + - name: Copy CA key from installer ansible.builtin.copy: src: /root/ssl-build/katello-default-ca.key 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 b19d12044..40911084b 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/default_certificates.yml b/src/vars/default_certificates.yml index 0ea2b5df8..a70f33583 100644 --- a/src/vars/default_certificates.yml +++ b/src/vars/default_certificates.yml @@ -6,6 +6,7 @@ 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" diff --git a/tests/certificates_test.py b/tests/certificates_test.py index 444dc8124..3efaf9c55 100644 --- a/tests/certificates_test.py +++ b/tests/certificates_test.py @@ -12,3 +12,21 @@ def test_certificate_expiry(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_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" diff --git a/tests/conftest.py b/tests/conftest.py index dea0f0060..0281b7f68 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -15,6 +15,7 @@ def pytest_addoption(parser): parser.addoption("--database-mode", action="store", default="internal", choices=('internal', 'external'), help="Whether the database is internal or external") + parser.addoption("--certificate-source", action="store", default="default", choices=('default', 'installer', 'custom'), help="Certificate source used during deployment") @pytest.fixture(scope="module") @@ -51,6 +52,15 @@ def certificates(server_fqdn): 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': + pytest.skip("Only applies to custom certificate deployments") + @pytest.fixture(scope="module") def database_mode(pytestconfig): return pytestconfig.getoption("database_mode")