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")