From bf7f28127e4f3683cdad5396bc80c4cfb2489d23 Mon Sep 17 00:00:00 2001 From: Joseph Hunkeler Date: Thu, 14 Jul 2022 00:33:29 -0400 Subject: Initial commit --- LICENSE | 29 ++++++++++ README.md | 70 ++++++++++++++++++++++++ bin/jupyter_safe_port | 143 ++++++++++++++++++++++++++++++++++++++++++++++++++ bin/next_tcp_port | 122 ++++++++++++++++++++++++++++++++++++++++++ install.sh | 51 ++++++++++++++++++ 5 files changed, 415 insertions(+) create mode 100644 LICENSE create mode 100644 README.md create mode 100755 bin/jupyter_safe_port create mode 100755 bin/next_tcp_port create mode 100755 install.sh diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a8dce53 --- /dev/null +++ b/LICENSE @@ -0,0 +1,29 @@ +BSD 3-Clause License + +Copyright (c) 2022, Joseph Hunkeler +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..f8ce34d --- /dev/null +++ b/README.md @@ -0,0 +1,70 @@ +# jupyter_safe_port + +``` +usage: jupyter_safe_port [-h] [-c] [-d] {host} [port] + +Discovers the next TCP port available for your notebook server and returns +execution instructions. If the argument '-c' is present and the requested +port is already bound on the remote host, return the SSH connection string + +Positional arguments: + host host name or IP of remote system + port notebook server port to poll (default: 1024) + +Arguments: + -h show this usage statement + -c only generate the SSH connection string + -d dump ports (useful in scripts) + format: local remote +``` + +## Install + +``` +./install.sh --prefix=/usr/local +``` + +## Examples + +_Oh no! I need to run two notebook servers on a remote system but which ports should I use?_ + +``` +$ jupyter_safe_port example.lan +Execute on example.lan: +jupyter notebook --no-browser --port=1024 + +Connect via: +ssh -N -f -L1024:localhost:1024 user@example.lan +``` + +You start the first notebook server. Now run `jupyter_safe_port` again... + +``` +$ jupyter_safe_port example.lan +Execute on example.lan: +jupyter notebook --no-browser --port=1025 + +Connect via: +ssh -N -f -L1025:localhost:1025 user@example.lan +``` + +The local port 1024 is already bound to the first server so it gives you 1025. On the remote system, `example.lan`, port 1024 is bound too so it returns 1025 as well. What if you want to use a higher port number on `example.lan`? Let's see... + +``` +$ jupyter_safe_port example.lan 8080 +Execute on example.lan: +jupyter notebook --no-browser --port=8081 + +Connect via: +ssh -N -f -L1026:localhost:8081 user@example.lan +``` + +Oops, you forgot about that web server test. 8080 is already bound so you're given 8081 instead. Locally 1024 and 1025 are already bound so `jupyter_safe_port` returns 1026. + +Let's say you have closed your laptop and lost all of your connections. If you can remember the remote port you used then `-c` will get you up and running in no time... + +``` +$ jupyter_safe_port example.lan -c 8080 +Connect via: +ssh -N -f -L1024:localhost:8080 user@example.lan +``` diff --git a/bin/jupyter_safe_port b/bin/jupyter_safe_port new file mode 100755 index 0000000..ced6ce1 --- /dev/null +++ b/bin/jupyter_safe_port @@ -0,0 +1,143 @@ +#!/usr/bin/env bash +usage() { + printf \ +"usage: %s [-h] [-c] [-d] {host} [port] + +Discovers the next TCP port available for your notebook server and returns +execution instructions. If the argument '-c' is present and the requested +port is already bound on the remote host, return the SSH connection string + +Positional arguments: + host host name or IP of remote system + port notebook server port to poll (default: $PORT_MIN) + +Arguments: + -h show this usage statement + -c only generate the SSH connection string + -d dump ports (useful in scripts) + format: local remote +" $(basename $0) +} + +user=$(id -u -n) +PORT_MIN=1024 +if ! (( EUID )); then + PORT_MIN=1 +fi +PORT_MAX=65535 +dump=0 +connect_only=0 + + +# Parse arguments +argv=($*) +argc="${#argv[@]}" +args=() + +if (( argc < 1 )); then + echo "error: not enough arguments" + usage + exit 1 +fi + +i=0 +while [[ $i < $argc ]]; do + key="${argv[$i]}" + if [[ $key =~ ^- ]]; then + key="${key#-*}" + case "$key" in + h) + usage + exit 0 + ;; + c) + connect_only=1 + (( i++ )) + continue + ;; + d) + dump=1 + (( i++ )) + continue + ;; + *) + echo "error: unknown argument" >&2 + usage + exit 1 + ;; + esac + fi + args+=("$key") + (( i++ )) +done + +server="${args[0]}" +port="${args[1]}" +port_local_begin=$PORT_MIN +port_remote_begin=${port:-$PORT_MIN} +port_remote_end= +(( connect_only )) && port_remote_end=$port_remote_begin + +if [[ -z "$server" ]]; then + echo "error: host name or IP required" >&2 + usage + exit 1 +fi + +if ! [[ $port_remote_begin =~ ^[0-9]+$ ]]; then + echo "error: port must be an integer" >&2 + usage + exit 1 +elif (( port_remote_begin < $PORT_MIN )) || (( port_remote_begin > $PORT_MAX )); then + echo "error: port must be an integer between $PORT_MIN-$PORT_MAX" >&2 + usage + exit 1 +fi + +# Execute the port test script on the remote host +port_remote=$(cat $(which next_tcp_port) | ssh $server "bash -s -- $port_remote_begin $port_remote_end") +if (( $? )); then + echo "error: $server: connection failed" >&2 + exit 1 +fi + +# Handle nonsensical host request (i.e. remote host is the local host) +# You can't bind to the same port twice +if [[ $(hostname) =~ $server ]] && (( port_remote == port_local_begin )); then + (( port_local_begin++ )) +fi +port_local=$(next_tcp_port $port_local_begin) + +if (( connect_only )); then + if ! (( port_remote < 0 )); then + if ! (( dump )); then + echo "error: $port_remote/tcp is not in use on $server" >&2 + exit 1 + fi + # Dump mode prints '-1' instead of throwing an error message + port_remote=-1 + else + port_remote=${port} + fi +else + if (( port_remote < 0 )); then + echo "error: no ports available in range ${port_remote_begin}+" >&2 + exit 1 + fi +fi + +# Show ports to use and exit +if (( dump )); then + echo "$port_local $port_remote" + exit 0 +fi + +# Show execution instructions +if ! (( connect_only )); then + echo "Execute on $server:" + echo "jupyter notebook --no-browser --port=$port_remote" + echo +fi + +echo "Connect via:" +echo "ssh -N -f -L$port_local:localhost:$port_remote $user@$server" diff --git a/bin/next_tcp_port b/bin/next_tcp_port new file mode 100755 index 0000000..7eaac75 --- /dev/null +++ b/bin/next_tcp_port @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +netstat_parse_linux() { + sed -r -n 's/tcp.*\.[0-9]+:([0-9]+).*/\1/p' <<< "$1" | sort -u +} + +netstat_parse_macos() { + sed -r -n 's/.*([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+\.([0-9]+)).*/\2/p;s/.*\*\.([0-9]+).*/\1/p' <<< "$1" | sort -u +} + +netstat_parse() { + if [[ $(uname -s) == Darwin ]]; then + netstat_parse_macos "$1" + else + netstat_parse_linux "$1" + fi +} + +netstat_args="-a -t -n" +if [[ $(uname -s) == Darwin ]]; then + netstat_args="-p tcp -a -n" +fi + +# Return a listing of all TCP ports in use +get_ports_tcp() { + local data=$(netstat $netstat_args) + local ports=$(netstat_parse "$data") + local i=0 + + while read port; do + if (( i < 2 ));then + (( i++ )) + continue + elif ! [[ $port =~ ^[0-9]+$ ]]; then + continue + fi + echo "$port" + (( i++ )) + done <<< "$ports" +} + +# Determine whether a port is in use +# Return +# 0 if yes +# >0 if no +get_port_status() { + local port="$1" + grep "$port" <<< $(get_ports_tcp) &>/dev/null + # returns exit code from grep +} + +usage() { + printf \ +"usage: %s [-h] [range_low] [range_high] + +Positional arguments: + range_low (default: $PORT_MIN) + range_high (default: $PORT_MAX) + +Arguments: + -h show this usage statement +" $(basename $0) +} + +# main() +PORT_MIN=1024 +PORT_MAX=65535 + +# Parse arguments +argv=($*) +argc="${#argv[@]}" +args=() + +i=0 +while [[ $i < $argc ]]; do + key="${argv[$i]}" + if [[ $key =~ ^- ]]; then + key="${key#-*}" + case "$key" in + h) + usage + exit 0 + ;; + *) + echo "error: unknown argument" >&2 + usage + exit 1 + ;; + esac + fi + args+=("$key") + (( i++ )) +done + +available=-1 +range_low=${args[0]:-$PORT_MIN} +range_high=${args[1]:-$PORT_MAX} + +# Check input boundaries +if (( range_low > range_high )); then + echo "Invalid port range: $range_low > $range_high" >&2 + usage + exit 1 +elif (( range_high < 0 )) || (( range_low < 0 )); then + echo "Range value must be a positive integer" >&2 + usage + exit 1 +elif (( range_high > PORT_MAX )) || (( range_low > PORT_MAX )); then + echo "Range value must be less than $PORT_MAX" >&2 + usage + exit 1 +fi + +# Print the next available port or -1 if unable +ports=($(seq $range_low $range_high)) +for port in "${ports[@]}"; do + if ! $(get_port_status $port); then + available=$port + break + fi +done + +echo $available diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..8d913b5 --- /dev/null +++ b/install.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash + +function usage() { + printf "Options: + --help (-h) Display this message + --prefix Path to install (default: $prefix) + --destdir A container directory (for packaging)\n" +} + +# Assign default paths if not modified by the user +[[ -z "${prefix}" ]] && prefix="/usr/local" +[[ -z "${destdir}" ]] && destdir="" + +# Parse arguments +i=0 +argv=($@) +nargs=${#argv[@]} +while [[ $i < $nargs ]]; do + key="${argv[$i]}" + if [[ "$key" =~ '=' ]]; then + value=${key#*=} + key=${key%=*} + else + value="${argv[$i+1]}" + fi + case "$key" in + --help|-h) + usage + exit 0 + ;; + --prefix) + prefix="$value" + (( i++ )) + ;; + --destdir) + destdir="$value" + (( i++ )) + ;; + esac + (( i++ )) +done + +set -e +dest="${destdir}${prefix}" +mkdir -p "${dest}"/bin +for src in bin/*; do + x="$(basename $src)" + echo "Installing $x in $dest"/bin + install -m755 "$src" "$dest"/bin +done +echo done -- cgit