Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down Expand Up @@ -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: |
Expand All @@ -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
Expand Down
13 changes: 13 additions & 0 deletions development/playbooks/custom-certs/custom-certs.yaml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion development/playbooks/deploy-dev/deploy-dev.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
76 changes: 67 additions & 9 deletions docs/user/certificates.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -46,6 +75,12 @@ After deployment, certificates are available at:
- Server Certificate: `/root/certificates/certs/<hostname>.crt`
- Client Certificate: `/root/certificates/certs/<hostname>-client.crt`

**Custom Server Source:**
- CA Certificate: `/root/certificates/certs/ca.crt` (internal CA)
- Server Certificate: `/root/certificates/certs/<hostname>.crt` (custom, user-provided)
- Server CA Certificate: `/root/certificates/certs/server-ca.crt` (custom CA that signed server cert)
- Client Certificate: `/root/certificates/certs/<hostname>-client.crt` (generated by internal CA)

**Installer Source:**
- CA Certificate: `/root/ssl-build/katello-default-ca.crt`
- Server Certificate: `/root/ssl-build/<hostname>/<hostname>-apache.crt`
Expand Down Expand Up @@ -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

Expand All @@ -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)
Expand All @@ -129,21 +168,40 @@ 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:

**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"
```

Expand Down Expand Up @@ -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:**
Expand Down
6 changes: 4 additions & 2 deletions src/playbooks/_certificate_source/metadata.obsah.yaml
Original file line number Diff line number Diff line change
@@ -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
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we really need this option explicitly? Can't we use the other two options, and if certificates_custom_server* options are specified, they will take precedence over the default paths specified by certificate-source?
This will also address my concern about combining installer client certs with custom server ones.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do, for this design to work where the user does not have to persist the certificates used as input on disk. We need some indicator of the mode they are operating in. I'll update the PR description as this design has deviated since I first opened it.

6 changes: 3 additions & 3 deletions src/playbooks/deploy/deploy.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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'
Expand Down
21 changes: 21 additions & 0 deletions src/playbooks/deploy/metadata.obsah.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
obsah >= 1.8.0
obsah >= 1.8.1
requests
requests-oauthlib
2 changes: 2 additions & 0 deletions src/roles/certificates/defaults/main.yml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
18 changes: 17 additions & 1 deletion src/roles/certificates/tasks/ca.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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
Comment thread
ehelms marked this conversation as resolved.
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'
28 changes: 28 additions & 0 deletions src/roles/certificates/tasks/custom.yml
Original file line number Diff line number Diff line change
@@ -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'
Loading