Skip to content

Commit e75e42c

Browse files
ehelmsclaude
andcommitted
Add custom server certificate support
Allow users to provide their own server certificates via --certificate-server-certificate, --certificate-server-key, and --certificate-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. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 06add80 commit e75e42c

23 files changed

Lines changed: 367 additions & 69 deletions

File tree

.github/workflows/test.yml

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,10 @@ jobs:
6161
security: none
6262
database: external
6363
box: centos/stream9
64+
- certificate_source: custom_server
65+
security: none
66+
database: internal
67+
box: centos/stream9
6468
runs-on: ubuntu-24.04
6569
env:
6670
FOREMANCTL_BASE_BOX: ${{ matrix.box }}
@@ -93,6 +97,10 @@ jobs:
9397
if: contains(matrix.certificate_source, 'installer')
9498
run: |
9599
./forge installer-certs
100+
- name: Create custom certificates
101+
if: matrix.certificate_source == 'custom_server'
102+
run: |
103+
./forge custom-certs
96104
- name: Setup security mode ${{ matrix.security }}
97105
if: matrix.security != 'none'
98106
run: |
@@ -110,7 +118,7 @@ jobs:
110118
./foremanctl pull-images
111119
- name: Run deployment
112120
run: |
113-
./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
121+
./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
114122
- name: Add optional feature - hammer
115123
run: |
116124
./foremanctl deploy --add-feature hammer
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
- name: Generate custom certificates for testing
3+
hosts:
4+
- quadlet
5+
become: true
6+
vars:
7+
certificates_ca_directory: /root/custom-certificates
8+
certificates_ca_password: "CUSTOMCA"
9+
certificates_ca_subject: 'Custom Test CA'
10+
certificates_hostnames:
11+
- "{{ ansible_facts['fqdn'] }}"
12+
roles:
13+
- role: certificates

development/playbooks/deploy-dev/deploy-dev.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
vars_files:
66
- "../../../src/vars/defaults.yml"
77
- "../../../src/vars/flavors/{{ flavor }}.yml"
8-
- "../../../src/vars/{{ certificate_source }}_certificates.yml"
8+
- "../../../src/vars/{{ certificates_source }}_certificates.yml"
99
- "../../../src/vars/images.yml"
1010
- "../../../src/vars/database.yml"
1111
- "../../../src/vars/foreman.yml"

docs/user/certificates.md

Lines changed: 67 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,19 @@ This document describes how certificate generation and management works in forem
66

77
### Certificate Sources
88

9-
foremanctl supports two certificate sources that determine how certificates are obtained:
9+
foremanctl supports three certificate sources that determine how certificates are obtained:
1010

1111
**Default Source (`certificate_source: default`)**
1212
- Automatically generates self-signed certificates during deployment
1313
- Creates a complete PKI infrastructure with CA, server, and client certificates
1414
- Recommended for development and testing environments
1515

16+
**Custom Server Source (`certificate_source: custom_server`)**
17+
- Uses custom server certificates provided by the user (e.g., signed by your organization's CA)
18+
- Automatically generates an internal CA for client certificates and localhost
19+
- Server certificate, key, and CA bundle are copied to `/root/certificates/`
20+
- Certificate source persists across deployments; original files only needed on first deploy or when updating certificates
21+
1622
**Installer Source (`certificate_source: installer`)**
1723
- Uses existing certificates from a previous `foreman-installer` deployment
1824
- Useful for migration scenarios where certificates already exist
@@ -30,6 +36,29 @@ foremanctl deploy
3036
foremanctl deploy --certificate-source=default
3137
```
3238

39+
#### Using Custom Server Certificates
40+
41+
```bash
42+
# First deployment with custom certificates
43+
foremanctl deploy \
44+
--certificate-source=custom_server \
45+
--certificate-server-certificate /path/to/server.crt \
46+
--certificate-server-key /path/to/server.key \
47+
--certificate-server-ca-certificate /path/to/ca-bundle.crt
48+
49+
# Subsequent deployments (certificate paths no longer needed)
50+
foremanctl deploy
51+
52+
# Update certificates (provide new paths)
53+
foremanctl deploy \
54+
--certificate-server-certificate /new/path/server.crt \
55+
--certificate-server-key /new/path/server.key \
56+
--certificate-server-ca-certificate /new/path/ca-bundle.crt
57+
58+
# Switch back to auto-generated certificates
59+
foremanctl deploy --certificate-source=default
60+
```
61+
3362
#### Using Existing Installer Certificates
3463

3564
```bash
@@ -46,6 +75,12 @@ After deployment, certificates are available at:
4675
- Server Certificate: `/root/certificates/certs/<hostname>.crt`
4776
- Client Certificate: `/root/certificates/certs/<hostname>-client.crt`
4877

78+
**Custom Server Source:**
79+
- CA Certificate: `/root/certificates/certs/ca.crt` (internal CA)
80+
- Server Certificate: `/root/certificates/certs/<hostname>.crt` (custom, user-provided)
81+
- Server CA Certificate: `/root/certificates/certs/server-ca.crt` (custom CA that signed server cert)
82+
- Client Certificate: `/root/certificates/certs/<hostname>-client.crt` (generated by internal CA)
83+
4984
**Installer Source:**
5085
- CA Certificate: `/root/ssl-build/katello-default-ca.crt`
5186
- Server Certificate: `/root/ssl-build/<hostname>/<hostname>-apache.crt`
@@ -94,9 +129,10 @@ The `--certificate-renew` flag is **not persisted** in foremanctl’s answers fi
94129

95130
### Current Limitations
96131

97-
- Cannot provide custom certificate files during deployment
98132
- Uses the same lifetime for both client and server certificates
99133
- Limited certificate customization options
134+
- Custom server certificates cannot be combined with `certificate_source: installer`
135+
- CNAMEs are only applied to certificates generated by the default CA
100136

101137
## Internal Design
102138

@@ -109,14 +145,17 @@ The certificate system uses a modular Ansible role-based approach with clear sep
109145
```
110146
src/roles/certificates/
111147
├── tasks/
112-
│ ├── main.yml # Entry point - orchestrates CA and certificate generation
113-
│ ├── ca.yml # CA certificate generation
114-
│ └── issue.yml # Host certificate issuance (server + client per hostname)
115-
└── defaults/main.yml # Default configuration variables (validity, algorithm, paths)
148+
│ ├── main.yml # Entry point for certificate management
149+
│ ├── ca.yml # CA certificate generation
150+
│ ├── issue.yml # Host certificate issuance
151+
│ └── custom.yml # Applies user-provided custom server certs
152+
└── defaults/main.yml # Default configuration variables
116153
```
117154

118155
#### Certificate Generation Workflow
119156

157+
For `certificate_source: default`:
158+
120159
1. **CA Generation** (when `certificates_ca: true`):
121160
- Install dependencies (`python3-cryptography`) and create directory layout under `certificates_ca_directory`
122161
- Generate RSA private key (size from `certificates_algorithm_size`, default 4096)
@@ -129,21 +168,40 @@ src/roles/certificates/
129168

130169
Generation uses **`community.crypto`** (keys, CSRs, X.509) and **`python3-cryptography`**.
131170

171+
For `certificate_source: custom_server`:
172+
173+
1. **CA Generation**: Generate self-signed internal CA certificate and key with 20-year validity
174+
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)
175+
3. **Host Certificate Issuance**: Generate client certificate and localhost certificate signed by the internal CA (server cert for FQDN is skipped)
176+
177+
For `certificate_source: installer`:
178+
179+
- Uses existing certificates from `/root/ssl-build/` generated by foreman-installer
180+
- No certificate generation performed; files must already exist
181+
132182
#### Variable System
133183

134184
Certificate paths are defined in source-specific variable files:
135185

136186
**Default Source (`src/vars/default_certificates.yml`):**
137187
```yaml
138188
ca_certificate: "{{ certificates_ca_directory }}/certs/ca.crt"
189+
ca_bundle: "{{ certificates_ca_directory }}/certs/ca-bundle.crt"
139190
server_certificate: "{{ certificates_ca_directory }}/certs/{{ ansible_facts['fqdn'] }}.crt"
191+
server_ca_certificate: "{{ certificates_ca_directory }}/certs/server-ca.crt"
140192
client_certificate: "{{ certificates_ca_directory }}/certs/{{ ansible_facts['fqdn'] }}-client.crt"
141193
```
142194
195+
**Custom Server Source (`src/vars/custom_server_certificates.yml`):**
196+
- Uses the same paths as default source
197+
- The `server_ca_certificate` points to the custom CA that signed the server certificate
198+
- The `ca_bundle` contains both the internal CA and custom server CA
199+
143200
**Installer Source (`src/vars/installer_certificates.yml`):**
144201
```yaml
145202
ca_certificate: "/root/ssl-build/katello-default-ca.crt"
146203
server_certificate: "/root/ssl-build/{{ ansible_facts['fqdn'] }}/{{ ansible_facts['fqdn'] }}-apache.crt"
204+
server_ca_certificate: "/root/ssl-build/katello-server-ca.crt"
147205
client_certificate: "/root/ssl-build/{{ ansible_facts['fqdn'] }}/{{ ansible_facts['fqdn'] }}-foreman-client.crt"
148206
```
149207

@@ -177,9 +235,9 @@ The `certificate_checks` role uses `foreman-certificate-check` binary to validat
177235
**Directory Structure:**
178236
```
179237
/root/certificates/
180-
├── certs/ # Public certificates
181-
├── private/ # Private keys and passwords
182-
└── requests/ # Certificate signing requests
238+
├── certs/ # Public certificates (ca.crt, server-ca.crt, ca-bundle.crt, *.crt)
239+
├── private/ # Private keys and passwords (ca.key, ca.pwd, *.key)
240+
└── requests/ # Certificate signing requests (*.csr)
183241
```
184242
185243
**SANs and CNAMEs:**
Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
---
22
variables:
3-
certificate_source:
4-
help: Where certificates are coming from. Currently default Ansible role or the foreman-installer.
3+
certificates_source:
4+
help: Where certificates are coming from. Currently default Ansible role, the foreman-installer, or custom server certificates.
5+
parameter: --certificate-source
56
choices:
67
- default
78
- installer
9+
- custom_server

src/playbooks/deploy/deploy.yaml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
vars_files:
77
- "../../vars/defaults.yml"
88
- "../../vars/flavors/{{ flavor }}.yml"
9-
- "../../vars/{{ certificate_source }}_certificates.yml"
9+
- "../../vars/{{ certificates_source }}_certificates.yml"
1010
- "../../vars/images.yml"
1111
- "../../vars/tuning/{{ tuning }}.yml"
1212
- "../../vars/database.yml"
@@ -16,12 +16,12 @@
1616
- role: pre_install
1717
- role: checks
1818
- role: certificates
19-
when: "certificate_source == 'default'"
19+
when: "certificates_source in ['default', 'custom_server']"
2020
- role: certificate_checks
2121
vars:
2222
certificate_checks_certificate: "{{ server_certificate }}"
2323
certificate_checks_key: "{{ server_key }}"
24-
certificate_checks_ca: "{{ ca_certificate }}"
24+
certificate_checks_ca: "{{ server_ca_certificate }}"
2525
- role: postgresql
2626
when:
2727
- database_mode == 'internal'

src/playbooks/deploy/metadata.obsah.yaml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,29 @@ variables:
2929
action: append_unique
3030
type: FQDN
3131
parameter: --certificate-cname
32+
certificates_custom_server_certificate:
33+
help: Path to a custom server certificate to use instead of the auto-generated one.
34+
type: AbsolutePath
35+
parameter: --certificate-server-certificate
36+
persist: false
37+
certificates_custom_server_key:
38+
help: Path to the private key for the custom server certificate.
39+
type: AbsolutePath
40+
parameter: --certificate-server-key
41+
persist: false
42+
certificates_custom_server_ca_certificate:
43+
help: Path to the CA certificate that signed the custom server certificate.
44+
type: AbsolutePath
45+
parameter: --certificate-server-ca-certificate
46+
persist: false
47+
48+
constraints:
49+
required_together:
50+
- [certificates_custom_server_certificate, certificates_custom_server_key, certificates_custom_server_ca_certificate]
51+
required_if:
52+
- [certificates_source, custom_server, [certificates_custom_server_certificate, certificates_custom_server_key, certificates_custom_server_ca_certificate]]
53+
forbidden_if:
54+
- [certificates_source, installer, [certificates_custom_server_certificate, certificates_custom_server_key, certificates_custom_server_ca_certificate]]
3255

3356
include:
3457
- _certificate_source

src/roles/certificates/defaults/main.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
---
2+
certificates_source: default
23
certificates_ca: true
34
certificates_ca_directory: /root/certificates # Change this to /var/lib?
45
certificates_ca_directory_keys: "{{ certificates_ca_directory }}/private"
56
certificates_ca_directory_certs: "{{ certificates_ca_directory }}/certs"
67
certificates_ca_directory_requests: "{{ certificates_ca_directory }}/requests"
8+
certificates_ca_subject: 'Foreman Self-signed CA'
79
certificates_cnames: []
810
certificates_algorithm_type: RSA
911
certificates_algorithm_size: 4096

src/roles/certificates/tasks/ca.yml

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@
4747
path: "{{ certificates_ca_directory_requests }}/ca.csr"
4848
privatekey_path: "{{ certificates_ca_directory_keys }}/ca.key"
4949
privatekey_passphrase: "{{ certificates_ca_password }}"
50-
common_name: "Foreman Self-signed CA"
50+
common_name: "{{ certificates_ca_subject }}"
5151
use_common_name_for_san: false
5252
basic_constraints:
5353
- 'CA:TRUE'
@@ -67,3 +67,19 @@
6767
privatekey_passphrase: "{{ certificates_ca_password }}"
6868
provider: selfsigned
6969
selfsigned_not_after: "+{{ certificates_ca_validity_days }}d"
70+
71+
- name: 'Copy CA as server CA certificate'
72+
ansible.builtin.copy:
73+
src: "{{ certificates_ca_directory_certs }}/ca.crt"
74+
dest: "{{ certificates_ca_directory_certs }}/server-ca.crt"
75+
remote_src: true
76+
force: false
77+
mode: '0444'
78+
79+
- name: 'Create CA bundle'
80+
ansible.builtin.copy:
81+
src: "{{ certificates_ca_directory_certs }}/ca.crt"
82+
dest: "{{ certificates_ca_directory_certs }}/ca-bundle.crt"
83+
remote_src: true
84+
force: false
85+
mode: '0444'
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
- name: Copy custom server certificate
3+
ansible.builtin.copy:
4+
src: "{{ certificates_custom_server_certificate }}"
5+
dest: "{{ certificates_ca_directory_certs }}/{{ ansible_facts['fqdn'] }}.crt"
6+
remote_src: true
7+
mode: '0444'
8+
9+
- name: Copy custom server key
10+
ansible.builtin.copy:
11+
src: "{{ certificates_custom_server_key }}"
12+
dest: "{{ certificates_ca_directory_keys }}/{{ ansible_facts['fqdn'] }}.key"
13+
remote_src: true
14+
mode: '0440'
15+
16+
- name: Copy custom server CA certificate
17+
ansible.builtin.copy:
18+
src: "{{ certificates_custom_server_ca_certificate }}"
19+
dest: "{{ certificates_ca_directory_certs }}/server-ca.crt"
20+
remote_src: true
21+
mode: '0444'
22+
23+
- name: Create CA bundle with internal CA and custom server CA
24+
ansible.builtin.assemble:
25+
src: "{{ certificates_ca_directory_certs }}"
26+
dest: "{{ certificates_ca_directory_certs }}/ca-bundle.crt"
27+
regexp: '(ca|server-ca)\.crt$'
28+
mode: '0444'

0 commit comments

Comments
 (0)