-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathssh-legion
executable file
·359 lines (314 loc) · 11.5 KB
/
ssh-legion
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
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
#!/usr/bin/env bash
#
# Creates an SSH Tunnel to the specified server.
reset=$'\033[0m' # Text Reset
cyan=$'\033[0;36m' # Cyan
yellow=$'\033[0;33m' # yellow
red=$'\033[0;31m' # red
# taken from sysexits.h
# Used as a return code for when ssh-tunnel fails because the port is in use
EX_UNAVAILABLE='69'
# Adds SSH options to the given array.
function ssh-global-options() {
# Bash 4.3+ nameref https://www.gnu.org/software/bash/manual/html_node/Shell-Parameters.html
local -n arr="$1"
if [ -r "${config}" ]; then
arr+=("-F" "${config}")
fi
}
function log-info() {
echo "${cyan}Info: ${1}${reset}" >&2
}
function log-warning() {
echo "${yellow}Warning: ${1}${reset}" >&2
}
function log-error() {
echo "${red}Error: ${1}${reset}" >&2
}
function ssh-check() {
local CONNECTION_TIME="1s"
local RETRY_ON_SUCCESS_EXIT="0"
# run ssh-legion for 1s only, then exit
if ssh-legion "$CONNECTION_TIME" "$RETRY_ON_SUCCESS_EXIT"; then
log-info "Testing SSH tunnel connection to ${destination} succeeded."
else
log-error "Testing SSH tunnel connection to ${destination} failed."
return 1
fi
}
# Main function for ssh-legion
#
# Args:
# - CONNECTION_TIME Passed to `ssh-tunnel`
# - RETRY_ON_SUCCESS_EXIT If set to 1 (default), automatically restart the SSH tunnel should it ever
# exit. Normally, it will only restart if it exits due to the port being
# used already.
function ssh-legion() {
local CONNECTION_TIME="${1-""}"
local RETRY_ON_SUCCESS_EXIT="${2-"1"}"
if [ -f /etc/machine-id ]; then
MACHINE_ID="$(cat /etc/machine-id)"
elif [ -f /var/lib/dbus/machine-id ]; then
MACHINE_ID=$(cat /var/lib/dbus/machine-id)
else
log-info 'No dbus machine-id or /etc/machine-id found, creating one from mac addresses.'
mac="$(cat /sys/class/net/*/address | head -1)"
MACHINE_ID="$(( 0x"${mac//:/''}" ))"
fi
SALTED_MACHINE_ID_HEX=$(
echo "${MACHINE_ID}_nqminds-random-fixed-saltWW19bNQ5VI2xkM" | openssl dgst -hex -sha512-256 | grep -oE '[[:xdigit:]]{64}'
)
SALTED_MACHINE_ID=$(\
echo "${MACHINE_ID}_nqminds-random-fixed-saltWW19bNQ5VI2xkM" \
| openssl dgst -binary -sha512-256 | openssl base64 -A )
# default tunnel port in 49152-65535 fixed on machine id
PORT=$((0x$SALTED_MACHINE_ID_HEX%16383 + 49152))
# see https://tools.ietf.org/html/rfc3548#page-6 for base64 in filenames
SALTED_MACHINE_ID=${SALTED_MACHINE_ID//"/"/"_"}
SALTED_MACHINE_ID=${SALTED_MACHINE_ID//"+"/"-"}
MYNAME="$(id -u -n)@$(uname -n)"
log-info "Creating tunnel to ${destination}, use CTRL+C to cancel."
# seed bash's random number generator with the initial port
# this means that ssh-legion will always try to use the same ports
RANDOM="$PORT"
while true; do
if ! ssh-tunnel "$PORT" "$host_port" "$destination" "$CONNECTION_TIME"; then
# RemotePortForwarding Failed!
# Port already in use
log-warning "Tunneling from port ${PORT} ${destination} failed, retrying with a random port."
# Try using random ports until one works.
# we want a port between 1024 and 65535, range of 64511 numbers
PORT=$((RANDOM % 64511 + 1024))
# make sure the PORT isn't in the list of IANA registered ports
while grep "${PORT}/tcp" /etc/services; do
# find a new random port
PORT=$((RANDOM % 64511 + 1024))
done
elif [ "${RETRY_ON_SUCCESS_EXIT}" -eq 1 ]; then
# ssh closed for some other reason, try again in a second
log-warning "SSH connection closed, restarting in a few seconds ..."
else
# exit ssh-legion
break
fi
sleep 1
done
}
# Joins an array by the given delimiter
#
# ```bash
# array=(a b c)
# csv="$(join-array ',' "${array[@]}")"
# echo "$csv" # prints 'a,b,c'
# ```
#
# Args:
# - delimiter The delimiter, can be multiple chars
# - ...array The arra The array
#
# Adapted from Nicholas Sushkin (trivial/CC BY-SA 4.0) https://stackoverflow.com/a/17841619/10149169
function join-array() {
local delimiter="$1"
local first_array_val="$2"
shift 2
printf %s "$first_array_val" "${@/#/$delimiter}"
}
# Creates the SSH Tunnel to the server
#
# If the ssh-tunnel ever outputs `"Error: remote port forwarding failed for listen port"`,
# then this function returns with `EX_UNAVAILABLE`.
# If this happens, you should try creating the SSH tunnel again with a different port.
#
# Otherwise, it returns with `0` (success), even the error is becasue ssh failed for some other reason
#
# Args:
# - PORT The server port to tunnel to this device.
# - HOST_PORT The port on this device to tunnel to.
# - DESTINATION The server name in your ssh config.
# - CONNECTION_TIME How long to keep the SSH tunnel connection open for
# We recommend `infinity` normally (assuming your server supports GNU sleep)
function ssh-tunnel() {
local PORT="$1"
local HOST_PORT="$2"
local DESTINATION="$3"
local CONNECTION_TIME="${4:-"infinity"}"
# this data will be stored in the ~/connections/... file on the reverse SSH server
# this data will only be updated when the tunnel restarts (so might be weeks)
local INFO
INFO="$(
cat << EOF
${MYNAME} tunneled to localhost:${PORT} on $(date -u +'%Y-%m-%dT%H:%M:%SZ')
Salted Machine ID: ${SALTED_MACHINE_ID}
${MYNAME}:~$ ip a \n $(ip a)
EOF
)"
local COMPRESSED_INFO
COMPRESSED_INFO="$(echo -n "$INFO" | gzip | base64 -w0)"
FNAME="${MYNAME}:${PORT}"
# These commands will be combined with `\n` and run on the Reverse SSH server
local SERVER_COMMANDS
# shellcheck disable=SC2016
SERVER_COMMANDS=(
"set -e" # error if any command fails
"mkdir -p ~/connections"
"FNAME=${FNAME@Q}" # FNAME is loaded from create-tunnel!
"SALTED_MACHINE_ID=${SALTED_MACHINE_ID@Q}"
'if [ -f ~/connections/"${FNAME}" ] && ! grep "Machine ID: ${SALTED_MACHINE_ID}" < ~/connections/"${FNAME}"; then \
FNAME="${FNAME}+${SALTED_MACHINE_ID}"; fi'
"echo -n ${COMPRESSED_INFO@Q} | base64 -d | gunzip > ~/connections/\${FNAME}"
'rm -f ~/connections/"${FNAME}+disconnected"'
"sleep ${CONNECTION_TIME@Q} &" # Run sleep forever in background until killed by trap
'SLEEP_PID="$!"'
'on_disconnect() {
mv ~/connections/"${FNAME}" ~/connections/"${FNAME}+disconnected"
echo "Disconnected at $(date -u "+%Y-%m-%dT%H:%M:%SZ")" >> ~/connections/"${FNAME}+disconnected"
local SLEEP_PID="$1"
if [ -n "${SLEEP_PID}" -a -d "/proc/${SLEEP_PID}" ]; then
# kill `sleep ${CONNECTION_TIME@Q}` command if it exists
kill -SIGINT "$SLEEP_PID"
fi
}'
'trap "on_disconnect ${SLEEP_PID}" EXIT'
'wait "${SLEEP_PID}"' # Keep SSH active until sleep command or shell is killed
# shellcheck enable=SC2016
)
server_commands_joined="$(join-array $'\n' "${SERVER_COMMANDS[@]}")"
# keep on running ssh tunnel until we don't have a listen port failure
# putting localhost:${PORT} means the port is only accessible from localhost.
# putting *:${PORT}, means the port is accessible from all interfaces, ie
# going www.server.com:8080 will connect to the client:22
ssh_options=(
# description of ssh flags at https://manpages.ubuntu.com/manpages/jammy/man1/ssh.1.html
"-tt" # force tty (so that `trap` works properly to monitor the tunnel)
"-o" "RemoteForward=localhost:${PORT} localhost:${HOST_PORT}"
# SSH will terminate connection if SSH tunnel fails
"-o" "ExitOnForwardFailure=yes"
)
ssh-global-options ssh_options
# pipe stderr through grep to see if SSH fails
# also pipe stdout and stderr to ssh-legion's stdout/stderr for debugging
if {
# shellcheck disable=SC2029 # escape on server side
ssh "${ssh_options[@]}" "${DESTINATION}" "$server_commands_joined" 2>&1 1>&"$ssh_stdout" \
| tee >( cat 1>&"$ssh_stderr" ) | grep -q "Error: remote port forwarding failed for listen port"
} {ssh_stdout}>&1 {ssh_stderr}>&2
then
# close temporary file descriptors
exec {ssh_stdout}<&- {ssh_stderr}<&-
# we need to change the port
return "$EX_UNAVAILABLE"
fi
# close temporary file descriptors
exec {ssh_stdout}<&- {ssh_stderr}<&-
}
function view-key() {
if [ ! -f ~/.ssh/id_ed25519 ]; then
ssh-keygen -t ed25519 -N "" -C "$(id -u -n)@$(uname -n)" -f ~/.ssh/id_ed25519
fi
log-info "Ouputing ~/.ssh/id_ed25519.pub, please add to your server's ~/.ssh/authorized_keys file."
cat ~/.ssh/id_ed25519.pub
}
destination=nqminds-iot-hub-ssh-control
config="/etc/ssh-legion/ssh-legion.config"
host_port=22
check=0
view_key=0
help_text="Usage: $(basename "$0") [OPTIONS] [DESTINATION]
Creates an SSH tunnel to the specified server.
The tunnel port will be 'localhost:<port>' on the server, where
<port> is a pseudo-random port based on this machines's /etc/machine-id value.
(or random if it's not available).
While the tunnel is active, a file called '~/connections/<hostname>:<port>'
will be created on the server, containing the 'ip a' data of this
machine. You can then SSH into this machine from the server using:
'ssh <user>@localhost -p <port>'.
When the tunnel is closed, the file will be renamed to
'~/connections/<hostname>:<port>+disconnected'.
Options:
-h, --help: Show this help message and exit.
-d, --destination: The destination to SSH to.
Defaults to nqminds-iot-hub-ssh-control
in your ssh_config file.
-P, --host-port PORT The host port to tunnel to. Defaults to port $host_port.
--check: Checks to see if SSH can be used to connect to the target.
Creates a file called '~/connections/<hostname>.test' to check
for permission errors.
--view-key Outputs the ~/.ssh/id_ed25519.pub.
Creates the file if it does not already exist.
-c, --config: Specify a custom ssh_config file.
Defaults to $config.
Ignored if this file doesn't exist or cannot be read.
"
showHelp() {
echo 2>&1 "$help_text"
}
options=$(getopt -l "help,destination:,check,config:,host-port:,view-key" -o "h,d:,P:" -- "$@")
eval set -- "$options"
while [ "$1" ]; do
case $1 in
-h|--help)
showHelp
exit 0
;;
-d|--destination)
if [ -z "$2" ]; then
echo "Warning: Ignoring empty --destination and keeping '$destination'." >&2
else
destination="$2"
fi
shift
;;
-P|--host-port)
host_port="$2"
shift
;;
--check)
check=1
;;
--config)
if [ -r "$2" ]; then
config="$2"
else
echo "Warning: Ignoring --config '$2' as it is not a readable file." >&2
fi
shift
;;
--view-key)
view_key=1
;;
--)
shift
break;;
*) # default
echo "Unknown option: $1" >&2
showHelp
exit 22
;;
esac
shift
done
export destination="${destination}"
export config="$config"
if [ "$view_key" -ne 0 ]; then
view-key
fi
function main() {
if [ "$check" -ne 0 ]; then
ssh-check
exit "$?"
fi
ssh-legion
echo 2>&1 "$(basename "$0") --destination ${destination} failed"
# always return failure since ssh-legion should never end
exit 1
}
# Run the main script in the background
# Otherwise our `trap`s only get called once the ssh-tunnel script fails
main &
main_pid="$!"
# On signal, removes trap for signal to all processes in this process group
# This is needed for older init systems (e.g. openwrt) to correctly close `ssh`
# when running `/etc/init.rc/... stop`
trap 'log-info "Caught SIGTERM, exiting..." && trap - SIGTERM && kill -TERM -- $(ps -o pid= --ppid "$main_pid") "$main_pid"' SIGTERM
trap 'log-info "Caught SIGINT, exiting..." && trap - SIGINT && kill -INT -- $(ps -o pid= --ppid "$main_pid") "$main_pid"' SIGINT
wait "$main_pid"