├── .gitignore ├── LICENSE ├── README.md ├── acme-inwx.py ├── dns_inwx.sh ├── inwx.py └── inwxcredentials.py.skel /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | inwxcredentials.py 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016 Christian Blechert 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # Update 2019-05-04 3 | 4 | This piece of software is not obsolete since there is a official support for the inwx API in acme.sh. 5 | 6 | --- 7 | 8 | **[EXPERIMENTAL]** Create Lets Encrypt Certificates with 9 | [acme.sh](https://github.com/Neilpang/acme.sh) 10 | and [inwx.de](https://www.inwx.de/) [API](https://github.com/inwx/python2.7-client) via DNS Challenge Validation. 11 | 12 | ## Installation 13 | 14 | ``` 15 | cp inwxcredentials.py.skel inwxcredentials.py 16 | ln -s ~/letsencrypt-acme.sh-inwx/dns_inwx.sh ~/.acme.sh/dns_inwx.sh 17 | ``` 18 | 19 | - Edit your credentials in inwxcredentials.py 20 | - Edit absolute path to acme-inwx.py in dns_inwx.sh 21 | 22 | ## Use it 23 | 24 | ``` 25 | ./acme.sh --issue --dns dns_inwx -d some.awesome.example.com 26 | ``` 27 | 28 | ## Files 29 | 30 | * `inwx.py` API lib by inwx.de 31 | * `acme-inwx.py` acme.sh plugin 32 | * `dns_inwx.sh` wrapper for acme.sh 33 | * `inwxcredentials.py.skel` inwx Credentials 34 | 35 | ## API Code by inwx 36 | 37 | https://github.com/inwx/python2.7-client 38 | 39 | -------------------------------------------------------------------------------- /acme-inwx.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import pprint 4 | import re 5 | import sys 6 | 7 | from inwx import domrobot, prettyprint, getOTP 8 | from inwxcredentials import * 9 | 10 | def main(inwxcredentials, args): 11 | pp = pprint.PrettyPrinter(indent=4) 12 | targetdomain = args[1] 13 | challenge = args[2] if len(args)>2 else None 14 | 15 | # Remove TXT name 16 | if targetdomain.startswith('_acme-challenge.'): 17 | targetdomain = targetdomain[16:] 18 | 19 | print("Input:") 20 | pp.pprint([ targetdomain, challenge ]) 21 | 22 | # Create api connection and login 23 | inwx_conn = domrobot(inwxcredentials["url"], False) 24 | loginRet = inwx_conn.account.login({'lang': 'en', 'user': inwxcredentials["username"], 'pass': inwxcredentials["password"] }) 25 | 26 | # Perform OTP login if necessary 27 | if 'tfa' in loginRet['resData'] and loginRet['resData']['tfa'] == 'GOOGLE-AUTH': 28 | inwx_conn.account.unlock({'tan': getOTP(inwxcredentials["otpsecret"])}) 29 | 30 | print("Searching nameserver zone for "+targetdomain) 31 | 32 | # Extract second level domain from given domain 33 | sldmatch = re.search(ur"(^|\.)([^\.]+\.[^\.]+)$", targetdomain) 34 | 35 | if sldmatch is None: 36 | print("Could not extract second level domain"); 37 | return False 38 | 39 | # Build domain filter for api request 40 | apisearchpattern = "*"+sldmatch.group(2) 41 | print("API search pattern: "+apisearchpattern) 42 | 43 | # Fetch all domains from api 44 | domains = inwx_conn.nameserver.list({ 'domain': apisearchpattern }) 45 | 46 | # Search domain in nameserver 47 | matchdomain = None 48 | matchsublevel = None 49 | 50 | for domain in domains['resData']['domains']: 51 | sld = domain['domain'] 52 | try: 53 | rgx = re.compile(ur"^(?P.+?)?(^|\.)"+re.escape(sld)+ur"$", re.UNICODE) 54 | rgxmatch = rgx.search(targetdomain) 55 | if rgxmatch: 56 | matchdomain = sld 57 | matchsublevel = rgxmatch.group('sublevel') 58 | break 59 | except UnicodeEncodeError: 60 | print("TODO: Support unicode domains") 61 | 62 | # Check match 63 | if matchdomain is None: 64 | print("Nameserver not found") 65 | return False 66 | 67 | print("Nameserver match: "+matchdomain) 68 | 69 | # Prepare 70 | rname = "_acme-challenge" 71 | rtype = "TXT" 72 | rvalue = challenge 73 | 74 | # Append sublevel part if exist 75 | if matchsublevel is not None: 76 | rname = rname+"."+matchsublevel 77 | 78 | # Debug info 79 | print("Debug:") 80 | pp.pprint([ rname, rtype, rvalue ]) 81 | 82 | # Check TXT record exist 83 | existscheck = inwx_conn.nameserver.info({ 'domain': matchdomain, 'type': rtype, 'name': rname }) 84 | 85 | # Delete if exist 86 | try: 87 | record = existscheck["resData"]['record'][0] 88 | print("TXT record exists -> delete") 89 | pp.pprint(record) 90 | result = inwx_conn.nameserver.deleteRecord({ 'id': record['id'] }) 91 | pp.pprint(result) 92 | except KeyError: 93 | pass 94 | 95 | # Create if challenge given 96 | if rvalue is not None: 97 | print("Create new TXT record") 98 | result = inwx_conn.nameserver.createRecord({ 99 | 'domain': matchdomain, 100 | 'type': rtype, 101 | 'name': rname, 102 | 'content': rvalue, 103 | 'ttl': 300 104 | }) 105 | 106 | pp.pprint(result) 107 | 108 | return True 109 | 110 | 111 | # Main 112 | if __name__ == '__main__': 113 | if main(inwxcredentials, sys.argv) == True: 114 | sys.exit(0) 115 | else: 116 | sys.exit(1) 117 | 118 | # EOF 119 | 120 | -------------------------------------------------------------------------------- /dns_inwx.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | BASEDIR="/home/le/git-acme-inwx-python" 4 | 5 | dns_inwx_add() { 6 | fulldomain=$1 7 | txtvalue=$2 8 | $BASEDIR/acme-inwx.py "$fulldomain" "$txtvalue" 9 | } 10 | 11 | dns_inwx_rm() { 12 | fulldomain=$1 13 | $BASEDIR/acme-inwx.py "$fulldomain" 14 | } 15 | 16 | -------------------------------------------------------------------------------- /inwx.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | if sys.version_info.major == 3: 4 | import xmlrpc.client 5 | from xmlrpc.client import _Method 6 | import urllib.request, urllib.error, urllib.parse 7 | else: 8 | import xmlrpclib 9 | from xmlrpclib import _Method 10 | import urllib2 11 | 12 | import base64 13 | import struct 14 | import time 15 | import hmac 16 | import hashlib 17 | 18 | def getOTP(shared_secret): 19 | key = base64.b32decode(shared_secret, True) 20 | msg = struct.pack(">Q", int(time.time())//30) 21 | h = hmac.new(key, msg, hashlib.sha1).digest() 22 | if sys.version_info.major == 3: 23 | o = h[19] & 15 24 | else: 25 | o = ord(h[19]) & 15 26 | h = (struct.unpack(">I", h[o:o+4])[0] & 0x7fffffff) % 1000000 27 | return h 28 | 29 | class domrobot (): 30 | def __init__ (self, address, debug = False): 31 | self.url = address 32 | self.debug = debug 33 | self.cookie = None 34 | self.version = "1.0" 35 | 36 | def __getattr__(self, name): 37 | return _Method(self.__request, name) 38 | 39 | def __request (self, methodname, params): 40 | tuple_params = tuple([params[0]]) 41 | if sys.version_info.major == 3: 42 | requestContent = xmlrpc.client.dumps(tuple_params, methodname) 43 | else: 44 | requestContent = xmlrpclib.dumps(tuple_params, methodname) 45 | if(self.debug == True): 46 | print(("Anfrage: "+str(requestContent).replace("\n", ""))) 47 | headers = { 'User-Agent' : 'DomRobot/'+self.version+' Python-v2.7', 'Content-Type': 'text/xml','content-length': str(len(requestContent))} 48 | if(self.cookie!=None): 49 | headers['Cookie'] = self.cookie 50 | 51 | if sys.version_info.major == 3: 52 | req = urllib.request.Request(self.url, bytearray(requestContent, 'ascii'), headers) 53 | response = urllib.request.urlopen(req) 54 | else: 55 | req = urllib2.Request(self.url, bytearray(requestContent, 'ascii'), headers) 56 | response = urllib2.urlopen(req) 57 | 58 | responseContent = response.read() 59 | 60 | if sys.version_info.major == 3: 61 | cookies = response.getheader('Set-Cookie') 62 | else: 63 | cookies = response.info().getheader('Set-Cookie') 64 | 65 | if(self.debug == True): 66 | print(("Antwort: "+str(responseContent).replace("\n", ""))) 67 | if sys.version_info.major == 3: 68 | apiReturn = xmlrpc.client.loads(responseContent) 69 | else: 70 | apiReturn = xmlrpclib.loads(responseContent) 71 | apiReturn = apiReturn[0][0] 72 | if(apiReturn["code"]!=1000): 73 | raise NameError('There was a problem: %s (Error code %s)' % (apiReturn['msg'], apiReturn['code']), apiReturn) 74 | return False 75 | 76 | if(cookies!=None): 77 | if sys.version_info.major == 3: 78 | cookies = response.getheader('Set-Cookie') 79 | else: 80 | cookies = response.info().getheader('Set-Cookie') 81 | self.cookie = cookies 82 | if(self.debug == True): 83 | print(("Cookie:" + self.cookie)) 84 | return apiReturn 85 | 86 | class prettyprint (object): 87 | """ 88 | This object is just a collection of prettyprint helper functions for the output of the XML-API. 89 | """ 90 | 91 | @staticmethod 92 | def contacts(contacts): 93 | """ 94 | iterable contacts: The list of contacts to be printed. 95 | """ 96 | if("resData" in contacts): 97 | contacts = contacts['resData'] 98 | 99 | output = "\nCurrently you have %i contacts set up for your account at InterNetworX:\n\n" % len(contacts['contact']) 100 | for contact in contacts['contact']: 101 | output += "ID: %s\nType: %s\n%s\n%s\n%s %s\n%s\n%s\nTel: %s\n------\n" % (contact['id'], contact['type'], contact['name'], contact['street'], contact['pc'], contact['city'], contact['cc'], contact['email'], contact['voice']) 102 | return output 103 | 104 | @staticmethod 105 | def domains(domains): 106 | """ 107 | list domains: The list of domains to be pretty printed. 108 | """ 109 | if("resData" in domains): 110 | domains = domains['resData'] 111 | 112 | output = "\n%i domains:\n" % len(domains['domain']) 113 | for domain in domains['domain']: 114 | output += "Domain: %s (Status: %s)\n" % (domain['domain'], domain['status']) 115 | return output 116 | 117 | @staticmethod 118 | def nameserversets(nameserversets): 119 | """ 120 | list namerserversets: The list of nameserversets to be pretty printed. 121 | """ 122 | if("resData" in nameserversets): 123 | nameserversets = nameserversets['resData'] 124 | 125 | count, total = 0, len(nameserversets['nsset']) 126 | output = "\n%i nameserversets:\n" % total 127 | for nameserverset in nameserversets['nsset']: 128 | count += 1 129 | output += "%i of %i - ID: %i consisting of [%s]\n" % (count, total, nameserverset['id'], ", ".join(nameserverset['ns'])) 130 | return output 131 | 132 | @staticmethod 133 | def domain_log(logs): 134 | """ 135 | list logs: The list of nameserversets to be pretty printed. 136 | """ 137 | if("resData" in logs): 138 | logs = logs['resData'] 139 | 140 | count, total = 0, len(logs['domain']) 141 | output = "\n%i log entries:\n" % total 142 | for log in logs['domain']: 143 | count += 1 144 | output += "%i of %i - %s status: '%s' price: %.2f invoice: %s date: %s remote address: %s\n" % (count, total, log['domain'], log['status'], log['price'], log['invoice'], log['date'], log['remoteAddr']) 145 | output += " user text: '%s'\n" % log['userText'].replace("\n",'\n ') 146 | return output 147 | 148 | @staticmethod 149 | def domain_check(checks): 150 | """ 151 | list checks: The list of domain checks to be pretty printed. 152 | """ 153 | if("resData" in checks): 154 | checks = checks['resData'] 155 | 156 | count, total = 0, len(checks) 157 | output = "\n%i domain check(s):\n" % total 158 | for check in checks['domain']: 159 | count += 1 160 | output += "%s = %s" % (check['domain'], check['status']) 161 | return output 162 | -------------------------------------------------------------------------------- /inwxcredentials.py.skel: -------------------------------------------------------------------------------- 1 | 2 | inwxcredentials = { 3 | "url": "https://api.domrobot.com/xmlrpc/", 4 | "username": "johndoe", 5 | "password": "eodnhoj", 6 | "otpsecret": "" 7 | } 8 | 9 | --------------------------------------------------------------------------------