diff options
Diffstat (limited to '')
-rwxr-xr-x | src/powerdns/pdns-backend-rgw.py | 284 |
1 files changed, 284 insertions, 0 deletions
diff --git a/src/powerdns/pdns-backend-rgw.py b/src/powerdns/pdns-backend-rgw.py new file mode 100755 index 00000000..db409b32 --- /dev/null +++ b/src/powerdns/pdns-backend-rgw.py @@ -0,0 +1,284 @@ +#!/usr/bin/python +''' +A backend for PowerDNS to direct RADOS Gateway bucket traffic to the correct regions. + +For example, two regions exist, US and EU. + +EU: o.myobjects.eu +US: o.myobjects.us + +A global domain o.myobjects.com exists. + +Bucket 'foo' exists in the region EU and 'bar' in US. + +foo.o.myobjects.com will return a CNAME to foo.o.myobjects.eu +bar.o.myobjects.com will return a CNAME to foo.o.myobjects.us + +The HTTP Remote Backend from PowerDNS is used in this case: http://doc.powerdns.com/html/remotebackend.html + +PowerDNS must be compiled with Remote HTTP backend support enabled, this is not default. + +Configuration for PowerDNS: + +launch=remote +remote-connection-string=http:url=http://localhost:6780/dns + +Usage for this backend is showed by invoking with --help. See rgw-pdns.conf.in for a configuration example + +The ACCESS and SECRET key pair requires the caps "metadata=read" + +To test: + +$ curl -X GET http://localhost:6780/dns/lookup/foo.o.myobjects.com/ANY + +Should return something like: + +{ + "result": [ + { + "content": "foo.o.myobjects.eu", + "qtype": "CNAME", + "qname": "foo.o.myobjects.com", + "ttl": 60 + } + ] +} + +''' + +# Copyright: Wido den Hollander <wido@42on.com> 2014 +# License: LGPL2.1 + +from ConfigParser import SafeConfigParser, NoSectionError +from flask import abort, Flask, request, Response +from hashlib import sha1 as sha +from time import gmtime, strftime +from six.moves.urllib.parse import urlparse +import argparse +import base64 +import hmac +import json +import pycurl +import StringIO +import urllib +import os +import sys + +config_locations = ['rgw-pdns.conf', '~/rgw-pdns.conf', '/etc/ceph/rgw-pdns.conf'] + +# PowerDNS expects a 200 what ever happends and always wants +# 'result' to 'true' if the query fails +def abort_early(): + return json.dumps({'result': 'true'}) + "\n" + +# Generate the Signature string for S3 Authorization with the RGW Admin API +def generate_signature(method, date, uri, headers=None): + sign = "%s\n\n" % method + + if 'Content-Type' in headers: + sign += "%s\n" % headers['Content-Type'] + else: + sign += "\n" + + sign += "%s\n/%s/%s" % (date, config['rgw']['admin_entry'], uri) + h = hmac.new(config['rgw']['secret_key'].encode('utf-8'), sign.encode('utf-8'), digestmod=sha) + return base64.encodestring(h.digest()).strip() + +def generate_auth_header(signature): + return str("AWS %s:%s" % (config['rgw']['access_key'], signature.decode('utf-8'))) + +# Do a HTTP request to the RGW Admin API +def do_rgw_request(uri, params=None, data=None, headers=None): + if headers == None: + headers = {} + + headers['Date'] = strftime("%a, %d %b %Y %H:%M:%S +0000", gmtime()) + signature = generate_signature("GET", headers['Date'], uri, headers) + headers['Authorization'] = generate_auth_header(signature) + + query = None + if params != None: + query = '&'.join("%s=%s" % (key,val) for (key,val) in params.iteritems()) + + c = pycurl.Curl() + b = StringIO.StringIO() + url = "http://" + config['rgw']['endpoint'] + "/" + config['rgw']['admin_entry'] + "/" + uri + "?format=json" + if query != None: + url += "&" + urllib.quote_plus(query) + + http_headers = [] + for header in headers.keys(): + http_headers.append(header + ": " + headers[header]) + + c.setopt(pycurl.URL, str(url)) + c.setopt(pycurl.HTTPHEADER, http_headers) + c.setopt(pycurl.WRITEFUNCTION, b.write) + c.setopt(pycurl.FOLLOWLOCATION, 0) + c.setopt(pycurl.CONNECTTIMEOUT, 5) + c.perform() + + response = b.getvalue() + if len(response) > 0: + return json.loads(response) + + return None + +def get_radosgw_metadata(key): + return do_rgw_request('metadata', {'key': key}) + +# Returns a string of the region where the bucket is in +def get_bucket_region(bucket): + meta = get_radosgw_metadata("bucket:%s" % bucket) + bucket_id = meta['data']['bucket']['bucket_id'] + meta_instance = get_radosgw_metadata("bucket.instance:%s:%s" % (bucket, bucket_id)) + region = meta_instance['data']['bucket_info']['region'] + return region + +# Returns the correct host for the bucket based on the regionmap +def get_bucket_host(bucket, region_map): + region = get_bucket_region(bucket) + return bucket + "." + region_map[region] + +# This should support multiple endpoints per region! +def parse_region_map(map): + regions = {} + for region in map['regions']: + url = urlparse(region['val']['endpoints'][0]) + regions.update({region['key']: url.netloc}) + + return regions + +def str2bool(s): + return s.lower() in ("yes", "true", "1") + +def init_config(): + parser = argparse.ArgumentParser() + parser.add_argument("--config", help="The configuration file to use.", action="store") + + args = parser.parse_args() + + defaults = { + 'listen_addr': '127.0.0.1', + 'listen_port': '6780', + 'dns_zone': 'rgw.local.lan', + 'dns_soa_record': 'dns1.icann.org. hostmaster.icann.org. 2012080849 7200 3600 1209600 3600', + 'dns_soa_ttl': '3600', + 'dns_default_ttl': '60', + 'rgw_endpoint': 'localhost:8080', + 'rgw_admin_entry': 'admin', + 'rgw_access_key': 'access', + 'rgw_secret_key': 'secret', + 'debug': False + } + + cfg = SafeConfigParser(defaults) + if args.config == None: + cfg.read(config_locations) + else: + if not os.path.isfile(args.config): + print("Could not open configuration file %s" % args.config) + sys.exit(1) + + cfg.read(args.config) + + config_section = 'powerdns' + + try: + return { + 'listen': { + 'port': cfg.getint(config_section, 'listen_port'), + 'addr': cfg.get(config_section, 'listen_addr') + }, + 'dns': { + 'zone': cfg.get(config_section, 'dns_zone'), + 'soa_record': cfg.get(config_section, 'dns_soa_record'), + 'soa_ttl': cfg.get(config_section, 'dns_soa_ttl'), + 'default_ttl': cfg.get(config_section, 'dns_default_ttl') + }, + 'rgw': { + 'endpoint': cfg.get(config_section, 'rgw_endpoint'), + 'admin_entry': cfg.get(config_section, 'rgw_admin_entry'), + 'access_key': cfg.get(config_section, 'rgw_access_key'), + 'secret_key': cfg.get(config_section, 'rgw_secret_key') + }, + 'debug': str2bool(cfg.get(config_section, 'debug')) + } + + except NoSectionError: + return None + +def generate_app(config): + # The Flask App + app = Flask(__name__) + + # Get the RGW Region Map + region_map = parse_region_map(do_rgw_request('config')) + + @app.route('/') + def index(): + abort(404) + + @app.route("/dns/lookup/<qname>/<qtype>") + def bucket_location(qname, qtype): + if len(qname) == 0: + return abort_early() + + split = qname.split(".", 1) + if len(split) != 2: + return abort_early() + + bucket = split[0] + zone = split[1] + + # If the received qname doesn't match our zone we abort + if zone != config['dns']['zone']: + return abort_early() + + # We do not serve MX records + if qtype == "MX": + return abort_early() + + # The basic result we always return, this is what PowerDNS expects. + response = {'result': 'true'} + result = {} + + # A hardcoded SOA response (FIXME!) + if qtype == "SOA": + result.update({'qtype': qtype}) + result.update({'qname': qname}) + result.update({'content': config['dns']['soa_record']}) + result.update({'ttl': config['dns']['soa_ttl']}) + else: + region_hostname = get_bucket_host(bucket, region_map) + result.update({'qtype': 'CNAME'}) + result.update({'qname': qname}) + result.update({'content': region_hostname}) + result.update({'ttl': config['dns']['default_ttl']}) + + if len(result) > 0: + res = [] + res.append(result) + response['result'] = res + + return json.dumps(response, indent=1) + "\n" + + return app + + +# Initialize the configuration and generate the Application +config = init_config() +if config == None: + print("Could not parse configuration file. " + "Tried to parse %s" % config_locations) + sys.exit(1) + +app = generate_app(config) +app.debug = config['debug'] + +# Only run the App if this script is invoked from a Shell +if __name__ == '__main__': + app.run(host=config['listen']['addr'], port=config['listen']['port']) + +# Otherwise provide a variable called 'application' for mod_wsgi +else: + application = app |