summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--NEWS2
-rwxr-xr-xsshvia219
2 files changed, 220 insertions, 1 deletions
diff --git a/NEWS b/NEWS
index 0ff4f84..58e6aed 100644
--- a/NEWS
+++ b/NEWS
@@ -9,7 +9,7 @@ I'll assume I'm the only user.
Version 1.20160229.2+git, not yet released
------------------------------------------
-* Add `mbox2maildir`.
+* Add `mbox2maildir` and `sshvia`.
Version 1.20160229.2, released 2016-02-29
----------------------------------------
diff --git a/sshvia b/sshvia
new file mode 100755
index 0000000..ea94018
--- /dev/null
+++ b/sshvia
@@ -0,0 +1,219 @@
+#!/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')