#!/usr/bin/python # # Summary # ======= # # ssh user1%foo:2222+user2%bar # # This logs in a user2@bar via user1@foo (port 2222). All # authentication happens locally, not on remote hosts. Private keys # are only locally, not on foo. # # Note that port for the last host must be given using ssh -p, not a # :22 suffix to the host. This is because this script doesn't get # called in that case and can't handle it, and ssh itself doesn't # handle it. # # Description # =========== # # This is a helper to allow ssh to be used in a "via this host" # manner. The user runs "ssh foo+bar" and this effectively logs them # into bar by first logging into foo and running ssh there. # # This is useful when bar is not directly accessible from the local # machine, but foo is. Authentication (ssh private keys, password # entry) is done on the local machine, even for the login to bar: # there is no need to, for example, store ssh private keys on foo. # # Example use case: one has a set of virtual machines behind a # firewall or NAT, and only one of them is accessible from anywhere on # the Internet. This is called a jump host. You're expected to access # the other hosts via the jumphost. # # Setup # ===== # # User needs to setup their ssh on the local host (via ~/.ssh/config) # to run this when there is a plus sign in the target hostname: # # Host *+* # ProxyCommand $(sshvia %h %p) # # Also, this script should be executable and accessible via $PATH, or # else the config file should use the full path to this script. # # There is no need to configure anything on remote hosts, assuming # they have a working ssh already. # # Implementation # ============== # # This program outputs the magic ssh command line to connect to the # first target on the command line and to forward its stdin, stdout to # to that host. In other words, given the following command line: # # ssh foo+bar # # Each target separated by + has the following form: # # [user%]host[:port] # # Note the use of % instead of @ to get around the parsing that ssh # itself does. # # Using the above configuration, when the user runs "ssh foo+bar", # the following happens: # # * ssh matches "foo+bar" to the "Host *+*" section, and runs # the proxy command. # # * The proxy command invokes "ssh foo -W bar:22", which means # it connects to foo and forwards the local stdin/stdout to # bar's ssh port. The forwarding happens over the secure link # to foo. # # * The ssh started by the user uses the proxy command's # stdin/stdout to talk to what it thinks is the "foo+bar" # server. In other words, instead of making a TCP connection # to the host named "foo+bar" and sets up an encrypted channel # over that TCP connection, it uses the stdin/stdout of the # proxy command instead. # # In other words, the ssh client started by the user talks to # the ssh server on bar, but the communication goes via the # secure channel to foo. # # Additionally, this script gives ssh options to set the remote user # and port for the first host in a chain. import re import sys import cliapp def debug(msg): # Set condition below to true to get debug output to stderr. if False: sys.stderr.write('SSHVIA DEBUG: {}\n'.format(msg)) def panic(msg): sys.stderr.write('SSHVIA ERROR: {}\n'.format(msg)) sys.exit(1) def extract_last_target(full_target): # Input is foo+bar. Return foo, bar. i = full_target.rfind('+') if i == -1: return None, full_target return full_target[:i], full_target[i+1:] def extract_targets(full_target): targets = [] remaining = full_target while remaining: remaining, target = extract_last_target(remaining) targets.append(target) return list(reversed(targets)) def parse_target(target): # Input is of the form: [user%]host[:port] # Return user, host, port (None for user, port if missing). percent = target.find('%') colon = target.find(':') user = None port = None if percent == -1 and colon == -1: # It's just the host. host = target elif percent >= 0 and colon >= 0 and percent < colon: # It's user%host:port user = target[:percent] host = target[percent+1:colon] port = target[colon+1:] elif percent >= 0 and colon >= 0 and percent >= colon: # it's an error panic('Do not understand %r' % target) elif percent >= 0: # It's user%host user = target[:percent] host = target[percent+1:] elif colon >= 0: # It's host:port host = target[:colon] port = target[colon+1:] else: # it's a programming error panic('Confused by %r' % target) return user, host, port # ssh parses its own command line, and extracts target host, target # port, and target (or remote) username. However, since ssh doesn't # actually understand the + syntax itself, it gets things muddled: it # parses user1@host1:8888+user2@host2:2222 in ways that will confuse # users. Thus, we want the user to use % instead of @ so that ssh # doesn't get in the way. # # ssh also allows the user to provide the remote user with the -l # option, but we do not need to care about that, since it'll only be # used to log into the last host in the chain. # # We do need to care about ports. ssh's -p option may be used for the # last host in the chain, but every host in the chain may use : to # separate a port number. # Parse command line. We expect the full host (%h) and the port for # thst last host in the chain (%p). That port may be the default 22, # and will be overriden by the post in the full host if given. debug('argv: %r' % sys.argv) full_target, last_target_default_port = sys.argv[1:] targets = extract_targets(full_target) debug('targets: %r' % targets) if len(targets) < 2: panic('ERROR: Must be called with foo+bar syntax!') first_target = targets[0] middle_targets = targets[1:-1] last_target = targets[-1] # Extract user from first target. first_user, first_host, first_port = parse_target(first_target) # Extract port from last target. Replace with default if missing. last_user, last_host, last_port = parse_target(last_target) if last_port is None: last_port = last_target_default_port # Set up the argv array. argv = ['ssh', '-W', '{}:{}'.format(last_host, last_port)] if first_user is not None: argv.extend(['-l', first_user]) if first_port is not None: argv.extend(['-p', first_port]) all_but_last = [first_host] + middle_targets argv.append('+'.join(all_but_last)) # Shell-quote everything in argv, to avoid shell metacharacters from # causing trouble. argv = [cliapp.shell_quote(x) for x in argv] # Output the command. debug('result: %r' % argv) sys.stdout.write(' '.join(argv) + '\n')