From 8bf6dfbefb9f88a89f18a48608921e576b9dabf3 Mon Sep 17 00:00:00 2001 From: June Date: Tue, 31 Mar 2026 16:48:00 +0200 Subject: [PATCH] certbot(role): support DNS-01 certs using acme-dns Introduce new configuration structure called certbot__certs, which allows for different challenge types per cert with the first challenge type supported being dns-01-acme-dns. --- roles/certbot/README.md | 30 +++++++++++++ roles/certbot/defaults/main.yaml | 1 + .../manual_auth_scripts/dns-01-acme-dns.sh | 14 +++++++ roles/certbot/meta/argument_specs.yaml | 19 +++++++++ roles/certbot/tasks/main/cert.yaml | 42 ++++++++++++++----- roles/certbot/tasks/main/certs.yaml | 12 +++++- roles/certbot/tasks/main/http_01_cert.yaml | 24 +++++++++++ roles/certbot/tasks/main/install.yaml | 33 +++++++++++---- roles/certbot/tasks/main/validate_cert.yaml | 31 ++++++++++++++ 9 files changed, 188 insertions(+), 18 deletions(-) create mode 100644 roles/certbot/files/manual_auth_scripts/dns-01-acme-dns.sh create mode 100644 roles/certbot/tasks/main/http_01_cert.yaml create mode 100644 roles/certbot/tasks/main/validate_cert.yaml diff --git a/roles/certbot/README.md b/roles/certbot/README.md index 3ad35bf..d9b7e73 100644 --- a/roles/certbot/README.md +++ b/roles/certbot/README.md @@ -6,6 +6,24 @@ Note: This role doesn't take care of deleting certificates. Also see the following documentation for a full How-to on how to get certificates using this role in the context of our infra: . +## Challenge Types + +For the `certbot__certs` configuration, depending on the challenge type, different preparation needs to be done. + +### `dns-01-acme-dns` + +For the `dns-01-acme-dns` challenge type, ensure that: + +1. An acme-dns entry got registered, so you have access to the `subdomain`, `apiUser` and `apiKey` for the configuration. The `apiKey` should be stored in some kind of secret, which then gets referenced in the relevant `certbot__certs` configuration. + For our acme-dns instance, go to , sign-in and register a new entry. +2. CNAME the `_acme-challenge` domain of the domain you want to obtain a certificate for (`certbot__certs.*.commonName`) to the full domain from the registration. + It should look something like this: `_acme-challenge.domainiwantcertfor.tld. CNAME 3ed25037-79f1-4a89-8934-db3e162fe2bc.auth.acmedns.hamburg.ccc.de.` + +For more info see: + +- [The ACME DNS hamburg.ccc.de Wiki page](https://wiki.hamburg.ccc.de/infrastructure:services:acme_dns) +- [The acme-dns GitHub](https://github.com/acme-dns/acme-dns) + ## Required Arguments - `certbot__acme_account_email_address`: The E-Mail address to use for the ACME account. @@ -15,6 +33,18 @@ Also see the following documentation for a full How-to on how to get certificate - `certbot__certificate_domains`: The domains for which to obtain a certificate using the HTTP-01 challenge. - `certbot__http_01_port`: The port number the bot listens on. Should be `80` if directly exposed to the internet. Defaults to `31820` (for the public-reverse-proxy setup). +- `certbot__certs`: Certificates to create. + Defaults to the empty list (`[ ]`). +- `certbot__certs.*.commonName`: The common name to issue the certificate for. +- `certbot__certs.*.challengeType`: The challenge type to use for getting the certificate. Challenge type-specific configuration must be provided as well. + Should be one of: + - `dns-01-acme-dns` +- `certbot__certs.*.dns_01_acme_dns`: Configuration for the `dns-01-acme-dns` challenge type. +- `certbot__certs.*.dns_01_acme_dns.serverUrl`: The acme-dns server API URL. + Defaults to `https://acmedns.hamburg.ccc.de`. +- `certbot__certs.*.dns_01_acme_dns.subdomain`: The acme-dns subdomain to use. +- `certbot__certs.*.dns_01_acme_dns.apiUser`: The acme-dns API user to use. +- `certbot__certs.*.dns_01_acme_dns.apiKey`: The acme-dns API key to use. - `certbot__new_cert_commands`: A list of commands to execute after getting a new certificate. Will be added into a bash script. Defaults to the empty list (`[ ]`). diff --git a/roles/certbot/defaults/main.yaml b/roles/certbot/defaults/main.yaml index 9e6551e..ab48293 100644 --- a/roles/certbot/defaults/main.yaml +++ b/roles/certbot/defaults/main.yaml @@ -1,3 +1,4 @@ certbot__certificate_domains: [ ] certbot__http_01_port: 31820 +certbot__certs: [ ] certbot__new_cert_commands: [ ] diff --git a/roles/certbot/files/manual_auth_scripts/dns-01-acme-dns.sh b/roles/certbot/files/manual_auth_scripts/dns-01-acme-dns.sh new file mode 100644 index 0000000..1b39bd6 --- /dev/null +++ b/roles/certbot/files/manual_auth_scripts/dns-01-acme-dns.sh @@ -0,0 +1,14 @@ +# #!/usr/bin/env bash + +CERT_CONFIG_FILE="/etc/ansible_certbot/cert_configs/$CERTBOT_DOMAIN.json" +ACME_DNS_SERVER_URL=$( jq -er '.dns_01_acme_dns.serverUrl' "$CERT_CONFIG_FILE" ) +export ACME_DNS_SUBDOMAIN=$( jq -er '.dns_01_acme_dns.subdomain' "$CERT_CONFIG_FILE" ) +ACME_DNS_API_USER=$( jq -er '.dns_01_acme_dns.apiUser' "$CERT_CONFIG_FILE" ) +ACME_DNS_API_KEY=$( jq -er '.dns_01_acme_dns.apiKey' "$CERT_CONFIG_FILE" ) + +jq -nec '{ "subdomain": env.ACME_DNS_SUBDOMAIN, "txt": env.CERTBOT_VALIDATION }' | curl "$ACME_DNS_SERVER_URL/update" \ + --request POST \ + --fail-with-body \ + --header "X-Api-User: $ACME_DNS_API_USER" \ + --header "X-Api-Key: $ACME_DNS_API_KEY" \ + --json @- diff --git a/roles/certbot/meta/argument_specs.yaml b/roles/certbot/meta/argument_specs.yaml index b895b5f..4899ea8 100644 --- a/roles/certbot/meta/argument_specs.yaml +++ b/roles/certbot/meta/argument_specs.yaml @@ -13,6 +13,25 @@ argument_specs: type: str required: false default: 31820 + certbot__certs: + type: list + elements: dict + required: false + default: [ ] + options: + commonName: + type: str + required: true + # ToDo: subjectAlternativeNames: + challengeType: + type: str + required: true + choices: + - dns-01-acme-dns + dns_01_acme_dns: + type: dict + required: false + # Further checking done in tasks/validate_cert.yaml certbot__new_cert_commands: type: list elements: str diff --git a/roles/certbot/tasks/main/cert.yaml b/roles/certbot/tasks/main/cert.yaml index d829fb1..61f6d45 100644 --- a/roles/certbot/tasks/main/cert.yaml +++ b/roles/certbot/tasks/main/cert.yaml @@ -1,24 +1,46 @@ - name: get expiry date before - ansible.builtin.command: /usr/bin/openssl x509 -enddate -noout -in /etc/letsencrypt/live/{{ item }}/fullchain.pem + ansible.builtin.command: /usr/bin/openssl x509 -enddate -noout -in /etc/letsencrypt/live/{{ item.commonName }}/fullchain.pem ignore_errors: true become: true changed_when: false register: certbot__cert_expiry_before -- name: obtain the certificate using certbot - ansible.builtin.command: /usr/bin/certbot certonly --keep-until-expiring --agree-tos --non-interactive --email "{{ certbot__acme_account_email_address }}" --no-eff-email --standalone --http-01-port "{{ certbot__http_01_port }}" -d "{{ item }}" +- name: ensure directory for cert configs exists + ansible.builtin.file: + path: "/etc/ansible_certbot/cert_configs/" + state: directory + owner: root + group: root + mode: "0750" + become: true + +- name: ensure cert config is stored + ansible.builtin.copy: + content: "{{ cert_config_defaults[item.challengeType] | combine(item, recursive=True) | ansible.builtin.to_nice_json }}" + dest: "/etc/ansible_certbot/cert_configs/{{ item.commonName }}.json" + owner: root + group: root + mode: "0640" + become: true + vars: + cert_config_defaults: + dns-01-acme-dns: + dns_01_acme_dns: + serverUrl: "https://acmedns.hamburg.ccc.de" + +# # https://eff-certbot.readthedocs.io/en/stable/using.html#manual +- name: obtain the certificate using certbot and the manual auth hook + ansible.builtin.command: /usr/bin/certbot certonly --keep-until-expiring --agree-tos --non-interactive --email "{{ certbot__acme_account_email_address }}" --no-eff-email --manual --preferred-challenge dns --manual-auth-hook "/usr/local/lib/ansible_certbot/manual_auth_scripts/{{ item.challengeType }}.sh" -d "{{ item.commonName }}" become: true changed_when: false - name: get expiry date after - ansible.builtin.command: /usr/bin/openssl x509 -enddate -noout -in /etc/letsencrypt/live/{{ item }}/fullchain.pem + ansible.builtin.command: /usr/bin/openssl x509 -enddate -noout -in /etc/letsencrypt/live/{{ item.commonName }}/fullchain.pem become: true changed_when: false register: certbot__cert_expiry_after -# Doesn't work anymore. Dunno why. -# TODO: Fix -# - name: potentially report changed -# ansible.builtin.debug: -# msg: "If this reports changed, then the certificate expiry date and therefore the certificate changed." -# changed_when: certbot__cert_expiry_before.stdout != certbot__cert_expiry_after.stdout +- name: potentially report changed + ansible.builtin.debug: + msg: "If this reports changed, then the certificate expiry date and therefore the certificate changed." + changed_when: certbot__cert_expiry_before.stdout != certbot__cert_expiry_after.stdout diff --git a/roles/certbot/tasks/main/certs.yaml b/roles/certbot/tasks/main/certs.yaml index 2b91184..16271b7 100644 --- a/roles/certbot/tasks/main/certs.yaml +++ b/roles/certbot/tasks/main/certs.yaml @@ -1,4 +1,14 @@ -- name: obtain certificates +- name: obtain http-01 challenge certificates loop: "{{ certbot__certificate_domains }}" + ansible.builtin.include_tasks: + file: main/http_01_cert.yaml + +- name: validate certs config + loop: "{{ certbot__certs }}" + ansible.builtin.include_tasks: + file: main/validate_cert.yaml + +- name: obtain certs + loop: "{{ certbot__certs }}" ansible.builtin.include_tasks: file: main/cert.yaml diff --git a/roles/certbot/tasks/main/http_01_cert.yaml b/roles/certbot/tasks/main/http_01_cert.yaml new file mode 100644 index 0000000..d829fb1 --- /dev/null +++ b/roles/certbot/tasks/main/http_01_cert.yaml @@ -0,0 +1,24 @@ +- name: get expiry date before + ansible.builtin.command: /usr/bin/openssl x509 -enddate -noout -in /etc/letsencrypt/live/{{ item }}/fullchain.pem + ignore_errors: true + become: true + changed_when: false + register: certbot__cert_expiry_before + +- name: obtain the certificate using certbot + ansible.builtin.command: /usr/bin/certbot certonly --keep-until-expiring --agree-tos --non-interactive --email "{{ certbot__acme_account_email_address }}" --no-eff-email --standalone --http-01-port "{{ certbot__http_01_port }}" -d "{{ item }}" + become: true + changed_when: false + +- name: get expiry date after + ansible.builtin.command: /usr/bin/openssl x509 -enddate -noout -in /etc/letsencrypt/live/{{ item }}/fullchain.pem + become: true + changed_when: false + register: certbot__cert_expiry_after + +# Doesn't work anymore. Dunno why. +# TODO: Fix +# - name: potentially report changed +# ansible.builtin.debug: +# msg: "If this reports changed, then the certificate expiry date and therefore the certificate changed." +# changed_when: certbot__cert_expiry_before.stdout != certbot__cert_expiry_after.stdout diff --git a/roles/certbot/tasks/main/install.yaml b/roles/certbot/tasks/main/install.yaml index 40ccf75..d0fa58d 100644 --- a/roles/certbot/tasks/main/install.yaml +++ b/roles/certbot/tasks/main/install.yaml @@ -1,11 +1,30 @@ -- name: make sure the `openssl` package is installed +- name: ensure relevant packages are installed ansible.builtin.apt: - name: openssl + name: + - openssl + - certbot + - jq state: present become: true -- name: make sure the `certbot` package is installed - ansible.builtin.apt: - name: certbot - state: present - become: true +- name: ensure manual auth scripts are deployed + block: + - name: ensure manual auth scripts directory exists + ansible.builtin.file: + path: "/usr/local/lib/ansible_certbot/manual_auth_scripts" + state: directory + owner: root + group: root + mode: "0755" + become: true + + - name: ensure manual auth scripts are deployed + ansible.builtin.copy: + src: "manual_auth_scripts/{{ item }}.sh" + dest: "/usr/local/lib/ansible_certbot/manual_auth_scripts/{{ item }}.sh" + owner: root + group: root + mode: "0754" + become: true + loop: + - "dns-01-acme-dns" diff --git a/roles/certbot/tasks/main/validate_cert.yaml b/roles/certbot/tasks/main/validate_cert.yaml new file mode 100644 index 0000000..a13b3b9 --- /dev/null +++ b/roles/certbot/tasks/main/validate_cert.yaml @@ -0,0 +1,31 @@ +- name: validate dns-01-acme-dns challenge type config + when: item.challengeType == "dns-01-acme-dns" + block: + - name: assert dns_01_acme_dns config exists + ansible.builtin.assert: + that: item.dns_01_acme_dns is defined + + - name: assert dns_01_acme_dns config is valid + ansible.builtin.validate_argument_spec: + argument_spec: "{{ required_data }}" + provided_arguments: + dns_01_acme_dns: "{{ item.dns_01_acme_dns }}" + vars: + required_data: + dns_01_acme_dns: + type: dict + required: true + options: + serverUrl: + type: str + required: false + default: https://acmedns.hamburg.ccc.de + subdomain: + type: str + required: true + apiUser: + type: str + required: true + apiKey: + type: str + required: true