summaryrefslogtreecommitdiff
path: root/sshvia
blob: ea94018ae499188c718e31e295e9844510fe4515 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
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')