├── .gitignore ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.rst ├── binaries ├── README.md └── mysocketctl ├── mysocketctl ├── __init__.py ├── account.py ├── connect.py ├── login.py ├── mysocketcli.py ├── socket.py ├── ssh │ ├── __init__.py │ ├── paramiko_client.py │ └── system.py ├── tunnel.py └── utils.py └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | ### vscode ### 2 | .vscode/ 3 | .vscode/* 4 | !.vscode/settings.json 5 | !.vscode/tasks.json 6 | !.vscode/launch.json 7 | !.vscode/extensions.json 8 | *.code-workspace 9 | 10 | ### Python ### 11 | # Byte-compiled / optimized / DLL files 12 | __pycache__/ 13 | *.py[cod] 14 | *$py.class 15 | 16 | # C extensions 17 | *.so 18 | 19 | # Distribution / packaging 20 | .Python 21 | build/ 22 | develop-eggs/ 23 | dist/ 24 | downloads/ 25 | eggs/ 26 | .eggs/ 27 | lib/ 28 | lib64/ 29 | parts/ 30 | sdist/ 31 | var/ 32 | wheels/ 33 | pip-wheel-metadata/ 34 | share/python-wheels/ 35 | *.egg-info/ 36 | .installed.cfg 37 | *.egg 38 | MANIFEST 39 | 40 | # PyInstaller 41 | # Usually these files are written by a python script from a template 42 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 43 | *.manifest 44 | *.spec 45 | 46 | # Installer logs 47 | pip-log.txt 48 | pip-delete-this-directory.txt 49 | 50 | # Unit test / coverage reports 51 | htmlcov/ 52 | .tox/ 53 | .nox/ 54 | .coverage 55 | .coverage.* 56 | .cache 57 | nosetests.xml 58 | coverage.xml 59 | *.cover 60 | *.py,cover 61 | .hypothesis/ 62 | .pytest_cache/ 63 | pytestdebug.log 64 | 65 | # Translations 66 | *.mo 67 | *.pot 68 | 69 | # Django stuff: 70 | *.log 71 | local_settings.py 72 | db.sqlite3 73 | db.sqlite3-journal 74 | 75 | # Flask stuff: 76 | instance/ 77 | .webassets-cache 78 | 79 | # Scrapy stuff: 80 | .scrapy 81 | 82 | # Sphinx documentation 83 | docs/_build/ 84 | doc/_build/ 85 | 86 | # PyBuilder 87 | target/ 88 | 89 | # Jupyter Notebook 90 | .ipynb_checkpoints 91 | 92 | # IPython 93 | profile_default/ 94 | ipython_config.py 95 | 96 | # pyenv 97 | .python-version 98 | 99 | # pipenv 100 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 101 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 102 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 103 | # install all needed dependencies. 104 | #Pipfile.lock 105 | 106 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 107 | __pypackages__/ 108 | 109 | # Celery stuff 110 | celerybeat-schedule 111 | celerybeat.pid 112 | 113 | # SageMath parsed files 114 | *.sage.py 115 | 116 | # Environments 117 | .env 118 | .venv 119 | env/ 120 | venv/ 121 | ENV/ 122 | env.bak/ 123 | venv.bak/ 124 | pythonenv* 125 | 126 | # Spyder project settings 127 | .spyderproject 128 | .spyproject 129 | 130 | # Rope project settings 131 | .ropeproject 132 | 133 | # mkdocs documentation 134 | /site 135 | 136 | # mypy 137 | .mypy_cache/ 138 | .dmypy.json 139 | dmypy.json 140 | 141 | # Pyre type checker 142 | .pyre/ 143 | 144 | # pytype static type analyzer 145 | .pytype/ 146 | 147 | # profiling data 148 | .prof 149 | 150 | # End of https://www.toptal.com/developers/gitignore/api/python 151 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | exclude mysocketctl/__pycache__ 2 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SHELL=/bin/bash 2 | 3 | install: 4 | -rm -r build dist *.egg-info 5 | python3 setup.py bdist_wheel sdist 6 | rm -rf */__pycache_ 7 | #twine upload --repository testpypi dist/* 8 | twine upload dist/* 9 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Mysocketctl: a CLI tool for Mysocket.io 2 | ================================================== 3 | Mysocketctl is a CLI wrapper around the Mysocket.io API 4 | 5 | Please check the full documentation here: 6 | `mysocketctl documentation on readthedocs.io `_ 7 | 8 | Installation 9 | -------------------- 10 | :: 11 | 12 | pip3 install mysocketctl 13 | 14 | Authors 15 | -------------------- 16 | * Andree Toonk 17 | 18 | Links 19 | -------------------- 20 | * `mysocketctl documentation on readthedocs.io `_ 21 | * `Mysocket home page `_ 22 | * `Project home page (GitHub) `_ 23 | 24 | Bugs 25 | -------------------- 26 | Please report bugs, issues, feature requests, etc. on `GitHub `_. 27 | -------------------------------------------------------------------------------- /binaries/README.md: -------------------------------------------------------------------------------- 1 | Warning: binary versions are not actively updated, and this is not actively maintained. This is a one off snapshot of [version 0.13](https://pypi.org/project/mysocketctl/0.13/) 2 | 3 | 4 | To download: 5 | ``` 6 | wget https://github.com/mysocketio/mysocketctl/raw/main/binaries/mysocketctl 7 | chmod +x ./mysocketctl 8 | ``` 9 | 10 | 11 | Linux binary for mysocketctl was created using pyinstaller: 12 | 13 | ```pyinstaller --onefile --additional-hooks-dir=. -w /usr/local/bin/mysocketctl``` 14 | -------------------------------------------------------------------------------- /binaries/mysocketctl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mysocketio/mysocketctl/HEAD/binaries/mysocketctl -------------------------------------------------------------------------------- /mysocketctl/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /mysocketctl/account.py: -------------------------------------------------------------------------------- 1 | import click 2 | from mysocketctl.utils import * 3 | 4 | 5 | @click.group() 6 | def account(): 7 | """Create a new account or see account information.""" 8 | pass 9 | 10 | 11 | def show_account(authorization_header, user_id): 12 | api_answer = requests.get(f"{api_url}user/{user_id}", headers=authorization_header) 13 | validate_response(api_answer) 14 | return api_answer.json() 15 | 16 | 17 | def create_account(name, email, password, sshkey): 18 | params = { 19 | "name": name, 20 | "email": email, 21 | "password": password, 22 | "sshkey": sshkey, 23 | } 24 | 25 | api_answer = requests.post( 26 | api_url + "user", 27 | data=json.dumps(params), 28 | headers={"accept": "application/json", "Content-Type": "application/json"}, 29 | ) 30 | validate_response(api_answer) 31 | return api_answer.json() 32 | 33 | 34 | @account.command() 35 | @click.option("--name", required=True, help="your name") 36 | @click.option("--email", required=True, help="your email") 37 | @click.password_option("--password", required=True, help="your pasword") 38 | @click.option( 39 | "--sshkey", 40 | required=True, 41 | help='your public sshkey as a string, or use: --sshkey "$(cat ~/.ssh/id_rsa.pub)"', 42 | ) 43 | def create(name, email, password, sshkey): 44 | """Create your new mysocket.io account""" 45 | try: 46 | if os.path.exists(sshkey): 47 | with open(sshkey, "r") as fp: 48 | sshkey = fp.read() 49 | except PermissionError: 50 | print( 51 | f"Unable to read the file {sshkey}, please check file permissions and try again." 52 | ) 53 | return 54 | register_result = create_account(name, email, password, sshkey) 55 | print( 56 | "Congratulation! your account has been created. A confirmation email has been sent to " 57 | + email 58 | ) 59 | print( 60 | "Please complete the account registration by following the confirmation link in your email." 61 | ) 62 | print(f"After that login with login --email '{email}' --password '*****'") 63 | 64 | 65 | @account.command() 66 | def show(): 67 | """Show your mysocket.io account information""" 68 | authorization_header = get_auth_header() 69 | user_details = show_account(authorization_header, get_user_id()) 70 | 71 | table = PrettyTable() 72 | # table.border = True 73 | # table.hrules=True 74 | table.header = False 75 | table.add_row(["Name", str(user_details["name"])]) 76 | table.add_row(["Email", str(user_details["email"])]) 77 | table.add_row(["user id", str(user_details["user_id"])]) 78 | table.add_row(["ssh username", str(user_details["user_name"])]) 79 | table.add_row(["ssh key", str(user_details["sshkey"])]) 80 | table.align = "l" 81 | print(table) 82 | 83 | 84 | # @click.option("--sshkey", required=True, help='your public sshkey as a string, or use: --sshkey "$(cat ~/.ssh/id_rsa.pub)"') 85 | # @account.command() 86 | # def update_sshkey(sshkey): 87 | # print("not implemented yet") 88 | -------------------------------------------------------------------------------- /mysocketctl/connect.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | import mysocketctl.socket 4 | from mysocketctl.utils import * 5 | from validate_email import validate_email 6 | 7 | 8 | @click.group() 9 | def connect(): 10 | """Quckly connect. Wrapper around sockets and tunnels""" 11 | pass 12 | 13 | 14 | def new_connection( 15 | authorization_header, 16 | connect_name, 17 | protected_socket, 18 | protected_user, 19 | protected_pass, 20 | socket_type, 21 | cloudauth, 22 | allowed_email_addresses_list, 23 | allowed_email_domain_list, 24 | ): 25 | if not protected_socket: 26 | protected_socket = False 27 | else: 28 | protected_socket = True 29 | 30 | if not cloudauth: 31 | cloudauth = False 32 | else: 33 | cloudauth = True 34 | 35 | params = { 36 | "name": connect_name, 37 | "protected_socket": protected_socket, 38 | "protected_username": protected_user, 39 | "protected_password": protected_pass, 40 | "socket_type": socket_type, 41 | "cloud_authentication_enabled": cloudauth, 42 | "cloud_authentication_email_allowed_addressses": allowed_email_addresses_list, 43 | "cloud_authentication_email_allowed_domains": allowed_email_domain_list, 44 | } 45 | api_answer = requests.post( 46 | api_url + "connect", data=json.dumps(params), headers=authorization_header 47 | ) 48 | validate_response(api_answer) 49 | return api_answer.json() 50 | 51 | 52 | @connect.command() 53 | @click.option("--port", required=True, type=int, help="Local port to connect") 54 | @click.option("--name", required=False, type=str, default="") 55 | @click.option("--protected/--not-protected", default=False) 56 | @click.option("--username", required=False, type=str, default="") 57 | @click.option("--password", required=False, type=str, default="") 58 | @click.option( 59 | "--host", 60 | required=False, 61 | type=str, 62 | default="127.0.0.1", 63 | help="Control where inbound traffic goes. Default localhost. ", 64 | ) 65 | @click.option( 66 | "--cloudauth/--no-cloudauth", default=False, help="Enable oauth/oidc authentication" 67 | ) 68 | @click.option( 69 | "--allowed_email_addresses", 70 | required=False, 71 | type=str, 72 | default="", 73 | help="comma seperated list of allowed Email addresses when using cloudauth", 74 | ) 75 | @click.option( 76 | "--allowed_email_domains", 77 | required=False, 78 | type=str, 79 | default="", 80 | help="comma seperated list of allowed Email domain (i.e. 'example.com', when using cloudauth", 81 | ) 82 | @click.option( 83 | "--type", 84 | required=False, 85 | type=click.Choice(["http", "https", "tcp", "tls"], case_sensitive=False), 86 | default="http", 87 | help="Socket type, http, https, tcp, tls", 88 | ) 89 | @click.option( 90 | "--engine", default="auto", type=click.Choice(("auto", "system", "paramiko")) 91 | ) 92 | @click.pass_context 93 | def connect( 94 | ctx, 95 | port, 96 | name, 97 | protected, 98 | username, 99 | password, 100 | host, 101 | type, 102 | engine, 103 | cloudauth, 104 | allowed_email_addresses, 105 | allowed_email_domains, 106 | ): 107 | """Quckly connect, Wrapper around sockets and tunnels""" 108 | 109 | if cloudauth: 110 | cloudauth = True 111 | allowed_email_addresses_list = [] 112 | if allowed_email_addresses: 113 | for email in allowed_email_addresses.split(","): 114 | if validate_email(email.strip()): 115 | allowed_email_addresses_list.append(email.strip()) 116 | else: 117 | print("Warning: ignoring invalid email " + email.strip()) 118 | 119 | allowed_email_domain_list = [] 120 | if allowed_email_domains: 121 | for domain in allowed_email_domains.split(","): 122 | allowed_email_domain_list.append(domain.strip()) 123 | 124 | # check if both email and domain list are empty and warn 125 | if not allowed_email_domain_list and not allowed_email_addresses_list: 126 | print( 127 | "Error: no allowed email addresses or domains provided. You will be unabled to get to your socket" 128 | ) 129 | sys.exit(1) 130 | else: 131 | cloudauth = False 132 | allowed_email_domain_list = [] 133 | allowed_email_addresses_list = [] 134 | 135 | if protected: 136 | if not username: 137 | print("--username required when using --protected") 138 | sys.exit(1) 139 | if not password: 140 | print("--password required when using --protected") 141 | sys.exit(1) 142 | if not name: 143 | name = f"Local port {port}" 144 | 145 | authorization_header = get_auth_header() 146 | new_conn = new_connection( 147 | authorization_header, 148 | name, 149 | protected, 150 | str(username), 151 | str(password), 152 | str(type), 153 | cloudauth, 154 | allowed_email_addresses_list, 155 | allowed_email_domain_list, 156 | ) 157 | remote_bind_port = new_conn["tunnels"][0]["local_port"] 158 | ssh_server = new_conn["tunnels"][0]["tunnel_server"] 159 | ssh_server = "ssh.mysocket.io" 160 | ssh_user = str(new_conn["user_name"]) 161 | 162 | print_sockets([new_conn]) 163 | if protected: 164 | print_protected(username, password) 165 | if cloudauth: 166 | print_cloudauth(allowed_email_addresses_list, allowed_email_domain_list) 167 | 168 | time.sleep(2) 169 | ssh_tunnel(port, remote_bind_port, ssh_server, ssh_user, host=host, engine=engine) 170 | print("cleaning up...") 171 | ctx.invoke(mysocketctl.socket.delete, socket_id=new_conn["socket_id"]) 172 | -------------------------------------------------------------------------------- /mysocketctl/login.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | from mysocketctl.utils import * 4 | 5 | 6 | @click.group() 7 | def login(): 8 | pass 9 | 10 | 11 | def get_token(user_email, user_pass): 12 | params = {"email": user_email, "password": user_pass} 13 | token = requests.post( 14 | api_url + "login", 15 | data=json.dumps(params), 16 | headers={"accept": "application/json", "Content-Type": "application/json"}, 17 | ) 18 | if token.status_code == 401: 19 | print("Login failed") 20 | sys.exit(1) 21 | if token.status_code != 200: 22 | print(token.status_code, token.text) 23 | sys.exit(1) 24 | return token.json() 25 | 26 | 27 | @login.command() 28 | @click.option("--email", required=True, help="your email") 29 | @click.password_option("--password", required=True, help="your pasword") 30 | def login(email, password): 31 | """Login to mysocket and get a token""" 32 | token = get_token(email, password)["token"] 33 | f = open(token_file, "w") 34 | f.write(token) 35 | f.close() 36 | os.chmod(token_file, 0o600) 37 | print(f"Logged in! Token stored in {token_file}\n") 38 | -------------------------------------------------------------------------------- /mysocketctl/mysocketcli.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import click 3 | 4 | 5 | @click.group() 6 | def cli(): 7 | pass 8 | 9 | 10 | # Import sub commands from commands 11 | from .account import account 12 | from .login import login 13 | from .connect import connect 14 | from .socket import socket 15 | from .tunnel import tunnel 16 | 17 | cli.add_command(account, "account") 18 | cli.add_command(login, "login") 19 | cli.add_command(connect, "connect") 20 | cli.add_command(socket, "socket") 21 | cli.add_command(tunnel, "tunnel") 22 | 23 | 24 | if __name__ == "__main__": 25 | cli() 26 | -------------------------------------------------------------------------------- /mysocketctl/socket.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | from mysocketctl.utils import * 4 | from validate_email import validate_email 5 | 6 | 7 | @click.group() 8 | def socket(): 9 | """Manage your global sockets""" 10 | pass 11 | 12 | 13 | def get_sockets(authorization_header): 14 | api_answer = requests.get(api_url + "connect", headers=authorization_header) 15 | validate_response(api_answer) 16 | return api_answer.json() 17 | 18 | 19 | def new_socket( 20 | authorization_header, 21 | connect_name, 22 | protected_socket, 23 | protected_user, 24 | protected_pass, 25 | socket_type, 26 | cloudauth, 27 | allowed_email_addresses_list, 28 | allowed_email_domain_list, 29 | ): 30 | 31 | if not cloudauth: 32 | cloudauth = False 33 | else: 34 | cloudauth = True 35 | 36 | if not protected_socket: 37 | protected_socket = False 38 | else: 39 | protected_socket = True 40 | 41 | params = { 42 | "name": connect_name, 43 | "protected_socket": protected_socket, 44 | "protected_username": protected_user, 45 | "protected_password": protected_pass, 46 | "socket_type": socket_type, 47 | "cloud_authentication_enabled": cloudauth, 48 | "cloud_authentication_email_allowed_addressses": allowed_email_addresses_list, 49 | "cloud_authentication_email_allowed_domains": allowed_email_domain_list, 50 | } 51 | api_answer = requests.post( 52 | api_url + "socket", data=json.dumps(params), headers=authorization_header 53 | ) 54 | validate_response(api_answer) 55 | return api_answer.json() 56 | 57 | 58 | def delete_socket(authorization_header, socket_id): 59 | api_answer = requests.delete( 60 | f"{api_url}socket/{socket_id}", headers=authorization_header 61 | ) 62 | validate_response(api_answer) 63 | return api_answer 64 | 65 | 66 | @socket.command() 67 | def ls(): 68 | authorization_header = get_auth_header() 69 | sockets = get_sockets(authorization_header) 70 | print_sockets(sockets) 71 | 72 | 73 | @socket.command() 74 | @click.option("--name", required=True, type=str) 75 | @click.option("--protected/--not-protected", default=False) 76 | @click.option("--username", required=False, type=str, default="") 77 | @click.option("--password", required=False, type=str, default="") 78 | @click.option( 79 | "--cloudauth/--no-cloudauth", default=False, help="Enable oauth/oidc authentication" 80 | ) 81 | @click.option( 82 | "--allowed_email_addresses", 83 | required=False, 84 | type=str, 85 | default="", 86 | help="comma seperated list of allowed Email addresses when using cloudauth", 87 | ) 88 | @click.option( 89 | "--allowed_email_domains", 90 | required=False, 91 | type=str, 92 | default="", 93 | help="comma seperated list of allowed Email domain (i.e. 'example.com', when using cloudauth", 94 | ) 95 | @click.option( 96 | "--type", 97 | required=False, 98 | type=click.Choice(["http", "https", "tcp", "tls"], case_sensitive=False), 99 | default="http", 100 | help="Socket type, http, https, tcp, tls", 101 | ) 102 | def create( 103 | name, 104 | protected, 105 | username, 106 | password, 107 | type, 108 | cloudauth, 109 | allowed_email_addresses, 110 | allowed_email_domains, 111 | ): 112 | 113 | if cloudauth: 114 | cloudauth = True 115 | 116 | allowed_email_addresses_list = [] 117 | if allowed_email_addresses: 118 | for email in allowed_email_addresses.split(","): 119 | if validate_email(email.strip()): 120 | allowed_email_addresses_list.append(email.strip()) 121 | else: 122 | print("Warning: ignoring invalid email " + email.strip()) 123 | 124 | allowed_email_domain_list = [] 125 | if allowed_email_domains: 126 | for domain in allowed_email_domains.split(","): 127 | allowed_email_domain_list.append(domain.strip()) 128 | 129 | # check if both email and domain list are empty and warn 130 | if not allowed_email_domain_list and not allowed_email_addresses_list: 131 | print( 132 | "Error: no allowed email addresses or domains provided. You will be unabled to get to your socket" 133 | ) 134 | sys.exit(1) 135 | 136 | else: 137 | cloudauth = False 138 | allowed_email_domain_list = [] 139 | allowed_email_addresses_list = [] 140 | 141 | if protected: 142 | if not username: 143 | print("--username required when using --protected") 144 | sys.exit(1) 145 | if not password: 146 | print("--password required when using --protected") 147 | sys.exit(1) 148 | 149 | authorization_header = get_auth_header() 150 | socket = new_socket( 151 | authorization_header, 152 | name, 153 | protected, 154 | str(username), 155 | str(password), 156 | str(type), 157 | cloudauth, 158 | allowed_email_addresses_list, 159 | allowed_email_domain_list, 160 | ) 161 | 162 | print_sockets([socket]) 163 | if protected: 164 | print_protected(username, password) 165 | if cloudauth: 166 | print_cloudauth(allowed_email_addresses_list, allowed_email_domain_list) 167 | 168 | 169 | @socket.command() 170 | @click.option("--socket_id", required=True, type=str) 171 | def delete(socket_id): 172 | authorization_header = get_auth_header() 173 | delete_socket(authorization_header, socket_id) 174 | print(f"Socket {socket_id} deleted") 175 | -------------------------------------------------------------------------------- /mysocketctl/ssh/__init__.py: -------------------------------------------------------------------------------- 1 | from mysocketctl.ssh.paramiko_client import Paramiko 2 | from mysocketctl.ssh.system import SystemSSH 3 | 4 | __all__ = [ 5 | SystemSSH, 6 | Paramiko, 7 | ] 8 | -------------------------------------------------------------------------------- /mysocketctl/ssh/paramiko_client.py: -------------------------------------------------------------------------------- 1 | import socket 2 | import select 3 | import sys 4 | import threading 5 | import os 6 | 7 | from paramiko import SSHClient 8 | from paramiko.client import AutoAddPolicy 9 | 10 | 11 | class LogOutput(threading.Thread): 12 | def __init__(self, sock, event): 13 | self._event = event 14 | self._sock = sock 15 | super().__init__(daemon=True) 16 | 17 | def run(self): 18 | while not self._event.is_set(): 19 | try: 20 | data = self._sock.recv(1024) 21 | except Exception as e: 22 | sys.stderr.write(f"Exception getting log from MySocket.io:\n{e}\n") 23 | sys.stderr.flush() 24 | break 25 | if not data: 26 | sys.stderr.write("Lost connection to MySocket.io, disconnecting...\n") 27 | sys.stderr.flush() 28 | # Let this fall through and attempt to reconnect 29 | break 30 | sys.stdout.write(data.decode("UTF-8")) 31 | sys.stdout.flush() 32 | self._event.set() 33 | self._sock.close() 34 | 35 | 36 | class ForwardingThread(threading.Thread): 37 | def __init__(self, chan, host, port): 38 | self._chan = chan 39 | self._host = host 40 | self._port = port 41 | super().__init__(daemon=True) 42 | 43 | def run(self): 44 | sock = socket.socket() 45 | try: 46 | sock.connect((self._host, self._port)) 47 | except Exception as e: 48 | sys.stderr.write( 49 | f"Forwarding request to {self._host}:{self._port} failed:\n{e}\n" 50 | ) 51 | sys.stderr.flush() 52 | return 53 | 54 | while True: 55 | try: 56 | r, w, x = select.select([sock, self._chan], [], []) 57 | if sock in r: 58 | data = sock.recv(1024) 59 | if len(data) == 0: 60 | break 61 | self._chan.send(data) 62 | if self._chan in r: 63 | data = self._chan.recv(1024) 64 | if len(data) == 0: 65 | break 66 | sock.send(data) 67 | except (EOFError, OSError) as e: 68 | sys.stderr.write( 69 | f"Disconnecting client, unable to send/recv data:\n{e}\n" 70 | ) 71 | sys.stderr.flush() 72 | break 73 | self._chan.close() 74 | sock.close() 75 | 76 | 77 | class Paramiko(object): 78 | def __init__(self): 79 | self.event = threading.Event() 80 | self.client = SSHClient() 81 | self.client.set_missing_host_key_policy(AutoAddPolicy) 82 | 83 | def is_enabled(self): 84 | return True 85 | 86 | def reverse_forward_tunnel(self, server_port, remote_host, remote_port): 87 | transport = self.client.get_transport() 88 | transport.set_keepalive(30) 89 | transport.request_port_forward("localhost", server_port) 90 | while not self.event.is_set(): 91 | # Wait 10 seconds for a new client 92 | chan = transport.accept(10) 93 | # Check if we have an exception in the transport 94 | e = transport.get_exception() 95 | # No client and no exception, do it again! 96 | if chan is None and e is None: 97 | continue 98 | elif e is not None: 99 | sys.stderr.write(f"Disconnected from MySocket.io tunnel:\n{e}\n") 100 | sys.stderr.flush() 101 | break 102 | thr = ForwardingThread(chan, remote_host, remote_port) 103 | thr.start() 104 | 105 | def connect( 106 | self, port, remote_bind_port, ssh_server, ssh_user, client_host="localhost" 107 | ): 108 | try: 109 | self.client.connect(ssh_server, username=ssh_user, timeout=10) 110 | except Exception as e: 111 | sys.stderr.write(f"Couldn't connect to MySocket.io server:\n{e}\n") 112 | sys.stderr.flush() 113 | self.client.close() 114 | return 115 | 116 | log_thread = LogOutput( 117 | self.client.invoke_shell(term=os.environ.get("TERM", "vt100")), self.event 118 | ) 119 | log_thread.start() 120 | 121 | # This enters an infinite loop, so anything important must happen before this 122 | try: 123 | self.reverse_forward_tunnel(remote_bind_port, client_host, int(port)) 124 | # This is to match the behavior we see in SystemSSH where you need to ^C twice 125 | except KeyboardInterrupt: 126 | pass 127 | except Exception as e: 128 | sys.stderr.write(f"Error setting up MySocket.io tunnel:\n{e}\n") 129 | sys.stderr.flush() 130 | 131 | self.client.close() 132 | self.event.set() 133 | log_thread.join() 134 | -------------------------------------------------------------------------------- /mysocketctl/ssh/system.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | 3 | 4 | class SystemSSH(object): 5 | def __init__(self, path="ssh"): 6 | self.ssh_path = path 7 | 8 | def is_enabled(self): 9 | try: 10 | subprocess.run( 11 | [self.ssh_path, "-V"], 12 | stdout=subprocess.DEVNULL, 13 | stderr=subprocess.DEVNULL, 14 | ) 15 | return True 16 | except FileNotFoundError: 17 | pass 18 | return False 19 | 20 | def connect( 21 | self, port, remote_bind_port, ssh_server, ssh_user, client_host="localhost" 22 | ): 23 | ssh_cmd = ( 24 | self.ssh_path, 25 | "-R", 26 | f"{remote_bind_port}:{client_host}:{port}", 27 | "-l", 28 | ssh_user, 29 | "-o", 30 | "StrictHostKeyChecking=no", 31 | "-o", 32 | "UserKnownHostsFile=/dev/null", 33 | "-o", 34 | "ExitOnForwardFailure=yes", 35 | "-o", 36 | "PasswordAuthentication=no", 37 | "-o", 38 | "ServerAliveInterval=30", 39 | "-o", 40 | "LogLevel=ERROR", 41 | str(ssh_server), 42 | ) 43 | 44 | cmd_output = subprocess.run( 45 | ssh_cmd, 46 | # stdout=subprocess.STDOUT, 47 | # stderr=subprocess.STDOUT, 48 | # universal_newlines=False, 49 | ) 50 | -------------------------------------------------------------------------------- /mysocketctl/tunnel.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | from mysocketctl.utils import * 4 | 5 | 6 | @click.group() 7 | def tunnel(): 8 | """Manage your tunnels""" 9 | pass 10 | 11 | 12 | def get_ssh_username(authorization_header): 13 | user_id = get_user_id() 14 | api_answer = requests.get(f"{api_url}user/{user_id}", headers=authorization_header) 15 | validate_response(api_answer) 16 | data = api_answer.json() 17 | return data["user_name"] 18 | 19 | 20 | def get_tunnels(authorization_header, socket_id): 21 | api_answer = requests.get( 22 | f"{api_url}socket/{socket_id}/tunnel", headers=authorization_header 23 | ) 24 | validate_response(api_answer) 25 | return api_answer.json() 26 | 27 | 28 | def get_tunnel_info(authorization_header, socket_id, tunnel_id): 29 | api_answer = requests.get( 30 | f"{api_url}socket/{socket_id}/tunnel/{tunnel_id}", 31 | headers=authorization_header, 32 | ) 33 | validate_response(api_answer) 34 | return api_answer.json() 35 | 36 | 37 | def new_tunnel(authorization_header, socket_id): 38 | 39 | params = {} 40 | api_answer = requests.post( 41 | f"{api_url}socket/{socket_id}/tunnel", 42 | data=json.dumps(params), 43 | headers=authorization_header, 44 | ) 45 | validate_response(api_answer) 46 | return api_answer.json() 47 | 48 | 49 | def delete_tunnel(authorization_header, socket_id, tunnel_id): 50 | api_answer = requests.delete( 51 | f"{api_url}socket/{socket_id}/tunnel/{tunnel_id}", 52 | headers=authorization_header, 53 | ) 54 | validate_response(api_answer) 55 | return api_answer 56 | 57 | 58 | def print_tunnels(tunnels, socket_id): 59 | table = PrettyTable( 60 | field_names=["socket_id", "tunnel_id", "tunnel_server", "relay_port"] 61 | ) 62 | table.align = "l" 63 | table.border = True 64 | 65 | for tunnel in tunnels: 66 | row = [ 67 | socket_id, 68 | tunnel["tunnel_id"], 69 | tunnel["tunnel_server"], 70 | tunnel["local_port"], 71 | ] 72 | table.add_row(row) 73 | print(table) 74 | 75 | 76 | @tunnel.command() 77 | @click.option("--socket_id", required=True, type=str) 78 | def ls(socket_id): 79 | authorization_header = get_auth_header() 80 | tunnels = get_tunnels(authorization_header, socket_id) 81 | print_tunnels(tunnels, socket_id) 82 | 83 | 84 | @tunnel.command() 85 | @click.option("--socket_id", required=True, type=str) 86 | def create(socket_id): 87 | authorization_header = get_auth_header() 88 | tunnel = new_tunnel(authorization_header, socket_id) 89 | print_tunnels([tunnel], socket_id) 90 | 91 | 92 | @tunnel.command() 93 | @click.option("--socket_id", required=True, type=str) 94 | @click.option("--tunnel_id", required=True, type=str) 95 | def delete(socket_id, tunnel_id): 96 | authorization_header = get_auth_header() 97 | delete_tunnel(authorization_header, socket_id, tunnel_id) 98 | print(f"Tunnel {tunnel_id} deleted") 99 | 100 | 101 | @tunnel.command() 102 | @click.option("--socket_id", required=True, type=str) 103 | @click.option("--tunnel_id", required=True, type=str) 104 | @click.option("--port", required=True, type=str) 105 | @click.option( 106 | "--host", 107 | hidden=False, 108 | type=str, 109 | default="127.0.0.1", 110 | help="Control where inbound traffic goes. Default localhost.", 111 | ) 112 | @click.option( 113 | "--engine", default="auto", type=click.Choice(("auto", "system", "paramiko")) 114 | ) 115 | def connect(socket_id, tunnel_id, port, engine, host): 116 | authorization_header = get_auth_header() 117 | tunnel = get_tunnel_info(authorization_header, socket_id, tunnel_id) 118 | ssh_username = get_ssh_username(authorization_header) 119 | ssh_server = "ssh.mysocket.io" 120 | 121 | ssh_tunnel(port, tunnel["local_port"], ssh_server, ssh_username, host, engine) 122 | -------------------------------------------------------------------------------- /mysocketctl/utils.py: -------------------------------------------------------------------------------- 1 | import click 2 | import json 3 | import os 4 | import requests 5 | import sys 6 | import jwt 7 | import time, sys 8 | 9 | from prettytable import PrettyTable 10 | 11 | from mysocketctl.ssh import SystemSSH, Paramiko 12 | 13 | api_url = "https://api.mysocket.io/" 14 | token_file = os.path.expanduser(os.path.join("~", ".mysocketio_token")) 15 | 16 | 17 | # For debug 18 | debug = False 19 | if "MYSOCKET_DEBUG" in os.environ: 20 | if os.environ["MYSOCKET_DEBUG"] == "TRUE": 21 | try: 22 | import http.client as http_client 23 | 24 | print("DEBUG ENABLED") 25 | except ImportError: 26 | print("unable to import http.client for debug") 27 | http_client.HTTPConnection.debuglevel = 10 28 | debug = True 29 | 30 | 31 | def get_user_id(): 32 | try: 33 | with open(token_file, "r") as myfile: 34 | 35 | for token in myfile: 36 | token = token.strip() 37 | try: 38 | data = jwt.decode(token, verify=False) 39 | except: 40 | print(f"barf on {token}") 41 | data = jwt.decode(token, verify=False) 42 | continue 43 | 44 | if "user_id" in data: 45 | return data["user_id"] 46 | 47 | except IOError: 48 | print("Could not read file:", token_file) 49 | print("Please login again") 50 | sys.exit(1) 51 | print(f"No valid token in {token_file}. Please login again") 52 | 53 | 54 | def get_auth_header(): 55 | try: 56 | with open(token_file, "r") as myfile: 57 | 58 | for token in myfile: 59 | token = token.strip() 60 | try: 61 | data = jwt.decode(token, verify=False) 62 | except: 63 | print("barf on {token}") 64 | data = jwt.decode(token, verify=False) 65 | continue 66 | 67 | authorization_header = { 68 | "x-access-token": token, 69 | "accept": "application/json", 70 | "Content-Type": "application/json", 71 | } 72 | return authorization_header 73 | except IOError: 74 | print("Could not read file:", token_file) 75 | print("Please login again") 76 | sys.exit(1) 77 | print(f"No valid token in {token_file}. Please login again") 78 | sys.exit(1) 79 | 80 | 81 | def validate_response(http_repsonse): 82 | if debug == True: 83 | print("Server responded with data:") 84 | print(http_repsonse.text) 85 | 86 | if http_repsonse.status_code == 200: 87 | return http_repsonse.status_code 88 | if http_repsonse.status_code == 204: 89 | return http_repsonse.status_code 90 | 91 | elif http_repsonse.status_code == 401: 92 | print("Login failed") 93 | else: 94 | print(http_repsonse.status_code, http_repsonse.text) 95 | 96 | sys.exit(1) 97 | 98 | 99 | def ssh_tunnel( 100 | port, remote_bind_port, ssh_server, ssh_username, host="localhost", engine="auto" 101 | ): 102 | print(f"\nConnecting to Server: {ssh_server}\n") 103 | 104 | while True: 105 | if engine == "auto": 106 | for ssh in [SystemSSH, Paramiko]: 107 | client = ssh() 108 | if ssh().is_enabled(): 109 | break 110 | elif engine == "system": 111 | client = SystemSSH() 112 | if not SystemSSH().is_enabled(): 113 | print("System SSH does not appear to be avaiable") 114 | return 115 | elif engine == "paramiko": 116 | client = Paramiko() 117 | 118 | try: 119 | client.connect(port, remote_bind_port, ssh_server, ssh_username, host) 120 | except KeyboardInterrupt: 121 | print("Bye") 122 | return 123 | 124 | try: 125 | print("Disconnected... Automatically reconnecting now..") 126 | print("Press ctrl-c to exit") 127 | time.sleep(2) 128 | except KeyboardInterrupt: 129 | print("Bye") 130 | return 131 | 132 | 133 | def print_sockets(sockets): 134 | table = PrettyTable() 135 | 136 | table.align = "l" 137 | table.border = True 138 | table.field_names = [ 139 | "socket_id", 140 | "dns_name", 141 | "port(s)", 142 | "type", 143 | "cloud auth", 144 | "name", 145 | ] 146 | for socket in sockets: 147 | ports_str = " ".join([str(elem) for elem in socket["socket_tcp_ports"]]) 148 | row = [ 149 | socket["socket_id"], 150 | socket["dnsname"], 151 | ports_str, 152 | socket["socket_type"], 153 | socket["cloud_authentication_enabled"], 154 | socket["name"], 155 | ] 156 | table.add_row(row) 157 | 158 | print(table) 159 | 160 | 161 | def print_protected(username, password): 162 | protectedtable = PrettyTable(field_names=["username", "password"]) 163 | protectedtable.align = "l" 164 | protectedtable.border = True 165 | protectedtable.add_row([str(username), str(password)]) 166 | print("\nProtected Socket, login details:") 167 | print(protectedtable) 168 | 169 | 170 | def print_cloudauth(allowed_email_addresses_list, allowed_email_domain_list): 171 | email_string = "\n".join(allowed_email_addresses_list) 172 | domain_string = "\n".join(allowed_email_domain_list) 173 | protectedtable = PrettyTable( 174 | field_names=["Allowed email addresses", "Allowed email domains"] 175 | ) 176 | protectedtable.align = "l" 177 | protectedtable.border = True 178 | protectedtable.add_row([str(email_string), str(domain_string)]) 179 | print("\nCloud Authentication, login details:") 180 | print(protectedtable) 181 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | setup( 4 | name="mysocketctl", 5 | packages=find_packages(), 6 | # include_package_data=True, 7 | license="Apache Software License", 8 | version="0.15", 9 | description="CLI tool for mysocket.io", 10 | long_description=open("README.rst").read(), 11 | url="https://github.com/mysocketio/mysocketctl", 12 | author="Andree Toonk", 13 | author_email="andree@mysocket.io", 14 | install_requires=[ 15 | "Click", 16 | "requests", 17 | "pyjwt==1.7.1", 18 | "prettytable", 19 | "paramiko", 20 | "validate_email", 21 | ], 22 | python_requires=">=3.6", 23 | classifiers=[ 24 | "Intended Audience :: Developers", 25 | "License :: OSI Approved :: Apache Software License", 26 | "Operating System :: MacOS :: MacOS X", 27 | "Operating System :: POSIX", 28 | "Programming Language :: Python", 29 | "Programming Language :: Python :: 3", 30 | "Programming Language :: Python :: 3 :: Only", 31 | "Programming Language :: Python :: 3.5", 32 | "Programming Language :: Python :: 3.6", 33 | "Programming Language :: Python :: 3.7", 34 | "Programming Language :: Python :: 3.8", 35 | "Programming Language :: Python :: 3.9", 36 | "Programming Language :: Python :: Implementation :: CPython", 37 | "Programming Language :: Python :: Implementation :: PyPy", 38 | "Topic :: Software Development :: Libraries :: Python Modules", 39 | ], 40 | entry_points={"console_scripts": ["mysocketctl = mysocketctl.mysocketcli:cli"]}, 41 | ) 42 | --------------------------------------------------------------------------------