genericDNSPowerDNS.py

Copy the following block to a file and save it as genericDNSPowerDNS.py. Modify the variables as needed.

  1#!/usr/bin/env python
  2'''
  3Version 1.9 - needs M&M Suite version >= 9.3.x
  4
  5A python script that implements an interface to PowerDNS 3.1, 3.3.1, 3.4.1 and 4.3.2
  6with MySQL backend as a generic DNS server in mmSuite.
  7
  8Needs MySQLdb to connect to the PDNS database
  9
 101. Modify the script so that it fits to your PowerDNS setup or get in contact
 11with the M&M support.
 12
 132. Add reference to the script in preferences.cfg for DNS remote. Example:
 14      <GenericDNSScript value="python /var/mmsuite/dns_server_controller/scripts/genericDNSPowerDNS.py" />
 15
 163. Start DNS remote, use MMMC to log in to central and add a generic DNS server refering to
 17the DNS remote.
 18
 194. Enjoy
 20
 21'''
 22from __future__ import print_function # Prepare for python 3.1
 23
 24import MySQLdb
 25import sys
 26import subprocess
 27import json
 28import logging
 29import logging.handlers
 30import re
 31
 32# new zones are created as NATIVE
 33# please change to MASTER in case you have configured your PDNS running as MASTER
 34# (check you pdns.conf file)
 35mmPDNSNewZoneType = "NATIVE"
 36
 37# PDNS with DNSSEC enabled
 38# please set to True, which will also set the "auth" column
 39mmPDNSDnsSec = False
 40
 41# Error constants
 42mmErr_zoneUnableToAddRecord     = 0x1212
 43mmErr_zoneUnableToDeleteRecord  = 0x1213
 44mmErr_zoneUnableToModifyRecord  = 0x1214
 45mmErr_zoneRecordAlreadyExist    = 0x1215
 46mmErr_zoneUnableToLockZoneFile  = 0x1216
 47
 48mmErr_zoneSOAConstraint                 = 0x1217
 49mmErr_zoneCNAMEConstraint               = 0x1218
 50mmErr_zoneOutOfZoneRecord               = 0x1219
 51mmErr_zoneApexConstraint                = 0x121A
 52mmErr_zoneRecordSyntax                  = 0x121B
 53
 54# Configure logging
 55LOG_FILENAME = '/tmp/mmGenericDNSPowerDNS.log'
 56
 57# Set up a specific logger with error output level
 58theLogger = logging.getLogger('men_and_mice_genericdns_powerdns_logger')
 59theLogger.setLevel(logging.INFO)
 60
 61# Add the log message handler to the logger 10 MB log file 5 times
 62handler = logging.handlers.RotatingFileHandler(LOG_FILENAME, maxBytes=1024*1024*10, backupCount=5)
 63
 64# create formatter
 65formatter = logging.Formatter("%(asctime)s - %(message)s")
 66handler.setFormatter(formatter)
 67
 68theLogger.addHandler(handler)
 69
 70#################################################
 71# Some helping hands
 72#
 73
 74# convert BIND scaled values to seconds, e.g. 1h => 3600
 75def scaledToSeconds(sttl):
 76    scaledvals = {"s":1,"m":60,"h":3600,"d":86400,"w":604800}
 77    regex = re.compile('^(?P<value>[0-9]+)(?P<unit>[smhdw])')
 78    seconds = 0
 79    try:
 80        if sttl == "NULL":
 81            return sttl
 82        else:
 83            return int(sttl) # first simply try to convert to int
 84    except:
 85        lsttl = sttl.lower()
 86        while lsttl:
 87            match = regex.match(lsttl)
 88            if match:
 89                value, unit = int(match.group("value")), match.group("unit")
 90                if int(value) and unit in scaledvals:
 91                    seconds += value * scaledvals[unit]
 92                    lsttl = lsttl[match.end():]
 93                else:
 94                    raise Exception("Can't convert TTL '%s' from scaled value to seconds! " % (sttl))
 95        return seconds
 96
 97# removes the trailing "." if the name ends with the fully qualified zone name
 98def deQualify(FQZN,FQDN):
 99    zone = FQZN.lower()
100    zonename = zone[:-1]
101    name = FQDN.lower()
102    if not name.endswith("."):
103        if not name.endswith(zonename):
104            if name != "":
105                return FQDN + "." + zonename # append zone name as BIND does mit without trailing dot.
106            else:
107                return zonename # just return the zone name
108        return FQDN # already non FQ
109    return FQDN[:-1]
110
111# adds the trailing . if the name ends with the zone name
112def qualify(ZN,DN,type,is_data=False):
113    if is_data and (type == "A" or type == "AAAA" or type == "TXT"):
114        return DN
115    zone = ZN.lower()
116    name = DN.lower()
117    if name.endswith("."):
118        return DN  # already FQDN
119    elif name.endswith(zone):
120        return DN+"." # make FQDN
121    adddot = type == "CNAME" or type == "NS" or type == "MX" or type == "SRV" or type == "PTR" or type == "NAPTR"
122    if adddot:
123        return DN + "."
124    return DN # some other type like TXT
125
126# returns the DB connection
127def getConnection():
128    connection = MySQLdb.connect(host="localhost",
129                                 user="powerdnstest",
130                                 passwd="abc123",
131                                 db="powerdnstest")
132    return connection
133
134# just adds double quotes at the begin and end of a string
135def wrapInQuotes(input):
136    return "\"" + input + "\""
137
138# converts a M&M record structure to PDNS
139def recToPDNS(zone, zoneFQ, record):
140    ttl = str(record['ttl']) if record['ttl'] != "" else "NULL"
141    ttl = scaledToSeconds(ttl)
142    record['ttl'] =  ttl
143    prio = "NULL"
144    if record['type'] == "CAA":
145        split = record['data'].split("\t")
146        if '"' in split[2]:
147            split[2] = split[2].replace('"', '')
148        split[2] = '"%s"' % (split[2])
149        record['data'] = " ".join(split)
150    elif record['type'] == "NAPTR":
151        split = record['data'].split("\t")
152        # now wrap the fields Flags = 2, Service = 3 and Regular Expression = 4 in double quotes
153        for idx in range(2,5):
154            split[idx] = wrapInQuotes(split[idx])
155        record['data'] = " ".join(split)
156        record['data'] = deQualify(zoneFQ, record['data'])
157    elif record['type'] == "MX" or record['type'] == "SRV":
158        split = record['data'].split("\t")
159        split[len(split)-1] = deQualify(zoneFQ, split[len(split)-1])
160        prio = str(split[0]) # extract prio for SRV and MX
161        del split[0] # remove the prio
162        record['data'] = " ".join(split) # and join space separated (if there is something to join)
163    elif record['type'] != "TXT" and record['type'] != "SPF":
164        if record['type'] == "CNAME" or record['type'] == "PTR" or record['type'] == "NS":
165            record['data'] = deQualify(zoneFQ, record['data'])
166        record['data'] = record['data'].replace("\t"," ")
167
168    if record['name'] == "":
169        record['name'] = zone
170    record['name'] = deQualify(zoneFQ,record['name'])
171
172    return [record,prio]
173
174# adds a M&M DNS record into DB
175def addRecord(cur, id, zone, zoneFQ, record):
176    result = recToPDNS(zone, zoneFQ, record)
177    record =  result[0]
178    prio = result[1]
179    if mmPDNSDnsSec:
180        cur.execute("insert into records(domain_id,name,ttl,type,content,prio,auth) values ('%s','%s',%s,'%s','%s',%s,1);" % (id,str(record['name']),str(record['ttl']),str(record['type']),str(record['data']),str(prio)))
181    else:
182        cur.execute("insert into records(domain_id,name,ttl,type,content,prio) values ('%s','%s',%s,'%s','%s',%s);" % (id,str(record['name']),str(record['ttl']),str(record['type']),str(record['data']),str(prio)))
183
184# returns the DNS record ID from the PowerDNS DB
185def getRecord(cur, id, zone, zoneFQ,  record):
186    result = recToPDNS(zone, zoneFQ, record)
187    record = result[0]
188    prio = result[1]
189    # PDNS wants the zone name instead of an empty name as e.g. BIND accepts
190    if record['name'] == "":
191        record['name'] = zone
192    selstr = "select id from records where domain_id=%s and name='%s' and content='%s' and type='%s' " % (id,str(record['name']),str(record['data']),str(record['type']))
193    if str(prio) == "NULL":
194        selstr += "and (prio is NULL or prio ='0');"
195    else:
196        selstr += "and prio=%s;" % (prio)
197
198    cur.execute(selstr)
199    row  = cur.fetchone()
200    if row:
201        return str(row[0])
202    # else return None
203
204# deletes a single record from the PowerDNS DB
205def delRecord(cur, id, zone, zoneFQ, record):
206    recid = getRecord(cur, id, zone, zoneFQ, record)
207    if recid:
208        cur.execute("delete from records where id=%s and domain_id=%s;" % (recid,id))
209
210# updates a record in the PowerDNS DB
211def modRecord(cur, id, zone, zoneFQ, recBefore, recAfter):
212    recid = getRecord(cur, id, zone, zoneFQ,  recBefore)
213    result = recToPDNS(zone, zoneFQ, recAfter)
214    record = result[0]
215    prio = result[1]
216    cur.execute("update records set name='%s',ttl=%s,content='%s',prio=%s where id=%s and type='%s';" % (record['name'],record['ttl'],record['data'],prio,recid,record['type']) )
217
218# special handling of SOA modifications
219def modSOARecord(serial, recDataAfter):
220    rdataarray =  str(recDataAfter).split("\t")
221    # check if the new serial is old-1
222    if int(rdataarray[2]) == int(serial)-1:
223        rdataarray[2] = str(serial) # yes, then the serial was not modified manually and we use the computed new serial value
224    return [rdataarray[2]," ".join(rdataarray)]
225
226#################################################
227#
228# mmSuite responses
229#
230
231# Return server info
232# please edit path to the pdns_server binary if necessary
233def doGetServerInfo():
234    p = subprocess.Popen(['/usr/sbin/pdns_server','--version'], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
235    out, err = p.communicate()
236    res = re.search("(\d+\.\d+[\.\d+]*)", str(err))
237    if res:
238        return {'type': 'Unknown'}
239        # return {'type': "PowerDNS Version " + str(res.group(0))}
240    return {'type': "Unknown" }
241
242# Return information about the status of the DNS service itself
243# possible return values are:
244#       "undefined" - we have no idea about the service
245#       "running" - the service is up and running
246#       "stopped" - the service is stopped
247#       "exited" - the service has exited
248#       "fatal" - the serivce has entered a fatal state
249#
250def doGetServiceStatus():
251    # For now we just try to connect and if we don't succeed
252    # we report service stopped (though more likely it's the
253    # connection that is broken)
254    try:
255        con = getConnection()
256        con.close()
257        return { 'serviceStatus': 'running' }
258    except:
259        return { 'serviceStatus': 'stopped' }
260
261# Return all views available on the DNS server (no views in PowerDNS)
262def doGetViews():
263    return { 'views': [''] }
264
265# Returns all zones in all views
266def doGetZones():
267    con = getConnection()
268    cur = con.cursor()
269    rows = cur.execute("select name, notified_serial, type from domains where type like('MASTER') or type like('NATIVE') or type like('SLAVE');")
270    zones = []
271    if rows > 0:
272        for row in cur.fetchall():
273            if str(row[2]) != "SLAVE":
274                zones.append({'view':'','name': str(row[0])+".",'type': 'Master','dynamic': False,'serial': str(row[1])})
275            else:
276                zones.append({'view':'','name': str(row[0])+".",'type': 'Slave','dynamic': False,'serial': str(row[1])})
277
278    cur.close()
279    con.close()
280    return {'zones': zones}
281
282# Return information for a specific zone- it's type and current serial
283def doGetZone():
284    # text = '{ "method": "GetZone", "params": {"view": "", "name": "zone1.com."}}'
285    text = sys.stdin.read()
286    input = json.loads(text)
287    viewName= input['params']['view']
288    zoneName= input['params']['name']
289    zoneName = zoneName[:-1] # remove trailing dot
290    con = getConnection()
291    cur = con.cursor()
292    numrows = cur.execute("select name,notified_serial from domains where (type like('MASTER') or type like('NATIVE')) and name='"+zoneName+"';")
293    if numrows != 1:
294        if con:
295            cur.close()
296            con.close()
297        raise Exception("Zone '%s' not found!" % (zoneName))
298
299    row = cur.fetchone()
300    zone = { 'zone': {'view': '', 'name': str(row[0])+".", 'type': 'Master', 'dynamic': False, 'serial': str(row[1])} }
301
302    cur.close()
303    con.close()
304    return zone
305
306# Return the content of a zone
307def doGetRecords():
308    # text = '{ "method": "GetRecords", "params": {"view": "", "name": "zone1.com."}}'
309    text = sys.stdin.read()
310    input = json.loads(text)
311    viewName= input['params']['view']
312    zoneNameFQ= input['params']['name']
313    zoneName = zoneNameFQ[:-1]
314
315    con = getConnection()
316    cur = con.cursor()
317    numrows = cur.execute("select name,ttl,type,content,prio from records where domain_id = (select id from domains where (type like('MASTER') or type like('NATIVE') or type like('SLAVE')) and name='"+zoneName+"');")
318    if numrows == 0:
319        if con:
320            cur.close()
321            con.close()
322        raise Exception("Error retrieving records of zone '%s'" %(zoneName))
323    records = []
324    for record in cur.fetchall():
325        type = str(record[2]).upper()
326        name = qualify(zoneName,str(record[0]),type)
327        ttl = str(record[1])
328        if ttl == "None" or ttl == "":
329            ttl = ""
330
331        content = qualify(zoneName,str(record[3]),type,True)
332        # MX and SRV store the priority in the separate prio column (index 4) see select statement
333        if type == "MX" or type == "SRV":
334            content = str(record[4]) + "\t" + content
335        elif type == "NAPTR":
336            split = content.split(" ")
337            split[2] = split[2].strip("\"")
338            split[3] = split[3].strip("\"")
339            split[4] = split[4].strip("\"")
340            content = " ".join(split)
341        # all other parameters are space separated, but we exclude TXT and SPF
342        if " "  in content and type != "TXT" and type != "SPF":
343            content = content.replace(" ", "\t")
344
345        records.append({'name':name, 'ttl':ttl, 'type':type, 'data':content})
346        #theLogger.info("name:%s type:%s data: %s" %(name,type,content))
347    theLogger.info("Zone: '%s' number of records retrieved: %s" % (zoneName,len(records)) )
348    return { 'dnsRecords': records }
349
350
351# Create a new zone
352def doCreateZone():
353    # text = '{ "method": "CreateZone", "params": {"view": "", "name": "zone1.com.", "type": "Master", "dynamic": "0", "masters": [], "dnsRecords":[]}}'
354    text = sys.stdin.read()
355    input = json.loads(text)
356    theLogger.info(json.dumps(input))
357    viewName = input['params']['view']
358    zoneNameFQ = input['params']['name']
359    zoneName = zoneNameFQ[:-1]
360    zoneType = input['params']['type']
361    records  = input['params']['dnsRecords']
362    if zoneType == "Slave":
363        masters =  input['params']['masters'][0]
364
365    if not (zoneType == "Master" or zoneType == "Slave"):
366        raise Exception("Can't create zone '%s': Only zone type Master supported!" % (zoneName))
367    con = getConnection()
368    cur = con.cursor()
369    nrows = cur.execute("select id from domains where (type like('MASTER') or type like('NATIVE') or type like('SLAVE')) and name='"+zoneName+"' limit 1;")
370    row = cur.fetchone()
371    if row:
372        raise Exception("Zone '%s' already exists!" % zoneName)
373
374    try:
375        if zoneType == "Slave":
376            cur.execute("insert into domains (name,type,master) values ('%s','SLAVE','%s');" % (zoneName, masters))
377        else:
378            cur.execute("insert into domains (name,type) values ('%s','%s');" % (zoneName, mmPDNSNewZoneType))
379            cur.execute("select id from domains where (type like('MASTER') or type like('NATIVE')) and name='"+zoneName+"' limit 1;")
380            row  = cur.fetchone()
381            id = str(row[0])
382
383            for record in records:
384                result = recToPDNS(zoneName, zoneNameFQ, record)
385                record = result[0]
386                prio = result[1]
387                ttl =  record['ttl']
388                if mmPDNSDnsSec:
389                    cur.execute("insert into records(domain_id,name,ttl,type,content,prio,auth) values ('%s','%s',%s,'%s','%s',%s,1);" % (id,str(record['name']),ttl,str(record['type']),str(record['data']),str(prio)))
390                else:
391                    cur.execute("insert into records(domain_id,name,ttl,type,content,prio) values ('%s','%s',%s,'%s','%s',%s);" % (id,str(record['name']),ttl,str(record['type']),str(record['data']),str(prio)))
392
393        con.commit()
394    except MySQLdb.Error, e:
395        if con:
396            con.rollback()
397            cur.close()
398            con.close()
399            error =  "zone: '%s' creation failed. [Error %d: %s]" % (zoneName,e.args[0],e.args[1])
400            raise Exception(error)
401    cur.close()
402    con.close()
403    return {}
404
405
406# Delete a specific zone
407def doDeleteZone():
408    # text = '{ "method": "DeleteZone", "params": {"view": "", "name": "zone1.com."}}'
409    text = sys.stdin.read()
410    input = json.loads(text)
411    viewName= input['params']['view']
412    zoneName= input['params']['name']
413    zoneName = zoneName[:-1]
414    con = getConnection()
415    cur = con.cursor()
416    nrows = cur.execute("select id from domains where (type like('MASTER') or type like('NATIVE') or type like('SLAVE')) and name='"+zoneName+"' limit 1;")
417    row = cur.fetchone()
418    if row == None:
419        raise Exception('zone: "' + zoneName + '" does not exist.')
420    try:
421        cur.execute("delete from records where domain_id='" + str(row[0]) + "';")
422        cur.execute("delete from domains where id='" + str(row[0]) + "';")
423        con.commit()
424    except MySQLdb.Error, e:
425        if con:
426            con.rollback()
427            error =  "zone: '%s'deletion failed. [Error %d: %s]" % (zoneName,e.args[0],e.args[1])
428            raise Exception(error)
429    finally:
430        if con:
431            cur.close()
432            con.close()
433        return {}
434
435# Update a zone - not finished yet
436def doUpdateZone():
437    #text = '''{ "method": "UpdateZone", "params": {"view": "", "name": "zone1.com.", "replaceZone": "0", "dnsRecordChanges":[
438    #       {"type": "ModifyDNSRecord", "changeIndex": "23"
439    #               , "dnsRecordBefore":    {"name":"newrec2", "ttl": "", "type": "A",      "data": "127.151.171.23", "comment":"a comment" }
440    #               , "dnsRecordAfter":     {"name":"newrec2",      "ttl": "", "type": "A",         "data": "127.151.171.24"}
441    #       }
442    #]}}'''
443
444    text = sys.stdin.read()
445    input = json.loads(text)
446    viewName= input['params']['view']
447    zoneNameFQ= input['params']['name']
448    zoneName = zoneNameFQ[:-1]
449    failedUpdates= []
450    newSerial = '1234'
451
452    con = getConnection()
453    cur = con.cursor()
454    cur.execute("select id from domains where (type like('MASTER') or type like('NATIVE')) and name='"+zoneName+"' limit 1;")
455    row  = cur.fetchone()
456    id = str(row[0])
457
458    # get current serial from zone SOA
459    cur.execute("select content from records where domain_id="+id+" and type='SOA';")
460    rdataarray = str(cur.fetchone()[0]).split(" ")
461    newSerial =  str(int(rdataarray[2])+1)
462    rdataarray[2] = newSerial
463    rdata = " ".join(rdataarray)
464    kTypeToErroMap = {'AddDNSRecord': mmErr_zoneUnableToAddRecord, 'ModifyDNSRecord': mmErr_zoneUnableToModifyRecord, 'RemoveDNSRecord': mmErr_zoneUnableToDeleteRecord}
465    changedRecords = 0
466    for dnsRecordChange in input['params']['dnsRecordChanges']:
467        try:
468            if dnsRecordChange['type'] == 'AddDNSRecord':
469                theLogger.info("AddDNSRecord")
470                addRecord(cur,id,zoneName,zoneNameFQ,dnsRecordChange['dnsRecordAfter'])
471            elif dnsRecordChange['type'] == 'ModifyDNSRecord':
472                theLogger.info("ModifyDNSRecord")
473                if dnsRecordChange['dnsRecordAfter']['type'] == "SOA":
474                   result = modSOARecord(newSerial, dnsRecordChange['dnsRecordAfter']['data'])
475                   newSerial = result[0]
476                   rdata = result[1]
477                   theLogger.info("Special case SOA record. New rdata %s" % (rdata))
478                else:
479                   modRecord(cur, id, zoneName, zoneNameFQ, dnsRecordChange['dnsRecordBefore'], dnsRecordChange['dnsRecordAfter'])
480            elif dnsRecordChange['type'] == 'RemoveDNSRecord':
481                theLogger.info("RemoveDNSRecord")
482                delRecord(cur,id,zoneName,zoneNameFQ,dnsRecordChange['dnsRecordBefore'])
483            # increase the number of successful updates
484            changedRecords += 1
485        except MySQLdb.Error, e:
486            failedUpdates.append({'changeIndex': dnsRecordChange['changeIndex'], 'errorValue': kTypeToErroMap[dnsRecordChange['type']], 'errorMessage': e.message})
487
488    if changedRecords > 0:
489        # after change we increase the serial ID.
490        cur.execute("update records set content='"+rdata+"' where domain_id="+id+" and type='SOA';")
491        con.commit()
492        cur.close()
493        con.close()
494        return { 'serial': newSerial, 'failedUpdates': failedUpdates }
495
496    if con:
497        con.rollback()
498        cur.close()
499        con.close()
500        error =  "Update of zone: '%s' failed. [%]" % (zoneName,str(failedUpdates))
501        raise Exception(error)
502
503if __name__ == '__main__':
504    result = dict()
505    try:
506            if (len(sys.argv) <= 1):
507                raise Exception('missing argument')
508            theLogger.info(sys.argv[1])
509            if   (sys.argv[1] == 'GetViews'):
510                result['result']= doGetViews()
511            elif (sys.argv[1] == 'GetServerInfo'):
512                result['result']= doGetServerInfo()
513            elif (sys.argv[1] == 'GetServiceStatus'):
514                result['result'] = doGetServiceStatus()
515            elif (sys.argv[1] == 'GetZones'):
516                result['result']= doGetZones()
517            elif (sys.argv[1] == 'GetZone'):
518                result['result']= doGetZone()
519            elif (sys.argv[1] == 'GetRecords'):
520                result['result']= doGetRecords()
521            elif (sys.argv[1] == 'UpdateZone'):
522                result['result']= doUpdateZone()
523            elif (sys.argv[1] == 'CreateZone'):
524                result['result']= doCreateZone()
525            elif (sys.argv[1] == 'DeleteZone'):
526                result['result']= doDeleteZone()
527            else:
528                # Unknown argument
529                raise Exception('unknown argument: "' + sys.argv[1] + '"')
530
531    except Exception,e:
532        result['error'] = {'code': 42, 'message' : 'error: ' + str(e) }
533
534    #theLogger.info(json.dumps(result))
535    theLogger.info("Convert result to json")
536    resultstr =  json.dumps(result, indent=4, sort_keys=True)
537    theLogger.info("Writing result to stdout")
538    print(resultstr)
539    theLogger.info("Done")