├── requirements.txt ├── service.example.sh ├── confs.py ├── localtls.service ├── setup.py ├── util ├── ubuntu1804-install.bash └── ubuntu2204-install.bash ├── LICENSE ├── .gitignore ├── certbotdns.py ├── www └── index.html ├── httpserver.py ├── README.md └── dnsserver.py /requirements.txt: -------------------------------------------------------------------------------- 1 | dnslib==0.9.16 2 | cherrypy==18.1.2 -------------------------------------------------------------------------------- /service.example.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | /usr/bin/python3 dnsserver.py --domain yourdomain.net --soa-master=ns1.yourdomain.net --soa-email=email@yourdomain.net --ns-servers=ns1.yourdomain.net,ns2.yourdomain.net --log-level ERROR --http-port 80 --http-index /somewhere/index.html -------------------------------------------------------------------------------- /confs.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import datetime 4 | 5 | BASE_DOMAIN = '' 6 | LOCAL_IPV4 = '' 7 | LOCAL_IPV6 = '' 8 | SOA_MNAME='' 9 | SOA_RNAME='' 10 | SOA_SERIAL=int(datetime.datetime.now().strftime('%Y%m%d%S')) 11 | NS_SERVERS=[] 12 | ONLY_PRIVATE_IPS = False 13 | NO_RESERVED_IPS = False -------------------------------------------------------------------------------- /localtls.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=localtls DNS Server 3 | After=network.target 4 | 5 | [Service] 6 | Type=simple 7 | User=root 8 | WorkingDirectory=/root/localtls 9 | ExecStart=/usr/bin/python3 -u /root/localtls/service.py 10 | Restart=always 11 | RestartSec=5 12 | 13 | [Install] 14 | WantedBy=multi-user.target -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from distutils.core import setup 4 | 5 | setup(name='localtls', 6 | version='1.0.8', 7 | description='DNS server for local TLS', 8 | author='Corollarium', 9 | author_email='email@corollarium.com', 10 | url='https://github.com/Corollarium/localtls', 11 | ) 12 | 13 | -------------------------------------------------------------------------------- /util/ubuntu1804-install.bash: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo "This script installs deps for running depserver on a Ubuntu server." 4 | 5 | # base/certbot ppa 6 | echo "Getting updates" 7 | sudo apt-get install software-properties-common 8 | sudo add-apt-repository universe 9 | sudo add-apt-repository ppa:certbot/certbot 10 | sudo apt-get update 11 | 12 | # packages 13 | echo "Installing updates" 14 | sudo apt install mosh python3-pip certbot 15 | sudo pip3 install dnslib cherrypy 16 | 17 | # kill resolved 18 | echo "Removing resolved" 19 | echo "127.0.0.1 $(hostname)" >> /etc/hosts 20 | sudo systemctl disable systemd-resolved 21 | sudo systemctl stop systemd-resolved 22 | rm -f /etc/resolv.conf 23 | echo "nameserver 127.0.0.1" > /etc/resolv.conf 24 | 25 | sudo ufw allow ssh 26 | sudo ufw allow 53 27 | sudo ufw allow 80 28 | sudo ufw allow 443 29 | sudo ufw allow 60000:61000/udp 30 | sudo ufw enable 31 | 32 | echo "Ready. Now run python3 dnsserver.py" -------------------------------------------------------------------------------- /util/ubuntu2204-install.bash: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo "This script will now install deps on your Ubuntu server..." 4 | 5 | # packages 6 | echo "Installing python, certbot and other DNS related libraries..." 7 | sudo apt -yq install mosh python3-pip certbot 8 | sudo pip3 install dnslib cherrypy 9 | 10 | # kill resolved 11 | echo "Stopping resolved from using port 53..." 12 | 13 | # thanks https://www.linuxuprising.com/2020/07/ubuntu-how-to-free-up-port-53-used-by.html 14 | sudo cat > /etc/systemd/resolved.conf << EOF 15 | [Resolve] 16 | DNS=1.1.1.1 17 | #FallbackDNS= 18 | #Domains= 19 | #LLMNR=no 20 | #MulticastDNS=no 21 | #DNSSEC=no 22 | #DNSOverTLS=no 23 | #Cache=no 24 | DNSStubListener=no 25 | #ReadEtcHosts=yes 26 | EOF 27 | sudo ln -sf /run/systemd/resolve/resolv.conf /etc/resolv.conf 28 | 29 | echo "Securing system with ufw..." 30 | sudo ufw allow ssh 31 | sudo ufw allow 53 32 | sudo ufw allow 80 33 | sudo ufw allow 443 34 | sudo ufw allow 60000:61000/udp 35 | sudo ufw enable 36 | 37 | echo "Please reboot your system after which you are ready to run python3 dnsserver.py" -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Corollarium 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 | -------------------------------------------------------------------------------- /.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 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 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 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | /.project 106 | /.pydevproject 107 | 108 | # log 109 | http_error_log 110 | 111 | service.sh 112 | .idea -------------------------------------------------------------------------------- /certbotdns.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3.6 2 | # -*- coding: utf-8 -*- 3 | 4 | import os 5 | import sys 6 | import json 7 | import subprocess 8 | from multiprocessing.connection import Client 9 | 10 | # To simulate certbot DNS hooks: 11 | # CERTBOT_DOMAIN=yourdomain.net CERTBOT_VALIDATION=xxx python3 certbottxt.py deploy 12 | # CERTBOT_DOMAIN=yourdomain.net CERTBOT_VALIDATION=xxx CERTBOT_AUTH_OUTPUT=_acme-challenge.asdf.com python3 certbottxt.py cleanup 13 | 14 | BASE_PATH=os.path.realpath(__file__) 15 | CERTBOT_DOMAIN=os.getenv('CERTBOT_DOMAIN') 16 | CERTBOT_VALIDATION=os.getenv('CERTBOT_VALIDATION') 17 | 18 | from multiprocessing.connection import Client 19 | 20 | address = ('localhost', 6000) 21 | 22 | def help(): 23 | print("Command: renovate [domain] [email]\n") 24 | 25 | if len(sys.argv) == 1: 26 | help() 27 | elif sys.argv[1] == 'deploy': 28 | DOMAIN="_acme-challenge.%s" % CERTBOT_DOMAIN 29 | conn = Client(address, authkey=b'secret') 30 | conn.send(json.dumps({'command': 'ADDTXT', 'key': DOMAIN, 'val': CERTBOT_VALIDATION}, ensure_ascii=False, indent=4)) 31 | print(DOMAIN) 32 | conn.close() 33 | elif sys.argv[1] == 'cleanup': 34 | CERTBOT_AUTH_OUTPUT=os.getenv('CERTBOT_AUTH_OUTPUT', '*') 35 | conn = Client(address, authkey=b'secret') 36 | conn.send(json.dumps({'command': 'REMOVETXT', 'key': CERTBOT_AUTH_OUTPUT}, ensure_ascii=False, indent=4)) 37 | conn.close() 38 | elif sys.argv[1] == 'wildcard' or sys.argv[1] == 'naked': 39 | if len(sys.argv) != 4: 40 | help() 41 | else: 42 | script = os.path.abspath(__file__) 43 | basename = sys.argv[2] + '-' + sys.argv[1] 44 | command = [ 45 | 'certbot', 'certonly', '--noninteractive', # TEST: '--test-cert', 46 | '--key-type', 'rsa', 47 | '--agree-tos', '--email', sys.argv[3], 48 | '--manual', '--preferred-challenges=dns', '--manual-public-ip-logging-ok', 49 | '--manual-auth-hook', 'python3 {0} deploy'.format(script), 50 | '--manual-cleanup-hook', 'python3 {0} cleanup'.format(script), 51 | '-d', ('*.' if sys.argv[1] == 'wildcard' else '') + sys.argv[2] 52 | ] 53 | output = subprocess.run(command) 54 | print(output.stdout) 55 | print(output.stderr) 56 | -------------------------------------------------------------------------------- /www/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Wildcard certs for your localhosts 6 | 7 | 44 | 45 | 46 |
47 |
48 |

Domain here

49 |

DNS service for local IP addresses with TLS/HTTPS support

50 |
51 |
52 |
53 | 54 |

Domain here - DNS service for local IP addresses with TLS/HTTPS 55 | support

56 | 57 | 58 |

What is this about

59 |

In one of our projects we faced a problem, that we wanted to connect to websocket on a local network from 60 | webpage, that was served from the Internet. In order to use secure web socket connection (wss://) we needed the 61 | SSL certificate, but you can't get one for local IP's. The bottomline was that we could not change client 62 | machines configuration (no /etc/hosts, no importing of own CA's).

63 | 64 |

So we came up with the idea, that we could use wildcard SSL certificate and are using 65 | localtls as a DNS server and web server 66 | and Let's Encrypt for wildcard certs.

67 | 68 |

Note: In general you should NEVER share the TLS private key as we are on this site. Please read the Security Considerations BEFORE using this service.

69 | 70 |

SSL certificate for *.Domain here

71 |

The real good news is, you can get from us SSL certificate for domain *.Domain here for free 72 | :) - here it comes:

73 | 80 | 81 | 82 |

Disclaimer

83 |

This service comes without any guarantee and is provided as-is. We will do our best to keep it working, but we 84 | reserve the right to turn it off anytime and without any reason.

85 |
86 | 94 | 95 | 96 | 97 | -------------------------------------------------------------------------------- /httpserver.py: -------------------------------------------------------------------------------- 1 | #-*- coding: utf-8 -*- 2 | 3 | import os 4 | import sys 5 | import confs 6 | import cherrypy 7 | import subprocess 8 | import logging 9 | from cherrypy.lib import static 10 | 11 | INDEX_HTML='Hi.' 12 | CERT_PATH='/etc/letsencrypt/live/' + confs.BASE_DOMAIN 13 | logger = logging.getLogger('localtls') 14 | 15 | class Root(object): 16 | @cherrypy.expose 17 | def index(self): 18 | return INDEX_HTML 19 | 20 | @cherrypy.expose 21 | def fullchain(self): 22 | return static.serve_file(os.path.join(CERT_PATH, 'fullchain.pem'), 'application/x-download', 'attachment') 23 | 24 | @cherrypy.expose 25 | def key(self): 26 | return static.serve_file(os.path.join(CERT_PATH, 'privkey.pem'), 'application/x-download', 'attachment') 27 | 28 | @cherrypy.expose 29 | def cert(self): 30 | return static.serve_file(os.path.join(CERT_PATH, 'cert.pem'), 'application/x-download', 'attachment') 31 | 32 | @cherrypy.expose 33 | def chain(self): 34 | return static.serve_file(os.path.join(CERT_PATH, 'chain.pem'), 'application/x-download', 'attachment') 35 | 36 | @cherrypy.expose 37 | @cherrypy.tools.json_out() 38 | def keys(self): 39 | privkey = cert = chain = fullchain = '' 40 | try: 41 | with open(os.path.join(CERT_PATH, 'cert.pem')) as f: 42 | cert = f.read() 43 | with open(os.path.join(CERT_PATH, 'chain.pem')) as f: 44 | chain = f.read() 45 | with open(os.path.join(CERT_PATH, 'fullchain.pem')) as f: 46 | fullchain = f.read() 47 | with open(os.path.join(CERT_PATH, 'privkey.pem')) as f: 48 | privkey = f.read() 49 | except ValueError as e: 50 | cherrypy.log(str(e)) 51 | except FileNotFoundError as e: 52 | cherrypy.log(str(e)) 53 | except: 54 | cherrypy.log("Unexpected error:", sys.exc_info()[0]) 55 | return {'privkey': privkey, 'cert': cert, 'chain': chain, 'fullchain': fullchain} 56 | 57 | @cherrypy.expose 58 | def favicon_ico(self): 59 | raise cherrypy.HTTPError(404) 60 | 61 | def listCertificates(): 62 | command = [ 63 | 'certbot', 'certificates' 64 | ] 65 | output = subprocess.Popen(command, stdout=subprocess.PIPE, bufsize=1, universal_newlines=True) 66 | current_certificate = '' 67 | current_domain = '' 68 | paths = {} 69 | for line in iter(output.stdout.readline,''): 70 | if line.find('Certificate Name') > -1: 71 | current_certificate = line.split(':')[1].strip() 72 | continue 73 | elif line.find('Domains') > -1: 74 | domains = line.split(':')[1].strip() 75 | current_domain = domains 76 | elif line.find('Certificate Path') > -1: 77 | p = line.split(':')[1].strip() 78 | paths[domains] = os.path.dirname(p) 79 | return paths 80 | 81 | def force_tls(self=None): 82 | # check if url is in https and redirect if http 83 | if cherrypy.request.scheme == "http": 84 | raise cherrypy.HTTPRedirect(cherrypy.url().replace("http:", "https:"), status=301) 85 | 86 | def run(port, index, certpath=''): 87 | global INDEX_HTML, CERT_PATH 88 | try: 89 | with open(index) as f: 90 | INDEX_HTML=bytes(f.read(), "utf8") 91 | except: 92 | pass 93 | 94 | # get certificates 95 | try: 96 | paths = listCertificates() 97 | if ('*.' + confs.BASE_DOMAIN) in paths: 98 | CERT_PATH = paths['*.' + confs.BASE_DOMAIN] 99 | else: 100 | logger.critical("Cannot find wildcard certificate. Run certbotdns.py now and then restart this. Meanwhile HTTP will not work.") 101 | return 102 | except: 103 | logger.critical("Cannot list certificates: {}. Is certbot installed?".format(sys.exc_info()[0])) 104 | #return 105 | 106 | cherrypy.config.update({ 107 | 'log.screen': False, 108 | 'log.access_file': '', 109 | 'log.error_file': 'http_error_log', 110 | 'environment': 'production', 111 | 'server.socket_host': '::', 112 | 'server.socket_port': int(port) 113 | }) 114 | if port == 443 and confs.BASE_DOMAIN in paths: 115 | logger.info('Starting TLS server.') 116 | cert = paths[confs.BASE_DOMAIN] 117 | cherrypy.tools.force_tls = cherrypy.Tool("before_handler", force_tls) 118 | cherrypy.config.update({ 119 | 'server.ssl_module': 'builtin', 120 | 'server.ssl_certificate': os.path.join(cert, "cert.pem"), 121 | 'server.ssl_private_key': os.path.join(cert, "privkey.pem"), 122 | 'server.ssl_certificate_chain': os.path.join(cert, "fullchain.pem"), 123 | 'tools.force_tls.on': True 124 | }) 125 | 126 | # extra server instance to dispatch HTTP 127 | server = cherrypy._cpserver.Server() 128 | server.socket_host = '::' 129 | server.socket_port = 80 130 | server.subscribe() 131 | 132 | logger.info('Starting HTTP server.') 133 | cherrypy.quickstart(Root(), '/') 134 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # localtls 2 | 3 | This is a simple DNS server in Python3 to provide TLS to webservices on local addresses. In short, it resolves addresses such as `192-168-0-1.yourdomain.net` to `192.168.0.1` and has a valid TLS certificate for them. 4 | 5 | This was written to circumvent the problem that current browsers require a secure context for a number of operations, such as opening the camera with `getUserMedia`, but the web service is running on a local network, where it is difficult to get a certificate or handling the local DNS servers is difficult or impossible (aham users aham). It can also be used to easily develop and debug web applications that require secure contexts other than in localhost. 6 | 7 | Technically it's a very simple DNS server written in Python, which uses [Let's Encrypt](https://letsencrypt.org/) to generate a wildcard certificate for *.yourdomain.net on a real public server. This certificate, both private and public keys, is available for download via both a `REST` call as well as two `GET` calls on a simple HTTP server, also provided. 8 | 9 | ## Technical explanation and motivation 10 | 11 | Browsers require a secure context (MDN) for several Web APIs to work. While this is simple for public websites, it is a difficult issue for intranets and private IPs. When you're deploying applications on networks that you have no control, it's a nightmare. 12 | 13 | This software provides: 14 | 15 | 1. DNS server: resolves to IP.yourdomain.net (for local IPs, see below) to IP. Run on the public internet. 16 | 2. HTTP server: show an `index.html` as well as REST endpoint public and private keys 17 | 3. Certbot one-liner: renews certificate with LetsEncrypt via DNS authentication. Run once a month. 18 | 19 | ## What this DNS resolves 20 | 21 | * `yourdomain.net` : to your server IP for both `A` and `AAAA` (if it exists) records. 22 | * `_acme-challenge.yourdomain.net` : necessary for the certbot authentication during renewal 23 | * `a-b-c-d.yourdomain.net`: resolves to `A` record to `a.b.c.d`. (replace `.` by `-`). 24 | * `fe80-[xxx].yourdomain.net`: resolves to `AAAA` record to `fe80:[xxx]` (replace any `:` by `-`). 25 | * `anything else`: falls back to `dns-fallback` as defined in config 26 | 27 | ## Security considerations 28 | 29 | "But if you provide the public and the private key, someone can do a man-in-the-middle attack!" Yes, that is correct. This is *as safe as a plain HTTP website if you release the private key*. 30 | 31 | This service here aims to solve the *requirement of browsers with secure contexts in LANs with a minimum fuss*: when you are developing an app that requires TLS, for example, and want to test it on several devices locally. Or when you want to deploy a web application on customer networks that have no expertise. Hopefully browsers will come up with a solution that makes secure contexts in intranets easier in the future, but it has been a problem for years and it's still unsolved at this time. 32 | 33 | In short, you have two possible scenarios. The first: you understand that by using this you may be prone for a MITM attack, but you need a secure context in the browser no matter what, and you do need absolute certainty that your traffic will not be snooped or your application won't be spoofed. This works for most webservices running in a LAN, and is as safe as running them on pure HTTP. 34 | 35 | The second: you need not only a secure context for the browser, but actual safety of a private TLS certificate validated by the browser. In this case you can run the DNS server yourself and not publish the private keys, but find someway to distribute them yourself privately to your application. Remember, any application you deploy using TLS will require a private key deployed with it. When distributing web apps that are supposed to run in intranets which you have no access this is hard to do; you'd ideally need to generate a different key for every host, even though they may use the same private IP, you have no access to a local nameserver and other complications. There is a [nice proposal of how this can be done](https://blog.heckel.io/2018/08/05/issuing-lets-encrypt-certificates-for-65000-internal-servers/) if you need this level of security. 36 | 37 | # How to Run 38 | 39 | ## Overview 40 | 41 | 1. Get a server that can run Python and certbot. It doesn't need to be big. Ideally you should have at least one slave, too, because NS entries require at least two servers. 42 | 2. Point the NS entry of your domain to this server. 43 | 3. [Install deps](#base-installation-and-deps). 44 | 4. [Run dnsserver.py](#running-the-dns-server). 45 | 5. [Create the certificates running certbotdns.py](#renewing-keys). 46 | 47 | ## Prerequisites 48 | 49 | This was tested on Ubuntu 22.04, but any server that can run all of these should work: 50 | 51 | * Python 3.6 or above (see `util/ubuntu2204-install.bash` script ) 52 | * certbot and the dnslib and cherrypy PIPs (see `util/ubuntu2204-install.bash` script ) 53 | * Static IP 54 | * 4 DNS entries for your TLD or sub-domain (or sub sub domain etc.). In this example we'll use the `local-ip` sub-domain, the domain `example.com` and the IP `1.2.3.97`. All three values are arbitrary and can be what ever you'd like: 55 | * A record: `local-ip.example.com` -> `1.2.3.97` 56 | * A record: `ns1.local-ip.example.com` -> `1.2.3.97` 57 | * A record: `ns2.local-ip.example.com` -> `1.2.3.97` 58 | * Name server: `NS` -> 59 | ``` 60 | ns1.local-ip.example.com 61 | ns2.local-ip.example.com 62 | ``` 63 | 64 | ## Running the DNS server 65 | 66 | This software uses port 6000 for internal communication. It is bound to 127.0.0.1 and is secured by the password `secret`. This is just to pass the validation code from certbot to the DNS Server to the Web Server. 67 | 68 | ### Manually 69 | 70 | Start the DNS Server to test and figure the correct values: 71 | 72 | `python3 dnsserver.py --domain yourdomain.net --soa-master=ns1.yourdomain.net --soa-email=email@yourdomain.net --ns-servers=ns1.yourdomain.net,ns2.yourdomain.net --log-level DEBUG --http-port 80 --http-index /somewhere/index.html` 73 | 74 | Run `python3 dnsserver.py --help` for a list of arguments: 75 | 76 | * `--domain`: REQUIRED. Your domain or subdomain. 77 | * `--soa-master`: STRONGLY RECOMMENDED. Primary master name server for SOA record. You should fill this to be compliant to RFC 1035. 78 | * `--soa-email`: STRONGLY RECOMMENDED. Email address for administrator for SOA record. You should fill this to be compliant to RFC 1035. 79 | * `--ns-servers`: STRONGLY RECOMMENDED. The list of nameservers, separated by commas, for the NS record. 80 | * `--dns-port`: DNS server port. Defaults to 53. You need to be root on linux to run this on a port < 1024. 81 | * `--dns-fallback`: The DNS fallback server. This server can be used as full DNS resolver in your network, falling back to this server. Defaults to the `1.1.1.1`. 82 | * `--domain-ipv4`: The ipv4 for the naked domain. Defaults to the server IPV4. 83 | * `--domain-ipv6`: The ipv6 for the naked domain. Defaults to the server IPV6, if present. 84 | * `--http-port`: the HTTP server port. If not set, no HTTP server is started. The HTTP server is used to serve a index.html for the `/` location and the `/keys` with the keys. 85 | * `--http-index-file`: path to the HTTP `index.html`. We don't serve assets. The file is read upon start and cached. Check out the `www` directory! 86 | * `--log-level`: INFO|WARNING|ERROR|DEBUG. You should run on ERROR level in production. 87 | * `--only-private-ips`: Only resolve private ips. 88 | * `--no-reserved-ips`: Don't resolve reserved ips. 89 | 90 | Create the wildcard domain: 91 | 92 | `python3 certbotdns.py wildcard yourdomain.net email@yourdomain.net` 93 | 94 | Create the naked domain: 95 | 96 | `python3 certbotdns.py naked yourdomain.net email@yourdomain.net` 97 | 98 | ### Automated 99 | 100 | Once you have manually tested and figured out how to run your server, use this technique to automate it: 101 | 102 | 1. Ensure your `localtls` directory is `/root/localtls` and `cd` into it 103 | 2. Create your own service script: `cp service.example.sh service.sh` 104 | 3. Put the one-liner from [above in "Manually"](#manually) into the newly created `service.sh` file 105 | 4. Copy the `systemd` file into place, reload `systemd`, start and enable it: 106 | ```commandline 107 | sudo cp localtls.service /etc/systemd/system/ 108 | sudo systemctl daemon-reload 109 | sudo systemctl enable localtls 110 | sudo systemctl start localtls 111 | ``` 112 | 5. Add a cron to ensure the certs are renewed once a month: 113 | 114 | ``` 115 | 0 0 1 * * /usr/bin/python3 /root/localtls/certbotdns.py wildcard yourdomain.net email@yourdomain.net; /usr/bin/python3 /root/localtls/certbotdns.py naked yourdomain.net email@yourdomain.net 116 | ``` 117 | 118 | ## Slave DNS server 119 | 120 | To run a secondary NS server, we suggest run dnsserver.py without a HTTP server. Remember to set `--domain-ipv4` and `--domain-ipv6` pointing to the master server. Do not run certbotdns.py on the slave servers. 121 | 122 | ### Testing 123 | 124 | Run locally like this for a minimal test at port 5300: 125 | 126 | `python3 dnsserver.py --domain=yourdomain.net --dns-port=5300` 127 | 128 | Run dig to test: 129 | 130 | `dig @localhost -p 5300 +nocmd 192-168-0-255.yourdomain.net ANY +multiline +noall +answer` 131 | 132 | # Using this in your webservice 133 | 134 | We at [Corollarium](https://corollarium.com) are using it at [videowall.online](https://videowall.online). It's used in our [video wall](https://softwarevideowall.com). 135 | 136 | You should fetch the keys remotely before you open your webservice. Keys are valid for three months, but renewed every month. If your service runs continuously for longer than that you should either restart the service or make it poll and replace the keys every 24h or so. 137 | 138 | First, make sure you run with `--http-port`. Make a REST GET rest for `[DOMAIN]/keys` and you'll get a JSON with the following keys: 139 | 140 | * privkey: the private key. 141 | * cert: the public certificate. 142 | * chain: the chain certificate. 143 | * fullchain: the full chain certificate. 144 | 145 | This follows the same pattern of files created by Let's Encrypt. 146 | 147 | ## Node.js code 148 | 149 | This code will try to get the keys until a timeout and open a HTTPS server using those keys locally. Remember to replace `yourdomain.net`. 150 | 151 | ``` 152 | function localtls(dnsserver) { 153 | const request = require('request'); 154 | return new Promise(function(resolve, reject) { 155 | request({ 156 | uri: dnsserver + '/keys', 157 | timeout: 10000, 158 | }, function (error, response, body) { 159 | if (error) { 160 | reject(error); 161 | } 162 | else { 163 | try { 164 | let d = JSON.parse(body); 165 | resolve({key: d.privkey, cert: d.cert, ca: d.chain}); 166 | } 167 | catch (e) { 168 | reject(e); 169 | } 170 | } 171 | }); 172 | }); 173 | } 174 | 175 | var app = express(), https; 176 | try { 177 | let keys = await localtls('http://yourdomain.net'); 178 | 179 | // reload keys every week, see https://github.com/nodejs/node/issues/15115 180 | let ctx = tls.createSecureContext(keys); 181 | setInterval(() => { 182 | lantls().then((k) => { keys = k; }).catch(e => {}); 183 | }, 7*24*60*60*1000); 184 | 185 | https = require('https').createServer({ 186 | SNICallback: (servername, cb) => { 187 | cb(null, ctx); 188 | } 189 | }, app); 190 | } 191 | catch(e) { 192 | // pass 193 | console.log("invalid https", e); 194 | } 195 | ``` 196 | 197 | 198 | # About and credits 199 | 200 | * Developed by [Corollarium](https://corollarium.com) and released under the MIT license. 201 | * Inspiration from [nip.io](https://nip.io), [SSLIP](https://sslip.io) and [XIP](http://xip.io/) 202 | * [Blog post explaining how to generate certificates per server](https://blog.heckel.io/2018/08/05/issuing-lets-encrypt-certificates-for-65000-internal-servers/) 203 | -------------------------------------------------------------------------------- /dnsserver.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3.6 2 | # -*- coding: utf-8 -*- 3 | 4 | import json 5 | import logging 6 | import os 7 | import sys 8 | import signal 9 | import re 10 | import socket 11 | import argparse 12 | import ipaddress 13 | from datetime import datetime 14 | from time import sleep 15 | import threading 16 | from multiprocessing.connection import Listener 17 | 18 | import dnslib 19 | from dnslib import DNSLabel, QTYPE, RR, dns 20 | from dnslib.proxy import ProxyResolver 21 | from dnslib.server import DNSServer, DNSLogger 22 | 23 | import httpserver 24 | import confs 25 | 26 | handler = logging.StreamHandler() 27 | handler.setLevel(logging.INFO) 28 | handler.setFormatter(logging.Formatter('%(asctime)s: %(message)s', datefmt='%H:%M:%S')) 29 | logger = logging.getLogger('localtls') 30 | logger.addHandler(handler) 31 | 32 | TYPE_LOOKUP = { 33 | 'A': (dns.A, QTYPE.A), 34 | 'AAAA': (dns.AAAA, QTYPE.AAAA), 35 | 'CAA': (dns.CAA, QTYPE.CAA), 36 | 'CNAME': (dns.CNAME, QTYPE.CNAME), 37 | 'DNSKEY': (dns.DNSKEY, QTYPE.DNSKEY), 38 | 'MX': (dns.MX, QTYPE.MX), 39 | 'NAPTR': (dns.NAPTR, QTYPE.NAPTR), 40 | 'NS': (dns.NS, QTYPE.NS), 41 | 'PTR': (dns.PTR, QTYPE.PTR), 42 | 'RRSIG': (dns.RRSIG, QTYPE.RRSIG), 43 | 'SOA': (dns.SOA, QTYPE.SOA), 44 | 'SRV': (dns.SRV, QTYPE.SRV), 45 | 'TXT': (dns.TXT, QTYPE.TXT), 46 | } 47 | 48 | TXT_RECORDS = {} 49 | 50 | def get_ipv4(): 51 | s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 52 | try: 53 | # doesn't even have to be reachable 54 | s.connect(('10.255.255.255', 1)) 55 | IP = s.getsockname()[0] 56 | except: 57 | IP = '' 58 | finally: 59 | s.close() 60 | return IP 61 | 62 | def get_ipv6(): 63 | s = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM) 64 | 65 | try: 66 | s.connect(('2001:0db8:85a3:0000:0000:8a2e:0370:7334', 1)) 67 | IP = s.getsockname()[0] 68 | except: 69 | IP = '' 70 | finally: 71 | s.close() 72 | return IP 73 | 74 | class Resolver(ProxyResolver): 75 | def __init__(self, upstream): 76 | super().__init__(upstream, 53, 5) 77 | if confs.SOA_MNAME and confs.SOA_RNAME: 78 | self.SOA = dnslib.SOA( 79 | mname=DNSLabel(confs.SOA_MNAME), 80 | rname=DNSLabel(confs.SOA_RNAME.replace('@', '.')), # TODO: . before @ should be escaped 81 | times=( 82 | confs.SOA_SERIAL, # serial number 83 | 60 * 60 * 1, # refresh 84 | 60 * 60 * 2, # retry 85 | 60 * 60 * 24, # expire 86 | 60 * 60 * 1, # minimum 87 | ) 88 | ) 89 | else: 90 | self.SOA=None 91 | 92 | if confs.NS_SERVERS: 93 | self.NS = [dnslib.NS(i) for i in confs.NS_SERVERS] 94 | else: 95 | self.NS = [] 96 | 97 | def match_suffix_insensitive(self, request): 98 | name = request.q.qname 99 | # skip the last dot 100 | suffixLower = str(name)[-len(confs.BASE_DOMAIN)-1:-1].lower() 101 | return suffixLower == confs.BASE_DOMAIN 102 | 103 | def resolve(self, request, handler): 104 | global TXT_RECORDS 105 | reply = request.reply() 106 | name = request.q.qname 107 | 108 | logger.info("query %s", request.q.qname) 109 | 110 | # handle the main domain 111 | if (name == confs.BASE_DOMAIN or 112 | name == '_acme-challenge.' + confs.BASE_DOMAIN 113 | ): 114 | r = RR( 115 | rname=request.q.qname, 116 | rdata=dns.A(confs.LOCAL_IPV4), 117 | rtype=QTYPE.A, 118 | ttl=60*60 119 | ) 120 | reply.add_answer(r) 121 | 122 | if self.SOA: 123 | r = RR( 124 | rname=request.q.qname, 125 | rdata=self.SOA, 126 | rtype=QTYPE.SOA, 127 | ttl=60*60 128 | ) 129 | reply.add_answer(r) 130 | 131 | if len(self.NS): 132 | for i in self.NS: 133 | r = RR( 134 | rname=request.q.qname, 135 | rdata=i, 136 | rtype=QTYPE.NS, 137 | ttl=60*60 138 | ) 139 | reply.add_answer(r) 140 | 141 | if confs.LOCAL_IPV6: 142 | r = RR( 143 | rname=request.q.qname, 144 | rdata=dns.AAAA(confs.LOCAL_IPV6), 145 | rtype=QTYPE.AAAA, 146 | ttl=60*60 147 | ) 148 | reply.add_answer(r) 149 | 150 | if len(TXT_RECORDS): 151 | r = RR( 152 | rname=request.q.qname, 153 | rdata=dns.TXT(['{1}'.format(k, v) for k, v in TXT_RECORDS.items()]), 154 | rtype=QTYPE.TXT 155 | ) 156 | reply.add_answer(r) 157 | return reply 158 | # handle subdomains 159 | elif self.match_suffix_insensitive(request): 160 | labelstr = str(request.q.qname) 161 | logger.info("requestx: %s, %s", labelstr, confs.ONLY_PRIVATE_IPS) 162 | 163 | if labelstr[:-1].endswith(confs.BASE_DOMAIN): 164 | ip = None 165 | subdomain = labelstr[:labelstr.find(confs.BASE_DOMAIN) - 1] 166 | try: 167 | ip = ipaddress.ip_address(subdomain.replace('-', '.')) 168 | except: 169 | pass 170 | try: 171 | if ip == None: 172 | ip = ipaddress.ip_address(subdomain.replace('-', ':')) 173 | except: 174 | logger.info('invalid ip %s', labelstr) 175 | return reply 176 | 177 | # check if we only want private ips 178 | if not ip.is_private and confs.ONLY_PRIVATE_IPS: 179 | return reply 180 | if ip.is_reserved and confs.NO_RESERVED_IPS: 181 | return reply 182 | # check if it's a valid ip for a machine 183 | if ip.is_multicast or ip.is_unspecified: 184 | return reply 185 | 186 | if type(ip) == ipaddress.IPv4Address: 187 | ipv4 = subdomain.replace('-', '.') 188 | logger.info("ip is ipv4 %s", ipv4) 189 | r = RR( 190 | rname=request.q.qname, 191 | rdata=dns.A(ipv4), 192 | rtype=QTYPE.A, 193 | ttl=24*60*60 194 | ) 195 | reply.add_answer(r) 196 | elif type(ip) == ipaddress.IPv6Address: 197 | ipv6 = subdomain.replace('-', ':') 198 | logger.info("ip is ipv6 %s", ipv6) 199 | r = RR( 200 | rname=request.q.qname, 201 | rdata=dns.AAAA(ipv6), 202 | rtype=QTYPE.AAAA, 203 | ttl=24*60*60 204 | ) 205 | reply.add_answer(r) 206 | else: 207 | return reply 208 | 209 | logger.info('found zone for %s, %d replies', request.q.qname, len(reply.rr)) 210 | return reply 211 | elif self.address == "": 212 | return reply 213 | 214 | return super().resolve(request, handler) 215 | 216 | 217 | def handle_sig(signum, frame): 218 | logger.info('pid=%d, got signal: %s, stopping...', os.getpid(), signal.Signals(signum).name) 219 | exit(0) 220 | 221 | # this is used to hear for new TXT records from the certbotdns script. We need to get them ASAP to 222 | # validate the certbot request. 223 | def messageListener(): 224 | global TXT_RECORDS 225 | address = ('localhost', 6000) # family is deduced to be 'AF_INET' 226 | listener = Listener(address, authkey=os.getenv('KEY', b'secret')) # not very secret, but we're bound to localhost. 227 | while True: 228 | conn = None 229 | try: 230 | conn = listener.accept() 231 | msg = conn.recv() 232 | # do something with msg 233 | msg = json.loads(msg.encode("utf-8")) 234 | if msg['command'] == "ADDTXT": 235 | TXT_RECORDS[msg["key"]] = msg["val"] 236 | elif msg['command'] == "REMOVETXT": 237 | TXT_RECORDS.pop(msg["key"]) 238 | conn.close() 239 | except Exception as e: 240 | logger.error(e) 241 | if conn: 242 | conn.close() 243 | pass 244 | listener.close() 245 | 246 | def main(): 247 | signal.signal(signal.SIGTERM, handle_sig) 248 | 249 | parser = argparse.ArgumentParser(description='LocalTLS') 250 | parser.add_argument( 251 | '--domain', 252 | required = True, 253 | help = "Your domain or subdomain." 254 | ) 255 | parser.add_argument( 256 | '--soa-master', 257 | help = "Primary master name server for SOA record. You should fill this." 258 | ) 259 | parser.add_argument( 260 | '--soa-email', 261 | help = "Email address for administrator for SOA record. You should fill this." 262 | ) 263 | parser.add_argument( 264 | '--ns-servers', 265 | help = "List of ns servers, separated by comma" 266 | ) 267 | parser.add_argument( 268 | '--dns-port', 269 | default=53, 270 | help = "DNS server port" 271 | ) 272 | parser.add_argument( 273 | '--domain-ipv4', 274 | default='', 275 | help = "The IPV4 for the naked domain. If empty, use this machine's." 276 | ) 277 | parser.add_argument( 278 | '--domain-ipv6', 279 | default='', 280 | help = "The IPV6 for the naked domain. If empty, use this machine's." 281 | ) 282 | parser.add_argument( 283 | '--only-private-ips', 284 | action='store_true', 285 | default=False, 286 | help = "Resolve only IPs in private ranges." 287 | ) 288 | parser.add_argument( 289 | '--no-reserved-ips', 290 | action='store_true', 291 | default=False, 292 | help = "If true ignore ips that are reserved." 293 | ) 294 | parser.add_argument( 295 | '--dns-fallback', 296 | default='1.1.1.1', 297 | help = "DNS fallback server. Default: 1.1.1.1" 298 | ) 299 | parser.add_argument( 300 | '--http-port', 301 | help = "HTTP server port. If not set, no HTTP server is started" 302 | ) 303 | parser.add_argument( 304 | '--http-index-file', 305 | default = 'index.html', 306 | help = "HTTP index.html file." 307 | ) 308 | parser.add_argument( 309 | '--log-level', 310 | default = 'ERROR', 311 | help = "INFO|WARNING|ERROR|DEBUG" 312 | ) 313 | args = parser.parse_args() 314 | 315 | # The primary addresses 316 | confs.LOCAL_IPV4 = args.domain_ipv4 if args.domain_ipv4 else get_ipv4() 317 | confs.LOCAL_IPV6 = args.domain_ipv6 if args.domain_ipv6 else get_ipv6() 318 | try: 319 | ipaddress.ip_address(confs.LOCAL_IPV4) 320 | except: 321 | logger.critical('Invalid IPV4 %s', LOCAL_IPV4) 322 | sys.exit(1) 323 | try: 324 | if confs.LOCAL_IPV6: 325 | ipaddress.ip_address(confs.LOCAL_IPV6) 326 | except: 327 | logger.critical('Invalid IPV6 %s', LOCAL_IPV6) 328 | sys.exit(1) 329 | logger.setLevel(args.log_level) 330 | 331 | confs.ONLY_PRIVATE_IPS = args.only_private_ips 332 | confs.NO_RESERVED_IPS = args.no_reserved_ips 333 | confs.BASE_DOMAIN = args.domain 334 | confs.SOA_MNAME = args.soa_master 335 | confs.SOA_RNAME = args.soa_email 336 | if not confs.SOA_MNAME or not confs.SOA_RNAME: 337 | logger.error('Setting SOA is strongly recommended') 338 | 339 | if args.ns_servers: 340 | confs.NS_SERVERS=args.ns_servers.split(',') 341 | 342 | # handle local messages to add TXT records 343 | threadMessage = threading.Thread(target=messageListener) 344 | threadMessage.start() 345 | 346 | # open the DNS server 347 | port = int(args.dns_port) 348 | upstream = args.dns_fallback 349 | resolver = Resolver(upstream) 350 | if args.log_level == 'debug': 351 | logmode = "+request,+reply,+truncated,+error" 352 | else: 353 | logmode = "-request,-reply,-truncated,+error" 354 | dnslogger = DNSLogger(log=logmode, prefix=False) 355 | udp_server = DNSServer(resolver, port=port, logger=dnslogger) 356 | tcp_server = DNSServer(resolver, port=port, tcp=True, logger=dnslogger) 357 | 358 | logger.critical('starting DNS server on %s/%s on port %d, upstream DNS server "%s"', confs.LOCAL_IPV4, confs.LOCAL_IPV6, port, upstream) 359 | udp_server.start_thread() 360 | tcp_server.start_thread() 361 | 362 | # open the HTTP server 363 | if args.http_port: 364 | logger.critical('Starting httpd...') 365 | threadHTTP = threading.Thread(target=httpserver.run, kwargs={"port": int(args.http_port), "index": args.http_index_file}) 366 | threadHTTP.start() 367 | 368 | try: 369 | while udp_server.isAlive(): 370 | sleep(1) 371 | except KeyboardInterrupt: 372 | pass 373 | 374 | if __name__ == '__main__': 375 | main() 376 | --------------------------------------------------------------------------------