|
1 | 1 | import io |
2 | 2 | import pathlib |
| 3 | +import uuid |
3 | 4 | from britive.britive import Britive |
4 | 5 | from .helpers.config import ConfigManager |
5 | 6 | from .helpers.credentials import FileCredentialManager, EncryptedFileCredentialManager |
@@ -435,6 +436,11 @@ def checkout(self, alias, blocktime, console, justification, mode, maxpolltime, |
435 | 436 | response = None |
436 | 437 | self.verbose_checkout = verbose |
437 | 438 |
|
| 439 | + # these 2 modes implicitly say that console access should be checked out without having to provide |
| 440 | + # the --console flag |
| 441 | + if mode in ['browser', 'console']: |
| 442 | + console = True |
| 443 | + |
438 | 444 | self._validate_justification(justification) |
439 | 445 |
|
440 | 446 | if mode == 'awscredentialprocess': |
@@ -787,11 +793,214 @@ def _validate_justification(justification: str): |
787 | 793 | if justification and len(justification) > 255: |
788 | 794 | raise ValueError('justification cannot be longer than 255 characters.') |
789 | 795 |
|
| 796 | + def ssh_aws_ssm_proxy(self, username, hostname, push_public_key, port_number, key_source): |
| 797 | + self.silent = True |
| 798 | + helper = hostname.split('.') |
| 799 | + instance_id = helper[0] |
| 800 | + aws_profile = None # will drop to using standard aws boto3/cli profile provider chain |
| 801 | + aws_region = None # will drop to using standard aws boto3/cli region provider chain |
| 802 | + if len(helper) > 1: |
| 803 | + aws_profile = helper[1] |
| 804 | + if len(helper) > 2: |
| 805 | + aws_region = helper[2] |
| 806 | + |
| 807 | + if push_public_key: |
| 808 | + self._ssh_aws_generate_key( |
| 809 | + instance_id=instance_id, |
| 810 | + aws_profile=aws_profile, |
| 811 | + aws_region=aws_region, |
| 812 | + username=username, |
| 813 | + hostname=hostname, |
| 814 | + key_source=key_source |
| 815 | + ) |
| 816 | + |
| 817 | + commands = [ |
| 818 | + 'aws', |
| 819 | + 'ssm', |
| 820 | + 'start-session', |
| 821 | + f'--parameters portNumber={port_number}', |
| 822 | + '--document-name AWS-StartSSHSession', |
| 823 | + f'--target {instance_id}' |
| 824 | + ] |
| 825 | + |
| 826 | + if aws_profile: |
| 827 | + commands.append(f'--profile {aws_profile}') |
| 828 | + if aws_region: |
| 829 | + commands.append(f'--region {aws_region}') |
| 830 | + |
| 831 | + self.print(' '.join(commands), ignore_silent=True) |
| 832 | + |
| 833 | + def _ssh_aws_generate_key(self, instance_id, aws_profile, aws_region, username, hostname, key_source): |
| 834 | + # doing imports here as these packages are not a requirement to use pybritive in general |
| 835 | + |
| 836 | + # cryptography is already a hard requirement so we know it will eixst |
| 837 | + from cryptography.hazmat.primitives.asymmetric import rsa |
| 838 | + from cryptography.hazmat.primitives import serialization |
| 839 | + |
| 840 | + # these 3 ship with python3.x |
| 841 | + import glob |
| 842 | + import time |
| 843 | + import subprocess |
| 844 | + |
| 845 | + # this is the one that may not be available so be careful |
| 846 | + try: |
| 847 | + import boto3 |
| 848 | + except ImportError: |
| 849 | + message = 'boto3 package is required. Please ensure the package is installed.' |
| 850 | + raise click.ClickException(message) |
| 851 | + |
| 852 | + # we know we will be pushing the key to the instance so establish the |
| 853 | + # boto3 clients which are required to perform those actions |
| 854 | + session = boto3.Session(profile_name=aws_profile, region_name=aws_region) |
| 855 | + eic = session.client('ec2-instance-connect') |
| 856 | + ec2 = session.client('ec2') |
| 857 | + |
| 858 | + # now let's create the ephemeral private and public key pair |
| 859 | + private_key = rsa.generate_private_key( |
| 860 | + public_exponent=65537, |
| 861 | + key_size=2048 |
| 862 | + ) |
| 863 | + |
| 864 | + pem_private_key = private_key.private_bytes( |
| 865 | + encoding=serialization.Encoding.PEM, |
| 866 | + format=serialization.PrivateFormat.TraditionalOpenSSL, |
| 867 | + encryption_algorithm=serialization.NoEncryption() |
| 868 | + ) |
| 869 | + |
| 870 | + pem_public_key = private_key.public_key().public_bytes( |
| 871 | + encoding=serialization.Encoding.OpenSSH, |
| 872 | + format=serialization.PublicFormat.OpenSSH |
| 873 | + ) |
| 874 | + |
| 875 | + # let's do the right thing and clean up old ephemeral keys |
| 876 | + ssh_dir = Path(self.config.path).parent.absolute() / 'ssh' |
| 877 | + ssh_dir.mkdir(exist_ok=True, parents=True) # create the directory if it doesn't exist already |
| 878 | + pem_file = None |
| 879 | + if key_source == 'ssh-agent': |
| 880 | + # cleanup any old ssh keys that were randomly generated |
| 881 | + now = int(time.time()) |
| 882 | + for key in glob.glob(f'{str(ssh_dir)}/random-*'): |
| 883 | + file = key.split('/')[-1].split('.')[0] |
| 884 | + expiration = int(file.split('-')[2]) |
| 885 | + if expiration < now: |
| 886 | + Path(key).unlink(missing_ok=True) |
| 887 | + |
| 888 | + pem_file = ssh_dir / f'random-{uuid.uuid4().hex}-{now + 60}.pem' |
| 889 | + elif key_source == 'static': |
| 890 | + # clean up the specific key if it exists, so we can create a new one |
| 891 | + pem_file = ssh_dir / f'{hostname}.{username}.pem' |
| 892 | + pem_file.unlink(missing_ok=True) |
| 893 | + else: |
| 894 | + raise ValueError(f'invalid --key-source value {key_source}') |
| 895 | + |
| 896 | + # we only need to persist the private key locally |
| 897 | + # as the public key is just pushed to the ec2 instance |
| 898 | + # as a string in the ec2 instance connect api call (no file |
| 899 | + # reference) |
| 900 | + with open(str(pem_file), 'w') as f: |
| 901 | + f.write(pem_private_key.decode()) |
| 902 | + os.chmod(pem_file, 0o400) |
| 903 | + |
| 904 | + # now push the key |
| 905 | + az = ec2.describe_instances( |
| 906 | + InstanceIds=[instance_id] |
| 907 | + )['Reservations'][0]['Instances'][0]['Placement']['AvailabilityZone'] |
| 908 | + |
| 909 | + eic.send_ssh_public_key( |
| 910 | + InstanceId=instance_id, |
| 911 | + InstanceOSUser=username, |
| 912 | + SSHPublicKey=pem_public_key.decode(), |
| 913 | + AvailabilityZone=az |
| 914 | + ) |
| 915 | + |
| 916 | + # and if we are using ssh-agent we need to add the private key via ssh-add |
| 917 | + if key_source == 'ssh-agent': |
| 918 | + subprocess.run(['ssh-add', str(pem_file), '-t', '60', '-q']) |
| 919 | + |
| 920 | + def ssh_aws_openssh_config(self, push_public_key, key_source): |
| 921 | + lines = ['Match host i-*,mi-*'] |
| 922 | + if push_public_key: |
| 923 | + commands = [ |
| 924 | + '\tProxyCommand eval $(pybritive ssh aws ssm-proxy --hostname %h', |
| 925 | + '--username %r --port-number %p --push-public-key', |
| 926 | + f'--key-source {key_source})' |
| 927 | + ] |
| 928 | + lines.append(' '.join(commands)) |
| 929 | + |
| 930 | + if key_source == 'static': |
| 931 | + ssh_dir = Path(self.config.path).parent.absolute() / 'ssh' |
| 932 | + lines.append(f'\tIdentityFile {str(ssh_dir)}/%h.%r.pem') |
| 933 | + else: |
| 934 | + line = '\tProxyCommand eval $(pybritive ssh aws ssm-proxy --hostname %h --username %r --port-number %p)' |
| 935 | + lines.append(line) |
| 936 | + |
| 937 | + self.print('Add the below Match directive to your SSH config file, after all Host directives.') |
| 938 | + self.print('This file is generally located at ~/.ssh/config.') |
| 939 | + self.print('Additional SSH config parameters can be added as required.') |
| 940 | + self.print('The below directive is the minimum required configuration.') |
| 941 | + self.print('') |
| 942 | + self.print('') |
| 943 | + self.print('\n'.join(lines)) |
| 944 | + |
| 945 | + @staticmethod |
| 946 | + def aws_console(profile, duration, browser): |
| 947 | + # doing imports here as these packages are not a requirement to use pybritive in general |
| 948 | + |
| 949 | + # requests is a hard requirement for pybritive (via britive sdk) |
| 950 | + # and webbrowser ships with python3.x |
| 951 | + import requests |
| 952 | + import webbrowser |
| 953 | + |
| 954 | + # this is the one that may not be available so be careful |
| 955 | + try: |
| 956 | + import boto3 |
| 957 | + except ImportError: |
| 958 | + message = 'boto3 package is required. Please ensure the package is installed.' |
| 959 | + raise click.ClickException(message) |
| 960 | + |
| 961 | + creds = boto3.Session(profile_name=profile).get_credentials() |
| 962 | + session_id = creds.access_key |
| 963 | + session_key = creds.secret_key |
| 964 | + session_token = creds.token |
| 965 | + json_creds = json.dumps({ |
| 966 | + 'sessionId': session_id, |
| 967 | + 'sessionKey': session_key, |
| 968 | + 'sessionToken': session_token |
| 969 | + }) |
| 970 | + |
| 971 | + # Make request to AWS federation endpoint to get sign-in token. Construct the parameter string with |
| 972 | + # the sign-in action request and the JSON document with temporary credentials as parameters. |
790 | 973 |
|
| 974 | + params = { |
| 975 | + 'Action': 'getSigninToken', |
| 976 | + 'SessionDuration': duration, |
| 977 | + 'Session': json_creds |
| 978 | + } |
791 | 979 |
|
| 980 | + url = 'https://signin.aws.amazon.com/federation' |
792 | 981 |
|
| 982 | + response = requests.get(url, params=params) |
793 | 983 |
|
| 984 | + signin_token = None |
| 985 | + try: |
| 986 | + signin_token = json.loads(response.text) |
| 987 | + except json.decoder.JSONDecodeError: |
| 988 | + click.ClickException('Credentials have expired or another issue occurred. ' |
| 989 | + 'Please re-authenticate and try again.') |
| 990 | + |
| 991 | + params = { |
| 992 | + 'Action': 'login', |
| 993 | + 'Issuer': 'pybritive', |
| 994 | + 'Destination': 'https://console.aws.amazon.com/', |
| 995 | + 'SigninToken': signin_token['SigninToken'] |
| 996 | + } |
794 | 997 |
|
| 998 | + # using requests.prepare() here to help construct the url (with url encoding, etc.) |
| 999 | + # vs. doing it "manually" - we do not want to actually make a request to the url in python |
| 1000 | + # but use the url to pop open a browser |
| 1001 | + console_url = requests.Request('GET', url, params=params).prepare().url |
795 | 1002 |
|
| 1003 | + browser = webbrowser.get(using=browser) |
| 1004 | + browser.open(console_url) |
796 | 1005 |
|
797 | 1006 |
|
0 commit comments