Skip to content

Commit bb4b3bf

Browse files
authored
Merge pull request #77 from britive/develop
v1.3.0
2 parents ffc8082 + 8506eff commit bb4b3bf

22 files changed

+540
-13
lines changed

CHANGELOG.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,32 @@
22

33
All changes to the package starting with v0.3.1 will be logged here.
44

5+
## v1.3.0 [2023-03-28]
6+
#### What's New
7+
* `pybritive ssh aws ssm-proxy` command
8+
* `pybritive aws console` command
9+
10+
#### Enhancements
11+
* Additional `--mode/-m` values
12+
* `console`: checkout console access (without having to specify --console/-c`) and print the URL
13+
* `browser-mozilla`: checkout console access and open a mozilla browser with the checked out URL
14+
* `browser-firefox`: checkout console access and open a firefox browser with the checked out URL
15+
* `browser-windows-default`: checkout console access and open a windows default browser with the checked out URL
16+
* `browser-macosx`: checkout console access and open a macosx browser with the checked out URL
17+
* `browser-safari`: checkout console access and open a safari browser with the checked out URL
18+
* `browser-chrome`: checkout console access and open a chrome browser with the checked out URL
19+
* `browser-chromium`: checkout console access and open a chromium browser with the checked out URL
20+
* For the `checkout` command the option `--mode/-m` with values of `browser` and `console` now implicitly indicate that the console version of the profile should be checked out (without having to specify `--console/-c`)
21+
22+
#### Bug Fixes
23+
* None
24+
25+
#### Dependencies
26+
* `britive>=2.18.0`
27+
28+
#### Other
29+
* Addition of Community Projects to the README.
30+
531
## v1.2.2 [2023-03-17]
632
#### What's New
733
* None

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,9 @@ everywhere.
2929
https://britive.github.io/python-cli/
3030

3131

32+
## Community Projects
33+
34+
Note: Britive, Inc. does not provide support for community projects. Community projects are also not considered when ensuring backwards compatibility for releases. The list below is provided as-is and use of these projects is subject to the licensing/restrictions of each individual project.
35+
36+
* `vim-britive`: https://github.com/pbnj/vim-britive
37+

docs/index.md

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,76 @@ Example of use: (`pybritive api method --parameter1 value1 --parameter2 value2 [
258258
which parameters are expected and which are optional. Parameters with `_` in the name should be translated to `-` when referencing them
259259
via the CLI.
260260

261+
## `ssh` Command
262+
263+
The `ssh` command facilitates using the native SSH protocol to connect to private cloud servers.
264+
265+
The goal is to allow all functionality offered by the SSH protocol like local port forwarding to access private resources and `scp` to copy files to the remote host.
266+
267+
At launch only AWS EC2 is supported. The requirements for using SSH with EC2 instances are provided below.
268+
269+
* EC2 instance must have the Systems Manager agent installed and operational.
270+
* EC2 instance must have the EC2 Instance Connect agent installed and operational (if using `--push-public-key`).
271+
* The caller must have appropriate IAM permissions to start a Session Manager session (for all `--key-source`s) and push a public key via EC2 Instance Connect (if using `--push-public-key`).
272+
* The caller's environment must have the AWS CLI installed along with the Session Manager plugin.
273+
* The caller's python environment must have the `boto3` package installed. As `boto3` is not required for the use of `pybritive` it is not automatically installed (if using `--push-public-key`).
274+
* The caller must use OpenSSH (and the SSH config file). Other SSH implementations are not currently supported.
275+
276+
There are 3 ways that `pybritive` can help proxy an SSH session to a private EC2 instance.
277+
278+
* Using just Session Manager SSH forwarding to establish the network path over which the SSH protocol will operate. It is left to the caller then to handle SSH authentication using whichever mechanism has already been established.
279+
280+
~~~bash
281+
Host bastion.dev
282+
HostName i-xxxxxxxxxxxxxxxxx.profile[.region]
283+
284+
Match host i-*,mi-*
285+
User ssm-user
286+
ProxyCommand eval $(pybritive ssh aws ssm-proxy --hostname %h --username %r --port-number %p)
287+
~~~
288+
289+
* Using Session Manager SSH forwarding along with pushing a randomly generated SSH key pair public key via EC2 Instance Connect and identifying the private key via static path in the `IdentityFile` parameter.
290+
291+
~~~bash
292+
Host bastion.dev
293+
HostName i-xxxxxxxxxxxxxxxxx.profile[.region]
294+
295+
Match host i-*,mi-*
296+
User ssm-user
297+
IdentityFile ~/.britive/ssh/%h.%r.pem
298+
ProxyCommand eval $(pybritive ssh aws ssm-proxy --hostname %h --username %r --port-number %p --push-pulbic-key --key-source static)
299+
~~~
300+
301+
* Using Session Manager SSH forwarding along with pushing a randomly generated SSH key pair public key via EC2 Instance Connect and adding the private key to the `ssh-agent` via `ssh-add` so it is available without having to specify the `IdentityFile` parameter.
302+
303+
~~~bash
304+
Host bastion.dev
305+
HostName i-xxxxxxxxxxxxxxxxx.profile[.region]
306+
307+
Match host i-*,mi-*
308+
User ssm-user
309+
ProxyCommand eval $(pybritive ssh aws ssm-proxy --hostname %h --username %r --port-number %p --push-pulbic-key --key-source ssh-agent)
310+
~~~
311+
312+
The `HostName` parameter must be in the appropriate format. That format is
313+
314+
~~~
315+
[instance-id][.aws-profile-name[.aws-region]]
316+
~~~
317+
318+
Both `aws-profile-name` and `aws-region` are optional. If `aws-profile-name` is omitted then credentials for Session Manager and EC2 Instance Connect will be sourced from the standard AWS credential provider chain.
319+
If `aws-region` is omitted then credentials for Session Manager and EC2 Instance Connect will be sourced from the standard AWS region provider chain.
320+
321+
The command `ssh aws config` can be invoked to generate the above `Match` directives.
322+
323+
## `aws` Command
324+
325+
The `aws` command group will hold actions related specifically to AWS.
326+
327+
The first supported sub-command is `console` which will sign an AWS console URL using programmatic access keys
328+
(long-lived IAM User keys or temporary AWS AssumeRole credentials). This will allow you to check out programmatic access
329+
for a Britive AWS profile (or any other system which issues AWS access keys) and use the resulting keys to get into the AWS console.
330+
261331
## Shell Completion
262332

263333
TODO: Provide more automated scripts here to automatically add the required configs to the profiles. For now the below works just fine though.

requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
britive>=2.17.0
1+
britive>=2.18.0
22
certifi>=2022.12.7
33
charset-normalizer==2.1.0
44
click==8.1.3

setup.cfg

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[metadata]
22
name = pybritive
3-
version = 1.2.2
3+
version = 1.3.0
44
author = Britive Inc.
55
author_email = support@britive.com
66
description = A pure Python CLI for Britive
@@ -26,7 +26,7 @@ install_requires =
2626
toml
2727
cryptography~=39.0.1
2828
python-dateutil
29-
britive>=2.17.0
29+
britive>=2.18.0
3030
jmespath
3131
pyjwt
3232

src/pybritive/britive_cli.py

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import io
22
import pathlib
3+
import uuid
34
from britive.britive import Britive
45
from .helpers.config import ConfigManager
56
from .helpers.credentials import FileCredentialManager, EncryptedFileCredentialManager
@@ -435,6 +436,11 @@ def checkout(self, alias, blocktime, console, justification, mode, maxpolltime,
435436
response = None
436437
self.verbose_checkout = verbose
437438

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+
438444
self._validate_justification(justification)
439445

440446
if mode == 'awscredentialprocess':
@@ -787,11 +793,214 @@ def _validate_justification(justification: str):
787793
if justification and len(justification) > 255:
788794
raise ValueError('justification cannot be longer than 255 characters.')
789795

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.
790973

974+
params = {
975+
'Action': 'getSigninToken',
976+
'SessionDuration': duration,
977+
'Session': json_creds
978+
}
791979

980+
url = 'https://signin.aws.amazon.com/federation'
792981

982+
response = requests.get(url, params=params)
793983

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+
}
794997

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
7951002

1003+
browser = webbrowser.get(using=browser)
1004+
browser.open(console_url)
7961005

7971006

src/pybritive/choices/browser.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import click
2+
3+
# eval example: eval $(pybritive checkout test -m env)
4+
5+
browser_choices = click.Choice(
6+
[
7+
'default'
8+
'mozilla',
9+
'firefox',
10+
'windows-default',
11+
'macosx',
12+
'safari',
13+
'chrome',
14+
'chromium'
15+
],
16+
case_sensitive=False
17+
)
18+

0 commit comments

Comments
 (0)