Mercurial > hg > KCl
comparison kcl/ec2.py @ 0:0f44ee073173 default tip
fake salt, initial commit
author | Jeff Hammel <k0scist@gmail.com> |
---|---|
date | Mon, 06 Feb 2017 01:10:22 +0000 |
parents | |
children |
comparison
equal
deleted
inserted
replaced
-1:000000000000 | 0:0f44ee073173 |
---|---|
1 """ | |
2 code for interfacing with EC2 instances: | |
3 | |
4 curl http://169.254.169.254/latest/meta-data/ | |
5 """ | |
6 | |
7 # imports | |
8 import argparse | |
9 import boto.utils | |
10 import hashlib | |
11 import hmac | |
12 import json | |
13 import os | |
14 import requests | |
15 import sys | |
16 import urllib | |
17 import urlparse | |
18 import ConfigParser | |
19 from collections import OrderedDict | |
20 from datetime import datetime | |
21 | |
22 | |
23 class FilesystemCredentials(object): | |
24 """ | |
25 Read credentials from the filesystem. See: | |
26 - http://boto.cloudhackers.com/en/latest/boto_config_tut.html | |
27 - https://blogs.aws.amazon.com/security/post/Tx3D6U6WSFGOK2H/A-New-and-Standardized-Way-to-Manage-Credentials-in-the-AWS-SDKs | |
28 | |
29 In Unix/Linux systems, on startup, the boto library looks | |
30 for configuration files in the following locations | |
31 and in the following order: | |
32 | |
33 /etc/boto.cfg - for site-wide settings that all users on this machine will use | |
34 (if profile is given) ~/.aws/credentials - for credentials shared between SDKs | |
35 (if profile is given) ~/.boto - for user-specific settings | |
36 ~/.aws/credentials - for credentials shared between SDKs | |
37 ~/.boto - for user-specific settings | |
38 | |
39 """ | |
40 | |
41 def read_aws_credentials(self, fp, section='default'): | |
42 parser = ConfigParser.RawConfigParser() | |
43 parser.readfp(fp) | |
44 if section in parser.sections(): | |
45 key = 'aws_access_key_id' | |
46 if parser.has_option(section, key): | |
47 secret = 'aws_secret_access_key' | |
48 if parser.has_option(section, secret): | |
49 return (parser.get(section, key), | |
50 parser.get(section, secret)) | |
51 | |
52 def __init__(self): | |
53 self.resolution = OrderedDict() | |
54 home = os.environ['HOME'] | |
55 if home: | |
56 self.resolution[os.path.join(home, '.aws', 'credentials')] = self.read_aws_credentials | |
57 | |
58 def __call__(self): | |
59 """ | |
60 return credentials....*if* available | |
61 """ | |
62 | |
63 for path, method in self.resolution.items(): | |
64 if os.path.isfile(path): | |
65 with open(path, 'r') as f: | |
66 credentials = method(f) | |
67 if credentials: | |
68 return credentials | |
69 | |
70 | |
71 class EC2Metadata(object): | |
72 """EC2 instance metadata interface""" | |
73 | |
74 def __init__(self, **kwargs): | |
75 self._kwargs = kwargs | |
76 | |
77 def __call__(self): | |
78 """http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-instance-metadata.html""" | |
79 return boto.utils.get_instance_metadata(**self._kwargs) | |
80 | |
81 def security_credentials(self): | |
82 """ | |
83 return IAM credentials for an instance, if possible | |
84 | |
85 See: | |
86 http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/iam-roles-for-amazon-ec2.html#instance-metadata-security-credentials | |
87 """ | |
88 # TODO: nested dict -> object notation mapping ; | |
89 # note also this is actually a `LazyLoader` value, | |
90 # not actually a dict | |
91 | |
92 return self()['iam']['security-credentials'] | |
93 | |
94 def credentials(self, role=None): | |
95 """return active credentials""" | |
96 | |
97 security_credentials = self.security_credentials() | |
98 if not security_credentials: | |
99 raise AssertionError("No security credentials available") | |
100 | |
101 roles=', '.join(sorted(security_credentials.keys())) | |
102 if role is None: | |
103 if len(security_credentials) > 1: | |
104 raise AssertionError("No role given and multiple roles found for instance: {roles}".format(roles)) | |
105 role = security_credentials.keys()[0] | |
106 | |
107 if role not in security_credentials: | |
108 raise KeyError("Role {role} not in available IAM roles: {roles}".format(role=role, roles=roles)) | |
109 | |
110 return security_credentials[role] | |
111 | |
112 class AWSCredentials(FilesystemCredentials): | |
113 """ | |
114 try to read credentials from the filesystem | |
115 then from ec2 metadata | |
116 """ | |
117 | |
118 def __call__(self): | |
119 | |
120 # return filesystem crednetials, if any | |
121 credentials = FilesystemCredentials.__call__(self) | |
122 if credentials: | |
123 return credentials | |
124 | |
125 # otherwise try to return credentials from metadata | |
126 metadata = EC2Metadata() | |
127 try: | |
128 ec2_credentials = metadata.credentials() | |
129 except AssertionError: | |
130 return | |
131 keys = ('AccessKeyId', 'SecretAccessKey') | |
132 if set(keys).issubset(ec2_credentials.keys()): | |
133 return [ec2_credentials[key] | |
134 for key in keys] | |
135 | |
136 class SignedRequest(object): | |
137 """ | |
138 Signed request using Signature Version 4 | |
139 | |
140 http://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html | |
141 """ | |
142 | |
143 # signing information: | |
144 algorithm = 'AWS4-HMAC-SHA256' | |
145 termination_string = 'aws4_request' | |
146 authorization_header = "{algorithm} Credential={access_key}/{credential_scope}, SignedHeaders={signed_headers}, Signature={signature}" | |
147 | |
148 # date format: | |
149 date_format = '%Y%m%d' | |
150 time_format = '%H%M%S' | |
151 | |
152 ### date methods | |
153 | |
154 @classmethod | |
155 def datetime_format(cls): | |
156 return '{date_format}T{time_format}Z'.format(date_format=cls.date_format, | |
157 time_format=cls.time_format) | |
158 | |
159 @classmethod | |
160 def datetime(cls, _datetime=None): | |
161 """ | |
162 returns formatted datetime string as appropriate | |
163 for `x-amz-date` header | |
164 """ | |
165 | |
166 if _datetime is None: | |
167 _datetime = datetime.utcnow() | |
168 return _datetime.strftime(cls.datetime_format()) | |
169 | |
170 ### constructor | |
171 | |
172 def __init__(self, access_key, secret_key, region, service): | |
173 self.access_key = access_key | |
174 self.secret_key = secret_key | |
175 self.region = region | |
176 self.service = service | |
177 | |
178 ### hashing methods | |
179 | |
180 def hash(self, message): | |
181 """hash a `message`""" | |
182 # from e.g. http://docs.aws.amazon.com/general/latest/gr/sigv4-signed-request-examples.html#sig-v4-examples-get-auth-header | |
183 return hashlib.sha256(message).hexdigest() | |
184 | |
185 def sign(self, key, msg): | |
186 """ | |
187 See: | |
188 http://docs.aws.amazon.com/general/latest/gr/signature-v4-examples.html#signature-v4-examples-python | |
189 """ | |
190 return hmac.new(key, msg.encode('utf-8'), hashlib.sha256).digest() | |
191 | |
192 def signature_key(self, date_stamp): | |
193 parts = [date_stamp, | |
194 self.region, | |
195 self.service, | |
196 self.termination_string] | |
197 signed = ('AWS4' + self.secret_key).encode('utf-8') | |
198 while parts: | |
199 signed = self.sign(signed, parts.pop(0)) | |
200 return signed | |
201 | |
202 ### | |
203 | |
204 def credential_scope(self, date_string): | |
205 """ | |
206 a string that includes the date, the region you are targeting, | |
207 the service you are requesting, and a termination string | |
208 ("aws4_request") in lowercase characters. | |
209 """ | |
210 | |
211 parts = [date_string, | |
212 self.region, | |
213 self.service, | |
214 self.termination_string] | |
215 # TODO: "The region and service name strings must be UTF-8 encoded." | |
216 return '/'.join(parts) | |
217 | |
218 ### method for canonical components | |
219 | |
220 @classmethod | |
221 def canonical_uri(cls, path): | |
222 """ | |
223 The canonical URI is the URI-encoded version | |
224 of the absolute path component of the URI | |
225 """ | |
226 | |
227 if path == '/': | |
228 path = None | |
229 if path: | |
230 canonical_uri = urllib.quote(path, safe='') | |
231 else: | |
232 # If the absolute path is empty, use a forward slash (/) | |
233 canonical_uri = '/' | |
234 return canonical_uri | |
235 | |
236 @classmethod | |
237 def canonical_query(cls, query_string): | |
238 """ | |
239 returns the canonical query string | |
240 """ | |
241 # TODO: currently this does not use `cls` | |
242 | |
243 # split into parameter names + values | |
244 query = urlparse.parse_qs(query_string) | |
245 | |
246 # make this into a more appropriate data structure for processing | |
247 keyvalues = sum([[[key, value] for value in values] | |
248 for key, values in query.items()], []) | |
249 | |
250 # a. URI-encode each parameter name and value | |
251 def encode(string): | |
252 return urllib.quote(string, safe='/') | |
253 encoded = [[encode(string) for string in pair] | |
254 for pair in keyvalues] | |
255 | |
256 # b. Sort the encoded parameter names by character code in ascending order (ASCII order) | |
257 encoded.sort() | |
258 | |
259 # c. Build the canonical query string by starting with the first parameter name in the sorted list. | |
260 # d. For each parameter, append the URI-encoded parameter name, followed by the character '=' (ASCII code 61), followed by the URI-encoded parameter value. | |
261 # e. Append the character '&' (ASCII code 38) after each parameter value, except for the last value in the list. | |
262 retval = '&'.join(['{name}={value}'.format(name=name, | |
263 value=value) | |
264 for name, value in encoded]) | |
265 return retval | |
266 | |
267 @classmethod | |
268 def signed_headers(cls, headers): | |
269 """ | |
270 return a list of signed headers | |
271 """ | |
272 names = [name.lower() for name in headers.keys()] | |
273 names.sort() | |
274 return ';'.join(names) | |
275 | |
276 @classmethod | |
277 def canonical_headers(cls, headers): | |
278 """ | |
279 return canonical headers: | |
280 Construct each header according to the following rules: | |
281 * Append the lowercase header name followed by a colon. | |
282 * ... | |
283 See: | |
284 - http://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html | |
285 - http://docs.python-requests.org/en/latest/user/quickstart/#custom-headers | |
286 """ | |
287 | |
288 canonical_headers = [] | |
289 for key, value in headers.items(): | |
290 | |
291 # convert header name to lowercase | |
292 key = key.lower() | |
293 | |
294 # trim excess white space from the header values | |
295 value = value.strip() | |
296 | |
297 # convert sequential spaces in the value to a single space. | |
298 # However, do not remove extra spaces from any values | |
299 # that are inside quotation marks. | |
300 quote = '"' | |
301 if not (value and value[0] == quote and value[-1] == quote): | |
302 value = ' '.join(value.split()) | |
303 | |
304 canonical_headers.append((key, value)) | |
305 | |
306 # check for duplicate headers | |
307 names = [name for name, value in canonical_headers] | |
308 if len(set(names)) != len(names): | |
309 raise AssertionError("You have duplicate header names :( While AWS supports this use-case, this library doesn't yet") | |
310 # Append a comma-separated list of values for that header. | |
311 # If there are duplicate headers, the values are comma-separated. | |
312 # Do not sort the values in headers that have multiple values. | |
313 | |
314 # Build the canonical headers list by sorting the headers by lowercase character code | |
315 canonical_headers.sort(key=lambda x: x[0]) | |
316 | |
317 # return canonical headers | |
318 return canonical_headers | |
319 | |
320 def __call__(self, url, method='GET', headers=None, session=None): | |
321 """create a signed request and return the response""" | |
322 | |
323 if session: | |
324 raise NotImplementedError('TODO') | |
325 else: | |
326 session = requests.Session() | |
327 signed_request = self.signed_request(url, | |
328 method=method, | |
329 headers=headers) | |
330 response = session.send(signed_request) | |
331 return response | |
332 | |
333 def canonical_request(self, url, headers, payload='', method='GET'): | |
334 """ | |
335 Return canonical request | |
336 | |
337 url: "http://k0s.org/home/documents and settings" | |
338 GET | |
339 %2Fhome%2Fdocuments%20and%20settings | |
340 ... | |
341 """ | |
342 | |
343 # parse the url | |
344 parsed = urlparse.urlsplit(url) | |
345 | |
346 # get canonical URI | |
347 canonical_uri = self.canonical_uri(parsed.path) | |
348 | |
349 # construct the canonical query string | |
350 canonical_query = self.canonical_query(parsed.query) | |
351 | |
352 # get the canonical headers | |
353 canonical_headers = self.canonical_headers(headers) | |
354 | |
355 # format the canonical headers | |
356 canonical_header_string = '\n'.join(['{0}:{1}'.format(*header) | |
357 for header in canonical_headers]) + '\n' | |
358 | |
359 # get the signed headers | |
360 signed_headers = self.signed_headers(headers) | |
361 | |
362 # get the hashed payload | |
363 hashed_payload = self.hash(payload) | |
364 | |
365 # join the parts to make the request: | |
366 # http://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html | |
367 # CanonicalRequest = | |
368 # HTTPRequestMethod + '\n' + | |
369 # CanonicalURI + '\n' + | |
370 # CanonicalQueryString + '\n' + | |
371 # CanonicalHeaders + '\n' + | |
372 # SignedHeaders + '\n' + | |
373 # HexEncode(Hash(RequestPayload)) | |
374 parts = [method, | |
375 canonical_uri, | |
376 canonical_query, | |
377 canonical_header_string, | |
378 signed_headers, | |
379 hashed_payload] | |
380 canonical_request = '\n'.join(parts) | |
381 return canonical_request | |
382 | |
383 def signed_request(self, url, method='GET', headers=None): | |
384 """ | |
385 prepare a request: | |
386 http://docs.python-requests.org/en/latest/user/advanced/#prepared-requests | |
387 """ | |
388 | |
389 # parse the URL, since we like doing that so much | |
390 parsed = urlparse.urlsplit(url) | |
391 | |
392 # setup the headers | |
393 if headers is None: | |
394 headers = {} | |
395 headers = OrderedDict(headers).copy() | |
396 mapping = dict([(key.lower(), key) for key in headers]) | |
397 # XXX this is..."fun" | |
398 # maybe we should just x-form everything to lowercase now? | |
399 # ensure host header is set | |
400 if 'host' not in mapping: | |
401 headers['Host'] = parsed.netloc | |
402 # add the `x-amz-date` in terms of now: | |
403 # http://docs.aws.amazon.com/AmazonS3/latest/API/RESTCommonRequestHeaders.html | |
404 if 'x-amz-date' not in mapping: | |
405 headers['x-amz-date'] = self.datetime() | |
406 | |
407 # create a PreparedRequest | |
408 req = requests.Request(method, url, headers) | |
409 prepped = req.prepare() | |
410 | |
411 # return a signed version | |
412 return self.sign_request(prepped) | |
413 | |
414 def sign_request(self, request): | |
415 """ | |
416 sign a request; | |
417 http://docs.aws.amazon.com/general/latest/gr/sigv4-create-string-to-sign.html | |
418 """ | |
419 | |
420 # ensure that we have a `x-amz-date` header in the request | |
421 request_date = request.headers.get('x-amz-date') | |
422 # Food for thought: perhaps here is a more appropriate place | |
423 # to add the headers? probably. Likely. | |
424 if request_date is None: | |
425 raise NotImplementedError('TODO') | |
426 | |
427 # get the canonical request | |
428 canonical_request = self.canonical_request(method=request.method, | |
429 url=request.url, | |
430 headers=request.headers) | |
431 | |
432 # Create a digest (hash) of the canonical request | |
433 # with the same algorithm that you used to hash the payload. | |
434 hashed_request = self.hash(canonical_request) | |
435 | |
436 # Create the string to sign: | |
437 # http://docs.aws.amazon.com/general/latest/gr/sigv4-create-string-to-sign.html | |
438 # 1. Start with the algorithm designation | |
439 parts = [self.algorithm] | |
440 # 2. Append the request date value | |
441 parts.append(request_date) # XXX we could validate the format | |
442 # 3. Append the credential scope value | |
443 date_string = request_date.split('T')[0] # XXX could do better | |
444 credential_scope = self.credential_scope(date_string) | |
445 parts.append(credential_scope) | |
446 # 4. Append the hash of the canonical request | |
447 parts.append(hashed_request) | |
448 string_to_sign = '\n'.join(parts) | |
449 | |
450 # Calculate the AWS Signature Version 4 | |
451 # http://docs.aws.amazon.com/general/latest/gr/sigv4-calculate-signature.html | |
452 # 1. Derive your signing key. | |
453 signing_key = self.signature_key(date_string) | |
454 # 2. Calculate the signature | |
455 signature = hmac.new(signing_key, | |
456 string_to_sign.encode('utf-8'), | |
457 hashlib.sha256).hexdigest() | |
458 | |
459 # Add the signing information to the request | |
460 # http://docs.aws.amazon.com/general/latest/gr/sigv4-add-signature-to-request.html | |
461 authorization = self.authorization_header.format(algorithm=self.algorithm, | |
462 access_key=self.access_key, | |
463 credential_scope=credential_scope, | |
464 signed_headers=self.signed_headers(request.headers), | |
465 signature=signature) | |
466 request.headers['Authorization'] = authorization | |
467 | |
468 # return the prepared requests | |
469 return request | |
470 | |
471 | |
472 def main(args=sys.argv[1:]): | |
473 """CLI""" | |
474 | |
475 # parse command line | |
476 parser = argparse.ArgumentParser(description=__doc__) | |
477 parser.add_argument('--credentials', '--print-credentials', | |
478 dest='print_credentials', | |
479 action='store_true', default=False, | |
480 help="print default credentials for instance") | |
481 parser.add_argument('--url', dest='url', | |
482 help="hit this URL with a signed HTTP GET request") | |
483 parser.add_argument('--service', dest='service', default='es', | |
484 help="AWS service to use") | |
485 parser.add_argument('--region', dest='region', default='us-west-1', | |
486 help="AWS region") | |
487 # TODO: `service` and `region` come from | |
488 # http://docs.aws.amazon.com/general/latest/gr/sigv4-create-string-to-sign.html | |
489 # We need to be able to derive the region from the environment. | |
490 # It would be very nice to derive the service from, say, the host | |
491 options = parser.parse_args(args) | |
492 | |
493 if options.url: | |
494 | |
495 # get credentials | |
496 credentials = AWSCredentials()() | |
497 if not credentials: | |
498 parser.error("No AWS credentials found") | |
499 aws_key, aws_secret = credentials | |
500 | |
501 # make a signed request to the URL and exit | |
502 request = SignedRequest(aws_key, | |
503 aws_secret, | |
504 region=options.region, | |
505 service=options.service) | |
506 response = request(options.url, method='GET') | |
507 print ('-'*10) | |
508 print (response.text) | |
509 response.raise_for_status() | |
510 return | |
511 | |
512 # metadata interface | |
513 metadata = EC2Metadata() | |
514 | |
515 # get desired data | |
516 if options.print_credentials: | |
517 data = metadata.credentials() | |
518 else: | |
519 data = metadata() | |
520 | |
521 # display data | |
522 print (json.dumps(data, indent=2, sort_keys=True)) | |
523 | |
524 | |
525 if __name__ == '__main__': | |
526 main() |