PKsG>>piu/__main__.pyimport piu.cli if __name__ == '__main__': piu.cli.main() PKG>yu|piu/__init__.py__version__ = '1.0.8' PKsGz.(.( piu/cli.py#!/usr/bin/env python3 ''' Helper script to request access to a certain host. ''' import click import datetime import ipaddress import json import os import requests import socket import sys import time import yaml import zign.api from clickclick import error, AliasedGroup, print_table, OutputFormat import piu try: import pyperclip except: pyperclip = None CONFIG_DIR_PATH = click.get_app_dir('piu') CONFIG_FILE_PATH = os.path.join(CONFIG_DIR_PATH, 'piu.yaml') CONTEXT_SETTINGS = dict(help_option_names=['-h', '--help']) DEFAULT_COMMAND = 'request-access' STUPS_CIDR = ipaddress.ip_network('172.31.0.0/16') STATUS_NAMES = [ 'REQUESTED', 'GRANTED', 'DENIED', 'FAILED', 'EXPIRED', 'REVOKED' ] STYLES = { 'REQUESTED': {'fg': 'yellow', 'bold': True}, 'GRANTED': {'fg': 'green'}, 'DENIED': {'fg': 'red', 'bold': True}, 'FAILED': {'fg': 'red', 'bold': True}, 'EXPIRED': {'fg': 'yellow', 'bold': True}, 'REVOKED': {'fg': 'red'}, 'OK': {'fg': 'green'}, 'ERROR': {'fg': 'red'}, } TITLES = { 'created_time': 'Created', 'lifetime_minutes': 'TTL' } MAX_COLUMN_WIDTHS = { 'reason': 50, 'remote_host': 20, 'status_reason': 50 } output_option = click.option('-o', '--output', type=click.Choice(['text', 'json', 'tsv']), default='text', help='Use alternative output format') def parse_time(s: str) -> float: ''' >>> parse_time('2015-04-14T19:09:01.000Z') > 0 True ''' try: utc = datetime.datetime.strptime(s, '%Y-%m-%dT%H:%M:%S.%fZ') ts = time.time() utc_offset = datetime.datetime.fromtimestamp(ts) - datetime.datetime.utcfromtimestamp(ts) local = utc + utc_offset return local.timestamp() except Exception as e: print(e) return None class AliasedDefaultGroup(AliasedGroup): def resolve_command(self, ctx, args): cmd_name = args[0] cmd = AliasedGroup.get_command(self, ctx, cmd_name) if not cmd: cmd_name = DEFAULT_COMMAND cmd = AliasedGroup.get_command(self, ctx, cmd_name) new_args = args else: new_args = args[1:] return cmd_name, cmd, new_args def load_config(path): try: with open(path, 'rb') as fd: config = yaml.safe_load(fd) except: config = None return config or {} def store_config(config, path): dir_path = os.path.dirname(path) if dir_path: os.makedirs(dir_path, exist_ok=True) with open(path, 'w') as fd: yaml.dump(config, fd) def print_version(ctx, param, value): if not value or ctx.resilient_parsing: return click.echo('Piu {}'.format(piu.__version__)) ctx.exit() def _request_access(even_url, cacert, username, hostname, reason, remote_host, lifetime, user, password, clip): data = {'username': username, 'hostname': hostname, 'reason': reason} host_via = hostname if remote_host: data['remote_host'] = remote_host host_via = '{} via {}'.format(remote_host, hostname) if lifetime: data['lifetime_minutes'] = lifetime try: token = zign.api.get_named_token(['uid'], 'employees', 'piu', user, password, prompt=True) except zign.api.ServerError as e: click.secho('{}'.format(e), fg='red', bold=True) return 500 access_token = token.get('access_token') click.secho('Requesting access to host {host_via} for {username}..'.format(host_via=host_via, username=username), bold=True) r = requests.post(even_url, headers={'Content-Type': 'application/json', 'Authorization': 'Bearer {}'.format(access_token)}, data=json.dumps(data), verify=cacert) if r.status_code == 200: click.secho(r.text, fg='green', bold=True) ssh_command = '' if remote_host: ssh_command = 'ssh -o StrictHostKeyChecking=no {username}@{remote_host}'.format(**vars()) click.secho('You can now access your server with the following command:') command = 'ssh -tA {username}@{hostname} {ssh_command}'.format( username=username, hostname=hostname, ssh_command=ssh_command) click.secho(command) if clip: click.secho('\nOr just check your clipboard and run ctrl/command + v (requires package "xclip" on Linux)') if pyperclip is not None: pyperclip.copy(command) else: click.secho('Server returned status {code}: {text}'.format(code=r.status_code, text=r.text), fg='red', bold=True) return r.status_code @click.group(cls=AliasedDefaultGroup, context_settings=CONTEXT_SETTINGS) @click.option('--config-file', '-c', help='Use alternative configuration file', default=CONFIG_FILE_PATH, metavar='PATH') @click.option('-V', '--version', is_flag=True, callback=print_version, expose_value=False, is_eager=True, help='Print the current version number and exit.') @click.pass_context def cli(ctx, config_file): ctx.obj = config_file @cli.command('request-access') @click.argument('host', metavar='[USER]@HOST') @click.argument('reason') @click.argument('reason_cont', nargs=-1, metavar='[..]') @click.option('-U', '--user', help='Username to use for OAuth2 authentication', envvar='PIU_USER', metavar='NAME') @click.option('-p', '--password', help='Password to use for OAuth2 authentication', envvar='PIU_PASSWORD', metavar='PWD') @click.option('-E', '--even-url', help='Even SSH Access Granting Service URL', envvar='EVEN_URL', metavar='URI') @click.option('-O', '--odd-host', help='Odd SSH bastion hostname', envvar='ODD_HOST', metavar='HOSTNAME') @click.option('-t', '--lifetime', help='Lifetime of the SSH access request in minutes (default: 60)', type=click.IntRange(1, 525600, clamp=True)) @click.option('--insecure', help='Do not verify SSL certificate', is_flag=True, default=False) @click.option('--clip', is_flag=True, help='Copy SSH command into clipboard', default=False) @click.pass_obj def request_access(obj, host, user, password, even_url, odd_host, reason, reason_cont, insecure, lifetime, clip): '''Request SSH access to a single host''' user = user or os.getenv('USER') parts = host.split('@') if len(parts) > 1: username = parts[0] else: username = user hostname = parts[-1] try: ip = ipaddress.ip_address(hostname) except ValueError: ip = None reason = ' '.join([reason] + list(reason_cont)).strip() cacert = not insecure config_file = obj config = load_config(config_file) even_url = even_url or config.get('even_url') odd_host = odd_host or config.get('odd_host') if 'cacert' in config: cacert = config['cacert'] while not even_url: even_url = click.prompt('Please enter the Even SSH access granting service URL') if not even_url.startswith('http'): # convenience for humans: add HTTPS by default even_url = 'https://{}'.format(even_url) try: requests.get(even_url) except: error('Could not reach {}'.format(even_url)) even_url = None config['even_url'] = even_url while ip and ip in STUPS_CIDR and not odd_host: odd_host = click.prompt('Please enter the Odd SSH bastion hostname') try: socket.getaddrinfo(odd_host, 22) except: error('Could not resolve hostname {}'.format(odd_host)) odd_host = None config['odd_host'] = odd_host store_config(config, config_file) if not even_url.endswith('/access-requests'): even_url = even_url.rstrip('/') + '/access-requests' first_host = hostname remote_host = hostname if odd_host: first_host = odd_host if first_host == remote_host: # user friendly behavior: it makes no sense to jump from bastion to itself remote_host = None elif remote_host.startswith('odd-'): # user friendly behavior: if the remote host is obviously a odd host, just use it first_host = remote_host remote_host = None return_code = _request_access(even_url, cacert, username, first_host, reason, remote_host, lifetime, user, password, clip) if return_code != 200: sys.exit(return_code) @cli.command('list-access-requests') @click.option('-u', '--user', help='Filter by username', metavar='NAME') @click.option('-O', '--odd-host', help='Odd SSH bastion hostname (default: my configured odd host)', envvar='ODD_HOST', metavar='HOSTNAME', default='MY-ODD-HOST') @click.option('-s', '--status', help='Filter by status', metavar='NAME', type=click.Choice(STATUS_NAMES)) @click.option('-l', '--limit', help='Limit number of results', type=int, default=20) @click.option('--offset', help='Offset', type=int, default=0) @output_option @click.pass_obj def list_access_requests(obj, user, odd_host, status, limit, offset, output): '''List access requests filtered by user, host and status''' config = load_config(obj) if user == '*': user = None if odd_host == '*': odd_host = None elif odd_host == 'MY-ODD-HOST': odd_host = config.get('odd_host') access_token = zign.api.get_token('piu', ['piu']) params = {'username': user, 'hostname': odd_host, 'status': status, 'limit': limit, 'offset': offset} r = requests.get(config.get('even_url').rstrip('/') + '/access-requests', params=params, headers={'Authorization': 'Bearer {}'.format(access_token)}) r.raise_for_status() rows = [] for req in r.json(): req['created_time'] = parse_time(req['created']) rows.append(req) rows.sort(key=lambda x: x['created_time']) with OutputFormat(output): print_table('username hostname remote_host reason lifetime_minutes status status_reason created_time'.split(), rows, styles=STYLES, titles=TITLES, max_column_widths=MAX_COLUMN_WIDTHS) def main(): cli() if __name__ == '__main__': main() PKGei)stups_piu-1.0.8.dist-info/DESCRIPTION.rst=== Più === .. image:: https://travis-ci.org/zalando-stups/piu.svg?branch=master :target: https://travis-ci.org/zalando-stups/piu :alt: Build Status .. image:: https://coveralls.io/repos/zalando-stups/piu/badge.svg :target: https://coveralls.io/r/zalando-stups/piu :alt: Code Coverage .. image:: https://img.shields.io/pypi/dw/stups-piu.svg :target: https://pypi.python.org/pypi/stups-piu/ :alt: PyPI Downloads .. image:: https://img.shields.io/pypi/v/stups-piu.svg :target: https://pypi.python.org/pypi/stups-piu/ :alt: Latest PyPI version .. image:: https://img.shields.io/pypi/l/stups-piu.svg :target: https://pypi.python.org/pypi/stups-piu/ :alt: License Più is the command line client for the "even" SSH access granting service. Installation ============ .. code-block:: bash $ sudo pip3 install --upgrade stups-piu Usage ===== .. code-block:: bash $ piu myuser@myhost my-reason See the `STUPS documentation on Più`_ for details. .. _STUPS documentation on Più: http://stups.readthedocs.org/en/latest/components/piu.html Releasing ========= .. code-block:: bash $ ./release.sh PKGطXS&&*stups_piu-1.0.8.dist-info/entry_points.txt[console_scripts] piu = piu.cli:main PKGQ9'stups_piu-1.0.8.dist-info/metadata.json{"classifiers": ["Development Status :: 4 - Beta", "Environment :: Console", "Intended Audience :: Developers", "Intended Audience :: System Administrators", "License :: OSI Approved :: Apache Software License", "Operating System :: POSIX :: Linux", "Programming Language :: Python", "Programming Language :: Python :: 3.4", "Programming Language :: Python :: Implementation :: CPython"], "extensions": {"python.commands": {"wrap_console": {"piu": "piu.cli:main"}}, "python.details": {"contacts": [{"email": "henning.jacobs@zalando.de", "name": "Henning Jacobs", "role": "author"}], "document_names": {"description": "DESCRIPTION.rst"}, "project_urls": {"Home": "https://github.com/zalando-stups/piu"}}, "python.exports": {"console_scripts": {"piu": "piu.cli:main"}}}, "extras": [], "generator": "bdist_wheel (0.26.0)", "keywords": ["aws", "account", "saml", "login", "federated", "shibboleth"], "license": "Apache License 2.0", "metadata_version": "2.0", "name": "stups-piu", "run_requires": [{"requires": ["PyYAML", "clickclick (>=0.10)", "pyperclip", "requests", "stups-zign (>=0.16)"]}], "summary": "Command line client for \"even\" SSH access granting service", "test_requires": [{"requires": ["pytest", "pytest-cov"]}], "version": "1.0.8"}PKG'stups_piu-1.0.8.dist-info/top_level.txtpiu PKG}\\stups_piu-1.0.8.dist-info/WHEELWheel-Version: 1.0 Generator: bdist_wheel (0.26.0) Root-Is-Purelib: true Tag: py3-none-any PKG9FP''"stups_piu-1.0.8.dist-info/METADATAMetadata-Version: 2.0 Name: stups-piu Version: 1.0.8 Summary: Command line client for "even" SSH access granting service Home-page: https://github.com/zalando-stups/piu Author: Henning Jacobs Author-email: henning.jacobs@zalando.de License: Apache License 2.0 Keywords: aws account saml login federated shibboleth Platform: UNKNOWN Classifier: Development Status :: 4 - Beta Classifier: Environment :: Console Classifier: Intended Audience :: Developers Classifier: Intended Audience :: System Administrators Classifier: License :: OSI Approved :: Apache Software License Classifier: Operating System :: POSIX :: Linux Classifier: Programming Language :: Python Classifier: Programming Language :: Python :: 3.4 Classifier: Programming Language :: Python :: Implementation :: CPython Requires-Dist: PyYAML Requires-Dist: clickclick (>=0.10) Requires-Dist: pyperclip Requires-Dist: requests Requires-Dist: stups-zign (>=0.16) === Più === .. image:: https://travis-ci.org/zalando-stups/piu.svg?branch=master :target: https://travis-ci.org/zalando-stups/piu :alt: Build Status .. image:: https://coveralls.io/repos/zalando-stups/piu/badge.svg :target: https://coveralls.io/r/zalando-stups/piu :alt: Code Coverage .. image:: https://img.shields.io/pypi/dw/stups-piu.svg :target: https://pypi.python.org/pypi/stups-piu/ :alt: PyPI Downloads .. image:: https://img.shields.io/pypi/v/stups-piu.svg :target: https://pypi.python.org/pypi/stups-piu/ :alt: Latest PyPI version .. image:: https://img.shields.io/pypi/l/stups-piu.svg :target: https://pypi.python.org/pypi/stups-piu/ :alt: License Più is the command line client for the "even" SSH access granting service. Installation ============ .. code-block:: bash $ sudo pip3 install --upgrade stups-piu Usage ===== .. code-block:: bash $ piu myuser@myhost my-reason See the `STUPS documentation on Più`_ for details. .. _STUPS documentation on Più: http://stups.readthedocs.org/en/latest/components/piu.html Releasing ========= .. code-block:: bash $ ./release.sh PKGL .. stups_piu-1.0.8.dist-info/RECORDpiu/__init__.py,sha256=mFFUUCx5TqyW1TTFRrWDhXXVMJDMRxXWrkHanVtp9oY,22 piu/__main__.py,sha256=id4GCcWf4YcK90OHJkp6pCXf6ec6_RMHnkbPsQ6M9SA,62 piu/cli.py,sha256=OuQsfvx-c4snEcy4koDz1kJZxIB9DgDcRyFHMqsF5XA,10286 stups_piu-1.0.8.dist-info/DESCRIPTION.rst,sha256=H5rTliPgPtT45I9Z171W_0kjjKPedjD93mSPGNESxow,1161 stups_piu-1.0.8.dist-info/METADATA,sha256=ZFBd_DUkidpTzxUq3X9agILf-yJ7BVQWBEDB1uPbSts,2087 stups_piu-1.0.8.dist-info/RECORD,, stups_piu-1.0.8.dist-info/WHEEL,sha256=zX7PHtH_7K-lEzyK75et0UBa3Bj8egCBMXe1M4gc6SU,92 stups_piu-1.0.8.dist-info/entry_points.txt,sha256=ZdzKVptJhXN1yKVKCZd0bgw82qqlTcriVVt96riVXM4,38 stups_piu-1.0.8.dist-info/metadata.json,sha256=5REM3hg1R5j9nm0hKpkdq-Hbg1Qh-lSwQUUu4KwFdDg,1245 stups_piu-1.0.8.dist-info/top_level.txt,sha256=OjtN8TwfigGnF5Yf4SeVp99hZsPY_iy2zwOag07w-P4,4 PKsG>>piu/__main__.pyPKG>yu|kpiu/__init__.pyPKsGz.(.( piu/cli.pyPKGei))stups_piu-1.0.8.dist-info/DESCRIPTION.rstPKGطXS&&*-stups_piu-1.0.8.dist-info/entry_points.txtPKGQ9'B.stups_piu-1.0.8.dist-info/metadata.jsonPKG'd3stups_piu-1.0.8.dist-info/top_level.txtPKG}\\3stups_piu-1.0.8.dist-info/WHEELPKG9FP''"F4stups_piu-1.0.8.dist-info/METADATAPKGL .. <stups_piu-1.0.8.dist-info/RECORDPK @