├── .gitignore ├── LICENSE ├── MANIFEST.in ├── README.rst ├── requirements.txt ├── setup.py ├── sshmenu.rb └── sshmenu ├── __init__.py ├── __main__.py └── sshmenu.py /.gitignore: -------------------------------------------------------------------------------- 1 | ### Python template 2 | # Byte-compiled / optimized / DLL files 3 | __pycache__/ 4 | *.py[cod] 5 | *$py.class 6 | 7 | # C extensions 8 | *.so 9 | 10 | # Distribution / packaging 11 | .Python 12 | env/ 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .coverage 43 | .coverage.* 44 | .cache 45 | nosetests.xml 46 | coverage.xml 47 | *,cover 48 | .hypothesis/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | 58 | # Flask instance folder 59 | instance/ 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # IPython Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # dotenv 80 | .env 81 | 82 | # virtualenv 83 | venv/ 84 | ENV/ 85 | 86 | # Spyder project settings 87 | .spyderproject 88 | 89 | # Rope project settings 90 | .ropeproject 91 | 92 | ### JetBrains 93 | .idea 94 | 95 | ### OSX template 96 | .DS_Store 97 | .AppleDouble 98 | .LSOverride 99 | 100 | # Icon must end with two \r 101 | Icon 102 | 103 | # Thumbnails 104 | ._* 105 | 106 | # Files that might appear in the root of a volume 107 | .DocumentRevisions-V100 108 | .fseventsd 109 | .Spotlight-V100 110 | .TemporaryItems 111 | .Trashes 112 | .VolumeIcon.icns 113 | 114 | # Directories potentially created on remote AFP share 115 | .AppleDB 116 | .AppleDesktop 117 | Network Trash Folder 118 | Temporary Items 119 | .apdisk 120 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Michael Meyer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include requirements.txt README.rst LICENSE 2 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | sshmenu 2 | ------- 3 | ``sshmenu`` is a simple tool for connecting to remote hosts via ssh. Great if you have trouble remembering ip addresses, hostnames, or usernames. 4 | 5 | This tool works by using Python's ``os.execvp(...)``, which will replace the current process (python) with ``ssh`` to create a seamless transition. 6 | 7 | .. image:: https://i.imgur.com/LGrrENa.gif 8 | 9 | 10 | Quick Setup 11 | ----------- 12 | Tested working on macOS High Sierra (10.13.1) and Ubuntu Trusty Tahr (14.04), Xenial Xerus (16.04) 13 | 14 | 15 | **macOS** 16 | 17 | .. code-block:: bash 18 | 19 | brew install https://raw.githubusercontent.com/mmeyer724/sshmenu/master/sshmenu.rb 20 | sshmenu 21 | 22 | **Linux** 23 | 24 | .. code-block:: bash 25 | 26 | pip3 install sshmenu 27 | sshmenu 28 | 29 | **Development** 30 | 31 | .. code-block:: bash 32 | 33 | git clone https://github.com/mmeyer724/sshmenu.git 34 | cd sshmenu 35 | pip3 install -r requirements.txt 36 | python3 -m sshmenu 37 | 38 | Configuration 39 | ------------- 40 | On first run an example configuration file will be created for you, along with the path. For reference, I've added this information here as well. 41 | 42 | **OS X** 43 | 44 | .. code-block:: bash 45 | 46 | nano ~/Library/Application\ Support/sshmenu/config.json 47 | 48 | **Linux** 49 | 50 | .. code-block:: bash 51 | 52 | nano ~/.config/sshmenu/config.json 53 | 54 | **Default contents** 55 | 56 | .. code-block:: json 57 | 58 | { 59 | "targets": [ 60 | { 61 | "host": "user@example-machine.local", 62 | "friendly": "This is an example target", 63 | "options": [] 64 | }, 65 | { 66 | "command": "mosh", 67 | "host": "user@example-machine.local", 68 | "friendly": "This is an example target using mosh", 69 | "options": [] 70 | } 71 | ] 72 | } 73 | 74 | You can specify additional command line options (see `man ssh`) as follows: 75 | 76 | .. code-block:: json 77 | 78 | { 79 | "targets": [ 80 | { 81 | "host": "user@example-machine.local", 82 | "friendly": "An example target listening non-standard port and verbose flag", 83 | "options" : [ 84 | "-p443", 85 | "-v" 86 | ] 87 | } 88 | ] 89 | } 90 | 91 | Todo 92 | ---- 93 | * Automatically ask to place your ``~/.ssh/id_rsa.pub`` into the remote host's ``~/.ssh/authorized_keys`` 94 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | args==0.1.0 2 | clint==0.5.1 3 | readchar==0.7 4 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | with open('requirements.txt') as requirements_file: 4 | required = requirements_file.read().splitlines() 5 | 6 | setup( 7 | name='sshmenu', 8 | version='0.0.5', 9 | license='MIT', 10 | description='Command line SSH menu and helper utility', 11 | long_description=open('README.rst').read(), 12 | author='Michael Meyer', 13 | author_email='michael@meyer.io', 14 | url='https://github.com/mmeyer724/sshmenu', 15 | packages=['sshmenu'], 16 | install_requires=required, 17 | classifiers=[ 18 | 'Environment :: Console', 19 | 'Intended Audience :: Developers', 20 | 'Natural Language :: English', 21 | 'License :: OSI Approved :: MIT License', 22 | 'Programming Language :: Python :: 3' 23 | ], 24 | entry_points={ 25 | 'console_scripts': ['sshmenu=sshmenu.sshmenu:main'] 26 | } 27 | ) 28 | -------------------------------------------------------------------------------- /sshmenu.rb: -------------------------------------------------------------------------------- 1 | class Sshmenu < Formula 2 | include Language::Python::Virtualenv 3 | 4 | homepage "https://github.com/mmeyer724/sshmenu" 5 | url "https://github.com/mmeyer724/sshmenu/archive/0.0.5.tar.gz" 6 | sha256 "f7c5c4e36c6e2e553fb3130a8c08761fb03628ac17d0f683a93bb2959fb2648f" 7 | 8 | depends_on "python3" 9 | 10 | resource "args" do 11 | url "https://files.pythonhosted.org/packages/e5/1c/b701b3f4bd8d3667df8342f311b3efaeab86078a840fb826bd204118cc6b/args-0.1.0.tar.gz" 12 | sha256 "a785b8d837625e9b61c39108532d95b85274acd679693b71ebb5156848fcf814" 13 | end 14 | 15 | resource "clint" do 16 | url "https://files.pythonhosted.org/packages/3d/b4/41ecb1516f1ba728f39ee7062b9dac1352d39823f513bb6f9e8aeb86e26d/clint-0.5.1.tar.gz" 17 | sha256 "05224c32b1075563d0b16d0015faaf9da43aa214e4a2140e51f08789e7a4c5aa" 18 | end 19 | 20 | resource "readchar" do 21 | url "https://files.pythonhosted.org/packages/61/a9/d552ab5bb2978b609a0acc917427fb0230ac923d92e32b817ef79908f6e3/readchar-0.7.tar.gz" 22 | sha256 "c3354162894634ff6a29a06a1cd04c92522f32b7bc6c17e247cf3bee27ee914c" 23 | end 24 | 25 | def install 26 | virtualenv_create(libexec, "python3") 27 | virtualenv_install_with_resources 28 | end 29 | 30 | test do 31 | false 32 | end 33 | end -------------------------------------------------------------------------------- /sshmenu/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mmeyer724/sshmenu/392121e206c4ad252311cc90d59979e854671877/sshmenu/__init__.py -------------------------------------------------------------------------------- /sshmenu/__main__.py: -------------------------------------------------------------------------------- 1 | """Called when launched with python -m sshmenu""" 2 | 3 | from .sshmenu import main 4 | main() 5 | -------------------------------------------------------------------------------- /sshmenu/sshmenu.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import json 3 | import os 4 | import readchar 5 | import sys 6 | import time 7 | import readline 8 | 9 | from subprocess import call, Popen, PIPE 10 | from clint import resources 11 | from clint.textui import puts, colored 12 | 13 | targets = [] 14 | config_name = '' 15 | 16 | # Time to sleep between transitions 17 | TRANSITION_DELAY_TIME = 0.5 18 | 19 | NUMBER_ENTRY_EXPIRE_TIME = 0.75 20 | 21 | 22 | def main(): 23 | global config_name 24 | 25 | # Check arguments 26 | parser = argparse.ArgumentParser(prog='sshmenu', 27 | description='A convenient tool for bookmarking ' 28 | 'hosts and connecting to them via ssh.') 29 | parser.add_argument('-c', '--configname', default='config', help='Specify an alternate configuration name.') 30 | args = parser.parse_args() 31 | 32 | # Get config name 33 | config_name = '{configname}.json'.format(configname=args.configname) 34 | 35 | # First parameter is 'company' name, hence duplicate arguments 36 | resources.init('sshmenu', 'sshmenu') 37 | 38 | # If the config file doesn't exist, create an example config 39 | if resources.user.read(config_name) is None: 40 | example_config = { 41 | 'targets': [ 42 | { 43 | 'host': 'user@example-machine.local', 44 | 'friendly': 'This is an example target', 45 | 'options': [] 46 | }, 47 | { 48 | 'command': 'mosh', 49 | 'host': 'user@example-machine.local', 50 | 'friendly': 'This is an example target using mosh', 51 | 'options': [] 52 | } 53 | ], 54 | 'saved_last_chosen_index': 0 55 | } 56 | resources.user.write(config_name, json.dumps(example_config, indent=4)) 57 | 58 | update_targets() 59 | display_menu() 60 | 61 | 62 | def get_terminal_height(): 63 | # Return height of terminal as int 64 | tput = Popen(['tput', 'lines'], stdout=PIPE) 65 | height, stderr = tput.communicate() 66 | 67 | return int(height) 68 | 69 | 70 | def display_help(): 71 | # Clear screen and show the help text 72 | call(['clear']) 73 | puts(colored.cyan('Available commands (press any key to exit)')) 74 | 75 | puts(' enter - Connect to your selection') 76 | puts(' crtl+c | q - Quit sshmenu') 77 | puts(' k (up) - Move your selection up') 78 | puts(' j (down) - Move your selection down') 79 | puts(' h - Show help menu') 80 | puts(' c - Create new connection') 81 | puts(' d - Delete connection') 82 | puts(' e - Edit connection') 83 | puts(' + (plus) - Move connection up') 84 | puts(' - (minus) - Move connection down') 85 | 86 | # Hang until we get a keypress 87 | readchar.readkey() 88 | 89 | 90 | def connection_create(): 91 | call(['clear']) 92 | puts(colored.cyan('Create new connection entry')) 93 | puts('') 94 | 95 | host = input('Hostname (user@machine): ') 96 | 97 | if host is '': 98 | puts('') 99 | puts('Nothing done') 100 | time.sleep(TRANSITION_DELAY_TIME) 101 | return 102 | 103 | friendly = input('Description []: ') 104 | command = input('Command [ssh]: ') 105 | options = input('Command Options []: ') 106 | 107 | # Set the defaults if our input was empty 108 | command = 'ssh' if command == '' else command 109 | options = [] if options == '' else options.split() 110 | 111 | # Append the new target to the config 112 | config = json.loads(resources.user.read(config_name)) 113 | config['targets'].append({'command': command, 'host': host, 'friendly': friendly, 'options': options}) 114 | 115 | # Save the new config 116 | resources.user.write(config_name, json.dumps(config, indent=4)) 117 | update_targets() 118 | 119 | puts('') 120 | puts('New connection added') 121 | time.sleep(TRANSITION_DELAY_TIME) 122 | 123 | 124 | def connection_edit(selected_target): 125 | call(['clear']) 126 | puts(colored.cyan('Editing connection %s' % targets[selected_target]['host'])) 127 | puts('') 128 | 129 | target = targets[selected_target] 130 | 131 | while True: 132 | host = input_prefill('Hostname: ', target['host']) 133 | if host is not '': 134 | break 135 | 136 | friendly = input_prefill('Description: ', target['friendly']) 137 | command = input_prefill('Command [ssh]: ', 'ssh' if not target.get('command') else target['command']) 138 | options = input_prefill('Options []: ', ' '.join(target['options'])) 139 | 140 | # Set the defaults if our input was empty 141 | command = 'ssh' if command == '' else command 142 | options = [] if options == '' else options.split() 143 | 144 | # Delete the old entry insert the edited one in its place 145 | config = json.loads(resources.user.read(config_name)) 146 | del config['targets'][selected_target] 147 | config['targets'].insert(selected_target, 148 | {'command': command, 'host': host, 'friendly': friendly, 'options': options}) 149 | 150 | resources.user.write(config_name, json.dumps(config, indent=4)) 151 | update_targets() 152 | 153 | puts('') 154 | puts('Changes saved') 155 | time.sleep(TRANSITION_DELAY_TIME) 156 | 157 | 158 | def connection_delete(selected_target): 159 | call(['clear']) 160 | puts(colored.red('Delete connection entry for %s' % targets[selected_target]['host'])) 161 | puts('') 162 | 163 | while True: 164 | response = input('Are you sure you want to delete this connection [yes|NO]: ').lower() 165 | 166 | if response == 'no' or response == 'n' or response == '': 167 | puts('') 168 | puts('Nothing done') 169 | break 170 | 171 | if response == 'yes': 172 | config = json.loads(resources.user.read(config_name)) 173 | del config['targets'][selected_target] 174 | 175 | resources.user.write(config_name, json.dumps(config, indent=4)) 176 | update_targets() 177 | 178 | puts('') 179 | puts('Connection deleted') 180 | break 181 | 182 | time.sleep(TRANSITION_DELAY_TIME) 183 | 184 | 185 | def connection_move_up(selected_target): 186 | config = json.loads(resources.user.read(config_name)) 187 | config['targets'].insert(selected_target - 1, config['targets'].pop(selected_target)) 188 | 189 | resources.user.write(config_name, json.dumps(config, indent=4)) 190 | update_targets() 191 | 192 | 193 | def connection_move_down(selected_target): 194 | config = json.loads(resources.user.read(config_name)) 195 | config['targets'].insert(selected_target + 1, config['targets'].pop(selected_target)) 196 | 197 | resources.user.write(config_name, json.dumps(config, indent=4)) 198 | update_targets() 199 | 200 | 201 | def update_targets(): 202 | global targets 203 | 204 | config = json.loads(resources.user.read(config_name)) 205 | if 'targets' in config: 206 | targets = config['targets'] 207 | 208 | 209 | def get_last_selected_target_index(): 210 | config = json.loads(resources.user.read(config_name)) 211 | return config.get('saved_last_chosen_index', 0) 212 | 213 | 214 | def save_last_selected_target_index(index: int): 215 | config = json.loads(resources.user.read(config_name)) 216 | config['saved_last_chosen_index'] = index 217 | resources.user.write(config_name, json.dumps(config, indent=4)) 218 | 219 | 220 | def display_menu(): 221 | # Save current cursor position so we can overwrite on list updates 222 | call(['tput', 'clear', 'sc']) 223 | 224 | # Keep track of currently selected target, read from config file the last chosen one to select 225 | selected_target = get_last_selected_target_index() 226 | 227 | # Support input of long numbers 228 | number_buffer = [] 229 | 230 | # Store time of last number that was entered 231 | time_last_digit_pressed = round(time.time()) 232 | 233 | # Get initial terminal height 234 | terminal_height = get_terminal_height() 235 | 236 | # Set initial visible target range. 237 | # Subtract 2 because one line is used by the instructions, 238 | # and one line is always empty at the bottom. 239 | visible_target_range = range(terminal_height - 2) 240 | 241 | while True: 242 | # Return to the saved cursor position 243 | call(['tput', 'clear', 'rc']) 244 | 245 | # We need at least one target for our UI to make sense 246 | num_targets = len(targets) 247 | if num_targets <= 0: 248 | puts(colored.red('Whoops, you don\'t have any connections defined in your config!')) 249 | puts('') 250 | puts('Press "c" to create a new connection') 251 | else: 252 | puts(colored.cyan('Select a target (press "h" for help)')) 253 | 254 | # Determine the longest host 255 | longest_host = -1 256 | longest_line = -1 257 | for index, target in enumerate(targets): 258 | length = len(target['host']) 259 | # Check host length 260 | if length > longest_host: 261 | longest_host = length 262 | 263 | # Generate description and check line length 264 | for index, target in enumerate(targets): 265 | desc = target['host'].ljust(longest_host) + ' | ' + target['friendly'] 266 | target['desc'] = desc 267 | line_length = len(desc) 268 | if line_length > longest_line: 269 | longest_line = line_length 270 | 271 | # Recalculate visible targets based on selected_target 272 | if selected_target > max(visible_target_range): 273 | visible_start = selected_target - terminal_height + 3 274 | visible_end = selected_target + 1 275 | visible_target_range = range(visible_start, visible_end) 276 | elif selected_target < min(visible_target_range): 277 | visible_start = selected_target 278 | visible_end = selected_target + terminal_height - 2 279 | visible_target_range = range(visible_start, visible_end) 280 | 281 | # Make sure our selected target is not higher than possible 282 | # This can happen if you delete the last target 283 | selected_target = selected_target if selected_target < num_targets else 0 284 | 285 | # Used to pad out the line numbers so that we can keep everything aligned 286 | num_digits = len(str(num_targets)) 287 | digits_format_specifier = '%' + str(num_digits) + 'd' 288 | 289 | # Print items 290 | for index, target in enumerate(targets): 291 | # Only print the items that are within the visible range. 292 | # Due to lines changing their position on the screen when scrolling, 293 | # we need to redraw the entire line + add padding to make sure all 294 | # traces of the previous line are erased. 295 | if index in visible_target_range: 296 | line = (digits_format_specifier + '. %s ') % (index + 1, target['desc'].ljust(longest_line)) 297 | if index == selected_target: 298 | puts(colored.green(' -> %s' % line)) 299 | else: 300 | puts(colored.white(' %s' % line)) 301 | 302 | # Hang until we get a keypress 303 | key = readchar.readkey() 304 | 305 | if key == readchar.key.UP or key == 'k' and num_targets > 0: 306 | # Ensure the new selection would be valid & reset number input buffer 307 | if (selected_target - 1) >= 0: 308 | selected_target -= 1 309 | number_buffer = [] 310 | 311 | elif key == readchar.key.DOWN or key == 'j' and num_targets > 0: 312 | # Ensure the new selection would be valid & reset number input buffer 313 | if (selected_target + 1) <= (num_targets - 1): 314 | selected_target += 1 315 | number_buffer = [] 316 | 317 | elif key == 'g': 318 | # Go to top & reset number input buffer 319 | selected_target = 0 320 | number_buffer = [] 321 | 322 | elif key == 'G': 323 | # Go to bottom & reset number input buffer 324 | selected_target = num_targets - 1 325 | number_buffer = [] 326 | 327 | # Check if key is a number 328 | elif key in map(lambda x: str(x), range(10)): 329 | current_time = time.time() 330 | if current_time - time_last_digit_pressed >= NUMBER_ENTRY_EXPIRE_TIME: 331 | number_buffer = [] 332 | time_last_digit_pressed = current_time 333 | 334 | number_buffer += key 335 | new_selection = int(''.join(number_buffer)) 336 | 337 | # If the new target is invalid, just keep the previously selected target instead 338 | if num_targets >= new_selection > 0: 339 | selected_target = new_selection - 1 340 | 341 | elif key == readchar.key.ENTER and num_targets > 0: 342 | # Save the chosen index into the config 343 | save_last_selected_target_index(selected_target) 344 | 345 | # For cleanliness clear the screen 346 | call(['tput', 'clear']) 347 | 348 | target = targets[selected_target] 349 | 350 | # Check if there is a custom command for this target 351 | if 'command' in target.keys(): 352 | command = target['command'] 353 | else: 354 | command = 'ssh' 355 | 356 | # Arguments to the child process should start with the name of the command being run 357 | args = [command] + target.get('options', []) + [target['host']] 358 | try: 359 | # After this line, ssh will replace the python process 360 | os.execvp(command, args) 361 | except FileNotFoundError: 362 | sys.exit('Command not found: {commandname}'.format(commandname=command)) 363 | 364 | elif key == 'h': 365 | display_help() 366 | 367 | elif key == 'c': 368 | connection_create() 369 | 370 | elif key == 'd' and num_targets > 0: 371 | connection_delete(selected_target) 372 | 373 | elif key == 'e' and num_targets > 0: 374 | connection_edit(selected_target) 375 | 376 | elif key == '-' and num_targets > 0: 377 | if selected_target < num_targets: 378 | connection_move_down(selected_target) 379 | selected_target += 1 380 | 381 | elif key == '+' and num_targets > 0: 382 | if selected_target > 0: 383 | connection_move_up(selected_target) 384 | selected_target -= 1 385 | 386 | elif key == readchar.key.CTRL_C or key == 'q': 387 | exit(0) 388 | 389 | 390 | def input_prefill(prompt, text): 391 | def hook(): 392 | readline.insert_text(text) 393 | readline.redisplay() 394 | 395 | readline.set_pre_input_hook(hook) 396 | result = input(prompt) 397 | readline.set_pre_input_hook() 398 | return result 399 | --------------------------------------------------------------------------------