From 1a7608066ab5eab5b7acb79a02b60ffc8d5b10f9 Mon Sep 17 00:00:00 2001 From: Rijul-A <31570722+Rijul-A@users.noreply.github.com> Date: Thu, 23 Dec 2021 18:32:53 +0000 Subject: [PATCH 1/4] Add support for the DNS-01 challenge If SNIKKET_DNS_CHALLENGE is set to 1, or SNIKKET_TWEAK_XMPP_DOMAIN is set, spin up an instance of CoreDNS to respond to DNS challenges. Shut it down once certificates are obtained, and only enable it on renewal. Rationale "Many DNS servers do not provide an API to enable automation for the ACME DNS challenges. Those which do, give the keys way too much power. Leaving the keys laying around your random boxes is too often a requirement to have a meaningful process automation." See https://github.com/joohoi/acme-dns Requirements (domain.tld is SNIKKET_TWEAK_XMPP_DOMAIN or SNIKKET_DOMAIN if not set) - The CNAME record of _acme-challenge.domain.tld needs to point to cert.snikket.domain.tld - The NS record of snikket.domain.tld needs to point to ns-snikket.domain.tld - The A/AAAA record(s) of ns-snikket.domain.tld needs(s) to point to the instance IP address Mechanics - Through a certbot pre-hook, the requirements are first validated (although a failure is only logged). Next, a CoreDNS instance is spun up with just SOA and NS records for snikket.domain.tld, with the NS set to ns-snikket.domain.tld - Using certbot's manual plugin + its authorization hook, the relevant TXT record is added to cert.snikket.domain.tld. Multiple records are supported here, so this script can be used for domain.tld and *.domain.tld in one go. - Through a certbot post-hook, the DNS server is shut down after certificates are obtained. Considerations - Since it is possible that people are hosting their instance on snikket.domain.tld, an override for the DNS ZONE can be added. Alternatively, we can keep the DNS server running, but that will add much more complexity to the setup. --- .gitignore | 2 + Dockerfile | 11 +++- certbot-coredns/__init__.py | 1 + certbot-coredns/auth.py | 53 +++++++++++++++ certbot-coredns/common.py | 82 +++++++++++++++++++++++ certbot-coredns/post.py | 20 ++++++ certbot-coredns/pre.py | 126 ++++++++++++++++++++++++++++++++++++ certbot.cron | 34 +++++++--- entrypoint.sh | 19 ++++++ 9 files changed, 338 insertions(+), 10 deletions(-) create mode 100644 .gitignore create mode 100755 certbot-coredns/__init__.py create mode 100755 certbot-coredns/auth.py create mode 100755 certbot-coredns/common.py create mode 100755 certbot-coredns/post.py create mode 100755 certbot-coredns/pre.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..34aada9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +snikket.conf +*/__pycache__ diff --git a/Dockerfile b/Dockerfile index 6db3b3e..40ef194 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,16 +1,24 @@ +FROM coredns/coredns:latest AS coredns +# this image is only used for copying coredns binary +# ca-certificates are added by apt below + FROM debian:buster-slim +COPY --from=coredns /coredns /usr/sbin/coredns ARG BUILD_SERIES=dev ARG BUILD_ID=0 VOLUME ["/snikket"] +EXPOSE 53 53/udp + ENTRYPOINT ["/usr/bin/tini"] CMD ["/bin/sh", "/entrypoint.sh"] RUN apt-get update \ && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ - certbot tini anacron \ + certbot tini anacron ca-certificates python3 dnsutils \ + && update-ca-certificates \ && rm -rf /var/lib/apt/lists/* \ && apt-get autoremove -y \ && rm -rf /var/cache/* \ @@ -23,3 +31,4 @@ ADD certbot.cron /etc/cron.daily/certbot ADD sendmail /usr/sbin/sendmail RUN chmod 555 /etc/cron.daily/certbot RUN useradd -md /snikket/letsencrypt letsencrypt +COPY certbot-coredns /certbot-coredns diff --git a/certbot-coredns/__init__.py b/certbot-coredns/__init__.py new file mode 100755 index 0000000..e5a0d9b --- /dev/null +++ b/certbot-coredns/__init__.py @@ -0,0 +1 @@ +#!/usr/bin/env python3 diff --git a/certbot-coredns/auth.py b/certbot-coredns/auth.py new file mode 100755 index 0000000..e28ee96 --- /dev/null +++ b/certbot-coredns/auth.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 + +import os +import subprocess +import time + +from common import write_or_update_master_file + +def main(): + # make sure certificates are being fetched for + # the correct Snikket domain + domain = os.environ["CERTBOT_DOMAIN"] + print("[AuthHook] Obtaining certificates for {}".format( + domain + ) + ) + if domain.startswith("*."): + domain = domain[2:] + assert(domain == os.environ["SNIKKET_TWEAK_XMPP_DOMAIN"]) + token = os.environ["CERTBOT_VALIDATION"] + print( + "[AuthHook] Writing TXT record {}".format( + token + ) + ) + write_or_update_master_file(token) + print( + "[AuthHook] Waiting for TXT record" + ) + # below section does not seem to work well + # that is, it gives old data + # perhaps this needs to be done @1.1.1.1 + # but that is not good for privacy + # so just do a sleep instead + # fetched_tokens = [] + # while len(fetched_tokens) < 1: + # result = subprocess.run(["dig", "-t", "txt", + # "cert.snikket.{}".format(domain), + # "+short"], + # stdout=subprocess.PIPE) + # fetched_tokens = result.stdout.decode("utf-8"). \ + # replace('"', '').strip().split("\n") + # if token not in fetched_tokens: + # print( + # "[AuthHook] Got different record {}".format( + # fetched_tokens + # ) + # ) + # fetched_tokens = [] + time.sleep(25) # how long Certbot example waits + +if __name__ == "__main__": + main() diff --git a/certbot-coredns/common.py b/certbot-coredns/common.py new file mode 100755 index 0000000..ee2bc97 --- /dev/null +++ b/certbot-coredns/common.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 + +import os +import time +from textwrap import dedent + +# converts email address to an SOA format, that is, +# @ is replaced by . +# dots in the username are replaced by \. +# for example, +# hostmaster@example.com becomes hostmaster.example.com +# host.master@example.com becomes host\.master.example.com +def email_to_rname(email): + username, domain = email.split("@", 1) + username = username.replace(".", "\\.") # escape . in username + return ".".join((username, domain)) + +# Creates or updates the RFC-1035 compliant file at master_file_path +# note that CoreDNS only reloads the file if serial of SOA changes +# this is why we rewrite the entire file with new timestamp +# even when we are updating / adding just a single record +def write_or_update_master_file(token = ""): + master_file_content = """\ + ; Zone: SNIKKET.{domain}. + + $ORIGIN SNIKKET.{domain}. + $TTL 300 + + ; SOA Record + @ IN SOA NS-SNIKKET.{domain}. {email}. ( + {serial} ;serial + 300 ;refresh + 600 ;retry + 600 ;expire + 300 ;minimum ttl + ) + + ; NS Records + @ IN NS NS-SNIKKET.{domain}. + + ; TXT Records + {last_line} + """ + domain = os.environ["SNIKKET_TWEAK_XMPP_DOMAIN"] + master_file_path = "/snikket/coredns/db.snikket.{}".format( + domain + ) + if os.path.exists(master_file_path): + with open(master_file_path, "r") as f: + existing_master_file_content = f.read() + existing_last_line = existing_master_file_content.strip(). \ + split("\n")[-1] + else: + existing_last_line = "" + existing_last_line = existing_last_line if "cert" in \ + existing_last_line else "" + if existing_last_line: # already had a TXT record + assert(token) # so we must be updating + last_line = \ + "{}\n cert IN TXT {}\n".format( + existing_last_line, + token + ) + elif token: # add a new token + last_line = "cert IN TXT {}\n".format( + token + ) + else: # initial set up without TXT + last_line = "" + with open(master_file_path, "w") as f: + f.write( + dedent( + master_file_content.format( + domain = domain.upper(), + serial = int(time.time()), + email = email_to_rname( + os.environ["SNIKKET_ADMIN_EMAIL"] + ).upper(), + last_line = last_line + ) + ) + ) diff --git a/certbot-coredns/post.py b/certbot-coredns/post.py new file mode 100755 index 0000000..11a4f66 --- /dev/null +++ b/certbot-coredns/post.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python3 + +import os +import signal + +def main(): + print("[PostHook] Killing coredns") + with open("/snikket/coredns/pid") as f: + pid = f.read() + os.kill(int(pid), signal.SIGTERM) + print("[PostHook] Removing config files") + os.remove("/snikket/coredns/pid") + os.remove("/snikket/coredns/Corefile") + os.remove("/snikket/coredns/db.snikket.{}".format( + os.environ["SNIKKET_TWEAK_XMPP_DOMAIN"] + ) + ) + +if __name__ == "__main__": + main() diff --git a/certbot-coredns/pre.py b/certbot-coredns/pre.py new file mode 100755 index 0000000..f5bd771 --- /dev/null +++ b/certbot-coredns/pre.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 + +# validate the NS and CNAME records +# A/AAAA records for the name server are not validated +# start the DNS server with only basic records +# will not contain any TXT records until auth.py is run + +import os +import subprocess +import sys +import time +from textwrap import dedent + +from common import write_or_update_master_file + +def write_core_file(domain): + core_file = """ + . {{ + file /snikket/coredns/db.snikket.{domain} snikket.{domain} {{ + reload 1s + }} + }} + """ + f = open("/snikket/coredns/Corefile", "w") + f.write( + dedent( + core_file.format( + domain = domain, + ) + ) + ) + f.close() + +def validate_dns_record(domain, record_name, expected_value, hint=""): + print( + "[PreHook] Validating {} record for {}: " + "expecting value of {}".format( + record_name.upper(), + domain, + expected_value + ) + ) + result = subprocess.run(["dig", + domain, + record_name.lower(), + "+short"], + stdout=subprocess.PIPE) + value = result.stdout.decode("utf-8").strip() + if value != expected_value: + print( + "[PreHook] {} record for {} needs to be " + "set to {} but got {}".format( + record_name.upper(), + domain, + expected_value, + value + ) + ) + if hint: + print(hint) + sys.exit(1) + print( + "[PreHook] Validated {} record for {}".format( + record_name.upper(), + domain, + ) + ) + +def validate_cname_record(domain): + validate_dns_record( + "_acme-challenge.{}".format(domain), + "cname", + "cert.snikket.{}.".format(domain) + ) + +def validate_ns_record(domain): + validate_dns_record( + "snikket.{}".format(domain), + "ns", + "ns-snikket.{}.".format(domain), + "[PreHook] Is the A/AAAA record of " + "ns-snikket.{} correctly set?".format(domain) + ) + +def validate_a_or_aaaa_record(domain): + if "SNIKKET_EXTERNAL_IP" in os.environ: + validate_dns_record( + "ns-snikket.{}".format(domain), + "aaaa" if ":" in os.environ["SNIKKET_EXTERNAL_IP"] else "a", + os.environ["SNIKKET_EXTERNAL_IP"] + ) + else: + print( + "[PreHook] Skipping A/AAAA check as " + "SNIKKET_EXTERNAL_IP is not available" + ) + +def main(): + domain = os.environ["SNIKKET_TWEAK_XMPP_DOMAIN"] + print( + "[PreHook] Running for domain {}".format( + domain + ) + ) + validate_a_or_aaaa_record(domain) + validate_cname_record(domain) + write_or_update_master_file() + write_core_file(domain) + print("[PreHook] Starting DNS server") + with open("/dev/null", "w") as devnull: + process = subprocess.Popen(["/usr/sbin/coredns", + "--conf", + "/snikket/coredns/Corefile"], + stdin=None, + stdout=devnull, + stderr=devnull, + close_fds=True) + print("[PreHook] Writing PID") + with open("/snikket/coredns/pid", "w") as f: + f.write(str(process.pid)) + # now that the CoreDNS server is running + # check for NS value + validate_ns_record(domain) + +if __name__ == "__main__": + main() diff --git a/certbot.cron b/certbot.cron index 04e4c4c..b8e314f 100644 --- a/certbot.cron +++ b/certbot.cron @@ -1,11 +1,27 @@ #!/bin/sh -su letsencrypt -- -c "certbot certonly -n --webroot --webroot-path /var/www \ - --cert-path /etc/ssl/certbot \ - --keep $SNIKKET_CERTBOT_OPTIONS \ - --agree-tos --email \"$SNIKKET_ADMIN_EMAIL\" --expand \ - --allow-subset-of-names \ - --config-dir /snikket/letsencrypt \ - --domain \"$SNIKKET_DOMAIN\" --domain \"share.$SNIKKET_DOMAIN\" \ - --domain \"groups.$SNIKKET_DOMAIN\" - " +if [ $SNIKKET_DNS_CHALLENGE = 0 ]; then + su letsencrypt -- -c "certbot certonly -n --webroot --webroot-path /var/www \ + --cert-path /etc/ssl/certbot \ + --keep $SNIKKET_CERTBOT_OPTIONS \ + --agree-tos --email \"$SNIKKET_ADMIN_EMAIL\" --expand \ + --allow-subset-of-names \ + --config-dir /snikket/letsencrypt \ + --domain \"$SNIKKET_DOMAIN\" --domain \"share.$SNIKKET_DOMAIN\" \ + --domain \"groups.$SNIKKET_DOMAIN\" + " +else + su letsencrypt -- -c "certbot certonly -n --manual \ + --manual-public-ip-logging-ok \ + --preferred-challenges dns \ + --pre-hook /certbot-coredns/pre.py \ + --manual-auth-hook /certbot-coredns/auth.py \ + --post-hook /certbot-coredns/post.py \ + --cert-path /etc/ssl/certbot \ + --keep $SNIKKET_CERTBOT_OPTIONS \ + --agree-tos --email \"$SNIKKET_ADMIN_EMAIL\" --expand \ + --config-dir /snikket/letsencrypt \ + --domain \"*.$SNIKKET_TWEAK_XMPP_DOMAIN\" \ + --domain \"$SNIKKET_TWEAK_XMPP_DOMAIN\" + " +fi diff --git a/entrypoint.sh b/entrypoint.sh index 8fde9a1..29fadf6 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -18,4 +18,23 @@ install -o letsencrypt -g letsencrypt -m 755 -d /var/www/.well-known/acme-challe chown -R letsencrypt:letsencrypt /snikket/letsencrypt +export SNIKKET_DNS_CHALLENGE=${SNIKKET_DNS_CHALLENGE:-0} +if [ $SNIKKET_DNS_CHALLENGE = 0 ]; then + if [ -z $SNIKKET_TWEAK_XMPP_DOMAIN ]; then + : + else + export SNIKKET_DNS_CHALLENGE=1 + fi +else + if [ -z $SNIKKET_TWEAK_XMPP_DOMAIN ]; then + export SNIKKET_TWEAK_XMPP_DOMAIN=$SNIKKET_DOMAIN + fi +fi +if [ $SNIKKET_DNS_CHALLENGE = 1 ]; then + if ! test -d /snikket/coredns; then + install -o letsencrypt -g letsencrypt -m 750 -d /snikket/coredns; + fi + chown -R letsencrypt:letsencrypt /snikket/coredns +fi + exec /bin/sh -c "/usr/sbin/anacron -d -n && sleep 3600" From fa7c8610908b8ee88167e41dc28d529c5682ec25 Mon Sep 17 00:00:00 2001 From: Rijul <31570722+Rijul-A@users.noreply.github.com> Date: Thu, 10 Mar 2022 07:31:19 +0000 Subject: [PATCH 2/4] dockerfile: Add missing backslash --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index a809d70..45ecb66 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,7 +18,7 @@ CMD ["/bin/sh", "/entrypoint.sh"] RUN apt-get update \ && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ certbot tini anacron jq ca-certificates python3 dnsutils \ - && update-ca-certificates + && update-ca-certificates \ && rm -rf /var/lib/apt/lists/* \ && apt-get autoremove -y \ && rm -rf /var/cache/* \ From 551fa554af873e0749714e558b950bc5340cb86e Mon Sep 17 00:00:00 2001 From: Rijul-A <31570722+Rijul-A@users.noreply.github.com> Date: Sat, 12 Mar 2022 19:28:26 +0000 Subject: [PATCH 3/4] fix: improve logs, decrease TTL, add style + wait - Add wait time between DNS server creation and NS record check - Add logs to indicate if DNS challenge is being used, and why - Add logs to indicate wait time and completion - Add style file and pre-commit hook (activate with `git config --local core.hooksPath .githooks`) --- .githooks/pre-commit | 83 ++++++++ certbot-coredns/.style.yapf | 404 ++++++++++++++++++++++++++++++++++++ certbot-coredns/auth.py | 32 ++- certbot-coredns/common.py | 44 ++-- certbot-coredns/post.py | 26 ++- certbot-coredns/pre.py | 115 +++++----- certbot.cron | 2 +- entrypoint.sh | 2 + 8 files changed, 600 insertions(+), 108 deletions(-) create mode 100755 .githooks/pre-commit create mode 100644 certbot-coredns/.style.yapf diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..3e68739 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,83 @@ +#!/usr/bin/env bash + +# Git pre-commit hook to check staged Python files for formatting issues with +# yapf. +# +# INSTALLING: Copy this script into `.git/hooks/pre-commit`, and mark it as +# executable. +# +# This requires that yapf is installed and runnable in the environment running +# the pre-commit hook. +# +# When running, this first checks for unstaged changes to staged files, and if +# there are any, it will exit with an error. Files with unstaged changes will be +# printed. +# +# If all staged files have no unstaged changes, it will run yapf against them, +# leaving the formatting changes unstaged. Changed files will be printed. +# +# BUGS: This does not leave staged changes alone when used with the -a flag to +# git commit, due to the fact that git stages ALL unstaged files when that flag +# is used. + +# Find all staged Python files, and exit early if there aren't any. +PYTHON_FILES=() +while IFS=$'\n' read -r line; do PYTHON_FILES+=("$line"); done \ + < <(git diff --name-only --cached --diff-filter=AM | grep --color=never '.py$') +if [ ${#PYTHON_FILES[@]} -eq 0 ]; then + exit 0 +fi + +########## PIP VERSION ############# +# Verify that yapf is installed; if not, warn and exit. +if ! command -v yapf >/dev/null; then + echo 'yapf not on path; can not format. Please install yapf:' + echo ' pip install yapf' + exit 2 +fi +######### END PIP VERSION ########## + +########## PIPENV VERSION ########## +# if ! pipenv run yapf --version 2>/dev/null 2>&1; then +# echo 'yapf not on path; can not format. Please install yapf:' +# echo ' pipenv install yapf' +# exit 2 +# fi +###### END PIPENV VERSION ########## + + +# Check for unstaged changes to files in the index. +CHANGED_FILES=() +while IFS=$'\n' read -r line; do CHANGED_FILES+=("$line"); done \ + < <(git diff --name-only "${PYTHON_FILES[@]}") +if [ ${#CHANGED_FILES[@]} -gt 0 ]; then + echo 'You have unstaged changes to some files in your commit; skipping ' + echo 'auto-format. Please stage, stash, or revert these changes. You may ' + echo 'find `git stash -k` helpful here.' + echo 'Files with unstaged changes:' "${CHANGED_FILES[@]}" + exit 1 +fi + +# Format all staged files, then exit with an error code if any have uncommitted +# changes. +echo 'Formatting staged Python files . . .' + +########## PIP VERSION ############# +yapf -i -r "${PYTHON_FILES[@]}" +######### END PIP VERSION ########## + +########## PIPENV VERSION ########## +# pipenv run yapf -i -r "${PYTHON_FILES[@]}" +###### END PIPENV VERSION ########## + + +CHANGED_FILES=() +while IFS=$'\n' read -r line; do CHANGED_FILES+=("$line"); done \ + < <(git diff --name-only "${PYTHON_FILES[@]}") +if [ ${#CHANGED_FILES[@]} -gt 0 ]; then + echo 'Reformatted staged files. Please review and stage the changes.' + echo 'Files updated: ' "${CHANGED_FILES[@]}" + exit 1 +else + exit 0 +fi diff --git a/certbot-coredns/.style.yapf b/certbot-coredns/.style.yapf new file mode 100644 index 0000000..108d85c --- /dev/null +++ b/certbot-coredns/.style.yapf @@ -0,0 +1,404 @@ +[style] +# Align closing bracket with visual indentation. +align_closing_bracket_with_visual_indent=True + +# Allow dictionary keys to exist on multiple lines. For example: +# +# x = { +# ('this is the first element of a tuple', +# 'this is the second element of a tuple'): +# value, +# } +allow_multiline_dictionary_keys=False + +# Allow lambdas to be formatted on more than one line. +allow_multiline_lambdas=False + +# Allow splitting before a default / named assignment in an argument list. +allow_split_before_default_or_named_assigns=True + +# Allow splits before the dictionary value. +allow_split_before_dict_value=False + +# Let spacing indicate operator precedence. For example: +# +# a = 1 * 2 + 3 / 4 +# b = 1 / 2 - 3 * 4 +# c = (1 + 2) * (3 - 4) +# d = (1 - 2) / (3 + 4) +# e = 1 * 2 - 3 +# f = 1 + 2 + 3 + 4 +# +# will be formatted as follows to indicate precedence: +# +# a = 1*2 + 3/4 +# b = 1/2 - 3*4 +# c = (1+2) * (3-4) +# d = (1-2) / (3+4) +# e = 1*2 - 3 +# f = 1 + 2 + 3 + 4 +# +arithmetic_precedence_indication=False + +# Number of blank lines surrounding top-level function and class +# definitions. +blank_lines_around_top_level_definition=2 + +# Number of blank lines between top-level imports and variable +# definitions. +blank_lines_between_top_level_imports_and_variables=1 + +# Insert a blank line before a class-level docstring. +blank_line_before_class_docstring=False + +# Insert a blank line before a module docstring. +blank_line_before_module_docstring=False + +# Insert a blank line before a 'def' or 'class' immediately nested +# within another 'def' or 'class'. For example: +# +# class Foo: +# # <------ this blank line +# def method(): +# ... +blank_line_before_nested_class_or_def=False + +# Do not split consecutive brackets. Only relevant when +# dedent_closing_brackets is set. For example: +# +# call_func_that_takes_a_dict( +# { +# 'key1': 'value1', +# 'key2': 'value2', +# } +# ) +# +# would reformat to: +# +# call_func_that_takes_a_dict({ +# 'key1': 'value1', +# 'key2': 'value2', +# }) +coalesce_brackets=False + +# The column limit. +column_limit=80 + +# The style for continuation alignment. Possible values are: +# +# - SPACE: Use spaces for continuation alignment. This is default behavior. +# - FIXED: Use fixed number (CONTINUATION_INDENT_WIDTH) of columns +# (ie: CONTINUATION_INDENT_WIDTH/INDENT_WIDTH tabs or +# CONTINUATION_INDENT_WIDTH spaces) for continuation alignment. +# - VALIGN-RIGHT: Vertically align continuation lines to multiple of +# INDENT_WIDTH columns. Slightly right (one tab or a few spaces) if +# cannot vertically align continuation lines with indent characters. +continuation_align_style=SPACE + +# Indent width used for line continuations. +continuation_indent_width=4 + +# Put closing brackets on a separate line, dedented, if the bracketed +# expression can't fit in a single line. Applies to all kinds of brackets, +# including function definitions and calls. For example: +# +# config = { +# 'key1': 'value1', +# 'key2': 'value2', +# } # <--- this bracket is dedented and on a separate line +# +# time_series = self.remote_client.query_entity_counters( +# entity='dev3246.region1', +# key='dns.query_latency_tcp', +# transform=Transformation.AVERAGE(window=timedelta(seconds=60)), +# start_ts=now()-timedelta(days=3), +# end_ts=now(), +# ) # <--- this bracket is dedented and on a separate line +dedent_closing_brackets=True + +# Disable the heuristic which places each list element on a separate line +# if the list is comma-terminated. +disable_ending_comma_heuristic=True + +# Place each dictionary entry onto its own line. +each_dict_entry_on_separate_line=True + +# Require multiline dictionary even if it would normally fit on one line. +# For example: +# +# config = { +# 'key1': 'value1' +# } +force_multiline_dict=True + +# The regex for an i18n comment. The presence of this comment stops +# reformatting of that line, because the comments are required to be +# next to the string they translate. +i18n_comment=#\..* + +# The i18n function call names. The presence of this function stops +# reformattting on that line, because the string it has cannot be moved +# away from the i18n comment. +i18n_function_call=N_, _ + +# Indent blank lines. +indent_blank_lines=False + +# Put closing brackets on a separate line, indented, if the bracketed +# expression can't fit in a single line. Applies to all kinds of brackets, +# including function definitions and calls. For example: +# +# config = { +# 'key1': 'value1', +# 'key2': 'value2', +# } # <--- this bracket is indented and on a separate line +# +# time_series = self.remote_client.query_entity_counters( +# entity='dev3246.region1', +# key='dns.query_latency_tcp', +# transform=Transformation.AVERAGE(window=timedelta(seconds=60)), +# start_ts=now()-timedelta(days=3), +# end_ts=now(), +# ) # <--- this bracket is indented and on a separate line +indent_closing_brackets=False + +# Indent the dictionary value if it cannot fit on the same line as the +# dictionary key. For example: +# +# config = { +# 'key1': +# 'value1', +# 'key2': value1 + +# value2, +# } +indent_dictionary_value=False + +# The number of columns to use for indentation. +indent_width=4 + +# Join short lines into one line. E.g., single line 'if' statements. +join_multiple_lines=False + +# Do not include spaces around selected binary operators. For example: +# +# 1 + 2 * 3 - 4 / 5 +# +# will be formatted as follows when configured with "*,/": +# +# 1 + 2*3 - 4/5 +no_spaces_around_selected_binary_operators= + +# Use spaces around default or named assigns. +spaces_around_default_or_named_assign=True + +# Adds a space after the opening '{' and before the ending '}' dict +# delimiters. +# +# {1: 2} +# +# will be formatted as: +# +# { 1: 2 } +spaces_around_dict_delimiters=True + +# Adds a space after the opening '[' and before the ending ']' list +# delimiters. +# +# [1, 2] +# +# will be formatted as: +# +# [ 1, 2 ] +spaces_around_list_delimiters=True + +# Use spaces around the power operator. +spaces_around_power_operator=False + +# Use spaces around the subscript / slice operator. For example: +# +# my_list[1 : 10 : 2] +spaces_around_subscript_colon=True + +# Adds a space after the opening '(' and before the ending ')' tuple +# delimiters. +# +# (1, 2, 3) +# +# will be formatted as: +# +# ( 1, 2, 3 ) +spaces_around_tuple_delimiters=True + +# The number of spaces required before a trailing comment. +# This can be a single value (representing the number of spaces +# before each trailing comment) or list of values (representing +# alignment column values; trailing comments within a block will +# be aligned to the first column value that is greater than the maximum +# line length within the block). For example: +# +# With spaces_before_comment=5: +# +# 1 + 1 # Adding values +# +# will be formatted as: +# +# 1 + 1 # Adding values <-- 5 spaces between the end of the +# # statement and comment +# +# With spaces_before_comment=15, 20: +# +# 1 + 1 # Adding values +# two + two # More adding +# +# longer_statement # This is a longer statement +# short # This is a shorter statement +# +# a_very_long_statement_that_extends_beyond_the_final_column # Comment +# short # This is a shorter statement +# +# will be formatted as: +# +# 1 + 1 # Adding values <-- end of line comments in block +# # aligned to col 15 +# two + two # More adding +# +# longer_statement # This is a longer statement <-- end of line +# # comments in block aligned to col 20 +# short # This is a shorter statement +# +# a_very_long_statement_that_extends_beyond_the_final_column # Comment <-- the end of line comments are aligned based on the line length +# short # This is a shorter statement +# +spaces_before_comment=2 + +# Insert a space between the ending comma and closing bracket of a list, +# etc. +space_between_ending_comma_and_closing_bracket=True + +# Use spaces inside brackets, braces, and parentheses. For example: +# +# method_call( 1 ) +# my_dict[ 3 ][ 1 ][ get_index( *args, **kwargs ) ] +# my_set = { 1, 2, 3 } +space_inside_brackets=True + +# Split before arguments +split_all_comma_separated_values=True + +# Split before arguments, but do not split all subexpressions recursively +# (unless needed). +split_all_top_level_comma_separated_values=False + +# Split before arguments if the argument list is terminated by a +# comma. +split_arguments_when_comma_terminated=True + +# Set to True to prefer splitting before '+', '-', '*', '/', '//', or '@' +# rather than after. +split_before_arithmetic_operator=False + +# Set to True to prefer splitting before '&', '|' or '^' rather than +# after. +split_before_bitwise_operator=False + +# Split before the closing bracket if a list or dict literal doesn't fit on +# a single line. +split_before_closing_bracket=True + +# Split before a dictionary or set generator (comp_for). For example, note +# the split before the 'for': +# +# foo = { +# variable: 'Hello world, have a nice day!' +# for variable in bar if variable != 42 +# } +split_before_dict_set_generator=True + +# Split before the '.' if we need to split a longer expression: +# +# foo = ('This is a really long string: {}, {}, {}, {}'.format(a, b, c, d)) +# +# would reformat to something like: +# +# foo = ('This is a really long string: {}, {}, {}, {}' +# .format(a, b, c, d)) +split_before_dot=True + +# Split after the opening paren which surrounds an expression if it doesn't +# fit on a single line. +split_before_expression_after_opening_paren=False + +# If an argument / parameter list is going to be split, then split before +# the first argument. +split_before_first_argument=False + +# Set to True to prefer splitting before 'and' or 'or' rather than +# after. +split_before_logical_operator=False + +# Split named assignments onto individual lines. +split_before_named_assigns=True + +# Set to True to split list comprehensions and generators that have +# non-trivial expressions and multiple clauses before each of these +# clauses. For example: +# +# result = [ +# a_long_var + 100 for a_long_var in xrange(1000) +# if a_long_var % 10] +# +# would reformat to something like: +# +# result = [ +# a_long_var + 100 +# for a_long_var in xrange(1000) +# if a_long_var % 10] +split_complex_comprehension=True + +# The penalty for splitting right after the opening bracket. +split_penalty_after_opening_bracket=300 + +# The penalty for splitting the line after a unary operator. +split_penalty_after_unary_operator=10000 + +# The penalty of splitting the line around the '+', '-', '*', '/', '//', +# ``%``, and '@' operators. +split_penalty_arithmetic_operator=300 + +# The penalty for splitting right before an if expression. +split_penalty_before_if_expr=0 + +# The penalty of splitting the line around the '&', '|', and '^' +# operators. +split_penalty_bitwise_operator=300 + +# The penalty for splitting a list comprehension or generator +# expression. +split_penalty_comprehension=2100 + +# The penalty for characters over the column limit. +split_penalty_excess_character=7000 + +# The penalty incurred by adding a line split to the logical line. The +# more line splits added the higher the penalty. +split_penalty_for_added_line_split=30 + +# The penalty of splitting a list of "import as" names. For example: +# +# from a_very_long_or_indented_module_name_yada_yad import (long_argument_1, +# long_argument_2, +# long_argument_3) +# +# would reformat to something like: +# +# from a_very_long_or_indented_module_name_yada_yad import ( +# long_argument_1, long_argument_2, long_argument_3) +split_penalty_import_names=0 + +# The penalty of splitting the line around the 'and' and 'or' +# operators. +split_penalty_logical_operator=300 + +# Use the Tab character for indentation. +use_tabs=False + diff --git a/certbot-coredns/auth.py b/certbot-coredns/auth.py index e28ee96..2210149 100755 --- a/certbot-coredns/auth.py +++ b/certbot-coredns/auth.py @@ -6,27 +6,19 @@ from common import write_or_update_master_file + def main(): # make sure certificates are being fetched for # the correct Snikket domain - domain = os.environ["CERTBOT_DOMAIN"] - print("[AuthHook] Obtaining certificates for {}".format( - domain - ) - ) - if domain.startswith("*."): - domain = domain[2:] - assert(domain == os.environ["SNIKKET_TWEAK_XMPP_DOMAIN"]) - token = os.environ["CERTBOT_VALIDATION"] - print( - "[AuthHook] Writing TXT record {}".format( - token - ) - ) - write_or_update_master_file(token) - print( - "[AuthHook] Waiting for TXT record" - ) + domain = os.environ[ "CERTBOT_DOMAIN" ] + print( "[AuthHook] Obtaining certificates for {}".format( domain ) ) + if domain.startswith( "*." ): + domain = domain[ 2 : ] + assert ( domain == os.environ[ "SNIKKET_TWEAK_XMPP_DOMAIN" ] ) + token = os.environ[ "CERTBOT_VALIDATION" ] + print( "[AuthHook] Writing TXT record {}".format( token ) ) + write_or_update_master_file( token ) + print( "[AuthHook] Waiting for 25s to propagate TXT record" ) # below section does not seem to work well # that is, it gives old data # perhaps this needs to be done @1.1.1.1 @@ -47,7 +39,9 @@ def main(): # ) # ) # fetched_tokens = [] - time.sleep(25) # how long Certbot example waits + time.sleep( 25 ) # how long Certbot example waits + print( "[AuthHook] Done waiting" ) + if __name__ == "__main__": main() diff --git a/certbot-coredns/common.py b/certbot-coredns/common.py index ee2bc97..73acd28 100755 --- a/certbot-coredns/common.py +++ b/certbot-coredns/common.py @@ -4,27 +4,29 @@ import time from textwrap import dedent + # converts email address to an SOA format, that is, # @ is replaced by . # dots in the username are replaced by \. # for example, # hostmaster@example.com becomes hostmaster.example.com # host.master@example.com becomes host\.master.example.com -def email_to_rname(email): - username, domain = email.split("@", 1) - username = username.replace(".", "\\.") # escape . in username - return ".".join((username, domain)) +def email_to_rname( email ): + username, domain = email.split( "@", 1 ) + username = username.replace( ".", "\\." ) # escape . in username + return ".".join( ( username, domain ) ) + # Creates or updates the RFC-1035 compliant file at master_file_path # note that CoreDNS only reloads the file if serial of SOA changes # this is why we rewrite the entire file with new timestamp # even when we are updating / adding just a single record -def write_or_update_master_file(token = ""): +def write_or_update_master_file( token = "" ): master_file_content = """\ ; Zone: SNIKKET.{domain}. $ORIGIN SNIKKET.{domain}. - $TTL 300 + $TTL 0 ; SOA Record @ IN SOA NS-SNIKKET.{domain}. {email}. ( @@ -32,7 +34,7 @@ def write_or_update_master_file(token = ""): 300 ;refresh 600 ;retry 600 ;expire - 300 ;minimum ttl + 0 ;minimum ttl ) ; NS Records @@ -41,12 +43,10 @@ def write_or_update_master_file(token = ""): ; TXT Records {last_line} """ - domain = os.environ["SNIKKET_TWEAK_XMPP_DOMAIN"] - master_file_path = "/snikket/coredns/db.snikket.{}".format( - domain - ) - if os.path.exists(master_file_path): - with open(master_file_path, "r") as f: + domain = os.environ[ "SNIKKET_TWEAK_XMPP_DOMAIN" ] + master_file_path = "/snikket/coredns/db.snikket.{}".format( domain ) + if os.path.exists( master_file_path ): + with open( master_file_path, "r" ) as f: existing_master_file_content = f.read() existing_last_line = existing_master_file_content.strip(). \ split("\n")[-1] @@ -55,27 +55,25 @@ def write_or_update_master_file(token = ""): existing_last_line = existing_last_line if "cert" in \ existing_last_line else "" if existing_last_line: # already had a TXT record - assert(token) # so we must be updating + assert ( token ) # so we must be updating last_line = \ "{}\n cert IN TXT {}\n".format( existing_last_line, token ) - elif token: # add a new token - last_line = "cert IN TXT {}\n".format( - token - ) - else: # initial set up without TXT + elif token: # add a new token + last_line = "cert IN TXT {}\n".format( token ) + else: # initial set up without TXT last_line = "" - with open(master_file_path, "w") as f: + with open( master_file_path, "w" ) as f: f.write( dedent( master_file_content.format( domain = domain.upper(), - serial = int(time.time()), + serial = int( time.time() ), email = email_to_rname( - os.environ["SNIKKET_ADMIN_EMAIL"] - ).upper(), + os.environ[ "SNIKKET_ADMIN_EMAIL" ] + ).upper(), last_line = last_line ) ) diff --git a/certbot-coredns/post.py b/certbot-coredns/post.py index 11a4f66..a56f6ad 100755 --- a/certbot-coredns/post.py +++ b/certbot-coredns/post.py @@ -3,18 +3,26 @@ import os import signal + def main(): - print("[PostHook] Killing coredns") - with open("/snikket/coredns/pid") as f: - pid = f.read() - os.kill(int(pid), signal.SIGTERM) - print("[PostHook] Removing config files") - os.remove("/snikket/coredns/pid") - os.remove("/snikket/coredns/Corefile") - os.remove("/snikket/coredns/db.snikket.{}".format( - os.environ["SNIKKET_TWEAK_XMPP_DOMAIN"] + if os.path.exists( "/snikket/coredns/pid" ): + print( "[PostHook] Killing CoreDNS" ) + with open( "/snikket/coredns/pid" ) as f: + pid = f.read() + os.kill( int( pid ), signal.SIGTERM ) + else: + print( + "[PostHook] Did not find CoreDNS PID, likely due to error in PreHook" + ) + print( "[PostHook] Removing config files" ) + os.remove( "/snikket/coredns/pid" ) + os.remove( "/snikket/coredns/Corefile" ) + os.remove( + "/snikket/coredns/db.snikket.{}".format( + os.environ[ "SNIKKET_TWEAK_XMPP_DOMAIN" ] ) ) + if __name__ == "__main__": main() diff --git a/certbot-coredns/pre.py b/certbot-coredns/pre.py index f5bd771..2f4fab0 100755 --- a/certbot-coredns/pre.py +++ b/certbot-coredns/pre.py @@ -13,7 +13,8 @@ from common import write_or_update_master_file -def write_core_file(domain): + +def write_core_file( domain ): core_file = """ . {{ file /snikket/coredns/db.snikket.{domain} snikket.{domain} {{ @@ -21,35 +22,32 @@ def write_core_file(domain): }} }} """ - f = open("/snikket/coredns/Corefile", "w") - f.write( - dedent( - core_file.format( - domain = domain, - ) - ) - ) + f = open( "/snikket/coredns/Corefile", "w" ) + f.write( dedent( core_file.format( domain = domain, ) ) ) f.close() -def validate_dns_record(domain, record_name, expected_value, hint=""): + +def validate_dns_record( domain, record_name, expected_value, hint = "" ): print( - "[PreHook] Validating {} record for {}: " - "expecting value of {}".format( + "[PreHook] Validating '{}' record for '{}': " + "expecting value of '{}'".format( record_name.upper(), domain, expected_value ) ) - result = subprocess.run(["dig", - domain, - record_name.lower(), - "+short"], - stdout=subprocess.PIPE) - value = result.stdout.decode("utf-8").strip() + result = subprocess.run( + [ "dig", + domain, + record_name.lower(), + "+short" ], + stdout = subprocess.PIPE + ) + value = result.stdout.decode( "utf-8" ).strip() if value != expected_value: print( - "[PreHook] {} record for {} needs to be " - "set to {} but got {}".format( + "[PreHook] '{}' record for '{}' needs to be " + "set to '{}' but got '{}'".format( record_name.upper(), domain, expected_value, @@ -57,37 +55,40 @@ def validate_dns_record(domain, record_name, expected_value, hint=""): ) ) if hint: - print(hint) - sys.exit(1) + print( hint ) + sys.exit( 1 ) print( - "[PreHook] Validated {} record for {}".format( + "[PreHook] Validated '{}' record for '{}'".format( record_name.upper(), domain, ) ) -def validate_cname_record(domain): + +def validate_cname_record( domain ): validate_dns_record( - "_acme-challenge.{}".format(domain), + "_acme-challenge.{}".format( domain ), "cname", - "cert.snikket.{}.".format(domain) + "cert.snikket.{}.".format( domain ) ) -def validate_ns_record(domain): + +def validate_ns_record( domain ): validate_dns_record( - "snikket.{}".format(domain), + "snikket.{}".format( domain ), "ns", - "ns-snikket.{}.".format(domain), + "ns-snikket.{}.".format( domain ), "[PreHook] Is the A/AAAA record of " - "ns-snikket.{} correctly set?".format(domain) + "ns-snikket.{} correctly set?".format( domain ) ) -def validate_a_or_aaaa_record(domain): + +def validate_a_or_aaaa_record( domain ): if "SNIKKET_EXTERNAL_IP" in os.environ: validate_dns_record( - "ns-snikket.{}".format(domain), - "aaaa" if ":" in os.environ["SNIKKET_EXTERNAL_IP"] else "a", - os.environ["SNIKKET_EXTERNAL_IP"] + "ns-snikket.{}".format( domain ), + "aaaa" if ":" in os.environ[ "SNIKKET_EXTERNAL_IP" ] else "a", + os.environ[ "SNIKKET_EXTERNAL_IP" ] ) else: print( @@ -95,32 +96,34 @@ def validate_a_or_aaaa_record(domain): "SNIKKET_EXTERNAL_IP is not available" ) + def main(): - domain = os.environ["SNIKKET_TWEAK_XMPP_DOMAIN"] - print( - "[PreHook] Running for domain {}".format( - domain - ) - ) - validate_a_or_aaaa_record(domain) - validate_cname_record(domain) + domain = os.environ[ "SNIKKET_TWEAK_XMPP_DOMAIN" ] + print( "[PreHook] Running for domain {}".format( domain ) ) + validate_a_or_aaaa_record( domain ) + validate_cname_record( domain ) write_or_update_master_file() - write_core_file(domain) - print("[PreHook] Starting DNS server") - with open("/dev/null", "w") as devnull: - process = subprocess.Popen(["/usr/sbin/coredns", - "--conf", - "/snikket/coredns/Corefile"], - stdin=None, - stdout=devnull, - stderr=devnull, - close_fds=True) - print("[PreHook] Writing PID") - with open("/snikket/coredns/pid", "w") as f: - f.write(str(process.pid)) + write_core_file( domain ) + print( "[PreHook] Starting DNS server" ) + with open( "/dev/null", "w" ) as devnull: + process = subprocess.Popen( + [ "/usr/sbin/coredns", + "--conf", + "/snikket/coredns/Corefile" ], + stdin = None, + stdout = devnull, + stderr = devnull, + close_fds = True + ) + print( "[PreHook] Writing PID" ) + with open( "/snikket/coredns/pid", "w" ) as f: + f.write( str( process.pid ) ) + print( "[PreHook] Sleeping for 25s to ensure everything is up" ) + time.sleep( 25 ) # now that the CoreDNS server is running # check for NS value - validate_ns_record(domain) + validate_ns_record( domain ) + if __name__ == "__main__": main() diff --git a/certbot.cron b/certbot.cron index 649f5ff..41e7a78 100644 --- a/certbot.cron +++ b/certbot.cron @@ -16,8 +16,8 @@ if [ $SNIKKET_DNS_CHALLENGE = 0 ]; then --domain \"groups.$SNIKKET_DOMAIN\" " else + echo "Using DNS challenge" su letsencrypt -- -c "certbot certonly -n --manual \ - --manual-public-ip-logging-ok \ --preferred-challenges dns \ --pre-hook /certbot-coredns/pre.py \ --manual-auth-hook /certbot-coredns/auth.py \ diff --git a/entrypoint.sh b/entrypoint.sh index 29fadf6..0d91c11 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -23,6 +23,7 @@ if [ $SNIKKET_DNS_CHALLENGE = 0 ]; then if [ -z $SNIKKET_TWEAK_XMPP_DOMAIN ]; then : else + echo "Using DNS challenge as SNIKKET_TWEAK_XMPP_DOMAIN is set" export SNIKKET_DNS_CHALLENGE=1 fi else @@ -31,6 +32,7 @@ else fi fi if [ $SNIKKET_DNS_CHALLENGE = 1 ]; then + echo "Using DNS challenge" if ! test -d /snikket/coredns; then install -o letsencrypt -g letsencrypt -m 750 -d /snikket/coredns; fi From d21e92c6e77b5ca4e32c6f12a355ba19b0d3556c Mon Sep 17 00:00:00 2001 From: Rijul-A <31570722+Rijul-A@users.noreply.github.com> Date: Sat, 19 Mar 2022 05:36:24 +0000 Subject: [PATCH 4/4] fix: Remove old CoreDNS configuration file To prevent `assert( token )` from failing for persistent volumes (certificate renewal or other option changes), remove the file each time the pre-hook is run. --- certbot-coredns/common.py | 4 +++- certbot-coredns/pre.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/certbot-coredns/common.py b/certbot-coredns/common.py index 73acd28..492fda2 100755 --- a/certbot-coredns/common.py +++ b/certbot-coredns/common.py @@ -21,7 +21,7 @@ def email_to_rname( email ): # note that CoreDNS only reloads the file if serial of SOA changes # this is why we rewrite the entire file with new timestamp # even when we are updating / adding just a single record -def write_or_update_master_file( token = "" ): +def write_or_update_master_file( token = "", delete = False ): master_file_content = """\ ; Zone: SNIKKET.{domain}. @@ -46,6 +46,8 @@ def write_or_update_master_file( token = "" ): domain = os.environ[ "SNIKKET_TWEAK_XMPP_DOMAIN" ] master_file_path = "/snikket/coredns/db.snikket.{}".format( domain ) if os.path.exists( master_file_path ): + if delete: + os.remove( master_file_path ) with open( master_file_path, "r" ) as f: existing_master_file_content = f.read() existing_last_line = existing_master_file_content.strip(). \ diff --git a/certbot-coredns/pre.py b/certbot-coredns/pre.py index 2f4fab0..1109f3b 100755 --- a/certbot-coredns/pre.py +++ b/certbot-coredns/pre.py @@ -102,7 +102,7 @@ def main(): print( "[PreHook] Running for domain {}".format( domain ) ) validate_a_or_aaaa_record( domain ) validate_cname_record( domain ) - write_or_update_master_file() + write_or_update_master_file( delete = True ) write_core_file( domain ) print( "[PreHook] Starting DNS server" ) with open( "/dev/null", "w" ) as devnull: