]>
Commit | Line | Data |
---|---|---|
7c673cae FG |
1 | #!/usr/bin/python |
2 | ''' | |
3 | A backend for PowerDNS to direct RADOS Gateway bucket traffic to the correct regions. | |
4 | ||
5 | For example, two regions exist, US and EU. | |
6 | ||
7 | EU: o.myobjects.eu | |
8 | US: o.myobjects.us | |
9 | ||
10 | A global domain o.myobjects.com exists. | |
11 | ||
12 | Bucket 'foo' exists in the region EU and 'bar' in US. | |
13 | ||
14 | foo.o.myobjects.com will return a CNAME to foo.o.myobjects.eu | |
15 | bar.o.myobjects.com will return a CNAME to foo.o.myobjects.us | |
16 | ||
17 | The HTTP Remote Backend from PowerDNS is used in this case: http://doc.powerdns.com/html/remotebackend.html | |
18 | ||
19 | PowerDNS must be compiled with Remote HTTP backend support enabled, this is not default. | |
20 | ||
21 | Configuration for PowerDNS: | |
22 | ||
23 | launch=remote | |
24 | remote-connection-string=http:url=http://localhost:6780/dns | |
25 | ||
26 | Usage for this backend is showed by invoking with --help. See rgw-pdns.conf.in for a configuration example | |
27 | ||
28 | The ACCESS and SECRET key pair requires the caps "metadata=read" | |
29 | ||
30 | To test: | |
31 | ||
32 | $ curl -X GET http://localhost:6780/dns/lookup/foo.o.myobjects.com/ANY | |
33 | ||
34 | Should return something like: | |
35 | ||
36 | { | |
37 | "result": [ | |
38 | { | |
39 | "content": "foo.o.myobjects.eu", | |
40 | "qtype": "CNAME", | |
41 | "qname": "foo.o.myobjects.com", | |
42 | "ttl": 60 | |
43 | } | |
44 | ] | |
45 | } | |
46 | ||
47 | ''' | |
48 | ||
49 | # Copyright: Wido den Hollander <wido@42on.com> 2014 | |
9f95a23c | 50 | # License: LGPL-2.1 or LGPL-3.0 |
7c673cae FG |
51 | |
52 | from ConfigParser import SafeConfigParser, NoSectionError | |
53 | from flask import abort, Flask, request, Response | |
54 | from hashlib import sha1 as sha | |
55 | from time import gmtime, strftime | |
f67539c2 | 56 | from urllib.parse import urlparse |
7c673cae FG |
57 | import argparse |
58 | import base64 | |
59 | import hmac | |
60 | import json | |
61 | import pycurl | |
62 | import StringIO | |
63 | import urllib | |
64 | import os | |
65 | import sys | |
66 | ||
67 | config_locations = ['rgw-pdns.conf', '~/rgw-pdns.conf', '/etc/ceph/rgw-pdns.conf'] | |
68 | ||
69 | # PowerDNS expects a 200 what ever happends and always wants | |
70 | # 'result' to 'true' if the query fails | |
71 | def abort_early(): | |
72 | return json.dumps({'result': 'true'}) + "\n" | |
73 | ||
74 | # Generate the Signature string for S3 Authorization with the RGW Admin API | |
75 | def generate_signature(method, date, uri, headers=None): | |
76 | sign = "%s\n\n" % method | |
77 | ||
78 | if 'Content-Type' in headers: | |
79 | sign += "%s\n" % headers['Content-Type'] | |
80 | else: | |
81 | sign += "\n" | |
82 | ||
83 | sign += "%s\n/%s/%s" % (date, config['rgw']['admin_entry'], uri) | |
84 | h = hmac.new(config['rgw']['secret_key'].encode('utf-8'), sign.encode('utf-8'), digestmod=sha) | |
85 | return base64.encodestring(h.digest()).strip() | |
86 | ||
87 | def generate_auth_header(signature): | |
88 | return str("AWS %s:%s" % (config['rgw']['access_key'], signature.decode('utf-8'))) | |
89 | ||
90 | # Do a HTTP request to the RGW Admin API | |
91 | def do_rgw_request(uri, params=None, data=None, headers=None): | |
92 | if headers == None: | |
93 | headers = {} | |
94 | ||
95 | headers['Date'] = strftime("%a, %d %b %Y %H:%M:%S +0000", gmtime()) | |
96 | signature = generate_signature("GET", headers['Date'], uri, headers) | |
97 | headers['Authorization'] = generate_auth_header(signature) | |
98 | ||
99 | query = None | |
100 | if params != None: | |
101 | query = '&'.join("%s=%s" % (key,val) for (key,val) in params.iteritems()) | |
102 | ||
103 | c = pycurl.Curl() | |
104 | b = StringIO.StringIO() | |
105 | url = "http://" + config['rgw']['endpoint'] + "/" + config['rgw']['admin_entry'] + "/" + uri + "?format=json" | |
106 | if query != None: | |
107 | url += "&" + urllib.quote_plus(query) | |
108 | ||
109 | http_headers = [] | |
110 | for header in headers.keys(): | |
111 | http_headers.append(header + ": " + headers[header]) | |
112 | ||
113 | c.setopt(pycurl.URL, str(url)) | |
114 | c.setopt(pycurl.HTTPHEADER, http_headers) | |
115 | c.setopt(pycurl.WRITEFUNCTION, b.write) | |
116 | c.setopt(pycurl.FOLLOWLOCATION, 0) | |
117 | c.setopt(pycurl.CONNECTTIMEOUT, 5) | |
118 | c.perform() | |
119 | ||
120 | response = b.getvalue() | |
121 | if len(response) > 0: | |
122 | return json.loads(response) | |
123 | ||
124 | return None | |
125 | ||
126 | def get_radosgw_metadata(key): | |
127 | return do_rgw_request('metadata', {'key': key}) | |
128 | ||
129 | # Returns a string of the region where the bucket is in | |
130 | def get_bucket_region(bucket): | |
131 | meta = get_radosgw_metadata("bucket:%s" % bucket) | |
132 | bucket_id = meta['data']['bucket']['bucket_id'] | |
133 | meta_instance = get_radosgw_metadata("bucket.instance:%s:%s" % (bucket, bucket_id)) | |
134 | region = meta_instance['data']['bucket_info']['region'] | |
135 | return region | |
136 | ||
137 | # Returns the correct host for the bucket based on the regionmap | |
138 | def get_bucket_host(bucket, region_map): | |
139 | region = get_bucket_region(bucket) | |
140 | return bucket + "." + region_map[region] | |
141 | ||
142 | # This should support multiple endpoints per region! | |
143 | def parse_region_map(map): | |
144 | regions = {} | |
145 | for region in map['regions']: | |
146 | url = urlparse(region['val']['endpoints'][0]) | |
147 | regions.update({region['key']: url.netloc}) | |
148 | ||
149 | return regions | |
150 | ||
151 | def str2bool(s): | |
152 | return s.lower() in ("yes", "true", "1") | |
153 | ||
154 | def init_config(): | |
155 | parser = argparse.ArgumentParser() | |
156 | parser.add_argument("--config", help="The configuration file to use.", action="store") | |
157 | ||
158 | args = parser.parse_args() | |
159 | ||
160 | defaults = { | |
161 | 'listen_addr': '127.0.0.1', | |
162 | 'listen_port': '6780', | |
163 | 'dns_zone': 'rgw.local.lan', | |
164 | 'dns_soa_record': 'dns1.icann.org. hostmaster.icann.org. 2012080849 7200 3600 1209600 3600', | |
165 | 'dns_soa_ttl': '3600', | |
166 | 'dns_default_ttl': '60', | |
167 | 'rgw_endpoint': 'localhost:8080', | |
168 | 'rgw_admin_entry': 'admin', | |
169 | 'rgw_access_key': 'access', | |
170 | 'rgw_secret_key': 'secret', | |
171 | 'debug': False | |
172 | } | |
173 | ||
174 | cfg = SafeConfigParser(defaults) | |
175 | if args.config == None: | |
176 | cfg.read(config_locations) | |
177 | else: | |
178 | if not os.path.isfile(args.config): | |
9f95a23c | 179 | print("Could not open configuration file %s" % args.config) |
7c673cae FG |
180 | sys.exit(1) |
181 | ||
182 | cfg.read(args.config) | |
183 | ||
184 | config_section = 'powerdns' | |
185 | ||
186 | try: | |
187 | return { | |
188 | 'listen': { | |
189 | 'port': cfg.getint(config_section, 'listen_port'), | |
190 | 'addr': cfg.get(config_section, 'listen_addr') | |
191 | }, | |
192 | 'dns': { | |
193 | 'zone': cfg.get(config_section, 'dns_zone'), | |
194 | 'soa_record': cfg.get(config_section, 'dns_soa_record'), | |
195 | 'soa_ttl': cfg.get(config_section, 'dns_soa_ttl'), | |
196 | 'default_ttl': cfg.get(config_section, 'dns_default_ttl') | |
197 | }, | |
198 | 'rgw': { | |
199 | 'endpoint': cfg.get(config_section, 'rgw_endpoint'), | |
200 | 'admin_entry': cfg.get(config_section, 'rgw_admin_entry'), | |
201 | 'access_key': cfg.get(config_section, 'rgw_access_key'), | |
202 | 'secret_key': cfg.get(config_section, 'rgw_secret_key') | |
203 | }, | |
204 | 'debug': str2bool(cfg.get(config_section, 'debug')) | |
205 | } | |
206 | ||
207 | except NoSectionError: | |
208 | return None | |
209 | ||
210 | def generate_app(config): | |
211 | # The Flask App | |
212 | app = Flask(__name__) | |
213 | ||
214 | # Get the RGW Region Map | |
215 | region_map = parse_region_map(do_rgw_request('config')) | |
216 | ||
217 | @app.route('/') | |
218 | def index(): | |
219 | abort(404) | |
220 | ||
221 | @app.route("/dns/lookup/<qname>/<qtype>") | |
222 | def bucket_location(qname, qtype): | |
223 | if len(qname) == 0: | |
224 | return abort_early() | |
225 | ||
226 | split = qname.split(".", 1) | |
227 | if len(split) != 2: | |
228 | return abort_early() | |
229 | ||
230 | bucket = split[0] | |
231 | zone = split[1] | |
232 | ||
233 | # If the received qname doesn't match our zone we abort | |
234 | if zone != config['dns']['zone']: | |
235 | return abort_early() | |
236 | ||
237 | # We do not serve MX records | |
238 | if qtype == "MX": | |
239 | return abort_early() | |
240 | ||
241 | # The basic result we always return, this is what PowerDNS expects. | |
242 | response = {'result': 'true'} | |
243 | result = {} | |
244 | ||
245 | # A hardcoded SOA response (FIXME!) | |
246 | if qtype == "SOA": | |
247 | result.update({'qtype': qtype}) | |
248 | result.update({'qname': qname}) | |
249 | result.update({'content': config['dns']['soa_record']}) | |
250 | result.update({'ttl': config['dns']['soa_ttl']}) | |
251 | else: | |
252 | region_hostname = get_bucket_host(bucket, region_map) | |
253 | result.update({'qtype': 'CNAME'}) | |
254 | result.update({'qname': qname}) | |
255 | result.update({'content': region_hostname}) | |
256 | result.update({'ttl': config['dns']['default_ttl']}) | |
257 | ||
258 | if len(result) > 0: | |
259 | res = [] | |
260 | res.append(result) | |
261 | response['result'] = res | |
262 | ||
263 | return json.dumps(response, indent=1) + "\n" | |
264 | ||
265 | return app | |
266 | ||
267 | ||
268 | # Initialize the configuration and generate the Application | |
269 | config = init_config() | |
270 | if config == None: | |
9f95a23c TL |
271 | print("Could not parse configuration file. " |
272 | "Tried to parse %s" % config_locations) | |
7c673cae FG |
273 | sys.exit(1) |
274 | ||
275 | app = generate_app(config) | |
276 | app.debug = config['debug'] | |
277 | ||
278 | # Only run the App if this script is invoked from a Shell | |
279 | if __name__ == '__main__': | |
280 | app.run(host=config['listen']['addr'], port=config['listen']['port']) | |
281 | ||
282 | # Otherwise provide a variable called 'application' for mod_wsgi | |
283 | else: | |
284 | application = app |