├── 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 |
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 |
--------------------------------------------------------------------------------