├── LICENSE ├── README.md ├── lancer ├── __init__.py ├── _cloudflare.py ├── _common.py ├── _gandi.py └── _impl.py └── pyproject.toml /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Glyph 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # This Project is Now Closed 2 | 3 | I wrote `lancer` at a time when: 4 | 5 | 1. `txacme` was still actively maintained, 6 | 2. it [worked with the production version of Let's 7 | Encrypt](https://github.com/twisted/txacme/issues/151), and 8 | 2. using certbot for offline challenges involved hand-manipulating DNS records 9 | and was really difficult. 10 | 11 | Today, none of these things are true any longer. If you want to provision 12 | certificates for your offline computers, you can use DNS plugins, like [this 13 | one for Gandi](https://github.com/obynio/certbot-plugin-gandi), [this one for 14 | Rackspace](https://github.com/komputerwiz/certbot-dns-rackspace), or [this one 15 | for route53](https://certbot-dns-route53.readthedocs.io/en/stable/). Getting a 16 | local certificate like the ones `lancer` used to issue is as simple as: 17 | 18 | ```bash 19 | # make a gandi.ini with your credentials 20 | 21 | mkdir -p ~/.certbot/config/live 22 | mkdir -p ~/.certbot/config/config 23 | mkdir -p ~/.certbot/config/work 24 | mkdir -p ~/.certbot/config/logs 25 | 26 | certbot \ 27 | --config-dir ~/.certbot/config/ \ 28 | --work-dir ~/.certbot/work/ \ 29 | --logs-dir ~/.certbot/logs/ \ 30 | certonly \ 31 | --domain "${GANDI_HOST}" \ 32 | --authenticator dns-gandi \ 33 | --dns-gandi-credentials ~/Secrets/Gandi/gandi.ini \ 34 | ; 35 | ``` 36 | 37 | Massaging this to work with `txsni` then involves just glomming the `pem` files 38 | together (at least until [`txsni` just does this 39 | directly](https://github.com/glyph/txsni/issues/31)), like so: 40 | 41 | ```bash 42 | cd ~/.certbot/config/live || exit 1; 43 | 44 | mkdir ~/.txsni/ 45 | 46 | for each in *; do 47 | if [ -d "${each}" ]; then 48 | ( 49 | cat "${each}/privkey.pem"; 50 | cat "${each}/fullchain.pem"; 51 | ) > ~/.txsni/"${each}".pem; 52 | fi; 53 | done; 54 | 55 | twist web --listen="txsni:$HOME/.txsni:tcp:8443" --path . 56 | ``` 57 | 58 | Since this project hasn't worked for quite some time, I will archive it; the 59 | original README is left for posterity below. 60 | 61 | ------ 62 | 63 | # LAN-Cer: Certificates For Your LAN 64 | 65 | `lancer` is a tool which will quickly and simply provision certificates for any 66 | number of hosts in a domain, using Let's Encrypt, assuming that you have an 67 | API-controlled DNS service. 68 | 69 | ## The Problem 70 | 71 | You have too many computers. Too many (all) of them have to talk to the 72 | Internet. And, as we all know, any computer on the internet needs a TLS 73 | certificate and the [lock 74 | icon](https://en.wikipedia.org/wiki/Padlock#Padlock_icon_symbolising_a_secure_web_transaction) 75 | that comes with it if you want to be able to talk to it. 76 | 77 | For example: 78 | 79 | 1. Maybe you need to test some web APIs that [don't 80 | work](https://sites.google.com/a/chromium.org/dev/Home/chromium-security/deprecating-powerful-features-on-insecure-origins) 81 | without HTTPS, so you need a development certificate for localhost. 82 | 2. Maybe you have an [OpenWRT](https://www.openwrt.org/) router and you need to 83 | administer it via its web interface; you don't want every compromised IoT 84 | device or bored teenager on your WiFi to be able to read your administrator 85 | password. 86 | 87 | ## The Bad Old Days 88 | 89 | Previously the way you'd address problems like this would be to: 90 | 91 | - ⚠️😡⚠️ use a garbage self-signed root and click through annoying warnings all 92 | the time 93 | - 🔒️🗑️🔒️ add a garbage self-signed root to your trust store 94 | - 🔥😱🔥 turn off certificate validation entirely in your software 95 | 96 | These are all bad in similar ways: they decrease your security and they require 97 | fiddly, machine-specific configuration that has to be repeated on every new 98 | machine that needs to talk to such endpoints. 99 | 100 | ## The Solution 101 | 102 | Let's Encrypt is 99% of the solution here. And, for public-facing internet 103 | services, it's almost trivially easy to use; many web servers provide built-in 104 | support for it. But you don't want to use production certificates for your 105 | main website on your development box: you want to put an entry in `/etc/hosts` 106 | under a dedicated test domain name, and you shouldn't have to figure out how to 107 | route inbound public traffic to a web server on that host name in order to 108 | respond to a challenge. 109 | 110 | Luckily, Let's Encrypt offers DNS-01 validation, so all you need to do is 111 | update a DNS record. Lancer uses this challenge. 112 | 113 | ## What You Need 114 | 115 | Your DNS needs to be hosted on a platform that supports `libcloud` (Rackspace 116 | DNS and CloudFlare are two that I have tested with), or Gandi's' V5 API which 117 | Lancer has specific support for. You will need an API key. 118 | 119 | ## How To Use It 120 | 121 | 1. `pip install lancer` 122 | 2. `mkdir certificates-for-mydomain.com` 123 | 3. Create empty files for the certificates you want to provision: `touch 124 | certificates-for-mydomain.com/myhost1.lan.mydomain.com.pem 125 | certificates-for-mydomain.com/myhost2.lan.mydomain.com.pem` . 126 | 4. `lancer certificates-for-mydomain.com` 127 | 128 | Upon first run, lancer will ask you 4 questions: 129 | 130 | 1. what driver do you want to use? this should be the libcloud driver name, or 131 | 'gandi' for the Gandi V5 API. 132 | 2. what is your username? 133 | 3. what is the DNS zone that you will be provisioning certificates under? 134 | (usually this is the [registrable part](https://publicsuffix.org/) of the 135 | domain name; if you want certificates for `lan.somecompany.com` then your 136 | zone is usually `somecompany.com`) 137 | 4. what is your API key? This will be prompted for and stored with 138 | [Secretly](https://github.com/glyph/secretly), which uses 139 | [Keyring](https://github.com/jaraco/keyring) to securely store secrets; this 140 | may mean that in certain unattended configurations you might need 141 | [keyrings.alt](https://github.com/jaraco/keyrings.alt) to store your API key 142 | in a configuration file rather than something like 143 | [Keychain](https://en.wikipedia.org/wiki/Keychain_(software)) or 144 | [GnomeKeyring](https://wiki.gnome.org/Projects/GnomeKeyring). 145 | 146 | It will store the answers to the first three questions in 147 | `certificates-for-mydomain.com/lancer.json` and the secrets depending upon your 148 | keyring configuration, so you shouldn't need to answer them again (although you 149 | may need to click through a security confirmation on subsequent attempts to 150 | allow access to your API key). 151 | 152 | Wait for `lancer` to log that it has successfully provisioned your 153 | certificates, and copy your now-no-longer-empty `.pem` files (which will each 154 | contain a certificate, chain certificates, and a private key) to wherever you 155 | need them on your LAN. You can kill it with `^C` or you can just leave it 156 | running in the background and let it auto-renew every 90 days or so. 157 | 158 | If you don't leave it running, to renew your certificates when they've expired, 159 | simply run `lancer certificates-for-mydomain.com` again, and any expired or 160 | soon-to-expire `.pem` files in that directory will be renewed and replaced. 161 | You can add new certificates at any time by creating new, empty 162 | `fully-qualified-domain-name.pem` files, 163 | -------------------------------------------------------------------------------- /lancer/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Provision TLS *Cer*tificates for your *LAN*, using the ACME DNS challenge. 3 | """ 4 | 5 | __version__ = '0.4.0' 6 | -------------------------------------------------------------------------------- /lancer/_cloudflare.py: -------------------------------------------------------------------------------- 1 | import attr 2 | import treq 3 | 4 | from txacme.interfaces import IResponder 5 | from txacme.challenges._libcloud import _validation 6 | from zope.interface import implementer 7 | from twisted.internet.defer import inlineCallbacks 8 | from twisted.internet.task import deferLater 9 | 10 | from hyperlink import parse 11 | 12 | from ._common import ConsistencyChecker 13 | 14 | base = parse("https://api.cloudflare.com/client/v4/") 15 | 16 | def global_reactor(): 17 | from twisted.internet import reactor 18 | return reactor 19 | 20 | @attr.s(hash=False) 21 | @implementer(IResponder) 22 | class CloudflareV4Responder(object): 23 | """ 24 | Cloudflare API V4 responder. 25 | """ 26 | _email = attr.ib() 27 | _api_key = attr.ib() 28 | _zone_name = attr.ib() 29 | _reactor = attr.ib(default=attr.Factory(global_reactor)) 30 | 31 | challenge_type = u'dns-01' 32 | 33 | def _headers(self): 34 | """ 35 | Auth headers that Cloudflare expects. 36 | """ 37 | return { 38 | b"X-Auth-Key": self._api_key, 39 | b"X-Auth-Email": self._email 40 | } 41 | 42 | 43 | @inlineCallbacks 44 | def start_responding(self, server_name, challenge, response): 45 | validation = _validation(response) 46 | full_name = challenge.validation_domain_name(server_name) 47 | # subdomain = _split_zone(full_name, self._zone_name) 48 | zones_list_url = str(base.child("zones").set("name", self._zone_name)) 49 | 50 | response = yield treq.get(zones_list_url, 51 | headers=self._headers()) 52 | data = yield response.json() 53 | assert len(data['result']) == 1 54 | zone_id = data['result'][0]['id'] 55 | records_base = base.child("zones").child(zone_id).child("dns_records") 56 | records_query_url = str(records_base 57 | .set("type", "TXT") 58 | .set("name", full_name)) 59 | response = yield treq.get(records_query_url, headers=self._headers()) 60 | data = yield response.json() 61 | records = data['result'] 62 | dns_record = { 63 | "type": "TXT", 64 | "ttl": 120, 65 | "name": full_name, 66 | "content": validation 67 | } 68 | if records: 69 | put_to = str(records_base.child(records[0]["id"])) 70 | response = yield treq.put( 71 | put_to, json=dns_record, 72 | headers=self._headers() 73 | ) 74 | else: 75 | post_to = str(records_base) 76 | response = yield treq.post(post_to, json=dns_record, headers=self._headers()) 77 | yield response.json() 78 | yield ConsistencyChecker.default(self._reactor).check(full_name, validation) 79 | 80 | 81 | @inlineCallbacks 82 | def stop_responding(self, server_name, challenge, response): 83 | # Ignore stop_responding right now. 84 | yield deferLater(self._reactor, 1.0, lambda: None) 85 | -------------------------------------------------------------------------------- /lancer/_common.py: -------------------------------------------------------------------------------- 1 | 2 | import attr 3 | 4 | from twisted.names.client import Resolver 5 | from twisted.logger import Logger 6 | from twisted.internet.defer import inlineCallbacks, gatherResults, returnValue 7 | from twisted.internet.task import deferLater 8 | 9 | log = Logger("lancer.consistency") 10 | 11 | INTERQUERY_DELAY = 5.0 12 | 13 | 14 | @attr.s 15 | class ConsistencyChecker(object): 16 | """ 17 | Check the consistency of DNS resolution results. 18 | """ 19 | 20 | _resolvers = attr.ib() 21 | _reactor = attr.ib() 22 | 23 | @classmethod 24 | def default(cls, reactor): 25 | """ 26 | Create a consistency checker with resolvers from Google, Cloudflare, 27 | OpenDNS, and Level3. 28 | """ 29 | return cls( 30 | [ 31 | Resolver(servers=[(addr, 53)], reactor=reactor) 32 | for addr in [ 33 | # Google 34 | "8.8.8.8", 35 | "8.8.4.4", 36 | # Level3 37 | "4.2.2.2", 38 | "4.2.2.1", 39 | # OpenDNS 40 | "208.67.222.222", 41 | "208.67.220.220", 42 | # Cloudflare 43 | "1.1.1.1", 44 | "1.0.0.1", 45 | ] 46 | ], 47 | reactor, 48 | ) 49 | 50 | @inlineCallbacks 51 | def check(self, name, content): 52 | """ 53 | Check DNS consistency between a challenge TXT record name and the 54 | observable response in the DNS. 55 | """ 56 | while True: 57 | gathered = yield gatherResults( 58 | [ 59 | resolver.lookupText(name).addCallbacks( 60 | lambda response: response[0][0].payload.data[0].decode("ascii"), 61 | lambda failure, resolver=resolver: f"", 62 | ) 63 | for resolver in self._resolvers 64 | ] 65 | ) 66 | if gathered and all((each == content) for each in gathered): 67 | log.info( 68 | "all resolvers confirm {name} is {content!r}", 69 | name=name, 70 | content=content, 71 | ) 72 | yield deferLater(self._reactor, INTERQUERY_DELAY, lambda: None) 73 | returnValue(True) 74 | else: 75 | dissenting = [each for each in gathered if each != content] 76 | print(f"dissenters for {name}", dissenting) 77 | log.warn( 78 | "expected {content} for {name}: dissenting responses {dissenting}", 79 | dissenting=dissenting, 80 | content=content, 81 | name=name, 82 | ) 83 | yield deferLater(self._reactor, INTERQUERY_DELAY, lambda: None) 84 | -------------------------------------------------------------------------------- /lancer/_gandi.py: -------------------------------------------------------------------------------- 1 | import attr 2 | import treq 3 | 4 | from txacme.interfaces import IResponder 5 | from txacme.challenges._libcloud import _validation, _split_zone 6 | from zope.interface import implementer 7 | from twisted.internet.defer import inlineCallbacks 8 | from twisted.internet.task import deferLater 9 | 10 | @attr.s(hash=False) 11 | @implementer(IResponder) 12 | class GandiV5Responder(object): 13 | """ 14 | Gandi V5 API responder. 15 | """ 16 | 17 | _api_key = attr.ib() 18 | _zone_name = attr.ib() 19 | _settle_delay = attr.ib(default=60.0) 20 | 21 | challenge_type = u'dns-01' 22 | 23 | def _headers(self): 24 | return { 25 | # b"Content-Type": [b"application/json"], 26 | b"X-API-Key": [self._api_key.encode("ascii")] 27 | } 28 | 29 | @inlineCallbacks 30 | def start_responding(self, server_name, challenge, response): 31 | from twisted.internet import reactor 32 | validation = _validation(response) 33 | full_name = challenge.validation_domain_name(server_name) 34 | subdomain = _split_zone(full_name, self._zone_name) 35 | if subdomain == '': 36 | subdomain = '@' 37 | 38 | url = ( 39 | 'https://dns.api.gandi.net/api/v5/domains/' 40 | '{zone}/records' 41 | ).format( 42 | zone=self._zone_name, subdomain=subdomain, type='TXT', 43 | ) 44 | body = {"rrset_name": subdomain, 45 | "rrset_type": "TXT", 46 | "rrset_ttl": 300, 47 | "rrset_values": [ 48 | validation 49 | ]} 50 | print(body) 51 | response = yield treq.post(url, json=body, headers=self._headers()) 52 | print((yield treq.json_content(response))) 53 | yield deferLater(reactor, self._settle_delay, lambda: None) 54 | print("start settled") 55 | 56 | @inlineCallbacks 57 | def stop_responding(self, server_name, challenge, response): 58 | from twisted.internet import reactor 59 | full_name = challenge.validation_domain_name(server_name) 60 | subdomain = _split_zone(full_name, self._zone_name) 61 | url = ( 62 | 'https://dns.api.gandi.net/api/v5/domains/' 63 | '{zone}/records/{subdomain}/{type}' 64 | ).format( 65 | zone=self._zone_name, subdomain=subdomain, type='TXT', 66 | ) 67 | if subdomain == '': 68 | subdomain = '@' 69 | response = yield treq.delete(url, headers=self._headers()) 70 | print((yield response.text())) 71 | yield deferLater(reactor, self._settle_delay, lambda: None) 72 | print("stop settled") 73 | -------------------------------------------------------------------------------- /lancer/_impl.py: -------------------------------------------------------------------------------- 1 | 2 | import sys, json, six 3 | 4 | from secretly import secretly 5 | from functools import partial 6 | 7 | from twisted.internet.defer import inlineCallbacks 8 | from twisted.internet.task import react 9 | from twisted.python.filepath import FilePath 10 | from twisted.python.components import proxyForInterface 11 | from twisted.logger import globalLogBeginner, textFileLogObserver 12 | 13 | from cryptography.hazmat.primitives import serialization 14 | from cryptography.hazmat.backends import default_backend 15 | 16 | from josepy.jwk import JWKRSA 17 | from josepy.jwa import RS256 18 | 19 | from txacme.service import AcmeIssuingService 20 | from txacme.store import DirectoryStore 21 | from txacme.client import Client 22 | from txacme.challenges._libcloud import _validation 23 | from txacme.interfaces import IResponder 24 | from txacme.urls import LETSENCRYPT_DIRECTORY, LETSENCRYPT_STAGING_DIRECTORY 25 | from txacme.util import generate_private_key 26 | from txacme.challenges import LibcloudDNSResponder 27 | 28 | from ._cloudflare import CloudflareV4Responder 29 | from ._gandi import GandiV5Responder 30 | from ._common import ConsistencyChecker 31 | 32 | def maybe_key(pem_path): 33 | acme_key_file = pem_path.child(u'client.key') 34 | if acme_key_file.exists(): 35 | key = serialization.load_pem_private_key( 36 | acme_key_file.getContent(), 37 | password=None, 38 | backend=default_backend() 39 | ) 40 | else: 41 | key = generate_private_key(u'rsa') 42 | acme_key_file.setContent( 43 | key.private_bytes( 44 | encoding=serialization.Encoding.PEM, 45 | format=serialization.PrivateFormat.TraditionalOpenSSL, 46 | encryption_algorithm=serialization.NoEncryption() 47 | ) 48 | ) 49 | acme_key = JWKRSA(key=key) 50 | return acme_key 51 | 52 | 53 | 54 | class WaitingResponder(proxyForInterface(IResponder, "_original")): 55 | 56 | def __init__(self, original, reactor): 57 | self._original = original 58 | self._reactor = reactor 59 | 60 | 61 | @inlineCallbacks 62 | def start_responding(self, server_name, challenge, response): 63 | validation = _validation(response) 64 | domain_name = challenge.validation_domain_name(server_name) 65 | yield super(WaitingResponder, self).start_responding(server_name, challenge, response) 66 | yield ConsistencyChecker.default(self._reactor).check(domain_name, validation) 67 | 68 | 69 | 70 | def main(reactor): 71 | acme_path = FilePath(sys.argv[1]).asTextMode() 72 | myconfig = acme_path.child("lancer.json") 73 | if myconfig.exists(): 74 | cfg = json.loads(myconfig.getContent().decode("utf-8")) 75 | driver_name = cfg['driver_name'] 76 | zone_name = cfg['zone_name'] 77 | user_name = cfg['user_name'] 78 | staging = cfg.get('staging', False) 79 | else: 80 | driver_name = six.moves.input("driver ('rackspace' or 'cloudflare')? ") 81 | user_name = six.moves.input("user? ") 82 | zone_name = six.moves.input("zone? ") 83 | staging = False 84 | myconfig.setContent(json.dumps({ 85 | "driver_name": driver_name, 86 | "user_name": user_name, 87 | "zone_name": zone_name, 88 | "staging": staging 89 | }).encode("utf-8")) 90 | 91 | globalLogBeginner.beginLoggingTo([textFileLogObserver(sys.stdout)]) 92 | def action(secret): 93 | password = secret 94 | if driver_name == 'gandi': 95 | responders = [ 96 | WaitingResponder( 97 | GandiV5Responder(api_key=password, zone_name=zone_name), 98 | reactor 99 | ) 100 | ] 101 | elif driver_name == 'cloudflare': 102 | responders = [ 103 | CloudflareV4Responder(email=user_name, api_key=password, 104 | zone_name=zone_name) 105 | ] 106 | else: 107 | responders = [ 108 | WaitingResponder( 109 | LibcloudDNSResponder.create( 110 | reactor, driver_name, user_name, password, zone_name 111 | ), 112 | reactor 113 | ) 114 | ] 115 | acme_key = maybe_key(acme_path) 116 | cert_store = DirectoryStore(acme_path) 117 | if staging: 118 | le_url = LETSENCRYPT_STAGING_DIRECTORY 119 | else: 120 | le_url = LETSENCRYPT_DIRECTORY 121 | client_creator = partial(Client.from_url, reactor=reactor, 122 | url=le_url, 123 | key=acme_key, alg=RS256) 124 | clock = reactor 125 | service = AcmeIssuingService(cert_store, client_creator, clock, 126 | responders) 127 | service._registered = False 128 | return service._check_certs() 129 | return secretly(reactor, action=action, 130 | system='libcloud/' + driver_name, 131 | username=user_name) 132 | 133 | 134 | def script(): 135 | react(main) 136 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["flit"] 3 | build-backend = "flit.buildapi" 4 | 5 | [tool.flit.metadata] 6 | module = "lancer" 7 | author = "Glyph" 8 | author-email = "glyph@twistedmatrix.com" 9 | home-page = "https://github.com/glyph/lancer" 10 | classifiers = ["License :: OSI Approved :: MIT License"] 11 | requires = ["attrs>=18.1.0", "secretly>=0.2", "treq>=18.6.0", "txacme[libcloud]>=0.9.2", "twisted[tls]>=18.7.0"] 12 | description-file = "README.md" 13 | 14 | 15 | [tool.flit.scripts] 16 | lancer = "lancer._impl:script" 17 | --------------------------------------------------------------------------------