Skip to content

Commit

Permalink
Container credential server (#6)
Browse files Browse the repository at this point in the history
  • Loading branch information
benkehoe authored Oct 4, 2021
1 parent ccc39c7 commit 4b9a4d4
Show file tree
Hide file tree
Showing 2 changed files with 118 additions and 13 deletions.
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,27 @@ aws-export-credentials --profile my-profile -c my-exported-profile
```
Put the credentials in the given profile in your [shared credentials file](https://ben11kehoe.medium.com/aws-configuration-files-explained-9a7ea7a5b42e), which is typically `~/.aws/credentials` but can be controlled using the environment variable [`AWS_SHARED_CREDENTIALS_FILE`](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-envvars.html).

### Containers
> :warning: This method of providing refreshable credentials only works on Linux using `--network host`. [On Mac](https://docs.docker.com/desktop/mac/networking/#use-cases-and-workarounds) and [Windows](https://docs.docker.com/desktop/windows/networking/#use-cases-and-workarounds), `--network host` doesn't work. On all three, the host cannot be referenced as `localhost`, only `host.docker.internal`, which is not an allowed host the AWS SDKs. Alternatives include mounting your `~/.aws` directory or using the environment variables from `--env`.
You can use `--container` to start a server, compliant with the ECS metadata server, that exports your credentials, suitable for use with containers.

You provide `--container` a port (you can optionally provide the host part as well) and an authorization token.
On your container, map the port from the server, set the `AWS_CONTAINER_CREDENTIALS_FULL_URI` environment variable to the URL as accessed inside the container, and set the `AWS_CONTAINER_AUTHORIZATION_TOKEN` environment variable to the same value you provided the server.

You can use any value for the authorization, but it's best use a random value.

```
# Generate token. For example, on Linux:
AWS_CONTAINER_AUTHORIZATION_TOKEN=$(/proc/sys/kernel/random/uuid)
# start the server in the background
aws-export-credentials --profile my-profile --container 8081 $AWS_CONTAINER_AUTHORIZATION_TOKEN &
# run your container
docker run --network host -e AWS_CONTAINER_CREDENTIALS_FULL_URI=http://localhost:8081 -e AWS_CONTAINER_AUTHORIZATION_TOKEN=$AWS_CONTAINER_AUTHORIZATION_TOKEN amazon/aws-cli sts get-caller-identity
```

## Caching
To avoid retrieving credentials every time when using `aws-export-credentials` with the same identity, you can cache the credentials in a file using the `--cache-file` argument.
**Note `aws-export-credentials` does not distinguish in the cache between different identities. Different identities should use different cache files.**
Expand Down Expand Up @@ -106,4 +127,6 @@ You can then use `my-assumed-role` like any other profile.
It uses the AWS SDKs' built-in support for role assumption, rather than relying on third party code.
It also gets you credential refreshing from the SDKs, where getting the credentials in the manner below cannot refresh them when they expire.

You can then, if needed, export the assumed role credentials with `aws-export-credentials --profile my-assumed-role`.

But if you absolutely must have ad hoc role assumption on the command line, you can accomplish that through [`aws-assume-role-lib`](https://github.com/benkehoe/aws-assume-role-lib#command-line-use).
108 changes: 95 additions & 13 deletions aws_export_credentials/aws_export_credentials.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@
import logging
from datetime import datetime, timezone, timedelta
from collections import namedtuple
from http.server import HTTPServer, BaseHTTPRequestHandler
from http import HTTPStatus
import functools
import stat

from botocore.session import Session
Expand All @@ -46,6 +49,32 @@
def convert_creds(read_only_creds, expiration=None):
return Credentials(*list(read_only_creds) + [expiration])

def parse_container_arg(value):
token = value[1]
host_post = value[0].rsplit(':', 1)
if len(host_post) == 1:
host = ''
port = int(host_post[0])
else:
host = host_post[0]
port = int(host_post[1])
return (host, port), token

def get_credentials(session):
session_credentials = session.get_credentials()
if not session_credentials:
return None

read_only_credentials = session_credentials.get_frozen_credentials()
expiration = None
if hasattr(session_credentials, '_expiry_time') and session_credentials._expiry_time:
if isinstance(session_credentials._expiry_time, datetime):
expiration = session_credentials._expiry_time
else:
LOGGER.debug("Expiration in session credentials is of type {}, not datetime".format(type(expiration)))
credentials = convert_creds(read_only_credentials, expiration)
return credentials

def main():
parser = argparse.ArgumentParser(description=DESCRIPTION)

Expand All @@ -57,6 +86,7 @@ def main():
group.add_argument('--env-export', action='store_const', const='env-export', dest='format', help="Print as env vars prefixed by 'export ' for shell sourcing")
group.add_argument('--exec', nargs=argparse.REMAINDER, help="Exec remaining input w/ creds injected as env vars")
group.add_argument('--credentials-file-profile', '-c', metavar='PROFILE_NAME', help="Write to a profile in AWS credentials file")
group.add_argument('--container', nargs=2, metavar=('HOST_PORT', 'TOKEN'), help="Start a server on [HOST:]PORT for use with containers, requires TOKEN for auth")

parser.add_argument('--pretty', action='store_true', help='For --json, pretty-print')

Expand All @@ -76,13 +106,21 @@ def main():
print(__version__)
parser.exit()

if not any([args.format, args.exec, args.credentials_file_profile]):
if not any([args.format, args.exec, args.credentials_file_profile, args.container]):
args.format = 'json'
args.pretty = True

if args.debug:
logging.basicConfig(level=logging.DEBUG)

if args.container:
try:
args.container = parse_container_arg(args.container)
except Exception:
parser.error("invalid value for --container")
if args.cache_file:
parser.error("cannot use --cache-file with --container")

for key in ['AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY', 'AWS_SESSION_TOKEN']:
os.environ.pop(key, None)

Expand All @@ -100,18 +138,12 @@ def main():
elif not credentials:
try:
session = Session(profile=args.profile)
session_credentials = session.get_credentials()
if not session_credentials:

credentials = get_credentials(session)

if not credentials:
print('Unable to locate credentials.', file=sys.stderr)
sys.exit(2)
read_only_credentials = session_credentials.get_frozen_credentials()
expiration = None
if hasattr(session_credentials, '_expiry_time') and session_credentials._expiry_time:
if isinstance(session_credentials._expiry_time, datetime):
expiration = session_credentials._expiry_time
else:
LOGGER.debug("Expiration in session credentials is of type {}, not datetime".format(type(expiration)))
credentials = convert_creds(read_only_credentials, expiration)

if args.cache_file:
save_cache(args.cache_file, credentials)
Expand All @@ -121,8 +153,6 @@ def main():
print(str(e), file=sys.stderr)
sys.exit(3)



if args.exec:
os.environ.update({
'AWS_ACCESS_KEY_ID': credentials.AccessKeyId,
Expand Down Expand Up @@ -176,6 +206,17 @@ def main():
values['aws_credentials_expiration'] = credentials.Expiration.strftime(TIME_FORMAT)

write_values(session, args.credentials_file_profile, values)
elif args.container:
server_address, token = args.container
handler_class = functools.partial(ContainerRequestHandler,
token=token,
session=session,
)
server = HTTPServer(server_address, handler_class)
try:
server.serve_forever()
except KeyboardInterrupt:
pass
else:
print("ERROR: no option set (this should never happen)", file=sys.stderr)
sys.exit(1)
Expand Down Expand Up @@ -271,6 +312,47 @@ def write_values(session, profile_name, values):
with open(credentials_file, 'w') as fp:
parser.write(fp)

class ContainerRequestHandler(BaseHTTPRequestHandler):
# error_message_format = json.dumps({"Error": {"Code": "InvalidRequest", "Message": "%(message)"}})
# error_content_type = "application/json"

def __init__(self, request, client_address, server, token, session):
self._token = token
self._session = session
super().__init__(request, client_address, server)

def do_GET(self):
if self.path.startswith('/role/') or self.path.startswith('/role-arn/'):
self.send_response(HTTPStatus.NOT_FOUND)
body = {"Error": {"Code": "NotImplemented", "Message": "Role assumption is not supported"}}
if self.path not in ['/', '/creds']:
self.send_response(HTTPStatus.NOT_FOUND)
body = {"Error": {"Code": "InvalidPath", "Message": "Only the base path is accepted"}}
if 'Authorization' not in self.headers:
self.send_response(HTTPStatus.UNAUTHORIZED)
body = {"Error": {"Code": "NoAuthorizationHeader", "Message": "Authorization header not provided"}}
elif self.headers['Authorization'] != self._token:
self.send_response(HTTPStatus.UNAUTHORIZED)
body = {"Error": {"Code": "InvalidToken", "Message": "The provided token was invalid"}}
else:
self.send_response(HTTPStatus.OK)
credentials = get_credentials(self._session)
body = {
'AccessKeyId': credentials.AccessKeyId,
'SecretAccessKey': credentials.SecretAccessKey,
}
if credentials.SessionToken:
body['Token'] = credentials.SessionToken
if credentials.Expiration:
body['Expiration'] = credentials.Expiration.strftime(TIME_FORMAT)

body_bytes = json.dumps(body).encode('utf-8')

self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", len(body_bytes))
self.end_headers()

self.wfile.write(body_bytes)

if __name__ == '__main__':
main()

0 comments on commit 4b9a4d4

Please sign in to comment.