Commit 1d7c9423 authored by Adrien Dorsaz's avatar Adrien Dorsaz

Fix DNS update requests to use IP address

Indeed, dnspython has only documented usage of IP address and not domain name.

Fix #11 (compatibility with dnspython 2.0)
parent c3820629
Pipeline #290 passed with stages
in 20 minutes and 34 seconds
......@@ -7,11 +7,21 @@ This is a tiny, auditable script that you can throw on any secure machine to
issue and renew [Let's Encrypt](https://letsencrypt.org/) certificates with DNS
validation.
Since it has to have access to your private ACME account key and the
Using DNS challenges from the [ACME](https://tools.ietf.org/html/rfc8555) RFC
to create TLS certificate allows
you to create wildcard certificates, to renew certificates without any
service interruption and to keep you TLS private key secure
(only the CSR request has to be shared with the computer running acme-dns-tiny
and the script can be run without root/administrator privileges).
Since this script has to access your private ACME account key and must have the
rights to update the DNS records of your DNS server, this code has been designed
to be as tiny as possible (currently less than 400 lines).
The only prerequisites are Python 3, OpenSSL and the dnspython module.
**PLEASE READ THE SOURCE CODE! YOU MUST TRUST IT!
IT HANDLES YOUR ACCOUNT PRIVATE KEY AND UPDATES SOME OF YOUR DNS RESOURCES !**
The only prerequisites are Python 3 (at least 3.4), OpenSSL and the dnspython module.
For the dnspython module, be aware that it won't work with release 1.14.0,
because this one have a bug with dynamic DNS updates.
......@@ -19,9 +29,6 @@ You should either use an older version from dnspython3 module (python3 specific
code) or any release of dnspython module (pyhton2 and python3 merged code) since
1.15.0.
**PLEASE READ THE SOURCE CODE! YOU MUST TRUST IT!
IT HANDLES YOUR ACCOUNT PRIVATE KEY AND UPDATE SOME OF YOUR DNS RESOURCES !**
Note: this script is a fork of the [acme-tiny project](https://github.com/diafygi/acme-tiny)
which uses ACME HTTP verification to create signed certificates.
......@@ -34,7 +41,7 @@ but they do fantastic work.
## How to use this script
See our the [HowTo Use](https://projects.adorsaz.ch/adrien/acme-dns-tiny/wikis/howto-use) wiki page for main informations.
See the [HowTo Use](https://projects.adorsaz.ch/adrien/acme-dns-tiny/wikis/howto-use) wiki page for main informations.
You may be interested by the [HowTo Setup with BIND9](https://projects.adorsaz.ch/adrien/acme-dns-tiny/wikis/howto-setup-with-bind9)
page too which show a step by step example to set up the script
......@@ -50,11 +57,11 @@ script is permissions.
You want to limit access for this script to:
* Your account private key
* Your Certificate Signing Request (CSR) file (without your domain key)
* Your configuration file (which contain DNS update secret)
* Your Certificate Signing Request (CSR) file (without your private domain key)
* Your configuration file (which contains the secret to do dynamic DNS updates)
I'd recommend to create a user specifically to run this script and the
above files. This user should *NOT* have access to your domain key!
above files. This user should *NOT* have access to your private domain key!
**BE SURE TO:**
* Backup your account private key (e.g. `account.key`)
......
#!/usr/bin/env python3
# pylint: disable=multiple-imports
"""ACME client to met DNS challenge and receive TLS certificate"""
import argparse, base64, binascii, configparser, copy, hashlib, json, logging
import argparse, base64, binascii, configparser, copy, hashlib, ipaddress, json, logging
import re, sys, subprocess, time
import requests, dns.resolver, dns.tsigkeyring, dns.update
......@@ -29,7 +29,7 @@ def _openssl(command, options, communicate=None):
def get_crt(config, log=LOGGER):
"""Get ACME certificate by resolving DNS challenge."""
def _update_dns(rrset, action):
def _update_dns(rrset, action, resolver):
"""Updates DNS resource by adding or deleting resource."""
algorithm = dns.name.from_text("{0}".format(config["TSIGKeyring"]["Algorithm"].lower()))
dns_update = dns.update.Update(config["DNS"]["zone"],
......@@ -38,9 +38,23 @@ def get_crt(config, log=LOGGER):
dns_update.add(rrset.name, rrset)
elif action == "delete":
dns_update.delete(rrset.name, rrset)
response = dns.query.tcp(dns_update, config["DNS"]["Host"],
port=config.getint("DNS", "Port"))
dns_update = None
# Try each IP address found for the configured DNS Host to apply the DNS resource update
response = None
for nameserver in resolver.nameservers:
try:
response = dns.query.tcp(dns_update, nameserver,
port=config.getint("DNS", "Port"))
# pylint: disable=broad-except
except Exception as exception:
log.debug("Unable to %s DNS resource on server with IP %s, try again with "
"next available IP. Error detail: %s", action, nameserver, exception)
response = None
finally:
dns_update = None
if response is not None:
break
if response is None:
raise RuntimeError("Unable to {0} DNS resource to {1}".format(action, rrset.name))
return response
def _send_signed_request(url, payload, extra_headers=None):
......@@ -108,15 +122,23 @@ def get_crt(config, log=LOGGER):
resolver.retry_servfail = True
nameserver = []
try:
nameserver = [ipv4_rrset.to_text() for ipv4_rrset
in dns.resolver.query(config["DNS"]["Host"], rdtype="A")]
nameserver = nameserver + [ipv6_rrset.to_text() for ipv6_rrset
in dns.resolver.query(config["DNS"]["Host"], rdtype="AAAA")]
except dns.exception.DNSException:
log.info(("A and/or AAAA DNS resources not found for configured dns host: we will use "
"either resource found if one exists or directly the DNS Host configuration."))
ipaddress.ip_address(config["DNS"]["Host"])
nameserver += config["DNS"]["Host"]
except ValueError:
log.debug(" - Configured DNS Host value is not a valid IP address, "
"try to resolve IP address by requesting system DNS servers.")
try:
nameserver += [ipv6_rrset.to_text() for ipv6_rrset
in dns.resolver.query(config["DNS"]["Host"], rdtype="AAAA")]
except dns.exception.DNSException:
log.debug((" - IPv6 addresses not found for the configured DNS Host."))
try:
nameserver += [ipv4_rrset.to_text() for ipv4_rrset
in dns.resolver.query(config["DNS"]["Host"], rdtype="A")]
except dns.exception.DNSException:
log.debug(" - IPv4 addresses not found for the configured DNS Host.")
if not nameserver:
nameserver = [config["DNS"]["Host"]]
raise ValueError("Unable to resolve any IP address for the configured DNS Host name")
resolver.nameservers = nameserver
log.info("Get private signature from account key.")
......@@ -228,7 +250,7 @@ def get_crt(config, log=LOGGER):
dnsrr_set = dns.rrset.from_text(dnsrr_domain, config["DNS"].getint("TTL"),
"IN", "TXT", '"{0}"'.format(keydigest64))
try:
_update_dns(dnsrr_set, "add")
_update_dns(dnsrr_set, "add", resolver)
except dns.exception.DNSException as dnsexception:
raise ValueError("Error updating DNS records: {0} : {1}"
.format(type(dnsexception).__name__, str(dnsexception)))
......@@ -279,7 +301,7 @@ def get_crt(config, log=LOGGER):
raise ValueError("Challenge for domain {0} did not pass: {1}".format(
domain, challenge_status))
finally:
_update_dns(dnsrr_set, "delete")
_update_dns(dnsrr_set, "delete", resolver)
log.info("Request to finalize the order (all chalenge have been completed)")
csr_der = _base64(_openssl("req", ["-in", config["acmednstiny"]["CSRFile"],
......
......@@ -51,7 +51,9 @@ Algorithm = hmac-sha256
# Required name of zone to update
Zone = dnszone
# Required name or IP of DNS server
# Required name or IP of the DNS server where to install resource records to resolve the ACME
# challenge.
# If a domain name is given, it must be resolvable as IP address by the system DNS server name.
Host = dnsserver
# Optional port to connect on DNS server (default: 53)
......
......@@ -132,16 +132,11 @@ class TestACMEDNSTiny(unittest.TestCase):
self._assert_certificate_chain(certchain)
def test_success_dnshost_ip(self):
"""When DNS Host is an IP, DNS resolution have to fail without error."""
"""Successfully issue a certificate when DNS Host is defined as an IP address."""
old_stdout = sys.stdout
sys.stdout = StringIO()
with self.assertLogs(level='INFO') as adnslog:
acme_dns_tiny.main([self.configs['dns_host_ip'],
"--verbose"])
self.assertIn(("INFO:acme_dns_tiny:A and/or AAAA DNS resources not found for configured "
"dns host: we will use either resource found if one exists or directly the "
"DNS Host configuration."), adnslog.output)
acme_dns_tiny.main([self.configs['dns_host_ip'], "--verbose"])
certchain = sys.stdout.getvalue()
sys.stdout.close()
......@@ -198,8 +193,9 @@ class TestACMEDNSTiny(unittest.TestCase):
def test_failure_dns_update_tsigkeyname(self):
"""Fail to update DNS records by invalid TSIG Key name."""
self.assertRaisesRegex(ValueError,
"Error updating DNS records",
self.assertRaisesRegex(RuntimeError,
"Unable to add DNS resource to _acme-challenge.{0}."
.format(os.getenv("GITLABCI_DOMAIN")),
acme_dns_tiny.main, [self.configs['invalid_tsig_name'],
"--verbose"])
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment