diff options
| author | Jan Tuomi <jan@jantuomi.fi> | 2025-12-17 10:00:59 +0200 |
|---|---|---|
| committer | Jan Tuomi <jan@jantuomi.fi> | 2025-12-17 10:00:59 +0200 |
| commit | d2d64fed66dfd6b4682bea118323315879bc30a5 (patch) | |
| tree | 26371ff4bcad57ed515ed76005d80c3379a0c3d9 | |
| parent | db38812d2daa7e19e74ae1b4ee9131eba06998bb (diff) | |
Use DNS-01 challenge with certbot
| -rw-r--r-- | playbook.yml | 9 | ||||
| -rw-r--r-- | tasks/jail_ingress.yml | 63 | ||||
| -rwxr-xr-x | templates/ingress/acme-dns-auth.py | 170 | ||||
| -rw-r--r-- | templates/ingress/etc_crontab.j2 | 4 | ||||
| -rw-r--r-- | templates/ingress/usr_local_etc_nginx_nginx.conf.j2 | 12 |
5 files changed, 239 insertions, 19 deletions
diff --git a/playbook.yml b/playbook.yml index 23d3b3b..ef51dcb 100644 --- a/playbook.yml +++ b/playbook.yml @@ -32,6 +32,7 @@ - { num: 11, name: spliit } - { num: 13, name: ente } - { num: 14, name: freshrss } + - { num: 15, name: paste } ingress_routes: - { host: jan.systems, jail: homepage } - { host: jantuomi.fi, redirect: jan.systems } @@ -41,6 +42,14 @@ - { host: freshrss.jan.systems, jail: freshrss } - { host: irc.jan.systems, jail: irc_thelounge } - { host: goaccess.jan.systems, static: /var/www/goaccess } + - { host: paste.jan.systems, jail: paste } + cert_domains: + - "jan.systems" + - "*.jan.systems" + - "jantuomi.fi" + - "*.jantuomi.fi" + cert_name: "{{ cert_domains[0] }}" + contact_email: jan@jantuomi.fi tasks: - name: Run general tasks diff --git a/tasks/jail_ingress.yml b/tasks/jail_ingress.yml index b40148e..75e364e 100644 --- a/tasks/jail_ingress.yml +++ b/tasks/jail_ingress.yml @@ -13,31 +13,74 @@ owner: root group: wheel mode: "0644" + register: ingress_pf_conf - name: Reload pf.conf shell: jexec ingress pfctl -f /etc/pf.conf + when: ingress_pf_conf.changed + +- name: Check if gateway mode is enabled + shell: jexec ingress sysrc gateway_enable | grep -q "YES" + register: ingress_gateway_enabled + failed_when: false + changed_when: false - name: Enable gateway mode - shell: jexec ingress sysrc gateway_enable=YES && jexec ingress sysctl net.inet.ip.forwarding=1 + shell: jexec ingress sysrc gateway_enable=YES + when: ingress_gateway_enabled.rc != 0 + +- name: Check if IP forwarding is enabled + shell: jexec ingress sysctl net.inet.ip.forwarding | grep -q "1" + register: ingress_ip_forwarding_enabled + failed_when: false + changed_when: false + +- name: Enable IP forwarding + shell: jexec ingress sysctl net.inet.ip.forwarding=1 + when: ingress_ip_forwarding_enabled.rc != 0 + +- name: Check if pf enabled + shell: jexec ingress sysrc pf_enable | grep -q "YES" + register: ingress_pf_enabled + failed_when: false + changed_when: false - name: Enable pf shell: jexec ingress sysrc pf_enable=YES + when: ingress_pf_enabled.rc != 0 - name: Check if pf is running shell: service -j ingress pf status - register: pf_status - changed_when: pf_status.rc != 0 + register: ingress_pf_status + changed_when: ingress_pf_status.rc != 0 - name: Start pf shell: service -j ingress pf start - when: pf_status.rc != 0 -# -#- name: Generate LetsEncrypt certs -# shell: jexec ingress certbot certonly --nginx -n -m jan@jantuomi.fi --agree-tos -d {{ item }} -# loop: "{{ ingress_routes | map(attribute='host') | unique }}" + when: ingress_pf_status.rc != 0 + +- name: Copy acme-dns-auth.py + copy: + src: templates/ingress/acme-dns-auth.py + dest: /usr/local/jails/containers/ingress/usr/local/bin/acme-dns-auth.py + owner: root + group: wheel + mode: "0755" + +- name: Check if LetsEncrypt certs are generated + shell: ls /usr/local/jails/containers/ingress/usr/local/etc/letsencrypt/live/{{ cert_name }} + register: cert_exists + failed_when: false + changed_when: false + +- name: Manually get certs with certbot and DNS challenge + pause: + prompt: | + /usr/local/etc/letsencrypt/live/{{ cert_name }} does not exist. This means that this is the first run of this playbook. + The {{ cert_name }} cert contains all of the @ and * certs for all domains. + Check the ingress crontab template and run the certbot command manually in the ingress jail. Remove the "-n" flag. + Add the requested DNS records manually into Hetzner DNS. Continue after this is done. + when: cert_exists.rc != 0 -# It's important to run this after generating certs, because nginx.conf refers to files -# generated by certbot. Certbot will fail validation if nginx.conf is configured too early. - name: Configure nginx.conf template: src: ingress/usr_local_etc_nginx_nginx.conf.j2 diff --git a/templates/ingress/acme-dns-auth.py b/templates/ingress/acme-dns-auth.py new file mode 100755 index 0000000..77928e6 --- /dev/null +++ b/templates/ingress/acme-dns-auth.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 +# +# Source: https://github.com/joohoi/acme-dns-certbot-joohoi/blob/master/acme-dns-auth.py +# Some modifications to configuration values present. + + +import json +import os +import sys + +import requests + +### EDIT THESE: Configuration values ### + +# URL to acme-dns instance +ACMEDNS_URL = "https://auth.acme-dns.io" +# Path for acme-dns credential storage +STORAGE_PATH = "/usr/local/etc/letsencrypt/acmedns.json" +# Whitelist for address ranges to allow the updates from +# Example: ALLOW_FROM = ["192.168.10.0/24", "::1/128"] +ALLOW_FROM = [] +# Force re-registration. Overwrites the already existing acme-dns accounts. +FORCE_REGISTER = False + +### DO NOT EDIT BELOW THIS POINT ### +### HERE BE DRAGONS ### + +DOMAIN = os.environ["CERTBOT_DOMAIN"] +if DOMAIN.startswith("*."): + DOMAIN = DOMAIN[2:] +VALIDATION_DOMAIN = "_acme-challenge." + DOMAIN +VALIDATION_TOKEN = os.environ["CERTBOT_VALIDATION"] + + +class AcmeDnsClient(object): + """ + Handles the communication with ACME-DNS API + """ + + def __init__(self, acmedns_url): + self.acmedns_url = acmedns_url + + def register_account(self, allowfrom): + """Registers a new ACME-DNS account""" + + if allowfrom: + # Include whitelisted networks to the registration call + reg_data = {"allowfrom": allowfrom} + res = requests.post( + self.acmedns_url + "/register", data=json.dumps(reg_data) + ) + else: + res = requests.post(self.acmedns_url + "/register") + if res.status_code == 201: + # The request was successful + return res.json() + else: + # Encountered an error + msg = ( + "Encountered an error while trying to register a new acme-dns " + "account. HTTP status {}, Response body: {}" + ) + print(msg.format(res.status_code, res.text)) + sys.exit(1) + + def update_txt_record(self, account, txt): + """Updates the TXT challenge record to ACME-DNS subdomain.""" + update = {"subdomain": account["subdomain"], "txt": txt} + headers = { + "X-Api-User": account["username"], + "X-Api-Key": account["password"], + "Content-Type": "application/json", + } + res = requests.post( + self.acmedns_url + "/update", headers=headers, data=json.dumps(update) + ) + if res.status_code == 200: + # Successful update + return + else: + msg = ( + "Encountered an error while trying to update TXT record in " + "acme-dns. \n" + "------- Request headers:\n{}\n" + "------- Request body:\n{}\n" + "------- Response HTTP status: {}\n" + "------- Response body: {}" + ) + s_headers = json.dumps(headers, indent=2, sort_keys=True) + s_update = json.dumps(update, indent=2, sort_keys=True) + s_body = json.dumps(res.json(), indent=2, sort_keys=True) + print(msg.format(s_headers, s_update, res.status_code, s_body)) + sys.exit(1) + + +class Storage(object): + def __init__(self, storagepath): + self.storagepath = storagepath + self._data = self.load() + + def load(self): + """Reads the storage content from the disk to a dict structure""" + data = dict() + filedata = "" + try: + with open(self.storagepath, "r") as fh: + filedata = fh.read() + except IOError as e: + if os.path.isfile(self.storagepath): + # Only error out if file exists, but cannot be read + print("ERROR: Storage file exists but cannot be read") + sys.exit(1) + try: + data = json.loads(filedata) + except ValueError: + if len(filedata) > 0: + # Storage file is corrupted + print("ERROR: Storage JSON is corrupted") + sys.exit(1) + return data + + def save(self): + """Saves the storage content to disk""" + serialized = json.dumps(self._data) + try: + with os.fdopen( + os.open(self.storagepath, os.O_WRONLY | os.O_CREAT, 0o600), "w" + ) as fh: + fh.truncate() + fh.write(serialized) + except IOError as e: + print("ERROR: Could not write storage file.") + sys.exit(1) + + def put(self, key, value): + """Puts the configuration value to storage and sanitize it""" + # If wildcard domain, remove the wildcard part as this will use the + # same validation record name as the base domain + if key.startswith("*."): + key = key[2:] + self._data[key] = value + + def fetch(self, key): + """Gets configuration value from storage""" + try: + return self._data[key] + except KeyError: + return None + + +if __name__ == "__main__": + # Init + client = AcmeDnsClient(ACMEDNS_URL) + storage = Storage(STORAGE_PATH) + + # Check if an account already exists in storage + account = storage.fetch(DOMAIN) + if FORCE_REGISTER or not account: + # Create and save the new account + account = client.register_account(ALLOW_FROM) + storage.put(DOMAIN, account) + storage.save() + + # Display the notification for the user to update the main zone + msg = "Please add the following CNAME record to your main DNS zone:\n{}" + cname = "{} CNAME {}.".format(VALIDATION_DOMAIN, account["fulldomain"]) + print(msg.format(cname)) + + # Update the TXT record in acme-dns instance + client.update_txt_record(account, VALIDATION_TOKEN) diff --git a/templates/ingress/etc_crontab.j2 b/templates/ingress/etc_crontab.j2 index 3aa39e1..6879766 100644 --- a/templates/ingress/etc_crontab.j2 +++ b/templates/ingress/etc_crontab.j2 @@ -7,9 +7,7 @@ PATH=/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin #minute hour mday month wday who command # Update LetsEncrypt certificates every day after 2:00 AM -{% for host in tls_hosts %} -{{ loop.index0 }} 2 * * * root certbot certonly --nginx -n -m jan@jantuomi.fi --agree-tos -d {{ host }} -{% endfor %} +0 2 * * * root certbot certonly -n -m {{ contact_email }} --agree-tos --manual --manual-auth-hook /usr/local/bin/acme-dns-auth.py --preferred-challenges dns --debug-challenges {% for domain in cert_domains %}-d '{{ domain }}' {% endfor %} || echo "ERROR: Failed to renew certs" >&2 # Update dynamic DNS (Hetzner) {% for elem in hetzner_zone_record_ids %} diff --git a/templates/ingress/usr_local_etc_nginx_nginx.conf.j2 b/templates/ingress/usr_local_etc_nginx_nginx.conf.j2 index 037a724..c568a7f 100644 --- a/templates/ingress/usr_local_etc_nginx_nginx.conf.j2 +++ b/templates/ingress/usr_local_etc_nginx_nginx.conf.j2 @@ -40,8 +40,8 @@ http { # See https://ssl-config.mozilla.org/#server=nginx&version=1.28.0&config=intermediate&openssl=3.4.0&guideline=5.7 add_header Strict-Transport-Security "max-age=63072000" always; - ssl_certificate /usr/local/etc/letsencrypt/live/{{ route.host }}/fullchain.pem; - ssl_certificate_key /usr/local/etc/letsencrypt/live/{{ route.host }}/privkey.pem; + ssl_certificate /usr/local/etc/letsencrypt/live/{{ cert_name }}/fullchain.pem; + ssl_certificate_key /usr/local/etc/letsencrypt/live/{{ cert_name }}/privkey.pem; include /usr/local/etc/letsencrypt/options-ssl-nginx.conf; ssl_dhparam /usr/local/etc/letsencrypt/ssl-dhparams.pem; @@ -78,8 +78,8 @@ http { # See https://ssl-config.mozilla.org/#server=nginx&version=1.28.0&config=intermediate&openssl=3.4.0&guideline=5.7 add_header Strict-Transport-Security "max-age=63072000" always; - ssl_certificate /usr/local/etc/letsencrypt/live/{{ route.host }}/fullchain.pem; - ssl_certificate_key /usr/local/etc/letsencrypt/live/{{ route.host }}/privkey.pem; + ssl_certificate /usr/local/etc/letsencrypt/live/{{ cert_name }}/fullchain.pem; + ssl_certificate_key /usr/local/etc/letsencrypt/live/{{ cert_name }}/privkey.pem; include /usr/local/etc/letsencrypt/options-ssl-nginx.conf; ssl_dhparam /usr/local/etc/letsencrypt/ssl-dhparams.pem; @@ -96,8 +96,8 @@ http { # See https://ssl-config.mozilla.org/#server=nginx&version=1.28.0&config=intermediate&openssl=3.4.0&guideline=5.7 add_header Strict-Transport-Security "max-age=63072000" always; - ssl_certificate /usr/local/etc/letsencrypt/live/{{ route.host }}/fullchain.pem; - ssl_certificate_key /usr/local/etc/letsencrypt/live/{{ route.host }}/privkey.pem; + ssl_certificate /usr/local/etc/letsencrypt/live/{{ cert_name }}/fullchain.pem; + ssl_certificate_key /usr/local/etc/letsencrypt/live/{{ cert_name }}/privkey.pem; include /usr/local/etc/letsencrypt/options-ssl-nginx.conf; ssl_dhparam /usr/local/etc/letsencrypt/ssl-dhparams.pem; |
