diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..189e361 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +**/__pycache__ +.idea +*-egg-info diff --git a/CHANGELOG b/CHANGELOG new file mode 100644 index 0000000..e69de29 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..059b6ce --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 Deutsche Telekom Pan-Net s.r.o + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.MD b/README.MD new file mode 100644 index 0000000..55245e4 --- /dev/null +++ b/README.MD @@ -0,0 +1,108 @@ +# pdns-umanager + +PDNS micro manager is a helper script for managing DNS zones in PowerDNS from +a source file (or stdin). + +It's a quick-win solution for those lacking interfaces or authorization for delegation +and sub-zone management. + +## Usage + +``` +virtualenv -p python3 .venv +. .venv/bin/activate +pip3 install git+https://github.com/pan-net-security/pdns-umanager.git >/dev/null +pdnsumanager --help +usage: pdnsumanager [-h] [--pdns-server-url PDNS_SERVER_URL] + [--pdns-api-key PDNS_API_KEY] [-f FILE] + [--ca-cert-file CA_CERT_FILE] [-d] [--dry-run] + +A utility to keep pdns zones tight and clean + +optional arguments: + -h, --help show this help message and exit + --pdns-server-url PDNS_SERVER_URL + The FQDN of the PowerDNS API endpoint + --pdns-api-key PDNS_API_KEY + The API key for the zone + -f FILE, --file FILE A file to process. If not defined, stdin is assumed + --ca-cert-file CA_CERT_FILE + A file containing root CA(s) for TLS verification + -d, --debug increase output verbosity + --dry-run don't make changes, only print +``` + +## Running + +``` +export PDNS_API_KEY="somekey" +export PDNS_SERVER_URL="https://pdns-api.example.org/" +pdns-umanager --file tests/test.yaml -d +2019-01-18 11:15:02,432 main+82: DEBUG [8774] PDNS_SERVER_URL: https://pdns-api.example.org/ +2019-01-18 11:15:02,432 main+83: DEBUG [8774] PDNS_API_KEY: **** +2019-01-18 11:15:02,432 main+84: DEBUG [8774] CA_CERT_FILE: /etc/ssl/certs/ca-certificates.crt +2019-01-18 11:15:02,436 main+102: DEBUG [8774] Loaded zone content from file +2019-01-18 11:15:02,436 main+103: DEBUG [8774] {'example.org': {'wwww': {'records': ['web.frontend.example.org']}, 'monitoring': {'type': 'cNaMe', 'records': ['web.monitoring.example.org']}, 'hello': {'type': 'A', 'records': ['172.162.3.5', '172.162.3.6']}, 'web': {'type': 'A', 'records': ['']}}} +2019-01-18 11:15:02,436 config+58: DEBUG [8774] Setting up config for PDNSJanitor +2019-01-18 11:15:02,436 setup_api+230: DEBUG [8774] API Host set to 'https://pdns-api.example.org' +2019-01-18 11:15:02,436 setup_api+234: DEBUG [8774] Full URL API set to 'https://pdns-api.example.org/api/v1/servers/localhost/' +2019-01-18 11:15:02,436 zone_order+69: DEBUG [8774] Found the following declared zones: +example.org +2019-01-18 11:15:02,436 zone_order+75: DEBUG [8774] Sorted zones: +example.org +2019-01-18 11:15:02,436 run+81: INFO [8774] * ZONE: 'example.org' +2019-01-18 11:15:02,436 query_zone+193: DEBUG [8774] Check if the zone 'example.org.' exists and is readable +2019-01-18 11:15:02,450 _new_conn+813: DEBUG [8774] Starting new HTTPS connection (1): pdns-api.example.org:443 +2019-01-18 11:15:02,698 _make_request+393: DEBUG [8774] https://pdns-api.example.org:443 "GET /api/v1/servers/localhost/zones/example.org. HTTP/1.1" 403 None +2019-01-18 11:15:02,704 query_zone+205: ERROR [8774] Unable to read zone 'example.org.'. +2019-01-18 11:15:02,705 query_zone+207: INFO [8774] Not enough rights to list 'example.org.' +2019-01-18 11:15:02,705 query_zone+208: DEBUG [8774] Server returned status '403': 'Not authorized for zone "example.org."!' +2019-01-18 11:15:02,705 run+90: WARNING [8774] Skipping zone 'example.org'... +``` + +## File format - yaml + +``` + +# the data structure should be read as: +# +# : +# : +# type: +# records: +# - +# ttls: +# +# +# DNS canonical format is optional (it's enforced in the script) + +example.org: + # 'type' can be omitted, defaults to 'cname' + wwww: + records: + - "web.frontend.example.org" + + # 'type can also be explicit, case insensitive + monitoring: + type: 'cNaMe' + records: + - "web.monitoring.example.org" + + # records could be multiple values + hello: + type: "A" + records: + - "172.162.3.5" + - "172.162.3.6" + + # empty records effectively delete the rrset + web: + type: "A" + records: + - "" + +# # reserved type, 'zone'; coming soon +# monitoring.example.org: +# type: "zone" + +``` \ No newline at end of file diff --git a/pdnsumanager/__init__.py b/pdnsumanager/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pdnsumanager/pdnsjanitor.py b/pdnsumanager/pdnsjanitor.py new file mode 100644 index 0000000..df22dc2 --- /dev/null +++ b/pdnsumanager/pdnsjanitor.py @@ -0,0 +1,242 @@ +""" +given a file with data yaml data in the format: + +example.org: + service: + type: "CNAME" + value: "www.somezone.com" + smtp: + type: "A" + value: "192.163.62.1" + ttl: 500 + www: + type: "A" + value: "" + ttl: 500 + foo: + type: "zone" + +This will result in: + +- creation of rrset: service.example.org CNAME www.somezone.com +- creation of rrset: smtp.service.org A 192.163.62.1 +- deletion of rrset: www +- creation of zone: "acme.example.org" + +TODO: +- This script could at the end manage the whole zone, including deleting the rrsets +which were added manually or not by this script + +""" + +import sys +import requests +import json +import logging +import urllib +import operator +import ipaddress + +DEFAULT_TYPE = "CNAME" +DEFAULT_TTL = "150" + + +class PDNSJanitor(object): + """ + Main execution + """ + + def __init__(self): + pass + + def config(self, api_host, api_key, api_version, zones_data): + self.apihost = api_host + self.apikey = api_key + self.zones_data = zones_data + + self.apiversion = api_version + logging.debug("Setting up config for PDNSJanitor") + self.setup_api() + self.zones = self.zone_order() + + def zone_order(self): + """ + Zones must be created in order of hierarchy. This will return a list of zones + sorted by the smalles zone number to the greatest zone number + :return: list + """ + zones = self.zones_data.keys() + logging.debug("Found the following declared zones:\n%s", "\n".join([i for i in zones])) + zone_by_length = dict() + for i in [k for k in zones]: + zone_by_length[i] = len(i.split(".")) + + sorted_zones = [x[0] for x in sorted(zone_by_length.items(), key=operator.itemgetter(1))] + logging.debug("Sorted zones:\n%s", "\n".join(sorted_zones)) + return sorted_zones + + def run(self): + for zone in self.zones: + zone = zone.strip() + logging.info("* ZONE: '%s'", zone) + + zone_check_result, zone_data = self.query_zone(self.ensure_dot(zone)) + if zone_data: + logging.info("Zone '%s' exists and is readable", zone) + else: + if zone_check_result.status_code == 404: + self.add_zone(zone) + else: + logging.warning("Skipping zone '%s'...", zone) + continue + + logging.info("Applying updates for zone '%s'", zone) + self.add_record(zone=zone, rrsets=self.zones_data[zone]) + + def add_record(self, zone, rrsets): + """ + Add new DNS rrset/records + + """ + + add_record_api_uri = self.uri + "zones/" + self.ensure_dot(zone) + if rrsets is None: + return + else: + for rrset_name in rrsets.keys(): + logging.debug("RRSET_NAME: %s", rrset_name) + + rrset_type = rrsets[rrset_name].get('type', None) + rrset_ttl = rrsets[rrset_name].get('ttl', None) + rrset_records = rrsets[rrset_name].get('records', None) + + if rrset_type is None: + logging.warning("Missing 'type' for record '%s' in zone '%s', using '%s'", + rrset_name, zone, DEFAULT_TYPE) + rrset_type = DEFAULT_TYPE + + if rrset_ttl is None: + logging.warning("Missing 'ttl' for record '%s' in zone '%s', using '%s'", + rrset_name, zone, DEFAULT_TTL) + rrset_ttl = DEFAULT_TTL + + if rrset_records is None: + logging.warning("[!] On zone '%s' rrset '%s' the record is empty, this will effectively erase all " + "records for '%s' type '%s'", rrset_name, rrset_name, rrset_type) + logging.debug(" RRSET_TYPE: %s", rrset_type) + logging.debug(" RRSET_TTL: %s", rrset_ttl) + logging.debug(" RRSET_RECORDS: %s", rrset_records) + + rrset_records_payload = [] + for record in rrset_records: + if record != "": + try: + record = ipaddress.ip_address(record).compressed + except ValueError: + record = self.ensure_dot(record) + + rrset_records_payload.append({ + "content": record.lower(), + "disabled": False, + "set-ptr": False, + }) + + logging.debug(" RECORD_PAYLOAD: %s", rrset_records_payload) + + payload = { + "rrsets": [ + { + "name": (self.ensure_dot(rrset_name) + self.ensure_dot(zone)).lower(), + "type": rrset_type.lower(), + "ttl": rrset_ttl, + "records": rrset_records_payload, + "changetype": "REPLACE", + } + ] + } + + logging.debug("Patching zone '%s' with payload %s", zone, payload) + try: + patch_zone = requests.patch(add_record_api_uri, data=json.dumps(payload), headers=self.headers) + if patch_zone.status_code == 204: + pass + else: + logging.error("Failed to update zone '%s', zone. Server returned status code '%s'", + zone, patch_zone.status_code) + logging.error("The server returned the following body message: %s", patch_zone.text) + logging.debug(patch_zone) + sys.exit(1) + except Exception as e: + logging.debug(e) + logging.error("There as an exception while updating zone '%s'", zone) + sys.exit(1) + + logging.info("OK: Done updating zone '%s' with rrset '%s'.", zone, rrset_name) + + def add_zone(self, zone): + """ + This is a placemark function to be implemented + """ + logging.info("Creating zone '%s", zone) + pass + + def query_zone(self, zone): + """ + Query a specific DNS zone + + :return: dictionary + + """ + zone_api_url = self.uri + "zones/" + zone + + try: + logging.debug("Check if the zone '%s' exists and is readable", zone) + r = requests.get(zone_api_url, headers=self.headers) + except Exception as e: + logging.debug(e) + logging.error("Failed to check zone '%s'.", zone) + sys.exit(1) + + if r.status_code == 200: + python_data = json.loads(r.text) + logging.debug("Content of zone '%s: %s", zone, json.dumps(python_data, indent=4)) + return r, python_data + else: + logging.error("Unable to read zone '%s'.", zone) + if r.status_code == 403: + logging.info("Not enough rights to list '%s'", zone) + logging.debug("Server returned status '%s': '%s'", r.status_code, r.text) + + return r, None + + def setup_api(self): + """ + Setup api endpoint + + """ + self.headers = { + 'Content-Type': 'application/json', + 'Connection': 'keep-alive', + 'Accept-Language': 'en-us', + 'Accept-Encoding': 'gzip, deflate', + 'X-API-Key': self.apikey + } + + parsed_apihost = urllib.parse.urlparse(self.apihost) + parsed_apihost = parsed_apihost._replace(path="") + if not parsed_apihost.scheme: + parsed_apihost = parsed_apihost._replace(scheme="https") + self.uri = parsed_apihost.geturl() + logging.debug("API Host set to '%s'", self.uri) + + self.uri = self.uri + self.apiversion + "/servers/localhost/" + + logging.debug("Full URL API set to '%s'", self.uri) + + def ensure_dot(self, text): + """ + This function makes sure a string contains a dot at the end + """ + if not text.endswith("."): + text = text + "." + return text diff --git a/pdnsumanager/pdnsumanager.py b/pdnsumanager/pdnsumanager.py new file mode 100644 index 0000000..48d0585 --- /dev/null +++ b/pdnsumanager/pdnsumanager.py @@ -0,0 +1,116 @@ +import logging +import os +import argparse +import sys +import yaml + +import pdnsumanager.pdnsjanitor as janitor + + +PDNS_SERVER_URL = os.getenv('PDNS_SERVER_URL') +PDNS_API_KEY = os.getenv('PDNS_API_KEY') + +def main(): + + parser = argparse.ArgumentParser( + description="A utility to keep pdns zones tight and clean" + ) + + parser.add_argument( + "--pdns-server-url", + help="The FQDN of the PowerDNS API endpoint", + action='store', + dest='pdns_server_url', + default=os.environ.get('PDNS_SERVER_URL'), + type=str + ) + + parser.add_argument( + "--pdns-api-key", + help="The API key for the zone", + action='store', + dest='pdns_api_key', + default=os.environ.get('PDNS_API_KEY'), + type=str + ) + + parser.add_argument( + "-f", + "--file", + help="A file to process. If not defined, stdin is assumed", + action='store', + dest='file', + default="", + type=str + ) + + parser.add_argument( + "--ca-cert-file", + help="A file containing root CA(s) for TLS verification", + action='store', + dest='ca_cert_file', + default=os.environ.get('CA_CERT_FILE', "/etc/ssl/certs/ca-certificates.crt"), + type=str + ) + + parser.add_argument("-d", "--debug", help="increase output verbosity", + action="store_true") + parser.add_argument("--dry-run", help="don't make changes, only print", + action="store_true") + + args = parser.parse_args() + config = vars(args) + + if config['debug']: + logLevel = logging.DEBUG + logFormat = '%(asctime)s %(funcName)s+%(lineno)s:\t%(levelname)-8s [%(process)d] %(message)s' + else: + logLevel = logging.INFO + logFormat = '%(asctime)s: %(levelname)-8s [%(process)d] %(message)s' + + + if not config['pdns_server_url']: + msg = "PowerDNS URL is not defined" + parser.error(msg) + + if not config['pdns_api_key']: + msg = "PowerDNS API KEY is not defined" + parser.error(msg) + + logging.basicConfig(level=logLevel,format=logFormat) + + logging.debug("PDNS_SERVER_URL: %s", config['pdns_server_url']) + logging.debug("PDNS_API_KEY: ****") + logging.debug("CA_CERT_FILE: %s", config['ca_cert_file']) + + if not os.path.isfile(config['ca_cert_file']): + logging.warning("Unable to find '%s'", config['ca_cert_file']) + else: + os.environ['REQUESTS_CA_BUNDLE'] = config['ca_cert_file'] + + data_yaml = "" + if(len(args.file)==0): + try: + data_yaml = yaml.safe_load(sys.stdin) + logging.debug("Loaded zone content from stdin") + logging.debug(data_yaml) + except Exception as e: + logging.error(e) + else: + try: + data_yaml = yaml.safe_load(open(args.file,'r').read()) + logging.debug("Loaded zone content from file") + logging.debug(data_yaml) + except Exception as e: + logging.error(e) + + janitor_object = janitor.PDNSJanitor() + janitor_object.config(api_host=config['pdns_server_url'], + api_key=config['pdns_api_key'], + api_version="/api/v1", + zones_data=data_yaml) + janitor_object.run() + + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..42ad3f4 --- /dev/null +++ b/setup.py @@ -0,0 +1,24 @@ +from setuptools import setup + +setup( + name='pdnsumanager', + version='0.0.1', + description='A PDNS micro manager tool for the poor', + author='Diogenes Santos de Jesus', + author_email='diogenes.jesus@pan-net.eu', + url='https://github.com/pan-net-security/pdns-umanager', + packages=['pdnsumanager'], + install_requires=['requests', + 'pyaml' + ], + keywords=['ci', 'devops', 'automation'], + classifiers=[ + 'Environment :: Console', + 'Programming Language :: Python :: 3', + 'Operating System :: OS Independent', + 'Intended Audience :: Developers', + 'Topic :: Software Development :: Libraries :: Python Modules', + 'License :: OSI Approved :: MIT License', + ], + entry_points={'console_scripts': ['pdns-umanager = pdnsumanager.pdnsumanager:main']}, +) diff --git a/tests/test.yaml b/tests/test.yaml new file mode 100644 index 0000000..3929509 --- /dev/null +++ b/tests/test.yaml @@ -0,0 +1,42 @@ +--- +# the data structure should be read as: +# +# : +# : +# type: +# records: +# - +# - +# ttls: +# +# +# DNS canonical format is optional (it's enforced in the script) + +example.org: + # 'type' can be omitted, defaults to 'cname' + wwww: + records: + - "web.frontend.example.org" + + # 'type can also be explicit, case insensitive + monitoring: + type: 'cNaMe' + records: + - "web.monitoring.example.org" + + # records could be multiple values + hello: + type: "A" + records: + - "172.162.3.5" + - "172.162.3.6" + + # empty records effectively delete the rrset + web: + type: "A" + records: + - "" + +# # reserved type, 'zone'; coming soon +# monitoring: +# type: "zone" \ No newline at end of file