├── .gitignore
├── README.md
├── aws-sts-console-url.py
└── vault-aws-creds.py
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 |
6 | # C extensions
7 | *.so
8 |
9 | # Distribution / packaging
10 | .Python
11 | env/
12 | build/
13 | develop-eggs/
14 | dist/
15 | downloads/
16 | eggs/
17 | .eggs/
18 | lib/
19 | lib64/
20 | parts/
21 | sdist/
22 | var/
23 | wheels/
24 | *.egg-info/
25 | .installed.cfg
26 | *.egg
27 |
28 | # PyInstaller
29 | # Usually these files are written by a python script from a template
30 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
31 | *.manifest
32 | *.spec
33 |
34 | # Installer logs
35 | pip-log.txt
36 | pip-delete-this-directory.txt
37 |
38 | # Unit test / coverage reports
39 | htmlcov/
40 | .tox/
41 | .coverage
42 | .coverage.*
43 | .cache
44 | nosetests.xml
45 | coverage.xml
46 | *.cover
47 | .hypothesis/
48 |
49 | # Translations
50 | *.mo
51 | *.pot
52 |
53 | # Django stuff:
54 | *.log
55 | local_settings.py
56 |
57 | # Flask stuff:
58 | instance/
59 | .webassets-cache
60 |
61 | # Scrapy stuff:
62 | .scrapy
63 |
64 | # Sphinx documentation
65 | docs/_build/
66 |
67 | # PyBuilder
68 | target/
69 |
70 | # Jupyter Notebook
71 | .ipynb_checkpoints
72 |
73 | # pyenv
74 | .python-version
75 |
76 | # celery beat schedule file
77 | celerybeat-schedule
78 |
79 | # SageMath parsed files
80 | *.sage.py
81 |
82 | # dotenv
83 | .env
84 |
85 | # virtualenv
86 | .venv
87 | venv/
88 | ENV/
89 |
90 | # Spyder project settings
91 | .spyderproject
92 | .spyproject
93 |
94 | # Rope project settings
95 | .ropeproject
96 |
97 | # mkdocs documentation
98 | /site
99 |
100 | # mypy
101 | .mypy_cache/
102 | .idea/
103 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # vault-aws-creds
2 |
3 | [](https://www.repostatus.org/#unsupported)
4 |
5 | Python helper to export Vault-provided temporary AWS credentials into the environment.
6 | Also includes a helper script to generate a Console login URL from STS temporary credentials (from Vault).
7 |
8 | ## Requirements
9 |
10 | Python 2.7+ or Python 3. No external dependencies.
11 |
12 | ## Installation
13 |
14 | 1. Place (or symlink) ``vault-aws-creds.py`` somewhere on your system and make it executable.
15 | 2. ``export VAULT_ADDR=
``; it's recommended to
16 | put that in your ``~/.bashrc`` as well.
17 | 3. Add ``eval $(vault-aws-creds.py -w)`` to your shell initialization file (i.e. ``~/.bashrc``).
18 | If vault-aws-creds.py is not on your PATH, specify the absolute path to it in the
19 | above snippet. This will setup a function that allows vault-aws-creds.py to export environment
20 | variables back into your _existing_ shell process.
21 | 4. *(optional)* If you wish to use the Console login URL generator, place
22 | (or symlink) ``aws-sts-console-url.py`` somewhere on your system and make it
23 | executable.
24 |
25 | ## Usage
26 |
27 | ### List available accounts
28 |
29 | ```bash
30 | $ vault-aws-creds
31 | Available Accounts:
32 | "aws_dev" a.k.a. "dev"
33 | "aws_prod" a.k.a. "prod"
34 | "aws_uat" a.k.a. "uat"
35 | ```
36 |
37 | __Note:__ This requires that your token have "read" access to ``sys/mounts``.
38 |
39 | ### List available roles for account "dev"
40 |
41 | ```bash
42 | $ vault-aws-creds --roles dev
43 | Available Vault Roles for Account 'aws_dev/':
44 | administrator
45 | dba
46 | deploy
47 | developer
48 | readonly
49 | ```
50 |
51 | __Note:__ This requires that your token have "list" access to ``roles`` under the specified mountpoint (i.e. ``aws_dev/roles`` in the above example).
52 |
53 | ### Get STS credentials for the "foo" role in the "dev" account
54 |
55 | ```bash
56 | $ vault-aws-creds dev foo
57 | Got credentials for account 'aws_dev/' role 'foo'
58 | Request ID (for troubleshooting): c0e952d4-61ea-72e8-7b56-2df50538eacf
59 | Lease (credentials) will expire in: 59m 59s
60 | Outputting the following for shell evaluation:
61 | export AWS_REGION='us-east-1'
62 | export AWS_DEFAULT_REGION='us-east-1'
63 | export AWS_ACCESS_KEY_ID='ASIAxxxxxxxxxxxxxxxx'
64 | export AWS_SECRET_ACCESS_KEY='8xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxE'
65 | export AWS_SESSION_TOKEN='F...F'
66 | ```
67 |
68 | "foo" will now be stored in ~/.vault-aws-creds.conf as the default role for the
69 | "dev" ("aws_dev/") account. To get new creds for the same role, you can omit
70 | the role name:
71 |
72 | ```bash
73 | $ vault-aws-creds dev
74 | Got credentials for account 'aws_dev/' role 'foo'
75 | Request ID (for troubleshooting): b02d0346-cce2-911f-d853-17cf8aa591a2
76 | Lease (credentials) will expire in: 59m 59s
77 | Outputting the following for shell evaluation:
78 | export AWS_REGION='us-east-1'
79 | export AWS_DEFAULT_REGION='us-east-1'
80 | export AWS_ACCESS_KEY_ID='ASIAzzzzzzzzzzzzzzzz'
81 | export AWS_SECRET_ACCESS_KEY='8zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzE'
82 | export AWS_SESSION_TOKEN='F...F'
83 | ```
84 |
85 | ### Get 4-hour-lifetime STS credentials for the "bar" role in the "prod" account
86 |
87 | (Note: this requires that your user in Vault have "update" capabilities for the sts path. Users of older Vault installations may only have "read".)
88 |
89 | ```bash
90 | $ vault-aws-creds --ttl=4h prod bar
91 | Got credentials for account 'aws_dev/' role 'foo'
92 | Request ID (for troubleshooting): b02d0346-cce2-911f-d853-17cf8aa591a2
93 | Lease (credentials) will expire in: 3h 59m 59s
94 | Outputting the following for shell evaluation:
95 | export AWS_REGION='us-east-1'
96 | export AWS_DEFAULT_REGION='us-east-1'
97 | export AWS_ACCESS_KEY_ID='ASIAzzzzzzzzzzzzzzzz'
98 | export AWS_SECRET_ACCESS_KEY='8zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzE'
99 | export AWS_SESSION_TOKEN='F...F'
100 | ```
101 |
102 | ### Get IAM User credentials for the "foo" role in the "dev" account
103 |
104 | ```bash
105 | $ vault-aws-creds --iam dev foo
106 | Got credentials for account 'aws_dev/' role 'foo'
107 | Request ID (for troubleshooting): e123a94c-4819-f75d-22b1-d754ec92f589
108 | Lease (credentials) will expire in: 1h
109 | To renew, run: vault renew aws_dev/creds/foo/54078039-7b6c-be74-5fde-0adb3b209317
110 | Outputting the following for shell evaluation:
111 | export AWS_REGION='us-east-1'
112 | export AWS_DEFAULT_REGION='us-east-1'
113 | export AWS_ACCESS_KEY_ID='AKIAxxxxxxxxxxxxxxxx'
114 | export AWS_SECRET_ACCESS_KEY='AzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzB'
115 | unset AWS_SESSION_TOKEN
116 | ```
117 |
118 | ## aws-sts-console-url.py Usage
119 |
120 | ``aws-sts-console-url.py`` is a script that uses STS temporary credentials
121 | from Vault to generate a pre-signed AWS Console login URL, allowing Console
122 | access with temporary credentials from Vault. **This can only be used with STS
123 | temporary credentials, i.e. not ``--iam`` credentials from ``vault-aws-creds``.**
124 |
125 | To use, first obtain STS temporary credentials with ``vault-aws-creds`` as shown
126 | above. Then, run ``aws-sts-console-url.py``; a Console login URL will be displayed
127 | to STDOUT. Alternatively, you can pass in the `-b` or `--browser` flag which
128 | will open the console automatically in your default browser
129 | `aws-sts-console-url.py --browser`.
130 |
131 | ## Suggested Vault Policies
132 |
133 | In addition to the required policies to retrieve the credentials you need,
134 | listing available accounts and roles requires the following policy on your token:
135 |
136 | ```
137 | # allows user to list mounts, to find all AWS secret backends
138 | path "sys/mounts" {
139 | capabilities = ["read"]
140 | }
141 |
142 | # allows user to list available roles for AWS secret backends
143 | # this assumes that all AWS backend mountpoints begin with "aws_"
144 | path "aws_*/roles" {
145 | capabilities = ["list"]
146 | }
147 | ```
148 |
--------------------------------------------------------------------------------
/aws-sts-console-url.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | """
3 | Script to use STS Temporary Credentials from environment variables to generate
4 | an AWS Console login URL.
5 |
6 | Canonical source of latest version:
7 |
8 |
9 | For further information, see also:
10 | https://docs.aws.amazon.com/IAM/latest/UserGuide/
11 | id_roles_providers_enable-console-custom-url.html
12 |
13 | Installation
14 | ------------
15 |
16 | Make this script executable and copy or symlink it somewhere on your PATH.
17 |
18 | Usage
19 | -----
20 |
21 | ``aws-sts-console-url.py``
22 |
23 | License
24 | -------
25 |
26 | Free for any use provided that changes and improvements are sent back to me.
27 |
28 | Changelog
29 | ---------
30 |
31 | (be sure to increment __version__ with Changelog additions!!)
32 |
33 | 0.2.8 2018-09-20 Chris Bartlett :
34 | - Add ``-b`` / ``--browser`` option to aws-sts-console-url.py to automatically
35 | open the console URL in your default browser.
36 |
37 | 0.2.7 2018-02-25 Jason Antman :
38 | - change to vault-aws-creds.py
39 |
40 | 0.2.6 2018-02-25 Jason Antman :
41 | - change to vault-aws-creds.py
42 |
43 | 0.2.5 2018-02-20 Jason Antman :
44 | - change to vault-aws-creds.py
45 |
46 | 0.2.4 2018-01-29 Jason Antman :
47 | - change to vault-aws-creds.py
48 |
49 | 0.2.3 2018-01-03 Jason Antman :
50 | - Initial version (versioned in sync with vault-aws-creds.py)
51 | """
52 |
53 | import sys
54 | import os
55 | import argparse
56 | import logging
57 | import json
58 | import webbrowser
59 |
60 | if sys.version_info[0] == 2:
61 | from httplib import HTTPSConnection, HTTPConnection
62 | import ConfigParser
63 | from urlparse import urlparse
64 | from urllib import quote_plus
65 | else:
66 | from http.client import HTTPSConnection, HTTPConnection
67 | import configparser as ConfigParser
68 | from urllib.parse import urlparse, quote_plus
69 |
70 | __version__ = '0.2.8' # increment version in other scripts in sync with this
71 | __author__ = 'jason@jasonantman.com'
72 | _SRC_URL = 'https://github.com/jantman/vault-aws-creds/blob/master/' \
73 | 'aws-sts-console-url.py'
74 |
75 | DEFAULT_REGION = 'us-east-1'
76 |
77 | FORMAT = "[%(asctime)s %(levelname)s] %(message)s"
78 | logging.basicConfig(level=logging.WARNING, format=FORMAT)
79 | logger = logging.getLogger()
80 |
81 |
82 | class StsUrlGenerator(object):
83 |
84 | def __init__(self):
85 | self.creds = self._get_creds_from_env()
86 |
87 | def _get_creds_from_env(self):
88 | """
89 | Get AWS credentials from environment variables.
90 |
91 | :return: dict of AWS credentials, suitable for passing to the
92 | https://signin.aws.amazon.com/federation API.
93 | :rtype: dict
94 | """
95 | res = {}
96 | logger.debug('Getting AWS credentials from environment')
97 | for varname, key in {
98 | 'AWS_ACCESS_KEY_ID': 'sessionId',
99 | 'AWS_SECRET_ACCESS_KEY': 'sessionKey',
100 | 'AWS_SESSION_TOKEN': 'sessionToken'
101 | }.items():
102 | if varname not in os.environ:
103 | raise RuntimeError(
104 | 'ERROR: %s environment variable must be set to use this '
105 | 'script.' % varname
106 | )
107 | res[key] = os.environ[varname]
108 | logger.info('Got AWS credentials from environment variables')
109 | return res
110 |
111 | def generate(self, browser=False):
112 | """
113 | Generate an STS login URL, and print it to STDOUT.
114 | """
115 | signin_token = self._get_signin_token(self.creds)
116 | logger.debug('Ok, got valid signin token.')
117 | url = 'https://signin.aws.amazon.com/federation' \
118 | '?Action=login' \
119 | '&Issuer=%s' \
120 | '&Destination=%s' \
121 | '&SigninToken=%s' % (
122 | quote_plus(_SRC_URL),
123 | quote_plus('https://console.aws.amazon.com/'),
124 | signin_token
125 | )
126 | sys.stderr.write(
127 | 'The following sign-in URL must be used within 15 minutes:\n'
128 | )
129 | print(url)
130 | if browser:
131 | webbrowser.open_new_tab(url)
132 |
133 |
134 | def _get_signin_token(self, creds):
135 | """
136 | GET the generated Signin Token from the federation endpoint
137 |
138 | :param creds: credentials to pass to the federation endpoint
139 | :type creds: dict
140 | :return: signin token returned by the federation endpoint
141 | :rtype: str
142 | """
143 | host = 'signin.aws.amazon.com'
144 | req_path = 'https://signin.aws.amazon.com/federation' \
145 | '?Action=getSigninToken' \
146 | '&Session=%s' % quote_plus(json.dumps(creds))
147 | logger.debug('HTTPS GET request to %s: %s', host, req_path)
148 | conn = HTTPSConnection(host, 443)
149 | conn.request('GET', req_path)
150 | resp = conn.getresponse()
151 | logger.debug('Response: HTTP %s %s', resp.status, resp.reason)
152 | logger.debug('Headers: %s', resp.getheaders())
153 | body = resp.read()
154 | logger.debug('Body: %s', body.strip())
155 | if resp.status != 200:
156 | logger.critical('AWS Federation endpoint responded HTTP %s %s: %s',
157 | resp.status, resp.reason, body)
158 | raise RuntimeError('Error obtaining console signin credentials.')
159 | try:
160 | b = json.loads(body)['SigninToken']
161 | except Exception:
162 | logger.critical(
163 | 'AWS Federation endpoint returned an invalid response: %s',
164 | body
165 | )
166 | raise RuntimeError('Invalid response from AWS Federation endpoint.')
167 | return b
168 |
169 |
170 | def set_log_info():
171 | """set logger level to INFO"""
172 | set_log_level_format(logging.INFO,
173 | '%(asctime)s %(levelname)s:%(name)s:%(message)s')
174 |
175 |
176 | def set_log_debug():
177 | """set logger level to DEBUG, and debug-level output format"""
178 | set_log_level_format(
179 | logging.DEBUG,
180 | "%(asctime)s [%(levelname)s %(filename)s:%(lineno)s - "
181 | "%(name)s.%(funcName)s() ] %(message)s"
182 | )
183 |
184 |
185 | def set_log_level_format(level, format):
186 | """
187 | Set logger level and format.
188 |
189 | :param level: logging level; see the :py:mod:`logging` constants.
190 | :type level: int
191 | :param format: logging formatter format string
192 | :type format: str
193 | """
194 | formatter = logging.Formatter(fmt=format)
195 | logger.handlers[0].setFormatter(formatter)
196 | logger.setLevel(level)
197 |
198 |
199 | def parse_args(argv):
200 | """
201 | Parse command line arguments
202 | :param argv: command line arguments, not including script name
203 | (i.e. ``sys.argv[1:]``)
204 | :type argv: list
205 | :return: parsed command line arguments
206 | :rtype: argparse.Namespace
207 | """
208 | p = argparse.ArgumentParser(
209 | description='Generate and print to STDOUT an AWS Console login URL, '
210 | 'based on STS temporary credentials in environment '
211 | 'variables.'
212 | )
213 | p.add_argument('-b', '--browser', dest='browser', action='store_true', default=False,
214 | help='open console URL in new tab in default browser')
215 | p.add_argument('-v', '--verbose', dest='verbose', action='count', default=0,
216 | help='verbose output. specify twice for debug-level output.')
217 | p.add_argument('-V', '--version', action='store_true', default=False,
218 | help='Print version number and exit', dest='version')
219 | args = p.parse_args(argv)
220 | if args.version:
221 | sys.stderr.write(
222 | "aws-sts-console-url.py version %s <%s>\n" % (
223 | __version__, _SRC_URL
224 | )
225 | )
226 | raise SystemExit(1)
227 | return args
228 |
229 | if __name__ == "__main__":
230 | args = parse_args(sys.argv[1:])
231 |
232 | # set logging level
233 | if args.verbose > 1:
234 | set_log_debug()
235 | elif args.verbose == 1:
236 | set_log_info()
237 |
238 | StsUrlGenerator().generate(browser=args.browser)
239 |
--------------------------------------------------------------------------------
/vault-aws-creds.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | """
3 | Script to export Vault-derived temporary AWS creds in the environment.
4 |
5 | Canonical source of latest version:
6 |
7 |
8 | Installation
9 | ------------
10 |
11 | Add a wrapper to your ``~/.bashrc`` to allow this script to set env vars in
12 | the current shell. The proper wrapper function will be output by running
13 | ``vault-aws-creds.py --wrapper-func``.
14 |
15 | Without such a wrapper, you'll need to manually pass this script's output
16 | through your shell's evaluation function, i.e. ``eval vault-aws-creds.py ``
17 |
18 | Usage
19 | -----
20 |
21 | see `vault-aws-creds.py -h`
22 |
23 | Development
24 | -----------
25 |
26 | __IMPORTANT__:
27 |
28 | - The only thing that should ever be printed to STDOUT is bash code to be
29 | evaluated by the shell (i.e. environment variable exports).
30 | - All logging, errors, and warnings to the user must go to STDERR.
31 | - Any critical exceptions should raise a VaultException with a helpful message.
32 |
33 | License
34 | -------
35 |
36 | Free for any use provided that changes and improvements are sent back to me.
37 |
38 | Changelog
39 | ---------
40 |
41 | (be sure to increment __version__ with Changelog additions!!)
42 |
43 | 0.2.9 2020-06-17 Jason Antman :
44 | - Fix bug where the ``-t`` / ``--ttl`` CLI option was ignored, instead using the
45 | saved value from ``~/.vault-aws-creds.conf``
46 |
47 | 0.2.8 2018-09-20 Chris Bartlett :
48 | - Add ``-b`` / ``--browser`` option to aws-sts-console-url.py to automatically
49 | open the console URL in your default browser.
50 |
51 | 0.2.7 2018-02-25 Jason Antman :
52 | - Fix bug in last release, where if we couldn't connect to Vault, the bashrc
53 | ``eval $(vault-aws-creds.py -w)`` would hang indefinitely. Fixed by changing
54 | ``VaultAwsCredExporter.bash_wrapper`` from a property to a static method, and
55 | calling it (and exiting) before class initialization.
56 |
57 | 0.2.6 2018-02-25 Jason Antman :
58 | - Update README and ``bash_wrapper()`` to use eval for retrieving shell rc
59 | file function, instead of hard-coding. This allows updating the wrapper
60 | function automatically. Thanks to Gerard Hickey
61 | for this PR.
62 | - The ``--region`` / ``-r`` CLI argument was formerly completely ignored. Change
63 | code to use that argument if present, or else ``AWS_REGION`` env var if
64 | present or else ``AWS_DEFAULT_REGION`` if present. Thanks to Gerard Hickey
65 | for this PR.
66 | - Fix #11 - When checking to see if the current token has capabilities to use
67 | a given credential path, consider this true if it has any of ['read',
68 | 'update', 'sudo', 'root']. Previously, we required "read" specifically, which
69 | is not valid in all cases. Thanks to Gerard Hickey
70 | for this PR.
71 | - Fix #10 - Specific, clearer error message if unable to connect to Vault.
72 |
73 | 0.2.5 2018-02-20 Jason Antman :
74 | - store and retrieve TTL value per-mountpoint from config file, like role.
75 |
76 | 0.2.4 2018-01-29 Jason Antman :
77 | - support --ttl option for STS creds. NOTICE: This changes the STS credential
78 | calls from read (GET) to update (POST) in keeping with the current API
79 | documentation. Your Vault policies may need to change for this.
80 |
81 | 0.2.3 2018-01-03 Jason Antman :
82 | - Fix version number in script
83 |
84 | 0.2.2 2017-09-09 Jason Antman :
85 | - Fix #3 - Correct misleading/incorrect log output
86 |
87 | 0.2.1 2017-09-01 Jason Antman :
88 | - Remove often-incorrect warning about STS creds not making IAM calls
89 |
90 | 0.2.0 2017-08-08 Jason Antman :
91 | - Fix numerous Python3 errors
92 |
93 | 0.1.2 2017-08-08 Marcus Smith:
94 | - Fix author-specific shebang line
95 | - Fix usage instructions - VAULT_ADDR must be exported before running -w
96 |
97 | 0.1.1 2017-08-07 Jason Antman :
98 | - Properly handle -h/--help through bash wrapper
99 |
100 | 0.1.0 2017-08-01 Jason Antman :
101 | - Initial version
102 | """
103 |
104 | import sys
105 | import os
106 | import argparse
107 | import logging
108 | from textwrap import dedent
109 | import json
110 | import re
111 | import socket
112 |
113 | if sys.version_info[0] == 2:
114 | from httplib import HTTPSConnection, HTTPConnection
115 | import ConfigParser
116 | from urlparse import urlparse
117 | else:
118 | from http.client import HTTPSConnection, HTTPConnection
119 | import configparser as ConfigParser
120 | from urllib.parse import urlparse
121 |
122 | if (
123 | sys.version_info[0] < 3 or
124 | sys.version_info[0] == 3 and sys.version_info[1] < 3
125 | ):
126 | SOCKET_EXC = socket.error
127 | else:
128 | SOCKET_EXC = ConnectionError
129 |
130 | __version__ = '0.2.9' # increment version in other scripts in sync with this
131 | __author__ = 'jason@jasonantman.com'
132 | _SRC_URL = 'https://github.com/jantman/vault-aws-creds/blob/master/' \
133 | 'vault-aws-creds.py'
134 |
135 | DEFAULT_REGION = 'us-east-1'
136 |
137 | FORMAT = "[%(asctime)s %(levelname)s] %(message)s"
138 | logging.basicConfig(level=logging.WARNING, format=FORMAT)
139 | logger = logging.getLogger()
140 |
141 |
142 | def humantime(int_seconds):
143 | """convert integer seconds to human time"""
144 | s = int_seconds
145 | res = ''
146 | day = 86400
147 | if s >= 86400:
148 | res += '{c}d '.format(c=int(s / day))
149 | s = s % 86400
150 | if s >= 3600:
151 | res += '{c}h '.format(c=int(s / 3600))
152 | s = s % 3600
153 | if s >= 60:
154 | res += '{c}m '.format(c=int(s / 60))
155 | s = s % 60
156 | if s > 0:
157 | res += '{c}s'.format(c=s)
158 | return res
159 |
160 |
161 | def bold(s):
162 | return "\033[1m%s\033[0m" % s
163 |
164 |
165 | def red(s):
166 | return "\033[31m%s\033[0m" % s
167 |
168 |
169 | def green(s):
170 | return "\033[32m%s\033[0m" % s
171 |
172 |
173 | class VaultException(Exception):
174 |
175 | pass
176 |
177 |
178 | class VaultAwsCredExporter(object):
179 |
180 | def __init__(self, config_path, region=None, ttl=None):
181 | self._config_path = os.path.abspath(config_path)
182 | self._ttl = ttl
183 | self._config = self._read_config(self._config_path)
184 | if region is None:
185 | region = self._get_conf('defaults', 'region_name')
186 | if region is None:
187 | region = DEFAULT_REGION
188 | self._region = region
189 | self._set_conf('defaults', 'region_name', region)
190 | self._cli_region = region
191 | self._v_addr, self._v_token = self._set_vault_creds()
192 | self._v_scheme, self._v_host, self._v_port = self._parse_vault_url(
193 | self._v_addr
194 | )
195 | logger.debug('VAULT_ADDR=%s; scheme=%s host=%s port=%d',
196 | self._v_addr, self._v_scheme, self._v_host, self._v_port)
197 | self._test_vault_creds()
198 |
199 | def _read_config(self, conf_path):
200 | """
201 | Read in the config file. Return a SafeConfigParser.
202 |
203 | :param conf_path: conf file path
204 | :type conf_path: str
205 | :return: ConfigParser
206 | :rtype: ConfigParser.SafeConfigParser
207 | """
208 | conf = ConfigParser.SafeConfigParser()
209 | conf.add_section('roles')
210 | logger.debug('Attempting to read config from: %s', conf_path)
211 | conf.read(conf_path)
212 | return conf
213 |
214 | def _set_conf(self, section, option, value):
215 | """
216 | Set a config value and then write the config file to disk.
217 |
218 | :param section: config file section name
219 | :type section: str
220 | :param option: option name in section
221 | :type option: str
222 | :param value: value to set for the option
223 | :type value: str
224 | """
225 | if not self._config.has_section(section):
226 | self._config.add_section(section)
227 | logger.debug('_set_conf: section=%s option=%s value=%s',
228 | section, option, value)
229 | self._config.set(section, option, value)
230 | if sys.version_info[0] == 2:
231 | mode = 'wb'
232 | else:
233 | mode = 'w'
234 | with open(self._config_path, mode) as fh:
235 | logger.debug('Writing config to: %s', self._config_path)
236 | self._config.write(fh)
237 |
238 | def _get_conf(self, section, option):
239 | """
240 | Attempt to read a given value (section and option) from the config
241 | file. If it does not exist, return a default if defined, or otherwise
242 | None.
243 |
244 | :param section: section name in config file
245 | :type section: str
246 | :param option: option name in section
247 | :type option: str
248 | :return: value or None
249 | """
250 | if not self._config.has_section(section):
251 | logger.debug('Config does not have section: %s', section)
252 | return None
253 | try:
254 | return self._config.get(section, option)
255 | except Exception as ex:
256 | logger.debug('Exception getting config %s/%s: %s',
257 | section, option, ex)
258 | return None
259 |
260 | def _get_conf_bool(self, section, option, default=False):
261 | v = self._get_conf(section, option)
262 | if v is None:
263 | return default
264 | if v == 'true':
265 | return True
266 | return False
267 |
268 | def _set_conf_bool(self, section, option, value):
269 | if value is True:
270 | self._set_conf(section, option, 'true')
271 | return
272 | self._set_conf(section, option, 'false')
273 |
274 | @staticmethod
275 | def bash_wrapper():
276 | """
277 | Return the string bash wrapper function to execute this command and
278 | evaluate the STDOUT.
279 |
280 | :return: bash wrapper function for this command
281 | :rtype: str
282 | """
283 | p = os.path.realpath(__file__)
284 | wrapper = "function vault-aws-creds() {\n" \
285 | " x=$(%s --called-from-wrapper \"$@\");\n" \
286 | " [[ \"$?\" == \"0\" ]] && eval \"$x\";\n" \
287 | "}\n" \
288 | " # generated by vault-aws-creds.py version %s\n" \
289 | " # <%s>\n" \
290 | " # This executes the script with the supplied " \
291 | "arguments and then\n" \
292 | " # evaluates the STDOUT. It lets us export env vars in " \
293 | "an existing session.\n" % (p, __version__, _SRC_URL)
294 | return wrapper
295 |
296 | def _set_vault_creds(self):
297 | """
298 | Get the Vault address and token. Confirm the token is valid, or raise
299 | an error otherwise. Return the address and token.
300 |
301 | :returns: 2-tuple of (VAULT_ADDR, VAULT_TOKEN) if token is valid
302 | :rtype: tuple
303 | """
304 | addr = os.environ.get('VAULT_ADDR', None)
305 | tkn = os.environ.get('VAULT_TOKEN', None)
306 | if addr is None:
307 | raise VaultException('Vault address not found; you must export the '
308 | 'VAULT_ADDR environment variable.')
309 | if tkn is not None:
310 | logger.debug(
311 | 'Using Vault Token from VAULT_TOKEN environment variable'
312 | )
313 | return addr, tkn
314 | p = os.path.expanduser('~/.vault-token')
315 | if not os.path.exists(p):
316 | raise VaultException(
317 | 'VAULT_TOKEN environment variable is not set and ~/.vault-token'
318 | ' does not exist. Please run "vault auth" to authenticate to '
319 | 'Vault.'
320 | )
321 | logger.debug('Reading Vault token from: %s', p)
322 | with open(p, 'r') as fh:
323 | tkn = fh.read().strip()
324 | return addr, tkn
325 |
326 | def _parse_vault_url(self, addr):
327 | """
328 | Parse the VAULT_ADDR into host and port. Return a 2-tuple of them.
329 |
330 | :param addr: VAULT_ADDR
331 | :type addr: str
332 | :return: 3-tuple (str scheme, str host, int port)
333 | :rtype: tuple
334 | """
335 | p = urlparse(addr)
336 | nl = p.netloc
337 | if ':' not in nl:
338 | return p.scheme, nl, 8200 # 8200 is default Vault port
339 | host, port = nl.split(':')
340 | return p.scheme, host, int(port)
341 |
342 | def _vault_request(self, method, path, body=None, headers={}, redir=None):
343 | """
344 | Send an HTTPS request to Vault. Return the response.
345 |
346 | :param method: HTTP method
347 | :type method: str
348 | :param path: Vault API path
349 | :type path: str
350 | :param body: request body
351 | :type body: str
352 | :param headers: additional headers to add to request
353 | :type headers: dict
354 | :param redir: Vault redirect location
355 | :type redir: str
356 | :return: HTTP response
357 | :rtype: str
358 | """
359 | if redir is None:
360 | scheme = self._v_scheme
361 | host = self._v_host
362 | port = self._v_port
363 | else:
364 | scheme, host, port = self._parse_vault_url(redir)
365 | if 'X-Vault-Token' not in headers:
366 | headers['X-Vault-Token'] = self._v_token
367 | if scheme == 'https':
368 | kls = HTTPSConnection
369 | else:
370 | kls = HTTPConnection
371 | conn = kls(host, port)
372 | logger.debug('%s request to %s:%s - %s %s (body: %s)',
373 | scheme, host, port, method, path, body)
374 | try:
375 | conn.request(method, path, body, headers)
376 | except SOCKET_EXC as exc:
377 | sys.stderr.write(
378 | 'ERROR Connecting to Vault at %s://%s:%s - %s\n' % (
379 | scheme, host, port, exc
380 | )
381 | )
382 | raise SystemExit(1)
383 | resp = conn.getresponse()
384 | logger.debug('Response: HTTP %s %s', resp.status, resp.reason)
385 | logger.debug('Headers: %s', resp.getheaders())
386 | loc = resp.getheader('location', None)
387 | if loc is not None and 300 <= resp.status < 400:
388 | logger.debug('Vault %s redirect to: %s', resp.status, loc)
389 | return self._vault_request(method, path, body, headers, loc)
390 | resp_body = resp.read()
391 | logger.debug('Body: %s', resp_body.strip())
392 | # test for auth failure
393 | try:
394 | b = json.loads(resp_body)
395 | except Exception:
396 | b = {}
397 | if 'errors' in b and len(b['errors']) > 0:
398 | if 'permission denied' in b['errors']:
399 | raise VaultException(
400 | 'Vault request got a "permission denied" error; your token'
401 | ' is probably expired. Run "vault auth" to reauthenticate '
402 | 'to Vault.'
403 | )
404 | raise VaultException(
405 | "Vault request %s %s resulted in error(s): %s" % (
406 | method, path, b['errors']
407 | )
408 | )
409 | return resp_body
410 |
411 | def _test_vault_creds(self):
412 | """
413 | Confirm that the Vault token is valid. Exit otherwise.
414 | """
415 | raw = self._vault_request('GET', '/v1/auth/token/lookup-self')
416 | res = json.loads(raw)
417 | logger.info('Vault token is authenticated as %s (accessor: %s)',
418 | res['data'].get('display_name', 'unknown'),
419 | res['data'].get('accessor', 'unknown'))
420 |
421 | def _get_aws_mountpoints(self):
422 | """
423 | Return a dict of account name to mountpoint for all AWS mounts in Vault.
424 |
425 | :return: account name to mountpoint
426 | :rtype: dict
427 | :raises: VaultException
428 | """
429 | res = json.loads(self._vault_request('GET', '/v1/sys/mounts'))
430 | mpoints = {}
431 | for k, v in res.items():
432 | if not isinstance(v, type({})):
433 | continue
434 | if v.get('type', 'unknown') != 'aws':
435 | continue
436 | mpoints[k.strip('/')] = k
437 | if k.startswith('aws_'):
438 | mpoints[k.strip('/')[4:]] = k
439 | logger.debug('AWS Mountpoints: %s', mpoints)
440 | return mpoints
441 |
442 | def list_roles(self, mpoint):
443 | """
444 | List the available Vault roles for the specified account (in Vault).
445 |
446 | :param mpoint: account name to list roles for
447 | :type mpoint: str
448 | :return: roles that the current user has access to
449 | :rtype: list
450 | """
451 | try:
452 | res = json.loads(
453 | self._vault_request('LIST', '/v1/%sroles' % mpoint)
454 | )
455 | except VaultException as ex:
456 | if 'permission denied' not in str(ex):
457 | raise
458 | logger.error(
459 | 'ERROR: Your token does not have permission to list available '
460 | 'roles for the %s mount point; you will have to obtain your '
461 | 'role name for this account from your Vault administrator.'
462 | '' % mpoint
463 | )
464 | return []
465 | roles = []
466 | required_caps = ['read', 'update', 'sudo', 'root']
467 | for rname in res['data']['keys']:
468 | path = '%ssts/%s' % (mpoint, rname)
469 | logger.debug('Checking capabilities for: %s', path)
470 | caps = json.loads(
471 | self._vault_request('POST', '/v1/sys/capabilities-self',
472 | body=json.dumps({'path': path}))
473 | )
474 | logger.debug('Capabilities: %s', caps['capabilities'])
475 | if any(cap in caps['capabilities'] for cap in required_caps):
476 | # if any of required_caps are in caps['capabilities'] ...
477 | roles.append(rname)
478 | logger.debug('Vault Roles for %s mountpoint: %s', mpoint, roles)
479 | return roles
480 |
481 | def get_creds(self, mountpoint, role_name, iam=False, store_role=True,
482 | store_ttl=True):
483 | """
484 | Given an AWS secret backend mountpoint and a role name, return export
485 | statements to set credentials for that role.
486 |
487 | :param mountpoint: AWS backend mountpoint to read from (account)
488 | :type mountpoint: str
489 | :param role_name: Vault role name to get creds for
490 | :type role_name: str
491 | :param iam: Whether to get actual IAM User creds; if false, STS creds
492 | :type iam: bool
493 | :param store_role: if True, store role selection in config file and
494 | use config file for default role selection
495 | :type store_role: bool
496 | :param store_ttl: if True, store TTL selection in config file and use
497 | config file for default TTL selection
498 | :type store_ttl: bool
499 | :return: string of bash source code to export credentials for the role
500 | :rtype: str
501 | """
502 | if role_name is None and not store_role:
503 | raise VaultException(
504 | "When running with -R/--no-stored-role, you must specify a "
505 | "Vault role name to get credentials for."
506 | )
507 | if role_name is None:
508 | role_name = self._get_conf('roles', mountpoint)
509 | iam = self._get_conf_bool('use_iam', mountpoint)
510 | if role_name is None:
511 | # not stored in config
512 | raise VaultException(
513 | "No role name specified on command line, and no default "
514 | "role name for account '%s' is stored in the config file. "
515 | "You must explicitly specify a role name; it will be stored "
516 | "as the default for future invocations." % mountpoint
517 | )
518 | default_ttl = self._get_conf('ttl', mountpoint)
519 | ttl = None
520 | if self._ttl is not None:
521 | ttl = self._ttl
522 | elif default_ttl is not None:
523 | ttl = default_ttl
524 | body = None
525 | if iam:
526 | path = "/v1/%screds/%s" % (mountpoint, role_name)
527 | logger.info(
528 | 'Getting AWS credentials via path: {0} body: {1}'.format(
529 | path,body
530 | )
531 | )
532 | creds = json.loads(self._vault_request('GET', path, body=body))
533 | else:
534 | path = "/v1/%ssts/%s" % (mountpoint, role_name)
535 | if ttl:
536 | body = json.dumps({'ttl': ttl})
537 | logger.info(
538 | 'Getting AWS credentials via path: {0} body: {1}'.format(
539 | path, body
540 | )
541 | )
542 | creds = json.loads(self._vault_request('POST', path, body=body))
543 | data = creds.get('data', {})
544 | if 'lease_id' not in creds or 'access_key' not in data:
545 | raise VaultException(
546 | "Requested credentials from Vault but received an invalid "
547 | "response: %s" % creds
548 | )
549 | sys.stderr.write("Got credentials for account '%s' role '%s'\n" % (
550 | mountpoint, role_name
551 | ))
552 | sys.stderr.write(
553 | "Request ID (for troubleshooting): %s\n" % creds['request_id']
554 | )
555 | sys.stderr.write(
556 | "Lease (credentials) will expire in: %s\n" % humantime(
557 | creds['lease_duration']
558 | )
559 | )
560 | if creds.get('renewable', False):
561 | sys.stderr.write(
562 | "To renew, run: vault renew %s\n" % creds['lease_id']
563 | )
564 |
565 | if self._cli_region:
566 | region = self._cli_region
567 | else:
568 | region = os.environ.get(
569 | 'AWS_REGION',
570 | os.environ.get('AWS_DEFAULT_REGION', None)
571 | )
572 | if region is None:
573 | region = self._region
574 | exports = [
575 | "export AWS_REGION='%s'" % region,
576 | "export AWS_DEFAULT_REGION='%s'" % region,
577 | "export AWS_ACCESS_KEY_ID='%s'" % data['access_key'],
578 | "export AWS_SECRET_ACCESS_KEY='%s'" % data['secret_key']
579 | ]
580 | sess = data.get('security_token', None)
581 | if sess is not None:
582 | exports.append("export AWS_SESSION_TOKEN='%s'" % sess)
583 | else:
584 | exports.append("unset AWS_SESSION_TOKEN")
585 | sys.stderr.write(
586 | "Outputting the following for shell evaluation:"
587 | "\n%s\n" % "\n".join(["\t%s" % x for x in exports])
588 | )
589 | if store_role:
590 | self._set_conf('roles', mountpoint, role_name)
591 | self._set_conf_bool('iam', mountpoint, iam)
592 | if store_ttl and ttl is not None:
593 | self._set_conf('ttl', mountpoint, ttl)
594 | return "\n".join(exports)
595 |
596 | def mountpoint_for_account(self, acct_name):
597 | """
598 | Given an account name input on the command line, return the Vault
599 | mountpoint corresponding to that account name.
600 |
601 | :param acct_name: account name as input on the CLI
602 | :type acct_name: str
603 | :return: Vault mountpoint for that account
604 | :rtype: str
605 | """
606 | mpoints = {}
607 | try:
608 | mpoints = self._get_aws_mountpoints()
609 | except VaultException as ex:
610 | if 'permission denied' not in str(ex):
611 | raise
612 | if acct_name in mpoints:
613 | logger.info('Account name "%s" mountpoint: %s',
614 | acct_name, mpoints[acct_name])
615 | return mpoints[acct_name]
616 | logger.warning('Account name "%s" not found in list of %d mountpoints; '
617 | 'using as an explicit mountpoint for AWS backend.',
618 | acct_name, len(mpoints))
619 | if not acct_name.endswith('/'):
620 | acct_name += '/'
621 | return acct_name
622 |
623 |
624 | def set_log_info():
625 | """set logger level to INFO"""
626 | set_log_level_format(logging.INFO,
627 | '%(asctime)s %(levelname)s:%(name)s:%(message)s')
628 |
629 |
630 | def set_log_debug():
631 | """set logger level to DEBUG, and debug-level output format"""
632 | set_log_level_format(
633 | logging.DEBUG,
634 | "%(asctime)s [%(levelname)s %(filename)s:%(lineno)s - "
635 | "%(name)s.%(funcName)s() ] %(message)s"
636 | )
637 |
638 |
639 | def set_log_level_format(level, format):
640 | """
641 | Set logger level and format.
642 |
643 | :param level: logging level; see the :py:mod:`logging` constants.
644 | :type level: int
645 | :param format: logging formatter format string
646 | :type format: str
647 | """
648 | formatter = logging.Formatter(fmt=format)
649 | logger.handlers[0].setFormatter(formatter)
650 | logger.setLevel(level)
651 |
652 |
653 | def parse_args(argv):
654 | """
655 | Parse command line arguments
656 | :param argv: command line arguments, not including script name
657 | (i.e. ``sys.argv[1:]``)
658 | :type argv: list
659 | :return: parsed command line arguments
660 | :rtype: argparse.Namespace
661 | """
662 | epil = dedent("""
663 | Usage Examples
664 | --------------
665 |
666 | First, export the address to your Vault instance and authenticate. It's
667 | recommended that you set VAULT_ADDR in your shell profile (~/.bashrc):
668 | export VAULT_ADDR=https://my.vault:8200
669 | vault auth
670 |
671 | Then, add the wrapper function to your ~/.bashrc:
672 | ./vault-aws-creds.py --wrapper-func >> ~/.bashrc
673 |
674 | List available accounts:
675 | vault-aws-creds
676 |
677 | List available roles for account "dev":
678 | vault-aws-creds --roles dev
679 |
680 | Get STS credentials for the "foo" role in the "dev" account:
681 | vault-aws-creds dev foo
682 |
683 | Same as above, but with a 4-hour TTL:
684 | vault-aws-creds --ttl 4h dev foo
685 |
686 | Get IAM User credentials for the "foo" role in the "dev" account:
687 | vault-aws-creds --iam dev foo
688 |
689 | Get STS credentials for your last-used role in the "dev" account:
690 | vault-aws-creds dev
691 |
692 | The latest source can be found at:
693 | <%s>
694 | """ % _SRC_URL)
695 | p = argparse.ArgumentParser(
696 | description='Retrieve temporary AWS credentials from Vault and print '
697 | 'bash export lines for them. Intended to be run from a '
698 | 'bash wrapper function.',
699 | epilog=epil, formatter_class=argparse.RawTextHelpFormatter,
700 | add_help=False
701 | )
702 | p.add_argument('-h', '--help', dest='help', action='store_true',
703 | default=False, help='show this help message and exit')
704 | p.add_argument('-w', '--wrapper-func', dest='show_wrapper',
705 | action='store_true', default=False, help='print bash wrapper'
706 | ' function to STDOUT and exit')
707 | p.add_argument('-v', '--verbose', dest='verbose', action='count', default=0,
708 | help='verbose output. specify twice for debug-level output.')
709 | p.add_argument('-V', '--version', action='store_true', default=False,
710 | help='Print version number and exit', dest='version')
711 | conf = os.path.abspath(os.path.expanduser('~/.vault-aws-creds.conf'))
712 | p.add_argument('-c', '--config-file', dest='config', action='store',
713 | type=str, default=conf,
714 | help='Path to config file (default: %s)' % conf)
715 | p.add_argument('--called-from-wrapper', dest='wrapper_called',
716 | action='store_true', default=False, help='DO NOT USE')
717 | p.add_argument('-r', '--region', dest='region', action='store', type=str,
718 | default=None,
719 | help='AWS_REGION to export if not already set')
720 | p.add_argument('-t', '--ttl', dest='ttl', action='store', type=str,
721 | default=None,
722 | help='TTL to request STS creds with; must be specified in '
723 | '[0-9]+[hms] format like "30m" or "5h".')
724 | p.add_argument('ACCOUNT', action='store', default=None, nargs='?',
725 | help='AWS account name (Vault AWS backend mount point, or '
726 | 'mount point after "aws_" prefix); if omitted, all '
727 | 'accounts you have access to will be listed.')
728 | p.add_argument('--roles', dest='list_roles', action='store_true',
729 | default=False, help='List available roles for account')
730 | p.add_argument('--iam', dest='iam', action='store_true', default=False,
731 | help='If specified, get IAM User credentials. Otherwise, get'
732 | ' STS credentials.')
733 | p.add_argument('-R', '--no-stored-role', dest='store_role',
734 | action='store_false', default=True,
735 | help='Do not store role selection in, or use previous role '
736 | 'selection from, config file')
737 | p.add_argument('-T', '--no-stored-ttl', dest='store_ttl',
738 | action='store_false', default=True,
739 | help='Do not store TTL selection in, or use previous TTL '
740 | 'selection from, config file')
741 | p.add_argument('ROLE', action='store', default=None, nargs='?',
742 | help='Vault role name to get creds for in the account; '
743 | 'if --no-stored-role is not specified, the role name '
744 | 'for each account will be stored to and retrieved from '
745 | 'the config file.')
746 | args = p.parse_args(argv)
747 | if args.help:
748 | p.print_help(sys.stderr)
749 | raise SystemExit(1)
750 | if args.version:
751 | sys.stderr.write("vault-aws-creds.py version %s\n" % __version__)
752 | raise SystemExit(1)
753 | if args.ttl is not None:
754 | if args.iam:
755 | raise RuntimeError('Error: --ttl can only be used with STS creds.')
756 | if not re.match(r'^\d+[hms]$', args.ttl):
757 | raise RuntimeError('Error: Invalid TTL format: %s' % args.ttl)
758 | return args
759 |
760 |
761 | if __name__ == "__main__":
762 | args = parse_args(sys.argv[1:])
763 |
764 | # set logging level
765 | if args.verbose > 1:
766 | set_log_debug()
767 | elif args.verbose == 1:
768 | set_log_info()
769 |
770 | if args.show_wrapper:
771 | print(VaultAwsCredExporter.bash_wrapper())
772 | raise SystemExit(1)
773 |
774 | try:
775 | exporter = VaultAwsCredExporter(args.config, args.region, args.ttl)
776 |
777 | if not args.wrapper_called:
778 | sys.stderr.write(
779 | bold('vault-aws-creds.py should be called through a bash wrapper '
780 | 'function; run with "-w" to output the appropriate '
781 | 'function.') + "\n"
782 | )
783 | if args.ACCOUNT is None:
784 | mpoints = {}
785 | try:
786 | mpoints = exporter._get_aws_mountpoints()
787 | except VaultException as ex:
788 | if 'permission denied' not in str(ex):
789 | raise
790 | logger.error(
791 | 'ERROR: Your token does not have permission to list Vault '
792 | 'mount points; you will have to obtain the AWS backend mount '
793 | 'points for your accounts from your Vault administrator.'
794 | )
795 | mpoint_listing = {}
796 | for name, mpoint in mpoints.items():
797 | if mpoint not in mpoint_listing:
798 | mpoint_listing[mpoint] = []
799 | mpoint_listing[mpoint].append(name)
800 | sys.stderr.write("Available Accounts:\n")
801 | for mpoint in sorted(mpoint_listing.keys()):
802 | sys.stderr.write(' a.k.a. '.join(
803 | ['"%s"' % x for x in mpoint_listing[mpoint]]
804 | ) + "\n")
805 | raise SystemExit(1)
806 | # ok, we have an account name
807 | acct = exporter.mountpoint_for_account(args.ACCOUNT)
808 | logger.info('Using mountpoint: %s', acct)
809 | if args.list_roles:
810 | sys.stderr.write("Available Vault Roles for Account '%s':\n" % acct)
811 | for rname in exporter.list_roles(acct):
812 | sys.stderr.write(rname + "\n")
813 | raise SystemExit(1)
814 | print(exporter.get_creds(acct, args.ROLE, iam=args.iam,
815 | store_role=args.store_role,
816 | store_ttl=args.store_ttl))
817 | except VaultException as ex:
818 | sys.stderr.write("ERROR: %s\n" % ex)
819 | raise SystemExit(1)
820 |
--------------------------------------------------------------------------------