aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorJan Tuomi <jan@jantuomi.fi>2025-12-17 10:00:59 +0200
committerJan Tuomi <jan@jantuomi.fi>2025-12-17 10:00:59 +0200
commitd2d64fed66dfd6b4682bea118323315879bc30a5 (patch)
tree26371ff4bcad57ed515ed76005d80c3379a0c3d9
parentdb38812d2daa7e19e74ae1b4ee9131eba06998bb (diff)
Use DNS-01 challenge with certbot
-rw-r--r--playbook.yml9
-rw-r--r--tasks/jail_ingress.yml63
-rwxr-xr-xtemplates/ingress/acme-dns-auth.py170
-rw-r--r--templates/ingress/etc_crontab.j24
-rw-r--r--templates/ingress/usr_local_etc_nginx_nginx.conf.j212
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;