diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 12b221970..4bcabfac1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -125,7 +125,7 @@ jobs: ./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' || '' }} \ + ${{ matrix.certificate_source == 'custom_server' && '--certificate-server-certificate /root/custom-certificates/quadlet.example.com/certs/quadlet.example.com.crt --certificate-server-key /root/custom-certificates/quadlet.example.com/private/quadlet.example.com.key --certificate-server-ca-certificate /root/custom-certificates/certs/ca.crt' || '' }} \ --initial-admin-password=changeme \ --initial-organization "Foreman CI" \ --initial-location "Internet" \ diff --git a/development/playbooks/custom-certs/custom-certs.yaml b/development/playbooks/custom-certs/custom-certs.yaml index fc64b29ad..7a8f6886b 100644 --- a/development/playbooks/custom-certs/custom-certs.yaml +++ b/development/playbooks/custom-certs/custom-certs.yaml @@ -4,10 +4,23 @@ - quadlet become: true vars: - certificates_ca_directory: /root/custom-certificates + _custom_certs_root: /root/custom-certificates certificates_ca_password: "CUSTOMCA" certificates_ca_subject: 'Custom Test CA' - certificates_hostnames: - - "{{ ansible_facts['fqdn'] }}" - roles: - - role: certificates + pre_tasks: + - name: Generate custom CA + ansible.builtin.include_role: + name: certificates + vars: + certificates_ca_directory: "{{ _custom_certs_root }}" + certificates_hostnames: [] + + - name: Generate host certificates + ansible.builtin.include_role: + name: certificates + vars: + certificates_ca: false + certificates_signing_ca_directory: "{{ _custom_certs_root }}" + certificates_ca_directory: "{{ _custom_certs_root }}/{{ hostname | default(ansible_facts['fqdn']) }}" + certificates_hostnames: + - "{{ hostname | default(ansible_facts['fqdn']) }}" diff --git a/development/playbooks/custom-certs/metadata.obsah.yaml b/development/playbooks/custom-certs/metadata.obsah.yaml new file mode 100644 index 000000000..deca17da6 --- /dev/null +++ b/development/playbooks/custom-certs/metadata.obsah.yaml @@ -0,0 +1,8 @@ +--- +help: | + Generate custom certificates for testing + +variables: + hostname: + help: Additional hostname to generate certificates for. + parameter: --hostname diff --git a/docs/user/certificates.md b/docs/user/certificates.md index 310419375..4dbd7fb54 100644 --- a/docs/user/certificates.md +++ b/docs/user/certificates.md @@ -127,6 +127,53 @@ foremanctl deploy --certificate-renew The `--certificate-renew` flag is **not persisted** in foremanctl’s answers file (one-shot). +### Certificate Bundle for Secondary Systems + +The `certificate-bundle` command generates a certificate tarball for a secondary system such as a foreman-proxy host. This is the foremanctl equivalent of `foreman-proxy-certs-generate`. + +The internal CA must already exist from a prior `foremanctl deploy`. + +#### Usage + +```bash +# Generate a certificate bundle using the internal CA +foremanctl certificate-bundle proxy.example.com + +# Generate a certificate bundle with custom server certificates for the proxy +foremanctl certificate-bundle \ + --certificate-server-certificate /path/to/proxy.example.com.crt \ + --certificate-server-key /path/to/proxy.example.com.key \ + --certificate-server-ca-certificate /path/to/ca.crt \ + proxy.example.com +``` + +The command generates certificates for the given hostname and packages them into a tarball at `/root/.tar.gz`. + +When no custom certificate flags are provided, all certificates (server and client) are generated by the internal CA. When custom server certificates are provided, the custom cert and key are used for the proxy's server certificates while client certificates are still generated by the internal CA. + +If the Foreman server was deployed with custom server certificates, each proxy must also have its own distinct custom server certificate. The three `--certificate-server-*` flags must be provided together. + +#### Tarball Contents + +The tarball follows the `ssl-build` directory layout: + +``` +ssl-build/ +├── katello-default-ca.crt # Internal CA certificate +├── katello-server-ca.crt # CA that signed the server certificate +└── / + ├── -apache.crt # Server certificate (HTTPS) + ├── -apache.key # Server private key + ├── -foreman-proxy.crt # Proxy server certificate + ├── -foreman-proxy.key # Proxy server private key + ├── -foreman-proxy-client.crt # Client certificate (proxy-to-Foreman) + ├── -foreman-proxy-client.key # Client private key + ├── -puppet-client.crt # Puppet client certificate + └── -puppet-client.key # Puppet client private key +``` + +When using the internal CA only, `katello-server-ca.crt` and `katello-default-ca.crt` are the same certificate. When custom server certificates are provided, `katello-server-ca.crt` contains the custom CA and `katello-default-ca.crt` contains the internal CA. + ### Current Limitations - Uses the same lifetime for both client and server certificates diff --git a/src/playbooks/certificate-bundle/certificate-bundle.yaml b/src/playbooks/certificate-bundle/certificate-bundle.yaml new file mode 100644 index 000000000..183b8b137 --- /dev/null +++ b/src/playbooks/certificate-bundle/certificate-bundle.yaml @@ -0,0 +1,43 @@ +--- +- name: Generate a certificate bundle for a hostname + hosts: + - quadlet + become: true + vars_files: + - "../../vars/defaults.yml" + vars: + _certificates_root: /root/certificates + certificates_ca: false + certificates_source: default + certificates_signing_ca_directory: "{{ _certificates_root }}" + certificates_ca_directory: "{{ _certificates_root }}/hosts/{{ hostname }}" + certificates_hostnames: + - "{{ hostname }}" + _ca_crt: "{{ _certificates_root }}/certs/ca.crt" + _host_crt: "{{ certificates_ca_directory }}/certs/{{ hostname }}.crt" + _host_key: "{{ certificates_ca_directory }}/private/{{ hostname }}.key" + pre_tasks: + - name: Read CA password from remote host + ansible.builtin.slurp: + src: "{{ _certificates_root }}/private/ca.pwd" + register: _ca_pwd_file + no_log: true + + - name: Set CA password + ansible.builtin.set_fact: + certificates_ca_password: "{{ _ca_pwd_file.content | b64decode }}" + no_log: true + roles: + - role: certificates + - role: certificate_bundle + vars: + certificate_bundle_hostname: "{{ hostname }}" + certificate_bundle_ca_certificate: "{{ _ca_crt }}" + certificate_bundle_server_ca_certificate: >- + {{ certificates_custom_server_ca_certificate | default(_ca_crt) }} + certificate_bundle_server_certificate: >- + {{ certificates_custom_server_certificate | default(_host_crt) }} + certificate_bundle_server_key: >- + {{ certificates_custom_server_key | default(_host_key) }} + certificate_bundle_client_certificate: "{{ certificates_ca_directory }}/certs/{{ hostname }}-client.crt" + certificate_bundle_client_key: "{{ certificates_ca_directory }}/private/{{ hostname }}-client.key" diff --git a/src/playbooks/certificate-bundle/metadata.obsah.yaml b/src/playbooks/certificate-bundle/metadata.obsah.yaml new file mode 100644 index 000000000..6d31b29f9 --- /dev/null +++ b/src/playbooks/certificate-bundle/metadata.obsah.yaml @@ -0,0 +1,33 @@ +--- +help: | + Generate a certificate bundle + +variables: + hostname: + parameter: hostname + help: Hostname to generate a certificate bundle for that will be the common name. + certificates_custom_server_certificate: + help: Path to a custom server certificate for the proxy. + 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 + + certificates_renew: + help: Regenerate certificates for this hostname. + parameter: --certificate-renew + action: store_true + persist: false + +constraints: + required_together: + - [certificates_custom_server_certificate, certificates_custom_server_key, certificates_custom_server_ca_certificate] diff --git a/src/roles/certificate_bundle/defaults/main.yml b/src/roles/certificate_bundle/defaults/main.yml new file mode 100644 index 000000000..23f4ef482 --- /dev/null +++ b/src/roles/certificate_bundle/defaults/main.yml @@ -0,0 +1,3 @@ +--- +certificate_bundle_output_directory: /root +certificate_bundle_server_ca_certificate: "{{ certificate_bundle_ca_certificate }}" diff --git a/src/roles/certificate_bundle/tasks/main.yml b/src/roles/certificate_bundle/tasks/main.yml new file mode 100644 index 000000000..37cd7f9c3 --- /dev/null +++ b/src/roles/certificate_bundle/tasks/main.yml @@ -0,0 +1,90 @@ +--- +- name: Check for existing certificate bundle + ansible.builtin.stat: + path: "{{ certificate_bundle_output_directory }}/{{ certificate_bundle_hostname }}.tar.gz" + register: _certificate_bundle_tarball + +- name: Build certificate bundle + when: not _certificate_bundle_tarball.stat.exists or (certificates_renew | default(false) | bool) + block: + - name: Create temporary directory + ansible.builtin.tempfile: + state: directory + suffix: certificate-build + register: certificate_bundle_build_directory + + - name: Create directory structure + ansible.builtin.file: + state: directory + path: "{{ certificate_bundle_build_directory.path }}/ssl-build/{{ certificate_bundle_hostname }}" + mode: '0755' + + - name: Copy default CA certificate + ansible.builtin.copy: + src: "{{ certificate_bundle_ca_certificate }}" + dest: "{{ certificate_bundle_build_directory.path }}/ssl-build/katello-default-ca.crt" + remote_src: true + mode: '0444' + + - name: Copy server CA certificate + ansible.builtin.copy: + src: "{{ certificate_bundle_server_ca_certificate }}" + dest: "{{ certificate_bundle_build_directory.path }}/ssl-build/katello-server-ca.crt" + remote_src: true + mode: '0444' + + - name: Copy server certificate + ansible.builtin.copy: + src: "{{ certificate_bundle_server_certificate }}" + dest: "{{ certificate_bundle_build_directory.path }}/ssl-build/{{ certificate_bundle_hostname }}/{{ certificate_bundle_hostname }}-{{ item }}" + remote_src: true + mode: '0444' + loop: + - apache.crt + - foreman-proxy.crt + + - name: Copy server key + ansible.builtin.copy: + src: "{{ certificate_bundle_server_key }}" + dest: "{{ certificate_bundle_build_directory.path }}/ssl-build/{{ certificate_bundle_hostname }}/{{ certificate_bundle_hostname }}-{{ item }}" + remote_src: true + mode: '0600' + loop: + - apache.key + - foreman-proxy.key + + - name: Copy client certificate + ansible.builtin.copy: + src: "{{ certificate_bundle_client_certificate }}" + dest: "{{ certificate_bundle_build_directory.path }}/ssl-build/{{ certificate_bundle_hostname }}/{{ certificate_bundle_hostname }}-{{ item }}" + remote_src: true + mode: '0444' + loop: + - foreman-proxy-client.crt + - puppet-client.crt + + - name: Copy client key + ansible.builtin.copy: + src: "{{ certificate_bundle_client_key }}" + dest: "{{ certificate_bundle_build_directory.path }}/ssl-build/{{ certificate_bundle_hostname }}/{{ certificate_bundle_hostname }}-{{ item }}" + remote_src: true + mode: '0600' + loop: + - foreman-proxy-client.key + - puppet-client.key + + - name: Create tarball + community.general.archive: + path: "{{ certificate_bundle_build_directory.path }}/ssl-build" + dest: "{{ certificate_bundle_output_directory }}/{{ certificate_bundle_hostname }}.tar.gz" + format: gz + mode: '0640' + + - name: Remove temporary directory + ansible.builtin.file: + path: "{{ certificate_bundle_build_directory.path }}" + state: absent + +- name: Report certificate bundle location + ansible.builtin.debug: + msg: "Certificate bundle created: {{ certificate_bundle_output_directory }}/{{ certificate_bundle_hostname }}.tar.gz" diff --git a/src/roles/certificates/defaults/main.yml b/src/roles/certificates/defaults/main.yml index 7258eaa8d..a2242d9cf 100644 --- a/src/roles/certificates/defaults/main.yml +++ b/src/roles/certificates/defaults/main.yml @@ -5,6 +5,7 @@ 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_signing_ca_directory: "{{ certificates_ca_directory }}" certificates_ca_subject: 'Foreman Self-signed CA' certificates_cnames: [] certificates_algorithm_type: RSA diff --git a/src/roles/certificates/tasks/issue.yml b/src/roles/certificates/tasks/issue.yml index 67b3c90ff..5f388ae54 100644 --- a/src/roles/certificates/tasks/issue.yml +++ b/src/roles/certificates/tasks/issue.yml @@ -1,4 +1,14 @@ --- +- name: Ensure certificate directories exist + ansible.builtin.file: + path: "{{ item }}" + state: directory + mode: '0755' + loop: + - "{{ certificates_ca_directory_certs }}" + - "{{ certificates_ca_directory_keys }}" + - "{{ certificates_ca_directory_requests }}" + - name: Issue server certificate when: - (certificates_source != 'custom_server') or (certificates_hostname == 'localhost') @@ -29,8 +39,8 @@ 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_path: "{{ certificates_signing_ca_directory }}/certs/ca.crt" + ownca_privatekey_path: "{{ certificates_signing_ca_directory }}/private/ca.key" ownca_privatekey_passphrase: "{{ certificates_ca_password }}" ownca_not_after: "+{{ certificates_validity_days }}d" force: "{{ certificates_renew | bool }}" @@ -60,8 +70,8 @@ path: "{{ certificates_ca_directory_certs }}/{{ certificates_hostname }}-client.crt" csr_path: "{{ certificates_ca_directory_requests }}/{{ certificates_hostname }}-client.csr" provider: ownca - ownca_path: "{{ certificates_ca_directory_certs }}/ca.crt" - ownca_privatekey_path: "{{ certificates_ca_directory_keys }}/ca.key" + ownca_path: "{{ certificates_signing_ca_directory }}/certs/ca.crt" + ownca_privatekey_path: "{{ certificates_signing_ca_directory }}/private/ca.key" ownca_privatekey_passphrase: "{{ certificates_ca_password }}" ownca_not_after: "+{{ certificates_validity_days }}d" force: "{{ certificates_renew | bool }}" diff --git a/tests/certificate_bundle_test.py b/tests/certificate_bundle_test.py new file mode 100644 index 000000000..5d606a8ab --- /dev/null +++ b/tests/certificate_bundle_test.py @@ -0,0 +1,125 @@ +import subprocess + +import pytest + + +HOSTNAME = 'proxy.example.com' +TARBALL = f'/root/{HOSTNAME}.tar.gz' + +EXPECTED_CA_FILES = [ + 'ssl-build/katello-server-ca.crt', + 'ssl-build/katello-default-ca.crt', +] + +EXPECTED_SERVER_FILES = [ + f'ssl-build/{HOSTNAME}/{HOSTNAME}-apache.crt', + f'ssl-build/{HOSTNAME}/{HOSTNAME}-apache.key', + f'ssl-build/{HOSTNAME}/{HOSTNAME}-foreman-proxy.crt', + f'ssl-build/{HOSTNAME}/{HOSTNAME}-foreman-proxy.key', +] + +EXPECTED_CLIENT_FILES = [ + f'ssl-build/{HOSTNAME}/{HOSTNAME}-foreman-proxy-client.crt', + f'ssl-build/{HOSTNAME}/{HOSTNAME}-foreman-proxy-client.key', + f'ssl-build/{HOSTNAME}/{HOSTNAME}-puppet-client.crt', + f'ssl-build/{HOSTNAME}/{HOSTNAME}-puppet-client.key', +] + + +@pytest.fixture(scope="module") +def generate_custom_proxy_certs(server, certificate_source): + if certificate_source != 'custom_server': + yield + return + + result = subprocess.run( + ['./forge', 'custom-certs', '--hostname', HOSTNAME], + capture_output=True, text=True, + ) + assert result.returncode == 0, f'forge custom-certs failed: {result.stdout}\n{result.stderr}' + yield + + +@pytest.fixture(scope="module") +def generate_bundle(server, certificate_source, generate_custom_proxy_certs): + command = ['./foremanctl', 'certificate-bundle'] + if certificate_source == 'custom_server': + command.extend([ + '--certificate-server-certificate', f'/root/custom-certificates/{HOSTNAME}/certs/{HOSTNAME}.crt', + '--certificate-server-key', f'/root/custom-certificates/{HOSTNAME}/private/{HOSTNAME}.key', + '--certificate-server-ca-certificate', '/root/custom-certificates/certs/ca.crt', + ]) + command.append(HOSTNAME) + + result = subprocess.run(command, capture_output=True, text=True) + assert result.returncode == 0, f'certificate-bundle failed: {result.stdout}\n{result.stderr}' + + +@pytest.fixture(scope="module") +def tarball_members(server, generate_bundle): + result = server.run(f'tar tzf {TARBALL}') + assert result.succeeded, f'Tarball {TARBALL} not found.' + return result.stdout.strip().splitlines() + + +def test_tarball_created(server, generate_bundle): + assert server.file(TARBALL).exists + + +@pytest.mark.parametrize("expected_file", EXPECTED_CA_FILES) +def test_tarball_contains_ca_certificate(tarball_members, expected_file): + assert expected_file in tarball_members + + +@pytest.mark.parametrize("expected_file", EXPECTED_SERVER_FILES) +def test_tarball_contains_server_certificate(tarball_members, expected_file): + assert expected_file in tarball_members + + +@pytest.mark.parametrize("expected_file", EXPECTED_CLIENT_FILES) +def test_tarball_contains_client_certificate(tarball_members, expected_file): + assert expected_file in tarball_members + + +def test_server_certs_are_identical(server, generate_bundle): + apache = server.run(f'tar xzf {TARBALL} -O ssl-build/{HOSTNAME}/{HOSTNAME}-apache.crt') + assert apache.succeeded + proxy = server.run(f'tar xzf {TARBALL} -O ssl-build/{HOSTNAME}/{HOSTNAME}-foreman-proxy.crt') + assert proxy.succeeded + assert apache.stdout == proxy.stdout + + +def test_client_certs_are_identical(server, generate_bundle): + proxy_client = server.run(f'tar xzf {TARBALL} -O ssl-build/{HOSTNAME}/{HOSTNAME}-foreman-proxy-client.crt') + assert proxy_client.succeeded + puppet_client = server.run(f'tar xzf {TARBALL} -O ssl-build/{HOSTNAME}/{HOSTNAME}-puppet-client.crt') + assert puppet_client.succeeded + assert proxy_client.stdout == puppet_client.stdout + + +def test_proxy_certs_isolated(server, generate_bundle): + proxy_cert = server.file(f'/root/certificates/hosts/{HOSTNAME}/certs/{HOSTNAME}.crt') + assert proxy_cert.exists + proxy_client_cert = server.file(f'/root/certificates/hosts/{HOSTNAME}/certs/{HOSTNAME}-client.crt') + assert proxy_client_cert.exists + + +def test_proxy_certs_not_in_flat_directory(server, generate_bundle): + flat_cert = server.file(f'/root/certificates/certs/{HOSTNAME}.crt') + assert not flat_cert.exists + + +def test_ca_certs_are_identical(server, generate_bundle, default_certificates): + server_ca = server.run(f'tar xzf {TARBALL} -O ssl-build/katello-server-ca.crt') + assert server_ca.succeeded + default_ca = server.run(f'tar xzf {TARBALL} -O ssl-build/katello-default-ca.crt') + assert default_ca.succeeded + assert server_ca.stdout == default_ca.stdout + + +def test_ca_certs_differ_for_custom(server, generate_bundle, custom_certificates): + server_ca = server.run(f'tar xzf {TARBALL} -O ssl-build/katello-server-ca.crt') + assert server_ca.succeeded + default_ca = server.run(f'tar xzf {TARBALL} -O ssl-build/katello-default-ca.crt') + assert default_ca.succeeded + assert server_ca.stdout != default_ca.stdout diff --git a/tests/fixtures/help/features.txt b/tests/fixtures/help/features.txt new file mode 100644 index 000000000..e69de29bb diff --git a/tests/fixtures/help/migrate.txt b/tests/fixtures/help/migrate.txt new file mode 100644 index 000000000..e69de29bb