From 14658d797ed5b70bcf626be0f5afe003aee60e69 Mon Sep 17 00:00:00 2001 From: Khanh Ngo Date: Sat, 14 Dec 2019 14:47:21 +0700 Subject: [PATCH 1/7] Adjustment in domain's record applying --- powerdnsadmin/lib/utils.py | 5 +- powerdnsadmin/models/record.py | 631 +++++++++++---------- powerdnsadmin/routes/domain.py | 116 ++-- powerdnsadmin/templates/domain.html | 6 + powerdnsadmin/templates/template_edit.html | 6 + 5 files changed, 401 insertions(+), 363 deletions(-) diff --git a/powerdnsadmin/lib/utils.py b/powerdnsadmin/lib/utils.py index 821489f..19009de 100644 --- a/powerdnsadmin/lib/utils.py +++ b/powerdnsadmin/lib/utils.py @@ -5,7 +5,6 @@ import hashlib import ipaddress import os -# from app import app from distutils.version import StrictVersion from urllib.parse import urlparse from datetime import datetime, timedelta @@ -300,6 +299,10 @@ def validate_ipaddress(address): return [] +def pretty_json(data): + return json.dumps(data, sort_keys=True, indent=4) + + class customBoxes: boxes = { "reverse": (" ", " "), diff --git a/powerdnsadmin/models/record.py b/powerdnsadmin/models/record.py index 4364793..d852b8f 100644 --- a/powerdnsadmin/models/record.py +++ b/powerdnsadmin/models/record.py @@ -7,6 +7,7 @@ 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 @@ -89,14 +90,34 @@ class Record(object): return jdata - def add(self, domain): + def get_rrsets(self, domain): """ - Add a record to domain + Query domain's rrsets via PDNS API """ - # validate record first - r = self.get_record_data(domain) - records = r['records'] - check = list(filter(lambda check: check['name'] == self.name, records)) + 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 (a reverse domain name) + """ + # 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'): @@ -106,315 +127,288 @@ class Record(object): 'Record already exists with type "A", "AAAA" or "CNAME"' } - # continue if the record is ready to be added + # Continue if the record is ready to be added headers = {} headers['X-API-Key'] = self.PDNS_API_KEY - if self.NEW_SCHEMA: - data = { - "rrsets": [{ - "name": - self.name.rstrip('.') + '.', - "type": - self.type, - "changetype": - "REPLACE", - "ttl": - self.ttl, - "records": [{ - "content": self.data, - "disabled": self.status, - }], - "comments": - [self.comment_data] if self.comment_data else [] - }] - } - else: - data = { - "rrsets": [{ - "name": - self.name, - "type": - self.type, - "changetype": - "REPLACE", - "records": [{ - "content": self.data, - "disabled": self.status, - "name": self.name, - "ttl": self.ttl, - "type": self.type - }], - "comments": - [self.comment_data] if self.comment_data else [] - }] - } + # if self.NEW_SCHEMA: + # data = { + # "rrsets": [{ + # "name": + # self.name.rstrip('.') + '.', + # "type": + # self.type, + # "changetype": + # "REPLACE", + # "ttl": + # self.ttl, + # "records": [{ + # "content": self.data, + # "disabled": self.status, + # }], + # "comments": + # [self.comment_data] if self.comment_data else [] + # }] + # } + # else: + # data = { + # "rrsets": [{ + # "name": + # self.name, + # "type": + # self.type, + # "changetype": + # "REPLACE", + # "records": [{ + # "content": self.data, + # "disabled": self.status, + # "name": self.name, + # "ttl": self.ttl, + # "type": self.type + # }], + # "comments": + # [self.comment_data] if self.comment_data else [] + # }] + # } try: jdata = utils.fetch_json(urljoin( self.PDNS_STATS_URL, self.API_EXTENDED_URL + - '/servers/localhost/zones/{0}'.format(domain)), + '/servers/localhost/zones/{0}'.format(domain_name)), headers=headers, timeout=int( Setting().get('pdns_api_timeout')), method='PATCH', - data=data) + 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 {0}/{1}/{2} to domain {3}. DETAIL: {4}". - format(self.name, self.type, self.data, domain, e)) + format(self.name, self.type, self.data, domain_name, e)) return { 'status': 'error', 'msg': 'There was something wrong, please contact administrator' } - def compare(self, domain_name, new_records): + def merge_rrsets(self, rrsets): """ - Compare new records with current powerdns record data - Input is a list of hashes (records) + Merge the rrsets that has same "name" and + "type". + Return: a new rrest which has multiple "records" + and "comments" """ - # get list of current records we have in powerdns - current_records = self.get_record_data(domain_name)['records'] + 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 - # convert them to list of list (just has [name, type]) instead of list of hash - # to compare easier - list_current_records = [[x['name'], x['type']] - for x in current_records] - list_new_records = [[x['name'], x['type']] for x in new_records] - - # get list of deleted records - # they are the records which exist in list_current_records but not in list_new_records - list_deleted_records = [ - x for x in list_current_records if x not in list_new_records - ] - - # convert back to list of hash - deleted_records = [ - x for x in current_records - if [x['name'], x['type']] in list_deleted_records and ( - x['type'] in Setting().get_records_allow_to_edit() - and x['type'] != 'SOA') - ] - - # return a tuple - return deleted_records, new_records - - def apply(self, domain, post_records): + def build_rrsets(self, domain_name, submitted_records): """ - Apply record changes to domain + 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 """ - records = [] - for r in post_records: - r_name = domain if r['record_name'] in [ - '@', '' - ] else r['record_name'] + '.' + domain - r_type = r['record_type'] - if self.PRETTY_IPV6_PTR: # only if activated - if self.NEW_SCHEMA: # only if new schema - if r_type == 'PTR': # only ptr - if ':' in r['record_name']: # dirty ipv6 check - r_name = r['record_name'] + rrsets = [] + for record in submitted_records: + # Format the record name + record_name = "{}.{}.".format( + record["record_name"], + domain_name) if record["record_name"] not in [ + '@', '' + ] else domain_name + '.' - r_data = domain if r_type == 'CNAME' and r['record_data'] in [ - '@', '' - ] else r['record_data'] + # 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 = { - "name": r_name, - "type": r_type, - "content": r_data, + record_conntent = { + "content": record["record_data"], "disabled": - True if r['record_status'] == 'Disabled' else False, - "ttl": int(r['record_ttl']) if r['record_ttl'] else 3600, - "comment_data": r['comment_data'] + False if record['record_status'] == 'Active' else True } - records.append(record) - deleted_records, new_records = self.compare(domain, records) + # Format the comment + record_comments = [{ + "content": record["record_comment"], + "account": "" + }] if record["record_comment"] else [] - records = [] - for r in deleted_records: - r_name = r['name'].rstrip( - '.') + '.' if self.NEW_SCHEMA else r['name'] - r_type = r['type'] - if self.PRETTY_IPV6_PTR: # only if activated - if self.NEW_SCHEMA: # only if new schema - if r_type == 'PTR': # only ptr - if ':' in r['name']: # dirty ipv6 check - r_name = dns.reversename.from_address( - r['name']).to_text() + # 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 + }) - record = { - "name": r_name, - "type": r_type, - "changetype": "DELETE", - "records": [] - } - records.append(record) + # Group the records which has the same name and type. + # The rrest then has multiple records inside. + transformed_rrsets = [] - postdata_for_delete = {"rrsets": records} + # 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)) - records = [] - for r in new_records: - if self.NEW_SCHEMA: - r_name = r['name'].rstrip('.') + '.' - r_type = r['type'] - if self.PRETTY_IPV6_PTR: # only if activated - if r_type == 'PTR': # only ptr - if ':' in r['name']: # dirty ipv6 check - r_name = r['name'] + return transformed_rrsets - record = { - "name": - r_name, - "type": - r_type, - "changetype": - "REPLACE", - "ttl": - r['ttl'], - "records": [{ - "content": r['content'], - "disabled": r['disabled'] - }], - "comments": - r['comment_data'] - } - else: - record = { - "name": - r['name'], - "type": - r['type'], - "changetype": - "REPLACE", - "records": [{ - "content": r['content'], - "disabled": r['disabled'], - "name": r['name'], - "ttl": r['ttl'], - "type": r['type'], - "priority": - 10, # priority field for pdns 3.4.1. https://doc.powerdns.com/md/authoritative/upgrading/ - }], - "comments": - r['comment_data'] - } + def compare(self, domain_name, submitted_records): + """ + Compare the submitted records with PDNS's actual data - records.append(record) + Args: + domain_name(str): The zone name + submitted_records(list): List of records submitted from PDA datatable - # Adjustment to add multiple records which described in - # https://github.com/ngoduykhanh/PowerDNS-Admin/issues/5#issuecomment-181637576 - final_records = [] - records = sorted(records, - key=lambda item: - (item["name"], item["type"], item["changetype"])) - for key, group in itertools.groupby( - records, lambda item: - (item["name"], item["type"], item["changetype"])): - if self.NEW_SCHEMA: - r_name = key[0] - r_type = key[1] - r_changetype = key[2] - - if self.PRETTY_IPV6_PTR: # only if activated - if r_type == 'PTR': # only ptr - if ':' in r_name: # dirty ipv6 check - r_name = dns.reversename.from_address( - r_name).to_text() - - new_record = { - "name": r_name, - "type": r_type, - "changetype": r_changetype, - "ttl": None, - "records": [] - } - for item in group: - temp_content = item['records'][0]['content'] - temp_disabled = item['records'][0]['disabled'] - if key[1] in ['MX', 'CNAME', 'SRV', 'NS']: - if temp_content.strip()[-1:] != '.': - temp_content += '.' - - if new_record['ttl'] is None: - new_record['ttl'] = item['ttl'] - new_record['records'].append({ - "content": temp_content, - "disabled": temp_disabled - }) - new_record['comments'] = item['comments'] - final_records.append(new_record) - - else: - - final_records.append({ - "name": - key[0], - "type": - key[1], - "changetype": - key[2], - "records": [{ - "content": item['records'][0]['content'], - "disabled": item['records'][0]['disabled'], - "name": key[0], - "ttl": item['records'][0]['ttl'], - "type": key[1], - "priority": 10, - } for item in group] - }) - - postdata_for_new = {"rrsets": final_records} + 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( - "postdata_for_new: {}".format(postdata_for_new)) + "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( - "postdata_for_delete: {}".format(postdata_for_delete)) - current_app.logger.info( - urljoin( - self.PDNS_STATS_URL, self.API_EXTENDED_URL + - '/servers/localhost/zones/{0}'.format(domain))) + "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) + + # records = [] + # for r in deleted_records: + # r_name = r['name'].rstrip( + # '.') + '.' if self.NEW_SCHEMA else r['name'] + # r_type = r['type'] + # if self.PRETTY_IPV6_PTR: # only if activated + # if self.NEW_SCHEMA: # only if new schema + # if r_type == 'PTR': # only ptr + # if ':' in r['name']: # dirty ipv6 check + # r_name = dns.reversename.from_address( + # r['name']).to_text() + + # record = { + # "name": r_name, + # "type": r_type, + # "changetype": "DELETE", + # "records": [] + # } + # records.append(record) + + # postdata_for_delete = {"rrsets": records} + + # records = [] + # for r in new_records: + # if self.NEW_SCHEMA: + # r_name = r['name'].rstrip('.') + '.' + # r_type = r['type'] + # if self.PRETTY_IPV6_PTR: # only if activated + # if r_type == 'PTR': # only ptr + # if ':' in r['name']: # dirty ipv6 check + # r_name = r['name'] + + # Submit the changes to PDNS API try: headers = {} headers['X-API-Key'] = self.PDNS_API_KEY - jdata1 = utils.fetch_json(urljoin( - self.PDNS_STATS_URL, self.API_EXTENDED_URL + - '/servers/localhost/zones/{0}'.format(domain)), - headers=headers, - method='PATCH', - data=postdata_for_delete) - jdata2 = 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=postdata_for_new) - if 'error' in jdata1.keys(): - current_app.logger.error('Cannot apply record changes.') - current_app.logger.debug(jdata1['error']) - return {'status': 'error', 'msg': jdata1['error']} - elif 'error' in jdata2.keys(): - current_app.logger.error('Cannot apply record changes.') - current_app.logger.debug(jdata2['error']) - return {'status': 'error', 'msg': jdata2['error']} - else: - self.auto_ptr(domain, new_records, deleted_records) - self.update_db_serial(domain) - current_app.logger.info('Record was applied successfully.') - return { - 'status': 'ok', - 'msg': 'Record was applied successfully' - } + 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, e)) + domain_name, e)) current_app.logger.debug(traceback.format_exc()) return { 'status': 'error', @@ -422,48 +416,93 @@ class Record(object): 'There was something wrong, please contact administrator' } - def auto_ptr(self, domain, new_records, deleted_records): + def auto_ptr(self, domain_name, new_rrsets, del_rrsets): """ Add auto-ptr records """ - domain_obj = Domain.query.filter(Domain.name == domain).first() - domain_auto_ptr = DomainSetting.query.filter( - DomainSetting.domain == domain_obj).filter( - DomainSetting.setting == 'auto_ptr').first() - domain_auto_ptr = strtobool( - domain_auto_ptr.value) if domain_auto_ptr else False + # 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 - system_auto_ptr = Setting().get('auto_ptr') - - if system_auto_ptr or domain_auto_ptr: + # 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_records: - if r['type'] in ['A', 'AAAA']: - r_name = r['name'] + '.' - r_content = r['content'] + 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( - r_content).to_text() + 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, domain_reverse_name) - self.name = dns.reversename.from_address( - r_content).to_text().rstrip('.') - self.type = 'PTR' - self.status = r['disabled'] - self.ttl = r['ttl'] - self.data = r_name - self.add(domain_reverse_name) - for r in deleted_records: - if r['type'] in ['A', 'AAAA']: - r_content = r['content'] + 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( - r_content).to_text() + 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 = r_content + self.data = record['content'] self.delete(domain_reverse_name) return { 'status': 'ok', @@ -472,7 +511,7 @@ class Record(object): except Exception as e: current_app.logger.error( "Cannot update auto-ptr record changes to domain {0}. Error: {1}" - .format(domain, e)) + .format(domain_name, e)) current_app.logger.debug(traceback.format_exc()) return { 'status': diff --git a/powerdnsadmin/routes/domain.py b/powerdnsadmin/routes/domain.py index 6d3b413..093f818 100644 --- a/powerdnsadmin/routes/domain.py +++ b/powerdnsadmin/routes/domain.py @@ -5,6 +5,7 @@ from distutils.version import StrictVersion from flask import Blueprint, render_template, make_response, url_for, current_app, request, redirect, abort, jsonify from flask_login import login_required, current_user +from ..lib.utils import pretty_json from ..decorators import can_create_domain, operator_role_required, can_access_domain, can_configure_dnssec from ..models.user import User from ..models.account import Account @@ -27,17 +28,17 @@ domain_bp = Blueprint('domain', @login_required @can_access_domain def domain(domain_name): - r = Record() + # Validate the domain existing in the local DB domain = Domain.query.filter(Domain.name == domain_name).first() if not domain: abort(404) - # query domain info from PowerDNS API - zone_info = r.get_record_data(domain.name) - if zone_info: - jrecords = zone_info['records'] - else: - # can not get any record, API server might be down + # Query domain's rrsets from PowerDNS API + rrsets = Record().get_rrsets(domain.name) + current_app.logger.debug("Fetched rrests: \n{}".format(pretty_json(rrsets))) + + # API server might be down, misconfigured + if not rrsets: abort(500) quick_edit = Setting().get('record_quick_edit') @@ -49,45 +50,40 @@ def domain(domain_name): ttl_options = Setting().get_ttl_options() records = [] + # Render the "records" to display in HTML datatable + # + # BUG: If we have multiple records with the same name + # and each record has its own comment, the display of + # [record-comment] may not consistent because PDNS API + # returns the rrsets (records, comments) has different + # order than its database records. + # TODO: + # - Find a way to make it consistent, or + # - Only allow one comment for that case if StrictVersion(Setting().get('pdns_version')) >= StrictVersion('4.0.0'): - for jr in jrecords: - if jr['type'] in records_allow_to_edit: - for subrecord in jr['records']: - record = RecordEntry(name=jr['name'], - type=jr['type'], - status='Disabled' if - subrecord['disabled'] else 'Active', - ttl=jr['ttl'], - data=subrecord['content'], - comment=jr['comment_data']['content'], - is_allowed_edit=True) - records.append(record) - if not re.search('ip6\.arpa|in-addr\.arpa$', domain_name): - editable_records = forward_records_allow_to_edit - else: - editable_records = reverse_records_allow_to_edit - return render_template('domain.html', - domain=domain, - records=records, - editable_records=editable_records, - quick_edit=quick_edit, - ttl_options=ttl_options) + for r in rrsets: + if r['type'] in records_allow_to_edit: + index = 0 + for record in r['records']: + record_entry = RecordEntry( + name=r['name'].rstrip('.'), + type=r['type'], + status='Disabled' if record['disabled'] else 'Active', + ttl=r['ttl'], + data=record['content'], + comment=r['comments'][index]['content'] if r['comments'] else '', + is_allowed_edit=True) + index += 1 + records.append(record_entry) else: - for jr in jrecords: - if jr['type'] in records_allow_to_edit: - record = RecordEntry( - name=jr['name'], - type=jr['type'], - status='Disabled' if jr['disabled'] else 'Active', - ttl=jr['ttl'], - data=jr['content'], - comment=jr['comment_data']['content'], - is_allowed_edit=True) - records.append(record) + # Unsupported version + abort(500) + if not re.search('ip6\.arpa|in-addr\.arpa$', domain_name): editable_records = forward_records_allow_to_edit else: editable_records = reverse_records_allow_to_edit + return render_template('domain.html', domain=domain, records=records, @@ -276,11 +272,11 @@ def change_type(domain_name): #TODO: Validate ip addresses input domain_master_ips = [] if domain_type == 'slave' and request.form.getlist('domain_master_address'): - domain_master_string = request.form.getlist( - 'domain_master_address')[0] - domain_master_string = domain_master_string.replace( - ' ', '') - domain_master_ips = domain_master_string.split(',') + domain_master_string = request.form.getlist( + 'domain_master_address')[0] + domain_master_string = domain_master_string.replace( + ' ', '') + domain_master_ips = domain_master_string.split(',') d = Domain() status = d.update_kind(domain_name=domain_name, @@ -354,19 +350,16 @@ def change_account(domain_name): @login_required @can_access_domain def record_apply(domain_name): - #TODO: filter removed records / name modified records. - try: jdata = request.json submitted_serial = jdata['serial'] submitted_record = jdata['record'] domain = Domain.query.filter(Domain.name == domain_name).first() - current_app.logger.debug( - 'Your submitted serial: {0}'.format(submitted_serial)) - current_app.logger.debug('Current domain serial: {0}'.format( - domain.serial)) if domain: + current_app.logger.debug('Current domain serial: {0}'.format( + domain.serial)) + if int(submitted_serial) != domain.serial: return make_response( jsonify({ @@ -384,26 +377,17 @@ def record_apply(domain_name): 'Domain name {0} does not exist'.format(domain_name) }), 404) - # Modify the record's comment data. We append - # the "current_user" into account field as it - # a field with user-defined meaning - for sr in submitted_record: - if sr.get('record_comment'): - sr['comment_data'] = [{ - 'content': sr['record_comment'], - 'account': current_user.username - }] - else: - sr['comment_data'] = [] - r = Record() result = r.apply(domain_name, submitted_record) if result['status'] == 'ok': - jdata.pop('_csrf_token', - None) # don't store csrf token in the history. history = History( msg='Apply record changes to domain {0}'.format(domain_name), - detail=str(json.dumps(jdata)), + detail=str( + json.dumps({ + "domain": domain_name, + "add_rrests": result['data'][0]['rrsets'], + "del_rrests": result['data'][1]['rrsets'] + })), created_by=current_user.username) history.add() return make_response(jsonify(result), 200) diff --git a/powerdnsadmin/templates/domain.html b/powerdnsadmin/templates/domain.html index 2146bb3..8eb4797 100644 --- a/powerdnsadmin/templates/domain.html +++ b/powerdnsadmin/templates/domain.html @@ -335,6 +335,9 @@ mx_server = modal.find('#mx_server').val(); mx_priority = modal.find('#mx_priority').val(); data = mx_priority + " " + mx_server; + if (data && !data.endsWith('.')) { + data = data + '.' + } record_data.val(data); modal.modal('hide'); }) @@ -370,6 +373,9 @@ srv_port = modal.find('#srv_port').val(); srv_target = modal.find('#srv_target').val(); data = srv_priority + " " + srv_weight + " " + srv_port + " " + srv_target; + if (data && !data.endsWith('.')) { + data = data + '.' + } record_data.val(data); modal.modal('hide'); }) diff --git a/powerdnsadmin/templates/template_edit.html b/powerdnsadmin/templates/template_edit.html index 8c22ca9..820a2e4 100644 --- a/powerdnsadmin/templates/template_edit.html +++ b/powerdnsadmin/templates/template_edit.html @@ -313,6 +313,9 @@ mx_server = modal.find('#mx_server').val(); mx_priority = modal.find('#mx_priority').val(); data = mx_priority + " " + mx_server; + if (data && !data.endsWith('.')) { + data = data + '.' + } record_data.val(data); modal.modal('hide'); }) @@ -348,6 +351,9 @@ srv_port = modal.find('#srv_port').val(); srv_target = modal.find('#srv_target').val(); data = srv_priority + " " + srv_weight + " " + srv_port + " " + srv_target; + if (data && !data.endsWith('.')) { + data = data + '.' + } record_data.val(data); modal.modal('hide'); }) From 95fe2a8a855a1a2aa5f17355871247c4085c1753 Mon Sep 17 00:00:00 2001 From: Khanh Ngo Date: Sat, 14 Dec 2019 20:47:34 +0700 Subject: [PATCH 2/7] Update yarn lockfile --- yarn.lock | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/yarn.lock b/yarn.lock index bb7f122..02be4aa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -417,6 +417,11 @@ datatables.net-bs@^1.10.19: datatables.net "1.10.19" jquery ">=1.7" +datatables.net-plugins@^1.10.19: + version "1.10.20" + resolved "https://registry.yarnpkg.com/datatables.net-plugins/-/datatables.net-plugins-1.10.20.tgz#c89f6bed3fa7e6605cbeaa35d60f223659e84c8c" + integrity sha512-rnhNmRHe9UEzvM7gtjBay1QodkWUmxLUhHNbmJMYhhUggjtm+BRSlE0PRilkeUkwckpNWzq+0fPd7/i0fpQgzA== + datatables.net@1.10.19, datatables.net@^1.10.19: version "1.10.19" resolved "https://registry.yarnpkg.com/datatables.net/-/datatables.net-1.10.19.tgz#97a1ed41c85e62d61040603481b59790a172dd1f" From 5ac58d5503f410c3fa71646d2ad8b5a6a9b0b19b Mon Sep 17 00:00:00 2001 From: Khanh Ngo Date: Sat, 14 Dec 2019 23:13:55 +0700 Subject: [PATCH 3/7] Re-add pretty_ipv6_ptr. Bug fixes --- powerdnsadmin/models/record.py | 118 ++++++++++----------------------- powerdnsadmin/routes/admin.py | 6 +- powerdnsadmin/routes/domain.py | 20 +++++- powerdnsadmin/routes/index.py | 28 +++++--- 4 files changed, 75 insertions(+), 97 deletions(-) diff --git a/powerdnsadmin/models/record.py b/powerdnsadmin/models/record.py index d852b8f..ecea629 100644 --- a/powerdnsadmin/models/record.py +++ b/powerdnsadmin/models/record.py @@ -1,5 +1,5 @@ +import re import traceback -import itertools import dns.reversename import dns.inet import dns.name @@ -113,7 +113,14 @@ class Record(object): def add(self, domain_name, rrset): """ - Add a record to a domain (a reverse domain name) + 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) @@ -131,46 +138,6 @@ class Record(object): headers = {} headers['X-API-Key'] = self.PDNS_API_KEY - # if self.NEW_SCHEMA: - # data = { - # "rrsets": [{ - # "name": - # self.name.rstrip('.') + '.', - # "type": - # self.type, - # "changetype": - # "REPLACE", - # "ttl": - # self.ttl, - # "records": [{ - # "content": self.data, - # "disabled": self.status, - # }], - # "comments": - # [self.comment_data] if self.comment_data else [] - # }] - # } - # else: - # data = { - # "rrsets": [{ - # "name": - # self.name, - # "type": - # self.type, - # "changetype": - # "REPLACE", - # "records": [{ - # "content": self.data, - # "disabled": self.status, - # "name": self.name, - # "ttl": self.ttl, - # "type": self.type - # }], - # "comments": - # [self.comment_data] if self.comment_data else [] - # }] - # } - try: jdata = utils.fetch_json(urljoin( self.PDNS_STATS_URL, self.API_EXTENDED_URL + @@ -184,8 +151,10 @@ class Record(object): return {'status': 'ok', 'msg': 'Record was added 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_name, e)) + "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': @@ -226,11 +195,26 @@ class Record(object): rrsets = [] for record in submitted_records: # Format the record name - record_name = "{}.{}.".format( - record["record_name"], - domain_name) if record["record_name"] not in [ - '@', '' - ] else domain_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 "..". If it is root + # domain name (name == '@' or ''), the name should + # be in format "." + 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 @@ -249,7 +233,7 @@ class Record(object): record_comments = [{ "content": record["record_comment"], "account": "" - }] if record["record_comment"] else [] + }] if record.get("record_comment") else [] # Add the formatted record to rrsets list rrsets.append({ @@ -267,7 +251,7 @@ class Record(object): # 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: + for _k, v in groups: group = list(v) transformed_rrsets.append(self.merge_rrsets(group)) @@ -336,38 +320,6 @@ class Record(object): # Get the list of rrsets to be added and deleted new_rrsets, del_rrsets = self.compare(domain_name, submitted_records) - # records = [] - # for r in deleted_records: - # r_name = r['name'].rstrip( - # '.') + '.' if self.NEW_SCHEMA else r['name'] - # r_type = r['type'] - # if self.PRETTY_IPV6_PTR: # only if activated - # if self.NEW_SCHEMA: # only if new schema - # if r_type == 'PTR': # only ptr - # if ':' in r['name']: # dirty ipv6 check - # r_name = dns.reversename.from_address( - # r['name']).to_text() - - # record = { - # "name": r_name, - # "type": r_type, - # "changetype": "DELETE", - # "records": [] - # } - # records.append(record) - - # postdata_for_delete = {"rrsets": records} - - # records = [] - # for r in new_records: - # if self.NEW_SCHEMA: - # r_name = r['name'].rstrip('.') + '.' - # r_type = r['type'] - # if self.PRETTY_IPV6_PTR: # only if activated - # if r_type == 'PTR': # only ptr - # if ':' in r['name']: # dirty ipv6 check - # r_name = r['name'] - # Submit the changes to PDNS API try: headers = {} diff --git a/powerdnsadmin/routes/admin.py b/powerdnsadmin/routes/admin.py index 7e31c28..f4ff840 100644 --- a/powerdnsadmin/routes/admin.py +++ b/powerdnsadmin/routes/admin.py @@ -3,7 +3,7 @@ import json import traceback from ast import literal_eval from distutils.version import StrictVersion -from flask import Blueprint, render_template, make_response, url_for, current_app, request, redirect, jsonify, abort +from flask import Blueprint, render_template, make_response, url_for, current_app, request, redirect, jsonify, abort, flash from flask_login import login_required, current_user from ..decorators import operator_role_required, admin_role_required @@ -843,7 +843,7 @@ def create_template_from_zone(): for jr in jrecords: if jr['type'] in Setting().get_records_allow_to_edit(): name = '@' if jr['name'] == domain_name else re.sub( - '\.{}$'.format(domain_name), '', jr['name']) + r'\.{}$'.format(domain_name), '', jr['name']) for subrecord in jr['records']: record = DomainTemplateRecord( name=name, @@ -858,7 +858,7 @@ def create_template_from_zone(): for jr in jrecords: if jr['type'] in Setting().get_records_allow_to_edit(): name = '@' if jr['name'] == domain_name else re.sub( - '\.{}$'.format(domain_name), '', jr['name']) + r'\.{}$'.format(domain_name), '', jr['name']) record = DomainTemplateRecord( name=name, type=jr['type'], diff --git a/powerdnsadmin/routes/domain.py b/powerdnsadmin/routes/domain.py index 093f818..c3c5880 100644 --- a/powerdnsadmin/routes/domain.py +++ b/powerdnsadmin/routes/domain.py @@ -1,6 +1,8 @@ import re import json import traceback +import dns.name +import dns.reversename from distutils.version import StrictVersion from flask import Blueprint, render_template, make_response, url_for, current_app, request, redirect, abort, jsonify from flask_login import login_required, current_user @@ -63,15 +65,27 @@ def domain(domain_name): if StrictVersion(Setting().get('pdns_version')) >= StrictVersion('4.0.0'): for r in rrsets: if r['type'] in records_allow_to_edit: + r_name = r['name'].rstrip('.') + + # If it is reverse zone and pretty_ipv6_ptr setting + # is enabled, we reformat the name for ipv6 records. + if Setting().get('pretty_ipv6_ptr') and r[ + 'type'] == 'PTR' and 'ip6.arpa' in r_name: + r_name = dns.reversename.to_address( + dns.name.from_text(r_name)) + + # Create the list of records in format that + # PDA jinja2 template can understand. index = 0 for record in r['records']: record_entry = RecordEntry( - name=r['name'].rstrip('.'), + name=r_name, type=r['type'], status='Disabled' if record['disabled'] else 'Active', ttl=r['ttl'], data=record['content'], - comment=r['comments'][index]['content'] if r['comments'] else '', + comment=r['comments'][index]['content'] + if r['comments'] else '', is_allowed_edit=True) index += 1 records.append(record_entry) @@ -79,7 +93,7 @@ def domain(domain_name): # Unsupported version abort(500) - if not re.search('ip6\.arpa|in-addr\.arpa$', domain_name): + if not re.search(r'ip6\.arpa|in-addr\.arpa$', domain_name): editable_records = forward_records_allow_to_edit else: editable_records = reverse_records_allow_to_edit diff --git a/powerdnsadmin/routes/index.py b/powerdnsadmin/routes/index.py index e244933..81008ee 100644 --- a/powerdnsadmin/routes/index.py +++ b/powerdnsadmin/routes/index.py @@ -1,4 +1,5 @@ import os +import re import json import traceback import datetime @@ -423,7 +424,7 @@ def dyndns_update(): domain = None domain_segments = hostname.split('.') - for index in range(len(domain_segments)): + for _index in range(len(domain_segments)): full_domain = '.'.join(domain_segments) potential_domain = Domain.query.filter( Domain.name == full_domain).first() @@ -489,12 +490,23 @@ def dyndns_update(): DomainSetting.setting == 'create_via_dyndns').first() if (ondemand_creation is not None) and (strtobool( ondemand_creation.value) == True): - record = Record(name=hostname, - type=rtype, - data=str(ip), - status=False, - ttl=3600) - result = record.add(domain.name) + + # Build the rrset + rrset_data = [{ + "changetype": "REPLACE", + "name": hostname + '.', + "ttl": 3600, + "type": rtype, + "records": [{ + "content": str(ip), + "disabled": False + }], + "comments": [] + }] + + # Format the rrset + rrset = {"rrsets": rrset_data} + result = Record().add(domain.name, rrset) if result['status'] == 'ok': history = History( msg= @@ -679,7 +691,7 @@ def handle_account(account_name): clean_name = ''.join(c for c in account_name.lower() if c in "abcdefghijklmnopqrstuvwxyz0123456789") if len(clean_name) > Account.name.type.length: - logging.error( + current_app.logger.error( "Account name {0} too long. Truncated.".format(clean_name)) account = Account.query.filter_by(name=clean_name).first() if not account: From 4263bccbba28748395bac7a9616560d4f3e2578d Mon Sep 17 00:00:00 2001 From: Khanh Ngo Date: Sun, 15 Dec 2019 21:42:08 +0700 Subject: [PATCH 4/7] Format the history popup content --- powerdnsadmin/static/custom/js/custom.js | 22 +++++++++++++--------- powerdnsadmin/templates/dashboard.html | 2 +- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/powerdnsadmin/static/custom/js/custom.js b/powerdnsadmin/static/custom/js/custom.js index 1cf0664..81d73fb 100644 --- a/powerdnsadmin/static/custom/js/custom.js +++ b/powerdnsadmin/static/custom/js/custom.js @@ -241,26 +241,30 @@ function reload_domains(url) { // pretty JSON json_library = { - replacer: function(match, pIndent, pKey, pVal, pEnd) { + replacer: function (match, pIndent, pKey, pVal, pEnd) { var key = ''; var val = ''; var str = ''; var r = pIndent || ''; - if (pKey){ - r = r + key + pKey.replace(/[": ]/g, '') + ': '; + if (pKey) { + // r = r + key + pKey.replace(/[": ]/g, '') + ': '; + // Keep the quote in the key + r = r + key + pKey.replace(/":/, '"') + ': '; } - if (pVal){ + if (pVal) { r = r + (pVal[0] == '"' ? str : val) + pVal + ''; } return r + (pEnd || ''); }, - prettyPrint: function(obj) { + prettyPrint: function (obj) { obj = obj.replace(/u'/g, "\'").replace(/'/g, "\"").replace(/(False|None)/g, "\"$1\""); var jsonData = JSON.parse(obj); - var jsonLine = /^( *)("[\w]+": )?("[^"]*"|[\w.+-]*)?([,[{])?$/mg; - return JSON.stringify(jsonData, null, 3) + // var jsonLine = /^( *)("[\w]+": )?("[^"]*"|[\w.+-]*)?([,[{])?$/mg; + // The new regex to handle case value is an empty list [] or dict {} + var jsonLine = /^( *)("[\w]+": )?("[^"]*"|[\w.+-]*)?([,[{])?/mg; + return JSON.stringify(jsonData, null, 3) .replace(/&/g, '&').replace(/\\"/g, '"') .replace(//g, '>') .replace(jsonLine, json_library.replacer); - } - }; + } +}; diff --git a/powerdnsadmin/templates/dashboard.html b/powerdnsadmin/templates/dashboard.html index 733eace..295a3f2 100755 --- a/powerdnsadmin/templates/dashboard.html +++ b/powerdnsadmin/templates/dashboard.html @@ -107,7 +107,7 @@ {{ history.msg }} {{ history.created_on }} - From a891ed38e2e52a92ad5b650ee5e56259f96330c8 Mon Sep 17 00:00:00 2001 From: Khanh Ngo Date: Mon, 16 Dec 2019 11:01:16 +0700 Subject: [PATCH 5/7] Add more log and history data --- powerdnsadmin/routes/admin.py | 2 +- powerdnsadmin/routes/index.py | 62 +++++++++++++++++++++++++++++++---- 2 files changed, 56 insertions(+), 8 deletions(-) diff --git a/powerdnsadmin/routes/admin.py b/powerdnsadmin/routes/admin.py index f4ff840..8af3daa 100644 --- a/powerdnsadmin/routes/admin.py +++ b/powerdnsadmin/routes/admin.py @@ -183,7 +183,7 @@ def manage_user(): # Then delete the user result = user.delete() if result: - history = History(msg='Delete username {0}'.format(data), + history = History(msg='Delete user {0}'.format(data), created_by=current_user.username) history.add() return make_response( diff --git a/powerdnsadmin/routes/index.py b/powerdnsadmin/routes/index.py index 81008ee..2696866 100644 --- a/powerdnsadmin/routes/index.py +++ b/powerdnsadmin/routes/index.py @@ -158,6 +158,7 @@ def login(): session['user_id'] = user.id login_user(user, remember=False) session['authentication_type'] = 'OAuth' + signin_history(user.username, 'Google OAuth', True) return redirect(url_for('index.index')) if 'github_token' in session: @@ -184,6 +185,7 @@ def login(): session['user_id'] = user.id session['authentication_type'] = 'OAuth' login_user(user, remember=False) + signin_history(user.username, 'Github OAuth', True) return redirect(url_for('index.index')) if 'azure_token' in session: @@ -222,6 +224,7 @@ def login(): session['user_id'] = user.id session['authentication_type'] = 'OAuth' login_user(user, remember=False) + signin_history(user.username, 'Azure OAuth', True) return redirect(url_for('index.index')) if 'oidc_token' in session: @@ -247,6 +250,7 @@ def login(): session['user_id'] = user.id session['authentication_type'] = 'OAuth' login_user(user, remember=False) + signin_history(user.username, 'OIDC OAuth', True) return redirect(url_for('index.index')) if request.method == 'GET': @@ -269,6 +273,7 @@ def login(): auth = user.is_validate(method=auth_method, src_ip=request.remote_addr) if auth == False: + signin_history(user.username, 'LOCAL', False) return render_template('login.html', saml_enabled=SAML_ENABLED, error='Invalid credentials') @@ -285,6 +290,7 @@ def login(): if otp_token and otp_token.isdigit(): good_token = user.verify_totp(otp_token) if not good_token: + signin_history(user.username, 'LOCAL', False) return render_template('login.html', saml_enabled=SAML_ENABLED, error='Invalid credentials') @@ -294,6 +300,7 @@ def login(): error='Token required') login_user(user, remember=remember_me) + signin_history(user.username, 'LOCAL', True) return redirect(session.get('next', url_for('index.index'))) @@ -306,6 +313,38 @@ def clear_session(): logout_user() +def signin_history(username, authenticator, success): + # Get user ip address + if request.headers.getlist("X-Forwarded-For"): + request_ip = request.headers.getlist("X-Forwarded-For")[0] + request_ip = request_ip.split(',')[0] + else: + request_ip = request.remote_addr + + # Write log + str_success = 'succeeded' if success else 'failed' + if success: + str_success = 'succeeded' + current_app.logger.info( + "User {} authenticated successfully via {} from {}".format( + username, authenticator, request_ip)) + else: + str_success = 'failed' + current_app.logger.warning( + "User {} failed to authenticate via {} from {}".format( + username, authenticator, request_ip)) + + # Write history + History(msg='User {} authentication {}'.format(username, str_success), + detail=str({ + "username": username, + "authenticator": authenticator, + "ip_address": request_ip, + "success": 1 if success else 0 + }), + created_by='System').add() + + @index_bp.route('/logout') def logout(): if current_app.config.get( @@ -465,7 +504,7 @@ def dyndns_update(): # Record content did not change, return 'nochg' history = History( msg= - "DynDNS update: attempted update of {0} but record did not change" + "DynDNS update: attempted update of {0} but record already up-to-date" .format(hostname), created_by=current_user.username) history.add() @@ -474,10 +513,14 @@ def dyndns_update(): result = r.update(domain.name, str(ip)) if result['status'] == 'ok': history = History( - msg= - 'DynDNS update: updated {0} record {1} in zone {2}, it changed from {3} to {4}' - .format(rtype, hostname, domain.name, oldip, str(ip)), - detail=str(result), + msg='DynDNS update: updated {} successfully'.format(hostname), + detail=str({ + "domain": domain.name, + "record": hostname, + "type": rtype, + "old_value": oldip, + "new_value": str(ip) + }), created_by=current_user.username) history.add() response = 'good' @@ -510,9 +553,13 @@ def dyndns_update(): if result['status'] == 'ok': history = History( msg= - 'DynDNS update: created record {0} in zone {1}, it now represents {2}' + 'DynDNS update: created record {0} in zone {1} successfully' .format(hostname, domain.name, str(ip)), - detail=str(result), + detail=str({ + "domain": domain.name, + "record": hostname, + "value": str(ip) + }), created_by=current_user.username) history.add() response = 'good' @@ -671,6 +718,7 @@ def saml_authorized(): user.update_profile() session['authentication_type'] = 'SAML' login_user(user, remember=False) + signin_history(user.username, 'SAML', True) return redirect(url_for('index')) else: return render_template('errors/SAML.html', errors=errors) From 3196297f57d1bfaeb92de9ffb07695cf11296d87 Mon Sep 17 00:00:00 2001 From: Khanh Ngo Date: Mon, 16 Dec 2019 14:23:17 +0700 Subject: [PATCH 6/7] Fix domain template. Drop PDNS 3.x support --- powerdnsadmin/models/domain.py | 11 +-- powerdnsadmin/models/record.py | 119 +++++++-------------------------- powerdnsadmin/routes/admin.py | 64 +++++++----------- powerdnsadmin/routes/domain.py | 2 +- 4 files changed, 49 insertions(+), 147 deletions(-) diff --git a/powerdnsadmin/models/domain.py b/powerdnsadmin/models/domain.py index 90173f1..be966b0 100644 --- a/powerdnsadmin/models/domain.py +++ b/powerdnsadmin/models/domain.py @@ -3,7 +3,6 @@ import traceback from flask import current_app from urllib.parse import urljoin from distutils.util import strtobool -from distutils.version import StrictVersion from ..lib import utils from .base import db, domain_apikey @@ -57,11 +56,6 @@ class Domain(db.Model): self.PDNS_VERSION = Setting().get('pdns_version') self.API_EXTENDED_URL = utils.pdns_api_extended_uri(self.PDNS_VERSION) - if StrictVersion(self.PDNS_VERSION) >= StrictVersion('4.0.0'): - self.NEW_SCHEMA = True - else: - self.NEW_SCHEMA = False - def __repr__(self): return ''.format(self.name) @@ -214,9 +208,8 @@ class Domain(db.Model): headers = {} headers['X-API-Key'] = self.PDNS_API_KEY - if self.NEW_SCHEMA: - domain_name = domain_name + '.' - domain_ns = [ns + '.' for ns in domain_ns] + domain_name = domain_name + '.' + domain_ns = [ns + '.' for ns in domain_ns] if soa_edit_api not in ["DEFAULT", "INCREASE", "EPOCH", "OFF"]: soa_edit_api = 'DEFAULT' diff --git a/powerdnsadmin/models/record.py b/powerdnsadmin/models/record.py index ecea629..e9bda92 100644 --- a/powerdnsadmin/models/record.py +++ b/powerdnsadmin/models/record.py @@ -3,7 +3,6 @@ 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 @@ -41,55 +40,6 @@ class Record(object): 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 @@ -524,17 +474,13 @@ class Record(object): """ 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 + rrsets = self.get_rrsets(domain) + for r in rrsets: + if r['name'].rstrip('.') == self.name and r['type'] == self.type and r['records']: + self.type = r['type'] + self.status = r['records'][0]['disabled'] + self.ttl = r['ttl'] + self.data = r['records'][0]['content'] return True return False @@ -545,42 +491,23 @@ class Record(object): 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, - }] + 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 + diff --git a/powerdnsadmin/routes/admin.py b/powerdnsadmin/routes/admin.py index 8af3daa..f81633a 100644 --- a/powerdnsadmin/routes/admin.py +++ b/powerdnsadmin/routes/admin.py @@ -2,7 +2,6 @@ import re import json import traceback from ast import literal_eval -from distutils.version import StrictVersion from flask import Blueprint, render_template, make_response, url_for, current_app, request, redirect, jsonify, abort, flash from flask_login import login_required, current_user @@ -829,59 +828,42 @@ def create_template_from_zone(): created_by=current_user.username) history.add() + # After creating the domain in Domain Template in the, + # local DB. We add records into it Record Template. records = [] - r = Record() domain = Domain.query.filter(Domain.name == domain_name).first() if domain: - # query domain info from PowerDNS API - zone_info = r.get_record_data(domain.name) - if zone_info: - jrecords = zone_info['records'] - - if StrictVersion(Setting().get( - 'pdns_version')) >= StrictVersion('4.0.0'): - for jr in jrecords: - if jr['type'] in Setting().get_records_allow_to_edit(): - name = '@' if jr['name'] == domain_name else re.sub( - r'\.{}$'.format(domain_name), '', jr['name']) - for subrecord in jr['records']: - record = DomainTemplateRecord( - name=name, - type=jr['type'], - status=True - if subrecord['disabled'] else False, - ttl=jr['ttl'], - data=subrecord['content'], - comment=jr['comment_data']['content']) - records.append(record) - else: - for jr in jrecords: - if jr['type'] in Setting().get_records_allow_to_edit(): - name = '@' if jr['name'] == domain_name else re.sub( - r'\.{}$'.format(domain_name), '', jr['name']) - record = DomainTemplateRecord( + # Query zone's rrsets from PowerDNS API + rrsets = Record().get_rrsets(domain.name) + if rrsets: + for r in rrsets: + name = '@' if r['name'] == domain_name + '.' else r[ + 'name'].replace('.{}.'.format(domain_name), '') + for record in r['records']: + t_record = DomainTemplateRecord( name=name, - type=jr['type'], - status=True if jr['disabled'] else False, - ttl=jr['ttl'], - data=jr['content'], - comment=jr['comment_data']['content']) - records.append(record) + type=r['type'], + status=False if record['disabled'] else True, + ttl=r['ttl'], + data=record['content']) + records.append(t_record) - result_records = t.replace_records(records) + result = t.replace_records(records) - if result_records['status'] == 'ok': + if result['status'] == 'ok': return make_response( jsonify({ 'status': 'ok', 'msg': result['msg'] }), 200) else: + # Revert the domain template (remove it) + # ff we cannot add records. t.delete_template() return make_response( jsonify({ 'status': 'error', - 'msg': result_records['msg'] + 'msg': result['msg'] }), 500) else: @@ -918,7 +900,7 @@ def edit_template(template): record = DomainTemplateRecord( name=jr.name, type=jr.type, - status='Disabled' if jr.status else 'Active', + status='Active' if jr.status else 'Disabled', ttl=jr.ttl, data=jr.data, comment=jr.comment if jr.comment else '') @@ -952,14 +934,14 @@ def apply_records(template): type = j['record_type'] data = j['record_data'] comment = j['record_comment'] - disabled = True if j['record_status'] == 'Disabled' else False + status = 0 if j['record_status'] == 'Disabled' else 1 ttl = int(j['record_ttl']) if j['record_ttl'] else 3600 dtr = DomainTemplateRecord(name=name, type=type, data=data, comment=comment, - status=disabled, + status=status, ttl=ttl) records.append(dtr) diff --git a/powerdnsadmin/routes/domain.py b/powerdnsadmin/routes/domain.py index c3c5880..b4962f6 100644 --- a/powerdnsadmin/routes/domain.py +++ b/powerdnsadmin/routes/domain.py @@ -168,7 +168,7 @@ def add(): record_row = { 'record_data': template_record.data, 'record_name': template_record.name, - 'record_status': template_record.status, + 'record_status': 'Active' if template_record.status else 'Disabled', 'record_ttl': template_record.ttl, 'record_type': template_record.type, 'comment_data': [{'content': template_record.comment, 'account': ''}] From 3e961d7b214ae597ed3f1fb77df0273a5f3b2d68 Mon Sep 17 00:00:00 2001 From: Khanh Ngo Date: Mon, 16 Dec 2019 16:27:18 +0700 Subject: [PATCH 7/7] LGTM fixes --- powerdnsadmin/models/record.py | 2 +- powerdnsadmin/routes/admin.py | 1 - powerdnsadmin/routes/index.py | 1 - 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/powerdnsadmin/models/record.py b/powerdnsadmin/models/record.py index e9bda92..7cf9007 100644 --- a/powerdnsadmin/models/record.py +++ b/powerdnsadmin/models/record.py @@ -57,7 +57,7 @@ class Record(object): current_app.logger.error( "Cannot fetch domain's record data from remote powerdns api. DETAIL: {0}" .format(e)) - return False + return [] return jdata['rrsets'] diff --git a/powerdnsadmin/routes/admin.py b/powerdnsadmin/routes/admin.py index f81633a..defe591 100644 --- a/powerdnsadmin/routes/admin.py +++ b/powerdnsadmin/routes/admin.py @@ -1,4 +1,3 @@ -import re import json import traceback from ast import literal_eval diff --git a/powerdnsadmin/routes/index.py b/powerdnsadmin/routes/index.py index 812fbe9..8539ef5 100644 --- a/powerdnsadmin/routes/index.py +++ b/powerdnsadmin/routes/index.py @@ -326,7 +326,6 @@ def signin_history(username, authenticator, success): request_ip = request.remote_addr # Write log - str_success = 'succeeded' if success else 'failed' if success: str_success = 'succeeded' current_app.logger.info(