├── .coveragerc ├── .github └── PULL_REQUEST_TEMPLATE.md ├── .gitignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── MANIFEST.in ├── NOTICE ├── README.md ├── README.rst ├── bin ├── msftp ├── msftp-putty.cmd ├── msftp.cmd ├── mssh ├── mssh-putty.cmd └── mssh.cmd ├── doc ├── Makefile ├── README.rst └── source │ ├── README.rst │ ├── conf.py │ └── index.rst ├── ec2instanceconnectcli ├── EC2InstanceConnectCLI.py ├── EC2InstanceConnectCommand.py ├── EC2InstanceConnectKey.py ├── EC2InstanceConnectLogger.py ├── __init__.py ├── ec2_util.py ├── input_parser.py ├── key_publisher.py ├── key_utils.py ├── mops.py └── mputty.py ├── pytest.ini ├── requirements-docs.txt ├── requirements.txt ├── setup.cfg ├── setup.py └── tests ├── configuration ├── config └── credentials ├── test_EC2ConnectCLI.py ├── test_ec2_util.py ├── test_input_parser.py ├── test_key_publisher.py ├── test_key_utils.py └── testloader ├── __init__.py └── test_base.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = true 3 | source = ec2instanceconnectcli 4 | 5 | [paths] 6 | source = 7 | src/ec2instanceconnectcli 8 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | *Issue #, if available:* 2 | 3 | *Description of changes:* 4 | 5 | 6 | By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license. 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | .pytest_cache/ 3 | *.egg-info/ 4 | .cache/ 5 | .coverage 6 | dist/ 7 | doc/generated/ 8 | venv/ 9 | build/ 10 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | ## Code of Conduct 2 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 3 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 4 | opensource-codeofconduct@amazon.com with any additional questions or comments. 5 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | Thank you for your interest in contributing to our project. Whether it's a bug report, new feature, correction, or additional 4 | documentation, we greatly value feedback and contributions from our community. 5 | 6 | Please read through this document before submitting any issues or pull requests to ensure we have all the necessary 7 | information to effectively respond to your bug report or contribution. 8 | 9 | 10 | ## Reporting Bugs/Feature Requests 11 | 12 | We welcome you to use the GitHub issue tracker to report bugs or suggest features. 13 | 14 | When filing an issue, please check [existing open](https://github.com/awslabs/aws-ec2-instance-connect-cli/issues), or [recently closed](https://github.com/awslabs/aws-ec2-instance-connect-cli/issues?utf8=%E2%9C%93&q=is%3Aissue%20is%3Aclosed%20), issues to make sure somebody else hasn't already 15 | reported the issue. Please try to include as much information as you can. Details like these are incredibly useful: 16 | 17 | * A reproducible test case or series of steps 18 | * The version of our code being used 19 | * Any modifications you've made relevant to the bug 20 | * Anything unusual about your environment or deployment 21 | 22 | 23 | ## Contributing via Pull Requests 24 | Contributions via pull requests are much appreciated. Before sending us a pull request, please ensure that: 25 | 26 | 1. You are working against the latest source on the *master* branch. 27 | 2. You check existing open, and recently merged, pull requests to make sure someone else hasn't addressed the problem already. 28 | 3. You open an issue to discuss any significant work - we would hate for your time to be wasted. 29 | 30 | To send us a pull request, please: 31 | 32 | 1. Fork the repository. 33 | 2. Modify the source; please focus on the specific change you are contributing. If you also reformat all the code, it will be hard for us to focus on your change. 34 | 3. Ensure local tests pass. 35 | 4. Commit to your fork using clear commit messages. 36 | 5. Send us a pull request, answering any default questions in the pull request interface. 37 | 6. Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation. 38 | 39 | GitHub provides additional document on [forking a repository](https://help.github.com/articles/fork-a-repo/) and 40 | [creating a pull request](https://help.github.com/articles/creating-a-pull-request/). 41 | 42 | 43 | ## Finding contributions to work on 44 | Looking at the existing issues is a great way to find something to contribute on. As our projects, by default, use the default GitHub issue labels ((enhancement/bug/duplicate/help wanted/invalid/question/wontfix), looking at any ['help wanted'](https://github.com/awslabs/aws-ec2-instance-connect-cli/labels/help%20wanted) issues is a great place to start. 45 | 46 | 47 | ## Code of Conduct 48 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 49 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 50 | opensource-codeofconduct@amazon.com with any additional questions or comments. 51 | 52 | 53 | ## Security issue notifications 54 | If you discover a potential security issue in this project we ask that you notify AWS/Amazon Security via our [vulnerability reporting page](http://aws.amazon.com/security/vulnerability-reporting/). Please do **not** create a public github issue. 55 | 56 | 57 | ## Licensing 58 | 59 | See the [LICENSE](https://github.com/awslabs/aws-ec2-instance-connect-cli/blob/master/LICENSE) file for our project's licensing. We will ask you to confirm the licensing of your contribution. 60 | 61 | We may ask you to sign a [Contributor License Agreement (CLA)](http://en.wikipedia.org/wiki/Contributor_License_Agreement) for larger changes. 62 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | exclude MANIFEST.in 2 | include README.md 3 | include LICENSE 4 | include requirements.txt 5 | graft doc 6 | prune doc/build 7 | prune tests 8 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | AWS Ec2 Instance Connect CLI 2 | Copyright 2018 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AWS EC2 Instance Connect CLI 2 | 3 | **_[IMPORTANT]_ Since June 2023, the AWS CLI includes the [ssh](https://awscli.amazonaws.com/v2/documentation/api/latest/reference/ec2-instance-connect/ssh.html) command. The AWS CLI ssh command allows you to connect to your instances directly over the internet or to instances in a private subnet using [EC2 Instance Connect](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/Connect-using-EC2-Instance-Connect.html). We strongly recommend using the AWS CLI ssh command rather than this package. 4 | 5 | This is a Python client for accessing EC2 instances via AWS EC2 Instance Connect. 6 | This module supports Python 3.6.x+. [This package is available on PyPI for pip installation](https://pypi.org/project/ec2instanceconnectcli/), ie, `pip install ec2instanceconnectcli` 7 | 8 | ## Setup 9 | 10 | It is strongly encouraged you set up a virtual environment for building and testing. 11 | 12 | ### Prerequisites 13 | 14 | To set up this package you need to have pip installed. 15 | 16 | ### Package Setup 17 | 18 | Install the package dependencies 19 | 20 | `pip install -r requirements.txt` 21 | 22 | ## Running 23 | 24 | Ensure your PYTHONPATH includes the package top-level directory. 25 | 26 | Run the desired script with standard UNIX pathing. For example, 27 | 28 | `./bin/mssh ec2-user@ec2-54-245-189-134.us-west-2.compute.amazonaws.com -pr dev -t i-0b01816d5c99826d8 -z us-west-2a` 29 | 30 | ## Testing 31 | 32 | Unit tests can be run with standard pytest. They may be run, for example, by 33 | 34 | `python -m pytest` 35 | 36 | Also, for correcting import when using virtualenv, you have to export PYTHONPATH by running: `export PYTHONPATH=$(pwd)` 37 | 38 | ## Generating Documentation 39 | 40 | Sphinx configuration has been included in this package. To generate Sphinx documentation, run 41 | 42 | `pip install -r requirements-docs.txt` 43 | 44 | to pull dependencies. Then, run 45 | 46 | `sphinx-apidoc -o doc/source ec2instanceconnectcli` 47 | 48 | to generate the module documentation reStructuredText files. Finally, run 49 | 50 | `sphinx-build ./doc/source [desired output directory]` 51 | 52 | to generate the actual documentation html. 53 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ============= 2 | ec2-connect-cli 3 | ============= 4 | 5 | This is a Python client for accessing EC2 instances via AWS EC2 Managed SSH. This client generates a short-lived RSA 6 | keypair and pushes it through the AWS Managed SSH service, then uses the key to connect to the target EC2 instance. 7 | 8 | This client supports all standard ssh operations with the `mssh` command and all standard sftp operations with the 9 | `msftp` command. 10 | 11 | The ec2-connect-cli package works on Python versions: 12 | 13 | * 3.6.x and greater 14 | 15 | ------------ 16 | Installation 17 | ------------ 18 | 19 | The easiest way to install ec2-connect-cli is to use `pip`_:: 20 | 21 | $ pip install ec2instanceconnectcli 22 | 23 | or, if you are not installing in a ``virtualenv``:: 24 | 25 | $ sudo pip install ec2instanceconnectcli 26 | 27 | If you have the ec2-connect-cli installed and want to upgrade to the latest version you can run:: 28 | 29 | $ pip install --upgrade ec2instanceconnectcli 30 | 31 | This will install the ec2instanceconnectcli package as well as all dependencies. Once you have the ec2instanceconnectcli 32 | directory structure on your workstation, you can just run:: 33 | 34 | $ cd 35 | $ python setup.py install 36 | 37 | --------------- 38 | Getting Started 39 | --------------- 40 | 41 | Before using ec2-connect-cli, you need to tell it about your AWS credentials. This can be done in the same way 42 | as you would configure `aws-cli`_ 43 | 44 | ^^^^^^^^ 45 | Examples 46 | ^^^^^^^^ 47 | 48 | Connect to an instance and open a shell 49 | 50 | $ mssh [instance id] 51 | 52 | Connect to an instance by DNS name as user "my-user" and run the command "ls" 53 | 54 | $ mssh my-user@ec2-[ec2 IP].us-east-1.compute.amazonaws.com -t [instance id] ls 55 | 56 | Run sftp against instance using the AWS CLI profile "otherprofile" and transferring my-file 57 | 58 | $ msftp -pr otherprofile [instance id]:my-file 59 | 60 | .. _pip: http://www.pip-installer.org/en/latest/ 61 | .. _aws-cli: https://github.com/aws/aws-cli 62 | -------------------------------------------------------------------------------- /bin/msftp: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | # Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"). You 6 | # may not use this file except in compliance with the License. A copy of 7 | # the License is located at 8 | # 9 | # http://aws.amazon.com/apache2.0/ 10 | # 11 | # or in the "license" file accompanying this file. This file is 12 | # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 13 | # ANY KIND, either express or implied. See the License for the specific 14 | # language governing permissions and limitations under the License. 15 | 16 | import sys 17 | from ec2instanceconnectcli import mops 18 | 19 | def main(): 20 | return mops.main("sftp", "sftp") 21 | 22 | if __name__ == '__main__': 23 | sys.exit(main()) 24 | -------------------------------------------------------------------------------- /bin/msftp-putty.cmd: -------------------------------------------------------------------------------- 1 | @echo OFF 2 | REM=""" 3 | setlocal 4 | set PythonExe="" 5 | set PythonExeFlags= 6 | 7 | for %%i in (cmd bat exe) do ( 8 | for %%j in (python.%%i) do ( 9 | call :SetPythonExe "%%~$PATH:j" 10 | ) 11 | ) 12 | for /f "tokens=2 delims==" %%i in ('assoc .py') do ( 13 | for /f "tokens=2 delims==" %%j in ('ftype %%i') do ( 14 | for /f "tokens=1" %%k in ("%%j") do ( 15 | call :SetPythonExe %%k 16 | ) 17 | ) 18 | ) 19 | %PythonExe% -x %PythonExeFlags% "%~f0" %* 20 | exit /B %ERRORLEVEL% 21 | goto :EOF 22 | 23 | :SetPythonExe 24 | if not ["%~1"]==[""] ( 25 | if [%PythonExe%]==[""] ( 26 | set PythonExe="%~1" 27 | ) 28 | ) 29 | goto :EOF 30 | """ 31 | 32 | # ======================= 33 | # Launch psftp 34 | # ======================= 35 | from ec2instanceconnectcli import mputty 36 | import sys 37 | 38 | def main(): 39 | return mputty.main("psftp", "sftp") 40 | 41 | if __name__ == '__main__': 42 | sys.exit(main()) 43 | -------------------------------------------------------------------------------- /bin/msftp.cmd: -------------------------------------------------------------------------------- 1 | @echo OFF 2 | REM=""" 3 | setlocal 4 | set PythonExe="" 5 | set PythonExeFlags= 6 | 7 | for %%i in (cmd bat exe) do ( 8 | for %%j in (python.%%i) do ( 9 | call :SetPythonExe "%%~$PATH:j" 10 | ) 11 | ) 12 | for /f "tokens=2 delims==" %%i in ('assoc .py') do ( 13 | for /f "tokens=2 delims==" %%j in ('ftype %%i') do ( 14 | for /f "tokens=1" %%k in ("%%j") do ( 15 | call :SetPythonExe %%k 16 | ) 17 | ) 18 | ) 19 | %PythonExe% -x %PythonExeFlags% "%~f0" %* 20 | exit /B %ERRORLEVEL% 21 | goto :EOF 22 | 23 | :SetPythonExe 24 | if not ["%~1"]==[""] ( 25 | if [%PythonExe%]==[""] ( 26 | set PythonExe="%~1" 27 | ) 28 | ) 29 | goto :EOF 30 | """ 31 | 32 | # ======================= 33 | # Launch msftp 34 | # ======================= 35 | from ec2instanceconnectcli import mops 36 | import sys 37 | 38 | def main(): 39 | return mops.main("sftp", "sftp") 40 | 41 | if __name__ == '__main__': 42 | sys.exit(main()) 43 | -------------------------------------------------------------------------------- /bin/mssh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | # Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"). You 6 | # may not use this file except in compliance with the License. A copy of 7 | # the License is located at 8 | # 9 | # http://aws.amazon.com/apache2.0/ 10 | # 11 | # or in the "license" file accompanying this file. This file is 12 | # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 13 | # ANY KIND, either express or implied. See the License for the specific 14 | # language governing permissions and limitations under the License. 15 | 16 | import sys 17 | from ec2instanceconnectcli import mops 18 | 19 | def main(): 20 | return mops.main("ssh", "ssh") 21 | 22 | if __name__ == '__main__': 23 | sys.exit(main()) 24 | -------------------------------------------------------------------------------- /bin/mssh-putty.cmd: -------------------------------------------------------------------------------- 1 | @echo OFF 2 | REM=""" 3 | setlocal 4 | set PythonExe="" 5 | set PythonExeFlags= 6 | 7 | for %%i in (cmd bat exe) do ( 8 | for %%j in (python.%%i) do ( 9 | call :SetPythonExe "%%~$PATH:j" 10 | ) 11 | ) 12 | for /f "tokens=2 delims==" %%i in ('assoc .py') do ( 13 | for /f "tokens=2 delims==" %%j in ('ftype %%i') do ( 14 | for /f "tokens=1" %%k in ("%%j") do ( 15 | call :SetPythonExe %%k 16 | ) 17 | ) 18 | ) 19 | %PythonExe% -x %PythonExeFlags% "%~f0" %* 20 | exit /B %ERRORLEVEL% 21 | goto :EOF 22 | 23 | :SetPythonExe 24 | if not ["%~1"]==[""] ( 25 | if [%PythonExe%]==[""] ( 26 | set PythonExe="%~1" 27 | ) 28 | ) 29 | goto :EOF 30 | """ 31 | 32 | # ======================= 33 | # Launch putty 34 | # ======================= 35 | from ec2instanceconnectcli import mputty 36 | import sys 37 | 38 | def main(): 39 | return mputty.main("putty -ssh", "ssh") 40 | 41 | if __name__ == '__main__': 42 | sys.exit(main()) 43 | -------------------------------------------------------------------------------- /bin/mssh.cmd: -------------------------------------------------------------------------------- 1 | @echo OFF 2 | REM=""" 3 | setlocal 4 | set PythonExe="" 5 | set PythonExeFlags= 6 | 7 | for %%i in (cmd bat exe) do ( 8 | for %%j in (python.%%i) do ( 9 | call :SetPythonExe "%%~$PATH:j" 10 | ) 11 | ) 12 | for /f "tokens=2 delims==" %%i in ('assoc .py') do ( 13 | for /f "tokens=2 delims==" %%j in ('ftype %%i') do ( 14 | for /f "tokens=1" %%k in ("%%j") do ( 15 | call :SetPythonExe %%k 16 | ) 17 | ) 18 | ) 19 | %PythonExe% -x %PythonExeFlags% "%~f0" %* 20 | exit /B %ERRORLEVEL% 21 | goto :EOF 22 | 23 | :SetPythonExe 24 | if not ["%~1"]==[""] ( 25 | if [%PythonExe%]==[""] ( 26 | set PythonExe="%~1" 27 | ) 28 | ) 29 | goto :EOF 30 | """ 31 | 32 | # ======================= 33 | # Launch mssh 34 | # ======================= 35 | from ec2instanceconnectcli import mops 36 | import sys 37 | 38 | def main(): 39 | return mops.main("ssh","ssh") 40 | 41 | if __name__ == '__main__': 42 | sys.exit(main()) 43 | -------------------------------------------------------------------------------- /doc/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SPHINXPROJ = AWSEC2InstanceConnectCLI 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /doc/README.rst: -------------------------------------------------------------------------------- 1 | ========================== 2 | Building The Documentation 3 | ========================== 4 | 5 | **Note: This is not complete and currently only does basic doc generation.** 6 | 7 | Before building the documentation, make sure you have Python 2.7, 8 | the ec2instanceconnectcli, and all the necessary dependencies installed. You can 9 | install dependencies by using the requirements-docs.txt file at the 10 | root of this repo:: 11 | 12 | pip install -r requirements-docs.txt 13 | 14 | The process for building the documentation is: 15 | 16 | * Run ``make html`` which will build all of the HTML documentation 17 | into the ``build/html`` directory. 18 | 19 | * Run ``make man`` which will build all of the man pages into 20 | ``../doc/man/man1``. These files are included in the source 21 | distribution and installed by ``python setup.py install``. 22 | 23 | * Run ``make text`` which will build all of the text pages that 24 | are used for interactive help on the Windows platform. These files 25 | are included in the source distribution and installed by 26 | ``python setup.py install``. 27 | 28 | You can perform all of these tasks by running ``make all`` in this 29 | directory. If you have previously built the documentation and want 30 | to regenerate it, run ``make clean`` first. 31 | -------------------------------------------------------------------------------- /doc/source/README.rst: -------------------------------------------------------------------------------- 1 | ============= 2 | README 3 | ============= 4 | 5 | This is a Python client for accessing EC2 instances via AWS EC2 Instance Connect. This client generates a short-lived RSA 6 | keypair and pushes it through the AWS EC2 Instance Connect service then uses the key to connect to the target EC2 instance. 7 | 8 | This client supports all standard ssh operations with the `mssh` command and all standard sftp operations with the 9 | `msftp` command. 10 | 11 | The ec2-connect-cli package works on Python versions: 12 | 13 | * 3.6.x and greater 14 | 15 | ------------ 16 | Installation 17 | ------------ 18 | 19 | The easiest way to install ec2-connect-cli is to use `pip`_:: 20 | 21 | $ pip install ec2instanceconnectcli 22 | 23 | or, if you are not installing in a ``virtualenv``:: 24 | 25 | $ sudo pip install ec2instanceconnectcli 26 | 27 | If you have the ec2-connect-cli installed and want to upgrade to the latest version you can run:: 28 | 29 | $ pip install --upgrade ec2instanceconnectcli 30 | 31 | This will install the ec2instanceconnectcli package as well as all dependencies. Once you have the ec2instanceconnectcli 32 | directory structure on your workstation, you can just run:: 33 | 34 | $ cd 35 | $ python setup.py install 36 | 37 | --------------- 38 | Getting Started 39 | --------------- 40 | 41 | Before using ec2-connect-cli, you need to tell it about your AWS credentials. This can be done in the same way 42 | as you would configure `aws-cli`_ 43 | 44 | ^^^^^^^^ 45 | Examples 46 | ^^^^^^^^ 47 | 48 | Connect to an instance and open a shell 49 | 50 | $ mssh [instance id] 51 | 52 | Connect to an instance by DNS name as user "my-user" and run the command "ls" 53 | 54 | $ mssh my-user@ec2-[ec2 IP].us-east-1.compute.amazonaws.com -t [instance id] ls 55 | 56 | Run sftp against instance using the AWS CLI profile "beta" and transferring my-file 57 | 58 | $ msftp -pr beta [instance id]:my-file 59 | 60 | .. _pip: http://www.pip-installer.org/en/latest/ 61 | .. _aws-cli: https://github.com/aws/aws-cli 62 | -------------------------------------------------------------------------------- /doc/source/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Configuration file for the Sphinx documentation builder. 4 | # 5 | # This file does only contain a selection of the most common options. For a 6 | # full list see the documentation: 7 | # http://www.sphinx-doc.org/en/master/config 8 | 9 | # -- Path setup -------------------------------------------------------------- 10 | 11 | # If extensions (or modules to document with autodoc) are in another directory, 12 | # add these directories to sys.path here. If the directory is relative to the 13 | # documentation root, use os.path.abspath to make it absolute, like shown here. 14 | # 15 | import os 16 | import sys 17 | sys.path.insert(0, os.path.abspath('.')) 18 | 19 | 20 | # -- Project information ----------------------------------------------------- 21 | 22 | project = 'AWS EC2 Instance Connect CLI' 23 | copyright = '2018, Amazon Web Services' 24 | author = 'Amazon Web Services' 25 | 26 | # The short X.Y version 27 | version = '1.0' 28 | # The full version, including alpha/beta/rc tags 29 | release = '1.0.3' 30 | 31 | 32 | # -- General configuration --------------------------------------------------- 33 | 34 | # If your documentation needs a minimal Sphinx version, state it here. 35 | # 36 | # needs_sphinx = '1.0' 37 | 38 | # Add any Sphinx extension module names here, as strings. They can be 39 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 40 | # ones. 41 | extensions = ['sphinx.ext.autodoc'] 42 | 43 | # Add any paths that contain templates here, relative to this directory. 44 | templates_path = ['_templates'] 45 | 46 | # The suffix(es) of source filenames. 47 | # You can specify multiple suffix as a list of string: 48 | # 49 | # source_suffix = ['.rst', '.md'] 50 | source_suffix = '.rst' 51 | 52 | # The master toctree document. 53 | master_doc = 'index' 54 | 55 | # The language for content autogenerated by Sphinx. Refer to documentation 56 | # for a list of supported languages. 57 | # 58 | # This is also used if you do content translation via gettext catalogs. 59 | # Usually you set "language" from the command line for these cases. 60 | language = None 61 | 62 | # List of patterns, relative to source directory, that match files and 63 | # directories to ignore when looking for source files. 64 | # This pattern also affects html_static_path and html_extra_path . 65 | exclude_patterns = [] 66 | 67 | # The name of the Pygments (syntax highlighting) style to use. 68 | pygments_style = 'sphinx' 69 | 70 | 71 | # -- Options for HTML output ------------------------------------------------- 72 | 73 | # The theme to use for HTML and HTML Help pages. See the documentation for 74 | # a list of builtin themes. 75 | # 76 | html_theme = 'alabaster' 77 | 78 | # Theme options are theme-specific and customize the look and feel of a theme 79 | # further. For a list of options available for each theme, see the 80 | # documentation. 81 | # 82 | # html_theme_options = {} 83 | 84 | # Add any paths that contain custom static files (such as style sheets) here, 85 | # relative to this directory. They are copied after the builtin static files, 86 | # so a file named "default.css" will overwrite the builtin "default.css". 87 | html_static_path = ['_static'] 88 | 89 | # Custom sidebar templates, must be a dictionary that maps document names 90 | # to template names. 91 | # 92 | # The default sidebars (for documents that don't match any pattern) are 93 | # defined by theme itself. Builtin themes are using these templates by 94 | # default: ``['localtoc.html', 'relations.html', 'sourcelink.html', 95 | # 'searchbox.html']``. 96 | # 97 | # html_sidebars = {} 98 | 99 | 100 | # -- Options for HTMLHelp output --------------------------------------------- 101 | 102 | # Output file base name for HTML help builder. 103 | htmlhelp_basename = 'AWSEC2InstanceConnectCLIdoc' 104 | 105 | 106 | # -- Options for LaTeX output ------------------------------------------------ 107 | 108 | latex_elements = { 109 | # The paper size ('letterpaper' or 'a4paper'). 110 | # 111 | # 'papersize': 'letterpaper', 112 | 113 | # The font size ('10pt', '11pt' or '12pt'). 114 | # 115 | # 'pointsize': '10pt', 116 | 117 | # Additional stuff for the LaTeX preamble. 118 | # 119 | # 'preamble': '', 120 | 121 | # Latex figure (float) alignment 122 | # 123 | # 'figure_align': 'htbp', 124 | } 125 | 126 | # Grouping the document tree into LaTeX files. List of tuples 127 | # (source start file, target name, title, 128 | # author, documentclass [howto, manual, or own class]). 129 | latex_documents = [ 130 | (master_doc, 'AWSEC2InstanceConnectCLI.tex', 'AWS EC2 Instance Connect CLI Documentation', 131 | 'Amazon Web Services', 'manual'), 132 | ] 133 | 134 | 135 | # -- Options for manual page output ------------------------------------------ 136 | 137 | # One entry per manual page. List of tuples 138 | # (source start file, name, description, authors, manual section). 139 | man_pages = [ 140 | (master_doc, 'ec2instanceconnectcli', 'AWS EC2 Instance Connect CLI Documentation', 141 | [author], 1) 142 | ] 143 | 144 | 145 | # -- Options for Texinfo output ---------------------------------------------- 146 | 147 | # Grouping the document tree into Texinfo files. List of tuples 148 | # (source start file, target name, title, author, 149 | # dir menu entry, description, category) 150 | texinfo_documents = [ 151 | (master_doc, 'ec2instanceconnectcli', 'AWS EC2 Instance Connect CLI Documentation', 152 | author, 'AWSEC2InstanceConnectCLI', 'One line description of project.', 153 | 'Miscellaneous'), 154 | ] 155 | 156 | 157 | # -- Extension configuration ------------------------------------------------- 158 | 159 | # -- Options for intersphinx extension --------------------------------------- 160 | 161 | # Example configuration for intersphinx: refer to the Python standard library. 162 | intersphinx_mapping = {'https://docs.python.org/': None} 163 | 164 | # -- Options for todo extension ---------------------------------------------- 165 | 166 | # If true, `todo` and `todoList` produce output, else they produce nothing. 167 | todo_include_todos = True 168 | -------------------------------------------------------------------------------- /doc/source/index.rst: -------------------------------------------------------------------------------- 1 | .. AWS EC2 Instance Connect CLI documentation master file, created by 2 | sphinx-quickstart on Wed Jul 25 14:06:13 2018. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to AWS EC2 Instance Connect CLI's documentation! 7 | ======================================================== 8 | 9 | .. toctree:: 10 | :maxdepth: 2 11 | :caption: Contents: 12 | 13 | README 14 | 15 | Indices and tables 16 | ================== 17 | 18 | * :ref:`genindex` 19 | * :ref:`modindex` 20 | * :ref:`search` 21 | 22 | -------------------------------------------------------------------------------- /ec2instanceconnectcli/EC2InstanceConnectCLI.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"). You 4 | # may not use this file except in compliance with the License. A copy of 5 | # the License is located at 6 | # 7 | # http://aws.amazon.com/apache2.0/ 8 | # 9 | # or in the "license" file accompanying this file. This file is 10 | # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 11 | # ANY KIND, either express or implied. See the License for the specific 12 | # language governing permissions and limitations under the License. 13 | 14 | import logging 15 | import sys 16 | import time 17 | from subprocess import Popen 18 | 19 | import botocore.session 20 | from ec2instanceconnectcli import __version__ as CLI_VERSION 21 | from ec2instanceconnectcli import ec2_util, key_publisher 22 | 23 | 24 | class EC2InstanceConnectCLI(object): 25 | """ 26 | SSH Transport via socket to EC2 Instance 27 | Pushes public key to EC2 Instance Metadata Service (via AWS EC2 Instance Connect) and 28 | establishes an SSH connection using the respective private key 29 | """ 30 | 31 | def __init__(self, instance_bundles, pub_key, cli_command, logger): 32 | """ 33 | :param instance_bundles: list of dicts that provide dns name, zone, etc information about EC2 instances 34 | :type instance_bundles: list 35 | :param pub_key: ssh public key 36 | :type pub_key: basestring 37 | :param cli_command: command to run in underlying shell 38 | :type cli_command: basestring 39 | :param logger: CLI logging utility to send log messages to 40 | :type logger: ec2instanceconnectcli.EC2InstanceConnectLogger.EC2InstanceConnectLogger 41 | """ 42 | self.instance_bundles = instance_bundles 43 | self.pub_key = pub_key 44 | self.logger = logger 45 | self.cli_command = cli_command 46 | 47 | def call_ec2(self): 48 | """ 49 | Fetches information on the associated EC2 instance 50 | """ 51 | 52 | for bundle in self.instance_bundles: 53 | session = bundle['session'] 54 | #If bundle['target'] has a value, then use it. 55 | if bundle['target']: 56 | bundle['host_info'] = bundle['target'] 57 | else: 58 | bundle['host_info'] = None 59 | 60 | if (bundle['target'] and bundle['zone']) or len(bundle['instance_id']) == 0: 61 | # If both are specified or we're not using an instance then we have no reason to call EC2 62 | self.logger.debug("{0} does not require lookup".format(bundle['target'])) 63 | continue 64 | 65 | instance_info = ec2_util.get_instance_data(session, bundle['instance_id']) 66 | bundle['zone'] = instance_info.availability_zone 67 | #If host_info is not available, fallback to using public ipaddress and then private ipaddress. 68 | if not bundle['host_info']: 69 | bundle['host_info'] = instance_info.public_ip if instance_info.public_ip else instance_info.private_ip 70 | self.logger.debug('Successfully got instance information from EC2 API for {0}'.format(bundle['instance_id'])) 71 | 72 | def handle_keys(self): 73 | """ 74 | Pushes the public key to the EC2 Instance(s) using AWS EC2 Instance Connect 75 | """ 76 | for bundle in self.instance_bundles: 77 | session = bundle['session'] 78 | if len(bundle['instance_id']) == 0: 79 | self.logger.debug("{0} does not require pushing public key using EC2InstanceConnect".format(bundle['target'])) 80 | continue 81 | key_publisher.push_public_key(session, bundle['instance_id'], bundle['username'], self.pub_key, bundle['zone']) 82 | self.logger.debug('Successfully pushed the public key to {0}'.format(bundle['instance_id'])) 83 | 84 | def run_command(self, command=None): 85 | """ 86 | Runs the given command in a sub-shell 87 | :param command: Command to invoke 88 | :type command: basestring 89 | :return: Return code for remote command 90 | :rtype: int 91 | """ 92 | if not command: 93 | raise ValueError('Must provide a command') 94 | 95 | invocation_proc = Popen(command, shell=True) 96 | while invocation_proc.poll() is None: #sub-process not terminated 97 | time.sleep(0.1) 98 | return invocation_proc.returncode 99 | 100 | def invoke_command(self): 101 | """ 102 | Generates the appropriate shell command and invokes it 103 | :return: Return code for remote command 104 | :rtype: int 105 | """ 106 | try: 107 | for bundle in self.instance_bundles: 108 | session = self._get_botocore_session(profile_name=bundle['profile'], region=bundle['region']) 109 | # enable debug logging on botocore session if command line debug option is set 110 | if self.logger.getEffectiveLevel() == logging.DEBUG: 111 | session.set_debug_logger() 112 | bundle['session'] = session 113 | 114 | self.call_ec2() 115 | self.handle_keys() 116 | 117 | #important to generate the command after calling call_ec2 and handle_keys 118 | return self.run_command(self.cli_command.get_command()) 119 | 120 | except Exception as e: 121 | self.logger.error("Failed with: " + str(e)) 122 | sys.exit(1) 123 | 124 | @staticmethod 125 | def _get_botocore_session(profile_name=None, region=None): 126 | """ 127 | Generates a botocore session with Managed SSH CLI set as the user agent 128 | 129 | :param profile_name: The name of a profile to use. If not given, then the \ 130 | default profile is used. 131 | :type profile_name: string 132 | :param region: An AWS region name to set as the default for the Botocore session 133 | :type region: string 134 | :return: A Botocore session object 135 | :rtype: botocore.session.Session 136 | """ 137 | session = botocore.session.get_session() 138 | botocore_info = 'Botocore/{0}'.format(session.user_agent_version) 139 | if session.user_agent_extra: 140 | session.user_agent_extra += ' ' + botocore_info 141 | else: 142 | session.user_agent_extra = botocore_info 143 | session.user_agent_name = 'aws-ec2-instance-connect-cli' 144 | session.user_agent_version = CLI_VERSION 145 | 146 | """ 147 | # Credential precedence: 148 | # 1. set user passed profile. 149 | # 2. set user passed region. 150 | # 3. let botocore handle the rest. 151 | """ 152 | 153 | if profile_name: 154 | session.set_config_variable('profile', profile_name) 155 | if region is not None: 156 | session.set_config_variable('region', region) 157 | 158 | return session 159 | -------------------------------------------------------------------------------- /ec2instanceconnectcli/EC2InstanceConnectCommand.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"). You 4 | # may not use this file except in compliance with the License. A copy of 5 | # the License is located at 6 | # 7 | # http://aws.amazon.com/apache2.0/ 8 | # 9 | # or in the "license" file accompanying this file. This file is 10 | # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 11 | # ANY KIND, either express or implied. See the License for the specific 12 | # language governing permissions and limitations under the License. 13 | 14 | class EC2InstanceConnectCommand(object): 15 | """ 16 | Generates commands relevant for the client. 17 | """ 18 | 19 | def __init__(self, program, instance_bundles, key_file, flags, program_command, logger): 20 | """ 21 | Utility class to generate program specific command. 22 | 23 | :param program: Client program to be invoked by the CLI. 24 | :type program: basestring 25 | :param key_file: private key file name. 26 | :type key_file: basestring 27 | :param flags: program specific flags. 28 | :type flags: basestring 29 | :param program_command: program specific ad-hoc command. 30 | :type program_command: basestring 31 | :param logger: EC2 Instance Connect CLI logger to write log messages to 32 | :type logger: ec2instanceconnectcli.EC2InstanceConnectLogger.EC2InstanceConnectLogger 33 | """ 34 | self.logger = logger 35 | self.program = program 36 | self.instance_bundles = instance_bundles 37 | self.key_file = key_file 38 | self.flags = flags 39 | self.program_command = program_command 40 | 41 | def get_command(self): 42 | """ 43 | Generates and returns the generated command 44 | """ 45 | # Start with protocol & identity file 46 | command = '{0} -o "IdentitiesOnly=yes" -i {1}'.format(self.program, self.key_file) 47 | 48 | # Next add command flags if present 49 | if len(self.flags) > 0: 50 | command = "{0} {1}".format(command, self.flags) 51 | 52 | # Target 53 | command = "{0} {1}".format(command, self._get_target(self.instance_bundles[0])) 54 | 55 | #program specific command 56 | if len(self.program_command) > 0: 57 | command = "{0} {1}".format(command, self.program_command) 58 | 59 | if len(self.instance_bundles) > 1: 60 | command = "{0} {1}".format(command, self._get_target(self.instance_bundles[1])) 61 | 62 | self.logger.debug('Generated command: {0}'.format(command)) 63 | 64 | return command 65 | 66 | @staticmethod 67 | def _get_target(instance_bundle): 68 | """ 69 | Determines the ssh target (and potentially sftp file target) for a given EC2 instance bundle dict 70 | :param instance_bundle: dict of information on the desired EC2 instance 71 | :type instance_bundle: dict 72 | :return: target in the form "user@host[:file]" 73 | :rtype: basestring 74 | """ 75 | target = '' 76 | if instance_bundle.get('host_info', None): 77 | target = "{0}@{1}".format(instance_bundle['username'], instance_bundle['host_info']) 78 | # file will exist only for SFTP and SCP operations. 79 | if instance_bundle.get('file', None): 80 | target = "{0}:{1}".format(target, instance_bundle['file']).lstrip(':') 81 | 82 | return target 83 | -------------------------------------------------------------------------------- /ec2instanceconnectcli/EC2InstanceConnectKey.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"). You 4 | # may not use this file except in compliance with the License. A copy of 5 | # the License is located at 6 | # 7 | # http://aws.amazon.com/apache2.0/ 8 | # 9 | # or in the "license" file accompanying this file. This file is 10 | # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 11 | # ANY KIND, either express or implied. See the License for the specific 12 | # language governing permissions and limitations under the License. 13 | 14 | import os 15 | import tempfile 16 | 17 | from ec2instanceconnectcli import key_utils 18 | 19 | 20 | class EC2InstanceConnectKey(object): 21 | """ 22 | Generates 2048 bit RSA SSH key pair to be used in conjunction with the CLI. 23 | Writes the private key to a temporary file on disk and changes it's permissions to 600 (read/write only by owner) 24 | """ 25 | def __init__(self, logger): 26 | """ 27 | :param logger: EC2 Instance Connect CLI logger to use for log messages 28 | :type logger: ec2instanceconnectcli.EC2InstanceConnectLogger.EC2InstanceConnectLogger 29 | """ 30 | self.logger = logger 31 | key = key_utils.generate_key(2048) 32 | self.pub_key = key_utils.serialize_key(key, encoding='OpenSSH').decode('utf-8') 33 | priv_key = key_utils.serialize_key(key, return_private=True).decode('utf-8') 34 | self.tempf = self._write_priv_key(priv_key) 35 | 36 | def _write_priv_key(self, _priv_key): 37 | """ 38 | Writes the private key to the pre-determined temp file 39 | 40 | :param _priv_key: private key body 41 | :type _priv_key: bytearray 42 | """ 43 | tempf = tempfile.NamedTemporaryFile(delete=False) 44 | with open(tempf.name, 'w') as f: 45 | f.write(_priv_key) 46 | os.chmod(tempf.name, 0o600) 47 | tempf.file.close() 48 | return tempf 49 | 50 | def get_pub_key(self): 51 | """ 52 | Returns the generated RSA public key. 53 | 54 | :return: Public key body 55 | :rtype: bytearray 56 | """ 57 | return self.pub_key 58 | 59 | def get_priv_key_file(self): 60 | """ 61 | Returns either user provided key file or the temp file with generated RSA private key. 62 | 63 | :return: Private key filepath 64 | :rtype: basestring 65 | """ 66 | return self.tempf.name 67 | 68 | def __del__(self): 69 | """ 70 | Remove the temp file with private key. 71 | """ 72 | if self.tempf is not None: 73 | self.logger.debug('Deleting the private key file: {0}'.format(self.tempf.name)) 74 | os.remove(self.tempf.name) 75 | -------------------------------------------------------------------------------- /ec2instanceconnectcli/EC2InstanceConnectLogger.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"). You 4 | # may not use this file except in compliance with the License. A copy of 5 | # the License is located at 6 | # 7 | # http://aws.amazon.com/apache2.0/ 8 | # 9 | # or in the "license" file accompanying this file. This file is 10 | # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 11 | # ANY KIND, either express or implied. See the License for the specific 12 | # language governing permissions and limitations under the License. 13 | 14 | import logging 15 | 16 | class EC2InstanceConnectLogger(object): 17 | """ 18 | Common logger for all the EC2InstanceConnect components. 19 | """ 20 | 21 | def __init__(self, debug=False): 22 | """ 23 | :param debug: Specifies if debug messages should be enabled 24 | :type debug: bool 25 | """ 26 | self.logger = logging.getLogger('EC2InstanceConnect') 27 | log_level = logging.ERROR 28 | if debug: 29 | log_level = logging.DEBUG 30 | self.logger.setLevel(log_level) 31 | ch = logging.StreamHandler() 32 | ch.setLevel(log_level) 33 | formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') 34 | ch.setFormatter(formatter) 35 | self.logger.addHandler(ch) 36 | 37 | def get_logger(self): 38 | return self.logger 39 | -------------------------------------------------------------------------------- /ec2instanceconnectcli/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"). You 4 | # may not use this file except in compliance with the License. A copy of 5 | # the License is located at 6 | # 7 | # http://aws.amazon.com/apache2.0/ 8 | # 9 | # or in the "license" file accompanying this file. This file is 10 | # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 11 | # ANY KIND, either express or implied. See the License for the specific 12 | # language governing permissions and limitations under the License. 13 | """ 14 | EC2InstanceConnectCLI 15 | ---- 16 | A Command Line Environment for connecting to EC2 instances through AWS EC2 Instance Connect. 17 | """ 18 | import os 19 | 20 | __version__ = '1.0.3' 21 | 22 | EnvironmentVariables = { 23 | 'ca_bundle': ('ca_bundle', 'AWS_CA_BUNDLE', None, None), 24 | 'output': ('output', 'AWS_DEFAULT_OUTPUT', 'json', None), 25 | } 26 | 27 | 28 | SCALAR_TYPES = set([ 29 | 'string', 'float', 'integer', 'long', 'boolean', 'double', 30 | 'blob', 'timestamp' 31 | ]) 32 | COMPLEX_TYPES = set(['structure', 'map', 'list']) 33 | -------------------------------------------------------------------------------- /ec2instanceconnectcli/ec2_util.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"). You 4 | # may not use this file except in compliance with the License. A copy of 5 | # the License is located at 6 | # 7 | # http://aws.amazon.com/apache2.0/ 8 | # 9 | # or in the "license" file accompanying this file. This file is 10 | # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 11 | # ANY KIND, either express or implied. See the License for the specific 12 | # language governing permissions and limitations under the License. 13 | 14 | from argparse import Namespace 15 | import sys 16 | 17 | 18 | def get_instance_data(session, instance_id): 19 | """ 20 | Calls EC2 DescribeInstances API to get the DNS Names and IP addresses of the instance both Public and Private 21 | and also gets the Availability Zone of an instance 22 | 23 | :param session: A Botocore session to use to generate the EC2 client 24 | :type session: Botocore.session.Session 25 | :param instance_id: InstanceID of the instance 26 | :type instance_id: basestring 27 | :return: Namespace with Public DNS Name, Private DNS Name, Public IP, Private IP and Availability Zone 28 | :rtype: argparse.Namespace 29 | """ 30 | 31 | try: 32 | client = session.create_client('ec2') 33 | instance_id = [instance_id] 34 | response = client.describe_instances(InstanceIds=instance_id) 35 | availability_zone = response['Reservations'][0]['Instances'][0]['Placement']['AvailabilityZone'] 36 | try: 37 | public_dns_name = response['Reservations'][0]['Instances'][0]['PublicDnsName'] 38 | except: 39 | public_dns_name = None 40 | pass 41 | try: 42 | private_dns_name = response['Reservations'][0]['Instances'][0]['PrivateDnsName'] 43 | except: 44 | private_dns_name = None 45 | pass 46 | try: 47 | public_ip = response['Reservations'][0]['Instances'][0]['PublicIpAddress'] 48 | except: 49 | public_ip = None 50 | pass 51 | try: 52 | private_ip = response['Reservations'][0]['Instances'][0]['PrivateIpAddress'] 53 | except: 54 | private_ip = None 55 | pass 56 | except Exception as e: 57 | print(str(e)) 58 | sys.exit(1) 59 | else: 60 | if len(availability_zone) == 0: 61 | print("Instance zone information not found") 62 | sys.exit(7) 63 | if not (public_dns_name or private_dns_name or public_ip or private_ip): 64 | print("No hostname or IPs found") 65 | sys.exit(8) 66 | else: 67 | instance_info = Namespace(public_dns_name=public_dns_name, 68 | private_dns_name=private_dns_name, 69 | public_ip=public_ip, 70 | private_ip=private_ip, 71 | availability_zone=availability_zone 72 | ) 73 | 74 | return instance_info 75 | -------------------------------------------------------------------------------- /ec2instanceconnectcli/input_parser.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"). You 4 | # may not use this file except in compliance with the License. A copy of 5 | # the License is located at 6 | # 7 | # http://aws.amazon.com/apache2.0/ 8 | # 9 | # or in the "license" file accompanying this file. This file is 10 | # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 11 | # ANY KIND, either express or implied. See the License for the specific 12 | # language governing permissions and limitations under the License. 13 | 14 | import re 15 | import socket 16 | 17 | INSTANCE_ID_RE = re.compile("i-[a-f0-9]+") 18 | UNIX_USER_RE = re.compile("[a-z_][a-z0-9_-]*[$]?") # Taken from useradd manpage 19 | REGION_RE = re.compile("([a-z]+-)+[0-9]+") 20 | ZONE_RE = re.compile("([a-z]+-)+[0-9]+[a-z]") 21 | 22 | def parseargs(args, mode='ssh'): 23 | """ 24 | Parses the input arguments to one of the EC2 Instance Connect CLI commands and splits it into pieces that will be 25 | used as appropriate at invocation time. 26 | 27 | All commands (ssh, sftp,) follow the same basic structure of 28 | protocol [ec2 connect flags] [command flags] [target] [command or files] 29 | 30 | The first two are free - protocol is the mode, ec2 connect flags we get from argparse in args[0] 31 | The rest we need to extract from args[1] 32 | What makes this particularly interesting is that the data we need varies by command. 33 | - For ssh, we need the user (if present), host (required), and command (if present) 34 | - For sftp, we need the user (if present), host (required), and dir or files (if present) 35 | 36 | To match this, we have a structure that may contain a given target's instance ID, target DNS/IP/etc, 37 | username to use for connection, EC2 availability zone, and/or a file associated with that host in the given command. 38 | There will be either one or two depending on what mode we're parsing for. 39 | 40 | The command field is then used to store either the ssh command or additional files passed to sftp. 41 | 42 | :param args: A tuple of known arguments and list of string with unknown arguments 43 | :type args: tuple 44 | :param mode: The protocol we will be using (ssh, sftp, potentially others in-future) 45 | :type mode: basestring 46 | :return: Args split into three pieces: EC2 instance information, command flags, and and the actual command to run 47 | :rtype: tuple 48 | """ 49 | 50 | if len(args) < 2: 51 | raise AssertionError('Missing target') 52 | 53 | custom_flags = args[1] 54 | _validate_custom_flags(custom_flags) 55 | 56 | """ 57 | Our flags. As these are via argparse they're free. 58 | Instance details are a bit weird. Since the instance ID can either be the actual "host" or a flag we have to group it. 59 | We do this with an "instance bundle" dict. 60 | Note we don't load the actual instance DNS/IP/ID here - that comes later. 61 | """ 62 | instance_bundles = [ 63 | { 64 | 'profile': args[0].profile, 65 | 'instance_id': args[0].instance_id, 66 | 'region': args[0].region, 67 | 'zone': args[0].zone 68 | } 69 | ] 70 | # We do this as an array to support future commands that may need multiple instances (eg, scp) 71 | 72 | # Process out the command flags & target data 73 | flags, command, instance_bundles = _parse_command_flags(custom_flags, instance_bundles, is_ssh=(mode=='ssh')) 74 | 75 | # Process the instance & target data to give us an actual picture of what end hosts we're working with 76 | instance_bundles = _parse_instance_bundles(instance_bundles) 77 | 78 | #validate instance_bundles 79 | _validate_instance_bundles(instance_bundles, mode) 80 | 81 | return instance_bundles, flags, command 82 | 83 | def _validate_custom_flags(flags): 84 | if len(flags) < 1: 85 | raise AssertionError('Missing target') 86 | for flag in flags: 87 | flag = flag.strip() 88 | if flag == '-i': 89 | raise AssertionError( 90 | "'-i' is not supported when using mssh. When using the mssh command to connect to your instance, you " 91 | "do not need to specify any kind of identity file, because EC2 Instance Connect manages the key pair. " 92 | "Remove the -i flag from 'mssh ' (or 'mssh @' for an Ubuntu instance) " 93 | "and try again." 94 | ) 95 | 96 | def _validate_instance_bundles(instance_bundles, mode): 97 | """ 98 | For supported modes ensure that instance_id exists. 99 | """ 100 | for bundle in instance_bundles: 101 | if mode in ['ssh', 'sftp']: 102 | if not INSTANCE_ID_RE.match(bundle['instance_id']): 103 | raise AssertionError('Missing instance_id') 104 | 105 | def _parse_command_flags(raw_command, instance_bundles, is_ssh=False): 106 | """ 107 | Parses the command from the user and strips out two pieces: 108 | 1) The flags for the underlying command 109 | 2) The actual underlying command or file list for ssh/sftp/etc 110 | 111 | :param raw_command: The raw command string, ie, anything not recognized by argparse 112 | :type raw_command: basestring 113 | :param instance_bundles: dicts containing information about desired EC2 instances 114 | :type instance_bundles: list 115 | :param is_ssh: Specifies if we are running an ssh command. There is an extra flag we consider if so. 116 | :type is_ssh: bool 117 | :return: tuple of flags and final comamnd or file list 118 | :rtype: tuple 119 | """ 120 | flags = '' 121 | is_user = False 122 | is_flagged = False 123 | command_index = 0 124 | used = 0 125 | """ 126 | Flags for the underlying command. These will always be in the format -[flag indicator] [flag value] 127 | """ 128 | while command_index < len(raw_command) - 1: 129 | if raw_command[command_index][0] != '-' and not is_flagged: 130 | # We found something that's not a flag or a flag value. Exit flag loop. 131 | break 132 | 133 | used += 1 134 | 135 | # This is either a flag or a flag value 136 | flags = '{0} {1}'.format(flags, raw_command[command_index]) 137 | 138 | if raw_command[command_index][0] == '-': 139 | # Flag 140 | is_flagged = True 141 | if raw_command[command_index][1] == 'l' and is_ssh: 142 | # We want to extract the user flag for ssh mode 143 | is_user = True 144 | 145 | else: 146 | # Flag value 147 | is_flagged = False 148 | if is_user: 149 | # We want to extract the user flag for ssh mode 150 | instance_bundles[0]['username'] = raw_command[command_index] 151 | is_user = False 152 | 153 | command_index += 1 154 | 155 | flags = flags.strip() 156 | 157 | """ 158 | Target host and command or file list 159 | """ 160 | 161 | if used == len(raw_command) and len(raw_command) != 1: 162 | # EVERYTHING was a flag or flag value 163 | raise AssertionError('Missing target') 164 | 165 | # Target 166 | instance_bundles[0]['target'] = raw_command[command_index] 167 | command_index += 1 168 | 169 | # Command/file list 170 | command_end = len(raw_command) 171 | command = ' '.join(raw_command[command_index:command_end]) 172 | 173 | return flags, command, instance_bundles 174 | 175 | 176 | def _parse_instance_bundles(instance_bundles): 177 | """ 178 | Processes instance bundles. The goal is to establish the final data on instance IDs 179 | and FQDNs/IPs and any target file to include for sftp 180 | This includes data validity checks. 181 | 182 | :param instance_bundles: The unprocessed instance bundle objects 183 | :type instance_bundles: list 184 | :return: Processed instance bundle objects 185 | :rtype: list 186 | """ 187 | for bundle in instance_bundles: 188 | # We parse target in a specific order based on mode due to how commands prioritize/mark parts optional 189 | if '@' in bundle['target']: 190 | if len(bundle['target'].split('@')) > 2: 191 | # Host details includes an @. Invalid. 192 | raise AssertionError('Invalid target') 193 | # A user was specified 194 | bundle['username'], bundle['target'] = bundle['target'].split('@') 195 | if ':' in bundle['target']: 196 | # May be present for sftp 197 | bundle['target'], bundle['file'] = bundle['target'].split(':') 198 | 199 | if bundle.get('target', None): 200 | if INSTANCE_ID_RE.match(bundle['target'].lower()): 201 | # We might have an instance as the target AND an instance flag and they don't match 202 | # Since ssh prioritizes user@ over -l login_name, we will prioritize the target over the flag 203 | # If we don't have the flag, we use the target anyways 204 | bundle['instance_id'] = bundle['target'] 205 | bundle['target'] = None 206 | 207 | if len(bundle.get('username', '')) == 0: 208 | bundle['username'] = 'ec2-user' 209 | 210 | # Validate region & zone if present 211 | if bundle.get('region') and len(bundle.get('region')) > 0: 212 | if REGION_RE.match(bundle['region']) is None: 213 | raise AssertionError('{0} is not a valid region'.format(bundle['region'])) 214 | 215 | if bundle.get('zone') and len(bundle.get('zone')) > 0: 216 | if ZONE_RE.match(bundle['zone']) is None: 217 | raise AssertionError('{0} is not a valid zone'.format(bundle['zone'])) 218 | 219 | # Validate username 220 | if not _is_valid_username(bundle['username']): 221 | raise AssertionError('{0} is not a valid UNIX username'.format(bundle['username'])) 222 | 223 | # Validate instance ID format 224 | if len(bundle['instance_id']) > 0 and not INSTANCE_ID_RE.match(bundle['instance_id'].lower()): 225 | raise AssertionError('Invalid instance_id') 226 | 227 | # Validate DNS/IP/hostname/etc 228 | if bundle.get('target', None): 229 | if not _is_valid_target(bundle.get('target', '')): 230 | # It might be an IP 231 | raise AssertionError('Invalid target') 232 | 233 | return instance_bundles 234 | 235 | 236 | def _is_valid_username(username): 237 | """ 238 | Validates if the provided username is a valid UNIX username 239 | 240 | :param username: username to validate 241 | :type username: basestring 242 | :return: Whether the given username is a valid UNIX username 243 | :rtype: bool 244 | """ 245 | return UNIX_USER_RE.match(username) is not None 246 | 247 | 248 | def _is_valid_ipv4_address(address): 249 | try: 250 | socket.inet_pton(socket.AF_INET, address) 251 | except AttributeError: # inet_pton is not available 252 | try: 253 | socket.inet_aton(address) 254 | except socket.error: 255 | return False 256 | return address.count('.') == 3 257 | except socket.error: 258 | return False 259 | 260 | return True 261 | 262 | 263 | def _is_valid_ipv6_address(address): 264 | try: 265 | socket.inet_pton(socket.AF_INET6, address) 266 | except socket.error: 267 | return False 268 | return True 269 | 270 | 271 | def _is_valid_target(hostname): 272 | """ 273 | Validates if the provided "hostname" is a valid DNS name or IP address 274 | 275 | :param hostname: FQDN to validate 276 | :type hostname: basestring 277 | :return: Whether the given hostname is a valid DNS name or IP address 278 | :rtype: bool 279 | """ 280 | if not hostname: 281 | return False 282 | 283 | # Check if it's a valid IP 284 | if _is_valid_ipv4_address(hostname) or _is_valid_ipv6_address(hostname): 285 | return True 286 | 287 | # Check if it's a valid DNS name 288 | 289 | if hostname[-1] == '.': 290 | hostname = hostname[:-1] # strip exactly one dot from the right, if present 291 | if len(hostname) < 1 or len(hostname) > 253: # Technically 255 octets but 2 are used for encoding 292 | return False 293 | 294 | labels = hostname.split(".") 295 | 296 | # the TLD must be not all-numeric 297 | if re.match(r"[0-9]+$", labels[-1]): 298 | return False 299 | 300 | allowed = re.compile(r"(?!-)[a-z0-9-]{1,63}(? [user@]instance_id | [user@]hostname 45 | [supported ssh flags] => [-l login_name] [-p port] 46 | """ 47 | elif mode == "sftp": 48 | usage=""" 49 | msftp [-u aws_profile] [-z availability_zone] [supported sftp flags] target 50 | target => [user@]instance_id[:file ...][:dir[/]] | [user@]hostname[:file ...][:dir[/]] 51 | [supported sftp flags] => [-P port] [-b batchfile] 52 | """ 53 | 54 | parser = argparse.ArgumentParser(usage=usage) 55 | parser.add_argument('-r', '--region', action='store', help='AWS region', type=str, metavar='') 56 | parser.add_argument('-z', '--zone', action='store', help='Availability zone', type=str, metavar='') 57 | parser.add_argument('-u', '--profile', action='store', help='AWS Config Profile', type=str, default=DEFAULT_PROFILE, metavar='') 58 | parser.add_argument('-t', '--instance_id', action='store', help='EC2 Instance ID. Required if target is hostname', type=str, default=DEFAULT_INSTANCE, metavar='') 59 | parser.add_argument('-d', '--debug', action="store_true", help='Turn on debug logging') 60 | 61 | args = parser.parse_known_args() 62 | 63 | logger = EC2InstanceConnectLogger(args[0].debug) 64 | try: 65 | instance_bundles, flags, program_command = input_parser.parseargs(args, mode) 66 | except Exception as e: 67 | print(str(e)) 68 | parser.print_help() 69 | sys.exit(1) 70 | 71 | #Generate temp key 72 | cli_key = EC2InstanceConnectKey(logger.get_logger()) 73 | cli_command = EC2InstanceConnectCommand(program, instance_bundles, cli_key.get_priv_key_file(), flags, program_command, logger.get_logger()) 74 | 75 | try: 76 | cli = EC2InstanceConnectCLI(instance_bundles, cli_key.get_pub_key(), cli_command, logger.get_logger()) 77 | return cli.invoke_command() 78 | except Exception as e: 79 | print('Failed with:\n' + str(e)) 80 | sys.exit(1) 81 | -------------------------------------------------------------------------------- /ec2instanceconnectcli/mputty.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"). You 4 | # may not use this file except in compliance with the License. A copy of 5 | # the License is located at 6 | # 7 | # http://aws.amazon.com/apache2.0/ 8 | # 9 | # or in the "license" file accompanying this file. This file is 10 | # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 11 | # ANY KIND, either express or implied. See the License for the specific 12 | # language governing permissions and limitations under the License. 13 | 14 | import argparse 15 | import sys 16 | 17 | from ec2instanceconnectcli.EC2InstanceConnectCLI import EC2InstanceConnectCLI 18 | from ec2instanceconnectcli.EC2InstanceConnectCommand import EC2InstanceConnectCommand 19 | from ec2instanceconnectcli.EC2InstanceConnectLogger import EC2InstanceConnectLogger 20 | from ec2instanceconnectcli import input_parser 21 | 22 | DEFAULT_INSTANCE = '' 23 | DEFAULT_PROFILE = None 24 | 25 | def main(program, mode): 26 | """ 27 | Parses system arguments sets defaults 28 | Calls `putty or psftp` to SSH or do file operations using EC2InstanceConnect. 29 | 30 | :param program: Client program to be used for SSH/SFTP operations. 31 | :type program: basestring 32 | :param mode: Identifies either SSH/SFTP operation. 33 | :type mode: basestring 34 | :return: Return code for remote command 35 | :rtype: int 36 | """ 37 | 38 | usage = "" 39 | if mode == "ssh": 40 | usage = """ 41 | mssh-putty [-t instance_id] [-u profile] [-r region] [-z availability_zone] [-i identity_file_ppk] [supported putty flags] target 42 | 43 | target => [user@]instance_id | [user@]dns_name 44 | [supported putty flags] => [-l login_name] [ -P port] [-m remote_command_file] 45 | """ 46 | elif mode == "sftp": 47 | usage = """ 48 | msftp-putty [-t instance_id] [-u profile] [-r region] [-z availability_zone] [-i identity_file_ppk] [supported psftp flags] target 49 | 50 | target => [user@]instance_id | [user@]dns_name 51 | [supported psftp flags] => [-l login_name] [ -P port] [-bc] [-b batchfile] 52 | """ 53 | 54 | parser = argparse.ArgumentParser(usage=usage) 55 | parser.add_argument('-r', '--region', action='store', help='AWS region', type=str, metavar='') 56 | parser.add_argument('-z', '--zone', action='store', help='Availability zone', type=str, metavar='') 57 | parser.add_argument('-i', '--identity', action='store', help="Required. Identity file in ppk format", type=str, required=True, metavar='') 58 | parser.add_argument('-u', '--profile', action='store', help='AWS Config Profile', type=str, default=DEFAULT_PROFILE, metavar='') 59 | parser.add_argument('-t', '--instance_id', action='store', help='EC2 Instance ID. Required if target is hostname', type=str, default=DEFAULT_INSTANCE, metavar='') 60 | parser.add_argument('-d', '--debug', action="store_true", help='Turn on debug logging') 61 | 62 | args = parser.parse_known_args() 63 | 64 | #Read public key from ppk file. 65 | #Public key is unencrypted and in rsa format. 66 | pub_key_lines = [] 67 | pub_key = "ssh-rsa " 68 | try: 69 | with open(args[0].identity, 'r') as f: 70 | pub_key_lines = f.readlines() 71 | 72 | #Validate that the identity file format is ppk. 73 | if pub_key_lines[0].find("PuTTY-User-Key-File-") == -1: 74 | print("Not a valid Putty key.") 75 | sys.exit(1) 76 | 77 | #public key starts from 4th line in ppk file. 78 | line_len = int(pub_key_lines[3].split(':')[1].strip()) 79 | pub_key_lines = pub_key_lines[4:(4+line_len)] 80 | for pub_key_line in pub_key_lines: 81 | pub_key += pub_key_line[:-1] 82 | except Exception as e: 83 | print(str(e)) 84 | sys.exit(1) 85 | 86 | logger = EC2InstanceConnectLogger(args[0].debug) 87 | try: 88 | instance_bundles, flags, program_command = input_parser.parseargs(args, mode) 89 | except Exception as e: 90 | print(str(e)) 91 | parser.print_help() 92 | sys.exit(1) 93 | 94 | cli_command = EC2InstanceConnectCommand(program, instance_bundles, args[0].identity, flags, program_command, logger.get_logger()) 95 | cli_command.get_command() 96 | 97 | try: 98 | mssh = EC2InstanceConnectCLI(instance_bundles, pub_key, cli_command, logger.get_logger()) 99 | return mssh.invoke_command() 100 | except Exception as e: 101 | print("Failed with:\n" + str(e)) 102 | sys.exit(1) 103 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = --doctest-modules ec2instanceconnectcli --cov ec2instanceconnectcli tests 3 | -------------------------------------------------------------------------------- /requirements-docs.txt: -------------------------------------------------------------------------------- 1 | Sphinx>=1.6.5 2 | -e . 3 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | setuptools>=3.3 2 | cryptography>=39.0.1 3 | botocore>=1.12.179 4 | pytest>=3.2.3 5 | pytest-cov>=2.5.1 6 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | 3 | [metadata] 4 | requires-dist= 5 | cryptography>=39.0.1 6 | botocore>=1.12.179 7 | 8 | [tool:pytest] 9 | # Test args for pytest; enable coverage for ec2instanceconnectcli, emit XML, HTML, and terminal reports. 10 | addopts = 11 | --verbose 12 | --cov ec2instanceconnectcli 13 | --cov-report term-missing 14 | --cov-report xml 15 | --cov-report html 16 | tests 17 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import codecs 3 | import os.path 4 | import re 5 | import sys 6 | from setuptools import find_packages, setup 7 | 8 | here = os.path.abspath(os.path.dirname(__file__)) 9 | 10 | 11 | def read(*parts): 12 | return codecs.open(os.path.join(here, *parts), 'r').read() 13 | 14 | 15 | def find_version(*file_paths): 16 | version_file = read(*file_paths) 17 | version_match = re.search(r"^__version__ = ['\"]([^'\"]*)['\"]", 18 | version_file, re.M) 19 | if version_match: 20 | return version_match.group(1) 21 | raise RuntimeError("Unable to find version string.") 22 | 23 | 24 | requires = ['cryptography>=39.0.1', 25 | 'botocore>=1.12.179' 26 | ] 27 | 28 | setup_options = dict( 29 | name='ec2instanceconnectcli', 30 | version=find_version('ec2instanceconnectcli', '__init__.py'), 31 | description='Command Line Interface for AWS EC2 Instance Connect', 32 | long_description='This CLI package handles publishing keys through EC2 Instance Connect' 33 | 'and using them to connect to EC2 instances.', 34 | author='Amazon Web Services', 35 | url='https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/Connect-using-EC2-Instance-Connect.html', 36 | scripts=['bin/mssh', 'bin/msftp', 'bin/mssh.cmd', 'bin/msftp.cmd', 37 | 'bin/mssh-putty.cmd', 'bin/msftp-putty.cmd'], 38 | packages=find_packages(exclude=['test']), 39 | package_data={}, 40 | install_requires=requires, 41 | license='Apache License 2.0', 42 | classifiers=( 43 | 'Development Status :: 5 - Production/Stable', 44 | 'Environment :: Console', 45 | 'Intended Audience :: Developers', 46 | 'Intended Audience :: System Administrators', 47 | 'Natural Language :: English', 48 | 'License :: OSI Approved :: Apache Software License', 49 | 'Operating System :: Microsoft :: Windows', 50 | 'Operating System :: OS Independent', 51 | 'Operating System :: Unix', 52 | 'Programming Language :: Python', 53 | 'Programming Language :: Python :: 3', 54 | 'Programming Language :: Python :: 3.6', 55 | 'Programming Language :: Python :: 3.7', 56 | 'Programming Language :: Python :: 3.8', 57 | 'Topic :: Internet :: File Transfer Protocol (FTP)', 58 | 'Topic :: System :: Systems Administration :: Authentication/Directory' 59 | ) 60 | ) 61 | 62 | setup(**setup_options) 63 | -------------------------------------------------------------------------------- /tests/configuration/config: -------------------------------------------------------------------------------- 1 | [default] 2 | output = json 3 | region = us-east-2 4 | 5 | [profile newprofile] 6 | output = json 7 | region = us-west-2 8 | 9 | [profile empty] 10 | -------------------------------------------------------------------------------- /tests/configuration/credentials: -------------------------------------------------------------------------------- 1 | [default] 2 | aws_access_key_id = notsecret 3 | aws_secret_access_key = secret 4 | 5 | [newprofile] 6 | aws_access_key_id = notatallsecret 7 | aws_secret_access_key = supersecret 8 | 9 | [empty] 10 | -------------------------------------------------------------------------------- /tests/test_EC2ConnectCLI.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"). You 4 | # may not use this file except in compliance with the License. A copy of 5 | # the License is located at 6 | # 7 | # http://aws.amazon.com/apache2.0/ 8 | # 9 | # or in the "license" file accompanying this file. This file is 10 | # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 11 | # ANY KIND, either express or implied. See the License for the specific 12 | # language governing permissions and limitations under the License. 13 | 14 | from ec2instanceconnectcli.EC2InstanceConnectCLI import EC2InstanceConnectCLI 15 | from ec2instanceconnectcli.EC2InstanceConnectCommand import EC2InstanceConnectCommand 16 | from ec2instanceconnectcli.EC2InstanceConnectLogger import EC2InstanceConnectLogger 17 | from testloader.test_base import TestBase 18 | try: 19 | from unittest import mock 20 | except ImportError: 21 | import mock 22 | 23 | 24 | class TestEC2InstanceConnectCLI(TestBase): 25 | 26 | @mock.patch('ec2instanceconnectcli.EC2InstanceConnectCLI.EC2InstanceConnectCLI.run_command') 27 | @mock.patch('ec2instanceconnectcli.key_publisher.push_public_key') 28 | @mock.patch('ec2instanceconnectcli.ec2_util.get_instance_data') 29 | def test_mssh_no_target(self, 30 | mock_instance_data, 31 | mock_push_key, 32 | mock_run): 33 | mock_file = 'identity' 34 | flag = '-f flag' 35 | command = 'command arg' 36 | logger = EC2InstanceConnectLogger() 37 | instance_bundles = [{'username': self.default_user, 'instance_id': self.instance_id, 38 | 'target': None, 'zone': self.availability_zone, 'region': self.region, 39 | 'profile': self.profile}] 40 | 41 | mock_instance_data.return_value = self.instance_info 42 | mock_push_key.return_value = None 43 | 44 | cli_command = EC2InstanceConnectCommand("ssh", instance_bundles, mock_file, flag, command, logger.get_logger()) 45 | cli = EC2InstanceConnectCLI(instance_bundles, "", cli_command, logger.get_logger()) 46 | cli.invoke_command() 47 | 48 | expected_command = 'ssh -o "IdentitiesOnly=yes" -i {0} {1} {2}@{3} {4}'.format(mock_file, flag, self.default_user, 49 | self.public_ip, command) 50 | 51 | # Check that we successfully get to the run 52 | self.assertTrue(mock_instance_data.called) 53 | self.assertTrue(mock_push_key.called) 54 | # Also check that we get the correct command generated 55 | mock_run.assert_called_with(expected_command) 56 | 57 | @mock.patch('ec2instanceconnectcli.EC2InstanceConnectCLI.EC2InstanceConnectCLI.run_command') 58 | @mock.patch('ec2instanceconnectcli.key_publisher.push_public_key') 59 | @mock.patch('ec2instanceconnectcli.ec2_util.get_instance_data') 60 | def test_mssh_no_target_no_public_ip(self, 61 | mock_instance_data, 62 | mock_push_key, 63 | mock_run): 64 | mock_file = "identity" 65 | flag = '-f flag' 66 | command = 'command arg' 67 | logger = EC2InstanceConnectLogger() 68 | instance_bundles = [{'username': self.default_user, 'instance_id': self.instance_id, 69 | 'target': None, 'zone': self.availability_zone, 'region': self.region, 70 | 'profile': self.profile}] 71 | 72 | mock_instance_data.return_value = self.private_instance_info 73 | mock_push_key.return_value = None 74 | 75 | cli_command = EC2InstanceConnectCommand("ssh", instance_bundles, mock_file, flag, command, logger.get_logger()) 76 | cli = EC2InstanceConnectCLI(instance_bundles, "", cli_command, logger.get_logger()) 77 | cli.invoke_command() 78 | 79 | expected_command = 'ssh -o "IdentitiesOnly=yes" -i {0} {1} {2}@{3} {4}'.format(mock_file, flag, self.default_user, 80 | self.private_ip, command) 81 | 82 | # Check that we successfully get to the run 83 | self.assertTrue(mock_instance_data.called) 84 | self.assertTrue(mock_push_key.called) 85 | mock_run.assert_called_with(expected_command) 86 | 87 | @mock.patch('ec2instanceconnectcli.EC2InstanceConnectCLI.EC2InstanceConnectCLI.run_command') 88 | @mock.patch('ec2instanceconnectcli.key_publisher.push_public_key') 89 | @mock.patch('ec2instanceconnectcli.ec2_util.get_instance_data') 90 | def test_mssh_with_target(self, 91 | mock_instance_data, 92 | mock_push_key, 93 | mock_run): 94 | mock_file = 'identity' 95 | flag = '-f flag' 96 | command = 'command arg' 97 | host = '0.0.0.0' 98 | logger = EC2InstanceConnectLogger() 99 | instance_bundles = [{'username': self.default_user, 'instance_id': self.instance_id, 100 | 'target': host, 'zone': self.availability_zone, 'region': self.region, 101 | 'profile': self.profile}] 102 | 103 | mock_instance_data.return_value = self.instance_info 104 | mock_push_key.return_value = None 105 | 106 | cli_command = EC2InstanceConnectCommand("ssh", instance_bundles, mock_file, flag, command, logger.get_logger()) 107 | cli = EC2InstanceConnectCLI(instance_bundles, "", cli_command, logger.get_logger()) 108 | cli.invoke_command() 109 | 110 | expected_command = 'ssh -o "IdentitiesOnly=yes" -i {0} {1} {2}@{3} {4}'.format(mock_file, flag, self.default_user, 111 | host, command) 112 | # Check that we successfully get to the run 113 | # Since both target and availability_zone are provided, mock_instance_data should not be called 114 | self.assertFalse(mock_instance_data.called) 115 | self.assertTrue(mock_push_key.called) 116 | mock_run.assert_called_with(expected_command) 117 | 118 | @mock.patch('ec2instanceconnectcli.EC2InstanceConnectCLI.EC2InstanceConnectCLI.run_command') 119 | @mock.patch('ec2instanceconnectcli.key_publisher.push_public_key') 120 | @mock.patch('ec2instanceconnectcli.ec2_util.get_instance_data') 121 | def test_msftp(self, 122 | mock_instance_data, 123 | mock_push_key, 124 | mock_run): 125 | mock_file = 'identity' 126 | flag = '-f flag' 127 | command = 'file2 file3' 128 | logger = EC2InstanceConnectLogger() 129 | instance_bundles = [{'username': self.default_user, 'instance_id': self.instance_id, 130 | 'target': None, 'zone': self.availability_zone, 'region': self.region, 131 | 'profile': self.profile, 'file': 'file1'}] 132 | 133 | mock_instance_data.return_value = self.instance_info 134 | mock_push_key.return_value = None 135 | 136 | expected_command = 'sftp -o "IdentitiesOnly=yes" -i {0} {1} {2}@{3}:{4} {5}'.format(mock_file, flag, self.default_user, 137 | self.public_ip, 'file1', command) 138 | 139 | cli_command = EC2InstanceConnectCommand("sftp", instance_bundles, mock_file, flag, command, logger.get_logger()) 140 | cli = EC2InstanceConnectCLI(instance_bundles, "", cli_command, logger.get_logger()) 141 | cli.invoke_command() 142 | 143 | # Check that we successfully get to the run 144 | self.assertTrue(mock_instance_data.called) 145 | self.assertTrue(mock_push_key.called) 146 | mock_run.assert_called_with(expected_command) 147 | 148 | @mock.patch('ec2instanceconnectcli.EC2InstanceConnectCLI.EC2InstanceConnectCLI.run_command') 149 | @mock.patch('ec2instanceconnectcli.key_publisher.push_public_key') 150 | @mock.patch('ec2instanceconnectcli.ec2_util.get_instance_data') 151 | def test_mscp(self, 152 | mock_instance_data, 153 | mock_push_key, 154 | mock_run): 155 | mock_file = 'identity' 156 | flag = '-f flag' 157 | command = 'file2 file3' 158 | logger = EC2InstanceConnectLogger() 159 | instance_bundles = [{'username': self.default_user, 'instance_id': self.instance_id, 160 | 'target': None, 'zone': self.availability_zone, 'region': self.region, 161 | 'profile': self.profile, 'file': 'file1'}, 162 | {'username': self.default_user, 'instance_id': self.instance_id, 163 | 'target': None, 'zone': self.availability_zone, 'region': self.region, 164 | 'profile': self.profile, 'file': 'file4'}] 165 | 166 | mock_instance_data.return_value = self.instance_info 167 | mock_push_key.return_value = None 168 | 169 | expected_command = 'scp -o "IdentitiesOnly=yes" -i {0} {1} {2}@{3}:{4} {5} {6}@{7}:{8}'.format(mock_file, flag, self.default_user, 170 | self.public_ip, 'file1', command, 171 | self.default_user, 172 | self.public_ip, 'file4') 173 | 174 | cli_command = EC2InstanceConnectCommand("scp", instance_bundles, mock_file, flag, command, logger.get_logger()) 175 | cli = EC2InstanceConnectCLI(instance_bundles, "", cli_command, logger.get_logger()) 176 | cli.invoke_command() 177 | 178 | # Check that we successfully get to the run 179 | self.assertTrue(mock_instance_data.called) 180 | self.assertTrue(mock_push_key.called) 181 | mock_run.assert_called_with(expected_command) 182 | 183 | def test_status_code(self): 184 | #TODO: Refine test for checking run_command status code 185 | cli = EC2InstanceConnectCLI(None, None, None, None) 186 | code = cli.run_command("echo ok; exit -1;") 187 | self.assertEqual(code, 255) 188 | -------------------------------------------------------------------------------- /tests/test_ec2_util.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"). You 4 | # may not use this file except in compliance with the License. A copy of 5 | # the License is located at 6 | # 7 | # http://aws.amazon.com/apache2.0/ 8 | # 9 | # or in the "license" file accompanying this file. This file is 10 | # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 11 | # ANY KIND, either express or implied. See the License for the specific 12 | # language governing permissions and limitations under the License. 13 | 14 | from testloader.test_base import TestBase 15 | from ec2instanceconnectcli import ec2_util 16 | try: 17 | from unittest import mock 18 | except ImportError: 19 | import mock 20 | 21 | 22 | class TestEC2Util(TestBase): 23 | 24 | def test_get_instance_data(self): 25 | 26 | describe_instance_correct_return_value = { 27 | 'Reservations': [ 28 | { 29 | 'Instances': [ 30 | { 31 | 'InstanceId': self.instance_id, 32 | 'Placement': { 33 | 'AvailabilityZone': self.availability_zone, 34 | }, 35 | 'PublicDnsName': self.public_dns_name, 36 | 'PrivateDnsName': self.private_dns_name, 37 | 'PublicIpAddress': self.public_ip, 38 | 'PrivateIpAddress': self.private_ip 39 | }, 40 | ], 41 | }, 42 | ], 43 | } 44 | 45 | mock_session = mock.Mock() 46 | mock_boto_client = mock.Mock() 47 | mock_session.create_client.return_value = mock_boto_client 48 | mock_boto_client.describe_instances.return_value = describe_instance_correct_return_value 49 | 50 | instance_info = ec2_util.get_instance_data(mock_session, self.instance_id) 51 | mock_boto_client.describe_instances.assert_called_with(InstanceIds=[self.instance_id]) 52 | 53 | self.assertEqual(instance_info.public_dns_name, self.public_dns_name) 54 | self.assertEqual(instance_info.private_dns_name, self.private_dns_name) 55 | self.assertEqual(instance_info.public_ip, self.public_ip) 56 | self.assertEqual(instance_info.private_ip, self.private_ip) 57 | self.assertEqual(instance_info.availability_zone, self.availability_zone) 58 | 59 | def test_invalid_key_in_response(self): 60 | 61 | describe_instance_wrong_return_value = { 62 | 'Reservations': [ 63 | { 64 | 'Instances': [ 65 | { 66 | 'InstanceId': self.instance_id, 67 | 'WRONG': { 68 | }, 69 | }, 70 | ], 71 | }, 72 | ], 73 | } 74 | 75 | mock_session = mock.Mock() 76 | mock_boto_client = mock.Mock() 77 | mock_session.create_client.return_value = mock_boto_client 78 | mock_boto_client.describe_instances.return_value = describe_instance_wrong_return_value 79 | 80 | with self.assertRaises(SystemExit) as context: 81 | ec2_util.get_instance_data(mock_session, self.instance_id) 82 | self.assertEqual(context.exception.code, 1) 83 | 84 | def test_blank_return_values(self): 85 | 86 | describe_instance_blank_return_value = { 87 | 'Reservations': [ 88 | { 89 | 'Instances': [ 90 | { 91 | 'InstanceId': self.instance_id, 92 | 'Placement': { 93 | 'AvailabilityZone': '', 94 | }, 95 | }, 96 | ], 97 | }, 98 | ], 99 | } 100 | 101 | mock_session = mock.Mock() 102 | mock_boto_client = mock.Mock() 103 | mock_session.create_client.return_value = mock_boto_client 104 | mock_boto_client.describe_instances.return_value = describe_instance_blank_return_value 105 | 106 | with self.assertRaises(SystemExit) as context: 107 | ec2_util.get_instance_data(mock_session, self.instance_id) 108 | mock_boto_client.describe_instances.assert_called_with(InstanceIds=[self.instance_id]) 109 | self.assertEqual(context.exception.code, 7) 110 | 111 | def test_no_dns_or_ip(self): 112 | 113 | describe_instance_blank_return_value = { 114 | 'Reservations': [ 115 | { 116 | 'Instances': [ 117 | { 118 | 'InstanceId': self.instance_id, 119 | 'Placement': { 120 | 'AvailabilityZone': self.availability_zone, 121 | }, 122 | 'PublicDnsName': None, 123 | 'PrivateDnsName': None, 124 | 'PublicIpAddress': None, 125 | 'PrivateIpAddress': None 126 | }, 127 | ], 128 | }, 129 | ], 130 | } 131 | 132 | mock_session = mock.Mock() 133 | mock_boto_client = mock.Mock() 134 | mock_session.create_client.return_value = mock_boto_client 135 | mock_boto_client.describe_instances.return_value = describe_instance_blank_return_value 136 | 137 | with self.assertRaises(SystemExit) as context: 138 | ec2_util.get_instance_data(mock_session, self.instance_id) 139 | mock_boto_client.describe_instances.assert_called_with(InstanceIds=[self.instance_id]) 140 | self.assertEqual(context.exception.code, 8) 141 | -------------------------------------------------------------------------------- /tests/test_input_parser.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"). You 4 | # may not use this file except in compliance with the License. A copy of 5 | # the License is located at 6 | # 7 | # http://aws.amazon.com/apache2.0/ 8 | # 9 | # or in the "license" file accompanying this file. This file is 10 | # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 11 | # ANY KIND, either express or implied. See the License for the specific 12 | # language governing permissions and limitations under the License. 13 | 14 | import argparse 15 | import getpass 16 | from ec2instanceconnectcli import input_parser 17 | from testloader.test_base import TestBase 18 | 19 | 20 | class TestInputParser(TestBase): 21 | 22 | parser = argparse.ArgumentParser(description='alternate usage: mssh [user]@[instance-id]:[port]') 23 | parser.add_argument('-u', '--profile', help='AWS Config Profile', type=str, default='default') 24 | parser.add_argument('-t', '--instance_id', help='EC2 Instance ID', type=str, default='') 25 | parser.add_argument('-r', '--region', action='store', help='AWS region', type=str) 26 | parser.add_argument('-z', '--zone', action='store', help='Availability zone', type=str) 27 | parser.add_argument('-U', '--dest_profile', action='store', 28 | help='AWS Config Profile (if specifying second instance as destination)', type=str, default='default') 29 | parser.add_argument('-R', '--dest_region', action='store', 30 | help='AWS region (if specifying second instance as destination)', type=str) 31 | parser.add_argument('-Z', '--dest_zone', action='store', 32 | help='Availability zone (if specifying second instance as destination', type=str) 33 | parser.add_argument('-T', '--dest_instance_id', action='store', type=str, default='', 34 | help='EC2 Instance ID. Required if destination is a second instance and is given as a DNS name' 35 | 'or IP address') 36 | 37 | def test_basic_target(self): 38 | args = self.parser.parse_known_args(['-u', self.profile, self.instance_id]) 39 | 40 | bundles, flags, command = input_parser.parseargs(args) 41 | 42 | self.assertEqual(bundles, [{'username': self.default_user, 'instance_id': self.instance_id, 43 | 'target': None, 'zone': None, 'region': None, 'profile': self.profile}]) 44 | self.assertEqual(flags, '') 45 | self.assertEqual(command, '') 46 | 47 | def test_username(self): 48 | args = self.parser.parse_known_args(['-u', self.profile, "myuser@{0}".format(self.instance_id)]) 49 | 50 | bundles, flags, command = input_parser.parseargs(args) 51 | 52 | self.assertEqual(bundles, [{'username': 'myuser', 'instance_id': self.instance_id, 53 | 'target': None, 'zone': None, 'region': None, 'profile': self.profile}]) 54 | self.assertEqual(flags, '') 55 | self.assertEqual(command, '') 56 | 57 | def test_dns_name(self): 58 | args = self.parser.parse_known_args(['-u', self.profile, '-t', self.instance_id, '-r', self.region, 59 | '-z', self.availability_zone, self.dns_name]) 60 | 61 | bundles, flags, command = input_parser.parseargs(args) 62 | 63 | self.assertEqual(bundles, [{'username': self.default_user, 'instance_id': self.instance_id, 64 | 'target': self.dns_name, 'zone': self.availability_zone, 65 | 'region': self.region, 'profile': self.profile}]) 66 | self.assertEqual(flags, '') 67 | self.assertEqual(command, '') 68 | 69 | def test_flags(self): 70 | args = self.parser.parse_known_args(['-u', self.profile, "-1", "-l", "login", self.instance_id]) 71 | 72 | bundles, flags, command = input_parser.parseargs(args) 73 | 74 | self.assertEqual(bundles, [{'username': 'login', 'instance_id': self.instance_id, 75 | 'target': None, 'zone': None, 'region': None, 'profile': self.profile}]) 76 | self.assertEqual(flags, '-1 -l login') 77 | self.assertEqual(command, '') 78 | 79 | def test_command(self): 80 | args = self.parser.parse_known_args(['-u', self.profile, self.instance_id, 'uname', '-a']) 81 | 82 | bundles, flags, command = input_parser.parseargs(args) 83 | 84 | self.assertEqual(bundles, [{'username': self.default_user, 'instance_id': self.instance_id, 85 | 'target': None, 'zone': None, 'region': None, 'profile': self.profile}]) 86 | self.assertEqual(flags, '') 87 | self.assertEqual(command, 'uname -a') 88 | 89 | def test_sftp(self): 90 | args = self.parser.parse_known_args(['-u', self.profile, "{0}:{1}".format(self.instance_id, 'first_file'), 91 | 'second_file']) 92 | 93 | bundles, flags, command = input_parser.parseargs(args, 'sftp') 94 | 95 | self.assertEqual(bundles, [{'username': self.default_user, 'instance_id': self.instance_id, 96 | 'target': None, 'zone': None, 'region': None, 'profile': self.profile, 97 | 'file': 'first_file'}]) 98 | self.assertEqual(flags, '') 99 | self.assertEqual(command, 'second_file') 100 | 101 | def test_invalid_username(self): 102 | args = self.parser.parse_known_args(['-u', self.profile, "BADUSER@{0}".format(self.instance_id)]) 103 | 104 | self.assertRaises(AssertionError, input_parser.parseargs, args) 105 | 106 | def test_invalid_ip(self): 107 | args = self.parser.parse_known_args(['-u', self.profile, '-t', self.instance_id, '-r', self.region, 108 | '-z', self.availability_zone, '123.123.123.555']) 109 | 110 | self.assertRaises(AssertionError, input_parser.parseargs, args) 111 | 112 | def test_invalid_dns_name(self): 113 | args = self.parser.parse_known_args(['-u', self.profile, '-t', self.instance_id, '-r', self.region, 114 | '-z', self.availability_zone, 'I!nv&ali$d']) 115 | 116 | self.assertRaises(AssertionError, input_parser.parseargs, args) 117 | 118 | def test_double_at(self): 119 | args = self.parser.parse_known_args(['-u', self.profile, '-t', self.instance_id, '-r', self.region, 120 | '-z', self.availability_zone, 'I!nv&@li@d']) 121 | 122 | self.assertRaises(AssertionError, input_parser.parseargs, args) 123 | 124 | def test_invalid_region(self): 125 | args = self.parser.parse_known_args(['-u', self.profile, '-t', self.instance_id, '-r', 'bad region', 126 | '-z', self.availability_zone, self.dns_name]) 127 | 128 | self.assertRaises(AssertionError, input_parser.parseargs, args) 129 | 130 | def test_invalid_zone(self): 131 | args = self.parser.parse_known_args(['-u', self.profile, '-t', self.instance_id, '-r', self.region, 132 | '-z', 'bad zone', self.dns_name]) 133 | 134 | self.assertRaises(AssertionError, input_parser.parseargs, args) 135 | 136 | def test_contains_identity_file(self): 137 | args = self.parser.parse_known_args(['-u', self.profile, "-1", "-l", "login", " -i ", self.instance_id]) 138 | 139 | self.assertRaises(AssertionError, input_parser.parseargs, args) 140 | -------------------------------------------------------------------------------- /tests/test_key_publisher.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"). You 4 | # may not use this file except in compliance with the License. A copy of 5 | # the License is located at 6 | # 7 | # http://aws.amazon.com/apache2.0/ 8 | # 9 | # or in the "license" file accompanying this file. This file is 10 | # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 11 | # ANY KIND, either express or implied. See the License for the specific 12 | # language governing permissions and limitations under the License. 13 | 14 | from ec2instanceconnectcli import key_publisher 15 | from testloader.test_base import TestBase 16 | try: 17 | from unittest import mock 18 | except ImportError: 19 | import mock 20 | 21 | class TestKeyPublisher(TestBase): 22 | 23 | def test_push_public_key(self): 24 | mock_session = mock.Mock() 25 | mock_boto_client = mock.Mock() 26 | mock_session.create_client.return_value = mock_boto_client 27 | 28 | params = { 29 | 'InstanceId': self.instance_id, 30 | 'InstanceOSUser': self.default_user, 31 | 'SSHPublicKey': 'pub_key', 32 | 'AvailabilityZone': self.availability_zone 33 | } 34 | 35 | key_publisher.push_public_key(mock_session, self.instance_id, 36 | self.default_user, 'pub_key', self.availability_zone) 37 | 38 | mock_boto_client.send_ssh_public_key.assert_called_with(**params) 39 | -------------------------------------------------------------------------------- /tests/test_key_utils.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"). You 4 | # may not use this file except in compliance with the License. A copy of 5 | # the License is located at 6 | # 7 | # http://aws.amazon.com/apache2.0/ 8 | # 9 | # or in the "license" file accompanying this file. This file is 10 | # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 11 | # ANY KIND, either express or implied. See the License for the specific 12 | # language governing permissions and limitations under the License. 13 | 14 | from cryptography.hazmat.backends import default_backend as crypto_default_backend 15 | from cryptography.hazmat.primitives import serialization as crypto_serialization 16 | from ec2instanceconnectcli import key_utils 17 | from unittest import TestCase 18 | try: 19 | from unittest import mock 20 | except ImportError: 21 | import mock 22 | 23 | 24 | class TestKeyUtils(TestCase): 25 | password = 'password' 26 | mock_key = mock.Mock() 27 | public_exponent = 65537 28 | key_size = 2048 29 | 30 | @mock.patch('cryptography.hazmat.primitives.asymmetric.rsa.generate_private_key') 31 | def test_generate_key(self, mock_gen_key): 32 | key_utils.generate_key(self.key_size) 33 | mock_gen_key.assert_called_with(backend=crypto_default_backend(), 34 | public_exponent=self.public_exponent, 35 | key_size=self.key_size) 36 | 37 | def test_invalid_encodings(self): 38 | try: 39 | key_utils.serialize_key(self.mock_key, encoding='INVALID') 40 | self.fail('Invalid encoding accepted') 41 | 42 | except AssertionError: 43 | pass 44 | 45 | try: 46 | key_utils.serialize_key(self.mock_key, encoding='OpenSSH', return_private=True) 47 | self.fail('Private keys shouldn''t accept OpenSSH encoding') 48 | 49 | except AssertionError: 50 | pass 51 | 52 | def test_public_encodings(self): 53 | mock_pub_key = mock.Mock() 54 | self.mock_key.public_key.return_value = mock_pub_key 55 | 56 | calls = [mock.call(encoding=crypto_serialization.Encoding.OpenSSH, 57 | format=crypto_serialization.PublicFormat.OpenSSH), 58 | mock.call(encoding=crypto_serialization.Encoding.DER, 59 | format=crypto_serialization.PublicFormat.SubjectPublicKeyInfo), 60 | mock.call(encoding=crypto_serialization.Encoding.PEM, 61 | format=crypto_serialization.PublicFormat.SubjectPublicKeyInfo)] 62 | 63 | key_utils.serialize_key(self.mock_key, encoding='OpenSSH', return_private=False) 64 | key_utils.serialize_key(self.mock_key, encoding='DER', return_private=False) 65 | key_utils.serialize_key(self.mock_key, encoding='PEM', return_private=False) 66 | 67 | mock_pub_key.public_bytes.assert_has_calls(calls) 68 | 69 | @mock.patch('cryptography.hazmat.primitives.serialization.NoEncryption') 70 | def test_private_encodings(self, mock_encryption): 71 | mock_enc = mock.Mock() 72 | mock_encryption.return_value = mock_enc 73 | 74 | calls = [mock.call(encoding=crypto_serialization.Encoding.DER, 75 | format=crypto_serialization.PrivateFormat.TraditionalOpenSSL, 76 | encryption_algorithm=mock_enc), 77 | mock.call(encoding=crypto_serialization.Encoding.PEM, 78 | format=crypto_serialization.PrivateFormat.TraditionalOpenSSL, 79 | encryption_algorithm=mock_enc)] 80 | 81 | key_utils.serialize_key(self.mock_key, encoding='DER', return_private=True) 82 | key_utils.serialize_key(self.mock_key, encoding='PEM', return_private=True) 83 | 84 | self.mock_key.private_bytes.assert_has_calls(calls) 85 | 86 | @mock.patch('cryptography.hazmat.primitives.serialization.BestAvailableEncryption') 87 | def test_private_password(self, mock_encryption): 88 | mock_enc = mock.Mock() 89 | mock_encryption.return_value = mock_enc 90 | 91 | key_utils.serialize_key(self.mock_key, encoding='PEM', return_private=True, password=self.password) 92 | 93 | mock_encryption.assert_called_with(self.password) 94 | self.mock_key.private_bytes.assert_called_with( 95 | encoding=crypto_serialization.Encoding.PEM, 96 | format=crypto_serialization.PrivateFormat.TraditionalOpenSSL, 97 | encryption_algorithm=mock_enc) 98 | 99 | @mock.patch('ec2instanceconnectcli.key_utils.serialize_key') 100 | @mock.patch('cryptography.hazmat.primitives.serialization.load_der_private_key') 101 | def test_convert_der_pem_private(self, mock_load_private, mock_serialize): 102 | mock_load_private.return_value = self.mock_key 103 | 104 | key_utils.convert_der_to_pem(self.mock_key, is_private=True) 105 | 106 | calls = [mock.call(self.mock_key, encoding='PEM', return_private=True)] 107 | 108 | mock_load_private.assert_called_with(self.mock_key, backend=crypto_default_backend()) 109 | mock_serialize.assert_has_calls(calls) 110 | 111 | @mock.patch('cryptography.hazmat.primitives.serialization.load_der_public_key') 112 | def test_convert_der_pem_public(self, mock_load_public): 113 | mock_load_public.return_value = self.mock_key 114 | 115 | key_utils.convert_der_to_pem(self.mock_key, is_private=False) 116 | 117 | mock_load_public.assert_called_with(self.mock_key, backend=crypto_default_backend()) 118 | self.mock_key.public_bytes.assert_called_with( 119 | encoding=crypto_serialization.Encoding.PEM, 120 | format=crypto_serialization.PublicFormat.SubjectPublicKeyInfo) 121 | 122 | @mock.patch('ec2instanceconnectcli.key_utils.serialize_key') 123 | @mock.patch('cryptography.hazmat.primitives.serialization.load_pem_private_key') 124 | def test_convert_pem_der_private(self, mock_load_private, mock_serialize): 125 | mock_load_private.return_value = self.mock_key 126 | 127 | fake_priv = str.encode("{0}\n".format(key_utils.begin_key.format(key_utils.private_str))) 128 | 129 | key_utils.convert_pem_to_der(fake_priv) 130 | 131 | calls = [mock.call(self.mock_key, encoding='DER', return_private=True)] 132 | 133 | mock_load_private.assert_called_with(fake_priv, backend=crypto_default_backend()) 134 | mock_serialize.assert_has_calls(calls) 135 | 136 | @mock.patch('cryptography.hazmat.primitives.serialization.load_pem_public_key') 137 | def test_convert_pem_der_public(self, mock_load_public): 138 | mock_load_public.return_value = self.mock_key 139 | 140 | fake_pub = str.encode("{0}\n".format(key_utils.begin_key.format(key_utils.public_str))) 141 | 142 | key_utils.convert_pem_to_der(fake_pub) 143 | 144 | mock_load_public.assert_called_with(fake_pub, backend=crypto_default_backend()) 145 | self.mock_key.public_bytes.assert_called_with( 146 | encoding=crypto_serialization.Encoding.DER, 147 | format=crypto_serialization.PublicFormat.SubjectPublicKeyInfo) 148 | 149 | @mock.patch('cryptography.hazmat.primitives.serialization.load_pem_public_key') 150 | def test_convert_pem_to_openssh(self, mock_load_public): 151 | mock_load_public.return_value = self.mock_key 152 | 153 | key_utils.convert_pem_to_openssh(self.mock_key) 154 | 155 | mock_load_public.assert_called_with(self.mock_key, backend=crypto_default_backend()) 156 | self.mock_key.public_bytes.assert_called_with( 157 | encoding=crypto_serialization.Encoding.OpenSSH, 158 | format=crypto_serialization.PublicFormat.OpenSSH) 159 | -------------------------------------------------------------------------------- /tests/testloader/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws/aws-ec2-instance-connect-cli/536a3e6ab02cfe62ddff7dcfaece279e67573fd7/tests/testloader/__init__.py -------------------------------------------------------------------------------- /tests/testloader/test_base.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"). You 4 | # may not use this file except in compliance with the License. A copy of 5 | # the License is located at 6 | # 7 | # http://aws.amazon.com/apache2.0/ 8 | # 9 | # or in the "license" file accompanying this file. This file is 10 | # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 11 | # ANY KIND, either express or implied. See the License for the specific 12 | # language governing permissions and limitations under the License. 13 | 14 | from unittest import TestCase 15 | import os 16 | from argparse import Namespace 17 | 18 | 19 | class TestBase(TestCase): 20 | """ 21 | This class does not actually provide any tests, but serves as common setup for every other test class. 22 | """ 23 | 24 | profile = 'default' 25 | new_profile = 'newprofile' 26 | region = 'us-east-2' 27 | new_region = 'us-west-2' 28 | user = 'youser' 29 | default_user = 'ec2-user' 30 | instance_id = 'i-abcd1234' 31 | dns_name = 'my.dns.name' 32 | port = 22 33 | command = 'uname -a' 34 | argument = user + '@' + instance_id + ':' + str(port) 35 | availability_zone = 'us-east-2b' 36 | public_dns_name = 'ec2-21-0-0-10.us-west-2.compute.amazonaws.com' 37 | private_dns_name = 'ip-10-0-0-21.us-west-2.compute.internal' 38 | public_ip = '21.0.0.10' 39 | private_ip = '10.0.0.21' 40 | file_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 41 | config_dir = "{0}/configuration".format(file_dir) 42 | config_file = "{0}/config".format(config_dir) 43 | cred_file = "{0}/credentials".format(config_dir) 44 | access_key = 'notsecret' 45 | secret_key = 'secret' 46 | new_access_key = 'notatallsecret' 47 | new_secret_key = 'supersecret' 48 | endpoint = 'http://0.0.0.0:6443' 49 | new_endpoint = 'endpoint.us-east-2.amazon.com' 50 | home = os.environ.get('HOME') 51 | if home is None: 52 | home = os.environ.get('ENVROOT') 53 | default_config_dir = str(home) + '/.aws' 54 | default_config_file = os.path.join(default_config_dir, 'config') 55 | default_creds_file = os.path.join(default_config_dir, 'credentials') 56 | client_config = { 57 | 'aws_access_key_id': access_key, 58 | 'aws_secret_access_key': secret_key, 59 | 'region_name': region, 60 | 'endpoint_url': endpoint 61 | } 62 | instance_info = Namespace( 63 | public_dns_name=public_dns_name, 64 | private_dns_name=private_dns_name, 65 | public_ip=public_ip, 66 | private_ip=private_ip, 67 | availability_zone=availability_zone 68 | ) 69 | private_instance_info = Namespace( 70 | public_dns_name=None, 71 | private_dns_name=private_dns_name, 72 | public_ip=None, 73 | private_ip=private_ip, 74 | availability_zone=availability_zone 75 | ) 76 | --------------------------------------------------------------------------------