aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--LICENSE29
-rw-r--r--README.md70
-rwxr-xr-xbin/jupyter_safe_port143
-rwxr-xr-xbin/next_tcp_port122
-rwxr-xr-xinstall.sh51
5 files changed, 415 insertions, 0 deletions
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