├── .gitignore ├── LICENSE ├── README.md ├── acme-dns-server.py └── example_hooks └── dehydrated_hook.sh /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # Jupyter Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # SageMath parsed files 80 | *.sage.py 81 | 82 | # dotenv 83 | .env 84 | 85 | # virtualenv 86 | .venv 87 | venv/ 88 | ENV/ 89 | 90 | # Spyder project settings 91 | .spyderproject 92 | .spyproject 93 | 94 | # Rope project settings 95 | .ropeproject 96 | 97 | # mkdocs documentation 98 | /site 99 | 100 | # mypy 101 | .mypy_cache/ 102 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Pawit Pornkitprasan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ACME DNS Server 2 | This is a very simple DNS server written in Python for serving DNS TXT records 3 | for the purpose of ACME (Let's Encrypt) DNS-01 validation, which is required 4 | for generating wildcard certificates. 5 | 6 | The server requires Python 3 and has no additional dependency. It can only 7 | serve TXT record and ignores everything in the query except the domain name. 8 | 9 | ## Why? 10 | Current solutions for DNS-01 validation generally involves using hook scripts 11 | to allow the ACME client to modify DNS records for the domain using some API 12 | available. However, this is not always feasible or desirable for the 13 | following reasons: 14 | 15 | - **Security**: You may not want your ACME client to be able to modify all 16 | DNS records. 17 | - **Speed**: Production DNS servers are often focused on reliability where 18 | as record update speed is not usually a concern and it may take time for 19 | record updates to replicate over several server. However, for ACME 20 | verification, reliability is not a concern (in the off chance that it 21 | failed, it can be retried without affecting users) but slow update can 22 | be frustrating to server administrators trying to generate certificates. 23 | 24 | ## How? 25 | The problem can be fixed by delegating the `_acme-challenge` sub-domain to 26 | ACME DNS server. All this DNS server does is load records from a text 27 | file and serve it as a reply with one line being one TXT records. No caching 28 | or replication is performed, allowing all updates to be reflected right away. 29 | 30 | ## Usage 31 | 32 | 1. First, add NS record to your domain names to delegate it to the server 33 | running ACME DNS server. You will need to add one record for every domain 34 | or subdomain you wish to generate certificates for. 35 | 36 | For example: 37 | 38 | | Certificate Name | Sub-domain where NS record is needed | 39 | | ---------------- | ------------------------------------ | 40 | | example.com | _acme-challenge.example.com | 41 | | *.example.com | _acme-challenge.example.com | 42 | | www.example.com | _acme-challenge.www.example.com | 43 | 44 | 2. Run the ACME DNS server. You will need to run it as root or use other 45 | methods to allow it to bind on port 53. 46 | 47 | `./acme-dns-server.py 53 /opt/records` 48 | 49 | Where `53` is the port to listen on (usually 53) and `/opt/records` is 50 | the where the script will load DNS records from. 51 | 52 | 3. Write a hook for the ACME client you are using to update the DNS record as 53 | desired. The hook should write to the `/opt/record/$NAME` with one TXT 54 | record per line. For example, the hook for generating certificates for 55 | `example.com` should write to `/opt/records/_acme-challenge.example.com`. 56 | 57 | An example hook for the [dehydrated](https://github.com/lukas2511/dehydrated) 58 | client is provided in the `example_hooks` directory. 59 | 60 | 4. Run your ACME client to generate the certificate. 61 | -------------------------------------------------------------------------------- /acme-dns-server.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import argparse 3 | import io 4 | import re 5 | import socketserver 6 | import struct 7 | import sys 8 | 9 | 10 | HEADER = '!HBBHHHH' 11 | HEADER_SIZE = struct.calcsize(HEADER) 12 | DOMAIN_PATTERN = re.compile('^[A-Za-z0-9\-\.\_]+$') 13 | 14 | # Data path, will be updated from argparse 15 | data_path = '' 16 | 17 | 18 | class DNSHandler(socketserver.BaseRequestHandler): 19 | 20 | def handle(self): 21 | socket = self.request[1] 22 | data = self.request[0] 23 | data_stream = io.BytesIO(data) 24 | 25 | # Read header 26 | (request_id, header_a, header_b, qd_count, an_count, ns_count, ar_count) = struct.unpack(HEADER, data_stream.read(HEADER_SIZE)) 27 | 28 | # Read questions 29 | questions = [] 30 | for i in range(qd_count): 31 | name_parts = [] 32 | length = struct.unpack('B', data_stream.read(1))[0] 33 | while length != 0: 34 | name_parts.append(data_stream.read(length).decode('us-ascii')) 35 | length = struct.unpack('B', data_stream.read(1))[0] 36 | name = '.'.join(name_parts) 37 | 38 | if not DOMAIN_PATTERN.match(name): 39 | print('Invalid domain received: ' + name) 40 | # Contains invalid characters, don't continue since it may be path traversal hacking 41 | return 42 | 43 | (qtype, qclass) = struct.unpack('!HH', data_stream.read(4)) 44 | 45 | questions.append({'name': name, 'type': qtype, 'class': qclass}) 46 | 47 | print('Got request for ' + questions[0]['name'] + ' from ' + str(self.client_address[0]) + ':' + str(self.client_address[1])) 48 | 49 | # Read answers 50 | try: 51 | with open(data_path + '/' + questions[0]['name'].lower(), 'r') as f: 52 | answers = [s.strip() for s in f.read().split("\n") if len(s.strip()) != 0] 53 | except: 54 | answers = [] 55 | 56 | # Make response (note: we don't actually care about the questions, just return our canned response) 57 | response = io.BytesIO() 58 | 59 | # Header 60 | # Response, Authoriative 61 | response_header = struct.pack(HEADER, request_id, 0b10000100, 0b00000000, qd_count, len(answers), 0, 0) 62 | response.write(response_header) 63 | 64 | # Questions 65 | for q in questions: 66 | # Name 67 | for part in q['name'].split('.'): 68 | response.write(struct.pack('B', len(part))) 69 | response.write(part.encode('us-ascii')) 70 | response.write(b'\x00') 71 | 72 | # qtype, qclass 73 | response.write(struct.pack('!HH', q['type'], q['class'])) 74 | 75 | # Answers 76 | for a in answers: 77 | response.write(b'\xc0\x0c') # Compressed name (pointer to question) 78 | response.write(struct.pack('!HH', 16, 1)) # type: TXT, class: IN 79 | response.write(struct.pack('!I', 0)) # TTL: 0 80 | response.write(struct.pack('!H', len(a) + 1)) # Record length 81 | response.write(struct.pack('B', len(a))) # TXT length 82 | response.write(a.encode('us-ascii')) # Text 83 | 84 | # Send response 85 | socket.sendto(response.getvalue(), self.client_address) 86 | 87 | if __name__ == '__main__': 88 | parser = argparse.ArgumentParser(description='Simple TXT DNS server') 89 | parser.add_argument('port', metavar='port', type=int, 90 | help='port to listen on') 91 | parser.add_argument('path', metavar='path', type=str, 92 | help='path to find results') 93 | 94 | args = parser.parse_args() 95 | port = args.port 96 | data_path = args.path 97 | 98 | server = socketserver.ThreadingUDPServer(('', port), DNSHandler) 99 | print('Running on port %d' % port) 100 | 101 | try: 102 | server.serve_forever() 103 | except KeyboardInterrupt: 104 | server.shutdown() 105 | -------------------------------------------------------------------------------- /example_hooks/dehydrated_hook.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Please note that this example script requires HOOK_CHAIN=no (default behavior) 3 | 4 | deploy_challenge() { 5 | local DOMAIN="${1}" TOKEN_FILENAME="${2}" TOKEN_VALUE="${3}" 6 | 7 | echo $TOKEN_VALUE >> /opt/records/_acme-challenge.$DOMAIN 8 | } 9 | 10 | exit_hook() { 11 | rm -f /opt/records/* 12 | } 13 | 14 | HANDLER="$1"; shift 15 | if [[ "${HANDLER}" =~ ^(deploy_challenge|exit_hook)$ ]]; then 16 | "$HANDLER" "$@" 17 | fi 18 | --------------------------------------------------------------------------------