powerdns-admin/powerdnsadmin/models/record.py
2019-12-14 23:13:55 +07:00

630 lines
24 KiB
Python

import re
import traceback
import dns.reversename
import dns.inet
import dns.name
from distutils.version import StrictVersion
from flask import current_app
from urllib.parse import urljoin
from distutils.util import strtobool
from itertools import groupby
from .. import utils
from .base import db
from .setting import Setting
from .domain import Domain
from .domain_setting import DomainSetting
class Record(object):
"""
This is not a model, it's just an object
which be assigned data from PowerDNS API
"""
def __init__(self,
name=None,
type=None,
status=None,
ttl=None,
data=None,
comment_data=None):
self.name = name
self.type = type
self.status = status
self.ttl = ttl
self.data = data
self.comment_data = comment_data
# PDNS configs
self.PDNS_STATS_URL = Setting().get('pdns_api_url')
self.PDNS_API_KEY = Setting().get('pdns_api_key')
self.PDNS_VERSION = Setting().get('pdns_version')
self.API_EXTENDED_URL = utils.pdns_api_extended_uri(self.PDNS_VERSION)
self.PRETTY_IPV6_PTR = Setting().get('pretty_ipv6_ptr')
if StrictVersion(self.PDNS_VERSION) >= StrictVersion('4.0.0'):
self.NEW_SCHEMA = True
else:
self.NEW_SCHEMA = False
def get_record_data(self, domain):
"""
Query domain's DNS records via API
"""
headers = {}
headers['X-API-Key'] = self.PDNS_API_KEY
try:
jdata = utils.fetch_json(urljoin(
self.PDNS_STATS_URL, self.API_EXTENDED_URL +
'/servers/localhost/zones/{0}'.format(domain)),
timeout=int(
Setting().get('pdns_api_timeout')),
headers=headers)
except Exception as e:
current_app.logger.error(
"Cannot fetch domain's record data from remote powerdns api. DETAIL: {0}"
.format(e))
return False
if self.NEW_SCHEMA:
rrsets = jdata['rrsets']
for rrset in rrsets:
if rrset['records']:
r_name = rrset['name'].rstrip('.')
if self.PRETTY_IPV6_PTR: # only if activated
if rrset['type'] == 'PTR': # only ptr
if 'ip6.arpa' in r_name: # only if v6-ptr
r_name = dns.reversename.to_address(
dns.name.from_text(r_name))
rrset['name'] = r_name
rrset['content'] = rrset['records'][0]['content']
rrset['disabled'] = rrset['records'][0]['disabled']
# Get the record's comment. PDNS support multiple comments
# per record. However, we are only interested in the 1st
# one, for now.
rrset['comment_data'] = {"content": "", "account": ""}
if rrset['comments']:
rrset['comment_data'] = rrset['comments'][0]
return {'records': rrsets}
return jdata
def get_rrsets(self, domain):
"""
Query domain's rrsets via PDNS API
"""
headers = {}
headers['X-API-Key'] = self.PDNS_API_KEY
try:
jdata = utils.fetch_json(urljoin(
self.PDNS_STATS_URL, self.API_EXTENDED_URL +
'/servers/localhost/zones/{0}'.format(domain)),
timeout=int(
Setting().get('pdns_api_timeout')),
headers=headers)
except Exception as e:
current_app.logger.error(
"Cannot fetch domain's record data from remote powerdns api. DETAIL: {0}"
.format(e))
return False
return jdata['rrsets']
def add(self, domain_name, rrset):
"""
Add a record to a domain (Used by auto_ptr and DynDNS)
Args:
domain_name(str): The zone name
rrset(dict): The record in PDNS rrset format
Returns:
(dict): A dict contains status code and message
"""
# Validate record first
rrsets = self.get_rrsets(domain_name)
check = list(filter(lambda check: check['name'] == self.name, rrsets))
if check:
r = check[0]
if r['type'] in ('A', 'AAAA', 'CNAME'):
return {
'status': 'error',
'msg':
'Record already exists with type "A", "AAAA" or "CNAME"'
}
# Continue if the record is ready to be added
headers = {}
headers['X-API-Key'] = self.PDNS_API_KEY
try:
jdata = utils.fetch_json(urljoin(
self.PDNS_STATS_URL, self.API_EXTENDED_URL +
'/servers/localhost/zones/{0}'.format(domain_name)),
headers=headers,
timeout=int(
Setting().get('pdns_api_timeout')),
method='PATCH',
data=rrset)
current_app.logger.debug(jdata)
return {'status': 'ok', 'msg': 'Record was added successfully'}
except Exception as e:
current_app.logger.error(
"Cannot add record to domain {}. Error: {}".format(
domain_name, e))
current_app.logger.debug("Submitted record rrset: \n{}".format(
utils.pretty_json(rrset)))
return {
'status': 'error',
'msg':
'There was something wrong, please contact administrator'
}
def merge_rrsets(self, rrsets):
"""
Merge the rrsets that has same "name" and
"type".
Return: a new rrest which has multiple "records"
and "comments"
"""
if not rrsets:
raise Exception("Empty rrsets to merge")
elif len(rrsets) == 1:
# It is unique rrest already
return rrsets[0]
else:
# Merge rrsets into one
rrest = rrsets[0]
for r in rrsets[1:]:
rrest['records'] = rrest['records'] + r['records']
rrest['comments'] = rrest['comments'] + r['comments']
return rrest
def build_rrsets(self, domain_name, submitted_records):
"""
Build rrsets from the datatable's records
Args:
domain_name(str): The zone name
submitted_records(list): List of records submitted from PDA datatable
Returns:
transformed_rrsets(list): List of rrests converted from PDA datatable
"""
rrsets = []
for record in submitted_records:
# Format the record name
#
# If it is ipv6 reverse zone and PRETTY_IPV6_PTR is enabled,
# We convert ipv6 address back to reverse record format
# before submitting to PDNS API.
if self.PRETTY_IPV6_PTR and re.search(r'ip6\.arpa', domain_name):
if record['record_type'] == 'PTR' and ':' in record[
'record_name']:
record_name = dns.reversename.from_address(
record['record_name']).to_text()
# Else, it is forward zone, then record name should be
# in format "<name>.<domain>.". If it is root
# domain name (name == '@' or ''), the name should
# be in format "<domain>."
else:
record_name = "{}.{}.".format(
record["record_name"],
domain_name) if record["record_name"] not in [
'@', ''
] else domain_name + '.'
# Format the record content, it musts end
# with a dot character if in following types
if record["record_type"] in [
'MX', 'CNAME', 'SRV', 'NS'
] and record["record_data"].strip()[-1:] != '.':
record["record_data"] += '.'
record_conntent = {
"content": record["record_data"],
"disabled":
False if record['record_status'] == 'Active' else True
}
# Format the comment
record_comments = [{
"content": record["record_comment"],
"account": ""
}] if record.get("record_comment") else []
# Add the formatted record to rrsets list
rrsets.append({
"name": record_name,
"type": record["record_type"],
"ttl": int(record["record_ttl"]),
"records": [record_conntent],
"comments": record_comments
})
# Group the records which has the same name and type.
# The rrest then has multiple records inside.
transformed_rrsets = []
# Sort the list before using groupby
rrsets = sorted(rrsets, key=lambda r: (r['name'], r['type']))
groups = groupby(rrsets, key=lambda r: (r['name'], r['type']))
for _k, v in groups:
group = list(v)
transformed_rrsets.append(self.merge_rrsets(group))
return transformed_rrsets
def compare(self, domain_name, submitted_records):
"""
Compare the submitted records with PDNS's actual data
Args:
domain_name(str): The zone name
submitted_records(list): List of records submitted from PDA datatable
Returns:
new_rrsets(list): List of rrests to be added
del_rrsets(list): List of rrests to be deleted
"""
# Create submitted rrsets from submitted records
submitted_rrsets = self.build_rrsets(domain_name, submitted_records)
current_app.logger.debug(
"submitted_rrsets_data: \n{}".format(utils.pretty_json(submitted_rrsets)))
# Current domain's rrsets in PDNS
current_rrsets = self.get_rrsets(domain_name)
current_app.logger.debug("current_rrsets_data: \n{}".format(
utils.pretty_json(current_rrsets)))
# Remove comment's 'modified_at' key
# PDNS API always return the comments with modified_at
# info, we have to remove it to be able to do the dict
# comparison between current and submitted rrsets
for r in current_rrsets:
for comment in r['comments']:
del comment['modified_at']
# List of rrsets to be added
new_rrsets = {"rrsets": []}
for r in submitted_rrsets:
if r not in current_rrsets and r['type'] in Setting(
).get_records_allow_to_edit():
r['changetype'] = 'REPLACE'
new_rrsets["rrsets"].append(r)
# List of rrsets to be removed
del_rrsets = {"rrsets": []}
for r in current_rrsets:
if r not in submitted_rrsets and r['type'] in Setting(
).get_records_allow_to_edit():
r['changetype'] = 'DELETE'
del_rrsets["rrsets"].append(r)
current_app.logger.debug("new_rrsets: \n{}".format(utils.pretty_json(new_rrsets)))
current_app.logger.debug("del_rrsets: \n{}".format(utils.pretty_json(del_rrsets)))
return new_rrsets, del_rrsets
def apply(self, domain_name, submitted_records):
"""
Apply record changes to a domain. This function
will make 2 calls to the PDNS API to DELETE and
REPLACE records (rrests)
"""
current_app.logger.debug(
"submitted_records: {}".format(submitted_records))
# Get the list of rrsets to be added and deleted
new_rrsets, del_rrsets = self.compare(domain_name, submitted_records)
# Submit the changes to PDNS API
try:
headers = {}
headers['X-API-Key'] = self.PDNS_API_KEY
if del_rrsets["rrsets"]:
jdata1 = utils.fetch_json(urljoin(
self.PDNS_STATS_URL, self.API_EXTENDED_URL +
'/servers/localhost/zones/{0}'.format(domain_name)),
headers=headers,
method='PATCH',
data=del_rrsets)
if 'error' in jdata1.keys():
current_app.logger.error(
'Cannot apply record changes with deleting rrsets step. PDNS error: {}'
.format(jdata1['error']))
return {'status': 'error', 'msg': jdata1['error']}
if new_rrsets["rrsets"]:
jdata2 = utils.fetch_json(
urljoin(
self.PDNS_STATS_URL, self.API_EXTENDED_URL +
'/servers/localhost/zones/{0}'.format(domain_name)),
headers=headers,
timeout=int(Setting().get('pdns_api_timeout')),
method='PATCH',
data=new_rrsets)
if 'error' in jdata2.keys():
current_app.logger.error(
'Cannot apply record changes with adding rrsets step. PDNS error: {}'
.format(jdata2['error']))
return {'status': 'error', 'msg': jdata2['error']}
self.auto_ptr(domain_name, new_rrsets, del_rrsets)
self.update_db_serial(domain_name)
current_app.logger.info('Record was applied successfully.')
return {'status': 'ok', 'msg': 'Record was applied successfully', 'data': (new_rrsets, del_rrsets)}
except Exception as e:
current_app.logger.error(
"Cannot apply record changes to domain {0}. Error: {1}".format(
domain_name, e))
current_app.logger.debug(traceback.format_exc())
return {
'status': 'error',
'msg':
'There was something wrong, please contact administrator'
}
def auto_ptr(self, domain_name, new_rrsets, del_rrsets):
"""
Add auto-ptr records
"""
# Check if auto_ptr is enabled for this domain
auto_ptr_enabled = False
if Setting().get('auto_ptr'):
auto_ptr_enabled = True
else:
domain_obj = Domain.query.filter(Domain.name == domain_name).first()
domain_setting = DomainSetting.query.filter(
DomainSetting.domain == domain_obj).filter(
DomainSetting.setting == 'auto_ptr').first()
auto_ptr_enabled = strtobool(
domain_setting.value) if domain_setting else False
# If it is enabled, we create/delete the PTR records automatically
if auto_ptr_enabled:
try:
RECORD_TYPE_TO_PTR = ['A', 'AAAA']
new_rrsets = new_rrsets['rrsets']
del_rrsets = del_rrsets['rrsets']
if not new_rrsets and not del_rrsets:
msg = 'No changes detected. Skipping auto ptr...'
current_app.logger.info(msg)
return {'status': 'ok', 'msg': msg}
new_rrsets = [
r for r in new_rrsets if r['type'] in RECORD_TYPE_TO_PTR
]
del_rrsets = [
r for r in del_rrsets if r['type'] in RECORD_TYPE_TO_PTR
]
d = Domain()
for r in new_rrsets:
for record in r['records']:
# Format the reverse record name
# It is the reverse of forward record's content.
reverse_host_address = dns.reversename.from_address(
record['content']).to_text()
# Create the reverse domain name in PDNS
domain_reverse_name = d.get_reverse_domain_name(
reverse_host_address)
d.create_reverse_domain(domain_name,
domain_reverse_name)
# Build the rrset for reverse zone updating
rrset_data = [{
"changetype":
"REPLACE",
"name":
reverse_host_address,
"ttl":
r['ttl'],
"type":
"PTR",
"records": [{
"content": r['name'],
"disabled": record['disabled']
}],
"comments": []
}]
# Format the rrset
rrset = {"rrsets": rrset_data}
self.add(domain_reverse_name, rrset)
for r in del_rrsets:
for record in r['records']:
# Format the reverse record name
# It is the reverse of forward record's content.
reverse_host_address = dns.reversename.from_address(
record['content']).to_text()
# Create the reverse domain name in PDNS
domain_reverse_name = d.get_reverse_domain_name(
reverse_host_address)
d.create_reverse_domain(domain_name,
domain_reverse_name)
# Delete the reverse zone
self.name = reverse_host_address
self.type = 'PTR'
self.data = record['content']
self.delete(domain_reverse_name)
return {
'status': 'ok',
'msg': 'Auto-PTR record was updated successfully'
}
except Exception as e:
current_app.logger.error(
"Cannot update auto-ptr record changes to domain {0}. Error: {1}"
.format(domain_name, e))
current_app.logger.debug(traceback.format_exc())
return {
'status':
'error',
'msg':
'Auto-PTR creation failed. There was something wrong, please contact administrator.'
}
def delete(self, domain):
"""
Delete a record from domain
"""
headers = {}
headers['X-API-Key'] = self.PDNS_API_KEY
data = {
"rrsets": [{
"name": self.name.rstrip('.') + '.',
"type": self.type,
"changetype": "DELETE",
"records": []
}]
}
try:
jdata = utils.fetch_json(urljoin(
self.PDNS_STATS_URL, self.API_EXTENDED_URL +
'/servers/localhost/zones/{0}'.format(domain)),
headers=headers,
timeout=int(
Setting().get('pdns_api_timeout')),
method='PATCH',
data=data)
current_app.logger.debug(jdata)
return {'status': 'ok', 'msg': 'Record was removed successfully'}
except Exception as e:
current_app.logger.error(
"Cannot remove record {0}/{1}/{2} from domain {3}. DETAIL: {4}"
.format(self.name, self.type, self.data, domain, e))
return {
'status': 'error',
'msg':
'There was something wrong, please contact administrator'
}
def is_allowed_edit(self):
"""
Check if record is allowed to edit
"""
return self.type in Setting().get_records_allow_to_edit()
def is_allowed_delete(self):
"""
Check if record is allowed to removed
"""
return (self.type in Setting().get_records_allow_to_edit()
and self.type != 'SOA')
def exists(self, domain):
"""
Check if record is present within domain records, and if it's present set self to found record
"""
jdata = self.get_record_data(domain)
jrecords = jdata['records']
for jr in jrecords:
if jr['name'] == self.name and jr['type'] == self.type:
self.name = jr['name']
self.type = jr['type']
self.status = jr['disabled']
self.ttl = jr['ttl']
self.data = jr['content']
self.priority = 10
return True
return False
def update(self, domain, content):
"""
Update single record
"""
headers = {}
headers['X-API-Key'] = self.PDNS_API_KEY
if self.NEW_SCHEMA:
data = {
"rrsets": [{
"name":
self.name + '.',
"type":
self.type,
"ttl":
self.ttl,
"changetype":
"REPLACE",
"records": [{
"content": content,
"disabled": self.status,
}]
}]
}
else:
data = {
"rrsets": [{
"name":
self.name,
"type":
self.type,
"changetype":
"REPLACE",
"records": [{
"content": content,
"disabled": self.status,
"name": self.name,
"ttl": self.ttl,
"type": self.type,
"priority": 10
}]
}]
}
try:
utils.fetch_json(urljoin(
self.PDNS_STATS_URL, self.API_EXTENDED_URL +
'/servers/localhost/zones/{0}'.format(domain)),
headers=headers,
timeout=int(Setting().get('pdns_api_timeout')),
method='PATCH',
data=data)
current_app.logger.debug("dyndns data: {0}".format(data))
return {'status': 'ok', 'msg': 'Record was updated successfully'}
except Exception as e:
current_app.logger.error(
"Cannot add record {0}/{1}/{2} to domain {3}. DETAIL: {4}".
format(self.name, self.type, self.data, domain, e))
return {
'status': 'error',
'msg':
'There was something wrong, please contact administrator'
}
def update_db_serial(self, domain):
headers = {}
headers['X-API-Key'] = self.PDNS_API_KEY
jdata = utils.fetch_json(urljoin(
self.PDNS_STATS_URL, self.API_EXTENDED_URL +
'/servers/localhost/zones/{0}'.format(domain)),
headers=headers,
timeout=int(
Setting().get('pdns_api_timeout')),
method='GET')
serial = jdata['serial']
domain = Domain.query.filter(Domain.name == domain).first()
if domain:
domain.serial = serial
db.session.commit()
return {
'status': True,
'msg': 'Synced local serial for domain name {0}'.format(domain)
}
else:
return {
'status': False,
'msg':
'Could not find domain name {0} in local db'.format(domain)
}